🔭 review: hash restores full camera pose; tick-loop hygiene (fable)

- pendingCam now restores heading+pitch (inverse of serializeHash spherical),
  not just distance — pasted URLs reproduce the exact view (Stage 2 gate)
- bodyWorld.sun allocated once, not per frame (§11 zero-alloc tick rule)
- spacecraft/comet label DOM writes throttled ≤5 Hz (§11); geometry still per-frame

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monsterrobotparty 2026-07-16 01:16:24 +10:00
parent 4352394a56
commit 228caf2270
3 changed files with 30 additions and 7 deletions

View File

@ -78,12 +78,17 @@ export default function create(ctx) {
c.geo.computeBoundingSphere();
}
let lastTextMs = 0;
return {
id: 'comets',
onClockTick(simMs, jd) {
if (!on) return;
const transitioning = scale.isTransitioning();
const camDist = ctx.camera.position.length();
// label DOM writes throttled ≤5 Hz (brief §11)
const nowMs = performance.now();
const updateText = nowMs - lastTextMs > 200;
if (updateText) lastTextMs = nowMs;
for (const c of comets) {
ephem.smallBodyEcl(c.el, jd, _e);
const r = Math.hypot(_e.x, _e.y, _e.z);
@ -98,7 +103,7 @@ export default function create(ctx) {
tp[0] = c.spr.position.x; tp[1] = c.spr.position.y; tp[2] = c.spr.position.z;
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;
c.lbl.div.textContent = `${c.name} · ${lib.fmtAU(r)}`;
if (updateText) c.lbl.div.textContent = `${c.name} · ${lib.fmtAU(r)}`;
// orbit rebuild on T-drift or scale transition (brief §11)
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); }

View File

@ -118,10 +118,15 @@ export default function create(ctx) {
return out;
}
let lastTextMs = 0;
return {
id: 'spacecraft',
onClockTick(simMs, jd) {
const camDist = ctx.camera.position.length();
// label DOM writes throttled ≤5 Hz (brief §11); geometry still updates per frame
const nowMs = performance.now();
const updateText = nowMs - lastTextMs > 200;
if (updateText) lastTextMs = nowMs;
let earthDone = false;
for (const cr of crafts) {
const live = show && cr.samples && interp(cr.samples, jd, _p);
@ -143,9 +148,11 @@ export default function create(ctx) {
cr.tgeo.setDrawRange(0, n);
cr.tgeo.attributes.position.needsUpdate = true;
cr.trail.visible = n > 1;
const rSun = Math.hypot(_p.x, _p.y, _p.z);
const rEarth = Math.hypot(_p.x - _earth.x, _p.y - _earth.y, _p.z - _earth.z);
cr.lbl.div.textContent = `${cr.c.name} · ${lib.fmtAU(rSun)} · ${lib.fmtLightTime(rEarth)} lt`;
if (updateText) {
const rSun = Math.hypot(_p.x, _p.y, _p.z);
const rEarth = Math.hypot(_p.x - _earth.x, _p.y - _earth.y, _p.z - _earth.z);
cr.lbl.div.textContent = `${cr.c.name} · ${lib.fmtAU(rSun)} · ${lib.fmtLightTime(rEarth)} lt`;
}
}
},
handlePick(raycaster) {

View File

@ -41,7 +41,9 @@ const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(CONFIG.camera.fov, innerWidth / innerHeight, CONFIG.camera.near, CONFIG.camera.far);
camera.position.set(0, 26, 78);
const controls = new OrbitControls(camera, labelRenderer.domElement);
// Bind to the WebGL canvas, NOT labelRenderer.domElement — that div lives inside
// #labels {pointer-events:none}, so OrbitControls would never receive input.
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = CONFIG.camera.minDistance;
@ -168,6 +170,7 @@ const focus = {
// bodyWorld[id] = absolute view-world position, plain float64 {x,y,z}.
const bodyWorld = Object.create(null);
bodyWorld.sun = { x: 0, y: 0, z: 0 }; // heliocentric origin, never moves
const focusOffset = { x: 0, y: 0, z: 0 };
const _ecl = {};
@ -176,7 +179,7 @@ function computeBodyWorld() {
for (const id in BODIES) {
const b = BODIES[id];
if (b.type === 'moon') continue;
if (id === 'sun') { bodyWorld.sun = { x: 0, y: 0, z: 0 }; continue; }
if (id === 'sun') continue;
if (b.ephemId) ephem.helioEcl(b.ephemId, clock.jd, _ecl);
else if (b.elements) ephem.smallBodyEcl(b.elements, clock.jd, _ecl);
else continue;
@ -464,7 +467,15 @@ wireControls();
if (DEBUG) document.getElementById('debug').hidden = false;
try { applyHash(); } catch (err) { console.warn('bad hash', err); }
setFocus(focus.id, false);
if (pendingCam) { camera.position.setLength(pendingCam[0]); } // heading/pitch restored loosely via length; orbit drag refines
if (pendingCam) {
// restore the full spherical pose — inverse of serializeHash's
// heading = atan2(x, z), pitch = asin(y / dist)
const [d, heading, pitch] = pendingCam;
camera.position.set(
d * Math.cos(pitch) * Math.sin(heading),
d * Math.sin(pitch),
d * Math.cos(pitch) * Math.cos(heading));
}
loadLayers().then(() => {
if (pendingLayers !== null) {