diff --git a/app.js b/app.js index 2d2885a..ed5c348 100644 --- a/app.js +++ b/app.js @@ -3175,6 +3175,9 @@ init().then(() => { ]; demoMemories.forEach(m => SpatialMemory.placeMemory(m)); + // Gravity well clustering — attract related crystals, bake positions (issue #1175) + SpatialMemory.runGravityLayout(); + fetchGiteaData(); setInterval(fetchGiteaData, 30000); runWeeklyAudit(); diff --git a/nexus/components/spatial-memory.js b/nexus/components/spatial-memory.js index 02e6e9c..b3a9bb7 100644 --- a/nexus/components/spatial-memory.js +++ b/nexus/components/spatial-memory.js @@ -456,6 +456,81 @@ const SpatialMemory = (() => { return count; } + // ─── GRAVITY WELL CLUSTERING ────────────────────────── + // Force-directed layout: same-category crystals attract, unrelated repel. + // Run on load (bake positions, not per-frame). Spec from issue #1175. + const GRAVITY_ITERATIONS = 20; + const ATTRACT_FACTOR = 0.10; // 10% closer to same-category centroid per iteration + const REPEL_FACTOR = 0.05; // 5% away from nearest unrelated crystal + + function runGravityLayout() { + const objs = Object.values(_memoryObjects); + if (objs.length < 2) { + console.info('[Mnemosyne] Gravity layout: fewer than 2 crystals, skipping'); + return; + } + console.info('[Mnemosyne] Gravity layout starting —', objs.length, 'crystals,', GRAVITY_ITERATIONS, 'iterations'); + + for (let iter = 0; iter < GRAVITY_ITERATIONS; iter++) { + // Accumulate displacements before applying (avoids order-of-iteration bias) + const dx = new Float32Array(objs.length); + const dy = new Float32Array(objs.length); + const dz = new Float32Array(objs.length); + + objs.forEach((obj, i) => { + const pos = obj.mesh.position; + const cat = obj.region; + + // ── Attraction toward same-category centroid ────────────── + let sx = 0, sy = 0, sz = 0, sameCount = 0; + objs.forEach(o => { + if (o === obj || o.region !== cat) return; + sx += o.mesh.position.x; + sy += o.mesh.position.y; + sz += o.mesh.position.z; + sameCount++; + }); + if (sameCount > 0) { + dx[i] += ((sx / sameCount) - pos.x) * ATTRACT_FACTOR; + dy[i] += ((sy / sameCount) - pos.y) * ATTRACT_FACTOR; + dz[i] += ((sz / sameCount) - pos.z) * ATTRACT_FACTOR; + } + + // ── Repulsion from nearest unrelated crystal ─────────────── + let nearestDist = Infinity; + let rnx = 0, rny = 0, rnz = 0; + objs.forEach(o => { + if (o === obj || o.region === cat) return; + const ex = pos.x - o.mesh.position.x; + const ey = pos.y - o.mesh.position.y; + const ez = pos.z - o.mesh.position.z; + const d = Math.sqrt(ex * ex + ey * ey + ez * ez); + if (d < nearestDist) { + nearestDist = d; + rnx = ex; rny = ey; rnz = ez; + } + }); + if (nearestDist > 0.001 && nearestDist < Infinity) { + const len = Math.sqrt(rnx * rnx + rny * rny + rnz * rnz); + dx[i] += (rnx / len) * nearestDist * REPEL_FACTOR; + dy[i] += (rny / len) * nearestDist * REPEL_FACTOR; + dz[i] += (rnz / len) * nearestDist * REPEL_FACTOR; + } + }); + + // Apply displacements + objs.forEach((obj, i) => { + obj.mesh.position.x += dx[i]; + obj.mesh.position.y += dy[i]; + obj.mesh.position.z += dz[i]; + }); + } + + // Bake final positions to localStorage + saveToStorage(); + console.info('[Mnemosyne] Gravity layout complete — positions baked to localStorage'); + } + // ─── SPATIAL SEARCH ────────────────────────────────── function searchNearby(position, maxResults, maxDist) { maxResults = maxResults || 10; @@ -516,7 +591,8 @@ const SpatialMemory = (() => { getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories, getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId, exportIndex, importIndex, searchNearby, REGIONS, - saveToStorage, loadFromStorage, clearStorage + saveToStorage, loadFromStorage, clearStorage, + runGravityLayout }; })();