Реструктуризация проекта и генератор синтетических датасетов эхолота.

Перенесены backend/frontend/desktop/engine, добавлены вкладки конструктора сцен и генератора датасета с параметрами лучей и длины сетки рельефа, обновлены API и Docker-сборка.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 12:25:00 +03:00
co-authored by Cursor
parent 18a58f2e85
commit 4f253b860f
134 changed files with 5263 additions and 863 deletions
@@ -0,0 +1,264 @@
import { onBeforeUnmount, onMounted } from "vue";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
export function usePointCloudViewer(containerRef) {
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, 2.5);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
let controls = null;
let pointCloud = null;
let meshObject = null;
let animationId = 0;
let lastPoints = null;
let defaultCameraState = null;
function storeDefaultCameraState() {
defaultCameraState = {
position: camera.position.clone(),
target: controls?.target.clone() || new THREE.Vector3(),
near: camera.near,
far: camera.far,
};
}
function resize() {
const el = containerRef.value;
if (!el) return;
const width = el.clientWidth;
const height = el.clientHeight;
camera.aspect = width / Math.max(height, 1);
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
}
function animate() {
controls?.update();
renderer.render(scene, camera);
animationId = requestAnimationFrame(animate);
}
function clearObjects() {
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 boundsRadius(points) {
const box = new THREE.Box3();
for (const p of points) box.expandByPoint(new THREE.Vector3(p[0], p[1], p[2]));
const size = box.getSize(new THREE.Vector3());
return {
box,
radius: Math.max(size.x, size.y, size.z) * 0.6 || 1,
};
}
function pointSizeForRadius(radius, pointCount) {
// Visible both on dense clouds and small demos; scale with scene size.
const densityFactor = Math.min(1.4, Math.max(0.55, 9000 / Math.max(pointCount, 1)));
return Math.max(radius * 0.012 * densityFactor, 0.002);
}
function fitCamera(points) {
if (!points?.length) return;
const { box, radius } = boundsRadius(points);
const center = box.getCenter(new THREE.Vector3());
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();
storeDefaultCameraState();
}
function setSurfaceVisible(visible) {
if (meshObject) {
meshObject.visible = !!visible;
}
}
function renderGeometry(points, triangleIndices, surfaceVisible = true) {
clearObjects();
lastPoints = points;
if (!points?.length) 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 { radius } = boundsRadius(points);
const pointsGeometry = new THREE.BufferGeometry();
pointsGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
pointCloud = new THREE.Points(
pointsGeometry,
new THREE.PointsMaterial({
color: 0x7dd3fc,
size: pointSizeForRadius(radius, points.length),
sizeAttenuation: true,
}),
);
scene.add(pointCloud);
if (triangleIndices?.length) {
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.slice(), 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,
}),
);
meshObject.visible = !!surfaceVisible;
scene.add(meshObject);
}
fitCamera(points);
}
/**
* Render labeled cloud rows: [x, y, z, class].
* highlightClass: null = both classes colored; 0/1 = emphasize that class.
*/
function renderLabeledCloud(rows, { highlightClass = null, fit = true } = {}) {
clearObjects();
const points = (rows || []).map((r) => [r[0], r[1], r[2]]);
lastPoints = points;
if (!points.length) return;
const positions = new Float32Array(points.length * 3);
const colors = new Float32Array(points.length * 3);
const hl =
highlightClass === null || highlightClass === undefined || highlightClass === ""
? null
: Number(highlightClass);
const colorAll = {
0: new THREE.Color(0x64748b),
1: new THREE.Color(0xf59e0b),
};
const colorHi = {
0: new THREE.Color(0x38bdf8),
1: new THREE.Color(0xfbbf24),
};
const colorDim = new THREE.Color(0x1e293b);
for (let i = 0; i < rows.length; i += 1) {
positions[i * 3] = rows[i][0];
positions[i * 3 + 1] = rows[i][1];
positions[i * 3 + 2] = rows[i][2];
const cls = Math.round(Number(rows[i][3] ?? 0));
let c;
if (hl === null || Number.isNaN(hl)) {
c = colorAll[cls] || colorAll[0];
} else if (cls === hl) {
c = colorHi[cls] || colorHi[1];
} else {
c = colorDim;
}
colors[i * 3] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
}
const { radius } = boundsRadius(points);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
pointCloud = new THREE.Points(
geometry,
new THREE.PointsMaterial({
size: pointSizeForRadius(radius, points.length) * (hl === null ? 1 : 1.15),
sizeAttenuation: true,
vertexColors: true,
}),
);
scene.add(pointCloud);
if (fit) fitCamera(points);
}
function resetCamera() {
if (defaultCameraState && controls) {
camera.position.copy(defaultCameraState.position);
controls.target.copy(defaultCameraState.target);
camera.near = defaultCameraState.near;
camera.far = defaultCameraState.far;
camera.updateProjectionMatrix();
controls.update();
return;
}
if (lastPoints?.length) {
fitCamera(lastPoints);
}
}
function downloadSnapshot() {
renderer.render(scene, camera);
const dataUrl = renderer.domElement.toDataURL("image/png");
const link = document.createElement("a");
link.href = dataUrl;
link.download = `dottosurface-view-${Date.now()}.png`;
link.click();
}
function setBackground(color) {
scene.background = new THREE.Color(color);
}
onMounted(() => {
const el = containerRef.value;
if (!el) return;
el.appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
const light = new THREE.DirectionalLight(0xffffff, 0.9);
light.position.set(4, 6, 3);
scene.add(light);
resize();
animate();
window.addEventListener("resize", resize);
});
onBeforeUnmount(() => {
cancelAnimationFrame(animationId);
window.removeEventListener("resize", resize);
clearObjects();
renderer.dispose();
});
return {
renderGeometry,
renderLabeledCloud,
setSurfaceVisible,
resize,
setBackground,
resetCamera,
downloadSnapshot,
};
}
@@ -0,0 +1,293 @@
import { onBeforeUnmount, onMounted } from "vue";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
function parseColor(hex, fallback = 0x7dd3fc) {
if (!hex) return fallback;
try {
return new THREE.Color(hex).getHex();
} catch {
return fallback;
}
}
function isGizmoMode(mode) {
return mode === "translate" || mode === "rotate";
}
function rotateXyz(x, y, z, rx, ry, rz) {
const cx = Math.cos(rx);
const sx = Math.sin(rx);
const cy = Math.cos(ry);
const sy = Math.sin(ry);
const cz = Math.cos(rz);
const sz = Math.sin(rz);
let yy = y * cx - z * sx;
let zz = y * sx + z * cx;
let xx = x * cy + zz * sy;
zz = -x * sy + zz * cy;
const x2 = xx * cz - yy * sz;
const y2 = xx * sz + yy * cz;
return new THREE.Vector3(x2, y2, zz);
}
export function useSceneCloudViewer(containerRef, options = {}) {
const {
getLayers = () => [],
getSelectedId = () => null,
getMode = () => "orbit",
onTransform = () => {},
onTransformGestureEnd = () => {},
} = options;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1118);
const camera = new THREE.PerspectiveCamera(55, 1, 0.001, 100000);
camera.position.set(3.2, 2.4, 3.2);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.domElement.style.width = "100%";
renderer.domElement.style.height = "100%";
renderer.domElement.style.display = "block";
let orbit = null;
let transformControls = null;
let animationId = 0;
let layerGroup = new THREE.Group();
scene.add(layerGroup);
const pivots = new Map();
let fittedOnce = false;
function resize() {
const el = containerRef.value;
if (!el) return;
const width = el.clientWidth;
const height = el.clientHeight;
camera.aspect = width / Math.max(height, 1);
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
}
function animate() {
orbit?.update();
renderer.render(scene, camera);
animationId = requestAnimationFrame(animate);
}
function clearLayers() {
while (layerGroup.children.length) {
const child = layerGroup.children[0];
layerGroup.remove(child);
child.traverse?.((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) obj.material.dispose();
});
}
pivots.clear();
}
function pointSizeForCount(pointCount, radius) {
const densityFactor = Math.min(1.4, Math.max(0.55, 9000 / Math.max(pointCount, 1)));
return Math.max(radius * 0.012 * densityFactor, 0.004);
}
function applyPivotTransform(pivot, transform) {
pivot.position.set(
transform?.x || 0,
transform?.y || 0,
transform?.z || 0,
);
pivot.rotation.set(
transform?.rx || 0,
transform?.ry || 0,
transform?.rz || 0,
"XYZ",
);
}
function sceneBounds(layers) {
const box = new THREE.Box3();
let any = false;
for (const layer of layers) {
if (!layer.points?.length || layer.visible === false) continue;
const rx = layer.transform?.rx || 0;
const ry = layer.transform?.ry || 0;
const rz = layer.transform?.rz || 0;
const tx = layer.transform?.x || 0;
const ty = layer.transform?.y || 0;
const tz = layer.transform?.z || 0;
for (const p of layer.points) {
const v = rotateXyz(p[0], p[1], p[2], rx, ry, rz);
box.expandByPoint(v.add(new THREE.Vector3(tx, ty, tz)));
any = true;
}
}
return any ? box : null;
}
function fitCamera(layers) {
const box = sceneBounds(layers);
if (!box) return;
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(size.x, size.y, size.z) * 0.6 || 1;
orbit.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();
orbit?.update();
fittedOnce = true;
}
function syncLayers(layers, { fit = false } = {}) {
const selectedId = getSelectedId();
const mode = getMode();
if (transformControls) {
transformControls.detach();
}
clearLayers();
const visible = layers.filter((layer) => layer.visible !== false && layer.points?.length);
const box = sceneBounds(visible);
const radius = box
? Math.max(...box.getSize(new THREE.Vector3()).toArray()) * 0.6 || 1
: 1;
for (const layer of visible) {
const positions = new Float32Array(layer.points.length * 3);
for (let i = 0; i < layer.points.length; i += 1) {
positions[i * 3] = layer.points[i][0];
positions[i * 3 + 1] = layer.points[i][1];
positions[i * 3 + 2] = layer.points[i][2];
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const points = new THREE.Points(
geometry,
new THREE.PointsMaterial({
color: parseColor(layer.color),
size: pointSizeForCount(layer.points.length, radius),
sizeAttenuation: true,
}),
);
const pivot = new THREE.Object3D();
applyPivotTransform(pivot, layer.transform);
pivot.userData.layerId = layer.id;
pivot.add(points);
layerGroup.add(pivot);
pivots.set(layer.id, pivot);
}
if (isGizmoMode(mode) && selectedId && pivots.has(selectedId) && transformControls) {
transformControls.setMode(mode);
transformControls.attach(pivots.get(selectedId));
}
if ((fit || !fittedOnce) && visible.length) {
fitCamera(visible);
}
}
function setMode(mode) {
if (!orbit || !transformControls) return;
const gizmo = isGizmoMode(mode);
transformControls.enabled = gizmo;
if (gizmo) {
transformControls.setMode(mode);
}
const helper = transformControls.getHelper?.();
if (helper) helper.visible = gizmo;
orbit.enabled = !gizmo;
const selectedId = getSelectedId();
if (gizmo && selectedId && pivots.has(selectedId)) {
transformControls.attach(pivots.get(selectedId));
} else {
transformControls.detach();
}
}
function applyTransforms(layers) {
for (const layer of layers) {
const pivot = pivots.get(layer.id);
if (!pivot) continue;
applyPivotTransform(pivot, layer.transform);
}
}
function setBackground(color) {
scene.background = new THREE.Color(color);
}
function resetCamera() {
fitCamera(getLayers());
}
onMounted(() => {
const el = containerRef.value;
if (!el) return;
el.appendChild(renderer.domElement);
orbit = new OrbitControls(camera, renderer.domElement);
orbit.enableDamping = true;
transformControls = new TransformControls(camera, renderer.domElement);
transformControls.setMode("translate");
transformControls.addEventListener("dragging-changed", (event) => {
if (isGizmoMode(getMode())) {
orbit.enabled = !event.value;
if (!event.value) {
onTransformGestureEnd?.(getMode() === "rotate" ? "Вращение" : "Перемещение");
}
} else {
orbit.enabled = true;
}
});
transformControls.addEventListener("objectChange", () => {
const obj = transformControls.object;
if (!obj?.userData?.layerId) return;
onTransform(obj.userData.layerId, {
x: obj.position.x,
y: obj.position.y,
z: obj.position.z,
rx: obj.rotation.x,
ry: obj.rotation.y,
rz: obj.rotation.z,
});
});
scene.add(transformControls.getHelper());
scene.add(new THREE.AmbientLight(0xffffff, 0.7));
const light = new THREE.DirectionalLight(0xffffff, 0.85);
light.position.set(4, 6, 3);
scene.add(light);
scene.add(new THREE.AxesHelper(1.2));
resize();
animate();
window.addEventListener("resize", resize);
syncLayers(getLayers(), { fit: true });
setMode(getMode());
});
onBeforeUnmount(() => {
cancelAnimationFrame(animationId);
window.removeEventListener("resize", resize);
if (transformControls) {
transformControls.detach();
transformControls.dispose();
}
clearLayers();
renderer.dispose();
});
return {
syncLayers,
applyTransforms,
setMode,
setBackground,
resetCamera,
resize,
};
}
+24
View File
@@ -0,0 +1,24 @@
import { ref, watch } from "vue";
const THEME_KEY = "dottosurface-theme";
const theme = ref("dark");
function applyTheme(value) {
document.documentElement.setAttribute("data-theme", value);
localStorage.setItem(THEME_KEY, value);
}
export function initTheme() {
const saved = localStorage.getItem(THEME_KEY);
theme.value = saved === "light" ? "light" : "dark";
applyTheme(theme.value);
}
watch(theme, applyTheme);
export function useTheme() {
function toggleTheme() {
theme.value = theme.value === "dark" ? "light" : "dark";
}
return { theme, toggleTheme };
}