From fde26416c71fd634043653d18a10fbbef6e8534b Mon Sep 17 00:00:00 2001 From: weitw Date: Mon, 14 Jul 2025 09:12:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=94=AF=E6=8C=81=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=A4=9A=E4=B8=AA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/iso_scatter.js | 798 +++++++++++++++++++++++++++++ static/pages/iso_scatter.html | 910 ++++++++-------------------------- 2 files changed, 1000 insertions(+), 708 deletions(-) create mode 100644 static/js/iso_scatter.js diff --git a/static/js/iso_scatter.js b/static/js/iso_scatter.js new file mode 100644 index 0000000..a216c21 --- /dev/null +++ b/static/js/iso_scatter.js @@ -0,0 +1,798 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; +import html2canvas from 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.esm.js'; + +const interfaceUrl = 'http://127.0.0.1:5000'; + +const uploadFiles = document.getElementById('uploadFiles'); +const uploadBtn = document.getElementById('uploadBtn'); +const serverFilesDiv = document.getElementById('serverFiles'); + +// 目录选择 & 列表渲染 +uploadBtn.addEventListener('click', async ()=>{ + const files = Array.from(uploadFiles.files); + if(!files.length) return alert('请选择要上传的文件'); + const fd = new FormData(); + files.forEach(f=>fd.append('files',f,f.name)); + const res = await fetch(interfaceUrl+'/upload_data_files',{method:'POST',body:fd}); + if(!res.ok) { const e=await res.json(); return alert('上传失败:'+e.error); } + alert('上传成功'); + loadServerFiles(); +}); + +async function loadServerFiles(){ + serverFilesDiv.innerHTML = ''; + const res = await fetch(interfaceUrl+'/list_data_files'); + const { files } = await res.json(); + files.forEach(fn=>{ + const cb=document.createElement('input'); + cb.type='checkbox';cb.value=fn;cb.id='sf_'+fn; + const lbl=document.createElement('label'); + lbl.htmlFor='sf_'+fn;lbl.textContent=fn; + const row=document.createElement('div'); + row.style.display='flex';row.style.alignItems='center';row.style.marginBottom='4px'; + lbl.style.marginLeft='6px';row.append(cb,lbl); + serverFilesDiv.appendChild(row); + }); +} +loadServerFiles(); + +// 不再硬编码 baseDate,由 UI 输入驱动 +let baseDate = new Date(); +const dateInput = document.getElementById('baseDate'); +const timeInput = document.getElementById('baseTime'); +function updateBaseDate() { + const d = dateInput.value; // YYYY-MM-DD + const t = timeInput.value; // HH:MM:SS + baseDate = new Date(`${d}T${t}`); +} + +function getBounds() { + const xMinI = document.getElementById('x-min'), + xMaxI = document.getElementById('x-max'), + yMinI = document.getElementById('y-min'), + yMaxI = document.getElementById('y-max'), + zMinI = document.getElementById('z-min'), + zMaxI = document.getElementById('z-max'); + return { + x: [ Number(xMinI.value), Number(xMaxI.value) ], + y: [ Number(yMinI.value), Number(yMaxI.value) ], + z: [ Number(zMinI.value), Number(zMaxI.value) ] + }; +} + +dateInput.addEventListener('change', updateBaseDate); +timeInput.addEventListener('change', updateBaseDate); +updateBaseDate(); + +let scene, camera, renderer, controls, currentGroup, dataGroup, axisGroup, labelRenderer = null; +let timeMin = 0, timeMax = 1, playIntervalSec = 600; +let isPlaying = false, frameIdx = 0; + +const coldMap = ['#800080', '#0000ff', '#00ffff']; +const warmMap = ['#00ff00', '#ffff00', '#ff7f00', '#ff0000']; +function lerpColor(arr, t) { + const n = arr.length, s = t * (n - 1), i = Math.floor(s), f = s - i; + if (i >= n - 1) return new THREE.Color(arr[n - 1]); + return new THREE.Color(arr[i]).lerp(new THREE.Color(arr[i + 1]), f); +} +function getHeatColor(t) { + return t <= 0.4 + ? lerpColor(coldMap, t / 0.4) + : lerpColor(warmMap, (t - 0.4) / 0.6); +} + +initScene(); +initControls(); + +// 获取时间间隔(分钟) +function getIntervalMin() { + return Number(document.getElementById('intervalMin').value); +} + +function refreshTimeRange() { + // 获取 time_range,转换为分钟并四舍五入 + fetch(interfaceUrl + '/time_range') + .then(r => r.json()) + .then(({ timeMin: secMin, timeMax: secMax }) => { + const minM = Math.round(secMin / 60); + const maxM = Math.round(secMax / 60); + timeMin = minM; + timeMax = maxM; + const st = document.getElementById('startTime'), + et = document.getElementById('endTime'); + st.min = et.min = minM; + st.max = et.max = maxM; + st.value = minM; + et.value = maxM; + updateTimeLabel(st.value); + }); +} +refreshTimeRange(); + + +function initScene() { + const C = document.getElementById('container'); + scene = new THREE.Scene(); + scene.rotateX(-Math.PI / 2); + scene.background = new THREE.Color(0xffffff); + + 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.setSize(C.clientWidth, C.clientHeight); + C.appendChild(renderer.domElement); + + // 光照 + scene.add(new THREE.AmbientLight(0xffffff, 0.8)); + const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); + dirLight.position.set(200, 300, 200); + dirLight.castShadow = true; + scene.add(dirLight); + const fillLight = new THREE.DirectionalLight(0xffffff, 0.4); + fillLight.position.set(-200, -100, -200); + scene.add(fillLight); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + + // CSS2DRenderer for axis labels + labelRenderer = new CSS2DRenderer(); + labelRenderer.setSize(C.clientWidth, C.clientHeight); + labelRenderer.domElement.style.position = 'absolute'; + labelRenderer.domElement.style.top = '0'; + labelRenderer.domElement.style.pointerEvents = 'none'; + C.appendChild(labelRenderer.domElement); + + // OrbitControls + controls = new OrbitControls(camera, renderer.domElement); + controls.target.set(150, 100, -80); + controls.enableDamping = false; + controls.dampingFactor = 0.05; + controls.enablePan = false; // 支持平移 + + let userInteracting = false; + controls.addEventListener('start', () => userInteracting = true); + controls.addEventListener('end', () => userInteracting = false); + controls.addEventListener('change', () => { + if (!userInteracting) return; + const az = THREE.MathUtils.radToDeg(controls.getAzimuthalAngle()); + const po = THREE.MathUtils.radToDeg(controls.getPolarAngle()); + document.getElementById('azimuth').value = az.toFixed(0); + document.getElementById('polar').value = po.toFixed(0); + }); + + window.addEventListener('resize', () => { + camera.aspect = C.clientWidth / C.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(C.clientWidth, C.clientHeight); + labelRenderer.setSize(C.clientWidth, C.clientHeight); + }); + + // 2) 井轨迹 + fetch(interfaceUrl + '/get_well_path') + .then(r => r.json()) + .then(({ points }) => { + // 1) 把后端返回的点转成 Vector3 数组 + const verts = points.map(p => new THREE.Vector3(p.x, p.y, p.z)); + + // 2) 构造 BufferGeometry + const geo = new THREE.BufferGeometry().setFromPoints(verts); + + // 3) LineBasicMaterial 支持一个简单的 linewidth + const mat = new THREE.LineBasicMaterial({ + color: 0xff8800, + linewidth: 3, // 粗细(注意:大多数浏览器/平台只支持 1px) + }); + + // 4) THREE.Line 或者 THREE.LineSegments(如果想把每段分离)都可以用 + const line = new THREE.Line(geo, mat); + line.renderOrder = 1; // 确保它按这个顺序绘制 + + // 5) 直接添加到场景,按世界坐标就能穿透等值面 + scene.add(line); + }); + + // 射孔簇 + fetch(interfaceUrl + '/get_perforations') + .then(r => r.json()) + .then(({ points }) => { + const geo = new THREE.BufferGeometry(); + const pos = new Float32Array(points.length * 3); + points.forEach((p, i) => { + pos[3*i] = p.x; + pos[3*i+1] = p.y; + pos[3*i+2] = p.z; + }); + geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + + const mat = new THREE.PointsMaterial({ + color: 0xffff00, + size: 25, + sizeAttenuation: true, + depthWrite: false, + depthTest: false + }); + const perfs = new THREE.Points(geo, mat); + perfs.renderOrder = 200; // 最后画 + scene.add(perfs); + }); + + // 桥塞 + fetch(interfaceUrl + '/get_plugs') + .then(r => r.json()) + .then(({ points }) => { + const geo = new THREE.BufferGeometry(); + const pos = new Float32Array(points.length * 3); + points.forEach((p, i) => { + pos[3*i] = p.x; + pos[3*i+1] = p.y; + pos[3*i+2] = p.z; + }); + geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + + const mat = new THREE.PointsMaterial({ + color: 0xff0000, + size: 25, + sizeAttenuation: true, + depthWrite: false, + depthTest: false + }); + const plugs = new THREE.Points(geo, mat); + plugs.renderOrder = 210; // 桥塞后画 + scene.add(plugs); + }); + + // 层位标记 + fetch(interfaceUrl + '/get_cells') + .then(r => r.json()) + .then(({ points }) => { + const geo = new THREE.BufferGeometry(); + const pos = new Float32Array(points.length * 3); + points.forEach((p, i) => { + pos[3*i] = p.x; + pos[3*i+1] = p.y; + pos[3*i+2] = p.z; + }); + geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + + const mat = new THREE.PointsMaterial({ + color: 'blue', + size: 20, + sizeAttenuation: true, + depthWrite: false, + depthTest: false + }); + const perfs = new THREE.Points(geo, mat); + perfs.renderOrder = 212; // 最后画 + scene.add(perfs); + }); + + axisGroup = new THREE.Group(); + dataGroup = new THREE.Group(); + + // 画坐标轴一次 + drawAxes(); + scene.add(axisGroup); + + // 把 dataGroup 一开始也加进去(里面现在空) + scene.add(dataGroup); + + // render loop + (function animate() { + requestAnimationFrame(animate); + // 每帧都重新应用用户锁定的中心点 + controls.update(); + renderer.render(scene, camera); + labelRenderer.render(scene, camera); + })(); + + // initial zoom-in + function zoomByScroll(ticks, factorPerTick = 0.8) { + const dir = new THREE.Vector3().subVectors(camera.position, controls.target); + dir.multiplyScalar(Math.pow(factorPerTick, ticks)); + camera.position.copy(controls.target).add(dir); + camera.updateProjectionMatrix(); + controls.update(); + } + zoomByScroll(8, 0.99); + // drawAxes(); +} + +function drawAxes() { + if (axisGroup) { + scene.remove(axisGroup); + // (可选:彻底 dispose 三维对象的 geometry/material) + axisGroup.traverse(o => { + if (o.geometry) o.geometry.dispose(); + if (o.material) o.material.dispose(); + }); + } + // —— ② 把之前留下的 CSS2D 标签清空 —— + labelRenderer.domElement.innerHTML = ''; + + // —— ③ 新建一个全新的 axisGroup —— + axisGroup = new THREE.Group(); + + const { x: [xmin, xmax], y: [ymin, ymax], z: [zmin, zmax] } = getBounds(); + console.log("x:" + xmin); + const lenX = xmax - xmin, lenY = ymax - ymin, lenZ = zmax - zmin; + const ticksMap = { X: Math.ceil(lenX / 50), Y: Math.ceil(lenY / 50), Z: Math.ceil(lenZ / 50) }; + const tickesNameMap = {X: 'X', Y: 'Y', Z: 'Z'}; + + // 通用刻度文字样式 + const LABEL_STYLE = { + fontSize: '12px', + color: '#000', // 黑色 + margin: '0px', + padding: '0px', + whiteSpace: 'nowrap', + }; + + // 画单条轴及其刻度和标签 + [['X', xmin, lenX, 'x', 0xff0000], + ['Y', ymin, lenY, 'y', 0x00ff00], + ['Z', zmin, lenZ, 'z', 0x0000ff] + ].forEach(([name, start, length, axis, color]) => { + const mat = new THREE.LineBasicMaterial({ color }); + // 计算轴起点 + const origin = new THREE.Vector3( + axis === 'x' ? start : (axis === 'y' ? xmax : xmin), + axis === 'y' ? start : ymin, + axis === 'z' ? start : zmin + ); + // 计算轴方向向量 + const dir = new THREE.Vector3( + axis === 'x' ? 1 : 0, + axis === 'y' ? 1 : 0, + axis === 'z' ? 1 : 0 + ); + + // —— 绘制轴主线 —— + const lineGeo = new THREE.BufferGeometry().setFromPoints([ + origin, + origin.clone().add(dir.clone().multiplyScalar(length)) + ]); + axisGroup.add(new THREE.Line(lineGeo, mat)); + + // 计算刻度外推方向(针对不同轴分别处理) + let offsetDir; + // Vector3(x,y,z) + if (axis === 'x') offsetDir = new THREE.Vector3(0, 0.8, -0.8); // X 轴刻度文字向下 + else if (axis === 'y') offsetDir = new THREE.Vector3(0.5, 0, -0.1); // Y 轴刻度文字向前 + else offsetDir = new THREE.Vector3(1, 0, 0); // Z 轴刻度文字向左 + const labelOffset = offsetDir.multiplyScalar(10); + + // —— 绘制刻度线和数字标签 —— + const ticks = ticksMap[name]; + for (let i = 0; i <= ticks; i++) { + const t = i / ticks; + const pos = origin.clone().add(dir.clone().multiplyScalar(length * t)); + + // 刻度线 + const mkGeo = new THREE.BufferGeometry().setFromPoints([ + pos.clone().add(offsetDir.clone().multiplyScalar(0.5)), + pos.clone().add(offsetDir.clone().multiplyScalar(-0.5)) + ]); + axisGroup.add(new THREE.Line(mkGeo, mat)); + + // 数字标签 + const val = (start + length * t).toFixed(0); + const div = document.createElement('div'); + Object.assign(div.style, LABEL_STYLE); + div.textContent = val; + const label = new CSS2DObject(div); + label.position.copy(pos.clone().add(labelOffset)); + axisGroup.add(label); + } + + // —— 绘制轴标题 —— + const titleDiv = document.createElement('div'); + Object.assign(titleDiv.style, LABEL_STYLE, { fontWeight: 'bold' }); + titleDiv.textContent = tickesNameMap[name]; + const titleLabel = new CSS2DObject(titleDiv); + let worldPos; + if (axis === 'x') { + worldPos = origin.clone() + .add(dir.clone().multiplyScalar(length * 0.5)) + .add(offsetDir.clone().multiplyScalar(length * 0.008)); + } else if (axis === 'y') { + worldPos = origin.clone() + .add(dir.clone().multiplyScalar(length * 0.5)) + .add(offsetDir.clone().multiplyScalar(length * 0.018)); + } else { + worldPos = origin.clone() + .add(dir.clone().multiplyScalar(length * 0.5)) + .add(offsetDir.clone().multiplyScalar(length * -0.018)); + } + + titleLabel.position.copy(worldPos); + axisGroup.add(titleLabel); + }); + + // —— 手工生成 XY/XZ/YZ 三个平面上的网格 —— + const gridMat = new THREE.LineBasicMaterial({ + color: 0xcccccc, + opacity: 0.6, + transparent: true + }); + function makePlaneGrid(fixedAxis, fixedValue, axis1, range1, div1, axis2, range2, div2) { + const geom = new THREE.BufferGeometry(); + const pos = []; + const step1 = (range1[1] - range1[0]) / div1; + const step2 = (range2[1] - range2[0]) / div2; + // 沿 axis1 方向的线 + for (let j = 0; j <= div2; j++) { + const c2 = range2[0] + step2 * j; + const p1 = {}, p2 = {}; + p1[fixedAxis] = fixedValue; p2[fixedAxis] = fixedValue; + p1[axis2] = c2; p2[axis2] = c2; + p1[axis1] = range1[0]; p2[axis1] = range1[1]; + pos.push(p1.x||0, p1.y||0, p1.z||0, p2.x||0, p2.y||0, p2.z||0); + } + // 沿 axis2 方向的线 + for (let i = 0; i <= div1; i++) { + const c1 = range1[0] + step1 * i; + const q1 = {}, q2 = {}; + q1[fixedAxis] = fixedValue; q2[fixedAxis] = fixedValue; + q1[axis1] = c1; q2[axis1] = c1; + q1[axis2] = range2[0]; q2[axis2] = range2[1]; + pos.push(q1.x||0, q1.y||0, q1.z||0, q2.x||0, q2.y||0, q2.z||0); + } + geom.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + return new THREE.LineSegments(geom, gridMat); + } + + // XY 平面: z = zmin + axisGroup.add(makePlaneGrid('z', zmin, 'x', [xmin, xmax], ticksMap.X, 'y', [ymin, ymax], ticksMap.Y)); + // XZ 平面: y = ymin + axisGroup.add(makePlaneGrid('y', ymax, 'x', [xmin, xmax], ticksMap.X, 'z', [zmin, zmax], ticksMap.Z)); + // YZ 平面: x = xmin + axisGroup.add(makePlaneGrid('x', xmin, 'z', [zmin, zmax], ticksMap.Z, 'y', [ymin, ymax], ticksMap.Y)); + + // 加入场景 + scene.add(axisGroup); +} + +function applyView() { + if(!currentGroup) return; + const sel = document.getElementById('viewSelect').value; + const box = new THREE.Box3().setFromObject(currentGroup); + const center = box.getCenter(new THREE.Vector3()); + const d = box.getSize(new THREE.Vector3()).length() * 1.2; + let pos; + switch(sel){ + case '主视图': pos = new THREE.Vector3(0,0,d); break; + case '左视图': pos = new THREE.Vector3(-d,0,0); break; + case '右视图': pos = new THREE.Vector3(d,0,0); break; + case '俯视图': pos = new THREE.Vector3(0,d,0); break; + default: pos = new THREE.Vector3(d,d,d); break; + } + camera.position.copy(center.clone().add(pos)); + controls.target.copy(center); + camera.lookAt(center); + controls.update(); +} + +function initControls() { + // 底层按钮和输入绑定 + document.getElementById('apply').onclick = renderSimple; + document.getElementById('play').onclick = startPlay; + document.getElementById('stop').onclick = stopPlay; + document.getElementById('record').onclick = startRecording; + document.getElementById('startTime').addEventListener('input', () => { + let st = +startTime.value; + if (st < timeMin) st = timeMin; + if (st > timeMax) st = timeMax; + startTime.value = st; + document.getElementById('timeLabel').innerText = st; + }); + // 限制 endTime 在 [startTime, timeMax] + document.getElementById('endTime').addEventListener('input', () => { + let et = +endTime.value, st = +startTime.value; + if (et < st) et = st; + if (et > timeMax) et = timeMax; + endTime.value = et; + }); + document.getElementById('viewSelect').onchange = applyView; +} + +function getStartEndInput() { + return { + start: +document.getElementById('startTime').value, + end: +document.getElementById('endTime').value + }; +} + +function getCurrentLabel() { + return +document.getElementById('timeLabel').innerText || 0; +} + +function updateTimeLabel(timeValue) { + const labelEl = document.getElementById('timeLabel'); + // 取当前文本,尝试解析为浮点数;如果不是数字(包括空字符串),则默认 0 + const current = parseFloat(labelEl.innerText) || 0; + const updated = current + timeValue; + if (updated > timeMax) { + updated = timeMax; + } + labelEl.innerText = updated; +} + +// 简单渲染 +async function renderSimple() { + // 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(); +} + + + +// 播放:start 固定,endLabel 动态 +async function startPlay() { + const { start, end } = getStartEndInput(); + let current = start; + document.getElementById('timeLabel').innerText = current; + isPlaying = true; + play.disabled = true; + + const interval = getIntervalMin(); // 分钟 + + while (isPlaying && current <= end) { + current += interval; + await renderFrame(start, current); + document.getElementById('timeLabel').innerText = current; + } + + stopPlay(); +} + +function stopPlay() { + isPlaying = false; + play.disabled = false; + 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(); + + while (isPlaying && current <= end) { + current += interval; + await renderFrame(start, current); + + // 截图上传 + const shot = await html2canvas(container, { backgroundColor: null, useCORS: true }); + const png = shot.toDataURL(); + await fetch(interfaceUrl + '/save_frame', { + method: 'POST', + headers: { 'Content-Type':'application/json' }, + body: JSON.stringify({ idx: frameIdx++, image: png }) + }); + document.getElementById('timeLabel').innerText = current; + } + + // 合成 GIF 并下载 + const res = await fetch(interfaceUrl + '/make_gif'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `animation_${Date.now()}.gif`; + document.body.appendChild(a); a.click(); + URL.revokeObjectURL(url); + + stopPlay(); +} + +let initialFocused = false; +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; + // 前端时间输入是分钟,需要转换成秒再传给后端 + const timeLabelVal = getCurrentLabel() * 60; + + updateBaseDate(); // 保证读到最新输入 + const actualDate = new Date(baseDate.getTime() + timeLabelVal * 1000); + const hh = String(actualDate.getHours()).padStart(2, '0'); + const mm = String(actualDate.getMinutes()).padStart(2, '0'); + const ss = String(actualDate.getSeconds()).padStart(2, '0'); + + 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(); + + document.getElementById('volume').innerText = '体积:' + volume + '立方米'; + + 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); + } + + 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; + } + 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); + + camera.position.set( + target.x + radius * sinP * cosA, + target.y + radius * cosP, + target.z + radius * sinP * sinA + ); + + // 5) 让相机正对 target + camera.lookAt(target); + + // 6) 同步 OrbitControls 的内部状态一次,之后不要在本函数里重复调用 + controls.update(); +} \ No newline at end of file diff --git a/static/pages/iso_scatter.html b/static/pages/iso_scatter.html index 3e723a2..f4cc17a 100644 --- a/static/pages/iso_scatter.html +++ b/static/pages/iso_scatter.html @@ -2,720 +2,214 @@ - - 三维能级渲染 & 服务端生成 GIF - - - + -
- -
-
-
- - - - - - -
- - - - - - -
- - - -
- - - -
当前时间:
-
- - - - -
-
-
-
加载中... 0%
-
-
-
+ 时间和体积显示 +
+
+ + +
+ + +
+ + + +
+ +
+
+ + + + + + + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+
- + + - - \ No newline at end of file +