mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +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 {
|
||||
|
||||
Reference in New Issue
Block a user