mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Release v1.61.0-beta.6 candidate
This commit is contained in:
+395
-120
@@ -7,6 +7,7 @@
|
||||
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
|
||||
*/
|
||||
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import './hp-dialog';
|
||||
import './hp-color-opacity';
|
||||
import './hp-device-preview';
|
||||
@@ -17,12 +18,12 @@ import {
|
||||
import {
|
||||
lqiColor, snapToGrid, snapSegment45, samePoint, pointInPolygon, markerIdForBinding,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointStrictlyInside, roomsOverlap,
|
||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, roomGlowOf, contentUrl,
|
||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, roomGlowOf, contentUrl,
|
||||
snapToWall, snapPointAlongPoly, openingAmount, openingShoulders, interiorPoint,
|
||||
poleOfInaccessibility, subst,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon, lightColorOf, parseRoomRef, diffNewDevices, resolveGlowValues, resolveGlowAppearance,
|
||||
glowAlpha, normalizeGlowColorOverride, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||
glowAlpha, normalizeGlowColorOverride, controlsAction, isControllable,
|
||||
spaceDisplayOf, resolveEffectiveRoomFill, fillColorsOf, DEFAULT_FILL_COLORS,
|
||||
customFillOf, roomCustomFillOf, DEFAULT_CUSTOM_FILL,
|
||||
type FillColors, type FillColorEntry, type ResolvedRoomFill, runServiceFor, RUN_TARGET_DOMAINS,
|
||||
@@ -53,7 +54,7 @@ import {
|
||||
import {
|
||||
degradeWalls, rekeyWallsAfterMove,
|
||||
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
|
||||
wallEdgeBodies, wallBodiesUnionPath, paperRoomShapesWithWalls,
|
||||
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath, paperRoomShapesWithWalls,
|
||||
innerContourForRoom, roomWallProfile, outsetContour,
|
||||
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
|
||||
openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom,
|
||||
@@ -98,9 +99,12 @@ import type {
|
||||
import {
|
||||
COLUMN_MAX_CM, canonicalColumnAngle, clampColumnCm, columnBody,
|
||||
directionalOccluders, draftBodies, floorMinusBodies, geometryArea, geometryOuterRings,
|
||||
partitionBody, polyclipPathD, radialOccluders,
|
||||
geometryAllRings, intersectionPaths, partitionBody, polyclipPathD,
|
||||
pointInPhysicalBody, sameColumnPlacement,
|
||||
} from './physical-geometry';
|
||||
import {
|
||||
LightSegment, polygonSegments, splitAtIntersections, visibilityPolygon,
|
||||
} from './light-visibility';
|
||||
import './editor';
|
||||
import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
@@ -148,7 +152,7 @@ import {
|
||||
import { renderOpeningTunnelFills } from './render/opening-tunnels';
|
||||
import { safeStoredColor } from './color';
|
||||
|
||||
const CARD_VERSION = '1.61.0-beta.5';
|
||||
const CARD_VERSION = '1.61.0-beta.6';
|
||||
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
|
||||
const DECOR_TEXT_CM_MAX = 2000;
|
||||
const CELL_CM_MIN = 0.1;
|
||||
@@ -354,6 +358,30 @@ const NORM_W = 1000; // side of the render space — the canvas is square (v1.48
|
||||
/** Short semantic-event / direct-terminal-transition window. Event uses
|
||||
three sequential 1.1 s waves; motion cool-down itself never animates. */
|
||||
const ACTIVITY_WINDOW_MS = 3300;
|
||||
/**
|
||||
* How finely the lit region is traced where nothing blocks the light. 96 steps
|
||||
* put the chord error at 0.05% of the radius — under a tenth of a pixel on a
|
||||
* wall tablet, and cheap because only unobstructed directions use them.
|
||||
*/
|
||||
const GLOW_ARC_STEPS = 96;
|
||||
/**
|
||||
* Width of the lit→unlit ramp along a shadow edge, in SCREEN pixels: the eye
|
||||
* reads a perfectly geometric edge as a cut-out, and a real penumbra is never
|
||||
* wider than a hair at this scale. Measured on screen on purpose, so zooming in
|
||||
* does not turn a hairline into a smear.
|
||||
*/
|
||||
const GLOW_EDGE_FEATHER_PX = 2;
|
||||
/**
|
||||
* Radial profile of a pool, as [offset %, share of the calibrated alpha].
|
||||
* Monotonic all the way out: a lamp is brightest under itself and dies at its
|
||||
* radius. The centre keeps the full calibrated alpha, so nothing about the
|
||||
* brightness maths (docs/specs/067) changes — only where that alpha is spent.
|
||||
*/
|
||||
const GLOW_FALLOFF: readonly (readonly [number, number])[] = [
|
||||
[0, 1], [45, 0.88], [70, 0.62], [86, 0.32], [100, 0],
|
||||
];
|
||||
/** A source pool fades in/out without changing its final calibrated alpha. */
|
||||
const GLOW_FADE_MS = 500;
|
||||
|
||||
/** Smallest rectangle holding both (docs/CANVAS.md §4). */
|
||||
const unionRect = (a: Rect, b: Rect): Rect => {
|
||||
@@ -366,6 +394,12 @@ type RoomFillFrame = {
|
||||
byRoom: Map<RoomCfg, ResolvedRoomFill | null>;
|
||||
byId: Map<string, ResolvedRoomFill | null>;
|
||||
};
|
||||
/**
|
||||
* The floor a source can see, and nothing else. One region means one clip:
|
||||
* a beam through a doorway, the room it lands in and the shadow of a column
|
||||
* are all the same computation, so they can never disagree with each other.
|
||||
*/
|
||||
type GlowClipGeometry = { lit: string[] };
|
||||
const MARKUP_TOOLS = new Set<MarkupTool>([
|
||||
'select', 'draw', 'partition', 'column', 'merge', 'split', 'resize',
|
||||
'opening', 'boundary', 'wallthick', 'delroom',
|
||||
@@ -844,7 +878,18 @@ class HouseplanCard extends LitElement {
|
||||
private _cleanFloorCache = new Map<string, {
|
||||
floor: number[][]; geom: any; path: string; area: number;
|
||||
}>();
|
||||
private _glowClipCache = new Map<string, string[] | null>();
|
||||
private _glowClipCache = new Map<string, GlowClipGeometry | null>();
|
||||
private _lightBarrierCache: {
|
||||
key: string;
|
||||
value: { occluders: LightSegment[]; floor: number[][][]; fingerprint: string };
|
||||
} | null = null;
|
||||
/** Active pools survive an off transition until their 500 ms fade completes. */
|
||||
private _glowRenderedSources = new Map<string, number>();
|
||||
private _glowLastAppearance = new Map<string, { c: string; alpha: number }>();
|
||||
private _glowEnteringSources = new Set<string>();
|
||||
private _glowEnterRafs = new Map<string, number>();
|
||||
private _glowFadeTimers = new Map<string, number>();
|
||||
private _glowSourceSeq = 0;
|
||||
/** Pending/false uses the exact historical normal-layer fallback. */
|
||||
private _glowScreenBlend = false;
|
||||
private _duplicateColumnId: string | null = null;
|
||||
@@ -1424,6 +1469,14 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
clearTimeout(this._layoutSyncTimer);
|
||||
clearTimeout(this._duplicateColumnTimer);
|
||||
for (const timer of this._glowFadeTimers.values()) clearTimeout(timer);
|
||||
for (const raf of this._glowEnterRafs.values()) cancelAnimationFrame(raf);
|
||||
this._glowFadeTimers.clear();
|
||||
this._glowEnterRafs.clear();
|
||||
this._glowEnteringSources.clear();
|
||||
this._glowRenderedSources.clear();
|
||||
this._glowLastAppearance.clear();
|
||||
this._glowSourceSeq = 0;
|
||||
// DEV-B703-03: the last thing this instance was showing, then the
|
||||
// tombstone that lets exactly one successor adopt the open dialog.
|
||||
// AUD-159B1-02: the snapshot runs while `_warmRevivePending` is still
|
||||
@@ -8881,7 +8934,15 @@ class HouseplanCard extends LitElement {
|
||||
.join(' ');
|
||||
}).filter(Boolean).join(' ');
|
||||
if (!d) return svg`` as unknown as TemplateResult;
|
||||
return svg`<path class="room-hover-outline" d="${d}"></path>` as unknown as TemplateResult;
|
||||
const pathOf = (points: number[][]) =>
|
||||
`M ${points.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`;
|
||||
const cleanFloor = this._cleanFloor(room, floor, space).path || pathOf(floor);
|
||||
const fillD = [cleanFloor, ...islandPolys.map(pathOf)].join(' ');
|
||||
return svg`<g class="room-hover" pointer-events="none">
|
||||
<path class="room-hover-fill" d="${fillD}" fill-rule="evenodd"></path>
|
||||
<path class="room-hover-halo" d="${d}"></path>
|
||||
<path class="room-hover-outline" d="${d}"></path>
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
/** Hover highlight for the wall-thickness tool (SVG). */
|
||||
@@ -11208,6 +11269,161 @@ class HouseplanCard extends LitElement {
|
||||
return this._imperial ? String(Math.round((cm / 30.48) * 10) / 10) : String(cm / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a source node alive while CSS fades it out. A new `on` state cancels
|
||||
* the pending removal, so rapid toggles reverse the same transition instead
|
||||
* of destroying and recreating the SVG/filter stack.
|
||||
*/
|
||||
private _glowTransition(
|
||||
key: string,
|
||||
active: boolean,
|
||||
): { domId: number; entering: boolean; leaving: boolean } | null {
|
||||
let domId = this._glowRenderedSources.get(key);
|
||||
if (active) {
|
||||
const timer = this._glowFadeTimers.get(key);
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
this._glowFadeTimers.delete(key);
|
||||
}
|
||||
if (domId == null) {
|
||||
domId = ++this._glowSourceSeq;
|
||||
this._glowRenderedSources.set(key, domId);
|
||||
this._glowEnteringSources.add(key);
|
||||
const raf = requestAnimationFrame(() => {
|
||||
if (this._glowEnterRafs.get(key) !== raf) return;
|
||||
this._glowEnterRafs.delete(key);
|
||||
this._glowEnteringSources.delete(key);
|
||||
if (this.isConnected) this.requestUpdate();
|
||||
});
|
||||
this._glowEnterRafs.set(key, raf);
|
||||
}
|
||||
return { domId, entering: this._glowEnteringSources.has(key), leaving: false };
|
||||
}
|
||||
if (domId == null) return null;
|
||||
const enterRaf = this._glowEnterRafs.get(key);
|
||||
if (enterRaf != null) cancelAnimationFrame(enterRaf);
|
||||
this._glowEnterRafs.delete(key);
|
||||
this._glowEnteringSources.delete(key);
|
||||
if (!this._glowFadeTimers.has(key)) {
|
||||
const timer = window.setTimeout(() => {
|
||||
if (this._glowFadeTimers.get(key) !== timer) return;
|
||||
this._glowFadeTimers.delete(key);
|
||||
this._glowRenderedSources.delete(key);
|
||||
this._glowLastAppearance.delete(key);
|
||||
if (this.isConnected) this.requestUpdate();
|
||||
}, GLOW_FADE_MS + 34); // keep one frame of slack after the CSS transition
|
||||
this._glowFadeTimers.set(key, timer);
|
||||
}
|
||||
return { domId, entering: false, leaving: true };
|
||||
}
|
||||
|
||||
private _forgetGlowSource(key: string): void {
|
||||
const timer = this._glowFadeTimers.get(key);
|
||||
if (timer != null) clearTimeout(timer);
|
||||
const raf = this._glowEnterRafs.get(key);
|
||||
if (raf != null) cancelAnimationFrame(raf);
|
||||
this._glowFadeTimers.delete(key);
|
||||
this._glowEnterRafs.delete(key);
|
||||
this._glowEnteringSources.delete(key);
|
||||
this._glowRenderedSources.delete(key);
|
||||
this._glowLastAppearance.delete(key);
|
||||
}
|
||||
|
||||
private _forgetGlowSpace(spaceId: string): void {
|
||||
const prefix = `${spaceId}|`;
|
||||
for (const key of this._glowRenderedSources.keys()) {
|
||||
if (key.startsWith(prefix)) this._forgetGlowSource(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything in a space that stops light, and the floor light may land on.
|
||||
*
|
||||
* Opaque: the wall bodies exactly as the plan draws them — with their real
|
||||
* thickness — plus every independent body (partition, column, room draft),
|
||||
* plus the bare outline of any edge that carries no thickness at all.
|
||||
* Transparent: doorways, gates and arches, cut out of the masonry so the
|
||||
* opening is a real gap between two jamb faces; and virtual (open)
|
||||
* boundaries, which are not walls to begin with. A window stays solid: an
|
||||
* indoor lamp must not wash the street, so the light's masonry is cut by
|
||||
* passages only and differs on purpose from the drawn one.
|
||||
*
|
||||
* The result depends only on the plan, so it is shared by every lamp.
|
||||
*/
|
||||
private _lightBarriers(
|
||||
space: SpaceModel, polys: { r: RoomCfg; poly: number[][] }[], physical: number[][][],
|
||||
): { occluders: LightSegment[]; floor: number[][][]; fingerprint: string } {
|
||||
// Gates are door-like: their different symbol must not change how light
|
||||
// crosses the clear opening.
|
||||
const openCuts = this._openPairs().flatMap((p) => p.segs);
|
||||
const cuts: number[][] = [...openCuts];
|
||||
// An opening is transparent only where it leads from floor to floor. A
|
||||
// front door has nothing behind it to light, so for light it is masonry
|
||||
// like a window — otherwise its tunnel glows halfway, up to the centreline
|
||||
// where the room polygon ends, and the plan shows a lit doorway to nowhere.
|
||||
const probe = Math.max(this._cmToUnits(10), this._gridPitch * 0.5);
|
||||
const onFloor = (point: number[]): boolean =>
|
||||
polys.some((x) => this._pointInRoom(point, x.r));
|
||||
const passages = this._openingsR.filter((o) => {
|
||||
if (o.type === 'window') return false;
|
||||
const rad = (o.angle * Math.PI) / 180;
|
||||
const nx = -Math.sin(rad);
|
||||
const ny = Math.cos(rad);
|
||||
return onFloor([o.rx + nx * probe, o.ry + ny * probe])
|
||||
&& onFloor([o.rx - nx * probe, o.ry - ny * probe]);
|
||||
});
|
||||
for (const o of passages) {
|
||||
const rad = (o.angle * Math.PI) / 180;
|
||||
const dx = (Math.cos(rad) * o.rlen) / 2;
|
||||
const dy = (Math.sin(rad) * o.rlen) / 2;
|
||||
cuts.push([o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy]);
|
||||
}
|
||||
// Keyed by what it is made of, never by `_cfgEpoch`: geometry edited in
|
||||
// place leaves the epoch behind, and a stale barrier set is invisible —
|
||||
// the plan simply keeps lighting through a wall that now exists.
|
||||
let hash = 0x811c9dc5;
|
||||
const mix = (value: number) => {
|
||||
hash ^= Math.round((Number.isFinite(value) ? value : 0) * 64);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
};
|
||||
const walls = this._spaceWalls;
|
||||
for (const { poly } of polys) { mix(poly.length); for (const p of poly) { mix(p[0]); mix(p[1]); } }
|
||||
for (const cut of cuts) for (const value of cut) mix(value);
|
||||
for (const body of physical) { mix(body.length); mix(body[0]?.[0] ?? 0); mix(body[0]?.[1] ?? 0); }
|
||||
for (const wall of walls) { mix(wall.cm); mix(wall.a?.[0] ?? 0); mix(wall.a?.[1] ?? 0); }
|
||||
const fingerprint = hash.toString(36);
|
||||
if (this._lightBarrierCache?.key === fingerprint) return this._lightBarrierCache.value;
|
||||
const eps = this._gridPitch * 0.02;
|
||||
const occluders: LightSegment[] = [];
|
||||
// The masonry the plan draws, cut by passages only — real thickness, real
|
||||
// jamb faces. Treating a wall as its centreline let light bleed half a wall
|
||||
// deep (a bright bar at every opening) and started every shadow half a wall
|
||||
// away from the corner that casts it.
|
||||
const masonry = walls.length
|
||||
? wallBodiesGeometry(
|
||||
space.rooms, walls, openCuts,
|
||||
passages.map((o) => ({ x: o.rx, y: o.ry, angle: o.angle, length: o.rlen })),
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
)
|
||||
: null;
|
||||
for (const ring of geometryAllRings(masonry?.geom)) occluders.push(...polygonSegments(ring));
|
||||
// Edges without any thickness are still walls; so is a room outline when
|
||||
// the boolean pass above could not run at all.
|
||||
for (const { poly } of polys) {
|
||||
for (const seg of (cuts.length ? outlineWithout(poly, cuts, eps) : polygonSegments(poly))) {
|
||||
occluders.push(seg as LightSegment);
|
||||
}
|
||||
}
|
||||
for (const body of physical) occluders.push(...polygonSegments(body));
|
||||
const value = {
|
||||
occluders: splitAtIntersections(occluders),
|
||||
floor: polys.map((x) => x.poly),
|
||||
fingerprint,
|
||||
};
|
||||
this._lightBarrierCache = { key: fingerprint, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Light pools of the current space: dark house, glowing sources. */
|
||||
private _renderGlowLayer(space: SpaceModel, disp: SpaceDisplay): TemplateResult {
|
||||
const colors = this._fillColors;
|
||||
@@ -11217,27 +11433,12 @@ class HouseplanCard extends LitElement {
|
||||
.map((r) => ({ r, poly: roomPoly(r) }))
|
||||
.filter((x): x is { r: RoomCfg; poly: number[][] } => !!x.poly);
|
||||
const enabled = polys.filter(({ r }) => roomGlowOf(disp.glow, r));
|
||||
if (!enabled.length) return svg`` as unknown as TemplateResult;
|
||||
// Gates are door-like openings: their different symbol must not change
|
||||
// how light crosses the clear wall tunnel.
|
||||
const passages = this._openingsR.filter((o) => o.type !== 'window');
|
||||
const walls = this._spaceWalls;
|
||||
const openCuts = this._openPairs().flatMap((p) => p.segs);
|
||||
const physical = this._physicalBodiesR(space);
|
||||
const passageTunnelDepth = new Map<string, number>();
|
||||
if (walls.length) {
|
||||
for (const o of passages) {
|
||||
const rad = (o.angle * Math.PI) / 180;
|
||||
const dx = (Math.cos(rad) * o.rlen) / 2;
|
||||
const dy = (Math.sin(rad) * o.rlen) / 2;
|
||||
const cm = intervalCmAt(
|
||||
space.rooms, walls, openCuts,
|
||||
[o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy],
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
);
|
||||
if (cm > 0) passageTunnelDepth.set(o.id, wallCmToUnits(cm, this._cellCm, this._gridPitch));
|
||||
}
|
||||
if (!enabled.length) {
|
||||
this._forgetGlowSpace(space.id);
|
||||
return svg`` as unknown as TemplateResult;
|
||||
}
|
||||
const physical = this._physicalBodiesR(space);
|
||||
const { occluders, floor, fingerprint } = this._lightBarriers(space, polys, physical);
|
||||
const resolvedSources = resolvedLightSources(
|
||||
this._planHass,
|
||||
this._devices.filter((d) => d.space === space.id),
|
||||
@@ -11249,119 +11450,190 @@ class HouseplanCard extends LitElement {
|
||||
list.push(source);
|
||||
sourcesByDevice.set(source.device.id, list);
|
||||
}
|
||||
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
|
||||
const spots: {
|
||||
key: string;
|
||||
sourceEid: string;
|
||||
domId: number;
|
||||
entering: boolean;
|
||||
leaving: boolean;
|
||||
pos: { x: number; y: number };
|
||||
c: string;
|
||||
alpha: number;
|
||||
geometry: GlowClipGeometry | null;
|
||||
r: number;
|
||||
}[] = [];
|
||||
const seenSourceKeys = new Set<string>();
|
||||
for (const d of this._devices) {
|
||||
if (d.space !== space.id) continue;
|
||||
const source = selectSpatialGlowSource(sourcesByDevice.get(d.id) || []);
|
||||
if (!source) continue;
|
||||
const glow = resolveGlowAppearance(
|
||||
// One marker owns one spatial pool even when its resolved HA source
|
||||
// changes; keeping the key marker-stable avoids a false fade/recreate.
|
||||
const key = `${space.id}|${d.id}`;
|
||||
seenSourceKeys.add(key);
|
||||
const visibleGlow = resolveGlowAppearance(
|
||||
this._planHass.states[source.eid], d.marker?.glow_color, colors.glow_light.c,
|
||||
);
|
||||
if (!glow) continue;
|
||||
// per-source radius (owner's decision v1.36.2): marker override, else global
|
||||
const ownCm = Number(d.marker?.glow_radius_cm);
|
||||
const R = Number.isFinite(ownCm) && ownCm > 0 ? (ownCm / this._cellCm) * this._gridPitch : defaultR;
|
||||
const pos = this._pos(d);
|
||||
// innermost room under the source (islands win — reverse order)
|
||||
const home = [...polys].reverse().find((x) => this._pointInRoom([pos.x, pos.y], x.r));
|
||||
let clip: string[] | null = null;
|
||||
const clipKey = home
|
||||
? `${space.id}|${this._cfgEpoch}|${pos.x.toFixed(4)},${pos.y.toFixed(4)}|${R.toFixed(4)}`
|
||||
: '';
|
||||
const cachedClip = home ? lruRead(this._glowClipCache, clipKey) : { hit: false as const };
|
||||
if (cachedClip.hit) {
|
||||
clip = cachedClip.value;
|
||||
} else if (home) {
|
||||
const occluders = physical.length
|
||||
? radialOccluders(physical, [pos.x, pos.y], R)
|
||||
: [];
|
||||
// open (virtual) boundaries: light flows through the whole connected
|
||||
// zone of rooms, not just the source's own room (owner's spec)
|
||||
const zoneIds = home.r.id ? openZoneOf(home.r.id, space.rooms) : new Set([home.r.id]);
|
||||
const zone = polys.filter((x) => x.r.id && zoneIds.has(x.r.id));
|
||||
const zoneList = zone.length ? zone : [home];
|
||||
const shapes: string[] = zoneList.map((z) => {
|
||||
const poly = (walls.length && z.r.id)
|
||||
? (innerContourForRoom(
|
||||
space.rooms, z.r.id!, walls, openCuts,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
) || z.poly)
|
||||
: z.poly;
|
||||
return occluders.length
|
||||
? polyclipPathD(floorMinusBodies(poly, occluders))
|
||||
: 'M ' + poly.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z';
|
||||
});
|
||||
// doorways on the ZONE's walls spill light into rooms outside the zone
|
||||
const others = polys.filter((x) => !zoneList.includes(x)).map((x) => x.poly);
|
||||
for (const o of passages) {
|
||||
const onZoneWall = zoneList.some((z) => {
|
||||
const near = closestPointOnBoundary([o.rx, o.ry], z.poly);
|
||||
return near && Math.hypot(near[0] - o.rx, near[1] - o.ry) <= g * 0.75;
|
||||
});
|
||||
if (!onZoneWall) continue;
|
||||
const rad = (o.angle * Math.PI) / 180;
|
||||
const dx = (Math.cos(rad) * o.rlen) / 2;
|
||||
const dy = (Math.sin(rad) * o.rlen) / 2;
|
||||
if (!hasRoomBehind([o.rx, o.ry], o.angle, [pos.x, pos.y], others, g * 0.6)) continue;
|
||||
const sector = doorSector(
|
||||
[pos.x, pos.y], [o.rx - dx, o.ry - dy], [o.rx + dx, o.ry + dy],
|
||||
R, 170, passageTunnelDepth.get(o.id) || 0,
|
||||
);
|
||||
if (sector) shapes.push(occluders.length
|
||||
? polyclipPathD(floorMinusBodies(sector, occluders))
|
||||
: 'M ' + sector.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z');
|
||||
}
|
||||
// IMPORTANT: separate <path> children — clipPath children always
|
||||
// UNION. Joining the room and a sector into ONE path made the default
|
||||
// nonzero fill-rule cancel their overlap when the windings opposed,
|
||||
// punching a dark wedge INSIDE the room (field report + screenshot).
|
||||
clip = shapes;
|
||||
lruWrite(this._glowClipCache, clipKey, clip, 256);
|
||||
// Invalid placement in masonry must remain dark. radialOccluders() also
|
||||
// guards this case, but the partial shadow mask below intentionally
|
||||
// never turns an otherwise valid room completely black.
|
||||
if (physical.some((body) => pointInPhysicalBody([pos.x, pos.y], body))) {
|
||||
this._glowTransition(key, false);
|
||||
continue;
|
||||
}
|
||||
spots.push({ pos, c: glow.c, alpha: glowAlpha(glow.bri, colors.glow_light.a), clip, r: R });
|
||||
const transition = this._glowTransition(key, !!visibleGlow);
|
||||
if (!transition) continue;
|
||||
if (visibleGlow) {
|
||||
this._glowLastAppearance.set(key, {
|
||||
c: visibleGlow.c,
|
||||
alpha: glowAlpha(visibleGlow.bri, colors.glow_light.a),
|
||||
});
|
||||
}
|
||||
const appearance = this._glowLastAppearance.get(key);
|
||||
if (!appearance) continue;
|
||||
let geometry: GlowClipGeometry | null = null;
|
||||
const clipKey =
|
||||
`${space.id}|${fingerprint}|${pos.x.toFixed(4)},${pos.y.toFixed(4)}|${R.toFixed(4)}`;
|
||||
const cachedClip = lruRead(this._glowClipCache, clipKey);
|
||||
if (cachedClip.hit) {
|
||||
geometry = cachedClip.value;
|
||||
} else {
|
||||
// The entire light model, in two lines: what can this lamp see, and
|
||||
// where is there floor to light. Doorways, gates and virtual walls are
|
||||
// simply missing from `occluders`, so light crosses them without any
|
||||
// notion of a "spill", a "sector", a "tunnel" or an "open zone" — and
|
||||
// a wall corner two rooms away casts its shadow for exactly the same
|
||||
// reason a column does.
|
||||
const seen = visibilityPolygon([pos.x, pos.y], R, occluders, GLOW_ARC_STEPS);
|
||||
geometry = {
|
||||
lit: seen.length >= 3 ? intersectionPaths([seen], floor) : [],
|
||||
};
|
||||
lruWrite(this._glowClipCache, clipKey, geometry, 256);
|
||||
}
|
||||
spots.push({
|
||||
key,
|
||||
sourceEid: source.eid,
|
||||
domId: transition.domId,
|
||||
entering: transition.entering,
|
||||
leaving: transition.leaving,
|
||||
pos,
|
||||
c: appearance.c,
|
||||
alpha: appearance.alpha,
|
||||
geometry,
|
||||
r: R,
|
||||
});
|
||||
}
|
||||
const sourcePrefix = `${space.id}|`;
|
||||
for (const key of this._glowRenderedSources.keys()) {
|
||||
if (key.startsWith(sourcePrefix) && !seenSourceKeys.has(key)) this._forgetGlowSource(key);
|
||||
}
|
||||
if (!spots.length) return svg`` as unknown as TemplateResult;
|
||||
// Per-room Glow overrides are visual clips only. The transport calculation
|
||||
// above still crosses a disabled room, but no base/pool pixels are painted
|
||||
// there. For the common all-enabled case this extra clip is omitted, which
|
||||
// preserves the established doorway-sector geometry byte for byte.
|
||||
// there. For the common all-enabled case this extra clip is omitted.
|
||||
const walls = this._spaceWalls;
|
||||
const enabledClip = enabled.length === polys.length ? null : enabled.map(({ r, poly }) => {
|
||||
const floor = walls.length && r.id
|
||||
const floorPoly = walls.length && r.id
|
||||
? (innerContourForRoom(
|
||||
space.rooms, r.id, walls, openCuts,
|
||||
space.rooms, r.id, walls, this._openPairs().flatMap((p) => p.segs),
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
) || poly)
|
||||
: poly;
|
||||
const clean = this._cleanFloor(r, floor, space).path;
|
||||
const clean = this._cleanFloor(r, floorPoly, space).path;
|
||||
const holes = islandsOf(
|
||||
floor,
|
||||
floorPoly,
|
||||
polys.filter((other) => other.r !== r).map((other) => other.poly),
|
||||
);
|
||||
const path = (points: number[][]) =>
|
||||
'M ' + points.map((point) => point[0] + ' ' + point[1]).join(' L ') + ' Z';
|
||||
return [clean || path(floor), ...holes.map(path)].join(' ');
|
||||
return [clean || path(floorPoly), ...holes.map(path)].join(' ');
|
||||
});
|
||||
// A hair on SCREEN, so an edge stays a hairline at any zoom instead of
|
||||
// turning into a smear when the plan is enlarged.
|
||||
const view = this._viewOr(this._baseVb());
|
||||
const perUnit = this._stageEl?.clientWidth && view.w
|
||||
? this._stageEl.clientWidth / view.w
|
||||
: 1;
|
||||
const feather = GLOW_EDGE_FEATHER_PX / 2 / (perUnit > 0 ? perUnit : 1);
|
||||
const pad = feather * 4;
|
||||
const featherBox = spots.reduce((box, sp) => ({
|
||||
x: Math.min(box.x, sp.pos.x - sp.r - pad),
|
||||
y: Math.min(box.y, sp.pos.y - sp.r - pad),
|
||||
maxX: Math.max(box.maxX, sp.pos.x + sp.r + pad),
|
||||
maxY: Math.max(box.maxY, sp.pos.y + sp.r + pad),
|
||||
w: 0, h: 0,
|
||||
}), { x: Infinity, y: Infinity, maxX: -Infinity, maxY: -Infinity, w: 0, h: 0 });
|
||||
featherBox.w = featherBox.maxX - featherBox.x;
|
||||
featherBox.h = featherBox.maxY - featherBox.y;
|
||||
return svg`<defs>
|
||||
${spots.map((sp, i) => svg`
|
||||
<radialGradient id="hp-glow-${i}">
|
||||
<stop offset="0%" stop-color="${sp.c}" stop-opacity="${sp.alpha.toFixed(3)}"></stop>
|
||||
<stop offset="70%" stop-color="${sp.c}" stop-opacity="${sp.alpha.toFixed(3)}"></stop>
|
||||
<stop offset="100%" stop-color="${sp.c}" stop-opacity="0"></stop>
|
||||
</radialGradient>
|
||||
${sp.clip ? svg`<clipPath id="hp-glowclip-${i}">${sp.clip.map((d) => svg`<path d="${d}" clip-rule="evenodd" fill-rule="evenodd"></path>`)}</clipPath>` : nothing}`)}
|
||||
${repeat(spots, (sp) => sp.key, (sp) => {
|
||||
const i = sp.domId;
|
||||
const geom = sp.geometry;
|
||||
return svg`
|
||||
${''/* One field per source, in plan coordinates. Attenuation is a
|
||||
property of DISTANCE FROM THE LAMP and nothing else, so the
|
||||
floor two rooms away is dimmer because it is far, not because
|
||||
a second gradient was pasted at a doorway. The profile decays
|
||||
over the whole radius: the old flat plateau out to 70% turned
|
||||
every clipped shape into a slab of solid colour with a rim. */}
|
||||
<radialGradient id="hp-glow-${i}" gradientUnits="userSpaceOnUse"
|
||||
cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}">
|
||||
${GLOW_FALLOFF.map(([offset, scale]) => svg`
|
||||
<stop offset="${offset}%" stop-color="${sp.c}"
|
||||
stop-opacity="${(sp.alpha * scale).toFixed(4)}"></stop>`)}
|
||||
</radialGradient>
|
||||
${''/* The one and only shape of this light: the floor this lamp can
|
||||
actually see. A shadow is simply floor that is not in here.
|
||||
The single blur below is the penumbra of that shape — one
|
||||
pass over one path, never a second layer of light. */}
|
||||
${geom ? svg`
|
||||
<clipPath id="hp-glowclip-${i}">
|
||||
<path class="glow-lit" d="${geom.lit.join(' ')}"
|
||||
clip-rule="evenodd" fill-rule="evenodd"></path>
|
||||
</clipPath>`
|
||||
: nothing}`;
|
||||
})}
|
||||
${enabledClip ? svg`<clipPath id="hp-glow-enabled">${enabledClip.map((d) => svg`<path d=${d} clip-rule="evenodd" fill-rule="evenodd"></path>`)}</clipPath>` : nothing}
|
||||
${''/* Penumbra, once for the whole layer. Every region is cut
|
||||
geometrically; blurring the composited result by a hair is what
|
||||
keeps a shadow edge from reading as a cut-out. One pass costs a
|
||||
fraction of one blurred mask per source, and the difference is
|
||||
invisible — neighbouring pools are already smooth where they
|
||||
meet. CSS `filter: blur()` is not usable here: Chromium applies
|
||||
it to an SVG group in name only. */}
|
||||
<filter id="hp-glowfeather" filterUnits="userSpaceOnUse"
|
||||
x="${featherBox.x}" y="${featherBox.y}"
|
||||
width="${featherBox.w}" height="${featherBox.h}"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur stdDeviation="${feather.toFixed(4)}" edgeMode="none"></feGaussianBlur>
|
||||
</filter>
|
||||
</defs>
|
||||
${''/* Glow is presentation only. It is painted above room fills, but must
|
||||
not become the pointer target: room hover and its tooltip still
|
||||
belong to the room underneath the light pool. */}
|
||||
<g class="glowlayer glow-pools-frame" pointer-events="none">
|
||||
<g class="glowlayer glow-pools-frame" pointer-events="none"
|
||||
filter="url(#hp-glowfeather)">
|
||||
<g class="glow-pools ${this._glowScreenBlend ? 'blend-screen' : 'blend-normal'}"
|
||||
data-blend=${this._glowScreenBlend ? 'screen' : 'normal'}
|
||||
data-feather-px="${GLOW_EDGE_FEATHER_PX}"
|
||||
clip-path=${enabledClip ? 'url(#hp-glow-enabled)' : nothing}>
|
||||
${spots.map((sp, i) => svg`<circle class="glow-pool"
|
||||
cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}"
|
||||
fill="url(#hp-glow-${i})"
|
||||
clip-path=${sp.clip ? `url(#hp-glowclip-${i})` : nothing}></circle>`)}
|
||||
${repeat(spots, (sp) => sp.key, (sp) => {
|
||||
const i = sp.domId;
|
||||
return svg`
|
||||
<g class="glow-spot ${sp.entering ? 'is-entering' : ''} ${sp.leaving ? 'is-leaving' : ''}"
|
||||
data-glow-spot="${i}" data-glow-source="${sp.sourceEid}">
|
||||
<circle class="glow-pool"
|
||||
cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}"
|
||||
data-lit-parts="${sp.geometry?.lit.length || 0}"
|
||||
data-feather-px="${GLOW_EDGE_FEATHER_PX}"
|
||||
fill="url(#hp-glow-${i})"
|
||||
clip-path=${sp.geometry ? `url(#hp-glowclip-${i})` : nothing}></circle>
|
||||
</g>`;
|
||||
})}
|
||||
</g>
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
@@ -14130,22 +14402,25 @@ class HouseplanCard extends LitElement {
|
||||
})} />` : nothing}
|
||||
${d.length ? html`<div class="muted">${this._t('physical.length')}: ${d.length}</div>` : nothing}
|
||||
</div>
|
||||
<div class="row" slot="footer">
|
||||
${d.kind === 'draft' ? html`
|
||||
<button class="btn danger" @click=${this._deleteDraftSegment}>
|
||||
<ha-icon icon="mdi:vector-line"></ha-icon>${this._t('physical.delete_segment')}
|
||||
<div class="row dialog-action-footer physicalfooter" slot="footer">
|
||||
<div class="dialog-action-group dialog-action-danger">
|
||||
${d.kind === 'draft' ? html`
|
||||
<button class="btn danger" @click=${this._deleteDraftSegment}>
|
||||
<ha-icon icon="mdi:vector-line"></ha-icon>${this._t('physical.delete_segment')}
|
||||
</button>
|
||||
<button class="btn danger" @click=${this._deleteDraftWhole}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('physical.delete_draft')}
|
||||
</button>` : html`
|
||||
<button class="btn danger" @click=${this._deletePhysicalSelection}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.delete')}
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="dialog-action-group dialog-action-commit">
|
||||
<button class="btn ghost" @click=${() => (this._physicalDialog = null)}>${this._t('btn.cancel')}</button>
|
||||
<button class="btn on" @click=${this._savePhysicalDialog}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
||||
</button>
|
||||
<button class="btn danger" @click=${this._deleteDraftWhole}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('physical.delete_draft')}
|
||||
</button>` : html`
|
||||
<button class="btn danger" @click=${this._deletePhysicalSelection}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.delete')}
|
||||
</button>`}
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${() => (this._physicalDialog = null)}>${this._t('btn.cancel')}</button>
|
||||
<button class="btn on" @click=${this._savePhysicalDialog}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</hp-dialog>`;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ export class HpDialog extends LitElement {
|
||||
--dialog-content-padding: 0;
|
||||
--dialog-surface-background: var(--card-background-color, var(--hp-bg, #202126));
|
||||
--ha-dialog-border-radius: var(--rad-l, 18px);
|
||||
/* HA's ha-dialog-header defaults this custom property to a one-line
|
||||
fixed height. Our localized slot is intentionally allowed to wrap,
|
||||
so leaving that default in place clips every line after the first at
|
||||
the bottom of the header. auto is HA's public sizing hook and also
|
||||
stays harmless on older ha-dialog implementations that do not consume
|
||||
it. */
|
||||
--ha-dialog-header-title-height: auto;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* What a lamp can see.
|
||||
*
|
||||
* The whole light model is one question asked once per source: which points of
|
||||
* the plan does a straight line from the lamp reach without crossing something
|
||||
* opaque? Walls, columns and free-standing partitions are opaque; doorways,
|
||||
* gates and virtual (open) boundaries are simply absent from the occluder set,
|
||||
* so light travels through them without any special case for "spill",
|
||||
* "sector", "tunnel" or "open zone". Everything the plan shows — a beam
|
||||
* through a door, a shadow behind a column, a wall corner cutting that beam —
|
||||
* falls out of this one polygon.
|
||||
*
|
||||
* The algorithm is the classic angular sweep: cast a ray at every occluder
|
||||
* corner (and just to either side of it), keep the nearest hit, and close the
|
||||
* fan with an arc at the lamp's own radius.
|
||||
*/
|
||||
|
||||
/** Opaque edge in plan coordinates: [x1, y1, x2, y2]. */
|
||||
export type LightSegment = readonly number[];
|
||||
|
||||
/** Rays are nudged by this angle either side of a corner to catch what the
|
||||
* corner hides and what it does not. Radians; ~2 µm at a 2 m radius. */
|
||||
const CORNER_NUDGE = 1e-5;
|
||||
/** Rays this close together resolve to the same point; keeping both only feeds
|
||||
* polyclip degenerate slivers. */
|
||||
const ANGLE_EPS = 1e-9;
|
||||
|
||||
/**
|
||||
* Break every barrier at the points where barriers cross each other.
|
||||
*
|
||||
* The sweep casts a ray at each barrier ENDPOINT, which is exact only while
|
||||
* barriers meet end to end. Two that cross in their middles — the face of one
|
||||
* wall running through the face of another at a junction — leave that corner
|
||||
* unsampled, and the fan closes it with a chord: a sliver of floor next to the
|
||||
* corner goes dark although the lamp sees it. Splitting first turns every
|
||||
* crossing into an endpoint and the sweep is exact again, whatever shape the
|
||||
* geometry arrived in. Collinear overlaps need no split: their corners are
|
||||
* already somebody's endpoint.
|
||||
*/
|
||||
export function splitAtIntersections(segments: readonly LightSegment[]): LightSegment[] {
|
||||
const cuts: number[][] = segments.map(() => []);
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const a = segments[i];
|
||||
const ax = a[2] - a[0];
|
||||
const ay = a[3] - a[1];
|
||||
for (let j = i + 1; j < segments.length; j++) {
|
||||
const b = segments[j];
|
||||
const denominator = ax * (b[3] - b[1]) - ay * (b[2] - b[0]);
|
||||
if (Math.abs(denominator) < 1e-12) continue;
|
||||
const ox = b[0] - a[0];
|
||||
const oy = b[1] - a[1];
|
||||
const t = (ox * (b[3] - b[1]) - oy * (b[2] - b[0])) / denominator;
|
||||
const u = (ox * ay - oy * ax) / denominator;
|
||||
if (t <= 1e-9 || t >= 1 - 1e-9 || u <= 1e-9 || u >= 1 - 1e-9) continue;
|
||||
cuts[i].push(t);
|
||||
cuts[j].push(u);
|
||||
}
|
||||
}
|
||||
const out: LightSegment[] = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (!cuts[i].length) { out.push(seg); continue; }
|
||||
const stops = [0, ...cuts[i].sort((left, right) => left - right), 1];
|
||||
for (let k = 1; k < stops.length; k++) {
|
||||
if (stops[k] - stops[k - 1] < 1e-9) continue;
|
||||
out.push([
|
||||
seg[0] + (seg[2] - seg[0]) * stops[k - 1], seg[1] + (seg[3] - seg[1]) * stops[k - 1],
|
||||
seg[0] + (seg[2] - seg[0]) * stops[k], seg[1] + (seg[3] - seg[1]) * stops[k],
|
||||
]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function polygonSegments(poly: readonly (readonly number[])[]): LightSegment[] {
|
||||
const out: LightSegment[] = [];
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[(i + 1) % poly.length];
|
||||
if (!a || !b) continue;
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < 1e-9) continue;
|
||||
out.push([a[0], a[1], b[0], b[1]]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const distanceToSegment = (point: readonly number[], seg: LightSegment): number => {
|
||||
const dx = seg[2] - seg[0];
|
||||
const dy = seg[3] - seg[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!(len2 > 0)) return Math.hypot(point[0] - seg[0], point[1] - seg[1]);
|
||||
const t = Math.max(0, Math.min(1,
|
||||
((point[0] - seg[0]) * dx + (point[1] - seg[1]) * dy) / len2));
|
||||
return Math.hypot(point[0] - (seg[0] + t * dx), point[1] - (seg[1] + t * dy));
|
||||
};
|
||||
|
||||
/** Distance from `source` along `dir` to a segment, or Infinity. */
|
||||
const rayHit = (
|
||||
source: readonly number[], dirX: number, dirY: number, seg: LightSegment,
|
||||
): number => {
|
||||
const ex = seg[2] - seg[0];
|
||||
const ey = seg[3] - seg[1];
|
||||
const denominator = dirX * ey - dirY * ex;
|
||||
if (Math.abs(denominator) < 1e-12) return Infinity; // parallel: never a blocker
|
||||
const ox = seg[0] - source[0];
|
||||
const oy = seg[1] - source[1];
|
||||
const t = (ox * ey - oy * ex) / denominator;
|
||||
if (!(t > 1e-9)) return Infinity;
|
||||
const s = (ox * dirY - oy * dirX) / denominator;
|
||||
if (s < -1e-9 || s > 1 + 1e-9) return Infinity;
|
||||
return t;
|
||||
};
|
||||
|
||||
/**
|
||||
* Region lit by a point source, as a single ring in plan coordinates.
|
||||
*
|
||||
* `segments` may contain anything: only the ones that can reach into the
|
||||
* radius are considered. A source sitting exactly on an opaque edge would make
|
||||
* the sweep degenerate, so such edges are dropped — the caller is expected to
|
||||
* reject a lamp placed inside masonry before asking.
|
||||
*/
|
||||
export function visibilityPolygon(
|
||||
source: readonly number[],
|
||||
radius: number,
|
||||
segments: readonly LightSegment[],
|
||||
arcSteps = 96,
|
||||
): number[][] {
|
||||
if (!(radius > 0) || !Number.isFinite(source[0]) || !Number.isFinite(source[1])) return [];
|
||||
const near: LightSegment[] = [];
|
||||
for (const seg of segments) {
|
||||
if (!seg || seg.length < 4) continue;
|
||||
if (![seg[0], seg[1], seg[2], seg[3]].every(Number.isFinite)) continue;
|
||||
const distance = distanceToSegment(source, seg);
|
||||
if (distance > radius || distance < 1e-7) continue;
|
||||
near.push(seg);
|
||||
}
|
||||
const angles: number[] = [];
|
||||
const steps = Math.max(12, Math.round(arcSteps));
|
||||
for (let i = 0; i < steps; i++) angles.push((i / steps) * Math.PI * 2 - Math.PI);
|
||||
for (const seg of near) {
|
||||
for (const point of [[seg[0], seg[1]], [seg[2], seg[3]]]) {
|
||||
const angle = Math.atan2(point[1] - source[1], point[0] - source[0]);
|
||||
angles.push(angle - CORNER_NUDGE, angle, angle + CORNER_NUDGE);
|
||||
}
|
||||
}
|
||||
angles.sort((left, right) => left - right);
|
||||
const ring: number[][] = [];
|
||||
let previous = Number.NEGATIVE_INFINITY;
|
||||
for (const angle of angles) {
|
||||
if (angle - previous < ANGLE_EPS) continue;
|
||||
previous = angle;
|
||||
const dirX = Math.cos(angle);
|
||||
const dirY = Math.sin(angle);
|
||||
let reach = radius;
|
||||
for (const seg of near) {
|
||||
const hit = rayHit(source, dirX, dirY, seg);
|
||||
if (hit < reach) reach = hit;
|
||||
}
|
||||
ring.push([source[0] + dirX * reach, source[1] + dirY * reach]);
|
||||
}
|
||||
return ring.length >= 3 ? ring : [];
|
||||
}
|
||||
@@ -1716,6 +1716,29 @@ export function glowColorOf(state: any, fallback: string): ResolvedGlowValues |
|
||||
return resolveGlowAppearance(state, null, fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound a doorway spill to the part of the source radius that remains after
|
||||
* the aperture. `reach` is deliberately aperture-relative: callers use it for
|
||||
* the spill gradient, while `outerRadius` remains source-relative for the
|
||||
* sight-line sector built by `doorSector`.
|
||||
*/
|
||||
export function doorSpillRange(
|
||||
sourceRadius: number,
|
||||
apertureEdgeDistance: number,
|
||||
requestedReach: number,
|
||||
): { reach: number; outerRadius: number } | null {
|
||||
if (!Number.isFinite(sourceRadius) || sourceRadius <= 0
|
||||
|| !Number.isFinite(apertureEdgeDistance) || apertureEdgeDistance < 0
|
||||
|| !Number.isFinite(requestedReach) || requestedReach <= 0
|
||||
|| apertureEdgeDistance >= sourceRadius) return null;
|
||||
const reach = Math.min(requestedReach, sourceRadius - apertureEdgeDistance);
|
||||
if (!(reach > 1e-6)) return null;
|
||||
return {
|
||||
reach,
|
||||
outerRadius: Math.min(sourceRadius, apertureEdgeDistance + reach),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Light spilling through a doorway: the sector of the glow circle between the
|
||||
* rays source→A and source→B (door edge points), out to radius r.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Geometry shared by independent partitions, saved room drafts and columns. */
|
||||
import { difference, union } from 'polyclip-ts';
|
||||
import { difference, intersection, union } from 'polyclip-ts';
|
||||
import { polygonArea } from './logic';
|
||||
import { wallCmToUnits } from './wall-thickness';
|
||||
import type {
|
||||
@@ -110,6 +110,66 @@ export function unionBodies(bodies: number[][][]): any | null {
|
||||
}
|
||||
}
|
||||
|
||||
const ringPath = (poly: number[][]): string =>
|
||||
`M ${poly.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`;
|
||||
|
||||
/**
|
||||
* One `d` per resulting polygon (its outer ring plus its holes), never one
|
||||
* merged string. Two separate paths cannot cancel each other, while a single
|
||||
* path with the default nonzero rule silently erases the overlap of two
|
||||
* subpaths wound the opposite way — the way ten joined shadow quads once
|
||||
* collapsed into nothing.
|
||||
*/
|
||||
export function geometryPolygonPaths(geom: any): string[] {
|
||||
const out: string[] = [];
|
||||
for (const poly of geom || []) {
|
||||
const parts: string[] = [];
|
||||
for (const ring of poly || []) {
|
||||
const pts = (ring || []).filter((p: any) => Array.isArray(p) && p.length >= 2);
|
||||
if (pts.length < 4) continue;
|
||||
parts.push(ringPath(pts.slice(0, -1)));
|
||||
}
|
||||
if (parts.length) out.push(parts.join(' '));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Union of overlapping polygons as disjoint paths: no pixel is painted twice. */
|
||||
export function unionPaths(polygons: number[][][]): string[] {
|
||||
const usable = polygons.filter((poly) => poly.length >= 3);
|
||||
if (!usable.length) return [];
|
||||
const geom = unionBodies(usable);
|
||||
return geom ? geometryPolygonPaths(geom) : usable.map(ringPath);
|
||||
}
|
||||
|
||||
/** `polygons` clipped to `bounds`, as disjoint paths. Empty when they miss. */
|
||||
export function intersectionPaths(polygons: number[][][], bounds: number[][][]): string[] {
|
||||
const base = unionBodies(polygons.filter((poly) => poly.length >= 3));
|
||||
const limit = unionBodies(bounds.filter((poly) => poly.length >= 3));
|
||||
if (!base) return [];
|
||||
if (!limit) return geometryPolygonPaths(base);
|
||||
try {
|
||||
return geometryPolygonPaths(intersection(base, limit));
|
||||
} catch {
|
||||
return geometryPolygonPaths(base);
|
||||
}
|
||||
}
|
||||
|
||||
/** `polygons` minus `subtract`, as disjoint paths. Falls back to the union. */
|
||||
export function differencePaths(polygons: number[][][], subtract: number[][][]): string[] {
|
||||
const usable = polygons.filter((poly) => poly.length >= 3);
|
||||
if (!usable.length) return [];
|
||||
const base = unionBodies(usable);
|
||||
if (!base) return usable.map(ringPath);
|
||||
const cut = unionBodies(subtract.filter((poly) => poly.length >= 3));
|
||||
if (!cut) return geometryPolygonPaths(base);
|
||||
try {
|
||||
return geometryPolygonPaths(difference(base, cut));
|
||||
} catch {
|
||||
return geometryPolygonPaths(base);
|
||||
}
|
||||
}
|
||||
|
||||
export function physicalBodiesPath(bodies: number[][][]): string {
|
||||
const geom = unionBodies(bodies);
|
||||
if (geom) return polyclipPathD(geom);
|
||||
@@ -147,6 +207,20 @@ export function geometryArea(geom: any): number {
|
||||
return Math.max(0, area);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ring of a geometry, holes included. For an occluder set the holes are
|
||||
* the important part: the room-facing faces of a wall ring ARE its holes.
|
||||
*/
|
||||
export function geometryAllRings(geom: any): number[][][] {
|
||||
const out: number[][][] = [];
|
||||
for (const poly of geom || []) {
|
||||
for (const ring of poly || []) {
|
||||
if (ring?.length >= 4) out.push(ring.slice(0, -1).map((p: number[]) => [p[0], p[1]]));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function geometryOuterRings(geom: any): number[][][] {
|
||||
const out: number[][][] = [];
|
||||
for (const poly of geom || []) {
|
||||
|
||||
+82
-21
@@ -475,9 +475,8 @@ export const cardStyles = css`
|
||||
stroke-width: 2;
|
||||
}
|
||||
.stage.mode-view .room.overlay:not(.styled):hover {
|
||||
fill: var(--hp-accent);
|
||||
fill-opacity: 0.18;
|
||||
stroke: var(--hp-accent);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
.room.yard {
|
||||
fill: rgba(75, 140, 90, 0.14);
|
||||
@@ -485,9 +484,8 @@ export const cardStyles = css`
|
||||
stroke-width: 2;
|
||||
}
|
||||
.stage.mode-view .room.yard:not(.styled):hover {
|
||||
fill: var(--hp-accent);
|
||||
fill-opacity: 0.2;
|
||||
stroke: var(--hp-accent);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
.room.styled {
|
||||
stroke: var(--room-stroke, transparent);
|
||||
@@ -499,28 +497,43 @@ export const cardStyles = css`
|
||||
.glow-base-layer,
|
||||
.glow-base-tunnels,
|
||||
.glow-pools-frame,
|
||||
.glow-pools {
|
||||
.glow-pools,
|
||||
.glow-spot,
|
||||
.glow-soft {
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The isolated group makes source pools blend with one another, never with
|
||||
the room data fill, Glow base, paper or backdrop. Per-stop alpha already
|
||||
contains the shared 0.7 ceiling; an outer opacity would apply it twice. */
|
||||
/* The parent isolates all source spots from the room data fill, Glow base,
|
||||
paper and backdrop. A spot is one circle clipped to the floor its lamp
|
||||
can see, so the whole spot screen-blends as a single primitive — there is
|
||||
no mask left to be dropped on a promoted layer. Per-stop alpha already
|
||||
contains the shared 0.7 ceiling; the spot opacity below only animates
|
||||
between 0 and 1 and is never another persistent alpha ceiling. */
|
||||
.glow-pools-frame,
|
||||
.glow-pools {
|
||||
.glow-pools,
|
||||
.glow-spot {
|
||||
isolation: isolate;
|
||||
}
|
||||
.glow-pools.blend-screen .glow-pool {
|
||||
.glow-pools.blend-screen .glow-spot {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
/* View hover: brighten the current fill; accent wash when unfilled. */
|
||||
.stage.mode-view .room.styled.filled:hover {
|
||||
filter: brightness(1.2) saturate(1.08) drop-shadow(0 0 4px var(--hp-accent));
|
||||
stroke: var(--hp-accent);
|
||||
stroke-opacity: 1;
|
||||
.glow-spot {
|
||||
opacity: 1;
|
||||
transition: opacity 500ms ease;
|
||||
}
|
||||
.stage.mode-view .room.styled:not(.filled):hover {
|
||||
fill: var(--hp-accent);
|
||||
fill-opacity: 0.18;
|
||||
.glow-spot.is-entering,
|
||||
.glow-spot.is-leaving {
|
||||
opacity: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glow-spot {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
/* The explicit late room-hover layer owns the wash and halo. Keeping CSS
|
||||
filters off room paths prevents Chromium from recompositing the sibling
|
||||
screen-blended Glow layer for one bright frame on every hover. */
|
||||
.stage.mode-view .room.styled:hover {
|
||||
stroke: var(--hp-accent);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
/* doors, windows & gates */
|
||||
@@ -1331,8 +1344,23 @@ export const cardStyles = css`
|
||||
stroke: rgba(62, 166, 255, 0.55);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
/* View hover follows the clean-floor face above the unioned wall body. The
|
||||
original room stroke remains responsible for fill and thin-wall fallback. */
|
||||
/* View hover follows the clean-floor face above the unioned wall body.
|
||||
Plain fill/strokes deliberately replace CSS filters: GPU promotion of a
|
||||
filtered SVG sibling used to flash the screen-blended Glow layer. */
|
||||
.room-hover-fill {
|
||||
fill: var(--hp-accent);
|
||||
fill-opacity: 0.10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.room-hover-halo {
|
||||
fill: none;
|
||||
stroke: var(--hp-accent);
|
||||
stroke-opacity: 0.28;
|
||||
stroke-width: 8;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
.room-hover-outline {
|
||||
fill: none;
|
||||
stroke: var(--hp-accent);
|
||||
@@ -1340,7 +1368,6 @@ export const cardStyles = css`
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 3px var(--hp-accent));
|
||||
}
|
||||
.openwall-preview {
|
||||
stroke: #ffc14d;
|
||||
@@ -2668,9 +2695,43 @@ export const cardStyles = css`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-4);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
border-top: 1px solid var(--hp-line);
|
||||
}
|
||||
/* Stable destructive/commit footer contract. A flex spacer cannot react
|
||||
when translated labels no longer fit: justify-content then overflows
|
||||
the destructive button through the left inset. Two real groups wrap
|
||||
as units instead — destructive actions stay left, while Cancel/Save
|
||||
move together to a right-aligned second row when necessary. */
|
||||
hp-dialog .row.dialog-action-footer {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--sp-4);
|
||||
}
|
||||
hp-dialog .dialog-action-group {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
hp-dialog .dialog-action-group .btn {
|
||||
flex: 0 0 auto;
|
||||
min-height: 44px;
|
||||
}
|
||||
hp-dialog .dialog-action-danger {
|
||||
margin-right: auto;
|
||||
}
|
||||
hp-dialog .dialog-action-commit {
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
hp-dialog .row.markerfooter {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
+48
-20
@@ -1297,7 +1297,15 @@ export function wallBodyRings(
|
||||
* room would then erase a wall owned by another room, leaving half-depth strips
|
||||
* and tiny holes at junctions.
|
||||
*/
|
||||
export function wallBodiesUnionPath(
|
||||
/**
|
||||
* The masonry itself, as polygons: room wall rings joined at their mitres,
|
||||
* with opening slots cut through. Drawing uses it as one path; the light model
|
||||
* uses the same geometry as its occluders, so a wall blocks light exactly
|
||||
* where the plan shows a wall — with its real thickness, and with a doorway
|
||||
* that is a real gap between two jamb faces. Null when there is nothing solid,
|
||||
* or when the boolean pass fails (callers fall back to their own outlines).
|
||||
*/
|
||||
export function wallBodiesGeometry(
|
||||
rooms: any[],
|
||||
walls: WallEntry[] | null | undefined,
|
||||
openCuts: number[][],
|
||||
@@ -1306,10 +1314,8 @@ export function wallBodiesUnionPath(
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
coordScale = 1,
|
||||
/** Independent physical bodies are unioned only after room openings are cut,
|
||||
* so a door/window/gate can never punch a coincident partition or column. */
|
||||
extraBodies: number[][][] = [],
|
||||
): { d: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
|
||||
): { geom: any; depthUnits: number } | null {
|
||||
if (!walls?.length && !extraBodies.length) return null;
|
||||
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
|
||||
let maxDepth = 0;
|
||||
@@ -1380,26 +1386,48 @@ export function wallBodiesUnionPath(
|
||||
if (extra.length < 3) continue;
|
||||
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
|
||||
}
|
||||
const d = polyclipToPathD(body);
|
||||
if (!d) return null;
|
||||
return { d, depthUnits: maxDepth, fillRule: 'evenodd' };
|
||||
return body ? { geom: body, depthUnits: maxDepth } : null;
|
||||
} catch {
|
||||
// fall back to evenodd rings concatenated
|
||||
const rings = wallBodyRings(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||||
const extraD = extraBodies.map((poly) => polyToPath(poly)).join(' ');
|
||||
if (!rings.length && !extraD) return null;
|
||||
// Each room ring already reverses its inset. `nonzero` therefore keeps
|
||||
// floors as holes while overlapping independent rings add instead of
|
||||
// cancelling one another (the old even-odd fallback produced pinholes at
|
||||
// exactly the complex junctions for which a fallback is needed).
|
||||
return {
|
||||
d: [rings.map((r) => r.d).join(' '), extraD].filter(Boolean).join(' '),
|
||||
depthUnits: maxDepth,
|
||||
fillRule: 'nonzero',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function wallBodiesUnionPath(
|
||||
rooms: any[],
|
||||
walls: WallEntry[] | null | undefined,
|
||||
openCuts: number[][],
|
||||
openings: Array<{ x: number; y: number; angle: number; length: number }> = [],
|
||||
pitch: number,
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
coordScale = 1,
|
||||
/** Independent physical bodies are unioned only after room openings are cut,
|
||||
* so a door/window/gate can never punch a coincident partition or column. */
|
||||
extraBodies: number[][][] = [],
|
||||
): { d: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
|
||||
if (!walls?.length && !extraBodies.length) return null;
|
||||
const united = wallBodiesGeometry(
|
||||
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
|
||||
);
|
||||
const d = united ? polyclipToPathD(united.geom) : '';
|
||||
if (united && d) return { d, depthUnits: united.depthUnits, fillRule: 'evenodd' };
|
||||
// fall back to evenodd rings concatenated
|
||||
const rings = wallBodyRings(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||||
const extraD = extraBodies.map((poly) => polyToPath(poly)).join(' ');
|
||||
if (!rings.length && !extraD) return null;
|
||||
let maxDepth = united?.depthUnits || 0;
|
||||
for (const ring of rings) maxDepth = Math.max(maxDepth, ring.depthUnits);
|
||||
// Each room ring already reverses its inset. `nonzero` therefore keeps
|
||||
// floors as holes while overlapping independent rings add instead of
|
||||
// cancelling one another (the old even-odd fallback produced pinholes at
|
||||
// exactly the complex junctions for which a fallback is needed).
|
||||
return {
|
||||
d: [rings.map((r) => r.d).join(' '), extraD].filter(Boolean).join(' '),
|
||||
depthUnits: maxDepth,
|
||||
fillRule: 'nonzero',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-edge wall quads for styling hooks and thick-cut suppression — one body
|
||||
* per unique wall key. Shared and outer walls both grow ±½ from the
|
||||
|
||||
Reference in New Issue
Block a user