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:
1 parent
81d9b7e04a
commit
0ee5e97db5
12 files changed
+572
-38
No files matched your search
@@ -17,11 +17,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .solidcraft design, or a .step/.stp file (opens as a new design)"),
|
||||
.params("path: .solidcraft design, or a .step/.stp file (opens as a new design); up?: z|y and names?: solid|product|instance for a STEP or IGES file (see file.insert_step)"),
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: .step/.stp file (its bodies join the design as an Import base feature); name?"),
|
||||
.params("path: .step/.stp file (its bodies join the design as an Import base feature); name?; up?: z|y (default z: the file's axes are kept; y: the file has Y up, so its bodies are turned a quarter turn about X, Y becoming Z, by a Move feature after the import); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's)"),
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -59,15 +59,60 @@ fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
|
||||
str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))
|
||||
}
|
||||
|
||||
/// How a STEP or IGES file comes in beyond its path: which way is up in the file, and what its
|
||||
/// bodies are named after.
|
||||
struct StepOptions {
|
||||
/// `up: "y"`: the file has Y up, so the bodies it brings are turned a quarter turn about X
|
||||
/// (Y becomes Z), as the app's "Stand it up" offer does. `z` (the default) keeps the file's axes.
|
||||
y_up: bool,
|
||||
names: solidcraft_io::StepNames,
|
||||
}
|
||||
|
||||
fn step_options(p: &Value, cmd: &str) -> Result<StepOptions> {
|
||||
let y_up = match p.get("up") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => match v.as_str().map(|u| u.trim().to_ascii_lowercase()).as_deref() {
|
||||
Some("z") => false,
|
||||
Some("y") => true,
|
||||
_ => return Err(bad(cmd, "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)")),
|
||||
},
|
||||
};
|
||||
let names = match p.get("names") {
|
||||
None | Some(Value::Null) => solidcraft_io::StepNames::default(),
|
||||
Some(v) => {
|
||||
v.as_str().and_then(solidcraft_io::StepNames::parse).ok_or_else(|| bad(cmd, "`names` must be \"solid\", \"product\" or \"instance\""))?
|
||||
}
|
||||
};
|
||||
Ok(StepOptions { y_up, names })
|
||||
}
|
||||
|
||||
/// Read a STEP file as an Import feature.
|
||||
fn read_step(path: &str) -> Result<solidcraft_io::StepFeature> {
|
||||
fn read_step(path: &str, names: solidcraft_io::StepNames) -> Result<solidcraft_io::StepFeature> {
|
||||
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_STEP_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
solidcraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
solidcraft_io::step_import_feature_named(&bytes, path, names).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// Turn bodies a quarter turn about X (a file's Y up becomes Z up) with one Move feature, the
|
||||
/// same one the app's "Stand it up" offer adds, and report its id.
|
||||
fn stand_up(s: &mut Session, bodies: &[String]) -> Result<u64> {
|
||||
let kind = solidcraft_doc::FeatureKind::Move {
|
||||
bodies: bodies.to_vec(),
|
||||
translate: ["0".into(), "0".into(), "0".into()],
|
||||
rotate_axis: Some(solidcraft_geom::Vec3::X),
|
||||
angle: Some("90 deg".into()),
|
||||
copy: false,
|
||||
};
|
||||
let id = s.doc_mut().add_feature(kind, None)?;
|
||||
s.refresh();
|
||||
match s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
Some(e) => Err(EngineError::Other(e)),
|
||||
None => Ok(id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an Import feature for a STEP file to the session's design.
|
||||
fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
let sf = read_step(path)?;
|
||||
fn add_step(s: &mut Session, path: &str, name: Option<&str>, options: &StepOptions) -> Result<Value> {
|
||||
let sf = read_step(path, options.names)?;
|
||||
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&sf.name).to_string();
|
||||
let id = s.doc_mut().add_feature(sf.kind, Some(&fname))?;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
@@ -78,10 +123,14 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
let created: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
|
||||
let turned = if options.y_up && !created.is_empty() { Some(stand_up(s, &created)?) } else { None };
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}))
|
||||
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": created, "created_bodies": created, "warnings": sf.warnings});
|
||||
if let Some(turned) = turned {
|
||||
out["stand_up"] = json!(turned);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Add a MeshImport feature for a 3MF or STL file.
|
||||
@@ -119,16 +168,24 @@ fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !solidcraft_io::is_step_path(path) {
|
||||
return Err(bad(cmd, "only STEP files (.step, .stp) can be inserted"));
|
||||
}
|
||||
add_step(s, path, str_(p, "name"))
|
||||
let options = step_options(p, cmd)?;
|
||||
add_step(s, path, str_(p, "name"), &options)
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_arg(p, "doc.open")?;
|
||||
if !solidcraft_io::is_step_path(path) && ["up", "names"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad("doc.open", "`up` and `names` apply to STEP and IGES files only"));
|
||||
}
|
||||
if solidcraft_io::is_step_path(path) || solidcraft_io::is_mesh_path(path) {
|
||||
// A new, unsaved design holding the file's bodies.
|
||||
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
|
||||
let mut fresh = Session::new(Document::new(&stem));
|
||||
let r = if solidcraft_io::is_step_path(path) { add_step(&mut fresh, path, None)? } else { add_mesh(&mut fresh, path, None)? };
|
||||
let r = if solidcraft_io::is_step_path(path) {
|
||||
add_step(&mut fresh, path, None, &step_options(p, "doc.open")?)?
|
||||
} else {
|
||||
add_mesh(&mut fresh, path, None)?
|
||||
};
|
||||
fresh.undo.clear();
|
||||
s.replace_design(fresh);
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
|
||||
@@ -318,6 +318,7 @@ mod rim_cut;
|
||||
mod robustness;
|
||||
mod robustness_goldens;
|
||||
mod spiral_join;
|
||||
mod step_import_options;
|
||||
mod xz_plane;
|
||||
|
||||
/// Plan area a round of radius `r` fills at the concave corner `x` where circles (c1, r1) and
|
||||
|
||||
@@ -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", ¶ms).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, ¶ms).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"]);
|
||||
}
|
||||
Reference in new issue
Block a user