diff --git a/assets/music/spacestation-01.mp3 b/assets/music/spacestation-01.mp3 new file mode 100644 index 0000000..d5d7f6c Binary files /dev/null and b/assets/music/spacestation-01.mp3 differ diff --git a/assets/videos/ss-surface-01.mp4 b/assets/videos/ss-surface-01.mp4 new file mode 100644 index 0000000..db48c6e Binary files /dev/null and b/assets/videos/ss-surface-01.mp4 differ diff --git a/dev/tether.test.mjs b/dev/tether.test.mjs index 31ffdb6..bae71c4 100644 --- a/dev/tether.test.mjs +++ b/dev/tether.test.mjs @@ -215,6 +215,25 @@ const dist = (x, y, t) => Math.hypot(x - t.x, y - t.y); return arcs.length === 1 && Math.abs(arcs[0].a1 - arcs[0].a0 - TAU) < 1e-9; })()); + // INTERNAL TANGENCY — the gate case: d + rInner = rOuter EXACTLY (a + // level-1 gate tether placed at exactly the level-2 anchor's range: + // 5120 + 5120 = 10240). The inner rim lies entirely inside the outer + // zone, touching at one point — it contributes nothing to the union + // boundary, so the inner tether draws NO line; the outer stays full. + // Regression: at k == −1 exactly the old branch fell into "no covered + // arc" and drew the whole inner circle as a spurious border inside the + // union (visible in-game and on the map). + const outerT = new Tether('outer', 0, 0, 1); outerT.radius = 10240; + const innerT = new Tether('inner', 5120, 0, 1); innerT.radius = 5120; + const fieldT = [outerT, innerT]; + check('internal tangency: inner rim fully covered → no line inside the union', + Tether.visibleArcs(innerT, fieldT).length === 0); + check('internal tangency: outer tether stays a full circle', + (() => { + const arcs = Tether.visibleArcs(outerT, fieldT); + return arcs.length === 1 && Math.abs(arcs[0].a1 - arcs[0].a0 - TAU) < 1e-9; + })()); + // Partial overlap — EXACT covered length (k from the circle geometry): // covered(θ) where cos(θ−β) ≥ k, k = (rA²+d²−rB²)/(2·rA·d) const A = new Tether('a', 0, 0, 1); // r = 5120 diff --git a/js/tether/Tether.js b/js/tether/Tether.js index 7ce1cef..bf2a461 100644 --- a/js/tether/Tether.js +++ b/js/tether/Tether.js @@ -103,9 +103,13 @@ export class Tether { * another zone o ⟺ |P − o| ≤ o.radius ⟺ cos(θ − β) ≥ k, where * β = atan2(o.y − y, o.x − x), k = (r² + d² − o.radius²) / (2·r·d), * d = |center → o|. So each other tether covers the angular interval - * [β − arccos k, β + arccos k] (when −1 < k < 1); k < −1 means o - * contains this whole rim; k ≥ 1 means no overlap. Union the covered - * intervals on the circle; the complement is the visible arcs. + * [β − arccos k, β + arccos k] (when −1 < k < 1); k ≤ −1 means o + * contains this whole rim — even at EXACT internal tangency (k = −1, + * e.g. a level-1 gate tether placed at exactly the level-2 anchor's + * range: 5120 + 5120 = 10240), where the rim lies entirely inside o + * and contributes no boundary; k ≥ 1 means externally tangent or + * disjoint (no covered arc). Union the covered intervals on the + * circle; the complement is the visible arcs. * * @returns {Array<{a0:number, a1:number}>} arcs in [0, TAU), a1 > a0, sorted */ @@ -123,8 +127,8 @@ export class Tether { continue; } const k = (r * r + d * d - o.radius * o.radius) / (2 * r * d); - if (k < -1) return []; // o contains this disc → the whole rim is covered - if (k <= -1 || k >= 1) continue; // tangent or disjoint → no covered arc + if (k <= -1) return []; // o contains this rim — strictly, or exactly internally tangent + if (k >= 1) continue; // externally tangent or disjoint → no covered arc const beta = Math.atan2(dy, dx); const half = Math.acos(k); covered.push([beta - half, beta + half]);