STEP import takes up for Y-up files and names for the bodies' names (#1312)

file.insert_step and doc.open on a STEP file take `up` (turn a Y-up file so its up is +Z) and `names` (names for the imported bodies). The MCP tool and the app's control requests pass them through; an import without them works as before. Tests in the kernel's STEP reader, the engine, MCP and the file-load UI; docs/step-import.md and docs/mcp.md describe both.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-15 02:18:55 -04:00
1 parent 81d9b7e04a
commit 0ee5e97db5
12 files changed
+572 -38

No files matched your search

@@ -0,0 +1,202 @@
//! STEP files written by other CAD systems: Y up (`up`) and the names of the bodies (`names`)
//! on `file.insert_step` and `doc.open`.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_kernel::{ExportBody, ExportProduct, StepHeader, box_solid, step_export_products};
use super::run;
use crate::*;
/// A frame as a Y-up file has it, in a STEP file in a fresh temp directory: a plate 40 wide,
/// 4 high (Y) and 20 deep, with two posts 4 wide and 30 high standing on it. The solids are
/// named after the last feature that made them (`Cut-Extrude3`), and the posts are two
/// occurrences of one part. As written the frame spans x 0..40, y 0..34, z 0..20.
fn frame_file(tag: &str) -> String {
let dir = std::env::temp_dir().join(format!("solidcraft-step-options-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 4.0, 20.0)).unwrap();
let post = box_solid(Vec3::ZERO, Vec3::new(4.0, 30.0, 4.0)).unwrap();
let at = |x: f64, y: f64, z: f64| [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [x, y, z, 1.0]];
let part = |name: &str, label: &str, body| ExportProduct {
name: name.into(),
bodies: vec![ExportBody { name: label.into(), body, color: None }],
children: Vec::new(),
};
let products = vec![
ExportProduct {
name: "Frame".into(),
bodies: Vec::new(),
children: vec![
(1, at(0.0, 0.0, 0.0), "Plate-1".into()),
(2, at(10.0, 4.0, 8.0), "Post-1".into()),
(2, at(26.0, 4.0, 8.0), "Post-2".into()),
],
},
part("Plate", "Cut-Extrude3", &plate),
part("Post", "Boss-Extrude1", &post),
];
let text = step_export_products(&products, 0, &StepHeader::default()).unwrap();
let path = dir.join("frame.step");
std::fs::write(&path, text).unwrap();
path.to_string_lossy().to_string()
}
/// The lowest and highest corner of everything the design shows.
fn extent(s: &Session) -> ([f64; 3], [f64; 3]) {
let b = crate::view::bounds(s);
([b.min.x, b.min.y, b.min.z], [b.max.x, b.max.y, b.max.z])
}
fn near(a: [f64; 3], b: [f64; 3]) -> bool {
a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6)
}
fn body_names(s: &Session) -> Vec<String> {
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
}
fn kinds(s: &Session) -> Vec<&'static str> {
s.doc.features.iter().map(|f| f.kind.type_name()).collect()
}
fn total_volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
/// `up: "y"` turns the file's bodies a quarter turn about X, Y becoming Z: the frame's height
/// lies along Z, and what was its depth now runs towards -Y.
#[test]
fn a_y_up_step_file_comes_in_standing_up() {
let path = frame_file("up");
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path}));
let (lo, hi) = extent(&s);
assert!(near(lo, [0.0, 0.0, 0.0]) && near(hi, [40.0, 34.0, 20.0]), "as written: {lo:?} {hi:?}");
assert_eq!(kinds(&s), ["BaseFeature"]);
// An explicit z keeps the file's axes, in any case.
for up in ["z", "Z"] {
let mut d = Session::default();
let r = run(&mut d, "file.insert_step", json!({"path": path, "up": up}));
assert_eq!(kinds(&d), ["BaseFeature"], "{r}");
assert!(r.get("stand_up").is_none(), "{r}");
assert!(near(extent(&d).1, [40.0, 34.0, 20.0]));
}
// y: (x, y, z) goes to (x, -z, y), so the frame, 34 high in Y, stands 34 high in Z.
let mut d = Session::default();
let undo = d.undo.len();
let r = run(&mut d, "file.insert_step", json!({"path": path, "up": "y"}));
let (lo, hi) = extent(&d);
assert!(near(lo, [0.0, -20.0, 0.0]) && near(hi, [40.0, 0.0, 34.0]), "stood up: {lo:?} {hi:?}");
assert_eq!(kinds(&d), ["BaseFeature", "MoveFeature"]);
assert_eq!(r["stand_up"], d.doc.features[1].id, "{r}");
assert_eq!(r["created_bodies"].as_array().unwrap().len(), 3, "{r}");
assert!((total_volume(&mut d) - (40.0 * 4.0 * 20.0 + 2.0 * 4.0 * 30.0 * 4.0)).abs() < 1e-6);
// The Import and the turn are one undo step.
assert_eq!(d.undo.len(), undo + 1);
run(&mut d, "edit.undo", json!({}));
assert!(d.doc.features.is_empty());
// Only the file's bodies turn, not the ones already in the design.
let mut d = Session::default();
run(&mut d, "solid.box", json!({"length": 5, "width": 6, "height": 7, "corner": [100, 100, 100]}));
run(&mut d, "file.insert_step", json!({"path": path, "up": "Y"}));
let (lo, hi) = extent(&d);
assert!(near(lo, [0.0, -20.0, 0.0]) && near(hi, [105.0, 106.0, 107.0]), "{lo:?} {hi:?}");
// The turn is an ordinary Move feature: the saved design has it and needs no new field.
let design = std::path::Path::new(&path).with_file_name("frame.solidcraft");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "file.insert_step", json!({"path": path, "up": "y"}));
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": design.to_string_lossy()}));
assert!(near(extent(&again).1, [40.0, 0.0, 34.0]));
}
/// `doc.open` takes the same options and starts a new design that has the turn in it.
#[test]
fn opening_a_y_up_step_file_can_stand_it_up() {
let path = frame_file("open");
let mut s = Session::default();
let r = run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
assert_eq!(r["features"], 2, "{r}");
assert!(near(extent(&s).1, [40.0, 0.0, 34.0]));
assert!(s.undo.is_empty(), "a freshly opened design has no history");
run(&mut s, "doc.open", json!({"path": path}));
assert_eq!(kinds(&s), ["BaseFeature"]);
// The options belong to STEP files: a design file does not take them.
let design = std::path::Path::new(&path).with_file_name("kept.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
let before = s.doc.clone();
for p in [json!({"up": "y"}), json!({"names": "product"})] {
let mut params = p.clone();
params["path"] = json!(design.to_string_lossy());
assert!(s.execute("doc.open", &params).is_err(), "{params}");
}
assert!(std::sync::Arc::ptr_eq(&s.doc, &before));
}
/// A value that is not one of the choices is refused, and the design stays as it was.
#[test]
fn wrong_up_and_names_are_refused() {
let path = frame_file("refused");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5}));
let before = s.doc.clone();
for p in [
json!({"up": "x"}),
json!({"up": "-y"}),
json!({"up": 5}),
json!({"up": ["y"]}),
json!({"names": "label"}),
json!({"names": 7}),
json!({"up": "y", "names": "nope"}),
] {
let mut params = p.clone();
params["path"] = json!(path);
for command in ["file.insert_step", "doc.open"] {
let e = s.execute(command, &params).unwrap_err().to_string();
assert!(e.contains("`up`") || e.contains("`names`"), "{command} {params}: {e}");
assert!(std::sync::Arc::ptr_eq(&s.doc, &before), "{command} {params} changed the design");
}
}
// Null means not given.
run(&mut s, "file.insert_step", json!({"path": path, "up": null, "names": null}));
assert_eq!(kinds(&s), ["BoxFeature", "BaseFeature"]);
}
/// `names` says what the bodies are called: the solid's own name (the default), the part's, or
/// the assembly occurrence's. Repeated names are numbered as ever.
#[test]
fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
let path = frame_file("names");
let named = |params: Value| {
let mut s = Session::default();
let r = run(&mut s, "file.insert_step", params);
assert_eq!(r["bodies"], json!(body_names(&s)), "{r}");
assert_eq!(r["name"], "Frame", "the timeline entry is the root product's");
body_names(&s)
};
let solids = ["Cut-Extrude3", "Boss-Extrude1", "Boss-Extrude1 (2)"];
assert_eq!(named(json!({"path": path})), solids);
assert_eq!(named(json!({"path": path, "names": "solid"})), solids);
assert_eq!(named(json!({"path": path, "names": "product"})), ["Plate", "Post", "Post (2)"]);
assert_eq!(named(json!({"path": path, "names": "Instance"})), ["Plate-1", "Post-1", "Post-2"]);
// The names are stored with the import: a saved design keeps them, and `up` does not change them.
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": path, "names": "instance", "up": "y"}));
assert_eq!(body_names(&s), ["Plate-1", "Post-1", "Post-2"]);
let design = std::path::Path::new(&path).with_file_name("frame-named.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": design.to_string_lossy()}));
assert_eq!(body_names(&again), ["Plate-1", "Post-1", "Post-2"]);
// A feature finds them by those names.
run(&mut again, "solid.move", json!({"bodies": ["Post-2"], "translate": [0, 0, 5]}));
assert_eq!(body_names(&again), ["Plate-1", "Post-1", "Post-2"]);
}