From 228caf22703fbc70ddf8347f996d11786474e2fb Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Thu, 16 Jul 2026 01:16:24 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=AD=20review:=20hash=20restores=20full?= =?UTF-8?q?=20camera=20pose;=20tick-loop=20hygiene=20(fable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- js/layers/comets.js | 7 ++++++- js/layers/spacecraft.js | 13 ++++++++++--- js/main.js | 17 ++++++++++++++--- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/js/layers/comets.js b/js/layers/comets.js index dbf4752..2cf8cfd 100644 --- a/js/layers/comets.js +++ b/js/layers/comets.js @@ -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); } diff --git a/js/layers/spacecraft.js b/js/layers/spacecraft.js index 3685045..20f9e49 100644 --- a/js/layers/spacecraft.js +++ b/js/layers/spacecraft.js @@ -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) { diff --git a/js/main.js b/js/main.js index 1fb645f..74b46ee 100644 --- a/js/main.js +++ b/js/main.js @@ -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) {