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

Перенесены 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
+674
View File
@@ -0,0 +1,674 @@
"""Parametric scene generator: objects, terrain surfaces, intersection clipping, export."""
from __future__ import annotations
import math
import random
from typing import Any
# ---------------------------------------------------------------------------
# Catalog / default params
# ---------------------------------------------------------------------------
LAYER_CATALOG: list[dict[str, Any]] = [
{
"kind": "object",
"type": "pipe",
"label": "Труба",
"params": [
{"key": "length", "label": "Длина", "type": "number", "default": 2.6, "min": 0.2, "max": 20, "step": 0.1},
{"key": "radius", "label": "Радиус", "type": "number", "default": 0.22, "min": 0.02, "max": 5, "step": 0.01},
{"key": "axis", "label": "Ось", "type": "select", "default": "y", "options": ["x", "y", "z"]},
{"key": "count", "label": "Точек", "type": "number", "default": 2500, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "object",
"type": "sphere",
"label": "Сфера",
"params": [
{"key": "radius", "label": "Радиус", "type": "number", "default": 0.5, "min": 0.05, "max": 10, "step": 0.05},
{"key": "count", "label": "Точек", "type": "number", "default": 2000, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.02, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "object",
"type": "box",
"label": "Параллелепипед",
"params": [
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 1.0, "min": 0.1, "max": 20, "step": 0.1},
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 0.6, "min": 0.1, "max": 20, "step": 0.1},
{"key": "sizeZ", "label": "Размер Z", "type": "number", "default": 0.4, "min": 0.1, "max": 20, "step": 0.1},
{"key": "count", "label": "Точек", "type": "number", "default": 2000, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "object",
"type": "torus",
"label": "Тор",
"params": [
{"key": "majorR", "label": "Большой R", "type": "number", "default": 1.0, "min": 0.1, "max": 10, "step": 0.05},
{"key": "minorR", "label": "Малый R", "type": "number", "default": 0.35, "min": 0.02, "max": 5, "step": 0.01},
{"key": "count", "label": "Точек", "type": "number", "default": 2500, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "surface",
"type": "ocean_floor",
"label": "Дно океана",
"params": [
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 4.0, "min": 0.5, "max": 50, "step": 0.1},
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 3.0, "min": 0.5, "max": 50, "step": 0.1},
{"key": "amplitude", "label": "Амплитуда", "type": "number", "default": 0.12, "min": 0, "max": 2, "step": 0.01},
{"key": "frequency", "label": "Частота", "type": "number", "default": 2.2, "min": 0.1, "max": 20, "step": 0.1},
{"key": "channel", "label": "Канал", "type": "number", "default": 0.08, "min": 0, "max": 1, "step": 0.01},
{"key": "baseZ", "label": "База Z", "type": "number", "default": -0.45, "min": -20, "max": 20, "step": 0.05},
{"key": "count", "label": "Точек", "type": "number", "default": 4000, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.02, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "surface",
"type": "wave",
"label": "Волна",
"params": [
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 2.4, "min": 0.5, "max": 50, "step": 0.1},
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 2.4, "min": 0.5, "max": 50, "step": 0.1},
{"key": "amplitude", "label": "Амплитуда", "type": "number", "default": 0.35, "min": 0, "max": 5, "step": 0.05},
{"key": "frequency", "label": "Частота", "type": "number", "default": 2.5, "min": 0.1, "max": 20, "step": 0.1},
{"key": "count", "label": "Точек", "type": "number", "default": 3000, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.015, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "surface",
"type": "flat",
"label": "Плоскость",
"params": [
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 4.0, "min": 0.5, "max": 50, "step": 0.1},
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 4.0, "min": 0.5, "max": 50, "step": 0.1},
{"key": "z", "label": "Высота Z", "type": "number", "default": -0.5, "min": -20, "max": 20, "step": 0.05},
{"key": "count", "label": "Точек", "type": "number", "default": 2500, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
]
_CATALOG_BY_KEY = {(item["kind"], item["type"]): item for item in LAYER_CATALOG}
LAYER_COLORS = {
("object", "pipe"): "#f59e0b",
("object", "sphere"): "#38bdf8",
("object", "box"): "#a78bfa",
("object", "torus"): "#34d399",
("surface", "ocean_floor"): "#64748b",
("surface", "wave"): "#94a3b8",
("surface", "flat"): "#78716c",
}
def catalog_payload() -> dict[str, Any]:
return {"layers": LAYER_CATALOG, "colors": {f"{k[0]}:{k[1]}": v for k, v in LAYER_COLORS.items()}}
def default_params(kind: str, type_name: str) -> dict[str, Any]:
entry = _CATALOG_BY_KEY.get((kind, type_name))
if entry is None:
raise ValueError(f"Unknown layer type: {kind}/{type_name}")
return {p["key"]: p["default"] for p in entry["params"]}
def merge_params(kind: str, type_name: str, params: dict[str, Any] | None) -> dict[str, Any]:
merged = default_params(kind, type_name)
if params:
for key, value in params.items():
if key in merged:
merged[key] = value
# Coerce numeric fields
entry = _CATALOG_BY_KEY[(kind, type_name)]
for p in entry["params"]:
key = p["key"]
if p["type"] == "number" and key in merged:
try:
merged[key] = float(merged[key])
if key in ("count", "seed"):
merged[key] = int(merged[key])
except (TypeError, ValueError):
merged[key] = p["default"]
if p["type"] == "select" and key in merged:
options = p.get("options") or []
if merged[key] not in options:
merged[key] = p["default"]
return merged
# ---------------------------------------------------------------------------
# Generation
# ---------------------------------------------------------------------------
def _jitter(rng: random.Random, noise: float) -> float:
if noise <= 0:
return 0.0
return rng.uniform(-noise, noise)
def generate_pipe(params: dict[str, Any]) -> list[list[float]]:
rng = random.Random(int(params["seed"]))
count = max(1, int(params["count"]))
length = float(params["length"])
radius = float(params["radius"])
noise = float(params["noise"])
axis = params.get("axis", "y")
points: list[list[float]] = []
half = length * 0.5
for _ in range(count):
angle = rng.random() * 2.0 * math.pi
t = rng.uniform(-half, half)
radial = radius + _jitter(rng, noise)
cx = radial * math.cos(angle)
cy = radial * math.sin(angle)
if axis == "x":
points.append([t, cx, cy])
elif axis == "z":
points.append([cx, cy, t])
else:
points.append([cx, t, cy])
return points
def generate_sphere(params: dict[str, Any]) -> list[list[float]]:
rng = random.Random(int(params["seed"]))
count = max(1, int(params["count"]))
radius = float(params["radius"])
noise = float(params["noise"])
points: list[list[float]] = []
for _ in range(count):
u = rng.uniform(-1.0, 1.0)
theta = rng.random() * 2.0 * math.pi
r = radius + _jitter(rng, noise)
s = math.sqrt(max(0.0, 1.0 - u * u))
points.append([r * s * math.cos(theta), r * s * math.sin(theta), r * u])
return points
def generate_box(params: dict[str, Any]) -> list[list[float]]:
"""Sample points on the box surface."""
rng = random.Random(int(params["seed"]))
count = max(1, int(params["count"]))
sx = float(params["sizeX"]) * 0.5
sy = float(params["sizeY"]) * 0.5
sz = float(params["sizeZ"]) * 0.5
noise = float(params["noise"])
faces = [
("x", sx, sy, sz),
("x", -sx, sy, sz),
("y", sy, sx, sz),
("y", -sy, sx, sz),
("z", sz, sx, sy),
("z", -sz, sx, sy),
]
areas = [abs(a[2]) * abs(a[3]) * 4.0 for a in faces]
total = sum(areas) or 1.0
points: list[list[float]] = []
for _ in range(count):
pick = rng.random() * total
acc = 0.0
face = faces[0]
for f, area in zip(faces, areas):
acc += area
if pick <= acc:
face = f
break
axis, fixed, u_max, v_max = face
u = rng.uniform(-u_max, u_max)
v = rng.uniform(-v_max, v_max)
jx, jy, jz = _jitter(rng, noise), _jitter(rng, noise), _jitter(rng, noise)
if axis == "x":
points.append([fixed + jx, u + jy, v + jz])
elif axis == "y":
points.append([u + jx, fixed + jy, v + jz])
else:
points.append([u + jx, v + jy, fixed + jz])
return points
def generate_torus(params: dict[str, Any]) -> list[list[float]]:
rng = random.Random(int(params["seed"]))
count = max(1, int(params["count"]))
major_r = float(params["majorR"])
minor_r = float(params["minorR"])
noise = float(params["noise"])
points: list[list[float]] = []
for _ in range(count):
u = rng.random() * 2.0 * math.pi
v = rng.random() * 2.0 * math.pi
radial = minor_r + _jitter(rng, noise)
x = (major_r + radial * math.cos(v)) * math.cos(u)
y = (major_r + radial * math.cos(v)) * math.sin(u)
z = radial * math.sin(v)
points.append([x, y, z])
return points
def height_ocean_floor(x: float, y: float, params: dict[str, Any]) -> float:
amplitude = float(params["amplitude"])
frequency = float(params["frequency"])
channel = float(params["channel"])
base_z = float(params["baseZ"])
waviness = amplitude * math.cos(frequency * y)
channel_term = channel * x * x
return base_z + channel_term + waviness
def height_wave(x: float, y: float, params: dict[str, Any]) -> float:
amplitude = float(params["amplitude"])
frequency = float(params["frequency"])
return amplitude * math.sin(frequency * x) * math.cos(frequency * y)
def height_flat(_x: float, _y: float, params: dict[str, Any]) -> float:
return float(params["z"])
def surface_height_fn(type_name: str):
if type_name == "ocean_floor":
return height_ocean_floor
if type_name == "wave":
return height_wave
if type_name == "flat":
return height_flat
raise ValueError(f"Unknown surface type: {type_name}")
def generate_surface(type_name: str, params: dict[str, Any]) -> list[list[float]]:
rng = random.Random(int(params["seed"]))
count = max(1, int(params["count"]))
size_x = float(params.get("sizeX", 2.0))
size_y = float(params.get("sizeY", 2.0))
noise = float(params["noise"])
height_fn = surface_height_fn(type_name)
half_x = size_x * 0.5
half_y = size_y * 0.5
points: list[list[float]] = []
for _ in range(count):
x = rng.uniform(-half_x, half_x)
y = rng.uniform(-half_y, half_y)
z = height_fn(x, y, params) + _jitter(rng, noise)
points.append([x, y, z])
return points
_GENERATORS = {
("object", "pipe"): generate_pipe,
("object", "sphere"): generate_sphere,
("object", "box"): generate_box,
("object", "torus"): generate_torus,
}
def generate_layer(kind: str, type_name: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
if (kind, type_name) not in _CATALOG_BY_KEY:
raise ValueError(f"Unknown layer type: {kind}/{type_name}")
merged = merge_params(kind, type_name, params)
if kind == "surface":
points = generate_surface(type_name, merged)
else:
points = _GENERATORS[(kind, type_name)](merged)
entry = _CATALOG_BY_KEY[(kind, type_name)]
return {
"kind": kind,
"type": type_name,
"label": entry["label"],
"params": merged,
"pointCount": len(points),
"points": points,
"color": LAYER_COLORS.get((kind, type_name), "#7dd3fc"),
}
# ---------------------------------------------------------------------------
# Transforms & intersections
# ---------------------------------------------------------------------------
def normalize_transform(transform: dict[str, Any] | None) -> dict[str, float]:
t = transform or {}
return {
"x": float(t.get("x", 0.0) or 0.0),
"y": float(t.get("y", 0.0) or 0.0),
"z": float(t.get("z", 0.0) or 0.0),
"rx": float(t.get("rx", 0.0) or 0.0),
"ry": float(t.get("ry", 0.0) or 0.0),
"rz": float(t.get("rz", 0.0) or 0.0),
}
def _rotate_xyz(x: float, y: float, z: float, rx: float, ry: float, rz: float) -> tuple[float, float, float]:
"""Euler XYZ (same as Three.js Object3D.rotation default order)."""
cx, sx = math.cos(rx), math.sin(rx)
cy, sy = math.cos(ry), math.sin(ry)
cz, sz = math.cos(rz), math.sin(rz)
y, z = y * cx - z * sx, y * sx + z * cx
x, z = x * cy + z * sy, -x * sy + z * cy
x, y = x * cz - y * sz, x * sz + y * cz
return x, y, z
def _rotate_xyz_inverse(x: float, y: float, z: float, rx: float, ry: float, rz: float) -> tuple[float, float, float]:
cx, sx = math.cos(rx), math.sin(rx)
cy, sy = math.cos(ry), math.sin(ry)
cz, sz = math.cos(rz), math.sin(rz)
x, y = x * cz + y * sz, -x * sz + y * cz
x, z = x * cy - z * sy, x * sy + z * cy
y, z = y * cx + z * sx, -y * sx + z * cx
return x, y, z
def apply_transform(points: list[list[float]], transform: dict[str, Any] | None) -> list[list[float]]:
t = normalize_transform(transform)
out: list[list[float]] = []
for p in points:
x, y, z = _rotate_xyz(p[0], p[1], p[2], t["rx"], t["ry"], t["rz"])
out.append([x + t["x"], y + t["y"], z + t["z"]])
return out
def _world_to_local(point: list[float], transform: dict[str, Any] | None) -> tuple[float, float, float]:
t = normalize_transform(transform)
x = point[0] - t["x"]
y = point[1] - t["y"]
z = point[2] - t["z"]
return _rotate_xyz_inverse(x, y, z, t["rx"], t["ry"], t["rz"])
def object_sdf(type_name: str, params: dict[str, Any], local: tuple[float, float, float]) -> float:
"""Signed distance: negative = inside."""
if type_name == "imported":
# No analytic SDF for imported clouds — skip solid clipping.
return 1.0
x, y, z = local
if type_name == "sphere":
return math.sqrt(x * x + y * y + z * z) - float(params["radius"])
if type_name == "pipe":
radius = float(params["radius"])
half = float(params["length"]) * 0.5
axis = params.get("axis", "y")
if axis == "x":
radial = math.sqrt(y * y + z * z) - radius
axial = abs(x) - half
elif axis == "z":
radial = math.sqrt(x * x + y * y) - radius
axial = abs(z) - half
else:
radial = math.sqrt(x * x + z * z) - radius
axial = abs(y) - half
# Approximate solid cylinder: inside if radial < 0 and axial < 0
outside = max(radial, axial)
if radial < 0 and axial < 0:
return max(radial, axial)
if axial > 0 and radial < 0:
return axial
if radial > 0 and axial < 0:
return radial
return math.sqrt(max(radial, 0) ** 2 + max(axial, 0) ** 2) if outside > 0 else outside
if type_name == "box":
hx = float(params["sizeX"]) * 0.5
hy = float(params["sizeY"]) * 0.5
hz = float(params["sizeZ"]) * 0.5
qx = abs(x) - hx
qy = abs(y) - hy
qz = abs(z) - hz
outside = math.sqrt(max(qx, 0) ** 2 + max(qy, 0) ** 2 + max(qz, 0) ** 2)
inside = min(max(qx, qy, qz), 0.0)
return outside + inside
if type_name == "torus":
major_r = float(params["majorR"])
minor_r = float(params["minorR"])
q = math.sqrt(x * x + y * y) - major_r
return math.sqrt(q * q + z * z) - minor_r
return 1.0
def point_below_surface(
world_pt: list[float],
surf_type: str,
surf_params: dict[str, Any],
surf_transform: dict[str, Any] | None,
eps: float,
) -> bool:
"""True if world point is below the heightfield in the surface local frame."""
lx, ly, lz = _world_to_local(world_pt, surf_transform)
size_x = float(surf_params.get("sizeX", 1e9))
size_y = float(surf_params.get("sizeY", 1e9))
if abs(lx) > size_x * 0.5 + eps or abs(ly) > size_y * 0.5 + eps:
return False
h = surface_height_fn(surf_type)(lx, ly, surf_params)
return lz < h + eps
def resolve_intersections(
layers: list[dict[str, Any]],
*,
eps: float = 0.01,
clip_surface_inside_objects: bool = True,
clip_objects_vs_objects: bool = True,
) -> list[dict[str, Any]]:
"""Return layers with points updated (local coords preserved via inverse transform)."""
prepared: list[dict[str, Any]] = []
for layer in layers:
kind = layer["kind"]
type_name = layer["type"]
transform = normalize_transform(layer.get("transform"))
local_points = layer.get("points")
if type_name == "imported":
params = dict(layer.get("params") or {})
if not local_points:
raise ValueError("Imported layer has no points.")
label = layer.get("label") or "OBJ"
color = layer.get("color") or "#f472b6"
else:
params = merge_params(kind, type_name, layer.get("params"))
if not local_points:
generated = generate_layer(kind, type_name, params)
local_points = generated["points"]
label = layer.get("label") or _CATALOG_BY_KEY[(kind, type_name)]["label"]
color = layer.get("color") or LAYER_COLORS.get((kind, type_name), "#7dd3fc")
world = apply_transform(local_points, transform)
prepared.append({
"id": layer.get("id"),
"kind": kind,
"type": type_name,
"params": params,
"transform": transform,
"local_points": local_points,
"world_points": world,
"color": color,
"label": label,
})
surfaces = [p for p in prepared if p["kind"] == "surface"]
objects = [p for p in prepared if p["kind"] == "object"]
result: list[dict[str, Any]] = []
for layer in prepared:
keep_local: list[list[float]] = []
keep_world: list[list[float]] = []
for local_pt, world_pt in zip(layer["local_points"], layer["world_points"]):
drop = False
if layer["kind"] == "object":
for surf in surfaces:
if point_below_surface(
world_pt, surf["type"], surf["params"], surf["transform"], eps
):
drop = True
break
if not drop and clip_objects_vs_objects:
for other in objects:
if other is layer:
continue
local_in_other = _world_to_local(world_pt, other["transform"])
if object_sdf(other["type"], other["params"], local_in_other) < -eps:
drop = True
break
elif layer["kind"] == "surface" and clip_surface_inside_objects:
for obj in objects:
local_in_obj = _world_to_local(world_pt, obj["transform"])
if object_sdf(obj["type"], obj["params"], local_in_obj) < -eps:
drop = True
break
if not drop:
keep_local.append([local_pt[0], local_pt[1], local_pt[2]])
keep_world.append(world_pt)
result.append({
"id": layer["id"],
"kind": layer["kind"],
"type": layer["type"],
"label": layer["label"],
"params": layer["params"],
"transform": layer["transform"],
"color": layer["color"],
"pointCount": len(keep_local),
"points": keep_local,
"removedCount": len(layer["local_points"]) - len(keep_local),
})
return result
def merge_layers_world(layers: list[dict[str, Any]]) -> list[list[float]]:
merged: list[list[float]] = []
for layer in layers:
kind = layer["kind"]
type_name = layer["type"]
transform = normalize_transform(layer.get("transform"))
points = layer.get("points")
if not points:
if type_name == "imported":
continue
params = merge_params(kind, type_name, layer.get("params"))
points = generate_layer(kind, type_name, params)["points"]
merged.extend(apply_transform(points, transform))
return merged
def layer_semantic_class(layer: dict[str, Any]) -> float:
"""Binary PointNet label: 1 = pipe, 0 = everything else."""
type_name = str(layer.get("type") or "").lower()
if type_name == "pipe":
return 1.0
# Optional name hint for renamed imported clouds
name = str(layer.get("name") or layer.get("label") or "").lower()
if "pipe" in name or "труб" in name:
return 1.0
return 0.0
def points_to_pointnet_rows(points: list[list[float]], class_label: float) -> list[list[float]]:
"""XYZRGB+class rows; RGB forced to 0; float values (stored as float64 in .npy)."""
c = float(class_label)
rows: list[list[float]] = []
for p in points:
rows.append([float(p[0]), float(p[1]), float(p[2]), 0.0, 0.0, 0.0, c])
return rows
def layers_to_pointnet_rows(layers: list[dict[str, Any]]) -> list[list[float]]:
rows: list[list[float]] = []
for layer in layers:
kind = layer.get("kind") or "object"
type_name = layer.get("type") or "imported"
transform = normalize_transform(layer.get("transform"))
points = layer.get("points")
if not points:
if type_name == "imported":
continue
params = merge_params(kind, type_name, layer.get("params"))
points = generate_layer(kind, type_name, params)["points"]
world = apply_transform(points, transform)
rows.extend(points_to_pointnet_rows(world, layer_semantic_class(layer)))
return rows
def export_npy_float64(rows: list[list[float]]) -> bytes:
"""Write NumPy .npy v1.0 binary array shape (N, C) dtype float64 little-endian."""
import struct
n = len(rows)
cols = len(rows[0]) if n else 7
if n and any(len(r) != cols for r in rows):
raise ValueError("All rows must have the same length for .npy export.")
header = "{'descr': '<f8', 'fortran_order': False, 'shape': (%d, %d), }" % (n, cols)
# Pad so magic(6)+ver(2)+hlen(2)+header is multiple of 64.
preamble = 10
pad = 64 - ((preamble + len(header) + 1) % 64)
if pad == 64:
pad = 0
header_padded = (header + (" " * pad) + "\n").encode("latin1")
out = bytearray()
out += b"\x93NUMPY"
out += struct.pack("<BB", 1, 0)
out += struct.pack("<H", len(header_padded))
out += header_padded
for row in rows:
for value in row:
out += struct.pack("<d", float(value))
return bytes(out)
def export_xyz(points: list[list[float]]) -> str:
return "\n".join(f"{p[0]:.8f} {p[1]:.8f} {p[2]:.8f}" for p in points) + ("\n" if points else "")
def export_ply(points: list[list[float]]) -> str:
header = (
"ply\n"
"format ascii 1.0\n"
f"element vertex {len(points)}\n"
"property float x\n"
"property float y\n"
"property float z\n"
"end_header\n"
)
body = "\n".join(f"{p[0]:.8f} {p[1]:.8f} {p[2]:.8f}" for p in points)
return header + body + ("\n" if points else "")
def export_obj(points: list[list[float]], object_name: str = "cloud") -> str:
safe_name = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in (object_name or "cloud")) or "cloud"
lines = [f"# DotsToSurface point cloud ({len(points)} vertices)", f"o {safe_name}"]
for p in points:
lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}")
return "\n".join(lines) + "\n"
def parse_obj_points(text: str) -> list[list[float]]:
"""Extract vertex positions from Wavefront OBJ (ignores faces/materials)."""
points: list[list[float]] = []
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.lower().startswith("v "):
parts = line.split()
if len(parts) < 4:
continue
try:
points.append([float(parts[1]), float(parts[2]), float(parts[3])])
except ValueError:
continue
return points