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.
Files changed (165) hide show
  1. DeepSDFStruct/SDF.py +1734 -0
  2. DeepSDFStruct/__init__.py +66 -0
  3. DeepSDFStruct/deep_sdf/__init__.py +69 -0
  4. DeepSDFStruct/deep_sdf/create_screenshots_from_plyfiles.py +65 -0
  5. DeepSDFStruct/deep_sdf/data.py +258 -0
  6. DeepSDFStruct/deep_sdf/metrics/__init__.py +14 -0
  7. DeepSDFStruct/deep_sdf/metrics/mesh_to_analytical.py +24 -0
  8. DeepSDFStruct/deep_sdf/models.py +197 -0
  9. DeepSDFStruct/deep_sdf/networks/__init__.py +8 -0
  10. DeepSDFStruct/deep_sdf/networks/analytic_round_cross.py +105 -0
  11. DeepSDFStruct/deep_sdf/networks/deep_sdf_decoder.py +155 -0
  12. DeepSDFStruct/deep_sdf/networks/hierarchical_deep_sdf_decoder.py +139 -0
  13. DeepSDFStruct/deep_sdf/networks/hierarchical_positional_sdf_decoder.py +190 -0
  14. DeepSDFStruct/deep_sdf/networks/resnet_positional_sdf_decoder.py +108 -0
  15. DeepSDFStruct/deep_sdf/nn_utils.py +70 -0
  16. DeepSDFStruct/deep_sdf/plotting.py +143 -0
  17. DeepSDFStruct/deep_sdf/reconstruction.py +165 -0
  18. DeepSDFStruct/deep_sdf/training.py +840 -0
  19. DeepSDFStruct/deep_sdf/workspace.py +363 -0
  20. DeepSDFStruct/design_of_experiments.py +235 -0
  21. DeepSDFStruct/flexicubes/__init__.py +15 -0
  22. DeepSDFStruct/flexicubes/flexicubes.py +1005 -0
  23. DeepSDFStruct/flexicubes/tables.py +2338 -0
  24. DeepSDFStruct/flexisquares/__init__.py +15 -0
  25. DeepSDFStruct/flexisquares/flexisquares.py +967 -0
  26. DeepSDFStruct/flexisquares/tables.py +63 -0
  27. DeepSDFStruct/lattice_structure.py +254 -0
  28. DeepSDFStruct/local_shapes.py +226 -0
  29. DeepSDFStruct/mesh.py +1184 -0
  30. DeepSDFStruct/optimization.py +357 -0
  31. DeepSDFStruct/parametrization.py +215 -0
  32. DeepSDFStruct/pretrained_models.py +128 -0
  33. DeepSDFStruct/sampling.py +670 -0
  34. DeepSDFStruct/sdf_operations.py +990 -0
  35. DeepSDFStruct/sdf_primitives.py +1416 -0
  36. DeepSDFStruct/splinepy_unitcells/__init__.py +35 -0
  37. DeepSDFStruct/splinepy_unitcells/chi_3D.py +1617 -0
  38. DeepSDFStruct/splinepy_unitcells/cross_lattice.py +165 -0
  39. DeepSDFStruct/splinepy_unitcells/double_lattice_extruded.py +298 -0
  40. DeepSDFStruct/splinepy_unitcells/snappy_3d.py +544 -0
  41. DeepSDFStruct/torch_spline.py +446 -0
  42. DeepSDFStruct/trained_models/analytic_round_cross/LatentCodes/latest.pth +0 -0
  43. DeepSDFStruct/trained_models/analytic_round_cross/ModelParameters/latest.pth +0 -0
  44. DeepSDFStruct/trained_models/analytic_round_cross/OptimizerParameters/latest.pth +0 -0
  45. DeepSDFStruct/trained_models/analytic_round_cross/specs.json +45 -0
  46. DeepSDFStruct/trained_models/chi_and_cross/LatentCodes/latest.pth +0 -0
  47. DeepSDFStruct/trained_models/chi_and_cross/Logs.pth +0 -0
  48. DeepSDFStruct/trained_models/chi_and_cross/ModelParameters/latest.pth +0 -0
  49. DeepSDFStruct/trained_models/chi_and_cross/OptimizerParameters/latest.pth +0 -0
  50. DeepSDFStruct/trained_models/chi_and_cross/specs.json +76 -0
  51. DeepSDFStruct/trained_models/primitives/LatentCodes/latest.pth +0 -0
  52. DeepSDFStruct/trained_models/primitives/ModelParameters/latest.pth +0 -0
  53. DeepSDFStruct/trained_models/primitives/OptimizerParameters/latest.pth +0 -0
  54. DeepSDFStruct/trained_models/primitives/specs.json +46 -0
  55. DeepSDFStruct/trained_models/primitives_2d/LatentCodes/latest.pth +0 -0
  56. DeepSDFStruct/trained_models/primitives_2d/ModelParameters/latest.pth +0 -0
  57. DeepSDFStruct/trained_models/primitives_2d/OptimizerParameters/latest.pth +0 -0
  58. DeepSDFStruct/trained_models/primitives_2d/specs.json +46 -0
  59. DeepSDFStruct/trained_models/round_cross/LatentCodes/latest.pth +0 -0
  60. DeepSDFStruct/trained_models/round_cross/Logs.pth +0 -0
  61. DeepSDFStruct/trained_models/round_cross/ModelParameters/latest.pth +0 -0
  62. DeepSDFStruct/trained_models/round_cross/OptimizerParameters/latest.pth +0 -0
  63. DeepSDFStruct/trained_models/round_cross/specs.json +45 -0
  64. DeepSDFStruct/trained_models/test_experiment/LatentCodes/latent_code_data_map.json +106 -0
  65. DeepSDFStruct/trained_models/test_experiment/specs.json +45 -0
  66. DeepSDFStruct/trained_models/test_experiment/training_summary.json +10 -0
  67. DeepSDFStruct/trained_models/test_experiment_hierarchical/LatentCodes/latent_code_data_map.json +106 -0
  68. DeepSDFStruct/trained_models/test_experiment_hierarchical/specs.json +45 -0
  69. DeepSDFStruct/trained_models/test_experiment_hierarchical/training_summary.json +10 -0
  70. DeepSDFStruct/trained_models/test_experiment_homogenization/LatentCodes/latent_code_data_map.json +5006 -0
  71. DeepSDFStruct/trained_models/test_experiment_homogenization/LatentCodes/latest.pth +0 -0
  72. DeepSDFStruct/trained_models/test_experiment_homogenization/Logs.png +0 -0
  73. DeepSDFStruct/trained_models/test_experiment_homogenization/ModelParameters/latest.pth +0 -0
  74. DeepSDFStruct/trained_models/test_experiment_homogenization/OptimizerParameters/latest.pth +0 -0
  75. DeepSDFStruct/trained_models/test_experiment_homogenization/specs.json +83 -0
  76. DeepSDFStruct/trained_models/test_experiment_homogenization/training_summary.json +10 -0
  77. DeepSDFStruct/utils.py +96 -0
  78. DeepSDFStruct/visualization/latent_viewer.py +211 -0
  79. DeepSDFStruct/visualization/latent_viewer_cli.py +8 -0
  80. benchmarks/generate_sdf_showcase.py +622 -0
  81. benchmarks/sdf_from_mesh_benchmark.py +52 -0
  82. benchmarks/sdf_from_mesh_comparison.md +33 -0
  83. benchmarks/sdf_showcase/operations/boolean/difference_sphere_box.png +0 -0
  84. benchmarks/sdf_showcase/operations/boolean/union_sphere_box.png +0 -0
  85. benchmarks/sdf_showcase/operations/transformations/circular_array_sphere.png +0 -0
  86. benchmarks/sdf_showcase/operations/transformations/dilate_sphere.png +0 -0
  87. benchmarks/sdf_showcase/operations/transformations/mirror_sphere.png +0 -0
  88. benchmarks/sdf_showcase/operations/transformations/repeat_sphere.png +0 -0
  89. benchmarks/sdf_showcase/operations/transformations/revolve_circle.png +0 -0
  90. benchmarks/sdf_showcase/operations/transformations/shell_sphere.png +0 -0
  91. benchmarks/sdf_showcase/operations/transformations/twist_torus.png +0 -0
  92. benchmarks/sdf_showcase/primitives/2D/circle.png +0 -0
  93. benchmarks/sdf_showcase/primitives/2D/equilateral_triangle.png +0 -0
  94. benchmarks/sdf_showcase/primitives/2D/hexagon.png +0 -0
  95. benchmarks/sdf_showcase/primitives/2D/polygon_pentagon.png +0 -0
  96. benchmarks/sdf_showcase/primitives/2D/rectangle.png +0 -0
  97. benchmarks/sdf_showcase/primitives/2D/rounded_rectangle.png +0 -0
  98. benchmarks/sdf_showcase/primitives/3D/box.png +0 -0
  99. benchmarks/sdf_showcase/primitives/3D/capped_cone.png +0 -0
  100. benchmarks/sdf_showcase/primitives/3D/capped_cylinder.png +0 -0
  101. benchmarks/sdf_showcase/primitives/3D/capsule.png +0 -0
  102. benchmarks/sdf_showcase/primitives/3D/cone.png +0 -0
  103. benchmarks/sdf_showcase/primitives/3D/corner_spheres.png +0 -0
  104. benchmarks/sdf_showcase/primitives/3D/cross_ms.png +0 -0
  105. benchmarks/sdf_showcase/primitives/3D/cylinder.png +0 -0
  106. benchmarks/sdf_showcase/primitives/3D/dodecahedron.png +0 -0
  107. benchmarks/sdf_showcase/primitives/3D/ellipsoid.png +0 -0
  108. benchmarks/sdf_showcase/primitives/3D/icosahedron.png +0 -0
  109. benchmarks/sdf_showcase/primitives/3D/octahedron.png +0 -0
  110. benchmarks/sdf_showcase/primitives/3D/pyramid.png +0 -0
  111. benchmarks/sdf_showcase/primitives/3D/rounded_box.png +0 -0
  112. benchmarks/sdf_showcase/primitives/3D/rounded_cone.png +0 -0
  113. benchmarks/sdf_showcase/primitives/3D/rounded_cylinder.png +0 -0
  114. benchmarks/sdf_showcase/primitives/3D/sphere.png +0 -0
  115. benchmarks/sdf_showcase/primitives/3D/tetrahedron.png +0 -0
  116. benchmarks/sdf_showcase/primitives/3D/torus.png +0 -0
  117. benchmarks/sdf_showcase/primitives/3D/wireframe_box.png +0 -0
  118. deepsdfstruct-1.7.2.dist-info/METADATA +44 -0
  119. deepsdfstruct-1.7.2.dist-info/RECORD +165 -0
  120. deepsdfstruct-1.7.2.dist-info/WHEEL +5 -0
  121. deepsdfstruct-1.7.2.dist-info/licenses/LICENSE +208 -0
  122. deepsdfstruct-1.7.2.dist-info/licenses/NOTICE +67 -0
  123. deepsdfstruct-1.7.2.dist-info/scm_file_list.json +178 -0
  124. deepsdfstruct-1.7.2.dist-info/scm_version.json +8 -0
  125. deepsdfstruct-1.7.2.dist-info/top_level.txt +4 -0
  126. docs/Makefile +20 -0
  127. docs/api_reference.rst +6 -0
  128. docs/conf.py +60 -0
  129. docs/index.rst +136 -0
  130. docs/make.bat +35 -0
  131. docs/qr_code_github.png +0 -0
  132. docs/qr_code_paper.png +0 -0
  133. docs/readme_images/example_output_01.png +0 -0
  134. docs/readme_images/example_output_02.png +0 -0
  135. docs/readme_images/example_output_03.png +0 -0
  136. docs/readme_images/example_output_04.png +0 -0
  137. tests/data/chairs/1005.obj +282 -0
  138. tests/data/chairs/1024.obj +222 -0
  139. tests/data/chairs/README.md +1 -0
  140. tests/data/circular_balls_test_case.stl +0 -0
  141. tests/data/cone.stl +1794 -0
  142. tests/data/example_disconnectd_mesh.inp +1784 -0
  143. tests/data/example_line_mesh.vtk +0 -0
  144. tests/data/stanford_bunny.stl +0 -0
  145. tests/data/sweep_test_case.stl +0 -0
  146. tests/test_DOE.py +49 -0
  147. tests/test_elongate_sdf.py +195 -0
  148. tests/test_flexisquares.py +187 -0
  149. tests/test_generate_dataset.py +76 -0
  150. tests/test_lattice_evaluation.py +137 -0
  151. tests/test_mesh_export.py +137 -0
  152. tests/test_mesh_functions.py +49 -0
  153. tests/test_networks.py +71 -0
  154. tests/test_pretrained_models.py +40 -0
  155. tests/test_reconstruction.py +101 -0
  156. tests/test_sdf_from_mesh.py +52 -0
  157. tests/test_sdf_functions.py +117 -0
  158. tests/test_sdf_primitives.py +668 -0
  159. tests/test_sdf_trimesh_comparison.py +482 -0
  160. tests/test_splinepy_unitcells.py +64 -0
  161. tests/test_structural_optimization.py +178 -0
  162. tests/test_torch_spline.py +216 -0
  163. tests/test_train_model.py +110 -0
  164. tests/test_training_data_ids.py +41 -0
  165. 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])