mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
v1.59.0-beta.8: audit follow-ups and inner-corner sun rays
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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]]});
|
||||
|
||||
@@ -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
Vendored
+37
-34
File diff suppressed because one or more lines are too long
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,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', () => {
|
||||
|
||||
Reference in New Issue
Block a user