Spaces:
Sleeping
Sleeping
| /** | |
| * 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; | |
| } | |