differt-core 0.10.0__tar.gz → 0.12.0__tar.gz

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 (48) hide show
  1. {differt_core-0.10.0 → differt_core-0.12.0}/Cargo.lock +2 -1
  2. {differt_core-0.10.0 → differt_core-0.12.0}/PKG-INFO +1 -1
  3. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/Cargo.toml +2 -1
  4. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/src/geometry/graph.rs +1 -3
  5. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/src/geometry/mesh.rs +94 -25
  6. differt_core-0.12.0/differt-core/src/geometry/scene.rs +361 -0
  7. {differt_core-0.10.0 → differt_core-0.12.0/differt-core}/src/geometry/sionna.rs +128 -9
  8. differt_core-0.12.0/differt-core/tests/geometry/test_core_scene.py +93 -0
  9. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/tests/geometry/test_graph.py +2 -29
  10. {differt_core-0.10.0 → differt_core-0.12.0}/python/differt_core/_differt_core/__init__.pyi +0 -2
  11. {differt_core-0.10.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/graph.pyi +0 -1
  12. {differt_core-0.10.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/mesh.pyi +4 -3
  13. differt_core-0.12.0/python/differt_core/_differt_core/geometry/scene.pyi +9 -0
  14. {differt_core-0.10.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/sionna.pyi +3 -1
  15. {differt_core-0.10.0 → differt_core-0.12.0}/python/differt_core/geometry/__init__.py +2 -4
  16. differt_core-0.12.0/python/differt_core/geometry/_mesh.py +6 -0
  17. differt_core-0.12.0/python/differt_core/geometry/_scene.py +5 -0
  18. {differt_core-0.10.0 → differt_core-0.12.0}/src/geometry/graph.rs +1 -3
  19. {differt_core-0.10.0 → differt_core-0.12.0}/src/geometry/mesh.rs +94 -25
  20. differt_core-0.12.0/src/geometry/scene.rs +361 -0
  21. {differt_core-0.10.0/differt-core → differt_core-0.12.0}/src/geometry/sionna.rs +128 -9
  22. differt_core-0.10.0/differt-core/src/geometry/scene.rs +0 -83
  23. differt_core-0.10.0/differt-core/tests/geometry/test_core_mesh.py +0 -11
  24. differt_core-0.10.0/differt-core/tests/geometry/test_core_scene.py +0 -11
  25. differt_core-0.10.0/python/differt_core/_differt_core/geometry/scene.pyi +0 -7
  26. differt_core-0.10.0/python/differt_core/geometry/_mesh.py +0 -28
  27. differt_core-0.10.0/python/differt_core/geometry/_scene.py +0 -27
  28. differt_core-0.10.0/src/geometry/scene.rs +0 -83
  29. {differt_core-0.10.0 → differt_core-0.12.0}/Cargo.toml +0 -0
  30. {differt_core-0.10.0 → differt_core-0.12.0}/LICENSE.md +0 -0
  31. {differt_core-0.10.0 → differt_core-0.12.0}/README.md +0 -0
  32. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/LICENSE.md +0 -0
  33. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/README.md +0 -0
  34. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/benches/bench_main.rs +0 -0
  35. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/benches/benchmarks/graph_iterators.rs +0 -0
  36. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/benches/benchmarks/mod.rs +0 -0
  37. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/src/geometry/mod.rs +0 -0
  38. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/src/lib.rs +0 -0
  39. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/tests/__init__.py +0 -0
  40. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/tests/geometry/__init__.py +0 -0
  41. {differt_core-0.10.0 → differt_core-0.12.0}/differt-core/tests/test_version.py +0 -0
  42. {differt_core-0.10.0 → differt_core-0.12.0}/pyproject.toml +0 -0
  43. {differt_core-0.10.0 → differt_core-0.12.0}/python/differt_core/__init__.py +0 -0
  44. {differt_core-0.10.0 → differt_core-0.12.0}/python/differt_core/geometry/_graph.py +0 -0
  45. {differt_core-0.10.0 → differt_core-0.12.0}/python/differt_core/geometry/_sionna.py +0 -0
  46. {differt_core-0.10.0 → differt_core-0.12.0}/python/differt_core/py.typed +0 -0
  47. {differt_core-0.10.0 → differt_core-0.12.0}/src/geometry/mod.rs +0 -0
  48. {differt_core-0.10.0 → differt_core-0.12.0}/src/lib.rs +0 -0
@@ -247,7 +247,7 @@ checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
247
247
 
248
248
  [[package]]
249
249
  name = "differt-core"
250
- version = "0.10.0"
250
+ version = "0.12.0"
251
251
  dependencies = [
252
252
  "criterion",
253
253
  "indexmap",
@@ -262,6 +262,7 @@ dependencies = [
262
262
  "quick-xml",
263
263
  "rstest",
264
264
  "serde",
265
+ "tempfile",
265
266
  "testing_logger",
266
267
  ]
267
268
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: differt-core
3
- Version: 0.10.0
3
+ Version: 0.12.0
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.11
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -19,6 +19,7 @@ serde = {version = "1.0", features = ["derive"]}
19
19
  criterion = "0.5.1"
20
20
  pyo3 = {version = "0.25", features = ["auto-initialize"]}
21
21
  rstest = "0.18.2"
22
+ tempfile = "3.20"
22
23
  testing_logger = "0.1.1"
23
24
 
24
25
  [features]
@@ -37,5 +38,5 @@ unexpected_cfgs = {level = "warn", check-cfg = ['cfg(tarpaulin_include)']}
37
38
  edition = "2021"
38
39
  name = "differt-core"
39
40
  rust-version = "1.78.0"
40
- version = "0.10.0"
41
+ version = "0.12.0"
41
42
  readme = "README.md"
@@ -1,5 +1,3 @@
1
- #![allow(unknown_lints, non_local_definitions)]
2
-
3
1
  use std::collections::VecDeque;
4
2
 
5
3
  use numpy::{
@@ -156,7 +154,7 @@ pub mod complete {
156
154
  ///
157
155
  /// Therefore, those iterators are equivalents:
158
156
  ///
159
- /// >>> from differt_core.rt import CompleteGraph, DiGraph
157
+ /// >>> from differt_core.geometry import CompleteGraph, DiGraph
160
158
  /// >>>
161
159
  /// >>> num_nodes, depth = 100, 5
162
160
  /// >>> complete_graph = CompleteGraph(num_nodes)
@@ -1,4 +1,8 @@
1
- use std::{fs::File, io::BufReader, path::PathBuf};
1
+ use std::{
2
+ fs::File,
3
+ io::BufReader,
4
+ path::{Path, PathBuf},
5
+ };
2
6
 
3
7
  use indexmap::IndexMap;
4
8
  use numpy::{PyArray1, PyArray2, ndarray::arr2};
@@ -207,7 +211,14 @@ impl Mesh {
207
211
  other.set_face_material(None);
208
212
  },
209
213
  (None, Some(_)) => {
214
+ // 'self' has no material of its own yet: fill placeholder (-1)
215
+ // entries for its existing triangles (same as the 'None' arm
216
+ // of 'set_face_material'), then adopt 'other's material names
217
+ // outright, since 'self' had none. 'other's existing
218
+ // face-material indices already correctly reference the
219
+ // just-adopted names, so they need no remapping.
210
220
  self.set_face_material(None);
221
+ self.material_names = std::mem::take(&mut other.material_names);
211
222
  },
212
223
  (Some(_), Some(_)) => {
213
224
  // We need to possibly renumber material indices.
@@ -273,22 +284,48 @@ impl Mesh {
273
284
  /// vertices.
274
285
  ///
275
286
  /// Args:
276
- /// file (str): The path to the Wavefront .obj file.
287
+ /// file (str | os.PathLike[str]): The path to the Wavefront .obj file.
277
288
  ///
278
289
  /// Returns:
279
290
  /// Mesh: The corresponding mesh containing only triangles.
280
291
  #[classmethod]
281
- pub(crate) fn load_obj(_: &Bound<'_, PyType>, filename: &str) -> PyResult<Self> {
282
- let input = BufReader::new(File::open(filename)?);
292
+ #[pyo3(name = "load_obj")]
293
+ pub(crate) fn py_load_obj(_: &Bound<'_, PyType>, file: PathBuf) -> PyResult<Self> {
294
+ Self::load_obj(&file)
295
+ }
296
+
297
+ /// Load a triangle mesh from a Stanford PLY .ply file.
298
+ ///
299
+ /// Currently, only vertices and triangles are loaded. Triangle normals
300
+ /// are ignored because they are computed with
301
+ /// :attr:`differt.geometry.Mesh.normals` using
302
+ /// JAX so that they can be differentiated with respect to triangle
303
+ /// vertices.
304
+ ///
305
+ /// Args:
306
+ /// file (str | os.PathLike[str]): The path to the Stanford PLY .ply file.
307
+ ///
308
+ /// Returns:
309
+ /// Mesh: The corresponding mesh containing only triangles.
310
+ #[classmethod]
311
+ #[pyo3(name = "load_ply")]
312
+ pub(crate) fn py_load_ply(_: &Bound<'_, PyType>, file: PathBuf) -> PyResult<Self> {
313
+ Self::load_ply(&file)
314
+ }
315
+ }
316
+
317
+ impl Mesh {
318
+ pub(crate) fn load_obj(file: &Path) -> PyResult<Self> {
319
+ let input = BufReader::new(File::open(file)?);
283
320
  let mut obj: RawObj = parse_obj(input).map_err(|err| {
284
321
  PyValueError::new_err(format!(
285
- "An error occurred while reading obj file {filename:#?}: {}",
322
+ "An error occurred while reading obj file {file:#?}: {}",
286
323
  err
287
324
  ))
288
325
  })?;
289
326
 
290
327
  for material_file in obj.material_libraries.iter_mut() {
291
- let mut path = PathBuf::from(filename);
328
+ let mut path = file.to_path_buf();
292
329
  path.set_file_name(&material_file);
293
330
  if let Some(path_str) = path.to_str() {
294
331
  *material_file = path_str.to_string();
@@ -298,29 +335,15 @@ impl Mesh {
298
335
  Ok(obj.into())
299
336
  }
300
337
 
301
- /// Load a triangle mesh from a Stanford PLY .ply file.
302
- ///
303
- /// Currently, only vertices and triangles are loaded. Triangle normals
304
- /// are ignored because they are computed with
305
- /// :attr:`differt.geometry.Mesh.normals` using
306
- /// JAX so that they can be differentiated with respect to triangle
307
- /// vertices.
308
- ///
309
- /// Args:
310
- /// file (str): The path to the Stanford PLY .ply file.
311
- ///
312
- /// Returns:
313
- /// Mesh: The corresponding mesh containing only triangles.
314
- #[classmethod]
315
- pub(crate) fn load_ply(_: &Bound<'_, PyType>, filename: &str) -> PyResult<Self> {
316
- let mut input = BufReader::new(File::open(filename)?);
338
+ pub(crate) fn load_ply(file: &Path) -> PyResult<Self> {
339
+ let mut input = BufReader::new(File::open(file)?);
317
340
 
318
341
  let vertex_parser = parser::Parser::<PlyVertex>::new();
319
342
  let face_parser = parser::Parser::<PlyFace>::new();
320
343
 
321
344
  let header = vertex_parser.read_header(&mut input).map_err(|err| {
322
345
  PyValueError::new_err(format!(
323
- "An error occurred while reading the header of ply file {filename:#?}: {}",
346
+ "An error occurred while reading the header of ply file {file:#?}: {}",
324
347
  err
325
348
  ))
326
349
  })?;
@@ -337,7 +360,7 @@ impl Mesh {
337
360
  .map_err(|err| {
338
361
  PyValueError::new_err(format!(
339
362
  "An error occurred while reading the vertex elements of ply \
340
- file {filename:#?}: {}",
363
+ file {file:#?}: {}",
341
364
  err
342
365
  ))
343
366
  })?
@@ -352,7 +375,7 @@ impl Mesh {
352
375
  .map_err(|err| {
353
376
  PyValueError::new_err(format!(
354
377
  "An error occurred while reading the face elements of PLY \
355
- file {filename:#?}: {}",
378
+ file {file:#?}: {}",
356
379
  err
357
380
  ))
358
381
  })?
@@ -507,3 +530,49 @@ pub(crate) fn mesh(m: Bound<'_, PyModule>) -> PyResult<()> {
507
530
  m.add_class::<Mesh>()?;
508
531
  Ok(())
509
532
  }
533
+
534
+ #[cfg(test)]
535
+ mod tests {
536
+ use super::*;
537
+
538
+ #[test]
539
+ fn append_backfills_placeholder_materials_and_adopts_other_material_names() {
540
+ // 'self' has no material info at all yet (no `set_face_material`
541
+ // call was ever made on it), while 'other' already has its single
542
+ // triangle assigned to material "foo".
543
+ let mut self_mesh = Mesh {
544
+ vertices: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
545
+ triangles: vec![[0, 1, 2]],
546
+ ..Default::default()
547
+ };
548
+ assert!(self_mesh.face_materials.is_none());
549
+ assert!(self_mesh.material_names.is_empty());
550
+
551
+ let mut other_mesh = Mesh {
552
+ vertices: vec![[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [0.0, 1.0, 1.0]],
553
+ triangles: vec![[0, 1, 2]],
554
+ ..Default::default()
555
+ };
556
+ other_mesh.set_face_material(Some("foo".to_string()));
557
+
558
+ let other_material_names = other_mesh.material_names.clone();
559
+ assert_eq!(other_material_names, vec!["foo".to_string()]);
560
+
561
+ self_mesh.append(&mut other_mesh);
562
+
563
+ // 'self' adopted 'other's material names outright (moved via
564
+ // `std::mem::take`), since it previously had none of its own.
565
+ assert_eq!(self_mesh.material_names, other_material_names);
566
+ // 'other's material names were moved out, leaving it empty.
567
+ assert!(other_mesh.material_names.is_empty());
568
+
569
+ // 'self' now has face materials for both its own triangle
570
+ // (backfilled with the -1 placeholder) and 'other's triangle
571
+ // (unchanged, index 0, i.e., "foo").
572
+ let face_materials = self_mesh
573
+ .face_materials
574
+ .expect("face materials should be set after append");
575
+ assert_eq!(face_materials, vec![-1, 0]);
576
+ assert_eq!(self_mesh.triangles.len(), 2);
577
+ }
578
+ }
@@ -0,0 +1,361 @@
1
+ use std::{
2
+ collections::{HashMap, HashSet},
3
+ path::{Path, PathBuf},
4
+ };
5
+
6
+ use pyo3::{exceptions::PyValueError, prelude::*, types::PyType};
7
+
8
+ use super::sionna::SionnaScene;
9
+ use crate::geometry::mesh::Mesh;
10
+
11
+ /// Return the set of material names for which at least two materials
12
+ /// (i.e., two distinct XML ids) share that name but disagree on
13
+ /// `thickness`, meaning that name alone is not enough to tell them apart.
14
+ fn non_uniform_material_names(sionna: &SionnaScene) -> HashSet<String> {
15
+ let mut seen: HashMap<&str, Option<f32>> = HashMap::new();
16
+ let mut non_uniform = HashSet::new();
17
+
18
+ for mat in sionna.materials.values() {
19
+ match seen.get(mat.name.as_str()) {
20
+ Some(thickness) if *thickness != mat.thickness => {
21
+ non_uniform.insert(mat.name.clone());
22
+ },
23
+ Some(_) => {},
24
+ None => {
25
+ seen.insert(&mat.name, mat.thickness);
26
+ },
27
+ }
28
+ }
29
+
30
+ non_uniform
31
+ }
32
+
33
+ /// A scene that contains one mesh, usually being the results of multiple call to :meth:`Mesh.append<differt_core.geometry.Mesh.append>`.
34
+ ///
35
+ /// This class is only useful to provide a fast constructor for scenes
36
+ /// created using the Sionna file format.
37
+ #[derive(Clone)]
38
+ #[pyclass(subclass)]
39
+ struct Scene {
40
+ /// differt_core.geometry.Mesh: The scene mesh.
41
+ #[pyo3(get)]
42
+ mesh: Mesh,
43
+ }
44
+
45
+ #[pymethods]
46
+ impl Scene {
47
+ /// Load a scene from a Sionna-compatible XML file.
48
+ ///
49
+ /// Args:
50
+ /// file (str | os.PathLike[str]): The path to the XML file.
51
+ ///
52
+ /// Returns:
53
+ /// Scene: The corresponding scene.
54
+ #[classmethod]
55
+ #[pyo3(name = "load_xml")]
56
+ fn py_load_xml(_cls: &Bound<'_, PyType>, file: PathBuf) -> PyResult<Self> {
57
+ Self::load_xml(&file)
58
+ }
59
+ }
60
+ impl Scene {
61
+ fn load_xml(file: &Path) -> PyResult<Self> {
62
+ let sionna = SionnaScene::load_xml(file)?;
63
+
64
+ let folder = file.parent().ok_or_else(|| {
65
+ PyValueError::new_err(format!(
66
+ "Could not determine parent folder of file: {file:#?}",
67
+ ))
68
+ })?;
69
+
70
+ let non_uniform_names = non_uniform_material_names(&sionna);
71
+
72
+ let mut mesh = Mesh::default();
73
+
74
+ for (_, shape) in sionna.shapes.into_iter() {
75
+ let mesh_file_path = folder.join(shape.file);
76
+ let mut other_mesh = match shape.r#type.as_str() {
77
+ "obj" => Mesh::load_obj(&mesh_file_path)?,
78
+ "ply" => Mesh::load_ply(&mesh_file_path)?,
79
+ ty => {
80
+ log::warn!("Unsupported shape type {ty}, skipping.");
81
+ continue;
82
+ },
83
+ };
84
+
85
+ let material = sionna.materials.get(&shape.material_id);
86
+
87
+ let color = material.map(|mat| mat.color);
88
+
89
+ // Materials whose name is shared by another, differently
90
+ // configured material (currently, only 'thickness' can differ)
91
+ // are kept under their unique XML id, so the two remain
92
+ // distinguishable; every other material still shares the
93
+ // generic, ITU-type-derived name, matching the previous
94
+ // behavior (and keeping it resolvable against the built-in ITU
95
+ // materials database, e.g. via `differt.em.materials_from_sionna`).
96
+ // A material with no 'thickness' override of its own is never
97
+ // renamed, even if its name collides with a differently
98
+ // configured one: 'differt.em._material._populate_materials'
99
+ // only ever creates a distinct, id-keyed entry for a material
100
+ // that actually carries a 'thickness' (see its own leading
101
+ // 'if mat.thickness is None: continue'), so keying a
102
+ // thickness-less shape by id here would leave it without any
103
+ // matching entry in the Python-side materials mapping.
104
+ let material_name = material.map(|mat| {
105
+ if mat.thickness.is_some() && non_uniform_names.contains(&mat.name) {
106
+ mat.id.clone()
107
+ } else {
108
+ mat.name.clone()
109
+ }
110
+ });
111
+
112
+ other_mesh.set_face_color(color.as_ref());
113
+ other_mesh.set_face_material(material_name);
114
+
115
+ mesh.append(&mut other_mesh);
116
+ }
117
+ Ok(Self { mesh })
118
+ }
119
+ }
120
+
121
+ #[cfg(not(tarpaulin_include))]
122
+ #[pymodule(gil_used = false)]
123
+ pub(crate) fn scene(m: Bound<'_, PyModule>) -> PyResult<()> {
124
+ m.add_class::<Scene>()?;
125
+ Ok(())
126
+ }
127
+
128
+ #[cfg(test)]
129
+ mod tests {
130
+ use std::fs;
131
+
132
+ use indexmap::IndexMap;
133
+ use pyo3::Python;
134
+ use tempfile::tempdir;
135
+
136
+ use super::*;
137
+ use crate::geometry::sionna::Material;
138
+
139
+ const TRIANGLE_OBJ: &str = "v 0.0 0.0 0.0\nv 1.0 0.0 0.0\nv 0.0 1.0 0.0\nf 1 2 3\n";
140
+
141
+ fn shape_xml(id: &str) -> String {
142
+ format!(
143
+ r#"
144
+ <shape type="obj" id="shape-{id}">
145
+ <string name="filename" value="mesh.obj"/>
146
+ <ref id="{id}"/>
147
+ </shape>
148
+ "#
149
+ )
150
+ }
151
+
152
+ #[test]
153
+ fn non_uniform_material_names_flags_same_name_different_thickness() {
154
+ let mut materials = IndexMap::new();
155
+ materials.insert(
156
+ "window1".to_string(),
157
+ Material {
158
+ name: "itu_glass".to_string(),
159
+ id: "window1".to_string(),
160
+ color: [0.168, 0.139, 0.509],
161
+ thickness: Some(0.01),
162
+ },
163
+ );
164
+ materials.insert(
165
+ "window2".to_string(),
166
+ Material {
167
+ name: "itu_glass".to_string(),
168
+ id: "window2".to_string(),
169
+ color: [0.168, 0.139, 0.509],
170
+ thickness: Some(0.05),
171
+ },
172
+ );
173
+ materials.insert(
174
+ "wall".to_string(),
175
+ Material {
176
+ name: "itu_concrete".to_string(),
177
+ id: "wall".to_string(),
178
+ color: [0.539, 0.539, 0.539],
179
+ thickness: None,
180
+ },
181
+ );
182
+ materials.insert(
183
+ "wall2".to_string(),
184
+ Material {
185
+ name: "itu_concrete".to_string(),
186
+ id: "wall2".to_string(),
187
+ color: [0.539, 0.539, 0.539],
188
+ thickness: None,
189
+ },
190
+ );
191
+
192
+ let sionna = SionnaScene {
193
+ materials,
194
+ shapes: IndexMap::new(),
195
+ };
196
+
197
+ let non_uniform = non_uniform_material_names(&sionna);
198
+
199
+ // Both "itu_glass" materials share a name but disagree on
200
+ // thickness, so the name alone cannot distinguish them.
201
+ assert!(non_uniform.contains("itu_glass"));
202
+ // Both "itu_concrete" materials agree (neither has a thickness
203
+ // override), so their shared name remains sufficient.
204
+ assert!(!non_uniform.contains("itu_concrete"));
205
+ }
206
+
207
+ #[test]
208
+ fn load_xml_keeps_ids_distinguishable_when_material_names_collide() {
209
+ let dir = tempdir().expect("failed to create temporary directory");
210
+
211
+ fs::write(dir.path().join("mesh.obj"), TRIANGLE_OBJ).expect("failed to write obj file");
212
+
213
+ let xml = format!(
214
+ r#"<scene version="2.1.0">
215
+ <bsdf type="itu-radio-material" id="window1">
216
+ <string name="type" value="glass"/>
217
+ <float name="thickness" value="0.01"/>
218
+ </bsdf>
219
+ <bsdf type="itu-radio-material" id="window2">
220
+ <string name="type" value="glass"/>
221
+ <float name="thickness" value="0.05"/>
222
+ </bsdf>
223
+ {}{}
224
+ </scene>"#,
225
+ shape_xml("window1"),
226
+ shape_xml("window2"),
227
+ );
228
+
229
+ let scene_file = dir.path().join("scene.xml");
230
+ fs::write(&scene_file, xml).expect("failed to write scene file");
231
+
232
+ let material_names: Vec<String> = Python::with_gil(|py| {
233
+ let scene = Scene::load_xml(&scene_file).expect("scene should load");
234
+ let py_scene =
235
+ Bound::new(py, scene).expect("failed to wrap the scene in a Python object");
236
+ let py_mesh = py_scene
237
+ .getattr("mesh")
238
+ .expect("scene should have a `mesh` attribute");
239
+ py_mesh
240
+ .getattr("material_names")
241
+ .expect("mesh should have a `material_names` attribute")
242
+ .extract()
243
+ .expect("`material_names` should be extractable as Vec<String>")
244
+ });
245
+
246
+ // Since the two "glass" materials disagree on thickness, they must
247
+ // remain distinguishable under their own XML ids, rather than both
248
+ // collapsing to the generic "itu_glass" name (which would make them
249
+ // indistinguishable from one another).
250
+ assert_eq!(
251
+ material_names,
252
+ vec!["window1".to_string(), "window2".to_string()]
253
+ );
254
+ }
255
+
256
+ #[test]
257
+ fn load_xml_keeps_generic_name_when_no_collision() {
258
+ let dir = tempdir().expect("failed to create temporary directory");
259
+
260
+ fs::write(dir.path().join("mesh.obj"), TRIANGLE_OBJ).expect("failed to write obj file");
261
+
262
+ let xml = format!(
263
+ r#"<scene version="2.1.0">
264
+ <bsdf type="itu-radio-material" id="window">
265
+ <string name="type" value="glass"/>
266
+ <float name="thickness" value="0.01"/>
267
+ </bsdf>
268
+ <bsdf type="itu-radio-material" id="wall">
269
+ <string name="type" value="concrete"/>
270
+ </bsdf>
271
+ {}{}
272
+ </scene>"#,
273
+ shape_xml("window"),
274
+ shape_xml("wall"),
275
+ );
276
+
277
+ let scene_file = dir.path().join("scene.xml");
278
+ fs::write(&scene_file, xml).expect("failed to write scene file");
279
+
280
+ let material_names: Vec<String> = Python::with_gil(|py| {
281
+ let scene = Scene::load_xml(&scene_file).expect("scene should load");
282
+ let py_scene =
283
+ Bound::new(py, scene).expect("failed to wrap the scene in a Python object");
284
+ let py_mesh = py_scene
285
+ .getattr("mesh")
286
+ .expect("scene should have a `mesh` attribute");
287
+ py_mesh
288
+ .getattr("material_names")
289
+ .expect("mesh should have a `material_names` attribute")
290
+ .extract()
291
+ .expect("`material_names` should be extractable as Vec<String>")
292
+ });
293
+
294
+ // Neither material name collides with another, so both keep their
295
+ // generic, ITU-type-derived name (resolvable against the built-in
296
+ // ITU materials database), rather than being keyed by XML id.
297
+ assert_eq!(
298
+ material_names,
299
+ vec!["itu_glass".to_string(), "itu_concrete".to_string()]
300
+ );
301
+ }
302
+
303
+ #[test]
304
+ fn load_xml_keeps_generic_name_for_thickness_less_material_despite_collision() {
305
+ let dir = tempdir().expect("failed to create temporary directory");
306
+
307
+ fs::write(dir.path().join("mesh.obj"), TRIANGLE_OBJ).expect("failed to write obj file");
308
+
309
+ let xml = format!(
310
+ r#"<scene version="2.1.0">
311
+ <bsdf type="itu-radio-material" id="window1">
312
+ <string name="type" value="glass"/>
313
+ <float name="thickness" value="0.01"/>
314
+ </bsdf>
315
+ <bsdf type="itu-radio-material" id="window2">
316
+ <string name="type" value="glass"/>
317
+ <float name="thickness" value="0.05"/>
318
+ </bsdf>
319
+ <bsdf type="itu-radio-material" id="window3">
320
+ <string name="type" value="glass"/>
321
+ </bsdf>
322
+ {}{}{}
323
+ </scene>"#,
324
+ shape_xml("window1"),
325
+ shape_xml("window2"),
326
+ shape_xml("window3"),
327
+ );
328
+
329
+ let scene_file = dir.path().join("scene.xml");
330
+ fs::write(&scene_file, xml).expect("failed to write scene file");
331
+
332
+ let material_names: Vec<String> = Python::with_gil(|py| {
333
+ let scene = Scene::load_xml(&scene_file).expect("scene should load");
334
+ let py_scene =
335
+ Bound::new(py, scene).expect("failed to wrap the scene in a Python object");
336
+ let py_mesh = py_scene
337
+ .getattr("mesh")
338
+ .expect("scene should have a `mesh` attribute");
339
+ py_mesh
340
+ .getattr("material_names")
341
+ .expect("mesh should have a `material_names` attribute")
342
+ .extract()
343
+ .expect("`material_names` should be extractable as Vec<String>")
344
+ });
345
+
346
+ // 'window1'/'window2' disagree on thickness and are kept
347
+ // distinguishable under their own ids, but 'window3' has no
348
+ // 'thickness' override of its own: it must keep the shared generic
349
+ // name, since 'differt.em._material._populate_materials' never
350
+ // creates an id-keyed entry for a thickness-less material (it would
351
+ // otherwise have no matching entry on the Python side at all).
352
+ assert_eq!(
353
+ material_names,
354
+ vec![
355
+ "window1".to_string(),
356
+ "window2".to_string(),
357
+ "itu_glass".to_string(),
358
+ ]
359
+ );
360
+ }
361
+ }