Feature graph: each feature of a timeline in plain words

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-17 00:50:52 -04:00
1 parent 3977a0b630
commit e955b67bb7
2 files changed
+384

No files matched your search

+383
View File
@@ -0,0 +1,383 @@
//! 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)");
}
}
+1
View File
@@ -30,6 +30,7 @@ pub mod context;
pub mod control;
pub mod describe;
pub mod elide;
pub mod feature_graph;
pub mod fields;
pub mod files;
pub mod form;