Добавить 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:
+236
@@ -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));
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DotsToSirface Web</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div>
|
||||
<h1>DotsToSirface Web</h1>
|
||||
<p>PLY upload, PCL pipeline in Docker, 3D preview in browser</p>
|
||||
</div>
|
||||
<div id="health" class="health">Checking API...</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="panel controls">
|
||||
<h2>Pipeline</h2>
|
||||
|
||||
<label class="field">
|
||||
<span>Point cloud (.ply)</span>
|
||||
<input id="fileInput" type="file" accept=".ply,.txt,.csv,.xyz,.bin">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Preset</span>
|
||||
<select id="presetSelect"></select>
|
||||
</label>
|
||||
|
||||
<button id="runButton" type="button">Run pipeline</button>
|
||||
<p id="status" class="status">Upload a PLY file and click Run.</p>
|
||||
|
||||
<div id="metrics" class="metrics hidden">
|
||||
<div><strong>Input:</strong> <span id="metricInput">-</span></div>
|
||||
<div><strong>After preprocess:</strong> <span id="metricAfter">-</span></div>
|
||||
<div><strong>Triangles:</strong> <span id="metricTriangles">-</span></div>
|
||||
<div><strong>Reconstruction:</strong> <span id="metricReconstruction">-</span></div>
|
||||
<div><strong>Time:</strong> <span id="metricTime">-</span> ms</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel viewer">
|
||||
<h2>3D Viewer</h2>
|
||||
<div id="viewport"></div>
|
||||
<p class="hint">Drag to rotate, wheel to zoom. Points and mesh are shown after pipeline run.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, Segoe UI, Roboto, sans-serif;
|
||||
background: #0f1720;
|
||||
color: #e8eef5;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #243447;
|
||||
background: #152231;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: #9db0c3;
|
||||
}
|
||||
|
||||
.health {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: #1f2f40;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.health.ok {
|
||||
background: #173528;
|
||||
color: #9be7b5;
|
||||
}
|
||||
|
||||
.health.error {
|
||||
background: #3a1d24;
|
||||
color: #ffb4c0;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
min-height: calc(100vh - 96px);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #152231;
|
||||
border: 1px solid #243447;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #31465d;
|
||||
background: #0f1720;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 14px 0 0;
|
||||
color: #9db0c3;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #243447;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
#viewport {
|
||||
flex: 1;
|
||||
min-height: 520px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #243447;
|
||||
background: #0b1118;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 12px 0 0;
|
||||
color: #7f93a8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user