🔧 review: fix 8 findings from adversarial self-review (opus)

Multi-agent adversarial review (verified findings) — the top finding (dead orbit
drag) fable had already fixed in 3e36a0c; these are the rest, composing on top of
fable's 228caf2/3e36a0c:

- scale.js viewFromEcl: allocation-free + alias-safe (read x/y/z into locals before
  writing out) — it ran per body per frame and allocated an object each call (§11).
- planets.js + comets.js: refill orbit paths on any P change, not just
  isTransitioning() — the MEGA↔TRUE tween's FINAL frame (P settled, tween nulled)
  was skipped, leaving body/orbit desync (§12).
- almanac.js: a moon's heliocentric position adds its true orbital offset, so the
  Moon's "From Earth" is its real ~384,400 km / 1.3 s light, not 0.
- asteroids.js: guard refresh() against null geometry when toggled on before the
  SBDB load completes (was a TypeError).
- serve.py: CAD close-approach responses are never cached immutable — the database
  keeps growing for past dates as new NEOs are discovered (only Horizons ephemeris
  is truly immutable).

Re-verified: 13/13 gates green; orbit zoom dollies 82→73 finite; Moon From Earth
0.003 AU; layer toggles throw nothing; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
monsterrobotparty 2026-07-16 08:43:40 +10:00
parent 3e36a0c16a
commit 4360269ad3
6 changed files with 37 additions and 18 deletions

View File

@ -42,7 +42,18 @@ export default function create(ctx) {
if (id === 'sun') { out.x = out.y = out.z = 0; return out; }
if (b.ephemId) return ephem.helioEcl(b.ephemId, jd, out);
if (b.elements) return ephem.smallBodyEcl(b.elements, jd, out);
if (b.parent) return helioOf(b.parent, jd, out); // moon ≈ parent at AU scale
if (b.moonOrbit && b.parent) {
// parent heliocentric + the moon's TRUE orbital offset (AU) — so e.g. the
// Moon's distance from Earth is its real ~384,400 km, not 0.
helioOf(b.parent, jd, out);
const theta = 2 * Math.PI * ((jd - lib.J2000_JD) / b.moonOrbit.periodDays);
const rAU = b.moonOrbit.aKm / lib.AU_KM;
const tilt = (bodies[b.parent].axialTiltDeg || 0) * lib.DEG;
const ex = Math.cos(theta) * rAU, ey = Math.sin(theta) * rAU;
out.x += ex; out.y += ey * Math.cos(tilt); out.z += ey * Math.sin(tilt);
return out;
}
if (b.parent) return helioOf(b.parent, jd, out);
out.x = out.y = out.z = 0; return out;
}

View File

@ -46,6 +46,7 @@ export default function create(ctx) {
}
function refresh(jd) {
if (!geo || !elements.length) return; // toggled on before SBDB load completed
for (let k = 0; k < elements.length; k++) {
ephem.smallBodyEcl(elements[k], jd, _e);
scale.viewFromEcl(_e, _v);

View File

@ -62,8 +62,9 @@ export default function create(ctx) {
const tail = new THREE.Line(tgeo, new THREE.LineBasicMaterial({ color: new THREE.Color(w.color), transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false }));
tail.frustumCulled = false; tail.visible = on; scene.add(tail);
const lbl = makeLabel(spr, name, 'craft-label'); lbl.div.style.color = w.color; lbl.obj.visible = on;
const c = { w, name, el, geo, line, spr, tail, tgeo, lbl, auPath: au, lastT: lib.centuriesSinceJ2000(ctx.clock.jd) };
const c = { w, name, el, geo, line, spr, tail, tgeo, lbl, auPath: au, lastT: lib.centuriesSinceJ2000(ctx.clock.jd), lastFillP: NaN };
fillOrbit(c);
c.lastFillP = scale.getP();
comets.push(c);
}
@ -83,7 +84,7 @@ export default function create(ctx) {
id: 'comets',
onClockTick(simMs, jd) {
if (!on) return;
const transitioning = scale.isTransitioning();
const P = scale.getP();
const camDist = ctx.camera.position.length();
// label DOM writes throttled ≤5 Hz (brief §11)
const nowMs = performance.now();
@ -104,10 +105,10 @@ export default function create(ctx) {
tp[3] = c.spr.position.x + _dir.x * len; tp[4] = c.spr.position.y + _dir.y * len; tp[5] = c.spr.position.z + _dir.z * len;
c.tgeo.attributes.position.needsUpdate = true;
if (updateText) c.lbl.div.textContent = `${c.name} · ${lib.fmtAU(r)}`;
// orbit rebuild on T-drift or scale transition (brief §11)
// orbit rebuild on T-drift or any P change (covers the tween + its final frame)
const needAu = Math.abs(lib.centuriesSinceJ2000(jd) - c.lastT) > CONFIG.orbitRebuildCy;
if (needAu) { c.auPath = ephem.orbitPathFromElements(c.el, N); c.lastT = lib.centuriesSinceJ2000(jd); }
if (needAu || transitioning) fillOrbit(c);
if (needAu || P !== c.lastFillP) { fillOrbit(c); c.lastFillP = P; }
}
},
handlePick(raycaster) {

View File

@ -35,9 +35,10 @@ export default function create(ctx) {
line.frustumCulled = false;
worldGroup.add(line);
const e = { id, b, group, mesh, mat, geo, line, lbl, auPath: null, lastT: NaN };
const e = { id, b, group, mesh, mat, geo, line, lbl, auPath: null, lastT: NaN, lastFillP: NaN };
buildAuPath(e);
fillView(e);
e.lastFillP = scale.getP();
entries.push(e);
}
@ -68,7 +69,7 @@ export default function create(ctx) {
return {
id: 'planets',
onClockTick(simMs, jd, isLive) {
const transitioning = scale.isTransitioning();
const P = scale.getP();
for (const e of entries) {
const abs = ctx.bodyWorld[e.id];
if (!abs) continue;
@ -83,10 +84,11 @@ export default function create(ctx) {
}
// focus highlight
e.lbl.div.classList.toggle('focused', e.id === focus.id);
// orbit rebuild only when needed
// orbit rebuild: on element drift, or whenever the compression P changed
// (covers the whole MEGA↔TRUE tween AND the final frame it settles on).
const needAu = Math.abs(lib.centuriesSinceJ2000(jd) - e.lastT) > CONFIG.orbitRebuildCy;
if (needAu) buildAuPath(e);
if (needAu || transitioning) fillView(e);
if (needAu || P !== e.lastFillP) { fillView(e); e.lastFillP = P; }
}
},
};

View File

@ -3,7 +3,7 @@
// out. Nothing else in the app maps AU→scene by hand (brief §4). "Truth before
// view": callers compute positions in AU, then pass through here.
import { AU_KM, eclToWorld, clamp, lerp, smoothstep } from './lib.js';
import { AU_KM, clamp, lerp, smoothstep } from './lib.js';
let K = 9.0;
let P = 0.4; // live exponent: MEGA=0.4, TRUE=1.0
@ -32,11 +32,18 @@ let moonBase = 1.8, moonK = 1.2;
export function radial(rAU) { return K * Math.pow(rAU, P); }
// Heliocentric ecliptic AU → view-world Vector3 (radial compression + frame map).
// Allocation-free (brief §11): the eclToWorld map is inlined with the scale so no
// intermediate object is created — this runs for every body every frame.
export function viewFromEcl(ecl, out) {
const r = Math.hypot(ecl.x, ecl.y, ecl.z);
// Read all components first so this is safe when out === ecl (callers reuse a
// single scratch, e.g. viewFromEcl(_v, _v)) and still allocation-free.
const x = ecl.x, y = ecl.y, z = ecl.z;
const r = Math.hypot(x, y, z);
if (r === 0) { out.x = out.y = out.z = 0; return out; }
const s = radial(r) / r; // scale factor along the true direction
eclToWorld({ x: ecl.x * s, y: ecl.y * s, z: ecl.z * s }, out);
out.x = x * s; // ecl X → world x
out.y = z * s; // ecl Z (north) → world y
out.z = -y * s; // ecl Y → world z
return out;
}

View File

@ -82,12 +82,9 @@ def _requested_span_is_past(name, query):
bump(_parse_epoch_to_unix(tok))
if "STOP_TIME" not in q and "TLIST" not in q:
return False
elif name == "cad":
for raw in q.get("date-max", []):
bump(_parse_epoch_to_unix(raw))
if "date-max" not in q:
return False
else: # sbdb elements query — has no time span, treat as mutable (TTL)
# CAD is NOT immutable even for past dates: close-approach records keep growing
# as new NEOs are discovered. SBDB elements advance too. Both get the 24 h TTL.
else:
return False
# Margin: only call it immutable if the latest epoch is > 1 day ago.