/** * 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` * 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; }