Реструктуризация проекта и генератор синтетических датасетов эхолота.
Перенесены backend/frontend/desktop/engine, добавлены вкладки конструктора сцен и генератора датасета с параметрами лучей и длины сетки рельефа, обновлены API и Docker-сборка. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user