mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
169 lines
6.8 KiB
TypeScript
169 lines
6.8 KiB
TypeScript
/**
|
|
* 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 on an opaque edge is invalid and returns no
|
|
* lit region. Dropping that edge would make the wall disappear precisely at a
|
|
* grid-snapped placement and illuminate the room on its other side.
|
|
*/
|
|
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 < 1e-7) return [];
|
|
if (distance > radius) 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);
|
|
}
|
|
}
|
|
// atan2's seam is a geometric non-event. Normalising every ray onto one
|
|
// cyclic interval keeps the +nudge ray beside its corner instead of sorting
|
|
// it to the opposite end and closing the fan with a long chord.
|
|
const turn = Math.PI * 2;
|
|
for (let i = 0; i < angles.length; i++) angles[i] = ((angles[i] % turn) + turn) % turn;
|
|
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 : [];
|
|
}
|