@mat3ra/made 2024.6.29-0 → 2024.7.2-0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mat3ra/made",
3
- "version": "2024.6.29-0",
3
+ "version": "2024.7.2-0",
4
4
  "description": "MAterials DEsign library",
5
5
  "scripts": {
6
6
  "lint": "eslint --cache src/js tests/js && prettier --write src/js tests/js",
@@ -1,4 +1,4 @@
1
- from typing import List, Optional
1
+ from typing import Callable, List, Optional
2
2
 
3
3
  import numpy as np
4
4
 
@@ -198,3 +198,34 @@ def get_atom_indices_within_radius_pbc(
198
198
 
199
199
  selected_indices = [site.index for site in sites_within_radius]
200
200
  return selected_indices
201
+
202
+
203
+ def get_atom_indices_with_condition_on_coordinates(
204
+ material: Material,
205
+ condition: Callable[[List[float]], bool],
206
+ use_cartesian_coordinates: bool = False,
207
+ ) -> List[int]:
208
+ """
209
+ Select atoms whose coordinates satisfy the given condition.
210
+
211
+ Args:
212
+ material (Material): Material object
213
+ condition (Callable[List[float], bool]): Function that checks if coordinates satisfy the condition.
214
+ use_cartesian (bool): Whether to use Cartesian coordinates for the condition evaluation.
215
+
216
+ Returns:
217
+ List[int]: List of indices of atoms whose coordinates satisfy the condition.
218
+ """
219
+ new_material = material.clone()
220
+ if use_cartesian_coordinates:
221
+ new_basis = new_material.basis
222
+ new_basis.to_cartesian()
223
+ new_material.basis = new_basis
224
+ coordinates = new_material.basis.coordinates.to_array_of_values_with_ids()
225
+
226
+ selected_indices = []
227
+ for coord in coordinates:
228
+ if condition(coord.value):
229
+ selected_indices.append(coord.id)
230
+
231
+ return selected_indices
@@ -1,11 +1,18 @@
1
- from typing import List, Union
1
+ from typing import Callable, List, Optional, Union
2
2
 
3
+ import numpy as np
3
4
  from mat3ra.made.material import Material
4
5
 
5
- from .analyze import get_atom_indices_within_layer_by_atom_index, get_atom_indices_within_radius_pbc
6
+ from .analyze import get_atom_indices_with_condition_on_coordinates, get_atom_indices_within_radius_pbc
6
7
  from .convert import decorator_convert_material_args_kwargs_to_structure
7
8
  from .third_party import PymatgenSpacegroupAnalyzer, PymatgenStructure
8
- from .utils import translate_to_bottom_pymatgen_structure
9
+ from .utils import (
10
+ is_coordinate_in_box,
11
+ is_coordinate_in_cylinder,
12
+ is_coordinate_in_triangular_prism,
13
+ is_coordinate_within_layer,
14
+ translate_to_bottom_pymatgen_structure,
15
+ )
9
16
 
10
17
 
11
18
  def filter_by_label(material: Material, label: Union[int, str]) -> Material:
@@ -81,30 +88,73 @@ def filter_material_by_ids(material: Material, ids: List[int], invert: bool = Fa
81
88
  return new_material
82
89
 
83
90
 
91
+ def filter_by_condition_on_coordinates(
92
+ material: Material,
93
+ condition: Callable[[List[float]], bool],
94
+ use_cartesian_coordinates: bool = False,
95
+ invert_selection: bool = False,
96
+ ) -> Material:
97
+ """
98
+ Filter atoms based on a condition on their coordinates.
99
+
100
+ Args:
101
+ material (Material): The material object to filter.
102
+ condition (Callable): The condition on coordinate function.
103
+ use_cartesian_coordinates (bool): Whether to use cartesian coordinates.
104
+ invert_selection (bool): Whether to invert the selection.
105
+
106
+ Returns:
107
+ Material: The filtered material object.
108
+ """
109
+ new_material = material.clone()
110
+ ids = get_atom_indices_with_condition_on_coordinates(
111
+ material,
112
+ condition,
113
+ use_cartesian_coordinates=use_cartesian_coordinates,
114
+ )
115
+
116
+ new_material = filter_material_by_ids(new_material, ids, invert=invert_selection)
117
+ return new_material
118
+
119
+
84
120
  def filter_by_layers(
85
- material: Material, central_atom_id: int, layer_thickness: float, invert: bool = False
121
+ material: Material,
122
+ center_coordinate: List[float] = [0, 0, 0],
123
+ central_atom_id: Optional[int] = None,
124
+ layer_thickness: float = 1.0,
125
+ invert_selection: bool = False,
86
126
  ) -> Material:
87
127
  """
88
128
  Filter out atoms within a specified layer thickness of a central atom along c-vector direction.
89
129
 
90
130
  Args:
91
131
  material (Material): The material object to filter.
132
+ center_coordinate (List[float]): Index of the central atom.
92
133
  central_atom_id (int): Index of the central atom.
93
134
  layer_thickness (float): Thickness of the layer in angstroms.
94
- invert (bool): Whether to invert the selection.
135
+ invert_selection (bool): Whether to invert the selection.
95
136
 
96
137
  Returns:
97
138
  Material: The filtered material object.
98
139
  """
99
- ids = get_atom_indices_within_layer_by_atom_index(
100
- material,
101
- central_atom_id,
102
- layer_thickness,
103
- )
104
- return filter_material_by_ids(material, ids, invert=invert)
140
+ if central_atom_id is not None:
141
+ center_coordinate = material.basis.coordinates.get_element_value_by_index(central_atom_id)
142
+ vectors = material.lattice.vectors
143
+ direction_vector = np.array(vectors[2])
144
+
145
+ def condition(coordinate):
146
+ return is_coordinate_within_layer(coordinate, center_coordinate, direction_vector, layer_thickness)
147
+
148
+ return filter_by_condition_on_coordinates(material, condition, invert_selection=invert_selection)
105
149
 
106
150
 
107
- def filter_by_sphere(material: Material, central_atom_id: int, radius: float, invert: bool = False) -> Material:
151
+ def filter_by_sphere(
152
+ material: Material,
153
+ center_coordinate: List[float] = [0, 0, 0],
154
+ central_atom_id: Optional[int] = None,
155
+ radius: float = 1,
156
+ invert: bool = False,
157
+ ) -> Material:
108
158
  """
109
159
  Filter out atoms within a specified radius of a central atom considering periodic boundary conditions.
110
160
 
@@ -120,6 +170,156 @@ def filter_by_sphere(material: Material, central_atom_id: int, radius: float, in
120
170
  ids = get_atom_indices_within_radius_pbc(
121
171
  material=material,
122
172
  atom_index=central_atom_id,
173
+ position=center_coordinate,
123
174
  radius=radius,
124
175
  )
125
176
  return filter_material_by_ids(material, ids, invert=invert)
177
+
178
+
179
+ def filter_by_circle_projection(
180
+ material: Material,
181
+ x: float = 0.5,
182
+ y: float = 0.5,
183
+ r: float = 0.25,
184
+ use_cartesian_coordinates: bool = False,
185
+ invert_selection: bool = False,
186
+ ) -> Material:
187
+ """
188
+ Get material with atoms that are within or outside an XY circle projection.
189
+
190
+ Args:
191
+ material (Material): The material object to filter.
192
+ x (float): The x-coordinate of the circle center.
193
+ y (float): The y-coordinate of the circle center.
194
+ r (float): The radius of the circle.
195
+ use_cartesian_coordinates (bool): Whether to use cartesian coordinates
196
+ invert_selection (bool): Whether to invert the selection.
197
+
198
+ Returns:
199
+ Material: The filtered material object.
200
+ """
201
+
202
+ def condition(coordinate):
203
+ return is_coordinate_in_cylinder(coordinate, [x, y, 0], r, min_z=0, max_z=1)
204
+
205
+ return filter_by_condition_on_coordinates(
206
+ material, condition, use_cartesian_coordinates=use_cartesian_coordinates, invert_selection=invert_selection
207
+ )
208
+
209
+
210
+ def filter_by_cylinder(
211
+ material: Material,
212
+ center_position: List[float] = [0.5, 0.5],
213
+ min_z: float = 0,
214
+ max_z: float = 1,
215
+ radius: float = 0.25,
216
+ use_cartesian_coordinates: bool = False,
217
+ invert_selection: bool = False,
218
+ ) -> Material:
219
+ """
220
+ Get material with atoms that are within or outside a cylinder.
221
+
222
+ Args:
223
+ material (Material): The material object to filter.
224
+ center_position (List[float]): The coordinates of the center position.
225
+ radius (float): The radius of the cylinder.
226
+ min_z (float): Lower limit of z-coordinate.
227
+ max_z (float): Upper limit of z-coordinate.
228
+ use_cartesian_coordinates (bool): Whether to use cartesian coordinates
229
+ invert_selection (bool): Whether to invert the selection.
230
+
231
+ Returns:
232
+ Material: The filtered material object.
233
+ """
234
+
235
+ def condition(coordinate):
236
+ return is_coordinate_in_cylinder(coordinate, center_position, radius, min_z, max_z)
237
+
238
+ return filter_by_condition_on_coordinates(
239
+ material, condition, use_cartesian_coordinates=use_cartesian_coordinates, invert_selection=invert_selection
240
+ )
241
+
242
+
243
+ def filter_by_rectangle_projection(
244
+ material: Material,
245
+ min_coordinate: List[float] = [0, 0],
246
+ max_coordinate: List[float] = [1, 1],
247
+ use_cartesian_coordinates: bool = False,
248
+ invert_selection: bool = False,
249
+ ) -> Material:
250
+ """
251
+ Get material with atoms that are within or outside an XY rectangle projection.
252
+
253
+ Args:
254
+ material (Material): The material object to filter.
255
+ min_coordinate (List[float]): The minimum coordinate of the rectangle.
256
+ max_coordinate (List[float]): The maximum coordinate of the rectangle.
257
+ use_cartesian_coordinates (bool): Whether to use cartesian coordinates
258
+ invert_selection (bool): Whether to invert the selection.
259
+
260
+ Returns:
261
+ Material: The filtered material object.
262
+ """
263
+ min_coordinate = min_coordinate[:2] + [0]
264
+ max_coordinate = max_coordinate[:2] + [1]
265
+
266
+ def condition(coordinate):
267
+ return is_coordinate_in_box(coordinate, min_coordinate, max_coordinate)
268
+
269
+ return filter_by_condition_on_coordinates(
270
+ material, condition, use_cartesian_coordinates=use_cartesian_coordinates, invert_selection=invert_selection
271
+ )
272
+
273
+
274
+ def filter_by_box(
275
+ material: Material,
276
+ min_coordinate: List[float] = [0.0, 0.0, 0.0],
277
+ max_coordinate: List[float] = [1.0, 1.0, 1.0],
278
+ use_cartesian_coordinates: bool = False,
279
+ invert_selection: bool = False,
280
+ ) -> Material:
281
+ """
282
+ Get material with atoms that are within or outside an XYZ box.
283
+ """
284
+
285
+ def condition(coordinate):
286
+ return is_coordinate_in_box(coordinate, min_coordinate, max_coordinate)
287
+
288
+ return filter_by_condition_on_coordinates(
289
+ material, condition, use_cartesian_coordinates=use_cartesian_coordinates, invert_selection=invert_selection
290
+ )
291
+
292
+
293
+ def filter_by_triangle_projection(
294
+ material: Material,
295
+ coordinate_1: List[float] = [0, 0],
296
+ coordinate_2: List[float] = [0, 1],
297
+ coordinate_3: List[float] = [1, 0],
298
+ min_z: float = 0,
299
+ max_z: float = 1,
300
+ use_cartesian_coordinates: bool = False,
301
+ invert_selection: bool = False,
302
+ ) -> Material:
303
+ """
304
+ Get material with atoms that are within or outside a prism formed by triangle projection.
305
+
306
+ Args:
307
+ material (Material): The material object to filter.
308
+ coordinate_1 (List[float]): The coordinate of the first vertex.
309
+ coordinate_2 (List[float]): The coordinate of the second vertex.
310
+ coordinate_3 (List[float]): The coordinate of the third vertex.
311
+ min_z (float): Lower limit of z-coordinate.
312
+ max_z (float): Upper limit of z-coordinate.
313
+ use_cartesian_coordinates (bool): Whether to use cartesian coordinates
314
+ invert_selection (bool): Whether to invert the selection.
315
+
316
+ Returns:
317
+ Material: The filtered material object.
318
+ """
319
+
320
+ def condition(coordinate):
321
+ return is_coordinate_in_triangular_prism(coordinate, coordinate_1, coordinate_2, coordinate_3, min_z, max_z)
322
+
323
+ return filter_by_condition_on_coordinates(
324
+ material, condition, use_cartesian_coordinates=use_cartesian_coordinates, invert_selection=invert_selection
325
+ )
@@ -96,3 +96,120 @@ def get_norm(vector: List[float]) -> float:
96
96
  float: The norm of the vector.
97
97
  """
98
98
  return float(np.linalg.norm(vector))
99
+
100
+
101
+ # Condition functions:
102
+
103
+
104
+ def is_coordinate_in_cylinder(
105
+ coordinate: List[float], center_position: List[float], radius: float = 0.25, min_z: float = 0, max_z: float = 1
106
+ ) -> bool:
107
+ """
108
+ Check if a coordinate is inside a cylinder.
109
+ Args:
110
+ coordinate (List[float]): The coordinate to check.
111
+ center_position (List[float]): The coordinates of the center position.
112
+ min_z (float): Lower limit of z-coordinate.
113
+ max_z (float): Upper limit of z-coordinate.
114
+ radius (float): The radius of the cylinder.
115
+
116
+ Returns:
117
+ bool: True if the coordinate is inside the cylinder, False otherwise.
118
+ """
119
+ return (coordinate[0] - center_position[0]) ** 2 + (coordinate[1] - center_position[1]) ** 2 <= radius**2 and (
120
+ min_z <= coordinate[2] <= max_z
121
+ )
122
+
123
+
124
+ def is_coordinate_in_box(
125
+ coordinate: List[float], min_coordinate: List[float] = [0, 0, 0], max_coordinate: List[float] = [1, 1, 1]
126
+ ) -> bool:
127
+ """
128
+ Check if a coordinate is inside a box.
129
+ Args:
130
+ coordinate (List[float]): The coordinate to check.
131
+ min_coordinate (List[float]): The minimum coordinate of the box.
132
+ max_coordinate (List[float]): The maximum coordinate of the box.
133
+ Returns:
134
+ bool: True if the coordinate is inside the box, False otherwise.
135
+ """
136
+ x_min, y_min, z_min = min_coordinate
137
+ x_max, y_max, z_max = max_coordinate
138
+ return x_min <= coordinate[0] <= x_max and y_min <= coordinate[1] <= y_max and z_min <= coordinate[2] <= z_max
139
+
140
+
141
+ def is_coordinate_within_layer(
142
+ coordinate: List[float], center_position: List[float], direction_vector: List[float], layer_thickness: float
143
+ ) -> bool:
144
+ """
145
+ Checks if a coordinate's projection along a specified direction vector
146
+ is within a certain layer thickness centered around a given position.
147
+
148
+ Args:
149
+ coordinate (List[float]): The coordinate to check.
150
+ center_position (List[float]): The coordinates of the center position.
151
+ direction_vector (List[float]): The direction vector along which the layer thickness is defined.
152
+ layer_thickness (float): The thickness of the layer along the direction vector.
153
+
154
+ Returns:
155
+ bool: True if the coordinate is within the layer thickness, False otherwise.
156
+ """
157
+ direction_norm = np.array(direction_vector) / np.linalg.norm(direction_vector)
158
+ central_projection = np.dot(center_position, direction_norm)
159
+ layer_thickness_frac = layer_thickness / np.linalg.norm(direction_vector)
160
+
161
+ lower_bound = central_projection - layer_thickness_frac / 2
162
+ upper_bound = central_projection + layer_thickness_frac / 2
163
+
164
+ return lower_bound <= np.dot(coordinate, direction_norm) <= upper_bound
165
+
166
+
167
+ def is_coordinate_in_triangular_prism(
168
+ coordinate: List[float],
169
+ coordinate_1: List[float],
170
+ coordinate_2: List[float],
171
+ coordinate_3: List[float],
172
+ min_z: float = 0,
173
+ max_z: float = 1,
174
+ ) -> bool:
175
+ """
176
+ Check if a coordinate is inside a triangular prism.
177
+ Args:
178
+ coordinate (List[float]): The coordinate to check.
179
+ coordinate_1 (List[float]): The first coordinate of the triangle.
180
+ coordinate_2 (List[float]): The second coordinate of the triangle.
181
+ coordinate_3 (List[float]): The third coordinate of the triangle.
182
+ min_z (float): Lower limit of z-coordinate.
183
+ max_z (float): Upper limit of z-coordinate.
184
+
185
+ Returns:
186
+ bool: True if the coordinate is inside the triangular prism, False otherwise.
187
+ """
188
+ # convert to 3D coordinates at the origin XY plane
189
+ coordinate_1.extend([0] * (3 - len(coordinate_1)))
190
+ coordinate_2.extend([0] * (3 - len(coordinate_2)))
191
+ coordinate_3.extend([0] * (3 - len(coordinate_3)))
192
+
193
+ coordinate = np.array(coordinate)
194
+ v1 = np.array(coordinate_1)
195
+ v2 = np.array(coordinate_2)
196
+ v3 = np.array(coordinate_3)
197
+
198
+ v2_v1 = v2 - v1
199
+ v3_v1 = v3 - v1
200
+ coordinate_v1 = coordinate - v1
201
+
202
+ # Compute dot products for the barycentric coordinates
203
+ d00 = np.dot(v2_v1, v2_v1)
204
+ d01 = np.dot(v2_v1, v3_v1)
205
+ d11 = np.dot(v3_v1, v3_v1)
206
+ d20 = np.dot(coordinate_v1, v2_v1)
207
+ d21 = np.dot(coordinate_v1, v3_v1)
208
+
209
+ # Calculate barycentric coordinates
210
+ denom = d00 * d11 - d01 * d01
211
+ v = (d11 * d20 - d01 * d21) / denom
212
+ w = (d00 * d21 - d01 * d20) / denom
213
+ u = 1.0 - v - w
214
+
215
+ return (u >= 0) and (v >= 0) and (w >= 0) and (u + v + w <= 1) and (min_z <= coordinate[2] <= max_z)
@@ -7,8 +7,8 @@ from mat3ra.utils import assertion as assertion_utils
7
7
 
8
8
  ase_ni = bulk("Ni", "fcc", a=3.52, cubic=True)
9
9
  material = Material(from_ase(ase_ni))
10
- section = filter_by_layers(material, 0, 1.0)
11
- cavity = filter_by_layers(material, 0, 1.0, invert=True)
10
+ section = filter_by_layers(material, central_atom_id=0, layer_thickness=1.0)
11
+ cavity = filter_by_layers(material, central_atom_id=0, layer_thickness=1.0, invert_selection=True)
12
12
 
13
13
  # Change 0th element
14
14
  section.basis.elements.values[0] = "Ge"
@@ -1,7 +1,14 @@
1
1
  from ase.build import bulk
2
2
  from mat3ra.made.material import Material
3
3
  from mat3ra.made.tools.convert import from_ase
4
- from mat3ra.made.tools.modify import filter_by_label, filter_by_layers, filter_by_sphere
4
+ from mat3ra.made.tools.modify import (
5
+ filter_by_circle_projection,
6
+ filter_by_label,
7
+ filter_by_layers,
8
+ filter_by_rectangle_projection,
9
+ filter_by_sphere,
10
+ filter_by_triangle_projection,
11
+ )
5
12
  from mat3ra.utils import assertion as assertion_utils
6
13
 
7
14
  from .fixtures import SI_CONVENTIONAL_CELL
@@ -46,15 +53,15 @@ expected_basis_layers_cavity = {
46
53
 
47
54
 
48
55
  expected_basis_sphere_cluster = {
49
- "elements": [{"id": 0, "value": "Si"}],
50
- "coordinates": [{"id": 0, "value": [0.5, 0.0, 0.0]}],
56
+ "elements": [{"id": 2, "value": "Si"}],
57
+ "coordinates": [{"id": 2, "value": [0.5, 0.5, 0.5]}],
51
58
  **COMMON_PART,
52
59
  }
53
60
 
54
61
  expected_basis_sphere_cavity = {
55
62
  "elements": [
63
+ {"id": 0, "value": "Si"},
56
64
  {"id": 1, "value": "Si"},
57
- {"id": 2, "value": "Si"},
58
65
  {"id": 3, "value": "Si"},
59
66
  {"id": 4, "value": "Si"},
60
67
  {"id": 5, "value": "Si"},
@@ -62,8 +69,8 @@ expected_basis_sphere_cavity = {
62
69
  {"id": 7, "value": "Si"},
63
70
  ],
64
71
  "coordinates": [
72
+ {"id": 0, "value": [0.5, 0.0, 0.0]},
65
73
  {"id": 1, "value": [0.25, 0.25, 0.75]},
66
- {"id": 2, "value": [0.5, 0.5, 0.5]},
67
74
  {"id": 3, "value": [0.25, 0.75, 0.25]},
68
75
  {"id": 4, "value": [0.0, 0.0, 0.5]},
69
76
  {"id": 5, "value": [0.75, 0.25, 0.25]},
@@ -73,6 +80,9 @@ expected_basis_sphere_cavity = {
73
80
  **COMMON_PART,
74
81
  }
75
82
 
83
+ CRYSTAL_RADIUS = 0.25 # in crystal coordinates
84
+ CRYSTAL_CENTER_3D = [0.5, 0.5, 0.5] # in crystal coordinates
85
+
76
86
 
77
87
  def test_filter_by_label():
78
88
  substrate = bulk("Si", cubic=True)
@@ -89,15 +99,40 @@ def test_filter_by_label():
89
99
 
90
100
  def test_filter_by_layers():
91
101
  material = Material(SI_CONVENTIONAL_CELL)
92
- section = filter_by_layers(material, 0, 3.0)
93
- cavity = filter_by_layers(material, 0, 3.0, invert=True)
102
+ section = filter_by_layers(material=material, central_atom_id=0, layer_thickness=3.0)
103
+ cavity = filter_by_layers(material=material, central_atom_id=0, layer_thickness=3.0, invert_selection=True)
94
104
  assertion_utils.assert_deep_almost_equal(expected_basis_layers_section, section.basis.to_json())
95
105
  assertion_utils.assert_deep_almost_equal(expected_basis_layers_cavity, cavity.basis.to_json())
96
106
 
97
107
 
98
108
  def test_filter_by_sphere():
99
109
  material = Material(SI_CONVENTIONAL_CELL)
100
- cluster = filter_by_sphere(material, 0, 2.0)
101
- cavity = filter_by_sphere(material, 0, 2.0, invert=True)
110
+ cluster = filter_by_sphere(material, center_coordinate=CRYSTAL_CENTER_3D, radius=CRYSTAL_RADIUS)
111
+ cavity = filter_by_sphere(material, center_coordinate=CRYSTAL_CENTER_3D, radius=CRYSTAL_RADIUS, invert=True)
102
112
  assertion_utils.assert_deep_almost_equal(expected_basis_sphere_cluster, cluster.basis.to_json())
103
113
  assertion_utils.assert_deep_almost_equal(expected_basis_sphere_cavity, cavity.basis.to_json())
114
+
115
+
116
+ def test_filter_by_circle_projection():
117
+ material = Material(SI_CONVENTIONAL_CELL)
118
+ # Small cylinder in the middle of the cell containing the central atom will be removed -- the same as with sphere
119
+ section = filter_by_circle_projection(material, 0.5, 0.5, CRYSTAL_RADIUS)
120
+ cavity = filter_by_circle_projection(material, 0.5, 0.5, CRYSTAL_RADIUS, invert_selection=True)
121
+ assertion_utils.assert_deep_almost_equal(expected_basis_sphere_cluster, section.basis.to_json())
122
+ assertion_utils.assert_deep_almost_equal(expected_basis_sphere_cavity, cavity.basis.to_json())
123
+
124
+
125
+ def test_filter_by_rectangle_projection():
126
+ material = Material(SI_CONVENTIONAL_CELL)
127
+ # Default will contain all the atoms
128
+ section = filter_by_rectangle_projection(material)
129
+ assertion_utils.assert_deep_almost_equal(material.basis.to_json(), section.basis.to_json())
130
+
131
+
132
+ def test_filter_by_triangle_projection():
133
+ # Small prism in the middle of the cell containing the central atom will be removed -- the same as with sphere
134
+ material = Material(SI_CONVENTIONAL_CELL)
135
+ section = filter_by_triangle_projection(material, [0.4, 0.4], [0.4, 0.5], [0.5, 0.5])
136
+ cavity = filter_by_triangle_projection(material, [0.4, 0.4], [0.4, 0.5], [0.5, 0.5], invert_selection=True)
137
+ assertion_utils.assert_deep_almost_equal(expected_basis_sphere_cluster, section.basis.to_json())
138
+ assertion_utils.assert_deep_almost_equal(expected_basis_sphere_cavity, cavity.basis.to_json())