1#[derive(Debug, Clone)]
11pub struct MaterialEntry {
12 pub name: String,
14 pub density: f64,
16 pub youngs_modulus: f64,
18 pub poisson_ratio: f64,
20 pub thermal_conductivity: f64,
22 pub specific_heat: f64,
24 pub thermal_expansion: f64,
26}
27
28impl MaterialEntry {
29 #[allow(clippy::too_many_arguments)]
31 pub fn new(
32 name: impl Into<String>,
33 density: f64,
34 youngs_modulus: f64,
35 poisson_ratio: f64,
36 thermal_conductivity: f64,
37 specific_heat: f64,
38 thermal_expansion: f64,
39 ) -> Self {
40 Self {
41 name: name.into(),
42 density,
43 youngs_modulus,
44 poisson_ratio,
45 thermal_conductivity,
46 specific_heat,
47 thermal_expansion,
48 }
49 }
50
51 fn to_json_fields(&self) -> String {
53 format!(
54 r#""name":"{name}","density":{density},"youngs_modulus":{ym},"poisson_ratio":{pr},"thermal_conductivity":{tc},"specific_heat":{sh},"thermal_expansion":{te}"#,
55 name = self.name,
56 density = self.density,
57 ym = self.youngs_modulus,
58 pr = self.poisson_ratio,
59 tc = self.thermal_conductivity,
60 sh = self.specific_heat,
61 te = self.thermal_expansion,
62 )
63 }
64}
65
66#[derive(Debug, Default)]
68pub struct MaterialDatabase {
69 entries: Vec<MaterialEntry>,
70}
71
72impl MaterialDatabase {
73 pub fn new() -> Self {
75 Self::default()
76 }
77
78 pub fn with_defaults() -> Self {
83 let mut db = Self::new();
84 db.add_material(MaterialEntry::new(
86 "steel_1020",
87 7_870.0,
88 200.0e9,
89 0.29,
90 51.9,
91 486.0,
92 11.7e-6,
93 ));
94 db.add_material(MaterialEntry::new(
96 "aluminum_6061",
97 2_700.0,
98 68.9e9,
99 0.33,
100 167.0,
101 896.0,
102 23.6e-6,
103 ));
104 db.add_material(MaterialEntry::new(
106 "copper", 8_960.0, 117.0e9, 0.34, 385.0, 385.0, 17.0e-6,
107 ));
108 db.add_material(MaterialEntry::new(
110 "titanium_ti6al4v",
111 4_430.0,
112 113.8e9,
113 0.342,
114 6.7,
115 526.3,
116 8.6e-6,
117 ));
118 db.add_material(MaterialEntry::new(
120 "ptfe", 2_200.0, 0.5e9, 0.46, 0.25, 1_004.0, 135.0e-6,
121 ));
122 db.add_material(MaterialEntry::new(
124 "water", 998.2, 2.2e9, 0.5, 0.598, 4_182.0, 0.207e-3,
125 ));
126 db.add_material(MaterialEntry::new(
128 "air", 1.204, 0.0, 0.0, 0.0257, 1_005.0, 3.43e-3,
129 ));
130 db
131 }
132
133 pub fn add_material(&mut self, entry: MaterialEntry) {
135 self.entries.push(entry);
136 }
137
138 pub fn get_material(&self, name: &str) -> Option<&MaterialEntry> {
142 self.entries.iter().find(|e| e.name == name)
143 }
144
145 pub fn remove_material(&mut self, name: &str) -> bool {
149 let before = self.entries.len();
150 self.entries.retain(|e| e.name != name);
151 self.entries.len() < before
152 }
153
154 pub fn len(&self) -> usize {
156 self.entries.len()
157 }
158
159 pub fn is_empty(&self) -> bool {
161 self.entries.is_empty()
162 }
163
164 pub fn search_by_density(&self, min_density: f64, max_density: f64) -> Vec<&MaterialEntry> {
166 self.entries
167 .iter()
168 .filter(|e| e.density >= min_density && e.density <= max_density)
169 .collect()
170 }
171
172 pub fn search_by_youngs_modulus(&self, min_e: f64, max_e: f64) -> Vec<&MaterialEntry> {
174 self.entries
175 .iter()
176 .filter(|e| e.youngs_modulus >= min_e && e.youngs_modulus <= max_e)
177 .collect()
178 }
179
180 pub fn export_json(&self) -> String {
184 let items: Vec<String> = self
185 .entries
186 .iter()
187 .map(|e| format!("{{{}}}", e.to_json_fields()))
188 .collect();
189 format!("[{}]", items.join(","))
190 }
191
192 pub fn import_json(json: &str) -> Result<Self, String> {
197 let mut db = Self::new();
198 let trimmed = json.trim();
199 if trimmed == "[]" {
200 return Ok(db);
201 }
202 let inner = trimmed
204 .strip_prefix('[')
205 .and_then(|s| s.strip_suffix(']'))
206 .ok_or("Expected JSON array")?;
207
208 let objects = split_json_objects(inner)?;
210 for obj in objects {
211 let entry = parse_material_json_object(&obj)?;
212 db.add_material(entry);
213 }
214 Ok(db)
215 }
216}
217
218fn split_json_objects(s: &str) -> Result<Vec<String>, String> {
222 let mut objects = Vec::new();
223 let mut depth = 0i32;
224 let mut start = 0usize;
225
226 for (i, c) in s.char_indices() {
227 match c {
228 '{' => {
229 if depth == 0 {
230 start = i;
231 }
232 depth += 1;
233 }
234 '}' => {
235 depth -= 1;
236 if depth == 0 {
237 objects.push(s[start..=i].to_string());
238 }
239 }
240 _ => {}
241 }
242 }
243
244 if depth != 0 {
245 return Err("Unbalanced braces in JSON".into());
246 }
247 Ok(objects)
248}
249
250fn extract_str_field<'a>(obj: &'a str, key: &str) -> Option<&'a str> {
252 let needle = format!("\"{}\":\"", key);
253 let start = obj.find(&needle)? + needle.len();
254 let end = obj[start..].find('"')? + start;
255 Some(&obj[start..end])
256}
257
258fn extract_f64_field(obj: &str, key: &str) -> Option<f64> {
260 let needle = format!("\"{}\":", key);
261 let start = obj.find(&needle)? + needle.len();
262 let rest = &obj[start..];
263 let end = rest.find([',', '}']).unwrap_or(rest.len());
265 rest[..end].trim().parse().ok()
266}
267
268fn parse_material_json_object(obj: &str) -> Result<MaterialEntry, String> {
270 let name = extract_str_field(obj, "name")
271 .ok_or("missing name")?
272 .to_string();
273 let density = extract_f64_field(obj, "density").ok_or("missing density")?;
274 let youngs_modulus =
275 extract_f64_field(obj, "youngs_modulus").ok_or("missing youngs_modulus")?;
276 let poisson_ratio = extract_f64_field(obj, "poisson_ratio").ok_or("missing poisson_ratio")?;
277 let thermal_conductivity =
278 extract_f64_field(obj, "thermal_conductivity").ok_or("missing thermal_conductivity")?;
279 let specific_heat = extract_f64_field(obj, "specific_heat").ok_or("missing specific_heat")?;
280 let thermal_expansion =
281 extract_f64_field(obj, "thermal_expansion").ok_or("missing thermal_expansion")?;
282
283 Ok(MaterialEntry {
284 name,
285 density,
286 youngs_modulus,
287 poisson_ratio,
288 thermal_conductivity,
289 specific_heat,
290 thermal_expansion,
291 })
292}
293
294#[cfg(test)]
297mod tests {
298 use super::*;
299
300 fn make_steel() -> MaterialEntry {
301 MaterialEntry::new("steel_test", 7_870.0, 200.0e9, 0.29, 51.9, 486.0, 11.7e-6)
302 }
303
304 #[test]
306 fn test_add_and_get() {
307 let mut db = MaterialDatabase::new();
308 db.add_material(make_steel());
309 assert!(db.get_material("steel_test").is_some());
310 }
311
312 #[test]
313 fn test_get_missing() {
314 let db = MaterialDatabase::new();
315 assert!(db.get_material("nonexistent").is_none());
316 }
317
318 #[test]
319 fn test_len_empty() {
320 let db = MaterialDatabase::new();
321 assert_eq!(db.len(), 0);
322 assert!(db.is_empty());
323 }
324
325 #[test]
326 fn test_len_after_add() {
327 let mut db = MaterialDatabase::new();
328 db.add_material(make_steel());
329 assert_eq!(db.len(), 1);
330 assert!(!db.is_empty());
331 }
332
333 #[test]
335 fn test_remove_existing() {
336 let mut db = MaterialDatabase::new();
337 db.add_material(make_steel());
338 assert!(db.remove_material("steel_test"));
339 assert!(db.is_empty());
340 }
341
342 #[test]
343 fn test_remove_missing() {
344 let mut db = MaterialDatabase::new();
345 assert!(!db.remove_material("ghost"));
346 }
347
348 #[test]
350 fn test_defaults_count() {
351 let db = MaterialDatabase::with_defaults();
352 assert_eq!(db.len(), 7);
353 }
354
355 #[test]
356 fn test_defaults_steel_exists() {
357 let db = MaterialDatabase::with_defaults();
358 assert!(db.get_material("steel_1020").is_some());
359 }
360
361 #[test]
362 fn test_defaults_aluminum_density() {
363 let db = MaterialDatabase::with_defaults();
364 let al = db.get_material("aluminum_6061").unwrap();
365 assert!((al.density - 2_700.0).abs() < 1.0);
366 }
367
368 #[test]
369 fn test_defaults_copper_youngs() {
370 let db = MaterialDatabase::with_defaults();
371 let cu = db.get_material("copper").unwrap();
372 assert!((cu.youngs_modulus - 117.0e9).abs() < 1e6);
373 }
374
375 #[test]
376 fn test_defaults_titanium_exists() {
377 let db = MaterialDatabase::with_defaults();
378 assert!(db.get_material("titanium_ti6al4v").is_some());
379 }
380
381 #[test]
382 fn test_defaults_ptfe_exists() {
383 let db = MaterialDatabase::with_defaults();
384 assert!(db.get_material("ptfe").is_some());
385 }
386
387 #[test]
388 fn test_defaults_water_exists() {
389 let db = MaterialDatabase::with_defaults();
390 assert!(db.get_material("water").is_some());
391 }
392
393 #[test]
394 fn test_defaults_air_exists() {
395 let db = MaterialDatabase::with_defaults();
396 assert!(db.get_material("air").is_some());
397 }
398
399 #[test]
401 fn test_search_by_density_finds_metals() {
402 let db = MaterialDatabase::with_defaults();
403 let results = db.search_by_density(2_000.0, 9_000.0);
404 assert!(results.len() >= 4); }
406
407 #[test]
408 fn test_search_by_density_excludes_air() {
409 let db = MaterialDatabase::with_defaults();
410 let results = db.search_by_density(2_000.0, 9_000.0);
411 assert!(!results.iter().any(|e| e.name == "air"));
412 }
413
414 #[test]
415 fn test_search_by_density_empty_range() {
416 let db = MaterialDatabase::with_defaults();
417 let results = db.search_by_density(1e12, 2e12);
418 assert!(results.is_empty());
419 }
420
421 #[test]
423 fn test_search_by_youngs_excludes_air() {
424 let db = MaterialDatabase::with_defaults();
425 let results = db.search_by_youngs_modulus(1.0e9, 300.0e9);
426 assert!(!results.iter().any(|e| e.name == "air"));
427 }
428
429 #[test]
430 fn test_search_by_youngs_finds_steel() {
431 let db = MaterialDatabase::with_defaults();
432 let results = db.search_by_youngs_modulus(150.0e9, 250.0e9);
433 assert!(results.iter().any(|e| e.name == "steel_1020"));
434 }
435
436 #[test]
438 fn test_export_import_roundtrip() {
439 let db = MaterialDatabase::with_defaults();
440 let json = db.export_json();
441 let db2 = MaterialDatabase::import_json(&json).unwrap();
442 assert_eq!(db2.len(), db.len());
443 }
444
445 #[test]
446 fn test_export_import_preserves_density() {
447 let db = MaterialDatabase::with_defaults();
448 let json = db.export_json();
449 let db2 = MaterialDatabase::import_json(&json).unwrap();
450 let orig = db.get_material("steel_1020").unwrap();
451 let restored = db2.get_material("steel_1020").unwrap();
452 assert!((orig.density - restored.density).abs() < 1e-3);
453 }
454
455 #[test]
456 fn test_export_import_preserves_youngs_modulus() {
457 let db = MaterialDatabase::with_defaults();
458 let json = db.export_json();
459 let db2 = MaterialDatabase::import_json(&json).unwrap();
460 let orig = db.get_material("copper").unwrap();
461 let restored = db2.get_material("copper").unwrap();
462 assert!((orig.youngs_modulus - restored.youngs_modulus).abs() < 1e4);
463 }
464
465 #[test]
466 fn test_export_import_empty_db() {
467 let db = MaterialDatabase::new();
468 let json = db.export_json();
469 let db2 = MaterialDatabase::import_json(&json).unwrap();
470 assert!(db2.is_empty());
471 }
472
473 #[test]
474 fn test_export_import_single_entry() {
475 let mut db = MaterialDatabase::new();
476 db.add_material(make_steel());
477 let json = db.export_json();
478 let db2 = MaterialDatabase::import_json(&json).unwrap();
479 assert_eq!(db2.len(), 1);
480 let entry = db2.get_material("steel_test").unwrap();
481 assert!((entry.poisson_ratio - 0.29).abs() < 1e-9);
482 }
483
484 #[test]
485 fn test_import_invalid_json() {
486 assert!(MaterialDatabase::import_json("{bad json}").is_err());
487 }
488
489 #[test]
491 fn test_material_entry_fields() {
492 let entry = make_steel();
493 assert_eq!(entry.name, "steel_test");
494 assert!((entry.density - 7870.0).abs() < 1.0);
495 assert!((entry.youngs_modulus - 200.0e9).abs() < 1e6);
496 assert!((entry.poisson_ratio - 0.29).abs() < 1e-9);
497 assert!((entry.thermal_conductivity - 51.9).abs() < 0.01);
498 assert!((entry.specific_heat - 486.0).abs() < 0.1);
499 assert!((entry.thermal_expansion - 11.7e-6).abs() < 1e-10);
500 }
501}