diff --git a/static/js/iso_scatter.js b/static/js/iso_scatter.js index a216c21..376bf2c 100644 --- a/static/js/iso_scatter.js +++ b/static/js/iso_scatter.js @@ -8,6 +8,8 @@ const interfaceUrl = 'http://127.0.0.1:5000'; const uploadFiles = document.getElementById('uploadFiles'); const uploadBtn = document.getElementById('uploadBtn'); const serverFilesDiv = document.getElementById('serverFiles'); +let startClick = true; +let isMulFiles = false; // 目录选择 & 列表渲染 uploadBtn.addEventListener('click', async ()=>{ @@ -121,7 +123,7 @@ function initScene() { camera = new THREE.PerspectiveCamera(50, C.clientWidth / C.clientHeight, 0.1, 5000); camera.position.set(200, 200, -1600); - renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); renderer.setSize(C.clientWidth, C.clientHeight); C.appendChild(renderer.domElement); @@ -248,6 +250,7 @@ function initScene() { fetch(interfaceUrl + '/get_cells') .then(r => r.json()) .then(({ points }) => { + // 1) 构造所有点的 BufferGeometry const geo = new THREE.BufferGeometry(); const pos = new Float32Array(points.length * 3); points.forEach((p, i) => { @@ -257,6 +260,7 @@ function initScene() { }); geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + // 2) 绘制所有点 const mat = new THREE.PointsMaterial({ color: 'blue', size: 20, @@ -264,9 +268,31 @@ function initScene() { depthWrite: false, depthTest: false }); - const perfs = new THREE.Points(geo, mat); - perfs.renderOrder = 212; // 最后画 - scene.add(perfs); + const pointCloud = new THREE.Points(geo, mat); + pointCloud.renderOrder = 212; + scene.add(pointCloud); + + // 3) 针对每个点,添加一个 CSS2D 标签显示它的 name + points.forEach(p => { + const span = document.createElement('span'); + span.className = 'cell-label'; + span.textContent = p.name; + Object.assign(span.style, { + fontSize: '12px', + color: '#e82121', + background: 'transparent', // 或者小半透明背景 + padding: '0 2px', // 左右留点空白 + lineHeight: '1em', + whiteSpace: 'nowrap', + pointerEvents: 'none', + display: 'inline-block' // 让 span 能设置宽高、padding + }); + // 用 span 生成标签对象 + const label = new CSS2DObject(span); + label.position.set(p.x, p.y, p.z + 3); + label.renderOrder = 213; + scene.add(label); + }); }); axisGroup = new THREE.Group(); @@ -297,7 +323,6 @@ function initScene() { controls.update(); } zoomByScroll(8, 0.99); - // drawAxes(); } function drawAxes() { @@ -473,6 +498,7 @@ function applyView() { controls.target.copy(center); camera.lookAt(center); controls.update(); + console.log("切换视图:" + sel); } function initControls() { @@ -495,7 +521,11 @@ function initControls() { if (et > timeMax) et = timeMax; endTime.value = et; }); - document.getElementById('viewSelect').onchange = applyView; + viewSelect.addEventListener('change', () => { + // 确保 currentGroup 有值 + currentGroup = currentGroup || dataGroup; + applyView(); + }); } function getStartEndInput() { @@ -520,101 +550,39 @@ function updateTimeLabel(timeValue) { labelEl.innerText = updated; } +function checkFileStatus() { + const selected = Array.from( + document.querySelectorAll('#serverFiles input:checked') + ).map(cb => cb.value); + if (!selected.length) { + return alert('请先勾选要渲染的文件'); + } + console.log("文件数量:" + selected.length); + if (selected.length > 1) { + console.log("多文件"); + isMulFiles = true; + } + return selected; +} + // 简单渲染 async function renderSimple() { + startClick = true; + const selected = checkFileStatus(); // 1) 读取所有输入参数 let { start, end } = getStartEndInput(); - const startSec = start * 60; - const endSec = end * 60; - const levels = +document.getElementById('levels').value; - const gridRes = +document.getElementById('gridRes').value; - const tau = +document.getElementById('tau').value; - const S1 = +document.getElementById('S1').value, R1 = +document.getElementById('R1').value; - const S2 = +document.getElementById('S2').value, R2 = +document.getElementById('R2').value; - const S3 = +document.getElementById('S3').value, R3 = +document.getElementById('R3').value; - const opacity = +document.getElementById('meshOpacity').value; - - // 2) 找到所有被勾选的文件 - const selected = Array.from( - document.querySelectorAll('#serverFiles input:checked') - ).map(cb => cb.value); - if (!selected.length) { - return alert('请先勾选要渲染的文件'); - } - // 3) 清空旧的数据 Group(但保留坐标轴) dataGroup.clear(); - // 4) 对每个文件,拉数据并生成一组等值面,添加到 dataGroup - for (const fn of selected) { - const url = new URL(interfaceUrl + '/get_meshes'); - url.searchParams.set('file', fn); - url.searchParams.set('levels', levels); - url.searchParams.set('energy_thresh', tau); - url.searchParams.set('grid_res', gridRes); - url.searchParams.set('S1', S1); - url.searchParams.set('R1', R1); - url.searchParams.set('S2', S2); - url.searchParams.set('R2', R2); - url.searchParams.set('S3', S3); - url.searchParams.set('R3', R3); - url.searchParams.set('startTime', startSec); - url.searchParams.set('endTime', endSec); - - const res = await fetch(url); - const { meshes, bounds, volume } = await res.json(); - - // (可选)更新总体积或显示最后一个文件的体积 - document.getElementById('volume').innerText = '体积:' + volume + '立方米'; - - // 构造一个小组来承载当前文件所有层 - const group = new THREE.Group(); - group.position.set(bounds.x[0], bounds.y[0], bounds.z[0]); - - for (let i = 0; i < meshes.length; i++) { - const m = meshes[i]; - const geom = new THREE.BufferGeometry(); - geom.setAttribute( - 'position', - new THREE.BufferAttribute(new Float32Array(m.vertices.flat()), 3) - ); - geom.setIndex( - new THREE.BufferAttribute(new Uint32Array(m.faces.flat()), 1) - ); - geom.computeVertexNormals(); - - const mat = new THREE.MeshPhongMaterial({ - color: getHeatColor(i / (meshes.length - 1)), - transparent: true, - opacity: opacity, - side: THREE.DoubleSide, - shininess: 14, - specular: 0x888888, - depthWrite: false, - depthTest: true - }); - - const mesh = new THREE.Mesh(geom, mat); - mesh.renderOrder = i; - group.add(mesh); - } - - // 把这个文件的等值面组加入 dataGroup - dataGroup.add(group); - } - - // 5) 重新聚焦到所有数据的中央(可选) - const box = new THREE.Box3().setFromObject(dataGroup); - const center = box.getCenter(new THREE.Vector3()); - controls.target.copy(center); - camera.position.set(center.x, center.y, center.z + box.getSize(new THREE.Vector3()).length()); - controls.update(); + await renderFrame(start, end); } - - // 播放:start 固定,endLabel 动态 async function startPlay() { + startClick = true; + const selected = checkFileStatus(); + // 3) 清空旧的数据 Group(但保留坐标轴) + dataGroup.clear(); const { start, end } = getStartEndInput(); let current = start; document.getElementById('timeLabel').innerText = current; @@ -625,6 +593,7 @@ async function startPlay() { while (isPlaying && current <= end) { current += interval; + // 4) 对每个文件,拉数据并生成一组等值面,添加到 dataGroup await renderFrame(start, current); document.getElementById('timeLabel').innerText = current; } @@ -638,32 +607,52 @@ function stopPlay() { alert("结束"); } -// 录制:逻辑跟播放一样,每帧多存图 -async function startRecording() { - const { start, end } = getStartEndInput(); - let current = start; - document.getElementById('timeLabel').innerText = current; - isPlaying = true; - frameIdx = 0; - await fetch(interfaceUrl + '/init_frames', { method: 'POST' }); - - const interval = getIntervalMin(); +async function savePng() { + // —— 用 WebGL + CSS2D canvas 合成一张图 + renderer.render(scene, camera); + labelRenderer.render(scene, camera); - while (isPlaying && current <= end) { - current += interval; - await renderFrame(start, current); + // 拿 WebGL 图 + const webglDataURL = renderer.domElement.toDataURL('image/png'); + const webglImg = new Image(); + webglImg.src = webglDataURL; + await new Promise(r => webglImg.onload = r); + + // 拿标签层(CSS2DRenderer)——这里还是用 html2canvas,只拍 labels div + const labelShot = await html2canvas(labelRenderer.domElement, { + backgroundColor: null, + useCORS: true, + allowTaint: true, + width: renderer.domElement.width, + height: renderer.domElement.height + }); - // 截图上传 - const shot = await html2canvas(container, { backgroundColor: null, useCORS: true }); - const png = shot.toDataURL(); - await fetch(interfaceUrl + '/save_frame', { + // 合并 + const w = renderer.domElement.width, h = renderer.domElement.height; + const combo = document.createElement('canvas'); + combo.width = w; combo.height = h; + const ctx = combo.getContext('2d'); + ctx.drawImage(webglImg, 0, 0, w, h); + ctx.drawImage(labelShot, 0, 0, w, h); + + // 画左上角的时间和体积(用白字,无背景) + const timeText = document.getElementById('timeStamp').textContent; + const volumeText = document.getElementById('volume').textContent; + ctx.font = '16px sans-serif'; + ctx.fillStyle = '#000'; + ctx.fillText(timeText, 10, 25); // 调整 y 使文字在合适位置 + ctx.fillText(volumeText, 10, 45); + + // 上传 + const png = combo.toDataURL('image/png'); + await fetch(interfaceUrl + '/save_frame', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify({ idx: frameIdx++, image: png }) - }); - document.getElementById('timeLabel').innerText = current; - } + }); +} +async function downloadGIF() { // 合成 GIF 并下载 const res = await fetch(interfaceUrl + '/make_gif'); const blob = await res.blob(); @@ -672,26 +661,58 @@ async function startRecording() { a.href = url; a.download = `animation_${Date.now()}.gif`; document.body.appendChild(a); a.click(); URL.revokeObjectURL(url); +} - stopPlay(); +// 录制:逻辑跟播放一样,每帧多存图 +async function startRecording() { + startClick = true; + const selected = checkFileStatus(); + // 3) 清空旧的数据 Group(但保留坐标轴) + dataGroup.clear(); + const { start, end } = getStartEndInput(); + let current = start; + document.getElementById('timeLabel').innerText = current; + isPlaying = true; + frameIdx = 0; + await fetch(interfaceUrl + '/init_frames', { method: 'POST' }); + const interval = getIntervalMin(); + while (isPlaying && current <= end) { + current += interval; + await renderFrame(start, current); + await savePng(); + document.getElementById('timeLabel').innerText = current; + } + downloadGIF(); + stopPlay(); } -let initialFocused = false; + +// —— 渲染单帧到 dataGroup(保持不变,只做数据 & Three.js 渲染)—— async function renderFrame(startTime, endTime) { - console.log("处理范围:" + startTime + "," + endTime); - startTime = startTime * 60; - endTime = endTime * 60; - const levels = +document.getElementById('levels').value; // 等值面层数 - const gridRes = +document.getElementById('gridRes').value; // 体素尺寸 - const tau = +document.getElementById('tau').value; // 阈值 - const S1 = +document.getElementById('S1').value, - R1 = +document.getElementById('R1').value; - const S2 = +document.getElementById('S2').value, - R2 = +document.getElementById('R2').value; - const S3 = +document.getElementById('S3').value, - R3 = +document.getElementById('R3').value; - const opacity = +document.getElementById('meshOpacity').value; - // 前端时间输入是分钟,需要转换成秒再传给后端 + // 1) 读取所有输入参数 + const levels = +document.getElementById('levels').value; + const gridRes = +document.getElementById('gridRes').value; + const tau = +document.getElementById('tau').value; + const S1 = +document.getElementById('S1').value, R1 = +document.getElementById('R1').value; + const S2 = +document.getElementById('S2').value, R2 = +document.getElementById('R2').value; + const S3 = +document.getElementById('S3').value, R3 = +document.getElementById('R3').value; + const opacity = +document.getElementById('meshOpacity').value; + + // 2) 构造 URL,只针对传入的那些文件 + const url = new URL(interfaceUrl + '/get_meshes'); + url.searchParams.set('levels', levels); + url.searchParams.set('energy_thresh', tau); + url.searchParams.set('grid_res', gridRes); + url.searchParams.set('S1', S1); + url.searchParams.set('R1', R1); + url.searchParams.set('S2', S2); + url.searchParams.set('R2', R2); + url.searchParams.set('S3', S3); + url.searchParams.set('R3', R3); + url.searchParams.set('startTime', startTime * 60); + url.searchParams.set('endTime', endTime * 60); + + // 前端时间输入是分钟,需要转换成秒再传给后端 const timeLabelVal = getCurrentLabel() * 60; updateBaseDate(); // 保证读到最新输入 @@ -702,97 +723,65 @@ async function renderFrame(startTime, endTime) { document.getElementById('timeStamp').innerText = `${actualDate.getFullYear()}-${(actualDate.getMonth() + 1).toString().padStart(2, '0')}-${actualDate.getDate().toString().padStart(2, '0')} ${hh}:${mm}:${ss}`; - const url = `${interfaceUrl}/get_meshes?levels=${levels}` - + `&energy_thresh=${tau}&grid_res=${gridRes}` - + `&S1=${S1}&R1=${R1}&S2=${S2}&R2=${R2}&S3=${S3}&R3=${R3}` - + `&startTime=${startTime}&endTime=${endTime}`; - const { meshes, bounds, volume } = await (await fetch(url)).json(); + const selected = checkFileStatus(); + let volumeTotal = 0; - document.getElementById('volume').innerText = '体积:' + volume + '立方米'; + for (let i = 0; i < selected.length; i++) { + url.searchParams.set('file', selected[i]); - const newG = new THREE.Group(); - newG.position.set(bounds.x[0], bounds.y[0], bounds.z[0]); - for (let i = 0; i < meshes.length; i++) { - const m = meshes[i]; - const geom = new THREE.BufferGeometry(); - geom.setAttribute('position', new THREE.BufferAttribute( - new Float32Array(m.vertices.flat()), 3 - )); - geom.setIndex(new THREE.BufferAttribute( - new Uint32Array(m.faces.flat()), 1 - )); - geom.computeVertexNormals(); - const iso = i / (levels - 1); - const mat = new THREE.MeshPhongMaterial({ - color: getHeatColor(iso), - transparent: true, - side: THREE.DoubleSide, - opacity: opacity, - shininess: 14, // 控制高光粗细 - specular: 0x888888, // 高光颜色 - depthWrite: false, - depthTest: true - }); - const mesh = new THREE.Mesh(geom, mat); - // mesh.renderOrder = meshes.length - i; - mesh.renderOrder = i; - newG.add(mesh); - } + // 3) 拿数据并渲染到 dataGroup + const res = await fetch(url); + const { meshes, bounds, volume } = await res.json(); + volumeTotal += volume; + document.getElementById('volume').innerText = '体积:' + volumeTotal + '立方米'; - scene.add(newG); - if (currentGroup) { - scene.remove(currentGroup); - currentGroup.traverse(o => o.geometry && o.geometry.dispose()); - } - currentGroup = newG; - - // 仅第一次渲染时对焦 - if (!initialFocused) { - const box = new THREE.Box3().setFromObject(currentGroup); - const ctr = box.getCenter(new THREE.Vector3()); - controls.target.copy(ctr); - camera.position.set(ctr.x, ctr.y, ctr.z); - controls.update(); - initialFocused = true; + const group = new THREE.Group(); + group.position.set(bounds.x[0], bounds.y[0], bounds.z[0]); + for (let j = 0; j < meshes.length; j++) { + const m = meshes[j]; + const geom = new THREE.BufferGeometry(); + geom.setAttribute( + 'position', + new THREE.BufferAttribute(new Float32Array(m.vertices.flat()), 3) + ); + geom.setIndex(new THREE.BufferAttribute( + new Uint32Array(m.faces.flat()), 1 + )); + geom.computeVertexNormals(); + const mat = new THREE.MeshPhongMaterial({ + color: getHeatColor(j/(meshes.length-1)), + transparent: true, + opacity, + side: THREE.DoubleSide, + shininess: 14, + specular: 0x888888, + depthWrite: false, + depthTest: true + }); + const mesh = new THREE.Mesh(geom, mat); + mesh.renderOrder = j; + group.add(mesh); } - drawAxes(); - // 后续帧只更新 mesh,不触碰 camera.target - controls.update(); - renderer.render(scene, camera); - labelRenderer.render(scene, camera); - applyView(); - // applyAngles(); -} -// 新增:根据两个输入框的度数,计算摄像机球坐标 -function applyAngles() { - // 1) 解析当前角度输入 - const azDeg = Number(document.getElementById('azimuth').value); - const poDeg = Number(document.getElementById('polar').value); - const az = THREE.MathUtils.degToRad(azDeg); - const po = THREE.MathUtils.degToRad(poDeg); - - // 2) 取出并更新 OrbitControls 自己维护的 target - const target = controls.target; // **不要** clone() - - // 3) 球面半径:相机到 target 的当前距离(保持不变) - const radius = camera.position.distanceTo(target); - - // 4) 用球坐标公式计算新的相机位置 - const sinP = Math.sin(po); - const cosP = Math.cos(po); - const sinA = Math.sin(az); - const cosA = Math.cos(az); + if (i === 0) { + // 第一次才清,第二个及以后直接叠加 + dataGroup.clear(); + } + dataGroup.add(group); + } + // 4) 第一次聚焦:如果不 “锁定坐标轴” + const fixAxes = document.getElementById('fixAxes').checked; + if (startClick && !fixAxes) { + drawAxes(); + const box = new THREE.Box3().setFromObject(dataGroup); + const ctr = box.getCenter(new THREE.Vector3()); + controls.target.copy(ctr); camera.position.set( - target.x + radius * sinP * cosA, - target.y + radius * cosP, - target.z + radius * sinP * sinA + ctr.x, ctr.y, + ctr.z + box.getSize(new THREE.Vector3()).length() ); - - // 5) 让相机正对 target - camera.lookAt(target); - - // 6) 同步 OrbitControls 的内部状态一次,之后不要在本函数里重复调用 controls.update(); -} \ No newline at end of file + } + startClick = false; +} diff --git a/static/pages/iso_scatter.html b/static/pages/iso_scatter.html index f4cc17a..55f0155 100644 --- a/static/pages/iso_scatter.html +++ b/static/pages/iso_scatter.html @@ -108,14 +108,14 @@
- 时间和体积显示 -