Stand-in import of elffuss/code
One parentless commit with only the files that the open seed tasks touch and the modules they import. The full squashed import (SPEC 8.6) is a later step: this tree is not the cutoff tree. T2T-StandIn-Source-Commit: 43f95b7333efe73aeaebf806023f17bffea27aba
This commit is contained in:
668
web/js/vcc/builder.js
Normal file
668
web/js/vcc/builder.js
Normal file
@@ -0,0 +1,668 @@
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user