Release v1.61.0-beta.6 candidate

This commit is contained in:
Matysh
2026-08-11 01:14:59 +03:00
parent b0c29fb57f
commit d2bc908280
59 changed files with 4417 additions and 1629 deletions
+395 -120
View File
@@ -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>`;
}
+7
View File
@@ -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;
}
+162
View File
@@ -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 : [];
}
+23
View File
@@ -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.
+75 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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