Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 83 additions & 11 deletions site/beacon/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,41 @@ import {
getProviderColor,
} from './data.js';
import {
getScene, registerClickable, registerHoverable, onAnimate,
getScene, getCamera, registerClickable, registerHoverable, onAnimate,
setAgentPerformanceMode,
} from './scene.js';

const agentMeshes = new Map(); // agentId -> { core, glow, group }
const agentPositions = new Map(); // agentId -> Vector3
const PERFORMANCE_AGENT_THRESHOLD = 100;
const LOD_NEAR_DISTANCE_SQ = 140 * 140;
const LOD_MEDIUM_DISTANCE_SQ = 320 * 320;
const LOD_UPDATE_INTERVAL_SECONDS = 0.25;

let sharedAgentGeometries = null;

export function shouldUseAgentPerformanceMode(agentCount) {
return Number.isFinite(agentCount) && agentCount >= PERFORMANCE_AGENT_THRESHOLD;
}

export function selectAgentLod(distanceSquared) {
if (distanceSquared <= LOD_NEAR_DISTANCE_SQ) return 'high';
if (distanceSquared <= LOD_MEDIUM_DISTANCE_SQ) return 'medium';
return 'low';
}

export function applyAgentLod(mesh, level) {
if (!mesh || !mesh.lodGeometries || !mesh.lodGeometries[level]) return false;
if (mesh.group.userData.lod === level) return false;

mesh.core.geometry = mesh.lodGeometries[level];
const showDetailEffects = level === 'high';
mesh.glow.visible = showDetailEffects;
mesh.light.visible = showDetailEffects;
mesh.label.visible = showDetailEffects;
mesh.group.userData.lod = level;
return true;
}

export function getAgentPosition(agentId) {
return agentPositions.get(agentId);
Expand All @@ -24,6 +54,10 @@ export function getAgentMesh(agentId) {

export function buildAgents() {
const scene = getScene();
const camera = getCamera();
const performanceMode = shouldUseAgentPerformanceMode(AGENTS.length);
const geometries = getSharedAgentGeometries();
setAgentPerformanceMode(performanceMode);

// Track per-city agent index for offset placement
const cityCounts = {};
Expand Down Expand Up @@ -58,35 +92,30 @@ export function buildAgents() {
? (getProviderColor(agent.provider) || '#ffffff')
: (GRADE_COLORS[agent.grade] || '#33ff33');
const color = new THREE.Color(colorHex);
const lodGeometries = isRelay ? geometries.relay : geometries.native;

// Core geometry: Octahedron (diamond) for relay, Sphere for native
const coreGeo = isRelay
? new THREE.OctahedronGeometry(1.8, 0)
: new THREE.SphereGeometry(1.5, 16, 12);
const coreMat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.9,
wireframe: isRelay, // Wireframe gives relay agents a "holographic bridge" look
});
const core = new THREE.Mesh(coreGeo, coreMat);
const core = new THREE.Mesh(lodGeometries.high, coreMat);
core.userData = { type: 'agent', agentId: agent.id };
group.add(core);
registerClickable(core);
registerHoverable(core);

// Outer glow — slightly larger for relay to emphasize presence
const glowGeo = isRelay
? new THREE.OctahedronGeometry(3.0, 1)
: new THREE.SphereGeometry(2.5, 16, 12);
const glowMat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: isRelay ? 0.08 : 0.12,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(glowGeo, glowMat);
const glow = new THREE.Mesh(lodGeometries.glow, glowMat);
group.add(glow);

// Point light for local illumination
Expand All @@ -102,12 +131,35 @@ export function buildAgents() {
group.add(label);

scene.add(group);
agentMeshes.set(agent.id, { core, glow, group, light, relay: isRelay });
const mesh = {
core, glow, group, light, label, relay: isRelay, lodGeometries,
};
agentMeshes.set(agent.id, mesh);

if (performanceMode) {
applyAgentLod(mesh, selectAgentLod(camera.position.distanceToSquared(pos)));
} else {
group.userData.lod = 'high';
}
}

// Bob + spin animation
onAnimate((elapsed) => {
let lodElapsed = 0;
onAnimate((elapsed, dt) => {
lodElapsed += dt;
const refreshLod = performanceMode && lodElapsed >= LOD_UPDATE_INTERVAL_SECONDS;
if (refreshLod) lodElapsed = 0;

for (const [agentId, mesh] of agentMeshes) {
if (refreshLod) {
const distanceSquared = camera.position.distanceToSquared(mesh.group.position);
applyAgentLod(mesh, selectAgentLod(distanceSquared));
}

// Far agents stay selectable and rendered with low-poly cores, but do not
// spend CPU time on per-frame bob, glow, or rotation updates.
if (performanceMode && mesh.group.userData.lod === 'low') continue;

const baseY = mesh.group.userData.baseY;
const phase = hashCode(agentId) * 0.001;
mesh.group.position.y = baseY + Math.sin(elapsed * 1.2 + phase) * 1.5;
Expand All @@ -126,6 +178,26 @@ export function buildAgents() {
});
}

function getSharedAgentGeometries() {
if (sharedAgentGeometries) return sharedAgentGeometries;

sharedAgentGeometries = {
native: {
high: new THREE.SphereGeometry(1.5, 16, 12),
medium: new THREE.SphereGeometry(1.5, 8, 6),
low: new THREE.OctahedronGeometry(1.5, 0),
glow: new THREE.SphereGeometry(2.5, 16, 12),
},
relay: {
high: new THREE.OctahedronGeometry(1.8, 1),
medium: new THREE.OctahedronGeometry(1.8, 0),
low: new THREE.TetrahedronGeometry(1.8, 0),
glow: new THREE.OctahedronGeometry(3.0, 1),
},
};
return sharedAgentGeometries;
}

export function highlightAgent(agentId, on) {
const mesh = agentMeshes.get(agentId);
if (!mesh) return;
Expand Down
16 changes: 15 additions & 1 deletion site/beacon/scene.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ let autoRotate = true;
let autoRotateSpeed = 0.001; // radians per frame (~0.06°)
let lerpTarget = null;
let lerpAlpha = 0;
let rendererPixelRatioCap = 2;

const PERFORMANCE_PIXEL_RATIO_CAP = 1.25;

// Day/Night Cycle - Lighting references
let ambientLight, dirLight;
Expand All @@ -28,6 +31,11 @@ export function registerClickable(mesh) { clickables.push(mesh); }
export function registerHoverable(mesh) { hoverables.push(mesh); }
export function onAnimate(fn) { animationCallbacks.push(fn); }

export function setAgentPerformanceMode(enabled) {
rendererPixelRatioCap = enabled ? PERFORMANCE_PIXEL_RATIO_CAP : 2;
updateRendererPixelRatio();
}

export function initScene(canvas) {
clock = new THREE.Clock();

Expand All @@ -44,7 +52,7 @@ export function initScene(canvas) {
// Renderer
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
updateRendererPixelRatio();
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.8;

Expand Down Expand Up @@ -99,6 +107,12 @@ function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
updateRendererPixelRatio();
}

function updateRendererPixelRatio() {
if (!renderer) return;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, rendererPixelRatioCap));
}

// --- Click detection ---
Expand Down
86 changes: 86 additions & 0 deletions tests/test_beacon_atlas.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
import time
import sys
import os
import pathlib
import re
import subprocess

# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
Expand Down Expand Up @@ -231,6 +234,89 @@ def test_state_opacity_mapping(self):
self.assertLessEqual(opacity, 1.0, "Opacity must be <= 1")


class TestBeaconAtlasPerformanceMode(unittest.TestCase):
"""Test the actual frontend LOD policy without requiring a WebGL browser."""

@classmethod
def setUpClass(cls):
root = pathlib.Path(__file__).resolve().parents[1]
cls.agents_source = (root / "site" / "beacon" / "agents.js").read_text()
cls.scene_source = (root / "site" / "beacon" / "scene.js").read_text()

def run_agents_probe(self, expression):
source = re.sub(
r"^import[\s\S]*?;\s*$",
"",
self.agents_source,
flags=re.MULTILINE,
)
source = re.sub(r"\bexport\s+", "", source)
result = subprocess.run(
["node", "--input-type=module", "--eval", source + "\n" + expression],
check=True,
capture_output=True,
text=True,
)
return json.loads(result.stdout)

def test_lod_boundaries_and_population_gate(self):
result = self.run_agents_probe("""
console.log(JSON.stringify({
levels: [0, 19600, 19601, 102400, 102401].map(selectAgentLod),
enabled: [99, 100, 125].map(shouldUseAgentPerformanceMode),
}));
""")
self.assertEqual(
result["levels"],
["high", "high", "medium", "medium", "low"],
)
self.assertEqual(result["enabled"], [False, True, True])

def test_lod_transition_reuses_geometry_and_culls_detail_effects(self):
result = self.run_agents_probe("""
const mesh = {
core: { geometry: 'initial' },
glow: { visible: true },
light: { visible: true },
label: { visible: true },
group: { userData: {} },
lodGeometries: { high: 'HIGH', medium: 'MEDIUM', low: 'LOW' },
};
const first = applyAgentLod(mesh, 'low');
const snapshot = {
geometry: mesh.core.geometry,
glow: mesh.glow.visible,
light: mesh.light.visible,
label: mesh.label.visible,
level: mesh.group.userData.lod,
};
const duplicate = applyAgentLod(mesh, 'low');
const restored = applyAgentLod(mesh, 'high');
console.log(JSON.stringify({ first, snapshot, duplicate, restored }));
""")
self.assertTrue(result["first"])
self.assertEqual(
result["snapshot"],
{
"geometry": "LOW",
"glow": False,
"light": False,
"label": False,
"level": "low",
},
)
self.assertFalse(result["duplicate"])
self.assertTrue(result["restored"])

def test_performance_mode_is_integrated_into_render_loop(self):
self.assertIn("camera.position.distanceToSquared", self.agents_source)
self.assertIn("LOD_UPDATE_INTERVAL_SECONDS", self.agents_source)
self.assertIn("mesh.group.userData.lod === 'low'", self.agents_source)
self.assertIn("setAgentPerformanceMode(performanceMode)", self.agents_source)
self.assertIn("PERFORMANCE_PIXEL_RATIO_CAP = 1.25", self.scene_source)
self.assertIn("renderer.setPixelRatio", self.scene_source)


class TestBeaconAtlasDataIntegrity(unittest.TestCase):
"""Test data integrity and consistency."""

Expand Down