DeepSDFStruct 1.7.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- DeepSDFStruct/SDF.py +1734 -0
- DeepSDFStruct/__init__.py +66 -0
- DeepSDFStruct/deep_sdf/__init__.py +69 -0
- DeepSDFStruct/deep_sdf/create_screenshots_from_plyfiles.py +65 -0
- DeepSDFStruct/deep_sdf/data.py +258 -0
- DeepSDFStruct/deep_sdf/metrics/__init__.py +14 -0
- DeepSDFStruct/deep_sdf/metrics/mesh_to_analytical.py +24 -0
- DeepSDFStruct/deep_sdf/models.py +197 -0
- DeepSDFStruct/deep_sdf/networks/__init__.py +8 -0
- DeepSDFStruct/deep_sdf/networks/analytic_round_cross.py +105 -0
- DeepSDFStruct/deep_sdf/networks/deep_sdf_decoder.py +155 -0
- DeepSDFStruct/deep_sdf/networks/hierarchical_deep_sdf_decoder.py +139 -0
- DeepSDFStruct/deep_sdf/networks/hierarchical_positional_sdf_decoder.py +190 -0
- DeepSDFStruct/deep_sdf/networks/resnet_positional_sdf_decoder.py +108 -0
- DeepSDFStruct/deep_sdf/nn_utils.py +70 -0
- DeepSDFStruct/deep_sdf/plotting.py +143 -0
- DeepSDFStruct/deep_sdf/reconstruction.py +165 -0
- DeepSDFStruct/deep_sdf/training.py +840 -0
- DeepSDFStruct/deep_sdf/workspace.py +363 -0
- DeepSDFStruct/design_of_experiments.py +235 -0
- DeepSDFStruct/flexicubes/__init__.py +15 -0
- DeepSDFStruct/flexicubes/flexicubes.py +1005 -0
- DeepSDFStruct/flexicubes/tables.py +2338 -0
- DeepSDFStruct/flexisquares/__init__.py +15 -0
- DeepSDFStruct/flexisquares/flexisquares.py +967 -0
- DeepSDFStruct/flexisquares/tables.py +63 -0
- DeepSDFStruct/lattice_structure.py +254 -0
- DeepSDFStruct/local_shapes.py +226 -0
- DeepSDFStruct/mesh.py +1184 -0
- DeepSDFStruct/optimization.py +357 -0
- DeepSDFStruct/parametrization.py +215 -0
- DeepSDFStruct/pretrained_models.py +128 -0
- DeepSDFStruct/sampling.py +670 -0
- DeepSDFStruct/sdf_operations.py +990 -0
- DeepSDFStruct/sdf_primitives.py +1416 -0
- DeepSDFStruct/splinepy_unitcells/__init__.py +35 -0
- DeepSDFStruct/splinepy_unitcells/chi_3D.py +1617 -0
- DeepSDFStruct/splinepy_unitcells/cross_lattice.py +165 -0
- DeepSDFStruct/splinepy_unitcells/double_lattice_extruded.py +298 -0
- DeepSDFStruct/splinepy_unitcells/snappy_3d.py +544 -0
- DeepSDFStruct/torch_spline.py +446 -0
- DeepSDFStruct/trained_models/analytic_round_cross/LatentCodes/latest.pth +0 -0
- DeepSDFStruct/trained_models/analytic_round_cross/ModelParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/analytic_round_cross/OptimizerParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/analytic_round_cross/specs.json +45 -0
- DeepSDFStruct/trained_models/chi_and_cross/LatentCodes/latest.pth +0 -0
- DeepSDFStruct/trained_models/chi_and_cross/Logs.pth +0 -0
- DeepSDFStruct/trained_models/chi_and_cross/ModelParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/chi_and_cross/OptimizerParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/chi_and_cross/specs.json +76 -0
- DeepSDFStruct/trained_models/primitives/LatentCodes/latest.pth +0 -0
- DeepSDFStruct/trained_models/primitives/ModelParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/primitives/OptimizerParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/primitives/specs.json +46 -0
- DeepSDFStruct/trained_models/primitives_2d/LatentCodes/latest.pth +0 -0
- DeepSDFStruct/trained_models/primitives_2d/ModelParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/primitives_2d/OptimizerParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/primitives_2d/specs.json +46 -0
- DeepSDFStruct/trained_models/round_cross/LatentCodes/latest.pth +0 -0
- DeepSDFStruct/trained_models/round_cross/Logs.pth +0 -0
- DeepSDFStruct/trained_models/round_cross/ModelParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/round_cross/OptimizerParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/round_cross/specs.json +45 -0
- DeepSDFStruct/trained_models/test_experiment/LatentCodes/latent_code_data_map.json +106 -0
- DeepSDFStruct/trained_models/test_experiment/specs.json +45 -0
- DeepSDFStruct/trained_models/test_experiment/training_summary.json +10 -0
- DeepSDFStruct/trained_models/test_experiment_hierarchical/LatentCodes/latent_code_data_map.json +106 -0
- DeepSDFStruct/trained_models/test_experiment_hierarchical/specs.json +45 -0
- DeepSDFStruct/trained_models/test_experiment_hierarchical/training_summary.json +10 -0
- DeepSDFStruct/trained_models/test_experiment_homogenization/LatentCodes/latent_code_data_map.json +5006 -0
- DeepSDFStruct/trained_models/test_experiment_homogenization/LatentCodes/latest.pth +0 -0
- DeepSDFStruct/trained_models/test_experiment_homogenization/Logs.png +0 -0
- DeepSDFStruct/trained_models/test_experiment_homogenization/ModelParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/test_experiment_homogenization/OptimizerParameters/latest.pth +0 -0
- DeepSDFStruct/trained_models/test_experiment_homogenization/specs.json +83 -0
- DeepSDFStruct/trained_models/test_experiment_homogenization/training_summary.json +10 -0
- DeepSDFStruct/utils.py +96 -0
- DeepSDFStruct/visualization/latent_viewer.py +211 -0
- DeepSDFStruct/visualization/latent_viewer_cli.py +8 -0
- benchmarks/generate_sdf_showcase.py +622 -0
- benchmarks/sdf_from_mesh_benchmark.py +52 -0
- benchmarks/sdf_from_mesh_comparison.md +33 -0
- benchmarks/sdf_showcase/operations/boolean/difference_sphere_box.png +0 -0
- benchmarks/sdf_showcase/operations/boolean/union_sphere_box.png +0 -0
- benchmarks/sdf_showcase/operations/transformations/circular_array_sphere.png +0 -0
- benchmarks/sdf_showcase/operations/transformations/dilate_sphere.png +0 -0
- benchmarks/sdf_showcase/operations/transformations/mirror_sphere.png +0 -0
- benchmarks/sdf_showcase/operations/transformations/repeat_sphere.png +0 -0
- benchmarks/sdf_showcase/operations/transformations/revolve_circle.png +0 -0
- benchmarks/sdf_showcase/operations/transformations/shell_sphere.png +0 -0
- benchmarks/sdf_showcase/operations/transformations/twist_torus.png +0 -0
- benchmarks/sdf_showcase/primitives/2D/circle.png +0 -0
- benchmarks/sdf_showcase/primitives/2D/equilateral_triangle.png +0 -0
- benchmarks/sdf_showcase/primitives/2D/hexagon.png +0 -0
- benchmarks/sdf_showcase/primitives/2D/polygon_pentagon.png +0 -0
- benchmarks/sdf_showcase/primitives/2D/rectangle.png +0 -0
- benchmarks/sdf_showcase/primitives/2D/rounded_rectangle.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/box.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/capped_cone.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/capped_cylinder.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/capsule.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/cone.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/corner_spheres.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/cross_ms.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/cylinder.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/dodecahedron.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/ellipsoid.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/icosahedron.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/octahedron.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/pyramid.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/rounded_box.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/rounded_cone.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/rounded_cylinder.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/sphere.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/tetrahedron.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/torus.png +0 -0
- benchmarks/sdf_showcase/primitives/3D/wireframe_box.png +0 -0
- deepsdfstruct-1.7.2.dist-info/METADATA +44 -0
- deepsdfstruct-1.7.2.dist-info/RECORD +165 -0
- deepsdfstruct-1.7.2.dist-info/WHEEL +5 -0
- deepsdfstruct-1.7.2.dist-info/licenses/LICENSE +208 -0
- deepsdfstruct-1.7.2.dist-info/licenses/NOTICE +67 -0
- deepsdfstruct-1.7.2.dist-info/scm_file_list.json +178 -0
- deepsdfstruct-1.7.2.dist-info/scm_version.json +8 -0
- deepsdfstruct-1.7.2.dist-info/top_level.txt +4 -0
- docs/Makefile +20 -0
- docs/api_reference.rst +6 -0
- docs/conf.py +60 -0
- docs/index.rst +136 -0
- docs/make.bat +35 -0
- docs/qr_code_github.png +0 -0
- docs/qr_code_paper.png +0 -0
- docs/readme_images/example_output_01.png +0 -0
- docs/readme_images/example_output_02.png +0 -0
- docs/readme_images/example_output_03.png +0 -0
- docs/readme_images/example_output_04.png +0 -0
- tests/data/chairs/1005.obj +282 -0
- tests/data/chairs/1024.obj +222 -0
- tests/data/chairs/README.md +1 -0
- tests/data/circular_balls_test_case.stl +0 -0
- tests/data/cone.stl +1794 -0
- tests/data/example_disconnectd_mesh.inp +1784 -0
- tests/data/example_line_mesh.vtk +0 -0
- tests/data/stanford_bunny.stl +0 -0
- tests/data/sweep_test_case.stl +0 -0
- tests/test_DOE.py +49 -0
- tests/test_elongate_sdf.py +195 -0
- tests/test_flexisquares.py +187 -0
- tests/test_generate_dataset.py +76 -0
- tests/test_lattice_evaluation.py +137 -0
- tests/test_mesh_export.py +137 -0
- tests/test_mesh_functions.py +49 -0
- tests/test_networks.py +71 -0
- tests/test_pretrained_models.py +40 -0
- tests/test_reconstruction.py +101 -0
- tests/test_sdf_from_mesh.py +52 -0
- tests/test_sdf_functions.py +117 -0
- tests/test_sdf_primitives.py +668 -0
- tests/test_sdf_trimesh_comparison.py +482 -0
- tests/test_splinepy_unitcells.py +64 -0
- tests/test_structural_optimization.py +178 -0
- tests/test_torch_spline.py +216 -0
- tests/test_train_model.py +110 -0
- tests/test_training_data_ids.py +41 -0
- tests/tmp_outputs/temp_file.txt +0 -0
DeepSDFStruct/SDF.py
ADDED
|
@@ -0,0 +1,1734 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Signed Distance Function (SDF) Base Classes and Operations
|
|
3
|
+
==========================================================
|
|
4
|
+
|
|
5
|
+
This module provides the foundational classes and utilities for working with
|
|
6
|
+
Signed Distance Functions (SDFs) in DeepSDFStruct. SDFs are implicit geometric
|
|
7
|
+
representations that encode the distance from any point in space to the nearest
|
|
8
|
+
surface, with the sign indicating whether the point is inside (negative) or
|
|
9
|
+
outside (positive) the geometry.
|
|
10
|
+
|
|
11
|
+
Key Features
|
|
12
|
+
------------
|
|
13
|
+
|
|
14
|
+
SDFBase Abstract Class
|
|
15
|
+
Base class for all SDF representations with support for:
|
|
16
|
+
- Spline-based geometric deformations
|
|
17
|
+
- Spatially-varying parametrization
|
|
18
|
+
- Boundary conditions and capping
|
|
19
|
+
- Boolean operations (union, intersection)
|
|
20
|
+
- Differentiable operations for optimization
|
|
21
|
+
|
|
22
|
+
SDFfromMesh
|
|
23
|
+
Convert triangular surface meshes to SDF representations using
|
|
24
|
+
fast winding number algorithms for robust inside/outside testing.
|
|
25
|
+
|
|
26
|
+
SDFfromDeepSDF
|
|
27
|
+
Neural network-based SDF using trained DeepSDF models for
|
|
28
|
+
complex, learned geometric representations.
|
|
29
|
+
|
|
30
|
+
Union and Intersection
|
|
31
|
+
Combine multiple SDFs using smooth boolean operations with
|
|
32
|
+
configurable smoothing for differentiable geometry.
|
|
33
|
+
|
|
34
|
+
Utility Functions
|
|
35
|
+
- Grid sampling for SDF evaluation
|
|
36
|
+
- Gradient computation for normal vectors
|
|
37
|
+
- Boundary condition application
|
|
38
|
+
|
|
39
|
+
The module enables flexible construction and manipulation of complex
|
|
40
|
+
3D geometries in a differentiable framework suitable for optimization,
|
|
41
|
+
simulation, and machine learning applications.
|
|
42
|
+
|
|
43
|
+
Examples
|
|
44
|
+
--------
|
|
45
|
+
Create and evaluate an SDF from a mesh::
|
|
46
|
+
|
|
47
|
+
import trimesh
|
|
48
|
+
from DeepSDFStruct.SDF import SDFfromMesh
|
|
49
|
+
|
|
50
|
+
mesh = trimesh.load('model.stl')
|
|
51
|
+
sdf = SDFfromMesh(mesh)
|
|
52
|
+
|
|
53
|
+
# Query SDF values
|
|
54
|
+
points = torch.rand(1000, 3)
|
|
55
|
+
distances = sdf(points)
|
|
56
|
+
|
|
57
|
+
Combine SDFs with boolean operations::
|
|
58
|
+
|
|
59
|
+
from DeepSDFStruct.sdf_primitives import SphereSDF
|
|
60
|
+
from DeepSDFStruct.SDF import Union
|
|
61
|
+
|
|
62
|
+
sphere1 = SphereSDF([0, 0, 0], radius=1.0)
|
|
63
|
+
sphere2 = SphereSDF([1, 0, 0], radius=1.0)
|
|
64
|
+
combined = Union([sphere1, sphere2], smoothing=0.1)
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from abc import ABC, abstractmethod
|
|
68
|
+
import torch
|
|
69
|
+
import numpy as np
|
|
70
|
+
import igl
|
|
71
|
+
import trimesh
|
|
72
|
+
import gustaf
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
from typing import TypedDict
|
|
76
|
+
from DeepSDFStruct.deep_sdf.models import DeepSDFModel
|
|
77
|
+
from DeepSDFStruct.parametrization import Constant
|
|
78
|
+
import DeepSDFStruct
|
|
79
|
+
|
|
80
|
+
import logging
|
|
81
|
+
|
|
82
|
+
import matplotlib.pyplot as plt
|
|
83
|
+
import matplotlib.tri as mtri
|
|
84
|
+
|
|
85
|
+
logger = logging.getLogger(DeepSDFStruct.__name__)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CapType(TypedDict):
|
|
89
|
+
cap: int
|
|
90
|
+
measure: float
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CapBorderDict(TypedDict):
|
|
94
|
+
"""
|
|
95
|
+
A dictionary type describing boundary conditions ("caps")
|
|
96
|
+
for each axis direction (x, y, z).
|
|
97
|
+
|
|
98
|
+
Each key (`x0`, `x1`, `y0`, `y1`, `z0`, `z1`) corresponds to
|
|
99
|
+
one boundary face of a 3D domain, and maps to a dictionary
|
|
100
|
+
with two fields:
|
|
101
|
+
|
|
102
|
+
- `cap` (int): Type of cap applied (e.g., -1 = none, 1 = active).
|
|
103
|
+
- `measure` (float): Numerical measure associated with the cap
|
|
104
|
+
(e.g., thickness, scaling factor, tolerance).
|
|
105
|
+
|
|
106
|
+
Example
|
|
107
|
+
-------
|
|
108
|
+
>>> caps: CapBorderDict = {
|
|
109
|
+
... "x0": {"cap": 1, "measure": 0.02},
|
|
110
|
+
... "x1": {"cap": 1, "measure": 0.02},
|
|
111
|
+
... "y0": {"cap": 1, "measure": 0.02},
|
|
112
|
+
... "y1": {"cap": 1, "measure": 0.02},
|
|
113
|
+
... "z0": {"cap": 1, "measure": 0.02},
|
|
114
|
+
... "z1": {"cap": 1, "measure": 0.02},
|
|
115
|
+
... }
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
x0: CapType = {"cap": -1, "measure": 0}
|
|
119
|
+
x1: CapType = {"cap": -1, "measure": 0}
|
|
120
|
+
y0: CapType = {"cap": -1, "measure": 0}
|
|
121
|
+
y1: CapType = {"cap": -1, "measure": 0}
|
|
122
|
+
z0: CapType = {"cap": -1, "measure": 0}
|
|
123
|
+
z1: CapType = {"cap": -1, "measure": 0}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
UNIT_CUBE_CAPS_2D: CapBorderDict = {
|
|
127
|
+
"x0": {"cap": -1, "measure": 0},
|
|
128
|
+
"x1": {"cap": -1, "measure": 0},
|
|
129
|
+
"y0": {"cap": -1, "measure": 0},
|
|
130
|
+
"y1": {"cap": -1, "measure": 0},
|
|
131
|
+
}
|
|
132
|
+
UNIT_CUBE_CAPS_3D: CapBorderDict = {
|
|
133
|
+
"x0": {"cap": -1, "measure": 0},
|
|
134
|
+
"x1": {"cap": -1, "measure": 0},
|
|
135
|
+
"y0": {"cap": -1, "measure": 0},
|
|
136
|
+
"y1": {"cap": -1, "measure": 0},
|
|
137
|
+
"z0": {"cap": -1, "measure": 0},
|
|
138
|
+
"z1": {"cap": -1, "measure": 0},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_equidistant_grid_sample(
|
|
143
|
+
bounds: torch.Tensor | np.ndarray,
|
|
144
|
+
grid_spacing: float,
|
|
145
|
+
dtype=torch.float32,
|
|
146
|
+
device="cpu",
|
|
147
|
+
) -> torch.Tensor:
|
|
148
|
+
"""
|
|
149
|
+
Generates an equidistant 3D grid of points within the given bounding box.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
bounds : torch.Tensor
|
|
154
|
+
Tensor of shape (2,3), [[xmin, ymin, zmin], [xmax, ymax, zmax]]
|
|
155
|
+
grid_spacing : float
|
|
156
|
+
Approximate spacing between points along each axis.
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
points : torch.Tensor
|
|
161
|
+
Tensor of shape (N,3) containing all grid points.
|
|
162
|
+
"""
|
|
163
|
+
if isinstance(bounds, np.ndarray):
|
|
164
|
+
bounds = torch.tensor(bounds, dtype=dtype, device=device)
|
|
165
|
+
assert bounds.shape == (2, 3), "Bounds should be of shape (2,3)"
|
|
166
|
+
mins, maxs = bounds[0], bounds[1]
|
|
167
|
+
|
|
168
|
+
# Compute number of points along each axis (ceil to include max)
|
|
169
|
+
num_points = torch.ceil((maxs - mins) / grid_spacing).to(torch.int64) + 1
|
|
170
|
+
|
|
171
|
+
# Generate linspace for each axis
|
|
172
|
+
xs = torch.linspace(mins[0], maxs[0], num_points[0], dtype=dtype, device=device)
|
|
173
|
+
ys = torch.linspace(mins[1], maxs[1], num_points[1], dtype=dtype, device=device)
|
|
174
|
+
zs = torch.linspace(mins[2], maxs[2], num_points[2], dtype=dtype, device=device)
|
|
175
|
+
|
|
176
|
+
# Generate full 3D grid
|
|
177
|
+
grid = torch.meshgrid(xs, ys, zs, indexing="ij")
|
|
178
|
+
points = torch.stack(grid, dim=-1).reshape(-1, 3)
|
|
179
|
+
|
|
180
|
+
# Assertions to verify grid covers the bounds
|
|
181
|
+
tol = 1e-6
|
|
182
|
+
mins_generated = points.min(dim=0).values
|
|
183
|
+
maxs_generated = points.max(dim=0).values
|
|
184
|
+
assert torch.allclose(
|
|
185
|
+
mins_generated, bounds[0], atol=tol
|
|
186
|
+
), f"Grid min {mins_generated} does not match bounds {bounds[0]}"
|
|
187
|
+
assert torch.allclose(
|
|
188
|
+
maxs_generated, bounds[1], atol=tol
|
|
189
|
+
), f"Grid max {maxs_generated} does not match bounds {bounds[1]}"
|
|
190
|
+
|
|
191
|
+
return points
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class SDFBase(torch.nn.Module, ABC):
|
|
195
|
+
"""Abstract base class for Signed Distance Functions with optional
|
|
196
|
+
deformation and parametrization.
|
|
197
|
+
|
|
198
|
+
This class provides the foundation for all SDF representations in
|
|
199
|
+
DeepSDFStruct. SDFs represent geometry as an implicit function that
|
|
200
|
+
returns the signed distance from any query point to the nearest
|
|
201
|
+
surface. Negative values indicate points inside the geometry,
|
|
202
|
+
positive values indicate points outside, and zero indicates points
|
|
203
|
+
on the surface.
|
|
204
|
+
|
|
205
|
+
The class supports:
|
|
206
|
+
- Optional spline-based deformations for smooth transformations
|
|
207
|
+
- Parametrization functions for spatially-varying properties
|
|
208
|
+
- Composition operations (union, intersection) via overloading
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
parametrization : torch.nn.Module, optional
|
|
213
|
+
A function that provides spatially-varying parameters for the
|
|
214
|
+
SDF (e.g., varying thickness in a lattice).
|
|
215
|
+
geometric_dim : int, default 3
|
|
216
|
+
Geometric dimension of the SDF (2 or 3).
|
|
217
|
+
|
|
218
|
+
Notes
|
|
219
|
+
-----
|
|
220
|
+
Subclasses must implement:
|
|
221
|
+
- ``_compute(queries)``: Calculate SDF values for query points
|
|
222
|
+
- ``_get_domain_bounds()``: Return the bounding box of geometry
|
|
223
|
+
|
|
224
|
+
Examples
|
|
225
|
+
--------
|
|
226
|
+
>>> from DeepSDFStruct.sdf_primitives import SphereSDF
|
|
227
|
+
>>> import torch
|
|
228
|
+
>>>
|
|
229
|
+
>>> # Create a sphere SDF
|
|
230
|
+
>>> sphere = SphereSDF(center=[0, 0, 0], radius=1.0)
|
|
231
|
+
>>>
|
|
232
|
+
>>> # Query SDF values
|
|
233
|
+
>>> points = torch.tensor([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]])
|
|
234
|
+
>>> distances = sphere(points)
|
|
235
|
+
>>> print(distances) # [-1.0, 1.0] (inside, outside)
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
geometric_dim: int
|
|
239
|
+
|
|
240
|
+
def __init__(self, parametrization: torch.nn.Module | None = None, geometric_dim=3):
|
|
241
|
+
super().__init__()
|
|
242
|
+
self.parametrization = parametrization
|
|
243
|
+
self.geometric_dim = geometric_dim
|
|
244
|
+
|
|
245
|
+
def forward(self, queries: torch.Tensor) -> torch.Tensor:
|
|
246
|
+
"""Evaluate the SDF at given query points.
|
|
247
|
+
|
|
248
|
+
This method validates input, computes SDF values using the subclass
|
|
249
|
+
implementation, and applies optional boundary conditions and capping.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
queries : torch.Tensor
|
|
254
|
+
Query points of shape (N, 2) for 2D or (N, 3) for 3D, where N is
|
|
255
|
+
the number of points to evaluate.
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
torch.Tensor
|
|
260
|
+
Signed distance values of shape (N, 1). Negative values indicate
|
|
261
|
+
points inside the geometry, positive values outside.
|
|
262
|
+
|
|
263
|
+
Raises
|
|
264
|
+
------
|
|
265
|
+
ValueError
|
|
266
|
+
If queries have invalid shape.
|
|
267
|
+
RuntimeError
|
|
268
|
+
If SDF computation returns invalid output.
|
|
269
|
+
"""
|
|
270
|
+
self._validate_input(queries)
|
|
271
|
+
sdf_values = self._compute(queries)
|
|
272
|
+
if sdf_values is None:
|
|
273
|
+
raise RuntimeError("Invalid SDF output")
|
|
274
|
+
if sdf_values.shape[0] != queries.shape[0]:
|
|
275
|
+
raise RuntimeError(
|
|
276
|
+
f"SDF _compute output shape mismatch: expected ({queries.shape[0]}, 1), "
|
|
277
|
+
f"got {sdf_values.shape}. This can happen when wrapper SDFs (ElongateSDF, "
|
|
278
|
+
f"TwistSDF, etc.) don't preserve the number of query points."
|
|
279
|
+
)
|
|
280
|
+
return sdf_values
|
|
281
|
+
|
|
282
|
+
def _validate_input(self, queries: torch.Tensor):
|
|
283
|
+
# Example check: 2D tensor with shape (N, 3) or (N, 2where N points, each point is a column vector
|
|
284
|
+
if queries.ndim != 2 or (queries.shape[1] not in [2, 3]):
|
|
285
|
+
raise ValueError(
|
|
286
|
+
f"Expected input of shape (N, 3) or (N, 2), got {queries.shape}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def get_device(self):
|
|
290
|
+
"""
|
|
291
|
+
Return the device of the first parameter or buffer in this module.
|
|
292
|
+
|
|
293
|
+
If the module has no parameters and no buffers, returns cpu.
|
|
294
|
+
"""
|
|
295
|
+
# Check parameters first
|
|
296
|
+
for p in self.parameters(recurse=True):
|
|
297
|
+
return p.device
|
|
298
|
+
|
|
299
|
+
# If no parameters, check buffers
|
|
300
|
+
for b in self.buffers(recurse=True):
|
|
301
|
+
return b.device
|
|
302
|
+
|
|
303
|
+
# No tensors at all
|
|
304
|
+
return "cpu"
|
|
305
|
+
|
|
306
|
+
def get_dtype(self):
|
|
307
|
+
"""
|
|
308
|
+
Return the dtype of the first parameter or buffer in this module.
|
|
309
|
+
|
|
310
|
+
If the module has no parameters and no buffers, returns float32.
|
|
311
|
+
"""
|
|
312
|
+
# Check parameters first
|
|
313
|
+
for p in self.parameters(recurse=True):
|
|
314
|
+
return p.dtype
|
|
315
|
+
|
|
316
|
+
# If no parameters, check buffers
|
|
317
|
+
for b in self.buffers(recurse=True):
|
|
318
|
+
return b.dtype
|
|
319
|
+
|
|
320
|
+
# No tensors at all
|
|
321
|
+
return torch.float32
|
|
322
|
+
|
|
323
|
+
@abstractmethod
|
|
324
|
+
def _compute(self, queries: torch.Tensor) -> torch.Tensor:
|
|
325
|
+
"""Compute SDF values for query points.
|
|
326
|
+
|
|
327
|
+
Subclasses must implement this method to define their specific
|
|
328
|
+
geometry. The method should return signed distances without
|
|
329
|
+
applying any boundary conditions or capping (those are handled
|
|
330
|
+
by __call__).
|
|
331
|
+
|
|
332
|
+
Parameters
|
|
333
|
+
----------
|
|
334
|
+
queries : torch.Tensor
|
|
335
|
+
Query points of shape (N, dim) where dim is 2 or 3.
|
|
336
|
+
|
|
337
|
+
Returns
|
|
338
|
+
-------
|
|
339
|
+
torch.Tensor
|
|
340
|
+
Signed distance values of shape (N, 1).
|
|
341
|
+
"""
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
@abstractmethod
|
|
345
|
+
def _get_domain_bounds(self) -> torch.Tensor:
|
|
346
|
+
"""Return the bounding box of the SDF's domain.
|
|
347
|
+
|
|
348
|
+
Subclasses must implement this to specify the spatial extent
|
|
349
|
+
of their geometry. This is used for mesh generation and sampling.
|
|
350
|
+
|
|
351
|
+
Returns
|
|
352
|
+
-------
|
|
353
|
+
np.ndarray
|
|
354
|
+
Array of shape (2, dim) where the first row contains minimum
|
|
355
|
+
coordinates and the second row contains maximum coordinates.
|
|
356
|
+
"""
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
def plot_slice(
|
|
360
|
+
self,
|
|
361
|
+
origin=(0, 0, 0),
|
|
362
|
+
normal=(0, 0, 1),
|
|
363
|
+
res=(100, 100),
|
|
364
|
+
ax=None,
|
|
365
|
+
clim=(-1, 1),
|
|
366
|
+
cmap="seismic",
|
|
367
|
+
show_zero_level=True,
|
|
368
|
+
deformation_function=None,
|
|
369
|
+
xlim=None,
|
|
370
|
+
ylim=None,
|
|
371
|
+
):
|
|
372
|
+
"""Plot a 2D slice through an SDF as a contour plot.
|
|
373
|
+
|
|
374
|
+
This function evaluates an SDF on a planar grid and visualizes the
|
|
375
|
+
signed distance values using a color map. The zero level set (the
|
|
376
|
+
actual surface) can be highlighted with a contour line.
|
|
377
|
+
|
|
378
|
+
Parameters
|
|
379
|
+
----------
|
|
380
|
+
fun : callable
|
|
381
|
+
The SDF function to visualize. Should accept a torch.Tensor
|
|
382
|
+
of shape (N, 3) and return distances of shape (N, 1).
|
|
383
|
+
origin : tuple of float, default (0, 0, 0)
|
|
384
|
+
A point on the slice plane.
|
|
385
|
+
normal : tuple of float, default (0, 0, 1)
|
|
386
|
+
Normal vector of the slice plane. Currently supports only
|
|
387
|
+
axis-aligned planes: (1,0,0), (0,1,0), or (0,0,1).
|
|
388
|
+
res : tuple of int, default (100, 100)
|
|
389
|
+
Resolution of the slice grid (num_points_u, num_points_v).
|
|
390
|
+
ax : matplotlib.axes.Axes, optional
|
|
391
|
+
Axes to plot on. If None, creates a new figure.
|
|
392
|
+
xlim : tuple of float, default (-1, 1)
|
|
393
|
+
Range along the first plane axis.
|
|
394
|
+
ylim : tuple of float, default (-1, 1)
|
|
395
|
+
Range along the second plane axis.
|
|
396
|
+
clim : tuple of float, default (-1, 1)
|
|
397
|
+
Color map limits for distance values.
|
|
398
|
+
cmap : str, default 'seismic'
|
|
399
|
+
Matplotlib colormap name.
|
|
400
|
+
show_zero_level : bool, default True
|
|
401
|
+
If True, draws a black contour line at distance=0 (the surface).
|
|
402
|
+
deformation_function : callable, optional
|
|
403
|
+
Deformation mapping from parametric to physical space. If given,
|
|
404
|
+
sample points are deformed before SDF evaluation and plotting.
|
|
405
|
+
|
|
406
|
+
Returns
|
|
407
|
+
-------
|
|
408
|
+
fig, ax : matplotlib.figure.Figure, matplotlib.axes.Axes
|
|
409
|
+
Only returned if ax was None (i.e., a new figure was created).
|
|
410
|
+
|
|
411
|
+
Examples
|
|
412
|
+
--------
|
|
413
|
+
>>> from DeepSDFStruct.sdf_primitives import SphereSDF
|
|
414
|
+
>>> from DeepSDFStruct.plotting import plot_slice
|
|
415
|
+
>>> import matplotlib.pyplot as plt
|
|
416
|
+
>>>
|
|
417
|
+
>>> # Create a sphere
|
|
418
|
+
>>> sphere = SphereSDF(center=[0, 0, 0], radius=0.5)
|
|
419
|
+
>>>
|
|
420
|
+
>>> # Plot XY slice at z=0
|
|
421
|
+
>>> fig, ax = plot_slice(
|
|
422
|
+
... sphere,
|
|
423
|
+
... origin=(0, 0, 0),
|
|
424
|
+
... normal=(0, 0, 1),
|
|
425
|
+
... res=(200, 200)
|
|
426
|
+
... )
|
|
427
|
+
>>> plt.title("XY Slice of Sphere")
|
|
428
|
+
>>> plt.show()
|
|
429
|
+
|
|
430
|
+
Notes
|
|
431
|
+
-----
|
|
432
|
+
The 'seismic' colormap is well-suited for SDFs as it uses blue for
|
|
433
|
+
negative (inside) and red for positive (outside), with white near zero.
|
|
434
|
+
"""
|
|
435
|
+
plt_show = False
|
|
436
|
+
if ax is None:
|
|
437
|
+
fig, ax = plt.subplots()
|
|
438
|
+
plt_show = True
|
|
439
|
+
bounds = self._get_domain_bounds()
|
|
440
|
+
|
|
441
|
+
if self.geometric_dim == 3:
|
|
442
|
+
if xlim is None:
|
|
443
|
+
xlim, ylim = project_bounds(origin, normal, bounds=bounds)
|
|
444
|
+
points = generate_plane_points(origin, normal, res, xlim, ylim)
|
|
445
|
+
else:
|
|
446
|
+
if xlim is None:
|
|
447
|
+
xlim, ylim = bounds.detach().cpu().numpy().T
|
|
448
|
+
points = generate_plane_points(origin, normal, res, xlim, ylim)[:, :2]
|
|
449
|
+
|
|
450
|
+
sdf_device = self.get_device()
|
|
451
|
+
points = torch.from_numpy(points).to(torch.float32).to(sdf_device)
|
|
452
|
+
|
|
453
|
+
if deformation_function is not None:
|
|
454
|
+
points_deformed = deformation_function.forward(points)
|
|
455
|
+
else:
|
|
456
|
+
points_deformed = points
|
|
457
|
+
|
|
458
|
+
sdf_values = self._compute(points).reshape(-1).detach().cpu().numpy()
|
|
459
|
+
points_np = points_deformed.detach().cpu().numpy()
|
|
460
|
+
axis0, axis1 = _get_plane_plot_axes(normal)
|
|
461
|
+
x_plot = points_np[:, axis0]
|
|
462
|
+
y_plot = points_np[:, axis1]
|
|
463
|
+
triangles = _build_structured_grid_triangles(res[0], res[1])
|
|
464
|
+
triangulation = mtri.Triangulation(x_plot, y_plot, triangles=triangles)
|
|
465
|
+
|
|
466
|
+
cbar = ax.tricontourf(triangulation, sdf_values, cmap=cmap, levels=10)
|
|
467
|
+
if show_zero_level:
|
|
468
|
+
ax.tricontour(
|
|
469
|
+
triangulation, sdf_values, levels=[0], colors="black", linewidths=0.5
|
|
470
|
+
)
|
|
471
|
+
cbar.set_clim(clim[0], clim[1])
|
|
472
|
+
ax.set_xticks([])
|
|
473
|
+
ax.set_yticks([])
|
|
474
|
+
ax.set_aspect(1)
|
|
475
|
+
if plt_show:
|
|
476
|
+
plt.show()
|
|
477
|
+
return fig, ax
|
|
478
|
+
|
|
479
|
+
def __add__(self, other):
|
|
480
|
+
return UnionSDF(self, other)
|
|
481
|
+
|
|
482
|
+
def to2D(self, axes: list[int], offset=0.0):
|
|
483
|
+
"""
|
|
484
|
+
Converts SDF to 2D
|
|
485
|
+
|
|
486
|
+
:param axis: list of axes that will be used for the 2D
|
|
487
|
+
"""
|
|
488
|
+
sdf2D = SDF2D(self, axes, offset=offset)
|
|
489
|
+
sdf2D.parametrization = self.parametrization
|
|
490
|
+
return sdf2D
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class SDF2D(SDFBase):
|
|
494
|
+
"""Convert a 3D SDF to 2D by slicing through specified axes.
|
|
495
|
+
|
|
496
|
+
Creates a 2D SDF by taking a cross-section of a 3D SDF along specified
|
|
497
|
+
axes at a given offset. This is useful for visualizing 3D SDFs or
|
|
498
|
+
working with 2D profiles.
|
|
499
|
+
|
|
500
|
+
Parameters
|
|
501
|
+
----------
|
|
502
|
+
obj : SDFBase
|
|
503
|
+
The 3D SDF object to convert to 2D.
|
|
504
|
+
axes : list of int
|
|
505
|
+
List of two axis indices [axis0, axis1] to keep for the 2D slice.
|
|
506
|
+
For example, [0, 1] keeps x and y axes (XY plane).
|
|
507
|
+
offset : float, default 0.0
|
|
508
|
+
Offset value for the third (unused) axis.
|
|
509
|
+
|
|
510
|
+
Examples
|
|
511
|
+
|
|
512
|
+
>>> sphere_3d = SphereSDF(center=[0, 0, 0], radius=1.0)
|
|
513
|
+
>>>
|
|
514
|
+
>>> # Convert to 2D (XY plane at z=0)
|
|
515
|
+
>>> sphere_2d = sphere_3d.to2D(axes=[0, 1], offset=0.0)
|
|
516
|
+
>>> points = torch.tensor([[0.0, 0.0], [0.5, 0.5]])
|
|
517
|
+
>>> distances = sphere_2d(points)
|
|
518
|
+
|
|
519
|
+
Notes
|
|
520
|
+
-----
|
|
521
|
+
The 2D SDF queries points in the plane defined by the two axes,
|
|
522
|
+
with the third axis fixed at the offset value.
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
obj: SDFBase
|
|
526
|
+
|
|
527
|
+
def __init__(self, obj: SDFBase, axes: list[int], offset=0.0):
|
|
528
|
+
"""Convert a 3D SDF to 2D.
|
|
529
|
+
|
|
530
|
+
Parameters
|
|
531
|
+
----------
|
|
532
|
+
obj : SDFBase
|
|
533
|
+
The 3D SDF object to convert.
|
|
534
|
+
axes : list of int
|
|
535
|
+
Two axis indices to keep for the 2D slice.
|
|
536
|
+
offset : float, default 0.0
|
|
537
|
+
Offset for the third axis.
|
|
538
|
+
"""
|
|
539
|
+
super().__init__()
|
|
540
|
+
self.obj = obj
|
|
541
|
+
assert (
|
|
542
|
+
len(axes) == 2
|
|
543
|
+
), "List of axes must be of size 2 and needs to correspond to the 2D plane"
|
|
544
|
+
self.axes = axes
|
|
545
|
+
self.offset = offset
|
|
546
|
+
self.geometric_dim = 2
|
|
547
|
+
|
|
548
|
+
def _compute(self, queries):
|
|
549
|
+
logger.debug(f"SDF2D._compute - {queries.shape[0]} points, axes={self.axes}")
|
|
550
|
+
queries_3D = (
|
|
551
|
+
torch.zeros(
|
|
552
|
+
(queries.shape[0], 3), dtype=queries.dtype, device=queries.device
|
|
553
|
+
)
|
|
554
|
+
+ self.offset
|
|
555
|
+
)
|
|
556
|
+
queries_3D[:, self.axes[0]] = queries[:, 0]
|
|
557
|
+
queries_3D[:, self.axes[1]] = queries[:, 1]
|
|
558
|
+
result = self.obj._compute(queries_3D)
|
|
559
|
+
return result
|
|
560
|
+
|
|
561
|
+
def _get_domain_bounds(self):
|
|
562
|
+
bounds_3d = self.obj._get_domain_bounds()
|
|
563
|
+
axes = torch.as_tensor(self.axes, device=bounds_3d.device)
|
|
564
|
+
return torch.index_select(bounds_3d, dim=1, index=axes)
|
|
565
|
+
|
|
566
|
+
def _set_param(self, parameter):
|
|
567
|
+
return self.obj._set_param(parameter)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class SummedSDF(SDFBase):
|
|
571
|
+
def __init__(self, obj1: SDFBase, obj2: SDFBase):
|
|
572
|
+
raise NotImplementedError("SummedSDF has been replaced by UnionSDF")
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
class UnionSDF(SDFBase):
|
|
576
|
+
"""Union of multiple SDFs using the minimum operator.
|
|
577
|
+
|
|
578
|
+
Combines multiple SDFs by computing the minimum distance at each query
|
|
579
|
+
point. This creates the union (boolean OR) of all input geometries.
|
|
580
|
+
|
|
581
|
+
Parameters
|
|
582
|
+
----------
|
|
583
|
+
*objects : SDFBase
|
|
584
|
+
Two or more SDF objects to combine. All objects must have the same
|
|
585
|
+
geometric dimension (2D or 3D).
|
|
586
|
+
|
|
587
|
+
Raises
|
|
588
|
+
------
|
|
589
|
+
ValueError
|
|
590
|
+
If fewer than two objects are provided, or if objects have
|
|
591
|
+
mismatched geometric dimensions.
|
|
592
|
+
|
|
593
|
+
Examples
|
|
594
|
+
--------
|
|
595
|
+
>>> sphere1 = SphereSDF([0, 0, 0], radius=1.0)
|
|
596
|
+
>>> sphere2 = SphereSDF([1.5, 0, 0], radius=1.0)
|
|
597
|
+
>>> union = UnionSDF(sphere1, sphere2)
|
|
598
|
+
"""
|
|
599
|
+
|
|
600
|
+
def __init__(self, *objects: SDFBase):
|
|
601
|
+
"""Initialize UnionSDF with two or more SDF objects.
|
|
602
|
+
|
|
603
|
+
Parameters
|
|
604
|
+
----------
|
|
605
|
+
*objects : SDFBase
|
|
606
|
+
Two or more SDF objects to combine.
|
|
607
|
+
"""
|
|
608
|
+
super().__init__()
|
|
609
|
+
|
|
610
|
+
if len(objects) < 2:
|
|
611
|
+
raise ValueError("UnionSDF requires at least two objects.")
|
|
612
|
+
|
|
613
|
+
self.objects = list(objects)
|
|
614
|
+
|
|
615
|
+
# Check geometric dimensions match
|
|
616
|
+
geometric_dim = self.objects[0].geometric_dim
|
|
617
|
+
for i, obj in enumerate(self.objects[1:], start=1):
|
|
618
|
+
if obj.geometric_dim != geometric_dim:
|
|
619
|
+
raise ValueError(
|
|
620
|
+
f"geometric dim mismatch between object 0 ({geometric_dim}) "
|
|
621
|
+
f"and object {i} ({obj.geometric_dim})"
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
self.geometric_dim = geometric_dim
|
|
625
|
+
|
|
626
|
+
def _compute(self, queries):
|
|
627
|
+
logger.debug(
|
|
628
|
+
f"UnionSDF._compute - {queries.shape[0]} points, {len(self.objects)} objects"
|
|
629
|
+
)
|
|
630
|
+
# Compute first object
|
|
631
|
+
result = self.objects[0]._compute(queries)
|
|
632
|
+
|
|
633
|
+
# Iteratively take minimum with the rest
|
|
634
|
+
for obj in self.objects[1:]:
|
|
635
|
+
result = torch.minimum(result, obj._compute(queries))
|
|
636
|
+
|
|
637
|
+
return result
|
|
638
|
+
|
|
639
|
+
def _get_domain_bounds(self):
|
|
640
|
+
# Initialize with first object's bounds
|
|
641
|
+
bounds = self.objects[0]._get_domain_bounds()
|
|
642
|
+
lower = bounds[0]
|
|
643
|
+
upper = bounds[1]
|
|
644
|
+
|
|
645
|
+
# Expand bounds across all objects
|
|
646
|
+
for obj in self.objects[1:]:
|
|
647
|
+
logger.debug(
|
|
648
|
+
f"UnionSDF._get_domain_bounds - iterating through {len(self.objects)} objects"
|
|
649
|
+
)
|
|
650
|
+
obj_bounds = obj._get_domain_bounds()
|
|
651
|
+
lower = torch.minimum(lower, obj_bounds[0])
|
|
652
|
+
upper = torch.maximum(upper, obj_bounds[1])
|
|
653
|
+
|
|
654
|
+
return torch.stack([lower, upper], dim=0)
|
|
655
|
+
|
|
656
|
+
def _set_param(self, parameter):
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class DifferenceSDF(SDFBase):
|
|
661
|
+
"""
|
|
662
|
+
Subtracts multiple objects from a base object.
|
|
663
|
+
|
|
664
|
+
Computes:
|
|
665
|
+
obj0 - (obj1 ∪ obj2 ∪ ...)
|
|
666
|
+
|
|
667
|
+
i.e.
|
|
668
|
+
max(d0, -min(d1, d2, ...))
|
|
669
|
+
"""
|
|
670
|
+
|
|
671
|
+
def __init__(self, base_obj: SDFBase, *subtract_objs: SDFBase):
|
|
672
|
+
super().__init__()
|
|
673
|
+
|
|
674
|
+
if len(subtract_objs) == 0:
|
|
675
|
+
raise ValueError("DifferenceSDF requires at least one object to subtract.")
|
|
676
|
+
|
|
677
|
+
self.base_obj = base_obj
|
|
678
|
+
self.subtract_objs = list(subtract_objs)
|
|
679
|
+
|
|
680
|
+
geometric_dim = base_obj.geometric_dim
|
|
681
|
+
|
|
682
|
+
for i, obj in enumerate(self.subtract_objs):
|
|
683
|
+
if obj.geometric_dim != geometric_dim:
|
|
684
|
+
raise ValueError(
|
|
685
|
+
f"Geometric dimension mismatch between base "
|
|
686
|
+
f"({geometric_dim}) and subtract object {i} "
|
|
687
|
+
f"({obj.geometric_dim})"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
self.geometric_dim = geometric_dim
|
|
691
|
+
|
|
692
|
+
def _compute(self, queries):
|
|
693
|
+
logger.debug(
|
|
694
|
+
f"DifferenceSDF._compute - {queries.shape[0]} points, base={type(self.base_obj).__name__}, subtract={len(self.subtract_objs)}"
|
|
695
|
+
)
|
|
696
|
+
d_base = self.base_obj._compute(queries)
|
|
697
|
+
|
|
698
|
+
# Compute union of subtraction objects
|
|
699
|
+
d_sub = self.subtract_objs[0]._compute(queries)
|
|
700
|
+
for obj in self.subtract_objs[1:]:
|
|
701
|
+
d_sub = torch.minimum(d_sub, obj._compute(queries))
|
|
702
|
+
|
|
703
|
+
# Subtract with bias so subtraction wins on ties (prevents slivers)
|
|
704
|
+
return torch.maximum(d_base, -d_sub - 1e-6)
|
|
705
|
+
|
|
706
|
+
def _get_domain_bounds(self):
|
|
707
|
+
# Difference cannot expand beyond base object
|
|
708
|
+
return self.base_obj._get_domain_bounds()
|
|
709
|
+
|
|
710
|
+
def _set_param(self, parameter):
|
|
711
|
+
return None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class SmoothUnionSDF(SDFBase):
|
|
715
|
+
"""Smooth blending of multiple SDFs with smoothing parameter k."""
|
|
716
|
+
|
|
717
|
+
def __init__(self, *sdfs: SDFBase, k=0):
|
|
718
|
+
super().__init__()
|
|
719
|
+
if len(sdfs) < 2:
|
|
720
|
+
raise ValueError("SmoothUnionSDF requires at least 2 SDFs")
|
|
721
|
+
self.sdfs = list(sdfs)
|
|
722
|
+
self.k = torch.nn.Parameter(torch.as_tensor(k, dtype=torch.float32))
|
|
723
|
+
|
|
724
|
+
def _compute(self, queries: torch.Tensor) -> torch.Tensor:
|
|
725
|
+
k_val = self.k.to(device=queries.device, dtype=queries.dtype)
|
|
726
|
+
smooth_mode = "smooth" if k_val != 0 else "sharp"
|
|
727
|
+
logger.debug(
|
|
728
|
+
f"SmoothUnionSDF._compute - {queries.shape[0]} points, sdfs={len(self.sdfs)}, mode={smooth_mode}"
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
if k_val == 0:
|
|
732
|
+
# Sharp union - same as UnionSDF
|
|
733
|
+
result = self.sdfs[0]._compute(queries)
|
|
734
|
+
for sdf in self.sdfs[1:]:
|
|
735
|
+
result = torch.minimum(result, sdf._compute(queries))
|
|
736
|
+
return result
|
|
737
|
+
|
|
738
|
+
# Smooth union with polynomial blending
|
|
739
|
+
d = torch.stack([sdf._compute(queries).squeeze(1) for sdf in self.sdfs], dim=1)
|
|
740
|
+
|
|
741
|
+
# Iterative smooth union using the same formula as smooth_min
|
|
742
|
+
d1 = d[:, 0]
|
|
743
|
+
for i in range(1, d.shape[1]):
|
|
744
|
+
d2 = d[:, i]
|
|
745
|
+
h = torch.clamp(0.5 + 0.5 * (d2 - d1) / k_val, 0, 1)
|
|
746
|
+
d1 = d2 + (d1 - d2) * h - k_val * h * (1 - h)
|
|
747
|
+
|
|
748
|
+
return d1.reshape(-1, 1)
|
|
749
|
+
|
|
750
|
+
def _get_domain_bounds(self) -> torch.Tensor:
|
|
751
|
+
bounds = self.sdfs[0]._get_domain_bounds()
|
|
752
|
+
lower, upper = bounds[0], bounds[1]
|
|
753
|
+
for sdf in self.sdfs[1:]:
|
|
754
|
+
b = sdf._get_domain_bounds()
|
|
755
|
+
lower = torch.minimum(lower, b[0])
|
|
756
|
+
upper = torch.maximum(upper, b[1])
|
|
757
|
+
return torch.stack([lower, upper])
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
class SmoothDifferenceSDF(SDFBase):
|
|
761
|
+
"""Smooth subtraction of SDFs."""
|
|
762
|
+
|
|
763
|
+
def __init__(self, base_sdf: SDFBase, *subtract_sdfs: SDFBase, k=0):
|
|
764
|
+
super().__init__()
|
|
765
|
+
self.base = base_sdf
|
|
766
|
+
self.subtract = list(subtract_sdfs)
|
|
767
|
+
self.k = torch.nn.Parameter(torch.as_tensor(k, dtype=torch.float32))
|
|
768
|
+
|
|
769
|
+
def _compute(self, queries: torch.Tensor) -> torch.Tensor:
|
|
770
|
+
k_val = self.k.to(device=queries.device, dtype=queries.dtype)
|
|
771
|
+
smooth_mode = "smooth" if k_val != 0 else "sharp"
|
|
772
|
+
logger.debug(
|
|
773
|
+
f"SmoothDifferenceSDF._compute - {queries.shape[0]} points, base={type(self.base).__name__}, subtract={len(self.subtract)}, mode={smooth_mode}"
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
d_base = self.base._compute(queries).squeeze(1)
|
|
777
|
+
|
|
778
|
+
# Union of subtraction objects
|
|
779
|
+
d_sub = self.subtract[0]._compute(queries).squeeze(1)
|
|
780
|
+
for sdf in self.subtract[1:]:
|
|
781
|
+
if k_val == 0:
|
|
782
|
+
d_sub = torch.minimum(d_sub, sdf._compute(queries).squeeze(1))
|
|
783
|
+
else:
|
|
784
|
+
d2 = sdf._compute(queries).squeeze(1)
|
|
785
|
+
h = torch.clamp(0.5 + 0.5 * (d2 - d_sub) / k_val, 0, 1)
|
|
786
|
+
d_sub = d2 + (d_sub - d2) * h - k_val * h * (1 - h)
|
|
787
|
+
|
|
788
|
+
if k_val == 0:
|
|
789
|
+
# Subtract with bias so subtraction wins on ties (prevents slivers)
|
|
790
|
+
return torch.maximum(d_base, -d_sub - 1e-6).reshape(-1, 1)
|
|
791
|
+
|
|
792
|
+
# Smooth difference
|
|
793
|
+
h = torch.clamp(0.5 - 0.5 * (d_base + d_sub) / k_val, 0, 1)
|
|
794
|
+
return (d_base + d_sub + k_val * h * (1 - h)).reshape(-1, 1)
|
|
795
|
+
|
|
796
|
+
def _get_domain_bounds(self) -> torch.Tensor:
|
|
797
|
+
return self.base._get_domain_bounds()
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class SmoothIntersectionSDF(SDFBase):
|
|
801
|
+
"""Smooth intersection of multiple SDFs with smoothing parameter k."""
|
|
802
|
+
|
|
803
|
+
def __init__(self, *sdfs: SDFBase, k=0):
|
|
804
|
+
super().__init__()
|
|
805
|
+
if len(sdfs) < 2:
|
|
806
|
+
raise ValueError("SmoothIntersectionSDF requires at least 2 SDFs")
|
|
807
|
+
self.sdfs = list(sdfs)
|
|
808
|
+
self.k = torch.nn.Parameter(torch.as_tensor(k, dtype=torch.float32))
|
|
809
|
+
|
|
810
|
+
def _compute(self, queries: torch.Tensor) -> torch.Tensor:
|
|
811
|
+
k_val = self.k.to(device=queries.device, dtype=queries.dtype)
|
|
812
|
+
smooth_mode = "smooth" if k_val != 0 else "sharp"
|
|
813
|
+
logger.debug(
|
|
814
|
+
f"SmoothIntersectionSDF._compute - {queries.shape[0]} points, sdfs={len(self.sdfs)}, mode={smooth_mode}"
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
if k_val == 0:
|
|
818
|
+
# Sharp intersection
|
|
819
|
+
result = self.sdfs[0]._compute(queries)
|
|
820
|
+
for sdf in self.sdfs[1:]:
|
|
821
|
+
result = torch.maximum(result, sdf._compute(queries))
|
|
822
|
+
return result
|
|
823
|
+
|
|
824
|
+
# Smooth intersection using the same formula as smooth_max
|
|
825
|
+
d = torch.stack([sdf._compute(queries).squeeze(1) for sdf in self.sdfs], dim=1)
|
|
826
|
+
|
|
827
|
+
# Iterative smooth intersection
|
|
828
|
+
d1 = d[:, 0]
|
|
829
|
+
for i in range(1, d.shape[1]):
|
|
830
|
+
d2 = d[:, i]
|
|
831
|
+
h = torch.clamp(0.5 - 0.5 * (d2 - d1) / k_val, 0, 1)
|
|
832
|
+
d1 = d2 - (d2 - d1) * h + k_val * h * (1 - h)
|
|
833
|
+
|
|
834
|
+
return d1.reshape(-1, 1)
|
|
835
|
+
|
|
836
|
+
def _get_domain_bounds(self) -> torch.Tensor:
|
|
837
|
+
bounds = self.sdfs[0]._get_domain_bounds()
|
|
838
|
+
lower, upper = bounds[0], bounds[1]
|
|
839
|
+
for sdf in self.sdfs[1:]:
|
|
840
|
+
b = sdf._get_domain_bounds()
|
|
841
|
+
lower = torch.maximum(lower, b[0])
|
|
842
|
+
upper = torch.minimum(upper, b[1])
|
|
843
|
+
return torch.stack([lower, upper])
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
class NegatedCallable(SDFBase):
|
|
847
|
+
def __init__(self, obj: SDFBase):
|
|
848
|
+
super().__init__()
|
|
849
|
+
self.obj = obj
|
|
850
|
+
|
|
851
|
+
def _compute(self, input_param):
|
|
852
|
+
logger.debug(
|
|
853
|
+
f"NegatedCallable._compute - {input_param.shape[0]} points, obj={type(self.obj).__name__}"
|
|
854
|
+
)
|
|
855
|
+
result = self.obj(input_param)
|
|
856
|
+
return -result
|
|
857
|
+
|
|
858
|
+
def _get_domain_bounds(self):
|
|
859
|
+
# the domain bounds get smaller when we substract something
|
|
860
|
+
return self.obj._get_domain_bounds()
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
class BoxSDF(SDFBase):
|
|
864
|
+
def __init__(
|
|
865
|
+
self, box_size: float = 1, center: torch.tensor = torch.tensor([0, 0, 0])
|
|
866
|
+
):
|
|
867
|
+
super().__init__()
|
|
868
|
+
self.box_size = box_size
|
|
869
|
+
self.center = center
|
|
870
|
+
|
|
871
|
+
def _compute(self, queries: torch.tensor) -> torch.tensor:
|
|
872
|
+
logger.debug(
|
|
873
|
+
f"BoxSDF._compute - {queries.shape[0]} points, box_size={self.box_size}"
|
|
874
|
+
)
|
|
875
|
+
output = (
|
|
876
|
+
torch.linalg.norm(queries - self.center, axis=1, ord=torch.inf)
|
|
877
|
+
- self.box_size
|
|
878
|
+
)
|
|
879
|
+
return output.reshape(-1, 1)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def union_torch(D, k=0):
|
|
883
|
+
"""
|
|
884
|
+
D: np.array of shape (num_points, num_geometries)
|
|
885
|
+
k: smoothness parameter
|
|
886
|
+
"""
|
|
887
|
+
if k == 0:
|
|
888
|
+
return torch.min(D, axis=1)[0].view(-1, 1)
|
|
889
|
+
# Start with the first column as d1
|
|
890
|
+
d1 = D[:, 0].copy()
|
|
891
|
+
|
|
892
|
+
# Loop over remaining columns
|
|
893
|
+
for i in range(1, D.shape[1]):
|
|
894
|
+
d2 = D[:, i]
|
|
895
|
+
h = torch.clip(0.5 + 0.5 * (d2 - d1) / k, 0, 1)
|
|
896
|
+
d1 = d2 + (d1 - d2) * h - k * h * (1 - h)
|
|
897
|
+
|
|
898
|
+
return d1.view(-1, 1)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def union_numpy(D, k=0):
|
|
902
|
+
"""
|
|
903
|
+
D: np.array of shape (num_points, num_geometries)
|
|
904
|
+
k: smoothness parameter
|
|
905
|
+
"""
|
|
906
|
+
if k == 0:
|
|
907
|
+
return np.min(D, axis=1)
|
|
908
|
+
# Start with the first column as d1
|
|
909
|
+
d1 = D[:, 0].copy()
|
|
910
|
+
|
|
911
|
+
# Loop over remaining columns
|
|
912
|
+
for i in range(1, D.shape[1]):
|
|
913
|
+
d2 = D[:, i]
|
|
914
|
+
h = np.clip(0.5 + 0.5 * (d2 - d1) / k, 0, 1)
|
|
915
|
+
d1 = d2 + (d1 - d2) * h - k * h * (1 - h)
|
|
916
|
+
|
|
917
|
+
return d1
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
class SDFfromMesh(SDFBase):
|
|
921
|
+
"""Create an SDF from a triangle mesh using closest-point queries.
|
|
922
|
+
|
|
923
|
+
This class wraps a triangle mesh and computes signed distances by finding
|
|
924
|
+
the closest point on the mesh surface to each query point. The sign is
|
|
925
|
+
determined using winding number or ray casting to determine inside/outside.
|
|
926
|
+
|
|
927
|
+
The mesh can be optionally normalized to fit within a unit cube centered
|
|
928
|
+
at the origin, which is useful for consistent scaling across different
|
|
929
|
+
geometries.
|
|
930
|
+
|
|
931
|
+
Parameters
|
|
932
|
+
----------
|
|
933
|
+
mesh : trimesh.Trimesh or gustaf.faces.Faces
|
|
934
|
+
The input triangle mesh. If a gustaf Faces object is provided,
|
|
935
|
+
it will be converted to a trimesh object.
|
|
936
|
+
dtype : numpy dtype, default np.float32
|
|
937
|
+
Data type for distance calculations.
|
|
938
|
+
flip_sign : bool, default False
|
|
939
|
+
If True, flips the sign of the computed distances (inside becomes
|
|
940
|
+
outside and vice versa).
|
|
941
|
+
scale : bool, default True
|
|
942
|
+
If True, normalizes the mesh to fit within a unit cube [-1, 1]^3
|
|
943
|
+
centered at the origin.
|
|
944
|
+
threshold : float, default 1e-5
|
|
945
|
+
Small threshold value for numerical stability in distance computations.
|
|
946
|
+
|
|
947
|
+
Attributes
|
|
948
|
+
----------
|
|
949
|
+
mesh : trimesh.Trimesh
|
|
950
|
+
The (possibly normalized) triangle mesh.
|
|
951
|
+
dtype : numpy dtype
|
|
952
|
+
Data type used for calculations.
|
|
953
|
+
flip_sign : bool
|
|
954
|
+
Whether distances are sign-flipped.
|
|
955
|
+
threshold : float
|
|
956
|
+
Numerical threshold for stability.
|
|
957
|
+
|
|
958
|
+
Notes
|
|
959
|
+
-----
|
|
960
|
+
This class uses libigl for efficient closest-point queries and embree
|
|
961
|
+
for ray intersection tests to determine inside/outside status.
|
|
962
|
+
|
|
963
|
+
Examples
|
|
964
|
+
--------
|
|
965
|
+
>>> import trimesh
|
|
966
|
+
>>> from DeepSDFStruct.SDF import SDFfromMesh
|
|
967
|
+
>>> import torch
|
|
968
|
+
>>>
|
|
969
|
+
>>> # Load or create a mesh
|
|
970
|
+
>>> mesh = trimesh.creation.box(extents=[1, 1, 1])
|
|
971
|
+
>>>
|
|
972
|
+
>>> # Create SDF from mesh
|
|
973
|
+
>>> sdf = SDFfromMesh(mesh, scale=True)
|
|
974
|
+
>>>
|
|
975
|
+
>>> # Query distances
|
|
976
|
+
>>> points = torch.tensor([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]])
|
|
977
|
+
>>> distances = sdf(points)
|
|
978
|
+
"""
|
|
979
|
+
|
|
980
|
+
def __init__(
|
|
981
|
+
self,
|
|
982
|
+
mesh,
|
|
983
|
+
dtype=np.float32,
|
|
984
|
+
flip_sign=False,
|
|
985
|
+
scale=True,
|
|
986
|
+
threshold=1e-5,
|
|
987
|
+
backend="igl",
|
|
988
|
+
):
|
|
989
|
+
super().__init__()
|
|
990
|
+
if type(mesh) is gustaf.faces.Faces:
|
|
991
|
+
mesh = trimesh.Trimesh(mesh.vertices, mesh.faces)
|
|
992
|
+
|
|
993
|
+
if scale:
|
|
994
|
+
# scales from [0,1] to [-1,1]
|
|
995
|
+
# https://www.brainvoyager.com/bv/doc/UsersGuide/CoordsAndTransforms/SpatialTransformationMatrices.html
|
|
996
|
+
mesh, _, _ = normalize_mesh_to_unit_cube(mesh)
|
|
997
|
+
self.mesh = mesh
|
|
998
|
+
self.dtype = dtype
|
|
999
|
+
self.flip_sign = flip_sign
|
|
1000
|
+
self.threshold = threshold
|
|
1001
|
+
self.backend = backend
|
|
1002
|
+
|
|
1003
|
+
def _set_param(self, mesh):
|
|
1004
|
+
self.mesh = mesh
|
|
1005
|
+
|
|
1006
|
+
def _get_domain_bounds(self):
|
|
1007
|
+
return self.mesh.bounds
|
|
1008
|
+
|
|
1009
|
+
def _compute(self, queries: torch.Tensor | np.ndarray):
|
|
1010
|
+
num_points = (
|
|
1011
|
+
queries.shape[0] if isinstance(queries, torch.Tensor) else queries.shape[0]
|
|
1012
|
+
)
|
|
1013
|
+
logger.debug(
|
|
1014
|
+
f"SDFfromMesh._compute - {num_points} points, backend={self.backend}"
|
|
1015
|
+
)
|
|
1016
|
+
is_tensor = isinstance(queries, torch.Tensor)
|
|
1017
|
+
|
|
1018
|
+
if is_tensor:
|
|
1019
|
+
orig_device = queries.device
|
|
1020
|
+
orig_dtype = queries.dtype
|
|
1021
|
+
queries_np = queries.detach().cpu().numpy()
|
|
1022
|
+
else:
|
|
1023
|
+
queries_np = np.asarray(queries)
|
|
1024
|
+
orig_device = None # No device for numpy input
|
|
1025
|
+
|
|
1026
|
+
if self.backend == "trimesh":
|
|
1027
|
+
# Compute squared distance
|
|
1028
|
+
squared_distance, hit_index, hit_coordinates = (
|
|
1029
|
+
igl.point_mesh_squared_distance(
|
|
1030
|
+
queries_np,
|
|
1031
|
+
self.mesh.vertices,
|
|
1032
|
+
np.array(self.mesh.faces, dtype=np.int32),
|
|
1033
|
+
)
|
|
1034
|
+
)
|
|
1035
|
+
distances = np.sqrt(squared_distance, dtype=self.dtype)
|
|
1036
|
+
|
|
1037
|
+
# Determine sign (negative if inside)
|
|
1038
|
+
contains = trimesh.ray.ray_pyembree.RayMeshIntersector(
|
|
1039
|
+
self.mesh, scale_to_box=False
|
|
1040
|
+
).contains_points(queries_np)
|
|
1041
|
+
|
|
1042
|
+
distances[contains] *= -1.0
|
|
1043
|
+
elif self.backend == "igl":
|
|
1044
|
+
distances, _, _, _ = igl.signed_distance(
|
|
1045
|
+
queries_np,
|
|
1046
|
+
self.mesh.vertices,
|
|
1047
|
+
np.array(self.mesh.faces, dtype=np.int32),
|
|
1048
|
+
)
|
|
1049
|
+
# Apply threshold
|
|
1050
|
+
distances -= self.threshold
|
|
1051
|
+
|
|
1052
|
+
result = distances.reshape(-1, 1)
|
|
1053
|
+
|
|
1054
|
+
if is_tensor:
|
|
1055
|
+
return torch.tensor(result, device=orig_device, dtype=orig_dtype)
|
|
1056
|
+
else:
|
|
1057
|
+
return result
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def normalize_mesh_to_unit_cube(mesh: trimesh.Trimesh, shrink_factor: float = 1.0):
|
|
1061
|
+
"""
|
|
1062
|
+
Transform mesh coordinates uniformly to [-1, 1] in all axes.
|
|
1063
|
+
Keeps aspect ratio of original mesh.
|
|
1064
|
+
|
|
1065
|
+
shrink_factor : float
|
|
1066
|
+
Uniform scaling factor applied after normalization.
|
|
1067
|
+
1.0 -> exactly fills [-1, 1]
|
|
1068
|
+
0.95 -> 5% smaller
|
|
1069
|
+
|
|
1070
|
+
:return: The fitted mesh, the inverse scaling factor applied (can directly be used as input for the TorchScaling), and the translation vector used.
|
|
1071
|
+
"""
|
|
1072
|
+
logger.debug(f"Scaling mesh from {mesh.bounds.flatten()}")
|
|
1073
|
+
# --- Compute bounding box ---
|
|
1074
|
+
bbox_min = mesh.bounds[0] # [x_min, y_min, z_min]
|
|
1075
|
+
bbox_max = mesh.bounds[1] # [x_max, y_max, z_max]
|
|
1076
|
+
|
|
1077
|
+
# Center of the mesh
|
|
1078
|
+
center = (bbox_max + bbox_min) / 2.0
|
|
1079
|
+
|
|
1080
|
+
# Largest extent
|
|
1081
|
+
base_scale = (
|
|
1082
|
+
np.max(bbox_max - bbox_min) / 2.0
|
|
1083
|
+
) # divide by 2 because [-1,1] spans 2 units
|
|
1084
|
+
|
|
1085
|
+
# --- Build transformation matrix ---
|
|
1086
|
+
matrix = np.eye(4)
|
|
1087
|
+
|
|
1088
|
+
# Translate to origin
|
|
1089
|
+
matrix[:3, 3] = -center
|
|
1090
|
+
|
|
1091
|
+
# Apply translation
|
|
1092
|
+
mesh.apply_transform(matrix)
|
|
1093
|
+
|
|
1094
|
+
# --- Apply uniform scaling ---
|
|
1095
|
+
applied_scale = shrink_factor / base_scale
|
|
1096
|
+
scale_matrix = np.eye(4)
|
|
1097
|
+
scale_matrix[:3, :3] *= applied_scale
|
|
1098
|
+
mesh.apply_transform(scale_matrix)
|
|
1099
|
+
logger.debug(f"to {mesh.bounds.flatten()}")
|
|
1100
|
+
return mesh, 1.0 / applied_scale, center
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
class SDFfromLineMesh(SDFBase):
|
|
1104
|
+
"""Signed distance function from a line mesh (collection of line segments).
|
|
1105
|
+
|
|
1106
|
+
Creates an SDF by computing the minimum distance from query points to
|
|
1107
|
+
line segments in the mesh. Each line segment is treated as a cylinder
|
|
1108
|
+
with the specified thickness.
|
|
1109
|
+
|
|
1110
|
+
Parameters
|
|
1111
|
+
----------
|
|
1112
|
+
line_mesh : gustaf.Edges
|
|
1113
|
+
Line mesh containing vertices and edge connectivity.
|
|
1114
|
+
thickness : float
|
|
1115
|
+
Thickness (diameter) of the lines. Points within half this distance
|
|
1116
|
+
are considered inside.
|
|
1117
|
+
smoothness : float, default 0
|
|
1118
|
+
Smoothing parameter for the union operation. Higher values create
|
|
1119
|
+
smoother transitions between line segments. Use 0 for sharp union.
|
|
1120
|
+
|
|
1121
|
+
Examples
|
|
1122
|
+
--------
|
|
1123
|
+
>>> import gustaf
|
|
1124
|
+
>>> import numpy as np
|
|
1125
|
+
>>> from DeepSDFStruct.SDF import SDFfromLineMesh
|
|
1126
|
+
>>>
|
|
1127
|
+
>>> # Create a simple line segment
|
|
1128
|
+
>>> vertices = np.array([[0, 0], [1, 1]])
|
|
1129
|
+
>>> edges = np.array([[0, 1]])
|
|
1130
|
+
>>> line_mesh = gustaf.Edges(vertices, edges)
|
|
1131
|
+
>>>
|
|
1132
|
+
>>> sdf = SDFfromLineMesh(line_mesh, thickness=0.1)
|
|
1133
|
+
>>> import torch
|
|
1134
|
+
>>> points = torch.tensor([[0.0, 0.0], [0.5, 0.5]])
|
|
1135
|
+
>>> distances = sdf(points)
|
|
1136
|
+
|
|
1137
|
+
Notes
|
|
1138
|
+
-----
|
|
1139
|
+
Currently supports only 2D line meshes.
|
|
1140
|
+
"""
|
|
1141
|
+
|
|
1142
|
+
line_mesh: gustaf.Edges
|
|
1143
|
+
|
|
1144
|
+
def __init__(self, line_mesh: gustaf.Edges, thickness, smoothness=0):
|
|
1145
|
+
"""Initialize SDF from a line mesh.
|
|
1146
|
+
|
|
1147
|
+
Parameters
|
|
1148
|
+
----------
|
|
1149
|
+
line_mesh : gustaf.Edges
|
|
1150
|
+
Line mesh containing vertices and edge connectivity.
|
|
1151
|
+
thickness : float
|
|
1152
|
+
Thickness (diameter) of the lines.
|
|
1153
|
+
smoothness : float, default 0
|
|
1154
|
+
Smoothing parameter for the union operation.
|
|
1155
|
+
"""
|
|
1156
|
+
super().__init__()
|
|
1157
|
+
self.line_mesh = line_mesh
|
|
1158
|
+
self.t = thickness
|
|
1159
|
+
self.smoothness = smoothness
|
|
1160
|
+
self.geometric_dim = line_mesh.vertices.shape[1]
|
|
1161
|
+
|
|
1162
|
+
def _get_domain_bounds(self):
|
|
1163
|
+
return torch.tensor(self.line_mesh.bounds())
|
|
1164
|
+
|
|
1165
|
+
def _set_param(self, parameters):
|
|
1166
|
+
self.t = parameters[0]
|
|
1167
|
+
self.smoothness = parameters[1]
|
|
1168
|
+
|
|
1169
|
+
def _compute(self, queries: torch.Tensor | np.ndarray):
|
|
1170
|
+
num_points = (
|
|
1171
|
+
queries.shape[0] if isinstance(queries, torch.Tensor) else queries.shape[0]
|
|
1172
|
+
)
|
|
1173
|
+
logger.debug(
|
|
1174
|
+
f"SDFfromLineMesh._compute - {num_points} points, thickness={self.t}, smoothness={self.smoothness}"
|
|
1175
|
+
)
|
|
1176
|
+
is_tensor = isinstance(queries, torch.Tensor)
|
|
1177
|
+
if is_tensor:
|
|
1178
|
+
orig_device = queries.device
|
|
1179
|
+
orig_dtype = queries.dtype
|
|
1180
|
+
queries_np = queries.detach().cpu().numpy()
|
|
1181
|
+
else:
|
|
1182
|
+
queries_np = np.asarray(queries)
|
|
1183
|
+
orig_device = None # No device for numpy input
|
|
1184
|
+
lines = self.line_mesh.vertices[self.line_mesh.edges]
|
|
1185
|
+
sdf = point_segment_distance(lines[:, 0], lines[:, 1], queries_np) - self.t / 2
|
|
1186
|
+
if is_tensor:
|
|
1187
|
+
sdf = torch.tensor(sdf, dtype=orig_dtype, device=orig_device)
|
|
1188
|
+
return union_torch(sdf, k=self.smoothness)
|
|
1189
|
+
else:
|
|
1190
|
+
return union_numpy(sdf, k=self.smoothness)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
class SDFfromDeepSDF(SDFBase):
|
|
1194
|
+
"""Signed distance function from a trained DeepSDF neural network model.
|
|
1195
|
+
|
|
1196
|
+
Wraps a trained DeepSDF model to provide SDF queries. The model uses
|
|
1197
|
+
latent vectors to condition the SDF on specific shapes from a learned
|
|
1198
|
+
distribution.
|
|
1199
|
+
|
|
1200
|
+
Parameters
|
|
1201
|
+
----------
|
|
1202
|
+
model : DeepSDFModel
|
|
1203
|
+
Trained DeepSDF model with decoder network.
|
|
1204
|
+
max_batch : int, default 8192 (32**3)
|
|
1205
|
+
Maximum number of query points to process in a single batch.
|
|
1206
|
+
Useful for managing GPU memory when querying many points.
|
|
1207
|
+
|
|
1208
|
+
Attributes
|
|
1209
|
+
----------
|
|
1210
|
+
model : DeepSDFModel
|
|
1211
|
+
The underlying DeepSDF model.
|
|
1212
|
+
latvec : torch.Tensor or None
|
|
1213
|
+
Latent vector(s) for conditioning. Can be set via set_latent_vec().
|
|
1214
|
+
geometric_dim : int
|
|
1215
|
+
Geometric dimension (2 or 3) from the model's decoder.
|
|
1216
|
+
|
|
1217
|
+
Examples
|
|
1218
|
+
--------
|
|
1219
|
+
>>> from DeepSDFStruct.SDF import SDFfromDeepSDF
|
|
1220
|
+
>>> from DeepSDFStruct.deep_sdf.models import DeepSDFModel
|
|
1221
|
+
>>> import torch
|
|
1222
|
+
>>>
|
|
1223
|
+
>>> # Load a trained model
|
|
1224
|
+
>>> model = DeepSDFModel.load_from_checkpoint("path/to/checkpoint")
|
|
1225
|
+
>>> sdf = SDFfromDeepSDF(model)
|
|
1226
|
+
>>>
|
|
1227
|
+
>>> # Query the SDF
|
|
1228
|
+
>>> points = torch.rand(1000, 3)
|
|
1229
|
+
>>> distances = sdf(points)
|
|
1230
|
+
|
|
1231
|
+
Notes
|
|
1232
|
+
-----
|
|
1233
|
+
The latent vector can be updated to query different shapes from the
|
|
1234
|
+
learned distribution using set_latent_vec().
|
|
1235
|
+
"""
|
|
1236
|
+
|
|
1237
|
+
def __init__(self, model: DeepSDFModel, max_batch=32**3):
|
|
1238
|
+
"""Initialize SDF from a trained DeepSDF model.
|
|
1239
|
+
|
|
1240
|
+
Parameters
|
|
1241
|
+
----------
|
|
1242
|
+
model : DeepSDFModel
|
|
1243
|
+
Trained DeepSDF model.
|
|
1244
|
+
max_batch : int, default 8192
|
|
1245
|
+
Maximum batch size for query processing.
|
|
1246
|
+
"""
|
|
1247
|
+
super().__init__()
|
|
1248
|
+
self.model = model
|
|
1249
|
+
self.latvec = None
|
|
1250
|
+
self.parametrization = None
|
|
1251
|
+
self.max_batch = max_batch
|
|
1252
|
+
self.set_latent_vec(model._trained_latent_vectors[0])
|
|
1253
|
+
self.geometric_dim = model._decoder.geom_dimension
|
|
1254
|
+
|
|
1255
|
+
def set_latent_vec(self, latent_vec: torch.Tensor):
|
|
1256
|
+
"""
|
|
1257
|
+
Set conditioning parameters for the model (e.g., latent code).
|
|
1258
|
+
"""
|
|
1259
|
+
# if dimension is 1, set parametrization
|
|
1260
|
+
# if dimnsion is equal to the number of query points, directly set latvec
|
|
1261
|
+
if latent_vec.ndim == 1:
|
|
1262
|
+
self.parametrization = Constant(latent_vec, device=self.model.device)
|
|
1263
|
+
elif latent_vec.ndim == 2:
|
|
1264
|
+
assert (
|
|
1265
|
+
latent_vec.shape[1] == self.model._trained_latent_vectors[0].shape[0]
|
|
1266
|
+
), "Learned latent shape and assigned latent shape mismatch"
|
|
1267
|
+
self.latvec = latent_vec # (n_query_points, latent_dim)
|
|
1268
|
+
else:
|
|
1269
|
+
raise ValueError(
|
|
1270
|
+
f"Expected latent_vec to have 1 or 2 dimensions, got shape {latent_vec.shape}"
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
def _get_domain_bounds(self):
|
|
1274
|
+
return torch.tensor([[-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]])
|
|
1275
|
+
|
|
1276
|
+
def _set_param(self, parameters):
|
|
1277
|
+
return self.set_latent_vec(parameters)
|
|
1278
|
+
|
|
1279
|
+
def _compute(self, queries: torch.Tensor) -> torch.Tensor:
|
|
1280
|
+
logger.debug(
|
|
1281
|
+
f"SDFfromDeepSDF._compute - {queries.shape[0]} points, batch_size={self.max_batch}, latent_shape={self.latvec.shape if self.latvec is not None else 'None'}"
|
|
1282
|
+
)
|
|
1283
|
+
# DeepSDF queries range from -1 to 1
|
|
1284
|
+
orig_device = queries.device
|
|
1285
|
+
queries = queries.to(self.get_device())
|
|
1286
|
+
n_queries = queries.shape[0]
|
|
1287
|
+
|
|
1288
|
+
sdf_values = torch.zeros(n_queries, device=self.get_device())
|
|
1289
|
+
|
|
1290
|
+
head = 0
|
|
1291
|
+
if self.latvec is None:
|
|
1292
|
+
latvec = self.parametrization(queries).to(self.get_device())
|
|
1293
|
+
else:
|
|
1294
|
+
latent_dim = self.model._trained_latent_vectors[0].shape[0]
|
|
1295
|
+
num_samples = queries.shape[0]
|
|
1296
|
+
if self.latvec.ndim == 1:
|
|
1297
|
+
if self.latvec.shape[0] != latent_dim:
|
|
1298
|
+
raise ValueError(
|
|
1299
|
+
f"Latent vector shape mismatch: {self.latvec.shape} does"
|
|
1300
|
+
f"not align with latent dimension {latent_dim}."
|
|
1301
|
+
)
|
|
1302
|
+
latvec = self.latvec.expand(-1, num_samples).T
|
|
1303
|
+
elif self.latvec.ndim == 2:
|
|
1304
|
+
if (self.latvec.shape[0] != num_samples) or (
|
|
1305
|
+
self.latvec.shape[1] != latent_dim
|
|
1306
|
+
):
|
|
1307
|
+
raise ValueError(
|
|
1308
|
+
f"Latent vector shape mismatch: {self.latvec.shape} does"
|
|
1309
|
+
f" not align with {num_samples} queries."
|
|
1310
|
+
f" Must be of shape ({num_samples}, {latent_dim})"
|
|
1311
|
+
)
|
|
1312
|
+
latvec = self.latvec
|
|
1313
|
+
|
|
1314
|
+
while head < n_queries:
|
|
1315
|
+
end = min(head + self.max_batch, n_queries)
|
|
1316
|
+
query_batch = queries[head:end]
|
|
1317
|
+
sdf_values[head:end] = (
|
|
1318
|
+
self.model._decode_sdf(latvec[head:end], query_batch).squeeze(1)
|
|
1319
|
+
# .detach()
|
|
1320
|
+
)
|
|
1321
|
+
head = end
|
|
1322
|
+
|
|
1323
|
+
return sdf_values.to(orig_device).reshape(-1, 1)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _cap_outside_of_unitcube(samples, sdf_values, max_dim=3):
|
|
1327
|
+
|
|
1328
|
+
for dim in range(max_dim):
|
|
1329
|
+
border_sdf = samples[:, dim]
|
|
1330
|
+
sdf_values = torch.maximum(sdf_values, -border_sdf.reshape(-1, 1))
|
|
1331
|
+
border_sdf = 1 - samples[:, dim]
|
|
1332
|
+
sdf_values = torch.maximum(sdf_values, -border_sdf.reshape(-1, 1))
|
|
1333
|
+
return sdf_values
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def point_segment_distance(P1, P2, query_points):
|
|
1337
|
+
"""
|
|
1338
|
+
Calculates the minimum distance from one or more query points
|
|
1339
|
+
to one or more line segments defined by endpoints P1 and P2.
|
|
1340
|
+
|
|
1341
|
+
Args:
|
|
1342
|
+
P1 (np.ndarray): Array of shape (M, 2) or (2,) representing
|
|
1343
|
+
first endpoints of segments.
|
|
1344
|
+
P2 (np.ndarray): Array of shape (M, 2) or (2,) representing
|
|
1345
|
+
second endpoints of segments.
|
|
1346
|
+
query_points (np.ndarray): Array of shape (N, 2) or (2,)
|
|
1347
|
+
representing query point(s).
|
|
1348
|
+
|
|
1349
|
+
Returns:
|
|
1350
|
+
np.ndarray: Array of shape (N,) with the minimum distance from
|
|
1351
|
+
each query point to the closest segment.
|
|
1352
|
+
"""
|
|
1353
|
+
P1 = np.atleast_2d(P1) # (M, 2)
|
|
1354
|
+
P2 = np.atleast_2d(P2) # (M, 2)
|
|
1355
|
+
Q = np.atleast_2d(query_points) # (N, 2)
|
|
1356
|
+
|
|
1357
|
+
# Handle degenerate case: one segment only
|
|
1358
|
+
# if P1.shape[0] == 1 and P2.shape[0] == 1:
|
|
1359
|
+
# P1 = np.repeat(P1, Q.shape[0], axis=0)
|
|
1360
|
+
# P2 = np.repeat(P2, Q.shape[0], axis=0)
|
|
1361
|
+
|
|
1362
|
+
v = P2 - P1 # (M, 2) segment vectors
|
|
1363
|
+
w = Q[:, None, :] - P1[None, :, :] # (N, M, 2): vector from P1 to each query point
|
|
1364
|
+
|
|
1365
|
+
seg_len2 = np.sum(v**2, axis=1) # (M,) squared lengths
|
|
1366
|
+
|
|
1367
|
+
# Avoid division by zero (degenerate segments)
|
|
1368
|
+
seg_len2_safe = np.where(seg_len2 == 0, 1, seg_len2)
|
|
1369
|
+
|
|
1370
|
+
# Projection factor t for each (Q, segment)
|
|
1371
|
+
t = np.einsum("nmd,md->nm", w, v) / seg_len2_safe # (N, M)
|
|
1372
|
+
t = np.clip(t, 0, 1) # clamp to segment
|
|
1373
|
+
|
|
1374
|
+
# Closest point on segment
|
|
1375
|
+
projection = P1[None, :, :] + t[..., None] * v[None, :, :] # (N, M, 2)
|
|
1376
|
+
|
|
1377
|
+
# Distances
|
|
1378
|
+
distances = np.linalg.norm(Q[:, None, :] - projection, axis=2) # (N, M)
|
|
1379
|
+
|
|
1380
|
+
# For each query point, take the closest segment
|
|
1381
|
+
return distances
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
# used to define the unit cube
|
|
1385
|
+
location_lookup = {
|
|
1386
|
+
"x0": (0, 0),
|
|
1387
|
+
"x1": (0, 1),
|
|
1388
|
+
"y0": (1, 0),
|
|
1389
|
+
"y1": (1, 1),
|
|
1390
|
+
"z0": (2, 0),
|
|
1391
|
+
"z1": (2, 1),
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
class TransformedSDF(SDFBase):
|
|
1396
|
+
"""
|
|
1397
|
+
Generic SDF wrapper that applies a transformation to the input queries.
|
|
1398
|
+
Transformation can be rotation, translation, or scaling.
|
|
1399
|
+
"""
|
|
1400
|
+
|
|
1401
|
+
def __init__(
|
|
1402
|
+
self, sdf: SDFBase, rotationMatrix=None, translation=None, scaleFactor=None
|
|
1403
|
+
):
|
|
1404
|
+
super().__init__()
|
|
1405
|
+
self.sdf = sdf
|
|
1406
|
+
if (rotationMatrix is None) or (rotationMatrix == [0, 0, 0]):
|
|
1407
|
+
rotationMatrix = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
|
|
1408
|
+
if translation is None:
|
|
1409
|
+
translation = [0, 0, 0]
|
|
1410
|
+
if scaleFactor is None:
|
|
1411
|
+
scaleFactor = 1
|
|
1412
|
+
r = torch.as_tensor(rotationMatrix, dtype=torch.float32)
|
|
1413
|
+
s = torch.as_tensor([scaleFactor], dtype=torch.float32)
|
|
1414
|
+
t = torch.as_tensor(translation, dtype=torch.float32)
|
|
1415
|
+
self.translation = torch.nn.Parameter(t.reshape(1, 3))
|
|
1416
|
+
self.rotationMatrix = torch.nn.Parameter(r.reshape(3, 3))
|
|
1417
|
+
self.scale = torch.nn.Parameter(s)
|
|
1418
|
+
|
|
1419
|
+
def _compute(self, queries: torch.Tensor) -> torch.Tensor:
|
|
1420
|
+
logger.debug(
|
|
1421
|
+
f"TransformedSDF._compute - {queries.shape[0]} points, sdf={type(self.sdf).__name__}"
|
|
1422
|
+
)
|
|
1423
|
+
xyz = queries
|
|
1424
|
+
|
|
1425
|
+
# apply scale, for now, only uniform scale is allowd
|
|
1426
|
+
xyz = xyz / self.scale # inverse scale
|
|
1427
|
+
|
|
1428
|
+
# apply rotation
|
|
1429
|
+
xyz = xyz @ self.rotationMatrix.T # rotate points
|
|
1430
|
+
|
|
1431
|
+
# apply translation
|
|
1432
|
+
xyz = xyz - self.translation
|
|
1433
|
+
|
|
1434
|
+
sdf_vals = self.sdf._compute(xyz)
|
|
1435
|
+
|
|
1436
|
+
# rescale distances if scaled
|
|
1437
|
+
sdf_vals = sdf_vals * self.scale
|
|
1438
|
+
return sdf_vals
|
|
1439
|
+
|
|
1440
|
+
def _get_domain_bounds(self) -> torch.Tensor:
|
|
1441
|
+
return self.sdf._get_domain_bounds()
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
class CappedBorderSDF(SDFBase):
|
|
1445
|
+
"""
|
|
1446
|
+
Applies planar boundary caps to another SDF.
|
|
1447
|
+
"""
|
|
1448
|
+
|
|
1449
|
+
cap_border_dict: CapBorderDict
|
|
1450
|
+
|
|
1451
|
+
def __init__(self, sdf: SDFBase, cap_border_dict=None, scale=(1, 1, 1)):
|
|
1452
|
+
super().__init__(geometric_dim=sdf.geometric_dim)
|
|
1453
|
+
self.sdf = sdf
|
|
1454
|
+
if cap_border_dict is None:
|
|
1455
|
+
match self.sdf.geometric_dim:
|
|
1456
|
+
case 2:
|
|
1457
|
+
cap_border_dict = UNIT_CUBE_CAPS_2D
|
|
1458
|
+
case 3:
|
|
1459
|
+
cap_border_dict = UNIT_CUBE_CAPS_3D
|
|
1460
|
+
self.cap_border_dict = cap_border_dict
|
|
1461
|
+
self.scale = scale
|
|
1462
|
+
|
|
1463
|
+
def _compute(self, queries: torch.Tensor) -> torch.Tensor:
|
|
1464
|
+
logger.debug(
|
|
1465
|
+
f"CappedBorderSDF._compute - {queries.shape[0]} points, sdf={type(self.sdf).__name__}"
|
|
1466
|
+
)
|
|
1467
|
+
sdf_values = self.sdf(queries)
|
|
1468
|
+
|
|
1469
|
+
bounds = self.sdf._get_domain_bounds().to(
|
|
1470
|
+
device=queries.device, dtype=queries.dtype
|
|
1471
|
+
)
|
|
1472
|
+
|
|
1473
|
+
for loc, cap_dict in self.cap_border_dict.items():
|
|
1474
|
+
cap = cap_dict["cap"]
|
|
1475
|
+
measure = cap_dict["measure"]
|
|
1476
|
+
|
|
1477
|
+
dim, side = location_lookup[loc]
|
|
1478
|
+
bound = bounds[side, dim]
|
|
1479
|
+
x = queries[:, dim].view(-1, 1)
|
|
1480
|
+
if side == 0 and cap == -1:
|
|
1481
|
+
border_sdf = x - (bound + measure)
|
|
1482
|
+
elif side == 0 and cap == 1:
|
|
1483
|
+
border_sdf = (bound + measure) - x
|
|
1484
|
+
elif side == 1 and cap == -1:
|
|
1485
|
+
border_sdf = (bound - measure) - x
|
|
1486
|
+
elif side == 1 and cap == 1:
|
|
1487
|
+
border_sdf = x - (bound - measure)
|
|
1488
|
+
else:
|
|
1489
|
+
raise RuntimeError(f"Side must be either 0 or 1, not {side}")
|
|
1490
|
+
|
|
1491
|
+
# cap == 1 means add material
|
|
1492
|
+
if cap == 1:
|
|
1493
|
+
sdf_values = torch.minimum(sdf_values, -border_sdf * self.scale[dim])
|
|
1494
|
+
# sdf_values = torch.where(outside, border_sdf, sdf_values)
|
|
1495
|
+
|
|
1496
|
+
# cap == -1 means remove material
|
|
1497
|
+
elif cap == -1:
|
|
1498
|
+
sdf_values = torch.maximum(sdf_values, -border_sdf * self.scale[dim])
|
|
1499
|
+
# sdf_values = torch.where(outside, border_sdf, sdf_values)
|
|
1500
|
+
|
|
1501
|
+
else:
|
|
1502
|
+
raise ValueError("Cap must be -1 (remove) or 1 (add)")
|
|
1503
|
+
|
|
1504
|
+
return sdf_values
|
|
1505
|
+
|
|
1506
|
+
def _get_domain_bounds(self):
|
|
1507
|
+
return self.sdf._get_domain_bounds()
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def generate_plane_points(origin, normal, res, xlim, ylim):
|
|
1511
|
+
"""Generate evenly spaced points on a plane in 3D space.
|
|
1512
|
+
|
|
1513
|
+
Creates a regular grid of points on a plane defined by a point and normal
|
|
1514
|
+
vector. The grid is axis-aligned in the plane's local coordinate system.
|
|
1515
|
+
|
|
1516
|
+
Parameters
|
|
1517
|
+
----------
|
|
1518
|
+
origin : array-like of shape (3,)
|
|
1519
|
+
A point on the plane (3D vector).
|
|
1520
|
+
normal : array-like of shape (3,)
|
|
1521
|
+
Normal vector of the plane (3D vector). Currently supports only
|
|
1522
|
+
axis-aligned normals: [1,0,0], [0,1,0], or [0,0,1].
|
|
1523
|
+
res : tuple of int
|
|
1524
|
+
Grid resolution (num_points_u, num_points_v).
|
|
1525
|
+
xlim : tuple of float
|
|
1526
|
+
Range along the first plane axis (umin, umax).
|
|
1527
|
+
ylim : tuple of float
|
|
1528
|
+
Range along the second plane axis (vmin, vmax).
|
|
1529
|
+
|
|
1530
|
+
Returns
|
|
1531
|
+
-------
|
|
1532
|
+
points : np.ndarray of shape (num_points_u * num_points_v, 3)
|
|
1533
|
+
3D coordinates of grid points.
|
|
1534
|
+
u : np.ndarray of shape (num_points_u * num_points_v,)
|
|
1535
|
+
First plane coordinate for each point.
|
|
1536
|
+
v : np.ndarray of shape (num_points_u * num_points_v,)
|
|
1537
|
+
Second plane coordinate for each point.
|
|
1538
|
+
|
|
1539
|
+
Raises
|
|
1540
|
+
------
|
|
1541
|
+
NotImplementedError
|
|
1542
|
+
If normal is not axis-aligned.
|
|
1543
|
+
|
|
1544
|
+
Examples
|
|
1545
|
+
--------
|
|
1546
|
+
>>> from DeepSDFStruct.plotting import generate_plane_points
|
|
1547
|
+
>>> import numpy as np
|
|
1548
|
+
>>>
|
|
1549
|
+
>>> # Generate points on XY plane at z=0.5
|
|
1550
|
+
>>> points, u, v = generate_plane_points(
|
|
1551
|
+
... origin=[0, 0, 0.5],
|
|
1552
|
+
... normal=[0, 0, 1],
|
|
1553
|
+
... res=(10, 10),
|
|
1554
|
+
... xlim=(-1, 1),
|
|
1555
|
+
... ylim=(-1, 1)
|
|
1556
|
+
... )
|
|
1557
|
+
>>> print(points.shape) # (100, 3)
|
|
1558
|
+
>>> print(np.allclose(points[:, 2], 0.5)) # True (all on z=0.5 plane)
|
|
1559
|
+
|
|
1560
|
+
Notes
|
|
1561
|
+
-----
|
|
1562
|
+
The function determines two orthogonal axes (u and v) in the plane
|
|
1563
|
+
based on the normal vector. For axis-aligned planes:
|
|
1564
|
+
- Normal [0,0,1] (XY plane): u=[1,0,0], v=[0,1,0]
|
|
1565
|
+
- Normal [0,1,0] (XZ plane): u=[1,0,0], v=[0,0,1]
|
|
1566
|
+
- Normal [1,0,0] (YZ plane): u=[0,1,0], v=[0,0,1]
|
|
1567
|
+
"""
|
|
1568
|
+
normal = np.array(normal)
|
|
1569
|
+
normal = normal / np.linalg.norm(normal)
|
|
1570
|
+
origin = np.array(origin)
|
|
1571
|
+
# Find two orthogonal vectors to the normal that lie on the plane (u and v axes)
|
|
1572
|
+
if np.allclose(normal, [0, 0, 1]): # Special case when the normal is along z-axis
|
|
1573
|
+
u = np.array([1, 0, 0])
|
|
1574
|
+
v = np.array([0, 1, 0])
|
|
1575
|
+
elif np.allclose(normal, [0, 1, 0]): # Special case when the normal is along z-axis
|
|
1576
|
+
u = np.array([1, 0, 0])
|
|
1577
|
+
v = np.array([0, 0, 1])
|
|
1578
|
+
elif np.allclose(normal, [1, 0, 0]): # Special case when the normal is along z-axis
|
|
1579
|
+
u = np.array([0, 1, 0])
|
|
1580
|
+
v = np.array([0, 0, 1])
|
|
1581
|
+
else:
|
|
1582
|
+
raise NotImplementedError(
|
|
1583
|
+
"Normal vector other than [1,0,0], [0,1,0] and [0,0,1] not supported yet."
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
u_coords = np.linspace(xlim[0], xlim[1], res[0])
|
|
1587
|
+
v_coords = np.linspace(ylim[0], ylim[1], res[1])
|
|
1588
|
+
u_mesh, v_mesh = np.meshgrid(u_coords, v_coords)
|
|
1589
|
+
u_flat = u_mesh.reshape(-1)
|
|
1590
|
+
v_flat = v_mesh.reshape(-1)
|
|
1591
|
+
points = origin + u_flat[:, None] * u + v_flat[:, None] * v
|
|
1592
|
+
|
|
1593
|
+
return points
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def _get_plane_plot_axes(normal):
|
|
1597
|
+
normal = np.array(normal)
|
|
1598
|
+
normal = normal / np.linalg.norm(normal)
|
|
1599
|
+
if np.allclose(normal, [0, 0, 1]):
|
|
1600
|
+
return 0, 1
|
|
1601
|
+
if np.allclose(normal, [0, 1, 0]):
|
|
1602
|
+
return 0, 2
|
|
1603
|
+
if np.allclose(normal, [1, 0, 0]):
|
|
1604
|
+
return 1, 2
|
|
1605
|
+
raise NotImplementedError(
|
|
1606
|
+
"Normal vector other than [1,0,0], [0,1,0] and [0,0,1] not supported yet."
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def _build_structured_grid_triangles(nx, ny):
|
|
1611
|
+
triangles = []
|
|
1612
|
+
for j in range(ny - 1):
|
|
1613
|
+
row_start = j * nx
|
|
1614
|
+
next_row_start = (j + 1) * nx
|
|
1615
|
+
for i in range(nx - 1):
|
|
1616
|
+
p0 = row_start + i
|
|
1617
|
+
p1 = row_start + i + 1
|
|
1618
|
+
p2 = next_row_start + i
|
|
1619
|
+
p3 = next_row_start + i + 1
|
|
1620
|
+
triangles.append([p0, p1, p2])
|
|
1621
|
+
triangles.append([p1, p3, p2])
|
|
1622
|
+
return np.asarray(triangles, dtype=np.int32)
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def project_bounds(origin, normal, bounds=None):
|
|
1626
|
+
"""
|
|
1627
|
+
Project 3D AABB bounds onto a slice plane and return 2D limits.
|
|
1628
|
+
|
|
1629
|
+
Parameters
|
|
1630
|
+
----------
|
|
1631
|
+
origin : (3,)
|
|
1632
|
+
Point on plane
|
|
1633
|
+
normal : (3,)
|
|
1634
|
+
Plane normal
|
|
1635
|
+
bounds : (2, 3)
|
|
1636
|
+
AABB bounds [[xmin,ymin,zmin],[xmax,ymax,zmax]]
|
|
1637
|
+
If None, defaults to unit cube [0,1]^3
|
|
1638
|
+
|
|
1639
|
+
Returns
|
|
1640
|
+
-------
|
|
1641
|
+
xlim, ylim : tuple
|
|
1642
|
+
Limits in plane coordinates
|
|
1643
|
+
"""
|
|
1644
|
+
import numpy as np
|
|
1645
|
+
|
|
1646
|
+
if bounds is None:
|
|
1647
|
+
bounds = np.array([[0, 0, 0], [1, 1, 1]])
|
|
1648
|
+
elif isinstance(bounds, torch.Tensor):
|
|
1649
|
+
bounds = bounds.detach().cpu().numpy()
|
|
1650
|
+
if bounds.shape[1] == 2:
|
|
1651
|
+
logger.info("cannot project 2D onto 2D, returning input")
|
|
1652
|
+
return bounds[0], bounds[1]
|
|
1653
|
+
|
|
1654
|
+
bmin, bmax = bounds
|
|
1655
|
+
|
|
1656
|
+
normal = np.asarray(normal, dtype=float)
|
|
1657
|
+
normal = normal / np.linalg.norm(normal)
|
|
1658
|
+
|
|
1659
|
+
origin = np.asarray(origin, dtype=float)
|
|
1660
|
+
|
|
1661
|
+
# --- build orthonormal basis (u, v) ---
|
|
1662
|
+
if np.allclose(normal, [0, 0, 1]):
|
|
1663
|
+
u = np.array([1, 0, 0])
|
|
1664
|
+
v = np.array([0, 1, 0])
|
|
1665
|
+
elif np.allclose(normal, [0, 1, 0]):
|
|
1666
|
+
u = np.array([1, 0, 0])
|
|
1667
|
+
v = np.array([0, 0, 1])
|
|
1668
|
+
elif np.allclose(normal, [1, 0, 0]):
|
|
1669
|
+
u = np.array([0, 1, 0])
|
|
1670
|
+
v = np.array([0, 0, 1])
|
|
1671
|
+
else:
|
|
1672
|
+
raise NotImplementedError(
|
|
1673
|
+
"normals different to the main axis are not implemented yet."
|
|
1674
|
+
)
|
|
1675
|
+
|
|
1676
|
+
# --- generate 8 corners of AABB ---
|
|
1677
|
+
corners = np.array(
|
|
1678
|
+
[
|
|
1679
|
+
[x, y, z]
|
|
1680
|
+
for x in [bmin[0], bmax[0]]
|
|
1681
|
+
for y in [bmin[1], bmax[1]]
|
|
1682
|
+
for z in [bmin[2], bmax[2]]
|
|
1683
|
+
]
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
# --- project corners into plane coordinates ---
|
|
1687
|
+
rel = corners - origin # important: relative to plane origin
|
|
1688
|
+
|
|
1689
|
+
u_coords = rel @ u
|
|
1690
|
+
v_coords = rel @ v
|
|
1691
|
+
|
|
1692
|
+
xlim = (u_coords.min(), u_coords.max())
|
|
1693
|
+
ylim = (v_coords.min(), v_coords.max())
|
|
1694
|
+
|
|
1695
|
+
return xlim, ylim
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
class ExtrudeSDF(SDFBase):
|
|
1699
|
+
"""
|
|
1700
|
+
Extrudes a 2D SDF into a 3D shape.
|
|
1701
|
+
"""
|
|
1702
|
+
|
|
1703
|
+
def __init__(self, sdf_2d: SDFBase, height: float):
|
|
1704
|
+
super().__init__(geometric_dim=3)
|
|
1705
|
+
if sdf_2d.geometric_dim != 2:
|
|
1706
|
+
raise ValueError("Input SDF for ExtrudeSDF must be 2D.")
|
|
1707
|
+
self.sdf_2d = sdf_2d
|
|
1708
|
+
self.height = height
|
|
1709
|
+
|
|
1710
|
+
def _compute(self, queries: torch.Tensor) -> torch.Tensor:
|
|
1711
|
+
logger.debug(
|
|
1712
|
+
f"ExtrudeSDF._compute - {queries.shape[0]} points, height={self.height}"
|
|
1713
|
+
)
|
|
1714
|
+
# queries are (N, 3)
|
|
1715
|
+
d = self.sdf_2d(queries[:, :2]) # (N, 1)
|
|
1716
|
+
|
|
1717
|
+
h = self.height
|
|
1718
|
+
|
|
1719
|
+
# w is a 2D vector for each query point
|
|
1720
|
+
w = torch.cat(
|
|
1721
|
+
[d, torch.abs(queries[:, 2].unsqueeze(1)) - h / 2.0], dim=1
|
|
1722
|
+
) # (N, 2)
|
|
1723
|
+
|
|
1724
|
+
# sdf of a 2d box
|
|
1725
|
+
outside_dist = torch.linalg.norm(torch.clamp(w, min=0.0), dim=1)
|
|
1726
|
+
inside_dist = torch.minimum(torch.max(w[:, 0], w[:, 1]), torch.tensor(0.0))
|
|
1727
|
+
|
|
1728
|
+
return (outside_dist + inside_dist).reshape(-1, 1)
|
|
1729
|
+
|
|
1730
|
+
def _get_domain_bounds(self) -> torch.Tensor:
|
|
1731
|
+
bounds_2d = self.sdf_2d._get_domain_bounds()
|
|
1732
|
+
lower = torch.cat([bounds_2d[0], torch.tensor([-self.height / 2.0])])
|
|
1733
|
+
upper = torch.cat([bounds_2d[1], torch.tensor([self.height / 2.0])])
|
|
1734
|
+
return torch.stack([lower, upper])
|