mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
feat: выбор граней окон для солнечных лучей (#577)
Issue: #577 User-Visible: yes
This commit is contained in:
+11
-6
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user