// === FLOATING BOOKSHELVES + SPINE TEXTURES + COMMIT BANNERS === import * as THREE from 'three'; import { NEXUS } from './constants.js'; import { scene } from './scene-setup.js'; import { fetchNexusCommits, fetchMergedPRs } from './data/gitea.js'; // === AGENT STATUS PANELS (declared early) === export const agentPanelSprites = []; // === COMMIT BANNERS === export const commitBanners = []; export const bookshelfGroups = []; function createCommitTexture(hash, message) { const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 64; const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgba(0, 0, 16, 0.75)'; ctx.fillRect(0, 0, 512, 64); ctx.strokeStyle = '#4488ff'; ctx.lineWidth = 1; ctx.strokeRect(0.5, 0.5, 511, 63); ctx.font = 'bold 11px "Courier New", monospace'; ctx.fillStyle = '#4488ff'; ctx.fillText(hash, 10, 20); ctx.font = '12px "Courier New", monospace'; ctx.fillStyle = '#ccd6f6'; const displayMsg = message.length > 54 ? message.slice(0, 54) + '\u2026' : message; ctx.fillText(displayMsg, 10, 46); return new THREE.CanvasTexture(canvas); } export async function initCommitBanners() { const raw = await fetchNexusCommits(5); const commits = raw.length > 0 ? raw.map(c => ({ hash: c.sha.slice(0, 7), message: c.commit.message.split('\n')[0] })) : [ { hash: 'a1b2c3d', message: 'feat: depth of field effect on distant objects' }, { hash: 'e4f5g6h', message: 'feat: photo mode with orbit controls' }, { hash: 'i7j8k9l', message: 'feat: sovereignty easter egg animation' }, { hash: 'm0n1o2p', message: 'feat: overview mode bird\'s-eye view' }, { hash: 'q3r4s5t', message: 'feat: star field and constellation lines' }, ]; const spreadX = [-7, -3.5, 0, 3.5, 7]; const spreadY = [1.0, -1.5, 2.2, -0.8, 1.6]; const spreadZ = [-1.5, -2.5, -1.0, -2.0, -1.8]; commits.forEach((commit, i) => { const texture = createCommitTexture(commit.hash, commit.message); const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0, depthWrite: false, }); const sprite = new THREE.Sprite(material); sprite.scale.set(12, 1.5, 1); sprite.position.set( spreadX[i % spreadX.length], spreadY[i % spreadY.length], spreadZ[i % spreadZ.length] ); sprite.userData = { baseY: spreadY[i % spreadY.length], floatPhase: (i / commits.length) * Math.PI * 2, floatSpeed: 0.25 + i * 0.07, startDelay: i * 2.5, lifetime: 12 + i * 1.5, spawnTime: null, zoomLabel: `Commit: ${commit.hash}`, }; scene.add(sprite); commitBanners.push(sprite); }); } // === FLOATING BOOKSHELVES === function createSpineTexture(prNum, title, bgColor) { const canvas = document.createElement('canvas'); canvas.width = 128; canvas.height = 512; const ctx = canvas.getContext('2d'); ctx.fillStyle = bgColor; ctx.fillRect(0, 0, 128, 512); ctx.strokeStyle = '#4488ff'; ctx.lineWidth = 3; ctx.strokeRect(3, 3, 122, 506); ctx.font = 'bold 32px "Courier New", monospace'; ctx.fillStyle = '#4488ff'; ctx.textAlign = 'center'; ctx.fillText(`#${prNum}`, 64, 58); ctx.strokeStyle = '#4488ff'; ctx.lineWidth = 1; ctx.globalAlpha = 0.4; ctx.beginPath(); ctx.moveTo(12, 78); ctx.lineTo(116, 78); ctx.stroke(); ctx.globalAlpha = 1.0; ctx.save(); ctx.translate(64, 300); ctx.rotate(-Math.PI / 2); const displayTitle = title.length > 30 ? title.slice(0, 30) + '\u2026' : title; ctx.font = '21px "Courier New", monospace'; ctx.fillStyle = '#ccd6f6'; ctx.textAlign = 'center'; ctx.fillText(displayTitle, 0, 0); ctx.restore(); return new THREE.CanvasTexture(canvas); } function buildBookshelf(books, position, rotationY) { const group = new THREE.Group(); group.position.copy(position); group.rotation.y = rotationY; const SHELF_W = books.length * 0.52 + 0.6; const SHELF_THICKNESS = 0.12; const SHELF_DEPTH = 0.72; const ENDPANEL_H = 2.0; const shelfMat = new THREE.MeshStandardMaterial({ color: 0x0d1520, metalness: 0.6, roughness: 0.5, emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.02), }); const plank = new THREE.Mesh(new THREE.BoxGeometry(SHELF_W, SHELF_THICKNESS, SHELF_DEPTH), shelfMat); group.add(plank); const endGeo = new THREE.BoxGeometry(0.1, ENDPANEL_H, SHELF_DEPTH); const leftEnd = new THREE.Mesh(endGeo, shelfMat); leftEnd.position.set(-SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); group.add(leftEnd); const rightEnd = new THREE.Mesh(endGeo.clone(), shelfMat); rightEnd.position.set(SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); group.add(rightEnd); const glowStrip = new THREE.Mesh( new THREE.BoxGeometry(SHELF_W, 0.035, 0.035), new THREE.MeshBasicMaterial({ color: NEXUS.colors.accent, transparent: true, opacity: 0.55 }) ); glowStrip.position.set(0, SHELF_THICKNESS / 2 + 0.017, SHELF_DEPTH / 2); group.add(glowStrip); const BOOK_COLORS = [ '#0f0818', '#080f18', '#0f1108', '#07120e', '#130c06', '#060b12', '#120608', '#080812', ]; const bookStartX = -(SHELF_W / 2) + 0.36; books.forEach((book, i) => { const spineW = 0.34 + (i % 3) * 0.05; const bookH = 1.35 + (i % 4) * 0.13; const coverD = 0.58; const bgColor = BOOK_COLORS[i % BOOK_COLORS.length]; const spineTexture = createSpineTexture(book.prNum, book.title, bgColor); const plainMat = new THREE.MeshStandardMaterial({ color: new THREE.Color(bgColor), roughness: 0.85, metalness: 0.05, }); const spineMat = new THREE.MeshBasicMaterial({ map: spineTexture }); const bookMats = [plainMat, plainMat, plainMat, plainMat, spineMat, plainMat]; const bookGeo = new THREE.BoxGeometry(spineW, bookH, coverD); const bookMesh = new THREE.Mesh(bookGeo, bookMats); bookMesh.position.set(bookStartX + i * 0.5, SHELF_THICKNESS / 2 + bookH / 2, 0); bookMesh.userData.zoomLabel = `PR #${book.prNum}: ${book.title.slice(0, 40)}`; group.add(bookMesh); }); const shelfLight = new THREE.PointLight(NEXUS.colors.accent, 0.25, 5); shelfLight.position.set(0, -0.4, 0); group.add(shelfLight); group.userData.zoomLabel = 'PR Archive — Merged Contributions'; group.userData.baseY = position.y; group.userData.floatPhase = bookshelfGroups.length * Math.PI; group.userData.floatSpeed = 0.17 + bookshelfGroups.length * 0.06; scene.add(group); bookshelfGroups.push(group); } export async function initBookshelves() { let prs = await fetchMergedPRs(20); if (prs.length === 0) { prs = [ { prNum: 324, title: 'Model training status — LoRA adapters' }, { prNum: 323, title: 'The Oath — interactive SOUL.md reading' }, { prNum: 320, title: 'Hermes session save/load' }, { prNum: 304, title: 'Session export as markdown' }, { prNum: 303, title: 'Procedural Web Audio ambient soundtrack' }, { prNum: 301, title: 'Warp tunnel effect for portals' }, { prNum: 296, title: 'Procedural terrain for floating island' }, { prNum: 294, title: 'Northern lights flash on PR merge' }, ]; } // Duplicate podcast handler removed — it was in original but is handled in audio.js // The original code had a duplicate podcast-toggle listener inside initBookshelves. Omitted. document.getElementById('podcast-error').style.display = 'none'; if (prs.length === 0) return; const mid = Math.ceil(prs.length / 2); buildBookshelf( prs.slice(0, mid), new THREE.Vector3(-8.5, 1.5, -4.5), Math.PI * 0.1, ); if (prs.slice(mid).length > 0) { buildBookshelf( prs.slice(mid), new THREE.Vector3(8.5, 1.5, -4.5), -Math.PI * 0.1, ); } }