mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4e5ce6822 | ||
|
|
46f20fe4e1 | ||
|
|
1108b2bc14 | ||
|
|
2fd46493db | ||
|
|
df233905c5 | ||
|
|
85263d520f | ||
|
|
02ae7f9588 | ||
|
|
1dc03e1fb1 | ||
|
|
d21d532f10 | ||
|
|
50ba443492 | ||
|
|
aa01eaef01 | ||
|
|
93b60c5b8e | ||
|
|
eb17396006 | ||
|
|
0af50a74ae | ||
|
|
232c4807fd | ||
|
|
285d569102 | ||
|
|
22b588e116 | ||
|
|
1da1aba625 | ||
|
|
de53d530fa | ||
|
|
ade8daab16 | ||
|
|
05a2a838d6 | ||
|
|
f4ad843619 | ||
|
|
a7d58f0552 | ||
|
|
fd72330549 | ||
|
|
2c947f4f7a | ||
|
|
693601a8e0 | ||
|
|
c7fa9542ba | ||
|
|
478d2042b2 | ||
|
|
47ab60cddd | ||
|
|
79142d9334 | ||
|
|
bb4d4e1f6e | ||
|
|
b6675dc3a4 | ||
|
|
53e1c7163d | ||
|
|
ca66791440 | ||
|
|
db19753bbf |
@@ -4,3 +4,4 @@ test-build/
|
||||
*.log
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# AGENTS.md
|
||||
|
||||
House Plan is one HACS package with two parts plus a demo harness:
|
||||
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
|
||||
|
||||
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
|
||||
|
||||
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
|
||||
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
|
||||
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
|
||||
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
|
||||
@@ -27,10 +27,16 @@ right on your Lovelace dashboard.
|
||||
|
||||
**Feature highlights**
|
||||
|
||||
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
|
||||
past: draw and place devices anywhere, pan at any zoom, zoom out to see
|
||||
everything, and let one tap fit the whole plan back on screen.
|
||||
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
|
||||
virtual walls and a visual decor layer, all drawn with clicks; room resize
|
||||
by dragging walls, with live lengths and areas as you drag; smart
|
||||
alignment guides and a live ruler in real meters/feet.
|
||||
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
|
||||
place and pull a corner to size it, with its real size in metres shown as
|
||||
you drag, so the drawing and the photo of your plan finally line up.
|
||||
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
|
||||
whole groups of lights (works for dumb switches and stateless remotes too).
|
||||
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
|
||||
@@ -39,6 +45,9 @@ right on your Lovelace dashboard.
|
||||
the day (white noon → golden hour → deep night), while windows on exterior
|
||||
walls cast real wedges of sunlight into the rooms; optional cloud cover
|
||||
from a weather entity.
|
||||
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
|
||||
stops a cover, and the icon itself morphs between open and closed while a
|
||||
soft ring pulses as it travels.
|
||||
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
|
||||
comfort-range temperature fills, per-room signal heatmap.
|
||||
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
|
||||
@@ -179,7 +188,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
|
||||
|
||||

|
||||
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is always square; an image keeps its own proportions and is centred inside it.
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
|
||||
|
||||

|
||||
|
||||
|
||||
+9
-1
@@ -25,6 +25,10 @@
|
||||
|
||||
**Главное**
|
||||
|
||||
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
|
||||
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
|
||||
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
|
||||
план обратно в экран.
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
@@ -37,6 +41,9 @@
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
weather-сущности.
|
||||
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
|
||||
останавливает штору, а сам значок морфится между открытым и закрытым
|
||||
видом и мягко пульсирует кольцом, пока штора едет.
|
||||
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
|
||||
температурная заливка по комфортным границам.
|
||||
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
|
||||
@@ -97,6 +104,7 @@ House Plan показывает ваш умный дом так, как он в
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
@@ -180,7 +188,7 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст всегда квадратный; картинка сохраняет свои пропорции и центрируется внутри него.
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.56.0"
|
||||
VERSION = "1.58.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.56.0"
|
||||
"version": "1.58.0"
|
||||
}
|
||||
|
||||
@@ -95,11 +95,15 @@ _TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
# Positions are normalised to the canvas (0..1). Allow generous slack for an
|
||||
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
|
||||
# used to pass, and a single stored 1e100 stretched every client's view of the
|
||||
# space until the plan was invisible (HP-1500-03).
|
||||
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
# The canvas is UNBOUNDED (docs/CANVAS.md). Coordinates are still normalised —
|
||||
# 1.0 is still one canvas width — but there is no frame any more, so a plan may
|
||||
# legitimately live at 2.7 or -1.4. The range below is GARBAGE INSURANCE, not a
|
||||
# boundary: at the product's own scale (cell_cm=5, 240 cells across the unit
|
||||
# width) 5000 is about 60 km of plan, unreachable in a home, while a stored
|
||||
# 1e100 still cannot stretch every client's view until the plan is invisible
|
||||
# (HP-1500-03 / HP-1501-01). Widened from +/-4 on 2026-08-03.
|
||||
CANVAS_LIMIT = 5000.0
|
||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
@@ -107,19 +111,26 @@ POS_SCHEMA = vol.Schema(
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
# Geometry is normalised to the canvas (0..1). ±4 is generous slack for a
|
||||
# vertex nudged past an edge, not an envelope for arbitrary magnitudes: any
|
||||
# finite float used to pass here, and a single 1e100 room vertex stretched the
|
||||
# frame until the whole space was unviewable for every client (HP-1501-01) —
|
||||
# the exact failure HP-1500-03 closed for layout positions, one schema over.
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||
# corruption (HP-1501-01, the room-geometry twin of HP-1500-03).
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
|
||||
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
|
||||
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
|
||||
# client. The floor is one thousandth of the canvas (1 render unit): far below
|
||||
# any real room, but keeps the maths finite.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
|
||||
# any real room, but keeps the maths finite. The CEILING follows the canvas
|
||||
# (docs/CANVAS.md) — a room on an unbounded plane may legitimately be wider
|
||||
# than the old unit square — while staying strictly positive.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
|
||||
|
||||
# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a
|
||||
# coordinate: strictly positive, and bounded by what a person could mean —
|
||||
# a hundredth of the canvas is already a thumbnail, a hundred canvases is
|
||||
# already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts.
|
||||
PLAN_SCALE_MIN = 0.01
|
||||
PLAN_SCALE_MAX = 100.0
|
||||
|
||||
|
||||
def _view_box(value):
|
||||
@@ -214,7 +225,9 @@ _DECOR_COMMON = {
|
||||
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
|
||||
}
|
||||
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
|
||||
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
|
||||
# it used to be pinned to -1..2, i.e. "one canvas of slack around the square".
|
||||
_NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
|
||||
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
|
||||
@@ -247,6 +260,17 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
|
||||
# scaled UNIFORMLY on the canvas. All three are optional and their
|
||||
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
|
||||
# and an old config validates unchanged. The offset is a normalised
|
||||
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
|
||||
# the scale is a positive multiplier in a range a human could mean.
|
||||
vol.Optional("plan_x"): vol.Any(None, _COORD),
|
||||
vol.Optional("plan_y"): vol.Any(None, _COORD),
|
||||
vol.Optional("plan_scale"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
vol.Required("view_box"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
@@ -258,7 +282,9 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
# a SIZE: strictly positive, capped by the canvas insurance
|
||||
# limit rather than by the old unit square (docs/CANVAS.md)
|
||||
vol.Required("length"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
vol.Optional("invert"): bool,
|
||||
@@ -291,7 +317,7 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Backdrop image (docs/BACKDROP.md) — two shots for the owner:
|
||||
// backdrop_frame — the transform frame mid-gesture: dashed outline, four
|
||||
// finger-sized corner handles, and the live "W × H" badge
|
||||
// stating the picture's real size through cell_cm;
|
||||
// backdrop_paper — the new paper rule on an image plan: the opaque sheet is
|
||||
// the ROOM CONTOURS, so the scene colour is visible around
|
||||
// them and under the parts of the picture nobody has drawn
|
||||
// over. The picture is scaled down here so both are obvious.
|
||||
// Usage: node demo/shot_backdrop.mjs <outdir>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const settle = () => page.waitForTimeout(260);
|
||||
|
||||
// ---- 1) the frame, mid-drag ---------------------------------------------
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('decor');
|
||||
c._decorTool = 'backdrop';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
{
|
||||
// grab the bottom-right handle and hold it mid-gesture, so the shot carries
|
||||
// the frame AND the live badge at once
|
||||
const [ax, ay] = await screenPt(1000, 900);
|
||||
const [bx, by] = await screenPt(820, 760);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 4 });
|
||||
await settle();
|
||||
await page.screenshot({ path: `${outDir}/backdrop_frame.png` });
|
||||
await page.mouse.up();
|
||||
}
|
||||
await settle();
|
||||
|
||||
// ---- 2) the paper is the rooms ------------------------------------------
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
// a picture pulled well inside the plan and an acid scene colour: what shows
|
||||
// through around the rooms is the SCENE, which is the whole new rule
|
||||
sp.plan_x = 0.16; sp.plan_y = 0.10; sp.plan_scale = 0.62;
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#7bd389' };
|
||||
c._cfgEpoch++; c._frame = null;
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._fitAll();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
await settle();
|
||||
await page.screenshot({ path: `${outDir}/backdrop_paper.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir + '/backdrop_frame.png, ' + outDir + '/backdrop_paper.png');
|
||||
@@ -0,0 +1,30 @@
|
||||
// Capture: a curtain on the move — the breathing ring (.covermove) around a
|
||||
// NEUTRAL plate (owner 2026-08-03). The pulse is frozen at a visible frame so
|
||||
// the shot is deterministic.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state: 'opening',
|
||||
attributes: { friendly_name: 'Curtain', device_class: 'curtain' } } } };
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.covermove').getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_move.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,69 @@
|
||||
// Capture: one curtain in the four states an owner sees — closed, open,
|
||||
// opening, closing (owner's contract 2026-08-04). The plate is the plain
|
||||
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
|
||||
// and the two travelling ones add the breathing .covermove ring (frozen at a
|
||||
// visible frame so the shot is deterministic).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
|
||||
const STATES = ['closed', 'open', 'opening', 'closing'];
|
||||
await page.evaluate(async (STATES) => {
|
||||
const c = window.__card;
|
||||
const devices = {}; const entities = {}; const states = {};
|
||||
STATES.forEach((s, i) => {
|
||||
const id = 'd_cur' + i;
|
||||
devices[id] = { id, name: 'Curtain ' + s, model: 'Roller shade driver E1',
|
||||
area_id: 'garden', identifiers: [['demo', id]], entry_type: null, via_device_id: null };
|
||||
entities['cover.cur' + i] = { entity_id: 'cover.cur' + i, device_id: id, platform: 'demo' };
|
||||
states['cover.cur' + i] = { entity_id: 'cover.cur' + i, state: s,
|
||||
attributes: { friendly_name: 'Curtain ' + s, device_class: 'curtain' } };
|
||||
});
|
||||
c.hass = { ...c.hass,
|
||||
devices: { ...c.hass.devices, ...devices },
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states } };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i, tap_action: 'cover' })),
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
const layout = { ...c._layout };
|
||||
STATES.forEach((s, i) => {
|
||||
const d = c._devices.find((x) => x.bindingRef === 'd_cur' + i);
|
||||
if (d) layout[d.id] = { s: 'garden', x: 0.18 + i * 0.215, y: 0.5 };
|
||||
});
|
||||
c._layout = layout;
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
}, STATES);
|
||||
await page.waitForTimeout(500);
|
||||
// captions under each badge, so the four states are readable in the file
|
||||
const box = await page.evaluate((STATES) => {
|
||||
const c = window.__card;
|
||||
const els = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.devlayer .dev')]
|
||||
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
|
||||
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
|
||||
els.forEach((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cap = document.createElement('div');
|
||||
cap.textContent = STATES[i];
|
||||
cap.style.cssText = `position:fixed;left:${r.x + r.width / 2}px;top:${r.y + r.height + 10}px;`
|
||||
+ 'transform:translateX(-50%);font:600 13px system-ui,sans-serif;color:#1c2530;'
|
||||
+ 'letter-spacing:.02em;z-index:9999;padding:2px 7px;border-radius:6px;'
|
||||
+ 'background:rgba(255,255,255,0.88);white-space:nowrap;';
|
||||
document.body.appendChild(cap);
|
||||
minX = Math.min(minX, r.x); maxX = Math.max(maxX, r.x + r.width);
|
||||
minY = Math.min(minY, r.y); maxY = Math.max(maxY, r.y + r.height);
|
||||
});
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}, STATES);
|
||||
const pad = 55;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_states.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad),
|
||||
width: box.w + pad * 2, height: box.h + pad * 2 + 22 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,27 @@
|
||||
// Capture: the shoulder rulers + centre tick while PLACING a new opening
|
||||
// (Opening tool, cursor hovering the wall centre) — owner 2026-08-03.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const pt = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return { x: r.left + ((293.7 - vx) / vw) * r.width, y: r.top + ((141 - vy) / vh) * r.height,
|
||||
sx: r.left, sy: r.top, sw: r.width, sh: r.height };
|
||||
});
|
||||
await page.mouse.move(pt.x, pt.y, { steps: 4 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/opening_place.png',
|
||||
clip: { x: pt.sx, y: pt.sy, width: pt.sw, height: Math.min(pt.sh, 420) } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,45 @@
|
||||
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами». A miter join on a
|
||||
// sharp corner shoots a spike far past the walls that meet there; past the
|
||||
// miter limit it flips to a flat bevel. Room borders now join round, like the
|
||||
// decor lines already did. Two rooms make the defect obvious: an L (a concave
|
||||
// 90° corner plus the convex ones) and a wedge with a ~45° apex — the angle
|
||||
// where the miter spike is longest before the miter limit clips it to a bevel.
|
||||
// Usage: node demo/shot_room_joins.mjs <outdir> <name>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const name = process.argv[3] || 'room_joins';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 6);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
delete sp.plan_url;
|
||||
sp.rooms = [
|
||||
{ id: 'rL', name: '', area: 'living_room',
|
||||
poly: [[0.06, 0.04], [0.52, 0.04], [0.52, 0.14], [0.94, 0.14], [0.94, 0.26], [0.06, 0.26]] },
|
||||
{ id: 'rW', name: '', area: 'kitchen',
|
||||
poly: [[0.20, 0.318], [0.90, 0.55], [0.20, 0.782]] },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, show_names: false,
|
||||
fill_mode: 'none', room_color: '#3ea6ff', room_opacity: 1 };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
// crop tight around the wedge's ~28° apex: at plan scale the whole stage hides
|
||||
// a 5-unit spike, and the spike is the entire point of the shot
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const svg = c.shadowRoot.querySelector('.stage svg');
|
||||
const poly = [...svg.querySelectorAll('polygon.room')].pop();
|
||||
const pts = poly.getAttribute('points').split(' ').map((s) => s.split(',').map(Number));
|
||||
const apex = pts.reduce((m, p) => (p[0] > m[0] ? p : m), pts[0]);
|
||||
const m = svg.getScreenCTM();
|
||||
return { x: m.a * apex[0] + m.c * apex[1] + m.e, y: m.b * apex[0] + m.d * apex[1] + m.f };
|
||||
});
|
||||
await page.screenshot({ path: `${outDir}/${name}.png`,
|
||||
clip: { x: box.x - 62, y: box.y - 30, width: 72, height: 60 } });
|
||||
await browser.close();
|
||||
console.log('shot written to ' + outDir + '/' + name + '.png');
|
||||
@@ -0,0 +1,46 @@
|
||||
// Brightness check for the wedges (owner 2026-08-03: «поярче, иногда плохо
|
||||
// видны»). Two hardest cases side by side: a DAYLIGHT sun on white paper (the
|
||||
// wedge colour is nearly neutral there) and a low sun over the dark glow
|
||||
// canvas. Usage: node demo/shot_sun_bright.mjs <outdir> [suffix]
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] ? '_' + process.argv[3] : '';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
// the fade is a 2 s animation; reduced motion pins the layer at full opacity
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const setup = (fill, azimuth, elevation, ceiling) => page.evaluate(async ([fill, azimuth, elevation, ceiling]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: fill };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: ceiling },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [fill, azimuth, elevation, ceiling]);
|
||||
|
||||
const shoot = async (name) => {
|
||||
await page.waitForTimeout(400);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}${tag}.png` });
|
||||
};
|
||||
|
||||
// 1) plain daylight on white paper: neutral wedge colour, brightest canvas
|
||||
await setup('none', 265, 24, 'off');
|
||||
await shoot('sun_bright_paper');
|
||||
// 2) a low warm sun over the dark glow canvas
|
||||
await setup('glow', 265, 4, 'off');
|
||||
await shoot('sun_bright_glow');
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Stills for the 2026-08-04 ray report: 30 % shorter shafts with SHARP sides
|
||||
// that dissolve only along the ray (docs/SUN.md). The low-sun frame is the
|
||||
// interesting one — the north and south windows get a nearly wall-grazing sun,
|
||||
// the case that used to hang a bright kerb across the floor. Usage:
|
||||
// node demo/shot_sun_short.mjs <outdir> <prefix>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] || 'sun_rays_short';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
|
||||
|
||||
const setup = async (az, el) => {
|
||||
await page.evaluate(async ([azimuth, elevation]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [az, el]);
|
||||
await page.waitForTimeout(350);
|
||||
};
|
||||
|
||||
// low sun in the west — the long shafts, the case that hit the far walls
|
||||
await setup(265, 5);
|
||||
let stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_low.png` });
|
||||
|
||||
// high southern sun — the short noon pools
|
||||
await setup(180, 55);
|
||||
stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_high.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -21,14 +21,27 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inPlan = c._baseVb();
|
||||
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
|
||||
// docs/CANVAS.md: холста-квадрата больше нет, поэтому «редактор видит весь
|
||||
// холст» переписано в исходное НАМЕРЕНИЕ HP-1490-03 — «в редакторе есть куда
|
||||
// рисовать наружу». Рамка теперь по содержимому и в редакторе тоже, а место
|
||||
// даёт запас панорамирования (§5) и зум-аут до 3x.
|
||||
o.editorFrameIsContent = inPlan[2] < 999 && inPlan[2] > 0;
|
||||
o.editorCanPanPastContent = (() => {
|
||||
const fit = c._viewOr(c._baseVb());
|
||||
const moved = c._clampView({ x: fit.x + fit.w * 0.9, y: fit.y, w: fit.w, h: fit.h }, fit);
|
||||
return moved.x > fit.x + fit.w * 0.5; // ушли почти на экран вправо, зажима нет
|
||||
})();
|
||||
o.editorZoomOutGivesRoom = (() => {
|
||||
c._resetZoom(); c._applyView(1 / 3);
|
||||
const wide = c._view.w; c._resetZoom();
|
||||
return wide > inPlan[2] * 2.5; // видно втрое больше, чем нарисовано
|
||||
})();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
|
||||
// в редакторе можно ткнуть в дальний угол холста
|
||||
o.canReachFarCorner = (() => {
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
|
||||
return pt[0] > 900 || pt[1] > 900;
|
||||
return pt[0] > inPlan[0] + inPlan[2] * 0.9 || pt[1] > inPlan[1] + inPlan[3] * 0.9;
|
||||
})();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Backdrop image: move, uniform scale, and the new paper rule (docs/BACKDROP.md).
|
||||
*
|
||||
* The whole contract, on the demo's f1 — which IS an image plan, so every
|
||||
* assertion here lands on exactly the case that used to behave differently:
|
||||
* • the transform frame exists in the backdrop editor and nowhere else;
|
||||
* • dragging the body moves the picture and writes plan_x/plan_y;
|
||||
* • a corner handle scales UNIFORMLY about the opposite corner;
|
||||
* • live badges state the real size in metres, through cell_cm;
|
||||
* • position and size land on the grid, Shift steps off it;
|
||||
* • the paper follows the ROOMS even here, and the picture is drawn above
|
||||
* the paper and below the walls;
|
||||
* • «Вписать всё» still finds a picture that has been dragged away;
|
||||
* • the static card reads the same three fields.
|
||||
*/
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(s);
|
||||
c._bdDrag = null;
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
|
||||
const mode = (m) => page.evaluate((m) => {
|
||||
const c = window.__card;
|
||||
c._setMode(m); c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, m);
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const q = (sel) => page.evaluate((s) => window.__card.renderRoot.querySelectorAll(s).length, sel);
|
||||
const spaceCfg = () => page.evaluate(() => {
|
||||
const sp = window.__card._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
return { x: sp.plan_x ?? null, y: sp.plan_y ?? null, k: sp.plan_scale ?? null };
|
||||
});
|
||||
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
|
||||
const imageRect = () => page.evaluate(() => {
|
||||
const im = window.__card.renderRoot.querySelector('.stage svg image');
|
||||
if (!im) return null;
|
||||
const a = (n) => Number(im.getAttribute(n));
|
||||
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
|
||||
});
|
||||
const frameRect = () => page.evaluate(() => {
|
||||
const b = window.__card.renderRoot.querySelector('.bdframe .bdbox');
|
||||
if (!b) return null;
|
||||
const a = (n) => Number(b.getAttribute(n));
|
||||
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
|
||||
});
|
||||
const badges = () => page.evaluate(() =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.measurelabel.bdmeasure')].map((e) => e.textContent.trim()));
|
||||
/** Screen position of a render-unit point (the svg viewBox fills the stage). */
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const near = (a, b, tol = 1e-4) => Math.abs(a - b) <= tol;
|
||||
|
||||
// f1: plan_aspect 1.25 → the centred default is 1000×800 at (0,100)
|
||||
const BASE = { x: 0, y: 100, w: 1000, h: 800 };
|
||||
const PITCH = 1000 / 240;
|
||||
// "on a node", allowing for the 6 decimals the normalised config keeps
|
||||
// (5e-7 normalised ≈ 5e-4 render units ≈ 1e-4 of a step)
|
||||
const onGrid = (v, tol = 1e-3) => Math.abs(v / PITCH - Math.round(v / PITCH)) < tol;
|
||||
/** Metres a render-unit length is worth on the demo plan (cell_cm = 5). */
|
||||
const metres = (u) => (((u / PITCH) * 5) / 100).toFixed(2) + ' m';
|
||||
|
||||
// ---------- 1) the frame lives in the backdrop editor and nowhere else -----
|
||||
await mode('view');
|
||||
await settle();
|
||||
check('no_frame_in_view', await q('.bdframe'), 0);
|
||||
await mode('plan');
|
||||
await settle();
|
||||
check('no_frame_in_plan', await q('.bdframe'), 0);
|
||||
const tool = (t) => page.evaluate((t) => {
|
||||
const c = window.__card;
|
||||
c._decorTool = t; c._decorDraft = null; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, t);
|
||||
|
||||
await mode('decor');
|
||||
await settle();
|
||||
// the frame is on screen the moment the backdrop editor opens…
|
||||
check('frame_in_backdrop_editor', await q('.bdframe'), 1);
|
||||
check('four_finger_handles', await q('.bdframe .bdhandle'), 4);
|
||||
check('frame_hugs_the_picture', await frameRect(), BASE);
|
||||
check('untouched_picture_is_where_it_always_was', await imageRect(), BASE);
|
||||
// …and the picture has a tool of its own, offered because f1 HAS a picture
|
||||
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 7);
|
||||
check('no_grab_cursor_until_that_tool', await page.evaluate(() =>
|
||||
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), false);
|
||||
await tool('backdrop');
|
||||
await settle();
|
||||
check('grab_cursor', await page.evaluate(() =>
|
||||
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
|
||||
check('frame_still_there_under_its_own_tool', await q('.bdframe'), 1);
|
||||
// a drawing tool owns the drag instead — no frame competing with it
|
||||
await tool('rect');
|
||||
await settle();
|
||||
check('no_frame_under_a_drawing_tool', await q('.bdframe'), 0);
|
||||
await tool('backdrop');
|
||||
await settle();
|
||||
|
||||
// ---------- 2) drag the body: the picture moves, the plan does not --------
|
||||
const roomsBefore = await page.evaluate(() =>
|
||||
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms));
|
||||
{
|
||||
const [ax, ay] = await screenPt(500, 500);
|
||||
// +102 / +52 render units: neither is a whole number of grid steps, so the
|
||||
// snap has something to do (24 steps = 100, 12 steps = 50)
|
||||
const [bx, by] = await screenPt(602, 552);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
|
||||
await settle();
|
||||
const live = await badges();
|
||||
check('live_badge_while_dragging', live.length, 1);
|
||||
// the demo's cell_cm is 5 → the untouched picture is 240 cells wide, i.e.
|
||||
// 12.00 m by 9.60 m. Moving does not change the size, and the badge says so.
|
||||
check('live_badge_is_the_real_size', live[0], `${metres(BASE.w)} × ${metres(BASE.h)}`);
|
||||
check('live_badge_is_metres', live[0], '12.00 m × 9.60 m');
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
}
|
||||
check('badge_gone_after_release', (await badges()).length, 0);
|
||||
{
|
||||
const cfg = await spaceCfg();
|
||||
const im = await imageRect();
|
||||
// the gesture asked for +102/+52 render units; the pointer arrives in whole
|
||||
// screen pixels, so the ASSERTION is the contract, not the arithmetic: the
|
||||
// corner lands on a lattice node within one step of where the finger let go
|
||||
check('plan_x_saved', near(cfg.x, im.x / 1000, 1e-6), true);
|
||||
check('plan_y_saved', near(cfg.y, (im.y - BASE.y) / 1000, 1e-6), true);
|
||||
check('scale_untouched_by_a_move', cfg.k, 1);
|
||||
check('size_untouched_by_a_move', [im.w, im.h], [BASE.w, BASE.h]);
|
||||
check('corner_lands_on_a_grid_node', onGrid(im.x) && onGrid(im.y), true);
|
||||
// …at the node NEAREST the finger: 102 → 100 (24 steps), 152 → 150 (36).
|
||||
// Exact, and it is meant to be — nothing may rescale the stage mid-gesture
|
||||
// (the frame is frozen while _bdDrag is live; the toolbar does not reflow),
|
||||
// or the picture would run away from the finger.
|
||||
check('landed_on_the_node_nearest_the_finger', [im.x, im.y], [100, 150]);
|
||||
check('frame_follows_the_picture', await frameRect(), im);
|
||||
}
|
||||
check('the_plan_geometry_never_moved', await page.evaluate(() =>
|
||||
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms)), roomsBefore);
|
||||
|
||||
// ---------- 3) Shift steps off the grid ----------------------------------
|
||||
await restore();
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(500, 500);
|
||||
const [bx, by] = await screenPt(602, 552);
|
||||
await page.keyboard.down('Shift');
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up('Shift');
|
||||
await settle();
|
||||
const im = await imageRect();
|
||||
// Shift is not "a different snap", it is NO snap: the corner is wherever the
|
||||
// finger left it, to the pixel — and a pixel is a fraction of a grid step,
|
||||
// so at least one axis is bound to sit between the nodes.
|
||||
check('shift_lands_where_the_finger_did',
|
||||
Math.abs(im.x - 102) <= 1 && Math.abs(im.y - 152) <= 1, true);
|
||||
check('shift_is_off_the_lattice', onGrid(im.x) && onGrid(im.y), false);
|
||||
}
|
||||
|
||||
// ---------- 4) a corner handle scales UNIFORMLY about the opposite one ----
|
||||
await restore();
|
||||
await settle();
|
||||
{
|
||||
// the bottom-right corner of the default rect is (1000, 900); pull it in to
|
||||
// (700, ...) — the top-left corner (0,100) must not budge and the ratio must
|
||||
// stay 1.25, whatever the vertical component of the gesture says
|
||||
const [ax, ay] = await screenPt(BASE.x + BASE.w, BASE.y + BASE.h);
|
||||
const [bx, by] = await screenPt(700, 600);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await settle();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
|
||||
await settle();
|
||||
check('live_badge_while_scaling', (await badges()).length, 1);
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const im = await imageRect();
|
||||
check('opposite_corner_stayed_put', near(im.x, BASE.x, 1e-6) && near(im.y, BASE.y, 1e-6), true);
|
||||
check('scaled_down', im.w < BASE.w, true);
|
||||
check('uniform_no_stretch', near(im.w / im.h, BASE.w / BASE.h, 1e-9), true);
|
||||
const cfg = await spaceCfg();
|
||||
check('plan_scale_saved', near(cfg.k, im.w / BASE.w, 1e-5), true);
|
||||
check('scaled_width_is_on_the_grid',
|
||||
Math.abs((im.x + im.w) / PITCH - Math.round((im.x + im.w) / PITCH)) < 1e-6, true);
|
||||
// …and the badge states the picture's REAL size through cell_cm
|
||||
check('badge_matches_the_new_size', await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._bdDrag = { kind: 'move', pid: 1, sx: 0, sy: 0, base: { x: 0, y: 100, w: 1000, h: 800 },
|
||||
p0: { dx: 0, dy: 0, k: 1 }, fx: 0, fy: 0, sgx: 0, sgy: 0, moved: false };
|
||||
c.requestUpdate();
|
||||
return c.updateComplete.then(() =>
|
||||
c.renderRoot.querySelector('.measurelabel.bdmeasure')?.textContent.trim() ?? null);
|
||||
}), `${metres(im.w)} × ${metres(im.h)}`);
|
||||
await page.evaluate(() => { const c = window.__card; c._bdDrag = null; c.requestUpdate(); return c.updateComplete; });
|
||||
}
|
||||
|
||||
// ---------- 5) the reset button ------------------------------------------
|
||||
check('reset_button_offered_once_moved', await q('.decorbar .bdreset'), 1);
|
||||
await page.evaluate(() => { const c = window.__card; c._bdReset?.(); c.requestUpdate(); return c.updateComplete; });
|
||||
await settle();
|
||||
check('reset_puts_the_picture_back', await imageRect(), BASE);
|
||||
check('reset_clears_the_fields', await spaceCfg(), { x: null, y: null, k: null });
|
||||
check('reset_button_gone_again', await q('.decorbar .bdreset'), 0);
|
||||
|
||||
// ---------- 5b) the SELECT tool still pans right over the picture ---------
|
||||
// The body of the picture is most of the screen; claiming it outside its own
|
||||
// tool would take away the one-finger pan (docs/BACKDROP.md §2), which is why
|
||||
// moving is a tool. smoke_pan_any_zoom guards the same thing from the outside.
|
||||
await tool('select');
|
||||
await settle();
|
||||
check('select_tool_still_pans_over_the_picture', await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const before = { ...c._viewOr(c._baseVb()) };
|
||||
c._stagePointerDown({ pointerId: 91, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 91, clientX: 360, clientY: 350 });
|
||||
const after = { ...c._viewOr(c._baseVb()) };
|
||||
c._stagePointerUp({ pointerId: 91, clientX: 360, clientY: 350 });
|
||||
await c.updateComplete;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
return Math.abs(after.x - before.x) > 1 && sp.plan_x == null;
|
||||
}), true);
|
||||
|
||||
// ---------- 6) the paper is the ROOMS, and the picture sits on it ---------
|
||||
await mode('view');
|
||||
await settle();
|
||||
const layers = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const m = c._spaceModel();
|
||||
const svgEl = c.renderRoot.querySelector('.stage svg');
|
||||
const kids = [...svgEl.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
const papers = [...svgEl.querySelectorAll('.hp-paper')];
|
||||
return {
|
||||
paperShapes: papers.length,
|
||||
rooms: m.rooms.length,
|
||||
// not one paper shape the size of the picture any more
|
||||
imageSizedPaper: papers.some((p) => p.tagName === 'rect'
|
||||
&& Math.abs(Number(p.getAttribute('width')) - m.bg.w) < 1e-6
|
||||
&& Math.abs(Number(p.getAttribute('height')) - m.bg.h) < 1e-6),
|
||||
iPaper: idx('.hp-paper'), iImage: idx('image'), iRoom: idx('.room-outline, .room'),
|
||||
};
|
||||
});
|
||||
check('paper_is_one_shape_per_room', layers.paperShapes, layers.rooms);
|
||||
check('no_paper_the_size_of_the_picture', layers.imageSizedPaper, false);
|
||||
check('picture_is_above_the_paper', layers.iImage > layers.iPaper && layers.iPaper >= 0, true);
|
||||
check('picture_is_below_the_walls', layers.iImage < layers.iRoom, true);
|
||||
|
||||
// ---------- 7) «Вписать всё» keeps a picture that was dragged away --------
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.plan_x = 1.5; sp.plan_y = 1.5; sp.plan_scale = 0.5;
|
||||
c._cfgEpoch++; c._frame = null; c.requestUpdate();
|
||||
return c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const fit = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._fitAll();
|
||||
const b = c._baseVb();
|
||||
const bg = c._spaceModel().bg;
|
||||
return { b, bg };
|
||||
});
|
||||
check('the_frame_reaches_the_moved_picture',
|
||||
fit.b[0] <= fit.bg.x && fit.b[1] <= fit.bg.y
|
||||
&& fit.b[0] + fit.b[2] >= fit.bg.x + fit.bg.w
|
||||
&& fit.b[1] + fit.b[3] >= fit.bg.y + fit.bg.h, true);
|
||||
check('and_the_moved_picture_is_where_the_transform_says',
|
||||
near(fit.bg.x, 1500) && near(fit.bg.y, 1600) && near(fit.bg.w, 500) && near(fit.bg.h, 400), true);
|
||||
|
||||
// ---------- 8) the static card reads the same three fields ---------------
|
||||
const stat = await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return { ok: true };
|
||||
} };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage svg image') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const root = card.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const im = root.querySelector('image');
|
||||
const a = (n) => Number(im.getAttribute(n));
|
||||
const kids = [...root.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
return {
|
||||
rect: { x: a('x'), y: a('y'), w: a('width'), h: a('height') },
|
||||
papers: root.querySelectorAll('.hp-paper').length,
|
||||
imageSizedPaper: [...root.querySelectorAll('.hp-paper')].some((p) => p.tagName === 'rect'
|
||||
&& Math.abs(Number(p.getAttribute('width')) - a('width')) < 1e-6),
|
||||
iPaper: idx('.hp-paper'), iImage: idx('image'),
|
||||
};
|
||||
});
|
||||
check('static_card_applies_the_transform', stat.rect, { x: 1500, y: 1600, w: 500, h: 400 });
|
||||
check('static_card_papers_the_rooms', stat.papers, 4);
|
||||
check('static_card_has_no_picture_paper', stat.imageSizedPaper, false);
|
||||
check('static_card_draws_the_picture_on_the_paper', stat.iImage > stat.iPaper, true);
|
||||
|
||||
await restore();
|
||||
await finish(browser);
|
||||
+19
-9
@@ -144,9 +144,11 @@ checkAll(res);
|
||||
|
||||
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
|
||||
// The scene bg_color must never bleed through the plan itself: opaque
|
||||
// .hp-paper shapes sit under everything the plan draws. An IMAGE plan papers
|
||||
// the backdrop image rect (the canvas is the paper); a DRAWN plan papers the
|
||||
// ROOM CONTOURS — one shape per room, never their bounding box (section 12).
|
||||
// .hp-paper shapes sit under everything the plan draws. Since v1.58.0 the
|
||||
// paper is the ROOM CONTOURS and ONLY them — one shape per room, never their
|
||||
// bounding box (section 12), and never the backdrop image rect either
|
||||
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
|
||||
// asserts the new rule on exactly the case that used to be the exception.
|
||||
// The scene colour is visible ONLY around the paper; the 'daynight' night
|
||||
// dims the paper via brightness only.
|
||||
const paper = await page.evaluate(async () => {
|
||||
@@ -158,20 +160,28 @@ const paper = await page.evaluate(async () => {
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
await upd();
|
||||
const m = c._spaceModel();
|
||||
const p = sr().querySelector('.stage svg rect.hp-paper');
|
||||
const shapes = () => [...sr().querySelectorAll('.stage svg .hp-paper')];
|
||||
const p = shapes()[0];
|
||||
out.paperExists = !!p;
|
||||
out.imagePlanPapersTheRooms = shapes().length === m.rooms.length;
|
||||
// …and NOT the image: no paper rect the size of the backdrop any more
|
||||
out.noImageSizedPaper = ![...sr().querySelectorAll('.stage svg rect.hp-paper')].some((r) =>
|
||||
Math.abs(Number(r.getAttribute('width')) - m.bg.w) < 1e-6
|
||||
&& Math.abs(Number(r.getAttribute('height')) - m.bg.h) < 1e-6);
|
||||
if (p) {
|
||||
const a = (n) => Number(p.getAttribute(n));
|
||||
out.paperHugsImage = Math.abs(a('x') - m.bg.x) < 1e-6 && Math.abs(a('y') - m.bg.y) < 1e-6
|
||||
&& Math.abs(a('width') - m.bg.w) < 1e-6 && Math.abs(a('height') - m.bg.h) < 1e-6;
|
||||
const cs = getComputedStyle(p);
|
||||
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
|
||||
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
|
||||
}
|
||||
// …and the picture is drawn ON that paper: image after paper, before walls
|
||||
const kids = [...sr().querySelector('.stage svg').children];
|
||||
const idxOf = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
out.imageAbovePaper = idxOf('image') > idxOf('.hp-paper') && idxOf('image') >= 0;
|
||||
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
|
||||
// editors keep the paper too (their canvas ignores bg_color anyway)
|
||||
c._setMode('decor');
|
||||
await upd();
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg rect.hp-paper');
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
// daynight night: the paper dims via the brightness filter ONLY, never alpha
|
||||
@@ -184,7 +194,7 @@ const paper = await page.evaluate(async () => {
|
||||
} } };
|
||||
await upd();
|
||||
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
const pn = sr().querySelector('.stage svg rect.hp-paper');
|
||||
const pn = sr().querySelector('.stage svg .hp-paper');
|
||||
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
|
||||
delete c._serverCfg.settings.bg_mode;
|
||||
delete c._serverCfg.settings.north_deg;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// The content frame is PRESENTATION (docs/CANVAS.md §4) — audit dev@2c947f4.
|
||||
//
|
||||
// DEV-2C947-01: a hidden device is not drawn, but it still stretched the
|
||||
// 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.
|
||||
//
|
||||
// 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);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// Load a config/layout into the card AND into the demo's WS stub, so the
|
||||
// static card (which fetches its own copy) sees exactly the same house.
|
||||
const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
|
||||
const c = window.__card;
|
||||
const srvCfg = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
|
||||
const srvLay = (await c.hass.callWS({ type: 'houseplan/layout/get' })).layout;
|
||||
for (const k of Object.keys(srvCfg)) delete srvCfg[k];
|
||||
Object.assign(srvCfg, JSON.parse(JSON.stringify(c2)));
|
||||
for (const k of Object.keys(srvLay)) delete srvLay[k];
|
||||
Object.assign(srvLay, JSON.parse(JSON.stringify(l2)));
|
||||
c._serverCfg = srvCfg;
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = sp;
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout, space]);
|
||||
|
||||
// ---------------------------------------------------------------- 01: hidden
|
||||
// The auditor's probe, verbatim: ONE visible room and ONE marker parked 90
|
||||
// canvases away. Two content items is below MIN_VOTERS, so the outlier vote
|
||||
// never runs and the far position is guaranteed to stay in the core — the
|
||||
// frame either sees the marker or it does not.
|
||||
const oneRoom = (hidden) => ({
|
||||
spaces: [{
|
||||
id: 'h1', title: 'Hidden', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]] }],
|
||||
}],
|
||||
// a made-up area: no auto device of the demo house lands in this space, so
|
||||
// the only content is the room and the one marker below
|
||||
markers: [{ id: 'm_far', binding: 'device:d_motion', area: 'zx_room', hidden }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const FAR_POS = { m_far: { s: 'h1', x: 90, y: 0.5 } };
|
||||
|
||||
await load(oneRoom(false), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const b = c._baseVb();
|
||||
return {
|
||||
// premise: while it IS drawn, the marker is content and the frame holds it
|
||||
visibleMarkerIsDrawn: sr.querySelectorAll('.devlayer .dev').length === 1,
|
||||
visibleMarkerWidensTheFrame: b[2] > 50000,
|
||||
};
|
||||
}));
|
||||
|
||||
await load(oneRoom(true), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
o.hiddenMarkerNotDrawn = sr.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.hiddenMarkerStillBuilt = !!c._devices.find((d) => d.id === 'm_far' && d.hidden === true);
|
||||
// the room is 100..900 render units, padded by 5 % of the longer side
|
||||
o.frameIsTheVisibleRoom = Math.round(b[0]) === 60 && Math.round(b[2]) === 880
|
||||
&& Math.round(b[1]) === 60 && Math.round(b[3]) === 880;
|
||||
// and the room fills the screen instead of being a dot 112x too small
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
const poly = sr.querySelector('polygon.room').getBoundingClientRect();
|
||||
o.roomFillsTheStage = poly.width > st.width * 0.7;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// the same house on the STATIC card (space-render.ts drew `devs` but framed
|
||||
// `spaceDevs` — the identical split)
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const o = {};
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'width:400px;height:400px';
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: 'h1' });
|
||||
el.hass = window.__card.hass;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
await el.updateComplete;
|
||||
const svg = el.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const vb = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
o.staticCardHidesTheMarker = el.renderRoot.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.staticFrameIsTheVisibleRoom = vb.length === 4
|
||||
&& Math.round(vb[0]) === 60 && Math.round(vb[2]) === 880;
|
||||
host.remove();
|
||||
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;
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------- 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);
|
||||
@@ -0,0 +1,197 @@
|
||||
// A cover NEVER wears a coloured plate (owner's contract 2026-08-04):
|
||||
// «у штор не должно быть жёлтой подложки никогда, индикация открыто/закрыто
|
||||
// за счёт морфинга иконки».
|
||||
//
|
||||
// Before this the domain shared one branch with `valve`: 'open'/'opening' put
|
||||
// the orange «открыто» frame on the badge (.dev.open — a FILLED orange plate,
|
||||
// --hp-open #ff9f43, not just a border), and the travelling states carried it
|
||||
// underneath the breathing ring. Now a cover returns no plate class at all:
|
||||
//
|
||||
// closed / open / ajar -> neutral plate, the ICON tells the state
|
||||
// opening / closing -> neutral plate + the .covermove ring (kept, the
|
||||
// owner approved it 2026-08-03)
|
||||
//
|
||||
// The frame itself is NOT gone: door/window binary sensors and locks still
|
||||
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
|
||||
// re-checked at the bottom, or «no yellow for curtains» would quietly cost
|
||||
// the whole house its «открыто» indication.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// --- colour probes: resolve the tokens the same way the badge does ------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)'); // «включено»
|
||||
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
|
||||
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
|
||||
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
|
||||
|
||||
// --- the gate marker, alone in the Garden, told it IS the curtain -------
|
||||
const setMarker = async () => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state, attributes: { friendly_name: 'Gate', ...attrs } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — a colour read in the same frame is
|
||||
// still mid-fade and would pass on a plate that ends up orange
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await setMarker();
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
|
||||
const devEl = () => sr().querySelector('.devlayer .dev');
|
||||
const cls = () => [...(devEl()?.classList || [])];
|
||||
const plate = () => getComputedStyle(devEl()).backgroundColor;
|
||||
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
|
||||
|
||||
/** One state: the plate must be the neutral one, and nothing else. */
|
||||
const plateIsNeutral = (tag) => {
|
||||
o[tag + 'PlateNeutral'] = plate() === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = plate() !== YELLOW;
|
||||
o[tag + 'NotOrange'] = plate() !== ORANGE;
|
||||
o[tag + 'NoPlateClass'] = !cls().includes('on') && !cls().includes('open');
|
||||
};
|
||||
|
||||
plateIsNeutral('closed');
|
||||
o.closedNoRing = !cls().includes('covermove');
|
||||
o.closedIconClosed = icon() === 'mdi:curtains-closed';
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
plateIsNeutral('open');
|
||||
o.openNoRing = !cls().includes('covermove');
|
||||
o.openIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
plateIsNeutral('ajar');
|
||||
o.ajarIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
plateIsNeutral('opening');
|
||||
o.openingRings = cls().includes('covermove');
|
||||
o.openingRingIsTheBreathingOne = (() => {
|
||||
const cs = getComputedStyle(devEl(), '::after');
|
||||
return cs.animationName === 'hp-covermove' && cs.animationDuration === '2.2s';
|
||||
})();
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
plateIsNeutral('closing');
|
||||
o.closingRings = cls().includes('covermove');
|
||||
|
||||
// --- the morph covers every class, both ways ---------------------------
|
||||
const morph = async (dc, closedIcon, openIcon) => {
|
||||
await setCover('closed', dc ? { device_class: dc } : {});
|
||||
const a = icon();
|
||||
await setCover('open', dc ? { device_class: dc } : {});
|
||||
const b = icon();
|
||||
return a === closedIcon && b === openIcon && a !== b;
|
||||
};
|
||||
o.morphBlind = await morph('blind', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShade = await morph('shade', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShutter = await morph('shutter', 'mdi:window-shutter', 'mdi:window-shutter-open');
|
||||
o.morphCurtain = await morph('curtain', 'mdi:curtains-closed', 'mdi:curtains');
|
||||
o.morphWindow = await morph('window', 'mdi:window-closed', 'mdi:window-open');
|
||||
o.morphAwning = await morph('awning', 'mdi:awning-outline', 'mdi:awning');
|
||||
o.morphDoor = await morph('door', 'mdi:door-closed', 'mdi:door-open');
|
||||
o.morphGarage = await morph('garage', 'mdi:garage', 'mdi:garage-open');
|
||||
o.morphGate = await morph('gate', 'mdi:gate', 'mdi:gate-open');
|
||||
o.morphDamper = await morph('damper', 'mdi:circle-slice-8', 'mdi:circle-outline');
|
||||
// no device_class at all (z2m ships plenty): the gate's auto icon is the
|
||||
// one the name rule «ворота|garage|gate» hands out, and it morphs too
|
||||
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
|
||||
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
|
||||
// …and an unreadable state morphs nothing, plate still neutral
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
plateIsNeutral('unknown');
|
||||
o.unknownNoMorph = icon() === 'mdi:garage-variant';
|
||||
o.unknownNoRing = !cls().includes('covermove');
|
||||
|
||||
// --- NOT touched: the «открыто» frame elsewhere ------------------------
|
||||
// the demo house has a lock and a window sensor on the ground floor
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const elOf = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'unlocked', attributes: { friendly_name: 'Front door' } },
|
||||
'binary_sensor.window': { entity_id: 'binary_sensor.window', state: 'on', attributes: { friendly_name: 'Window', device_class: 'window' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const lockEl = elOf(dev('d_lock'));
|
||||
const winEl = elOf(dev('d_window'));
|
||||
o.lockMarkerFound = !!lockEl;
|
||||
o.windowMarkerFound = !!winEl;
|
||||
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
|
||||
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
|
||||
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
|
||||
&& getComputedStyle(winEl).backgroundColor === ORANGE;
|
||||
// …and a locked lock is neutral again, so the frame still MEANS something
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
|
||||
|
||||
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
|
||||
// only thing it has to say «открыт» with (deliberately left alone)
|
||||
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
|
||||
const clsOfState = (d, states) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
|
||||
o.valveClosedIsNeutral = clsOfState(valve, { 'valve.water': { state: 'closed', attributes: {} } }) === '';
|
||||
// and the cover's own class string, straight from the source
|
||||
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: null };
|
||||
o.coverOpenClassIsEmpty = clsOfState(cover, { 'cover.blind': { state: 'open', attributes: { device_class: 'blind' } } }) === '';
|
||||
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'covermove';
|
||||
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'covermove';
|
||||
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,234 @@
|
||||
// «Открыть/закрыть» on a curtain whose cover entity is NOT the primary one
|
||||
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
|
||||
// по-прежнему инфо-карточка»).
|
||||
//
|
||||
// The device below is his, entity for entity: an Aqara «Roller shade driver
|
||||
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
|
||||
// `switch.*_reverse_direction` beside it. `primaryEntity` ranks visible above
|
||||
// hidden and `switch` above `cover`, so the marker's primary was the service
|
||||
// switch — the tap then resolved on the domain `switch` and the action the
|
||||
// user had explicitly chosen degraded to the info card, while the dialog kept
|
||||
// OFFERING the action, because that check already looked at every entity of
|
||||
// the device. The tap now finds the cover the same way the climate
|
||||
// temperature finds the thermostat: among all of them.
|
||||
//
|
||||
// End to end: the action is chosen in the dialog and SAVED through the normal
|
||||
// path, and the click is done on the marker the card rebuilt from that config.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
|
||||
// ---- the owner's curtain driver, joined to the demo house --------------
|
||||
const ENTS = {
|
||||
// hidden by the integration — exactly as z2m ships it
|
||||
'cover.office_curtain': { entity_id: 'cover.office_curtain', device_id: 'd_curtain', platform: 'demo', hidden: true },
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
|
||||
// a MIXED device: a lamp that also happens to ship a cover. Its primary is
|
||||
// meaningfully the light, and nothing below may take that away from it.
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
};
|
||||
const coverState = (state, attrs = {}) => ({
|
||||
entity_id: 'cover.office_curtain', state,
|
||||
attributes: { friendly_name: 'Office curtain', ...attrs },
|
||||
});
|
||||
const push = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null },
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'cover.office_curtain': coverState(state, attrs),
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: 'opening', attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
},
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
// park both extra markers in their own corners: the DOM assertions below
|
||||
// find a marker by its position, and the auto grid would sit them on top
|
||||
// of the demo house's own bedroom devices
|
||||
const cu = c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
if (cu && mx) {
|
||||
c._layout = { ...c._layout,
|
||||
[cu.id]: { s: cu.space, x: 0.06, y: 0.94 },
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
const mixed = () => c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
o.deviceIsOnThePlan = !!curtain();
|
||||
// the premise of the whole report: the cover is NOT the primary entity
|
||||
o.primaryIsNotTheCover = curtain()?.primary === 'switch.office_curtain_reverse';
|
||||
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
|
||||
|
||||
// ---- the dialog offers the action and SAVES it -------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
|
||||
o.dialogOffersCover = optionValues().includes('cover');
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' };
|
||||
await c.updateComplete;
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
o.markerSaved = saved?.tap_action === 'cover';
|
||||
|
||||
// ---- and the tap on THAT marker opens the curtain -----------------------
|
||||
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const tap = async () => {
|
||||
c._clickDevice({ stopPropagation() {} }, curtain());
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
};
|
||||
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.noInfoCard = !c._infoCard;
|
||||
c._infoCard = null;
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.openClosesTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'close_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.travellingStops = (calls[calls.length - 1] || [])[1] === 'stop_cover';
|
||||
|
||||
// the service switch, which used to be the whole story, is never touched
|
||||
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
|
||||
|
||||
// ---- the guarded classes still degrade, read off the COVER --------------
|
||||
await push('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.garageCallsNothing = calls.length === 0;
|
||||
o.garageShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
|
||||
// ---- and the marker SHOWS that cover, not the service switch -----------
|
||||
// Owner, 2026-08-04: «нет ни дышащего кольца во время хода, ни рамки
|
||||
// "открыто", ни морфинга иконки». (The «открыто» frame itself was retired
|
||||
// for covers later the same day — the marker still had to start speaking for
|
||||
// the cover, which is what this section pins.) Same cause, same helper:
|
||||
// _stateClass and
|
||||
// the icon morph read d.primary, so the plan reported the state of
|
||||
// `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker
|
||||
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
|
||||
// exactly when the owner has explicitly chosen «Открыть/закрыть».
|
||||
const devEl = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
|
||||
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
const setSwitch = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state, attributes: {} } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
o.markerIsOnScreen = !!devEl(curtain());
|
||||
o.closedIsNeitherOpenNorMoving =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('covermove');
|
||||
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
// the plate is neutral in every cover state since 2026-08-04 — the morph is
|
||||
// the whole open/closed story (smoke_cover_no_plate.mjs)
|
||||
o.openWearsNoColouredPlate =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('on');
|
||||
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
o.travellingBreathes = clsOf(curtain()).includes('covermove');
|
||||
await push('closing', { device_class: 'curtain' });
|
||||
o.closingBreathesToo = clsOf(curtain()).includes('covermove');
|
||||
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('covermove');
|
||||
|
||||
// the service switch never speaks for the marker again: reverse-direction
|
||||
// ON used to paint the curtain yellow («включено») while it stood still
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.reverseSwitchNeverLightsTheMarker = !clsOf(curtain()).includes('on');
|
||||
|
||||
// THE BOUNDARY of the rule: take the explicit action away and the marker
|
||||
// goes back to its primary entity — no cover indication is invented
|
||||
const savedMarker = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
savedMarker.tap_action = 'info';
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.withoutTheActionThePrimarySpeaks = clsOf(curtain()).includes('on')
|
||||
&& !clsOf(curtain()).includes('covermove');
|
||||
savedMarker.tap_action = 'cover';
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
o.actionBackMeansCoverBack = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
// ---- the auditor's own probe: BOTH entities visible ---------------------
|
||||
// DEV-2C947-04 was written up with a plain visible `cover.*` beside a
|
||||
// visible `switch.*`: no hidden tier involved, `switch` simply outranks
|
||||
// `cover` inside DOMAIN_PRIORITY. Same premise, same cure — pinned here so
|
||||
// the finding cannot come back through the other door.
|
||||
ENTS['cover.office_curtain'] = { ...ENTS['cover.office_curtain'], hidden: false };
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
o.visibleCoverIsStillNotThePrimary = curtain()?.primary === 'switch.office_curtain_reverse';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.visibleCoverAlsoIndicates = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
o.noInfoCardForTheVisibleCover = !c._infoCard;
|
||||
|
||||
// ---- a MIXED device is not hijacked ------------------------------------
|
||||
// A lit lamp that also owns a cover keeps the lamp: no explicit «Open/close»
|
||||
// on this marker, so nothing changes for it — and even if there were one,
|
||||
// a shining light still wins the yellow plate (the glow and the badge may
|
||||
// never disagree, owner's principle 2026-07-29).
|
||||
o.mixedPrimaryIsTheLight = mixed()?.primary === 'light.mixed';
|
||||
o.mixedCoverIsTravelling = c.hass.states['cover.mixed'].state === 'opening';
|
||||
o.mixedStaysALitLamp = clsOf(mixed()).includes('on') && !clsOf(mixed()).includes('covermove');
|
||||
// its icon is the device's own auto icon, NOT the cover's open/closed pair
|
||||
o.mixedIconIsNotMorphedByTheCover =
|
||||
iconOf(mixed()) === mixed()?.icon && !iconOf(mixed()).includes('curtains');
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,207 @@
|
||||
// An EXPLICIT «Открыть/закрыть» marker is a curtain, and a curtain is never
|
||||
// painted — audit DEV-1DA1-01 (P2).
|
||||
//
|
||||
// `_stateClass` used to answer in this order: bound `controls` first, then any
|
||||
// lit `light.*` of the device, and only then the entity the tap acts on
|
||||
// (`_actEntity` → `_coverIndicator`). So the two combinations below still made
|
||||
// a curtain yellow, in flat contradiction with the owner's contract («у штор
|
||||
// не должно быть жёлтой подложки НИКОГДА») and with the promise that a marker
|
||||
// indicates the entity its tap drives:
|
||||
//
|
||||
// 1. a MIXED device (a lamp that also ships a blind) told `tap_action:
|
||||
// 'cover'` — the lit `light.mixed` returned 'on' before the cover branch
|
||||
// was ever reached, so the travelling blind lost its breathing ring too;
|
||||
// 2. a curtain marker with a bound `controls` target that happened to be on
|
||||
// — `controls` returned 'on' first, same result.
|
||||
//
|
||||
// The order now starts with the explicit cover: saying «this thing is a
|
||||
// curtain» in the dialog is the strongest statement the card has about what a
|
||||
// marker IS, so nothing may paint over it. Everything else keeps the old
|
||||
// precedence — a marker that was NOT told it is a curtain is untouched, and
|
||||
// that is re-checked at the bottom.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// ---- the plate colours, resolved the way the badge resolves them -------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)');
|
||||
const ORANGE = probe('var(--hp-open)');
|
||||
const NEUTRAL = probe('var(--hp-bg)');
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
|
||||
// =======================================================================
|
||||
// Part 1 — the class itself, straight from _stateClass, over every state
|
||||
// =======================================================================
|
||||
const withStates = (states, fn) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = fn();
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
const st = (state, attrs = {}) => ({ state, attributes: attrs });
|
||||
/** The auditor's marker #1: primary is a LIT light, the cover is explicit. */
|
||||
const litLamp = {
|
||||
id: 'p_lamp', primary: 'light.mixed', entities: ['light.mixed', 'cover.mixed'],
|
||||
tapAction: 'cover', marker: null,
|
||||
};
|
||||
/** The auditor's marker #2: a cover marker whose bound control is on. */
|
||||
const controlled = {
|
||||
id: 'p_ctrl', primary: 'cover.mixed', entities: ['cover.mixed'],
|
||||
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'] },
|
||||
};
|
||||
const CV = { closed: '', open: '', opening: 'covermove', closing: 'covermove' };
|
||||
for (const [state, want] of Object.entries(CV)) {
|
||||
const states = {
|
||||
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
|
||||
'switch.control': st('on', {}),
|
||||
'cover.mixed': st(state, { device_class: 'curtain' }),
|
||||
};
|
||||
o['litLamp_' + state] = withStates(states, () => c._stateClass(litLamp)) === want;
|
||||
o['controlled_' + state] = withStates(states, () => c._stateClass(controlled)) === want;
|
||||
}
|
||||
// an unreachable cover still only fades — never yellow, never orange
|
||||
o.litLampUnavailFades = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(litLamp)) === 'unavail';
|
||||
o.controlledUnavailFades = withStates({
|
||||
'switch.control': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(controlled)) === 'unavail';
|
||||
// …and a cover entity with no state at all is simply neutral, not yellow
|
||||
o.litLampNoCoverStateIsNeutral = withStates({
|
||||
'light.mixed': st('on', {}),
|
||||
}, () => c._stateClass(litLamp)) === '';
|
||||
|
||||
// ---- the OTHER side of the rule: nothing changed for the rest ----------
|
||||
// the very same mixed device WITHOUT the explicit action keeps its primary
|
||||
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null };
|
||||
o.mixedWithoutTheOptionStaysYellow = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === 'on';
|
||||
o.mixedWithoutTheOptionOffIsNeutral = withStates({
|
||||
'light.mixed': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === '';
|
||||
// a wall switch with controls and NO cover in sight still mirrors them
|
||||
const remote = {
|
||||
id: 'p_remote', primary: 'sensor.remote', entities: ['sensor.remote'], tapAction: null,
|
||||
marker: { id: 'p_remote', binding: 'device:y', controls: ['light.hall'] },
|
||||
};
|
||||
o.remoteMirrorsItsControls = withStates({ 'light.hall': st('on', {}) },
|
||||
() => c._stateClass(remote)) === 'on';
|
||||
o.remoteOffIsNeutral = withStates({ 'light.hall': st('off', {}) },
|
||||
() => c._stateClass(remote)) === '';
|
||||
// a curtain marker with controls that are OFF is still just a curtain
|
||||
o.controlledOffStillRings = withStates({
|
||||
'switch.control': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(controlled)) === 'covermove';
|
||||
// an explicit «cover» marker with NO cover among its entities falls back to
|
||||
// the old order — the statement is only as strong as the entity behind it
|
||||
const noCover = {
|
||||
id: 'p_nocover', primary: 'light.mixed', entities: ['light.mixed'], tapAction: 'cover', marker: null,
|
||||
};
|
||||
o.explicitCoverWithoutACoverKeepsThePrimary = withStates({ 'light.mixed': st('on', {}) },
|
||||
() => c._stateClass(noCover)) === 'on';
|
||||
|
||||
// =======================================================================
|
||||
// Part 2 — the real markers on the real plan: the PLATE, not just a class
|
||||
// =======================================================================
|
||||
const ENTS = {
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', device_id: 'd_ctrl', platform: 'demo' },
|
||||
'switch.control': { entity_id: 'switch.control', device_id: 'd_wall', platform: 'demo' },
|
||||
};
|
||||
const push = async (coverState) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null },
|
||||
d_ctrl: { id: 'd_ctrl', name: 'Office curtain', model: 'Curtain motor',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_ctrl']], entry_type: null, via_device_id: null },
|
||||
d_wall: { id: 'd_wall', name: 'Wall switch', model: 'Wall switch',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_wall']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: coverState, attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', state: coverState, attributes: { friendly_name: 'Office curtain', device_class: 'curtain' } },
|
||||
'switch.control': { entity_id: 'switch.control', state: 'on', attributes: { friendly_name: 'Wall switch' } },
|
||||
},
|
||||
};
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover' },
|
||||
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'] },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
const ct = c._devices.find((x) => x.bindingRef === 'd_ctrl');
|
||||
if (mx && ct) {
|
||||
c._layout = { ...c._layout,
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 },
|
||||
[ct.id]: { s: ct.space, x: 0.06, y: 0.94 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — read a settled colour, not a fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await push('opening');
|
||||
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
const elOf = (d) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(d);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const report = (tag, ref) => {
|
||||
const d = dev(ref);
|
||||
const el = d && elOf(d);
|
||||
o[tag + 'MarkerFound'] = !!el;
|
||||
if (!el) return;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = bg !== YELLOW;
|
||||
o[tag + 'NotOrange'] = bg !== ORANGE;
|
||||
o[tag + 'NoOnClass'] = !el.classList.contains('on') && !el.classList.contains('open');
|
||||
o[tag + 'Rings'] = el.classList.contains('covermove');
|
||||
o[tag + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
|
||||
};
|
||||
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
|
||||
o.controlIsTrulyOn = c.hass.states['switch.control'].state === 'on';
|
||||
report('litLampDom', 'd_mixed');
|
||||
report('controlledDom', 'd_ctrl');
|
||||
|
||||
// …and at rest (closed) they are plain, with no ring
|
||||
await push('closed');
|
||||
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
|
||||
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
|
||||
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
|
||||
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('covermove');
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,196 @@
|
||||
// Covers: the «Open/close» tap action + the travelling indication
|
||||
// (owner's contract 2026-08-03).
|
||||
//
|
||||
// closed -> cover.open_cover
|
||||
// open / ajar -> cover.close_cover
|
||||
// opening / closing -> cover.stop_cover (a tap during travel is a STOP;
|
||||
// the next tap simply travels the other way)
|
||||
// unknown -> cover.toggle, no morphing, no pulse
|
||||
//
|
||||
// The option is offered ONLY for a binding that HAS a cover entity, and never
|
||||
// for the guarded classes (garage/door/gate) — for those a value smuggled into
|
||||
// the config degrades to 'info', exactly like a card-wide toggle does.
|
||||
// While travelling the icon breathes a soft ring (.covermove, the vacuum
|
||||
// puck's 2.2s period) and the plate stays NEUTRAL — yellow means «включено».
|
||||
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
|
||||
// frame included: open/closed is told by the icon morph alone (the full
|
||||
// contract lives in smoke_cover_no_plate.mjs).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
const YELLOW = 'rgb(255, 212, 92)'; // --hp-on, the «включено» plate
|
||||
|
||||
const gateEntity = (state, attrs) => ({
|
||||
entity_id: 'cover.gate', state,
|
||||
attributes: { friendly_name: 'Gate', ...attrs },
|
||||
});
|
||||
/** Push a cover state into hass keeping the service recorder in place. */
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: { ...c.hass.states, 'cover.gate': gateEntity(state, attrs) },
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
/** Marker for the gate device: tap action + optional confirmation.
|
||||
* `solo` also hides the mower, leaving exactly one badge in the Garden —
|
||||
* the indication checks then read the cover's own plate, not a neighbour's. */
|
||||
const setMarker = async (tapAction, confirm = false, solo = false) => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction, tap_confirm: confirm || null },
|
||||
...(solo ? [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] : []),
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const gate = () => c._devices.find((x) => x.bindingRef === 'd_gate');
|
||||
const tap = async () => { c._clickDevice({ stopPropagation() {} }, gate()); await c.updateComplete; };
|
||||
// never throw when the feature is missing: the smoke must REPORT the gap
|
||||
const lastCall = () => calls[calls.length - 1] || ['-', '-', {}];
|
||||
// the gate is the only cover in the Garden space; the plan draws no names
|
||||
// inside the badge, so the icon set of the space is the readable handle
|
||||
const devEl = () => sr().querySelector('.dev');
|
||||
const icons = () => [...sr().querySelectorAll('.dev ha-icon')].map((e) => e.getAttribute('icon'));
|
||||
|
||||
// the gate lives in the Garden space — look at it, not at the ground floor
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- the dialog offers the option only where it makes sense ------------
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
|
||||
// …and the confirmation checkbox follows the choice, like it does for run/toggle
|
||||
const rowsBefore = sr().querySelectorAll('.dialog .srcrow').length;
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
|
||||
o.confirmCheckboxShownForCover = sr().querySelectorAll('.dialog .srcrow').length > rowsBefore;
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// …and neither does a device without a cover entity at all
|
||||
c._openMarkerDialog(c._devices.find((x) => x.bindingRef === 'd_mower')); await c.updateComplete;
|
||||
o.dialogHidesCoverWithoutCover = !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._setMode('view'); c._space = 'garden'; c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// ---- the tap itself ----------------------------------------------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.closedOpens = JSON.stringify(lastCall()) === JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.gate' }]);
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.openCloses = lastCall()[0] === 'cover' && lastCall()[1] === 'close_cover';
|
||||
|
||||
// a half-drawn curtain is still 'open' for HA — it closes
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.ajarCloses = lastCall()[1] === 'close_cover';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.openingStops = lastCall()[1] === 'stop_cover';
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.closingStops = lastCall()[1] === 'stop_cover';
|
||||
|
||||
// no readable state — a plain cover.toggle, nothing clever
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.unknownToggles = lastCall()[0] === 'cover' && lastCall()[1] === 'toggle';
|
||||
|
||||
// ---- the confirmation checkbox works for this action too ---------------
|
||||
await setMarker('cover', true);
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.confirmAsks = !!c._tapConfirm && c._tapConfirm.text.includes('Gate');
|
||||
c._tapConfirm = null; await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmCancelCallsNothing = calls.length === 0;
|
||||
await tap();
|
||||
const conf = c._tapConfirm; c._tapConfirm = null; conf?.exec?.();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmOkOpens = calls.length === 1 && calls[0][1] === 'open_cover';
|
||||
|
||||
// ---- a saved 'cover' on a GARAGE degrades to the info card -------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.garageSavedCoverCallsNothing = calls.length === 0;
|
||||
o.garageSavedCoverShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
|
||||
// ---- the indication ----------------------------------------------------
|
||||
const afterAnim = () => {
|
||||
const el = devEl();
|
||||
if (!el) return null;
|
||||
const cs = getComputedStyle(el, '::after');
|
||||
return { name: cs.animationName, dur: cs.animationDuration };
|
||||
};
|
||||
await setMarker('cover', false, true); // the gate alone on the plan
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
// .dev animates `background 0.15s`: lit recycles the badge node the hidden
|
||||
// mower used, so a plate colour read in the same frame is still mid-fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
|
||||
o.pulseWhileOpening = sr().querySelectorAll('.dev.covermove').length === 1;
|
||||
const a1 = afterAnim();
|
||||
o.pulseIsBreathingRing = a1?.name === 'hp-covermove' && a1?.dur === '2.2s';
|
||||
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
|
||||
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.pulseWhileClosing = sr().querySelectorAll('.dev.covermove').length === 1;
|
||||
o.closingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
o.noPulseWhenOpen = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.iconMorphOpen = icons().includes('mdi:curtains') && !icons().includes('mdi:curtains-closed');
|
||||
// owner 2026-08-04: never a coloured plate on a cover — not even «открыто»
|
||||
o.openWearsNoFrame = sr().querySelectorAll('.dev.open').length === 0;
|
||||
o.openPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
o.noPulseWhenClosed = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.iconMorphClosed = icons().includes('mdi:curtains-closed') && !icons().includes('mdi:curtains');
|
||||
|
||||
await setCover('open', { device_class: 'shutter' });
|
||||
o.iconMorphShutterOpen = icons().includes('mdi:window-shutter-open');
|
||||
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
o.noPulseWhenUnknown = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.noMorphWhenUnknown = icons().includes(gate().icon)
|
||||
&& !icons().some((i) => i.startsWith('mdi:curtains'));
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
+110
-2
@@ -11,7 +11,9 @@ const res = await page.evaluate(async () => {
|
||||
tabs[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
out.decorBar = !!sr().querySelector('.editbar.decorbar');
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
|
||||
// six drawing tools + «Картинка-подложка», which f1 offers because it HAS a
|
||||
// picture (docs/BACKDROP.md §2); a hand-drawn space still shows six
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn.dtool').length === 7;
|
||||
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
|
||||
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
@@ -45,6 +47,45 @@ const res = await page.evaluate(async () => {
|
||||
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
out.degenerateSkipped = c._decorList.length === 3;
|
||||
// 3b) живая плашка размера, пока фигуру ещё тянут (владелец 2026-08-04:
|
||||
// «в редакторе подложки у линий писать длину, как при рисовании комнат в
|
||||
// редакторе плана»). Та же .measurelabel, тот же formatLength/cell_cm.
|
||||
const label = () => sr().querySelector('.measurelabel.dmeasure');
|
||||
const txt = () => (label() ? label().textContent.trim() : null);
|
||||
const cellCm = 5; // cell_cm по умолчанию
|
||||
const fmt = (px) => (((px / g) * cellCm) / 100).toFixed(2) + ' m';
|
||||
const vw = () => c._viewOr(c._baseVb());
|
||||
c._decorTool = 'line';
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 4, g * 20], pid: 3 };
|
||||
await c.updateComplete;
|
||||
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
|
||||
await c.updateComplete;
|
||||
out.lineBadgeShown = !!label();
|
||||
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
|
||||
out.lineBadgeText = txt();
|
||||
out.lineBadgeMatchesGeometry = txt() === fmt(g * 12) + ' · 0°';
|
||||
// ...ровно на середине отрезка (владелец: «плашка на середине линии»)
|
||||
out.lineBadgeAtTheMiddle = !!label() && Math.abs(
|
||||
parseFloat(label().style.left) - ((g * 10 - vw().x) / vw().w) * 100) < 0.02;
|
||||
// 0° кратен 45° — плашка зелёная, как у стены на 45°
|
||||
out.badgeGreenOnAxis = !!label() && label().classList.contains('on45');
|
||||
// косой отрезок 3-4-5: длина 5 клеток, угол 53.1° — не кратен 45°
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 7, g * 24], pid: 3 };
|
||||
await c.updateComplete;
|
||||
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
|
||||
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
|
||||
// прямоугольник и овал: длины у них нет, есть габарит «Ш × В»
|
||||
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
|
||||
await c.updateComplete;
|
||||
out.rectBadgeIsSize = txt() === fmt(g * 8) + ' × ' + fmt(g * 6);
|
||||
// отпустили — плашки нет
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.badgeGoneAfterRelease = !label();
|
||||
c._curSpaceCfg.decor = c._decorList.slice(0, 3); // назад к трём фигурам
|
||||
c._decorTool = 'select';
|
||||
await c.updateComplete;
|
||||
out.stillThreeShapes = c._decorList.length === 3;
|
||||
// 4) надпись через диалог
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
@@ -62,6 +103,72 @@ const res = await page.evaluate(async () => {
|
||||
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
|
||||
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
|
||||
c._decorMove = null;
|
||||
// 5b) инструмент рисования ВЛАДЕЕТ холстом: клик по существующей фигуре
|
||||
// начинает новую фигуру в этой точке, а не выделяет старую (владелец
|
||||
// 2026-08-04: «нельзя поставить начало линии на конец другой»).
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
// экранные координаты точки холста (обратное _screenToVb)
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height };
|
||||
};
|
||||
const press = (el, x, y, pid) => {
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
pointerId: pid, clientX, clientY, button: 0, isPrimary: true }));
|
||||
};
|
||||
// подопытная линия с известными концами
|
||||
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; c._decorMove = null;
|
||||
const decorBefore = c._decorList.slice();
|
||||
c._curSpaceCfg.decor = [{ id: 'dcprobe', kind: 'line', x1: (g * 6) / 1000, y1: (g * 30) / 1000,
|
||||
x2: (g * 18) / 1000, y2: (g * 30) / 1000, color: '#000000', width: 3 }];
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const probe = () => [...sr().querySelectorAll('.decorlayer line.dshape')]
|
||||
.find((l) => Math.abs(+l.getAttribute('x1') - g * 6) < 0.01);
|
||||
out.probeRendered = !!probe();
|
||||
// — инструмент «линия»: жмём ровно на КОНЕЦ линии
|
||||
c._decorTool = 'line'; await c.updateComplete;
|
||||
// фигура вообще перестаёт быть мишенью для указателя
|
||||
out.shapeInertUnderLineTool = getComputedStyle(probe()).pointerEvents === 'none';
|
||||
press(probe(), g * 18, g * 30, 41);
|
||||
await c.updateComplete;
|
||||
out.lineToolStartsDraft = !!c._decorDraft && c._decorDraft.kind === 'line';
|
||||
// ...и именно из этой точки (снап к концу старой линии)
|
||||
out.draftStartsAtTheEnd = !!c._decorDraft
|
||||
&& Math.abs(c._decorDraft.a[0] - g * 18) < 0.51 && Math.abs(c._decorDraft.a[1] - g * 30) < 0.51;
|
||||
out.lineToolNoSelect = c._decorSel === null;
|
||||
out.lineToolNoMove = !c._decorMove;
|
||||
c._decorDraft = null; await c.updateComplete;
|
||||
// — прямоугольник и овал ведут себя так же
|
||||
for (const [tool, key] of [['rect', 'rectToolStartsDraft'], ['ellipse', 'ellipseToolStartsDraft']]) {
|
||||
c._decorTool = tool; c._decorSel = null; await c.updateComplete;
|
||||
press(probe(), g * 12, g * 30, 42);
|
||||
await c.updateComplete;
|
||||
out[key] = !!c._decorDraft && c._decorDraft.kind === tool && c._decorSel === null;
|
||||
c._decorDraft = null;
|
||||
}
|
||||
// — «текст» по фигуре открывает диалог новой надписи, а не выделяет
|
||||
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null; await c.updateComplete;
|
||||
press(probe(), g * 12, g * 30, 43);
|
||||
await c.updateComplete;
|
||||
out.textToolOpensDialog = !!c._decorTextDialog && !c._decorTextDialog.id && c._decorSel === null;
|
||||
c._decorTextDialog = null;
|
||||
// — а вот в «выборе» всё по-прежнему: клик выделяет и берёт на перетаскивание
|
||||
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; await c.updateComplete;
|
||||
out.shapeLiveUnderSelect = getComputedStyle(probe()).pointerEvents !== 'none';
|
||||
press(probe(), g * 12, g * 30, 44);
|
||||
await c.updateComplete;
|
||||
out.selectStillSelects = c._decorSel === 'dcprobe';
|
||||
out.selectStillGrabs = !!c._decorMove && c._decorMove.id === 'dcprobe';
|
||||
out.selectMakesNoDraft = !c._decorDraft;
|
||||
c._decorMove = null; c._decorSel = null;
|
||||
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
|
||||
c._decorTool = 'select'; await c.updateComplete;
|
||||
out.decorRestored = c._decorList.length === decorBefore.length;
|
||||
// 6) erase удаляет
|
||||
const n0 = c._decorList.length;
|
||||
c._decorTool = 'erase';
|
||||
@@ -82,5 +189,6 @@ const res = await page.evaluate(async () => {
|
||||
out.deleteKey = c._decorList.length === n1 - 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
// значение плашки зафиксировано числом: 12 клеток × cell_cm 5 = 0.60 m, 0°
|
||||
checkAll(res, { lineBadgeText: '0.60 m · 0°' });
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// DEV-B58-01 — the infinite canvas reached the FRAME and the drawing, but not
|
||||
// the drag handlers. A device marker was clamped into the CONTENT FRAME (with
|
||||
// a 0.8 % inset), a room label into the space's stored view_box — which, for
|
||||
// every plan the card has ever written, is the old unit square. The owner and a
|
||||
// user hit the same wall: "названия комнат и устройства не перетаскиваются
|
||||
// дальше старых границ холста".
|
||||
//
|
||||
// Every interactive coordinate now has ONE bound: the ±5000 the backend
|
||||
// enforces (docs/CANVAS.md §9). This smoke starts from an ORDINARY plan — one
|
||||
// that lives inside 0..1, so the old clamps really were in the way — and drags
|
||||
// each kind of thing out to 2.5 / 2.2 and beyond.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const NORM_W = 1000;
|
||||
const near = (a, b, eps = 1 / 240) => Math.abs(a - b) <= eps + 1e-9;
|
||||
const onGrid = (v) => Math.abs(v * 240 - Math.round(v * 240)) < 1e-9;
|
||||
|
||||
// the demo plan is an ordinary one: rooms inside the old unit square
|
||||
const b0 = c._baseVb();
|
||||
o.startsFromAnOrdinaryPlan = b0[0] + b0[2] < 1.3 * NORM_W && b0[1] + b0[3] < 1.3 * NORM_W;
|
||||
|
||||
/** Turn a wanted RENDER-unit delta into the clientX/clientY a handler wants. */
|
||||
const mk = (dxu, dyu, sx = 400, sy = 400, extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: sx + (dxu / v.w) * rect.width,
|
||||
clientY: sy + (dyu / v.h) * rect.height,
|
||||
bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
|
||||
// ---- (a) a DEVICE MARKER goes to 2.5 / 2.2 ---------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => !d.virtual);
|
||||
const p0 = c._pos(dev);
|
||||
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
|
||||
c._pointerMove(mk(2.5 * NORM_W - p0.x, 2.2 * NORM_W - p0.y), dev);
|
||||
c._pointerUp(new PointerEvent('pointerup'), dev);
|
||||
await c.updateComplete;
|
||||
const saved = c._layout[dev.id];
|
||||
o.markerLeftTheOldCanvas = near(saved.x, 2.5) && near(saved.y, 2.2);
|
||||
o.markerIsOnTheGrid = onGrid(saved.x) && onGrid(saved.y);
|
||||
|
||||
// ---- (b) it SURVIVES a rebuild ---------------------------------------
|
||||
c._drag = null;
|
||||
c._modelCache = null; c._frame = null; c._defPos = c._defaultPositions();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const back = c._pos(c._devices.find((d) => d.id === dev.id));
|
||||
o.markerSurvivesRebuild = near(back.x / NORM_W, 2.5) && near(back.y / NORM_W, 2.2);
|
||||
// …and the frame followed it out there instead of pretending it is elsewhere
|
||||
const b1 = c._baseVb();
|
||||
o.frameFollowedTheMarker = b1[0] + b1[2] > 2.4 * NORM_W;
|
||||
|
||||
// ---- (c) a ROOM LABEL, the worse of the two (it clamped to view_box) --
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
|
||||
const lp = c._labelPos(room, c._space);
|
||||
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
|
||||
c._labelMove(mk(2.7 * NORM_W - lp.x, 2.4 * NORM_W - lp.y), room, c._space);
|
||||
c._labelUp(room);
|
||||
await c.updateComplete;
|
||||
const sl = c._layout['rl_' + room.id];
|
||||
o.labelLeftTheOldCanvas = near(sl.x, 2.7) && near(sl.y, 2.4);
|
||||
o.labelIsOnTheGrid = onGrid(sl.x) && onGrid(sl.y);
|
||||
c._drag = null;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const lp2 = c._labelPos(room, c._space);
|
||||
o.labelSurvivesRebuild = near(lp2.x / NORM_W, 2.7) && near(lp2.y / NORM_W, 2.4);
|
||||
|
||||
// ---- (d) DECOR born far out, and moved further still -------------------
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._decorTool = 'rect';
|
||||
c._decorDraft = { kind: 'rect', a: [2.6 * NORM_W, 2.6 * NORM_W], b: [2.9 * NORM_W, 2.8 * NORM_W], pid: 1 };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
const sh = c._decorList[c._decorList.length - 1];
|
||||
o.decorBornFarOut = near(sh.x, 2.6) && near(sh.y, 2.6);
|
||||
// the decor mover works in ABSOLUTE svg points, so the gesture is built the
|
||||
// way _decorShapeDown builds it: grab at the shape, release 0.6/0.5 away
|
||||
const atUnits = (X, Y) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
|
||||
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true,
|
||||
});
|
||||
};
|
||||
const grab = c._svgPoint(atUnits(2.6 * NORM_W, 2.6 * NORM_W));
|
||||
c._decorMove = { id: sh.id, start: grab,
|
||||
orig: JSON.parse(JSON.stringify(sh)), pid: 2, moved: false };
|
||||
c._decorMoveUpdate(atUnits(3.2 * NORM_W, 3.1 * NORM_W));
|
||||
await c.updateComplete;
|
||||
const sh2 = c._decorList.find((x) => x.id === sh.id);
|
||||
o.decorMovedFurtherOut = near(sh2.x, 3.2) && near(sh2.y, 3.1);
|
||||
c._decorMove = null;
|
||||
|
||||
// ---- (e) the ONE bound that is left is the backend's ±5000 ------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev2 = c._devices.find((d) => d.id !== dev.id && !d.virtual);
|
||||
const q0 = c._pos(dev2);
|
||||
c._drag = { id: dev2.id, sx: 400, sy: 400, ox: q0.x, oy: q0.y, moved: false };
|
||||
c._pointerMove(mk(9e9, 9e9), dev2);
|
||||
c._pointerUp(new PointerEvent('pointerup'), dev2);
|
||||
c._drag = null;
|
||||
o.garbageStillClamped = c._layout[dev2.id].x === 5000 && c._layout[dev2.id].y === 5000;
|
||||
|
||||
// ---- (f) Shift is the only way to land BETWEEN the nodes --------------
|
||||
const dev3 = c._devices.find((d) => d.id !== dev.id && d.id !== dev2.id && !d.virtual);
|
||||
const r0 = c._pos(dev3);
|
||||
const halfStep = NORM_W / 240 / 2;
|
||||
c._drag = { id: dev3.id, sx: 400, sy: 400, ox: r0.x, oy: r0.y, moved: false };
|
||||
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y, 400, 400, { shiftKey: true }), dev3);
|
||||
c._drag = null;
|
||||
o.shiftPlacesBetweenTheNodes = !onGrid(c._layout[dev3.id].x);
|
||||
|
||||
c._setMode('view');
|
||||
return o;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -1,4 +1,5 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -8,6 +9,10 @@ const res = await page.evaluate(async () => {
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.rows = sr().querySelectorAll('.gsrow').length;
|
||||
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
|
||||
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
|
||||
out.aboutVersion = sr().querySelector('.dialog .aboutver')?.textContent.trim() ?? null;
|
||||
out.aboutLinks = [...sr().querySelectorAll('.dialog a.aboutlink')].map((a) => ({
|
||||
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
|
||||
// 2) сменить цвет light_on и сохранить
|
||||
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
|
||||
await c._saveSettingsDialog(); await c.updateComplete;
|
||||
@@ -26,10 +31,25 @@ const res = await page.evaluate(async () => {
|
||||
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
||||
return out;
|
||||
});
|
||||
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
|
||||
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
|
||||
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
|
||||
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
|
||||
const bundle = readFileSync(new URL('./srv/assets/houseplan-card.js', import.meta.url), 'utf8');
|
||||
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
|
||||
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
|
||||
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
|
||||
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"rows": 14, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun"],
|
||||
"rows": 15, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
|
||||
// + «Выровнять всё по сетке» (docs/CANVAS.md §9)
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun", "Grid", "About"],
|
||||
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
|
||||
"aboutLinks": [
|
||||
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
|
||||
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
|
||||
],
|
||||
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||
"lqiBefore": 7,
|
||||
"lqiAfter": 0,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// «Всё строго по сетке» (docs/CANVAS.md §9). Two halves:
|
||||
// 1. every element placed or dragged by hand lands on a node — devices, room
|
||||
// labels, decor, room vertices, resize handles; an opening lands ON ITS
|
||||
// WALL at a whole number of steps along it; Shift is the only way out;
|
||||
// 2. the explicit «Выровнять всё по сетке» action moves a deliberately
|
||||
// detuned plan onto the grid, tells the truth about how much it moves,
|
||||
// and does nothing at all the second time.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const NORM_W = 1000, GRID_N = 240;
|
||||
const PITCH = NORM_W / GRID_N;
|
||||
const onGridN = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
|
||||
const onGridR = (v) => Math.abs(v / PITCH - Math.round(v / PITCH)) < 1e-7;
|
||||
|
||||
/** A click/pointer event at a given RENDER-unit point of the current view. */
|
||||
const at = (X, Y, type = 'pointermove', extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent(type, {
|
||||
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
|
||||
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
// an off-grid target: a third of a step past a node, in both axes
|
||||
const OFF = PITCH / 3;
|
||||
|
||||
// ---- 1a) the snap itself, the one gate every gesture goes through -----
|
||||
const s1 = c._snap([100 + OFF, 200 + OFF]);
|
||||
o.snapRoundsToTheNode = onGridR(s1[0]) && onGridR(s1[1]);
|
||||
const s2 = c._snap([100 + OFF, 200 + OFF], { shiftKey: true });
|
||||
o.shiftSuspendsTheSnap = !onGridR(s2[0]);
|
||||
o.snapAlsoClampsTheGarbage = c._snap([1e12, -1e12])[0] === 5000 * NORM_W;
|
||||
// the step is NORM_W/GRID_N and nothing else — it did NOT change with the
|
||||
// infinite canvas, so no plan's nodes ever moved out from under it
|
||||
o.pitchIsTheCanvasFreeConstant = Math.abs(c._gridPitch - PITCH) < 1e-12;
|
||||
|
||||
// ---- 1b) a DEVICE dropped with the mouse lands on a node --------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => !d.virtual);
|
||||
const p0 = c._pos(dev);
|
||||
const mkDelta = (dxu, dyu, extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: 400 + (dxu / v.w) * rect.width, clientY: 400 + (dyu / v.h) * rect.height,
|
||||
bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
|
||||
c._pointerMove(mkDelta(137.7 - p0.x + OFF, 251.3 - p0.y + OFF), dev);
|
||||
c._drag = null;
|
||||
o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y);
|
||||
|
||||
// ---- 1c) a ROOM LABEL too ---------------------------------------------
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
|
||||
const lp = c._labelPos(room, c._space);
|
||||
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
|
||||
c._labelMove(mkDelta(311.4 - lp.x + OFF, 402.9 - lp.y + OFF), room, c._space);
|
||||
c._drag = null;
|
||||
o.roomLabelLandsOnANode = onGridN(c._layout['rl_' + room.id].x)
|
||||
&& onGridN(c._layout['rl_' + room.id].y);
|
||||
// …and one that has never been dragged is on a node as well
|
||||
const other = c._spaceModel(c._space).rooms.find((r) => r.name && r.id !== room.id);
|
||||
const op = c._labelPos(other, c._space);
|
||||
o.untouchedLabelIsOnANode = onGridR(op.x) && onGridR(op.y);
|
||||
|
||||
// ---- 1d) DECOR: draft, text anchor, and a move ------------------------
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._decorTool = 'rect';
|
||||
// dispatched for real, so the handler sees a target (it looks for .dshape)
|
||||
sr().querySelector('.stage').dispatchEvent(at(300 + OFF, 300 + OFF, 'pointerdown', { pointerId: 21 }));
|
||||
o.decorDraftStartsOnANode = onGridR(c._decorDraft.a[0]) && onGridR(c._decorDraft.a[1]);
|
||||
c._decorDraft = { ...c._decorDraft, b: c._snap([420 + OFF, 380 + OFF]) };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
const dsh = c._decorList[c._decorList.length - 1];
|
||||
o.decorShapeIsOnNodes = onGridN(dsh.x) && onGridN(dsh.y)
|
||||
&& onGridN(dsh.x + dsh.w) && onGridN(dsh.y + dsh.h);
|
||||
c._decorTool = 'text';
|
||||
sr().querySelector('.stage').dispatchEvent(at(500 + OFF, 500 + OFF, 'pointerdown', { pointerId: 22 }));
|
||||
o.decorTextAnchorIsOnANode = onGridN(c._decorTextDialog.x) && onGridN(c._decorTextDialog.y);
|
||||
c._decorTextDialog = null;
|
||||
// a shape that is ALREADY off the grid is put on it by one drag: the mover
|
||||
// snaps the resulting anchor, not the delta (which used to preserve the drift)
|
||||
const sp = c._curSpaceCfg;
|
||||
sp.decor = [...c._decorList, { id: 'dcOff', kind: 'rect',
|
||||
x: 0.3013, y: 0.4017, w: 0.1, h: 0.05, color: '#889', width: 2 }];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const off = c._decorList.find((x) => x.id === 'dcOff');
|
||||
c._decorMove = { id: 'dcOff', start: c._svgPoint(at(301.3, 401.7)),
|
||||
orig: JSON.parse(JSON.stringify(off)), pid: 9, moved: false };
|
||||
c._decorMoveUpdate(at(351.3, 451.7));
|
||||
await c.updateComplete;
|
||||
const off2 = c._decorList.find((x) => x.id === 'dcOff');
|
||||
o.oneDragPutsADriftedShapeOnTheGrid = onGridN(off2.x) && onGridN(off2.y);
|
||||
c._decorMove = null;
|
||||
sp.decor = sp.decor.filter((x) => x.id !== 'dcOff' && x.id !== dsh.id);
|
||||
|
||||
// ---- 1e) an OPENING is WALL-bound: on its wall, whole steps along it ---
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._tool = 'opening';
|
||||
const spm = c._spaceModel(c._space);
|
||||
const wall = (() => {
|
||||
for (const r of spm.rooms) {
|
||||
const poly = r.poly || null;
|
||||
if (!poly) continue;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) > PITCH * 20) return [a, b];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
o.foundAWallToTestWith = !!wall;
|
||||
if (wall) {
|
||||
const [a, b] = wall;
|
||||
const mid = [(a[0] + b[0]) / 2 + 0.37 * PITCH, (a[1] + b[1]) / 2 + 0.4];
|
||||
c._openingClick(mid);
|
||||
const d = c._openingDialog;
|
||||
o.openingPlaced = !!d;
|
||||
if (d) {
|
||||
// exactly on the wall segment…
|
||||
const t = ((d.x - a[0]) * (b[0] - a[0]) + (d.y - a[1]) * (b[1] - a[1]))
|
||||
/ ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
|
||||
const px = a[0] + t * (b[0] - a[0]), py = a[1] + t * (b[1] - a[1]);
|
||||
o.openingSitsOnItsWall = Math.hypot(d.x - px, d.y - py) < 1e-6;
|
||||
// …and at a whole number of steps along it (or at the wall's own centre,
|
||||
// which is what the magnet is for and what the ruler shows)
|
||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
const along = t * len;
|
||||
o.openingIsQuantisedAlongTheWall =
|
||||
Math.abs(along / PITCH - Math.round(along / PITCH)) < 1e-6
|
||||
|| Math.abs(along - len / 2) < 1e-6;
|
||||
}
|
||||
c._openingDialog = null;
|
||||
}
|
||||
|
||||
// ---- 1f) a room VERTEX drawn by hand ----------------------------------
|
||||
c._tool = 'draw'; c._path = [];
|
||||
c._markupClick(at(220 + OFF, 220 + OFF, 'click'));
|
||||
o.roomVertexOnANode = c._path.length === 1 && onGridR(c._path[0][0]) && onGridR(c._path[0][1]);
|
||||
c._path = []; c._tool = 'draw';
|
||||
|
||||
// ---- 2) «Выровнять всё по сетке» --------------------------------------
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// a deliberately detuned fixture: a third of a step off, everywhere
|
||||
const D = 1 / GRID_N / 3;
|
||||
const FIX = { spaces: [{
|
||||
id: 'g1', title: 'Grid test', view_box: [0, 0, 1, 1],
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'A', area: 'living_room',
|
||||
poly: [[0.2 + D, 0.2], [0.5, 0.2 - D], [0.5 + D, 0.5], [0.2, 0.5]] },
|
||||
{ id: 'r2', name: 'B', area: 'kitchen', x: 0.5, y: 0.2, w: 0.3 + D, h: 0.3 },
|
||||
],
|
||||
openings: [{ id: 'o1', type: 'window', x: 0.3 + D, y: 0.2 + D / 2, angle: 0, length: 0.06 }],
|
||||
decor: [{ id: 'd1', kind: 'line', x1: 0.1 + D, y1: 0.7, x2: 0.9, y2: 0.7 - D }],
|
||||
}], markers: [], settings: {} };
|
||||
const FIXLAY = { d_light1: { s: 'g1', x: 0.3 + D, y: 0.3 }, rl_r1: { s: 'g1', x: 0.25 + D, y: 0.25 } };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(FIX));
|
||||
c._layout = JSON.parse(JSON.stringify(FIXLAY));
|
||||
c._modelCache = null; c._frame = null; c._space = 'g1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
c._openAlignDialog();
|
||||
await c.updateComplete;
|
||||
const dlg = c._alignDialog;
|
||||
o.alignDialogOpens = !!dlg && !!sr().querySelector('.dialogwrap .alignmsg');
|
||||
o.alignCountsWhatMoves = !!dlg && dlg.report.moved > 0 && dlg.report.moved <= dlg.report.total;
|
||||
// half a step of 5 cm cells ≈ 2.5 cm; a third of a step is well under that
|
||||
o.alignPromisesASmallShift = !!dlg && dlg.cm > 0 && dlg.cm < 3;
|
||||
o.alignWarnsAboutNoUndo = (sr().querySelector('.dialogwrap .rhint')?.textContent || '').length > 20;
|
||||
|
||||
// capture what the network is told, then run it
|
||||
const sent = [];
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m.type); return base(m); } };
|
||||
await c._runAlignToGrid();
|
||||
await c.updateComplete;
|
||||
o.alignDialogClosed = c._alignDialog === null;
|
||||
o.alignWroteConfigOnce = sent.filter((t) => t === 'houseplan/config/set').length === 1;
|
||||
o.alignWroteLayout = sent.some((t) => t === 'houseplan/layout/update');
|
||||
|
||||
const g = c._serverCfg.spaces[0];
|
||||
o.alignedRoomPoly = g.rooms[0].poly.every((p) => onGridN(p[0]) && onGridN(p[1]));
|
||||
o.alignedRoomRect = onGridN(g.rooms[1].x) && onGridN(g.rooms[1].x + g.rooms[1].w);
|
||||
o.alignedDecor = onGridN(g.decor[0].x1) && onGridN(g.decor[0].y1)
|
||||
&& onGridN(g.decor[0].x2) && onGridN(g.decor[0].y2);
|
||||
o.alignedLayout = onGridN(c._layout.d_light1.x) && onGridN(c._layout.rl_r1.x);
|
||||
// the opening stayed ON the top wall of r1 rather than being rounded off it
|
||||
const w0 = g.rooms[0].poly[0], w1 = g.rooms[0].poly[1];
|
||||
const tt = ((g.openings[0].x - w0[0]) * (w1[0] - w0[0]) + (g.openings[0].y - w0[1]) * (w1[1] - w0[1]))
|
||||
/ ((w1[0] - w0[0]) ** 2 + (w1[1] - w0[1]) ** 2);
|
||||
o.alignedOpeningStaysOnItsWall = Math.hypot(
|
||||
g.openings[0].x - (w0[0] + tt * (w1[0] - w0[0])),
|
||||
g.openings[0].y - (w0[1] + tt * (w1[1] - w0[1]))) < 1e-9;
|
||||
|
||||
// ---- 2b) …and the second run has nothing to do ------------------------
|
||||
c._openAlignDialog();
|
||||
await c.updateComplete;
|
||||
o.secondRunMovesNothing = c._alignDialog.report.moved === 0;
|
||||
o.secondRunOffersNoButton = !sr().querySelector('.dialogwrap .btn.on');
|
||||
c._alignDialog = null;
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- 2c) the promise is an UPPER BOUND, in EACH space's own scale -----
|
||||
// AUD-158B1-01: one normalised maximum converted through the first space's
|
||||
// cell size promised 2.5 cm for a vertex that moved 50 cm on the floor above.
|
||||
const MULTI = { spaces: [
|
||||
{ id: 'm1', title: 'Ground', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'ma', name: 'A', area: 'living_room',
|
||||
poly: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3], [0.1, 0.3]] }] },
|
||||
{ id: 'm2', title: 'Attic', cell_cm: 100, view_box: [0, 0, 1, 1],
|
||||
// the ONLY off-grid vertex on the plan, and it is on the 100 cm floor
|
||||
rooms: [{ id: 'mb', name: 'B', area: 'kitchen',
|
||||
poly: [[0.2 + 1 / GRID_N / 2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]] }] },
|
||||
], markers: [], settings: {} };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(MULTI));
|
||||
c._layout = {};
|
||||
c._modelCache = null; c._frame = null; c._space = 'm1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._openAlignDialog(); await c.updateComplete;
|
||||
const md = c._alignDialog;
|
||||
o.alignPromiseUsesTheOwnScaleOfEachSpace = !!md && md.cm >= 50 && md.cm < 51;
|
||||
o.alignPromiseNamesTheSpaceItBelongsTo = !!md && md.where === 'Attic'
|
||||
&& (sr().querySelector('.dialogwrap .body')?.textContent || '').includes('Attic');
|
||||
c._alignDialog = null; await c.updateComplete;
|
||||
|
||||
// ---- 2d) an angle-only opening fix is offerable (AUD-158B1-02) --------
|
||||
// its centre is already on the wall; only the stored angle is wrong, and the
|
||||
// batch used to report "nothing to move" while returning a different plan
|
||||
const TURN = { spaces: [{ id: 'a1', title: 'Flat', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r1', name: 'A', area: 'living_room',
|
||||
poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
|
||||
openings: [{ id: 'oa', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }],
|
||||
}], markers: [], settings: {} };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(TURN));
|
||||
c._layout = {};
|
||||
c._modelCache = null; c._frame = null; c._space = 'a1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._openAlignDialog(); await c.updateComplete;
|
||||
const ad = c._alignDialog;
|
||||
o.angleOnlyOpeningCounts = !!ad && ad.report.moved === 1 && ad.report.rotated === 1;
|
||||
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('.dialogwrap .btn.on');
|
||||
await c._runAlignToGrid();
|
||||
await c.updateComplete;
|
||||
o.angleOnlyOpeningIsActuallyFixed = c._serverCfg.spaces[0].openings[0].angle === 0;
|
||||
|
||||
return o;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -175,8 +175,14 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
|
||||
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
|
||||
// статичная карточка рендерит от полного квадрата 0..1000
|
||||
const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10];
|
||||
// docs/CANVAS.md §4: статичная карточка теперь кадрирует СОДЕРЖИМОЕ, как и
|
||||
// полная, а не квадрат 0..1000 — координаты берём из её же viewBox
|
||||
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '0 0 1000 1000')
|
||||
.trim().split(/\s+/).map(Number);
|
||||
const stVb = st && [
|
||||
svb[0] + (parseFloat(st.style.left) / 100) * svb[2],
|
||||
svb[1] + (parseFloat(st.style.top) / 100) * svb[3],
|
||||
];
|
||||
o.autoGridParity = !!fullVb && !!stVb
|
||||
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
|
||||
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
// Infinite canvas (docs/CANVAS.md): a plan drawn far past the old unit square
|
||||
// renders whole, devices can be placed and saved out there, the opening view
|
||||
// follows the content, one far stray neither breaks the view nor hides itself,
|
||||
// zoom-out stops at 3x the content and the icons keep their proportion to it.
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const SHOTS = process.env.HP_SHOTS || '';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old
|
||||
// canvas. Before the infinite canvas the -25%..125% envelope threw every one
|
||||
// of these vertices away and the card framed empty space.
|
||||
const FAR_CFG = {
|
||||
spaces: [{
|
||||
id: 'f1', title: 'Ground floor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'Living room', area: 'living_room',
|
||||
poly: [[1.5, 1.5], [2.2, 1.5], [2.2, 2.2], [1.5, 2.2]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[2.2, 1.5], [3.0, 1.5], [3.0, 2.0], [2.2, 2.0]] },
|
||||
{ id: 'r3', name: 'Bedroom', area: 'bedroom',
|
||||
poly: [[2.2, 2.0], [3.0, 2.0], [3.0, 2.6], [2.2, 2.6]] },
|
||||
{ id: 'r4', name: 'Hallway', area: 'hallway',
|
||||
poly: [[1.5, 2.2], [2.2, 2.2], [2.2, 2.6], [1.5, 2.6]] },
|
||||
],
|
||||
openings: [{ id: 'o1', type: 'window', x: 1.8, y: 1.5, angle: 0, length: 0.15 }],
|
||||
decor: [{ id: 'd1', kind: 'line', x1: 1.4, y1: 2.7, x2: 3.1, y2: 2.7, color: '#8899aa' }],
|
||||
}, {
|
||||
id: 'garden', title: 'Garden', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'g1', name: 'Garden', area: 'garden',
|
||||
poly: [[0.03, 0.178], [0.97, 0.178], [0.97, 0.822], [0.03, 0.822]] }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const FAR_LAYOUT = {
|
||||
d_light1: { s: 'f1', x: 1.7, y: 1.7 }, d_lamp: { s: 'f1', x: 2.0, y: 1.9 },
|
||||
d_tv: { s: 'f1', x: 1.6, y: 2.1 }, d_temp: { s: 'f1', x: 1.9, y: 1.6 },
|
||||
d_kettle: { s: 'f1', x: 2.5, y: 1.7 }, d_leak: { s: 'f1', x: 2.9, y: 1.8 },
|
||||
d_bedlight: { s: 'f1', x: 2.6, y: 2.3 }, d_window: { s: 'f1', x: 2.9, y: 2.5 },
|
||||
d_lock: { s: 'f1', x: 1.6, y: 2.5 }, d_motion: { s: 'f1', x: 2.0, y: 2.4 },
|
||||
};
|
||||
|
||||
const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(JSON.stringify(c2));
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = 'f1';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout]);
|
||||
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
|
||||
// ---- (b) the plan past the old square renders WHOLE ----------------------
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
// the content is 1500..3000 x 1500..2700 render units, plus decor/openings
|
||||
o.frameHoldsFarPlan = b[0] < 1450 && b[0] + b[2] > 3050 && b[1] < 1450 && b[1] + b[3] > 2750;
|
||||
o.frameIsNotTheOldSquare = b[0] > 1000; // nothing is framed at the origin any more
|
||||
const v = c._viewOr(b);
|
||||
o.viewShowsFarPlan = v.x <= 1500 && v.x + v.w >= 3000;
|
||||
// every room polygon is inside the painted viewBox
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const polys = [...sr.querySelectorAll('polygon.room')];
|
||||
o.allRoomsDrawn = polys.length === 4;
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
o.roomsOnScreen = polys.every((p) => {
|
||||
const r = p.getBoundingClientRect();
|
||||
return r.width > 4 && r.height > 4 && r.left >= st.left - 2 && r.right <= st.right + 2
|
||||
&& r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
|
||||
});
|
||||
// and the markers landed on the plan, not in a corner of a phantom canvas
|
||||
const devs = [...sr.querySelectorAll('.devlayer .dev')];
|
||||
o.markersOnPlan = devs.length >= 10 && devs.every((d) => {
|
||||
const r = d.getBoundingClientRect();
|
||||
return r.left >= st.left - 2 && r.right <= st.right + 2 && r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
|
||||
});
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- a device can be PLACED past the old square, and it saves -------------
|
||||
const ws = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sent = [];
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return base(m); } };
|
||||
// drop a virtual marker at 3.4 / 2.9 — a long way outside the old canvas
|
||||
const far = { s: 'f1', x: 3.4, y: 2.9 };
|
||||
c._layout = { ...c._layout, d_gate: far };
|
||||
c._dirtyPos.add('d_gate');
|
||||
await c._persistLayout?.();
|
||||
// and a room extended even further out, saved through the config writer
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].rooms.push({ id: 'r5', name: 'Workshop',
|
||||
poly: [[3.1, 2.0], [3.8, 2.0], [3.8, 2.6], [3.1, 2.6]] });
|
||||
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
|
||||
await c._saveConfig?.();
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
return { sent: JSON.parse(JSON.stringify(sent)), layout: JSON.parse(JSON.stringify(c._layout)) };
|
||||
});
|
||||
out.farDeviceSaved = ws.layout.d_gate.x === 3.4 && ws.layout.d_gate.y === 2.9;
|
||||
const cfgMsg = ws.sent.filter((m) => m.type === 'houseplan/config/set').pop();
|
||||
const layMsg = ws.sent.filter((m) => m.type === 'houseplan/layout/update').pop();
|
||||
out.wsCarriedFarGeometry = !!cfgMsg
|
||||
&& JSON.stringify(cfgMsg.config).includes('3.8');
|
||||
out.wsCarriedFarPosition = !!layMsg && JSON.stringify(layMsg).includes('3.4');
|
||||
|
||||
// the payload the card sends must pass the REAL backend schema (docs/CANVAS.md
|
||||
// §3: ±5000). Skipped, not failed, where voluptuous is not installed.
|
||||
out.backendAcceptsFarPayload = (() => {
|
||||
if (!cfgMsg) return false;
|
||||
const f = '/tmp/hp_infinite_payload.json';
|
||||
writeFileSync(f, JSON.stringify({ config: cfgMsg.config, layout: (layMsg || {}).layout || ws.layout }));
|
||||
try {
|
||||
execFileSync('python3', ['-c', 'import voluptuous'], { stdio: 'ignore' });
|
||||
} catch { console.log('(voluptuous absent — backend schema check skipped)'); return true; }
|
||||
const code = [
|
||||
'import json,sys',
|
||||
'sys.path.insert(0, "custom_components/houseplan")',
|
||||
'import validation as v',
|
||||
`d=json.load(open(${JSON.stringify(f)}))`,
|
||||
'v.CONFIG_SCHEMA(d["config"]); v.LAYOUT_SCHEMA(d["layout"])',
|
||||
'print("VALID")',
|
||||
].join('\n');
|
||||
try {
|
||||
return execFileSync('python3', ['-c', code], { encoding: 'utf8' }).includes('VALID');
|
||||
} catch (e) { console.log('backend refused the payload:', String(e.stdout || e.message).slice(-400)); return false; }
|
||||
})();
|
||||
|
||||
// ---- an OUTLIER: the view ignores it, the hint offers it ------------------
|
||||
await loadFar({
|
||||
...FAR_CFG,
|
||||
spaces: FAR_CFG.spaces.map((s) => (s.id === 'f1' ? { ...s } : s)),
|
||||
}, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
o.outlierReported = c._outliers === 1;
|
||||
const b = c._baseVb();
|
||||
o.outlierDoesNotCommandView = b[2] < 4000 && b[0] + b[2] < 5000; // still the house, not 90 canvases
|
||||
o.outlierViewStillHoldsPlan = b[0] < 1500 && b[0] + b[2] > 3000;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const hint = sr.querySelector('.farhint');
|
||||
o.hintShown = !!hint && !!hint.querySelector('button');
|
||||
o.hintIsNotAModal = !sr.querySelector('.dialogwrap');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// «Показать» / «Вписать всё» takes the stray in
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
sr.querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
const o = {};
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000;
|
||||
o.fitAllStillHoldsPlan = v.x <= 1500 && v.y <= 1500;
|
||||
await c.updateComplete;
|
||||
o.hintGoneAfterShow = !sr.querySelector('.farhint');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- zoom-out stops at 3x, zoom-in unchanged -----------------------------
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
c._resetZoom();
|
||||
const fit = { ...c._viewOr(c._baseVb()) };
|
||||
c._applyView(0.001);
|
||||
o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9
|
||||
&& Math.abs(c._view.w - fit.w * 3) < fit.w * 0.02;
|
||||
c._applyView(50);
|
||||
o.zoomInUnchanged = c._zoom === 8;
|
||||
c._resetZoom();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- pan has slack, and "home is that way" brings you back ---------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const fit = { ...c._view };
|
||||
// pan most of a screen to the right: nothing pins the content over the scene
|
||||
c._view = c._clampView({ ...fit, x: fit.x + fit.w * 0.9 }, fit);
|
||||
o.panPastContentAllowed = c._view.x > fit.x + fit.w * 0.5;
|
||||
// walk right off the plan: the frame is entirely outside the view now
|
||||
c._view = { ...fit, x: fit.x + fit.w * 1.6 };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const arrow = sr.querySelector('.homearrow');
|
||||
o.homeArrowAppears = !!arrow;
|
||||
if (arrow) {
|
||||
arrow.click();
|
||||
await c.updateComplete;
|
||||
o.homeArrowFitsBack = Math.abs(c._view.x - fit.x) < 2 && Math.abs(c._view.w - fit.w) < 2;
|
||||
await c.updateComplete;
|
||||
o.homeArrowGone = !sr.querySelector('.homearrow');
|
||||
}
|
||||
// and it is NOT there while the plan is on screen
|
||||
c._resetZoom(); c.requestUpdate(); await c.updateComplete;
|
||||
o.noHomeArrowWhenVisible = !sr.querySelector('.homearrow');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- icons scale WITH THE PLAN across zooms (docs/CANVAS.md §6) ----------
|
||||
// The infinite canvas briefly made --icon-size a percentage of the viewport,
|
||||
// so a marker kept its pixel size at every zoom. The owner looked at it and
|
||||
// asked for the original contract back (2026-08-03): an icon is a percentage
|
||||
// of the PLAN, it grows when you zoom in and shrinks when you zoom out, like
|
||||
// everything else drawn on the plan. What the infinite canvas contributes is
|
||||
// only the base unit the percentage is taken OF: `iconUnit` instead of the
|
||||
// stored `vb.w`, checked right below on this very plan — which is drawn
|
||||
// entirely outside the old square.
|
||||
const farIcon = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
// the badge is --dev-size PLUS a 1px border on each side, and the border
|
||||
// is not part of the scaled geometry (same correction as smoke_icon_scale)
|
||||
const size = () => {
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
return Math.round((el.getBoundingClientRect().width - 2) * 100) / 100;
|
||||
};
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const at1 = size();
|
||||
c._applyView(4); await c.updateComplete;
|
||||
const at4 = size();
|
||||
c._applyView(1 / 3); await c.updateComplete;
|
||||
const atOut = size();
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
|
||||
o.iconShrinksWithZoomOut = Math.abs(atOut / at1 - 1 / 3) < 0.02;
|
||||
if (!o.iconGrowsWithZoomIn || !o.iconShrinksWithZoomOut) console.log('icon px', at1, at4, atOut);
|
||||
return { o, at1 };
|
||||
});
|
||||
Object.assign(out, farIcon.o);
|
||||
|
||||
// ---- an OLD plan (small, with a view_box) opens exactly as before ---------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const cfg = {
|
||||
spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
|
||||
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
c._serverCfg = cfg; c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions(); // the auto grid follows the new rooms
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const b = c._baseVb();
|
||||
// identical to what contentBounds produced before: the content bbox
|
||||
// (40..960 x 140..580) plus 5% of the longer side (920 -> 46)
|
||||
o.legacyFrameUnchanged = Math.abs(b[0] - -6) < 1 && Math.abs(b[1] - 94) < 1
|
||||
&& Math.abs(b[2] - 1012) < 1 && Math.abs(b[3] - 532) < 1;
|
||||
o.legacyNoHint = c._outliers === 0;
|
||||
// an EMPTY space falls back to the stored view_box hint (§4)
|
||||
c._serverCfg = { spaces: [{ id: 'f1', title: 'E', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }],
|
||||
markers: [], settings: {} };
|
||||
// a space with NOTHING in it at all — not even a marker parked in it
|
||||
c._layout = {}; c._modelCache = null; c._frame = null;
|
||||
c._devices = [];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const e = c._baseVb();
|
||||
o.emptySpaceUsesViewBoxHint = JSON.stringify(e.map(Math.round)) === '[100,200,500,400]';
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- and the icon does not DEGENERATE on the runaway plan ----------------
|
||||
// The catch of "a percentage of the plan" on an unbounded canvas: keep the
|
||||
// old fixed numerator (vb.w = NORM_W) and a plan drawn 2.3 canvases wide
|
||||
// divides it by a 2.5-canvas-wide frame — the marker shrinks by that factor
|
||||
// and keeps shrinking the further out the plan is drawn. `iconUnit` grows
|
||||
// with the plan, so the marker on the far plan measured above is the same
|
||||
// number of pixels as on this ordinary 0..1 one.
|
||||
out.farIconNotDegenerate = await page.evaluate(async (farPx) => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
// the small plan from the block above, with its devices back
|
||||
c._serverCfg = { spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
|
||||
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
|
||||
markers: [], settings: {} };
|
||||
c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
const px = el.getBoundingClientRect().width - 2;
|
||||
if (!(px > 4 && Math.abs(px / farPx - 1) < 0.3)) console.log('icon px near/far', px, farPx);
|
||||
return px > 4 && Math.abs(px / farPx - 1) < 0.3;
|
||||
}, farIcon.at1);
|
||||
|
||||
// ---- screenshots ---------------------------------------------------------
|
||||
if (SHOTS) {
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_far_plan.png` });
|
||||
// fit-all with a stray far away
|
||||
await loadFar(FAR_CFG, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_far_hint.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
(c.shadowRoot || c.renderRoot).querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` });
|
||||
// adaptive grid, zoomed far out in the plan editor
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._resetZoom(); c._applyView(1 / 3); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._resetZoom(); c._applyView(4); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` });
|
||||
await page.evaluate(async () => { window.__card._setMode('view'); });
|
||||
}
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,138 @@
|
||||
// A kiosk gesture is classified ONCE, and the release obeys that decision —
|
||||
// audit DEV-1DA1-02 (P2).
|
||||
//
|
||||
// `_stagePointerMove` locks the gesture on the first movement past 8 px
|
||||
// (`_panLock`: 'swipe' if it is horizontal enough inside the swipe zone, 'pan'
|
||||
// otherwise) and the plan then follows the finger. But `_stagePointerUp` used
|
||||
// to ignore that lock and ask `swipeTarget()` again, from the raw start→end
|
||||
// vector alone. A CURVED gesture — a small vertical lead-in that locks 'pan',
|
||||
// then a long horizontal sweep — therefore panned under the finger and still
|
||||
// switched the floor on release. On a wall tablet that is the worst kind of
|
||||
// surprise: you watch the plan drag along and land on another storey.
|
||||
//
|
||||
// The lock is now final: with `_panLock === 'pan'` the floor never changes, no
|
||||
// matter what the overall vector looks like. The two straight gestures keep
|
||||
// their old behaviour, and so does the motionless double tap (no movement,
|
||||
// no lock — the swipe path is never even reached by it).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c0 = window.__card;
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c0.hass;
|
||||
document.body.appendChild(k);
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
k.hass = { ...c0.hass };
|
||||
await k.updateComplete;
|
||||
const sr = k.shadowRoot || k.renderRoot;
|
||||
const stage = sr.querySelector('.stage');
|
||||
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
o.kioskHasSeveralSpaces = k._model.length > 1;
|
||||
|
||||
const home = async () => {
|
||||
const vb = k._baseVb();
|
||||
k._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
await k.updateComplete;
|
||||
};
|
||||
/**
|
||||
* Play one trajectory and report what the gesture decided and what it did.
|
||||
* `pts` are the moves; the finger lifts at the last one.
|
||||
*/
|
||||
const play = async (id, from, pts) => {
|
||||
await home();
|
||||
const s0 = k._space;
|
||||
const v0 = { ...k._viewOr(k._baseVb()) };
|
||||
fire('pointerdown', id, from[0], from[1]);
|
||||
let lockAfterLeadIn = null;
|
||||
let moved = false;
|
||||
pts.forEach((p, i) => {
|
||||
fire('pointermove', id, p[0], p[1]);
|
||||
if (i === 0) lockAfterLeadIn = k._panLock;
|
||||
if (Math.abs(k._viewOr(k._baseVb()).x - v0.x) > 1
|
||||
|| Math.abs(k._viewOr(k._baseVb()).y - v0.y) > 1) moved = true;
|
||||
});
|
||||
const lockBeforeRelease = k._panLock;
|
||||
const last = pts[pts.length - 1];
|
||||
fire('pointerup', id, last[0], last[1]);
|
||||
await k.updateComplete;
|
||||
return { s0, s1: k._space, lockAfterLeadIn, lockBeforeRelease, moved };
|
||||
};
|
||||
|
||||
// ---- 1. the auditor's curved pan: vertical lead-in, horizontal ending --
|
||||
// (500,300) → (502,312) locks 'pan' → curve left to (350,304) → release.
|
||||
// dx = -150, dy = +4: swipeTarget() would happily call that a swipe.
|
||||
const curvedPan = await play(51, [500, 300], [[502, 312], [460, 310], [400, 306], [350, 304]]);
|
||||
o.curvedPanLocksPan = curvedPan.lockAfterLeadIn === 'pan';
|
||||
o.curvedPanKeepsTheLock = curvedPan.lockBeforeRelease === 'pan';
|
||||
o.curvedPanActuallyPans = curvedPan.moved === true;
|
||||
o.curvedPanKeepsTheFloor = curvedPan.s1 === curvedPan.s0;
|
||||
// the same trajectory the other way round — a swipe to the right would have
|
||||
// been the previous floor, so the bug is symmetric and so is the fix
|
||||
const curvedPanRight = await play(52, [400, 300], [[402, 312], [460, 308], [520, 305], [560, 304]]);
|
||||
o.curvedPanRightLocksPan = curvedPanRight.lockAfterLeadIn === 'pan';
|
||||
o.curvedPanRightKeepsTheFloor = curvedPanRight.s1 === curvedPanRight.s0;
|
||||
// a long diagonal that ends up dominated by x, but started as a pan
|
||||
const diagonalPan = await play(53, [500, 300], [[496, 316], [420, 340], [330, 350], [260, 352]]);
|
||||
o.diagonalPanLocksPan = diagonalPan.lockAfterLeadIn === 'pan';
|
||||
o.diagonalPanKeepsTheFloor = diagonalPan.s1 === diagonalPan.s0;
|
||||
|
||||
// ---- 2. a gesture locked as a SWIPE keeps its own semantics ------------
|
||||
// horizontal lead-in locks 'swipe'; the plan must not slide under it, even
|
||||
// when the trajectory then bends vertically and the final vector no longer
|
||||
// qualifies — the floor simply stays, and nothing pans
|
||||
const curvedSwipe = await play(54, [600, 300], [[540, 302], [536, 380], [545, 500]]);
|
||||
o.curvedSwipeLocksSwipe = curvedSwipe.lockAfterLeadIn === 'swipe';
|
||||
o.curvedSwipeNeverPans = curvedSwipe.moved === false;
|
||||
o.curvedSwipeThatDiesChangesNothing = curvedSwipe.s1 === curvedSwipe.s0;
|
||||
// a swipe that bends but still ends as a swipe does switch the floor
|
||||
const bentSwipe = await play(55, [600, 300], [[540, 302], [470, 330], [420, 340]]);
|
||||
o.bentSwipeLocksSwipe = bentSwipe.lockAfterLeadIn === 'swipe';
|
||||
o.bentSwipeNeverPans = bentSwipe.moved === false;
|
||||
o.bentSwipeStillSwitches = bentSwipe.s1 !== bentSwipe.s0;
|
||||
|
||||
// ---- 3. the straight gestures are exactly as they were -----------------
|
||||
const straightSwipe = await play(56, [600, 300], [[540, 302], [480, 305], [450, 305]]);
|
||||
o.straightSwipeSwitches = straightSwipe.s1 !== straightSwipe.s0;
|
||||
o.straightSwipeDoesNotPan = straightSwipe.moved === false;
|
||||
const straightPan = await play(57, [450, 200], [[452, 260], [454, 330]]);
|
||||
o.straightPanPans = straightPan.moved === true;
|
||||
o.straightPanKeepsTheFloor = straightPan.s1 === straightPan.s0;
|
||||
|
||||
// ---- 4. a motionless double tap still resets the zoom ------------------
|
||||
// no movement means no lock at all, so nothing above can reach this path
|
||||
await home();
|
||||
k._applyView(2);
|
||||
await k.updateComplete;
|
||||
k._lastTap = Date.now() - 100;
|
||||
fire('pointerdown', 58, 500, 300);
|
||||
fire('pointerup', 58, 501, 300);
|
||||
await k.updateComplete;
|
||||
o.doubleTapStillResetsZoom = k._zoom === 1;
|
||||
o.doubleTapLeavesNoLock = k._panLock === null;
|
||||
|
||||
// ---- 5. zoomed in there is no swipe zone, and the lock says so ---------
|
||||
k._applyView(2);
|
||||
await k.updateComplete;
|
||||
const zoomed = await (async () => {
|
||||
const s0 = k._space;
|
||||
fire('pointerdown', 59, 600, 300);
|
||||
fire('pointermove', 59, 540, 302);
|
||||
const lock = k._panLock;
|
||||
fire('pointermove', 59, 480, 305);
|
||||
fire('pointerup', 59, 480, 305);
|
||||
await k.updateComplete;
|
||||
return { lock, same: k._space === s0 };
|
||||
})();
|
||||
o.zoomedHorizontalLocksPan = zoomed.lock === 'pan';
|
||||
o.zoomedHorizontalKeepsTheFloor = zoomed.same;
|
||||
|
||||
k.remove();
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -169,4 +169,108 @@ check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
|
||||
// ---------- PLACING a new opening: the same rulers (owner 2026-08-03) -------
|
||||
// Moving along a wall with the Opening tool must show exactly what dragging an
|
||||
// existing opening shows: a badge on each shoulder of the WOULD-BE opening, a
|
||||
// perpendicular tick + magnet at the wall centre, Shift opting out — and the
|
||||
// whole lot vanishing the moment the opening is placed. Default length is
|
||||
// 90 cm = 75 units, so the shoulders are measured from the edges c +- 37.5.
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.rooms = [
|
||||
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] },
|
||||
];
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await settle();
|
||||
const ghosts = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opghost').length);
|
||||
|
||||
// hover off-centre on r1's top wall (own edge 40..550, y = 140)
|
||||
{
|
||||
const [hx, hy] = await screenPt(460, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_ghost_shown', await ghosts(), 1);
|
||||
check('place_two_badges', (await badges()).length, 2);
|
||||
// edges 422.5 / 497.5 -> 382.5 u (4.59 m) left, 52.5 u (0.63 m) right
|
||||
const [a, b] = await nums();
|
||||
check('place_badge_right_0_63', near(a, 0.63, 0.05));
|
||||
check('place_badge_left_4_59', near(b, 4.59, 0.05));
|
||||
check('place_no_tick_off_center', await tick(), 0);
|
||||
}
|
||||
|
||||
// live update on the way — the badges follow the cursor
|
||||
{
|
||||
const [hx, hy] = await screenPt(200, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
// edges 162.5 / 237.5 -> 122.5 u (1.47 m) left, 312.5 u (3.75 m) right
|
||||
const [a, b] = await nums();
|
||||
check('place_badge_updates_1_47', near(a, 1.47, 0.05));
|
||||
check('place_badge_updates_3_75', near(b, 3.75, 0.05));
|
||||
}
|
||||
|
||||
// near the wall centre (40+550)/2 = 295: tick appears and the magnet bites
|
||||
{
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_tick_at_center', await tick(), 1);
|
||||
// equal shoulders after the magnet: (510 - 75) / 2 = 217.5 u -> 2.61 m
|
||||
check('place_badges_equal_at_center', await badges(), ['2.61 m', '2.61 m']);
|
||||
const g = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opcentertick');
|
||||
return el && { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
|
||||
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2') };
|
||||
});
|
||||
check('place_tick_perpendicular', !!g && near(g.x1, 295, 1e-6) && near(g.x2, 295, 1e-6)
|
||||
&& near(g.y2 - g.y1, 30, 1e-6));
|
||||
// the ghost itself already sits on the magnetised centre
|
||||
const gh = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opghost');
|
||||
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
|
||||
});
|
||||
check('place_ghost_magnetised', near(gh, 295, 1e-6));
|
||||
}
|
||||
|
||||
// Shift opts out of the magnet, the badges stay
|
||||
{
|
||||
await page.keyboard.down('Shift');
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_no_tick_with_shift', await tick(), 0);
|
||||
check('place_badges_with_shift', (await badges()).length, 2);
|
||||
const gh = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opghost');
|
||||
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
|
||||
});
|
||||
check('place_ghost_free_with_shift', !!gh && !near(gh, 295, 1e-6) && near(gh, 293.7, 1));
|
||||
await page.keyboard.up('Shift');
|
||||
}
|
||||
|
||||
// the click places it AT THE MAGNET and clears every hint
|
||||
{
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.click(hx, hy);
|
||||
await settle();
|
||||
check('place_dialog_opened', await page.evaluate(() => !!window.__card._openingDialog), true);
|
||||
check('place_dialog_x_magnetised', near(await page.evaluate(() => window.__card._openingDialog.x), 295, 1e-6));
|
||||
check('place_badges_gone', (await badges()).length, 0);
|
||||
check('place_tick_gone', await tick(), 0);
|
||||
check('place_ghost_gone', await ghosts(), 0);
|
||||
// …and confirming the dialog writes the opening at the magnetised point
|
||||
await page.evaluate(() => { const c = window.__card; c._saveOpening(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
check('place_committed_x_center', near(await page.evaluate(() =>
|
||||
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
|
||||
}
|
||||
|
||||
await finish(browser, { done: true });
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Panning at ANY zoom (owner's report 2026-08-04: «добавь возможность таскать
|
||||
// план при любом масштабе, а не только при более 100%»).
|
||||
//
|
||||
// Until this fix `_stagePointerMove` moved the view only while `_zoom > 1`:
|
||||
// the rule was inherited from the old bounded canvas, where a plan smaller
|
||||
// than the scene had nowhere to go. The infinite canvas removed that edge —
|
||||
// there is always somewhere to go, and an editor extending a plan outwards
|
||||
// needs exactly that. So a drag on empty scene now pans at 100 %, at 50 % and
|
||||
// at 800 %, in view mode and in every editor; how far you may walk is decided
|
||||
// by `_clampView` (PAN_SLACK: about a screen past the content) and not by the
|
||||
// zoom. The tools keep the pointer they already owned — a resize handle, a
|
||||
// device badge, an opening — and the kiosk keeps its floor swipe.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// ---- a drag on empty scene pans, at every zoom and in every mode ---------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
let pid = 100;
|
||||
/** Drag 80x60 px across the middle of the scene; did the view follow? */
|
||||
const drag = async (zoom) => {
|
||||
// always start from the plan in the middle: the clamp is a real limit, and
|
||||
// a dozen drags in a row would otherwise walk into it
|
||||
const vb = c._baseVb();
|
||||
c._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
await c.updateComplete;
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const id = ++pid;
|
||||
pd(id, 300, 300);
|
||||
pm(id, 340, 330);
|
||||
pm(id, 380, 360);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
pu(id, 380, 360);
|
||||
// dragging down-right pulls the viewport up-left, and the zoom is untouched
|
||||
return Math.abs(v1.x - v0.x) > 1 && Math.abs(v1.y - v0.y) > 1
|
||||
&& v1.x < v0.x && v1.y < v0.y && Math.abs(c._zoom - zoom) < 1e-9;
|
||||
};
|
||||
const inMode = async (name, setup) => {
|
||||
await setup();
|
||||
await c.updateComplete;
|
||||
o['pan_' + name + '_at100'] = await drag(1);
|
||||
o['pan_' + name + '_at50'] = await drag(0.5);
|
||||
o['pan_' + name + '_at33'] = await drag(1 / 3);
|
||||
};
|
||||
|
||||
await inMode('view', async () => { c._setMode('view'); });
|
||||
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'openwall', 'delroom']) {
|
||||
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
|
||||
}
|
||||
await inMode('devices', async () => { c._setMode('devices'); });
|
||||
await inMode('decor', async () => { c._setMode('decor'); c._decorTool = 'select'; });
|
||||
c._setMode('view');
|
||||
c._applyView(1);
|
||||
await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- 400 % still pans (the behaviour that already worked) ----------------
|
||||
out.panStillWorksZoomedIn = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('view'); c._applyView(4); await c.updateComplete;
|
||||
const v0 = { ...c._view };
|
||||
c._stagePointerDown({ pointerId: 7, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 7, clientX: 380, clientY: 360 });
|
||||
const moved = Math.abs(c._view.x - v0.x) > 1;
|
||||
c._stagePointerUp({ pointerId: 7, clientX: 380, clientY: 360 });
|
||||
c._applyView(1);
|
||||
return moved;
|
||||
});
|
||||
|
||||
// ---- the clamp: you may walk about a screen off, never further ------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
c._setMode('view');
|
||||
const vb = c._baseVb();
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); // at 100 % the view IS fitView
|
||||
await c.updateComplete;
|
||||
const fit = { ...c._view };
|
||||
c._stagePointerDown({ pointerId: 8, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 8, clientX: -40000, clientY: 400 }); // shove it into the void
|
||||
const far = { ...c._view };
|
||||
c._stagePointerUp({ pointerId: 8, clientX: -40000, clientY: 400 });
|
||||
o.panLeavesTheContent = far.x > fit.x + fit.w * 0.5;
|
||||
o.panStopsAtTheSlack = far.x <= fit.x + fit.w + 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
o.homeArrowOffersTheWayBack = !!sr.querySelector('.homearrow');
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// the same at 50 %: the screen holds more, the walk is still finite
|
||||
const fit2 = { ...c._view };
|
||||
c._applyView(0.5, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
|
||||
c._stagePointerDown({ pointerId: 9, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 9, clientX: -40000, clientY: 400 });
|
||||
const far2 = { ...c._view };
|
||||
c._stagePointerUp({ pointerId: 9, clientX: -40000, clientY: 400 });
|
||||
o.panStopsAtTheSlackZoomedOut = far2.x <= fit2.x + fit2.w + 1 && far2.x > fit2.x;
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- the tools still own their pointer ------------------------------------
|
||||
const ev = (type, id, x, y) => ({ type, id, x, y });
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
const fire = (el, type, id, x, y) => el.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
|
||||
// a) resize handle: the wall moves, the view does not
|
||||
c._setMode('plan'); c._tool = 'resize'; c._applyView(1); await c.updateComplete;
|
||||
const h = sr.querySelector('.rszhandle');
|
||||
o.resizeHandlePresent = !!h;
|
||||
if (h) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = h.getBoundingClientRect();
|
||||
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.resizeHandleTakesThePointer = !!c._rszDrag && c._panStart === null;
|
||||
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
o.resizeHandleResizes = !!c._rszDrag && c._rszDrag.moved === true;
|
||||
c._rszCancelDrag?.();
|
||||
fire(h, 'pointerup', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
}
|
||||
c._rszSel = null;
|
||||
|
||||
// b) a device badge in the devices editor drags the DEVICE
|
||||
c._setMode('devices'); c._applyView(1); await c.updateComplete;
|
||||
const dev = sr.querySelector('.devlayer .dev');
|
||||
o.devicePresent = !!dev;
|
||||
if (dev) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = dev.getBoundingClientRect();
|
||||
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.deviceTakesThePointer = !!c._drag && c._panStart === null;
|
||||
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
o.deviceActuallyMoved = !!c._drag && c._drag.moved === true;
|
||||
fire(dev, 'pointerup', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
// _pointerUp releases _drag on the next macrotask, and _stagePointerDown
|
||||
// refuses to start a gesture while a device drag is live
|
||||
await new Promise((r2) => setTimeout(r2, 20));
|
||||
o.deviceDragReleased = c._drag === null;
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// c) an opening drags along its wall, the view stays put
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].openings = [{ id: 'o_pan', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.1 }];
|
||||
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
|
||||
c._setMode('plan'); c._tool = 'draw'; c._applyView(1);
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const op = sr.querySelector('.op-hit');
|
||||
o.openingPresent = !!op;
|
||||
if (op) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = op.getBoundingClientRect();
|
||||
fire(op, 'pointerdown', 23, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.openingTakesThePointer = !!c._opDrag && c._panStart === null;
|
||||
fire(op, 'pointermove', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.openingDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
fire(op, 'pointerup', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// d) a drawing tool is still a drawing tool: two fingers pinch, they do not pan
|
||||
c._setMode('plan'); c._tool = 'draw'; c._applyView(1); await c.updateComplete;
|
||||
const z0 = c._zoom;
|
||||
c._stagePointerDown({ pointerId: 31, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerDown({ pointerId: 32, clientX: 400, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 31, clientX: 250, clientY: 300 });
|
||||
c._stagePointerMove({ pointerId: 32, clientX: 450, clientY: 300 });
|
||||
o.pinchStillZooms = c._zoom > z0 * 1.5;
|
||||
c._stagePointerUp({ pointerId: 31, clientX: 250, clientY: 300 });
|
||||
c._stagePointerUp({ pointerId: 32, clientX: 450, clientY: 300 });
|
||||
c._path = [];
|
||||
c._setMode('view'); c._applyView(1); await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- the kiosk keeps its floor swipe ---------------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c0 = window.__card;
|
||||
const o = {};
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c0.hass;
|
||||
document.body.appendChild(k);
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
k.hass = { ...c0.hass };
|
||||
await k.updateComplete;
|
||||
const sr = k.shadowRoot || k.renderRoot;
|
||||
const stage = sr.querySelector('.stage');
|
||||
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
k._applyView(1); await k.updateComplete;
|
||||
const s0 = k._space;
|
||||
const v0 = { ...k._viewOr(k._baseVb()) };
|
||||
// a REAL swipe: down, several moves, up — the moves are what used to be
|
||||
// impossible to add to smoke_kiosk without hijacking the gesture
|
||||
fire('pointerdown', 41, 600, 300);
|
||||
fire('pointermove', 41, 540, 302);
|
||||
fire('pointermove', 41, 480, 305);
|
||||
fire('pointermove', 41, 450, 305);
|
||||
const midPan = Math.abs(k._viewOr(k._baseVb()).x - v0.x);
|
||||
fire('pointerup', 41, 450, 305);
|
||||
await k.updateComplete;
|
||||
o.kioskSwipeStillSwitchesFloors = k._space !== s0;
|
||||
o.kioskHorizontalDragIsNotAPan = midPan < 1;
|
||||
// …and the plan is not left hanging half-panned after the switch
|
||||
o.kioskViewCenteredAfterSwipe = Math.abs(k._viewOr(k._baseVb()).x - v0.x) < 2000;
|
||||
// a VERTICAL drag is nobody's swipe, so it pans like everywhere else
|
||||
const s1 = k._space;
|
||||
const v1 = { ...k._viewOr(k._baseVb()) };
|
||||
fire('pointerdown', 42, 450, 200);
|
||||
fire('pointermove', 42, 452, 260);
|
||||
fire('pointermove', 42, 454, 330);
|
||||
const panned = Math.abs(k._viewOr(k._baseVb()).y - v1.y) > 1;
|
||||
fire('pointerup', 42, 454, 330);
|
||||
await k.updateComplete;
|
||||
o.kioskVerticalDragPans = panned;
|
||||
o.kioskVerticalDragKeepsFloor = k._space === s1;
|
||||
// zoomed in, a horizontal drag pans (no swipe up there — unchanged rule)
|
||||
k._applyView(2); await k.updateComplete;
|
||||
const v2 = { ...k._view };
|
||||
const s2 = k._space;
|
||||
fire('pointerdown', 43, 600, 300);
|
||||
fire('pointermove', 43, 540, 302);
|
||||
fire('pointermove', 43, 480, 305);
|
||||
const pannedZoomed = Math.abs(k._view.x - v2.x) > 1;
|
||||
fire('pointerup', 43, 480, 305);
|
||||
await k.updateComplete;
|
||||
o.kioskZoomedHorizontalPans = pannedZoomed;
|
||||
o.kioskZoomedDoesNotSwipe = k._space === s2;
|
||||
k.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -13,6 +13,7 @@ const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
// ---------- phase 1: normal mode, HA panel lands late ----------
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
@@ -119,6 +120,7 @@ check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
|
||||
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const reduced = await page.evaluate(async () => {
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
|
||||
@@ -22,6 +22,7 @@ const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
const rec = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
@@ -48,6 +49,7 @@ const rec = await page.evaluate(async () => {
|
||||
out.veilGone = !sr().querySelector('.bootveil');
|
||||
|
||||
// A2: a second detach DURING the fade must not leave a zombie veil either
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c2 = document.createElement('houseplan-card');
|
||||
c2.setConfig({ type: 'custom:houseplan-card' });
|
||||
c2.hass = window.__card.hass;
|
||||
@@ -72,6 +74,7 @@ const outB = {};
|
||||
for (const shiftMs of shifts) {
|
||||
const res = await page.evaluate(async (shiftAt) => {
|
||||
const out = {};
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
@@ -133,6 +136,7 @@ for (const shiftMs of shifts) {
|
||||
const late = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
|
||||
@@ -47,6 +47,29 @@ const res = await page.evaluate(async () => {
|
||||
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
|
||||
out.overrideRespected = out.staticCardRoom0 === 'transparent';
|
||||
|
||||
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами» — a miter join
|
||||
// spikes far past a sharp corner (and bevels it flat once past the miter
|
||||
// limit). Room borders join ROUND now, and BOTH renderers share the rule:
|
||||
// the styles live in one cardStyles, and this is the smoke that keeps it so.
|
||||
const joins = (root) => [...root.querySelectorAll('.room')]
|
||||
.map((el) => getComputedStyle(el).strokeLinejoin);
|
||||
const fullJoins = joins(main.shadowRoot || main.renderRoot);
|
||||
const staticJoins = joins(card.renderRoot);
|
||||
out.fullCardBordersRound = fullJoins.length > 0 && fullJoins.every((j) => j === 'round');
|
||||
out.staticCardBordersRound = staticJoins.length > 0 && staticJoins.every((j) => j === 'round');
|
||||
// the trimmed outline of a room with open boundaries is a set of separate
|
||||
// subpaths: its CAPS are the corners, so they must be round as well
|
||||
const f1cfg = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1cfg.rooms[0].open_to = [f1cfg.rooms[1].id];
|
||||
main._cfgEpoch++;
|
||||
main.requestUpdate(); await main.updateComplete;
|
||||
const outlines = [...(main.shadowRoot || main.renderRoot).querySelectorAll('.room-outline')];
|
||||
out.trimmedOutlineDrawn = outlines.length > 0;
|
||||
out.trimmedOutlineRound = outlines.every((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return cs.strokeLinejoin === 'round' && cs.strokeLinecap === 'round';
|
||||
});
|
||||
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
|
||||
@@ -58,4 +58,33 @@ const out = await page.evaluate(async () => {
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
checkAll(out);
|
||||
|
||||
// Шаг угла в диалоге устройства — 5°, а не 10° (владелец, 2026-08-03):
|
||||
// значок часто надо выровнять по стене, которая не лежит на сетке в 10°.
|
||||
const out3 = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.space === 'f1');
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
// строка «размер · угол»: два ползунка, второй — угол
|
||||
const rows = [...sr().querySelectorAll('.dialog .colorrow')];
|
||||
const row = rows.find((r) => r.textContent.includes('°'));
|
||||
const ctl = [...row.querySelectorAll('ha-slider, input[type=range]')];
|
||||
const stepOf = (el) => Number(el.step ?? el.getAttribute('step'));
|
||||
const maxOf = (el) => Number(el.max ?? el.getAttribute('max'));
|
||||
o.angleStepIs5 = ctl.length === 2 && stepOf(ctl[1]) === 5;
|
||||
o.angleReaches355 = maxOf(ctl[1]) === 355;
|
||||
o.sizeStepUnchanged = stepOf(ctl[0]) === 0.1;
|
||||
// и значение реально ложится на 5°
|
||||
ctl[1].value = 35;
|
||||
ctl[1].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
o.angle35Applied = c._markerDialog.angle === 35;
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out3));
|
||||
|
||||
@@ -58,6 +58,6 @@ checkAll(res, {
|
||||
"atticSquare": true,
|
||||
// fill_mode 'glow' — the default for NEW spaces since v1.54 (owner call).
|
||||
// Existing spaces with an absent fill_mode still resolve to 'none'.
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
+55
-2
@@ -133,10 +133,10 @@ const res = await page.evaluate(async () => {
|
||||
await setWeather('cloudy');
|
||||
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|
||||
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.18 * 0.4) < 1e-6;
|
||||
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.3 * 0.4) < 1e-6;
|
||||
await setWeather('sunny');
|
||||
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
out.clearFullAlpha = Math.abs(stopClear - 0.18) < 1e-6;
|
||||
out.clearFullAlpha = Math.abs(stopClear - 0.3) < 1e-6;
|
||||
delete cfg().settings.weather_entity;
|
||||
touchCfg();
|
||||
|
||||
@@ -290,4 +290,57 @@ const final = await rayInfo();
|
||||
check('b701_write_landed', final.rev !== base.rev, true);
|
||||
check('b701_ray_survives_ack', Math.abs(final.midY - final.wEy * 1000) < 1, true);
|
||||
|
||||
// 15) the 3° threshold + the 2 s dissolve (owner 2026-08-03). Runs LAST and in
|
||||
// its own evaluate: it deliberately waits out a two-second fade, and the b701
|
||||
// regression above measures a debounced config write against wall-clock time.
|
||||
const thr = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const setSun = async (azimuth, elevation) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation },
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
const layer = () => sr().querySelector('.sunlayer');
|
||||
const stopAlpha = () => Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
|
||||
// the old build ramped the alpha in over the first ~2°; now the layer is
|
||||
// either absent or at FULL strength, and crossing 3° fades the whole LAYER
|
||||
// (never the geometry) in or out over exactly two seconds
|
||||
await setSun(90, 10);
|
||||
out.aboveThresholdDrawn = domPolys().length > 0 && !layer().classList.contains('out');
|
||||
{
|
||||
const cs = getComputedStyle(layer());
|
||||
out.fadeInTakesTwoSeconds = cs.animationName === 'hp-sunfade-in' && cs.animationDuration === '2s';
|
||||
}
|
||||
out.fullAlphaWellAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
|
||||
await setSun(90, 3.2);
|
||||
out.fullAlphaJustAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
|
||||
|
||||
// below 3°: the layer stays for exactly the dissolve, then leaves the DOM
|
||||
await setSun(90, 2);
|
||||
{
|
||||
const dying = layer();
|
||||
const cs = dying && getComputedStyle(dying);
|
||||
out.belowThresholdDissolves = !!dying && dying.classList.contains('out')
|
||||
&& cs.animationName === 'hp-sunfade-out' && cs.animationDuration === '2s';
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2300));
|
||||
await upd();
|
||||
out.belowThresholdGoneAfterFade = !layer() && domPolys().length === 0;
|
||||
|
||||
// a sun that never crossed the threshold draws nothing at all — no ghost
|
||||
await setSun(90, 1);
|
||||
out.coldStartBelowThresholdEmpty = domPolys().length === 0 && !layer();
|
||||
await setSun(90, 5);
|
||||
out.backAboveThreshold = domPolys().length > 0 && !layer().classList.contains('out');
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, thr);
|
||||
|
||||
await finish(browser, checkAll(res));
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Owner 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
|
||||
// после обновления страницы». The day/night sky must follow sun.sun on a plain
|
||||
// hass tick — no reload, no requestUpdate() from the outside — and the COMPUTED
|
||||
// background of the stage (what the eye sees, not the style attribute) must
|
||||
// actually arrive at the sky colour (docs/SUN.md).
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'daynight', sun_rays: true };
|
||||
cfg.spaces.find((s) => s.id === 'f1').openings = [
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
c._cfgEpoch++;
|
||||
// NO requestUpdate anywhere below: production only ever assigns `hass`.
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
} } };
|
||||
await c.updateComplete;
|
||||
// one paint so the sky lands on screen, not only in the attribute
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
};
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
const comp = () => getComputedStyle(stage()).backgroundColor;
|
||||
const dim = () => (sr().querySelector('.zoomwrap').getAttribute('style') || '');
|
||||
const polys = () => sr().querySelectorAll('.sunlayer polygon').length;
|
||||
const hexToRgb = (h) => 'rgb(' + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(', ') + ')';
|
||||
|
||||
await setSun(180, 40);
|
||||
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
|
||||
out.dayNoDim = dim().includes('brightness(1.000)');
|
||||
out.dayRays = polys() > 0;
|
||||
|
||||
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
|
||||
await setSun(180, -10);
|
||||
out.nightComputedDark = comp() === 'rgb(10, 16, 25)';
|
||||
out.nightDimmed = dim().includes('brightness(0.900');
|
||||
out.nightNoRays = polys() === 0;
|
||||
|
||||
// ...and back, again on a plain tick
|
||||
await setSun(180, 40);
|
||||
out.backToDayComputed = comp() === 'rgb(255, 255, 255)';
|
||||
out.backToDayRays = polys() > 0;
|
||||
|
||||
// a small step still moves the sky (0.1° granularity of the model)...
|
||||
await setSun(180, 20);
|
||||
const twenty = comp();
|
||||
await setSun(180, 12);
|
||||
out.smallStepMovesSky = comp() !== twenty;
|
||||
|
||||
// ...but a step the size of a REAL sun update (~1° per 4 minutes) must still
|
||||
// GLIDE: the catch-up jump is for a card that was not watching, never for the
|
||||
// day/night breathing itself (docs/SUN.md).
|
||||
await setSun(180, 12.6);
|
||||
const target = stage().getAttribute('style').split('background:')[1];
|
||||
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
|
||||
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
|
||||
|
||||
// the tab came back from the background: the sky catches up at once
|
||||
await setSun(180, 25); // 12° of sun happened while we were not painting
|
||||
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
|
||||
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,216 @@
|
||||
// Owner 2026-08-04, on the first attempt: «с лучами солнца ты сделал фигню —
|
||||
// не надо размывать их боковые грани». A shaft of sunlight has HARD sides; it
|
||||
// fades only with distance, along the ray, from the glass inward. So:
|
||||
// * the reach is still 70 % of the v1.56 curve;
|
||||
// * the gradient still spans the FULL wedge and is dead from RAY_FADE_END;
|
||||
// * the sides are SHARP — no blur filter on the wedge, none defined at all;
|
||||
// * and because the sides are sharp, the wedge must end ON an iso-alpha line
|
||||
// of that gradient: nothing is ever drawn past its end, so the old bright
|
||||
// kerb (a far edge parallel to the wall, cut while still lit) cannot come
|
||||
// back at an oblique sun.
|
||||
// DEV-EB173-01 turned the last of those into a contract of its own: the fade
|
||||
// runs along the wall's INWARD NORMAL, not along the ray. For parallel rays
|
||||
// the distance travelled from the glass is an affine function of the point, so
|
||||
// its iso-alpha lines are parallel to the WALL — which is where an honest
|
||||
// parallelogram puts its far edge. All three invariants then hold at once:
|
||||
// peak alpha across the whole pane, the same fade distance along every ray,
|
||||
// and a far edge exactly on the gradient's end. The auditor's own grazing
|
||||
// repro is re-run below with his numbers.
|
||||
// The "light never crosses a wall" clip is asserted in demo/smoke_sun.mjs
|
||||
// (wedgeClippedToRoom) — the polygons arrive from computeSunRays() already
|
||||
// intersected with the room, which is why no clip-path is needed here.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
|
||||
c._cfgEpoch++;
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
};
|
||||
const grads = () => [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
|
||||
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => [
|
||||
parseFloat(s.getAttribute('offset')), Number(s.getAttribute('stop-opacity'))]);
|
||||
// everything below is measured off the DOM gradient, exactly like the audit
|
||||
// probe: axis, the offset a point lands on, and the alpha there
|
||||
const axisOf = (g) => {
|
||||
const x1 = +g.getAttribute('x1'), y1 = +g.getAttribute('y1');
|
||||
const x2 = +g.getAttribute('x2'), y2 = +g.getAttribute('y2');
|
||||
const len = Math.hypot(x2 - x1, y2 - y1);
|
||||
return { x1, y1, dx: x2 - x1, dy: y2 - y1, len, ux: (x2 - x1) / len, uy: (y2 - y1) / len };
|
||||
};
|
||||
const offsetOf = (g, p) => {
|
||||
const a = axisOf(g);
|
||||
return ((p[0] - a.x1) * a.dx + (p[1] - a.y1) * a.dy) / (a.len * a.len);
|
||||
};
|
||||
// a bundle without the normal-axis fade must FAIL these by name, not blow up
|
||||
const nrm = (r) => r.normal || [NaN, NaN];
|
||||
const dep = (r) => (r.depth === undefined ? NaN : r.depth);
|
||||
const alphaAt = (g, off) => {
|
||||
const st = stopsOf(g).map(([o, a]) => [o / 100, a]);
|
||||
if (off <= st[0][0]) return st[0][1];
|
||||
for (let i = 1; i < st.length; i++) {
|
||||
if (off <= st[i][0]) {
|
||||
const t = (off - st[i - 1][0]) / (st[i][0] - st[i - 1][0] || 1);
|
||||
return st[i - 1][1] + t * (st[i][1] - st[i - 1][1]);
|
||||
}
|
||||
}
|
||||
return st[st.length - 1][1];
|
||||
};
|
||||
|
||||
// ---- 1) 30 % shorter: the wedge reach in window lengths ----------------
|
||||
await setSun(270, 5); // low western sun into the west window
|
||||
const oldK = (e) => 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
|
||||
const winLen = 0.08 * 1000;
|
||||
const low = c._sunRaysCache.rays[0].len;
|
||||
out.lowSunIs70Percent = Math.abs(low - oldK(5) * 0.7 * winLen) < 1e-6;
|
||||
await setSun(180, 60);
|
||||
const high = c._sunRaysCache.rays[0].len;
|
||||
out.highSunIs70Percent = Math.abs(high - oldK(60) * 0.7 * winLen) < 1e-6;
|
||||
out.lowStillReachesFurther = low > high;
|
||||
|
||||
// ---- 2) the shaft always dissolves BEFORE its own far edge -------------
|
||||
await setSun(270, 5);
|
||||
const gs = grads();
|
||||
out.gradientsDrawn = gs.length > 0;
|
||||
// the axis is the wall's inward normal, `len · cos(incidence)` long — the
|
||||
// perpendicular depth a ray reaches after running the FULL wedge length
|
||||
out.gradientRunsAlongTheWallNormal = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
const a = axisOf(g);
|
||||
return Math.abs(a.ux - nrm(r)[0]) < 1e-6 && Math.abs(a.uy - nrm(r)[1]) < 1e-6;
|
||||
});
|
||||
out.gradientSpansWholeWedge = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
|
||||
return Math.abs(axisOf(g).len - r.len * cos) < 1e-6
|
||||
&& Math.abs(dep(r) - r.len * cos) < 1e-9
|
||||
&& stopsOf(g).length > 2;
|
||||
});
|
||||
// the ONLY thing that matters about that axis: a point `source + dir·u`
|
||||
// lands on offset `u / len`, whichever ray it rode in on
|
||||
out.offsetIsDistanceAlongTheRay = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
return [0, 0.3, 0.85, 1].every((u) => [r.a, r.b].every((src) => {
|
||||
const p = [src[0] + r.dir[0] * r.len * u, src[1] + r.dir[1] * r.len * u];
|
||||
return Math.abs(offsetOf(g, p) - u) < 1e-6;
|
||||
}));
|
||||
});
|
||||
out.deadWellBeforeTheEnd = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
const firstZero = st.find(([, a]) => a === 0);
|
||||
return !!firstZero && firstZero[0] <= 85.001; // %
|
||||
});
|
||||
out.lastStopIsZero = gs.every((g) => stopsOf(g).slice(-1)[0][1] === 0);
|
||||
out.brightAtTheGlass = gs.every((g) => stopsOf(g)[0][1] > 0.2);
|
||||
out.neverBrightensInward = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
return st.every(([, a], i) => i === 0 || a <= st[i - 1][1] + 1e-9);
|
||||
});
|
||||
|
||||
// ---- 3) SHARP sides: no blur on the wedge, and none defined anywhere ----
|
||||
const wedges = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
// walk the polygon and its ancestors up to and including .sunlayer — the
|
||||
// day/night `brightness` filter lives further up, on the zoomwrap, and is
|
||||
// none of this test's business
|
||||
const blurredChain = (el) => {
|
||||
for (let n = el; n; n = n.parentElement) {
|
||||
const attr = n.getAttribute && n.getAttribute('filter');
|
||||
if (attr && attr !== 'none') return true;
|
||||
const cs = getComputedStyle(n).filter;
|
||||
if (cs && cs !== 'none' && cs !== '') return true;
|
||||
if (n.classList && n.classList.contains('sunlayer')) break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
out.wedgesDrawn = wedges().length > 0;
|
||||
out.everyWedgeHasSharpSides = wedges().length > 0 && wedges().every((p) => !blurredChain(p));
|
||||
out.noSoftFilterDefined = sr().querySelectorAll('filter[id^=hp-sunsoft-]').length === 0;
|
||||
out.noGaussianBlurAtAll = sr().querySelectorAll('feGaussianBlur').length === 0;
|
||||
|
||||
// ---- 4) an OBLIQUE sun: the shaft still dies of its gradient -----------
|
||||
// The sides are hard again, so the only thing that may end the wedge is the
|
||||
// gradient. That holds ONLY if the far edge is square to the RAY: with the
|
||||
// old wall-parallel edge one far corner sat at offset ~0.7 (low sun) or
|
||||
// ~0.11 (high sun) — i.e. still lit — which is exactly the bright kerb.
|
||||
// offset ALONG THE GRADIENT, i.e. depth under the wall over `len · cos`
|
||||
const tOf = (r, p) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
return ((p[0] - mx) * nrm(r)[0] + (p[1] - my) * nrm(r)[1]) / dep(r);
|
||||
};
|
||||
const skew = (r) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
return Math.abs((r.a[0] - mx) * r.dir[0] + (r.a[1] - my) * r.dir[1]) / r.len;
|
||||
};
|
||||
out.obliqueChecked = [];
|
||||
out.sunIsReallyOblique = true;
|
||||
out.nothingDrawnPastTheGradient = true;
|
||||
for (const [az, el] of [[230, 8], [225, 55]]) {
|
||||
await setSun(az, el);
|
||||
const rays = c._sunRaysCache.rays;
|
||||
out.obliqueChecked.push(rays.length);
|
||||
if (!rays.length) { out.sunIsReallyOblique = false; continue; }
|
||||
if (!rays.some((r) => skew(r) > 1e-3)) out.sunIsReallyOblique = false;
|
||||
for (const r of rays) {
|
||||
for (const poly of r.polys) {
|
||||
for (const p of poly) if (tOf(r, p) > 1 + 1e-6) out.nothingDrawnPastTheGradient = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.obliqueSunHasWedges = out.obliqueChecked.every((n) => n > 0);
|
||||
delete out.obliqueChecked;
|
||||
|
||||
// ---- 5) DEV-EB173-01: the auditor's own grazing repro ------------------
|
||||
// West window 80 render units long, elevation 90 (nominal reach 0.56 · 80 =
|
||||
// 44.8, i.e. 70 % of the old 64), azimuth 190 — the light enters the glass
|
||||
// and travels 10° off the wall's own direction. The probe on the broken
|
||||
// build read sides 5.408 / 84.192 (ratio 15.57) and source offsets ±0.879
|
||||
// with opacity 0 at one end of the pane.
|
||||
sp.openings = [{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++;
|
||||
await setSun(190, 90);
|
||||
const gr = c._sunRaysCache.rays;
|
||||
out.grazingRayDrawn = gr.length === 1;
|
||||
if (gr.length === 1) {
|
||||
const r = gr[0];
|
||||
const g = grads()[0];
|
||||
out.grazingIsReallyGrazing = Math.abs(r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1] - 0.17365) < 1e-4;
|
||||
out.grazingLengthIs70Percent = Math.abs(r.len - 0.7 * (0.8 * 80)) < 1e-6
|
||||
&& Math.abs(r.len - 44.8) < 1e-6;
|
||||
// both sides of the shaft, measured off the DRAWN polygon: the depth of a
|
||||
// vertex divided by cos is how far its ray ran
|
||||
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
|
||||
const ran = (p) => ((p[0] - r.a[0]) * nrm(r)[0] + (p[1] - r.a[1]) * nrm(r)[1]) / cos;
|
||||
const far = r.polys[0].map(ran).filter((u) => u > 1e-6);
|
||||
out.grazingHasTwoFarCorners = far.length === 2;
|
||||
out.grazingSidesEqualWithin1Percent = far.length === 2
|
||||
&& Math.abs(far[0] - far[1]) <= 0.01 * r.len;
|
||||
out.grazingBothSidesAreTheNominalLength = far.every((u) => Math.abs(u - r.len) <= 0.01 * r.len);
|
||||
// the whole pane of glass at peak alpha (was 0 at one end)
|
||||
const peak = stopsOf(g)[0][1];
|
||||
out.grazingGlassAtOffsetZero = [r.a, r.b].every((p) => Math.abs(offsetOf(g, p)) < 1e-6);
|
||||
out.grazingGlassAtPeakAlpha = [r.a, r.b].every((p) => Math.abs(alphaAt(g, offsetOf(g, p)) - peak) < 1e-9);
|
||||
out.grazingPeakIsTheRealPeak = peak > 0.2;
|
||||
// and nothing is drawn past the gradient
|
||||
out.grazingInsideTheGradient = r.polys.every((poly) =>
|
||||
poly.every((p) => offsetOf(g, p) >= -1e-6 && offsetOf(g, p) <= 1 + 1e-6));
|
||||
}
|
||||
// a sun 2° off the wall's plane (cos 0.035 < RAY_MIN_COS) casts nothing
|
||||
await setSun(182, 90);
|
||||
out.sunAlongTheWallCastsNothing = c._sunRaysCache.rays.length === 0;
|
||||
await setSun(186, 90);
|
||||
out.sunJustClearOfTheWallStillCasts = c._sunRaysCache.rays.length === 1;
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,119 @@
|
||||
// DEV-B703-01: Lovelace re-creates card elements when the websocket reconnects
|
||||
// after a long-backgrounded tab; the fresh instance used to run the whole
|
||||
// first-open boot (veil + 700 ms + quiescence) — the owner's «план
|
||||
// перезагружается при возврате на вкладку». Within one loaded page a repeat
|
||||
// instance must open WARM: no veil, no wait, not a single frame at a stale
|
||||
// stage height or a default zoom. A window resize between instances
|
||||
// invalidates the memo → the full protective boot is back.
|
||||
// FAILS on the build before DEV-B703-01 (every instance booted cold).
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // deterministic: the page starts cold
|
||||
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ f1: 1.8 }));
|
||||
localStorage.removeItem('houseplan_card_nav_v1');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
document.body.appendChild(wrap);
|
||||
const mk = () => {
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__mkHass();
|
||||
wrap.appendChild(c);
|
||||
return c;
|
||||
};
|
||||
|
||||
// ---- cold boot: the first instance pays the full protective window ----
|
||||
const c1 = mk();
|
||||
out.coldBoots = c1._booting === true;
|
||||
const t0 = performance.now();
|
||||
let veilSeen = false;
|
||||
while (c1._booting && performance.now() - t0 < 2500) {
|
||||
if ((c1.shadowRoot || c1.renderRoot).querySelector('.bootveil')) veilSeen = true;
|
||||
await sleep(30);
|
||||
}
|
||||
out.coldVeilSeen = veilSeen;
|
||||
out.coldSettled = c1._booting === false;
|
||||
await sleep(300); // fade out + soft grace start
|
||||
const stage1 = (c1.shadowRoot || c1.renderRoot).querySelector('.stage');
|
||||
const hFinal = stage1.clientHeight;
|
||||
const zoomSaved = c1._zoom;
|
||||
out.zoomArmed = Math.abs(zoomSaved - 1.8) < 0.01;
|
||||
c1.remove(); // Lovelace throws the old element away…
|
||||
await sleep(30);
|
||||
|
||||
// ---- …and creates a new one: same page, same viewport → WARM mount ----
|
||||
const c2 = mk();
|
||||
out.warmNoBootFlag = c2._booting === false; // decided synchronously in setConfig
|
||||
const sr2 = () => c2.shadowRoot || c2.renderRoot;
|
||||
const frames = [];
|
||||
const t1 = performance.now();
|
||||
await new Promise((done) => {
|
||||
const sample = () => {
|
||||
const stage = sr2().querySelector('.stage');
|
||||
if (stage) {
|
||||
const zw = stage.querySelector(':scope > .zoomwrap');
|
||||
frames.push({
|
||||
t: Math.round(performance.now() - t1),
|
||||
veil: !!sr2().querySelector('.bootveil'),
|
||||
planVisible: !!zw && getComputedStyle(zw).visibility === 'visible',
|
||||
rooms: stage.querySelectorAll('.room').length,
|
||||
stageH: stage.clientHeight,
|
||||
zoom: c2._zoom,
|
||||
});
|
||||
}
|
||||
if (performance.now() - t1 < 900) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
out.framesSampled = frames.length > 20;
|
||||
out.warmNoVeilEver = !frames.some((f) => f.veil);
|
||||
out.warmPlanVisibleEveryFrame = frames.length > 0 && frames.every((f) => f.planVisible)
|
||||
? true : 'a frame hid the plan';
|
||||
const badH = frames.filter((f) => Math.abs(f.stageH - hFinal) > 2);
|
||||
out.warmNoStaleHeightFrame = badH.length === 0
|
||||
? true : `stage h=${badH[0].stageH} vs final ${hFinal} at t=${badH[0].t}ms`;
|
||||
out.warmZoomEveryFrame = frames.every((f) => Math.abs(f.zoom - zoomSaved) < 0.01)
|
||||
? true : 'a frame at a non-saved zoom';
|
||||
// the server config reload on a warm mount must not blank the plan
|
||||
out.warmNeverEmptyPlan = frames.every((f) => f.rooms > 0) ? true : 'a frame rendered no rooms';
|
||||
c2.remove();
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
|
||||
// ---- a window resize between instances → memo stale → the full boot ----
|
||||
await page.setViewportSize({ width: 900, height: 680 });
|
||||
const rez = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__mkHass();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.appendChild(c);
|
||||
document.body.appendChild(wrap);
|
||||
out.resizedBootsCold = c._booting === true;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let veil = false;
|
||||
while (c._booting && performance.now() - t0 < 2500) {
|
||||
if (sr().querySelector('.bootveil')) veil = true;
|
||||
await sleep(30);
|
||||
}
|
||||
out.resizedVeilShown = veil;
|
||||
out.resizedSettles = c._booting === false;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(rez)) check(k, v);
|
||||
|
||||
await finish(browser, { ...res, ...rez });
|
||||
@@ -0,0 +1,80 @@
|
||||
// DEV-B703-02: temporary WS failures must NEVER blank a plan that was already
|
||||
// shown. On the build before the fix _loadFromServer's catch nulled
|
||||
// _serverCfg after 8 failed tries — the plan disappeared even though a valid
|
||||
// config (the LS snapshot) was on screen. Stale-while-revalidate: the last
|
||||
// valid config lives until a successful reload replaces it, and once the
|
||||
// socket recovers the card revalidates QUIETLY on its own retry clock
|
||||
// (willUpdate stops driving loads after 8 tries).
|
||||
// planSurvivesOutage + reloadedAfterRecovery FAIL on the pre-fix build.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.();
|
||||
// the LS snapshot is in place (the page's main card cached it): the new
|
||||
// instance renders instantly from it — a wall tablet rejoining wifi
|
||||
out.lsCachePresent = !!localStorage.getItem('houseplan_card_cfg_v1');
|
||||
|
||||
let fail = true;
|
||||
let failedCalls = 0;
|
||||
const base = window.__mkHass();
|
||||
const mkFlaky = () => ({
|
||||
...base,
|
||||
callWS: async (m) => {
|
||||
if (fail) { failedCalls++; throw new Error('ws down'); }
|
||||
return base.callWS(m);
|
||||
},
|
||||
});
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = mkFlaky();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.appendChild(c);
|
||||
document.body.appendChild(wrap);
|
||||
await sleep(50);
|
||||
out.rendersFromCache = !!c._serverCfg;
|
||||
|
||||
// hass keeps ticking while the socket is down — drive past the 8-try budget
|
||||
for (let i = 0; i < 12; i++) {
|
||||
c.hass = mkFlaky();
|
||||
await sleep(60);
|
||||
}
|
||||
out.wsFailedEnough = failedCalls >= 8 ? true : `only ${failedCalls} failed calls`;
|
||||
out.planSurvivesOutage = c._serverCfg ? true : '_serverCfg was cleared by the outage';
|
||||
out.stillServerMode = c._serverStorage === true;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = sr().querySelector('.stage');
|
||||
out.roomsStillRendered = !!stage && stage.querySelectorAll('.room').length > 0;
|
||||
|
||||
// ---- the socket comes back: quiet self-driven revalidation ----
|
||||
fail = false;
|
||||
const t0 = performance.now();
|
||||
while (!c._loadOk && performance.now() - t0 < 12000) await sleep(100);
|
||||
out.reloadedAfterRecovery = c._loadOk === true ? true : 'never revalidated after recovery';
|
||||
out.cfgValidAfterRecovery = !!c._serverCfg && Array.isArray(c._serverCfg.spaces)
|
||||
&& c._serverCfg.spaces.length > 0;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
|
||||
// ---- a LIVE instance: a failing config reload keeps the last config ----
|
||||
const live = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const before = c._serverCfg;
|
||||
const origHass = c.hass;
|
||||
c.hass = { ...c.hass, callWS: async () => { throw new Error('ws down'); } };
|
||||
await c._reloadConfigOnly(true);
|
||||
out.liveReloadFailKeepsCfg = c._serverCfg === before
|
||||
? true : 'a failed reload replaced/cleared the live config';
|
||||
c.hass = origHass;
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(live)) check(k, v);
|
||||
|
||||
await finish(browser, { ...res, ...live });
|
||||
@@ -44,8 +44,11 @@ out.zoomOut = await page.evaluate(() => {
|
||||
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
|
||||
&& out.zoomOut.centredX && out.zoomOut.centredY;
|
||||
delete out.zoomOut;
|
||||
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
|
||||
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
|
||||
// docs/CANVAS.md §5: the zoom-out floor moved from a fixed 0.4 of the old
|
||||
// view_box to 1/3 of the CONTENT frame — three canvases of empty plane is as
|
||||
// far as it is useful to go when the canvas has no edges.
|
||||
out.floorIsThird = Math.abs(await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
|
||||
c._applyView(0.01); return c._zoom; }) - 1 / 3) < 1e-9; // clamped at the floor
|
||||
await page.evaluate(() => window.__card._resetZoom());
|
||||
|
||||
// -- editor zoom is a working tool, not the viewing intent ----------------
|
||||
|
||||
+536
-248
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+536
-248
File diff suppressed because one or more lines are too long
@@ -109,7 +109,9 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
|
||||
|
||||
`.storage/houseplan.config` (Store):
|
||||
```json
|
||||
{ "spaces": [{ "id","title","plan_url","plan_aspect","view_box":[4],
|
||||
{ "spaces": [{ "id","title","plan_url","plan_aspect",
|
||||
"plan_x","plan_y","plan_scale", // optional, docs/BACKDROP.md
|
||||
"view_box":[4],
|
||||
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
|
||||
"openings":[…], "decor":[…], "settings":{…} }],
|
||||
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden",
|
||||
@@ -122,7 +124,9 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
|
||||
All coordinates are **normalized (0..1 of the canvas)**; the canvas is always
|
||||
**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no
|
||||
proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox
|
||||
it centred on the square. The schema bounds geometry to ±4 with strictly
|
||||
it centred on the square; the optional `plan_x`/`plan_y`/`plan_scale` then move
|
||||
and uniformly scale that rectangle (`planRect`, docs/BACKDROP.md), and their
|
||||
absence is the centred default exactly. The schema bounds geometry to ±4 with strictly
|
||||
positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long
|
||||
gone — markers carry everything, `marker.hidden` is the explicit
|
||||
"hide from plan" flag seeded once by the old filter (docs/FILTERING.md).
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# The backdrop picture — the spec (source of truth)
|
||||
|
||||
*Owner-approved 2026-08-04. Companion to docs/CANVAS.md (the plane the
|
||||
picture lives on) and docs/RESIZE.md (the other transform frame).*
|
||||
|
||||
Until v1.58.0 an uploaded plan image was nailed to the canvas: centred, at
|
||||
its own proportions, filling the square. If it did not line up with the
|
||||
rooms you had drawn — or with the rooms you were about to draw — the only
|
||||
recourse was to edit the file. And because it filled the canvas, it also
|
||||
decided where the opaque **paper** was, which quietly made a picture plan
|
||||
and a drawn plan two different kinds of thing.
|
||||
|
||||
This closes both. The picture is now **one of the objects on the plane**:
|
||||
it can be moved and scaled like anything else, it counts as content for
|
||||
the frame, and it no longer makes paper of its own.
|
||||
|
||||
---
|
||||
|
||||
## §1 Model — three optional numbers
|
||||
|
||||
A space gains three optional fields, next to `plan_url` / `plan_aspect`:
|
||||
|
||||
| Field | Meaning | Units | Range |
|
||||
| --- | --- | --- | --- |
|
||||
| `plan_x` | offset of the image's **top-left corner** from its default place | normalised (as rooms, decor, openings) | ±`CANVAS_LIMIT` (±5000) |
|
||||
| `plan_y` | the same, vertically | normalised | ±`CANVAS_LIMIT` |
|
||||
| `plan_scale` | **one** uniform multiplier for both sides, anchored at the (already offset) top-left corner | multiplier | `0.01 … 100` |
|
||||
|
||||
There is **no rotation** and no independent width/height: the picture keeps
|
||||
its own aspect ratio for ever, which is what makes "scale" a single number.
|
||||
|
||||
**The absence of all three is exactly the pre-v1.58.0 behaviour.** No
|
||||
migration runs, nothing is written into anybody's store, and a plan that has
|
||||
never been through the backdrop editor renders bit-identically. Junk (a
|
||||
string, `NaN`, a zero or negative scale) is treated as absence, not as an
|
||||
error — the same reading rule the rest of the geometry uses.
|
||||
|
||||
One function owns the arithmetic, `planRect()` in `src/space-geometry.ts`:
|
||||
|
||||
```
|
||||
base = fitInSquare(plan_aspect, 1000) // the centred default
|
||||
rect.x = base.x + plan_x * 1000
|
||||
rect.y = base.y + plan_y * 1000
|
||||
rect.w = base.w * plan_scale
|
||||
rect.h = base.h * plan_scale
|
||||
```
|
||||
|
||||
`spaceModels()` calls it, so **every** consumer — the full card, the static
|
||||
`houseplan-space-card`, the kiosk, the content frame — reads the same
|
||||
rectangle. The card's model cache key (`_cfgFingerprint`) carries the three
|
||||
fields, so a drag invalidates the memo.
|
||||
|
||||
`validation.py` mirrors the ranges (`PLAN_SCALE_MIN` / `PLAN_SCALE_MAX`
|
||||
alongside `CANVAS_LIMIT`). Detaching the image (space dialog → «Нарисую
|
||||
комнаты вручную») deletes all three: there is nothing left for them to
|
||||
describe, and a stale transform must not silently apply to the next upload.
|
||||
|
||||
---
|
||||
|
||||
## §2 The transform frame
|
||||
|
||||
**Where.** The **backdrop editor** (`mode: 'decor'`, «Редактор подложки»).
|
||||
The frame is on screen as soon as that editor opens (tools **select** and
|
||||
**«Картинка-подложка»**); the drawing tools own the drag, so the frame
|
||||
steps aside for them. Nowhere else: View, the Plan editor, the Device
|
||||
editor and the kiosk never touch the picture. A space with no image never
|
||||
grows a frame, and its toolbar is unchanged.
|
||||
|
||||
**What.** A dashed outline on the picture's rectangle plus four
|
||||
finger-sized corner handles (radius = 2 % of the visible view, so they stay
|
||||
grabbable at any zoom — the same rule the vacuum fit frame uses). The
|
||||
outline never takes a pointer; the handles do, with `setPointerCapture` and
|
||||
`nwse-resize` / `nesw-resize` cursors. Under the picture's own tool the
|
||||
stage cursor is `grab`, and `grabbing` while a gesture is live.
|
||||
|
||||
**Move** — press anywhere inside the picture and drag, **under the
|
||||
«Картинка-подложка» tool**. Why a tool and not just the select tool: the
|
||||
picture's body is most of the screen, and claiming it would take away the
|
||||
one-finger pan of the whole plane, which the owner asked for on 2026-08-04
|
||||
(«таскать план при любом масштабе») and `smoke_pan_any_zoom` guards. The
|
||||
corner handles are precise targets and need no such protection — they are
|
||||
live whenever the frame is. The gesture is also only started INSIDE the
|
||||
rectangle, so pressing beside the picture pans even under its own tool.
|
||||
|
||||
**Nothing may rescale the stage mid-gesture.** Two things wanted to, and
|
||||
both moved the picture away from the finger before they were stopped:
|
||||
|
||||
* the picture is a content item, so dragging it GROWS the editor's content
|
||||
frame, which rescales the view — `_frameOf()` therefore returns the
|
||||
memoized frame untouched while `_bdDrag` is live, and catches up on
|
||||
release;
|
||||
* «Вернуть картинку» appears the moment the picture has moved, and the
|
||||
toolbar sits above the stage — so it is withheld until the gesture ends.
|
||||
|
||||
**Scale** — pull a corner. Uniform, about the **opposite** corner, which
|
||||
stays exactly where it was (the mechanic borrowed from the vacuum
|
||||
calibration panel). `k = max(|dx| / base.w, |dy| / base.h)`, so the gesture
|
||||
follows whichever axis you pulled harder, and both sides then follow `k`.
|
||||
|
||||
**Nothing else moves.** Rooms, openings, devices, decor and room labels are
|
||||
untouched by both gestures — this is a picture-only transform, and the
|
||||
smoke asserts the room geometry is byte-identical afterwards.
|
||||
|
||||
**Undo** is the «Вернуть картинку» button in the backdrop toolbar: it
|
||||
appears only once the picture HAS been moved, and it deletes the three
|
||||
fields (back to centred, own size).
|
||||
|
||||
---
|
||||
|
||||
## §3 THE PAPER IS THE ROOMS — and only the rooms
|
||||
|
||||
*This is a behaviour CHANGE, deliberately.*
|
||||
|
||||
The opaque "paper" exists so the scene background — `bg_color`, or the
|
||||
`daynight` sky (docs/SUN.md) — never bleeds through the plan itself. Until
|
||||
v1.58.0 it had two forms: the ROOM CONTOURS for a drawn plan, and **the
|
||||
image rectangle** for a picture plan.
|
||||
|
||||
The second form is gone. The paper is now `paperRoomShapes(space.rooms)`
|
||||
in every case:
|
||||
|
||||
* one opaque shape per room, in exactly the room's own geometry — an
|
||||
L-shaped house or a pair of detached buildings never grows a bounding
|
||||
rectangle;
|
||||
* an empty space has **no paper at all**, image or no image;
|
||||
* its colour is unchanged (`styles.ts .hp-paper`): white on a hand-drawn
|
||||
plan (`.stage.noplan`), the theme card background where an image is
|
||||
attached. `daynight` dims it via the `.zoomwrap` brightness filter only —
|
||||
its alpha stays 1.
|
||||
|
||||
**Layer order**, top to bottom, in both renderers:
|
||||
|
||||
```
|
||||
devices / labels
|
||||
sun wedges and rays
|
||||
walls, openings, room fills, decor
|
||||
THE PICTURE ← above the paper, below the geometry
|
||||
the paper (room contours)
|
||||
the scene background (bg_color / the daynight sky)
|
||||
```
|
||||
|
||||
**The consequence, and the owner knows it:** a picture with transparency
|
||||
over a space with no rooms drawn shows the scene background through itself.
|
||||
That is the honest reading of "the paper belongs to the rooms" — draw the
|
||||
rooms and the paper appears under them.
|
||||
|
||||
---
|
||||
|
||||
## §4 The picture is content
|
||||
|
||||
`contentItems()` has always counted the image rectangle as one content item
|
||||
alongside the rooms; it now counts the **transformed** one. So the frame,
|
||||
«Вписать всё», the zoom floor and the pan slack (docs/CANVAS.md §4–§5) all
|
||||
follow the picture wherever it is dragged, and a picture scaled down to a
|
||||
quarter tightens the frame around it instead of framing empty plane.
|
||||
|
||||
`iconUnit()` deliberately does **not** count it: an icon is a percentage of
|
||||
the drawn plan (docs/CANVAS.md §6), and scaling the backdrop must not
|
||||
resize every marker on it.
|
||||
|
||||
---
|
||||
|
||||
## §5 Snap
|
||||
|
||||
Both gestures obey the canvas snap contract (docs/CANVAS.md §9):
|
||||
|
||||
| Gesture | What is snapped |
|
||||
| --- | --- |
|
||||
| move | the resulting **top-left corner** — the anchor, not the delta, so one drag is enough to put a legacy off-grid picture onto the lattice |
|
||||
| scale | the dragged corner **along the picture's longer side**; the scale is then read back off it |
|
||||
|
||||
The fixed corner of a scale keeps its (on-grid) place and the long side
|
||||
lands on a node. The short side follows from the aspect ratio and generally
|
||||
does **not** — that is what "uniform, no stretch" costs, and it is the
|
||||
right trade: a picture whose proportions drift is a broken picture.
|
||||
|
||||
**Shift** suspends the snap for the duration of the gesture, exactly as
|
||||
everywhere else (§9.4). The `±CANVAS_LIMIT` clamp rides along regardless —
|
||||
`_snap()` and `clampCanvasN` are the only bound either gesture has.
|
||||
|
||||
---
|
||||
|
||||
## §6 Live measurements
|
||||
|
||||
While the picture is dragged or scaled, one badge rides its centre with the
|
||||
picture's **real size**, width × height, through the space's `cell_cm` and
|
||||
the HA unit system (metres or feet). It is the same `_fmtLen`
|
||||
(`segmentCm` → `formatLength`) and the same `.measurelabel` element the
|
||||
wall ruler, the opening shoulders and the room resize use — the card states
|
||||
a length exactly one way.
|
||||
|
||||
---
|
||||
|
||||
## §6b One tool at a time — a drawing tool owns the canvas
|
||||
|
||||
The backdrop editor has seven tools, and they are **exclusive**: only
|
||||
**select** talks to an existing shape, only **erase** deletes one, only
|
||||
**«Картинка-подложка»** grabs the picture's body, and the four drawing
|
||||
tools (**line**, **rect**, **ellipse**, **text**) do nothing but create.
|
||||
|
||||
The rule exists because of a concrete failure (owner, 2026-08-04): *«нельзя
|
||||
поставить начало линии на конец другой — при клике на конец выделяется
|
||||
первая линия, а не начинает рисоваться новая»*. The shape's own
|
||||
`pointerdown` handler ran first, stopped the event and did nothing under a
|
||||
drawing tool, so the press never reached the stage and no draft was born —
|
||||
exactly on the one spot where a new line most wants to start, since §5's
|
||||
snap anchors include every line end.
|
||||
|
||||
So, under a drawing tool (and under the picture tool):
|
||||
|
||||
* `.dshape` is `pointer-events: none` — an existing figure is not a target
|
||||
at all, and the cursor is the stage's;
|
||||
* `_decorShapeDown` returns before `stopPropagation` for any tool that is
|
||||
not select/erase, so a synthetic press cannot swallow the gesture either;
|
||||
* `_decorPointerDown` only looks for a shape under the pointer for
|
||||
select/erase, and otherwise starts the draft at the pressed point;
|
||||
* the transform frame's corner handles stay off (§2) — they were the first
|
||||
thing taught not to swallow the first point of a line.
|
||||
|
||||
Selecting, moving and the text dialog's double-click therefore live in the
|
||||
select tool only. `demo/smoke_decor.mjs` asserts both halves: a press on an
|
||||
existing line starts a new line **from that end** under the line tool, and
|
||||
still selects and grabs it under select.
|
||||
|
||||
---
|
||||
|
||||
## §7 What is deliberately NOT done
|
||||
|
||||
* **No rotation.** A skewed scan is a scan to re-take, not a plan to skew
|
||||
the whole editor's maths for.
|
||||
* **No non-uniform scale.** See above: the picture keeps its ratio.
|
||||
* **No numeric fields** in the space dialog. The frame is the interface;
|
||||
the numbers exist for the config, not for typing.
|
||||
* **No per-room clipping of the picture.** It is one rectangle.
|
||||
* **No transform for the sun's paper/wedges.** Those follow the rooms, and
|
||||
now so does the paper — one rule, no second geometry.
|
||||
|
||||
---
|
||||
|
||||
## §8 Where it lives
|
||||
|
||||
| Concern | File |
|
||||
| --- | --- |
|
||||
| the rectangle (`planRect`, `PLAN_SCALE_MIN/MAX`), content items | `src/space-geometry.ts` |
|
||||
| the frame, the gestures, the live badge, the reset | `src/houseplan-card.ts` (`_bd*`, `_renderBackdropFrame`) |
|
||||
| the frame's look, the paper, the cursors | `src/styles.ts` (`.bdframe`, `.hp-paper`) |
|
||||
| the static card's copy of the paper + picture order | `src/space-render.ts` |
|
||||
| the stored ranges | `custom_components/houseplan/validation.py` |
|
||||
| unit coverage | `test/backdrop.test.mjs` |
|
||||
| DOM coverage | `demo/smoke_backdrop.mjs`, `demo/smoke_bg_color.mjs` §11 |
|
||||
| screenshots | `demo/shot_backdrop.mjs` |
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
# Infinite canvas — the spec (source of truth)
|
||||
|
||||
Status: approved by the owner 2026-08-03, **shipped in v1.57.0**.
|
||||
Scope decisions final: there is no "plan size" any more, the canvas
|
||||
is conceptually unbounded, storage does not change, and the
|
||||
opening view is always derived from what is actually drawn.
|
||||
|
||||
## The problem it closes
|
||||
|
||||
Several users drew plans that ran past the edge of the grid and then
|
||||
could not place devices outside it. The only workaround was to redraw
|
||||
the whole plan. The card behaved as if the normalised unit square
|
||||
(`0..1`, rendered as `NORM_W x NORM_W = 1000 x 1000` units) were a
|
||||
sheet of paper with edges. It is not a sheet of paper — it is just the
|
||||
coordinate system.
|
||||
|
||||
## Principle
|
||||
|
||||
1. **Coordinates keep their meaning.** Rooms, openings, decor and
|
||||
device positions are still stored normalised. `1.0` is still the
|
||||
same distance it always was; `cell_cm` still ties a grid cell to
|
||||
real centimetres. **No data migration.** An existing plan opens as
|
||||
before — including the size of the markers (§6).
|
||||
2. **`0..1` is not a boundary, it is an origin.** Any finite
|
||||
coordinate is legal. `2.7` simply means "2.7 canvas widths to the
|
||||
right of the origin".
|
||||
3. **Nothing in the product may say "you cannot go past the edge".**
|
||||
No clamp on drawing, dragging, decor or device placement stops at
|
||||
a rectangle.
|
||||
4. **What is stored is only where something is drawn.** There is no
|
||||
stored extent to keep in sync.
|
||||
|
||||
## Model
|
||||
|
||||
| Concept | Before | Now |
|
||||
| --- | --- | --- |
|
||||
| Canvas | square `0..1`, rendered `0..1000` | unbounded plane, same units |
|
||||
| `space.view_box` | the frame; everything was clamped into it | an OPTIONAL hint for the very first frame; used only when there is nothing to frame |
|
||||
| "fit" rectangle | `view_box` (or the content bbox in view mode) | always the **content frame** (§4) |
|
||||
| Zoom out floor | `ZOOM_MIN = 0.4` (fraction of `view_box`) | 3x the content frame (`MIN_ZOOM = 1/3`) |
|
||||
| Pan bounds | content must cover the scene, and only above 100% zoom | content frame + one screen of slack in each direction, at any zoom |
|
||||
| Icon size | % of `view_box`, i.e. grew with zoom | % of `iconUnit` — still grows with zoom (§6) |
|
||||
| Validation range | `+/-4` | `+/-5000` (§3) |
|
||||
|
||||
### Render frame vs. view
|
||||
|
||||
* **Frame** (`_baseVb()` / `spaceFrame()`) — the rectangle that "fit to
|
||||
screen" fits and that zoom `1` means. It is recomputed from content,
|
||||
never stored.
|
||||
* **View** (`_view`) — the SVG `viewBox` actually painted. It is in
|
||||
absolute render units, so recomputing the frame never teleports the
|
||||
plan; it only changes what zoom `100 %` means and where panning
|
||||
stops.
|
||||
|
||||
## §3 Validation limits
|
||||
|
||||
`custom_components/houseplan/validation.py`:
|
||||
|
||||
| Symbol | Before | Now | What it is |
|
||||
| --- | --- | --- | --- |
|
||||
| `_COORD` (layout x/y) | `-4 .. 4` | `-5000 .. 5000` | coordinate |
|
||||
| `_GEOM` (room x/y, poly points, opening x/y, `view_box` origin) | `-4 .. 4` | `-5000 .. 5000` | coordinate |
|
||||
| `_EXTENT` (room w/h, `view_box` w/h) | `0.001 .. 4` | `0.001 .. 5000` | size — strictly positive |
|
||||
| `_NORM` (decor x/y/w/h) | `-1 .. 2` | `-5000 .. 5000` | coordinate |
|
||||
| opening `length` | `0.001 .. 1` | `0.001 .. 5000` | size — strictly positive |
|
||||
|
||||
`+/-5000` is **garbage insurance, not a frame**. At the product's own
|
||||
scale (`cell_cm` = 5 by default, 240 grid cells across the unit width)
|
||||
one canvas width is ~12 m, so `5000` is ~60 km of plan — unreachable
|
||||
in a home, while still stopping a stored `1e100` from making the plan
|
||||
invisible for every client (the failure HP-1500-03 / HP-1501-01
|
||||
closed). Sizes stay strictly positive because SVG divides by them and
|
||||
`viewBox="0 0 0 0"` paints nothing (HP-1502-01).
|
||||
|
||||
## §4 The content frame
|
||||
|
||||
`contentFrame(items, opts)` in `src/space-geometry.ts` — pure, unit
|
||||
tested. Input is a list of **items**, one per drawn/placed object:
|
||||
|
||||
* every room (its own bounding box — polygon or legacy rect);
|
||||
* the backdrop image rectangle, when the space has one;
|
||||
* every opening (door/window) end-to-end segment;
|
||||
* every decor shape;
|
||||
* every device the layout actually places in this space **and that the card
|
||||
actually draws** — a HIDDEN device (docs/FILTERING.md) is not content: the
|
||||
frame is presentation, and an object nobody can see must not decide what the
|
||||
plan opens on. It keeps its auto-grid cell and every aggregation it feeds;
|
||||
it simply is not an item here. Ghosts in the device editor are not items
|
||||
either — reaching them is §5's job, not the opening view's.
|
||||
|
||||
Output:
|
||||
|
||||
```ts
|
||||
{ core: Rect | null, all: Rect | null, outliers: number }
|
||||
```
|
||||
|
||||
* `core` — bbox of the **main mass**, padded. This is the opening view.
|
||||
* `all` — bbox of **everything**, padded. This is what "show the far
|
||||
objects" fits.
|
||||
* `outliers` — how many items were left out of `core`.
|
||||
|
||||
Both rectangles are padded by `pad` (default `0.05`) of the longer
|
||||
side, and degenerate axes are inflated (see §4.2).
|
||||
|
||||
For a space with a **backdrop image** the image rectangle is one of the
|
||||
items, so the image still sets the extent — cropping to the rooms would
|
||||
hide the parts of the picture nobody has outlined yet (owner, point 2).
|
||||
|
||||
Fallback order when there are no items at all: the stored `view_box`
|
||||
(the "hint"), then the legacy unit square. This is the only place
|
||||
`view_box` is still read for framing.
|
||||
|
||||
### §4.1 Outlier rejection
|
||||
|
||||
An object standing an order of magnitude further away than the rest
|
||||
must not decide the opening view, but must still be reachable. The
|
||||
criterion is deliberately rank-based (medians/percentiles), so a single
|
||||
absurd value cannot move it:
|
||||
|
||||
1. Items whose coordinates fall outside the sane range
|
||||
(`+/-CANVAS_LIMIT`, i.e. the same `+/-5000` the backend accepts) are
|
||||
dropped outright — that is corruption, not content.
|
||||
2. With fewer than `MIN_VOTERS = 4` items no outlier is declared:
|
||||
with two objects there is no majority to be far from.
|
||||
3. `m` = component-wise **median** of the item centres.
|
||||
4. `d_i` = Chebyshev distance `max(|x_i-m_x|, |y_i-m_y|)` from `m`.
|
||||
5. `spread` = the **75th percentile** of `d`, floored at
|
||||
`MIN_SPREAD = 0.05 * NORM_W` (50 render units, about a small room),
|
||||
so a tightly clustered plan does not call its own neighbour an
|
||||
outlier.
|
||||
6. Item `i` is an outlier iff `d_i > OUTLIER_K * spread`, with
|
||||
`OUTLIER_K = 10` — literally "an order of magnitude further than
|
||||
the bulk".
|
||||
7. **Majority veto**: if more than a third of the items came out as
|
||||
outliers, this is not a plan with strays — it is a spread-out plan.
|
||||
No outliers are declared and `core = all`.
|
||||
|
||||
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
|
||||
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
|
||||
a floor:
|
||||
|
||||
* an axis shorter than `DEGENERATE = 0.03 * NORM_W` is grown to
|
||||
`FLOOR = 0.2 * NORM_W`, centred on itself.
|
||||
|
||||
That covers "one lone marker" and "a collinear row of markers". A real
|
||||
thin shape (a 100-unit corridor) is well above the threshold and keeps
|
||||
its tight frame. This is the only survivor of the old safety props —
|
||||
the `-25 % .. 125 %` envelope that used to reject far content is gone,
|
||||
replaced by §4.1 (the envelope WAS the bug: content past the old square
|
||||
was silently excluded from the frame).
|
||||
|
||||
## §5 Zoom and pan
|
||||
|
||||
* **Zoom in** — unchanged, `ZOOM_MAX = 8`.
|
||||
* **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content
|
||||
frame and no further. Empty space beyond that is not information.
|
||||
* **Pan** — available at **every zoom**, in view mode and in every
|
||||
editor, and bounded by the content frame inflated by
|
||||
`PAN_SLACK = 1.0` of `max(view, frame)` on each side. You can walk
|
||||
off the plan (there is no edge), but not into infinity.
|
||||
Until 2026-08-04 a drag moved the view only while `zoom > 1`: on the
|
||||
old bounded canvas a plan smaller than the scene genuinely had
|
||||
nowhere to go, so the gate was harmless. With no edge left it was
|
||||
simply a missing feature, and the owner reported it as one
|
||||
(«таскать план при любом масштабе»). The zoom no longer takes part
|
||||
in the decision — `_clampView` alone says how far you may walk.
|
||||
* **Who owns the pointer.** A drag pans only when it starts on empty
|
||||
scene: the room-resize handles, device badges, openings, room labels
|
||||
and the decor shapes take the pointer first (`_stagePointerDown`
|
||||
bails out on them), and a drawing tool that consumes the press —
|
||||
decor line/rect/ellipse/text — bails out too. Two fingers are always
|
||||
a pinch, never a pan. On a **kiosk** screen at swipe zoom (`≤ 1`,
|
||||
more than one space) a *horizontal* drag belongs to the floor swipe:
|
||||
the gesture is classified once, on the first movement past 8 px, and
|
||||
keeps that role until the finger lifts (`_panLock`), so the plan
|
||||
never slides under a swipe and a vertical drag still pans.
|
||||
* **The lock is final, at the release too** (audit DEV-1DA1-02). The
|
||||
release used to ask `swipeTarget()` again from the raw start→end
|
||||
vector, ignoring the lock — so a *curved* gesture (a short vertical
|
||||
lead-in that locks `pan`, then a long horizontal sweep) dragged the
|
||||
plan under the finger and still landed on another storey when it
|
||||
lifted: the worst kind of surprise on a wall tablet. `_panLock ===
|
||||
'pan'` now means no floor change, whatever the overall vector ends up
|
||||
looking like; only a gesture locked as `swipe` may reach
|
||||
`swipeTarget()`, and it never pans on the way. A motionless tap locks
|
||||
nothing, so the double-tap zoom reset is untouched.
|
||||
* **"Home is that way" arrow** — when the content frame is entirely
|
||||
outside the current view, a small pointer appears at the view edge
|
||||
in the frame's direction. Clicking it fits the content. Cheap
|
||||
insurance against getting lost in the empty plane.
|
||||
|
||||
## §6 Icon size — a percentage of the plan
|
||||
|
||||
**Unchanged behaviour** — an icon scales with the plan, exactly as it
|
||||
always did. (A first cut of the infinite canvas made it a fixed
|
||||
percentage of the viewport; the owner looked at it on 2026-08-03 and
|
||||
asked for the original back. The history is kept here because the
|
||||
reasoning for the *numerator* below is the whole point.)
|
||||
|
||||
Before the infinite canvas:
|
||||
|
||||
```
|
||||
--icon-size: iconPct * vb.w / view.w (cqw)
|
||||
```
|
||||
|
||||
Now (`iconCqw()` in `src/space-geometry.ts`, pure and unit tested):
|
||||
|
||||
```
|
||||
--icon-size: iconPct * iconUnit(space) * kioskScale / view.w (cqw)
|
||||
```
|
||||
|
||||
Read it in render units: a marker always occupies
|
||||
`iconPct/100 * iconUnit` **render units** of the plan, whatever the
|
||||
frame and whatever the zoom. Dividing by the width of the visible view
|
||||
turns that into the percentage of the container `cqw` means. Zoom in
|
||||
2x and the marker is 2x bigger, together with the walls it sits on.
|
||||
|
||||
**Why the numerator changed.** `vb.w` was the stored `view_box`, and
|
||||
`view_box` is not a frame any more (§4). Keeping a fixed `NORM_W`
|
||||
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, 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 ===
|
||||
vb.w` there and the rendered pixel size is bit-identical to the
|
||||
pre-canvas card (verified against the v1.56.0 bundle at a fixed view:
|
||||
`3.400 / 3.091 / 6.182 / 12.364 cqw`, i.e. `28.52 / 26.11 / 50.22 /
|
||||
98.44 px`, both bundles);
|
||||
* **proportional to an outsized plan**, so a runaway plan gets markers
|
||||
of the same apparent size as an ordinary one.
|
||||
|
||||
Everything else is untouched: the per-device multiplier `marker.size`
|
||||
and the kiosk icon/font scales still feed `--dev-size`, and every
|
||||
satellite (badges, LQI chips, presence rings, ripples) still derives
|
||||
from `--dev-size`. The full card and the static
|
||||
`houseplan-space-card` call the same `iconCqw()` — the static card has
|
||||
no zoom, but its frame is the content now, so a bare `iconPct` would
|
||||
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 outlier vote reaches the spacing through the very same call.
|
||||
|
||||
## §7 Adaptive grid
|
||||
|
||||
The drawing grid is a dot pattern at `pitch = NORM_W / GRID_N`. On a
|
||||
plan several canvases wide, zoomed out, the dots merged into a grey
|
||||
wash. `gridLevels(pitch, pxPerUnit, minPx)` (pure, unit tested) picks:
|
||||
|
||||
* `fine` — the smallest multiplier from `1, 2, 5, 10, 20, 50, 100,
|
||||
200, 500, 1000` whose on-screen step is at least `minPx` (7 px);
|
||||
finer dots are simply not drawn;
|
||||
* `coarse` — the next multiplier that is at least `5 x fine`, drawn
|
||||
bigger/darker, so the eye keeps a scale reference (the usual CAD
|
||||
every-5th/10th-line convention);
|
||||
* `null` when even the coarsest step would be sub-pixel — then there
|
||||
is no grid at all rather than a grey fog.
|
||||
|
||||
The grid rectangle also follows the **view**, not the old `view_box`,
|
||||
so it is there wherever you pan.
|
||||
|
||||
The dots are a **hint, not content** (owner 2026-08-04): at full strength
|
||||
they argued with the plan on white paper. `.griddot` is `opacity: 0.35`,
|
||||
`.griddot.major` `opacity: 0.5` (styles.ts) — both muted, the CAD
|
||||
hierarchy kept, coarse nodes still denser than fine ones. The grid lives
|
||||
in the editors only; View never draws it (`smoke_grid_fade`).
|
||||
|
||||
## §8 Toolbar
|
||||
|
||||
The middle button of the zoom control was "Reset zoom" (`_resetZoom`,
|
||||
disabled at zoom 1). It **is** the fit-everything action, so it was
|
||||
re-labelled rather than duplicated: `title.zoom_fit` — "Fit all" /
|
||||
«Вписать всё», icon unchanged (`mdi:fit-to-page-outline`), and it is no
|
||||
longer disabled at zoom 1 (at zoom 1 off-centre it still has work to
|
||||
do). It fits `core` — the same rectangle the plan opens with. Far
|
||||
objects are reached through the outlier hint's **Show** action, which
|
||||
fits `all`.
|
||||
|
||||
## §9 Drag limits and the snap contract (dev, DEV-B58)
|
||||
|
||||
### 9.1 One bound, and it is the backend's
|
||||
|
||||
v1.57.0 freed the FRAME and the DRAWING, but not the drag handlers. Two
|
||||
of them still clamped, and the owner and a user hit both:
|
||||
|
||||
| Handler | Old clamp | Effect |
|
||||
| --- | --- | --- |
|
||||
| `_pointerMove` (device marker) | `_baseVb()` ± a 0.8 % inset — the CONTENT FRAME | a marker could never be dragged past the outline of what was already drawn, so a plan could not be extended by putting a device where the next room was going to be |
|
||||
| `_labelMove` (room label) | `_spaceModel().vb` — the space's STORED `view_box` | worse: that is `[0,0,1,1]` for every plan the card has ever written, i.e. literally the old square. A room drawn at 2.5 had a name that could not reach its own room |
|
||||
| `_decorCommitDraft` / decor text anchor | *none at all* | asymmetric with `_decorMoveUpdate`, which did clamp — a draft could be born outside the range the mover then refused to leave |
|
||||
|
||||
The rule now: **an editor gesture has exactly one bound, `±CANVAS_LIMIT`
|
||||
(±5000 normalised, ±`SANE_LIMIT` in render units), and it is the same
|
||||
number `validation.py` enforces.** It is a garbage limit — insurance
|
||||
against a stored `1e100` — and never a frame. `clampCanvasR` /
|
||||
`clampCanvasN` (`space-geometry.ts`) are the only two functions that may
|
||||
impose it, and `_snap()` applies `clampCanvasR` on the way out, so every
|
||||
gesture that goes through the snap is bounded by construction.
|
||||
|
||||
Room drawing, split, resize and opening placement had no clamp before
|
||||
and still have none of their own — they inherit it from `_snap()` /
|
||||
`clampCanvasN` at the write.
|
||||
|
||||
### 9.2 The grid step never changed
|
||||
|
||||
`_gridPitch = NORM_W / GRID_N = 1000 / 240`. Both constants; neither
|
||||
depends on the content frame, the view, the zoom, `view_box`, or
|
||||
`cell_cm`. `git log -S` confirms neither has been touched since v1.4.0
|
||||
(the one historical change, `GRID_N` 120 → 240, halved the step, so the
|
||||
old nodes stayed a subset of the new ones and every position was
|
||||
preserved).
|
||||
|
||||
**So the infinite canvas did NOT move any existing element off the
|
||||
grid.** `gridLevels()` (§7) chooses which multiples of that pitch are
|
||||
still legible at the current zoom — it changes what is DRAWN, never what
|
||||
is SNAPPED TO. An element that looks off-grid is off-grid because it was
|
||||
placed by something that never snapped, not because the lattice moved.
|
||||
|
||||
### 9.3 What snaps, and to what
|
||||
|
||||
Two kinds of element, because a door rounded to a grid node while its
|
||||
wall runs diagonally is broken geometry, not a tidy plan:
|
||||
|
||||
**GRID-BOUND — rounded to the nearest node:**
|
||||
|
||||
| Element | Where |
|
||||
| --- | --- |
|
||||
| the backdrop picture: move (its top-left corner) and corner scale (the dragged corner, along the longer side) | `_bdMove` → `_snap` / `snapToGrid` (docs/BACKDROP.md §5) |
|
||||
| room vertices (draw tool) | `_markupClick` → `_snap` |
|
||||
| split tool's interior vertices | `_splitClick` → `_snap` |
|
||||
| resize edge drag / corner scale | `_rszMove` → `_snap` |
|
||||
| decor draft endpoints, text anchor | `_decorPointerDown` / `_stagePointerMove` → `_snap` |
|
||||
| decor move | `_decorMoveUpdate` → `_snap` of the resulting ANCHOR |
|
||||
| device markers | `_savePos` |
|
||||
| room labels | `_labelMove` → `_savePos` |
|
||||
| auto-placed markers (`defaultPositions`), the `spaceCenter` fallback, an undragged room label | `snapPt` |
|
||||
|
||||
**WALL-BOUND — projected onto the wall, then the offset ALONG the wall
|
||||
quantised to the same step, measured from the wall's first corner:**
|
||||
|
||||
| Element | Where |
|
||||
| --- | --- |
|
||||
| openings, placed and dragged | `snapToWall(..., { step, length })` |
|
||||
| split tool's points ON a wall | `snapPointAlongPoly` |
|
||||
|
||||
On an axis-aligned wall whose corners are on the grid — every wall the
|
||||
editor itself draws — the two rules give the same point. An opening is
|
||||
also kept inside its wall by half its own length.
|
||||
|
||||
Three things were fixed here besides the new coverage:
|
||||
|
||||
* `_decorMoveUpdate` used to snap the **delta**, which preserves any
|
||||
off-grid offset the shape already had for ever, one step at a time.
|
||||
It snaps the resulting anchor now, so one drag is enough.
|
||||
* `defaultPositions` / `labelPos` / the `spaceCenter` fallback placed
|
||||
auto elements at centroids, which are not nodes for an odd-sized or
|
||||
polygonal room. These were the most likely source of "some elements
|
||||
are between the points" on an untouched plan.
|
||||
* `snapToGrid` and `snapR` now return a value that is already on a node
|
||||
**bit-identical**. The round trip through a non-dyadic pitch
|
||||
(1000/240) otherwise turns an exact `500` into `500.00000000000006`,
|
||||
and "is this on the grid?" starts answering no.
|
||||
|
||||
### 9.4 Shift
|
||||
|
||||
`Shift` suspends the snap for the duration of the gesture, everywhere:
|
||||
`_snap(p, ev)` reads `ev.shiftKey`, `_savePos(..., shift)` takes it, and
|
||||
`snapToWall` is called without `step` when it is held. It keeps its two
|
||||
older meanings too — it opts out of the opening's centre magnet, and it
|
||||
turns the compass to coarse 15° steps.
|
||||
|
||||
### 9.5 «Выровнять всё по сетке» — an ACTION, not a migration
|
||||
|
||||
Existing plans may hold coordinates between the nodes. The card does
|
||||
**not** round them on update. Instead, general settings grow a **Grid**
|
||||
group with one button; it opens a confirmation that states how many
|
||||
elements will move and by how much at most, warns that there is no undo,
|
||||
and only then writes.
|
||||
|
||||
Why an action rather than a silent migration:
|
||||
|
||||
1. It moves the user's data without asking. A house plan is a drawing;
|
||||
the card has no mandate to redraw it on a version bump.
|
||||
2. Some elements are off-grid **on purpose** — a small decor label
|
||||
nudged next to an icon, a window on a diagonal wall, a plan traced
|
||||
over a photo whose scale was never a whole number of cells.
|
||||
3. A silent migration is unattributable. When a room looks 3 cm wrong
|
||||
the owner cannot tell whether the card did it or they did.
|
||||
4. An update that touches stored geometry cannot be rolled back by
|
||||
downgrading the card. An action can simply not be pressed.
|
||||
|
||||
`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it
|
||||
copies its input, never mutates it, and returns the new spaces, the new
|
||||
layout and the report. The dialog therefore measures and commits the
|
||||
**same object** — the numbers it promises cannot differ from what it
|
||||
does. The write is one `config/set` plus the layout updates, in one go.
|
||||
|
||||
Guarantees, all covered by `test/align-grid.test.mjs`:
|
||||
|
||||
* every grid-bound element ends on a node; a rect's FAR corner too (a
|
||||
snapped *size* on an off-grid origin leaves the other side between
|
||||
the nodes);
|
||||
* an opening ends on its wall, at whole steps along it, inside it, and
|
||||
**with the wall's own angle** — the angle is written, so it is part of
|
||||
the diff (AUD-158B1-02: an opening already on its wall with a wrong
|
||||
angle used to be returned changed inside `changed: false`, which made
|
||||
it unfixable);
|
||||
* a stray opening with no wall within 6 steps is left exactly where it
|
||||
is rather than teleported;
|
||||
* **idempotent**: a second run reports `moved: 0`, `changed: false`, and
|
||||
returns objects deep-equal to the first run's;
|
||||
* the report is an **upper bound**, not a sample (AUD-158B1-01).
|
||||
|
||||
### The report is a promise
|
||||
|
||||
The confirmation is the only gate in front of an action with no undo, so
|
||||
`maxShift`/`maxShiftCm` must never be smaller than what the run does:
|
||||
|
||||
* displacement is measured on the geometry **actually written back** —
|
||||
all FOUR corners of a rect, minimum-size correction included. The two
|
||||
corners nobody used to measure are exactly the two that can be worst:
|
||||
they carry the X error of one side together with the Y error of the
|
||||
other, which is √2 of either;
|
||||
* an opening is measured on its **ends**, flip-invariantly, so turning
|
||||
it in place costs what it really costs and a 180° rewrite costs
|
||||
nothing;
|
||||
* the maximum is accumulated in **centimetres**, each space through its
|
||||
own `cell_cm`, and the report names the space it belongs to. One
|
||||
normalised maximum converted through the *first* space's cell size
|
||||
promised 2.5 cm for a vertex that moved 50 cm on a 100 cm floor;
|
||||
* the dialog rounds the last tenth **up** and, on a multi-space plan,
|
||||
says which space the maximum is in; openings corrected in angle alone
|
||||
are counted on a line of their own.
|
||||
|
||||
There is no undo, and the dialog says so: the card keeps no snapshot of
|
||||
the previous geometry. Re-running the action does not undo it either —
|
||||
it is a projection, and a projection is not invertible.
|
||||
|
||||
## Every place that assumed the unit square
|
||||
|
||||
|
||||
| Place | Assumption | Decision |
|
||||
| --- | --- | --- |
|
||||
| `contentBounds` envelope `-25 %..125 %` | content outside the square does not count | **removed** — replaced by §4.1 outlier rejection |
|
||||
| `_baseVb()` `if (mode !== 'view') return m.vb` | editors need the whole square to have room to draw | **removed** — the content frame plus §5 pan slack and 3x zoom-out gives more room than the square ever did |
|
||||
| `_baseVb()` `if (m.bg) return m.vb` | image plans frame on the square | image rect is now just one content item (§4) |
|
||||
| `--icon-size` scaled by `vb.w / view.w` | the canvas is what an icon is a fraction OF | numerator becomes `iconUnit()`; the icon still scales with the plan (§6) |
|
||||
| `defaultPositions` `minDist` from `NORM_W` | one canvas = one plan | `iconUnit()` (§6) |
|
||||
| `markerPos` / `_pos` fallback = `view_box` centre | a device with no position belongs in the middle of the square | `spaceCenter()` — the middle of the content |
|
||||
| grid `<rect>` over `vb` | the grid ends with the square | rect follows the view (§7) |
|
||||
| grid pitch fixed | fine at 1 canvas wide | `gridLevels()` (§7) |
|
||||
| `_clampView` pinned content over the scene | you cannot pan past the edge | §5 pan slack |
|
||||
| `_stagePointerMove` panned only while `zoom > 1` | below 100% the content already covered the scene, so a drag had nowhere to go | **removed** — §5, panning at every zoom |
|
||||
| `ZOOM_MIN = 0.4` | fraction of the square | `MIN_ZOOM = 1/3` of the content frame (§5) |
|
||||
| `_decorMoveUpdate` clamp `-0.25 .. 1.25` | decor may hang a quarter past the edge | clamp widened to the sane range (`+/-CANVAS_LIMIT`) — corruption insurance, not a frame |
|
||||
| `_pointerMove` clamp to `_baseVb()`, `_labelMove` clamp to `view_box` | a marker/label belongs inside the canvas | **removed** — §9.1; missed in v1.57.0 and reported by the owner |
|
||||
| static card `aspect-ratio` + `viewBox` from `space.vb` | the static card frames the square | `spaceFrame()` — same content frame as the full card |
|
||||
| `validation.py` `+/-4`, `_EXTENT <= 4`, decor `-1..2`, opening `length <= 1` | the square plus slack | §3 |
|
||||
| `safeViewBox` fallback `[0,0,1,1]` | a broken `view_box` means the square | kept — it is only the last-resort hint (§4) |
|
||||
| `fitInSquare` (image placement) | image is centred in the square | **kept** — it defines the image's own rectangle in canvas units, which is exactly what §4 wants as a content item. Since v1.58.0 it is only the DEFAULT placement: `planRect()` adds the owner's `plan_x`/`plan_y`/`plan_scale` on top, and the moved rectangle is what §4 counts (docs/BACKDROP.md) |
|
||||
| image plan papers the image rect | the picture IS the sheet | **removed** in v1.58.0 — the opaque paper is the room contours in every case, and the picture is drawn on top of it (docs/BACKDROP.md §3) |
|
||||
| `_spaceH` / `_decorH` = `NORM_W` | the canvas is square | **kept** — this is the coordinate system's aspect, not a frame |
|
||||
| `_gridPitch = NORM_W / GRID_N` | grid pitch is tied to the canvas unit | **kept** — the pitch is the real-world cell (`cell_cm`), it must not change with the plan's size |
|
||||
| sun wedges / glow radii / resize maths | all in render units, relative to their own geometry | **unaffected** — verified: no `NORM_W`-relative constants |
|
||||
|
||||
## What is deliberately NOT done
|
||||
|
||||
* No new stored field. The frame is derived every time; there is
|
||||
nothing to migrate, nothing to keep in sync, nothing to corrupt.
|
||||
* `view_box` is still WRITTEN as `[0,0,1,1]` on space creation and is
|
||||
still required by the schema — removing a required field is a
|
||||
breaking storage change for old clients and buys nothing.
|
||||
* The outlier hint has no "hide this object" action. Deciding what to
|
||||
do with a stray marker is the device editor's job.
|
||||
@@ -1,5 +1,174 @@
|
||||
# Changelog
|
||||
|
||||
## v1.58.0 — 2026-08-04
|
||||
|
||||
Minor release: the backdrop picture becomes a movable, scalable object,
|
||||
the opaque plan sheet is redefined as the room contours, the decor
|
||||
drawing tools stop stealing clicks, and «Align everything to the grid»
|
||||
tells the truth about what it is going to do.
|
||||
|
||||
### The backdrop picture can be moved and scaled (docs/BACKDROP.md)
|
||||
|
||||
- **The plan image is no longer nailed down.** In the Background
|
||||
editor the picture gets a transform frame: drag it by its body to
|
||||
move it, pull a corner to resize it evenly (proportions kept, the
|
||||
opposite corner stays put). A live badge states the picture's real
|
||||
size in metres (or feet) while you drag, through the space's
|
||||
`cell_cm`. Position and size land on the grid; Shift steps off it.
|
||||
Nothing else moves — rooms, doors, devices and decor stay put — and
|
||||
there is no rotation.
|
||||
- Three new optional space fields: `plan_x`, `plan_y`, `plan_scale`.
|
||||
Their absence is exactly the old behaviour, so **existing plans
|
||||
render bit-identically and nothing is migrated**. «Вернуть картинку»
|
||||
in the toolbar clears them.
|
||||
- **The opaque plan sheet is now always the room contours.** It used
|
||||
to be the image rectangle whenever a picture was attached; now the
|
||||
picture is drawn ON the sheet — above it, below the walls. The
|
||||
scene colour (`bg_color`, the `daynight` sky) therefore reaches the
|
||||
exterior walls on an image plan too. Consequence, deliberate: a
|
||||
transparent picture over a space with no rooms drawn shows the
|
||||
scene background through itself.
|
||||
- «Вписать всё» counts the moved and scaled picture, so it can no
|
||||
longer be left off screen.
|
||||
|
||||
### Drawing tools no longer grab the shape under the cursor
|
||||
|
||||
- **A drawing tool owns the canvas.** With Line, Rectangle, Oval or
|
||||
Text picked in the decor editor, clicking on an existing figure now
|
||||
starts a NEW figure instead of selecting the old one — so a line can
|
||||
begin exactly on the end of another line, and a rectangle can be
|
||||
drawn on top of a filled one. Double-clicking a text under a drawing
|
||||
tool no longer opens it for editing either. The same inertness
|
||||
applies in the «Картинка-подложка» tool, where a decor shape lying
|
||||
over the plan must not block the picture's own drag.
|
||||
- «Выбрать» and «Стереть» are unchanged: shapes stay clickable there,
|
||||
which is where selecting and deleting belong.
|
||||
|
||||
### «Align everything to the grid» keeps its promise (AUD-158B1-01)
|
||||
|
||||
The action has no undo, so its confirmation dialog is the only safety
|
||||
there is — and it was understating the damage.
|
||||
|
||||
- **The maximum shift is measured on the geometry that is actually
|
||||
written**, not on an intermediate one. A rect was measured by its
|
||||
origin and its far corner only — so the minimum-size correction that
|
||||
widens a too-thin box afterwards went unmeasured, and the two corners
|
||||
that carry the X error of one side together with the Y error of the
|
||||
other were never looked at at all. An ordinary box could move √2
|
||||
times further than promised; a box thinner than one grid step, much
|
||||
further.
|
||||
- **Each space is converted through its own `cell_cm`.** The dialog
|
||||
used to take one normalised maximum and multiply it by the cell size
|
||||
of the FIRST space: a plan whose ground floor is drawn at 5 cm per
|
||||
cell and whose attic is at 100 cm promised 2.5 cm for a vertex that
|
||||
moved 50 cm. The report now carries the maximum in centimetres and
|
||||
the space it belongs to, and the dialog names that space when there
|
||||
is more than one.
|
||||
- The last tenth of a centimetre is rounded **up**: the promise can
|
||||
never be smaller than the deed.
|
||||
|
||||
### An opening whose only error is its angle can now be fixed (AUD-158B1-02)
|
||||
|
||||
- A window sitting exactly on its wall but holding a wrong `angle` used
|
||||
to come back as `changed: false` — the dialog said there was nothing
|
||||
to move and offered no button, while the returned plan differed from
|
||||
the one given. Such openings could never be aligned. The angle is
|
||||
part of the diff now; the dialog counts them separately, and the
|
||||
displacement is measured on the opening's ENDS, so turning it in
|
||||
place is not free in the report either. Turning an opening end over
|
||||
end (180°) is still counted as a correction but displaces nothing.
|
||||
|
||||
## v1.57.0 — 2026-08-04
|
||||
|
||||
Minor release: the canvas becomes infinite — no more "plan size", no
|
||||
more edge to run past — plus a tap action for curtains and blinds,
|
||||
a polish pass over the sun, live rulers while placing openings, and a
|
||||
warm re-mount that no longer flashes.
|
||||
|
||||
### The infinite canvas (docs/CANVAS.md)
|
||||
|
||||
The idea of a "grid size" or "plan size" is gone. The canvas is
|
||||
conceptually unbounded — any coordinate is legal, with a `+/-5000`
|
||||
limit kept only as garbage insurance (about 60 km of plan at the
|
||||
card's own scale).
|
||||
|
||||
- **If your plan ran off the edge, it now just works.** Some plans
|
||||
grew past the old square and devices simply could not be placed
|
||||
outside it; the only workaround was to redraw everything. That
|
||||
limit no longer exists: draw, drag and place devices anywhere, and
|
||||
nothing needs to be redrawn. Your stored plan is untouched — the
|
||||
coordinates mean exactly what they always meant, and there is no
|
||||
migration.
|
||||
- **Existing plans open exactly as before.** Verified on a real
|
||||
three-floor config: the opening frames come out bit-for-bit
|
||||
identical to v1.56.0.
|
||||
- **The opening view is derived from what is actually drawn.**
|
||||
`view_box` is now an optional hint for the very first frame only;
|
||||
after that the starting view is always computed from the real
|
||||
content, with outliers rejected so one stray room cannot shrink the
|
||||
whole plan.
|
||||
- **Pan at any zoom, in every editor.** Dragging the plan used to work
|
||||
only above 100% zoom; now it works at any zoom and in all editing
|
||||
modes, with one screen of slack around the content.
|
||||
- **Zoom out to 3x the content**, so you can see the whole plan and
|
||||
the space around it.
|
||||
- **"Fit everything" button** — one tap frames all the content again.
|
||||
- **A "home is that way" arrow** appears when you have panned away
|
||||
from the plan, pointing back to it.
|
||||
- **An adaptive grid.** The grid picks its step from the zoom
|
||||
(1 / 2 / 5 / 10 ... 1000) instead of a fixed cell, and the dots stay
|
||||
a muted hint rather than a pattern that competes with the plan.
|
||||
|
||||
### Curtains and blinds
|
||||
|
||||
- **New "Open/close" tap action for covers.** Closed opens, open
|
||||
closes, and a cover in motion stops. Offered only for the
|
||||
unprotected device classes — garage doors, doors and gates stay on
|
||||
the info dialog, on purpose.
|
||||
- **The icon itself tells you the state**: it morphs between the open
|
||||
and closed shape (a table of cover classes, plus aliases resolved
|
||||
from the base icon), and pulses with a soft ring while the cover is
|
||||
travelling.
|
||||
- **A curtain never wears a coloured plate.** No state fill behind a
|
||||
cover marker, ever.
|
||||
- **The cover is found among all of the marker's entities**, not just
|
||||
the primary one — which is the common case for Aqara and
|
||||
zigbee2mqtt devices — and an explicit "Open/close" choice always
|
||||
wins the indication.
|
||||
|
||||
### Sun polish
|
||||
|
||||
- **Brighter, shorter rays.** Opacity up to 0.30, length down 30%,
|
||||
with crisp sides again.
|
||||
- **Light fades only along the ray**, and is guaranteed to reach zero
|
||||
before the wedge ends — the wedge is now clipped by distance along
|
||||
the ray, so the "bright rim" that appeared with a low, oblique sun
|
||||
is gone.
|
||||
- **A hard 3 degree threshold.** The rays appear and disappear at 3
|
||||
degrees of solar elevation over a two-second fade, instead of
|
||||
creeping in.
|
||||
- **The day/night sky catches up with the sun** when you come back to
|
||||
the tab: a gap of more than 3 degrees is repainted at once, while
|
||||
normal movement keeps breathing smoothly.
|
||||
|
||||
### Everything else
|
||||
|
||||
- **Live rulers and the centre magnet while PLACING an opening**, not
|
||||
only while dragging one — with the distances measured along the
|
||||
owning room's own edge.
|
||||
- **A warm re-mount.** When Lovelace recreates the card on reconnect
|
||||
there is no preloader and no flash of the plan any more, and a
|
||||
dropped WebSocket never blanks a plan that is already on screen.
|
||||
- **Room borders and names default to dark grey `#55606c`** instead of
|
||||
the accent blue. If you picked a colour explicitly, nothing changes.
|
||||
- **An "About" block at the end of the general settings** — card
|
||||
version, GitHub and Telegram links.
|
||||
- **The icon angle now steps by 5 degrees.**
|
||||
- Audit fixes: the sun-ray cache survives local edits, hidden devices
|
||||
no longer stretch the frame, the editor's grown frame does not leak
|
||||
into view mode, an outlier room no longer inflates icon sizes, and a
|
||||
kiosk pan stays a pan instead of flipping to the next floor.
|
||||
|
||||
## v1.56.0 — 2026-08-03
|
||||
|
||||
Minor release: the sun comes to the floor plan — a compass, a day/night
|
||||
|
||||
@@ -6,6 +6,175 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.58.0 — 2026-08-04
|
||||
|
||||
Минорный релиз: картинка-подложка становится объектом, который можно
|
||||
двигать и масштабировать, непрозрачный лист плана переопределён как
|
||||
контуры комнат, рисующие инструменты декора перестают перехватывать
|
||||
клик по чужой фигуре, а «Выровнять всё по сетке» честно говорит, что
|
||||
собирается сделать.
|
||||
|
||||
### Картинку-подложку можно двигать и масштабировать (docs/BACKDROP.md)
|
||||
|
||||
- **Картинка плана больше не прибита гвоздями.** В редакторе подложки
|
||||
у неё появляется рамка трансформации: тянешь за тело — двигается,
|
||||
тянешь за угол — меняет размер равномерно (пропорции сохраняются,
|
||||
противоположный угол стоит на месте). Пока тянешь, плашка пишет
|
||||
реальный размер картинки в метрах (или футах) — через `cell_cm`
|
||||
пространства. Положение и размер ложатся на сетку, Shift снимает
|
||||
привязку. Больше ничего не двигается: комнаты, двери, устройства и
|
||||
декор остаются на месте; поворота нет.
|
||||
- Три новых необязательных поля пространства: `plan_x`, `plan_y`,
|
||||
`plan_scale`. Их отсутствие — ровно прежнее поведение, поэтому
|
||||
**старые планы рисуются один в один и ничего не мигрируется**.
|
||||
Кнопка «Вернуть картинку» в панели их удаляет.
|
||||
- **Непрозрачный лист-бумага теперь всегда строится по контурам
|
||||
комнат.** Раньше при картинке бумагой был её прямоугольник; теперь
|
||||
картинка рисуется НА бумаге — над ней и под стенами. Цвет сцены
|
||||
(`bg_color`, небо `daynight`) поэтому доходит до наружных стен и на
|
||||
плане с картинкой. Осознанное следствие: у прозрачной картинки без
|
||||
нарисованных комнат сквозь неё виден фон сцены.
|
||||
- «Вписать всё» учитывает сдвинутую и отмасштабированную картинку —
|
||||
потерять её за экраном больше нельзя.
|
||||
|
||||
### Рисующие инструменты больше не хватают фигуру под курсором
|
||||
|
||||
- **Рисующий инструмент владеет холстом.** Когда в редакторе декора
|
||||
выбрана Линия, Прямоугольник, Овал или Текст, клик по существующей
|
||||
фигуре начинает НОВУЮ фигуру, а не выделяет старую — поэтому линию
|
||||
можно начать ровно на конце другой линии, а прямоугольник нарисовать
|
||||
поверх залитого. Двойной клик по тексту под рисующим инструментом
|
||||
тоже больше не открывает его на правку. Та же инертность работает в
|
||||
инструменте «Картинка-подложка»: лежащая поверх плана фигура декора
|
||||
не должна мешать тащить саму картинку.
|
||||
- «Выбрать» и «Стереть» не изменились: там фигуры по-прежнему
|
||||
кликабельны — выделять и удалять положено именно ими.
|
||||
|
||||
### «Выровнять всё по сетке» держит обещание (AUD-158B1-01)
|
||||
|
||||
У действия нет отмены, поэтому диалог подтверждения — единственная
|
||||
защита, а он занижал масштаб происходящего.
|
||||
|
||||
- **Максимальный сдвиг измеряется по той геометрии, которая
|
||||
записывается**, а не по промежуточной. У прямоугольника мерились
|
||||
только исходный угол и дальний — поэтому доводка до минимального
|
||||
размера, которая потом раздвигает слишком узкую коробку, в замер не
|
||||
попадала, а два угла, где ошибка по X одной стороны складывается с
|
||||
ошибкой по Y другой, не смотрели вовсе. Обычная коробка могла
|
||||
сдвинуться в √2 раза дальше обещанного, а коробка тоньше шага сетки —
|
||||
намного дальше.
|
||||
- **Каждое пространство переводится через свой `cell_cm`.** Диалог брал
|
||||
один нормализованный максимум и умножал его на размер клетки ПЕРВОГО
|
||||
пространства: на плане, где первый этаж нарисован по 5 см на клетку, а
|
||||
мансарда по 100 см, обещали 2,5 см вершине, которая уезжала на 50 см.
|
||||
Отчёт теперь несёт максимум в сантиметрах и пространство, которому он
|
||||
принадлежит, а диалог называет это пространство, если их больше
|
||||
одного.
|
||||
- Последняя десятая сантиметра округляется **вверх**: обещание не может
|
||||
оказаться меньше сделанного.
|
||||
|
||||
### Проём, у которого неверен только угол, теперь можно исправить (AUD-158B1-02)
|
||||
|
||||
- Окно, стоящее ровно на своей стене, но с неправильным `angle`,
|
||||
возвращалось с `changed: false` — диалог писал «двигать нечего» и не
|
||||
показывал кнопку, хотя возвращённый план отличался от поданного.
|
||||
Такие проёмы нельзя было выровнять никогда. Угол теперь входит в
|
||||
разницу; диалог считает такие проёмы отдельно, а смещение меряется по
|
||||
КОНЦАМ проёма, так что поворот на месте в отчёте тоже не бесплатен.
|
||||
Разворот проёма на 180° по-прежнему считается исправлением, но ничего
|
||||
не смещает.
|
||||
|
||||
## v1.57.0 — 2026-08-04
|
||||
|
||||
Минорный релиз: холст становится бесконечным — больше нет «размера
|
||||
плана» и края, за который нельзя выйти, — плюс тап-действие для штор и
|
||||
жалюзи, полировка солнца, живые линейки при размещении проёмов и
|
||||
тёплый ре-маунт без миганий.
|
||||
|
||||
### Бесконечный холст (docs/CANVAS.md)
|
||||
|
||||
Понятие «размер сетки» или «размер плана» упразднено. Холст условно
|
||||
бесконечен — допустима любая координата, а предел `±5000` оставлен
|
||||
только как страховка от мусора (около 60 км плана в масштабе самой
|
||||
карточки).
|
||||
|
||||
- **Если ваш план «уехал» за границу — теперь он просто работает.**
|
||||
Некоторые планы выросли за пределы старого квадрата, и устройства
|
||||
снаружи было не поставить; единственным выходом было перерисовать
|
||||
всё заново. Этого ограничения больше нет: рисуйте, двигайте и
|
||||
ставьте устройства где угодно, перерисовывать ничего не нужно. Сам
|
||||
сохранённый план не тронут — координаты значат ровно то же, что и
|
||||
раньше, никакой миграции.
|
||||
- **Существующие планы открываются в точности как прежде.** Проверено
|
||||
на реальном конфиге с тремя этажами: стартовые рамки совпадают с
|
||||
v1.56.0 бит-в-бит.
|
||||
- **Стартовый вид строится по тому, что реально нарисовано.**
|
||||
`view_box` стал необязательной подсказкой только для самого первого
|
||||
кадра; дальше вид всегда считается по фактическому содержимому, с
|
||||
отбраковкой выбросов — одна случайная комната больше не сжимает
|
||||
весь план.
|
||||
- **Панорамирование при любом зуме и во всех редакторах.** Раньше
|
||||
план можно было тащить только выше 100%; теперь — на любом
|
||||
масштабе и в любом режиме правки, с запасом в один экран вокруг
|
||||
содержимого.
|
||||
- **Зум-аут до 3× содержимого** — видно и весь план, и воздух вокруг
|
||||
него.
|
||||
- **Кнопка «Вписать всё»** — одно нажатие возвращает кадр по всему
|
||||
содержимому.
|
||||
- **Стрелка «дом там»** появляется, когда вы уехали от плана, и
|
||||
показывает дорогу обратно.
|
||||
- **Адаптивная сетка.** Шаг сетки выбирается по зуму
|
||||
(1 / 2 / 5 / 10 … 1000) вместо фиксированной клетки, а точки
|
||||
остаются приглушённой подсказкой и не спорят с планом.
|
||||
|
||||
### Шторы и жалюзи
|
||||
|
||||
- **Новое тап-действие «Открыть/закрыть» для штор.** Закрытая —
|
||||
откроется, открытая — закроется, а штора в движении остановится.
|
||||
Предлагается только для незащищённых классов: гаражные ворота,
|
||||
двери и калитки намеренно остаются на информационном диалоге.
|
||||
- **Состояние видно по самому значку**: он морфится между открытой и
|
||||
закрытой формой (таблица классов штор плюс алиасы по базовой
|
||||
иконке) и мягко пульсирует кольцом, пока штора едет.
|
||||
- **У шторы никогда нет цветной подложки.** Заливки по состоянию за
|
||||
маркером cover не будет.
|
||||
- **Cover ищется среди всех сущностей маркера**, а не только среди
|
||||
основной — частый случай для устройств Aqara и zigbee2mqtt, — а
|
||||
явно выбранное «Открыть/закрыть» всегда имеет приоритет индикации.
|
||||
|
||||
### Полировка солнца
|
||||
|
||||
- **Лучи ярче и короче.** Непрозрачность поднята до 0.30, длина
|
||||
уменьшена на 30%, боковые грани снова чёткие.
|
||||
- **Свет затухает только вдоль луча** и гарантированно доходит до
|
||||
нуля раньше конца клина — клин теперь обрезается по расстоянию
|
||||
вдоль луча, и «светлый бортик», вылезавший на низком косом солнце,
|
||||
исчез.
|
||||
- **Жёсткий порог в 3°.** Лучи появляются и исчезают на трёх градусах
|
||||
высоты солнца с двухсекундной анимацией, а не вкатываются плавно.
|
||||
- **Фон «день/ночь» догоняет солнце** при возврате на вкладку: разрыв
|
||||
больше 3° перекрашивается мгновенно, обычный ход по-прежнему дышит
|
||||
плавно.
|
||||
|
||||
### Прочее
|
||||
|
||||
- **Живые линейки и магнит центра теперь и при РАЗМЕЩЕНИИ проёма**, а
|
||||
не только при перетаскивании; расстояния считаются по ребру своей
|
||||
комнаты.
|
||||
- **Тёплый ре-маунт.** Когда Lovelace пересоздаёт карточку при
|
||||
реконнекте, прелоадер больше не показывается и план не мигает, а
|
||||
обрыв WebSocket никогда не очищает уже показанный план.
|
||||
- **Цвет границ и названий комнат по умолчанию — тёмно-серый
|
||||
`#55606c`** вместо синего акцента. Если цвет выбран явно, ничего не
|
||||
меняется.
|
||||
- **Блок «О карточке» в конце общих настроек** — версия, ссылки на
|
||||
GitHub и Telegram.
|
||||
- **Шаг угла значка — 5°.**
|
||||
- Находки аудитов: кэш солнечных лучей переживает локальные правки,
|
||||
скрытые устройства не растягивают кадр, кадр редактора не протекает
|
||||
в режим просмотра, комната-выброс не раздувает размер иконок, а
|
||||
киоск-пан остаётся паном и не перелистывает этаж.
|
||||
|
||||
## v1.56.0 — 2026-08-03
|
||||
|
||||
Минорный релиз: на план приходит солнце — компас, фон «день/ночь» и
|
||||
|
||||
@@ -51,6 +51,15 @@ the old behaviour until an editing client materialises it.
|
||||
- "Show hidden devices" (rename of "Show all") is LOCAL, ephemeral state of
|
||||
the current tab.
|
||||
- Room LQI counts hidden devices (owner's decision).
|
||||
- Hidden devices are NOT content for the CONTENT FRAME (docs/CANVAS.md §4,
|
||||
audit DEV-2C947-01). The frame is presentation: an object the plan does not
|
||||
draw may not decide what the plan opens on. Hiding a marker that had once
|
||||
been dragged into the yard used to leave the visible house a dot in the
|
||||
corner of a frame 112x too wide — on the full card and on
|
||||
`houseplan-space-card` alike. They keep their place in the auto-grid roster
|
||||
(so a visible neighbour does not move when one is hidden) and in every
|
||||
aggregation listed here; only the frame stops seeing them, ghosts in the
|
||||
device editor included — reaching a ghost is the pan slack's job (§5).
|
||||
- Light fill and glow do NOT count hidden devices — an invisible device casts
|
||||
no visible light (owner's decision). Room climate is registry-wide and
|
||||
unaffected, as before.
|
||||
@@ -59,3 +68,89 @@ the old behaviour until an editing client materialises it.
|
||||
one way to hide); a virtual device's "Delete" remains a real deletion.
|
||||
- Duplicate names are still numbered, light groups still fold — those are
|
||||
aggregation, not hiding.
|
||||
|
||||
## What a marker SHOWS
|
||||
|
||||
A marker's live indication — the yellow «on» plate, the «open» frame (never
|
||||
on a cover, see below), the breathing `covermove` ring, the state-morphed
|
||||
icon, the ripple — speaks for ONE entity of the device, resolved in this
|
||||
order (`_stateClass` / `_actEntity`):
|
||||
|
||||
1. the device's **cover**, when the marker's tap action is explicitly
|
||||
«Открыть/закрыть» (`tap_action: 'cover'` — `coverEntityOf`, the same helper
|
||||
and the same entity the tap drives). It wins over EVERYTHING below;
|
||||
2. the marker's bound **controls**, if it has any (a stateless remote or a
|
||||
virtual wall switch mirrors what it drives, not itself);
|
||||
3. a **lit light** among its entities (owner's principle 2026-07-29: the glow
|
||||
spot and the badge may never disagree);
|
||||
4. otherwise the **primary** entity (`primaryEntity`).
|
||||
|
||||
Rule 1 was added 2026-08-04 on the owner's report: his Aqara «Roller shade
|
||||
driver E1» curtains ship the `cover.*` hidden by the integration and a visible
|
||||
`switch.*_reverse_direction`, so `primaryEntity` picked the service switch —
|
||||
the plan showed no ring while a curtain travelled, no `curtains` /
|
||||
`curtains-closed` morph, and a yellow «включено» plate whenever the
|
||||
reverse-direction option happened to be on. The tap had already been
|
||||
taught to find the cover among ALL the device's entities (2026-08-04, the
|
||||
same `coverEntityOf`); the indication now follows it.
|
||||
|
||||
**Why the cover is FIRST and not third** (audit DEV-1DA1-01, fixed the same
|
||||
day). It went in below `controls` and the lit light at first, and that left
|
||||
the contract below («у штор не должно быть жёлтой подложки НИКОГДА») with two
|
||||
holes big enough to walk through: a mixed device — a lamp that also ships a
|
||||
blind — told «Открыть/закрыть» went yellow off its own lit `light.*`, and a
|
||||
curtain marker with a bound wall switch went yellow off `controls`. In both
|
||||
the early `return 'on'` never reached the cover branch, so the travelling
|
||||
curtain also lost its breathing ring, and in glow fill (where the renderer
|
||||
strips `on` from a shining source) it was left with no indicator at all —
|
||||
while the tap still drove the cover. A rule that «шторы никогда не жёлтые»
|
||||
cannot have exceptions decided by the neighbours in the entity list.
|
||||
|
||||
**Why it hangs on the explicit action and not on «the device has a cover».**
|
||||
Choosing «Открыть/закрыть» in the marker dialog is the only statement the card
|
||||
has that means *this marker IS the curtain* — and the dialog offers that option
|
||||
for exactly the devices where a cover exists. Tying the indication to it keeps
|
||||
one answer to «what is this marker»: the option offered, the entity tapped and
|
||||
the state shown are the same entity, decided in one place. Nothing changes
|
||||
behind the user's back for a mixed device (a lamp that also owns a blind, a TRV
|
||||
with a service switch) that was NOT told it is a curtain: it keeps its primary
|
||||
and the old 2–3–4 precedence, controls and lit light included, until its owner
|
||||
says otherwise. Two edges follow from the wording: a marker set to
|
||||
«Открыть/закрыть» whose device carries no `cover.*` at all falls through to
|
||||
rules 2–4 (the statement is only as strong as the entity behind it), and a
|
||||
curtain left on «Инфо-карточка» still indicates its primary — one click in the
|
||||
dialog away, and the honest reading of what the marker was told it is.
|
||||
|
||||
### A cover is never painted (owner 2026-08-04)
|
||||
|
||||
«У штор не должно быть жёлтой подложки НИКОГДА, индикация открыто/закрыто за
|
||||
счёт морфинга иконки.» For the `cover` domain — and for the cover an
|
||||
«Открыть/закрыть» marker indicates, rule 3 above — `_stateClass` returns no
|
||||
plate class in any state:
|
||||
|
||||
| cover state | plate | ring | icon |
|
||||
|---|---|---|---|
|
||||
| `closed` | neutral | — | closed glyph |
|
||||
| `open`, ajar (`open` + position) | neutral | — | open glyph |
|
||||
| `opening`, `closing` | neutral | `.covermove` breathes | open glyph |
|
||||
| `unknown` / no state | neutral | — | base icon, no morph |
|
||||
| `unavailable` | neutral, faded (`.unavail`) | — | base icon |
|
||||
|
||||
Until this the domain shared one branch with `valve` and wore `.dev.open` —
|
||||
an orange FILLED badge (`--hp-open`), not a mere border — while open or
|
||||
opening. The open/closed story is now told by the icon alone (`stateIcon` /
|
||||
`COVER_ICONS`), so the morph has to be exhaustive: every device class maps
|
||||
its two states to two DIFFERENT glyphs, and a cover with no `device_class` at
|
||||
all (z2m ships plenty) morphs within the family of its own base icon —
|
||||
`mdi:roller-shade` (what the name rule «штор|curtain|blind|shade» hands out),
|
||||
`mdi:garage-variant`, `mdi:blinds-horizontal`, `mdi:door`. The one place a
|
||||
hand-picked icon is not final: a cover whose custom icon IS one of those pair
|
||||
members morphs inside that pair — never traded for another family — because
|
||||
otherwise choosing an icon would silently switch the marker's only indicator
|
||||
off.
|
||||
|
||||
WHAT KEEPS THE FRAME. `.dev.open` is untouched everywhere else: door / window
|
||||
/ garage_door / opening binary sensors, an unlocked `lock`, and `valve`. A
|
||||
valve is deliberately left out of the owner's rule — no icon pair morphs for
|
||||
it, so the frame is the only thing it has to say «открыт» with. If the owner
|
||||
ever wants the two domains to read alike, a valve needs an icon pair first.
|
||||
|
||||
+5
-4
@@ -11,16 +11,17 @@
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-08-03)
|
||||
## Snapshot (2026-08-04)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.56.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance. Minor release: the Sun feature (docs/SUN.md — north compass, day/night backdrop, window light wedges, optional weather cloud cover, opaque room-contour paper), opt-in room temperature from climate devices, design-token pass over styles.ts + native ha-switch/ha-slider, opening-drag shoulder rulers with a center magnet, and the DEV-B701-01 sun-ray cache fix |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| Version | **v1.58.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable release, tag `v1.58.0` on `main`, GitHub Release with `prerelease=false`; **no direct deploy** — the home instance picks it up through HACS by tag. Contents: the backdrop picture becomes a movable/scalable object (docs/BACKDROP.md — transform frame in the «Картинка-подложка» tool, even corner scaling, live size in metres, grid snap with a Shift bypass, new optional fields `plan_x`/`plan_y`/`plan_scale`, counted by "fit everything"); **NEW PAPER RULE — the opaque plan sheet is always the room contours**, the picture is drawn on top of the sheet and under the geometry (the "paper = image rectangle" branch is gone); decor drawing tools (line/rect/oval/text) and the backdrop tool no longer select the shape under the cursor — existing figures are inert there, select/erase unchanged; the two P2 fixes from the beta.1 audit of «Выровнять всё по сетке» — the confirmation now promises a true upper bound measured per space in its own `cell_cm` (AUD-158B1-01), and an opening whose only error is its angle is a real change that can be applied (AUD-158B1-02). Shipped through v1.58.0-beta.1 (pre-release, 2026-08-04). Previous stable: v1.57.0 "the infinite canvas" |
|
||||
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.56.0** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Sunlight that reads on a light plan — the spec
|
||||
|
||||
Status: **approved in principle, not implemented.** Planned for the release
|
||||
after v1.58.0. Owner asked for the spec first (2026-08-04); nothing in this
|
||||
document is code yet. Builds on docs/SUN.md, which describes what ships today.
|
||||
|
||||
## The problem
|
||||
|
||||
Today a lit window casts a warm translucent wedge (docs/SUN.md). It reads
|
||||
beautifully on a dark scene — glow fill, night, a dark theme — and almost
|
||||
disappears on a light one, which is now the common case: `bg_mode: daynight`
|
||||
makes a clear day **white**, and a hand-drawn plan's paper is white too.
|
||||
|
||||
The cause is not opacity, it is physics. Painting light means adding
|
||||
luminance, and white has none left to give. Raising the alpha does not add
|
||||
contrast, it only tints the paper towards beige until the whole room looks
|
||||
dirty. Any fix has to take its contrast from something other than brightness.
|
||||
|
||||
## The model: paint the shade, not the light
|
||||
|
||||
On a light scene the wedges invert. The lit areas stay untouched paper; the
|
||||
**rest of the room** gets a light, cool veil. Contrast now comes from the
|
||||
unlit part, which has plenty of room to go darker, and the wedge becomes a
|
||||
window of clean paper inside a softly shaded room.
|
||||
|
||||
This is how architects draw insolation on white sheets, and it stays
|
||||
physically honest: a sunlit room really is brighter where the shaft lands.
|
||||
It also rhymes with the mode we already have — glow paints a dark house with
|
||||
pools of lamp light; this paints a light house with the shade between shafts.
|
||||
|
||||
Geometrically it is the same wedge polygon, used as a hole: a veil rectangle
|
||||
over the room's polygon, minus the wedges (an even-odd path, or a mask). The
|
||||
maths of `computeSunRays()` does not change at all — only what we do with the
|
||||
result.
|
||||
|
||||
## Switching between the two
|
||||
|
||||
Automatic, by the luminance of what the wedge is drawn on (the paper under
|
||||
the room, or the scene colour where a picture backdrop shows through):
|
||||
|
||||
- luminance below the threshold → **light model** (today's warm wedge);
|
||||
- above it → **shade model** (veil with wedge-shaped holes).
|
||||
|
||||
The threshold sits around mid-grey; the exact value is a visual decision to be
|
||||
made against real plans, dark and light themes, and the day/night palette.
|
||||
Whether this is fully automatic or also offered as an explicit choice is an
|
||||
open question (below).
|
||||
|
||||
## Two amplifiers, both models
|
||||
|
||||
**A rim on the wedge.** Light is invisible but its boundary is not: a hairline
|
||||
warm stroke along the two side edges of the shaft, fading out with the shaft
|
||||
itself. It costs one stroke, works on any background, and gives the shape a
|
||||
"beam" reading even when the fill is nearly transparent. In the shade model
|
||||
the same rim marks the edge of the veil hole.
|
||||
|
||||
**A mark on the window.** Independent of the wedges: a lit window gets a
|
||||
thicker warm stroke and a short arrow along the sun direction. It answers the
|
||||
question "which windows is the sun in right now" without reading the floor,
|
||||
and it survives everything the wedge does not — furniture-heavy plans, room
|
||||
fills, small kiosk scale, a wedge clipped to almost nothing by a narrow room.
|
||||
|
||||
## Colour
|
||||
|
||||
The veil is cool and desaturated (a blue-grey), never black: a black veil
|
||||
reads as a printing defect and fights the room fills. Its opacity is small —
|
||||
the target is a perceptible step, not a dimmed room; the value is to be tuned
|
||||
visually, in the same order as the wedge alpha today.
|
||||
|
||||
The light model keeps its warm ramp near the horizon (docs/SUN.md), but should
|
||||
lean on **hue rather than luminance** on medium backgrounds: an amber tint
|
||||
reads as colour even where it cannot read as brightness.
|
||||
|
||||
## What must not break
|
||||
|
||||
- **Room fills.** Temperature and LQI fills carry meaning; the veil must not
|
||||
be mistaken for them, and must not shift their perceived colour more than
|
||||
marginally. The veil goes under the fills, not over.
|
||||
- **The 3° threshold and the two-second fade** (docs/SUN.md) apply unchanged
|
||||
to whichever model is active. Crossing the luminance threshold — a theme
|
||||
switch, day turning to night — must cross-fade between models, never pop.
|
||||
- **Cloudiness** keeps dimming the effect, in the shade model by thinning the
|
||||
veil.
|
||||
- **Editors** stay neutral, as today: no sun, no day/night.
|
||||
- **The static card and kiosk** must pick the same model as the full card on
|
||||
the same plan; the choice is a pure function of the background.
|
||||
- **prefers-reduced-motion**: no cross-fade, switch instantly.
|
||||
- **Performance**: one veil path per room per render at most, memoised on the
|
||||
same key as the wedges.
|
||||
|
||||
## Open questions for the owner
|
||||
|
||||
1. Automatic switching only, or also an explicit setting «свет / тень / авто»
|
||||
in the space dialog? (Automatic is one less knob; explicit lets someone on
|
||||
a light theme keep the warm wedges if they like them.)
|
||||
2. Does the veil cover the whole room, or only the part of the room the sun
|
||||
could reach at all (the room's window-facing side)? Whole-room is simpler
|
||||
and reads as "this room is in shade"; partial is subtler.
|
||||
3. Should the window mark be always on, or follow the same «Solar rays»
|
||||
toggle?
|
||||
|
||||
## Testing notes (for when this is built)
|
||||
|
||||
Unit: the model chooser is a pure function of a colour → assert the switch at
|
||||
the threshold, both sides, plus junk input. The veil-with-holes geometry:
|
||||
holes equal the wedges, an unlit room gets a plain veil, a room with no
|
||||
exterior window gets nothing.
|
||||
|
||||
Browser: on a white paper plan a lit wedge is measurably lighter than its
|
||||
surroundings (sample pixels inside and outside the wedge — the current
|
||||
implementation would fail this, which is the point); on a dark scene nothing
|
||||
changes versus today; crossing the threshold cross-fades; room fills keep
|
||||
their identity; the window mark appears exactly on lit windows.
|
||||
+150
-9
@@ -67,6 +67,28 @@ only — no entities are created, no services are called.
|
||||
daytime room fills stay readable. Transitions are a CSS
|
||||
background/filter transition tens of seconds long;
|
||||
`prefers-reduced-motion` gets the current colors statically.
|
||||
- **Glide, but never lag behind reality** (owner 2026-08-04: «цвет фона
|
||||
не меняется сам с течением времени суток, только после
|
||||
обновления страницы»). The sky colour and the plan dimming are
|
||||
delivered by a 45 s CSS transition, and a CSS transition only advances
|
||||
while the card is being PAINTED. A card that was not painting — a
|
||||
background tab, another dashboard view, a sleeping wall tablet, an
|
||||
editor session — comes back holding a stale sky and then crawls toward
|
||||
the truth 45 s at a time; a page reload, by contrast, paints the right
|
||||
colour outright, because a freshly mounted element has nothing to
|
||||
transition FROM. So the card measures the gap: HA refreshes `sun.sun`
|
||||
every ~4 minutes by day, i.e. ≤1° per update, and anything from
|
||||
`SKY_SNAP_DEG` = 3° up therefore means "we were not watching". Such a
|
||||
step is applied with `transition: none` for a single frame
|
||||
(`.stage.daynight.skysnap`, released on the next
|
||||
`requestAnimationFrame`); everything smaller keeps the 45 s breathing.
|
||||
`visibilitychange → visible` arms the catch-up outright.
|
||||
- The elevation the sky is computed from is rounded to 0.1°
|
||||
(`skyElevation()`) — finer than the eye can tell across a 45 s glide,
|
||||
and it keeps `dayPhase` (and the style attribute lit has to commit)
|
||||
from churning on every `hass` tick. The wedge GEOMETRY keeps its own,
|
||||
coarser memo: the two have deliberately different granularity — the
|
||||
sky is cheap, the polygon clipping is not.
|
||||
- The UI is a two-option selector; the color picker shows only for
|
||||
`'static'`.
|
||||
- Backend validation: `In(['static', 'daynight'])` at both levels.
|
||||
@@ -102,18 +124,123 @@ when BOTH hold:
|
||||
|
||||
- the sun is above the horizon (`elevation > 0`), and
|
||||
- the dot product of the wall's outward normal with the direction
|
||||
toward the sun is positive (the sun actually faces this window).
|
||||
toward the sun — the cosine of the angle of incidence — is above
|
||||
`RAY_MIN_COS` = 0.05, i.e. the sun faces this window AND clears the
|
||||
plane of its wall by ~2.9° (~87.1° of incidence). Below that there is
|
||||
nothing to paint: glass reflects almost all of it, and the shaft's
|
||||
perpendicular depth (`len · cos`, see «Dissolving») would be thinner
|
||||
than the wall it came through.
|
||||
|
||||
The wedge is a quadrilateral cast from the window's span along the
|
||||
direction AWAY from the sun (light falls inward), clipped by the
|
||||
The wedge is a PARALLELOGRAM: the window's span extruded by the same
|
||||
length along the direction AWAY from the sun (light falls inward), so
|
||||
its far edge is parallel to the wall, clipped by the
|
||||
room's polygon (`polyclip` intersection, the same dependency
|
||||
`src/resize.ts` already uses). Its length is `k(elevation)` in window
|
||||
lengths: ~2.5 at sunrise/sunset tapering to ~0.8 at the zenith
|
||||
(`0.8 + 1.7·(1 − elevation/90)^1.6`). A linear gradient runs bright at
|
||||
the window and dissolves inward; the color is warm orange while
|
||||
`elevation < 10°` and neutral by day; peak opacity is modest (~0.18 —
|
||||
two overlapping wedges never exceed a readable ceiling). Near the
|
||||
horizon the opacity ramps in over the first ~2° so wedges never pop.
|
||||
lengths: ~1.75 at sunrise/sunset tapering to ~0.56 at the zenith
|
||||
(`0.56 + 1.19·(1 − elevation/90)^1.6` — the v1.56 curve
|
||||
`0.8 + 1.7·(1 − elevation/90)^1.6` times `RAY_LENGTH_K` = 0.7, owner
|
||||
2026-08-04: «лучи от солнца сделать короче на 30%»; scaling the whole
|
||||
curve keeps the "a low sun reaches much further" shape intact). The
|
||||
color is warm orange while `elevation < 10°` and neutral by day; peak
|
||||
opacity is `RAY_MAX_ALPHA` = 0.30 (owner 2026-08-03: «лучи поярче,
|
||||
иногда плохо видны» — raised from 0.18; two overlapping wedges
|
||||
still stay under a readable ceiling on white paper and on the dark glow
|
||||
canvas alike).
|
||||
|
||||
### Dissolving — along the ray only (owner 2026-08-04)
|
||||
|
||||
Two rounds with the owner on the same day:
|
||||
|
||||
1. «Проверить, чтобы они всегда плавно рассеивались (сейчас есть
|
||||
ощущение, что они упираются во что-то невидимое)» — the wedge was
|
||||
ending on a visible line;
|
||||
2. «С лучами солнца ты сделал фигню — не надо размывать их боковые
|
||||
грани» — the first answer to (1) was a Gaussian blur over the whole
|
||||
wedge, which feathered the SIDES too. Wrong: a shaft of sunlight
|
||||
through a window has crisp sides. Only its reach fades.
|
||||
|
||||
So the falloff is one-dimensional: **along the ray, from the glass
|
||||
inward, and nothing else.** Three invariants have to hold at once:
|
||||
|
||||
1. the whole pane of glass is at peak alpha — light does not start out
|
||||
half-dark at one end of the window;
|
||||
2. every ray fades over the same distance, its own `len`;
|
||||
3. the wedge's far edge lies exactly on an iso-alpha line, so the shaft
|
||||
dies of its gradient and never of its own outline (that visible
|
||||
straight «bright kerb» hanging in mid-floor).
|
||||
|
||||
**The axis of the fade is the wall's INWARD NORMAL, not the ray.**
|
||||
The light is a bundle of PARALLEL rays, so the distance a point has
|
||||
travelled from the glass is `depth / cos`, where `depth` is its
|
||||
perpendicular distance from the wall and `cos = dir·normal` is fixed
|
||||
for the whole wedge. That is an affine function of the point, and its
|
||||
level sets are straight lines PARALLEL TO THE WALL. A linear gradient
|
||||
whose axis is the normal therefore describes the travelled distance
|
||||
exactly:
|
||||
|
||||
- `x1,y1` = the middle of the window span (any point of the glass —
|
||||
they all have depth 0);
|
||||
- `x2,y2` = that point plus `normal · len · cos` — `SunRay.depth`, the
|
||||
perpendicular depth a ray reaches after running the full `len`;
|
||||
- a point `source + dir·u` lands on offset `u / len`, whichever ray it
|
||||
rode in on.
|
||||
|
||||
Hence: the glass is all at offset 0 (invariant 1), the alpha at any
|
||||
point is a function of how far its own ray has run (invariant 2), and
|
||||
the parallelogram's far edge — parallel to the wall — IS the gradient's
|
||||
last iso-alpha line (invariant 3). The «30 % shorter» reach is then a
|
||||
fact about every SIDE of every wedge, at any sun angle.
|
||||
|
||||
The stops (`rayStops()`) ease out to **zero at `RAY_FADE_END` = 85 %**
|
||||
of the axis: `1 → .86 → .60 → .32 → .10 → 0`. The last 15 % of every
|
||||
wedge is guaranteed empty, so a shaft that ends in mid-air has nothing
|
||||
left to draw an edge with.
|
||||
|
||||
> **DEV-EB173-01 (fixed).** The previous attempt kept the gradient along
|
||||
> `dir` from the span's midpoint and bent the GEOMETRY to match,
|
||||
> extruding the two ends of the window by different amounts so both far
|
||||
> corners projected onto the same point of that axis. It bought
|
||||
> invariant 3 with the other two: at a grazing sun the ends of the glass
|
||||
> themselves sat at offsets ±0.879 — one of them fully transparent
|
||||
> before the shaft even started — and the two sides came out 5.41 and
|
||||
> 84.19 long (ratio 15.6), the long one 31 % LONGER than the pre-cut 64
|
||||
> rather than 30 % shorter. One linear gradient along the ray cannot
|
||||
> satisfy all three; along the normal it satisfies all three by
|
||||
> construction.
|
||||
|
||||
- the two SIDES carry no falloff at all, on purpose. They are hard
|
||||
lines, because that is what light through a window looks like. There
|
||||
is **no filter, no `feGaussianBlur`, no `clip-path`** anywhere in the
|
||||
sun layer — the polygons arrive from `computeSunRays()` already
|
||||
intersected with the room, so a wall stops the light by geometry;
|
||||
- where the room outline does cut a still-lit shaft (the opposite wall,
|
||||
the inner corner of an L, an OPEN boundary) the edge stays crisp:
|
||||
that is light landing on a wall, and blurring it was the mistake.
|
||||
|
||||
Clipping by the room is unchanged; only the visible edge changed.
|
||||
|
||||
### The 3° threshold and the 2-second fade
|
||||
|
||||
Wedge opacity does NOT depend on elevation any more — the old ramp-in
|
||||
over the first ~2° is gone. The contract (owner 2026-08-03) is a hard
|
||||
threshold:
|
||||
|
||||
- `elevation < 3°` → NO rays at all;
|
||||
- `elevation ≥ 3°` → rays at full strength (`rayPeakAlpha`, cloud cover
|
||||
being the only multiplier).
|
||||
|
||||
Crossing the threshold is animated, but on the LAYER, never on the
|
||||
geometry: the `<g class="sunlayer">` fades in with `hp-sunfade-in` and
|
||||
out with `hp-sunfade-out`, both exactly 2 s (`RAY_FADE_MS` in
|
||||
`src/sun.ts` must stay in sync with `styles.ts`). To let the fade-out
|
||||
play at all, the card keeps the layer mounted with `.out` for those two
|
||||
seconds and only then drops it. `prefers-reduced-motion: reduce` skips
|
||||
the animation entirely — the rays are simply there or simply gone.
|
||||
|
||||
Everything else that removes wedges — leaving view mode, switching the
|
||||
feature off, night (`elevation ≤ 0`), rain — is instant: those are not
|
||||
threshold crossings, and a wedge lingering while you enter the editor
|
||||
would just be a bug.
|
||||
|
||||
Layer order: ABOVE room fills (and the glow layer), BELOW devices and
|
||||
labels (those live in the HTML `devlayer` anyway). Night
|
||||
@@ -165,3 +292,17 @@ Backend validation: string or null.
|
||||
- `custom_components/houseplan/validation.py` — the four settings at
|
||||
both levels; tests in `tests_backend/test_validation.py`.
|
||||
- `demo/smoke_sun.mjs` — end-to-end behaviour against the demo rig.
|
||||
- `demo/smoke_sun_soft.mjs` — the −30 % reach and the "dissolves along
|
||||
the ray only" contract: the gradient axis is the wall normal and is
|
||||
`len · cos` long, an offset is exactly how far a ray has run, the
|
||||
stops die at 85 %, the sides are sharp (no filter on the wedge, no
|
||||
`feGaussianBlur` at all), and at an oblique sun nothing is drawn past
|
||||
the end of the gradient — the kerb cannot come back. It re-runs the
|
||||
DEV-EB173-01 grazing repro end to end (west window 80, elevation 90,
|
||||
azimuth 190): equal sides of the nominal length, peak alpha at BOTH
|
||||
ends of the glass, and no wedge at all below `RAY_MIN_COS`.
|
||||
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
|
||||
`hass` tick with no reload, asserted on the COMPUTED background of the
|
||||
stage; small steps still glide, big ones catch up at once.
|
||||
- `demo/shot_sun_short.mjs` — stills at a low and a high sun
|
||||
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).
|
||||
|
||||
+274
@@ -128,6 +128,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
perpendicular dashed tick appears and the center magnet-snaps — Shift
|
||||
disables the magnet; badges and tick vanish on release
|
||||
[auto: smoke_opening_measure + unit openingShoulders]
|
||||
- [ ] The SAME rulers while PLACING a new opening (2026-08-03): with the
|
||||
Opening tool, moving along a wall shows the dashed ghost together with a
|
||||
badge on each shoulder of the would-be opening (default 90 cm, measured
|
||||
on the snapped room's OWN edge), a perpendicular tick + magnet at that
|
||||
edge's centre, Shift opting out; the click places the opening at the
|
||||
magnetised point and ghost, badges and tick all disappear at once
|
||||
[auto: smoke_opening_measure, the «PLACING a new opening» section]
|
||||
|
||||
## Onboarding ★
|
||||
|
||||
@@ -269,6 +276,68 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
device classes (explicit per-device toggle still works for them)
|
||||
[auto: smoke_tap_run + unit resolveTapAction/runServiceFor + backend
|
||||
test_run_target_is_bounded_to_runnable_domains]
|
||||
- [ ] Tap opens/closes a cover (dev, owner's spec 2026-08-03): the tap-action
|
||||
list gains "Open/close (curtains/blinds)" — offered ONLY when the binding
|
||||
has a cover entity, and never for the guarded classes garage/door/gate
|
||||
(a value smuggled into the config there degrades to the info card).
|
||||
closed -> cover.open_cover, open (incl. ajar) -> cover.close_cover,
|
||||
opening/closing -> cover.stop_cover, no readable state -> cover.toggle;
|
||||
the «ask for confirmation» checkbox guards it like toggle/run.
|
||||
Indication: while travelling the icon breathes a soft yellow ring
|
||||
(.covermove, 2.2s, static under prefers-reduced-motion) and the plate
|
||||
stays NEUTRAL in EVERY state (2026-08-04, see the next item); the icon
|
||||
morphs by state + device_class (blinds/shutter/curtain…), unknown state
|
||||
morphs nothing; no position percentages anywhere
|
||||
[auto: smoke_cover_tap + units resolveTapAction/coverService/stateIcon +
|
||||
backend test_cover_tap_action_is_accepted]
|
||||
- [ ] A cover is NEVER painted (dev, owner 2026-08-04): «у штор не должно быть
|
||||
жёлтой подложки никогда, индикация открыто/закрыто за счёт морфинга
|
||||
иконки». Walk one curtain through closed / open / ajar / opening /
|
||||
closing: the plate is the plain neutral badge every time — never the
|
||||
yellow «включено» one, never the orange «открыто» frame it used to wear
|
||||
while open — the icon is the only open/closed signal, and the breathing
|
||||
.covermove ring appears in the two travelling states and nowhere else.
|
||||
The morph is exhaustive: every device class gives two DIFFERENT glyphs
|
||||
(awning included), a cover with no device_class morphs within its own
|
||||
auto-icon family (mdi:roller-shade, mdi:garage-variant), a hand-picked
|
||||
icon morphs only inside the pair it was picked from, and an
|
||||
unknown/unavailable state morphs nothing. NOT touched: an open door /
|
||||
window binary sensor, an unlocked lock and an open valve still wear the
|
||||
orange «открыто» frame (a valve has no icon pair, so the frame is all it
|
||||
has) [auto: smoke_cover_no_plate + unit stateIcon «every class, both
|
||||
ways»]
|
||||
- [ ] Open/close works when the cover is NOT the primary entity (dev, owner's
|
||||
report 2026-08-04): a curtain driver that ships its `cover.*` hidden by
|
||||
the integration next to a visible `switch.*_reverse_direction` (Aqara
|
||||
E1 — his own) has a SWITCH for a primary; picking «Open/close» in the
|
||||
dialog, saving it and tapping the marker sends `cover.open_cover` to the
|
||||
cover, never touches the service switch and never falls back to the info
|
||||
card. The guarded class is read off that same cover, so a garage still
|
||||
degrades to info and is still not offered in the dialog
|
||||
[auto: smoke_cover_not_primary + unit coverEntityOf]
|
||||
- [ ] Curtain INDICATION follows the same cover (dev, owner 2026-08-04): with
|
||||
«Open/close» chosen on that same Aqara marker, the plan shows the cover
|
||||
and not the service switch — the breathing ring while it travels
|
||||
(`covermove`, opening AND closing), the `mdi:curtains-closed` /
|
||||
`mdi:curtains` morph, a neutral plate throughout (the «открыто» frame
|
||||
was retired for covers later the same day), and no yellow plate when
|
||||
`switch.*_reverse_direction` happens to be on. The rule is exactly
|
||||
the explicit action (docs/FILTERING.md «What a marker SHOWS»): take the
|
||||
action away and the marker speaks for its primary again; a lit lamp that
|
||||
also owns a cover keeps its yellow and its own icon
|
||||
[auto: smoke_cover_not_primary (the indication section)]
|
||||
- [ ] Nothing paints over an explicit curtain (dev, audit DEV-1DA1-01): the
|
||||
cover is the FIRST rule of «What a marker SHOWS», above `controls` and
|
||||
above a lit light. Two markers set to «Открыть/закрыть»: one on a mixed
|
||||
device whose own `light.*` is ON, one whose bound `controls` switch is
|
||||
ON. In every cover state (closed / open / opening / closing) the plate
|
||||
stays neutral — never the yellow «включено», never the orange «открыто»
|
||||
— the travelling ring breathes, the icon morphs with the cover, and in
|
||||
glow fill the ring is still there (that is where a yellow-plated curtain
|
||||
used to lose BOTH indicators). Untouched: the same mixed device without
|
||||
the explicit action is yellow again, a wall switch still mirrors its
|
||||
controls, and an «Открыть/закрыть» marker whose device has no `cover.*`
|
||||
at all falls back to its primary [auto: smoke_cover_plate_precedence]
|
||||
- [ ] Light-source badges (v1.52.0): in glow fill a lit lamp's badge stays
|
||||
standard (the spot is the indicator) and a lit socket stays yellow; in
|
||||
other fills a lit lamp is plain yellow with no RGB tint; morphing and
|
||||
@@ -910,6 +979,15 @@ require hands on real hardware — they remain for the human pass.
|
||||
- [ ] Wedge direction follows the compass; length grows toward
|
||||
sunrise/sunset and shrinks toward noon; every wedge is clipped by its
|
||||
room polygon; night = no wedges at all
|
||||
- [ ] Brightness + the 3° threshold (2026-08-03): wedges are visibly brighter
|
||||
(peak alpha 0.30, was 0.18) yet still readable over white paper AND the
|
||||
dark glow canvas; there is NO gradual ramp near the horizon — below 3°
|
||||
no rays at all, at/above 3° full strength; crossing the threshold fades
|
||||
the whole layer in/out over exactly 2 s (CSS on `.sunlayer`, the
|
||||
geometry never moves), and `prefers-reduced-motion` makes it instant.
|
||||
Every other way of losing the wedges (editor, feature off, night, rain)
|
||||
stays instant [auto: smoke_sun «the 3° threshold» + unit rayAlpha/
|
||||
raysVisible/rayPeakAlpha; shots: demo/shot_sun_bright.mjs]
|
||||
- [ ] Weather entity (optional, global): cloudy fades the wedges, rain/snow
|
||||
removes them, a dead/unknown weather sensor changes nothing
|
||||
- [ ] Sun geometry recomputes ONLY when the sun attributes or the config
|
||||
@@ -930,3 +1008,199 @@ require hands on real hardware — they remain for the human pass.
|
||||
(registry-wide climate, like hidden thermometers); the tick survives
|
||||
dialog recreation [auto: smoke_climate_temp; units: test/devices.test.mjs;
|
||||
backend: tests_backend/test_validation.py (use_climate_temp)]
|
||||
|
||||
## Infinite canvas (docs/CANVAS.md, dev)
|
||||
|
||||
- [ ] **A plan drawn past the old square opens whole**: a space whose rooms
|
||||
live at normalised 1.5..3.0 renders complete and centred (it used to
|
||||
frame empty canvas with the house off-screen) [auto:
|
||||
smoke_infinite_canvas; units: test/canvas.test.mjs]
|
||||
- [ ] **Nothing stops at an edge any more**: in the Plan / Devices / Decor
|
||||
editors a room, a marker and a decor shape can be drawn, dragged and
|
||||
SAVED far outside `0..1`, on any floor; reload keeps them there
|
||||
[backend: tests_backend/test_validation.py::test_infinite_canvas_range]
|
||||
- [ ] **A typical small plan is visually unchanged** — same framing, same
|
||||
room and label positions as before the feature. The ONE intended
|
||||
difference is icon size (below) [auto: smoke_infinite_canvas
|
||||
(legacyFrameUnchanged); the whole smoke suite is the regression net]
|
||||
- [ ] **Icons no longer grow with zoom** (§6, owner is aware): a marker keeps
|
||||
the same pixel size at zoom 1, 4 and at the zoom-out floor; the
|
||||
per-device size multiplier, kiosk icon/font scales, badges, LQI chips
|
||||
and presence rings all still scale from `--dev-size`
|
||||
- [ ] **Start view follows the content**: opening a space frames what is
|
||||
drawn plus a small margin, on every floor, with and without a backdrop
|
||||
image (with one the IMAGE sets the extent — it must not be cropped to
|
||||
the outlined rooms)
|
||||
- [ ] **What is not drawn does not frame** (audit DEV-2C947-01): tick «hide
|
||||
from plan» on a marker standing far from the house and the view snaps
|
||||
back to the house — the hidden marker neither renders nor stretches the
|
||||
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 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.
|
||||
«Показать» fits the plan AND the stray; the chip then disappears
|
||||
- [ ] **«Вписать всё»** (middle zoom button): fits the content from any pan
|
||||
and any zoom, is never disabled, tooltip en/ru
|
||||
- [ ] **Zoom-out floor**: the wheel / the minus button stop at three times the
|
||||
content frame; zoom-in still stops at 800 %
|
||||
- [ ] **Pan has slack, not walls**: you can pan a full screen past the plan in
|
||||
every direction; when the plan is fully off screen a small arrow points
|
||||
home and one click fits it back
|
||||
- [ ] **Pan at ANY zoom** (owner's report 2026-08-04): dragging empty scene
|
||||
moves the view at 100 %, at 50 % and at the zoom-out floor — in View and
|
||||
in all three editors, with every plan tool selected. The tools keep the
|
||||
pointer they own (a resize handle resizes, a device badge in the Devices
|
||||
editor moves the device, an opening slides along its wall — none of them
|
||||
pan), two fingers still pinch, and on a kiosk screen a horizontal drag
|
||||
is still the floor swipe (a vertical one pans) [auto: smoke_pan_any_zoom]
|
||||
- [ ] **Kiosk: a pan stays a pan to the very end** (dev, audit DEV-1DA1-02):
|
||||
on a wall tablet at 100 % start a drag with a small VERTICAL lead-in
|
||||
(the plan starts following the finger — the gesture is locked as `pan`),
|
||||
then curve it far to the left or right and lift. The floor must NOT
|
||||
change: the decision taken on the first movement is final, and only a
|
||||
gesture locked as a swipe may switch storeys. Mirror check: a horizontal
|
||||
lead-in locks the swipe — the plan never slides under it, and if the
|
||||
trajectory then bends vertically and no longer qualifies as a swipe, the
|
||||
gesture simply does nothing (it does not turn into a pan). Straight
|
||||
swipes still switch, straight vertical drags still pan, a motionless
|
||||
double tap still resets the zoom [auto: smoke_kiosk_pan_lock]
|
||||
- [ ] **Adaptive grid** (§7): in the Plan editor zoomed far out the grid does
|
||||
not merge into a grey wash — fine dots thin out, every 5th/10th node
|
||||
stays bigger; zoomed in the grid is the usual one and snapping still
|
||||
lands on the same nodes as before
|
||||
- [ ] **Everything else on a far-out plan**: sun wedges, glow radii, open
|
||||
boundaries, room resize handles/rulers, opening rulers, split/merge,
|
||||
vacuum trails, the static `houseplan-space-card` and kiosk carousel all
|
||||
behave exactly as on a plan inside `0..1`
|
||||
- [ ] **Real config regression**: a production config (e.g. the dacha, 3
|
||||
floors / 106 markers) frames bit-identically to the previous release —
|
||||
no outliers reported, no frame movement
|
||||
|
||||
## Batch 2026-08-04 (dev, unreleased)
|
||||
|
||||
- [ ] **Room borders have no teeth** (owner 2026-08-04): draw a room with a
|
||||
sharp corner (a wedge with a 30-60° apex, or an L) — the corner is
|
||||
ROUNDED off by the stroke's own radius, never a spike sticking out past
|
||||
the two walls and never a flat bevel. Same in the plan View, in the Plan
|
||||
editor and on the static `houseplan-space-card`; a room with OPEN
|
||||
boundaries (its trimmed outline) has round corners too
|
||||
[auto: smoke_render_parity, still: demo/shot_room_joins.mjs]
|
||||
- [ ] **The Background editor measures what you draw** (owner 2026-08-04, «в
|
||||
редакторе подложки у линий писать длину»): while a decor LINE is being
|
||||
dragged out, a badge on the MIDDLE of the segment shows «length · angle»
|
||||
in the HA unit system (`cell_cm`, metres or feet) and turns green on a
|
||||
45° multiple — exactly the badge a wall gets in the Plan editor. It
|
||||
updates on every move, is absent before the drag has any length, and is
|
||||
gone the moment the shape is committed. Rectangles and ovals show their
|
||||
bounding box «W × H» instead [auto: smoke_decor]
|
||||
- [ ] **Grazing sunlight** (dev, audit DEV-EB173-01): with `sun_rays` on, set
|
||||
the sun almost ALONG a wall carrying a window (e.g. a west window,
|
||||
azimuth 190°, elevation 90° at north_deg 0). The shaft is a true
|
||||
parallelogram — both sides exactly as long as the nominal reach, 70 % of
|
||||
the pre-v1.57 curve — the whole pane of glass is at FULL brightness (no
|
||||
end of the window starts out transparent), and the light fades along the
|
||||
ray, dying out before the far edge. A sun within ~3° of the wall plane
|
||||
(`RAY_MIN_COS`) casts nothing at all [auto: smoke_sun_soft + unit sun.test]
|
||||
- [ ] **Nothing stops at the old canvas border** (owner 2026-08-04, DEV-B58-01:
|
||||
«названия комнат и устройства не перетаскиваются дальше старых границ
|
||||
холста»). On an ORDINARY plan (rooms inside 0..1), in the Devices editor
|
||||
drag a marker far outside the drawing — to about 2.5 / 2.2 normalised. It
|
||||
follows the cursor the whole way, the position is stored, the plan's frame
|
||||
grows to include it, and it is still there after a reload. Repeat with a
|
||||
room NAME in the Plan editor (this one used to stop at the old unit square
|
||||
exactly), with a decor shape in the Background editor (draw it far out,
|
||||
then drag it further), and with an opening on a wall that lives past the
|
||||
old square. The only thing that still stops you is ±5000 — drag wildly and
|
||||
the marker parks there instead of at 1e12 [auto: smoke_drag_bounds]
|
||||
- [ ] **Everything lands on the grid** (owner 2026-08-04, docs/CANVAS.md §9):
|
||||
place a device, a room name, a decor rectangle, a decor text, a room
|
||||
vertex and a resize handle with the mouse — each ends exactly on a grid
|
||||
node, never between two. An opening is the one exception and deliberately
|
||||
so: it stays ON its wall, at a whole number of steps along it. Hold
|
||||
**Shift** while dropping and the element lands where the cursor is,
|
||||
off-grid — that is the only way to get there [auto: smoke_grid_snap]
|
||||
- [ ] **«Выровнять всё по сетке»** (owner 2026-08-04): gear → general settings →
|
||||
**Grid** → the button. On an already tidy plan it says everything is
|
||||
already on the grid and offers no confirm button. On a plan with elements
|
||||
between the nodes it names how many will move and the largest shift in cm,
|
||||
and warns there is no undo. Press it: rooms, decor, markers and room names
|
||||
snap to nodes in ONE write, openings stay on their walls, and pressing the
|
||||
button a second time reports nothing to do. Cancel does nothing at all
|
||||
[auto: smoke_grid_snap + unit test/align-grid.test.mjs]
|
||||
|
||||
## Backdrop picture: move & scale (docs/BACKDROP.md, dev)
|
||||
|
||||
- [ ] **The frame is there, and only there** (owner 2026-08-04): open a space
|
||||
that HAS an uploaded plan image → **Редактор подложки**. A dashed frame
|
||||
hugs the picture straight away, with four round corner handles, and the
|
||||
toolbar has gained a «Картинка-подложка» tool. Switch to Line /
|
||||
Rectangle / Oval / Text / Erase — the frame disappears (those tools own
|
||||
the drag). Leave for View, the Plan editor, the Device editor and the
|
||||
kiosk — no frame anywhere. A space with NO image never shows one, and
|
||||
its toolbar has no extra button [auto: smoke_backdrop, smoke_decor]
|
||||
- [ ] **Dragging the picture moves the picture, and nothing else** (owner
|
||||
2026-08-04): pick the «Картинка-подложка» tool (the cursor over the
|
||||
picture becomes a hand), grab the picture by its body and pull it aside.
|
||||
It follows the finger the whole way — it must NOT drift away from the
|
||||
cursor or jump when the toolbar changes — while the rooms, walls, doors,
|
||||
windows, devices, room names and decor stay exactly where they were.
|
||||
Release, reload the page — the picture is still where you left it
|
||||
[auto: smoke_backdrop]
|
||||
- [ ] **One-finger pan survives** (regression, DEV-B58 «таскать план при любом
|
||||
масштабе»): back on the «Выбрать» tool, drag across the middle of the
|
||||
picture — the PLANE pans, the picture does not move. Same at 50 % and
|
||||
33 % zoom [auto: smoke_backdrop, smoke_pan_any_zoom]
|
||||
- [ ] **The corners scale it evenly**: pull a corner handle. The picture grows
|
||||
and shrinks in BOTH directions at once, keeps its proportions (nothing
|
||||
is stretched), and the OPPOSITE corner does not move a pixel. Try all
|
||||
four corners; the cursor over a handle is a diagonal resize arrow. On a
|
||||
tablet the handles are big enough to hit with a finger [auto: smoke_backdrop]
|
||||
- [ ] **Live size in metres**: while dragging or scaling, a badge in the middle
|
||||
of the picture states its real size, «Ш × В», in the same units the wall
|
||||
ruler uses (metres, or feet on an imperial HA). Change the space's
|
||||
`cell_cm` in its settings and the numbers change with it. The badge
|
||||
disappears on release [auto: smoke_backdrop]
|
||||
- [ ] **Snap, and Shift**: after a plain drag the picture's corner sits on a
|
||||
grid node (zoom in on the corner — it is on a crossing, not between
|
||||
two). After a corner scale one side of the picture ends on a node too.
|
||||
Hold **Shift** during the gesture and it lands exactly where the cursor
|
||||
is instead [auto: smoke_backdrop]
|
||||
- [ ] **«Вернуть картинку»**: the button appears in the backdrop toolbar only
|
||||
after the picture has been moved or scaled. Press it — the picture goes
|
||||
back to centred, at its own size, and the button disappears again
|
||||
[auto: smoke_backdrop]
|
||||
- [ ] **NEW PAPER RULE — the sheet is the rooms** (owner 2026-08-04, changes
|
||||
the old behaviour): set a loud `bg_color` (or `daynight`) on a space
|
||||
that has BOTH a picture and drawn rooms, then shrink the picture with a
|
||||
corner handle. The opaque white/card-coloured sheet follows the ROOM
|
||||
CONTOURS — it is no longer a rectangle the size of the picture. The
|
||||
scene colour is visible around the rooms, including in the pocket of an
|
||||
L-shaped house and between detached buildings. The picture is drawn ON
|
||||
that sheet: above it, below the walls, doors, decor and devices. A space
|
||||
with a picture and NO rooms has no sheet at all, so a transparent PNG
|
||||
shows the scene through itself — deliberate
|
||||
[auto: smoke_backdrop + smoke_bg_color, still: demo/shot_backdrop.mjs]
|
||||
- [ ] **«Вписать всё» does not lose the picture**: drag the picture well away
|
||||
from the rooms (or scale it right down), leave the editor, press «Вписать
|
||||
всё». The view frames the rooms AND the picture; the "home is that way"
|
||||
arrow points at them together [auto: smoke_backdrop]
|
||||
- [ ] **The static card and the kiosk agree**: put a `houseplan-space-card` for
|
||||
the same space on a dashboard and open the kiosk view. Both draw the
|
||||
picture at the same offset and size, with the same room-contour paper
|
||||
[auto: smoke_backdrop, smoke_render_parity]
|
||||
- [ ] **Old plans are untouched**: a space whose picture has never been moved
|
||||
renders exactly as before the update — same place, same size. Nothing is
|
||||
written to its config until the first drag [auto: unit test/backdrop.test.mjs
|
||||
+ tests_backend/test_validation.py]
|
||||
|
||||
@@ -64,6 +64,19 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
|
||||
👁 "Show hidden" (local editor tool; replaced the shared show-all toggle),
|
||||
⬡ icon rules.
|
||||
|
||||
## Background — the decor underlay
|
||||
|
||||
- Toolbar tools: select / line / rect / oval / text / erase, plus colour, width
|
||||
and fill. Shapes are drag-drawn with grid snap and a live preview.
|
||||
- **Live size badge** (owner 2026-08-04): while a LINE is being dragged out, the
|
||||
same `.measurelabel` the Plan editor puts on a wall shows «length · angle» —
|
||||
`segmentCm` over the space's `cell_cm`, metres or feet per the HA unit system,
|
||||
green on a 45° multiple. It rides the MIDDLE of the segment (a wall badge
|
||||
follows the cursor because the cursor is the wall's free end; a decor line is
|
||||
pulled out by both ends at once), updates on every move and disappears the
|
||||
moment the shape is committed. Rectangles and ovals have no length, so they
|
||||
show their bounding box «W × H» instead; a draft with no size shows nothing.
|
||||
|
||||
## Deprecations decided
|
||||
|
||||
1. "Drag anywhere" (v1.9) — reversed by this design.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.56.0",
|
||||
"version": "1.58.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.56.0",
|
||||
"version": "1.58.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.56.0",
|
||||
"version": "1.58.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* «Выровнять всё по сетке» / «Align everything to the grid» — docs/CANVAS.md §9.
|
||||
*
|
||||
* WHY THIS IS AN ACTION AND NOT A MIGRATION
|
||||
* -----------------------------------------
|
||||
* Every gesture in the editor now lands on the grid, but plans drawn before
|
||||
* that contract existed may hold coordinates between the nodes. The obvious
|
||||
* fix — quietly rounding everything on the next update — is the wrong one:
|
||||
*
|
||||
* 1. It moves the user's data without asking. A house plan is a drawing; the
|
||||
* card has no mandate to redraw it on a version bump.
|
||||
* 2. Some things are off-grid ON PURPOSE — a small decor label nudged next to
|
||||
* an icon, a diagonal wall's window, a plan traced over a photo where the
|
||||
* scale never was a whole number of cells.
|
||||
* 3. A silent migration is unattributable. When a room looks 3 cm wrong the
|
||||
* owner cannot tell whether the card did it or they did.
|
||||
*
|
||||
* So the alignment lives behind an explicit button that first says how many
|
||||
* elements it would move and by how much at most, and only then writes — once,
|
||||
* in a single config+layout operation.
|
||||
*
|
||||
* WALL-BOUND VS GRID-BOUND (the contract this file implements)
|
||||
* -----------------------------------------------------------
|
||||
* Room vertices, decor geometry, device markers and room labels are GRID-BOUND:
|
||||
* they are rounded to the nearest node. Openings are WALL-BOUND: a door that
|
||||
* sits on a grid node but half a metre off its wall is broken geometry, so an
|
||||
* opening is re-projected onto the nearest wall and its offset ALONG that wall
|
||||
* is snapped to the same step. On an axis-aligned wall with grid-aligned
|
||||
* corners the two rules agree exactly.
|
||||
*
|
||||
* THE REPORT IS A PROMISE, SO IT IS AN UPPER BOUND (AUD-158B1-01)
|
||||
* --------------------------------------------------------------
|
||||
* The confirmation is the only safety gate in front of an action with no undo.
|
||||
* A number that is merely typical of what happens is worse than no number at
|
||||
* all, so the maximum is measured on the geometry that is actually written
|
||||
* back. Two things used to make it a sample instead of a bound:
|
||||
*
|
||||
* * a rect was measured by its origin and its far corner only. The far corner
|
||||
* is then pushed out again by the minimum-size correction, and the other two
|
||||
* corners — the ones that carry the X error of one side together with the Y
|
||||
* error of the other — were never looked at, understating an ordinary box by
|
||||
* up to √2 and a box thinner than one step by much more.
|
||||
* * the single NORMALISED maximum was turned into centimetres by the caller,
|
||||
* through the `cell_cm` of the FIRST space. Two floors drawn at 5 cm and at
|
||||
* 100 cm per cell made that promise twenty times too small.
|
||||
*
|
||||
* So displacement is accumulated in CENTIMETRES, every space through its own
|
||||
* `cell_cm`, and the report says which space the maximum belongs to.
|
||||
*
|
||||
* AN OPENING ALSO CARRIES AN ANGLE (AUD-158B1-02)
|
||||
* ----------------------------------------------
|
||||
* The batch rewrites `angle` as well as `x`/`y`. A window whose centre is
|
||||
* already on its wall but whose stored angle is wrong is therefore a real
|
||||
* correction — yet it used to come back inside `changed:false`, so the dialog
|
||||
* said there was nothing to do and the fix could never be applied. The angle is
|
||||
* part of the diff now, and displacement is measured on the opening's ENDS, so
|
||||
* turning it is not free in the report either.
|
||||
*/
|
||||
|
||||
import { GRID_N, GRID_STEP_N } from './space-geometry';
|
||||
import { snapToWall } from './logic';
|
||||
|
||||
/** Anything closer than this to a node already counts as being on it. */
|
||||
const EPS = GRID_STEP_N * 1e-6;
|
||||
/** How far an opening may be from a wall and still be re-projected onto it. */
|
||||
const WALL_TOL = GRID_STEP_N * 6;
|
||||
/** What one cell is worth when a space does not say — the card's own default. */
|
||||
const DEFAULT_CELL_CM = 5;
|
||||
|
||||
/** Round a NORMALISED coordinate to the nearest grid node, idempotently.
|
||||
* A value already on a node is returned UNCHANGED (bit for bit), so a second
|
||||
* run of the alignment writes nothing at all. */
|
||||
export function snapN(v: number): number {
|
||||
if (!Number.isFinite(v)) return v;
|
||||
const s = Math.round(v / GRID_STEP_N) * GRID_STEP_N;
|
||||
return Math.abs(s - v) <= EPS ? v : s;
|
||||
}
|
||||
|
||||
export interface AlignReport {
|
||||
/** Elements whose coordinates the run would change. */
|
||||
moved: number;
|
||||
/** Elements examined (rooms, decor shapes, openings, markers, labels). */
|
||||
total: number;
|
||||
/** Largest displacement in NORMALISED units (1 = the plan's width). */
|
||||
maxShift: number;
|
||||
/** The same maximum in CENTIMETRES — every space through its own `cell_cm`,
|
||||
* because a normalised number means nothing until it meets a scale. This is
|
||||
* the number the confirmation promises, and it is an upper bound. */
|
||||
maxShiftCm: number;
|
||||
/** Which space holds that maximum (`''` when nothing moves). */
|
||||
maxSpace: string;
|
||||
/** Openings whose stored `angle` is corrected — possibly without moving. */
|
||||
rotated: number;
|
||||
}
|
||||
|
||||
export interface AlignResult {
|
||||
spaces: any[];
|
||||
layout: Record<string, any>;
|
||||
report: AlignReport;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
const dist = (ax: number, ay: number, bx: number, by: number) => Math.hypot(bx - ax, by - ay);
|
||||
|
||||
/** The displacement of an axis-aligned box: the largest of its FOUR corners,
|
||||
* measured against the box that is really written back (minimum-size
|
||||
* correction included). The worst corner combines the largest X error of
|
||||
* either side with the largest Y error of either side — which is why the two
|
||||
* that were never measured are exactly the two that can be the worst. */
|
||||
const boxShift = (
|
||||
x: number, y: number, w: number, h: number,
|
||||
nx: number, ny: number, nw: number, nh: number,
|
||||
): number => Math.hypot(
|
||||
Math.max(Math.abs(nx - x), Math.abs((nx + nw) - (x + w))),
|
||||
Math.max(Math.abs(ny - y), Math.abs((ny + nh) - (y + h))),
|
||||
);
|
||||
|
||||
/** The displacement of an opening, measured on its two ENDS so that turning it
|
||||
* in place is not free. An opening is a symmetric segment on its wall: 180°
|
||||
* apart is the same segment, so the pairing of ends that gives the smaller
|
||||
* answer is the true one — a flipped angle is a rewrite, not a move. */
|
||||
const openingShift = (
|
||||
x: number, y: number, a: number, len: number,
|
||||
nx: number, ny: number, na: number,
|
||||
): number => {
|
||||
const h = Math.max(Number(len) || 0, 0) / 2;
|
||||
const R = Math.PI / 180;
|
||||
const ux = Math.cos(a * R) * h, uy = Math.sin(a * R) * h;
|
||||
const vx = Math.cos(na * R) * h, vy = Math.sin(na * R) * h;
|
||||
const same = Math.max(dist(x + ux, y + uy, nx + vx, ny + vy),
|
||||
dist(x - ux, y - uy, nx - vx, ny - vy));
|
||||
const flip = Math.max(dist(x + ux, y + uy, nx - vx, ny - vy),
|
||||
dist(x - ux, y - uy, nx + vx, ny + vy));
|
||||
return Math.min(same, flip);
|
||||
};
|
||||
|
||||
const cellCmOf = (sp: any): number => {
|
||||
const v = Number(sp?.cell_cm);
|
||||
return v > 0 ? v : DEFAULT_CELL_CM;
|
||||
};
|
||||
|
||||
/**
|
||||
* The whole batch, as a pure function: give it the spaces and the layout, get
|
||||
* back new ones plus the report the confirmation dialog shows. Nothing here
|
||||
* touches the network, so the dialog can call it twice — once to preview, once
|
||||
* to write — and be certain the numbers it promised are the numbers it did.
|
||||
*/
|
||||
export function alignAllToGrid(
|
||||
spacesIn: any[], layoutIn: Record<string, any>,
|
||||
): AlignResult {
|
||||
const spaces = JSON.parse(JSON.stringify(spacesIn || []));
|
||||
const layout: Record<string, any> = JSON.parse(JSON.stringify(layoutIn || {}));
|
||||
let moved = 0, total = 0, maxShift = 0, maxShiftCm = 0, maxSpace = '', rotated = 0;
|
||||
|
||||
// a marker or a room label names its space; the scale of THAT space is the
|
||||
// one its centimetres are in
|
||||
const cellById: Record<string, number> = {};
|
||||
let cellWorst = DEFAULT_CELL_CM;
|
||||
for (const sp of spaces) {
|
||||
const c = cellCmOf(sp);
|
||||
if (sp?.id != null) cellById[String(sp.id)] = c;
|
||||
if (c > cellWorst) cellWorst = c;
|
||||
}
|
||||
|
||||
/** Record one element. `d` is normalised, `cellCm` turns it into the
|
||||
* centimetres of its own space. `forced` is for a change that is real
|
||||
* without being a displacement — an opening's angle. */
|
||||
const note = (d: number, cellCm: number, spaceId: string, forced = false): void => {
|
||||
if (!(d > EPS) && !forced) return;
|
||||
moved++;
|
||||
if (d > maxShift) maxShift = d;
|
||||
const cm = d * GRID_N * cellCm;
|
||||
if (cm > maxShiftCm) { maxShiftCm = cm; maxSpace = spaceId; }
|
||||
};
|
||||
|
||||
for (const sp of spaces) {
|
||||
const cell = cellCmOf(sp);
|
||||
const sid = sp?.id != null ? String(sp.id) : '';
|
||||
|
||||
// ---- rooms: every vertex to the nearest node ----------------------
|
||||
for (const r of sp.rooms || []) {
|
||||
total++;
|
||||
let d = 0;
|
||||
if (r.poly?.length) {
|
||||
r.poly = r.poly.map((p: number[]) => {
|
||||
const q = [snapN(p[0]), snapN(p[1])];
|
||||
d = Math.max(d, dist(p[0], p[1], q[0], q[1]));
|
||||
return q;
|
||||
});
|
||||
} else if (r.x != null && r.y != null) {
|
||||
// a rect keeps its far corner on the grid too, hence w/h are snapped
|
||||
// as corners and not as sizes (a snapped size on an off-grid origin
|
||||
// would leave the other side between the nodes).
|
||||
const x0 = r.x, y0 = r.y, w0 = r.w || 0, h0 = r.h || 0;
|
||||
const x2 = snapN(x0 + w0), y2 = snapN(y0 + h0);
|
||||
const nx = snapN(x0), ny = snapN(y0);
|
||||
const nw = Math.max(GRID_STEP_N, x2 - nx), nh = Math.max(GRID_STEP_N, y2 - ny);
|
||||
d = boxShift(x0, y0, w0, h0, nx, ny, nw, nh);
|
||||
r.x = nx; r.y = ny; r.w = nw; r.h = nh;
|
||||
}
|
||||
note(d, cell, sid);
|
||||
}
|
||||
|
||||
// ---- decor ---------------------------------------------------------
|
||||
for (const sh of sp.decor || []) {
|
||||
total++;
|
||||
let d = 0;
|
||||
if (sh.kind === 'line') {
|
||||
const a = [snapN(sh.x1), snapN(sh.y1)], b = [snapN(sh.x2), snapN(sh.y2)];
|
||||
d = Math.max(dist(sh.x1, sh.y1, a[0], a[1]), dist(sh.x2, sh.y2, b[0], b[1]));
|
||||
sh.x1 = a[0]; sh.y1 = a[1]; sh.x2 = b[0]; sh.y2 = b[1];
|
||||
} else {
|
||||
const nx = snapN(sh.x), ny = snapN(sh.y);
|
||||
if (sh.w != null && sh.h != null) {
|
||||
const x2 = snapN(sh.x + sh.w), y2 = snapN(sh.y + sh.h);
|
||||
const nw = Math.max(GRID_STEP_N, x2 - nx), nh = Math.max(GRID_STEP_N, y2 - ny);
|
||||
d = boxShift(sh.x, sh.y, sh.w, sh.h, nx, ny, nw, nh);
|
||||
sh.w = nw; sh.h = nh;
|
||||
} else {
|
||||
d = dist(sh.x, sh.y, nx, ny);
|
||||
}
|
||||
sh.x = nx; sh.y = ny;
|
||||
}
|
||||
note(d, cell, sid);
|
||||
}
|
||||
|
||||
// ---- openings: wall-bound, snapped ALONG the (already aligned) wall --
|
||||
// The very same helper the live editor uses, so the button cannot disagree
|
||||
// with the drag. A stray opening with no wall within WALL_TOL is left
|
||||
// exactly where it is rather than teleported across the plan.
|
||||
for (const o of sp.openings || []) {
|
||||
total++;
|
||||
const q = snapToWall([o.x, o.y], sp.rooms || [], WALL_TOL,
|
||||
{ step: GRID_STEP_N, length: Number(o.length) || 0 });
|
||||
if (!q) continue;
|
||||
// the angle is rewritten too, so it belongs in the diff: an opening
|
||||
// already on its wall with a wrong angle is a correction that must be
|
||||
// offerable, not a silent no-op (AUD-158B1-02)
|
||||
const raw = Number(o.angle);
|
||||
const turned = !(Number.isFinite(raw) && raw === q.angle);
|
||||
const d = openingShift(o.x, o.y, Number.isFinite(raw) ? raw : q.angle,
|
||||
Number(o.length) || 0, q.x, q.y, q.angle);
|
||||
o.x = q.x; o.y = q.y; o.angle = q.angle;
|
||||
if (turned) rotated++;
|
||||
note(d, cell, sid, turned);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- layout: device markers AND room labels (rl_<id>) ----------------
|
||||
for (const [k, v] of Object.entries(layout)) {
|
||||
if (!v || typeof v !== 'object') continue;
|
||||
const p: any = v;
|
||||
if (typeof p.x !== 'number' || typeof p.y !== 'number') continue;
|
||||
total++;
|
||||
const nx = snapN(p.x), ny = snapN(p.y);
|
||||
const d = dist(p.x, p.y, nx, ny);
|
||||
layout[k] = { ...p, x: nx, y: ny };
|
||||
// an entry whose space is gone still moves, and the promise must not
|
||||
// shrink because of it: the largest scale on the plan is the safe one
|
||||
const sid = typeof p.s === 'string' ? p.s : '';
|
||||
note(d, cellById[sid] ?? cellWorst, sid);
|
||||
}
|
||||
|
||||
return {
|
||||
spaces, layout,
|
||||
report: { moved, total, maxShift, maxShiftCm, maxSpace, rotated },
|
||||
changed: moved > 0,
|
||||
};
|
||||
}
|
||||
+1337
-203
File diff suppressed because it is too large
Load Diff
+26
-2
@@ -19,7 +19,7 @@
|
||||
"btn.no_area": "No area",
|
||||
"title.zoom_in": "Zoom in",
|
||||
"title.zoom_out": "Zoom out",
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"title.zoom_fit": "Fit all",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show hidden devices (ghosted, this tab only)",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
@@ -277,6 +277,10 @@
|
||||
"decor.size_s": "Small",
|
||||
"decor.size_m": "Medium",
|
||||
"decor.size_l": "Large",
|
||||
"decor.backdrop": "Backdrop image",
|
||||
"decor.backdrop_hint": "Drag the picture to move it, pull a corner to resize it. Shift — off the grid.",
|
||||
"decor.backdrop_reset": "Reset the picture",
|
||||
"decor.backdrop_reset_done": "The picture is back at its original place and size",
|
||||
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
|
||||
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
|
||||
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
|
||||
@@ -347,6 +351,7 @@
|
||||
"marker.hide": "Hide device from plan",
|
||||
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor.",
|
||||
"tap.run": "Run automation/script/scene",
|
||||
"tap.cover": "Open/close (curtains/blinds)",
|
||||
"marker.run_target_label": "What to run",
|
||||
"marker.run_search_ph": "Search: automation, script or scene…",
|
||||
"marker.run_target_gone": "Target {id} not found — pick again",
|
||||
@@ -357,6 +362,7 @@
|
||||
"run.scene": "scene",
|
||||
"confirm.tap_run": "Run \"{name}\"?",
|
||||
"confirm.tap_toggle": "Toggle \"{name}\"?",
|
||||
"confirm.tap_cover": "Open/close \"{name}\"?",
|
||||
"toast.run_started": "Started: {name}",
|
||||
"toast.run_target_missing": "Run target not found — check the device settings",
|
||||
"toast.run_target_required": "Pick an automation, script or scene",
|
||||
@@ -400,6 +406,10 @@
|
||||
"gs.sun_rays": "Sunlight through windows",
|
||||
"gs.weather": "Weather entity",
|
||||
"gs.weather_ph": "weather.home (optional)",
|
||||
"gs.about_group": "About",
|
||||
"gs.about_version": "Houseplan Card v{v}",
|
||||
"gs.about_github": "GitHub · docs & issues",
|
||||
"gs.about_telegram": "Telegram chat",
|
||||
"space.bg_color": "Background around the plan",
|
||||
"space.bg_inherit": "Inherit general",
|
||||
"space.bg_inherited": "inherits general settings",
|
||||
@@ -409,5 +419,19 @@
|
||||
"space.sun_rays": "Sunlight through windows",
|
||||
"space.sun_inherit": "Inherit general",
|
||||
"space.sun_on": "On",
|
||||
"space.sun_off": "Off"
|
||||
"space.sun_off": "Off",
|
||||
"canvas.far_objects": "{n} object(s) far from the plan",
|
||||
"canvas.show_far": "Show",
|
||||
"canvas.home_tip": "The plan is over there — click to fit it",
|
||||
"gs.grid_group": "Grid",
|
||||
"gs.grid_hint": "Everything you draw or drag now lands on the grid. Hold Shift to place something between the nodes on purpose.",
|
||||
"gs.align_all": "Align everything to the grid",
|
||||
"gs.align_title": "Align everything to the grid",
|
||||
"gs.align_none": "Everything is already on the grid — nothing to move.",
|
||||
"gs.align_count": "{n} of {total} elements will move, by at most {cm} cm.",
|
||||
"gs.align_where": "The largest shift is in \u201c{s}\u201d.",
|
||||
"gs.align_turned": "Openings whose angle is corrected: {n}.",
|
||||
"gs.align_warn": "This rewrites the plan and the device positions in one go. There is no undo: the only way back is to move things by hand. If some elements are deliberately between the nodes, cancel.",
|
||||
"gs.align_run": "Align",
|
||||
"gs.align_done": "Aligned to the grid: {n} elements moved"
|
||||
}
|
||||
|
||||
+26
-2
@@ -19,7 +19,7 @@
|
||||
"btn.no_area": "Без зоны",
|
||||
"title.zoom_in": "Приблизить",
|
||||
"title.zoom_out": "Отдалить",
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.zoom_fit": "Вписать всё",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показать скрытые устройства (полупрозрачными, только в этой вкладке)",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
@@ -277,6 +277,10 @@
|
||||
"decor.size_s": "Мелкий",
|
||||
"decor.size_m": "Средний",
|
||||
"decor.size_l": "Крупный",
|
||||
"decor.backdrop": "Картинка-подложка",
|
||||
"decor.backdrop_hint": "Тяните картинку — двигается, тяните угол — меняет размер. Shift — мимо сетки.",
|
||||
"decor.backdrop_reset": "Вернуть картинку",
|
||||
"decor.backdrop_reset_done": "Картинка вернулась на своё место и в свой размер",
|
||||
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
|
||||
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
|
||||
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
|
||||
@@ -347,6 +351,7 @@
|
||||
"marker.hide": "Скрыть устройство с плана",
|
||||
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств.",
|
||||
"tap.run": "Запустить автоматизацию/скрипт/сцену",
|
||||
"tap.cover": "Открыть/закрыть (шторы/жалюзи)",
|
||||
"marker.run_target_label": "Что запускать",
|
||||
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
|
||||
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
|
||||
@@ -357,6 +362,7 @@
|
||||
"run.scene": "сцена",
|
||||
"confirm.tap_run": "Запустить «{name}»?",
|
||||
"confirm.tap_toggle": "Переключить «{name}»?",
|
||||
"confirm.tap_cover": "Открыть/закрыть «{name}»?",
|
||||
"toast.run_started": "Запущено: {name}",
|
||||
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
|
||||
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
|
||||
@@ -400,6 +406,10 @@
|
||||
"gs.sun_rays": "Солнце в окнах",
|
||||
"gs.weather": "Сущность погоды",
|
||||
"gs.weather_ph": "weather.home (необязательно)",
|
||||
"gs.about_group": "О карточке",
|
||||
"gs.about_version": "Houseplan Card v{v}",
|
||||
"gs.about_github": "GitHub · документация и issues",
|
||||
"gs.about_telegram": "Чат в Telegram",
|
||||
"space.bg_color": "Цвет фона вокруг плана",
|
||||
"space.bg_inherit": "Наследовать общий",
|
||||
"space.bg_inherited": "наследуется из общих настроек",
|
||||
@@ -409,5 +419,19 @@
|
||||
"space.sun_rays": "Солнце в окнах",
|
||||
"space.sun_inherit": "Наследовать общий",
|
||||
"space.sun_on": "Вкл",
|
||||
"space.sun_off": "Выкл"
|
||||
"space.sun_off": "Выкл",
|
||||
"canvas.far_objects": "Объектов далеко от плана: {n}",
|
||||
"canvas.show_far": "Показать",
|
||||
"canvas.home_tip": "План там — нажмите, чтобы вписать",
|
||||
"gs.grid_group": "Сетка",
|
||||
"gs.grid_hint": "Всё, что вы рисуете и перетаскиваете, ложится строго на сетку. Shift — временно поставить элемент между узлами.",
|
||||
"gs.align_all": "Выровнять всё по сетке",
|
||||
"gs.align_title": "Выровнять всё по сетке",
|
||||
"gs.align_none": "Всё уже на сетке — двигать нечего.",
|
||||
"gs.align_count": "Сдвинется элементов: {n} из {total}, максимум на {cm} см.",
|
||||
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
|
||||
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
|
||||
"gs.align_warn": "Действие разом перезапишет план и позиции устройств. Отмены нет: вернуть можно только руками. Если какие-то элементы стоят между узлами намеренно — отмените.",
|
||||
"gs.align_run": "Выровнять",
|
||||
"gs.align_done": "Выровнено по сетке: сдвинуто элементов — {n}"
|
||||
}
|
||||
|
||||
+191
-8
@@ -9,9 +9,14 @@ export function lqiColor(lqi: number): string {
|
||||
return `hsl(${Math.round(hue)}, 85%, 55%)`;
|
||||
}
|
||||
|
||||
/** Snap a coordinate to the nearest grid node with step pitch. */
|
||||
/** Snap a coordinate to the nearest grid node with step pitch.
|
||||
* A value already ON a node comes back bit-identical: the round-trip through
|
||||
* a non-dyadic pitch (1000/240) otherwise turns an exact 500 into
|
||||
* 500.00000000000006, and "is it on the grid?" starts answering no. */
|
||||
export function snapToGrid(v: number, pitch: number): number {
|
||||
return Math.round(v / pitch) * pitch;
|
||||
if (!Number.isFinite(v) || !(pitch > 0)) return v;
|
||||
const q = Math.round(v / pitch) * pitch;
|
||||
return Math.abs(q - v) <= pitch * 1e-9 ? v : q;
|
||||
}
|
||||
|
||||
/** Real-world length (cm) of a segment given the grid pitch (render units per cell) and cm per cell. */
|
||||
@@ -111,8 +116,15 @@ export function roomEdges(rooms: any[]): number[][] {
|
||||
* placed with this stays valid however rooms are later edited — it keeps absolute
|
||||
* coordinates and is not tied to a room id or edge index.
|
||||
*/
|
||||
export interface WallSnapOpts {
|
||||
/** Quantise the offset along the wall to this step (render or config units). */
|
||||
step?: number;
|
||||
/** Length of the thing being placed, so it is kept inside its wall. */
|
||||
length?: number;
|
||||
}
|
||||
|
||||
export function snapToWall(
|
||||
p: number[], rooms: any[], maxDist: number,
|
||||
p: number[], rooms: any[], maxDist: number, opts: WallSnapOpts = {},
|
||||
): { x: number; y: number; angle: number } | null {
|
||||
let best: { x: number; y: number; angle: number } | null = null;
|
||||
let bestD = maxDist;
|
||||
@@ -133,12 +145,58 @@ export function snapToWall(
|
||||
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
if (angle >= 90) angle -= 180;
|
||||
else if (angle < -90) angle += 180;
|
||||
best = { x: q[0], y: q[1], angle };
|
||||
// docs/CANVAS.md §9: an opening is WALL-bound, so it cannot be rounded to
|
||||
// a grid node — that would lift it off a diagonal wall. What IS quantised
|
||||
// is its offset ALONG the wall, in the same step, measured from the
|
||||
// wall's first corner. On an axis-aligned wall whose corners are on the
|
||||
// grid (i.e. every wall the editor itself draws) the two agree exactly.
|
||||
if (opts.step && opts.step > 0) {
|
||||
const len = Math.sqrt(len2);
|
||||
const half = Math.min(Math.max(opts.length || 0, 0) / 2, len / 2);
|
||||
let along = Math.round((t * len) / opts.step) * opts.step;
|
||||
// the wall's own CENTRE wins inside one step of it: that is the editor's
|
||||
// magnet (docs/CANVAS.md §9.3), and a batch alignment must not knock a
|
||||
// deliberately centred window off centre by a couple of centimetres
|
||||
if (Math.abs(t * len - len / 2) <= opts.step / 2) along = len / 2;
|
||||
along = Math.max(half, Math.min(len - half, along));
|
||||
const u = along / len;
|
||||
best = { x: x1 + u * dx, y: y1 + u * dy, angle };
|
||||
} else {
|
||||
best = { x: q[0], y: q[1], angle };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Snap the point to the nearest wall EDGE of a single polygon, quantising the
|
||||
* offset along that edge — the split tool's flavour of the same contract. */
|
||||
export function snapPointAlongPoly(
|
||||
p: number[], poly: number[][], step: number,
|
||||
): number[] | null {
|
||||
let best: number[] | null = null;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const [x1, y1] = poly[i];
|
||||
const [x2, y2] = poly[(i + 1) % poly.length];
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!len2) continue;
|
||||
let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const d = Math.hypot(p[0] - (x1 + t * dx), p[1] - (y1 + t * dy));
|
||||
if (d >= bestD) continue;
|
||||
bestD = d;
|
||||
const len = Math.sqrt(len2);
|
||||
const along = step > 0
|
||||
? Math.max(0, Math.min(len, Math.round((t * len) / step) * step))
|
||||
: t * len;
|
||||
const u = along / len;
|
||||
best = [x1 + u * dx, y1 + u * dy];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface OpeningShoulders {
|
||||
/** Endpoints of the wall edge — the ONE room-polygon edge the opening sits on. */
|
||||
wallA: number[];
|
||||
@@ -690,7 +748,7 @@ export function safeUrl(url: string | null | undefined): string | null {
|
||||
|
||||
// ---------------- tap actions ----------------
|
||||
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run' | 'cover';
|
||||
|
||||
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
|
||||
/**
|
||||
@@ -709,7 +767,7 @@ export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
|
||||
* Adding an option here and forgetting the schema now fails the test suite.
|
||||
*/
|
||||
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run', 'cover'] as const;
|
||||
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
|
||||
// 'glow' leads: it is the default for new spaces since v1.54 — the owner's
|
||||
// call, it sells the card best. Existing configs keep whatever they chose;
|
||||
@@ -755,6 +813,14 @@ export function resolveTapAction(
|
||||
// 'run' is EXPLICIT-only by construction: it needs a per-marker target, so
|
||||
// it can never arrive as a card-wide default
|
||||
if (want === 'run') return explicit === 'run' ? 'run' : 'info';
|
||||
// 'cover' (open/close/stop, owner's spec 2026-08-03) is EXPLICIT-only and
|
||||
// cover-only. The guarded classes never get it: the option is not offered
|
||||
// in the dialog, and a value smuggled into the config degrades to 'info'
|
||||
// — exactly what a card-wide toggle does for a garage door.
|
||||
if (want === 'cover') {
|
||||
if (explicit !== 'cover' || domain !== 'cover') return 'info';
|
||||
return COVER_GUARDED_CLASSES.has(String(deviceClass || '')) ? 'info' : 'cover';
|
||||
}
|
||||
if (want !== 'toggle') return 'info';
|
||||
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
|
||||
if (explicit === 'toggle') return 'toggle';
|
||||
@@ -765,6 +831,47 @@ export function resolveTapAction(
|
||||
return 'toggle';
|
||||
}
|
||||
|
||||
/**
|
||||
* The cover service one tap should call, from the entity's CURRENT state
|
||||
* (owner's spec 2026-08-03):
|
||||
* closed -> open_cover
|
||||
* open (incl. ajar) -> close_cover
|
||||
* opening / closing -> stop_cover (a tap during travel is a STOP;
|
||||
* the next tap then travels the other way, which
|
||||
* is simply what HA's own state machine does)
|
||||
* anything else/unknown -> toggle (no meaningful state to reason about)
|
||||
*/
|
||||
export function coverService(state: string | null | undefined): string {
|
||||
const s = String(state || '');
|
||||
if (s === 'closed') return 'open_cover';
|
||||
if (s === 'open') return 'close_cover';
|
||||
if (s === 'opening' || s === 'closing') return 'stop_cover';
|
||||
return 'toggle';
|
||||
}
|
||||
|
||||
/**
|
||||
* The cover entity a marker's «Open/close» acts on: the FIRST `cover.*` among
|
||||
* ALL the device's entities — not necessarily the primary one.
|
||||
*
|
||||
* Why this exists (owner's report 2026-08-04, verified on his own install):
|
||||
* an Aqara E1 curtain driver ships the `cover.*` HIDDEN by the integration and
|
||||
* a perfectly visible `switch.*_reverse_direction` next to it. `primaryEntity`
|
||||
* ranks visible before hidden, so the marker's primary was the service switch;
|
||||
* the tap then resolved on the domain `switch`, and the action the user had
|
||||
* explicitly chosen in the dialog degraded to the info card — while the dialog
|
||||
* kept offering it, because THAT check already looked at every entity of the
|
||||
* device. Same lesson as the climate temperature: what a device DOES is not
|
||||
* always what its primary entity is.
|
||||
*/
|
||||
export function coverEntityOf(entIds: string[] | null | undefined): string | null {
|
||||
return (entIds || []).find((e) => e.startsWith('cover.')) || null;
|
||||
}
|
||||
|
||||
/** Is a cover travelling right now? Drives the breathing ring on the icon. */
|
||||
export function coverMoving(state: string | null | undefined): boolean {
|
||||
return state === 'opening' || state === 'closing';
|
||||
}
|
||||
|
||||
/** Domains a tap may RUN (owner's spec 2026-07-29): the runnable units of
|
||||
* HA. An automation is triggered, a script and a scene are turned on. */
|
||||
export const RUN_TARGET_DOMAINS = ['automation', 'script', 'scene'] as const;
|
||||
@@ -837,7 +944,13 @@ export interface SpaceDisplay {
|
||||
bgColor: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
// Default for borders + room names when room_color is absent. Dark slate
|
||||
// grey since 2026-08-03 (owner call, was the accent '#3ea6ff'): reads on
|
||||
// the white paper of drawn plans and on the glow-dark theme alike.
|
||||
// CHANGELOG draft: "Default room border/name colour is now dark grey
|
||||
// (#55606c). Spaces with an explicitly chosen room_color keep it; only
|
||||
// spaces that never touched the colour pick up the new default."
|
||||
export const DEFAULT_ROOM_COLOR = '#55606c';
|
||||
export const DEFAULT_ROOM_OPACITY = 0.55;
|
||||
export const DEFAULT_TEMP_MIN = 20;
|
||||
export const DEFAULT_TEMP_MAX = 25;
|
||||
@@ -1014,6 +1127,54 @@ export function roomFillColor(
|
||||
|
||||
// ---------------- state-reflecting icons ----------------
|
||||
|
||||
/**
|
||||
* cover device_class -> [closed icon, open icon] (owner's spec 2026-08-03).
|
||||
* Same idea as core HA's cover icons; kept here so the plan's morphing lives
|
||||
* in one table with the door/window/lock pairs below.
|
||||
*/
|
||||
const COVER_ICONS: Record<string, [string, string]> = {
|
||||
blind: ['mdi:blinds', 'mdi:blinds-open'],
|
||||
shade: ['mdi:blinds', 'mdi:blinds-open'],
|
||||
shutter: ['mdi:window-shutter', 'mdi:window-shutter-open'],
|
||||
curtain: ['mdi:curtains-closed', 'mdi:curtains'],
|
||||
window: ['mdi:window-closed', 'mdi:window-open'],
|
||||
awning: ['mdi:awning-outline', 'mdi:awning'],
|
||||
door: ['mdi:door-closed', 'mdi:door-open'],
|
||||
garage: ['mdi:garage', 'mdi:garage-open'],
|
||||
gate: ['mdi:gate', 'mdi:gate-open'],
|
||||
damper: ['mdi:circle-slice-8', 'mdi:circle-outline'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra [closed, open] pairs recognised on the BASE icon only (owner's
|
||||
* contract 2026-08-04: for a cover the morph is the ONLY open/closed signal,
|
||||
* so it must not fall silent on the icons the card itself hands out). These
|
||||
* are never picked by device_class — they only let a cover whose class is
|
||||
* missing (z2m ships plenty) or whose icon the user chose by hand still swap
|
||||
* within its OWN icon family. `mdi:roller-shade` is what the name rule
|
||||
* «штор|curtain|blind|shade» gives every curtain, `mdi:garage-variant` what
|
||||
* «ворота|garage|gate» gives every gate.
|
||||
*/
|
||||
const COVER_ICON_ALIASES: [string, string][] = [
|
||||
['mdi:roller-shade-closed', 'mdi:roller-shade'],
|
||||
['mdi:blinds-horizontal-closed', 'mdi:blinds-horizontal'],
|
||||
['mdi:garage-variant', 'mdi:garage-open-variant'],
|
||||
['mdi:door', 'mdi:door-open'],
|
||||
];
|
||||
|
||||
/** Every [closed, open] pair a cover's BASE icon may be recognised by. */
|
||||
function coverPairs(): [string, string][] {
|
||||
return [...Object.values(COVER_ICONS), ...COVER_ICON_ALIASES];
|
||||
}
|
||||
|
||||
/** The pair a cover icon belongs to, or null when it is not a known one. */
|
||||
function coverPairOf(base: string): [string, string] | null {
|
||||
for (const pair of coverPairs()) {
|
||||
if (base === pair[0] || base === pair[1]) return pair;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the auto icon for a state variant (open door, unlocked lock…), like core
|
||||
* HA does. Conservative: only well-known pairs, only when the user has NOT set
|
||||
@@ -1026,12 +1187,34 @@ export function stateIcon(
|
||||
state: string | null | undefined,
|
||||
hasCustomIcon: boolean,
|
||||
): string {
|
||||
if (hasCustomIcon || !state || state === 'unavailable' || state === 'unknown') return base;
|
||||
if (!state || state === 'unavailable' || state === 'unknown') return base;
|
||||
if (hasCustomIcon) {
|
||||
// A hand-picked icon normally wins outright. The ONE exception is a cover:
|
||||
// its plate is neutral in every state (owner 2026-08-04), so the morph is
|
||||
// all it has — and morphing WITHIN the very pair the user picked from
|
||||
// (mdi:curtains -> mdi:curtains-closed) shows the state without ever
|
||||
// trading their icon for a different family.
|
||||
const pair = domain === 'cover' ? coverPairOf(base) : null;
|
||||
if (!pair) return base;
|
||||
return state === 'closed' ? pair[0] : pair[1];
|
||||
}
|
||||
if (domain === 'binary_sensor') {
|
||||
if (deviceClass === 'door') return state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';
|
||||
if (deviceClass === 'window') return state === 'on' ? 'mdi:window-open' : 'mdi:window-closed';
|
||||
if (deviceClass === 'garage_door') return state === 'on' ? 'mdi:garage-open-variant' : 'mdi:garage-variant';
|
||||
}
|
||||
if (domain === 'cover') {
|
||||
const pair = COVER_ICONS[String(deviceClass || '')];
|
||||
if (pair) return state === 'closed' ? pair[0] : pair[1];
|
||||
// no device_class: morph only when the base icon IS one of the known
|
||||
// pairs, so a hand-picked auto icon is never swapped for a guess.
|
||||
// «Closed» is the only state that shows the closed icon: open, ajar
|
||||
// (HA reports plain 'open' with a position) and both travelling states
|
||||
// all read as open, exactly like the classed branch above.
|
||||
const own = coverPairOf(base);
|
||||
if (own) return state === 'closed' ? own[0] : own[1];
|
||||
return base;
|
||||
}
|
||||
if (domain === 'lock') return state === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant';
|
||||
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
|
||||
return base;
|
||||
|
||||
+352
-53
@@ -24,6 +24,44 @@ export function fitInSquare(ratio: number | null | undefined, side: number) {
|
||||
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
|
||||
}
|
||||
|
||||
/** Uniform backdrop scale bounds — mirrors validation.py (docs/BACKDROP.md). */
|
||||
export const PLAN_SCALE_MIN = 0.01;
|
||||
export const PLAN_SCALE_MAX = 100;
|
||||
|
||||
/**
|
||||
* WHERE THE BACKDROP IMAGE SITS (docs/BACKDROP.md).
|
||||
*
|
||||
* `fitInSquare` is only the DEFAULT placement: the image centred in the square
|
||||
* canvas at its own proportions. On top of it a space may carry three optional
|
||||
* numbers, the ones the owner produces by dragging the transform frame in the
|
||||
* backdrop editor:
|
||||
*
|
||||
* plan_x, plan_y — offset of the image's top-left corner from that default,
|
||||
* in NORMALISED units (the very units rooms, openings and
|
||||
* decor use, bounded by ±CANVAS_LIMIT);
|
||||
* plan_scale — ONE uniform multiplier for both sides, anchored at the
|
||||
* (already offset) top-left corner. There is no rotation.
|
||||
*
|
||||
* All three are OPTIONAL and their absence is exactly the pre-v1.58.0
|
||||
* behaviour, so every plan written before renders bit-identically and there is
|
||||
* no migration to run.
|
||||
*/
|
||||
export function planRect(space: any, side = NORM_W): Rect {
|
||||
const base = fitInSquare(space?.plan_aspect, side);
|
||||
const raw = Number(space?.plan_scale);
|
||||
const k = Number.isFinite(raw) && raw > 0
|
||||
? Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, raw))
|
||||
: 1;
|
||||
const dx = Number(space?.plan_x);
|
||||
const dy = Number(space?.plan_y);
|
||||
return {
|
||||
x: base.x + (Number.isFinite(dx) ? clampCanvasN(dx) : 0) * side,
|
||||
y: base.y + (Number.isFinite(dy) ? clampCanvasN(dy) : 0) * side,
|
||||
w: base.w * k,
|
||||
h: base.h * k,
|
||||
};
|
||||
}
|
||||
|
||||
export type Pt = { x: number; y: number };
|
||||
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
|
||||
|
||||
@@ -78,67 +116,323 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
|
||||
// the image's own placement — the centred default plus whatever the
|
||||
// backdrop frame has stored (plan_x/plan_y/plan_scale, docs/BACKDROP.md)
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...planRect(s, NORM_W) } : null,
|
||||
rooms: (s.rooms || []).map(scale),
|
||||
} as SpaceModel;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What the plan actually occupies, padded by `pad` of the larger side.
|
||||
*
|
||||
* The canvas is a square big enough for any house, so a small hand-drawn plan
|
||||
* used to open as a speck in the middle of it. Zooming to the CONTENT instead
|
||||
* of the canvas is what people expect — but only when there is no background
|
||||
* image: with one, the image is the plan, and cropping to the rooms would hide
|
||||
* the parts of it nobody has outlined yet.
|
||||
*
|
||||
* Returns null when there is nothing drawn, so the caller keeps the full canvas.
|
||||
*/
|
||||
export function contentBounds(
|
||||
space: SpaceModel, pad = 0.05, extra?: ReadonlyArray<readonly [number, number]>,
|
||||
): { x: number; y: number; w: number; h: number } | null {
|
||||
/* =====================================================================
|
||||
* INFINITE CANVAS (docs/CANVAS.md)
|
||||
* ===================================================================== */
|
||||
|
||||
/** Sane coordinate range in NORMALISED units — mirrors validation.py.
|
||||
* Not a frame: insurance against a stored 1e100 (HP-1500-03/HP-1501-01). */
|
||||
export const CANVAS_LIMIT = 5000;
|
||||
/** The same range in RENDER units. */
|
||||
export const SANE_LIMIT = CANVAS_LIMIT * NORM_W;
|
||||
|
||||
/** Grid points across the plan width — the lattice the editor snaps to.
|
||||
* It is derived from NORM_W alone, so it is the SAME step for every plan and
|
||||
* it did NOT change when the canvas became infinite (docs/CANVAS.md §9). */
|
||||
export const GRID_N = 240;
|
||||
/** One grid step in RENDER units. */
|
||||
export const GRID_PITCH = NORM_W / GRID_N;
|
||||
/** One grid step in NORMALISED units — what the config and the layout store. */
|
||||
export const GRID_STEP_N = 1 / GRID_N;
|
||||
|
||||
/** Snap a RENDER-unit coordinate to the editor's grid (docs/CANVAS.md §9). */
|
||||
export function snapR(v: number): number {
|
||||
if (!Number.isFinite(v)) return v;
|
||||
// integer node index first, then back — dividing by 1000/240 directly turns
|
||||
// an exact 500 into 500.00000000000006 and every equality downstream lies
|
||||
const q = (Math.round((v * GRID_N) / NORM_W) * NORM_W) / GRID_N;
|
||||
return Math.abs(q - v) <= GRID_PITCH * 1e-9 ? v : q;
|
||||
}
|
||||
/** …and a point. Used for AUTO placements (a device with no saved position, a
|
||||
* room label nobody has dragged) so that "everything is on the grid" holds for
|
||||
* what the card puts there itself, not only for what the user drags. */
|
||||
export function snapPt(p: { x: number; y: number }): { x: number; y: number } {
|
||||
return { x: snapR(p.x), y: snapR(p.y) };
|
||||
}
|
||||
|
||||
/** Clamp a RENDER-unit coordinate to the sane canvas range (docs/CANVAS.md §9).
|
||||
* This is the ONLY bound any editor gesture may impose: the plan has no edges
|
||||
* any more, only a garbage limit that mirrors validation.py. */
|
||||
export function clampCanvasR(v: number): number {
|
||||
return Number.isFinite(v) ? Math.min(SANE_LIMIT, Math.max(-SANE_LIMIT, v)) : 0;
|
||||
}
|
||||
/** The same range in NORMALISED units. */
|
||||
export function clampCanvasN(v: number): number {
|
||||
return Number.isFinite(v) ? Math.min(CANVAS_LIMIT, Math.max(-CANVAS_LIMIT, v)) : 0;
|
||||
}
|
||||
|
||||
/** Zoom-out floor: three times the content frame and no further (CANVAS.md §5). */
|
||||
export const MIN_ZOOM = 1 / 3;
|
||||
/** How far past the content frame panning may go, in screens (CANVAS.md §5). */
|
||||
export const PAN_SLACK = 1;
|
||||
|
||||
/** Outlier vote tuning — see docs/CANVAS.md §4.1. */
|
||||
export const OUTLIER_K = 10; // "an order of magnitude further"
|
||||
export const MIN_VOTERS = 4; // fewer items: nobody to be far FROM
|
||||
export const MIN_SPREAD = NORM_W * 0.05; // ~a small room: floor for the scale
|
||||
export const OUTLIER_MAX_SHARE = 1 / 3; // more than this: not strays, a wide plan
|
||||
|
||||
/** A degenerate axis (a lone marker) is grown to this, so the SVG paints. */
|
||||
export const DEGENERATE = NORM_W * 0.03;
|
||||
export const FLOOR = NORM_W * 0.2;
|
||||
|
||||
export type Rect = { x: number; y: number; w: number; h: number };
|
||||
/** One drawn/placed object, as its own bounding box (render units). */
|
||||
export type ContentItem = { minX: number; minY: number; maxX: number; maxY: number };
|
||||
|
||||
/** Bounding box of a point cloud, as a ContentItem. */
|
||||
export function itemOf(pts: ReadonlyArray<readonly [number, number] | number[]>): ContentItem | null {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
// Only points within a bounded envelope around the canvas command the
|
||||
// opening view. The server bounds what it ACCEPTS now (±4 normalised,
|
||||
// HP-1501-01), but a store may already hold an absurd coordinate from
|
||||
// before that door existed — and one 1e100 vertex framed the space so wide
|
||||
// the plan was a dot for every client. An out-of-envelope point is still
|
||||
// rendered wherever it is; it just does not decide the frame. This applies
|
||||
// to ROOM GEOMETRY and device positions alike (HP-1500-03, HP-1501-01).
|
||||
const lo = -NORM_W * 0.25, hi = NORM_W * 1.25;
|
||||
const add = (x: number, y: number) => {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
if (x < lo || x > hi || y < lo || y > hi) return;
|
||||
for (const p of pts) {
|
||||
const x = Number(p[0]), y = Number(p[1]);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
};
|
||||
for (const r of space.rooms || []) {
|
||||
if (r.poly) for (const p of r.poly) add(p[0], p[1]);
|
||||
else if (r.x != null && r.y != null) {
|
||||
add(r.x, r.y);
|
||||
add(r.x + (r.w || 0), r.y + (r.h || 0));
|
||||
}
|
||||
}
|
||||
// things that live outside any room still count as content — a gate sensor
|
||||
// by the fence, a camera on a pole (the card passes device positions here)
|
||||
for (const p of extra || []) add(p[0], p[1]);
|
||||
return minX > maxX ? null : { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/** The room's own bounding box as a content item (polygon or legacy rect). */
|
||||
export function roomItem(r: RoomCfg): ContentItem | null {
|
||||
if (r.poly && r.poly.length) return itemOf(r.poly);
|
||||
if (r.x == null || r.y == null) return null;
|
||||
return itemOf([[r.x, r.y], [r.x + (r.w || 0), r.y + (r.h || 0)]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every object of a space that counts as content (docs/CANVAS.md §4):
|
||||
* the rooms, the backdrop image rectangle, plus whatever the caller adds
|
||||
* (devices, openings, decor — the model does not carry those).
|
||||
*/
|
||||
export function contentItems(
|
||||
space: SpaceModel,
|
||||
extra?: ReadonlyArray<ContentItem | readonly [number, number]>,
|
||||
): ContentItem[] {
|
||||
const out: ContentItem[] = [];
|
||||
for (const r of space.rooms || []) { const it = roomItem(r); if (it) out.push(it); }
|
||||
// The backdrop image is ONE OF the objects of the space, exactly like a room
|
||||
// (docs/BACKDROP.md §4): cropping to the outlined rooms would hide the parts
|
||||
// of the picture nobody has drawn over yet, and — since v1.58.0 — the
|
||||
// rectangle here is the MOVED and SCALED one, so «Вписать всё» follows the
|
||||
// picture wherever the owner has dragged it.
|
||||
if (space.bg) out.push({ minX: space.bg.x, minY: space.bg.y, maxX: space.bg.x + space.bg.w, maxY: space.bg.y + space.bg.h });
|
||||
for (const e of extra || []) {
|
||||
if (Array.isArray(e)) { const it = itemOf([e as any]); if (it) out.push(it); }
|
||||
else out.push(e as ContentItem);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const median = (a: number[]): number => {
|
||||
if (!a.length) return 0;
|
||||
const s = [...a].sort((p, q) => p - q);
|
||||
const m = s.length >> 1;
|
||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||||
};
|
||||
|
||||
/** Rank-based quantile of an ALREADY SORTED array (nearest-rank, clamped). */
|
||||
const quantile = (sorted: number[], q: number): number => {
|
||||
if (!sorted.length) return 0;
|
||||
const i = Math.min(sorted.length - 1, Math.max(0, Math.round(q * (sorted.length - 1))));
|
||||
return sorted[i];
|
||||
};
|
||||
|
||||
const boxOf = (items: ContentItem[]): Rect | null => {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const it of items) {
|
||||
if (it.minX < minX) minX = it.minX;
|
||||
if (it.minY < minY) minY = it.minY;
|
||||
if (it.maxX > maxX) maxX = it.maxX;
|
||||
if (it.maxY > maxY) maxY = it.maxY;
|
||||
}
|
||||
if (minX > maxX || minY > maxY) return null;
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
};
|
||||
|
||||
/** Pad by `pad` of the longer side and lift a degenerate axis off zero. */
|
||||
function padRect(b: Rect, pad: number): Rect {
|
||||
let { x, y, w, h } = b;
|
||||
// A single marker (or a collinear row of them) has no area, and an SVG
|
||||
// viewBox with a zero axis draws nothing at all (HP-1500-03). An axis with
|
||||
// essentially no span — nothing there but icons — opens up to a floor:
|
||||
// enough canvas around a lone marker to see where it stands. A REAL thin
|
||||
// shape (a 100-unit corridor) keeps its tight frame; only the degenerate
|
||||
// case is padded, so the threshold sits at about an icon's size.
|
||||
const FLOOR = NORM_W * 0.2;
|
||||
const DEGENERATE = NORM_W * 0.03;
|
||||
if (maxX - minX < DEGENERATE) { const c = (minX + maxX) / 2; minX = c - FLOOR / 2; maxX = c + FLOOR / 2; }
|
||||
if (maxY - minY < DEGENERATE) { const c = (minY + maxY) / 2; minY = c - FLOOR / 2; maxY = c + FLOOR / 2; }
|
||||
const m = Math.max(maxX - minX, maxY - minY) * pad;
|
||||
const x = minX - m, y = minY - m;
|
||||
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
|
||||
// viewBox with a zero axis draws nothing at all (HP-1500-03). Only the
|
||||
// DEGENERATE case is inflated — a real 100-unit corridor keeps its frame.
|
||||
if (w < DEGENERATE) { x = x + w / 2 - FLOOR / 2; w = FLOOR; }
|
||||
if (h < DEGENERATE) { y = y + h / 2 - FLOOR / 2; h = FLOOR; }
|
||||
const m = Math.max(w, h) * pad;
|
||||
return { x: x - m, y: y - m, w: w + m * 2, h: h + m * 2 };
|
||||
}
|
||||
|
||||
export interface ContentFrame {
|
||||
/** The main mass, padded — the opening view. */
|
||||
core: Rect | null;
|
||||
/** Everything, padded — what "show the far objects" fits. */
|
||||
all: Rect | null;
|
||||
/** How many items the core deliberately leaves out. */
|
||||
outliers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The content frame (docs/CANVAS.md §4). Pure and rank-based: one absurd
|
||||
* coordinate can move neither the median nor the 75th percentile, so it
|
||||
* cannot decide the frame — but it is never DELETED either, it is simply
|
||||
* not a voter, and "show everything" still reaches it.
|
||||
*/
|
||||
export function contentFrame(
|
||||
items: ReadonlyArray<ContentItem>,
|
||||
opts: { pad?: number; k?: number; minSpread?: number } = {},
|
||||
): ContentFrame {
|
||||
const pad = opts.pad ?? 0.05;
|
||||
const k = opts.k ?? OUTLIER_K;
|
||||
const minSpread = opts.minSpread ?? MIN_SPREAD;
|
||||
// Step 1 — corruption, not content: anything past the range the backend
|
||||
// itself accepts is dropped outright (it is not reachable by "show all"
|
||||
// either, because it is not a coordinate anybody meant).
|
||||
const sane = items.filter((it) =>
|
||||
Number.isFinite(it.minX) && Number.isFinite(it.minY)
|
||||
&& Number.isFinite(it.maxX) && Number.isFinite(it.maxY)
|
||||
&& Math.abs(it.minX) <= SANE_LIMIT && Math.abs(it.maxX) <= SANE_LIMIT
|
||||
&& Math.abs(it.minY) <= SANE_LIMIT && Math.abs(it.maxY) <= SANE_LIMIT);
|
||||
if (!sane.length) return { core: null, all: null, outliers: 0 };
|
||||
const all = boxOf(sane)!;
|
||||
if (sane.length < MIN_VOTERS) {
|
||||
const r = padRect(all, pad);
|
||||
return { core: r, all: r, outliers: 0 };
|
||||
}
|
||||
const cx = sane.map((it) => (it.minX + it.maxX) / 2);
|
||||
const cy = sane.map((it) => (it.minY + it.maxY) / 2);
|
||||
const mx = median(cx), my = median(cy);
|
||||
const d = sane.map((_, i) => Math.max(Math.abs(cx[i] - mx), Math.abs(cy[i] - my)));
|
||||
const spread = Math.max(quantile([...d].sort((p, q) => p - q), 0.75), minSpread);
|
||||
const far = d.map((v) => v > k * spread);
|
||||
const n = far.filter(Boolean).length;
|
||||
// Majority veto: a plan whose objects are simply spread out has no strays.
|
||||
const keep = n && n <= sane.length * OUTLIER_MAX_SHARE ? sane.filter((_, i) => !far[i]) : sane;
|
||||
const core = boxOf(keep) || all;
|
||||
return {
|
||||
core: padRect(core, pad),
|
||||
all: padRect(all, pad),
|
||||
outliers: keep === sane ? 0 : n,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the plan actually occupies, padded by `pad` of the larger side —
|
||||
* the MAIN MASS only (docs/CANVAS.md §4.1). Returns null when nothing is
|
||||
* drawn, so the caller can fall back to the stored view_box hint.
|
||||
*
|
||||
* Kept as the narrow entry point used by the renderers; `contentFrame`
|
||||
* is the one that also reports the outliers and the fit-everything box.
|
||||
*/
|
||||
export function contentBounds(
|
||||
space: SpaceModel, pad = 0.05, extra?: ReadonlyArray<readonly [number, number]>,
|
||||
): Rect | null {
|
||||
return contentFrame(contentItems(space, extra), { pad }).core;
|
||||
}
|
||||
|
||||
/** The rectangle a renderer frames a space with: content, else the stored
|
||||
* view_box HINT, else the legacy unit square (docs/CANVAS.md §4). */
|
||||
export function spaceFrame(
|
||||
space: SpaceModel,
|
||||
extra?: ReadonlyArray<ContentItem | readonly [number, number]>,
|
||||
pad = 0.05,
|
||||
): Rect {
|
||||
const f = contentFrame(contentItems(space, extra), { pad });
|
||||
if (f.core) return f.core;
|
||||
const vb = space.vb && space.vb.length === 4 && space.vb[2] > 0 && space.vb[3] > 0
|
||||
? space.vb : [0, 0, NORM_W, NORM_W];
|
||||
return { x: vb[0], y: vb[1], w: vb[2], h: vb[3] };
|
||||
}
|
||||
|
||||
/** Middle of the content (fallback anchor for a device with no position). */
|
||||
export function spaceCenter(space: SpaceModel): Pt {
|
||||
const r = spaceFrame(space);
|
||||
return { x: r.x + r.w / 2, y: r.y + r.h / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The BASE UNIT one icon-percent is measured against, in render units
|
||||
* (docs/CANVAS.md §6). It is both the icon's own footprint and the
|
||||
* auto-placement spacing, so the two can never drift apart.
|
||||
*
|
||||
* For any plan that fits the old square this is exactly NORM_W — which is
|
||||
* what `vb.w` was for every plan the card itself ever wrote (the editor only
|
||||
* ever stored `view_box: [0,0,1,1]`), so sizes and layouts are bit-identical
|
||||
* to the pre-infinite-canvas card. A plan drawn three canvases wide gets a
|
||||
* 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); }
|
||||
// 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)));
|
||||
}
|
||||
|
||||
/**
|
||||
* `--icon-size` in cqw (docs/CANVAS.md §6) — the ONE expression both
|
||||
* renderers use.
|
||||
*
|
||||
* An icon is a percentage of the PLAN, not of the viewport: it scales with
|
||||
* the plan as you zoom, exactly as it did before the infinite canvas (owner,
|
||||
* 2026-08-03). In render units the marker always occupies
|
||||
* `iconPct/100 * iconUnit`, whatever the frame or the zoom happens to be;
|
||||
* dividing by the width of the visible view turns that into a percentage of
|
||||
* the container, which is what `cqw` means.
|
||||
*
|
||||
* The only thing the infinite canvas changed here is the numerator: it used
|
||||
* to be `vb.w`, the stored view_box, and a plan drawn past the old square
|
||||
* kept a 1000-unit numerator while its frame grew to tens of thousands —
|
||||
* markers would shrink to invisible dots. `iconUnit` is that same 1000 for
|
||||
* an ordinary plan and grows with an outsized one.
|
||||
*/
|
||||
export function iconCqw(
|
||||
iconPct: number, space: SpaceModel, viewW: number, kioskIcon = 1,
|
||||
): number {
|
||||
const w = Number(viewW);
|
||||
const k = Number.isFinite(kioskIcon) && kioskIcon > 0 ? kioskIcon : 1;
|
||||
// No view yet (first paint, zero-width stage): fall back to the plain
|
||||
// percentage rather than to Infinity/NaN — the frame arrives a tick later.
|
||||
if (!Number.isFinite(w) || w <= 0) return iconPct * k;
|
||||
return (iconPct * iconUnit(space) * k) / w;
|
||||
}
|
||||
|
||||
/** Grid step multipliers offered to the adaptive grid (docs/CANVAS.md §7). */
|
||||
export const GRID_STEPS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
|
||||
|
||||
/**
|
||||
* Adaptive grid density: which multiple of the base pitch is still legible
|
||||
* at the current scale, and which coarser one carries the accent dots.
|
||||
* `null` = even the coarsest step is sub-pixel, draw no grid at all.
|
||||
*/
|
||||
export function gridLevels(
|
||||
pitch: number, pxPerUnit: number, minPx = 7,
|
||||
): { fine: number; coarse: number } | null {
|
||||
if (!(pitch > 0) || !(pxPerUnit > 0) || !Number.isFinite(pxPerUnit)) return null;
|
||||
const fine = GRID_STEPS.find((m) => pitch * m * pxPerUnit >= minPx);
|
||||
if (fine === undefined) return null;
|
||||
const coarse = GRID_STEPS.find((m) => m >= fine * 5) ?? fine * 5;
|
||||
return { fine, coarse };
|
||||
}
|
||||
|
||||
/** Bounding rectangle of a room (rect or polygon) in render units. */
|
||||
@@ -165,7 +459,10 @@ export function roomCenter(r: RoomCfg): number[] {
|
||||
/** Auto grid positions for a single space's area devices (identical to the full card). */
|
||||
export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: number): Record<string, Pt> {
|
||||
const map: Record<string, Pt> = {};
|
||||
const minDist = (iconPct / 100) * NORM_W * 1.3;
|
||||
// NOT a bare NORM_W any more (docs/CANVAS.md §6): on a plan wider than the
|
||||
// old square the icons are proportionally larger in render units, so the
|
||||
// declump distance has to grow with the plan or auto-placed markers overlap.
|
||||
const minDist = (iconPct / 100) * iconUnit(model) * 1.3;
|
||||
for (const r of model.rooms) {
|
||||
if (!r.area) continue;
|
||||
const ds = devs.filter((d) => d.area === r.area);
|
||||
@@ -182,7 +479,7 @@ export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: nu
|
||||
y: b.y + pad + ch * (Math.floor(i / cols) + 0.5),
|
||||
}));
|
||||
declump(pts, b, minDist, pad * 0.5);
|
||||
ds.forEach((d, i) => (map[d.id] = pts[i]));
|
||||
ds.forEach((d, i) => (map[d.id] = snapPt(pts[i])));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -194,8 +491,9 @@ export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos:
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
if (defPos[d.id]) return defPos[d.id];
|
||||
const vb = model.vb;
|
||||
return { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 };
|
||||
// no saved position, no room to auto-place in: the middle of what IS drawn,
|
||||
// not the middle of a canvas that no longer has edges (docs/CANVAS.md)
|
||||
return snapPt(spaceCenter(model));
|
||||
}
|
||||
|
||||
/** Saved room-label position (layout key rl_<roomId>) or the room centre. */
|
||||
@@ -204,6 +502,7 @@ export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: Serve
|
||||
if (saved && saved.s === spaceId) {
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
// never dragged: the centroid, put on the nearest node (docs/CANVAS.md §9)
|
||||
const c = roomCenter(r);
|
||||
return { x: c[0], y: c[1] };
|
||||
return snapPt({ x: c[0], y: c[1] });
|
||||
}
|
||||
|
||||
+38
-19
@@ -14,7 +14,8 @@ import { t, type Lang } from './i18n';
|
||||
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
|
||||
import type { ServerConfig } from './types';
|
||||
import {
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
|
||||
type Layout, type ContentItem,
|
||||
} from './space-geometry';
|
||||
|
||||
export { spaceModels } from './space-geometry';
|
||||
@@ -43,7 +44,6 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const models = spaceModels(o.cfg);
|
||||
const space = models.find((s) => s.id === o.spaceId);
|
||||
if (!space) return null;
|
||||
const vb = space.vb;
|
||||
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
|
||||
const cfgSize = o.iconSize ?? 2.5;
|
||||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||
@@ -80,6 +80,24 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
// two cards (HP-1511-01). Rendering still draws `devs` only.
|
||||
const defPos = defaultPositions(spaceDevs, space, iconPct);
|
||||
|
||||
// docs/CANVAS.md §4: the static card frames the CONTENT, exactly like the
|
||||
// full one — `space.vb` is only the stored hint now, and on a plan drawn
|
||||
// past the old unit square it framed empty canvas with the house off-screen.
|
||||
// Markers placed outside every room count too (a gate sensor by the fence)
|
||||
// — but only the ones this card DRAWS: a hidden device is never painted
|
||||
// here, so it must not stretch the frame either (DEV-2C947-01). It keeps
|
||||
// its grid cell above; the frame is presentation, the roster is not.
|
||||
const placed: ContentItem[] = [];
|
||||
for (const d of devs) {
|
||||
const sv = o.layout[d.id];
|
||||
if (sv && sv.s === o.spaceId) {
|
||||
const x = sv.x * NORM_W, y = sv.y * NORM_W;
|
||||
placed.push({ minX: x, minY: y, maxX: x, maxY: y });
|
||||
}
|
||||
}
|
||||
const fr = spaceFrame(space, placed);
|
||||
const vb = [fr.x, fr.y, fr.w, fr.h];
|
||||
|
||||
const roomShapes = space.rooms
|
||||
.filter((r) => r.area || disp.showBorders)
|
||||
.map((r) => {
|
||||
@@ -158,32 +176,33 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
}
|
||||
const stageBg = sunBg || stageBgOf(o.cfg?.settings, disp);
|
||||
|
||||
// Opaque plan paper (owner 2026-08-03), same contract as the full card: the
|
||||
// scene background never bleeds through the plan. With a backdrop image the
|
||||
// paper is the image rect; a hand-drawn plan papers the ROOM CONTOURS
|
||||
// (paperRoomShapes) — never their bounding box, so the scene colour reaches
|
||||
// the exterior walls of an L-shaped house and fills the gaps between
|
||||
// detached buildings. An empty drawn space has no paper at all.
|
||||
const paper = space.bg
|
||||
? { x: space.bg.x, y: space.bg.y, w: space.bg.w, h: space.bg.h }
|
||||
: null;
|
||||
// Opaque plan paper, same contract as the full card (docs/BACKDROP.md §3):
|
||||
// the paper is ALWAYS the ROOM CONTOURS and only them — never their bounding
|
||||
// box, and (since v1.58.0) never the backdrop image rect either. The scene
|
||||
// colour therefore reaches the exterior walls of an L-shaped house, fills the
|
||||
// gaps between detached buildings, and an empty space has no paper at all,
|
||||
// image or no image. The picture is drawn ON the paper, one layer above.
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
|
||||
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
||||
${paper
|
||||
? svg`<rect class="hp-paper" x="${paper.x}" y="${paper.y}" width="${paper.w}" height="${paper.h}"></rect>`
|
||||
: paperRoomShapes(space.rooms).map((sh) =>
|
||||
'poly' in sh
|
||||
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
|
||||
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
|
||||
)}
|
||||
${paperRoomShapes(space.rooms).map((sh) =>
|
||||
'poly' in sh
|
||||
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
|
||||
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
|
||||
)}
|
||||
${bgHref
|
||||
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${roomShapes}
|
||||
</svg>
|
||||
<div class="devlayer" style="--icon-size:${iconPct}cqw">${markers}${labels}</div>
|
||||
${''/* docs/CANVAS.md §6: the same expression as the full card. The
|
||||
static card has no zoom, but its frame is the CONTENT now, so a
|
||||
bare `iconPct` would make markers shrink relative to the plan the
|
||||
tighter the frame got. `iconCqw` keeps the marker's footprint at
|
||||
iconPct% of the plan's base unit, which is what it was when the
|
||||
frame was the stored view_box. */}
|
||||
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, vb[2]).toFixed(3)}cqw">${markers}${labels}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
+212
-1
@@ -184,6 +184,14 @@ export const cardStyles = css`
|
||||
.stage.daynight.hpsettle {
|
||||
transition: height 0.25s ease, background-color 45s linear;
|
||||
}
|
||||
/* Catch-up frame (docs/SUN.md): the sky is out of date because the card
|
||||
was not painting — show the truth at once, the glide comes back on the
|
||||
very next frame. */
|
||||
.stage.daynight.skysnap,
|
||||
.stage.daynight.skysnap .zoomwrap,
|
||||
.stage.daynight.skysnap.hpsettle {
|
||||
transition: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stage.daynight,
|
||||
.stage.daynight .zoomwrap,
|
||||
@@ -314,6 +322,60 @@ export const cardStyles = css`
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* docs/CANVAS.md §4.1: objects an order of magnitude away from the plan
|
||||
do not decide the opening view — one quiet chip says so and offers to
|
||||
take them in. Never a modal (owner). */
|
||||
.farhint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: var(--sp-4);
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
max-width: calc(100% - var(--sp-8));
|
||||
background: var(--card-background-color, var(--hp-bg));
|
||||
opacity: 0.94;
|
||||
color: var(--hp-txt);
|
||||
border: 1px solid var(--divider-color, #33404d);
|
||||
border-radius: var(--rad-m);
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
font-size: var(--fs-s);
|
||||
}
|
||||
.farhint ha-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
color: var(--hp-warn, #e2a03f);
|
||||
flex: none;
|
||||
}
|
||||
.farhint span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* docs/CANVAS.md §5: the plane has no edges, so you can pan until nothing
|
||||
is on screen. One pointer home, one click back. */
|
||||
.homearrow {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: var(--card-background-color, var(--hp-bg));
|
||||
color: var(--hp-accent);
|
||||
border: 1px solid var(--hp-accent);
|
||||
opacity: 0.9;
|
||||
padding: 0;
|
||||
}
|
||||
.homearrow ha-icon {
|
||||
--mdc-icon-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
.zoombadge {
|
||||
position: absolute;
|
||||
left: var(--sp-4);
|
||||
@@ -358,9 +420,19 @@ export const cardStyles = css`
|
||||
.stage.daynight .hp-paperg {
|
||||
filter: drop-shadow(0 2px 8px rgba(10, 16, 26, 0.28));
|
||||
}
|
||||
/* Owner 2026-08-04: «углы границ комнат всё ещё с зубцами». A miter join
|
||||
on a 30-45° corner shoots a spike far past the wall (and flips to an
|
||||
ugly bevel once past the miter limit) — the same defect the decor lines
|
||||
had before they got round caps. Every room border, in EVERY renderer
|
||||
that reuses these styles (plan view, plan editor, static space-card),
|
||||
joins its walls with a ROUND join instead: the corner reads as the
|
||||
stroke's own radius, never as a tooth. The linecap matters only for the
|
||||
open outlines below, but it costs nothing to state it here. */
|
||||
.room {
|
||||
transition: 0.12s;
|
||||
cursor: default; /* v1.40.1: rooms are not clickable — the label's link icon is */
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.room.overlay {
|
||||
fill: transparent;
|
||||
@@ -814,6 +886,16 @@ export const cardStyles = css`
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage.mode-decor.dtool-select .decorlayer .dshape { cursor: move; }
|
||||
/* …but a DRAWING tool owns the canvas: existing shapes stop being targets
|
||||
entirely, so a new line can start exactly on the end of an old one
|
||||
instead of grabbing it (owner, 2026-08-04). Same for the picture tool —
|
||||
a shape lying over the plan must not block the picture's own drag.
|
||||
Only select (move) and erase (delete) keep the shapes clickable. */
|
||||
.stage.mode-decor.dtool-line .decorlayer .dshape,
|
||||
.stage.mode-decor.dtool-rect .decorlayer .dshape,
|
||||
.stage.mode-decor.dtool-ellipse .decorlayer .dshape,
|
||||
.stage.mode-decor.dtool-text .decorlayer .dshape,
|
||||
.stage.mode-decor.dtool-backdrop .decorlayer .dshape { pointer-events: none; }
|
||||
.decorlayer .dsel {
|
||||
filter: drop-shadow(0 0 3px var(--hp-accent));
|
||||
}
|
||||
@@ -832,6 +914,37 @@ export const cardStyles = css`
|
||||
outline: 2px solid #26a69a;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* backdrop transform frame (docs/BACKDROP.md §2). Editor chrome: the
|
||||
outline never takes a pointer, the four corner handles do — and they are
|
||||
finger-sized (r = 2 % of the visible view), because this is dragged on a
|
||||
tablet as often as with a mouse. */
|
||||
.bdframe .bdbox {
|
||||
fill: none;
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 10 7;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.bdframe .bdhandle {
|
||||
fill: var(--hp-accent);
|
||||
stroke: #fff;
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: all;
|
||||
touch-action: none;
|
||||
}
|
||||
.bdframe .bd-nwse { cursor: nwse-resize; }
|
||||
.bdframe .bd-nesw { cursor: nesw-resize; }
|
||||
/* the picture itself is the drag target for a move (grab, then grabbing) */
|
||||
.stage.mode-decor.bdgrab { cursor: grab; }
|
||||
.stage.mode-decor.bdgrabbing,
|
||||
.stage.mode-decor.bdgrabbing .bdframe .bdhandle { cursor: grabbing; }
|
||||
.measurelabel.bdmeasure {
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid var(--hp-accent);
|
||||
}
|
||||
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
|
||||
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
|
||||
cursor: crosshair;
|
||||
@@ -853,6 +966,13 @@ export const cardStyles = css`
|
||||
background: var(--hp-bg2, transparent); color: var(--hp-txt); border: 1px solid var(--hp-muted);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
.decorbar .bdhint {
|
||||
font-size: var(--fs-s);
|
||||
color: var(--hp-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.decorbar .dfill {
|
||||
display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-s); cursor: pointer;
|
||||
}
|
||||
@@ -904,9 +1024,15 @@ export const cardStyles = css`
|
||||
.room.noedge {
|
||||
stroke-opacity: 0 !important;
|
||||
}
|
||||
/* rooms with open boundaries draw their walls as separate M..L subpaths,
|
||||
so a corner between two of them is two stroke ENDS meeting: round caps
|
||||
fill it in the same way a round join fills a closed contour's corner
|
||||
(owner 2026-08-04 — no teeth anywhere on a room border). */
|
||||
.room-outline {
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Plan editor: trimmed outlines use the markup blue */
|
||||
@@ -999,16 +1125,29 @@ export const cardStyles = css`
|
||||
stroke-width: 3;
|
||||
fill: rgba(255, 193, 77, 0.25);
|
||||
}
|
||||
/* Owner 2026-08-04: the grid is a HINT, not content — at full strength the
|
||||
dots argued with the plan on white paper. Both levels are muted, the
|
||||
hierarchy is kept (major still denser than fine). */
|
||||
.griddot {
|
||||
fill: var(--hp-accent);
|
||||
opacity: 0.75;
|
||||
opacity: 0.35;
|
||||
stroke: rgba(0, 0, 0, 0.35);
|
||||
stroke-width: 0.4;
|
||||
}
|
||||
/* docs/CANVAS.md §7: every coarse node (5x/10x the live step) keeps a
|
||||
bigger, more opaque dot, so zoomed far out the grid still reads as a
|
||||
grid instead of a grey wash. */
|
||||
.griddot.major {
|
||||
opacity: 0.5;
|
||||
stroke-width: 0;
|
||||
}
|
||||
/* the contour being drawn in the Plan editor: each wall is its own <line>,
|
||||
so the round cap IS the corner (matches the finished .room border) */
|
||||
.seg {
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.pathline {
|
||||
stroke: #ffc14d;
|
||||
@@ -1106,6 +1245,50 @@ export const cardStyles = css`
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* COVER ON THE MOVE (owner 2026-08-03): «не жёлтая подложка, а лёгкая
|
||||
пульсация вокруг значка в стиле шайбы пылесоса». Same yellow as the
|
||||
sense rings, the vacuum puck's 2.2s period, and a moderate opacity so
|
||||
two curtains travelling at once never turn into a strobe. The plate
|
||||
itself stays neutral — yellow means «включено», nothing else. */
|
||||
/* Sun wedges (docs/SUN.md). The layer is present ONLY above the 3°
|
||||
threshold; crossing it fades the whole layer in or out over EXACTLY
|
||||
2 s (owner 2026-08-03 — RAY_FADE_MS in src/sun.ts must match). The
|
||||
geometry is untouched: this is a plain opacity animation on the group,
|
||||
so overlapping wedges keep their own blending while it plays. */
|
||||
.sunlayer {
|
||||
animation: hp-sunfade-in 2s linear both;
|
||||
}
|
||||
.sunlayer.out {
|
||||
animation: hp-sunfade-out 2s linear both;
|
||||
}
|
||||
@keyframes hp-sunfade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes hp-sunfade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* no fade at all: the rays are simply there or simply gone */
|
||||
.sunlayer, .sunlayer.out { animation: none; }
|
||||
.sunlayer.out { opacity: 0; }
|
||||
}
|
||||
.dev.covermove::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
|
||||
border: 2px solid var(--hp-on);
|
||||
border-radius: 50%;
|
||||
opacity: 0.45;
|
||||
animation: hp-covermove 2.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes hp-covermove {
|
||||
0% { transform: scale(0.92); opacity: 0.16; }
|
||||
50% { transform: scale(1.12); opacity: 0.5; }
|
||||
100% { transform: scale(0.92); opacity: 0.16; }
|
||||
}
|
||||
@keyframes hp-sense {
|
||||
0% { transform: scale(0.9); opacity: 0.5; }
|
||||
60% { transform: scale(1.12); opacity: 0.12; }
|
||||
@@ -1139,6 +1322,9 @@ export const cardStyles = css`
|
||||
by design already */
|
||||
.dev.senseflash::after,
|
||||
.dev.senseflash.sf2::after { animation: none; opacity: 0.4; } /* HP-1543-02: retrip re-arms the window, ring stays static */
|
||||
/* a travelling cover keeps a STATIC ring — the movement is still shown,
|
||||
it just stops breathing */
|
||||
.dev.covermove::after { animation: none; opacity: 0.4; }
|
||||
}
|
||||
.dev .newdot {
|
||||
position: absolute;
|
||||
@@ -1627,6 +1813,12 @@ export const cardStyles = css`
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* the decor draft badge rides the MIDDLE of the shape, so it is centred
|
||||
horizontally and lifted clear of the line instead of trailing the
|
||||
cursor the way a wall badge does (owner 2026-08-04) */
|
||||
.measurelabel.dmeasure {
|
||||
transform: translate(-50%, -160%);
|
||||
}
|
||||
.measurelabel.rszarea {
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
@@ -1858,6 +2050,25 @@ export const cardStyles = css`
|
||||
font-size: var(--fs-m);
|
||||
color: var(--hp-muted);
|
||||
}
|
||||
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
|
||||
.btn.alignall { width: 100%; justify-content: center; }
|
||||
.aboutver {
|
||||
font-size: var(--fs-s);
|
||||
color: var(--hp-muted);
|
||||
margin: var(--sp-2) 0 var(--sp-3);
|
||||
}
|
||||
.aboutlink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: fit-content;
|
||||
color: var(--hp-accent);
|
||||
text-decoration: none;
|
||||
font-size: var(--fs-m);
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
.aboutlink:hover { text-decoration: underline; }
|
||||
.aboutlink ha-icon { --mdc-icon-size: 18px; line-height: 1; }
|
||||
.dialogwrap {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
|
||||
+174
-16
@@ -119,26 +119,70 @@ export function windowWallInfo(
|
||||
: { normal: n, roomId: minus!.id };
|
||||
}
|
||||
|
||||
/** Does the sun actually shine INTO this window right now? (A grazing sun
|
||||
* exactly along the wall does not count — hence the epsilon, which also
|
||||
* swallows the sin/cos float dust of the right-angle directions.) */
|
||||
/**
|
||||
* How square the sun has to be to a wall before that wall's windows cast
|
||||
* anything: the cosine of the angle of incidence, i.e. `outward normal · dir
|
||||
* to the sun`. 0.05 is ~87.1°, so the sun has to clear the plane of the wall
|
||||
* by ~2.9° — the same order as the 3° elevation threshold, and for the same
|
||||
* reason: below it there is no light worth painting. Glass agrees (Fresnel
|
||||
* reflects almost everything at that incidence), and so does the geometry —
|
||||
* the shaft's perpendicular depth is `len · cos`, so under this threshold the
|
||||
* whole wedge is a sliver thinner than the wall it came through, drawn with a
|
||||
* gradient axis shorter than a pixel (DEV-EB173-01).
|
||||
*/
|
||||
export const RAY_MIN_COS = 0.05;
|
||||
|
||||
/** Does the sun actually shine INTO this window right now? (A sun grazing
|
||||
* along the wall does not count — see RAY_MIN_COS, which also swallows the
|
||||
* sin/cos float dust of the right-angle directions.) */
|
||||
export function windowLit(normal: number[], sunDir: number[], elevation: number): boolean {
|
||||
return elevation > 0 && normal[0] * sunDir[0] + normal[1] * sunDir[1] > 1e-9;
|
||||
return elevation > 0 && normal[0] * sunDir[0] + normal[1] * sunDir[1] > RAY_MIN_COS;
|
||||
}
|
||||
|
||||
// ---------------- wedge geometry ----------------
|
||||
|
||||
/**
|
||||
* Wedge length in WINDOW LENGTHS: longest (~2.5) at sunrise/sunset, shortest
|
||||
* (~0.8) at the zenith. `0.8 + 1.7·(1 − e/90)^1.6` — long low shafts, short
|
||||
* noon pools, smooth in between (docs/SUN.md).
|
||||
* Wedge length in WINDOW LENGTHS: longest (~1.75) at sunrise/sunset, shortest
|
||||
* (~0.56) at the zenith. `0.56 + 1.19·(1 − e/90)^1.6` — long low shafts, short
|
||||
* noon pools, smooth in between (docs/SUN.md). Owner 2026-08-04: «лучи от
|
||||
* солнца сделать короче на 30%» — the whole curve is the old
|
||||
* `0.8 + 1.7·(1 − e/90)^1.6` scaled by RAY_LENGTH_K, so the "low sun reaches
|
||||
* further" shape is untouched.
|
||||
*/
|
||||
export const RAY_LENGTH_K = 0.7;
|
||||
|
||||
export function rayLength(elevation: number): number {
|
||||
const e = Math.min(90, Math.max(0, elevation));
|
||||
return 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
|
||||
return RAY_LENGTH_K * (0.8 + 1.7 * Math.pow(1 - e / 90, 1.6));
|
||||
}
|
||||
|
||||
/** The unclipped wedge: window span a-b extruded by `len` along `dir`. */
|
||||
/**
|
||||
* The unclipped wedge: the window span a-b extruded along `dir` by the SAME
|
||||
* `len` at both ends. An honest parallelogram — every ray through the glass
|
||||
* travels exactly the wedge's reach, so the promised "30 % shorter" holds for
|
||||
* each side of every wedge, at any sun angle.
|
||||
*
|
||||
* Its far edge is parallel to the WALL, and that is not a compromise: it is
|
||||
* the iso-alpha line of the gradient the card actually draws. For parallel
|
||||
* rays the distance travelled from the glass is `depth / cos`, an affine
|
||||
* function of the point whose level sets are lines PARALLEL TO THE WALL, so
|
||||
* the fade must run along the wall's NORMAL (see `SunRay.normal/depth` and
|
||||
* docs/SUN.md), not along `dir`. With that axis all three invariants hold at
|
||||
* once: the whole pane of glass sits at offset 0 (peak alpha end to end), the
|
||||
* alpha at any point depends only on how far its own ray has travelled, and
|
||||
* the wedge's far edge coincides with the gradient's end — a bright kerb is
|
||||
* impossible by construction.
|
||||
*
|
||||
* The previous attempt (DEV-EB173-01) kept the gradient along `dir` from the
|
||||
* span's midpoint and bent the GEOMETRY to match, extruding the two ends by
|
||||
* different amounts. At a grazing sun that put one end of the glass itself at
|
||||
* offset 0.88 — fully transparent before the shaft even started — and made
|
||||
* the long side 88 % longer than the nominal reach instead of 30 % shorter.
|
||||
*
|
||||
* The two SIDES stay razor-sharp on purpose — owner 2026-08-04: «с лучами
|
||||
* солнца ты сделал фигню — не надо размывать их боковые грани». A shaft of
|
||||
* light through a window HAS crisp sides; only its reach fades.
|
||||
*/
|
||||
export function rayQuad(a: number[], b: number[], dir: number[], len: number): number[][] {
|
||||
return [
|
||||
[a[0], a[1]],
|
||||
@@ -177,8 +221,21 @@ export interface SunRay {
|
||||
b: number[];
|
||||
/** Direction the light travels (AWAY from the sun), unit vector. */
|
||||
dir: [number, number];
|
||||
/** Wedge reach in render units (the gradient's fade distance). */
|
||||
/** Wedge reach in render units: how far along `dir` every ray travels. */
|
||||
len: number;
|
||||
/**
|
||||
* INWARD wall normal (unit) — the axis of the fade. The distance a point
|
||||
* has travelled from the glass is the same affine function of the point as
|
||||
* its depth under the wall, so the gradient's iso-alpha lines are parallel
|
||||
* to the wall and its axis is this normal (docs/SUN.md, DEV-EB173-01).
|
||||
*/
|
||||
normal: [number, number];
|
||||
/**
|
||||
* Length of that axis: `len · (dir·normal)` — the perpendicular depth a ray
|
||||
* reaches after travelling `len`. A point `source + dir·u` therefore lands
|
||||
* at offset `u/len`: the glass is all at 0, the far edge all at 1.
|
||||
*/
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,20 +270,53 @@ export function computeSunRays(
|
||||
const len = k * w.length;
|
||||
const polys = clipToRoom(rayQuad(a, b, away, len), room.poly);
|
||||
if (!polys.length) continue;
|
||||
out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len });
|
||||
// inward normal + how deep the ray gets: cos of the incidence angle,
|
||||
// which windowLit() has already found to be above RAY_MIN_COS
|
||||
const normal: [number, number] = [-info.normal[0], -info.normal[1]];
|
||||
const cos = away[0] * normal[0] + away[1] * normal[1];
|
||||
out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len,
|
||||
normal, depth: len * cos });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------- wedge dressing ----------------
|
||||
|
||||
/** Peak wedge opacity; two overlapping wedges stay readable (docs/SUN.md). */
|
||||
export const RAY_MAX_ALPHA = 0.18;
|
||||
/**
|
||||
* Peak wedge opacity (owner 2026-08-03: «лучи поярче, иногда плохо видны» —
|
||||
* raised from 0.18). Two overlapping wedges still stay under a readable
|
||||
* ceiling on white paper AND on the dark glow canvas (docs/SUN.md).
|
||||
*/
|
||||
export const RAY_MAX_ALPHA = 0.3;
|
||||
|
||||
/** Wedge opacity: ramps in over the first ~2° so sunrise never pops. */
|
||||
/**
|
||||
* The elevation threshold, degrees. Below it there are NO rays at all, above
|
||||
* it they are at FULL strength — the owner's 2026-08-03 contract replacing
|
||||
* the old gradual ramp-in. The switch itself is not instant: the card fades
|
||||
* the whole layer in/out over RAY_FADE_MS (CSS, not geometry).
|
||||
*/
|
||||
export const RAY_ELEVATION_MIN = 3;
|
||||
|
||||
/** Duration of that fade, ms — «ровно 2 секунды» (mirrored in styles.ts). */
|
||||
export const RAY_FADE_MS = 2000;
|
||||
|
||||
/** Are the wedges present at this elevation at all? (The hard 3° threshold.) */
|
||||
export function raysVisible(elevation: number): boolean {
|
||||
return Number(elevation) >= RAY_ELEVATION_MIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-strength wedge opacity for the current cloud cover — elevation plays
|
||||
* no part. The card feeds this into the gradient and lets CSS fade the layer,
|
||||
* so a wedge dissolving at the threshold keeps its colour while it goes.
|
||||
*/
|
||||
export function rayPeakAlpha(cloud = 1): number {
|
||||
return RAY_MAX_ALPHA * clamp01(cloud);
|
||||
}
|
||||
|
||||
/** Wedge opacity: nothing below the threshold, full strength above it. */
|
||||
export function rayAlpha(elevation: number, cloud = 1): number {
|
||||
if (!(elevation > 0)) return 0;
|
||||
return RAY_MAX_ALPHA * Math.min(1, elevation / 2) * clamp01(cloud);
|
||||
return raysVisible(elevation) ? rayPeakAlpha(cloud) : 0;
|
||||
}
|
||||
|
||||
/** Wedge color: warm orange at the horizon → neutral daylight. */
|
||||
@@ -234,6 +324,74 @@ export function rayColor(warmth: number): string {
|
||||
return lerpColor('#ffe9c2', '#ff9a45', clamp01(warmth));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the shaft is already fully dissolved, as a fraction of its own length.
|
||||
* Owner 2026-08-04: «проверить, чтобы они всегда плавно рассеивались (сейчас
|
||||
* есть ощущение, что они упираются во что-то невидимое)». The old gradient ran
|
||||
* to alpha 0 exactly AT the far edge, so any wedge that ended in mid-air still
|
||||
* carried a sliver of colour up to its last pixel — and the eye reads the
|
||||
* straight line of a polygon edge long before the alpha reaches zero. The last
|
||||
* visible light now sits at 85 % of the length; the remaining 15 % is empty.
|
||||
*/
|
||||
export const RAY_FADE_END = 0.85;
|
||||
|
||||
/**
|
||||
* Gradient stops along the shaft: `[offset 0..1, share of the peak alpha]`.
|
||||
* Convex ease-out — bright at the glass, half gone by a third of the way,
|
||||
* a whisper at two thirds, nothing from RAY_FADE_END on. Consumed by the card
|
||||
* as SVG <stop>s over the FULL wedge length, so the geometry and the gradient
|
||||
* always describe the same shaft (docs/SUN.md).
|
||||
*
|
||||
* This gradient is the ONLY thing that dissolves a wedge: the falloff runs
|
||||
* along the ray, from the glass inward, and the sides of the shaft keep the
|
||||
* hard edge light actually has (owner 2026-08-04: «не надо размывать их
|
||||
* боковые грани»). No blur is involved anywhere.
|
||||
*/
|
||||
export function rayStops(): [number, number][] {
|
||||
return [
|
||||
[0, 1],
|
||||
[0.26, 0.86],
|
||||
[0.46, 0.6],
|
||||
[0.64, 0.32],
|
||||
[0.77, 0.1],
|
||||
[RAY_FADE_END, 0],
|
||||
[1, 0],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Day/night sky: how far the painted sky may drift from the real sun before
|
||||
* the card stops gliding and simply JUMPS to the right colour.
|
||||
*
|
||||
* The stage colour is delivered by a 45 s CSS transition, and a transition only
|
||||
* advances while the card is actually painting. A card that was not painting —
|
||||
* a background tab, another dashboard view, a sleeping wall tablet — comes back
|
||||
* with a stale sky and then crawls toward the truth 45 s at a time, which is
|
||||
* exactly the owner's 2026-08-04 report («цвет фона не меняется сам с течением
|
||||
* времени суток, только после обновления страницы»: a reload paints the right
|
||||
* colour outright, because a freshly mounted element has nothing to transition
|
||||
* FROM). The sun never moves more than ~1° between two `sun.sun` updates (HA
|
||||
* refreshes the position every 4 minutes by day), so a gap this big can only
|
||||
* mean "we were not watching" — catch up at once, then breathe again.
|
||||
*/
|
||||
export const SKY_SNAP_DEG = 3;
|
||||
|
||||
/** Should the sky jump rather than glide from `prev`° to `next`°? */
|
||||
export function skyNeedsSnap(prev: number | null, next: number): boolean {
|
||||
return prev === null || !Number.isFinite(prev)
|
||||
|| Math.abs(next - prev) >= SKY_SNAP_DEG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sky granularity: the elevation the background is computed from, rounded to
|
||||
* 0.1°. Finer than the eye can tell on a 45 s glide, and it keeps `dayPhase`
|
||||
* (and therefore the style attribute lit has to commit) from churning on every
|
||||
* hass tick while the ray GEOMETRY keeps its own, coarser memo.
|
||||
*/
|
||||
export function skyElevation(elevation: number): number {
|
||||
return Math.round((Number(elevation) || 0) * 10) / 10;
|
||||
}
|
||||
|
||||
// ---------------- cloud cover ----------------
|
||||
|
||||
/** weather.* state → wedge opacity multiplier (docs/SUN.md table). */
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// «Выровнять всё по сетке» (docs/CANVAS.md §9) — the batch that the general
|
||||
// settings offer as an explicit action, never as a silent migration.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { alignAllToGrid, snapN } from '../test-build/align-grid.js';
|
||||
import { GRID_N, GRID_STEP_N } from '../test-build/space-geometry.js';
|
||||
|
||||
const S = GRID_STEP_N; // 1/240 of the plan width
|
||||
const onGrid = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
|
||||
|
||||
/** A plan deliberately knocked off the grid in every direction. */
|
||||
const detuned = () => ({
|
||||
spaces: [{
|
||||
id: 'f1', view_box: [0, 0, 1, 1],
|
||||
rooms: [
|
||||
// a polygon a third of a step off on every vertex
|
||||
{ id: 'r1', name: 'Living', area: 'a1',
|
||||
poly: [[0.2 + S / 3, 0.2], [0.5, 0.2 - S / 3], [0.5 + S / 3, 0.5], [0.2, 0.5]] },
|
||||
// a rect whose far corner is off even though its origin is not
|
||||
{ id: 'r2', name: 'Kitchen', area: 'a2', x: 0.5, y: 0.2, w: 0.3 + S / 3, h: 0.3 },
|
||||
],
|
||||
openings: [
|
||||
// just off the r1 top wall, and not at a whole number of steps along it
|
||||
{ id: 'o1', type: 'window', x: 0.3 + S / 3, y: 0.2 + S / 5, angle: 0, length: 0.06 },
|
||||
],
|
||||
decor: [
|
||||
{ id: 'd1', kind: 'line', x1: 0.1 + S / 3, y1: 0.7, x2: 0.9, y2: 0.7 - S / 4 },
|
||||
{ id: 'd2', kind: 'rect', x: 0.1 + S / 4, y: 0.8, w: 0.2 + S / 3, h: 0.05 },
|
||||
{ id: 'd3', kind: 'text', x: 0.4 + S / 3, y: 0.9, text: 'hi' },
|
||||
],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
});
|
||||
const detunedLayout = () => ({
|
||||
dev1: { s: 'f1', x: 0.3 + S / 3, y: 0.3 },
|
||||
dev2: { s: 'f1', x: 0.35, y: 0.35, k: 1.5 }, // already on a node
|
||||
rl_r1: { s: 'f1', x: 0.25 + S / 2.5, y: 0.25 }, // a room LABEL counts too
|
||||
junk: { s: 'f1' }, // no coordinates: skipped
|
||||
});
|
||||
|
||||
test('snapN is idempotent and leaves a node bit-identical', () => {
|
||||
const node = 12 / GRID_N;
|
||||
assert.equal(snapN(node), node); // untouched, not "re-rounded"
|
||||
const off = node + S / 3;
|
||||
assert.ok(Math.abs(snapN(off) - node) < 1e-12);
|
||||
assert.equal(snapN(snapN(off)), snapN(off));
|
||||
});
|
||||
|
||||
test('alignAllToGrid puts every grid-bound element on a node', () => {
|
||||
const { spaces, layout, report } = alignAllToGrid(detuned().spaces, detunedLayout());
|
||||
const sp = spaces[0];
|
||||
for (const p of sp.rooms[0].poly) { assert.ok(onGrid(p[0])); assert.ok(onGrid(p[1])); }
|
||||
const r2 = sp.rooms[1];
|
||||
assert.ok(onGrid(r2.x) && onGrid(r2.y));
|
||||
assert.ok(onGrid(r2.x + r2.w) && onGrid(r2.y + r2.h)); // the FAR corner, not the size
|
||||
const [l, rc, tx] = sp.decor;
|
||||
assert.ok(onGrid(l.x1) && onGrid(l.y1) && onGrid(l.x2) && onGrid(l.y2));
|
||||
assert.ok(onGrid(rc.x) && onGrid(rc.y) && onGrid(rc.x + rc.w) && onGrid(rc.y + rc.h));
|
||||
assert.ok(onGrid(tx.x) && onGrid(tx.y));
|
||||
assert.ok(onGrid(layout.dev1.x) && onGrid(layout.dev1.y));
|
||||
assert.ok(onGrid(layout.rl_r1.x) && onGrid(layout.rl_r1.y));
|
||||
assert.ok(report.moved > 0 && report.moved <= report.total);
|
||||
});
|
||||
|
||||
test('an opening stays ON its wall — it is wall-bound, not node-bound', () => {
|
||||
const { spaces } = alignAllToGrid(detuned().spaces, {});
|
||||
const o = spaces[0].openings[0];
|
||||
const wall = spaces[0].rooms[0].poly; // the aligned r1
|
||||
// the top edge runs wall[0] → wall[1]; the opening must lie on that segment
|
||||
const [ax, ay] = wall[0], [bx, by] = wall[1];
|
||||
const t = ((o.x - ax) * (bx - ax) + (o.y - ay) * (by - ay)) / ((bx - ax) ** 2 + (by - ay) ** 2);
|
||||
const px = ax + t * (bx - ax), py = ay + t * (by - ay);
|
||||
assert.ok(Math.hypot(o.x - px, o.y - py) < 1e-9, 'opening is off its wall');
|
||||
assert.ok(t > 0 && t < 1, 'opening slid off the end of its wall');
|
||||
});
|
||||
|
||||
test('idempotent: a second run moves nothing and changes nothing', () => {
|
||||
const first = alignAllToGrid(detuned().spaces, detunedLayout());
|
||||
const second = alignAllToGrid(first.spaces, first.layout);
|
||||
assert.equal(second.report.moved, 0);
|
||||
assert.equal(second.changed, false);
|
||||
assert.deepEqual(second.spaces, first.spaces);
|
||||
assert.deepEqual(second.layout, first.layout);
|
||||
});
|
||||
|
||||
test('an already-aligned plan reports nothing to do', () => {
|
||||
const clean = {
|
||||
spaces: [{ id: 'f1', rooms: [{ id: 'r1', poly: [[0.25, 0.25], [0.5, 0.25], [0.5, 0.5], [0.25, 0.5]] }] }],
|
||||
};
|
||||
const r = alignAllToGrid(clean.spaces, { d1: { s: 'f1', x: 0.25, y: 0.5 } });
|
||||
assert.equal(r.report.moved, 0);
|
||||
assert.equal(r.changed, false);
|
||||
assert.ok(r.report.total >= 2); // it still LOOKED at everything
|
||||
});
|
||||
|
||||
test('the report is honest: it counts what moved and the largest shift', () => {
|
||||
const r = alignAllToGrid(detuned().spaces, detunedLayout());
|
||||
// never more than half a step in each axis → at most half a step diagonally… ×√2
|
||||
assert.ok(r.report.maxShift <= S * 0.71 + 1e-12, String(r.report.maxShift / S));
|
||||
assert.ok(r.report.maxShift > 0);
|
||||
// "junk" has no coordinates and is not counted; dev2 is on a node already
|
||||
assert.equal(r.report.total, 2 /*rooms*/ + 3 /*decor*/ + 1 /*opening*/ + 3 /*layout*/);
|
||||
assert.ok(r.report.moved < r.report.total);
|
||||
});
|
||||
|
||||
test('nothing is invented: the input objects are not mutated', () => {
|
||||
const cfg = detuned();
|
||||
const before = JSON.stringify(cfg.spaces);
|
||||
const lay = detunedLayout();
|
||||
const layBefore = JSON.stringify(lay);
|
||||
alignAllToGrid(cfg.spaces, lay);
|
||||
assert.equal(JSON.stringify(cfg.spaces), before);
|
||||
assert.equal(JSON.stringify(lay), layBefore);
|
||||
});
|
||||
|
||||
test('a stray opening with no wall in reach is left exactly where it is', () => {
|
||||
const cfg = detuned();
|
||||
cfg.spaces[0].openings.push({ id: 'o2', type: 'door', x: 3.7, y: 4.2, angle: 0, length: 0.06 });
|
||||
const { spaces } = alignAllToGrid(cfg.spaces, {});
|
||||
const o2 = spaces[0].openings.find((o) => o.id === 'o2');
|
||||
assert.equal(o2.x, 3.7);
|
||||
assert.equal(o2.y, 4.2);
|
||||
});
|
||||
|
||||
test('far-out coordinates align just as well as near ones (infinite canvas)', () => {
|
||||
const cfg = { spaces: [{ id: 'f1', rooms: [
|
||||
{ id: 'r1', poly: [[2.5 + S / 3, 2.2], [3.0, 2.2], [3.0, 2.6], [2.5, 2.6]] }] }] };
|
||||
const { spaces, report } = alignAllToGrid(cfg.spaces, { d: { s: 'f1', x: 2.5 + S / 3, y: 2.2 } });
|
||||
for (const p of spaces[0].rooms[0].poly) { assert.ok(onGrid(p[0])); assert.ok(onGrid(p[1])); }
|
||||
assert.ok(report.moved >= 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AUD-158B1-01 — the confirmation promises an exact maximum before an action
|
||||
// with no undo, so the report must be an UPPER BOUND of what the run does.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** What the run REALLY did, measured on the returned objects: the largest
|
||||
* displacement of any corner / vertex / end, in the centimetres of the space
|
||||
* it happened in. The report may never be smaller than this. */
|
||||
const reallyMovedCm = (before, after, cell = {}) => {
|
||||
const cm = (sp) => (Number(sp?.cell_cm) > 0 ? Number(sp.cell_cm) : 5) * GRID_N;
|
||||
let max = 0;
|
||||
const hit = (d, sp) => { if (d * cm(sp) > max) max = d * cm(sp); };
|
||||
const box = (a, b, sp) => {
|
||||
const dx = Math.max(Math.abs(b.x - a.x), Math.abs((b.x + b.w) - (a.x + (a.w || 0))));
|
||||
const dy = Math.max(Math.abs(b.y - a.y), Math.abs((b.y + b.h) - (a.y + (a.h || 0))));
|
||||
hit(Math.hypot(dx, dy), sp);
|
||||
};
|
||||
before.forEach((sp, i) => {
|
||||
const sq = after[i];
|
||||
(sp.rooms || []).forEach((r, j) => {
|
||||
const q = sq.rooms[j];
|
||||
if (r.poly?.length) r.poly.forEach((p, k) => hit(Math.hypot(q.poly[k][0] - p[0], q.poly[k][1] - p[1]), sp));
|
||||
else if (r.x != null) box(r, q, sp);
|
||||
});
|
||||
(sp.decor || []).forEach((d, j) => {
|
||||
const q = sq.decor[j];
|
||||
if (d.kind === 'line') {
|
||||
hit(Math.hypot(q.x1 - d.x1, q.y1 - d.y1), sp);
|
||||
hit(Math.hypot(q.x2 - d.x2, q.y2 - d.y2), sp);
|
||||
} else if (d.w != null) box(d, q, sp);
|
||||
else hit(Math.hypot(q.x - d.x, q.y - d.y), sp);
|
||||
});
|
||||
(sp.openings || []).forEach((o, j) => {
|
||||
const q = sq.openings[j];
|
||||
const h = (Number(o.length) || 0) / 2, R = Math.PI / 180;
|
||||
const e = (px, py, a, s) => [px + s * Math.cos(a * R) * h, py + s * Math.sin(a * R) * h];
|
||||
const same = Math.max(
|
||||
Math.hypot(...e(q.x, q.y, q.angle, 1).map((v, k) => v - e(o.x, o.y, o.angle, 1)[k])),
|
||||
Math.hypot(...e(q.x, q.y, q.angle, -1).map((v, k) => v - e(o.x, o.y, o.angle, -1)[k])));
|
||||
const flip = Math.max(
|
||||
Math.hypot(...e(q.x, q.y, q.angle, -1).map((v, k) => v - e(o.x, o.y, o.angle, 1)[k])),
|
||||
Math.hypot(...e(q.x, q.y, q.angle, 1).map((v, k) => v - e(o.x, o.y, o.angle, -1)[k])));
|
||||
hit(Math.min(same, flip), sp);
|
||||
});
|
||||
});
|
||||
return max;
|
||||
};
|
||||
|
||||
test('AUD-158B1-01: the maximum is converted through the scale of ITS OWN space', () => {
|
||||
const spaces = [
|
||||
{ id: 'first', cell_cm: 5, rooms: [{ id: 'a', poly: [[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2]] }] },
|
||||
{ id: 'second', cell_cm: 100, rooms: [{ id: 'b',
|
||||
poly: [[0.2 + S / 2, 0.2], [0.3, 0.2], [0.3, 0.3], [0.2, 0.3]] }] },
|
||||
];
|
||||
const { report } = alignAllToGrid(spaces, {});
|
||||
// half a step of a 100 cm cell is 50 cm — NOT the 2.5 cm of the first floor
|
||||
assert.ok(Math.abs(report.maxShiftCm - 50) < 1e-9, String(report.maxShiftCm));
|
||||
assert.equal(report.maxSpace, 'second');
|
||||
});
|
||||
|
||||
test('AUD-158B1-01: a rect thinner than one step is measured AFTER it is widened', () => {
|
||||
const spaces = [{ id: 'f1', cell_cm: 5,
|
||||
rooms: [{ id: 'r', x: 0.5, y: 0.5, w: 0.001, h: 0.001 }] }];
|
||||
const before = JSON.parse(JSON.stringify(spaces));
|
||||
const res = alignAllToGrid(spaces, {});
|
||||
const real = reallyMovedCm(before, res.spaces);
|
||||
assert.ok(res.report.maxShiftCm + 1e-9 >= real,
|
||||
`promised ${res.report.maxShiftCm} cm, moved ${real} cm`);
|
||||
});
|
||||
|
||||
test('AUD-158B1-01: a rect whose X and Y errors land on opposite corners', () => {
|
||||
// origin off in X only, far corner off in Y only → the mixed corner moves by
|
||||
// both at once, and that diagonal is the honest maximum
|
||||
const spaces = [{ id: 'f1', cell_cm: 5,
|
||||
rooms: [{ id: 'r', x: 0.25 + S * 0.4, y: 0.25, w: 0.1 - S * 0.4, h: 0.1 + S * 0.4 }] }];
|
||||
const before = JSON.parse(JSON.stringify(spaces));
|
||||
const res = alignAllToGrid(spaces, {});
|
||||
assert.ok(Math.abs(res.report.maxShift - S * 0.4 * Math.SQRT2) < 1e-12,
|
||||
String(res.report.maxShift / S));
|
||||
assert.ok(res.report.maxShiftCm + 1e-9 >= reallyMovedCm(before, res.spaces));
|
||||
});
|
||||
|
||||
test('AUD-158B1-01: the promise is an upper bound for the whole detuned plan', () => {
|
||||
const cfg = detuned();
|
||||
const before = JSON.parse(JSON.stringify(cfg.spaces));
|
||||
const res = alignAllToGrid(cfg.spaces, detunedLayout());
|
||||
assert.ok(res.report.maxShiftCm + 1e-9 >= reallyMovedCm(before, res.spaces),
|
||||
`promised ${res.report.maxShiftCm} cm`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AUD-158B1-02 — an opening whose only error is its angle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('AUD-158B1-02: an angle-only correction is a change and can be applied', () => {
|
||||
const spaces = [{ id: 'f1', cell_cm: 5,
|
||||
rooms: [{ id: 'r1', poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
|
||||
openings: [{ id: 'o', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }] }];
|
||||
const res = alignAllToGrid(spaces, {});
|
||||
const o = res.spaces[0].openings[0];
|
||||
assert.equal(o.x, 0.35); // the centre was already right…
|
||||
assert.equal(o.y, 0.2);
|
||||
assert.equal(o.angle, 0); // …only the angle was wrong
|
||||
assert.equal(res.changed, true, 'output differs but changed:false');
|
||||
assert.equal(res.report.moved, 1);
|
||||
assert.equal(res.report.rotated, 1);
|
||||
assert.ok(res.report.maxShiftCm > 0); // its ENDS really do move
|
||||
});
|
||||
|
||||
test('AUD-158B1-02: turning an opening end over end is not a move', () => {
|
||||
// 180 apart is the same segment on the same wall: it counts as a correction
|
||||
// (the field is rewritten) but it displaces nothing
|
||||
const spaces = [{ id: 'f1', cell_cm: 5,
|
||||
rooms: [{ id: 'r1', poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
|
||||
openings: [{ id: 'o', type: 'window', x: 0.35, y: 0.2, angle: 180, length: 0.1 }] }];
|
||||
const res = alignAllToGrid(spaces, {});
|
||||
assert.equal(res.spaces[0].openings[0].angle, 0);
|
||||
assert.equal(res.changed, true);
|
||||
assert.equal(res.report.rotated, 1);
|
||||
assert.equal(res.report.maxShiftCm, 0);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The backdrop transform and the paper contract (docs/BACKDROP.md).
|
||||
*
|
||||
* Two questions only, and both are pure geometry:
|
||||
* 1. where the picture ends up once it has been moved and scaled, and
|
||||
* 2. that the frame ("Вписать всё") counts THAT rectangle, not the old one.
|
||||
* The third half of the feature — the paper is the rooms and only the rooms —
|
||||
* lives in the renderers, so it is asserted in the DOM by demo/smoke_backdrop.mjs.
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
NORM_W, planRect, fitInSquare, spaceModels, contentItems, contentBounds,
|
||||
PLAN_SCALE_MIN, PLAN_SCALE_MAX, CANVAS_LIMIT,
|
||||
} from '../test-build/space-geometry.js';
|
||||
import { paperRoomShapes } from '../test-build/logic.js';
|
||||
|
||||
const wide = { id: 's', title: 's', plan_url: '/p.svg', plan_aspect: 2, view_box: [0, 0, 1, 1], rooms: [] };
|
||||
const cfgOf = (space) => ({ spaces: [space], markers: [], settings: {} });
|
||||
|
||||
test('planRect: no fields at all is EXACTLY the centred default (no migration)', () => {
|
||||
assert.deepEqual(planRect(wide), fitInSquare(2, NORM_W));
|
||||
assert.deepEqual(planRect(wide), { x: 0, y: 250, w: 1000, h: 500 });
|
||||
// an explicitly neutral transform is the same rectangle, to the bit
|
||||
assert.deepEqual(planRect({ ...wide, plan_x: 0, plan_y: 0, plan_scale: 1 }),
|
||||
{ x: 0, y: 250, w: 1000, h: 500 });
|
||||
// …and so is every kind of junk a legacy store could hold. A non-number is
|
||||
// no transform at all; for the SCALE zero and negatives are junk too (it is
|
||||
// a multiplier), while for the OFFSET they are perfectly good coordinates.
|
||||
for (const bad of [null, undefined, 'x', NaN, Infinity])
|
||||
assert.deepEqual(planRect({ ...wide, plan_scale: bad, plan_x: bad, plan_y: bad }),
|
||||
{ x: 0, y: 250, w: 1000, h: 500 }, `plan_* = ${String(bad)}`);
|
||||
for (const bad of [0, -3])
|
||||
assert.deepEqual(planRect({ ...wide, plan_scale: bad }),
|
||||
{ x: 0, y: 250, w: 1000, h: 500 }, `plan_scale = ${String(bad)}`);
|
||||
});
|
||||
|
||||
test('planRect: the offset is normalised, the scale is uniform about the top-left', () => {
|
||||
// 0.25 of the canvas right, 0.1 down
|
||||
assert.deepEqual(planRect({ ...wide, plan_x: 0.25, plan_y: 0.1 }),
|
||||
{ x: 250, y: 350, w: 1000, h: 500 });
|
||||
// half size: BOTH sides halve, the top-left corner stays put — no rotation,
|
||||
// no stretch, one number
|
||||
assert.deepEqual(planRect({ ...wide, plan_scale: 0.5 }),
|
||||
{ x: 0, y: 250, w: 500, h: 250 });
|
||||
// offset and scale compose: the offset moves the (already scaled) corner
|
||||
assert.deepEqual(planRect({ ...wide, plan_x: -0.5, plan_y: 0.2, plan_scale: 2 }),
|
||||
{ x: -500, y: 450, w: 2000, h: 1000 });
|
||||
// the aspect ratio is preserved by construction, whatever the scale
|
||||
const r = planRect({ ...wide, plan_scale: 3.7 });
|
||||
assert.ok(Math.abs(r.w / r.h - 2) < 1e-12);
|
||||
});
|
||||
|
||||
test('planRect: the stored numbers are clamped, never trusted', () => {
|
||||
const far = planRect({ ...wide, plan_x: 1e9, plan_y: -1e9 });
|
||||
assert.equal(far.x, 0 + CANVAS_LIMIT * NORM_W);
|
||||
assert.equal(far.y, 250 - CANVAS_LIMIT * NORM_W);
|
||||
assert.equal(planRect({ ...wide, plan_scale: 1e9 }).w, 1000 * PLAN_SCALE_MAX);
|
||||
assert.equal(planRect({ ...wide, plan_scale: 1e-9 }).w, 1000 * PLAN_SCALE_MIN);
|
||||
});
|
||||
|
||||
test('spaceModels: the model carries the TRANSFORMED image rectangle', () => {
|
||||
const [m] = spaceModels(cfgOf({ ...wide, plan_x: 0.5, plan_y: 0.5, plan_scale: 0.5 }));
|
||||
assert.deepEqual(m.bg, { href: '/p.svg', x: 500, y: 750, w: 500, h: 250 });
|
||||
// and an untouched space is bit-identical to what it rendered before
|
||||
const [old] = spaceModels(cfgOf(wide));
|
||||
assert.deepEqual(old.bg, { href: '/p.svg', x: 0, y: 250, w: 1000, h: 500 });
|
||||
});
|
||||
|
||||
test('the content frame follows the picture (Вписать всё never loses it)', () => {
|
||||
const moved = spaceModels(cfgOf({
|
||||
...wide, plan_x: 2, plan_y: 2, plan_scale: 0.5,
|
||||
rooms: [{ id: 'r', name: 'r', poly: [[0.4, 0.4], [0.5, 0.4], [0.5, 0.5], [0.4, 0.5]] }],
|
||||
}))[0];
|
||||
// the picture is still ONE content item, exactly like a room
|
||||
const items = contentItems(moved);
|
||||
assert.equal(items.length, 2);
|
||||
const img = items[1];
|
||||
assert.deepEqual(img, { minX: 2000, minY: 2250, maxX: 2500, maxY: 2500 });
|
||||
const b = contentBounds(moved);
|
||||
assert.ok(b.x <= 400 && b.x + b.w >= 2500, 'the frame reaches the moved picture');
|
||||
assert.ok(b.y <= 400 && b.y + b.h >= 2500);
|
||||
// …and shrinking the picture shrinks the frame with it
|
||||
const small = spaceModels(cfgOf({ ...wide, plan_scale: 0.25 }))[0];
|
||||
const sb = contentBounds(small);
|
||||
assert.ok(sb.w < 400, `a quarter-scale picture frames tight, got ${sb.w}`);
|
||||
});
|
||||
|
||||
test('the paper is the ROOMS, image or no image (docs/BACKDROP.md §3)', () => {
|
||||
// paperRoomShapes knows nothing about plan_url — which is the whole point:
|
||||
// there is no longer a branch where the picture makes paper of its own.
|
||||
const rooms = [{ id: 'r', name: 'r', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] }];
|
||||
assert.equal(paperRoomShapes(rooms).length, 1);
|
||||
assert.equal(paperRoomShapes([]).length, 0, 'an empty space has no paper at all');
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
// Infinite canvas — pure geometry (docs/CANVAS.md).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
NORM_W, CANVAS_LIMIT, SANE_LIMIT, MIN_ZOOM, OUTLIER_K, MIN_VOTERS,
|
||||
spaceModels, contentItems, contentFrame, contentBounds, spaceFrame, spaceCenter,
|
||||
iconUnit, iconCqw, gridLevels, itemOf, roomItem, defaultPositions,
|
||||
} from '../test-build/space-geometry.js';
|
||||
|
||||
const model = (space) => spaceModels({ spaces: [{ view_box: [0, 0, 1, 1], rooms: [], ...space }], markers: [] })[0];
|
||||
const pt = (x, y) => ({ minX: x, minY: y, maxX: x, maxY: y });
|
||||
const box = (x0, y0, x1, y1) => ({ minX: x0, minY: y0, maxX: x1, maxY: y1 });
|
||||
const r = (o) => [o.x, o.y, o.w, o.h].map((n) => Math.round(n));
|
||||
|
||||
// ---------------------------------------------------------------- constants
|
||||
test('canvas limits mirror the backend and the spec', () => {
|
||||
assert.equal(CANVAS_LIMIT, 5000);
|
||||
assert.equal(SANE_LIMIT, 5000 * NORM_W);
|
||||
assert.ok(Math.abs(MIN_ZOOM - 1 / 3) < 1e-12, 'zoom out stops at 3x the content');
|
||||
assert.equal(OUTLIER_K, 10);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- the ordinary plan
|
||||
test('typical small plan: the frame is exactly what is drawn (unchanged behaviour)', () => {
|
||||
const m = model({ id: 's', rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }] });
|
||||
assert.deepEqual(contentBounds(m), { x: 390, y: 390, w: 220, h: 220 });
|
||||
// the stored view_box has no say once there IS content
|
||||
const withVb = model({ id: 's', view_box: [0.2, 0.2, 0.1, 0.1],
|
||||
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }] });
|
||||
assert.deepEqual(contentBounds(withVb), { x: 390, y: 390, w: 220, h: 220 });
|
||||
});
|
||||
|
||||
// --------------------------------------------- (b) the plan PAST the square
|
||||
test('a plan drawn far outside the old unit square is framed whole', () => {
|
||||
// rooms at normalised 1.5 .. 3.0 — the case that used to break: the old
|
||||
// -25%..125% envelope threw every one of these points away and the frame
|
||||
// collapsed onto whatever happened to be near the origin.
|
||||
const m = model({ id: 's', rooms: [
|
||||
{ id: 'a', poly: [[1.5, 1.5], [2.0, 1.5], [2.0, 2.0], [1.5, 2.0]] },
|
||||
{ id: 'b', poly: [[2.0, 1.5], [3.0, 1.5], [3.0, 2.4], [2.0, 2.4]] },
|
||||
] });
|
||||
const b = contentBounds(m);
|
||||
assert.deepEqual(r(b), [1425, 1425, 1650, 1050]);
|
||||
assert.ok(b.x < 1500 && b.x + b.w > 3000, 'both far rooms are inside the frame');
|
||||
// and a device placed even further out still counts as content
|
||||
const withDev = contentBounds(m, 0.05, [[3500, 2000]]);
|
||||
assert.ok(withDev.x + withDev.w > 3500);
|
||||
});
|
||||
|
||||
test('a plan past the square keeps its icon spacing in proportion', () => {
|
||||
const small = model({ id: 's', rooms: [{ id: 'r', area: 'a', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }] });
|
||||
const big = model({ id: 's', rooms: [{ id: 'r', area: 'a', poly: [[1.0, 1.0], [3.0, 1.0], [3.0, 3.0], [1.0, 3.0]] }] });
|
||||
assert.equal(iconUnit(small), NORM_W, 'anything inside the old square is unchanged');
|
||||
assert.equal(iconUnit(big), 2000, 'a 2-canvas-wide plan scales the spacing with it');
|
||||
// the auto grid still lands inside the room in both cases
|
||||
const devs = [0, 1, 2, 3].map((i) => ({ id: 'd' + i, space: 's', area: 'a', entities: [] }));
|
||||
for (const [mm, lo, hi] of [[small, 100, 500], [big, 1000, 3000]]) {
|
||||
const pos = defaultPositions(devs, mm, 2.5);
|
||||
for (const k of Object.keys(pos)) {
|
||||
assert.ok(pos[k].x >= lo && pos[k].x <= hi && pos[k].y >= lo && pos[k].y <= hi, 'inside the room');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- icon size
|
||||
test('icon size: a percentage of the PLAN, exactly as before the canvas', () => {
|
||||
// The pre-infinite-canvas card rendered `--icon-size: iconPct * vb.w / view.w`
|
||||
// and the editor only ever wrote view_box [0,0,1,1], i.e. vb.w === NORM_W.
|
||||
// iconCqw must reproduce that number to the last digit on such a plan.
|
||||
const small = model({ id: 's', rooms: [{ id: 'r', poly: [[0.04, 0.14], [0.96, 0.14], [0.96, 0.86], [0.04, 0.86]] }] });
|
||||
const legacy = (pct, viewW) => (pct * NORM_W) / viewW;
|
||||
for (const viewW of [1000, 1100, 1093.969144460028, 550, 275, 137.5]) {
|
||||
assert.equal(iconCqw(3.4, small, viewW), legacy(3.4, viewW));
|
||||
}
|
||||
// and that number IS "scales with the plan": half the view, twice the icon
|
||||
assert.equal(iconCqw(2.5, small, 500) / iconCqw(2.5, small, 1000), 2);
|
||||
assert.equal(iconCqw(2.5, small, 8000) / iconCqw(2.5, small, 1000), 0.125);
|
||||
// the kiosk icon multiplier is a plain factor, as it was
|
||||
assert.equal(iconCqw(2.5, small, 1000, 2), 2 * iconCqw(2.5, small, 1000));
|
||||
});
|
||||
|
||||
test('icon size: a plan drawn past the old square keeps its markers', () => {
|
||||
// rooms at 1.0..3.0 — 2 canvases wide, framed by ~2.2 canvases once the
|
||||
// frame is the content. With the old fixed NORM_W numerator the marker
|
||||
// would be 2.2x smaller than on an ordinary plan, and 55x smaller on a
|
||||
// plan 50 canvases wide: the reason the numerator is iconUnit.
|
||||
const big = model({ id: 's', rooms: [{ id: 'r', poly: [[1, 1], [3, 1], [3, 3], [1, 3]] }] });
|
||||
const small = model({ id: 's', rooms: [{ id: 'r', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }] });
|
||||
const frame = (m) => { const f = spaceFrame(m); return f.w; }; // content + 5%
|
||||
assert.equal(frame(small), NORM_W * 1.1);
|
||||
assert.equal(frame(big), 2 * NORM_W * 1.1);
|
||||
// same percentage of the frame on both plans — the marker did not degenerate
|
||||
assert.equal(iconCqw(2.5, big, frame(big)), iconCqw(2.5, small, frame(small)));
|
||||
assert.ok(iconCqw(2.5, big, frame(big)) > 2, 'and it is a real size, not a dot');
|
||||
// the degenerate alternative, for the record
|
||||
assert.ok((2.5 * NORM_W) / frame(big) < iconCqw(2.5, big, frame(big)) / 1.9);
|
||||
// the size unit and the auto-placement spacing are the same unit
|
||||
assert.equal(iconUnit(big), 2 * NORM_W);
|
||||
});
|
||||
|
||||
test('icon size: no view yet is a plain percentage, never NaN', () => {
|
||||
const m = model({ id: 's', rooms: [{ id: 'r', poly: [[0.1, 0.1], [0.5, 0.5]] }] });
|
||||
for (const bad of [0, -1, NaN, Infinity, null, undefined]) {
|
||||
assert.equal(iconCqw(2.5, m, bad), 2.5);
|
||||
}
|
||||
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 = [
|
||||
box(400, 400, 500, 500), box(500, 400, 620, 520), box(400, 500, 520, 640),
|
||||
box(520, 520, 640, 660), pt(450, 450), pt(600, 600),
|
||||
pt(90000, 90000), // one marker an order of magnitude away
|
||||
];
|
||||
const f = contentFrame(items);
|
||||
assert.equal(f.outliers, 1);
|
||||
assert.ok(f.core.x + f.core.w < 1000, 'the stray is outside the opening view');
|
||||
assert.ok(f.all.x + f.all.w > 90000, 'but the fit-everything box holds it');
|
||||
assert.ok(f.all.w > f.core.w * 50);
|
||||
});
|
||||
|
||||
test('an outlier vote needs a majority to be far FROM', () => {
|
||||
// three objects: no vote at all (MIN_VOTERS), so the far one still counts
|
||||
const few = contentFrame([box(400, 400, 500, 500), box(500, 500, 600, 600), pt(90000, 90000)]);
|
||||
assert.equal(few.outliers, 0);
|
||||
assert.deepEqual(few.core, few.all);
|
||||
assert.ok(MIN_VOTERS === 4);
|
||||
// a genuinely spread-out plan (detached buildings) is not a pile of strays:
|
||||
// no object is an order of magnitude further than the 75th percentile
|
||||
const spread = contentFrame([pt(0, 0), pt(3000, 0), pt(0, 3000), pt(3000, 3000), pt(1500, 1500)]);
|
||||
assert.equal(spread.outliers, 0);
|
||||
assert.deepEqual(spread.core, spread.all);
|
||||
// majority veto: half the objects "far" means the plan is wide, not stray
|
||||
const half = contentFrame([pt(0, 0), pt(10, 0), pt(0, 10), pt(90000, 0), pt(90000, 10), pt(90010, 0)]);
|
||||
assert.equal(half.outliers, 0);
|
||||
});
|
||||
|
||||
test('a tight cluster does not call its own neighbour an outlier (MIN_SPREAD)', () => {
|
||||
// five markers within one room; the "furthest" is 60 units away — with a
|
||||
// percentile-only scale that would be 10x the p75 and get thrown out.
|
||||
const f = contentFrame([pt(500, 500), pt(502, 501), pt(499, 503), pt(501, 498), pt(560, 500)]);
|
||||
assert.equal(f.outliers, 0);
|
||||
assert.ok(f.core.x + f.core.w > 560);
|
||||
});
|
||||
|
||||
test('corruption is dropped outright, not shown by "show all"', () => {
|
||||
const f = contentFrame([box(400, 400, 500, 500), box(500, 500, 600, 600),
|
||||
box(400, 500, 500, 600), box(500, 400, 600, 500), pt(1e100, 1e100)]);
|
||||
assert.equal(f.outliers, 0, 'not an outlier — not content at all');
|
||||
assert.ok(f.all.w < 1000, 'the fit-everything box does not chase 1e100');
|
||||
// exactly at the sane limit it is still content
|
||||
const edge = contentFrame([pt(0, 0), pt(1, 1), pt(2, 2), pt(SANE_LIMIT, SANE_LIMIT)]);
|
||||
assert.ok(edge.all.x + edge.all.w >= SANE_LIMIT);
|
||||
assert.equal(contentFrame([pt(NaN, 0), pt(0, Infinity)]).core, null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------- degenerate and empty
|
||||
test('empty space: no frame, the caller falls back to the view_box hint', () => {
|
||||
const empty = model({ id: 's', view_box: [0.1, 0.2, 0.5, 0.4] });
|
||||
assert.equal(contentBounds(empty), null);
|
||||
assert.deepEqual(spaceFrame(empty), { x: 100, y: 200, w: 500, h: 400 });
|
||||
// a broken stored hint falls back to the legacy square
|
||||
const broken = model({ id: 's', view_box: [0, 0, 0, 0] });
|
||||
assert.deepEqual(spaceFrame(broken), { x: 0, y: 0, w: 1000, h: 1000 });
|
||||
});
|
||||
|
||||
test('one room, one marker: the frame never has a zero axis', () => {
|
||||
const one = model({ id: 's', rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }] });
|
||||
const b = contentBounds(one);
|
||||
assert.ok(b.w > 0 && b.h > 0);
|
||||
const lone = contentBounds(model({ id: 's' }), 0.05, [[2500, 2500]]);
|
||||
assert.ok(lone.w >= 200 && lone.h >= 200, 'a lone marker far out still frames some canvas');
|
||||
assert.ok(Math.abs(lone.x + lone.w / 2 - 2500) < 1, 'centred on it');
|
||||
// a real thin corridor keeps its tight frame (only DEGENERATE is inflated)
|
||||
const corridor = contentBounds(model({ id: 's', rooms: [{ id: 'r', x: 0.1, y: 0.4, w: 0.6, h: 0.1 }] }));
|
||||
assert.equal(Math.round(corridor.h), 160);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ image plans
|
||||
test('with a backdrop the image still sets the extent', () => {
|
||||
const m = model({ id: 's', plan_url: '/p.svg', plan_aspect: 2,
|
||||
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.5, 0.4], [0.5, 0.5], [0.4, 0.5]] }] });
|
||||
const items = contentItems(m);
|
||||
assert.equal(items.length, 2);
|
||||
const b = contentBounds(m);
|
||||
assert.ok(b.x <= 0 && b.x + b.w >= 1000, 'the whole image width is framed');
|
||||
assert.ok(b.y <= 250 && b.y + b.h >= 750, 'the whole image height is framed');
|
||||
// and content drawn OUTSIDE the image widens the frame further
|
||||
const past = contentBounds(m, 0.05, [[2000, 500]]);
|
||||
assert.ok(past.x + past.w > 2000);
|
||||
});
|
||||
|
||||
test('spaceCenter is the middle of the content', () => {
|
||||
const m = model({ id: 's', rooms: [{ id: 'r', poly: [[2.0, 2.0], [3.0, 2.0], [3.0, 3.0], [2.0, 3.0]] }] });
|
||||
const c = spaceCenter(m);
|
||||
assert.ok(Math.abs(c.x - 2500) < 1 && Math.abs(c.y - 2500) < 1);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ helper units
|
||||
test('itemOf / roomItem', () => {
|
||||
assert.deepEqual(itemOf([[1, 2], [5, 0]]), { minX: 1, minY: 0, maxX: 5, maxY: 2 });
|
||||
assert.equal(itemOf([]), null);
|
||||
assert.deepEqual(roomItem({ x: 10, y: 20, w: 30, h: 40 }), { minX: 10, minY: 20, maxX: 40, maxY: 60 });
|
||||
assert.deepEqual(roomItem({ poly: [[0, 0], [4, 9]] }), { minX: 0, minY: 0, maxX: 4, maxY: 9 });
|
||||
assert.equal(roomItem({ name: 'no geometry' }), null);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ adaptive grid
|
||||
test('gridLevels: fine dots vanish before they merge, every 5th stays', () => {
|
||||
const pitch = NORM_W / 240; // ~4.167 render units, the drawing grid
|
||||
// zoomed in / normal: 1 px per unit — the base grid is legible
|
||||
assert.deepEqual(gridLevels(pitch, 2), { fine: 1, coarse: 5 });
|
||||
// zoomed out 4x: the base step is ~1 px, so every 2nd survives, accent 10th
|
||||
assert.deepEqual(gridLevels(pitch, 0.9), { fine: 2, coarse: 10 });
|
||||
// far out: only every 20th, accent every 100th
|
||||
assert.deepEqual(gridLevels(pitch, 0.09), { fine: 20, coarse: 100 });
|
||||
// monotone: zooming out never makes the grid finer
|
||||
let prev = 0;
|
||||
for (const s of [4, 2, 1, 0.5, 0.2, 0.1, 0.05, 0.02, 0.01]) {
|
||||
const g = gridLevels(pitch, s);
|
||||
if (!g) break;
|
||||
assert.ok(g.fine >= prev, 'never finer as we zoom out');
|
||||
assert.ok(g.coarse >= g.fine * 5, 'the accent step is at least every 5th');
|
||||
assert.ok(pitch * g.fine * s >= 7, 'and it is still legible');
|
||||
prev = g.fine;
|
||||
}
|
||||
// absurdly far out: no grid at all rather than a grey fog
|
||||
assert.equal(gridLevels(pitch, 1e-6), null);
|
||||
assert.equal(gridLevels(0, 1), null);
|
||||
assert.equal(gridLevels(pitch, 0), null);
|
||||
assert.equal(gridLevels(pitch, NaN), null);
|
||||
});
|
||||
+106
-2
@@ -15,7 +15,7 @@ import {
|
||||
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf } from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
} from '../test-build/rules.js';
|
||||
@@ -245,7 +245,7 @@ test('spaceDisplayOf: defaults differ for spaces with and without a plan', () =>
|
||||
assert.equal(s.opacity, 1);
|
||||
assert.equal(s.fill, 'lqi');
|
||||
const g = spaceDisplayOf({ settings: { room_color: 'javascript:alert(1)', fill_mode: 'weird' } });
|
||||
assert.equal(g.color, '#3ea6ff');
|
||||
assert.equal(g.color, '#55606c'); // dark-grey default since 2026-08-03
|
||||
assert.equal(g.fill, 'none');
|
||||
});
|
||||
|
||||
@@ -531,6 +531,110 @@ test('stateIcon: doors/locks/bulbs reflect state; custom icons and outages never
|
||||
assert.equal(stateIcon('mdi:cctv', 'camera', undefined, 'recording', false), 'mdi:cctv'); // unknown pair
|
||||
});
|
||||
|
||||
test('stateIcon: covers morph by device_class (owner 2026-08-03)', () => {
|
||||
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'closed', false), 'mdi:blinds');
|
||||
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'open', false), 'mdi:blinds-open');
|
||||
assert.equal(stateIcon('mdi:window-shutter', 'cover', 'shutter', 'open', false), 'mdi:window-shutter-open');
|
||||
assert.equal(stateIcon('mdi:window-shutter', 'cover', 'shutter', 'closed', false), 'mdi:window-shutter');
|
||||
assert.equal(stateIcon('mdi:curtains', 'cover', 'curtain', 'closed', false), 'mdi:curtains-closed');
|
||||
assert.equal(stateIcon('mdi:curtains', 'cover', 'curtain', 'open', false), 'mdi:curtains');
|
||||
// travelling covers show where they are HEADED: closing keeps the open art
|
||||
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'opening', false), 'mdi:blinds-open');
|
||||
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'closing', false), 'mdi:blinds-open');
|
||||
// no state at all — never morph, never guess
|
||||
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'unknown', false), 'mdi:blinds');
|
||||
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'unavailable', false), 'mdi:blinds');
|
||||
assert.equal(stateIcon('mdi:custom', 'cover', 'blind', 'open', true), 'mdi:custom'); // user icon wins
|
||||
// no device_class: only a known base icon morphs, an unrelated one stays
|
||||
assert.equal(stateIcon('mdi:blinds', 'cover', undefined, 'open', false), 'mdi:blinds-open');
|
||||
assert.equal(stateIcon('mdi:window-shutter', 'cover', null, 'closed', false), 'mdi:window-shutter');
|
||||
assert.equal(stateIcon('mdi:sofa', 'cover', undefined, 'open', false), 'mdi:sofa');
|
||||
});
|
||||
|
||||
test('stateIcon: a cover morphs for EVERY class, both ways (owner 2026-08-04)', () => {
|
||||
// With the «открыто» plate gone from covers, the morph is the only signal —
|
||||
// so no class may map both states to the same glyph, and the icons the card
|
||||
// itself hands out (rules.ts) have to morph even without a device_class.
|
||||
const CLASSES = ['blind', 'shade', 'shutter', 'curtain', 'window', 'awning',
|
||||
'door', 'garage', 'gate', 'damper'];
|
||||
for (const dc of CLASSES) {
|
||||
const closed = stateIcon('mdi:chip', 'cover', dc, 'closed', false);
|
||||
const open = stateIcon('mdi:chip', 'cover', dc, 'open', false);
|
||||
assert.notEqual(closed, open, `${dc}: closed and open share one icon`);
|
||||
// ajar and travelling all read as OPEN (HA reports a positioned cover 'open')
|
||||
assert.equal(stateIcon('mdi:chip', 'cover', dc, 'opening', false), open);
|
||||
assert.equal(stateIcon('mdi:chip', 'cover', dc, 'closing', false), open);
|
||||
}
|
||||
assert.equal(stateIcon('mdi:chip', 'cover', 'awning', 'closed', false), 'mdi:awning-outline');
|
||||
assert.equal(stateIcon('mdi:chip', 'cover', 'awning', 'open', false), 'mdi:awning');
|
||||
// no device_class: the auto icons of rules.ts morph within their own family
|
||||
assert.equal(stateIcon('mdi:roller-shade', 'cover', undefined, 'closed', false), 'mdi:roller-shade-closed');
|
||||
assert.equal(stateIcon('mdi:roller-shade-closed', 'cover', undefined, 'open', false), 'mdi:roller-shade');
|
||||
assert.equal(stateIcon('mdi:garage-variant', 'cover', undefined, 'open', false), 'mdi:garage-open-variant');
|
||||
assert.equal(stateIcon('mdi:blinds-horizontal', 'cover', null, 'closed', false), 'mdi:blinds-horizontal-closed');
|
||||
// a HAND-PICKED icon morphs only inside the pair it was picked from — never
|
||||
// traded for another family, never touched outside the cover domain
|
||||
assert.equal(stateIcon('mdi:curtains', 'cover', 'blind', 'closed', true), 'mdi:curtains-closed');
|
||||
assert.equal(stateIcon('mdi:curtains-closed', 'cover', undefined, 'open', true), 'mdi:curtains');
|
||||
assert.equal(stateIcon('mdi:sofa', 'cover', 'blind', 'open', true), 'mdi:sofa');
|
||||
assert.equal(stateIcon('mdi:door-closed', 'binary_sensor', 'door', 'on', true), 'mdi:door-closed');
|
||||
assert.equal(stateIcon('mdi:lock', 'lock', undefined, 'unlocked', true), 'mdi:lock');
|
||||
});
|
||||
|
||||
test('coverService: closed→open, open→close, travelling→stop (owner 2026-08-03)', () => {
|
||||
assert.equal(coverService('closed'), 'open_cover');
|
||||
assert.equal(coverService('open'), 'close_cover'); // incl. ajar: HA reports 'open'
|
||||
assert.equal(coverService('opening'), 'stop_cover'); // a tap during travel STOPS
|
||||
assert.equal(coverService('closing'), 'stop_cover');
|
||||
assert.equal(coverService('unknown'), 'toggle'); // no readable state → plain toggle
|
||||
assert.equal(coverService('unavailable'), 'toggle');
|
||||
assert.equal(coverService(null), 'toggle');
|
||||
assert.equal(coverService(undefined), 'toggle');
|
||||
});
|
||||
|
||||
test('coverEntityOf: the cover among ALL entities, primary or not (owner 2026-08-04)', () => {
|
||||
// the owner's Aqara E1 curtain driver, entity for entity: the cover is
|
||||
// hidden by the integration and sits BEHIND the service switch, so
|
||||
// primaryEntity picks the switch — the tap has to look wider than that
|
||||
const aqara = [
|
||||
'cover.office_curtain', 'sensor.office_curtain_battery',
|
||||
'switch.office_curtain_reverse', 'sensor.office_curtain_motor_state',
|
||||
];
|
||||
assert.equal(coverEntityOf(aqara), 'cover.office_curtain');
|
||||
assert.equal(coverEntityOf(['switch.a', 'cover.b']), 'cover.b'); // order does not matter
|
||||
assert.equal(coverEntityOf(['cover.first', 'cover.second']), 'cover.first');
|
||||
assert.equal(coverEntityOf(['light.only']), null);
|
||||
assert.equal(coverEntityOf([]), null);
|
||||
assert.equal(coverEntityOf(null), null);
|
||||
assert.equal(coverEntityOf(undefined), null);
|
||||
// a `cover_something` sensor is not a cover
|
||||
assert.equal(coverEntityOf(['sensor.cover_position']), null);
|
||||
});
|
||||
|
||||
test('coverMoving: only opening/closing breathe', () => {
|
||||
assert.equal(coverMoving('opening'), true);
|
||||
assert.equal(coverMoving('closing'), true);
|
||||
assert.equal(coverMoving('open'), false);
|
||||
assert.equal(coverMoving('closed'), false);
|
||||
assert.equal(coverMoving('unknown'), false);
|
||||
assert.equal(coverMoving(null), false);
|
||||
});
|
||||
|
||||
test("tap action 'cover': explicit-only, cover-only, never a guarded class", () => {
|
||||
assert.equal(resolveTapAction('cover', null, 'cover', 'curtain'), 'cover');
|
||||
assert.equal(resolveTapAction('cover', null, 'cover', 'blind'), 'cover');
|
||||
assert.equal(resolveTapAction('cover', null, 'cover', null), 'cover'); // no class = a plain cover
|
||||
// the guarded classes degrade to the info card even if the value was saved
|
||||
assert.equal(resolveTapAction('cover', null, 'cover', 'garage'), 'info');
|
||||
assert.equal(resolveTapAction('cover', null, 'cover', 'door'), 'info');
|
||||
assert.equal(resolveTapAction('cover', null, 'cover', 'gate'), 'info');
|
||||
// and it is meaningless anywhere but the cover domain
|
||||
assert.equal(resolveTapAction('cover', null, 'light'), 'info');
|
||||
assert.equal(resolveTapAction('cover', null, 'lock'), 'info');
|
||||
// never a card-wide default (like 'run'): it needs a conscious per-marker choice
|
||||
assert.equal(resolveTapAction(null, 'cover', 'cover', 'curtain'), 'info');
|
||||
});
|
||||
|
||||
test('lightColorOf: rgb of an on light; off/unavailable/no-color → null', () => {
|
||||
assert.equal(lightColorOf({ state: 'on', attributes: { rgb_color: [255, 20, 40] } }), 'rgb(255, 20, 40)');
|
||||
assert.equal(lightColorOf({ state: 'off', attributes: { rgb_color: [255, 20, 40] } }), null);
|
||||
|
||||
@@ -58,8 +58,10 @@ test('markerPos: saved layout → default grid → space centre', () => {
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, defPos, model), defPos.d1);
|
||||
const b = roomBounds(model.rooms[0]);
|
||||
assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h);
|
||||
// no layout, no defPos → space centre
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 500 });
|
||||
// no layout, no defPos → the middle of the CONTENT, not of a canvas that no
|
||||
// longer has edges (docs/CANVAS.md): f1 is a 2:1 image (y 250..750) plus a
|
||||
// room at y 100..500, so the content spans y 100..750 — centre 425.
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 425 });
|
||||
});
|
||||
|
||||
test('labelPos: saved rl_<id> → render units; else room centre', () => {
|
||||
|
||||
+184
-8
@@ -5,6 +5,9 @@ import {
|
||||
isExteriorWall, windowWallInfo, windowLit,
|
||||
rayLength, rayQuad, clipToRoom, computeSunRays,
|
||||
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
|
||||
raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS,
|
||||
RAY_LENGTH_K, RAY_FADE_END, rayStops, RAY_MIN_COS,
|
||||
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
|
||||
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
|
||||
} from '../test-build/sun.js';
|
||||
|
||||
@@ -93,20 +96,108 @@ test('isExteriorWall probes the outer side', () => {
|
||||
assert.ok(!isExteriorWall([500, 300], [1, 0], ROOMS)); // r2 is outside r1 here
|
||||
});
|
||||
|
||||
test('windowLit: above the horizon AND facing the sun', () => {
|
||||
test('windowLit: above the horizon, facing the sun, and NOT along the wall', () => {
|
||||
const east = [1, 0];
|
||||
assert.ok(windowLit(east, sunDirOnPlan(90, 0), 10));
|
||||
assert.ok(!windowLit(east, sunDirOnPlan(270, 0), 10)); // sun behind the house
|
||||
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), 0)); // sunset moment
|
||||
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), -5)); // night
|
||||
// DEV-EB173-01: a sun sliding ALONG the wall lights nothing. The dot product
|
||||
// is the cosine of the incidence angle: for this wall it is exactly sin(az).
|
||||
assert.equal(RAY_MIN_COS, 0.05);
|
||||
const cos = (az) => Math.sin((az * Math.PI) / 180);
|
||||
assert.ok(cos(2) < RAY_MIN_COS && !windowLit(east, sunDirOnPlan(2, 0), 40));
|
||||
assert.ok(cos(4) > RAY_MIN_COS && windowLit(east, sunDirOnPlan(4, 0), 40));
|
||||
// ~87.1° of incidence, i.e. the sun ~2.9° clear of the wall's own plane
|
||||
assert.ok(near((Math.acos(RAY_MIN_COS) * 180) / Math.PI, 87.13, 0.01));
|
||||
});
|
||||
|
||||
test('rayLength: longest at the horizon, shortest at noon, monotonic', () => {
|
||||
assert.ok(near(rayLength(0), 2.5, 1e-9));
|
||||
assert.ok(near(rayLength(90), 0.8, 1e-9));
|
||||
test('rayLength: 30% shorter than v1.56 (owner 2026-08-04), same shape', () => {
|
||||
// the old curve, kept here so the -30% stays a fact and not a memory
|
||||
const before = (e) => 0.8 + 1.7 * Math.pow(1 - Math.min(90, Math.max(0, e)) / 90, 1.6);
|
||||
assert.equal(RAY_LENGTH_K, 0.7);
|
||||
assert.ok(near(rayLength(0), 1.75, 1e-9)); // was 2.5
|
||||
assert.ok(near(rayLength(90), 0.56, 1e-9)); // was 0.8
|
||||
for (const e of [-5, 0, 3, 10, 30, 45, 60, 89, 90, 120]) {
|
||||
assert.ok(near(rayLength(e), before(e) * 0.7, 1e-12), 'exactly 70% at ' + e);
|
||||
}
|
||||
// the shape survives: a low sun still reaches much further than a high one
|
||||
assert.ok(rayLength(10) > rayLength(30));
|
||||
assert.ok(rayLength(30) > rayLength(60));
|
||||
assert.ok(near(rayLength(-5), 2.5, 1e-9)); // clamped
|
||||
assert.ok(near(rayLength(-5), 1.75, 1e-9)); // clamped
|
||||
});
|
||||
|
||||
test('rayStops: the shaft is fully dissolved BEFORE its own far edge', () => {
|
||||
const stops = rayStops();
|
||||
assert.ok(near(stops[0][0], 0) && near(stops[0][1], 1), 'brightest at the glass');
|
||||
assert.equal(RAY_FADE_END, 0.85);
|
||||
// offsets are sorted, alphas never rise, and the tail is a hard zero
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
assert.ok(stops[i][0] > stops[i - 1][0] || stops[i][0] === 1, 'offsets ascend');
|
||||
assert.ok(stops[i][1] <= stops[i - 1][1], 'alpha never brightens inward');
|
||||
}
|
||||
assert.ok(near(stops[stops.length - 1][0], 1), 'the gradient spans the FULL wedge');
|
||||
for (const [off, k] of stops) {
|
||||
if (off >= RAY_FADE_END) assert.equal(k, 0, 'nothing left at/after ' + RAY_FADE_END);
|
||||
else assert.ok(k > 0, 'still lit at ' + off);
|
||||
}
|
||||
// half gone well before the middle — the eye must not find a straight edge
|
||||
const half = stops.find(([, k]) => k <= 0.5);
|
||||
assert.ok(half[0] <= 0.65, 'past half-dark by two thirds of the way');
|
||||
});
|
||||
|
||||
test('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away', () => {
|
||||
assert.equal(SKY_SNAP_DEG, 3);
|
||||
assert.equal(skyNeedsSnap(null, 12), true); // nothing painted yet
|
||||
assert.equal(skyNeedsSnap(NaN, 12), true);
|
||||
assert.equal(skyNeedsSnap(12, 12), false);
|
||||
assert.equal(skyNeedsSnap(12, 13), false); // a real 4-minute sun step
|
||||
assert.equal(skyNeedsSnap(12, 14.9), false);
|
||||
assert.equal(skyNeedsSnap(12, 15), true); // ~12 minutes unwatched
|
||||
assert.equal(skyNeedsSnap(12, 9), true); // and in both directions
|
||||
assert.equal(skyElevation(12.3456), 12.3);
|
||||
assert.equal(skyElevation(-0.04), -0);
|
||||
assert.equal(skyElevation('nonsense'), 0);
|
||||
});
|
||||
|
||||
test('rayQuad: an honest parallelogram, both sides exactly `len` (DEV-EB173-01)', () => {
|
||||
// «Не надо размывать их боковые грани» — the sides are hard lines, so the
|
||||
// only thing that may dissolve a shaft is the gradient. That gradient runs
|
||||
// along the wall's NORMAL (see SunRay.normal/depth), and ITS iso-alpha lines
|
||||
// are parallel to the wall — which is exactly where an equal extrusion of
|
||||
// both ends puts the far edge. So the wedge is a plain parallelogram again
|
||||
// and every side is the full, promised reach.
|
||||
const a = [100, 100];
|
||||
const b = [100, 200]; // a window along +y
|
||||
const len = 300;
|
||||
for (const deg of [0, 20, 45, 70, -35, -60]) {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const dir = [Math.cos(rad), Math.sin(rad)]; // oblique sun in most cases
|
||||
const q = rayQuad(a, b, dir, len);
|
||||
assert.equal(q.length, 4);
|
||||
// the near edge is still the window itself
|
||||
assert.deepEqual(q[0], [100, 100]);
|
||||
assert.deepEqual(q[1], [100, 200]);
|
||||
for (const [near0, far] of [[q[0], q[3]], [q[1], q[2]]]) {
|
||||
const ex = far[0] - near0[0];
|
||||
const ey = far[1] - near0[1];
|
||||
// both sides run exactly along the ray — razor-sharp, never splayed
|
||||
assert.ok(Math.abs(ex * dir[1] - ey * dir[0]) < 1e-9, 'side parallel to the ray at ' + deg);
|
||||
assert.ok(ex * dir[0] + ey * dir[1] > 0, 'side runs away from the glass');
|
||||
// ...and each is the FULL reach: the 30 % cut is a fact on every side,
|
||||
// at every sun angle (the old skewed quad made one side 88 % longer)
|
||||
assert.ok(near(Math.hypot(ex, ey), len, 1e-9), 'side is exactly len at ' + deg);
|
||||
}
|
||||
// the far edge is parallel to the wall — the gradient's last iso-alpha line
|
||||
const fx = q[2][0] - q[3][0];
|
||||
const fy = q[2][1] - q[3][1];
|
||||
const sx = b[0] - a[0];
|
||||
const sy = b[1] - a[1];
|
||||
assert.ok(Math.abs(fx * sy - fy * sx) < 1e-6, 'far edge parallel to the wall at ' + deg);
|
||||
}
|
||||
// head-on sun: the classic parallelogram, unchanged
|
||||
const straight = rayQuad(a, b, [1, 0], len);
|
||||
assert.deepEqual(straight, [[100, 100], [100, 200], [400, 200], [400, 100]]);
|
||||
});
|
||||
|
||||
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {
|
||||
@@ -145,6 +236,67 @@ test('computeSunRays: evening west sun → west window', () => {
|
||||
assert.deepEqual(rays.map((r) => r.openingId), ['wW']);
|
||||
});
|
||||
|
||||
test('grazing sun: the auditor\'s repro, fixed by a normal-axis fade (DEV-EB173-01)', () => {
|
||||
// The report's browser probe: a WEST window 80 render units long, elevation
|
||||
// 90 (so the nominal reach is 0.56 · 80 = 44.8 — «на 30 % короче»), azimuth
|
||||
// 190 at north_deg 0, i.e. the light enters the glass but travels only 10°
|
||||
// off the wall's own direction. It measured sides of 5.408 and 84.192
|
||||
// (ratio 15.57, the long one 31 % LONGER than the pre-cut 64) and source
|
||||
// offsets of ±0.879 — one end of the glass already fully transparent,
|
||||
// because rayStops() is dead from 0.85 on.
|
||||
const win = { id: 'wW', x: 100, y: 300, angle: 90, length: 80 };
|
||||
const rays = computeSunRays(ROOMS, [win], 190, 90, 0);
|
||||
assert.equal(rays.length, 1);
|
||||
const r = rays[0];
|
||||
assert.ok(near(r.dir[0], 0.17365, 1e-5) && near(r.dir[1], -0.98481, 1e-5));
|
||||
assert.ok(near(r.len, 44.8, 1e-9), 'nominal reach is the 70 % one');
|
||||
|
||||
// 1) EQUAL sides, each exactly the nominal reach
|
||||
const q = rayQuad([r.a[0], r.a[1]], [r.b[0], r.b[1]], r.dir, r.len);
|
||||
const side = (p0, p1) => Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
|
||||
const sides = [side(q[0], q[3]), side(q[1], q[2])];
|
||||
assert.ok(near(sides[0], sides[1], 1e-9), 'sides equal (was a ratio of 15.57)');
|
||||
for (const l of sides) assert.ok(near(l, 44.8, 1e-9), 'each side is 44.8 (was 5.41 / 84.19)');
|
||||
|
||||
// 2) the fade axis is the INWARD wall normal, len · cos(incidence) long
|
||||
assert.ok(near(r.normal[0], 1, 1e-12) && near(r.normal[1], 0, 1e-12));
|
||||
const cos = r.dir[0] * r.normal[0] + r.dir[1] * r.normal[1];
|
||||
assert.ok(near(cos, 0.17365, 1e-5), 'a 10°-off-the-wall sun');
|
||||
assert.ok(near(r.depth, 44.8 * cos, 1e-9));
|
||||
assert.ok(near(r.depth, 7.7794, 1e-4));
|
||||
|
||||
// 3) offsets along THAT axis: the whole pane of glass at 0 (peak alpha at
|
||||
// BOTH ends — the probe's ±0.879 is gone), the far edge exactly at 1
|
||||
const mx = (r.a[0] + r.b[0]) / 2;
|
||||
const my = (r.a[1] + r.b[1]) / 2;
|
||||
const off = (p) => ((p[0] - mx) * r.normal[0] + (p[1] - my) * r.normal[1]) / r.depth;
|
||||
assert.ok(near(off(r.a), 0, 1e-12) && near(off(r.b), 0, 1e-12), 'glass all at peak alpha');
|
||||
assert.ok(near(off(q[2]), 1, 1e-12) && near(off(q[3]), 1, 1e-12), 'far edge on the last iso-alpha line');
|
||||
|
||||
// 4) ...and the offset of any point is exactly how far ITS ray has run
|
||||
for (const u of [0, 0.25, 0.5, 0.85, 1]) {
|
||||
for (const src of [r.a, r.b, [r.a[0], r.a[1] + 17]]) {
|
||||
const p = [src[0] + r.dir[0] * r.len * u, src[1] + r.dir[1] * r.len * u];
|
||||
assert.ok(near(off(p), u, 1e-9), 'offset = travelled / len at u=' + u);
|
||||
}
|
||||
}
|
||||
// 5) nothing drawn past the gradient, on the clipped geometry too
|
||||
for (const poly of r.polys) for (const p of poly) {
|
||||
assert.ok(off(p) >= -1e-6 && off(p) <= 1 + 1e-6, 'inside the gradient');
|
||||
}
|
||||
});
|
||||
|
||||
test('grazing sun: below RAY_MIN_COS a window casts nothing at all', () => {
|
||||
// azimuth 182° at north_deg 0 puts the sun 2° off the west wall's plane:
|
||||
// cos = sin(2°) = 0.035 < RAY_MIN_COS. 186° (0.105) still lights it.
|
||||
const win = { id: 'wW', x: 100, y: 300, angle: 90, length: 80 };
|
||||
assert.deepEqual(computeSunRays(ROOMS, [win], 182, 90, 0), []);
|
||||
assert.equal(computeSunRays(ROOMS, [win], 186, 90, 0).length, 1);
|
||||
// the surviving wedge is never thinner than 5 % of its own reach
|
||||
const r = computeSunRays(ROOMS, [win], 186, 90, 0)[0];
|
||||
assert.ok(r.depth >= r.len * RAY_MIN_COS);
|
||||
});
|
||||
|
||||
test('computeSunRays: night → nothing at all', () => {
|
||||
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, 0, 0), []);
|
||||
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, -10, 0), []);
|
||||
@@ -163,15 +315,39 @@ test('computeSunRays: rotating the compass swings the light to another window',
|
||||
}
|
||||
});
|
||||
|
||||
test('rayAlpha: capped, ramps in near the horizon, scaled by clouds', () => {
|
||||
assert.equal(rayAlpha(0), 0);
|
||||
test('rayAlpha: nothing below 3°, full strength above (owner 2026-08-03)', () => {
|
||||
// the old gradual ramp-in is gone: it is a threshold, not a fade
|
||||
assert.equal(rayAlpha(-3), 0);
|
||||
assert.ok(near(rayAlpha(1), RAY_MAX_ALPHA / 2));
|
||||
assert.equal(rayAlpha(0), 0);
|
||||
assert.equal(rayAlpha(1), 0);
|
||||
assert.equal(rayAlpha(2.99), 0);
|
||||
assert.ok(near(rayAlpha(3), RAY_MAX_ALPHA)); // exactly at the threshold: on
|
||||
assert.ok(near(rayAlpha(3.1), RAY_MAX_ALPHA));
|
||||
assert.ok(near(rayAlpha(30), RAY_MAX_ALPHA));
|
||||
assert.ok(near(rayAlpha(89), RAY_MAX_ALPHA)); // no elevation shaping at all
|
||||
// clouds still scale it, rain still kills it
|
||||
assert.ok(near(rayAlpha(30, 0.25), RAY_MAX_ALPHA * 0.25));
|
||||
assert.equal(rayAlpha(30, 0), 0);
|
||||
});
|
||||
|
||||
test('raysVisible / rayPeakAlpha: the threshold and the cloud-only ceiling', () => {
|
||||
assert.equal(RAY_ELEVATION_MIN, 3);
|
||||
assert.equal(RAY_FADE_MS, 2000); // «ровно 2 секунды», mirrored in styles.ts
|
||||
assert.equal(raysVisible(2.9), false);
|
||||
assert.equal(raysVisible(3), true);
|
||||
assert.equal(raysVisible(45), true);
|
||||
assert.equal(raysVisible(-10), false);
|
||||
// the peak is what the gradient uses while the layer fades — cloud only
|
||||
assert.ok(near(rayPeakAlpha(), RAY_MAX_ALPHA));
|
||||
assert.ok(near(rayPeakAlpha(1), RAY_MAX_ALPHA));
|
||||
assert.ok(near(rayPeakAlpha(0.4), RAY_MAX_ALPHA * 0.4));
|
||||
assert.equal(rayPeakAlpha(0), 0);
|
||||
});
|
||||
|
||||
test('RAY_MAX_ALPHA is the brighter 0.3 ceiling (owner 2026-08-03)', () => {
|
||||
assert.equal(RAY_MAX_ALPHA, 0.3);
|
||||
});
|
||||
|
||||
test('rayColor: warm at the horizon, neutral by day', () => {
|
||||
assert.equal(rayColor(1), '#ff9a45');
|
||||
assert.equal(rayColor(0), '#ffe9c2');
|
||||
|
||||
@@ -107,6 +107,35 @@ def test_space_schema_drops_the_old_aspect_and_bounds_the_image_ratio():
|
||||
v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]})
|
||||
|
||||
|
||||
def test_space_backdrop_transform_is_optional_and_bounded():
|
||||
"""plan_x / plan_y / plan_scale — the backdrop placement (docs/BACKDROP.md).
|
||||
|
||||
Optional to the letter: a space that has never been through the backdrop
|
||||
editor validates exactly as it did before, and the keys stay absent (there
|
||||
is no migration and no default written into anybody's store).
|
||||
"""
|
||||
ok = {"id": "f1", "title": "1", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
out = v.SPACE_SCHEMA(ok)
|
||||
assert "plan_x" not in out and "plan_y" not in out and "plan_scale" not in out
|
||||
|
||||
v.SPACE_SCHEMA({**ok, "plan_x": 0.25, "plan_y": -1.5, "plan_scale": 2.5})
|
||||
v.SPACE_SCHEMA({**ok, "plan_x": None, "plan_y": None, "plan_scale": None})
|
||||
# the offset is a coordinate: the whole canvas range, both signs
|
||||
v.SPACE_SCHEMA({**ok, "plan_x": v.CANVAS_LIMIT, "plan_y": -v.CANVAS_LIMIT})
|
||||
# the scale is a MULTIPLIER: strictly positive, human-sized
|
||||
v.SPACE_SCHEMA({**ok, "plan_scale": v.PLAN_SCALE_MIN})
|
||||
v.SPACE_SCHEMA({**ok, "plan_scale": v.PLAN_SCALE_MAX})
|
||||
|
||||
for bad in ({"plan_x": v.CANVAS_LIMIT + 1}, {"plan_y": -v.CANVAS_LIMIT - 1},
|
||||
{"plan_x": float("nan")}, {"plan_y": float("inf")},
|
||||
{"plan_scale": 0}, {"plan_scale": -1},
|
||||
{"plan_scale": v.PLAN_SCALE_MIN / 2},
|
||||
{"plan_scale": v.PLAN_SCALE_MAX * 2},
|
||||
{"plan_scale": float("nan")}):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA({**ok, **bad})
|
||||
|
||||
|
||||
def test_marker_schema():
|
||||
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc"})
|
||||
v.MARKER_SCHEMA({"id": "m2", "binding": "virtual", "name": "X",
|
||||
@@ -273,8 +302,8 @@ def test_finite_on_every_coordinate():
|
||||
def test_geometry_magnitudes_are_bounded():
|
||||
"""HP-1501-01: any finite float used to pass, and one schema-valid 1e100
|
||||
room vertex made the space unviewable for every client — the exact failure
|
||||
HP-1500-03 closed for layout positions, one schema over. ±4 is slack for a
|
||||
vertex nudged past an edge, not an envelope for absurdity."""
|
||||
HP-1500-03 closed for layout positions, one schema over. The range is
|
||||
garbage insurance, not an envelope for the plan (docs/CANVAS.md)."""
|
||||
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
huge = 1e100
|
||||
for cfg in (
|
||||
@@ -295,6 +324,80 @@ def test_geometry_magnitudes_are_bounded():
|
||||
{"id": "r", "name": "R", "poly": [[-0.2, 0], [1.3, 0], [1, 1]]}]}]})
|
||||
|
||||
|
||||
def test_infinite_canvas_range():
|
||||
"""docs/CANVAS.md §3: the canvas is unbounded, so a plan drawn far past the
|
||||
old unit square must SAVE. The limits moved from ±4 to ±5000: 4.5 and 1000
|
||||
are ordinary plans now, 6000 is still refused."""
|
||||
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
assert v.CANVAS_LIMIT == 5000.0
|
||||
# --- coordinates that used to be rejected by the ±4 envelope -----------
|
||||
for good in (4.5, 12.0, 1000.0, -4.5, -1000.0):
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "poly": [[good, good], [good + 0.5, good],
|
||||
[good + 0.5, good + 0.5]]}]}]})
|
||||
assert v.LAYOUT_SCHEMA({"d1": {"s": "s1", "x": good, "y": good}})
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
|
||||
{"id": "o", "type": "door", "x": good, "y": good,
|
||||
"angle": 30, "length": 0.1}]}]})
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [
|
||||
{"id": "d", "kind": "rect", "x": good, "y": good, "w": 0.2, "h": 0.2}]}]})
|
||||
# sizes follow the same ceiling but stay strictly positive
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "x": 1.5, "y": 1.5, "w": 4.5, "h": 1000.0}]}]})
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [1.5, 1.5, 4.5, 1000.0]}]})
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
|
||||
{"id": "o", "type": "door", "x": 2.5, "y": 2.5, "angle": 0, "length": 4.5}]}]})
|
||||
# --- and 6000 is still garbage ---------------------------------------
|
||||
for bad in (6000.0, -6000.0):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "poly": [[bad, 0], [1, 0], [1, 1]]}]}]})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.LAYOUT_SCHEMA({"d1": {"s": "s1", "x": bad, "y": 0.5}})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [
|
||||
{"id": "d", "kind": "line", "x1": bad, "y1": 0, "x2": 1, "y2": 1}]}]})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [0, 0, 6000.0, 1]}]})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "x": 0, "y": 0, "w": 6000.0, "h": 1}]}]})
|
||||
# sizes are still not coordinates: zero and negative stay refused
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
|
||||
{"id": "o", "type": "door", "x": 0.5, "y": 0.5, "angle": 0, "length": 0}]}]})
|
||||
|
||||
|
||||
def test_existing_plans_stay_valid():
|
||||
"""No migration (docs/CANVAS.md §1): a config written by any released
|
||||
version must validate untouched after the limits moved."""
|
||||
legacy = {
|
||||
"spaces": [{
|
||||
"id": "f1", "title": "Ground", "aspect": 1.0, "plan_url": "/local/f1.svg",
|
||||
"plan_aspect": 1.25, "view_box": [0, 0, 1, 1],
|
||||
"segments": [[0, 0, 1, 0]],
|
||||
"rooms": [
|
||||
{"id": "r1", "name": "Living", "area": "living_room",
|
||||
"poly": [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]},
|
||||
{"id": "r2", "name": "Kitchen", "x": 0.55, "y": 0.14, "w": 0.41, "h": 0.32},
|
||||
],
|
||||
"openings": [{"id": "o1", "type": "window", "x": 0.3, "y": 0.14,
|
||||
"angle": 0, "length": 0.08}],
|
||||
"decor": [{"id": "d1", "kind": "line", "x1": 0.1, "y1": 0.1,
|
||||
"x2": 0.9, "y2": 0.1, "color": "#ffffff"},
|
||||
{"id": "d2", "kind": "text", "x": 0.5, "y": 0.9, "text": "Porch"}],
|
||||
"settings": {"show_borders": True, "room_opacity": 0.5},
|
||||
}],
|
||||
"markers": [{"id": "m1", "binding": "device:abc", "space": "f1", "size": 1.4}],
|
||||
"settings": {"glow_radius_cm": 300},
|
||||
}
|
||||
out = v.CONFIG_SCHEMA(legacy)
|
||||
assert out["spaces"][0]["rooms"][0]["poly"][0] == [0.04, 0.14]
|
||||
assert "segments" not in out["spaces"][0]
|
||||
assert v.LAYOUT_SCHEMA({"d_light1": {"s": "f1", "x": 0.22, "y": 0.22},
|
||||
"rl_r1": {"s": "f1", "x": 0.3, "y": 0.3}})
|
||||
|
||||
|
||||
def test_sizes_are_not_coordinates():
|
||||
"""HP-1502-01: a size must be strictly positive — SVG refuses zero and
|
||||
negative width/height, and the clients divide by these. view_box [0,0,0,0]
|
||||
@@ -466,6 +569,16 @@ def test_every_tap_action_the_editor_offers_is_accepted():
|
||||
v.MARKER_SCHEMA(_marker(tap_action="launch-missiles"))
|
||||
|
||||
|
||||
def test_cover_tap_action_is_accepted():
|
||||
"""Owner's spec 2026-08-03: a marker may carry tap_action='cover'
|
||||
(open/close/stop). The card offers it only for non-guarded covers, but the
|
||||
schema is the door the config walks through — reject it and the whole plan
|
||||
stops saving (the v1.26.0 'value' lesson, see DISPLAY_MODES above)."""
|
||||
assert "cover" in _ts_list("TAP_ACTIONS")
|
||||
v.MARKER_SCHEMA(_marker(tap_action="cover"))
|
||||
v.MARKER_SCHEMA(_marker(tap_action="cover", tap_confirm=True))
|
||||
|
||||
|
||||
def _space(**settings):
|
||||
return {
|
||||
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
|
||||
|
||||
+2
-1
@@ -15,6 +15,7 @@
|
||||
"src/devices.ts",
|
||||
"src/types.ts",
|
||||
"src/space-geometry.ts",
|
||||
"src/signing.ts"
|
||||
"src/signing.ts",
|
||||
"src/align-grid.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user