File size: 4,482 Bytes
bc4c433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
"""Job B: unlabeled AABB lattice inside an uploaded OBJ (no occupancy GT).

Lattice math matches ``scatter_generation.raycast_scatter`` occupancy grid
(``_padded_bounds`` / ``_uniform_grid_points``) without importing that module
(Open3D / package ``__init__``). Does not import torch and does not raycast-label.
"""

from __future__ import annotations

import base64
import os
import tempfile
from pathlib import Path

import numpy as np


from scatteringnet.geometry.mesh_io import load_obj_triangles  # noqa: E402

MAX_FILL_POINTS = 200_000
SPACING_MIN = 0.04
SPACING_MAX = 0.50
# Slider 0 = coarse (0.40), 100 = dense (0.05). Training occupancy often uses 0.15.
SPACING_COARSE = 0.40
SPACING_FINE = 0.05


def spacing_from_slider(value: float) -> float:
    """Map UI density 0–100 to lattice spacing (higher = denser = smaller step)."""
    t = min(1.0, max(0.0, float(value) / 100.0))
    return float(SPACING_COARSE + (SPACING_FINE - SPACING_COARSE) * t)


def clamp_spacing(spacing: float) -> float:
    s = float(spacing)
    if s < SPACING_MIN or s > SPACING_MAX:
        raise ValueError(
            f"spacing must be between {SPACING_MIN} and {SPACING_MAX}, got {s}"
        )
    return s


def _padded_bounds(bounds: np.ndarray, pad: float) -> np.ndarray:
    """Expand AABB by ``pad`` on every side (same as occupancy lattice)."""
    bounds = np.asarray(bounds, dtype=np.float64)
    out = bounds.copy()
    out[0] -= pad
    out[1] += pad
    return out


def _uniform_grid_points(
    bounds: np.ndarray,
    spacing: float,
    *,
    max_points: int = MAX_FILL_POINTS,
) -> tuple[np.ndarray, float, tuple[int, int, int]]:
    """Regular XYZ lattice; coarsen spacing by 1.25 until under ``max_points``."""
    if spacing <= 0:
        raise ValueError("point_spacing must be > 0")
    bmin = bounds[0].astype(np.float64)
    bmax = bounds[1].astype(np.float64)
    extents = np.maximum(bmax - bmin, 1e-12)
    used = float(spacing)

    def counts(step: float) -> tuple[int, int, int]:
        return tuple(max(2, int(np.floor(extents[i] / step)) + 1) for i in range(3))

    nx, ny, nz = counts(used)
    while nx * ny * nz > max_points:
        used *= 1.25
        nx, ny, nz = counts(used)

    xs = np.linspace(bmin[0], bmax[0], nx, dtype=np.float64)
    ys = np.linspace(bmin[1], bmax[1], ny, dtype=np.float64)
    zs = np.linspace(bmin[2], bmax[2], nz, dtype=np.float64)
    xx, yy, zz = np.meshgrid(xs, ys, zs, indexing="ij")
    points = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()])
    return points, used, (nx, ny, nz)


def triangles_from_obj_text(text: str) -> tuple[np.ndarray, np.ndarray]:
    """Parse Wavefront text via a temp file (same loader as training)."""
    raw = str(text or "")
    if not raw.strip():
        raise ValueError("OBJ is empty")
    fd, path = tempfile.mkstemp(suffix=".obj")
    try:
        os.write(fd, raw.encode("utf-8"))
        os.close(fd)
        fd = -1
        return load_obj_triangles(path, cache=False)
    finally:
        if fd >= 0:
            try:
                os.close(fd)
            except OSError:
                pass
        try:
            os.unlink(path)
        except OSError:
            pass


def fill_aabb_lattice(
    vertices: np.ndarray,
    spacing: float,
    *,
    max_points: int = MAX_FILL_POINTS,
) -> tuple[np.ndarray, float, tuple[int, int, int]]:
    """
    Regular grid in a padded mesh AABB. Pad = spacing (outside shell), no jitter.
    """
    step = clamp_spacing(spacing)
    verts = np.asarray(vertices, dtype=np.float64)
    if verts.ndim != 2 or verts.shape[1] != 3 or verts.shape[0] < 1:
        raise ValueError(f"vertices must be (V, 3), got {tuple(verts.shape)}")
    bounds = np.stack([verts.min(axis=0), verts.max(axis=0)])
    sample_bounds = _padded_bounds(bounds, step)
    points, used, grid = _uniform_grid_points(
        sample_bounds, step, max_points=int(max_points)
    )
    return np.ascontiguousarray(points, dtype=np.float32), float(used), grid


def fill_from_obj_text(
    obj_text: str,
    spacing: float,
) -> dict:
    """Return lattice points (float32) and grid metadata."""
    vertices, _faces = triangles_from_obj_text(obj_text)
    points, used, grid = fill_aabb_lattice(vertices, spacing)
    return {
        "n": int(points.shape[0]),
        "used_spacing": used,
        "grid": [int(grid[0]), int(grid[1]), int(grid[2])],
        "points_b64": base64.b64encode(np.ascontiguousarray(points)).decode("ascii"),
    }