Добавить PLY-загрузку и Docker Web UI с PCL-пайплайном.

PLY читается в FilePointCloudSource, CLI отдаёт геометрию в output JSON,
а Docker/FastAPI/Three.js дают веб-запуск пайплайна без конфликта libpq на хосте.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 10:39:34 +03:00
co-authored by Cursor
parent 98afe5af5a
commit 45b2ed6e22
17 changed files with 1332 additions and 36 deletions
+236
View File
@@ -0,0 +1,236 @@
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
const fileInput = document.getElementById("fileInput");
const presetSelect = document.getElementById("presetSelect");
const runButton = document.getElementById("runButton");
const statusEl = document.getElementById("status");
const healthEl = document.getElementById("health");
const metricsEl = document.getElementById("metrics");
const metricInput = document.getElementById("metricInput");
const metricAfter = document.getElementById("metricAfter");
const metricTriangles = document.getElementById("metricTriangles");
const metricReconstruction = document.getElementById("metricReconstruction");
const metricTime = document.getElementById("metricTime");
const viewport = document.getElementById("viewport");
let presets = [];
let pointCloud = null;
let meshObject = null;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1118);
const camera = new THREE.PerspectiveCamera(55, 1, 0.001, 100000);
camera.position.set(2.5, 2.0, 2.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio || 1);
viewport.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
const keyLight = new THREE.DirectionalLight(0xffffff, 0.9);
keyLight.position.set(4, 6, 3);
scene.add(keyLight);
const grid = new THREE.GridHelper(10, 20, 0x31465d, 0x1d2a38);
grid.position.y = -0.001;
scene.add(grid);
function resize() {
const width = viewport.clientWidth;
const height = viewport.clientHeight;
camera.aspect = width / Math.max(height, 1);
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
}
function animate() {
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
function clearSceneObjects() {
if (pointCloud) {
scene.remove(pointCloud);
pointCloud.geometry.dispose();
pointCloud.material.dispose();
pointCloud = null;
}
if (meshObject) {
scene.remove(meshObject);
meshObject.geometry.dispose();
meshObject.material.dispose();
meshObject = null;
}
}
function fitCameraToPoints(points) {
if (!points || points.length === 0) {
return;
}
const box = new THREE.Box3();
for (const point of points) {
box.expandByPoint(new THREE.Vector3(point[0], point[1], point[2]));
}
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const radius = Math.max(size.x, size.y, size.z) * 0.6 || 1.0;
controls.target.copy(center);
camera.position.copy(center.clone().add(new THREE.Vector3(radius * 1.8, radius * 1.2, radius * 1.8)));
camera.near = Math.max(radius / 1000, 0.0001);
camera.far = radius * 100;
camera.updateProjectionMatrix();
controls.update();
}
function renderGeometry(points, triangleIndices) {
clearSceneObjects();
if (!points || points.length === 0) {
return;
}
const positions = new Float32Array(points.length * 3);
for (let i = 0; i < points.length; i += 1) {
positions[i * 3] = points[i][0];
positions[i * 3 + 1] = points[i][1];
positions[i * 3 + 2] = points[i][2];
}
const pointsGeometry = new THREE.BufferGeometry();
pointsGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
pointCloud = new THREE.Points(
pointsGeometry,
new THREE.PointsMaterial({ color: 0x7dd3fc, size: 0.01, sizeAttenuation: true })
);
scene.add(pointCloud);
if (triangleIndices && triangleIndices.length > 0) {
const indices = new Uint32Array(triangleIndices.length * 3);
for (let i = 0; i < triangleIndices.length; i += 1) {
indices[i * 3] = triangleIndices[i][0];
indices[i * 3 + 1] = triangleIndices[i][1];
indices[i * 3 + 2] = triangleIndices[i][2];
}
const meshGeometry = new THREE.BufferGeometry();
meshGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
meshGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
meshGeometry.computeVertexNormals();
meshObject = new THREE.Mesh(
meshGeometry,
new THREE.MeshStandardMaterial({
color: 0x60a5fa,
transparent: true,
opacity: 0.55,
side: THREE.DoubleSide,
flatShading: false,
})
);
scene.add(meshObject);
}
fitCameraToPoints(points);
}
function setStatus(message, isError = false) {
statusEl.textContent = message;
statusEl.style.color = isError ? "#ffb4c0" : "#9db0c3";
}
function updateMetrics(result) {
metricsEl.classList.remove("hidden");
metricInput.textContent = String(result.inputPoints ?? "-");
metricAfter.textContent = String(result.afterPreprocess ?? "-");
metricTriangles.textContent = String(result.triangles ?? "-");
metricReconstruction.textContent = String(result.reconstruction ?? "-");
metricTime.textContent = String(result.reconstructionMs ?? "-");
}
async function loadHealth() {
try {
const response = await fetch("/api/health");
const data = await response.json();
if (!response.ok) {
throw new Error("Health check failed");
}
healthEl.textContent = data.binaryExists === "True" || data.binaryExists === true
? "API online, pipeline binary ready"
: "API online, binary missing";
healthEl.className = "health ok";
} catch (error) {
healthEl.textContent = "API unavailable";
healthEl.className = "health error";
}
}
async function loadPresets() {
const response = await fetch("/api/presets");
presets = await response.json();
presetSelect.innerHTML = "";
const defaultOption = document.createElement("option");
defaultOption.value = "";
defaultOption.textContent = "Default pipeline";
presetSelect.appendChild(defaultOption);
for (const preset of presets) {
const option = document.createElement("option");
option.value = preset.id;
option.textContent = preset.title || preset.id;
presetSelect.appendChild(option);
}
}
async function runPipeline() {
const file = fileInput.files?.[0];
if (!file) {
setStatus("Select a point cloud file first.", true);
return;
}
runButton.disabled = true;
setStatus("Running pipeline...");
const formData = new FormData();
formData.append("file", file);
const presetId = presetSelect.value;
if (presetId) {
formData.append("preset_id", presetId);
}
try {
const response = await fetch("/api/run", {
method: "POST",
body: formData,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.detail || "Pipeline failed");
}
updateMetrics(payload);
renderGeometry(payload.points || [], payload.triangleIndices || []);
setStatus(payload.stdout || "Pipeline completed.");
} catch (error) {
setStatus(String(error.message || error), true);
} finally {
runButton.disabled = false;
}
}
runButton.addEventListener("click", runPipeline);
window.addEventListener("resize", resize);
resize();
animate();
loadHealth();
loadPresets().catch((error) => setStatus(`Failed to load presets: ${error}`, true));