Icons scale with the plan again, and a 5 degree angle step

Two owner corrections after the infinite canvas.

- --icon-size goes back to being a percentage of the PLAN: a marker
  grows and shrinks with the zoom, like everything else drawn on the
  plan. The infinite canvas had made it a percentage of the viewport
  (fixed pixel size) — the owner looked at it and asked for the
  original contract back.
  What survives from the canvas work is the NUMERATOR. The old
  expression divided by `vb.w`, the stored view_box, which is not a
  frame any more; a fixed NORM_W in its place would have shrunk every
  marker on a plan drawn past the old square by exactly the factor the
  plan is outsized (an invisible dot 50 canvases out). So it is now
  `iconCqw() = iconPct * iconUnit(space) * kioskScale / view.w`, one
  pure helper both renderers call. `iconUnit` is exactly NORM_W for
  any plan that fits the old square — and the editor has never written
  anything but `view_box: [0,0,1,1]` — so the rendered size is
  bit-identical to the pre-canvas card: measured against the v1.56.0
  bundle at a fixed view, both give 3.400 / 3.091 / 6.182 / 12.364 cqw
  = 28.52 / 26.11 / 50.22 / 98.44 px. On a plan drawn at 1.5..3.8 the
  marker is 26.1 px, the same as on an ordinary plan, instead of the
  ~11 px a fixed numerator would have given.
  The static space-card uses the same helper: it has no zoom, but its
  frame is the content now, so a bare iconPct shrank its markers as
  the frame tightened. marker.size, the kiosk scales and every
  satellite still ride on --dev-size, untouched.
- the icon angle in the device dialog steps by 5 degrees, not 10
  (0..355): a marker often has to line up with a wall that is not on a
  10-degree grid.

Tests: three unit tests on iconCqw (the legacy expression reproduced
digit for digit, the runaway plan, the no-view fallback); the infinite
canvas smoke's "same pixel size at zoom 1/4/1/3" assert is turned back
into "scales 4x / 1/3 with the zoom" plus a new one that the marker on
the far plan measures the same as on an ordinary one; the angle step
is pinned in smoke_size_angle_parity. docs/CANVAS.md §6 rewritten.
This commit is contained in:
houseplan-dev
2026-08-04 00:06:21 +03:00
parent 693601a8e0
commit 2c947f4f7a
10 changed files with 291 additions and 103 deletions
+14 -9
View File
@@ -51,7 +51,7 @@ import './space-card';
import { cardStyles } from './styles';
import {
fitInSquare, contentBounds, spaceModels, contentFrame, contentItems, spaceFrame,
spaceCenter, iconUnit, gridLevels, itemOf,
spaceCenter, iconUnit, iconCqw, gridLevels, itemOf,
MIN_ZOOM, PAN_SLACK, CANVAS_LIMIT, type ContentItem, type Rect,
} from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
@@ -5663,14 +5663,17 @@ class HouseplanCard extends LitElement {
${this._renderOpenings(disp)}
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
</svg>
${''/* docs/CANVAS.md §6: --icon-size is a percentage of the VISIBLE
viewport (cqw), no longer of the canvas — the old
`* vb.w / view.w` made a marker grow with the zoom until it
covered a room, and on an unbounded canvas "a percent of the
canvas" has no meaning at all. Same expression as the static
space-card, so the two renderers finally agree. The per-device
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
scales with it when you zoom — the behaviour the card always
had, restored by the owner. `iconCqw` is `iconPct * iconUnit
/ view.w`: the old expression with the stored `vb.w` replaced
by the plan's own base unit, which is the same NORM_W for an
ordinary plan (pixel-identical) but grows with a plan drawn
past the old square, where a fixed 1000 would have shrunk
every marker to a dot. Same expression as the static
space-card, so the two renderers agree. The per-device
multiplier and the kiosk scales still feed --dev-size. */}
<div class="devlayer" style="--icon-size:${(iconPct * (this._kiosk ? this._kioskScale.icon : 1)).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi, disp.fill === 'glow' && !this._markup))}
${this._renderVacuums(devs, view)}
${this._renderVacFit(view)}
@@ -7553,7 +7556,9 @@ class HouseplanCard extends LitElement {
${this._rangeInput(0.5, 3, 0.1, d.size, (n) => (this._markerDialog = { ...d, size: n }))}
<span class="opv">×${d.size.toFixed(1)}</span>
<span class="opl">${this._t('marker.angle_label')}</span>
${this._rangeInput(0, 350, 10, d.angle, (n) => (this._markerDialog = { ...d, angle: n }))}
${''/* 5 degrees, not 10 (owner 2026-08-03): a marker often has to
line up with a wall that is not on a 10-degree grid. */}
${this._rangeInput(0, 355, 5, d.angle, (n) => (this._markerDialog = { ...d, angle: n }))}
<span class="opv">${d.angle}°</span>
</div>
+38 -4
View File
@@ -281,10 +281,16 @@ export function spaceCenter(space: SpaceModel): Pt {
}
/**
* 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.
* The BASE UNIT one icon-percent is measured against, in render units
* (docs/CANVAS.md §6). It is both the icon's own footprint and the
* auto-placement spacing, so the two can never drift apart.
*
* For any plan that fits the old square this is exactly NORM_W — which is
* what `vb.w` was for every plan the card itself ever wrote (the editor only
* ever stored `view_box: [0,0,1,1]`), so sizes and layouts are bit-identical
* to the pre-infinite-canvas card. A plan drawn three canvases wide gets a
* proportionally bigger unit, which is what keeps its markers from
* degenerating into dots once the frame is the content (see `iconCqw`).
* Rooms only — deterministic, so the full card and the static card agree.
*/
export function iconUnit(space: SpaceModel): number {
@@ -295,6 +301,34 @@ export function iconUnit(space: SpaceModel): number {
return Math.max(NORM_W, Math.min(SANE_LIMIT, Math.max(b.w, b.h)));
}
/**
* `--icon-size` in cqw (docs/CANVAS.md §6) — the ONE expression both
* renderers use.
*
* An icon is a percentage of the PLAN, not of the viewport: it scales with
* the plan as you zoom, exactly as it did before the infinite canvas (owner,
* 2026-08-03). In render units the marker always occupies
* `iconPct/100 * iconUnit`, whatever the frame or the zoom happens to be;
* dividing by the width of the visible view turns that into a percentage of
* the container, which is what `cqw` means.
*
* The only thing the infinite canvas changed here is the numerator: it used
* to be `vb.w`, the stored view_box, and a plan drawn past the old square
* kept a 1000-unit numerator while its frame grew to tens of thousands —
* markers would shrink to invisible dots. `iconUnit` is that same 1000 for
* an ordinary plan and grows with an outsized one.
*/
export function iconCqw(
iconPct: number, space: SpaceModel, viewW: number, kioskIcon = 1,
): number {
const w = Number(viewW);
const k = Number.isFinite(kioskIcon) && kioskIcon > 0 ? kioskIcon : 1;
// No view yet (first paint, zero-width stage): fall back to the plain
// percentage rather than to Infinity/NaN — the frame arrives a tick later.
if (!Number.isFinite(w) || w <= 0) return iconPct * k;
return (iconPct * iconUnit(space) * k) / w;
}
/** Grid step multipliers offered to the adaptive grid (docs/CANVAS.md §7). */
export const GRID_STEPS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
+8 -2
View File
@@ -14,7 +14,7 @@ import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
import type { ServerConfig } from './types';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, NORM_W,
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
type Layout, type ContentItem,
} from './space-geometry';
@@ -198,7 +198,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: nothing}
${roomShapes}
</svg>
<div class="devlayer" style="--icon-size:${iconPct}cqw">${markers}${labels}</div>
${''/* docs/CANVAS.md §6: the same expression as the full card. The
static card has no zoom, but its frame is the CONTENT now, so a
bare `iconPct` would make markers shrink relative to the plan the
tighter the frame got. `iconCqw` keeps the marker's footprint at
iconPct% of the plan's base unit, which is what it was when the
frame was the stored view_box. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, vb[2]).toFixed(3)}cqw">${markers}${labels}</div>
</div>
`;
}