Files
OneCAD-Simulation/crates/ui-egui/src/feature_graph.rs
T

384 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
pub error: Option<String>,
}
/// `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::<String>() + 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<String> {
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<String> = [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<String> = 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)");
}
}