differt-core 0.11.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 (47) hide show
  1. {differt_core-0.11.0 → differt_core-0.12.0}/Cargo.lock +2 -1
  2. {differt_core-0.11.0 → differt_core-0.12.0}/PKG-INFO +1 -1
  3. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/Cargo.toml +2 -1
  4. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/src/geometry/graph.rs +1 -3
  5. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/src/geometry/mesh.rs +53 -0
  6. differt_core-0.12.0/differt-core/src/geometry/scene.rs +361 -0
  7. {differt_core-0.11.0 → differt_core-0.12.0/differt-core}/src/geometry/sionna.rs +39 -5
  8. differt_core-0.12.0/differt-core/tests/geometry/test_core_scene.py +93 -0
  9. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/tests/geometry/test_graph.py +2 -2
  10. {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/_differt_core/__init__.pyi +0 -2
  11. {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/scene.pyi +2 -2
  12. {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/geometry/__init__.py +2 -4
  13. differt_core-0.12.0/python/differt_core/geometry/_mesh.py +6 -0
  14. differt_core-0.12.0/python/differt_core/geometry/_scene.py +5 -0
  15. {differt_core-0.11.0 → differt_core-0.12.0}/src/geometry/graph.rs +1 -3
  16. {differt_core-0.11.0 → differt_core-0.12.0}/src/geometry/mesh.rs +53 -0
  17. differt_core-0.12.0/src/geometry/scene.rs +361 -0
  18. {differt_core-0.11.0/differt-core → differt_core-0.12.0}/src/geometry/sionna.rs +39 -5
  19. differt_core-0.11.0/differt-core/src/geometry/scene.rs +0 -78
  20. differt_core-0.11.0/differt-core/tests/geometry/test_core_mesh.py +0 -11
  21. differt_core-0.11.0/differt-core/tests/geometry/test_core_scene.py +0 -11
  22. differt_core-0.11.0/python/differt_core/geometry/_mesh.py +0 -28
  23. differt_core-0.11.0/python/differt_core/geometry/_scene.py +0 -27
  24. differt_core-0.11.0/src/geometry/scene.rs +0 -78
  25. {differt_core-0.11.0 → differt_core-0.12.0}/Cargo.toml +0 -0
  26. {differt_core-0.11.0 → differt_core-0.12.0}/LICENSE.md +0 -0
  27. {differt_core-0.11.0 → differt_core-0.12.0}/README.md +0 -0
  28. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/LICENSE.md +0 -0
  29. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/README.md +0 -0
  30. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/benches/bench_main.rs +0 -0
  31. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/benches/benchmarks/graph_iterators.rs +0 -0
  32. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/benches/benchmarks/mod.rs +0 -0
  33. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/src/geometry/mod.rs +0 -0
  34. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/src/lib.rs +0 -0
  35. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/tests/__init__.py +0 -0
  36. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/tests/geometry/__init__.py +0 -0
  37. {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/tests/test_version.py +0 -0
  38. {differt_core-0.11.0 → differt_core-0.12.0}/pyproject.toml +0 -0
  39. {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/__init__.py +0 -0
  40. {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/graph.pyi +0 -0
  41. {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/mesh.pyi +0 -0
  42. {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/sionna.pyi +0 -0
  43. {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/geometry/_graph.py +0 -0
  44. {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/geometry/_sionna.py +0 -0
  45. {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/py.typed +0 -0
  46. {differt_core-0.11.0 → differt_core-0.12.0}/src/geometry/mod.rs +0 -0
  47. {differt_core-0.11.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.11.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.11.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.11.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)
@@ -211,7 +211,14 @@ impl Mesh {
211
211
  other.set_face_material(None);
212
212
  },
213
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.
214
220
  self.set_face_material(None);
221
+ self.material_names = std::mem::take(&mut other.material_names);
215
222
  },
216
223
  (Some(_), Some(_)) => {
217
224
  // We need to possibly renumber material indices.
@@ -523,3 +530,49 @@ pub(crate) fn mesh(m: Bound<'_, PyModule>) -> PyResult<()> {
523
530
  m.add_class::<Mesh>()?;
524
531
  Ok(())
525
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
+ }
@@ -194,7 +194,7 @@ impl<'de> Deserialize<'de> for Material {
194
194
  id: String,
195
195
  #[serde(rename = "string")]
196
196
  r#type: Type,
197
- #[serde(skip)]
197
+ #[serde(rename = "float", default)]
198
198
  thickness: Option<Thickness>,
199
199
  }),
200
200
  ("diffuse" => Diffuse {
@@ -763,9 +763,7 @@ mod tests {
763
763
  }
764
764
 
765
765
  #[test]
766
- fn deserializes_itu_thickness_ignored() {
767
- // Note: thickness elements in ITU materials are currently skipped
768
- // in deserialization (marked with #[serde(skip)])
766
+ fn deserializes_itu_thickness() {
769
767
  let xml = r#"
770
768
  <bsdf type="itu-radio-material" id="window">
771
769
  <string name="type" value="glass"/>
@@ -778,10 +776,46 @@ mod tests {
778
776
  assert_eq!(material.id, "window");
779
777
  assert_eq!(material.name, "itu_glass");
780
778
  assert_eq!(material.color, [0.168, 0.139, 0.509]);
781
- // Thickness is currently not deserialized for ITU materials
779
+ assert_eq!(material.thickness, Some(0.01));
780
+ }
781
+
782
+ #[test]
783
+ fn deserializes_itu_material_without_thickness() {
784
+ let xml = r#"
785
+ <bsdf type="itu-radio-material" id="marble">
786
+ <string name="type" value="marble"/>
787
+ </bsdf>
788
+ "#;
789
+
790
+ let material: Material = quick_xml::de::from_str(xml).expect("material should parse");
791
+
792
+ assert_eq!(material.id, "marble");
793
+ assert_eq!(material.name, "itu_marble");
782
794
  assert_eq!(material.thickness, None);
783
795
  }
784
796
 
797
+ #[test]
798
+ fn deserializes_itu_material_with_duplicate_thickness_errors() {
799
+ // Now that `thickness` is actually deserialized from `<float>`
800
+ // elements (instead of being `#[serde(skip)]`ped), a second
801
+ // `<float name="thickness">` element is recognized as a duplicate
802
+ // of the same field and must be rejected.
803
+ let xml = r#"
804
+ <bsdf type="itu-radio-material" id="window">
805
+ <string name="type" value="glass"/>
806
+ <float name="thickness" value="0.01"/>
807
+ <float name="thickness" value="0.02"/>
808
+ </bsdf>
809
+ "#;
810
+
811
+ let result: Result<Material, _> = quick_xml::de::from_str(xml);
812
+
813
+ assert!(
814
+ result.is_err(),
815
+ "duplicate <float> (thickness) elements should be rejected, got {result:?}"
816
+ );
817
+ }
818
+
785
819
  #[test]
786
820
  fn deserializes_material_without_mat_prefix() {
787
821
  let xml = r#"
@@ -0,0 +1,93 @@
1
+ from pathlib import Path
2
+
3
+ from differt_core.geometry import Scene
4
+
5
+ _TRIANGLE_OBJ = """\
6
+ v 0.0 0.0 0.0
7
+ v 1.0 0.0 0.0
8
+ v 0.0 1.0 0.0
9
+ f 1 2 3
10
+ """
11
+
12
+
13
+ def _write_scene(tmp_path: Path, bsdfs: str, shapes: str) -> Path:
14
+ (tmp_path / "mesh.obj").write_text(_TRIANGLE_OBJ)
15
+ scene_file = tmp_path / "scene.xml"
16
+ scene_file.write_text(f"<scene version='2.1.0'>{bsdfs}{shapes}</scene>")
17
+ return scene_file
18
+
19
+
20
+ def _shape(shape_id: str, material_id: str) -> str:
21
+ return f"""
22
+ <shape type="obj" id="{shape_id}">
23
+ <string name="filename" value="mesh.obj"/>
24
+ <ref id="{material_id}"/>
25
+ </shape>
26
+ """
27
+
28
+
29
+ class TestScene:
30
+ def test_append_preserves_first_shape_material(self, tmp_path: Path) -> None:
31
+ # Regression test: 'Mesh.append' used to silently drop the material
32
+ # name of the very first appended shape, mislabeling its faces with
33
+ # whatever material the *second* shape happened to have.
34
+ bsdfs = """
35
+ <bsdf type="itu-radio-material" id="glass">
36
+ <string name="type" value="glass"/>
37
+ </bsdf>
38
+ <bsdf type="itu-radio-material" id="wood">
39
+ <string name="type" value="wood"/>
40
+ </bsdf>
41
+ """
42
+ shapes = _shape("shape-0", "glass") + _shape("shape-1", "wood")
43
+ scene = Scene.load_xml(_write_scene(tmp_path, bsdfs, shapes))
44
+
45
+ assert scene.mesh.material_names == ["itu_glass", "itu_wood"]
46
+
47
+ object_bounds = scene.mesh.object_bounds
48
+ face_materials = scene.mesh.face_materials
49
+ assert object_bounds is not None
50
+ assert face_materials is not None
51
+
52
+ first_start, _ = object_bounds[0]
53
+ second_start, _ = object_bounds[1]
54
+ assert scene.mesh.material_names[face_materials[first_start]] == "itu_glass"
55
+ assert scene.mesh.material_names[face_materials[second_start]] == "itu_wood"
56
+
57
+ def test_materials_keyed_by_id_only_when_thickness_differs(
58
+ self, tmp_path: Path
59
+ ) -> None:
60
+ # Two shapes of the same ITU type but with different 'thickness'
61
+ # overrides must remain distinguishable (kept under their own XML
62
+ # id), while two shapes of the same type that agree (or that don't
63
+ # specify a thickness at all) must still share the generic,
64
+ # ITU-type-derived name.
65
+ bsdfs = """
66
+ <bsdf type="itu-radio-material" id="window1">
67
+ <string name="type" value="glass"/>
68
+ <float name="thickness" value="0.01"/>
69
+ </bsdf>
70
+ <bsdf type="itu-radio-material" id="window2">
71
+ <string name="type" value="glass"/>
72
+ <float name="thickness" value="0.05"/>
73
+ </bsdf>
74
+ <bsdf type="itu-radio-material" id="wall1">
75
+ <string name="type" value="concrete"/>
76
+ </bsdf>
77
+ <bsdf type="itu-radio-material" id="wall2">
78
+ <string name="type" value="concrete"/>
79
+ </bsdf>
80
+ """
81
+ shapes = (
82
+ _shape("shape-0", "window1")
83
+ + _shape("shape-1", "window2")
84
+ + _shape("shape-2", "wall1")
85
+ + _shape("shape-3", "wall2")
86
+ )
87
+ scene = Scene.load_xml(_write_scene(tmp_path, bsdfs, shapes))
88
+
89
+ assert scene.mesh.material_names == [
90
+ "window1",
91
+ "window2",
92
+ "itu_concrete",
93
+ ]
@@ -26,7 +26,7 @@ class TestDiGraph:
26
26
  TypeError,
27
27
  match="takes 0 positional arguments but 1 was given",
28
28
  ):
29
- _ = graph.insert_from_and_to_nodes(True) # ruff:ignore[boolean-positional-value-in-call]
29
+ _ = graph.insert_from_and_to_nodes(True) # ruff: ignore[boolean-positional-value-in-call]
30
30
 
31
31
  @pytest.mark.parametrize("fast_mode", [True, False])
32
32
  def test_disconnect_nodes(self, fast_mode: bool) -> None:
@@ -153,7 +153,7 @@ class TestDiGraph:
153
153
  TypeError,
154
154
  match="takes 3 positional arguments but 4 were given",
155
155
  ):
156
- _ = graph.all_paths(0, 1, 0, True) # ruff:ignore[boolean-positional-value-in-call]
156
+ _ = graph.all_paths(0, 1, 0, True) # ruff: ignore[boolean-positional-value-in-call]
157
157
 
158
158
  @pytest.mark.parametrize(
159
159
  ("num_nodes", "depth"),
@@ -4,5 +4,3 @@ __version__: str
4
4
  __version_info__: tuple[int, int, int]
5
5
 
6
6
  geometry: ModuleType
7
- rt: ModuleType
8
- scene: ModuleType
@@ -1,9 +1,9 @@
1
1
  from os import PathLike
2
2
 
3
- from differt_core.geometry import TriangleMesh
3
+ from differt_core.geometry import Mesh
4
4
 
5
5
  class Scene:
6
- mesh: list[TriangleMesh]
6
+ mesh: Mesh
7
7
 
8
8
  @classmethod
9
9
  def load_xml(cls, file: str | PathLike[str]) -> Scene: ...