llm-semantic-visualizer / src /visualizer /ReliefRenderer.js
hbauzan's picture
Release v4.17.7 — sync from GitHub main
819a47f
Raw
History Blame Contribute Delete
27.8 kB
/**
* ReliefRenderer.js
* Renderizador 3D para el sub-modo "Relieve Semántico" del layout BLANKET.
*
* Desde la Fase 1 es MULTI-DOCUMENTO: mantiene un registry `Map<docId, record>`
* donde cada documento vive en su propio `THREE.Group` (posicionable con un
* offset) y conserva su matriz, modo, colormap y |max| para rebuilds reactivos.
*
* Modos de visualización por documento ('mode'):
* - 'MESH' : superficie triangulada indexada (MeshStandardMaterial + vertex colors + luces).
* - 'POINTS' : nube de puntos coloreada por valor.
* - 'RIBBONS': hilos por chunk (líneas de perfil + puntos cuadrados).
*
* Colormap por celda ('colorMode'):
* - 'RAINBOW' : arcoíris HSL por valor normalizado (default, relieve clásico).
* - 'TINT' : color sólido del documento modulado por magnitud (overlay multi-doc).
* - 'DIVERGING' : negro→rojo (+) / negro→violeta (-) por valor (mapa delta A-B).
*
* API pública (back-compat single-doc):
* renderRelief(scene, matrix, textMetadata, mode, sx, sy, sz) → documento DEFAULT
* clearRelief(scene) → limpia TODOS los documentos
* updateReliefScale(sx, sy, sz) / updateReliefThickness(t) / highlightSearch(topIdx)
* getActiveReliefObjects() / isReliefActive()
*
* API multi-documento (Fase 1):
* renderReliefDoc(scene, opts) / clearReliefDoc(scene, docId) / clearAllReliefs(scene)
* renderComparison(scene) ← orquesta los modos A/B leyendo STATE
* resolveDocByObject(object) ← raycast → documento dueño del objeto impactado
*/
import * as THREE from 'three';
import { STATE } from '../core/State.js';
import { visibleReliefDocs, getReliefDoc } from '../core/State.js';
import { alignMatrices, computeDelta, computeAbsMax } from './ReliefMath.js';
import { createLabelSprite } from './LabelHelper.js';
import { PALETTE_3D, sampleViridis } from '../engine/palette.js';
export const DEFAULT_DOC_ID = '__single__';
// ---------------------------------------------------------------------------
// Constructor de ejes 3D + leyendas para las esquinas del relieve
// ---------------------------------------------------------------------------
function _createCornerAxesGroup(arrowLength = 4.0) {
const group = new THREE.Group();
const colorX = PALETTE_3D.AXIS_X; // Red
const colorY = PALETTE_3D.AXIS_Y; // Green
const colorZ = PALETTE_3D.AXIS_Z; // Blue
const arrowX = new THREE.ArrowHelper(
new THREE.Vector3(1, 0, 0),
new THREE.Vector3(0, 0, 0),
arrowLength,
colorX,
arrowLength * 0.25,
arrowLength * 0.15
);
const arrowY = new THREE.ArrowHelper(
new THREE.Vector3(0, 1, 0),
new THREE.Vector3(0, 0, 0),
arrowLength,
colorY,
arrowLength * 0.25,
arrowLength * 0.15
);
const arrowZ = new THREE.ArrowHelper(
new THREE.Vector3(0, 0, 1),
new THREE.Vector3(0, 0, 0),
arrowLength,
colorZ,
arrowLength * 0.25,
arrowLength * 0.15
);
if (arrowX.line && arrowX.line.material) arrowX.line.material.linewidth = 2;
if (arrowY.line && arrowY.line.material) arrowY.line.material.linewidth = 2;
if (arrowZ.line && arrowZ.line.material) arrowZ.line.material.linewidth = 2;
group.add(arrowX);
group.add(arrowY);
group.add(arrowZ);
const labelX = createLabelSprite("Dimensión", arrowLength + 1.6, 0, 0, colorX);
const labelY = createLabelSprite("Valor", 0, arrowLength + 0.6, 0, colorY);
const labelZ = createLabelSprite("Chunk", 0, 0, arrowLength + 1.6, colorZ);
group.add(labelX);
group.add(labelY);
group.add(labelZ);
return {
group,
arrowX,
arrowY,
arrowZ,
labelX,
labelY,
labelZ
};
}
// ---------------------------------------------------------------------------
// Registry de documentos renderizados. Cada record:
// { id, group, mesh, cloud, lines, matrix, textMetadata, mode, absMax,
// scaleX, scaleY, scaleZ, colorMode, tint, dimensions_count }
// ---------------------------------------------------------------------------
const _docs = new Map();
// Luces compartidas (solo activas mientras exista al menos un documento MESH).
let _ambientLight = null;
let _dirLight = null;
// ---------------------------------------------------------------------------
// Colormap DIVERGENTE simétrico por VALOR — usado en RIBBONS clásico y en DELTA.
// +absMax → rojo (1,0,0) · 0 → negro · -absMax → violeta (0.55,0,0.9)
// ---------------------------------------------------------------------------
const _VIOLET = { r: 0.55, g: 0.0, b: 0.9 };
function _valueToDivergingColor(v, absMax) {
const denom = absMax > 1e-9 ? absMax : 1.0;
let t = v / denom;
if (t > 1.0) t = 1.0;
if (t < -1.0) t = -1.0;
if (t >= 0) return { r: t, g: 0.0, b: 0.0 };
const f = -t;
return { r: _VIOLET.r * f, g: _VIOLET.g * f, b: _VIOLET.b * f };
}
// ---------------------------------------------------------------------------
// Colormap perceptual Viridis por VALOR normalizado al |max| de la matriz.
// ---------------------------------------------------------------------------
function _valueToRainbow(v, absMax) {
const denom = absMax > 1e-9 ? absMax : 1.0;
let t = v / denom;
if (t > 1.0) t = 1.0;
if (t < -1.0) t = -1.0;
const t01 = (t + 1.0) * 0.5;
return sampleViridis(t01);
}
// Color sólido del documento (tint) modulado por magnitud normalizada, con un
// piso de luminancia para que las celdas planas sigan siendo visibles.
function _makeTintFn(tint) {
return (v, absMax) => {
const denom = absMax > 1e-9 ? absMax : 1.0;
const f = Math.min(1, Math.abs(v) / denom) * 0.7 + 0.3;
return { r: tint.r * f, g: tint.g * f, b: tint.b * f };
};
}
// Resuelve la función de color a partir del colorMode de un record.
function _colorFnFor(record) {
if (record.colorMode === 'DIVERGING') return _valueToDivergingColor;
if (record.colorMode === 'TINT' && record.tint) return _makeTintFn(record.tint);
return _valueToRainbow;
}
// Convierte un hex 0xRRGGBB a {r,g,b} en 0..1.
function _hexToRgb(hex) {
const c = new THREE.Color(hex);
return { r: c.r, g: c.g, b: c.b };
}
// Altura normalizada al |max|: el valor de mayor magnitud alcanza ±scaleY.
function _normHeight(y, absMax, scaleY) {
const denom = absMax > 1e-9 ? absMax : 1.0;
return (y / denom) * scaleY;
}
// ---------------------------------------------------------------------------
// Construcción de geometrías (parametrizadas por colorFn y absMax del record)
// ---------------------------------------------------------------------------
function _fillPositionsColors(matrix, scaleX, scaleY, scaleZ, absMax, colorFn, topIndices) {
const numRows = matrix.length;
const numCols = matrix[0].length;
const vertexCount = numRows * numCols;
const positions = new Float32Array(vertexCount * 3);
const colors = new Float32Array(vertexCount * 3);
const hasSearch = topIndices && Array.isArray(topIndices) && topIndices.length > 0;
for (let z = 0; z < numRows; z++) {
const isHighlighted = hasSearch && topIndices.includes(z);
for (let x = 0; x < numCols; x++) {
const vi = (z * numCols + x) * 3;
const y = matrix[z][x];
positions[vi] = x * scaleX;
positions[vi + 1] = _normHeight(y, absMax, scaleY);
positions[vi + 2] = z * scaleZ;
let r, g, b;
if (hasSearch) {
if (isHighlighted) { r = 1.0; g = 0.9; b = 0.0; }
else { r = 0.1; g = 0.15; b = 0.25; }
} else {
const col = colorFn(y, absMax);
r = col.r; g = col.g; b = col.b;
}
colors[vi] = r; colors[vi + 1] = g; colors[vi + 2] = b;
}
}
return { positions, colors, numRows, numCols };
}
function _buildMeshGeometry(matrix, scaleX, scaleY, scaleZ, absMax, colorFn, topIndices) {
const { positions, colors, numRows, numCols } =
_fillPositionsColors(matrix, scaleX, scaleY, scaleZ, absMax, colorFn, topIndices);
const indexCount = (numRows - 1) * (numCols - 1) * 6;
const indices = new Uint32Array(indexCount);
let idx = 0;
for (let z = 0; z < numRows - 1; z++) {
for (let x = 0; x < numCols - 1; x++) {
const a = z * numCols + x;
const b = z * numCols + (x + 1);
const c = (z + 1) * numCols + x;
const d = (z + 1) * numCols + (x + 1);
indices[idx++] = a; indices[idx++] = c; indices[idx++] = b;
indices[idx++] = b; indices[idx++] = c; indices[idx++] = d;
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
geometry.computeVertexNormals();
return geometry;
}
function _buildPointsGeometry(matrix, scaleX, scaleY, scaleZ, absMax, colorFn, topIndices) {
const { positions, colors } =
_fillPositionsColors(matrix, scaleX, scaleY, scaleZ, absMax, colorFn, topIndices);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
return geometry;
}
function _buildRibbonsGeometries(matrix, scaleX, scaleY, scaleZ, absMax, colorFn, topIndices) {
const { positions, colors, numRows, numCols } =
_fillPositionsColors(matrix, scaleX, scaleY, scaleZ, absMax, colorFn, topIndices);
const pointsGeometry = new THREE.BufferGeometry();
pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
pointsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const segmentCount = numRows * (numCols - 1);
const lineIndices = new Uint32Array(segmentCount * 2);
let li = 0;
for (let z = 0; z < numRows; z++) {
const base = z * numCols;
for (let x = 0; x < numCols - 1; x++) {
lineIndices[li++] = base + x;
lineIndices[li++] = base + x + 1;
}
}
const lineGeometry = new THREE.BufferGeometry();
lineGeometry.setAttribute('position', new THREE.BufferAttribute(positions.slice(), 3));
lineGeometry.setAttribute('color', new THREE.BufferAttribute(colors.slice(), 3));
lineGeometry.setIndex(new THREE.BufferAttribute(lineIndices, 1));
return { pointsGeometry, lineGeometry };
}
// ---------------------------------------------------------------------------
// Luces (compartidas; se agregan al primer MESH y se quitan cuando no queda ninguno)
// ---------------------------------------------------------------------------
function _ensureMeshLights(scene) {
if (_ambientLight) return;
_ambientLight = new THREE.AmbientLight(PALETTE_3D.AMBIENT_LIGHT, 0.4);
scene.add(_ambientLight);
_dirLight = new THREE.DirectionalLight(PALETTE_3D.DIRECTIONAL_LIGHT, 0.8);
_dirLight.position.set(50, 80, 30);
scene.add(_dirLight);
}
function _removeMeshLightsIfUnused(scene) {
const stillHasMesh = [..._docs.values()].some(r => r.mesh);
if (stillHasMesh) return;
if (_ambientLight) { scene.remove(_ambientLight); _ambientLight.dispose(); _ambientLight = null; }
if (_dirLight) { scene.remove(_dirLight); _dirLight.dispose(); _dirLight = null; }
}
// ---------------------------------------------------------------------------
// Limpieza de un documento
// ---------------------------------------------------------------------------
function _disposeObject(obj) {
if (!obj) return;
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
else obj.material.dispose();
}
}
function _disposeCorner(corner) {
if (!corner || !corner.group) return;
corner.group.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (child.material.map) child.material.map.dispose();
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
else child.material.dispose();
}
});
}
export function clearReliefDoc(scene, docId) {
const record = _docs.get(docId);
if (!record) return;
if (record.group) {
scene.remove(record.group);
[record.mesh, record.cloud, record.lines].forEach(_disposeObject);
if (record.corners) {
record.corners.forEach(_disposeCorner);
}
}
_docs.delete(docId);
_removeMeshLightsIfUnused(scene);
}
export function clearAllReliefs(scene) {
for (const id of [..._docs.keys()]) clearReliefDoc(scene, id);
}
// Back-compat: el nombre histórico limpiaba el único relieve. Ahora limpia todos.
export function clearRelief(scene) {
clearAllReliefs(scene);
}
// ---------------------------------------------------------------------------
// Render de UN documento dentro de su propio grupo posicionado en offsetX.
// opts = { id, matrix, textMetadata, mode, scaleX, scaleY, scaleZ,
// offsetX, colorMode, tint, absMax (opcional override),
// opacity (opcional), topIndices, dimensions_count }
// ---------------------------------------------------------------------------
export function renderReliefDoc(scene, opts) {
const {
id = DEFAULT_DOC_ID,
matrix,
textMetadata = [],
mode = 'MESH',
scaleX = STATE.reliefScaleX,
scaleY = STATE.reliefScaleY,
scaleZ = STATE.reliefScaleZ,
offsetX = 0,
colorMode = 'RAINBOW',
tint = null,
opacity = 1.0,
topIndices = null,
dimensions_count = null,
} = opts;
if (!matrix || matrix.length === 0) {
console.warn('[ReliefRenderer] renderReliefDoc: matriz vacia, abortando.');
return;
}
// Reemplazar cualquier render previo del mismo documento.
clearReliefDoc(scene, id);
const absMax = (typeof opts.absMax === 'number') ? opts.absMax : computeAbsMax(matrix);
const colorFn = _colorFnFor({ colorMode, tint });
const group = new THREE.Group();
group.position.set(offsetX, 0, 0);
const record = {
id, group, mesh: null, cloud: null, lines: null,
matrix, textMetadata, mode, absMax,
scaleX, scaleY, scaleZ, colorMode, tint, offsetX,
dimensions_count: dimensions_count || matrix[0].length,
};
const translucent = opacity < 1.0;
if (mode === 'MESH') {
const geometry = _buildMeshGeometry(matrix, scaleX, scaleY, scaleZ, absMax, colorFn, topIndices);
const material = new THREE.MeshStandardMaterial({
vertexColors: true, flatShading: true, side: THREE.DoubleSide,
transparent: translucent, opacity,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.userData.reliefDocId = id;
// El relieve es un único objeto gigante que el usuario sobrevuela libremente.
// El frustum culling de three usa la boundingSphere cacheada en el build; al
// reescalar (updateReliefScale) las posiciones cambian pero la esfera queda
// obsoleta, y three culla TODO el objeto en ciertas poses de cámara (la malla
// "desaparece" al mover el mouse un pelo). Lo desactivamos: cullar un solo
// objeto que llena la vista no ahorra nada y evita la clase entera de bug.
mesh.frustumCulled = false;
group.add(mesh);
record.mesh = mesh;
_ensureMeshLights(scene);
} else if (mode === 'RIBBONS') {
const { pointsGeometry, lineGeometry } =
_buildRibbonsGeometries(matrix, scaleX, scaleY, scaleZ, absMax, colorFn, topIndices);
const lineMaterial = new THREE.LineBasicMaterial({
vertexColors: true, transparent: true, opacity: translucent ? opacity * 0.55 : 0.55,
});
const lines = new THREE.LineSegments(lineGeometry, lineMaterial);
lines.frustumCulled = false; // ver nota en MESH: evita el culling por esfera obsoleta
group.add(lines);
record.lines = lines;
const pointsMaterial = new THREE.PointsMaterial({
size: STATE.reliefThickness * 0.05, vertexColors: true, sizeAttenuation: true,
transparent: translucent, opacity,
});
const cloud = new THREE.Points(pointsGeometry, pointsMaterial);
cloud.userData.reliefDocId = id;
cloud.frustumCulled = false; // ver nota en MESH: evita el culling por esfera obsoleta
group.add(cloud);
record.cloud = cloud;
} else { // POINTS
const geometry = _buildPointsGeometry(matrix, scaleX, scaleY, scaleZ, absMax, colorFn, topIndices);
const material = new THREE.PointsMaterial({
size: 0.08, vertexColors: true, sizeAttenuation: true,
transparent: translucent, opacity,
});
const cloud = new THREE.Points(geometry, material);
cloud.userData.reliefDocId = id;
cloud.frustumCulled = false; // ver nota en MESH: evita el culling por esfera obsoleta
group.add(cloud);
record.cloud = cloud;
}
// Agregar indicadores de ejes en las 4 esquinas del relieve
const numRows = matrix.length;
const numCols = matrix[0].length;
const maxX = (numCols - 1) * scaleX;
const maxZ = (numRows - 1) * scaleZ;
const corners = [];
const cornerOffsets = [
{ xFact: 0, zFact: 0 },
{ xFact: 1, zFact: 0 },
{ xFact: 0, zFact: 1 },
{ xFact: 1, zFact: 1 }
];
for (const offset of cornerOffsets) {
const cornerAxes = _createCornerAxesGroup(4.0);
cornerAxes.group.position.set(offset.xFact * maxX, 0, offset.zFact * maxZ);
group.add(cornerAxes.group);
corners.push({
group: cornerAxes.group,
xFact: offset.xFact,
zFact: offset.zFact
});
}
record.corners = corners;
scene.add(group);
_docs.set(id, record);
}
// ---------------------------------------------------------------------------
// Render principal SINGLE-DOC (back-compat). Renderiza en el slot DEFAULT.
// ---------------------------------------------------------------------------
export function renderRelief(scene, matrix, textMetadata, mode, scaleX = STATE.reliefScaleX, scaleY = STATE.reliefScaleY, scaleZ = STATE.reliefScaleZ) {
clearAllReliefs(scene);
const topIndices = (STATE.reliefSearchResults && STATE.reliefSearchResults.top_indices) || null;
renderReliefDoc(scene, {
id: DEFAULT_DOC_ID, matrix, textMetadata, mode,
scaleX, scaleY, scaleZ, colorMode: 'RAINBOW', topIndices,
});
console.log(
`[ReliefRenderer] Relieve (single) — Modo: ${mode} | Chunks (Z): ${matrix.length} | Dims (X): ${matrix[0].length}`
);
}
// ---------------------------------------------------------------------------
// ORQUESTADOR de comparación multi-documento (modos A y B). Lee STATE.
// El modo C (inspector de dimensión) es un panel 2D externo: aquí solo limpia
// la escena 3D para no superponer relieves al inspector.
// ---------------------------------------------------------------------------
export function renderComparison(scene) {
clearAllReliefs(scene);
const docs = visibleReliefDocs();
if (docs.length === 0) return;
const mode = STATE.comparisonMode;
const rmode = STATE.reliefRenderMode;
const sx = STATE.reliefScaleX, sy = STATE.reliefScaleY, sz = STATE.reliefScaleZ;
if (mode === 'C') return; // panel 2D, sin geometría 3D
if (mode === 'A') {
// Lado a lado: cada documento, su terreno (arcoíris) desplazado en X.
let cursorX = 0;
for (const doc of docs) {
const width = doc.matrix[0].length * sx;
renderReliefDoc(scene, {
id: doc.id, matrix: doc.matrix, textMetadata: doc.text_metadata,
mode: rmode, scaleX: sx, scaleY: sy, scaleZ: sz,
offsetX: cursorX, colorMode: 'RAINBOW',
dimensions_count: doc.dimensions_count,
});
cursorX += width + STATE.reliefSideGap;
}
return;
}
// Modo B — ejes compartidos (offsetX = 0 para todos).
if (STATE.reliefBSubMode === 'DELTA') {
// Delta A - B: usa el par elegido o los dos primeros documentos visibles.
const a = getReliefDoc(STATE.reliefDeltaPair.a) || docs[0];
const b = getReliefDoc(STATE.reliefDeltaPair.b) || docs[1] || docs[0];
if (!a || !b || a === b) {
// Sin par válido: degradar a overlay del único documento.
renderReliefDoc(scene, {
id: docs[0].id, matrix: docs[0].matrix, textMetadata: docs[0].text_metadata,
mode: rmode, scaleX: sx, scaleY: sy, scaleZ: sz,
colorMode: 'TINT', tint: _hexToRgb(docs[0].color), opacity: 0.85,
dimensions_count: docs[0].dimensions_count,
});
return;
}
const delta = computeDelta(a.matrix, b.matrix, STATE.reliefAlignMode);
renderReliefDoc(scene, {
id: 'delta', matrix: delta, textMetadata: a.text_metadata,
mode: rmode, scaleX: sx, scaleY: sy, scaleZ: sz,
colorMode: 'DIVERGING', dimensions_count: a.dimensions_count,
});
return;
}
// Overlay: alinear todas las matrices al mismo largo Z y dibujarlas
// translúcidas con el tinte de cada documento, sobre el mismo |max| global.
const { aligned } = alignMatrices(docs.map(d => d.matrix), STATE.reliefAlignMode);
let sharedAbsMax = 0;
for (const m of aligned) { const a = computeAbsMax(m); if (a > sharedAbsMax) sharedAbsMax = a; }
docs.forEach((doc, i) => {
renderReliefDoc(scene, {
id: doc.id, matrix: aligned[i], textMetadata: doc.text_metadata,
mode: rmode, scaleX: sx, scaleY: sy, scaleZ: sz,
colorMode: 'TINT', tint: _hexToRgb(doc.color), opacity: 0.55,
absMax: sharedAbsMax, dimensions_count: doc.dimensions_count,
});
});
}
// ---------------------------------------------------------------------------
// Actualización rápida de escala (deformación de geometría) — TODOS los docs
// ---------------------------------------------------------------------------
export function updateReliefScale(scaleX, scaleY, scaleZ) {
for (const record of _docs.values()) {
record.scaleX = scaleX; record.scaleY = scaleY; record.scaleZ = scaleZ;
const matrix = record.matrix;
const numRows = matrix.length;
const numCols = matrix[0].length;
const objects = [record.mesh, record.cloud, record.lines].filter(Boolean);
for (const obj of objects) {
const positionAttr = obj.geometry.attributes.position;
if (!positionAttr) continue;
for (let z = 0; z < numRows; z++) {
for (let x = 0; x < numCols; x++) {
const idx = z * numCols + x;
positionAttr.setXYZ(idx, x * scaleX, _normHeight(matrix[z][x], record.absMax, scaleY), z * scaleZ);
}
}
positionAttr.needsUpdate = true;
// Las posiciones cambiaron in situ: la boundingSphere cacheada quedó
// obsoleta. Aunque el render ya no la usa (frustumCulled=false), el
// raycaster SÍ la usa como early-out para el hover de relieve; sin
// recomputar, apuntar a celdas fuera de la esfera vieja deja de
// detectar el punto. La mantenemos sincronizada.
obj.geometry.computeBoundingSphere();
if (obj === record.mesh) obj.geometry.computeVertexNormals();
}
if (record.corners) {
const maxX = (numCols - 1) * scaleX;
const maxZ = (numRows - 1) * scaleZ;
for (const corner of record.corners) {
corner.group.position.set(corner.xFact * maxX, 0, corner.zFact * maxZ);
}
}
}
// Recalcular offsets en modo A (el ancho de cada terreno depende de scaleX).
if (STATE.comparisonMode === 'A' && _docs.size > 1) {
let cursorX = 0;
for (const doc of visibleReliefDocs()) {
const record = _docs.get(doc.id);
if (!record) continue;
record.offsetX = cursorX;
record.group.position.x = cursorX;
cursorX += doc.matrix[0].length * scaleX + STATE.reliefSideGap;
}
}
}
// ---------------------------------------------------------------------------
// Actualización rápida del grosor de puntos (RIBBONS / POINTS) — TODOS los docs
// ---------------------------------------------------------------------------
export function updateReliefThickness(thickness) {
for (const record of _docs.values()) {
if (record.cloud && record.cloud.material) {
record.cloud.material.size = thickness * 0.05;
record.cloud.material.needsUpdate = true;
}
}
}
// ---------------------------------------------------------------------------
// Resaltado de búsqueda (single-doc). Repinta los colores de cada record activo
// según su propio colormap; las filas en topIndices quedan en amarillo.
// ---------------------------------------------------------------------------
export function highlightSearch(topIndices) {
const hasSearch = topIndices && Array.isArray(topIndices) && topIndices.length > 0;
for (const record of _docs.values()) {
const matrix = record.matrix;
const numRows = matrix.length;
const numCols = matrix[0].length;
const colorFn = _colorFnFor(record);
const objects = [record.mesh, record.cloud, record.lines].filter(Boolean);
for (const obj of objects) {
const colorAttr = obj.geometry.attributes.color;
if (!colorAttr) continue;
for (let z = 0; z < numRows; z++) {
const isHighlighted = hasSearch && topIndices.includes(z);
for (let x = 0; x < numCols; x++) {
const idx = z * numCols + x;
let r, g, b;
if (hasSearch) {
if (isHighlighted) { r = 1.0; g = 0.9; b = 0.0; }
else { r = 0.1; g = 0.15; b = 0.25; }
} else {
const col = colorFn(matrix[z][x], record.absMax);
r = col.r; g = col.g; b = col.b;
}
colorAttr.setXYZ(idx, r, g, b);
}
}
colorAttr.needsUpdate = true;
}
}
}
// ---------------------------------------------------------------------------
// Raycasting
// ---------------------------------------------------------------------------
export function getActiveReliefObjects() {
const list = [];
for (const record of _docs.values()) {
if (record.mesh) list.push(record.mesh);
if (record.cloud) list.push(record.cloud);
}
return list;
}
// Resuelve a qué record/documento pertenece un objeto impactado por el raycast.
export function resolveDocByObject(object) {
const id = object && object.userData ? object.userData.reliefDocId : null;
if (id == null) return null;
return _docs.get(id) || null;
}
export function isReliefActive() {
return _docs.size > 0;
}