Вкладки позволяют готовить рельеф, двигать АНПА, накапливать поверхность по лучам и сохранять скриншоты окон; ГБО использует бортовые секторы 12–75° и чёрные зоны вне обзора. Co-authored-by: Cursor <cursoragent@cursor.com>
749 lines
29 KiB
Python
749 lines
29 KiB
Python
"""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",
|
|
classes: list[int | float] | None = None,
|
|
) -> str:
|
|
safe_name = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in (object_name or "cloud")) or "cloud"
|
|
if classes is None:
|
|
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"
|
|
|
|
if len(classes) != len(points):
|
|
raise ValueError("classes length must match points length")
|
|
|
|
class_names = {0: "background", 1: "object"}
|
|
grouped: dict[int, list[list[float]]] = {}
|
|
for point, cls in zip(points, classes):
|
|
grouped.setdefault(int(cls), []).append(point)
|
|
|
|
lines = [
|
|
f"# DotsToSurface labeled point cloud ({len(points)} vertices)",
|
|
"# Classes: background=0, object=1",
|
|
f"o {safe_name}",
|
|
]
|
|
for cls_id in sorted(grouped.keys()):
|
|
group_name = class_names.get(cls_id, f"class_{cls_id}")
|
|
lines.append(f"o {group_name}")
|
|
lines.append(f"# class {cls_id}")
|
|
for p in grouped[cls_id]:
|
|
lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def _class_from_obj_group(name: str) -> float | None:
|
|
key = (name or "").strip().lower()
|
|
if key == "background":
|
|
return 0.0
|
|
if key == "object":
|
|
return 1.0
|
|
if key.startswith("class_"):
|
|
try:
|
|
return float(key.split("_", 1)[1])
|
|
except (IndexError, ValueError):
|
|
return None
|
|
return None
|
|
|
|
|
|
def parse_obj_labeled_points(text: str) -> list[list[float]]:
|
|
"""Extract [x, y, z, class] from OBJ with class groups or ``# class N`` markers."""
|
|
labeled: list[list[float]] = []
|
|
current_class = 0.0
|
|
for raw in text.splitlines():
|
|
line = raw.strip()
|
|
if not line:
|
|
continue
|
|
lower = line.lower()
|
|
if lower.startswith("# class "):
|
|
try:
|
|
current_class = float(line.split()[-1])
|
|
except ValueError:
|
|
pass
|
|
continue
|
|
if lower.startswith("o "):
|
|
cls = _class_from_obj_group(line[2:])
|
|
if cls is not None:
|
|
current_class = cls
|
|
continue
|
|
if lower.startswith("v "):
|
|
parts = line.split()
|
|
if len(parts) < 4:
|
|
continue
|
|
try:
|
|
labeled.append(
|
|
[float(parts[1]), float(parts[2]), float(parts[3]), current_class]
|
|
)
|
|
except ValueError:
|
|
continue
|
|
return labeled
|
|
|
|
|
|
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
|