mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Infinite canvas: the spec, the pure geometry and the ±5000 limits
docs/CANVAS.md is the source of truth (owner-approved 2026-08-03): the normalised square was never a sheet of paper, only a coordinate system, and users who drew past its edge could not place devices there. Storage does not change and there is no migration. What changes is what the renderers DERIVE from it: - space-geometry.ts gains contentFrame() — one item per drawn object, a rank-based outlier vote (median centre, 75th-percentile spread, 10x threshold, majority veto) and a fit-everything box beside the opening view. contentBounds() is now a thin wrapper over it; the old -25%..125% envelope is gone — it WAS the bug that made a plan drawn at 1.5..3.0 frame empty canvas. - spaceFrame()/spaceCenter() make view_box an optional first-frame hint used only when there is nothing to frame; iconUnit() keeps auto placement spacing in proportion (NORM_W for anything inside the old square, so no layout moves); gridLevels() picks a legible grid step. - validation.py: coordinates ±4 -> ±5000, sizes 0.001..5000, decor -1..2 -> ±5000, opening length <= 5000. Garbage insurance, not a frame — a stored 1e100 is still refused. Units: test/canvas.test.mjs covers the plan past the square, the outlier (and the three ways NOT to declare one), corruption, empty space, a lone marker, image plans and the adaptive grid. Backend: the limits, and that a config from any released version validates untouched.
This commit is contained in:
@@ -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,
|
||||
|
||||
+259
@@ -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 `<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 |
|
||||
| `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.
|
||||
+226
-50
@@ -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<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;
|
||||
|
||||
/** 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); }
|
||||
// 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<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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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);
|
||||
@@ -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_<roomId>) or the room centre. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user