feat: выбор граней окон для солнечных лучей (#577)

Issue: #577
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-15 00:16:35 +03:00
parent 949422fe91
commit db4b397e2e
70 changed files with 1209 additions and 885 deletions
+11 -6
View File
@@ -65,10 +65,10 @@ import {
type ResizeAreaPlacement,
} from './resize-labels';
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn,
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, sunRayOriginOf,
sunStateOf, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
rayStops, resolveDayCycle, dayCycleFingerprint, type DayCycleState,
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR,
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR, type SunRayOrigin,
} from './sun';
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
import {
@@ -2202,7 +2202,7 @@ export class HouseplanCard extends LitElement {
private _settingsDialog: {
colors: FillColors; glowRadius: number; bgColor: string | null;
/** sun on the plan (docs/SUN.md) */
northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean;
northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean; sunRayOrigin: SunRayOrigin;
showRoomTooltip: boolean; zigbeeTopology: import('./zigbee-topology-settings').ZigbeeTopologySettings;
radarShowLive: boolean;
busy: boolean;
@@ -10372,6 +10372,7 @@ export class HouseplanCard extends LitElement {
north_deg: gd.northDeg ?? undefined,
bg_mode: gd.bgMode,
sun_rays: gd.sunRays,
sun_ray_origin: gd.sunRayOrigin,
};
}
@@ -10445,6 +10446,8 @@ export class HouseplanCard extends LitElement {
return sunRaysOn(this._sunGlobal(), this._sunSpace());
}
private _effSunRayOrigin(): SunRayOrigin { return sunRayOriginOf(this._sunGlobal()); }
/** sun.sun, but only when the feature is armed (north_deg set somewhere). */
private _sunNow(): { azimuth: number; elevation: number } | null {
return this._effNorth() !== null ? sunStateOf(this._renderPlanHass) : null;
@@ -10498,7 +10501,8 @@ export class HouseplanCard extends LitElement {
// during the whole write window (and forever if the write failed).
const zeroWalls = this._zeroWalls();
const zeroKey = zeroWalls.barriers.map((line) => line.join(',')).join(';');
const key = `${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${this._cfgEpoch}`
const origin = this._effSunRayOrigin();
const key = `${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${origin}|${this._cfgEpoch}`
+ `|${zeroWalls.style}|${zeroKey}`;
if (!this._sunRaysCache || this._sunRaysCache.key !== key) {
const rooms = space.rooms
@@ -10534,6 +10538,7 @@ export class HouseplanCard extends LitElement {
rooms, windows, sun.azimuth, sun.elevation, north!,
walls.length ? innerByRoom : undefined,
walls.length ? wallDepthByOpening : undefined,
origin,
);
const physical = this._physicalBodiesR(space);
// A two-point body has zero area at rest, but its extrusion along the
@@ -10576,11 +10581,11 @@ export class HouseplanCard extends LitElement {
// side of a wall.
//
// DEV-EB173-01: the axis runs along the wall's INWARD NORMAL, from the
// room-side opening face inward, and is `r.depth` = `len·cos` long — NOT
// selected 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 inner opening at peak alpha, identical fade distance along every
// `u/len`. Whole selected 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
+9 -5
View File
@@ -74,12 +74,13 @@ import {
type ResizeAreaPlacement,
} from './resize-labels';
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn,
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, sunRayOriginOf,
sunStateOf, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
rayStops, resolveDayCycle, dayCycleFingerprint, type DayCycleState,
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR,
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR, type SunRayOrigin,
} from './sun';
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
import { renderSunRayOriginSelect } from './sun-settings-view';
import {
furnitureDefaultCm,
furnitureGraphic, furnitureCorners,
@@ -1113,9 +1114,9 @@ export interface HouseplanEditorHostPort {
_sentPos: Map<string, DeviceLayout[string] | null>;
_serverCfg: ServerConfig | null;
_serverStorage: boolean;
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; radar?: { version?: 1; show_live?: boolean; [key: string]: unknown }; };
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; sun_ray_origin?: SunRayOrigin; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; radar?: { version?: 1; show_live?: boolean; [key: string]: unknown }; };
_terminalFrame: 0 | 1 | 2;
_settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; northDeg: number | null; bgMode: "static" | "daynight"; sunRays: boolean; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; radarShowLive: boolean; busy: boolean; } | null;
_settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; northDeg: number | null; bgMode: "static" | "daynight"; sunRays: boolean; sunRayOrigin: SunRayOrigin; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; radarShowLive: boolean; busy: boolean; } | null;
_supportDialog: SupportDialogState | null;
_showAll: boolean;
_showHidden: boolean;
@@ -8837,6 +8838,7 @@ public _openSettingsDialog = (): void => {
northDeg: northDegOf(this.host._settings, {}),
bgMode: bgModeOf(this.host._settings, {}),
sunRays: sunRaysOn(this.host._settings, {}),
sunRayOrigin: sunRayOriginOf(this.host._settings),
radarShowLive: this.host._settings.radar?.show_live !== false,
showRoomTooltip: showRoomTooltipOf(this.host._settings), zigbeeTopology: zigbeeTopologySettingsOf(this.host._settings), busy: false,
};
@@ -10032,6 +10034,7 @@ public _updateDecorStyle(next: DecorStyle): void {
settings.bg_mode = d.bgMode;
if (d.sunRays) settings.sun_rays = true;
else delete settings.sun_rays;
settings.sun_ray_origin = d.sunRayOrigin;
if (d.showRoomTooltip) delete settings.show_room_tooltip;
else settings.show_room_tooltip = false;
const radarSettings = settings.radar;
@@ -10444,6 +10447,7 @@ public _renderSettingsDialog(): TemplateResult {
(this.host._settingsDialog = { ...this.host._settingsDialog!, sunRays: v }))}
<span>${this.host._t('gs.sun_rays')}</span>
</label>
${renderSunRayOriginSelect(this.host._settingsDialog!.sunRayOrigin, (key) => this.host._t(key), (sunRayOrigin) => (this.host._settingsDialog = { ...this.host._settingsDialog!, sunRayOrigin }))}
${this.host._canEdit ? html`
<label class="dispsection">${this.host._t('gs.backup_group')}</label>
<div class="rhint">${this.host._t('gs.backup_hint')}</div>
@@ -10480,7 +10484,7 @@ public _renderSettingsDialog(): TemplateResult {
</div>
<div class="row" slot="footer">
<button class="btn ghost" @click=${() =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this.host._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, showRoomTooltip: true, radarShowLive: true, zigbeeTopology: { enabled: false, z2mBaseTopics: [] } })}>
(this.host._settingsDialog = { ...this.host._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this.host._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, sunRayOrigin: 'inner', showRoomTooltip: true, radarShowLive: true, zigbeeTopology: { enabled: false, z2mBaseTopics: [] } })}>
${this.host._t('gs.reset')}
</button>
<span class="spacer"></span>
+3
View File
@@ -994,6 +994,9 @@
"gs.north_clear": "Löschen",
"gs.north_letter": "N",
"gs.sun_rays": "Sonnenlicht durch Fenster",
"gs.sun_ray_origin": "Sonnenstrahlen",
"gs.sun_ray_origin.inner": "Von den inneren Fensterecken",
"gs.sun_ray_origin.outer": "Von den äußeren Fensterecken",
"gs.about_version": "House Plan Card v{v}",
"gs.about_github": "GitHub · Dokumentation & Probleme",
"gs.about_telegram": "Telegram-Chat",
+3
View File
@@ -994,6 +994,9 @@
"gs.north_clear": "Clear",
"gs.north_letter": "N",
"gs.sun_rays": "Sunlight through windows",
"gs.sun_ray_origin": "Sun rays",
"gs.sun_ray_origin.inner": "From the inner window corners",
"gs.sun_ray_origin.outer": "From the outer window corners",
"gs.about_version": "Houseplan Card v{v}",
"gs.about_github": "GitHub · docs & issues",
"gs.about_telegram": "Telegram chat",
+3
View File
@@ -994,6 +994,9 @@
"gs.north_clear": "Effacer",
"gs.north_letter": "N",
"gs.sun_rays": "Lumière du soleil à travers les fenêtres",
"gs.sun_ray_origin": "Rayons du soleil",
"gs.sun_ray_origin.inner": "Depuis les angles intérieurs des fenêtres",
"gs.sun_ray_origin.outer": "Depuis les angles extérieurs des fenêtres",
"gs.about_version": "Houseplan Card v{v}",
"gs.about_github": "GitHub · documentation et problèmes",
"gs.about_telegram": "Discussion Telegram",
+3
View File
@@ -994,6 +994,9 @@
"gs.north_clear": "Сбросить",
"gs.north_letter": "С",
"gs.sun_rays": "Солнце в окнах",
"gs.sun_ray_origin": "Лучи солнца",
"gs.sun_ray_origin.inner": "От внутренних углов окон",
"gs.sun_ray_origin.outer": "От внешних углов окон",
"gs.about_version": "Houseplan Card v{v}",
"gs.about_github": "GitHub · документация и issues",
"gs.about_telegram": "Чат в Telegram",
+20
View File
@@ -0,0 +1,20 @@
import { html, type TemplateResult } from 'lit';
import type { I18nKey } from './i18n';
import type { SunRayOrigin } from './sun';
export function renderSunRayOriginSelect(
value: SunRayOrigin,
translate: (key: I18nKey) => string,
changed: (value: SunRayOrigin) => void,
): TemplateResult {
return html`<div class="colorrow gsrow">
<span class="gsl"><label for="gs-sun-ray-origin">${translate('gs.sun_ray_origin')}</label></span>
<select id="gs-sun-ray-origin" class="areasel"
@change=${(event: Event) => changed(
(event.target as HTMLSelectElement).value === 'outer' ? 'outer' : 'inner',
)}>
<option value="inner" ?selected=${value === 'inner'}>${translate('gs.sun_ray_origin.inner')}</option>
<option value="outer" ?selected=${value === 'outer'}>${translate('gs.sun_ray_origin.outer')}</option>
</select>
</div>`;
}
+34 -13
View File
@@ -366,7 +366,7 @@ export interface SunRay {
polys: number[][][];
/** Optional even-odd polygons after physical-obstacle subtraction. */
paths?: string[];
/** Room-side opening corners (the bright end of the gradient). */
/** Selected opening corners (inner or outer; the bright end of the gradient). */
a: number[];
b: number[];
/** Direction the light travels (AWAY from the sun), unit vector. */
@@ -396,8 +396,8 @@ export interface SunRay {
*
* `innerByRoom` (optional): when wall thickness is set, clip wedges to each
* 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).
* the wall centreline to the selected inner or outer face, so the two side
* rays start at that face's corners (docs/WALL-THICKNESS.md §5).
*/
export function computeSunRays(
rooms: SunRoom[],
@@ -407,6 +407,7 @@ export function computeSunRays(
northDeg: number,
innerByRoom?: Record<string, number[][]>,
wallDepthByOpening?: Record<string, number>,
origin: SunRayOrigin = 'inner',
): SunRay[] {
if (!(elevation > 0)) return [];
const toSun = sunDirOnPlan(azimuth, northDeg);
@@ -424,17 +425,29 @@ export function computeSunRays(
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;
// A wall grows ±½ from its centreline. The compatibility default starts
// at the room-side face. `outer` starts at the exterior face and admits
// only the physical window tunnel before joining the clean room floor.
const side = origin === 'outer' ? -1 : 1;
const sourceX = w.x + normal[0] * d * side / 2;
const sourceY = w.y + normal[1] * d * side / 2;
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);
const quad = rayQuad(a, b, away, len);
const polys = clipToRoom(quad, clipPoly);
if (origin === 'outer' && d > 0) {
const innerX = w.x + normal[0] * d / 2;
const innerY = w.y + normal[1] * d / 2;
polys.push(...clipToRoom(quad, [
[sourceX - hx, sourceY - hy],
[sourceX + hx, sourceY + hy],
[innerX + hx, innerY + hy],
[innerX - hx, innerY - hy],
]));
}
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
@@ -498,13 +511,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 inner opening, half gone by a third of the way,
* Convex ease-out — bright at the selected opening face, 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 inner opening inward, and the sides of the shaft keep the
* along the ray, from the selected opening face inward, and the sides of the shaft keep the
* hard edge light actually has (owner 2026-08-04: «не надо размывать их
* боковые грани»). No blur is involved anywhere.
*/
@@ -536,7 +549,7 @@ export function rayStops(): [number, number][] {
*
* Contract (docs/SUN.md, «The rim»):
*
* - only the two SIDE edges — the ones running from the inner opening corners
* - only the two SIDE edges — the ones running from the selected 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`);
@@ -549,7 +562,7 @@ export function rayStops(): [number, number][] {
*/
/**
* Peak rim opacity at the inner opening. Visually tuned on the
* Peak rim opacity at the selected opening face. 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
@@ -673,6 +686,14 @@ export function northDegOf(settings: any, spaceSettings: any): number | null {
export const BG_MODES = ['static', 'daynight'] as const; // #33 parity
export type BgMode = (typeof BG_MODES)[number];
export const SUN_RAY_ORIGINS = ['inner', 'outer'] as const; // #577 parity
export type SunRayOrigin = (typeof SUN_RAY_ORIGINS)[number];
/** Global window-light source; unknown/legacy values preserve the old geometry. */
export function sunRayOriginOf(settings: any): SunRayOrigin {
return settings?.sun_ray_origin === 'outer' ? 'outer' : 'inner';
}
/** Effective background mode; anything unknown falls back to 'static'. */
export function bgModeOf(settings: any, spaceSettings: any): BgMode {
const pick = (v: any): BgMode | null => (v === 'static' || v === 'daynight' ? v : null);
+5
View File
@@ -301,6 +301,11 @@ export interface ServerConfig {
icon_rules?: { pattern: string; icon: string }[];
/** Only an explicit false hides the room hover information window. */
show_room_tooltip?: boolean;
north_deg?: number;
bg_mode?: 'static' | 'daynight';
sun_rays?: boolean;
/** Global source face for window sunlight; absent keeps the legacy inner face. */
sun_ray_origin?: 'inner' | 'outer';
/** Opt-in, admin-only contextual Zigbee diagnostics (#54). */
zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] };
/** Read-only grouped values shown over the plan (#437). */