//! The `geometry.features` reply in words: each feature of a body's SolidCraft timeline as one //! line a person reads ("Extrude 16 mm along +Z", "Hole Ø 14 through", "Fillet R 1.5 on 4 //! edges") and its remaining inputs as labelled details. Pure functions of the reply's JSON; the //! context panel ([`crate::reply`]) draws them. use serde_json::Value; use crate::describe::quantity; /// What a feature does, for its glyph. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { Extrude, Cut, Revolve, Primitive, Hole, Fillet, Chamfer, Shell, Pattern, Mirror, Boolean, Other, } /// One feature made readable. #[derive(Clone, Debug, PartialEq)] pub struct Feature { pub id: u64, pub kind: Kind, pub name: String, /// What it does with its key inputs, in plain words. pub summary: String, /// Every other input and fact the reply has: (label, value). pub details: Vec<(String, String)>, /// The persistent names of the body's faces it made or shaped. pub faces: Vec, pub error: Option, } /// `v` to 3 significant figures, as the panel writes numbers. fn num(v: f64) -> String { if v == 0.0 || !v.is_finite() { return quantity(v); } let scale = 10f64.powi(2 - v.abs().log10().floor() as i32); quantity((v * scale).round() / scale) } fn mm(v: f64) -> String { format!("{} mm", num(v)) } fn deg(radians: f64) -> String { format!("{}°", num(radians.to_degrees())) } /// A direction as its axis (`+Z`) when it is one, else its components. pub fn axis(d: [f64; 3]) -> String { const TOL: f64 = 1e-6; for (i, name) in ["X", "Y", "Z"].iter().enumerate() { let c = d.get(i).copied().unwrap_or(0.0); let rest: f64 = d.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, v)| v.abs()).sum(); if (c.abs() - 1.0).abs() < TOL && rest < TOL { return format!("{}{name}", if c > 0.0 { "+" } else { "−" }); } } format!("({}, {}, {})", num(d[0]), num(d[1]), num(d[2])) } /// A snake-case input key as a label: `taper_angle` → "Taper angle". fn label(key: &str) -> String { let words = key.replace('_', " "); let mut c = words.chars(); c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() } /// An input's value with its unit, from its key: angles in degrees, counts and scales bare, /// everything else a length in mm. fn input(key: &str, v: f64) -> String { if key.contains("angle") { deg(v) } else if key.contains("quantity") || key.contains("scale") || key.contains("factor") || key == "ribs" { num(v) } else { mm(v) } } fn plural(n: usize, one: &str, many: &str) -> String { format!("{n} {}", if n == 1 { one } else { many }) } fn vec3(v: &Value) -> Option<[f64; 3]> { serde_json::from_value(v.clone()).ok() } fn strings(v: &Value) -> Vec { v.as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() } /// The feature node `f` of a `geometry.features` reply made readable. pub fn describe(f: &Value) -> Feature { let kind_name = f["type"].as_str().unwrap_or_default(); let op = f["operation"].as_str(); let def = &f["definition"]; let get = |k: &str| f["values"][k].as_f64(); let mut used: Vec<&str> = Vec::new(); let mut take = |k: &'static str| { used.push(k); get(k) }; let dir = vec3(&f["direction"]).map(axis); let along = dir.as_ref().map(|d| format!(" along {d}")).unwrap_or_default(); let joined = match op { Some("join") => ", joined", Some("cut") => ", cut", Some("intersect") => ", intersected", _ => "", }; let edges_n = def["edges"].as_array().map(Vec::len); let on_edges = edges_n.map(|n| format!(" on {}", plural(n, "edge", "edges"))).unwrap_or_default(); let copies = f["transforms"].as_array().map_or(0, Vec::len); let (kind, summary) = match kind_name { "extrude" | "revolve" => { let (kind, verb) = match op { Some("cut") => (Kind::Cut, "Cut"), Some("intersect") => (Kind::Cut, "Intersect"), Some("join") if kind_name == "extrude" => (Kind::Extrude, "Join"), _ if kind_name == "revolve" => (Kind::Revolve, "Revolve"), _ => (Kind::Extrude, "Extrude"), }; let mut s = if kind_name == "revolve" { format!("{verb} {}", take("angle").map(deg).unwrap_or_default()) } else { let d = take("distance"); if def["extent"]["through_all"] == true { format!("{verb} through all{along}") } else if f["two_sided"] == true { format!("{verb} {} both sides", d.map(mm).unwrap_or_default()) } else { format!("{verb} {}{along}", d.map(mm).unwrap_or_default()) } }; if let Some(a) = take("taper_angle").filter(|a| *a != 0.0) { s += &format!(", taper {}", deg(a)); } if let Some(t) = take("thickness") { s += &format!(", thin {}", mm(t)); } (kind, s.trim_end().to_string()) } "box" => { let (l, w, h) = (take("length"), take("width"), take("height")); let dims: Vec = [l, w, h].into_iter().flatten().map(num).collect(); (Kind::Primitive, format!("Box {} mm{joined}", dims.join(" × "))) } "cylinder" => { let (r, h) = (take("radius"), take("height")); let s = format!("Cylinder Ø {} × {} mm{along}{joined}", r.map(|r| num(2.0 * r)).unwrap_or_default(), h.map(num).unwrap_or_default()); (Kind::Primitive, s) } "sphere" => (Kind::Primitive, format!("Sphere Ø {}{joined}", take("radius").map(|r| mm(2.0 * r)).unwrap_or_default())), "torus" => { let (a, b) = (take("major_radius"), take("minor_radius")); (Kind::Primitive, format!("Torus R {} × r {}{joined}", a.map(num).unwrap_or_default(), b.map(mm).unwrap_or_default())) } "hole" => { let d = take("diameter").map(num).unwrap_or_default(); let mut s = match take("depth") { Some(depth) => format!("Hole Ø {d} × {} deep", mm(depth)), None => format!("Hole Ø {d} through"), }; if let (Some(a), Some(b)) = (take("counterbore_diameter"), take("counterbore_depth")) { s += &format!(", counterbore Ø {} × {}", num(a), mm(b)); } if let (Some(a), Some(b)) = (take("countersink_diameter"), take("countersink_angle")) { s += &format!(", countersink Ø {} × {}", num(a), deg(b)); } (Kind::Hole, s) } "fillet" => { let r = match (take("radius"), take("start_radius"), take("end_radius")) { (Some(r), _, _) => format!("R {}", num(r)), (None, Some(a), Some(b)) => format!("R {}–{}", num(a), num(b)), _ => String::new(), }; (Kind::Fillet, format!("Fillet {r}{on_edges}").replace(" ", " ")) } "chamfer" => { let d = take("distance").map(mm).unwrap_or_default(); let a = take("angle").map(|a| format!(" × {}", deg(a))).unwrap_or_default(); (Kind::Chamfer, format!("Chamfer {d}{a}{on_edges}")) } "shell" => { let faces = def["faces"].as_array().map(Vec::len).unwrap_or(0); let open = if faces > 0 { format!(", {} open", plural(faces, "face", "faces")) } else { String::new() }; (Kind::Shell, format!("Shell, {} wall{open}", take("thickness").map(mm).unwrap_or_default())) } "pattern" | "linear_pattern" | "circular_pattern" => { let n = take("quantity"); let s = match (n, take("spacing").or_else(|| take("distance")), take("total_angle")) { (Some(n), _, Some(a)) => format!("Pattern {} × over {}", num(n), deg(a)), (Some(n), Some(d), _) => format!("Pattern {} × {} apart", num(n), mm(d)), _ => format!("Pattern of {}", plural(copies + 1, "instance", "instances")), }; (Kind::Pattern, s) } "mirror" => (Kind::Mirror, format!("Mirror, {}", plural(copies.max(1), "copy", "copies"))), "combine" => { let verb = match op { Some("cut") => "cut", Some("intersect") => "intersect", _ => "join", }; let tools = strings(&f["tools"]); let targets = strings(&f["targets"]); let s = match (tools.is_empty(), targets.is_empty()) { (false, false) => format!("Combine: {verb} {} with {}", targets.join(", "), tools.join(", ")), _ => format!("Combine: {verb}"), }; (Kind::Boolean, s) } other => { let mut s = label(other); let first: Vec = f["values"] .as_object() .into_iter() .flatten() .take(2) .filter_map(|(k, v)| v.as_f64().map(|v| format!("{} {}", label(k).to_lowercase(), input(k, v)))) .collect(); if !first.is_empty() { s += &format!(", {}", first.join(", ")); } (Kind::Other, format!("{s}{joined}")) } }; let mut details: Vec<(String, String)> = Vec::new(); for (k, v) in f["values"].as_object().into_iter().flatten() { if let Some(v) = v.as_f64() && !used.contains(&k.as_str()) { details.push((label(k), input(k, v))); } } if let Some(sk) = f["sketch"].as_object() { let n = sk.get("profiles").and_then(Value::as_array).map_or(0, Vec::len); let name = sk.get("name").and_then(Value::as_str).unwrap_or("sketch"); details.push(("Sketch".into(), format!("{name} ({})", plural(n, "profile", "profiles")))); } if let Some(d) = dir { details.push(("Direction".into(), d)); } if let Some(p) = vec3(&f["placement"]) { details.push(("At".into(), format!("({}, {}, {}) mm", num(p[0]), num(p[1]), num(p[2])))); } if let Some(op) = op { details.push(("Operation".into(), op.replace('_', " "))); } for (key, name) in [("targets", "Changes"), ("tools", "Tools"), ("creates", "Creates")] { let v = strings(&f[key]); if !v.is_empty() { details.push((name.into(), v.join(", "))); } } let faces = strings(&f["faces"]); let edges = f["edges"].as_array().map_or(0, Vec::len); details.push(("Shapes".into(), format!("{}, {}", plural(faces.len(), "face", "faces"), plural(edges, "edge", "edges")))); Feature { id: f["id"].as_u64().unwrap_or(0), kind, name: f["name"].as_str().unwrap_or(kind_name).to_string(), summary, details, faces, error: f["error"].as_str().map(str::to_string), } } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn summary(f: Value) -> String { describe(&f).summary } #[test] fn extrudes_and_cuts_say_how_far_and_which_way() { let f = json!({"type": "extrude", "operation": "new_body", "values": {"distance": 16.0}, "direction": [0, 0, 1], "definition": {}}); assert_eq!(summary(f), "Extrude 16 mm along +Z"); let f = json!({"type": "extrude", "operation": "cut", "values": {"distance": 8.0}, "direction": [0, 0, -1], "definition": {}}); assert_eq!(summary(f), "Cut 8 mm along −Z"); let f = json!({"type": "extrude", "operation": "cut", "values": {"distance": 0.0}, "direction": [0, 0, 1], "definition": {"extent": {"through_all": true}}}); assert_eq!(summary(f), "Cut through all along +Z"); let f = json!({"type": "extrude", "operation": "join", "two_sided": true, "values": {"distance": 10.0}, "definition": {}}); assert_eq!(summary(f), "Join 10 mm both sides"); let f = json!({"type": "extrude", "values": {"distance": 5.0, "taper_angle": 0.0523599}, "direction": [0.6, 0.8, 0], "definition": {}}); assert_eq!(describe(&f).kind, Kind::Extrude); assert_eq!(summary(f), "Extrude 5 mm along (0.6, 0.8, 0), taper 3°"); } #[test] fn primitives_give_their_size() { let f = json!({"type": "cylinder", "operation": "join", "values": {"radius": 23.0, "height": 22.0}, "direction": [0, 0, 1], "definition": {}}); assert_eq!(summary(f), "Cylinder Ø 46 × 22 mm along +Z, joined"); let f = json!({"type": "box", "values": {"length": 100.0, "width": 40.0, "height": 5.0}, "definition": {}}); assert_eq!(summary(f), "Box 100 × 40 × 5 mm"); let f = json!({"type": "sphere", "operation": "cut", "values": {"radius": 2.5}, "definition": {}}); assert_eq!(summary(f), "Sphere Ø 5 mm, cut"); } #[test] fn holes_say_through_or_how_deep() { let f = json!({"type": "hole", "operation": "cut", "values": {"diameter": 3.2}, "definition": {}}); assert_eq!(describe(&f).kind, Kind::Hole); assert_eq!(summary(f), "Hole Ø 3.2 through"); let f = json!({"type": "hole", "values": {"diameter": 6.0, "depth": 10.0, "counterbore_diameter": 11.0, "counterbore_depth": 6.5}, "definition": {}}); assert_eq!(summary(f), "Hole Ø 6 × 10 mm deep, counterbore Ø 11 × 6.5 mm"); } #[test] fn edge_features_count_their_edges() { let f = json!({"type": "fillet", "values": {"radius": 2.0}, "definition": {"edges": [[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]]}}); assert_eq!(describe(&f).kind, Kind::Fillet); assert_eq!(summary(f), "Fillet R 2 on 4 edges"); let f = json!({"type": "fillet", "values": {"start_radius": 1.0, "end_radius": 3.0}, "definition": {"edges": [[0, 0, 0]]}}); assert_eq!(summary(f), "Fillet R 1–3 on 1 edge"); let f = json!({"type": "chamfer", "values": {"distance": 0.5}, "definition": {"edges": [[0, 0, 0], [1, 1, 1]]}}); assert_eq!(describe(&f).kind, Kind::Chamfer); assert_eq!(summary(f), "Chamfer 0.5 mm on 2 edges"); } #[test] fn shells_patterns_mirrors_and_combines() { let f = json!({"type": "shell", "values": {"thickness": 2.0}, "definition": {"faces": ["top"]}}); assert_eq!(summary(f), "Shell, 2 mm wall, 1 face open"); let f = json!({"type": "pattern", "values": {"quantity": 4.0, "spacing": 30.0}, "definition": {}}); assert_eq!(describe(&f).kind, Kind::Pattern); assert_eq!(summary(f), "Pattern 4 × 30 mm apart"); let f = json!({"type": "pattern", "values": {"quantity": 6.0, "total_angle": std::f64::consts::TAU}, "definition": {}}); assert_eq!(summary(f), "Pattern 6 × over 360°"); let f = json!({"type": "mirror", "values": {}, "transforms": [[[1, 0, 0, 0], [0, -1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]], "definition": {}}); assert_eq!(describe(&f).kind, Kind::Mirror); assert_eq!(summary(f), "Mirror, 1 copy"); let f = json!({"type": "combine", "operation": "cut", "targets": ["Base"], "tools": ["Tool"], "values": {}, "definition": {}}); assert_eq!(describe(&f).kind, Kind::Boolean); assert_eq!(summary(f), "Combine: cut Base with Tool"); } #[test] fn other_kinds_name_their_first_inputs_and_details_hold_the_rest() { let f = json!({"type": "offset_face", "values": {"distance": 1.5}, "definition": {}}); assert_eq!(summary(f), "Offset face, distance 1.5 mm"); let f = json!({ "id": 2, "name": "Arm", "type": "extrude", "operation": "new_body", "values": {"distance": 16.0, "start_offset": 1.0}, "direction": [0, 0, 1], "creates": ["CrankArm"], "sketch": {"name": "ArmProfile", "profiles": [{}]}, "faces": ["F2:top", "F2:start"], "edges": ["e1"], "definition": {}, "error": "no profile" }); let d = describe(&f); assert_eq!((d.id, d.name.as_str(), d.faces.len(), d.error.as_deref()), (2, "Arm", 2, Some("no profile"))); let get = |k: &str| d.details.iter().find(|(l, _)| l == k).map(|(_, v)| v.as_str()); assert_eq!(get("Start offset"), Some("1 mm")); assert_eq!(get("Distance"), None, "the summary already says it"); assert_eq!(get("Sketch"), Some("ArmProfile (1 profile)")); assert_eq!(get("Creates"), Some("CrankArm")); assert_eq!(get("Shapes"), Some("2 faces, 1 edge")); } #[test] fn hostile_nodes_never_panic() { for f in [json!(null), json!([]), json!({"type": 3, "values": "x", "direction": [1]}), json!({"type": "hole", "values": {"diameter": f64::MAX}})] { let _ = describe(&f); } assert_eq!(axis([0.0, 0.0, 0.0]), "(0, 0, 0)"); } }