feat: add right-click object inspection for 3D objects
Some checks failed
CI / validate (pull_request) Failing after 9s
CI / auto-merge (pull_request) Has been skipped

Right-clicking any inspectable 3D mesh or sprite now shows a
holographic tooltip with the object's name and description.

Tagged objects: glass platform, platform rim, sovereignty meter,
sovereignty score sprite, agent panels, and commit banners.
The contextmenu handler uses THREE.Raycaster against all scene
children and surfaces the first hit with userData.inspectName.
Tooltip dismisses on click or Escape.

Fixes #141
This commit is contained in:
Alexander Whitestone
2026-03-24 00:37:33 -04:00
parent 1dc82b656f
commit d4f57943ce
3 changed files with 95 additions and 0 deletions

58
app.js
View File

@@ -163,12 +163,16 @@ const platformFrameMat = new THREE.MeshStandardMaterial({
const platformRimGeo = new THREE.RingGeometry(4.7, 5.3, 64);
const platformRim = new THREE.Mesh(platformRimGeo, platformFrameMat);
platformRim.rotation.x = -Math.PI / 2;
platformRim.userData.inspectName = 'Glass Platform';
platformRim.userData.inspectDesc = 'Central floating platform — the sovereign foundation of the Nexus.';
glassPlatformGroup.add(platformRim);
// Raised border torus for visible 3-D thickness
const borderTorusGeo = new THREE.TorusGeometry(5.0, 0.1, 6, 64);
const borderTorus = new THREE.Mesh(borderTorusGeo, platformFrameMat);
borderTorus.rotation.x = Math.PI / 2;
borderTorus.userData.inspectName = 'Platform Rim';
borderTorus.userData.inspectDesc = 'Raised metallic torus encircling the Nexus platform.';
glassPlatformGroup.add(borderTorus);
// Glass tile material — highly transmissive to reveal the void below
@@ -370,6 +374,8 @@ const scoreArcMat = new THREE.MeshBasicMaterial({
});
const scoreArcMesh = new THREE.Mesh(buildScoreArcGeo(sovereigntyScore), scoreArcMat);
scoreArcMesh.rotation.z = Math.PI / 2; // arc starts at 12 o'clock
scoreArcMesh.userData.inspectName = 'Sovereignty Meter';
scoreArcMesh.userData.inspectDesc = 'Holographic arc gauge tracking Timmy\'s sovereignty score across all systems.';
sovereigntyGroup.add(scoreArcMesh);
// Glow light at gauge center
@@ -403,6 +409,8 @@ const meterSpriteMat = new THREE.SpriteMaterial({
});
const meterSprite = new THREE.Sprite(meterSpriteMat);
meterSprite.scale.set(3.2, 1.6, 1);
meterSprite.userData.inspectName = 'Sovereignty Score';
meterSprite.userData.inspectDesc = 'Live sovereignty score display. Reads from sovereignty-status.json.';
sovereigntyGroup.add(meterSprite);
scene.add(sovereigntyGroup);
@@ -749,6 +757,8 @@ async function initCommitBanners() {
startDelay: i * 2.5,
lifetime: 12 + i * 1.5,
spawnTime: /** @type {number|null} */ (null),
inspectName: `Commit ${commit.hash}`,
inspectDesc: commit.message,
};
scene.add(sprite);
commitBanners.push(sprite);
@@ -894,6 +904,8 @@ function rebuildAgentPanels(statusData) {
baseY: BOARD_Y,
floatPhase: (i / n) * Math.PI * 2,
floatSpeed: 0.18 + i * 0.04,
inspectName: `Agent: ${agent.name.toUpperCase()}`,
inspectDesc: `Status: ${agent.status} · PRs today: ${agent.prs_today}${agent.issue ? '\n' + agent.issue : ''}`,
};
agentBoardGroup.add(sprite);
agentPanelSprites.push(sprite);
@@ -922,3 +934,49 @@ async function refreshAgentBoard() {
// Initial render, then poll every 30 s
refreshAgentBoard();
setInterval(refreshAgentBoard, 30000);
// === OBJECT INSPECTION (right-click) ===
const inspectRaycaster = new THREE.Raycaster();
const inspectTooltip = document.getElementById('inspect-tooltip');
const inspectNameEl = document.getElementById('inspect-name');
const inspectDescEl = document.getElementById('inspect-desc');
/**
* Shows the inspection tooltip near the cursor.
* @param {number} x - Client X position
* @param {number} y - Client Y position
* @param {string} name - Object name
* @param {string} desc - Object description
*/
function showInspectTooltip(x, y, name, desc) {
inspectNameEl.textContent = name;
inspectDescEl.textContent = desc;
// Offset from cursor; keep within viewport
const tx = Math.min(x + 14, window.innerWidth - 300);
const ty = Math.min(y + 14, window.innerHeight - 80);
inspectTooltip.style.left = tx + 'px';
inspectTooltip.style.top = ty + 'px';
inspectTooltip.classList.add('visible');
}
renderer.domElement.addEventListener('contextmenu', (/** @type {MouseEvent} */ e) => {
e.preventDefault();
const mouse = new THREE.Vector2(
(e.clientX / window.innerWidth) * 2 - 1,
-(e.clientY / window.innerHeight) * 2 + 1
);
inspectRaycaster.setFromCamera(mouse, camera);
const hits = inspectRaycaster.intersectObjects(scene.children, true);
const hit = hits.find(h => h.object.userData.inspectName);
if (hit) {
const ud = hit.object.userData;
showInspectTooltip(e.clientX, e.clientY, ud.inspectName, ud.inspectDesc || '');
} else {
inspectTooltip.classList.remove('visible');
}
});
document.addEventListener('click', () => inspectTooltip.classList.remove('visible'));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') inspectTooltip.classList.remove('visible');
});

View File

@@ -48,6 +48,11 @@
<div id="sovereignty-msg">⚡ SOVEREIGNTY ⚡</div>
<div id="inspect-tooltip">
<div id="inspect-name"></div>
<div id="inspect-desc"></div>
</div>
<script type="module" src="app.js"></script>
<div id="loading" style="position: fixed; top: 0; left: 0; right: 0; height: 4px; background: #222; z-index: 1000;">
<div id="loading-bar" style="height: 100%; background: var(--color-accent); width: 0;"></div>

View File

@@ -184,6 +184,38 @@ body.photo-mode #overview-indicator {
100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
}
/* === OBJECT INSPECTION TOOLTIP === */
#inspect-tooltip {
display: none;
position: fixed;
z-index: 50;
background: rgba(0, 8, 24, 0.92);
border: 1px solid var(--color-primary);
padding: 8px 12px;
font-family: var(--font-body);
pointer-events: none;
max-width: 280px;
}
#inspect-tooltip.visible {
display: block;
}
#inspect-name {
font-size: 11px;
color: var(--color-primary);
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 4px;
}
#inspect-desc {
font-size: 11px;
color: var(--color-text);
line-height: 1.4;
opacity: 0.85;
}
/* === CRT / CYBERPUNK OVERLAY === */
.crt-overlay {
position: fixed;