mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +00:00
feat(card): pure LED strip geometry and the space model field (#780)
Stage 2 of #780. src/led-strip-geometry.ts (pure, not imported by the initial graph): the anchor at half the polyline length; stripPieces and visibleStripPath — a segment lying on a thick body face within epsilonGeom is shifted t/2 into free floor, free floor and zero-wall axes stay at 0, a face→floor transition is a connector without gap; emitter samples epsilon outward on a face and none inside a body; placement that stops at the first face and lets a strip touch and slide along it, a vertex drag clamped on its path and both neighbours; the screen hit owner with radius max(22 px, t/2) and a stable-id tie. SpaceModel gains optional led_strips (render units, data only, no geometry import in space-geometry.ts). Tests: test/led-strip-geometry.test.mjs (10). Issue: #780 User-Visible: no
This commit is contained in:
Executable
+396
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Pure geometry of an LED strip (#780): anchor, derived visible path,
|
||||
* placement against physical bodies, emitter samples and hit-testing.
|
||||
*
|
||||
* The stored points are the single source. Everything here is a derivation
|
||||
* that never writes back: the anchor (half the polyline length), the visible
|
||||
* path (offset `t/2` from a thick wall face into free floor, ТЗ §3), the light
|
||||
* emitters (on a face, `epsilonGeom` outward, ТЗ §6) and the screen hit path.
|
||||
*
|
||||
* Units are plan units unless a name says otherwise. The module knows neither
|
||||
* the card nor the editor, so the unit suite exercises every rule directly.
|
||||
*/
|
||||
|
||||
export type Pt = readonly [number, number];
|
||||
|
||||
export interface LedStripCfg {
|
||||
id: string;
|
||||
points: Array<[number, number]>;
|
||||
marker: string | null;
|
||||
/** Absent/true — the strip is the marker's representation; false — hidden shape. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const LED_MAX_STRIPS = 50;
|
||||
export const LED_MAX_POINTS = 50;
|
||||
/** Geometric tolerance for "lies on a face": 0.001 cm, never a screen magnet. */
|
||||
export const LED_EPSILON_CM = 0.001;
|
||||
/** Default linear field radius (ТЗ §3): 50 cm, independent of the shared one. */
|
||||
export const LED_DEFAULT_RADIUS_CM = 50;
|
||||
/** Total stripe thickness in base device diameters (ТЗ §3). */
|
||||
export const LED_THICKNESS_OFF_D = 0.08;
|
||||
export const LED_THICKNESS_ON_D = 0.12;
|
||||
/** Minimum touch radius across the visible stripe (ТЗ §7). */
|
||||
export const LED_HIT_MIN_CSS_PX = 22;
|
||||
|
||||
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
export function isPoint(value: unknown): value is [number, number] {
|
||||
return Array.isArray(value) && value.length === 2 && finite(value[0]) && finite(value[1]);
|
||||
}
|
||||
|
||||
const same = (a: Pt, b: Pt): boolean => a[0] === b[0] && a[1] === b[1];
|
||||
const dist = (a: Pt, b: Pt): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
|
||||
/** True when the repeated first point closes the strip (≥3 distinct vertices). */
|
||||
export function isClosedStrip(points: readonly Pt[]): boolean {
|
||||
return points.length >= 4 && same(points[0], points[points.length - 1]);
|
||||
}
|
||||
|
||||
/** Drop zero-length steps; keeps the closing point of a closed strip. */
|
||||
export function compactPoints(points: readonly Pt[]): Pt[] {
|
||||
const out: Pt[] = [];
|
||||
for (const point of points) {
|
||||
if (!isPoint(point)) continue;
|
||||
if (out.length && same(out[out.length - 1], point)) continue;
|
||||
out.push([point[0], point[1]]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function polylineLength(points: readonly Pt[]): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) total += dist(points[i - 1], points[i]);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchor of a strip: the point at half its total length — not the mean of
|
||||
* the vertices, not the bounding-box centre (ТЗ §5). `[[0,0],[10,0],[10,10]]`
|
||||
* gives `[10,0]`; `[[0,0],[4,0]]` gives `[2,0]`.
|
||||
*/
|
||||
export function stripAnchor(points: readonly Pt[]): [number, number] | null {
|
||||
const path = compactPoints(points);
|
||||
if (path.length < 2) return path.length ? [path[0][0], path[0][1]] : null;
|
||||
const half = polylineLength(path) / 2;
|
||||
let walked = 0;
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const step = dist(path[i - 1], path[i]);
|
||||
if (walked + step >= half) {
|
||||
const t = step > 0 ? (half - walked) / step : 0;
|
||||
return [
|
||||
path[i - 1][0] + (path[i][0] - path[i - 1][0]) * t,
|
||||
path[i - 1][1] + (path[i][1] - path[i - 1][1]) * t,
|
||||
];
|
||||
}
|
||||
walked += step;
|
||||
}
|
||||
const last = path[path.length - 1];
|
||||
return [last[0], last[1]];
|
||||
}
|
||||
|
||||
/** Is the stored shape valid for a write (mirror of led_strips.py)? */
|
||||
export function validStripPoints(points: unknown): points is Array<[number, number]> {
|
||||
if (!Array.isArray(points) || points.length < 2 || points.length > LED_MAX_POINTS) return false;
|
||||
if (!points.every(isPoint)) return false;
|
||||
const distinct = new Set(points.map((p) => `${p[0]},${p[1]}`));
|
||||
if (distinct.size < 2 || !(polylineLength(points) > 0)) return false;
|
||||
if (points.length > 2 && same(points[0], points[points.length - 1])) {
|
||||
const open = new Set(points.slice(0, -1).map((p) => `${p[0]},${p[1]}`));
|
||||
if (open.size < 3) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Physical faces: where a strip lies on a thick body, its visible stripe and
|
||||
// its light move to the free side.
|
||||
|
||||
/** A straight edge of an opaque physical body (masonry, partition, column). */
|
||||
export interface BodyFace {
|
||||
a: Pt;
|
||||
b: Pt;
|
||||
}
|
||||
|
||||
export interface FaceContext {
|
||||
faces: readonly BodyFace[];
|
||||
/** Is a point strictly inside an opaque body? */
|
||||
inside: (point: Pt) => boolean;
|
||||
/** `LED_EPSILON_CM` in plan units. */
|
||||
epsilon: number;
|
||||
}
|
||||
|
||||
/** One piece of a stored segment: on a face (side = unit normal into free floor) or free. */
|
||||
export interface StripPiece {
|
||||
a: [number, number];
|
||||
b: [number, number];
|
||||
/** Unit normal into the free floor for a face piece; null on free floor or a zero wall. */
|
||||
free: [number, number] | null;
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
function pointSegmentDistance(p: Pt, a: Pt, b: Pt): number {
|
||||
const [dx, dy] = sub(b, a);
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 === 0) return dist(p, a);
|
||||
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2));
|
||||
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter interval of AB that lies on face F (both within `eps` of F's
|
||||
* line and inside F's extent), or null. Collinearity is judged by distance,
|
||||
* not by the screen magnet (ТЗ §3, §13.4).
|
||||
*/
|
||||
function overlapOnFace(a: Pt, b: Pt, face: BodyFace, eps: number): [number, number] | null {
|
||||
const len = dist(a, b);
|
||||
if (len === 0) return null;
|
||||
const fLen = dist(face.a, face.b);
|
||||
if (fLen === 0) return null;
|
||||
// AB must be parallel to the face within eps over its length.
|
||||
const ux = (face.b[0] - face.a[0]) / fLen, uy = (face.b[1] - face.a[1]) / fLen;
|
||||
const offA = Math.abs((a[0] - face.a[0]) * -uy + (a[1] - face.a[1]) * ux);
|
||||
const offB = Math.abs((b[0] - face.a[0]) * -uy + (b[1] - face.a[1]) * ux);
|
||||
if (offA > eps || offB > eps) return null;
|
||||
const proj = (p: Pt) => (p[0] - face.a[0]) * ux + (p[1] - face.a[1]) * uy;
|
||||
const pa = proj(a), pb = proj(b);
|
||||
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)));
|
||||
return t0 < t1 ? [t0, t1] : [t1, t0];
|
||||
}
|
||||
|
||||
/** Unit normal of AB pointing to the free side, or null when neither/both sides are free. */
|
||||
function freeNormal(a: Pt, b: Pt, at: Pt, ctx: FaceContext): [number, number] | null {
|
||||
const len = dist(a, b);
|
||||
const nx = -(b[1] - a[1]) / len, ny = (b[0] - a[0]) / len;
|
||||
const probe = Math.max(ctx.epsilon * 50, 1e-6);
|
||||
const plusInside = ctx.inside([at[0] + nx * probe, at[1] + ny * probe]);
|
||||
const minusInside = ctx.inside([at[0] - nx * probe, at[1] - ny * probe]);
|
||||
if (plusInside === minusInside) return null;
|
||||
// `+ 0` turns -0 into 0: a normal is data, not a sign artefact.
|
||||
return plusInside ? [-nx + 0, -ny + 0] : [nx + 0, ny + 0];
|
||||
}
|
||||
|
||||
/** Split every stored segment into face pieces and free pieces (ТЗ §3). */
|
||||
export function stripPieces(points: readonly Pt[], ctx: FaceContext | null): StripPiece[] {
|
||||
const path = compactPoints(points);
|
||||
const pieces: StripPiece[] = [];
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const a = path[i - 1], b = path[i];
|
||||
const intervals: Array<[number, number]> = [];
|
||||
if (ctx) {
|
||||
for (const face of ctx.faces) {
|
||||
const hit = overlapOnFace(a, b, face, ctx.epsilon);
|
||||
if (hit) intervals.push(hit);
|
||||
}
|
||||
}
|
||||
intervals.sort((x, y) => x[0] - y[0]);
|
||||
const merged: Array<[number, number]> = [];
|
||||
for (const interval of intervals) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && interval[0] <= last[1] + 1e-12) last[1] = Math.max(last[1], interval[1]);
|
||||
else merged.push([interval[0], interval[1]]);
|
||||
}
|
||||
let cursor = 0;
|
||||
for (const [t0, t1] of merged) {
|
||||
if (t0 > cursor) pieces.push({ a: lerp(a, b, cursor), b: lerp(a, b, t0), free: null });
|
||||
const pa = lerp(a, b, t0), pb = lerp(a, b, t1);
|
||||
const mid = lerp(a, b, (t0 + t1) / 2);
|
||||
pieces.push({ a: pa, b: pb, free: ctx ? freeNormal(a, b, mid, ctx) : null });
|
||||
cursor = t1;
|
||||
}
|
||||
if (cursor < 1) pieces.push({ a: lerp(a, b, cursor), b: [b[0], b[1]], free: null });
|
||||
}
|
||||
return pieces.filter((piece) => dist(piece.a, piece.b) > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The derived visible path (ТЗ §3): a face piece shifted `offset` along its
|
||||
* free normal, a free piece unshifted, consecutive pieces joined by a short
|
||||
* connector (drawn with round joins) — no gap, no square patch, no long miter.
|
||||
* 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 out: Array<[number, number]> = [];
|
||||
const push = (p: [number, number]) => {
|
||||
const last = out[out.length - 1];
|
||||
if (!last || Math.hypot(last[0] - p[0], last[1] - p[1]) > 1e-12) out.push(p);
|
||||
};
|
||||
for (const piece of pieces) {
|
||||
const shift = piece.free ? [piece.free[0] * offset, piece.free[1] * offset] : [0, 0];
|
||||
push([piece.a[0] + shift[0], piece.a[1] + shift[1]]);
|
||||
push([piece.b[0] + shift[0], piece.b[1] + shift[1]]);
|
||||
}
|
||||
if (closed && out.length > 2) {
|
||||
const first = out[0], last = out[out.length - 1];
|
||||
if (Math.hypot(first[0] - last[0], first[1] - last[1]) <= 1e-12) out.pop();
|
||||
}
|
||||
return { points: out, closed };
|
||||
}
|
||||
|
||||
export function pathD(path: { points: ReadonlyArray<Pt>; closed: boolean }): string {
|
||||
if (!path.points.length) return '';
|
||||
const body = path.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]} ${p[1]}`).join(' ');
|
||||
return path.closed ? `${body} Z` : body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Light emitters along the strip (ТЗ §6): every vertex and evenly spaced
|
||||
* points no farther apart than `spacing`. On a face piece the emitter sits
|
||||
* `epsilon` outward into free floor — never inside the masonry and never the
|
||||
* visual `t/2`. Emitters strictly inside an opaque body are dropped: a buried
|
||||
* part does not glow.
|
||||
*/
|
||||
export function emitterSamples(
|
||||
points: readonly Pt[], ctx: FaceContext | null, spacing: number,
|
||||
): Array<[number, number]> {
|
||||
const out: Array<[number, number]> = [];
|
||||
const step = spacing > 0 ? spacing : Infinity;
|
||||
for (const piece of stripPieces(points, ctx)) {
|
||||
const len = dist(piece.a, piece.b);
|
||||
const n = Math.max(1, Math.ceil(len / step));
|
||||
const shift = piece.free && ctx ? [piece.free[0] * ctx.epsilon, piece.free[1] * ctx.epsilon] : [0, 0];
|
||||
for (let k = 0; k <= n; k++) {
|
||||
const p = lerp(piece.a, piece.b, k / n);
|
||||
const s: [number, number] = [p[0] + shift[0], p[1] + shift[1]];
|
||||
if (ctx && ctx.inside(s)) continue;
|
||||
const last = out[out.length - 1];
|
||||
if (!last || Math.hypot(last[0] - s[0], last[1] - s[1]) > 1e-12) out.push(s);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Placement: a strip may touch and follow a face but never pass through a body.
|
||||
|
||||
export interface PlacementBodies {
|
||||
/** Opaque bodies for placement: masonry with doors/gates/passages cut, windows NOT cut. */
|
||||
rings: ReadonlyArray<ReadonlyArray<Pt>>;
|
||||
inside: (point: Pt) => boolean;
|
||||
}
|
||||
|
||||
function segmentParams(a: Pt, b: Pt, c: Pt, d: Pt): number | null {
|
||||
const r = sub(b, a), s = sub(d, c);
|
||||
const den = r[0] * s[1] - r[1] * s[0];
|
||||
if (Math.abs(den) < 1e-15) return null;
|
||||
const q = sub(c, a);
|
||||
const t = (q[0] * s[1] - q[1] * s[0]) / den;
|
||||
const u = (q[0] * r[1] - q[1] * r[0]) / den;
|
||||
if (t < -1e-12 || t > 1 + 1e-12 || u < -1e-12 || u > 1 + 1e-12) return null;
|
||||
return Math.max(0, Math.min(1, t));
|
||||
}
|
||||
|
||||
/**
|
||||
* The farthest safe point on `from → to` (ТЗ §6): the walk stops at the first
|
||||
* face it would cross into a body's interior. Touching or sliding along a face
|
||||
* is allowed. Returns `{ point, stopped }`; `null` when `from` is inside a body.
|
||||
*/
|
||||
export function clampToBodies(
|
||||
from: Pt, to: Pt, bodies: PlacementBodies,
|
||||
): { point: [number, number]; stopped: boolean } | null {
|
||||
if (bodies.inside(from)) return null;
|
||||
const params = [0, 1];
|
||||
for (const ring of bodies.rings) {
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const c = ring[i], d = ring[(i + 1) % ring.length];
|
||||
const t = segmentParams(from, to, c, d);
|
||||
if (t != null) params.push(t);
|
||||
}
|
||||
}
|
||||
const sorted = [...new Set(params.map((t) => Math.round(t * 1e12) / 1e12))].sort((x, y) => x - y);
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const mid = lerp(from, to, (sorted[i - 1] + sorted[i]) / 2);
|
||||
if (bodies.inside(mid)) {
|
||||
return { point: lerp(from, to, sorted[i - 1]), stopped: true };
|
||||
}
|
||||
}
|
||||
return { point: [to[0], to[1]], stopped: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* A moved vertex: both neighbouring segments and the drag path itself must
|
||||
* stay clear (ТЗ §6). Returns the last safe position along `previous → wanted`.
|
||||
*/
|
||||
export function clampVertexMove(
|
||||
points: readonly Pt[], index: number, wanted: Pt, bodies: PlacementBodies,
|
||||
): [number, number] {
|
||||
const previous = points[index];
|
||||
if (!previous) return [wanted[0], wanted[1]];
|
||||
const closed = isClosedStrip(points);
|
||||
const last = points.length - 1;
|
||||
const isEnd = closed && (index === 0 || index === last);
|
||||
const prev: Pt | null = isEnd ? points[last - 1] : index > 0 ? points[index - 1] : null;
|
||||
const next: Pt | null = isEnd ? points[1] : index < last ? points[index + 1] : null;
|
||||
const clear = (p: Pt) => !bodies.inside(p)
|
||||
&& (!prev || !clampToBodies(prev, p, bodies)?.stopped)
|
||||
&& (!next || !clampToBodies(next, p, bodies)?.stopped);
|
||||
const path = clampToBodies(previous, wanted, bodies);
|
||||
let candidate: [number, number] = path ? path.point : [previous[0], previous[1]];
|
||||
if (clear(candidate)) return candidate;
|
||||
// Binary search back towards the last known-clear position.
|
||||
let lo = 0, hi = 1;
|
||||
for (let k = 0; k < 40; k++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (clear(lerp(previous, candidate, mid))) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
candidate = lerp(previous, candidate, lo);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hit-testing in screen space (ТЗ §7).
|
||||
|
||||
export interface ScreenStrip {
|
||||
id: string;
|
||||
/** The derived visible path already projected to screen pixels. */
|
||||
points: ReadonlyArray<Pt>;
|
||||
closed: boolean;
|
||||
/** Visible stripe thickness on screen, CSS px. */
|
||||
thicknessPx: number;
|
||||
}
|
||||
|
||||
export function stripHitRadiusPx(thicknessPx: number): number {
|
||||
return Math.max(LED_HIT_MIN_CSS_PX, thicknessPx / 2);
|
||||
}
|
||||
|
||||
export function distanceToScreenStrip(p: Pt, strip: ScreenStrip): number {
|
||||
const pts = strip.points;
|
||||
if (!pts.length) return Infinity;
|
||||
if (pts.length === 1) return dist(p, pts[0]);
|
||||
let best = Infinity;
|
||||
const count = strip.closed ? pts.length : pts.length - 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
best = Math.min(best, pointSegmentDistance(p, pts[i], pts[(i + 1) % pts.length]));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The strip that owns a pointer (ТЗ §7): nearest visible stripe within its own
|
||||
* hit radius; ties go to the stable id. Icons win over strips — the caller
|
||||
* asks the icon owner first and only falls back to this.
|
||||
*/
|
||||
export function stripHitOwner(p: Pt, strips: readonly ScreenStrip[]): string | null {
|
||||
let owner: string | null = null;
|
||||
let best = Infinity;
|
||||
for (const strip of strips) {
|
||||
const d = distanceToScreenStrip(p, strip);
|
||||
if (d > stripHitRadiusPx(strip.thicknessPx)) continue;
|
||||
if (d < best || (d === best && owner != null && strip.id < owner)) {
|
||||
best = d;
|
||||
owner = strip.id;
|
||||
}
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
@@ -187,6 +187,17 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
...(c.shape === 'circle' ? {} : { angle: canonicalColumnAngle(c.angle) }),
|
||||
})),
|
||||
stairs: stairList(s.stairs),
|
||||
// #780: data only — no LED geometry module in the initial graph.
|
||||
led_strips: (Array.isArray(s.led_strips) ? s.led_strips : [])
|
||||
.filter((strip: any) => strip && typeof strip.id === 'string' && Array.isArray(strip.points))
|
||||
.map((strip: any) => ({
|
||||
id: strip.id,
|
||||
points: strip.points
|
||||
.filter((p: any) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]))
|
||||
.map((p: number[]) => [p[0] * NORM_W, p[1] * H]),
|
||||
marker: typeof strip.marker === 'string' && strip.marker ? strip.marker : null,
|
||||
active: strip.active !== false,
|
||||
})),
|
||||
} as SpaceModel;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,6 +86,20 @@ export interface SpaceModel {
|
||||
wall_columns: WallColumnCfg[];
|
||||
/** Independent continuous plan objects linking this floor to another one. */
|
||||
stairs: import('./stairs').Stair[];
|
||||
/**
|
||||
* #780: LED strip shapes in render units. Geometry, light and hit-testing
|
||||
* live in the lazy `led-strip-*` modules; the model only carries the data.
|
||||
*/
|
||||
led_strips?: LedStripModel[];
|
||||
}
|
||||
|
||||
/** #780: one stored LED strip shape (render units), see led_strips.py. */
|
||||
export interface LedStripModel {
|
||||
id: string;
|
||||
points: number[][];
|
||||
marker: string | null;
|
||||
/** false — a hidden shape: the marker is shown as an ordinary icon. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface PdfRef {
|
||||
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
// #780: pure LED strip geometry — anchor, derived visible path, emitters,
|
||||
// placement against bodies and the hit owner (ТЗ §3, §5, §6, §7; AC2, AC5,
|
||||
// AC8, AC12 unit part). Results are judged, not the source text.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
LED_HIT_MIN_CSS_PX, clampToBodies, clampVertexMove, compactPoints,
|
||||
emitterSamples, isClosedStrip, polylineLength, stripAnchor, stripHitOwner,
|
||||
stripHitRadiusPx, stripPieces, validStripPoints, visibleStripPath,
|
||||
} from '../test-build/led-strip-geometry.js';
|
||||
|
||||
const close = (actual, expected, eps = 1e-9, msg = '') => {
|
||||
assert.ok(Math.abs(actual - expected) <= eps, `${msg} ${actual} ≉ ${expected}`);
|
||||
};
|
||||
const closePt = (actual, expected, eps = 1e-9) => {
|
||||
close(actual[0], expected[0], eps, 'x');
|
||||
close(actual[1], expected[1], eps, 'y');
|
||||
};
|
||||
|
||||
// A thick wall: the rectangle y ∈ [0, 1] across x ∈ [0, 10]. Its top face is
|
||||
// y = 1 (free floor above), its bottom face y = 0 (free floor below).
|
||||
const wall = [[0, 0], [10, 0], [10, 1], [0, 1]];
|
||||
const insideRect = (r) => (p) => p[0] > r[0][0] && p[0] < r[1][0] && p[1] > r[0][1] && p[1] < r[2][1];
|
||||
const ringFaces = (ring) => ring.map((a, i) => ({ a, b: ring[(i + 1) % ring.length] }));
|
||||
const ctx = { faces: ringFaces(wall), inside: insideRect(wall), epsilon: 1e-5 };
|
||||
|
||||
test('AC2: the anchor is the point at half the polyline length', () => {
|
||||
assert.deepEqual(stripAnchor([[0, 0], [10, 0], [10, 10]]), [10, 0]);
|
||||
assert.deepEqual(stripAnchor([[0, 0], [4, 0]]), [2, 0]);
|
||||
// Unequal segments: not the vertex mean (4.67, 0.33), not the bbox centre (5, 0.5).
|
||||
closePt(stripAnchor([[0, 0], [9, 0], [9, 1]]), [5, 0]);
|
||||
// A closed strip: half of the perimeter, measured from the first vertex.
|
||||
closePt(stripAnchor([[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]), [4, 4]);
|
||||
// Repeated points do not shift the anchor.
|
||||
closePt(stripAnchor([[0, 0], [0, 0], [4, 0], [4, 0]]), [2, 0]);
|
||||
});
|
||||
|
||||
test('AC1 mirror: shape validity matches the backend rules', () => {
|
||||
assert.equal(validStripPoints([[0, 0], [1, 0]]), true);
|
||||
assert.equal(validStripPoints([[0, 0]]), false);
|
||||
assert.equal(validStripPoints([[0, 0], [0, 0]]), false);
|
||||
assert.equal(validStripPoints([[0, 0], [1, 0], [0, 0]]), false, 'closed with two distinct vertices');
|
||||
assert.equal(validStripPoints([[0, 0], [1, 0], [1, 1], [0, 0]]), true);
|
||||
assert.equal(validStripPoints([[0, 0], ['1', 0]]), false);
|
||||
assert.equal(validStripPoints(Array.from({ length: 51 }, (_, i) => [i, 0])), false);
|
||||
assert.equal(validStripPoints(Array.from({ length: 50 }, (_, i) => [i, 0])), true);
|
||||
assert.equal(isClosedStrip([[0, 0], [1, 0], [1, 1], [0, 0]]), true);
|
||||
assert.equal(isClosedStrip([[0, 0], [1, 0]]), false);
|
||||
assert.deepEqual(compactPoints([[0, 0], [0, 0], [1, 0]]), [[0, 0], [1, 0]]);
|
||||
assert.equal(polylineLength([[0, 0], [3, 4], [3, 10]]), 11);
|
||||
});
|
||||
|
||||
test('AC8: offset is t/2 on a thick face, 0 on free floor and on a zero wall', () => {
|
||||
const t2 = 0.25;
|
||||
// On the top face: shifted up into the free floor.
|
||||
const top = visibleStripPath([[2, 1], [8, 1]], ctx, t2);
|
||||
assert.deepEqual(top.points, [[2, 1.25], [8, 1.25]]);
|
||||
// On the bottom face: shifted down — the side is the free floor, not a fixed sign.
|
||||
const bottom = visibleStripPath([[8, 0], [2, 0]], ctx, t2);
|
||||
assert.deepEqual(bottom.points, [[8, -0.25], [2, -0.25]]);
|
||||
// Free floor: no shift.
|
||||
assert.deepEqual(visibleStripPath([[2, 3], [8, 3]], ctx, t2).points, [[2, 3], [8, 3]]);
|
||||
// A zero-thickness wall is no body: a strip on its axis gets no offset.
|
||||
assert.deepEqual(visibleStripPath([[2, 5], [8, 5]], { faces: [{ a: [0, 5], b: [10, 5] }], inside: () => false, epsilon: 1e-5 }, t2).points,
|
||||
[[2, 5], [8, 5]]);
|
||||
// Near the face but outside epsilon is not "on the face".
|
||||
assert.deepEqual(visibleStripPath([[2, 1.001], [8, 1.001]], ctx, t2).points, [[2, 1.001], [8, 1.001]]);
|
||||
});
|
||||
|
||||
test('AC8: a mixed strip leaves the face continuously — no gap, the stored points stay', () => {
|
||||
const t2 = 0.25;
|
||||
const stored = [[2, 1], [6, 1], [6, 4]];
|
||||
const before = JSON.stringify(stored);
|
||||
const path = visibleStripPath(stored, ctx, t2);
|
||||
// Face piece shifted, then a connector, then the free piece unshifted.
|
||||
assert.deepEqual(path.points, [[2, 1.25], [6, 1.25], [6, 1], [6, 4]]);
|
||||
for (let i = 1; i < path.points.length; i++) {
|
||||
const [a, b] = [path.points[i - 1], path.points[i]];
|
||||
assert.ok(Math.hypot(b[0] - a[0], b[1] - a[1]) > 0, 'no zero step');
|
||||
}
|
||||
assert.equal(JSON.stringify(stored), before, 'derivation never writes back');
|
||||
// A segment that only partly lies on the face splits into pieces.
|
||||
const pieces = stripPieces([[-4, 1], [4, 1]], ctx);
|
||||
assert.equal(pieces.length, 2);
|
||||
assert.equal(pieces[0].free, null);
|
||||
assert.deepEqual(pieces[1].free, [0, 1]);
|
||||
closePt(pieces[1].a, [0, 1]);
|
||||
});
|
||||
|
||||
test('AC8: a closed strip closes through the same rule without a seam point', () => {
|
||||
const path = visibleStripPath([[2, 3], [6, 3], [6, 6], [2, 6], [2, 3]], ctx, 0.25);
|
||||
assert.equal(path.closed, true);
|
||||
assert.deepEqual(path.points, [[2, 3], [6, 3], [6, 6], [2, 6]]);
|
||||
});
|
||||
|
||||
test('ТЗ §6: emitters sit epsilon outward on a face, cover the length, skip buried parts', () => {
|
||||
const onFace = emitterSamples([[2, 1], [8, 1]], ctx, 1);
|
||||
assert.equal(onFace.length, 7, 'every vertex plus spacing ≤ 1');
|
||||
for (const p of onFace) close(p[1], 1 + 1e-5, 1e-12, 'epsilon outward, never t/2');
|
||||
// Long strip with many vertices: every segment contributes, none is lost.
|
||||
const many = Array.from({ length: 30 }, (_, i) => [i % 2 ? 20 : 12, 3 + i]);
|
||||
const samples = emitterSamples(many, ctx, 2);
|
||||
for (const vertex of many) {
|
||||
assert.ok(samples.some((p) => Math.hypot(p[0] - vertex[0], p[1] - vertex[1]) < 1e-9), 'vertex kept');
|
||||
}
|
||||
// Partly inside the wall: the buried part emits nothing.
|
||||
const buried = emitterSamples([[5, 0.5], [5, 4]], ctx, 0.5);
|
||||
assert.ok(buried.every((p) => !ctx.inside(p)));
|
||||
assert.ok(buried.length > 0);
|
||||
assert.deepEqual(emitterSamples([[2, 0.5], [8, 0.5]], ctx, 1), [], 'entirely inside: no field');
|
||||
});
|
||||
|
||||
const bodies = { rings: [wall], inside: insideRect(wall) };
|
||||
|
||||
test('AC5: a new segment stops at the first face; touching and sliding are allowed', () => {
|
||||
const hit = clampToBodies([5, 4], [5, -4], bodies);
|
||||
assert.equal(hit.stopped, true);
|
||||
closePt(hit.point, [5, 1]);
|
||||
const along = clampToBodies([1, 1], [9, 1], bodies);
|
||||
assert.equal(along.stopped, false, 'sliding along the face');
|
||||
assert.deepEqual(along.point, [9, 1]);
|
||||
const touch = clampToBodies([5, 4], [5, 1], bodies);
|
||||
assert.equal(touch.stopped, false, 'ending on the face');
|
||||
assert.equal(clampToBodies([5, 0.5], [5, 4], bodies), null, 'a start inside a body is refused');
|
||||
// Past the wall's end: free.
|
||||
assert.equal(clampToBodies([11, 4], [11, -4], bodies).stopped, false);
|
||||
});
|
||||
|
||||
test('AC5: a fast vertex drag cannot jump the wall; neighbours are checked too', () => {
|
||||
const pts = [[2, 4], [5, 4], [8, 4]];
|
||||
const moved = clampVertexMove(pts, 1, [5, -4], bodies);
|
||||
assert.ok(moved[1] >= 1 - 1e-9, `vertex stays above the wall: ${moved}`);
|
||||
// The vertex itself may move freely, but a neighbour segment would cross.
|
||||
const sideways = clampVertexMove([[2, -4], [5, 4], [8, 4]], 1, [6, 4], bodies);
|
||||
assert.ok(!bodies.inside(sideways));
|
||||
// A closed strip: first and last are one handle; both neighbours judged.
|
||||
const ring = [[2, 4], [8, 4], [8, 8], [2, 8], [2, 4]];
|
||||
const dragged = clampVertexMove(ring, 0, [2, -2], bodies);
|
||||
assert.ok(dragged[1] >= 1 - 1e-9);
|
||||
});
|
||||
|
||||
test('AC12: hit radius is max(22 px, t/2): 20 px hits, 30 px misses for a thin stripe', () => {
|
||||
assert.equal(stripHitRadiusPx(6), LED_HIT_MIN_CSS_PX);
|
||||
assert.equal(stripHitRadiusPx(60), 30);
|
||||
const strip = { id: 'a', points: [[0, 0], [200, 0], [200, 200]], closed: false, thicknessPx: 6 };
|
||||
assert.equal(stripHitOwner([100, 20], [strip]), 'a');
|
||||
assert.equal(stripHitOwner([100, 30], [strip]), null);
|
||||
// Round end caps: the radius applies past the free end too.
|
||||
assert.equal(stripHitOwner([-20, 0], [strip]), 'a');
|
||||
// Measured from the derived path, along its whole length incl. the corner.
|
||||
assert.equal(stripHitOwner([215, 100], [strip]), 'a');
|
||||
});
|
||||
|
||||
test('AC12: nearest visible stripe wins; an exact tie goes to the stable id', () => {
|
||||
const a = { id: 'b-strip', points: [[0, 0], [100, 0]], closed: false, thicknessPx: 6 };
|
||||
const b = { id: 'a-strip', points: [[0, 30], [100, 30]], closed: false, thicknessPx: 6 };
|
||||
assert.equal(stripHitOwner([50, 10], [a, b]), 'b-strip');
|
||||
assert.equal(stripHitOwner([50, 15], [a, b]), 'a-strip', 'tie → smaller id');
|
||||
const loop = { id: 'loop', points: [[0, 0], [100, 0], [100, 100], [0, 100]], closed: true, thicknessPx: 6 };
|
||||
assert.equal(stripHitOwner([-10, 50], [loop]), 'loop', 'the closing edge is hit');
|
||||
});
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"src/radar-model.ts", "src/radar-editor.ts", "src/radar-geometry.ts", "src/radar-setup.ts", "src/radar-render.ts",
|
||||
"src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts", "src/vacuum-route-edit.ts",
|
||||
"src/sun.ts", "src/moon.ts", "src/moon-gate.ts", "src/moon-runtime.ts", "src/moon-art.generated.ts",
|
||||
"src/light-visibility.ts", "src/glow-scene.ts", "src/space-render.ts",
|
||||
"src/light-visibility.ts", "src/led-strip-geometry.ts", "src/glow-scene.ts", "src/space-render.ts",
|
||||
"src/resize.ts", "src/resize-labels.ts", "src/resize-controller.ts", "src/wall-record-preservation.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts", "src/device-inbox.ts",
|
||||
|
||||
Reference in New Issue
Block a user