v1.59.0-beta.8: audit follow-ups and inner-corner sun rays

This commit is contained in:
Matysh
2026-08-05 20:07:27 +03:00
parent 3ad4e9d803
commit e188f9d609
31 changed files with 619 additions and 204 deletions
+33 -1
View File
@@ -4,11 +4,43 @@ on:
types: [published]
permissions:
contents: write
actions: read
jobs:
build:
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
# workflow only built and uploaded, so an asset shipped while both Validate
# runs for the very same commit were red. The asset now waits for a green
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
#
# Needs a push with a token that has the `workflow` scope (the ordinary
# Personal Access Token used for `git push` refuses workflow file updates).
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Require a green Validate for this exact commit
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
# HEAD is the peeled commit even when TAG is annotated. Do not trust
# target_commitish (it may be a branch name) or an event-context SHA.
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
node scripts/release-gate.mjs "$SHA"
build:
needs: gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.59.0-beta.7"
VERSION = "1.59.0-beta.8"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.59.0-beta.7"
"version": "1.59.0-beta.8"
}
+3 -3
View File
@@ -42,9 +42,9 @@ const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 15, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
// + «Выровнять всё по сетке» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun", "Grid", "About"],
"rows": 16, // 11 цветов (включая wall_fill) + радиус свечения + фон + погода (docs/SUN.md)
// + «Выровнять всё по сетке» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Walls", "Stage background", "Sun", "Grid", "About"],
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
"aboutLinks": [
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
+29
View File
@@ -68,6 +68,35 @@ out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
await restore();
// AUD-159B7-01: one open span crossing the Split point belongs to two room
// pairs afterwards. The card transaction must persist both pieces and derive
// light-zone connectivity for both children, not keep only the first half.
const spanSplit = await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((x) => x.id === 'f1');
sp.rooms = [
{ id: 'left_top', name: 'Left top', area: null, poly: [[0, 0], [.5, 0], [.5, .2], [0, .2]] },
{ id: 'left_bottom', name: 'Left bottom', area: null, poly: [[0, .2], [.5, .2], [.5, .4], [0, .4]] },
{ id: 'right', name: 'Right', area: null, poly: [[.5, 0], [1, 0], [1, .4], [.5, .4]] },
];
sp.open_spans = [{ a: [.5, .1], b: [.5, .3] }];
c._commitOpenSpans();
const pieces = (sp.open_spans || []).map((e) =>
[Math.min(e.a[1], e.b[1]), Math.max(e.a[1], e.b[1])]);
const links = Object.fromEntries(sp.rooms.map((r) => [r.id, r.open_to || []]));
return { pieces, links };
});
out.splitKeepsOpenPieces = spanSplit.pieces.length === 2
&& Math.abs(spanSplit.pieces[0][0] - .1) < 1e-9
&& Math.abs(spanSplit.pieces[0][1] - .2) < 1e-9
&& Math.abs(spanSplit.pieces[1][0] - .2) < 1e-9
&& Math.abs(spanSplit.pieces[1][1] - .3) < 1e-9;
out.splitSyncsBothSides = spanSplit.links.left_top?.includes('right')
&& spanSplit.links.left_bottom?.includes('right')
&& spanSplit.links.right?.includes('left_top')
&& spanSplit.links.right?.includes('left_bottom');
await restore();
// SPLIT along a wall → refused (both pts on top wall of rg)
await page.evaluate(()=>{const c=window.__card; c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
{id:'rg2', name:'G2', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
+65
View File
@@ -70,6 +70,30 @@ const res = await page.evaluate(async () => {
const areaAfter = floorAreaOf('r1');
out.areaShrinks = areaAfter > 0 && areaAfter < areaBefore - 1;
// A sun shaft starts at the two room-side corners of a thick opening, not
// on the wall centreline and not at an incidence-narrowed pseudo-aperture.
sp().openings = [{
id: 'wtSun', type: 'window', x: 0.04, y: 0.25, angle: 90, length: 0.09,
}];
sp().settings = { ...(sp().settings || {}), north_deg: 0, sun_rays: true };
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 240, elevation: 60 },
} } };
c._setMode('view');
await upd();
const sunRay = (c._sunRaysCache?.rays || []).find((r) => r.openingId === 'wtSun');
const sunDepth = (20 / c._cellCm) * c._gridPitch;
const ys = sunRay ? [sunRay.a[1], sunRay.b[1]].sort((a, b) => a - b) : [];
out.sunStartsInnerFace = !!sunRay
&& Math.abs(sunRay.a[0] - (40 + sunDepth / 2)) < 1e-6
&& Math.abs(sunRay.b[0] - (40 + sunDepth / 2)) < 1e-6;
out.sunStartsInnerCorners = !!sunRay
&& Math.abs(ys[0] - 205) < 1e-6 && Math.abs(ys[1] - 295) < 1e-6;
c._setMode('plan');
c._tool = 'wallthick';
await upd();
c._tool = 'wallthick';
c._wallThickClick([550, 250]);
await upd();
@@ -201,6 +225,47 @@ const res = await page.evaluate(async () => {
}
out.pixelProbe = !!sr().querySelector('.wallbody');
// AUD-159B7-04: the same screen-depth policy owns both cards. At 1 cm the
// hatch must be suppressed in full and static views; at 20 cm both restore
// it. The static card measures its actual stage width via ResizeObserver.
c._setMode('view');
const parityCfg = JSON.parse(JSON.stringify(c._serverCfg));
const paritySp = parityCfg.spaces.find((x) => x.id === c._space);
paritySp.settings = { ...(paritySp.settings || {}), show_borders: true };
paritySp.walls = (paritySp.walls || []).map((w) => ({ ...w, cm: 1 }));
c._serverCfg = parityCfg;
await upd();
await customElements.whenDefined('houseplan-space-card');
const baseCall = c.hass.callWS.bind(c.hass);
const staticHass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: parityCfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
return baseCall(m);
} };
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = staticHass;
document.body.appendChild(staticCard);
const t0 = Date.now();
while ((!staticCard.renderRoot?.querySelector('.wallbody') || !(staticCard._stageWidth > 0))
&& Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await staticCard.updateComplete;
out.thinWallFullSolid = !!sr().querySelector('.wallbody.solid');
out.thinWallStaticSolid = !!staticCard.renderRoot?.querySelector('.wallbody.solid');
for (const w of paritySp.walls || []) w.cm = 20;
c._cfgEpoch++;
c.requestUpdate();
staticCard.requestUpdate();
await c.updateComplete;
await staticCard.updateComplete;
out.thickWallFullHatched = !!sr().querySelector('.wallbody:not(.solid)');
out.thickWallStaticHatched = !!staticCard.renderRoot?.querySelector('.wallbody:not(.solid)');
staticCard.remove();
return out;
});
File diff suppressed because one or more lines are too long
+37 -34
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -391,7 +391,10 @@ hash falls back to the default.
geometric virtual stretches; `room.open_to` remains the light-zone index
derived from spans (legacy `open_to`-only configs expand to full
`sharedBoundary` on read). Shared stretches drawn as a TRUE dash; outlines
trimmed (`outlineWithout`/`cutSegments`).
trimmed (`outlineWithout`/`cutSegments`). Geometry mutations clip one stored
span to **every** surviving shared segment. Adjacent pieces owned by different
room pairs stay separate: their midpoints are the source of the corresponding
`open_to` links after Split (`AUD-159B7-01`).
- **Marker controls** (v1.36): `marker.controls[]` (lights/switches only)
toggled as one HA-group-semantics service call on the marker's EXPLICIT
tap_action=toggle; icon state/tint mirrors the targets. `primaryEntity`
+22
View File
@@ -2,6 +2,28 @@
## Unreleased (dev)
## v1.59.0-beta.8 — 2026-08-05
Eighth pre-release of the 1.59 line: beta.7 audit follow-ups, a fail-closed
release path, full/static wall-render parity, and sunlight anchored to the
room-side corners of thick window openings.
- **Sun rays start at the inner window corners.** With wall thickness, the
wedge's full source span now sits on the room-side face of the opening, so
both crisp side edges begin at its two inner corners even at an oblique sun
angle. The clean-floor contour still clips the light at the wall body.
- **Beta.7 audit follow-up.** A Split crossing one `open_span` now keeps every
valid room-pair piece and derives `open_to` for both children instead of
silently turning one half back into a solid wall (`AUD-159B7-01`).
- **Release safety.** `release.yml` resolves the published tag to its exact
commit and waits for all matching Validate runs to finish green; missing,
failed, cancelled or timed-out validation withholds the asset
(`AUD-159B7-02`). The same fail-closed policy has automated negative tests.
- **Test/render parity.** General-settings smoke includes the Walls group,
inventory counts class-based and trail backend tests, and the static space
card uses the same 3 px thin-wall hatch fallback as the full card
(`AUD-159B7-03`, `AUD-159B7-04`).
## v1.59.0-beta.7 — 2026-08-05
Seventh pre-release of the 1.59 line: audit fixes for partial-wall geometry and
+24
View File
@@ -8,6 +8,30 @@
## Не выпущено (dev)
## v1.59.0-beta.8 — 2026-08-05
Восьмой pre-release линейки 1.59: правки после аудита beta.7, fail-closed
контур выпуска, паритет рендера стен в полной и статической карточках
и солнечные лучи из внутренних углов толстых оконных проёмов.
- **Солнечные лучи начинаются из внутренних углов окна.** При заданной
толщине стены полное основание луча теперь лежит на внутренней грани
проёма, поэтому оба чётких боковых ребра выходят точно из двух
внутренних углов даже при косом солнце. Внутренний контур пола по-прежнему
не даёт свету проходить через тело стены.
- **Правки по аудиту beta.7.** Split поперёк одного `open_span` теперь сохраняет
все валидные отрезки для разных пар комнат и строит `open_to` для обеих новых
комнат, не превращая половину прохода обратно в сплошную стену
(`AUD-159B7-01`).
- **Безопасный выпуск.** `release.yml` разворачивает опубликованный tag в точный
commit и ждёт зелёного завершения всех Validate этого SHA; отсутствие,
ошибка, отмена или timeout не дают приложить asset (`AUD-159B7-02`).
Для той же fail-closed политики добавлены автоматические negative tests.
- **Паритет тестов и рендера.** Smoke общих настроек учитывает группу Walls,
inventory считает методы классов и trail-тесты backend, а статическая
карточка применяет тот же порог 3 px для отключения штриховки тонкой стены,
что и полная (`AUD-159B7-03`, `AUD-159B7-04`).
## v1.59.0-beta.7 — 2026-08-05
Седьмой pre-release линейки 1.59: правки по аудиту частичных стен и lifecycle,
+6 -3
View File
@@ -95,9 +95,12 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
## Release
Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` builds and attaches
`houseplan-card.js`. Bump the version everywhere in sync: `src/houseplan-card.ts` (CARD_VERSION),
`package.json`, `custom_components/houseplan/manifest.json`, `custom_components/houseplan/const.py`.
Tag `vX.Y.Z` + GitHub Release → `.github/workflows/release.yml` resolves that
tag to its exact commit, waits for every Validate run of the SHA to complete
successfully, then builds and attaches `houseplan-card.js`. A missing, failed,
cancelled or one-hour-timed-out Validate withholds the asset. Bump the version
everywhere in sync: `src/houseplan-card.ts` (CARD_VERSION), `package.json`,
`custom_components/houseplan/manifest.json`, `custom_components/houseplan/const.py`.
## Reproducible scripts (data)
+4 -4
View File
@@ -15,15 +15,15 @@
| Item | State |
|---|---|
| Version | **v1.59.0-beta.7** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.7` on **`dev`**, GitHub Release with `prerelease=true`; `main` is NOT touched and **nothing is copied to the home instance by hand** — HACS delivers it on the beta channel. On top of beta.6: audit fixes (atomic wall intervals, open_spans transaction, backend schema, warm-nav, release gate, smokes) + wall fill **under** hatch. Previous pre-release: v1.59.0-beta.6; previous stable: v1.58.0 |
| Version | **v1.59.0-beta.8** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.8` on **`dev`**, GitHub Release with `prerelease=true`; `main` is not touched and nothing is copied to the home instance by hand — HACS delivers it on the beta channel. On top of beta.7: Split preserves every room-pair piece of `open_spans`, the exact-tag release gate fails closed, smoke/inventory and full/static thin-wall parity are repaired, and sun rays start at the inner corners of thick window openings. Previous pre-release: v1.59.0-beta.7; previous stable: v1.58.0 |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| GitHub | https://github.com/Matysh/houseplan-card — `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | beta.8 repairs the stale general-settings smoke that left beta.7 Validate red (`111/112`) and adds exact-SHA release gating: `release.yml` withholds the asset until every matching Validate run finishes green. The full local gate is green; GitHub Validate and the release asset are verified after the tag is pushed |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Tests | Four layers: Node unit (`npm test`: frontend pure modules + tooling policy), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
| Demo stand | **https://demo.houseplan.tech** — public, login `demo`/`demo`, resets to a pristine synthetic home every hour. **https://dev.houseplan.tech** — closed (basic auth), auto-deploys the `dev` branch every 10 min. Host: `ssh -i ~/.ssh/hp_stand hp@135.106.166.146`; layout, seeds and gotchas in the memory note `houseplan-demo-stand`. Since 2026-07-31 the stand covers most of the manual checklist: a scripted robot vacuum (`demo/stand/demo_robot` — Tasshack-shaped map sensor, serpentine run, pre-solved calibration, seeded server trail), Zigbee-style LQI template sensors, hand/auto-triggered leak+smoke alarms, an hvac_action climate marker and working script/scene/automation targets for tap-run. The stand-specific how-to-check guide is **docs/TESTING-DEMO.md** |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
+17 -15
View File
@@ -131,15 +131,17 @@ when BOTH hold:
perpendicular depth (`len · cos`, see «Dissolving») would be thinner
than the wall it came through.
The wedge is a PARALLELOGRAM: the window's span extruded by the same
The wedge is a PARALLELOGRAM: the window's full **room-side span**, from
one inner corner of the opening to the other, extruded by the same
length along the direction AWAY from the sun (light falls inward), so
its far edge is parallel to the wall, clipped by the receiving room's
**inner contour** when wall thickness is set (`inset` of the polygon by
half the wall thickness — see `docs/WALL-THICKNESS.md`); otherwise by
the room polygon (`polyclip` intersection). With thickness `d` the
effective lit width narrows through the opening tunnel:
`max(0, L − d·tan(|α|))` where `α` is the angle between the ray and the
inward normal (`d = 0` keeps the previous full-span wedge). Its length
the room polygon (`polyclip` intersection). With wall depth `d`, the
source span is translated from the wall centreline by `d/2` along the
inward normal. Thus both crisp side edges begin exactly at the two inner
corners of the opening at every incidence angle (`d = 0` keeps the
previous centreline/full-span geometry). Its length
is `k(elevation)` in window lengths: ~1.75 at sunrise/sunset tapering to ~0.56 at the zenith
(`0.56 + 1.19·(1 − elevation/90)^1.6` — the v1.56 curve
`0.8 + 1.7·(1 − elevation/90)^1.6` times `RAY_LENGTH_K` = 0.7, owner
@@ -163,10 +165,10 @@ Two rounds with the owner on the same day:
wedge, which feathered the SIDES too. Wrong: a shaft of sunlight
through a window has crisp sides. Only its reach fades.
So the falloff is one-dimensional: **along the ray, from the glass
So the falloff is one-dimensional: **along the ray, from the room-side opening
inward, and nothing else.** Three invariants have to hold at once:
1. the whole pane of glass is at peak alpha — light does not start out
1. the whole room-side opening is at peak alpha — light does not start out
half-dark at one end of the window;
2. every ray fades over the same distance, its own `len`;
3. the wedge's far edge lies exactly on an iso-alpha line, so the shaft
@@ -175,21 +177,21 @@ inward, and nothing else.** Three invariants have to hold at once:
**The axis of the fade is the wall's INWARD NORMAL, not the ray.**
The light is a bundle of PARALLEL rays, so the distance a point has
travelled from the glass is `depth / cos`, where `depth` is its
perpendicular distance from the wall and `cos = dir·normal` is fixed
travelled from the room-side source span is `depth / cos`, where `depth`
is its perpendicular distance from that span and `cos = dir·normal` is fixed
for the whole wedge. That is an affine function of the point, and its
level sets are straight lines PARALLEL TO THE WALL. A linear gradient
whose axis is the normal therefore describes the travelled distance
exactly:
- `x1,y1` = the middle of the window span (any point of the glass —
- `x1,y1` = the middle of the room-side window span (any point of that span —
they all have depth 0);
- `x2,y2` = that point plus `normal · len · cos` — `SunRay.depth`, the
perpendicular depth a ray reaches after running the full `len`;
- a point `source + dir·u` lands on offset `u / len`, whichever ray it
rode in on.
Hence: the glass is all at offset 0 (invariant 1), the alpha at any
Hence: the inner opening span is all at offset 0 (invariant 1), the alpha at any
point is a function of how far its own ray has run (invariant 2), and
the parallelogram's far edge — parallel to the wall — IS the gradient's
last iso-alpha line (invariant 3). The «30 % shorter» reach is then a
@@ -237,8 +239,8 @@ of its cheap half, verbatim: «тонкая (1px) чёрная граница п
The contract:
- **Two side edges only.** The rim runs along the two edges that leave
the ends of the glass and travel inward with the ray — `a → a+dir·len`
and `b → b+dir·len`. Never the glass edge `a-b` (that is the source,
the inner opening corners and travel inward with the ray — `a → a+dir·len`
and `b → b+dir·len`. Never the source edge `a-b` (that is the source,
not a boundary) and never the far edge (there is nothing left to
outline there — the fill is already at zero, see below).
- **One screen pixel at any zoom**: `stroke-width="1"` plus
@@ -249,7 +251,7 @@ The contract:
`x1,y1,x2,y2`** (the wall's inward normal, `depth` long) and **the same
normalised curve** — `rimStops()` returns `rayStops()` by identity, not
by copy, so the two can never drift apart. Only the colour and the peak
differ: `RIM_MAX_ALPHA` = 0.42 at the glass, tuned on the demo rig
differ: `RIM_MAX_ALPHA` = 0.42 at the inner opening, tuned on the demo rig
against both extremes (below ~0.3 the line vanishes on paper at kiosk
scale, above ~0.5 it reads as an ink contour over the dark glow
canvas). Zero from `RAY_FADE_END` = 85 % on, like the fill.
@@ -346,7 +348,7 @@ Backend validation: string or null.
the end of the gradient — the kerb cannot come back. It re-runs the
DEV-EB173-01 grazing repro end to end (west window 80, elevation 90,
azimuth 190): equal sides of the nominal length, peak alpha at BOTH
ends of the glass, and no wedge at all below `RAY_MIN_COS`.
ends of the source span, and no wedge at all below `RAY_MIN_COS`.
- `demo/smoke_sun_rim.mjs` — the rim: exactly two lines per wedge, on the
two SIDE edges (their coordinates checked against the wedge's own
vertices), `url(#hp-sunrim-N)` with BLACK stops on the same axis and
+12
View File
@@ -995,6 +995,10 @@ require hands on real hardware — they remain for the human pass.
- [ ] Wedge direction follows the compass; length grows toward
sunrise/sunset and shrinks toward noon; every wedge is clipped by its
room polygon; night = no wedges at all
- [ ] With wall thickness, both crisp side edges start at the two room-side
corners of the window opening (the full span translated inward by half
the wall depth), including at an oblique sun angle; no edge starts on the
wall centreline [auto: unit `sun.test.mjs` + `smoke_wall_thickness`]
- [ ] Brightness + the 3° threshold (2026-08-03): wedges are visibly brighter
(peak alpha 0.30, was 0.18) yet still readable over white paper AND the
dark glow canvas; there is NO gradual ramp near the horizon — below 3°
@@ -1460,6 +1464,14 @@ require hands on real hardware — they remain for the human pass.
- [ ] **Unit + backend**: inset/mitre/bevel, key from either end, degrade,
rekey, cm↔inches; `walls` schema bounds
[auto: test/wall-thickness.test.mjs + tests_backend/test_validation.py]
- [ ] **Thin-on-screen parity**: at the same card width a 1 cm wall suppresses
the hatch (solid fill stays) in both `houseplan-card` and
`houseplan-space-card`; a 20 cm wall restores the hatch in both
[auto: smoke_wall_thickness + test/wall-thickness.test.mjs]
- [ ] **Split through an open span**: split one side of a shared wall through
the middle of an existing open stretch. Both resulting pieces remain in
`open_spans`, and both new rooms link to the neighbour in `open_to`
[auto: smoke_merge_split + test/open-spans.test.mjs]
## The furniture library (docs/FURNITURE.md, dev, unreleased)
+14 -10
View File
@@ -6,8 +6,8 @@ Status: **implemented / evolving** (post beta.4 redesign). Visual reference:
Owner intent: thick walls form one continuous hatched body (seamless L and T),
grow both ways from the room centreline, fills/light stay inside the inner
contour, displayed area is the clean floor, and sun wedges narrow through the
opening tunnel.
contour, displayed area is the clean floor, and sun wedges start at the two
room-side corners of an opening.
Code: `src/wall-thickness.ts`, render in `src/houseplan-card.ts` /
`src/space-render.ts`. Sun: `src/sun.ts`. Tests: `test/wall-thickness.test.mjs`,
@@ -36,8 +36,10 @@ room, **union**ed across rooms so shared and T junctions show one continuous
body with no internal seams (as in the reference plan). The body is painted in
two layers: a solid fill from global `settings.fill_colors.wall_fill` (default
opaque white, with its own opacity) **under** the diagonal hatch whose stroke
matches the wall outline. Neither replaces the other. Mitre joins; bevel when
the mitre spike exceeds `MITRE_LIMIT × thickness`.
matches the wall outline. Normally neither replaces the other. When the body is
thinner than 3 CSS px on screen, the shared full/static render policy suppresses
only the hatch so it does not collapse into noise; the solid fill remains. Mitre
joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`.
Openings cut the body full-depth; jambs cap the cut; window glass mid-tunnel;
door swing from the **inner face**. Association uses wall direction ≈ opening
@@ -54,10 +56,11 @@ angle (mod 180°), then nearest span — never a perpendicular neighbour at a T.
## 5. Sun
Wedges do not draw through wall bodies. The window is a tunnel of depth `cm`:
effective lit width ≈ `max(0, L − d·tan(|α|))` where `α` is the ray angle from
the inward normal. Clip to the receiving room’s inner contour. `d = 0` keeps
the previous wedge. `hide_openings` hides the symbol only.
Wedges do not draw through wall bodies. Their full source span is translated
from the centreline by half the wall depth along the receiving room's inward
normal, so both side rays begin at the two room-side corners of the window
opening. Clip to the receiving room's inner contour. `d = 0` keeps the previous
centreline/full-span wedge. `hide_openings` hides the symbol only.
## 6. Tool / hooks / i18n
@@ -79,5 +82,6 @@ Decor-line thickness, per-side finish, auto-from-backdrop, plan-wide default.
Unit: ring closed at corners; half-out; inner area; atomic partial shared;
angle-aware opening; rekey after edge/scale. Browser: seamless frame; fill not
in hatch; m² drops with thickness; real resize/undo keeps walls; sun narrows
with thickness + obliqueness; nav mode restores after `can_write`.
in hatch; m² drops with thickness; real resize/undo keeps walls; sun starts at
the room-side opening corners; nav mode restores after `can_write`; a 1 cm body
uses solid-only in both full and static cards while a 20 cm body keeps its hatch.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.59.0-beta.7",
"version": "1.59.0-beta.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.59.0-beta.7",
"version": "1.59.0-beta.8",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.59.0-beta.7",
"version": "1.59.0-beta.8",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+6 -6
View File
@@ -14,13 +14,13 @@ const count = (dir, match, re) =>
const files = (dir, match) => readdirSync(dir).filter((f) => match.test(f)).length;
// AUD-159B6-06: counting only `test(` under-reported by every suite written
// with describe/it — the inventory claimed 339 while `npm test` ran 352. Both
// spellings are the same layer, so both are counted (indented `it(` included).
// Count both node:test spellings and indented pytest methods. Pure backend
// includes validation plus trail helper/recorder tests; only `test_ha_*` needs
// the Home Assistant harness (AUD-159B7-03).
const rows = [
['frontend unit (node:test)', count('test', /\.test\.mjs$/, /^\s*(test|it)\(/gm)],
['pure backend (pytest, no HA)', count('tests_backend', /^test_validation\.py$/, /^def test_/gm)],
['HA-harness backend (CI, py3.13)', count('tests_backend', /^test_ha_.*\.py$/, /^async def test_|^def test_/gm)],
['Node unit (frontend + tooling)', count('test', /\.test\.mjs$/, /^\s*(test|it)\(/gm)],
['pure backend (pytest, no HA)', count('tests_backend', /^test_(?!ha_).*\.py$/, /^\s*(?:async\s+)?def test_/gm)],
['HA-harness backend (CI, py3.13)', count('tests_backend', /^test_ha_.*\.py$/, /^\s*(?:async\s+)?def test_/gm)],
['browser smokes (headless chromium)', files('demo', /^smoke_.*\.mjs$/)],
];
const w = Math.max(...rows.map(([n]) => n.length));
+61
View File
@@ -0,0 +1,61 @@
// Exact-SHA Validate gate used by .github/workflows/release.yml.
// The policy is exported so failure/cancel/skip scenarios stay unit-tested.
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export function classifyValidateRuns(runs) {
if (!Array.isArray(runs) || runs.length === 0) return 'wait';
if (runs.some((run) => run?.status === 'completed' && run?.conclusion !== 'success')) return 'fail';
if (runs.some((run) => run?.status !== 'completed')) return 'wait';
return 'success';
}
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
export async function waitForGreenValidate({ repo, sha, token, timeoutMs = 60 * 60 * 1000 }) {
if (!repo || !sha || !token) throw new Error('repo, sha and token are required');
const deadline = Date.now() + timeoutMs;
const url = `https://api.github.com/repos/${repo}/actions/workflows/validate.yml/runs?head_sha=${encodeURIComponent(sha)}&per_page=100`;
while (true) {
const response = await fetch(url, {
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'User-Agent': 'houseplan-release-gate',
'X-GitHub-Api-Version': '2022-11-28',
},
});
if (!response.ok) throw new Error(`GitHub Actions API ${response.status}: ${await response.text()}`);
const body = await response.json();
const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [];
const state = classifyValidateRuns(runs);
if (state === 'fail') {
const failed = runs.filter((run) => run?.status === 'completed' && run?.conclusion !== 'success');
throw new Error(`Validate is not green for ${sha}: ${JSON.stringify(failed.map((run) => ({
conclusion: run.conclusion, url: run.html_url,
})))}`);
}
if (state === 'success') {
console.log(`Validate is green for ${sha} (${runs.length} run(s))`);
return;
}
if (Date.now() >= deadline) throw new Error(`No completed green Validate for ${sha} within the deadline`);
const running = runs.filter((run) => run?.status !== 'completed').length;
console.log(runs.length ? `waiting: ${running} Validate run(s) still going` : `waiting: no Validate run for ${sha} yet`);
await sleep(30_000);
}
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const sha = process.argv[2];
waitForGreenValidate({
repo: process.env.REPO || process.env.GITHUB_REPOSITORY,
sha,
token: process.env.GH_TOKEN || process.env.GITHUB_TOKEN,
}).catch((err) => {
console.error(`::error::${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
});
}
+7 -8
View File
@@ -49,13 +49,13 @@ import {
wallEdgeBodies, wallBodiesUnionPath, paperRoomShapesWithWalls,
innerContourForRoom, openingInnerFaceOffset, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, wallIntervals, normalizeWallIntervals,
intervalCmAt, type WallEntry,
intervalCmAt, wallBodyNeedsSolid, type WallEntry,
} from './wall-thickness';
import {
resolveOpenCuts, hitSharedWall, hitOuterWall, hitOpenSpan, snapOpenPoint,
clampToEdgeEnds, jointsOnEdge, cutsToSpanEntries, syncOpenToFromCuts,
applyThicknessOnClose, purgeOpeningsOnSpan,
pointOnOpenCut, removeCut, rekeyOpenSpansAfterMove, degradeOpenSpans, clipOpenSpansToShared,
pointOnOpenCut, removeCut, rekeyOpenSpansAfterMove, clipOpenSpansToShared,
sanitizeOpenSpans, entryToSeg,
type OpenSpanEntry,
} from './open-spans';
@@ -85,7 +85,7 @@ import {
import { alignAllToGrid, type AlignReport } from './align-grid';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.59.0-beta.7';
const CARD_VERSION = '1.59.0-beta.8';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -5086,7 +5086,6 @@ class HouseplanCard extends LitElement {
// first-save migration the spec asks for).
spans = cutsToSpanEntries(resolveOpenCuts(rooms, null, NORM_W, eps), NORM_W);
}
spans = degradeOpenSpans(spans, rooms, NORM_W, eps);
spans = clipOpenSpansToShared(spans, rooms, NORM_W, eps);
this._persistOpenCuts(spans.map((e) => entryToSeg(e, NORM_W)));
}
@@ -5437,7 +5436,7 @@ class HouseplanCard extends LitElement {
const v = this._viewOr(this._baseVb());
const px = stage && stage.clientWidth && v.w ? stage.clientWidth / v.w : 1;
const stroke = disp?.color || 'var(--hp-muted)';
const solid = united.depthUnits * px < 3;
const solid = wallBodyNeedsSolid(united.depthUnits, px);
const wf = this._fillColors.wall_fill;
// Fill colour UNDER the hatch (owner 2026-08-05): both, never one instead
// of the other. When the body is thinner than ~3px on screen the hatch
@@ -7052,11 +7051,11 @@ class HouseplanCard extends LitElement {
// side of a wall.
//
// DEV-EB173-01: the axis runs along the wall's INWARD NORMAL, from the
// window line inward, and is `r.depth` = `len·cos` long — NOT along the
// ray. For parallel rays the distance travelled from the glass is an
// room-side opening face inward, and is `r.depth` = `len·cos` long — NOT
// along the ray. For parallel rays, distance from the source span is an
// affine function of the point, so its iso-alpha lines are parallel to the
// wall; with this axis every point `source + dir·u` lands at offset
// `u/len`. Whole pane at peak alpha, identical fade distance along every
// `u/len`. Whole inner opening at peak alpha, identical fade distance along every
// ray, and the parallelogram's far edge exactly on the gradient's end.
//
// THE RIM (owner 2026-08-04, docs/SUN.md «The rim»): a 1 px black hairline
+40 -14
View File
@@ -126,9 +126,15 @@ export function snapOpenPoint(
return [ax + dx * u, ay + dy * u];
}
/** All shared-boundary segments between rooms (render units). */
export function allSharedSegs(rooms: any[], eps: number): number[][] {
const out: number[][] = [];
interface SharedSeg {
seg: number[];
/** Room-pair identity. Adjacent pieces from different pairs must stay split. */
pair: string;
}
/** Shared-boundary segments with their room-pair semantics (render units). */
function sharedSegsWithPairs(rooms: any[], eps: number): SharedSeg[] {
const out: SharedSeg[] = [];
const list = (rooms || []).filter((r) => r?.id);
for (let i = 0; i < list.length; i++) {
const pa = roomPoly(list[i]);
@@ -136,12 +142,17 @@ export function allSharedSegs(rooms: any[], eps: number): number[][] {
for (let j = i + 1; j < list.length; j++) {
const pb = roomPoly(list[j]);
if (!pb) continue;
for (const sg of sharedBoundary(pa, pb, eps)) out.push(sg);
for (const sg of sharedBoundary(pa, pb, eps)) out.push({ seg: sg, pair: `${i}:${j}` });
}
}
return out;
}
/** All shared-boundary segments between rooms (render units). */
export function allSharedSegs(rooms: any[], eps: number): number[][] {
return sharedSegsWithPairs(rooms, eps).map((x) => x.seg);
}
/** Find shared edge under a point; returns rooms + the atomic shared segment. */
export function hitSharedWall(
raw: number[],
@@ -471,7 +482,10 @@ export function degradeOpenSpans(
/**
* Project each open span onto the current shared-boundary geometry and clip
* to the overlap. Spans that no longer overlap any shared stretch are dropped.
* to every overlap. Spans that no longer overlap any shared stretch are dropped.
* A Split may turn one continuous physical boundary into adjacent stretches
* owned by different room pairs. Those pieces must remain separate so their
* midpoints derive the correct `open_to` links (AUD-159B7-01).
* Prevents "solid outline + dashed open" after resize when a span drifts off
* the true shared edge.
*/
@@ -483,7 +497,7 @@ export function clipOpenSpansToShared(
): OpenSpanEntry[] {
const clean = sanitizeOpenSpans(spans);
if (!clean.length) return [];
const shared = allSharedSegs(roomsModel, eps);
const shared = sharedSegsWithPairs(roomsModel, eps);
if (!shared.length) return [];
const out: OpenSpanEntry[] = [];
const minLen = Math.max(eps * 4, 1e-6);
@@ -494,8 +508,8 @@ export function clipOpenSpansToShared(
const aLen = Math.hypot(adx, ady);
if (aLen < minLen) continue;
const ux = adx / aLen, uy = ady / aLen;
let best: { lo: number; hi: number } | null = null;
for (const sh of shared) {
const byPair = new Map<string, { lo: number; hi: number }[]>();
for (const { seg: sh, pair } of shared) {
// both endpoints of `sh` must lie on the line of sg
const d1 = Math.abs((sh[0] - ax) * uy - (sh[1] - ay) * ux);
const d2 = Math.abs((sh[2] - ax) * uy - (sh[3] - ay) * ux);
@@ -505,13 +519,25 @@ export function clipOpenSpansToShared(
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(aLen, Math.max(t1, t2));
if (hi - lo < minLen) continue;
if (!best || hi - lo > best.hi - best.lo) best = { lo, hi };
const ranges = byPair.get(pair) || [];
ranges.push({ lo, hi });
byPair.set(pair, ranges);
}
for (const ranges of byPair.values()) {
ranges.sort((a, b) => a.lo - b.lo || a.hi - b.hi);
const merged: { lo: number; hi: number }[] = [];
for (const range of ranges) {
const tail = merged[merged.length - 1];
if (tail && range.lo <= tail.hi + minLen) tail.hi = Math.max(tail.hi, range.hi);
else merged.push({ ...range });
}
for (const range of merged) {
const na = [ax + ux * range.lo, ay + uy * range.lo];
const nb = [ax + ux * range.hi, ay + uy * range.hi];
if (Math.hypot(nb[0] - na[0], nb[1] - na[1]) < minLen) continue;
out.push(spanToEntry(na, nb, coordScale));
}
}
if (!best) continue;
const na = [ax + ux * best.lo, ay + uy * best.lo];
const nb = [ax + ux * best.hi, ay + uy * best.hi];
if (Math.hypot(nb[0] - na[0], nb[1] - na[1]) < minLen) continue;
out.push(spanToEntry(na, nb, coordScale));
}
return out;
}
+31
View File
@@ -42,6 +42,9 @@ class HouseplanSpaceCard extends LitElement {
private _snap: HpConfigSnapshot | null = null;
private _loading = false;
private _unsub?: () => void;
private _stageWidth = 0;
private _stageObserver?: ResizeObserver;
private _observedStage?: HTMLElement;
static properties = {
hass: { attribute: false },
@@ -82,6 +85,9 @@ class HouseplanSpaceCard extends LitElement {
public disconnectedCallback(): void {
this._unsub?.();
this._unsub = undefined;
this._stageObserver?.disconnect();
this._stageObserver = undefined;
this._observedStage = undefined;
this._signer.dispose();
super.disconnectedCallback();
}
@@ -92,6 +98,27 @@ class HouseplanSpaceCard extends LitElement {
}
}
protected updated(): void {
const stage = this.renderRoot.querySelector<HTMLElement>('.hp-static-stage') || undefined;
if (stage === this._observedStage) return;
this._stageObserver?.disconnect();
this._observedStage = stage;
if (!stage) {
this._stageObserver = undefined;
return;
}
const measure = () => {
const width = stage.clientWidth;
if (width > 0 && Math.abs(width - this._stageWidth) > 0.5) {
this._stageWidth = width;
this.requestUpdate();
}
};
this._stageObserver = new ResizeObserver(measure);
this._stageObserver.observe(stage);
measure();
}
private _loadedOnce = false;
private async _load(): Promise<void> {
if (!this.hass || this._loading) return;
@@ -152,6 +179,7 @@ class HouseplanSpaceCard extends LitElement {
layout: this._snap?.layout || {},
spaceId,
iconSize: this._config.icon_size,
stageWidth: this._stageWidth,
lang: this._lang,
// resolved at render time: a url baked in earlier would be the unsigned one
displayUrl: (raw) => this._signer.display(this.hass, raw),
@@ -228,6 +256,9 @@ class HouseplanSpaceCard extends LitElement {
stroke-width: 0.6;
pointer-events: none;
}
.wallbody.solid {
fill: none;
}
.hp-static-stage .devlayer {
position: absolute;
inset: 0;
+8 -2
View File
@@ -9,7 +9,9 @@
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf, paperRoomShapes } from './logic';
import { wallBodiesUnionPath, paperRoomShapesWithWalls, type WallEntry } from './wall-thickness';
import {
wallBodiesUnionPath, paperRoomShapesWithWalls, wallBodyNeedsSolid, type WallEntry,
} from './wall-thickness';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
@@ -28,6 +30,8 @@ export interface StaticRenderOpts {
layout: Layout;
spaceId: string;
iconSize?: number;
/** Measured CSS width of the static stage, used for screen-depth policies. */
stageWidth?: number;
lang: Lang;
/**
* Resolve a stored content url to what the DOM may actually request — the
@@ -207,6 +211,8 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const wallUnion = walls.length && disp.showBorders
? wallBodiesUnionPath(space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
: null;
const pxPerUnit = o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1;
const solidWall = !!wallUnion && wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit);
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}">
@@ -229,7 +235,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
${wallUnion
? svg`<g class="wallbodies" style="--room-stroke:${disp.color}">
<path class="wallbody-fill" d="${wallUnion.d}"></path>
<path class="wallbody" data-hp="wall" data-id="union" data-kind="union"
<path class="wallbody ${solidWall ? 'solid' : ''}" data-hp="wall" data-id="union" data-kind="union"
d="${wallUnion.d}"></path>
</g>`
: nothing}
+25 -29
View File
@@ -158,17 +158,17 @@ export function rayLength(elevation: number): number {
/**
* The unclipped wedge: the window span a-b extruded along `dir` by the SAME
* `len` at both ends. An honest parallelogram — every ray through the glass
* `len` at both ends. An honest parallelogram — every ray through the opening
* travels exactly the wedge's reach, so the promised "30 % shorter" holds for
* each side of every wedge, at any sun angle.
*
* Its far edge is parallel to the WALL, and that is not a compromise: it is
* the iso-alpha line of the gradient the card actually draws. For parallel
* rays the distance travelled from the glass is `depth / cos`, an affine
* rays the distance travelled from the source span is `depth / cos`, an affine
* function of the point whose level sets are lines PARALLEL TO THE WALL, so
* the fade must run along the wall's NORMAL (see `SunRay.normal/depth` and
* docs/SUN.md), not along `dir`. With that axis all three invariants hold at
* once: the whole pane of glass sits at offset 0 (peak alpha end to end), the
* once: the whole source span sits at offset 0 (peak alpha end to end), the
* alpha at any point depends only on how far its own ray has travelled, and
* the wedge's far edge coincides with the gradient's end — a bright kerb is
* impossible by construction.
@@ -216,7 +216,7 @@ export interface SunRay {
roomId: string;
/** Clipped wedge outline(s), render units. */
polys: number[][][];
/** Window span endpoints (the bright end of the gradient). */
/** Room-side opening corners (the bright end of the gradient). */
a: number[];
b: number[];
/** Direction the light travels (AWAY from the sun), unit vector. */
@@ -225,7 +225,7 @@ export interface SunRay {
len: number;
/**
* INWARD wall normal (unit) — the axis of the fade. The distance a point
* has travelled from the glass is the same affine function of the point as
* has travelled from the source span is the same affine function of the point as
* its depth under the wall, so the gradient's iso-alpha lines are parallel
* to the wall and its axis is this normal (docs/SUN.md, DEV-EB173-01).
*/
@@ -233,7 +233,7 @@ export interface SunRay {
/**
* Length of that axis: `len · (dir·normal)` — the perpendicular depth a ray
* reaches after travelling `len`. A point `source + dir·u` therefore lands
* at offset `u/len`: the glass is all at 0, the far edge all at 1.
* at offset `u/len`: the source span is all at 0, the far edge all at 1.
*/
depth: number;
}
@@ -245,8 +245,9 @@ export interface SunRay {
* NOT considered (documented limit).
*
* `innerByRoom` (optional): when wall thickness is set, clip wedges to each
* room's inner contour and narrow the lit width through the opening tunnel
* (`max(0, L − d·tan(|α|))`, docs/WALL-THICKNESS.md §5).
* room's inner contour. `wallDepthByOpening` moves the full window span from
* the wall centreline to its room-side face, so the two side rays start at the
* opening's two inner corners (docs/WALL-THICKNESS.md §5).
*/
export function computeSunRays(
rooms: SunRoom[],
@@ -270,28 +271,23 @@ export function computeSunRays(
if (!room) continue;
const clipPoly = (innerByRoom && innerByRoom[info.roomId]) || room.poly;
const rad = (w.angle * Math.PI) / 180;
let half = w.length / 2;
const d = wallDepthByOpening?.[w.id] || 0;
if (d > 0) {
// tunnel narrowing: α = angle between ray and inward normal
const inward: [number, number] = [-info.normal[0], -info.normal[1]];
const cos = Math.max(-1, Math.min(1, away[0] * inward[0] + away[1] * inward[1]));
const sin = Math.sqrt(Math.max(0, 1 - cos * cos));
const tan = cos > 1e-9 ? sin / cos : 1e6;
const eff = Math.max(0, w.length - d * tan);
half = eff / 2;
if (!(eff > 0)) continue;
}
const half = w.length / 2;
const normal: [number, number] = [-info.normal[0], -info.normal[1]];
const d = Math.max(0, wallDepthByOpening?.[w.id] || 0);
// A wall grows ±½ from its centreline. Start the whole light span on the
// room-side face: its endpoints are the two inner corners of the opening,
// independent of the sun's incidence angle.
const sourceX = w.x + normal[0] * d / 2;
const sourceY = w.y + normal[1] * d / 2;
const hx = Math.cos(rad) * half;
const hy = Math.sin(rad) * half;
const a = [w.x - hx, w.y - hy];
const b = [w.x + hx, w.y + hy];
const a = [sourceX - hx, sourceY - hy];
const b = [sourceX + hx, sourceY + hy];
const len = k * w.length;
const polys = clipToRoom(rayQuad(a, b, away, len), clipPoly);
if (!polys.length) continue;
// inward normal + how deep the ray gets: cos of the incidence angle,
// which windowLit() has already found to be above RAY_MIN_COS
const normal: [number, number] = [-info.normal[0], -info.normal[1]];
const cos = away[0] * normal[0] + away[1] * normal[1];
out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len,
normal, depth: len * cos });
@@ -356,13 +352,13 @@ export const RAY_FADE_END = 0.85;
/**
* Gradient stops along the shaft: `[offset 0..1, share of the peak alpha]`.
* Convex ease-out — bright at the glass, half gone by a third of the way,
* Convex ease-out — bright at the inner opening, half gone by a third of the way,
* a whisper at two thirds, nothing from RAY_FADE_END on. Consumed by the card
* as SVG <stop>s over the FULL wedge length, so the geometry and the gradient
* always describe the same shaft (docs/SUN.md).
*
* This gradient is the ONLY thing that dissolves a wedge: the falloff runs
* along the ray, from the glass inward, and the sides of the shaft keep the
* along the ray, from the inner opening inward, and the sides of the shaft keep the
* hard edge light actually has (owner 2026-08-04: «не надо размывать их
* боковые грани»). No blur is involved anywhere.
*/
@@ -394,8 +390,8 @@ export function rayStops(): [number, number][] {
*
* Contract (docs/SUN.md, «The rim»):
*
* - only the two SIDE edges — the ones running from the ends of the window
* along `dir`. Never the glass edge (a-b) and never the far edge: those are
* - only the two SIDE edges — the ones running from the inner opening corners
* along `dir`. Never the source edge (a-b) and never the far edge: those are
* not boundaries of the beam, they are its source and its end;
* - one screen pixel at any zoom (`vector-effect: non-scaling-stroke`);
* - black, and it dies EXACTLY with the fill: same gradient axis (the wall's
@@ -407,7 +403,7 @@ export function rayStops(): [number, number][] {
*/
/**
* Peak rim opacity at the glass, before cloud cover. Visually tuned on the
* Peak rim opacity at the inner opening, before cloud cover. Visually tuned on the
* demo rig at both extremes: it has to make the shaft legible on white paper
* (the whole point) yet not read as an ink outline over the dark glow canvas.
* Below ~0.3 the line disappears on paper at kiosk scale; above ~0.5 it turns
@@ -456,7 +452,7 @@ export function rayRimEdges(ray: SunRay, eps = 1e-4): number[][][] {
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
// off the side's line? then this boundary edge is the glass, the far
// off the side's line? then this boundary edge is the source, the far
// edge, or a wall the room cut the wedge with — not a side of the beam
if (Math.abs((p[0] - src[0]) * nx + (p[1] - src[1]) * ny) > eps) continue;
if (Math.abs((q[0] - src[0]) * nx + (q[1] - src[1]) * ny) > eps) continue;
+9
View File
@@ -18,12 +18,21 @@ export const WALL_MIN_CM = 1;
export const WALL_MAX_CM = 100;
/** Default thickness offered in the Draw toolbar (docs/WALL-THICKNESS.md §6). */
export const DRAW_WALL_DEFAULT_CM = 15;
/** Below this screen depth the diagonal hatch becomes visual noise. */
export const WALL_HATCH_MIN_PX = 3;
/** Mitre spikes longer than this × thickness fall back to a bevel. */
export const MITRE_LIMIT = 4;
// ------------------------------- units --------------------------------------
/** Shared full/static render policy for the thin-on-screen fallback. */
export function wallBodyNeedsSolid(depthUnits: number, pxPerUnit: number): boolean {
return Number.isFinite(depthUnits) && depthUnits > 0
&& Number.isFinite(pxPerUnit) && pxPerUnit > 0
&& depthUnits * pxPerUnit < WALL_HATCH_MIN_PX;
}
export function clampWallCm(cm: number): number {
if (!Number.isFinite(cm)) return WALL_MIN_CM;
return Math.max(WALL_MIN_CM, Math.min(WALL_MAX_CM, cm));
+20
View File
@@ -141,6 +141,26 @@ describe('open-spans', () => {
assert.ok(Math.max(sg[1], sg[3]) <= 4 + 1e-6, JSON.stringify(sg));
});
it('clipOpenSpansToShared keeps both room-pair pieces after Split', () => {
const rooms = [
{ id: 'left_top', poly: [[0, 0], [5, 0], [5, 2], [0, 2]] },
{ id: 'left_bottom', poly: [[0, 2], [5, 2], [5, 4], [0, 4]] },
{ id: 'right', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] },
];
const clipped = clipOpenSpansToShared(
[spanToEntry([5, 1], [5, 3], 1)], rooms, 1, eps,
);
const pieces = clipped.map((e) => entryToSeg(e, 1))
.map((sg) => [Math.min(sg[1], sg[3]), Math.max(sg[1], sg[3])]);
assert.deepEqual(pieces, [[1, 2], [2, 3]]);
const cfg = rooms.map(({ id }) => ({ id }));
syncOpenToFromCuts(cfg, rooms, clipped.map((e) => entryToSeg(e, 1)), eps);
assert.deepEqual(cfg[0].open_to, ['right']);
assert.deepEqual(cfg[1].open_to, ['right']);
assert.deepEqual(cfg[2].open_to, ['left_top', 'left_bottom']);
});
it('entry round-trip', () => {
const e = spanToEntry([100, 200], [100, 400], scale);
const sg = entryToSeg(e, scale);
+36
View File
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { classifyValidateRuns } from '../scripts/release-gate.mjs';
test('release gate waits until an exact-SHA Validate exists and completes', () => {
assert.equal(classifyValidateRuns([]), 'wait');
assert.equal(classifyValidateRuns([{ status: 'queued', conclusion: null }]), 'wait');
assert.equal(classifyValidateRuns([
{ status: 'completed', conclusion: 'success' },
{ status: 'in_progress', conclusion: null },
]), 'wait');
});
test('release gate accepts only completed success runs', () => {
assert.equal(classifyValidateRuns([
{ status: 'completed', conclusion: 'success' },
{ status: 'completed', conclusion: 'success' },
]), 'success');
});
test('release gate fails closed for red, cancelled and skipped runs', () => {
for (const conclusion of ['failure', 'cancelled', 'timed_out', 'action_required', 'skipped', null]) {
assert.equal(
classifyValidateRuns([{ status: 'completed', conclusion }]),
'fail',
`completed/${conclusion} must withhold the asset`,
);
}
});
test('one red duplicate blocks a green duplicate for the same SHA', () => {
assert.equal(classifyValidateRuns([
{ status: 'completed', conclusion: 'success' },
{ status: 'completed', conclusion: 'failure' },
]), 'fail');
});
+18
View File
@@ -329,6 +329,24 @@ test('computeSunRays: evening west sun → west window', () => {
assert.deepEqual(rays.map((r) => r.openingId), ['wW']);
});
test('computeSunRays: a thick-wall ray starts at both room-side opening corners', () => {
const win = { id: 'wW', x: 100, y: 300, angle: 90, length: 80 };
const inner = {
r1: [[110, 110], [490, 110], [490, 490], [110, 490]],
};
// Oblique sunlight is intentional: the source must remain the full inner
// aperture instead of shrinking or sliding away from either jamb corner.
const [ray] = computeSunRays(ROOMS, [win], 240, 60, 0, inner, { wW: 20 });
assert.ok(ray, 'the west window is lit');
assert.ok(near(ray.a[0], 110) && near(ray.a[1], 260), 'first inner corner');
assert.ok(near(ray.b[0], 110) && near(ray.b[1], 340), 'second inner corner');
assert.ok(near(Math.hypot(ray.b[0] - ray.a[0], ray.b[1] - ray.a[1]), 80), 'full opening width');
for (const poly of ray.polys) for (const [x, y] of poly) {
assert.ok(x >= 110 - 1e-6 && x <= 490 + 1e-6, 'clipped to the clean-floor contour');
assert.ok(y >= 110 - 1e-6 && y <= 490 + 1e-6, 'clipped to the clean-floor contour');
}
});
test('grazing sun: the auditor\'s repro, fixed by a normal-axis fade (DEV-EB173-01)', () => {
// The report's browser probe: a WEST window 80 render units long, elevation
// 90 (so the nominal reach is 0.56 · 80 = 44.8 — «на 30 % короче»), azimuth
+9 -1
View File
@@ -9,7 +9,7 @@ import {
wallBodyRings, wallBodiesUnionPath, innerContourForRoom,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, normalizeWallIntervals,
intervalCmAt,
intervalCmAt, wallBodyNeedsSolid, WALL_HATCH_MIN_PX,
} from '../test-build/wall-thickness.js';
import { polygonArea, paperRoomShapes } from '../test-build/logic.js';
import { GRID_PITCH } from '../test-build/space-geometry.js';
@@ -61,6 +61,14 @@ test('wallCmToUnits goes through cell_cm like every other length', () => {
closeTo(wallCmToUnits(10, 5, GRID_PITCH), 2 * GRID_PITCH);
});
test('thin-on-screen fallback policy is shared by both renderers', () => {
assert.equal(wallBodyNeedsSolid(2, 1), true);
assert.equal(wallBodyNeedsSolid(WALL_HATCH_MIN_PX, 1), false);
assert.equal(wallBodyNeedsSolid(2, 2), false);
assert.equal(wallBodyNeedsSolid(Number.NaN, 1), false);
assert.equal(wallBodyNeedsSolid(2, 0), false);
});
// ------------------------------- degrade / rekey ----------------------------
test('degradeWalls drops a key with no matching room edge', () => {