diff --git a/custom_components/houseplan/validation.py b/custom_components/houseplan/validation.py index 21a7b84d..e49cfdc5 100644 --- a/custom_components/houseplan/validation.py +++ b/custom_components/houseplan/validation.py @@ -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,19 @@ 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)) def _view_box(value): @@ -214,7 +218,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, @@ -258,7 +264,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, diff --git a/docs/CANVAS.md b/docs/CANVAS.md new file mode 100644 index 00000000..8e785269 --- /dev/null +++ b/docs/CANVAS.md @@ -0,0 +1,259 @@ +# Infinite canvas — the spec (source of truth) + +Status: approved by the owner 2026-08-03. Dev-branch feature, **no +release**. 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 (the one deliberate exception is icon size — see §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 | content frame + one screen of slack in each direction | +| Icon size | % of `view_box`, i.e. grew with zoom | % of the visible viewport (§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. + +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`. + +### §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** — 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. +* **"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 — from the viewport + +**Changed behaviour, owner is aware.** Before: + +``` +--icon-size: iconPct * vb.w / view.w (cqw) +``` + +so an icon grew as you zoomed in — at 8x zoom a marker covered a whole +room. Now: + +``` +--icon-size: iconPct * kioskScale (cqw) +``` + +An icon is a fixed percentage of the **visible viewport**: zooming +changes how much plan you see, not how big the markers are. Side +effects, all intended: + +* the full card and the static `houseplan-space-card` now use the + identical expression — the two renderers finally agree at every zoom; +* the per-device multiplier `marker.size` and the kiosk icon/font + scales are untouched: they still feed `--dev-size`, and every + satellite (badges, LQI chips, presence rings, ripples) still derives + from `--dev-size` exactly as before. + +**Auto-placement spacing** (`defaultPositions` -> `declump`) is measured +in render units, so it needs the icon's render-unit footprint, which is +now frame-relative. `iconUnit(space) = max(NORM_W, roomsExtent)` is +used instead of a bare `NORM_W`: for any plan that fits the old square +this is exactly `NORM_W` (bit-identical placement, no churn), and for a +plan three canvases wide the spacing grows with it. Both renderers call +the same helper, so the static card and the full card stay in step. + +## §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. + +## §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`. + +## 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` | icon is a fraction of the canvas | §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 `` 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 | +| `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 | +| 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 | +| `_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. diff --git a/src/space-geometry.ts b/src/space-geometry.ts index c0ada111..7ca46257 100644 --- a/src/space-geometry.ts +++ b/src/space-geometry.ts @@ -84,61 +84,233 @@ export function spaceModels(cfg: ServerConfig | null): 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, -): { 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; + +/** 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): 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[] { + const out: ContentItem[] = []; + for (const r of space.rooms || []) { const it = roomItem(r); if (it) out.push(it); } + // With a backdrop the IMAGE sets the extent: cropping to the outlined rooms + // would hide the parts of the picture nobody has drawn over yet. + 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, + 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, +): 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, + 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 }; +} + +/** + * Render units one icon-percent is measured against for AUTO-PLACEMENT + * spacing (docs/CANVAS.md §6). For any plan that fits the old square this + * is exactly NORM_W, so existing layouts do not move by a hair; a plan + * three canvases wide gets proportionally wider spacing. + * Rooms only — deterministic, so the full card and the static card agree. + */ +export function iconUnit(space: SpaceModel): number { + const items: ContentItem[] = []; + for (const r of space.rooms || []) { const it = roomItem(r); if (it) items.push(it); } + const b = boxOf(items); + if (!b) return NORM_W; + return Math.max(NORM_W, Math.min(SANE_LIMIT, Math.max(b.w, b.h))); +} + +/** 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 +337,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 { const map: Record = {}; - 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); @@ -194,8 +369,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 spaceCenter(model); } /** Saved room-label position (layout key rl_) or the room centre. */ diff --git a/test/canvas.test.mjs b/test/canvas.test.mjs new file mode 100644 index 00000000..6d97d208 --- /dev/null +++ b/test/canvas.test.mjs @@ -0,0 +1,189 @@ +// 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, 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'); + } + } +}); + +// ------------------------------------------------------------- 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); +}); diff --git a/test/space-geometry.test.mjs b/test/space-geometry.test.mjs index 382acb9f..c016012b 100644 --- a/test/space-geometry.test.mjs +++ b/test/space-geometry.test.mjs @@ -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_ → render units; else room centre', () => { diff --git a/tests_backend/test_validation.py b/tests_backend/test_validation.py index 97466245..1e5324cb 100644 --- a/tests_backend/test_validation.py +++ b/tests_backend/test_validation.py @@ -273,8 +273,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 +295,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]