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.
- {differt_core-0.11.0 → differt_core-0.12.0}/Cargo.lock +2 -1
- {differt_core-0.11.0 → differt_core-0.12.0}/PKG-INFO +1 -1
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/Cargo.toml +2 -1
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/src/geometry/graph.rs +1 -3
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/src/geometry/mesh.rs +53 -0
- differt_core-0.12.0/differt-core/src/geometry/scene.rs +361 -0
- {differt_core-0.11.0 → differt_core-0.12.0/differt-core}/src/geometry/sionna.rs +39 -5
- differt_core-0.12.0/differt-core/tests/geometry/test_core_scene.py +93 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/tests/geometry/test_graph.py +2 -2
- {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/_differt_core/__init__.pyi +0 -2
- {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/scene.pyi +2 -2
- {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/geometry/__init__.py +2 -4
- differt_core-0.12.0/python/differt_core/geometry/_mesh.py +6 -0
- differt_core-0.12.0/python/differt_core/geometry/_scene.py +5 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/src/geometry/graph.rs +1 -3
- {differt_core-0.11.0 → differt_core-0.12.0}/src/geometry/mesh.rs +53 -0
- differt_core-0.12.0/src/geometry/scene.rs +361 -0
- {differt_core-0.11.0/differt-core → differt_core-0.12.0}/src/geometry/sionna.rs +39 -5
- differt_core-0.11.0/differt-core/src/geometry/scene.rs +0 -78
- differt_core-0.11.0/differt-core/tests/geometry/test_core_mesh.py +0 -11
- differt_core-0.11.0/differt-core/tests/geometry/test_core_scene.py +0 -11
- differt_core-0.11.0/python/differt_core/geometry/_mesh.py +0 -28
- differt_core-0.11.0/python/differt_core/geometry/_scene.py +0 -27
- differt_core-0.11.0/src/geometry/scene.rs +0 -78
- {differt_core-0.11.0 → differt_core-0.12.0}/Cargo.toml +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/LICENSE.md +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/README.md +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/LICENSE.md +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/README.md +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/benches/bench_main.rs +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/benches/benchmarks/graph_iterators.rs +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/benches/benchmarks/mod.rs +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/src/geometry/mod.rs +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/src/lib.rs +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/tests/__init__.py +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/tests/geometry/__init__.py +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/differt-core/tests/test_version.py +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/pyproject.toml +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/__init__.py +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/graph.pyi +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/mesh.pyi +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/_differt_core/geometry/sionna.pyi +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/geometry/_graph.py +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/geometry/_sionna.py +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/python/differt_core/py.typed +0 -0
- {differt_core-0.11.0 → differt_core-0.12.0}/src/geometry/mod.rs +0 -0
- {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.
|
|
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
|
|
|
@@ -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.
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
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"),
|