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
+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));