Spaces:
Running
Running
File size: 7,306 Bytes
b30b7c5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | // Vista «Ciudad 3D» — el proyecto abierto renderizado con el MOTOR REAL de
// VibeCodeViewer (Sendery/VibeCodeViewer, Apache-2.0), vendorizado en js/vcc.
// Aquí solo hacemos de glue: escaneamos la carpeta abierta al árbol empaquetado
// que espera buildModel, montamos el render (cámara + OrbitControls + bloom) y
// conectamos el raycast → abrir fichero en Monaco. La geometría (ciudad anidada
// distrito→edificio→planta→…, cristal fresnel, fachadas con el árbol impreso,
// suelo de circuito, leyenda) es la del builder original, sin tocar.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import * as code from './tools/code.js';
import { buildModel } from './vcc/model.js';
import { buildCity } from './vcc/builder.js';
import { THEME } from './vcc/theme.js';
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
const MAX_FILES = 8000; // tope de seguridad para no colgar el navegador en monorepos
let disposer = null;
// Escanea el handle del proyecto al formato empaquetado de VibeCodeViewer:
// dir = [nombre, [subdirs], [ficheros]] · fichero = [nombre, bytes]
async function scanTree(dir, budget) {
const dirs = [], files = [];
for await (const e of dir.values()) {
if (IGNORE.has(e.name)) continue;
(e.kind === 'directory' ? dirs : files).push(e);
}
dirs.sort((a, b) => a.name.localeCompare(b.name));
files.sort((a, b) => a.name.localeCompare(b.name));
const outFiles = [];
for (const f of files) {
if (budget.n >= MAX_FILES) break;
let size = 0; try { size = (await f.getFile()).size; } catch { /* ilegible */ }
outFiles.push([f.name, size]); budget.n++;
}
const outDirs = [];
for (const d of dirs) { if (budget.n >= MAX_FILES) break; outDirs.push(await scanTree(d, budget)); }
return [dir.name, outDirs, outFiles];
}
export async function renderCity(container, onOpenFile) {
disposeCity();
const root = code.handle();
if (!root) { container.innerHTML = '<div class="view-loading">Abre un proyecto primero.</div>'; return; }
container.innerHTML = '<div class="view-loading">Construyendo la metrópolis del proyecto…</div>';
const budget = { n: 0 };
const tree = await scanTree(root, budget);
const model = buildModel(tree, null);
if (!model.files.length) { container.innerHTML = '<div class="view-loading">Proyecto vacío.</div>'; return; }
container.innerHTML =
'<canvas id="city-canvas"></canvas>' +
'<div class="view-head">Ciudad 3D · ' + model.files.length + (budget.n >= MAX_FILES ? '+' : '') +
' ficheros en ' + model.districts.length + ' distritos' +
'<span class="view-hint">arrastra para orbitar · rueda para zoom · clic en un edificio → abrir</span></div>';
const canvas = container.querySelector('#city-canvas');
const W = container.clientWidth || 800, H = container.clientHeight || 600;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(W, H, false);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.setClearColor(THEME.background, 1);
const scene = new THREE.Scene();
scene.background = new THREE.Color(THEME.background);
const extent = Math.max(model.city.box.sx, model.city.box.sz);
scene.fog = new THREE.FogExp2(THEME.fog, 0.62 / extent);
const camera = new THREE.PerspectiveCamera(55, W / H, 0.1, extent * 14);
camera.position.set(extent * 0.7, extent * 0.6, extent * 0.7);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.maxPolarAngle = Math.PI * 0.495; // no bajar del suelo
controls.target.set(0, extent * 0.04, 0);
controls.update();
// pipeline con bloom (la receta del builder: HDR + bloom solo en lo resaltado)
const composer = new EffectComposer(renderer);
composer.setSize(W, H);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(W, H), THEME.bloom.strength, 0.25, THEME.bloom.threshold);
composer.addPass(bloom);
composer.addPass(new OutputPass());
const city = buildCity(scene, model, THEME);
// ── interacción: hover resalta edificios, clic abre el fichero ────────────
const ray = new THREE.Raycaster(), ptr = new THREE.Vector2();
let hovered = null, lastHover = 0, downXY = null;
const setPtr = e => { const r = canvas.getBoundingClientRect(); ptr.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); };
const onMove = e => {
setPtr(e);
const now = performance.now(); if (now - lastHover < 50) return; lastHover = now;
ray.setFromCamera(ptr, camera);
const b = ray.intersectObjects(city.shells)[0]?.object.userData.building ?? null;
if (b !== hovered) {
if (hovered) city.paintNode(hovered, 0);
hovered = b;
if (b) city.paintNode(b, 0.16);
canvas.style.cursor = b ? 'pointer' : 'grab';
}
};
const onDown = e => { downXY = [e.clientX, e.clientY]; };
const onUp = e => {
if (!downXY) return;
const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]); downXY = null;
if (moved > 5) return; // fue un arrastre de cámara
setPtr(e); ray.setFromCamera(ptr, camera);
const fileHit = ray.intersectObject(city.filesMesh)[0];
const shellHit = ray.intersectObjects(city.shells)[0];
if (fileHit && (!shellHit || fileHit.distance < shellHit.distance + 2)) {
const f = model.files[fileHit.instanceId];
if (f) onOpenFile(f.rel);
} else if (shellHit) {
// clic en un edificio (carpeta): centra la cámara en él
const b = shellHit.object.userData.building;
controls.target.set(b.box.cx, b.box.cy, b.box.cz);
}
};
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerdown', onDown);
canvas.addEventListener('pointerup', onUp);
const onResize = () => {
const w = container.clientWidth || W, h = container.clientHeight || H;
renderer.setSize(w, h, false); composer.setSize(w, h); bloom.resolution.set(w, h);
camera.aspect = w / h; camera.updateProjectionMatrix();
};
window.addEventListener('resize', onResize);
let raf = null;
const loop = () => { raf = requestAnimationFrame(loop); controls.update(); composer.render(); };
loop();
disposer = () => {
if (raf) cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerdown', onDown);
canvas.removeEventListener('pointerup', onUp);
controls.dispose();
city.dispose();
composer.dispose?.();
renderer.dispose();
};
}
export function disposeCity() { if (disposer) { disposer(); disposer = null; } }
|