Spaces:
Running
Running
| // Construcción de la escena: ciudad de cristal nítida. | |
| // Receta anti-difuminado: aristas finas con colores HDR (el bloom solo se | |
| // dispara en lo resaltado), cristal con fresnel de opacidad mínima y nada | |
| // de volúmenes aditivos permanentes. | |
| // Toda la paleta viene del tema activo (src/themes.js); los temas claros | |
| // usan blending normal en lugar de aditivo. | |
| import * as THREE from 'three'; | |
| function lerp3(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; } | |
| function configureCanvasTexture(tx) { | |
| tx.colorSpace = THREE.SRGBColorSpace; | |
| tx.anisotropy = 8; | |
| // Las texturas son texto fino sobre fondo transparente. Sin mipmaps, los | |
| // píxeles transparentes no se mezclan con negro al orbitar y solaparse. | |
| tx.generateMipmaps = false; | |
| tx.minFilter = THREE.LinearFilter; | |
| tx.magFilter = THREE.LinearFilter; | |
| return tx; | |
| } | |
| // 12 aristas de una caja → 24 vértices | |
| const EDGE_PAIRS = [ | |
| [0, 1], [1, 3], [3, 2], [2, 0], | |
| [4, 5], [5, 7], [7, 6], [6, 4], | |
| [0, 4], [1, 5], [2, 6], [3, 7], | |
| ]; | |
| function corners(b) { | |
| const x0 = b.cx - b.sx / 2, x1 = b.cx + b.sx / 2; | |
| const y0 = b.cy - b.sy / 2, y1 = b.cy + b.sy / 2; | |
| const z0 = b.cz - b.sz / 2, z1 = b.cz + b.sz / 2; | |
| return [ | |
| [x0, y0, z0], [x1, y0, z0], [x0, y0, z1], [x1, y0, z1], | |
| [x0, y1, z0], [x1, y1, z0], [x0, y1, z1], [x1, y1, z1], | |
| ]; | |
| } | |
| // ── suelo: placa de circuito impreso ────────────────────────────────────── | |
| function circuitTexture(theme) { | |
| const T = theme.ground; | |
| const S = 1024; | |
| const cv = document.createElement('canvas'); | |
| cv.width = cv.height = S; | |
| const g = cv.getContext('2d'); | |
| g.fillStyle = T.bg; | |
| g.fillRect(0, 0, S, S); | |
| g.strokeStyle = T.grid; | |
| g.lineWidth = 1; | |
| for (let i = 0; i <= S; i += 64) { | |
| g.beginPath(); g.moveTo(i, 0); g.lineTo(i, S); g.stroke(); | |
| g.beginPath(); g.moveTo(0, i); g.lineTo(S, i); g.stroke(); | |
| } | |
| const rnd = (() => { let s = 1234567; return () => (s = (s * 16807) % 2147483647) / 2147483647; })(); | |
| for (let i = 0; i < 260; i++) { | |
| const isA = rnd() < 0.58; | |
| const a = 0.18 + rnd() * 0.5; | |
| const [tr, tg, tb] = isA ? T.traceA : T.traceB; | |
| g.strokeStyle = `rgba(${tr}, ${tg}, ${tb}, ${isA ? a : a * 0.9})`; | |
| g.lineWidth = rnd() < 0.12 ? 3 : 1.4; | |
| let x = rnd() * S, y = rnd() * S; | |
| g.beginPath(); g.moveTo(x, y); | |
| const segs = 2 + Math.floor(rnd() * 4); | |
| for (let sg = 0; sg < segs; sg++) { | |
| const len = 24 + rnd() * 150; | |
| if (rnd() < 0.5) x += rnd() < 0.5 ? len : -len; else y += rnd() < 0.5 ? len : -len; | |
| g.lineTo(x, y); | |
| } | |
| g.stroke(); | |
| const [pr, pg, pb] = isA ? T.padA : T.padB; | |
| g.fillStyle = `rgba(${pr}, ${pg}, ${pb}, ${a + 0.2})`; | |
| const ps = 2 + rnd() * 3; | |
| g.fillRect(x - ps / 2, y - ps / 2, ps, ps); | |
| } | |
| const tx = new THREE.CanvasTexture(cv); | |
| tx.wrapS = tx.wrapT = THREE.RepeatWrapping; | |
| tx.colorSpace = THREE.SRGBColorSpace; | |
| tx.anisotropy = 8; | |
| return tx; | |
| } | |
| // ── cristal con fresnel (bordes algo más vivos, centro casi invisible) ──── | |
| function glassMaterial(theme, dim = 1, solid = 0) { | |
| return new THREE.ShaderMaterial({ | |
| uniforms: { | |
| uColor: { value: new THREE.Color(...theme.glass) }, | |
| uHi: { value: new THREE.Color(...theme.glassHi) }, | |
| uGlow: { value: 0 }, | |
| uDim: { value: dim }, | |
| uSolid: { value: solid }, | |
| }, | |
| vertexShader: /* glsl */` | |
| varying vec3 vN, vV; | |
| void main() { | |
| vec4 mv = modelViewMatrix * vec4(position, 1.0); | |
| vN = normalMatrix * normal; | |
| vV = -mv.xyz; | |
| gl_Position = projectionMatrix * mv; | |
| }`, | |
| fragmentShader: /* glsl */` | |
| uniform vec3 uColor; | |
| uniform vec3 uHi; | |
| uniform float uGlow; | |
| uniform float uDim; | |
| uniform float uSolid; | |
| varying vec3 vN, vV; | |
| void main() { | |
| float fr = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.6); | |
| vec3 col = mix(uColor, uHi, uGlow); | |
| float alpha = (0.022 + fr * 0.12 + uSolid * 0.12) * uDim + uGlow * 0.05; | |
| gl_FragColor = vec4(col * ((0.5 + fr * 1.9) * uDim + uGlow), alpha); | |
| }`, | |
| transparent: true, | |
| depthWrite: false, | |
| side: THREE.DoubleSide, | |
| }); | |
| } | |
| // ── fachada: árbol de ficheros impreso sobre el cristal ─────────────────── | |
| const FACADE_MAX_LINES = 150; | |
| function facadeLines(building) { | |
| const lines = []; | |
| let overflow = false; | |
| (function walk(node, depth) { | |
| if (lines.length > FACADE_MAX_LINES) { overflow = true; return; } | |
| lines.push({ text: node.name + '/', depth, key: 'n' + node.id }); | |
| for (const c of node.children) walk(c, depth + 1); | |
| for (const fid of node.fileIds) { | |
| if (lines.length > FACADE_MAX_LINES) { overflow = true; return; } | |
| const f = building.modelFiles[fid]; | |
| lines.push({ text: f.name, depth: depth + 1, key: 'f' + f.id }); | |
| } | |
| })(building, 0); | |
| if (overflow) lines.push({ text: '· · ·', depth: 1, key: '' }); | |
| return lines; | |
| } | |
| function drawFacade(building, highlight, theme) { | |
| const cv = building.facadeCanvas; | |
| if (!cv) return; // edificio sin fachada (recorte en ciudades enormes) | |
| const g = cv.getContext('2d'); | |
| const W = cv.width, H = cv.height; | |
| g.clearRect(0, 0, W, H); | |
| const lines = building.facadeLineCache; | |
| const fs = Math.max(9, Math.min(30, (H - 60) / (lines.length + 1))); | |
| g.font = `${fs}px "JetBrains Mono", "Cascadia Code", monospace`; | |
| g.textBaseline = 'top'; | |
| let y = 26; | |
| for (const ln of lines) { | |
| const x = 22 + ln.depth * fs * 1.1; | |
| const hot = highlight && highlight.has(ln.key); | |
| if (hot) { | |
| g.shadowColor = theme.facade.hotGlow; | |
| g.shadowBlur = 14; | |
| g.fillStyle = theme.facade.hotBg; | |
| g.fillRect(x - 6, y - 3, g.measureText(ln.text).width + 12, fs + 6); | |
| g.fillStyle = theme.facade.hot; | |
| } else { | |
| g.shadowBlur = 0; | |
| g.fillStyle = theme.facade.text; | |
| } | |
| g.fillText(ln.text, x, y); | |
| y += fs * 1.18; | |
| if (y > H - fs) break; | |
| } | |
| g.shadowBlur = 0; | |
| building.facadeTexture.needsUpdate = true; | |
| } | |
| function buildFacade(building, model, group, theme) { | |
| building.modelFiles = model.files; | |
| building.facadeLineCache = facadeLines(building); | |
| const b = building.box; | |
| const H = 1024; | |
| const W = Math.round(Math.min(2048, Math.max(256, H * (b.sx / b.sy)))); | |
| const cv = document.createElement('canvas'); | |
| cv.width = W; cv.height = H; | |
| building.facadeCanvas = cv; | |
| const tx = configureCanvasTexture(new THREE.CanvasTexture(cv)); | |
| building.facadeTexture = tx; | |
| drawFacade(building, null, theme); | |
| const mat = new THREE.MeshBasicMaterial({ | |
| map: tx, transparent: true, alphaTest: 0.025, depthWrite: false, side: THREE.FrontSide, | |
| blending: theme.light ? THREE.NormalBlending : THREE.AdditiveBlending, | |
| }); | |
| mat.userData.vccKind = 'facade'; | |
| const E = 0.14; | |
| const faces = [ | |
| { w: b.sx, pos: [b.cx, b.cy, b.cz + b.sz / 2 + E], rot: 0 }, | |
| { w: b.sx, pos: [b.cx, b.cy, b.cz - b.sz / 2 - E], rot: Math.PI }, | |
| { w: b.sz, pos: [b.cx + b.sx / 2 + E, b.cy, b.cz], rot: Math.PI / 2 }, | |
| { w: b.sz, pos: [b.cx - b.sx / 2 - E, b.cy, b.cz], rot: -Math.PI / 2 }, | |
| ]; | |
| for (const f of faces) { | |
| const plane = new THREE.Mesh(new THREE.PlaneGeometry(f.w, b.sy), mat); | |
| plane.position.set(...f.pos); | |
| plane.rotation.y = f.rot; | |
| plane.renderOrder = 3; | |
| group.add(plane); | |
| } | |
| } | |
| // ── leyenda: nombres serigrafiados (suelo de distrito y tejado) ─────────── | |
| function labelTexture(text, color) { | |
| const fs = 96, pad = 40; | |
| const probe = document.createElement('canvas').getContext('2d'); | |
| probe.font = `bold ${fs}px "JetBrains Mono", monospace`; | |
| const tw = probe.measureText(text).width; | |
| const cv = document.createElement('canvas'); | |
| cv.width = Math.ceil(tw + pad * 2 + 220); | |
| cv.height = 190; | |
| const g = cv.getContext('2d'); | |
| g.font = `bold ${fs}px "JetBrains Mono", monospace`; | |
| g.textBaseline = 'middle'; | |
| // pista de circuito: línea con pads a ambos lados del texto | |
| g.strokeStyle = color; g.globalAlpha = 0.65; g.lineWidth = 5; | |
| const y = cv.height / 2; | |
| g.beginPath(); g.moveTo(14, y); g.lineTo(pad + 60, y); g.stroke(); | |
| g.beginPath(); g.moveTo(pad + 100 + tw, y); g.lineTo(cv.width - 14, y); g.stroke(); | |
| g.fillStyle = color; | |
| for (const px of [14, pad + 60, pad + 100 + tw, cv.width - 26]) g.fillRect(px - 7, y - 7, 14, 14); | |
| g.globalAlpha = 0.92; | |
| g.shadowColor = color; g.shadowBlur = 16; | |
| g.fillText(text, pad + 80, y); | |
| const tx = configureCanvasTexture(new THREE.CanvasTexture(cv)); | |
| return { tx, aspect: cv.width / cv.height }; | |
| } | |
| const LEGEND_MAX_BUILDINGS = 400; | |
| function buildLegend(model, labeled, theme) { | |
| const legend = new THREE.Group(); | |
| const add = (text, color, width, pos, flat) => { | |
| const { tx, aspect } = labelTexture(text, color); | |
| const w = Math.min(width, aspect * 8); | |
| const mesh = new THREE.Mesh( | |
| new THREE.PlaneGeometry(w, w / aspect), | |
| new THREE.MeshBasicMaterial({ | |
| map: tx, transparent: true, alphaTest: 0.025, depthWrite: false, | |
| blending: theme.light ? THREE.NormalBlending : THREE.AdditiveBlending, | |
| }), | |
| ); | |
| mesh.material.userData.vccKind = 'label'; | |
| if (flat) mesh.rotation.x = -Math.PI / 2; | |
| mesh.position.set(...pos); | |
| mesh.renderOrder = 4; | |
| legend.add(mesh); | |
| }; | |
| for (const di of model.districts) { | |
| add(di.name, theme.labels.district, di.box.sx * 0.85, | |
| [di.box.cx, 0.3, di.box.cz + di.box.sz / 2 - 2], true); | |
| } | |
| // urbanizaciones: mismo serigrafiado de suelo que los distritos, en tono | |
| // intermedio; en monorepos se etiquetan las de mayor superficie | |
| const urbs = [...model.urbs].sort((a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz); | |
| for (const u of urbs.slice(0, LEGEND_MAX_BUILDINGS)) { | |
| add(u.name, theme.labels.urb, u.box.sx * 0.8, | |
| [u.box.cx, 0.32, u.box.cz + u.box.sz / 2 - 1.4], true); | |
| } | |
| for (const b of labeled.slice(0, LEGEND_MAX_BUILDINGS)) { | |
| if (b.isBasement) continue; // su tejado es la calle: sin etiqueta | |
| add(b.name, theme.labels.building, b.box.sx * 0.95, | |
| [b.box.cx, b.box.cy + b.box.sy / 2 + 0.12, b.box.cz], true); | |
| } | |
| return legend; | |
| } | |
| // ── ciudad completa ─────────────────────────────────────────────────────── | |
| export function buildCity(scene, model, theme) { | |
| const group = new THREE.Group(); | |
| scene.add(group); | |
| // paleta del tema; en temas claros el blending aditivo no funciona | |
| const ROLE_COLOR = theme.roleColor; | |
| const HI = theme.hi; | |
| const FILE_BASE = theme.fileBase; | |
| const FILE_HI = theme.fileHi; | |
| const BLEND = theme.light ? THREE.NormalBlending : THREE.AdditiveBlending; | |
| // suelo | |
| const extent = Math.max(model.city.box.sx, model.city.box.sz); | |
| const ground = new THREE.Mesh( | |
| new THREE.PlaneGeometry(extent * 7, extent * 7), | |
| // semitransparente y a doble cara: los sótanos (ficheros ignorados) | |
| // se intuyen a través del plano de circuitos. depthWrite: false — era el | |
| // único material que escribía profundidad y, con el rango near/far de la | |
| // ciudad, las plotas y serigrafías a ras de suelo hacían z-fighting | |
| // (parpadeo en franjas) al mover la cámara | |
| new THREE.MeshBasicMaterial({ | |
| map: circuitTexture(theme), transparent: true, opacity: 0.8, side: THREE.DoubleSide, | |
| depthWrite: false, | |
| // separa el suelo en el z-buffer de plotas y serigrafías a ras de | |
| // suelo (evita el z-fighting cuando el modo opaco activa depthWrite) | |
| polygonOffset: true, polygonOffsetFactor: 2, polygonOffsetUnits: 2, | |
| }), | |
| ); | |
| ground.material.map.repeat.set(7, 7); | |
| ground.rotation.x = -Math.PI / 2; | |
| ground.position.y = -0.05; | |
| group.add(ground); | |
| // aristas de las cajas en un único buffer con rangos por nodo. | |
| // LOD por presupuesto para monorepos: decenas de miles de wireframes | |
| // aditivos se acumulan en blanco. Distritos y edificios siempre dibujan; | |
| // del interior se conservan las cajas más grandes y menos profundas. | |
| const LINE_BUDGET = 12000; | |
| const ROLE_DEPTH = { distrito: 0, urbanizacion: 0, edificio: 0, planta: 1, apartamento: 2, habitacion: 3, archivador: 4, cajon: 5 }; | |
| const candidates = model.nodes.filter(n => n.role !== 'ciudad' && n.box); | |
| let boxNodes = candidates; | |
| if (candidates.length > LINE_BUDGET) { | |
| const score = (n) => (n.role === 'distrito' || n.role === 'urbanizacion' || n.role === 'edificio') | |
| ? Infinity | |
| : Math.max(n.box.sx, n.box.sy, n.box.sz) - ROLE_DEPTH[n.role] * 3; | |
| boxNodes = candidates.sort((a, b) => score(b) - score(a)).slice(0, LINE_BUDGET); | |
| } | |
| const positions = new Float32Array(boxNodes.length * 24 * 3); | |
| const colors = new Float32Array(boxNodes.length * 24 * 3); | |
| // brillo de arista normalizado por edificio: el interior de un edificio | |
| // reparte un presupuesto de luz constante (mismo principio que las celdas) | |
| const boxesPerBuilding = new Map(); | |
| for (const n of boxNodes) { | |
| if (n.building && n.building !== n) { | |
| boxesPerBuilding.set(n.building.id, (boxesPerBuilding.get(n.building.id) ?? 0) + 1); | |
| } | |
| } | |
| // …y una atenuación global suave en ciudades con miles de edificios: | |
| // uniforme (todos los edificios conservan el mismo aspecto entre sí), | |
| // solo evita que la acumulación aditiva a vista de pájaro se queme | |
| const cityDim = Math.min(1, Math.max(0.45, Math.sqrt(900 / Math.max(model.buildings.length, 1)))); | |
| for (const n of boxNodes) { | |
| const local = (!n.building || n.building === n) | |
| ? 1 | |
| : Math.min(1, Math.max(0.12, 30 / (boxesPerBuilding.get(n.building.id) ?? 1))); | |
| // las plotas (distrito y urbanización) son solo perímetro: siempre nítidas | |
| n.lineDim = (n.role === 'distrito' || n.role === 'urbanizacion') ? 1 : local * cityDim; | |
| } | |
| let v = 0; | |
| for (const node of boxNodes) { | |
| node.lineStart = v; | |
| const cs = corners(node.box); | |
| const base = (ROLE_COLOR[node.role] ?? ROLE_COLOR.cajon).map(c => c * node.lineDim); | |
| node.baseColor = base; | |
| for (const [a, b] of EDGE_PAIRS) { | |
| for (const p of [cs[a], cs[b]]) { | |
| positions[v * 3] = p[0]; positions[v * 3 + 1] = p[1]; positions[v * 3 + 2] = p[2]; | |
| colors[v * 3] = base[0]; colors[v * 3 + 1] = base[1]; colors[v * 3 + 2] = base[2]; | |
| v++; | |
| } | |
| } | |
| } | |
| const lineGeo = new THREE.BufferGeometry(); | |
| lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| lineGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| lineGeo.computeBoundingSphere(); | |
| // opacidad adaptativa: cuantas más cajas, más tenue cada arista | |
| const lineOpacity = Math.min(0.95, Math.max(0.4, Math.sqrt(4000 / Math.max(boxNodes.length, 1)))); | |
| const lineBaseOpacity = lineOpacity; | |
| const lines = new THREE.LineSegments(lineGeo, new THREE.LineBasicMaterial({ | |
| vertexColors: true, transparent: true, opacity: lineOpacity, | |
| blending: BLEND, depthWrite: false, | |
| })); | |
| lines.renderOrder = 2; | |
| group.add(lines); | |
| // carcasas de cristal por edificio (también sirven para el raycast). | |
| // En ciudades enormes las fachadas con texto se reservan a los edificios | |
| // más grandes (una textura canvas por edificio no escala a miles). | |
| const byArea = [...model.buildings].sort( | |
| (a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz); | |
| const facadeSet = new Set(byArea.slice(0, 250)); | |
| const shells = []; | |
| for (const b of model.buildings) { | |
| const mat = glassMaterial(theme, cityDim, b.onlyBasement ? 1 : 0); | |
| mat.userData.baseDim = cityDim; | |
| const mesh = new THREE.Mesh(new THREE.BoxGeometry(b.box.sx, b.box.sy, b.box.sz), mat); | |
| mesh.position.set(b.box.cx, b.box.cy, b.box.cz); | |
| mesh.renderOrder = 1; | |
| mesh.userData.building = b; | |
| b.shell = mesh; | |
| shells.push(mesh); | |
| group.add(mesh); | |
| if (facadeSet.has(b)) buildFacade(b, model, group, theme); | |
| } | |
| // forjados de las plantas (losa fina, casi invisible); con tope en | |
| // monorepos, priorizando las plantas de mayor superficie | |
| let slabNodes = model.nodes.filter(n => n.role === 'planta'); | |
| if (slabNodes.length > 4000) { | |
| slabNodes = slabNodes | |
| .sort((a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz) | |
| .slice(0, 4000); | |
| } | |
| if (slabNodes.length) { | |
| const slabGeo = new THREE.BoxGeometry(1, 1, 1); | |
| const slabMat = new THREE.MeshBasicMaterial({ | |
| color: new THREE.Color(...theme.slab), transparent: true, | |
| opacity: Math.min(0.045, 0.045 * Math.sqrt(1500 / slabNodes.length)), | |
| depthWrite: false, blending: BLEND, | |
| }); | |
| const slabs = new THREE.InstancedMesh(slabGeo, slabMat, slabNodes.length); | |
| const m = new THREE.Matrix4(); | |
| slabNodes.forEach((n, i) => { | |
| m.makeScale(n.box.sx, 0.12, n.box.sz); | |
| m.setPosition(n.box.cx, n.box.cy - n.box.sy / 2, n.box.cz); | |
| slabs.setMatrixAt(i, m); | |
| }); | |
| slabs.computeBoundingSphere(); | |
| slabs.renderOrder = 1; | |
| group.add(slabs); | |
| } | |
| // celdas de fichero (instanciadas) | |
| const fileGeo = new THREE.BoxGeometry(1, 1, 1); | |
| const fileMat = new THREE.MeshBasicMaterial({ | |
| transparent: true, opacity: 0.9, depthWrite: false, blending: BLEND, | |
| }); | |
| const fileBaseOpacity = fileMat.opacity; | |
| const filesMesh = new THREE.InstancedMesh(fileGeo, fileMat, model.files.length); | |
| { | |
| const m = new THREE.Matrix4(); | |
| const c = new THREE.Color(); | |
| for (const f of model.files) { | |
| m.makeScale(f.box.sx, f.box.sy, f.box.sz); | |
| m.setPosition(f.box.cx, f.box.cy, f.box.cz); | |
| filesMesh.setMatrixAt(f.id, m); | |
| filesMesh.setColorAt(f.id, c.setRGB(...FILE_BASE)); | |
| } | |
| filesMesh.instanceColor.needsUpdate = true; | |
| filesMesh.computeBoundingSphere(); | |
| } | |
| filesMesh.renderOrder = 2; | |
| group.add(filesMesh); | |
| // cajas de relleno para los niveles encendidos (pool reutilizable) | |
| const fillPool = []; | |
| for (let i = 0; i < 10; i++) { | |
| const mesh = new THREE.Mesh( | |
| new THREE.BoxGeometry(1, 1, 1), | |
| new THREE.MeshBasicMaterial({ | |
| color: new THREE.Color(...theme.fill), transparent: true, opacity: 0, | |
| depthWrite: false, blending: BLEND, | |
| }), | |
| ); | |
| mesh.visible = false; | |
| mesh.renderOrder = 2; | |
| group.add(mesh); | |
| fillPool.push(mesh); | |
| } | |
| // ── API de iluminación ────────────────────────────────────────────── | |
| const touchedNodes = new Set(); | |
| const touchedFiles = new Set(); | |
| const colorAttr = lineGeo.getAttribute('color'); | |
| const tmpColor = new THREE.Color(); | |
| // atenuación normalizada por edificio: cada edificio reparte un presupuesto | |
| // de luz constante entre sus celdas — así un rascacielos con 50k ficheros | |
| // no se quema y uno con 20 sigue viéndose igual que antes | |
| const filesPerBuilding = new Map(); | |
| for (const f of model.files) { | |
| const b = f.node.building; | |
| if (b) filesPerBuilding.set(b.id, (filesPerBuilding.get(b.id) ?? 0) + 1); | |
| } | |
| const dimOf = (f) => { | |
| const n = filesPerBuilding.get(f.node.building?.id) ?? 1; | |
| return Math.min(1, Math.max(0.05, 350 / n)) * cityDim; | |
| }; | |
| // color base por fichero, reescribible por los modos de coloreado | |
| const fileBase = model.files.map(f => FILE_BASE.map(c => c * dimOf(f))); | |
| function paintNode(node, glow, hi = HI) { | |
| // los nodos diminutos no tienen aristas (LOD): solo carcasa/relleno | |
| if (node.lineStart !== undefined) { | |
| const col = lerp3(node.baseColor, hi, Math.pow(glow, 1.35)); | |
| for (let i = node.lineStart; i < node.lineStart + 24; i++) { | |
| colorAttr.array[i * 3] = col[0]; | |
| colorAttr.array[i * 3 + 1] = col[1]; | |
| colorAttr.array[i * 3 + 2] = col[2]; | |
| } | |
| // subida parcial: en monorepos el buffer completo pesa decenas de MB | |
| colorAttr.addUpdateRange(node.lineStart * 3, 72); | |
| colorAttr.needsUpdate = true; | |
| } | |
| if (node.shell) node.shell.material.uniforms.uGlow.value = glow * 0.3; | |
| if (glow > 0.01) touchedNodes.add(node); else touchedNodes.delete(node); | |
| } | |
| function paintFile(fileId, glow, hi = FILE_HI) { | |
| const col = lerp3(fileBase[fileId], hi, glow); | |
| filesMesh.setColorAt(fileId, tmpColor.setRGB(...col)); | |
| filesMesh.instanceColor.addUpdateRange(fileId * 3, 3); | |
| filesMesh.instanceColor.needsUpdate = true; | |
| if (glow > 0.01) touchedFiles.add(fileId); else touchedFiles.delete(fileId); | |
| } | |
| function clearGlows() { | |
| for (const n of [...touchedNodes]) paintNode(n, 0); | |
| for (const f of [...touchedFiles]) paintFile(f, 0); | |
| for (const mesh of fillPool) mesh.visible = false; | |
| } | |
| // haces del modo Blast: del fichero seleccionado a sus relacionados, | |
| // con pulsos viajando por la línea como paquetes de datos | |
| let blastBeams = null; | |
| function setBlastBeams(pairs) { | |
| if (blastBeams) { | |
| group.remove(blastBeams); | |
| blastBeams.geometry.dispose(); | |
| blastBeams.material.dispose(); | |
| blastBeams = null; | |
| } | |
| if (!pairs?.length) return; | |
| // pairs: { a, b, impact } — impact=true: b depende del seleccionado | |
| // (consecuencias de un cambio); el pulso siempre viaja en el sentido de | |
| // la propagación: hacia fuera en impactos, hacia dentro en dependencias | |
| const pos = new Float32Array(pairs.length * 6); | |
| const dist = new Float32Array(pairs.length * 2); | |
| const dir = new Float32Array(pairs.length * 2); | |
| pairs.forEach(({ a, b, impact }, i) => { | |
| pos.set(a, i * 6); | |
| pos.set(b, i * 6 + 3); | |
| const len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); | |
| dist[i * 2] = impact ? 0 : len; | |
| dist[i * 2 + 1] = impact ? len : 0; | |
| dir[i * 2] = dir[i * 2 + 1] = impact ? 1 : 0; | |
| }); | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); | |
| geo.setAttribute('aDist', new THREE.BufferAttribute(dist, 1)); | |
| geo.setAttribute('aDir', new THREE.BufferAttribute(dir, 1)); | |
| blastBeams = new THREE.LineSegments(geo, new THREE.ShaderMaterial({ | |
| uniforms: { | |
| uTime: { value: 0 }, | |
| uColor: { value: new THREE.Color(...theme.beam) }, | |
| uColorIn: { value: new THREE.Color(...(theme.beamIn ?? theme.beam)) }, | |
| }, | |
| vertexShader: /* glsl */` | |
| attribute float aDist; | |
| attribute float aDir; | |
| varying float vD; | |
| varying float vDir; | |
| void main() { | |
| vD = aDist; | |
| vDir = aDir; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| }`, | |
| fragmentShader: /* glsl */` | |
| uniform float uTime; | |
| uniform vec3 uColor; | |
| uniform vec3 uColorIn; | |
| varying float vD; | |
| varying float vDir; | |
| void main() { | |
| // tren de pulsos avanzando por la línea (cada ~8 unidades); la | |
| // línea base se mantiene bien visible entre pulsos | |
| float w = fract(vD * 0.125 - uTime * 1.4); | |
| float pulse = exp(-pow((w - 0.2) * 6.0, 2.0)); | |
| vec3 col = mix(uColor, uColorIn, vDir); | |
| gl_FragColor = vec4(col * (0.6 + pulse * 2.4), 0.5 + pulse * 0.5); | |
| }`, | |
| transparent: true, | |
| blending: BLEND, | |
| depthWrite: false, | |
| })); | |
| blastBeams.renderOrder = 3; | |
| group.add(blastBeams); | |
| } | |
| function tickBeams(timeMs) { | |
| if (blastBeams) blastBeams.material.uniforms.uTime.value = timeMs / 1000; | |
| } | |
| // modos de coloreado: reescribe los colores base de nodos y ficheros | |
| // (fileColorFn/nodeColorFn → [r,g,b] o null para el color por defecto) | |
| function setModeColors(fileColorFn, nodeColorFn) { | |
| for (const node of boxNodes) { | |
| const col = nodeColorFn?.(node) ?? (ROLE_COLOR[node.role] ?? ROLE_COLOR.cajon); | |
| node.baseColor = col.map(c => c * (node.lineDim ?? 1)); | |
| paintNode(node, 0); | |
| } | |
| for (const f of model.files) { | |
| const dim = dimOf(f); | |
| const col = fileColorFn?.(f) ?? FILE_BASE; | |
| fileBase[f.id] = col.map(c => c * dim); | |
| paintFile(f.id, 0); | |
| } | |
| // se ha tocado todo: mejor una única subida completa que miles de rangos | |
| colorAttr.clearUpdateRanges(); | |
| filesMesh.instanceColor.clearUpdateRanges(); | |
| } | |
| function fillBoxes(boxesWithGlow) { | |
| fillPool.forEach((mesh, i) => { | |
| const entry = boxesWithGlow[i]; | |
| if (!entry) { mesh.visible = false; return; } | |
| const { box, glow } = entry; | |
| mesh.scale.set(box.sx, box.sy, box.sz); | |
| mesh.position.set(box.cx, box.cy, box.cz); | |
| mesh.material.opacity = 0.008 + glow * 0.045; | |
| mesh.visible = true; | |
| }); | |
| } | |
| // leyenda serigrafiada (conmutable) | |
| const legend = buildLegend(model, byArea, theme); | |
| group.add(legend); | |
| let groundOpaque = false; | |
| const groundBaseOpacity = 0.8; | |
| let visualSettings = {}; | |
| function setVisuals(v = {}) { | |
| visualSettings = { ...visualSettings, ...v }; | |
| const linesMul = visualSettings.lines ?? 1; | |
| const filesMul = visualSettings.files ?? 1; | |
| const glassMul = visualSettings.glass ?? 1; | |
| const facadeMul = visualSettings.facades ?? 1; | |
| const labelMul = visualSettings.labels ?? 1; | |
| const groundMul = visualSettings.ground ?? 1; | |
| lines.material.opacity = Math.min(1, lineBaseOpacity * linesMul); | |
| fileMat.opacity = Math.min(1, fileBaseOpacity * filesMul); | |
| if (!groundOpaque) ground.material.opacity = Math.min(1, groundBaseOpacity * groundMul); | |
| for (const shell of shells) { | |
| const mat = shell.material; | |
| mat.uniforms.uDim.value = (mat.userData.baseDim ?? cityDim) * glassMul; | |
| } | |
| group.traverse((o) => { | |
| const mats = Array.isArray(o.material) ? o.material : [o.material]; | |
| for (const mat of mats) { | |
| if (mat?.userData?.vccKind === 'facade') mat.opacity = Math.min(1, facadeMul); | |
| if (mat?.userData?.vccKind === 'label') mat.opacity = Math.min(1, labelMul); | |
| } | |
| }); | |
| } | |
| function dispose() { | |
| scene.remove(group); | |
| const geometries = new Set(); | |
| const materials = new Set(); | |
| group.traverse((o) => { | |
| if (o.geometry) geometries.add(o.geometry); | |
| for (const m of Array.isArray(o.material) ? o.material : [o.material]) { | |
| if (m) materials.add(m); | |
| } | |
| }); | |
| for (const g of geometries) g.dispose(); | |
| for (const m of materials) { | |
| m.map?.dispose?.(); | |
| m.dispose(); | |
| } | |
| } | |
| return { | |
| group, lines, shells, filesMesh, ground, legend, | |
| paintNode, paintFile, clearGlows, fillBoxes, dispose, setModeColors, setBlastBeams, tickBeams, setVisuals, | |
| setLegend: (v) => { legend.visible = v; }, | |
| // suelo opaco/translúcido. Opaco = escribe profundidad y se dibuja en la | |
| // pasada de opacos: oculta de verdad los sótanos (ficheros ignorados) | |
| setGroundOpaque: (v) => { | |
| groundOpaque = v; | |
| const m = ground.material; | |
| m.opacity = v ? 1 : Math.min(1, groundBaseOpacity * (visualSettings.ground ?? 1)); | |
| m.transparent = !v; | |
| m.depthWrite = v; | |
| m.needsUpdate = true; | |
| }, | |
| redrawFacade: (building, highlightKeys) => drawFacade(building, highlightKeys, theme), | |
| }; | |
| } | |