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
@@ -0,0 +1,990 @@
1
+ """
2
+ SDF Operations and Transformations
3
+ ==================================
4
+
5
+ This module provides various operations that can be applied to SDFs,
6
+ including space warping transformations like elongation, twisting,
7
+ bending, dilation/erosion, and shell creation.
8
+ """
9
+
10
+ import torch
11
+ import numpy as np
12
+ from DeepSDFStruct.SDF import SDFBase
13
+
14
+
15
+ class ElongateSDF(SDFBase):
16
+ """Elongate an SDF by adding material along the coordinate axes.
17
+
18
+ Extends the geometry by adding material in the positive and negative
19
+ directions along each axis. Useful for creating stretched or extended
20
+ versions of existing shapes.
21
+
22
+ Parameters
23
+ ----------
24
+ sdf : SDFBase
25
+ The base SDF to elongate.
26
+ size : array-like of shape (3,) or float
27
+ Elongation amount along each axis (x, y, z). If a single float,
28
+ applies uniform elongation in all directions.
29
+
30
+ Examples
31
+ --------
32
+ >>> # Elongate a sphere by 0.5 in all directions
33
+ >>> sphere = SphereSDF(center=[0, 0, 0], radius=0.5)
34
+ >>> ElongateSDF(sphere, size=0.5)
35
+ >>>
36
+ >>> # Elongate more in x-direction than y and z
37
+ >>> ElongateSDF(sphere, size=[1.0, 0.2, 0.2])
38
+ """
39
+
40
+ def __init__(self, sdf: SDFBase, size):
41
+ """Initialize ElongateSDF.
42
+
43
+ Parameters
44
+ ----------
45
+ sdf : SDFBase
46
+ The base SDF to elongate.
47
+ size : array-like of shape (3,) or float
48
+ Elongation amount along each axis.
49
+ """
50
+ super().__init__()
51
+ self.sdf = sdf
52
+ self.size = torch.nn.Parameter(torch.as_tensor(size, dtype=torch.float32))
53
+
54
+ def _compute(self, queries: torch.Tensor) -> torch.Tensor:
55
+ size = self.size.to(device=queries.device, dtype=queries.dtype)
56
+ q = torch.abs(queries) - size
57
+ w = torch.minimum(
58
+ torch.maximum(torch.maximum(q[:, 0], q[:, 1]), q[:, 2]), torch.tensor(0.0)
59
+ ).unsqueeze(1)
60
+ sdf_q = self.sdf._compute(torch.clamp(q, min=0.0))
61
+ return torch.maximum(sdf_q, w)
62
+
63
+ def _get_domain_bounds(self) -> torch.Tensor:
64
+ b = self.sdf._get_domain_bounds()
65
+ return torch.stack([b[0] - self.size, b[1] + self.size])
66
+
67
+
68
+ class TwistSDF(SDFBase):
69
+ """Twist an SDF around the Z-axis by an angle proportional to height.
70
+
71
+ Applies a rotational deformation where the rotation angle increases
72
+ linearly with the z-coordinate. Creates a spiral or twisted effect.
73
+
74
+ Parameters
75
+ ----------
76
+ sdf : SDFBase
77
+ The SDF to twist.
78
+ k : float
79
+ Twist rate (radians per unit length). Positive values create
80
+ counter-clockwise twist when looking down the Z-axis.
81
+
82
+ Examples
83
+ --------
84
+ >>> # Twist a box by 0.5 radians per unit height
85
+ >>> box = BoxSDF(center=[0, 0, 0], extents=[1, 1, 2])
86
+ >>> TwistSDF(box, k=0.5)
87
+ >>>
88
+ >>> # Stronger twist (full rotation over height 2π)
89
+ >>> TwistSDF(box, k=1.0)
90
+ """
91
+
92
+ def __init__(self, sdf: SDFBase, k):
93
+ """Initialize TwistSDF.
94
+
95
+ Parameters
96
+ ----------
97
+ sdf : SDFBase
98
+ The SDF to twist.
99
+ k : float
100
+ Twist rate in radians per unit length.
101
+ """
102
+ super().__init__()
103
+ self.sdf = sdf
104
+ self.k = torch.nn.Parameter(torch.as_tensor(k, dtype=torch.float32))
105
+
106
+ def _compute(self, queries: torch.Tensor) -> torch.Tensor:
107
+ k = self.k.to(device=queries.device, dtype=queries.dtype)
108
+ x, y, z = queries[:, 0], queries[:, 1], queries[:, 2]
109
+ c = torch.cos(k * z)
110
+ s = torch.sin(k * z)
111
+ x2 = c * x - s * y
112
+ y2 = s * x + c * y
113
+ rotated = torch.stack([x2, y2, z], dim=1)
114
+ return self.sdf._compute(rotated)
115
+
116
+ def _get_domain_bounds(self) -> torch.Tensor:
117
+ return self.sdf._get_domain_bounds()
118
+
119
+
120
+ class BendLinearSDF(SDFBase):
121
+ """Bend SDF linearly between two control points."""
122
+
123
+ def __init__(self, sdf: SDFBase, p0, p1, v):
124
+ super().__init__()
125
+ self.sdf = sdf
126
+ self.p0 = torch.nn.Parameter(torch.as_tensor(p0, dtype=torch.float32))
127
+ self.p1 = torch.nn.Parameter(torch.as_tensor(p1, dtype=torch.float32))
128
+ self.v = torch.nn.Parameter(torch.as_tensor(v, dtype=torch.float32))
129
+
130
+ def _compute(self, queries: torch.Tensor) -> torch.Tensor:
131
+ p0 = self.p0.to(device=queries.device, dtype=queries.dtype)
132
+ p1 = self.p1.to(device=queries.device, dtype=queries.dtype)
133
+ v = self.v.to(device=queries.device, dtype=queries.dtype)
134
+
135
+ ab = p1 - p0
136
+ t = torch.clamp(torch.sum((queries - p0) * ab, dim=1) / torch.sum(ab**2), 0, 1)
137
+ t = t.reshape(-1, 1)
138
+ return self.sdf._compute(queries + t * v)
139
+
140
+ def _get_domain_bounds(self) -> torch.Tensor:
141
+ b = self.sdf._get_domain_bounds()
142
+ return b
143
+
144
+
145
+ class BendRadialSDF(SDFBase):
146
+ """Bend SDF radially."""
147
+
148
+ def __init__(self, sdf: SDFBase, r0, r1, dz):
149
+ super().__init__()
150
+ self.sdf = sdf
151
+ self.r0 = torch.nn.Parameter(torch.as_tensor(r0, dtype=torch.float32))
152
+ self.r1 = torch.nn.Parameter(torch.as_tensor(r1, dtype=torch.float32))
153
+ self.dz = torch.nn.Parameter(torch.as_tensor(dz, dtype=torch.float32))
154
+
155
+ def _compute(self, queries: torch.Tensor) -> torch.Tensor:
156
+ r0 = self.r0.to(device=queries.device, dtype=queries.dtype)
157
+ r1 = self.r1.to(device=queries.device, dtype=queries.dtype)
158
+ dz = self.dz.to(device=queries.device, dtype=queries.dtype)
159
+
160
+ x = queries[:, 0]
161
+ y = queries[:, 1]
162
+ r = torch.sqrt(x**2 + y**2)
163
+ t = torch.clamp((r - r0) / (r1 - r0), 0, 1)
164
+ z = queries[:, 2] - dz * t
165
+
166
+ p = torch.stack([queries[:, 0], queries[:, 1], z], dim=1)
167
+ return self.sdf._compute(p)
168
+
169
+ def _get_domain_bounds(self) -> torch.Tensor:
170
+ return self.sdf._get_domain_bounds()
171
+
172
+
173
+ class DilateSDF(SDFBase):
174
+ """Expand an SDF uniformly by adding material to the surface.
175
+
176
+ Increases the size of the geometry by moving the surface outward
177
+ by a specified distance. Equivalent to making the object larger
178
+ while maintaining its shape.
179
+
180
+ Parameters
181
+ ----------
182
+ sdf : SDFBase
183
+ The SDF to expand.
184
+ r : float
185
+ Expansion distance. Positive values add material (make larger),
186
+ negative values remove material (make smaller).
187
+
188
+ Examples
189
+ --------
190
+ >>> # Expand a sphere by 0.1 units
191
+ >>> sphere = SphereSDF(center=[0, 0, 0], radius=0.5)
192
+ >>> DilateSDF(sphere, r=0.1) # radius effectively becomes 0.6
193
+ >>>
194
+ >>> # Shrink by using negative dilation
195
+ >>> DilateSDF(sphere, r=-0.1) # radius effectively becomes 0.4
196
+ """
197
+
198
+ def __init__(self, sdf: SDFBase, r):
199
+ """Initialize DilateSDF.
200
+
201
+ Parameters
202
+ ----------
203
+ sdf : SDFBase
204
+ The SDF to expand.
205
+ r : float
206
+ Expansion distance (positive=larger, negative=smaller).
207
+ """
208
+ super().__init__()
209
+ self.sdf = sdf
210
+ self.r = torch.nn.Parameter(torch.as_tensor(r, dtype=torch.float32))
211
+
212
+ def _compute(self, queries) -> torch.Tensor:
213
+ r = self.r.to(device=queries.device, dtype=queries.dtype)
214
+ return self.sdf._compute(queries) - r
215
+
216
+ def _get_domain_bounds(self) -> torch.Tensor:
217
+ b = self.sdf._get_domain_bounds()
218
+ # Expand bounds by r
219
+ return torch.stack([b[0] - self.r, b[1] + self.r])
220
+
221
+
222
+ class ErodeSDF(SDFBase):
223
+ """Contract an SDF uniformly by removing material from the surface.
224
+
225
+ Decreases the size of the geometry by moving the surface inward
226
+ by a specified distance. Equivalent to making the object smaller
227
+ while maintaining its shape.
228
+
229
+ Parameters
230
+ ----------
231
+ sdf : SDFBase
232
+ The SDF to contract.
233
+ r : float
234
+ Contraction distance. Must be positive. For expansion, use
235
+ DilateSDF instead.
236
+
237
+ Examples
238
+ --------
239
+ >>> # Shrink a sphere by 0.1 units
240
+ >>> sphere = SphereSDF(center=[0, 0, 0], radius=0.5)
241
+ >>> ErodeSDF(sphere, r=0.1) # radius effectively becomes 0.4
242
+ >>>
243
+ >>> # Create a thin shell by combining with original
244
+ >>> ErodeSDF(sphere, r=0.05)
245
+ """
246
+
247
+ def __init__(self, sdf: SDFBase, r):
248
+ """Initialize ErodeSDF.
249
+
250
+ Parameters
251
+ ----------
252
+ sdf : SDFBase
253
+ The SDF to contract.
254
+ r : float
255
+ Contraction distance (must be positive).
256
+ """
257
+ super().__init__()
258
+ self.sdf = sdf
259
+ self.r = torch.nn.Parameter(torch.as_tensor(r, dtype=torch.float32))
260
+
261
+ def _compute(self, queries) -> torch.Tensor:
262
+ r = self.r.to(device=queries.device, dtype=queries.dtype)
263
+ return self.sdf._compute(queries) + r
264
+
265
+ def _get_domain_bounds(self) -> torch.Tensor:
266
+ return self.sdf._get_domain_bounds()
267
+
268
+
269
+ class ShellSDF(SDFBase):
270
+ """Create a hollow shell by keeping only a thin layer around the surface.
271
+
272
+ Converts a solid object into a hollow shell by taking the absolute
273
+ value of the SDF and offsetting by half the thickness. The result
274
+ is a shell centered on the original surface.
275
+
276
+ Parameters
277
+ ----------
278
+ sdf : SDFBase
279
+ The SDF to convert to a shell.
280
+ thickness : float
281
+ Total thickness of the shell wall. Must be positive.
282
+
283
+ Examples
284
+ --------
285
+ >>> # Create a hollow sphere with 0.05 wall thickness
286
+ >>> sphere = SphereSDF(center=[0, 0, 0], radius=0.5)
287
+ >>> ShellSDF(sphere, thickness=0.05)
288
+ >>>
289
+ >>> # Create a thin-walled box
290
+ >>> box = BoxSDF(center=[0, 0, 0], extents=[1, 1, 1])
291
+ >>> ShellSDF(box, thickness=0.02)
292
+ """
293
+
294
+ def __init__(self, sdf: SDFBase, thickness):
295
+ """Initialize ShellSDF.
296
+
297
+ Parameters
298
+ ----------
299
+ sdf : SDFBase
300
+ The SDF to convert to a shell.
301
+ thickness : float
302
+ Total thickness of the shell wall.
303
+ """
304
+ super().__init__()
305
+ self.sdf = sdf
306
+ self.thickness = torch.nn.Parameter(
307
+ torch.as_tensor(thickness, dtype=torch.float32)
308
+ )
309
+
310
+ def _compute(self, queries) -> torch.Tensor:
311
+ t = self.thickness.to(device=queries.device, dtype=queries.dtype)
312
+ return torch.abs(self.sdf._compute(queries)) - t / 2
313
+
314
+ def _get_domain_bounds(self) -> torch.Tensor:
315
+ return self.sdf._get_domain_bounds()
316
+
317
+
318
+ class RepeatSDF(SDFBase):
319
+ """Infinite or finite grid repetition of an SDF."""
320
+
321
+ def __init__(self, sdf: SDFBase, spacing, count=None):
322
+ super().__init__()
323
+ self.sdf = sdf
324
+ self.spacing = torch.nn.Parameter(torch.as_tensor(spacing, dtype=torch.float32))
325
+ self.count = count # None for infinite, odd number for finite
326
+
327
+ def _compute(self, queries: torch.Tensor) -> torch.Tensor:
328
+ spacing = self.spacing.to(device=queries.device, dtype=queries.dtype)
329
+
330
+ # Fold all points symmetrically into [-spacing/2, spacing/2]
331
+ q = queries - spacing * torch.round(queries / spacing)
332
+
333
+ if self.count is not None:
334
+ # Clamp the grid index so only `count` copies are active
335
+ half = (self.count - 1) / 2.0
336
+ n = torch.round(queries / spacing)
337
+ n = torch.clamp(n, -half, half)
338
+ q = queries - spacing * n
339
+
340
+ return self.sdf._compute(q)
341
+
342
+ def _get_domain_bounds(self) -> torch.Tensor:
343
+ if self.count is None:
344
+ # Return single-period bounds for visualization
345
+ return self.sdf._get_domain_bounds()
346
+ else:
347
+ b = self.sdf._get_domain_bounds()
348
+ span = (self.count - 1) * self.spacing
349
+ return torch.stack([b[0] - span / 2, b[1] + span / 2])
350
+
351
+
352
+ class MirrorSDF(SDFBase):
353
+ """Reflect an SDF across a plane to create symmetric geometry.
354
+
355
+ Creates a mirror copy of an SDF across a specified plane, effectively
356
+ doubling the geometry. The result is the union of the original and
357
+ reflected SDF, creating a symmetric object.
358
+
359
+ Parameters
360
+ ----------
361
+ sdf : SDFBase
362
+ The SDF to mirror. This will be reflected across the plane.
363
+ plane_point : array-like of shape (3,)
364
+ A point that lies on the mirror plane. This point, together with
365
+ plane_normal, defines the mirror plane.
366
+ plane_normal : array-like of shape (3,)
367
+ Normal vector of the mirror plane. The plane is perpendicular to
368
+ this vector. Will be normalized internally.
369
+
370
+ Examples
371
+ --------
372
+ >>> # Mirror a sphere across the YZ plane (x=0)
373
+ >>> sphere = SphereSDF(center=[0.5, 0, 0], radius=0.3)
374
+ >>> MirrorSDF(sphere, plane_point=[0, 0, 0], plane_normal=[1, 0, 0])
375
+ >>>
376
+ >>> # Mirror across the XY plane (z=0)
377
+ >>> MirrorSDF(sphere, plane_point=[0, 0, 0], plane_normal=[0, 0, 1])
378
+ >>>
379
+ >>> # Mirror across a diagonal plane
380
+ >>> MirrorSDF(sphere, plane_point=[0, 0, 0], plane_normal=[1, 1, 0])
381
+
382
+ Notes
383
+ -----
384
+ - The result is the union (minimum) of original and mirrored SDF
385
+ - Use plane_point and plane_normal to define any mirror plane
386
+ - Common mirror planes:
387
+ - YZ plane: point=[0,0,0], normal=[1,0,0]
388
+ - XZ plane: point=[0,0,0], normal=[0,1,0]
389
+ - XY plane: point=[0,0,0], normal=[0,0,1]
390
+ - The mirrored geometry is exactly symmetric - no thickness or gap
391
+ """
392
+
393
+ def __init__(self, sdf: SDFBase, plane_point, plane_normal):
394
+ """Initialize MirrorSDF.
395
+
396
+ Parameters
397
+ ----------
398
+ sdf : SDFBase
399
+ The SDF to mirror.
400
+ plane_point : array-like of shape (3,)
401
+ A point on the mirror plane.
402
+ plane_normal : array-like of shape (3,)
403
+ Normal vector of the mirror plane.
404
+ """
405
+ super().__init__()
406
+ self.sdf = sdf
407
+ self.point = torch.nn.Parameter(
408
+ torch.as_tensor(plane_point, dtype=torch.float32)
409
+ )
410
+ self.normal = torch.nn.Parameter(
411
+ torch.as_tensor(plane_normal, dtype=torch.float32)
412
+ )
413
+
414
+ def _compute(self, queries: torch.Tensor) -> torch.Tensor:
415
+ pt = self.point.to(device=queries.device, dtype=queries.dtype)
416
+ nm = self.normal.to(device=queries.device, dtype=queries.dtype)
417
+ nm = nm / torch.linalg.norm(nm)
418
+
419
+ # Signed distance to plane
420
+ d = torch.sum((queries - pt) * nm, dim=1, keepdim=True)
421
+
422
+ # Distance from original SDF
423
+ d_orig = self.sdf._compute(queries)
424
+
425
+ # Distance from reflected SDF
426
+ queries_reflected = queries - 2 * d * nm
427
+ d_reflected = self.sdf._compute(queries_reflected)
428
+
429
+ # Take minimum (union of original and mirror)
430
+ return torch.minimum(d_orig, d_reflected)
431
+
432
+ def _get_domain_bounds(self) -> torch.Tensor:
433
+ return self.sdf._get_domain_bounds()
434
+
435
+
436
+ class CircularArraySDF(SDFBase):
437
+ """Create count copies of an SDF rotated around an arbitrary axis line.
438
+
439
+ The axis line is defined by an ``axis`` direction and a ``base_point``
440
+ lying on that axis. Copies are sampled at evenly spaced angles in the
441
+ half-open interval [start_angle, end_angle).
442
+
443
+ """
444
+
445
+ def __init__(
446
+ self,
447
+ sdf: SDFBase,
448
+ count,
449
+ axis=torch.tensor([0, 0, 1], dtype=torch.float32),
450
+ base_point=torch.tensor([0, 0, 0], dtype=torch.float32),
451
+ start_angle_deg=0.0,
452
+ end_angle_deg=360,
453
+ ):
454
+ super().__init__()
455
+ self.sdf = sdf
456
+ self.count = int(count)
457
+
458
+ self.axis = torch.nn.Parameter(torch.as_tensor(axis, dtype=torch.float32))
459
+ self.base_point = torch.nn.Parameter(
460
+ torch.as_tensor(base_point, dtype=torch.float32)
461
+ )
462
+ self.start_angle = torch.nn.Parameter(
463
+ torch.as_tensor(start_angle_deg, dtype=torch.float32)
464
+ )
465
+ self.end_angle = torch.nn.Parameter(
466
+ torch.as_tensor(end_angle_deg, dtype=torch.float32)
467
+ )
468
+
469
+ @staticmethod
470
+ def _normalize_axis(axis: torch.Tensor) -> torch.Tensor:
471
+ axis_norm = torch.linalg.norm(axis)
472
+ if axis_norm <= 0:
473
+ raise ValueError("Axis must be non-zero")
474
+ return axis / axis_norm
475
+
476
+ @staticmethod
477
+ def _rotate_about_axis(
478
+ points: torch.Tensor, axis_unit: torch.Tensor, angles: torch.Tensor
479
+ ) -> torch.Tensor:
480
+ """Rotate points around an arbitrary axis for a batch of angles."""
481
+ k = axis_unit.view(1, 1, 3)
482
+ p = points.unsqueeze(0).expand(angles.shape[0], -1, -1)
483
+ a = angles.view(-1, 1, 1)
484
+ c = torch.cos(torch.deg2rad(a))
485
+ s = torch.sin(torch.deg2rad(a))
486
+
487
+ cross = torch.cross(k, p, dim=2)
488
+ dot = torch.sum(p * k, dim=2, keepdim=True)
489
+ return p * c + cross * s + k * dot * (1 - c)
490
+
491
+ def _compute(self, queries: torch.Tensor) -> torch.Tensor:
492
+ if self.count <= 0:
493
+ raise ValueError("count must be a positive integer")
494
+
495
+ axis = self.axis.to(device=queries.device, dtype=queries.dtype)
496
+ axis_unit = self._normalize_axis(axis)
497
+ base = self.base_point.to(device=queries.device, dtype=queries.dtype)
498
+ start = self.start_angle.to(device=queries.device, dtype=queries.dtype)
499
+ end = self.end_angle.to(device=queries.device, dtype=queries.dtype)
500
+
501
+ step = (end - start) / self.count
502
+ angles = start + step * torch.arange(
503
+ self.count, device=queries.device, dtype=queries.dtype
504
+ )
505
+
506
+ # Pull query points back through inverse rotations and evaluate each copy
507
+ # without changing the number of points passed to the child SDF.
508
+ centered = queries - base
509
+ dmin = None
510
+ for angle in angles:
511
+ rotated = self._rotate_about_axis(centered, axis_unit, (-angle).reshape(1))[
512
+ 0
513
+ ]
514
+ eval_points = rotated + base
515
+ d = self.sdf._compute(eval_points)
516
+ dmin = d if dmin is None else torch.minimum(dmin, d)
517
+
518
+ return dmin
519
+
520
+ def _get_domain_bounds(self) -> torch.Tensor:
521
+ if self.count <= 0:
522
+ raise ValueError("count must be a positive integer")
523
+
524
+ b = self.sdf._get_domain_bounds()
525
+ minb = torch.minimum(b[0], b[1])
526
+ maxb = torch.maximum(b[0], b[1])
527
+
528
+ corners = torch.stack(
529
+ [
530
+ torch.tensor([x, y, z], dtype=minb.dtype, device=minb.device)
531
+ for x in (minb[0], maxb[0])
532
+ for y in (minb[1], maxb[1])
533
+ for z in (minb[2], maxb[2])
534
+ ],
535
+ dim=0,
536
+ )
537
+
538
+ axis = self.axis.detach().to(device=minb.device, dtype=minb.dtype)
539
+ axis_unit = self._normalize_axis(axis)
540
+ base = self.base_point.detach().to(device=minb.device, dtype=minb.dtype)
541
+ start = self.start_angle.detach().to(device=minb.device, dtype=minb.dtype)
542
+ end = self.end_angle.detach().to(device=minb.device, dtype=minb.dtype)
543
+
544
+ step = (end - start) / self.count
545
+ angles = start + step * torch.arange(
546
+ self.count, device=minb.device, dtype=minb.dtype
547
+ )
548
+
549
+ centered = corners - base
550
+ rotated = self._rotate_about_axis(centered, axis_unit, angles)
551
+ rotated_world = rotated + base.view(1, 1, 3)
552
+
553
+ lower = torch.min(rotated_world.reshape(-1, 3), dim=0).values
554
+ upper = torch.max(rotated_world.reshape(-1, 3), dim=0).values
555
+ return torch.stack([lower, upper], dim=0)
556
+
557
+
558
+ class RevolveSDF(SDFBase):
559
+ """Revolve a 2D profile around an axis to create a 3D surface of revolution.
560
+
561
+ Takes a 2D SDF profile and rotates it around a specified axis to generate
562
+ a 3D axisymmetric object. The 2D profile is defined in a plane containing
563
+ the rotation axis.
564
+
565
+ Parameters
566
+ ----------
567
+ sdf_2d : SDFBase
568
+ A 2D SDF (geometric_dim=2) representing the profile to revolve.
569
+ The profile should be in the plane where the first coordinate is
570
+ the radial distance from the axis, and the second coordinate is
571
+ the height along the axis.
572
+ axis : torch.Tensor, default [0, 0, 1]
573
+ The 3D axis vector to revolve around. Must be one of the principal
574
+ axes: [1,0,0] (X-axis), [0,1,0] (Y-axis), or [0,0,1] (Z-axis).
575
+ The 2D profile's first coordinate becomes the radial distance from
576
+ this axis.
577
+ offset : float, default 0.0
578
+ Radial offset from the axis. Positive values move the profile away
579
+ from the axis, creating a hole in the center. Use this to create
580
+ hollow objects or to position the profile at a specific radius.
581
+
582
+ Raises
583
+ ------
584
+ ValueError
585
+ If sdf_2d is not a 2D SDF, or if axis is not a principal axis.
586
+
587
+ Examples
588
+ --------
589
+ >>> # Create a sphere by revolving a circle around Z-axis
590
+ >>> circle = CircleSDF(center=[0.5, 0], radius=0.5)
591
+ >>> RevolveSDF(circle, axis=[0, 0, 1])
592
+ >>>
593
+ >>> # Create a torus by revolving a circle with offset
594
+ >>> circle = CircleSDF(center=[1.0, 0], radius=0.2)
595
+ >>> RevolveSDF(circle, axis=[0, 0, 1])
596
+ >>>
597
+ >>> # Revolve around X-axis instead
598
+ >>> RevolveSDF(circle, axis=[1, 0, 0])
599
+
600
+ Notes
601
+ -----
602
+ - The 2D profile's first coordinate (x) represents radial distance from axis
603
+ - The 2D profile's second coordinate (y) represents height along the axis
604
+ - Only principal axes ([1,0,0], [0,1,0], [0,0,1]) are supported
605
+ - The profile should be positioned appropriately for the desired shape
606
+ - Use offset to create holes or position the profile away from the axis
607
+ """
608
+
609
+ def __init__(
610
+ self,
611
+ sdf_2d: SDFBase,
612
+ axis=torch.tensor([0, 0, 1], dtype=torch.float32),
613
+ offset=0.0,
614
+ ):
615
+ """Initialize RevolveSDF with a 2D profile.
616
+
617
+ Parameters
618
+ ----------
619
+ sdf_2d : SDFBase
620
+ 2D SDF profile to revolve.
621
+ axis : torch.Tensor, default [0, 0, 1]
622
+ Axis of revolution (must be principal axis).
623
+ offset : float, default 0.0
624
+ Radial offset from the axis.
625
+ """
626
+ super().__init__()
627
+ if sdf_2d.geometric_dim != 2:
628
+ raise ValueError("RevolveSDF requires a 2D SDF")
629
+ self.sdf_2d = sdf_2d
630
+ self.axis = torch.nn.Parameter(torch.as_tensor(axis, dtype=torch.float32))
631
+ self.offset = torch.nn.Parameter(torch.as_tensor(offset, dtype=torch.float32))
632
+
633
+ def _compute(self, queries: torch.Tensor) -> torch.Tensor:
634
+ offset = self.offset.to(device=queries.device, dtype=queries.dtype)
635
+ axis = self.axis.to(device=queries.device, dtype=queries.dtype)
636
+
637
+ if torch.all(
638
+ axis == torch.tensor([0, 0, 1], device=queries.device, dtype=queries.dtype)
639
+ ):
640
+ # Use x,y plane distance as radius, z as height in 2D space
641
+ radius = torch.sqrt(queries[:, 0] ** 2 + queries[:, 1] ** 2) - offset
642
+ pts_2d = torch.stack([radius, queries[:, 2]], dim=1)
643
+ elif torch.all(
644
+ axis == torch.tensor([1, 0, 0], device=queries.device, dtype=queries.dtype)
645
+ ):
646
+ # Use y,z plane
647
+ radius = torch.sqrt(queries[:, 1] ** 2 + queries[:, 2] ** 2) - offset
648
+ pts_2d = torch.stack([radius, queries[:, 0]], dim=1)
649
+ elif torch.all(
650
+ axis == torch.tensor([0, 1, 0], device=queries.device, dtype=queries.dtype)
651
+ ):
652
+ # Use x,z plane
653
+ radius = torch.sqrt(queries[:, 0] ** 2 + queries[:, 2] ** 2) - offset
654
+ pts_2d = torch.stack([radius, queries[:, 1]], dim=1)
655
+ else:
656
+ raise ValueError(
657
+ f"Invalid axis: {self.axis}. Must be [1,0,0], [0,1,0], or [0,0,1]"
658
+ )
659
+
660
+ return self.sdf_2d._compute(pts_2d)
661
+
662
+ def _get_domain_bounds(self) -> torch.Tensor:
663
+ b = self.sdf_2d._get_domain_bounds()
664
+ # The 2D profile's first coordinate is the radial distance from the axis.
665
+ # When revolved, this creates a full circle, so x/y bounds are symmetric.
666
+ max_radius = torch.maximum(b[0, 0].abs(), b[1, 0].abs())
667
+ return torch.stack(
668
+ [
669
+ torch.stack([-max_radius, -max_radius, b[0, 1]]),
670
+ torch.stack([max_radius, max_radius, b[1, 1]]),
671
+ ]
672
+ )
673
+
674
+
675
+ def cubic_bezier_distance(p, control_points, samples=500, chunk_size=1000, refine=True):
676
+ """
677
+ Compute distance from point p to cubic bezier curve using sampling with refinement.
678
+
679
+ Samples the curve at many points and finds the closest one, then refines locally.
680
+
681
+ Args:
682
+ p: Query point(s), shape (N, 3)
683
+ control_points: Control points tensor, shape (4, 3)
684
+ samples: Number of samples along the curve
685
+ chunk_size: Number of query points to process at a time (for memory efficiency)
686
+ refine: Whether to refine the minimum with local optimization
687
+
688
+ Returns:
689
+ Tuple of (dist, t, closest_point) where:
690
+ - dist: distance to curve, shape (N, 1)
691
+ - t: parameter value at closest point, shape (N, 1)
692
+ - closest_point: closest point on curve, shape (N, 3)
693
+ """
694
+ if p.dim() == 1:
695
+ p = p.unsqueeze(0)
696
+
697
+ # Move control points to same device as p
698
+ control_points = control_points.to(device=p.device, dtype=p.dtype)
699
+
700
+ p0, p1, p2, p3 = control_points
701
+
702
+ # Sample the curve once (shared across all queries)
703
+ t_samples = torch.linspace(0, 1, samples, device=p.device, dtype=p.dtype)
704
+ u_samples = 1 - t_samples
705
+
706
+ # Evaluate cubic bezier at all sample points
707
+ t_vec = t_samples.unsqueeze(1) # (samples, 1)
708
+ u_vec = u_samples.unsqueeze(1) # (samples, 1)
709
+
710
+ curve_points = (
711
+ (u_vec**3) * p0
712
+ + (3 * u_vec**2 * t_vec) * p1
713
+ + (3 * u_vec * t_vec**2) * p2
714
+ + (t_vec**3) * p3
715
+ ) # (samples, 3)
716
+
717
+ # Initialize output tensors
718
+ n_points = p.shape[0]
719
+ dtype = p.dtype
720
+ device = p.device
721
+
722
+ min_dists = torch.full((n_points, 1), float("inf"), dtype=dtype, device=device)
723
+ min_idx = torch.zeros(n_points, dtype=torch.long, device=device)
724
+
725
+ # Process in chunks to reduce memory
726
+ for i in range(0, n_points, chunk_size):
727
+ end_idx = min(i + chunk_size, n_points)
728
+ chunk = p[i:end_idx] # (chunk_size, 3)
729
+
730
+ # Compute distances for this chunk
731
+ chunk_expanded = chunk.unsqueeze(1) # (chunk_size, 1, 3)
732
+ curve_expanded = curve_points.unsqueeze(0) # (1, samples, 3)
733
+
734
+ diff = chunk_expanded - curve_expanded # (chunk_size, samples, 3)
735
+ dists_chunk = torch.linalg.norm(diff, dim=2) # (chunk_size, samples)
736
+
737
+ # Find minimum for this chunk
738
+ min_dists_chunk, min_idx_chunk = torch.min(
739
+ dists_chunk, dim=1
740
+ ) # (chunk_size), (chunk_size)
741
+
742
+ min_dists[i:end_idx] = min_dists_chunk.unsqueeze(1)
743
+ min_idx[i:end_idx] = min_idx_chunk
744
+
745
+ # Get initial best t and points
746
+ t_closest = t_samples[min_idx].unsqueeze(1) # (N, 1)
747
+ point_closest = curve_points[min_idx] # (N, 3)
748
+
749
+ # Local refinement around the minimum (optional)
750
+ if refine:
751
+ # Process refinement in chunks as well to save memory
752
+ for i in range(0, n_points, chunk_size):
753
+ end_idx = min(i + chunk_size, n_points)
754
+ chunk = p[i:end_idx]
755
+ t_chunk_base = t_closest[i:end_idx]
756
+
757
+ # Create refined samples around each minimum for this chunk
758
+ refine_points = 20
759
+ refine_delta = 0.02 # Larger refinement window
760
+
761
+ t_refine_base = t_chunk_base.squeeze(1) # (chunk,)
762
+ t_refine_start = torch.clamp(t_refine_base - refine_delta, 0, 1)
763
+ t_refine_width = (
764
+ torch.clamp(t_refine_base + refine_delta, 0, 1) - t_refine_start
765
+ )
766
+
767
+ min_dists_chunk = min_dists[i:end_idx].clone()
768
+ min_t_chunk = t_chunk_base.clone()
769
+
770
+ # Evaluate at multiple refinement points for this chunk
771
+ for j in range(refine_points + 1):
772
+ t_refine = t_refine_start + (t_refine_width * j / refine_points)
773
+ t_refine_exp = t_refine.unsqueeze(1) # (chunk, 1)
774
+
775
+ # Evaluate bezier at this t
776
+ u_val = 1 - t_refine_exp
777
+ curve_at_t = (
778
+ (u_val**3) * p0
779
+ + (3 * u_val**2 * t_refine_exp) * p1
780
+ + (3 * u_val * t_refine_exp**2) * p2
781
+ + (t_refine_exp**3) * p3
782
+ )
783
+
784
+ # Compute distances
785
+ diff = chunk - curve_at_t # (chunk, 3)
786
+ dists = torch.linalg.norm(diff, dim=1, keepdim=True)
787
+
788
+ # Update minimum if better
789
+ mask = dists < min_dists_chunk
790
+ min_dists_chunk = torch.where(mask, dists, min_dists_chunk)
791
+ min_t_chunk = torch.where(mask, t_refine_exp, min_t_chunk)
792
+
793
+ # Update global results with refined chunk results
794
+ min_dists[i:end_idx] = min_dists_chunk
795
+ t_closest[i:end_idx] = min_t_chunk
796
+
797
+ # Recompute closest points with refined t values
798
+ t_closest_exp = t_closest
799
+ point_closest = (
800
+ (1 - t_closest_exp) ** 3 * p0
801
+ + 3 * (1 - t_closest_exp) ** 2 * t_closest_exp * p1
802
+ + 3 * (1 - t_closest_exp) * t_closest_exp**2 * p2
803
+ + t_closest_exp**3 * p3
804
+ )
805
+
806
+ return min_dists, t_closest, point_closest
807
+
808
+
809
+ class SweepSDF(SDFBase):
810
+ """Sweep a 2D profile along a cubic bezier curve with flat end caps."""
811
+
812
+ def __init__(
813
+ self,
814
+ profile_sdf: SDFBase,
815
+ trajectory,
816
+ bezier_samples=500,
817
+ cap_ends=True,
818
+ chunk_size=1000,
819
+ refine=True,
820
+ ):
821
+ super().__init__()
822
+ assert profile_sdf.geometric_dim == 2, "SweepSDF requires a 2D profile SDF"
823
+ self.profile_sdf = profile_sdf
824
+ self.trajectory = trajectory
825
+ self.bezier_samples = bezier_samples
826
+ self.cap_ends = cap_ends
827
+ self.chunk_size = chunk_size
828
+ self.refine = refine
829
+
830
+ def _compute(self, queries: torch.Tensor) -> torch.Tensor:
831
+ # Ensure queries has shape (N, 3)
832
+ original_shape = queries.shape
833
+ if queries.dim() == 1:
834
+ queries = queries.unsqueeze(0)
835
+
836
+ control_points = self.trajectory.control_points
837
+ device = queries.device
838
+
839
+ # Move control points to correct device
840
+ control_points = control_points.to(device=device, dtype=queries.dtype)
841
+
842
+ n_points = queries.shape[0]
843
+
844
+ # Initialize output
845
+ result = torch.zeros(n_points, 1, dtype=queries.dtype, device=device)
846
+
847
+ # Process in chunks to reduce memory
848
+ for i in range(0, n_points, self.chunk_size):
849
+ end_idx = min(i + self.chunk_size, n_points)
850
+ chunk_queries = queries[i:end_idx] # (chunk, 3)
851
+
852
+ # Find closest point and parameter t on bezier for this chunk
853
+ dist_to_curve, t, closest_point = cubic_bezier_distance(
854
+ chunk_queries,
855
+ control_points,
856
+ self.bezier_samples,
857
+ chunk_size=self.chunk_size,
858
+ refine=self.refine,
859
+ )
860
+
861
+ # Compute tangent at closest point
862
+ t_values = t[:, 0:1]
863
+ u = 1 - t_values
864
+ p0, p1, p2, p3 = control_points
865
+
866
+ tangent_per_point = (
867
+ (3 * u**2) * (p1 - p0).unsqueeze(0)
868
+ + (6 * u * t_values) * (p2 - p1).unsqueeze(0)
869
+ + (3 * t_values**2) * (p3 - p2).unsqueeze(0)
870
+ )
871
+
872
+ tangent_norms = torch.linalg.norm(tangent_per_point, dim=1, keepdim=True)
873
+ tangent = tangent_per_point / (tangent_norms + 1e-10)
874
+
875
+ # Compute normal using curvature (second derivative)
876
+ curvature_per_point = (6 * u) * (p2 - 2 * p1 + p0).unsqueeze(0) + (
877
+ 6 * t_values
878
+ ) * (p3 - 2 * p2 + p1).unsqueeze(0)
879
+
880
+ normal = torch.zeros_like(chunk_queries)
881
+ curvature_norms = torch.linalg.norm(
882
+ curvature_per_point, dim=1, keepdim=True
883
+ )
884
+ has_curvature = curvature_norms.squeeze(1) > 1e-10
885
+
886
+ if has_curvature.any():
887
+ normal_curv = curvature_per_point / (curvature_norms + 1e-10)
888
+ proj = torch.sum(normal_curv * tangent, dim=1, keepdim=True) * tangent
889
+ normal_curv = normal_curv - proj
890
+ normal_curv_norms = torch.linalg.norm(normal_curv, dim=1, keepdim=True)
891
+ normal_curv = normal_curv / (normal_curv_norms + 1e-10)
892
+ normal = torch.where(
893
+ has_curvature.unsqueeze(1).expand_as(normal), normal_curv, normal
894
+ )
895
+
896
+ no_curvature = ~has_curvature
897
+ if no_curvature.any():
898
+ try_normal = torch.tensor(
899
+ [0.0, 1.0, 0.0], device=device, dtype=chunk_queries.dtype
900
+ )
901
+ try_normal = try_normal.unsqueeze(0).expand(chunk_queries.shape[0], -1)
902
+
903
+ dot_with_tangent = torch.sum(tangent * try_normal, dim=1)
904
+
905
+ parallel_to_y = torch.abs(dot_with_tangent) > 0.9
906
+ n_base = torch.where(
907
+ parallel_to_y.unsqueeze(1).expand_as(try_normal),
908
+ torch.tensor(
909
+ [1.0, 0.0, 0.0], device=device, dtype=chunk_queries.dtype
910
+ )
911
+ .unsqueeze(0)
912
+ .expand(chunk_queries.shape[0], -1),
913
+ try_normal,
914
+ )
915
+
916
+ proj = torch.sum(n_base * tangent, dim=1, keepdim=True) * tangent
917
+ n_perp = n_base - proj
918
+ n_perp_norms = torch.linalg.norm(n_perp, dim=1, keepdim=True)
919
+ n_perp = n_perp / (n_perp_norms + 1e-10)
920
+
921
+ normal = torch.where(
922
+ no_curvature.unsqueeze(1).expand_as(normal), n_perp, normal
923
+ )
924
+
925
+ # Compute binormal
926
+ binormal = torch.linalg.cross(tangent, normal, dim=1)
927
+ binormal_norm = torch.linalg.norm(binormal, dim=1, keepdim=True)
928
+ binormal = binormal / (binormal_norm + 1e-10)
929
+
930
+ # Vector from closest point to query
931
+ vec_to_query = chunk_queries - closest_point
932
+
933
+ # Project onto normal and binormal to get profile coordinates
934
+ coord_n = torch.sum(vec_to_query * normal, dim=1, keepdim=True)
935
+ coord_b = torch.sum(vec_to_query * binormal, dim=1, keepdim=True)
936
+
937
+ # Profile SDF expects 2D points (u, v)
938
+ pts_2d = torch.cat([coord_n, coord_b], dim=1)
939
+
940
+ # Evaluate profile SDF
941
+ profile_dist = self.profile_sdf._compute(pts_2d)
942
+
943
+ chunk_result = profile_dist
944
+
945
+ # Add end caps if requested
946
+ if self.cap_ends:
947
+ start_point = control_points[0]
948
+ end_point = control_points[3]
949
+
950
+ # Tangent at t=0: 3(P1 - P0)
951
+ start_tangent = 3 * (control_points[1] - control_points[0])
952
+ start_tangent_norm = torch.linalg.norm(start_tangent)
953
+ if start_tangent_norm > 1e-10:
954
+ start_tangent = start_tangent / start_tangent_norm
955
+
956
+ # Tangent at t=1: 3(P3 - P2)
957
+ end_tangent = 3 * (control_points[3] - control_points[2])
958
+ end_tangent_norm = torch.linalg.norm(end_tangent)
959
+ if end_tangent_norm > 1e-10:
960
+ end_tangent = end_tangent / end_tangent_norm
961
+
962
+ dist_to_start_plane = torch.sum(
963
+ (chunk_queries - start_point) * start_tangent, dim=1, keepdim=True
964
+ )
965
+ start_cap_dist = -dist_to_start_plane
966
+
967
+ dist_to_end_plane = torch.sum(
968
+ (chunk_queries - end_point) * end_tangent, dim=1, keepdim=True
969
+ )
970
+ end_cap_dist = dist_to_end_plane
971
+
972
+ chunk_result = torch.maximum(chunk_result, start_cap_dist)
973
+ chunk_result = torch.maximum(chunk_result, end_cap_dist)
974
+
975
+ result[i:end_idx] = chunk_result
976
+
977
+ return result
978
+
979
+ def _get_domain_bounds(self) -> torch.Tensor:
980
+ cp = self.trajectory.control_points
981
+ curve_min = cp.min(dim=0).values
982
+ curve_max = cp.max(dim=0).values
983
+
984
+ profile_bounds = self.profile_sdf._get_domain_bounds()
985
+ profile_radius = max(abs(profile_bounds[0, 0]), abs(profile_bounds[1, 0]))
986
+
987
+ lower = curve_min - profile_radius
988
+ upper = curve_max + profile_radius
989
+
990
+ return torch.stack([lower, upper])