diff --git a/src/led-strip-geometry.ts b/src/led-strip-geometry.ts new file mode 100755 index 00000000..f6585850 --- /dev/null +++ b/src/led-strip-geometry.ts @@ -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; 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>; + 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; + 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; +} diff --git a/src/space-geometry.ts b/src/space-geometry.ts index d591870b..835cfb68 100644 --- a/src/space-geometry.ts +++ b/src/space-geometry.ts @@ -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; }); } diff --git a/src/types.ts b/src/types.ts index bf38d6d4..b40fceba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/test/led-strip-geometry.test.mjs b/test/led-strip-geometry.test.mjs new file mode 100755 index 00000000..291c8a8a --- /dev/null +++ b/test/led-strip-geometry.test.mjs @@ -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'); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index ca3175ee..3e6a5bc5 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -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",