fix(led): preserve strip corners and feather the complete light field

Keep real end and corner emitters, robust decimal joins and wall-circle
sweep events. Render one positive-winding compound visibility clip so
Chromium cannot cancel or cut away overlapping light regions.

Add independent pixel oracles for glow falloff and wall-following tubes.
Replace the lossy fan-count limit with explicit cached-path bounds while
retaining the original timing and warm-cycle heap-growth limits.

Issue: #788
User-Visible: yes
This commit is contained in:
Matysh
2026-10-03 18:46:35 +03:00
parent ad10a5b099
commit c142e4f7b5
19 changed files with 881 additions and 121 deletions
+78 -61
View File
@@ -6,8 +6,8 @@
* loads it (ТЗ §13.1).
*
* The field is the distance field of one continuous strip path with the shared
* falloff. Visibility remains sampled in bounded pieces, but their visible
* regions are unioned into one clip before the continuous path is painted.
* falloff. Visibility is sampled along the complete path and retained in
* bounded batches whose regions form one clip before the path is painted.
* That separation keeps walls opaque without exposing piece boundaries in the
* gradient at straight cuts or corners.
*/
@@ -54,9 +54,10 @@ export const LED_FIELD_BANDS = 48;
const pts = (points: readonly number[][]): Pt[] => points.map((p) => [p[0], p[1]] as Pt);
interface FieldPiece {
d: string;
/** Visibility fans of the piece's emitters, including full discs when nothing blocks them. */
clip: string[];
/** Positive-winding union retained as one compact path, not per-emitter objects. */
clip: string;
/** Actual constituent fans; compaction never disguises or drops sources. */
sourceCount: number;
}
interface FieldGeometry {
@@ -102,10 +103,25 @@ export class LedFieldCache {
return value;
}
clear(): void { this.entries.clear(); this.ids.clear(); this.space = ''; }
/** Retained visibility fans (the per-emitter source cache, ТЗ §13.2: ≤ 2500). */
/** Actual constituent fan count; compact path/character metrics measure retained representation. */
get sources(): number {
let n = 0;
for (const value of this.entries.values()) for (const piece of value?.pieces || []) n += piece.clip?.length ?? 0;
for (const value of this.entries.values()) for (const piece of value?.pieces || []) n += piece.sourceCount;
return n;
}
/** Retained compact visibility path batches, composed into one SVG clip child. */
get visibilityPaths(): number {
let n = 0;
for (const value of this.entries.values()) n += value?.pieces.length ?? 0;
return n;
}
/** SVG path characters retained by the cache (at most two bytes per UTF-16 code unit). */
get pathChars(): number {
let n = 0;
for (const value of this.entries.values()) {
n += value?.d.length ?? 0;
for (const piece of value?.pieces || []) n += piece.clip.length;
}
return n;
}
}
@@ -120,11 +136,29 @@ const pointsKey = (points: readonly number[][]): string =>
*/
const LED_ARC_STEPS = 12;
const segmentDistance = (p: Pt, s: readonly number[]): number => {
const dx = s[2] - s[0], dy = s[3] - s[1];
const len2 = dx * dx + dy * dy;
const t = len2 ? Math.max(0, Math.min(1, ((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / len2)) : 0;
return Math.hypot(p[0] - s[0] - t * dx, p[1] - s[1] - t * dy);
/**
* A long wall can cross the radius without having either endpoint in it.
* Make its exact circle intersections sweep events. Without them the last
* wall hit and first free-radius hit are connected by a chord that removes
* a bright, genuinely visible crescent (#788).
*/
const circleSegments = (p: Pt, radius: number, segments: LightBarrierScene['occluders']): number[][] => {
const out: number[][] = [];
for (const s of segments) {
if (!s || s.length < 4) continue;
const dx = s[2] - s[0], dy = s[3] - s[1], len2 = dx * dx + dy * dy;
if (!(len2 > 0)) continue;
const ox = s[0] - p[0], oy = s[1] - p[1];
const cross = ox * dy - oy * dx;
const distance2 = cross * cross / len2;
if (distance2 >= radius * radius) continue;
const center = -(ox * dx + oy * dy) / len2;
const span = Math.sqrt((radius * radius - distance2) / len2);
const lo = Math.max(0, center - span), hi = Math.min(1, center + span);
if (hi <= lo) continue;
out.push([s[0] + lo * dx, s[1] + lo * dy, s[0] + hi * dx, s[1] + hi * dy]);
}
return out;
};
/** SVG does not gain visible precision from JS's full decimal expansion. */
@@ -136,11 +170,14 @@ const coord = (value: number): string => {
const ringPath = (ring: readonly number[][]): string =>
`${ring.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' ')} Z`;
/** Exact disc in one path: two half-circle arcs avoid a polygonal free end. */
/**
* Exact disc in one path. Sweep=1 is also the winding of the angle-sorted
* visibility fans; overlapping subpaths must add, never cancel (#788).
*/
const discPath = (center: Pt, radius: number): string => {
const left = coord(center[0] - radius), right = coord(center[0] + radius);
const cy = coord(center[1]), r = coord(radius);
return `M${left} ${cy} A${r} ${r} 0 1 0 ${right} ${cy} A${r} ${r} 0 1 0 ${left} ${cy} Z`;
return `M${left} ${cy} A${r} ${r} 0 1 1 ${right} ${cy} A${r} ${r} 0 1 1 ${left} ${cy} Z`;
};
/**
@@ -173,12 +210,10 @@ const visibilityPath = (center: Pt, radius: number, ring: readonly number[][]):
* (`fieldFloor`).
*/
function fans(emitters: readonly Pt[], radius: number, scene: LightBarrierScene): string[] {
const reach = radius * 1.01;
return emitters.flatMap((p) => {
const blocked = scene.occluders.some((seg) => seg?.length >= 4
&& segmentDistance(p, seg) < reach);
if (!blocked) return [discPath(p, radius)];
const fan = visibilityPolygon([p[0], p[1]], radius, scene.occluders, LED_ARC_STEPS);
const near = circleSegments(p, radius, scene.occluders);
if (!near.length) return [discPath(p, radius)];
const fan = visibilityPolygon([p[0], p[1]], radius, near, LED_ARC_STEPS);
const path = visibilityPath(p, radius, fan);
return path ? [path] : [];
});
@@ -196,13 +231,13 @@ function fieldFloor(scene: LightBarrierScene): string[] {
}
/**
* Pieces of a strip for the field (ТЗ §6, §13.2): the stored polyline is cut
* into consecutive pieces no longer than the radius — short segments of a
* dense strip share one piece, a long one is split — and every piece is
* clipped to the union of what its own emitters see. Emitters keep every
* vertex and the radius/4 spacing of `emitterSamples`, thinned to radius/4 on
* dense strips; a piece whose emitters are all inside a body emits nothing; a
* failed clip makes that piece dark, never an unclipped field.
* Classify and sample the complete strip before grouping visibility work.
* Reclassifying radius-sized runs loses the two flanking wall faces of an
* opening. Sampling those artificial cuts also depends on path direction,
* and post-thinning drops real endpoints and acute corners (#788).
* Every true vertex/end and the radius/4 samples therefore survive. Groups
* are only bounded cache/DOM batches; all their fans form one actual union.
* A buried emitter or a failed fan stays dark, never an unclipped field.
*/
export function buildFieldGeometry(input: {
points: readonly number[][];
@@ -218,41 +253,17 @@ export function buildFieldGeometry(input: {
const closed = isClosedStrip(path);
const visiblePath = closed ? path.slice(0, -1) : path;
const d = `${visiblePath.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' ')}${closed ? ' Z' : ''}`;
// Consecutive pieces of at most r along the polyline.
const runs: Pt[][] = [];
let run: Pt[] = [path[0]];
let left = r;
for (let i = 1; i < path.length; i++) {
let a = path[i - 1];
const b = path[i];
let len = Math.hypot(b[0] - a[0], b[1] - a[1]);
while (len > left + 1e-12) {
const t = left / len;
const cut: Pt = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
run.push(cut);
runs.push(run);
run = [cut];
a = cut;
len -= left;
left = r;
}
run.push(b);
left -= len;
}
if (run.length > 1) runs.push(run);
const emitters = emitterSamples(path, input.faces, r / 4);
const pieces: FieldPiece[] = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const piece of runs) {
const emitters: Pt[] = [];
for (const p of emitterSamples(piece, input.faces, r / 4)) {
const last = emitters[emitters.length - 1];
if (!last || Math.hypot(p[0] - last[0], p[1] - last[1]) >= r / 4) emitters.push(p);
}
if (!emitters.length) continue;
// Keep at most five actual emitters in each retained visibility path. The
// batches manufacture no source positions and have no optical significance.
for (let i = 0; i < emitters.length; i += 5) {
const piece = emitters.slice(i, i + 5);
let clip: string[];
try { clip = fans(emitters, r, input.scene); } catch { continue; } // fail-dark for this piece
try { clip = fans(piece, r, input.scene); } catch { continue; } // fail-dark for this batch
if (!clip.length) continue;
pieces.push({ d: piece.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' '), clip });
pieces.push({ clip: clip.join(' '), sourceCount: clip.length });
for (const p of piece) {
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]);
@@ -343,9 +354,13 @@ export function hasLedField(owner: object): boolean {
return !!fieldLifecycles.get(owner)?.state.renderedSources.size;
}
/** The performance witness: what this owner retains right now. */
export function ledFieldStats(owner: object): { visibility: number; sources: number; recomputes: number } {
export function ledFieldStats(owner: object): {
visibility: number; sources: number; visibilityPaths: number; pathChars: number; recomputes: number;
} {
const cache = fieldCaches.get(owner);
return { visibility: cache?.size ?? 0, sources: cache?.sources ?? 0, recomputes: cache?.recomputes ?? 0 };
return { visibility: cache?.size ?? 0, sources: cache?.sources ?? 0,
visibilityPaths: cache?.visibilityPaths ?? 0, pathChars: cache?.pathChars ?? 0,
recomputes: cache?.recomputes ?? 0 };
}
export function ledFieldCache(owner: object): LedFieldCache {
let cache = fieldCaches.get(owner);
@@ -360,7 +375,9 @@ export function ledFieldCache(owner: object): LedFieldCache {
* The linear fields of the strips that are on in a Glow room. Fade uses the
* shared spot transition (`.glow-spot`, GLOW_FADE_MS) — no animation system of
* its own; an off strip keeps its node at opacity 0, so a fade-out completes
* and leaves no residual light.
* and leaves no residual light. All positive-winding batches enter ONE clip
* child: Chromium's union of several compound clip children can cut a bright
* crescent even when each individual fan's mathematical membership is correct.
*/
export function renderLedField(input: LedFieldInput): TemplateResult {
if (!input.scene) return svg`` as unknown as TemplateResult;
@@ -414,7 +431,7 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
data-closed="${closed ? 'true' : 'false'}">
<defs>
<clipPath id="hp-led-visible-${id}">
${geometry.pieces.map((piece) => svg`<path d="${piece.clip.join(' ')}"></path>`)}
<path d="${geometry.pieces.map((piece) => piece.clip).join(' ')}" clip-rule="nonzero"></path>
</clipPath>
<mask id="hp-led-mask-${id}" maskUnits="userSpaceOnUse"
x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
+53 -11
View File
@@ -130,7 +130,13 @@ export interface StripPiece {
}
const sub = (a: Pt, b: Pt): [number, number] => [a[0] - b[0], a[1] - b[1]];
const lerp = (a: Pt, b: Pt, t: number): [number, number] => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
/** Arithmetic uncertainty in these coordinates, never a physical snap tolerance. */
const roundoff = (a: Pt, b: Pt): number => Math.max(1,
Math.abs(a[0]), Math.abs(a[1]), Math.abs(b[0]), Math.abs(b[1])) * Number.EPSILON * 16;
// Preserve shared vertices exactly: a + (b - a) need not equal b in floating
// point, which used to break the two left-hand miters of decimal rectangles.
const lerp = (a: Pt, b: Pt, t: number): [number, number] => t === 0 ? [a[0], a[1]]
: t === 1 ? [b[0], b[1]] : [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
function pointSegmentDistance(p: Pt, a: Pt, b: Pt): number {
const [dx, dy] = sub(b, a);
@@ -160,7 +166,16 @@ function overlapOnFace(a: Pt, b: Pt, face: BodyFace, eps: number): [number, numb
const lo = Math.max(Math.min(pa, pb), 0), hi = Math.min(Math.max(pa, pb), fLen);
if (hi - lo <= eps) return null;
const toT = (s: number) => (pb === pa ? 0 : (s - pa) / (pb - pa));
const t0 = Math.max(0, Math.min(1, toT(lo))), t1 = Math.max(0, Math.min(1, toT(hi)));
// Projection and length use different floating-point operations. A face
// ending exactly at b can otherwise stop at t=.9999999999999998, inventing
// a microscopic free tail and a full t/2 connector. Canonicalise only the
// coordinate-scale arithmetic uncertainty; real face gaps stay untouched.
const tolerance = Math.max(roundoff(a, b), roundoff(face.a, face.b)) / len;
const endpoint = (value: number): number => {
const t = Math.max(0, Math.min(1, value));
return t <= tolerance ? 0 : 1 - t <= tolerance ? 1 : t;
};
const t0 = endpoint(toT(lo)), t1 = endpoint(toT(hi));
return t0 < t1 ? [t0, t1] : [t1, t0];
}
@@ -236,11 +251,19 @@ export function stripPieces(points: readonly Pt[], ctx: FaceContext | null): Str
function shiftedLineIntersection(
prev: StripPiece, next: StripPiece, offset: number,
): [number, number] | null {
if (!prev.free || !next.free || !same(prev.b, next.a)) return null;
const pa: [number, number] = [prev.a[0] + prev.free[0] * offset, prev.a[1] + prev.free[1] * offset];
const pb: [number, number] = [prev.b[0] + prev.free[0] * offset, prev.b[1] + prev.free[1] * offset];
const qa: [number, number] = [next.a[0] + next.free[0] * offset, next.a[1] + next.free[1] * offset];
const qb: [number, number] = [next.b[0] + next.free[0] * offset, next.b[1] + next.free[1] * offset];
if (!prev.free && !next.free) return null;
// Adjacent derived endpoints may have arithmetic tails; this is numerical
// equality only, never the editor's magnet or the wall-face tolerance.
const tolerance = roundoff(prev.b, next.a);
if (dist(prev.b, next.a) > tolerance) return null;
// A free stretch keeps its stored line. Intersect that line with a shifted
// wall stretch too: an explicit connector would double back at the corner
// and leave a round protruding stub, even on an almost rectangular loop.
const pn = prev.free ?? [0, 0], qn = next.free ?? [0, 0];
const pa: [number, number] = [prev.a[0] + pn[0] * offset, prev.a[1] + pn[1] * offset];
const pb: [number, number] = [prev.b[0] + pn[0] * offset, prev.b[1] + pn[1] * offset];
const qa: [number, number] = [next.a[0] + qn[0] * offset, next.a[1] + qn[1] * offset];
const qb: [number, number] = [next.b[0] + qn[0] * offset, next.b[1] + qn[1] * offset];
const r = sub(pb, pa), s = sub(qb, qa);
const den = r[0] * s[1] - r[1] * s[0];
if (Math.abs(den) <= 1e-12) return null;
@@ -282,16 +305,35 @@ function simplifyVisiblePoints(points: Array<[number, number]>, closed: boolean)
/**
* The derived visible path (ТЗ §3): a face piece shifted `offset` along its
* free normal, a free piece unshifted. Shifted sides meeting at a real corner
* meet at their bounded line intersection; wall/free transitions and unsafe
* acute angles retain the short connector — no gap and no long miter spike.
* meet at their bounded line intersection, including a wall/free transition
* with one unshifted line. Parallel transitions and unsafe acute angles keep
* the short connector — no gap and no long miter spike.
* Closed strips close through the same rule. Shared by both strokes, the hit
* path, focus and 2.5D: one derivation, never a stored position.
*/
export function visibleStripPath(
points: readonly Pt[], ctx: FaceContext | null, offset: number,
): { points: Array<[number, number]>; closed: boolean } {
const closed = isClosedStrip(compactPoints(points));
const pieces = stripPieces(points, ctx);
const source = compactPoints(points);
const closed = isClosedStrip(source);
// Offset complete straight stretches, not arbitrary stored subdivisions.
// A miter can lie beyond a very short collinear step: retaining that step
// afterwards would make the stripe double back before continuing forward.
// The original vertices still belong to the saved shape and its emitters.
const vertices = simplifyVisiblePoints((closed ? source.slice(0, -1) : source)
.map((p) => [p[0], p[1]] as [number, number]), closed);
const pieces: StripPiece[] = [];
for (const piece of stripPieces(closed && vertices.length ? [...vertices, vertices[0]] : vertices, ctx)) {
const previous = pieces[pieces.length - 1];
const sameSide = previous && (previous.free && piece.free
? dist(previous.free, piece.free) <= 1e-9 : previous.free === piece.free);
// An opening may also subdivide a straight stretch closer to its corner
// than t/2. Its inherited normal makes it one visible line, even though
// the physical face/gap pieces stay separate in the emitter derivation.
if (previous && sameSide && same(previous.b, piece.a)
&& redundantCollinear(previous.a, previous.b, piece.b)) previous.b = piece.b;
else pieces.push(piece);
}
const shifted = pieces.map((piece) => {
const shift = piece.free ? [piece.free[0] * offset, piece.free[1] * offset] : [0, 0];
return {
+4 -2
View File
@@ -448,9 +448,11 @@ export function releaseLed(owner: object): void {
}
/** The performance witness: shapes (frame), visibility entries and retained fans of this card. */
export function ledStats(owner: object): { shapes: number; visibility: number; sources: number; recomputes: number } {
export function ledStats(owner: object): {
shapes: number; visibility: number; sources: number; visibilityPaths: number; pathChars: number; recomputes: number;
} {
return { shapes: frames.get(owner)?.frame.views.length ?? 0,
...(field?.ledFieldStats(owner) ?? { visibility: 0, sources: 0, recomputes: 0 }) };
...(field?.ledFieldStats(owner) ?? { visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, recomputes: 0 }) };
}
/** The LED frame of a space for this card, rebuilt only when an input changed. */