//! The feature graph of an opened design: for each body, the features of the SolidCraft timeline //! that built it, with their parameters evaluated and the persistent face and edge names each //! one left on the body. //! //! A design opened from `.solidcraft` is rebuilt from its timeline, so how every body was made is //! known, not guessed: an extrude of a sketch profile along a direction, a box, a shell of a //! thickness, a hole of a diameter. The provenance-driven mesh plan (`mesh.plan`) reads it. //! //! Which features built a body comes from two places: the persistent face names (each names the //! features that made the face, `F8:side:l1`, `F3:inner:F1:+y`, `F82:-z@F84.2` for a pattern //! copy), and the features that name the body (targets, tools, the body a fillet or shell //! works on). A feature whose faces were all removed later and that names no body (a cut that a //! later cut swallowed) is left out. use std::collections::{BTreeMap, BTreeSet}; use serde::Serialize; use solidcraft_doc::expr::Kind; use solidcraft_doc::{Document, FeatureKind, ModelState, Operation, SolvedSketch}; use solidcraft_geom::{Seg2, Vec3}; use solidcraft_kernel::guard; use crate::{Design, Result, Shape}; /// Most features listed per body (a design's timeline is capped far above any real part). pub const MAX_FEATURES_PER_BODY: usize = 10_000; /// The timeline a design was rebuilt from, kept with the design for [`Design::features`]. pub(crate) struct History { pub(crate) doc: Document, pub(crate) state: std::sync::Arc, /// Error per feature id, from the rebuild. pub(crate) errors: BTreeMap, } impl std::fmt::Debug for History { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("History").field("features", &self.doc.features.len()).field("errors", &self.errors).finish() } } /// The features that built one body, in timeline order. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct BodyFeatures { pub body: String, pub features: Vec, } /// One feature of the timeline as it built a body. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct FeatureNode { /// The feature's id: `F` in persistent names. pub id: u64, pub name: String, /// The definition's type (`extrude`, `box`, `cylinder`, `hole`, `shell`, `fillet`, …). #[serde(rename = "type")] pub kind: String, /// `new_body`, `join`, `cut` or `intersect`, for features that have one. #[serde(skip_serializing_if = "Option::is_none")] pub operation: Option, /// The numeric inputs evaluated: lengths in mm, angles in radians, counts as numbers, keyed /// by their snake-case label (`distance`, `radius`, `thickness`, …). pub values: BTreeMap, /// Where a primitive sits (a box's corner, a cylinder's base, a sphere's centre), mm. #[serde(skip_serializing_if = "Option::is_none")] pub placement: Option<[f64; 3]>, /// The direction the feature builds along: an extrude's (the sketch normal, flipped for a /// negative extrude), a cylinder's axis, a hole's drilling direction. #[serde(skip_serializing_if = "Option::is_none")] pub direction: Option<[f64; 3]>, /// An extrude in both directions (`symmetric` or a second distance). #[serde(skip_serializing_if = "std::ops::Not::not")] pub two_sided: bool, /// The sketch the feature takes its profiles from, with the profiles it uses. #[serde(skip_serializing_if = "Option::is_none")] pub sketch: Option, /// Pattern and mirror copies: each copy's 4×4 transform (row-major), the identity excluded. #[serde(skip_serializing_if = "Vec::is_empty")] pub transforms: Vec<[[f64; 4]; 4]>, /// The bodies a join, cut or intersect changes (empty: all), or a combine's target. #[serde(skip_serializing_if = "Vec::is_empty")] pub targets: Vec, /// The tool bodies of a combine. #[serde(skip_serializing_if = "Vec::is_empty")] pub tools: Vec, /// The names the feature gives the bodies it makes (a combine's tool is found by them). #[serde(skip_serializing_if = "Vec::is_empty")] pub creates: Vec, /// The definition as stored in the design (expressions unevaluated). pub definition: serde_json::Value, /// The body's faces this feature made or shaped (their persistent names name it). pub faces: Vec, /// The body's edges on those faces. pub edges: Vec, /// Why the feature failed to rebuild, when it did. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } /// A sketch plane and the profiles a feature uses from it (sketch coordinates, mm). #[derive(Debug, Clone, PartialEq, Serialize)] pub struct SketchGeometry { pub id: u64, pub name: String, pub origin: [f64; 3], pub x_dir: [f64; 3], pub y_dir: [f64; 3], pub normal: [f64; 3], pub profiles: Vec, } /// A closed profile: its outer loop and holes. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct ProfileGeometry { /// Index in the solved sketch's profile list. pub index: usize, pub area: f64, pub outer: Vec, pub holes: Vec>, /// The sketch curve ids of the outer loop and of each hole. pub outer_curves: Vec, pub hole_curves: Vec>, } /// A curve of a profile loop (sketch coordinates). A loop of arcs about one centre that closes is /// one [`Curve::Circle`]. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Curve { Line { a: [f64; 2], b: [f64; 2], }, Arc { center: [f64; 2], radius: f64, start: f64, sweep: f64, }, Circle { center: [f64; 2], radius: f64, }, /// A spline, conic or other curve the planner does not decompose. Other { kind: String, }, } fn xyz(v: Vec3) -> [f64; 3] { [v.x, v.y, v.z] } /// `v` scaled to unit length, components within 1e-12 of zero made zero (a drilling direction /// found from the geometry carries round-off). fn unit(v: [f64; 3]) -> Option<[f64; 3]> { let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); (n.is_finite() && n > 1e-12).then(|| v.map(|c| if (c / n).abs() < 1e-12 { 0.0 } else { c / n })) } /// The ids of the features a persistent face name mentions (`F3:inner:F1:+y` → 3, 1). pub fn feature_ids(name: &str) -> impl Iterator + '_ { let bytes = name.as_bytes(); (0..bytes.len()).filter_map(move |i| { let starts = bytes.get(i) == Some(&b'F') && (i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric())); if !starts { return None; } let digits: &str = name.get(i + 1..)?.split(|c: char| !c.is_ascii_digit()).next()?; if digits.is_empty() { None } else { digits.parse().ok() } }) } /// `Taper angle` → `taper_angle`, `Distance (0 to 1)` → `distance_0_to_1`. fn snake(label: &str) -> String { let mut out = String::with_capacity(label.len()); for c in label.chars() { if c.is_ascii_alphanumeric() { out.push(c.to_ascii_lowercase()); } else if !out.ends_with('_') && !out.is_empty() { out.push('_'); } } out.trim_end_matches('_').to_string() } fn operation_name(op: Operation) -> &'static str { match op { Operation::NewBody => "new_body", Operation::Join => "join", Operation::Cut => "cut", Operation::Intersect => "intersect", } } fn curves(segs: &[Seg2]) -> Vec { // A loop of arcs about one centre and radius that turns a full circle is a circle. let arcs: Vec<(solidcraft_geom::Vec2, f64, f64)> = segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, sweep, .. } = s { Some((*center, *radius, *sweep)) } else { None }).collect(); if !arcs.is_empty() && arcs.len() == segs.len() { let (c0, r0, _) = arcs[0]; let same = arcs.iter().all(|(c, r, _)| (c.x - c0.x).abs() < 1e-9 && (c.y - c0.y).abs() < 1e-9 && (r - r0).abs() < 1e-9); let turn: f64 = arcs.iter().map(|a| a.2).sum(); if same && (turn.abs() - std::f64::consts::TAU).abs() < 1e-6 { return vec![Curve::Circle { center: [c0.x, c0.y], radius: r0 }]; } } segs.iter() .map(|s| match s { Seg2::Line { a, b } => Curve::Line { a: [a.x, a.y], b: [b.x, b.y] }, Seg2::Arc { center, radius, start, sweep } => Curve::Arc { center: [center.x, center.y], radius: *radius, start: *start, sweep: *sweep }, other => Curve::Other { kind: format!("{other:?}").split(['{', ' ', '(']).next().unwrap_or("curve").to_ascii_lowercase() }, }) .collect() } fn sketch_geometry(ss: &SolvedSketch, sel: Option<&solidcraft_doc::ProfileSel>) -> SketchGeometry { let indices: Vec = match sel { Some(sel) => solidcraft_doc::profile_selection_indices(ss, sel).unwrap_or_else(|_| (0..ss.profiles.len()).collect()), None => (0..ss.profiles.len()).collect(), }; let profiles = indices .into_iter() .filter_map(|i| { let p = ss.profiles.get(i)?; Some(ProfileGeometry { index: i, area: p.area, outer: curves(&p.region.outer.segs), holes: p.region.holes.iter().map(|h| curves(&h.segs)).collect(), outer_curves: p.outer_curves.clone(), hole_curves: p.hole_curves.clone(), }) }) .collect(); SketchGeometry { id: ss.feature, name: ss.name.clone(), origin: xyz(ss.plane.origin), x_dir: xyz(ss.plane.x), y_dir: xyz(ss.plane.y), normal: xyz(ss.plane.normal()), profiles, } } impl History { /// The feature `id` of the timeline, described against `body` (its faces and edges). fn node(&self, f: &solidcraft_doc::Feature, faces: Vec, edges: Vec) -> FeatureNode { let (vals, _) = self.doc.param_values(); let eval = |e: &str, k: Kind| Document::eval_in(&vals, e, k).ok().filter(|v| v.is_finite()); let mut values = BTreeMap::new(); for (label, e, k) in f.kind.inputs() { if let Some(v) = eval(&e, k) { let mut key = snake(label); let mut n = 2; while values.contains_key(&key) { key = format!("{}_{n}", snake(label)); n += 1; } values.insert(key, v); } } let point = |p: &[String; 3]| -> Option<[f64; 3]> { Some([eval(&p[0], Kind::Length)?, eval(&p[1], Kind::Length)?, eval(&p[2], Kind::Length)?]) }; let sketch_of = |id: u64| self.state.sketch(id); let mut node = FeatureNode { id: f.id, name: f.name.clone(), kind: String::new(), operation: None, values, placement: None, direction: None, two_sided: false, sketch: None, transforms: Vec::new(), targets: Vec::new(), tools: Vec::new(), creates: f.body_names.clone(), definition: serde_json::to_value(&f.kind).unwrap_or_default(), faces, edges, error: self.errors.get(&f.id).cloned(), }; node.kind = node.definition.get("type").and_then(serde_json::Value::as_str).unwrap_or("feature").to_string(); match &f.kind { FeatureKind::Extrude { sketch, profiles, extent, operation, targets, .. } => { node.operation = Some(operation_name(*operation).into()); node.targets = targets.clone(); if let Some(ss) = sketch_of(*sketch) { let n = xyz(ss.plane.normal()); let negative = matches!(extent.direction, solidcraft_doc::Direction::Negative); node.direction = unit(if negative { n.map(|c| -c) } else { n }); node.two_sided = matches!(extent.direction, solidcraft_doc::Direction::Symmetric) || extent.distance2.is_some(); node.sketch = Some(sketch_geometry(ss, Some(profiles))); } } FeatureKind::Revolve { sketch, profiles, operation, targets, .. } | FeatureKind::Sweep { sketch, profiles, operation, targets, .. } => { node.operation = Some(operation_name(*operation).into()); node.targets = targets.clone(); node.sketch = sketch_of(*sketch).map(|ss| sketch_geometry(ss, Some(profiles))); } FeatureKind::Loft { operation, targets, .. } => { node.operation = Some(operation_name(*operation).into()); node.targets = targets.clone(); } FeatureKind::Box { corner, operation, .. } => { node.operation = Some(operation_name(*operation).into()); node.placement = point(corner); node.direction = Some([0.0, 0.0, 1.0]); } FeatureKind::Cylinder { base, axis, operation, .. } => { node.operation = Some(operation_name(*operation).into()); node.placement = point(base); node.direction = unit(xyz(*axis)); } FeatureKind::Sphere { center, operation, .. } | FeatureKind::Torus { center, operation, .. } => { node.operation = Some(operation_name(*operation).into()); node.placement = point(center); } FeatureKind::Hole { position, direction, .. } => { node.operation = Some("cut".into()); node.placement = Some(xyz(*position)); node.direction = unit(xyz(*direction)); } FeatureKind::Combine { target, tools, operation, .. } => { node.operation = Some(operation_name(*operation).into()); node.targets = vec![target.clone()]; node.tools = tools.clone(); } FeatureKind::Shell { body, .. } | FeatureKind::Fillet { body, .. } | FeatureKind::Chamfer { body, .. } => { node.targets = body.iter().cloned().collect(); } FeatureKind::Split { body, .. } => node.targets = vec![body.clone()], FeatureKind::Pattern { pattern, bodies, .. } => { node.targets = bodies.clone(); if let Ok(m) = solidcraft_doc::pattern_matrices(&self.doc, &self.state, pattern) { // SolidCraft's matrices are column-major (`m[3]` the translation); the graph's are row-major. let rows = |m: [[f64; 4]; 4]| std::array::from_fn(|i| std::array::from_fn(|j| m[j][i])); node.transforms = m.into_iter().filter(|m| *m != solidcraft_doc::IDENTITY).map(rows).collect(); } } FeatureKind::Mirror { plane, bodies, .. } => { node.targets = bodies.clone(); if let Ok(p) = self.doc.resolve_plane_in(&vals, plane, 0, Some(&self.state)) && let Some(n) = unit(xyz(p.normal())) { // x' = x − 2 ((x − o)·n) n let o = xyz(p.origin); let d = 2.0 * (o[0] * n[0] + o[1] * n[1] + o[2] * n[2]); let mut m = solidcraft_doc::IDENTITY; for (i, row) in m.iter_mut().take(3).enumerate() { for (j, c) in row.iter_mut().take(3).enumerate() { *c -= 2.0 * n[i] * n[j]; } row[3] = d * n[i]; } node.transforms = vec![m]; } } _ => {} } node } } impl Design { /// The features that built each body of the design as opened (`body`: only that one), in /// timeline order. Partition cells are described by the body they were cut from. An error /// for a design not opened from a timeline (shapes built in code) or a body it lacks. pub fn features(&self, body: Option<&str>) -> Result> { let history = self.history.as_deref().ok_or_else(|| crate::Error::Invalid("the design has no feature timeline".into()))?; let shapes: Vec<&Shape> = match body { Some(name) => { let source = self.source_of(name).ok_or_else(|| crate::Error::Invalid(format!("no body `{name}`")))?; self.opened.iter().filter(|b| b.name == source).collect() } None => self.opened.iter().collect(), }; shapes.into_iter().map(|shape| guard("feature graph", || Ok(body_features(history, shape))).map_err(crate::Error::from)?).collect() } } fn body_features(history: &History, shape: &Shape) -> Result { // Faces by the features their names mention (a merged face: the names of its B-rep faces). let mut faces: BTreeMap> = BTreeMap::new(); for f in shape.faces() { let ids: BTreeSet = if f.members.is_empty() { feature_ids(&f.name).collect() } else { f.members.iter().flat_map(|m| feature_ids(m)).collect() }; for id in ids { faces.entry(id).or_default().push(f.name.clone()); } } let edges = shape.edges()?; // The body as SolidCraft names it before split pieces were numbered (`Bracket.2` was // `Bracket (1)`): features name it by its base. let base = shape.name.rsplit_once('.').filter(|(_, n)| n.bytes().all(|b| b.is_ascii_digit())).map_or(shape.name.as_str(), |(b, _)| b); let names_body = |f: &solidcraft_doc::Feature| { let mentioned = |n: &String| n == &shape.name || n == base || crate::design::split_base(n) == base; // The bodies the feature changes. A combine's tools are not among them: kept tools come // out unchanged, and the others are gone. let targets: Vec<&String> = match &f.kind { FeatureKind::Extrude { targets, .. } | FeatureKind::Revolve { targets, .. } | FeatureKind::Sweep { targets, .. } | FeatureKind::Loft { targets, .. } => targets.iter().collect(), FeatureKind::Combine { target, .. } => vec![target], FeatureKind::Shell { body, .. } | FeatureKind::Fillet { body, .. } | FeatureKind::Chamfer { body, .. } => body.iter().collect(), FeatureKind::Split { body, .. } => vec![body], // A body pattern or mirror: the original is the instance the copies repeat. FeatureKind::Pattern { bodies, .. } | FeatureKind::Mirror { bodies, .. } => bodies.iter().collect(), _ => Vec::new(), }; targets.into_iter().chain(&f.body_names).any(mentioned) }; let mut features = Vec::new(); for f in history.doc.features.iter().filter(|f| !f.suppressed) { if matches!( f.kind, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. } ) { continue; } let own = faces.remove(&f.id).unwrap_or_default(); if own.is_empty() && !names_body(f) { continue; } let tagged: BTreeSet = own.iter().filter_map(|n| shape.face_index(n)).collect(); let on: Vec = edges.iter().filter(|e| e.faces.iter().any(|i| tagged.contains(i))).map(|e| e.name.clone()).collect(); features.push(history.node(f, own, on)); if features.len() >= MAX_FEATURES_PER_BODY { break; } } Ok(BodyFeatures { body: shape.name.clone(), features }) } #[cfg(test)] mod tests { use super::*; #[test] fn feature_ids_come_from_every_name_part() { let ids = |n: &str| feature_ids(n).collect::>(); assert_eq!(ids("F3:inner:F2:blend:F1:+y|F1:-x"), vec![3, 2, 1, 1]); assert_eq!(ids("F82:-z@F84.2"), vec![82, 84]); assert_eq!(ids("F44:side:l12"), vec![44]); assert_eq!(ids("partition:Half:a"), Vec::::new()); assert_eq!(ids("OFF7"), Vec::::new()); assert_eq!(ids("F"), Vec::::new()); } #[test] fn the_plate_is_an_extrude_of_its_outline_and_a_cut_of_a_circle() { use crate::sample::{Plate, plate_design}; let plate = Plate::default(); let design = crate::open_design(&plate_design(&plate).unwrap()).unwrap(); let graph = design.features(None).unwrap(); assert_eq!(graph.len(), 1); let f = &graph[0].features; assert_eq!( f.iter().map(|n| (n.kind.as_str(), n.operation.as_deref())).collect::>(), [("extrude", Some("new_body")), ("extrude", Some("cut"))] ); let (outline, hole) = (&f[0], &f[1]); // Expressions come out evaluated: the thickness parameter, and `thickness + 1 mm`. assert_eq!(outline.values.get("distance"), Some(&plate.thickness)); assert_eq!(hole.values.get("distance"), Some(&(plate.thickness + 1.0))); assert_eq!(outline.direction, Some([0.0, 0.0, 1.0])); let sketch = outline.sketch.as_ref().unwrap(); assert_eq!(sketch.normal, [0.0, 0.0, 1.0]); assert_eq!(sketch.profiles.len(), 1); assert!(sketch.profiles[0].outer.iter().all(|c| matches!(c, Curve::Line { .. })) && sketch.profiles[0].outer.len() == 4); let circle = &hole.sketch.as_ref().unwrap().profiles[0].outer; let c = plate.depth / 2.0; assert_eq!(circle, &[Curve::Circle { center: [c, c], radius: plate.hole_diameter / 2.0 }]); // Each feature's faces: the outline's four sides and caps, the hole's two halves. assert_eq!(outline.faces.len(), 6, "{:?}", outline.faces); assert_eq!(hole.faces.len(), 2, "{:?}", hole.faces); assert!(!hole.edges.is_empty()); assert!(outline.error.is_none() && hole.error.is_none()); // Bodies the design lacks are an error, not an empty graph. assert!(design.features(Some("Nope")).is_err()); } #[test] fn labels_become_snake_case_keys() { assert_eq!(snake("Taper angle"), "taper_angle"); assert_eq!(snake("Distance (0 to 1)"), "distance_0_to_1"); assert_eq!(snake("Radius"), "radius"); } /// Primitives, a shell, a hole, a combine, a body pattern and a mirror: each body lists the /// features that built it, with their placements, directions, targets and transforms. #[cfg(feature = "scripts")] #[test] fn a_timeline_of_primitives_and_copies_is_listed_per_body() { let script = r#"[ {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "body_name": "Case"}}, {"command": "solid.shell", "params": {"faces": [[20, 15, 20]], "thickness": 2, "body": "Case"}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 5, "base": [10, 10, 2], "operation": "join", "targets": ["Case"]}}, {"command": "solid.hole", "params": {"position": [10, 10, 7], "diameter": 2, "depth": 4}}, {"command": "solid.box", "params": {"length": 4, "width": 4, "height": 20, "corner": [30, 20, 0], "body_name": "Slot"}}, {"command": "solid.combine", "params": {"target": "Case", "tools": ["Slot"], "operation": "cut"}}, {"command": "solid.box", "params": {"length": 1, "width": 1, "height": 3, "corner": [60, 0, 0], "body_name": "Pin"}}, {"command": "solid.pattern.rectangular", "params": {"bodies": ["Pin"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 2}}, {"command": "solid.box", "params": {"length": 2, "width": 2, "height": 2, "corner": [0, 50, 0], "body_name": "Foot"}}, {"command": "solid.mirror", "params": {"bodies": ["Foot"], "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}} ]"#; let design = crate::open_design(&crate::script_design(script).unwrap()).unwrap(); let case = design.features(Some("Case")).unwrap(); let kinds: Vec<&str> = case[0].features.iter().map(|f| f.kind.as_str()).collect(); assert_eq!(kinds, ["box", "shell", "cylinder", "hole", "box", "combine"], "{case:#?}"); let f = &case[0].features; assert_eq!(f[0].placement, Some([0.0, 0.0, 0.0])); assert_eq!((f[0].values["length"], f[0].values["width"], f[0].values["height"]), (40.0, 30.0, 20.0)); assert_eq!(f[1].values["thickness"], 2.0); assert_eq!((f[2].values["radius"], f[2].direction, f[2].operation.as_deref()), (3.0, Some([0.0, 0.0, 1.0]), Some("join"))); assert_eq!((f[3].values["diameter"], f[3].direction, f[3].placement), (2.0, Some([0.0, 0.0, -1.0]), Some([10.0, 10.0, 7.0]))); assert_eq!((f[5].targets.as_slice(), f[5].tools.as_slice()), (&["Case".to_string()][..], &["Slot".to_string()][..])); // The cut tool's faces are the slot's walls in the case. assert!(!f[4].faces.is_empty()); // The pattern's copies: each a body of its own with the pattern's transforms. let pins: Vec<&str> = design.bodies.iter().map(|b| b.name.as_str()).filter(|n| n.starts_with("Pin")).collect(); assert_eq!(pins.len(), 3, "{pins:?}"); for pin in pins { let g = design.features(Some(pin)).unwrap(); let kinds: Vec<&str> = g[0].features.iter().map(|f| f.kind.as_str()).collect(); assert_eq!(kinds, ["box", "pattern"], "{pin}"); let t = &g[0].features[1].transforms; assert_eq!(t.len(), 2); assert!(t.iter().any(|m| (m[0][3] - 2.0).abs() < 1e-9) && t.iter().any(|m| (m[0][3] - 4.0).abs() < 1e-9), "{t:?}"); } // The mirror: x' = 20 − x. let foot = design.bodies.iter().find(|b| b.name.starts_with("Foot") && b.name != "Foot").map(|b| b.name.clone()).unwrap_or_default(); let g = design.features(Some(&foot)).unwrap(); let mirror = g[0].features.iter().find(|f| f.kind == "mirror").unwrap_or_else(|| panic!("{foot}: {g:#?}")); assert_eq!(mirror.transforms, vec![[[-1.0, 0.0, 0.0, 20.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]]); // The cut tool is gone; the graph of every body comes in the design's order. let all = design.features(None).unwrap(); assert_eq!(all.iter().map(|b| b.body.as_str()).collect::>(), design.bodies.iter().map(|b| b.name.as_str()).collect::>()); } }