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

+68 -11
View File
@@ -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();
+1
View File
@@ -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", &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"]);
}
+111 -6
View File
@@ -90,12 +90,58 @@ pub fn is_iges_path(path: &str) -> bool {
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("igs") || e.eq_ignore_ascii_case("iges"))
}
/// What the bodies of an imported STEP file are named after. Files from different CAD systems
/// fill the solid's own name with different things (the part's name, the last feature's name,
/// nothing), so the caller chooses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StepNames {
/// The solid's own name in the file, else the name of the product that holds it.
#[default]
Solid,
/// The name of the product (part) that holds the body, else the solid's name.
Product,
/// The name of the assembly occurrence (instance) that holds the body, else the product's
/// name (a body of a root part has no occurrence), else the solid's.
Instance,
}
impl StepNames {
/// `solid`, `product` or `instance` (any case); `None` for anything else.
pub fn parse(s: &str) -> Option<StepNames> {
match s.trim().to_ascii_lowercase().as_str() {
"solid" => Some(StepNames::Solid),
"product" => Some(StepNames::Product),
"instance" => Some(StepNames::Instance),
_ => None,
}
}
}
/// A name that says nothing about its body: empty, or the placeholder a file or the importer
/// gives to something it has no name for.
fn generic_name(n: &str) -> bool {
n.trim().is_empty() || n.starts_with('(') || n.eq_ignore_ascii_case("import") || n.eq_ignore_ascii_case("unnamed")
}
/// The name an imported body gets under the `names` choice; the solid's name when the chosen
/// one is missing or says nothing.
fn body_name(b: &solidcraft_kernel::ImportedBody, names: StepNames) -> String {
let usable = |n: &&String| !generic_name(n);
let chosen = match names {
StepNames::Solid => None,
StepNames::Product => b.path.last().filter(usable),
StepNames::Instance => b.instance.as_ref().filter(usable).or(b.path.last().filter(usable)),
};
chosen.cloned().unwrap_or_else(|| b.name.clone())
}
/// A STEP file read as an import feature.
pub struct StepFeature {
/// Timeline name: the root product's name, else the file name.
pub name: String,
pub kind: solidcraft_doc::FeatureKind,
/// Body names from the file (solid names, else product names).
/// Body names from the file, as [`StepNames`] chose them (by default the solid names, else
/// the product names).
pub body_names: Vec<String>,
pub warnings: Vec<String>,
/// The file as read. Hold it until the feature has been evaluated: the timeline then reuses
@@ -106,6 +152,11 @@ pub struct StepFeature {
/// Read STEP bytes (`file` is the source file name) into an Import base feature. The file is
/// read once here to fail early and collect names; the feature holds the STEP text.
pub fn step_import_feature(bytes: &[u8], file: &str) -> Result<StepFeature> {
step_import_feature_named(bytes, file, StepNames::default())
}
/// [`step_import_feature`], with the bodies named after `names`.
pub fn step_import_feature_named(bytes: &[u8], file: &str, names: StepNames) -> Result<StepFeature> {
if bytes.len() > MAX_STEP_BYTES {
return Err(IoError::Invalid(format!("STEP file too large ({} MB, limit {} MB)", bytes.len() >> 20, MAX_STEP_BYTES >> 20)));
}
@@ -120,17 +171,17 @@ pub fn step_import_feature(bytes: &[u8], file: &str) -> Result<StepFeature> {
.map(|s| s.to_string_lossy().to_string())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "Import".into());
let generic = |n: &str| n.trim().is_empty() || n.starts_with('(') || n.eq_ignore_ascii_case("import") || n.eq_ignore_ascii_case("unnamed");
let body_names: Vec<String> = imp.bodies.iter().map(|b| body_name(b, names)).collect();
// The root product's name; else the only body's name; else the file name.
let name = match (imp.tree.as_slice(), imp.bodies.as_slice()) {
([root], _) if !generic(&root.name) => root.name.clone(),
(_, [only]) if !generic(&only.name) => only.name.clone(),
let name = match (imp.tree.as_slice(), body_names.as_slice()) {
([root], _) if !generic_name(&root.name) => root.name.clone(),
(_, [only]) if !generic_name(only) => only.clone(),
_ => stem,
};
let file_name = std::path::Path::new(file).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file.to_string());
Ok(StepFeature {
name,
body_names: imp.bodies.iter().map(|b| b.name.clone()).collect(),
body_names,
kind: solidcraft_doc::FeatureKind::Import { file: file_name, step: text.into(), components: imp.tree.clone() },
warnings: iges_warnings.into_iter().chain(imp.warnings.iter().cloned()).collect(),
import: imp,
@@ -707,6 +758,60 @@ mod tests {
assert!(read_stl(&fake).is_err());
}
/// A frame holding a plate and two posts, whose solids carry the names of the last feature
/// that made them, as some CAD systems write them; as STEP text.
fn frame_step() -> String {
use solidcraft_kernel::{ExportBody, ExportProduct, StepHeader, box_solid, step_export_products};
let plate = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 2.0)).unwrap();
let post = box_solid(Vec3::ZERO, Vec3::new(2.0, 2.0, 8.0)).unwrap();
let at = |x: 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, 0.0, 0.0, 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), "Plate-1".into()), (2, at(20.0), "Post-1".into()), (2, at(30.0), "Post-2".into())],
},
part("Plate", "Cut-Extrude3", &plate),
part("Post", "Boss-Extrude1", &post),
];
step_export_products(&products, 0, &StepHeader::default()).unwrap()
}
/// The bodies of a STEP file are named after the solid (the default), the product that holds
/// them, or the assembly occurrence; a name the file leaves out or fills with a placeholder
/// falls back to the next one.
#[test]
fn step_import_names_follow_the_choice() {
let text = frame_step();
let names = |text: &str, choice| step_import_feature_named(text.as_bytes(), "frame.step", choice).unwrap().body_names;
assert_eq!(names(&text, StepNames::Solid), ["Cut-Extrude3", "Boss-Extrude1", "Boss-Extrude1"]);
assert_eq!(names(&text, StepNames::Product), ["Plate", "Post", "Post"]);
assert_eq!(names(&text, StepNames::Instance), ["Plate-1", "Post-1", "Post-2"]);
// Without a choice it is the solid's name, as before.
assert_eq!(step_import_feature(text.as_bytes(), "frame.step").unwrap().body_names, names(&text, StepNames::Solid));
// The timeline entry is the root product's name whatever the bodies are called.
for choice in [StepNames::Solid, StepNames::Product, StepNames::Instance] {
assert_eq!(step_import_feature_named(text.as_bytes(), "frame.step", choice).unwrap().name, "Frame");
}
// A product the file leaves unnamed says nothing: its bodies keep the solid's name.
let unnamed = text.replace("PRODUCT('Plate','Plate',", "PRODUCT('(unnamed)','(unnamed)',");
assert_eq!(names(&unnamed, StepNames::Product), ["Cut-Extrude3", "Post", "Post"]);
// A part file has no occurrence: the instance choice gives the product's name.
let (_, m) = model();
let part = String::from_utf8(export(&m.state(), &[], Format::Step, "Bracket").unwrap()).unwrap();
assert_eq!(names(&part, StepNames::Solid), ["Body1"]);
assert_eq!(names(&part, StepNames::Product), ["Bracket"]);
assert_eq!(names(&part, StepNames::Instance), ["Bracket"]);
assert_eq!(StepNames::parse(" Product "), Some(StepNames::Product));
assert_eq!(StepNames::parse("INSTANCE"), Some(StepNames::Instance));
assert_eq!(StepNames::parse("label"), None);
}
fn stl_volume(bytes: &[u8]) -> f64 {
read_stl(bytes)
.unwrap()
+30 -8
View File
@@ -48,8 +48,12 @@ pub struct ImportedBody {
pub body: Body,
/// Surface colour (linear-ish RGB 0..1) from the file's presentation styles.
pub color: Option<[f32; 3]>,
/// Product names from the root to the part that holds the body.
/// Product names from the root to the part that holds the body (the last is the part's own
/// name; empty for a solid outside any product).
pub path: Vec<String>,
/// The name of the assembly occurrence (instance) that holds the body: the usage's name in
/// the file, else its id. `None` for a body of a root product or outside any product.
pub instance: Option<String>,
/// The body is a closed solid (false: faces were left out, or the file had a surface model).
pub closed: bool,
/// Faces read from the file. The body can have more: a face that wraps all the way around
@@ -599,7 +603,7 @@ impl<'a> Reader<'a> {
}
/// Bodies of a representation's items (following mapped items), placed by `m`.
fn rep_bodies(&mut self, rep: u64, m: &mt::Matrix4, path: &[String], product: &str, out: &mut Vec<usize>, depth: usize) {
fn rep_bodies(&mut self, rep: u64, m: &mt::Matrix4, path: &[String], product: &str, instance: Option<&str>, out: &mut Vec<usize>, depth: usize) {
if depth > MAX_ASSEMBLY_DEPTH {
self.warn("mapped items nested too deeply");
return;
@@ -699,6 +703,7 @@ impl<'a> Reader<'a> {
body,
color,
path: path.to_vec(),
instance: instance.map(str::to_string),
closed,
file_faces,
source_item: item,
@@ -726,7 +731,7 @@ impl<'a> Reader<'a> {
(Ok(t), Ok(o)) => {
let mm = mat_mul(m, &mat_mul(&t, &rigid_inverse(&o)));
for r in self.rep_closure(&[mapped]) {
self.rep_bodies(r, &mm, path, product, out, depth + 1);
self.rep_bodies(r, &mm, path, product, instance, out, depth + 1);
}
}
(Err(e), _) | (_, Err(e)) => self.warn(format!("mapped item #{item}: {e}")),
@@ -737,6 +742,13 @@ impl<'a> Reader<'a> {
}
}
/// The name of an assembly occurrence: the usage's name, else its id.
fn occurrence_name(&self, nauo: u64) -> Option<String> {
let p = self.ex.get(nauo)?.params();
let name = p.get(1).and_then(Param::as_str).filter(|s| !s.trim().is_empty()).or_else(|| p.first().and_then(Param::as_str))?;
Some(name.trim().to_string()).filter(|n| !n.is_empty())
}
fn product_name(&self, pd: u64) -> String {
let name = (|| {
let pdf = self.ex.get(self.ex.get(pd)?.params().get(2)?.as_ref_id()?)?;
@@ -787,7 +799,16 @@ impl<'a> Reader<'a> {
}
}
fn node(&mut self, pd: u64, local: mt::Matrix4, world: mt::Matrix4, path: &[String], depth: usize, stack: &mut Vec<u64>) -> StepNode {
fn node(
&mut self,
pd: u64,
local: mt::Matrix4,
world: mt::Matrix4,
path: &[String],
instance: Option<&str>,
depth: usize,
stack: &mut Vec<u64>,
) -> StepNode {
let name = self.product_name(pd);
let mut node = StepNode { name: name.clone(), transform: to_array(&local), bodies: Vec::new(), children: Vec::new() };
if self.stop() {
@@ -810,7 +831,7 @@ impl<'a> Reader<'a> {
let mut bodies = Vec::new();
self.node_items.clear();
for r in &reps {
self.rep_bodies(*r, &world, &path, &name, &mut bodies, 0);
self.rep_bodies(*r, &world, &path, &name, instance, &mut bodies, 0);
}
node.bodies = bodies;
for nauo in self.children.get(&pd).cloned().unwrap_or_default() {
@@ -819,7 +840,8 @@ impl<'a> Reader<'a> {
let child_reps = self.rep_closure(&child_own);
let m = self.occurrence_matrix(nauo, &child_reps);
let w = mat_mul(&world, &m);
let c = self.node(child, m, w, &path, depth + 1, stack);
let instance = self.occurrence_name(nauo);
let c = self.node(child, m, w, &path, instance.as_deref(), depth + 1, stack);
node.children.push(c);
}
stack.pop();
@@ -1054,7 +1076,7 @@ fn step_import_with_workers(text: &str, workers: usize) -> Result<StepImport> {
let identity = mt::Matrix4::from_scale(1.0);
let mut tree = Vec::new();
for pd in roots {
let n = rd.node(pd, identity, identity, &[], 0, &mut Vec::new());
let n = rd.node(pd, identity, identity, &[], None, 0, &mut Vec::new());
if !n.bodies.is_empty() || !n.children.is_empty() {
tree.push(n);
}
@@ -1077,7 +1099,7 @@ fn step_import_with_workers(text: &str, workers: usize) -> Result<StepImport> {
rd.node_items = rd.used_items.clone();
rd.visits = 0;
for r in loose {
rd.rep_bodies(r, &identity, &[], "Import", &mut bodies, 0);
rd.rep_bodies(r, &identity, &[], "Import", None, &mut bodies, 0);
}
if !bodies.is_empty() {
tree.push(StepNode { name: "Import".into(), transform: to_array(&identity), bodies, children: Vec::new() });
+42 -1
View File
@@ -184,7 +184,7 @@ fn parallel_import_does_not_prepare_past_body_budget_with_duplicates_or_mapped_i
reader.bodies = vec![seed.clone(); MAX_BODIES - 2];
reader.node_items.insert(2000);
let mut out = Vec::new();
reader.rep_bodies(60, &mt::Matrix4::from_scale(1.0), &[], "Budget", &mut out, 0);
reader.rep_bodies(60, &mt::Matrix4::from_scale(1.0), &[], "Budget", None, &mut out, 0);
assert_eq!(reader.bodies.len(), MAX_BODIES);
assert_eq!(out, vec![MAX_BODIES - 2, MAX_BODIES - 1]);
assert_eq!(reader.bodies.last().unwrap().name, "Second");
@@ -453,6 +453,7 @@ fn hand_written_part_in_inches_with_colour() {
let b = &imp.bodies[0];
assert_eq!(b.name, "Cube");
assert_eq!(b.path, vec!["Widget".to_string()]);
assert_eq!(b.instance, None, "a root part is no occurrence");
assert_eq!(b.color, Some([1.0, 0.5, 0.0]));
let m = measure(&b.body).unwrap();
assert!(rel(m.volume, 25.4f64.powi(3)) < 1e-9, "{}", m.volume);
@@ -520,6 +521,7 @@ fn hand_written_assembly() {
assert_eq!(root.children.len(), 2);
assert!(root.children.iter().all(|c| c.name == "Block" && c.bodies.len() == 1));
assert_eq!(imp.bodies[1].path, vec!["Assembly".to_string(), "Block".to_string()]);
assert_eq!([imp.bodies[0].instance.as_deref(), imp.bodies[1].instance.as_deref()], [Some("Block:0"), Some("Block:1")]);
assert!((root.children[1].transform[3][0] - 100.0).abs() < 1e-9);
}
@@ -548,6 +550,45 @@ fn later_instances_show_the_first_instances_mesh_placed() {
assert!(split.display_of.is_none());
}
/// A body knows the product that holds it and the assembly occurrence it is, next to the name of
/// its solid: a caller can name bodies after the part or the instance when the solids carry the
/// name of the last feature that made them (`Cut-Extrude3`), which says nothing about the part.
#[test]
fn bodies_know_their_product_and_instance() {
use crate::step_out::{ExportBody, ExportProduct, StepHeader, step_export_products};
let plate = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 2.0)).unwrap();
let post = box_solid(Vec3::ZERO, Vec3::new(2.0, 2.0, 8.0)).unwrap();
let at = |x: 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, 0.0, 0.0, 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), "Plate-1".into()), (2, at(20.0), "Post-1".into()), (2, at(30.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 imp = step_import(&text).unwrap();
let seen: Vec<(&str, Vec<&str>, Option<&str>)> =
imp.bodies.iter().map(|b| (b.name.as_str(), b.path.iter().map(String::as_str).collect(), b.instance.as_deref())).collect();
assert_eq!(
seen,
[
("Cut-Extrude3", vec!["Frame", "Plate"], Some("Plate-1")),
("Boss-Extrude1", vec!["Frame", "Post"], Some("Post-1")),
("Boss-Extrude1", vec!["Frame", "Post"], Some("Post-2")),
]
);
// The same product's two placements are two occurrences, told apart by their names.
assert_eq!(imp.tree[0].children.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Plate", "Post", "Post"]);
}
/// The import keeps a conversion only where the file can ask for the item again. A flat file of
/// separate solids converts each item exactly once (no second copy of the whole import is held);
/// an assembly that instances one item keeps it, and each instance still gets geometry of its
+28
View File
@@ -196,6 +196,34 @@ fn agent_builds_box_fillet_hole() {
let _ = std::fs::remove_dir_all(&dir);
}
/// `open` hands `up` and `names` to the STEP import, and the tool's schema refuses other values.
#[test]
fn open_stands_a_y_up_step_file_up() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 40, "height": 5}}));
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-up-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let step = dir.join("tall.step").to_string_lossy().to_string();
tool(&mut s, "export", json!({"path": step}));
let height = |s: &mut Server| {
let bbox = tool(s, "measure", json!({}))["bodies"][0]["bbox"].clone();
bbox["max"][2].as_f64().unwrap() - bbox["min"][2].as_f64().unwrap()
};
let r = tool(&mut s, "open", json!({"path": step}));
assert_eq!(r["features"], 1, "{r}");
assert!((height(&mut s) - 5.0).abs() < 1e-6, "as written");
let r = tool(&mut s, "open", json!({"path": step, "up": "y", "names": "product"}));
assert_eq!(r["features"], 2, "the import and the turn: {r}");
assert!((height(&mut s) - 40.0).abs() < 1e-6, "Y became Z");
for args in [json!({"path": step, "up": "x"}), json!({"path": step, "names": "label"}), json!({"path": step, "up": 1})] {
let r = raw_tool(&mut s, "open", args.clone());
assert_eq!(r["error"]["code"], -32602, "{args}: {r}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_selects_container_or_json_and_reopens_by_contents() {
let mut s = server();
+12 -3
View File
@@ -238,8 +238,17 @@ pub fn tool_definitions() -> Vec<Value> {
"Open a design",
"Open a .solidcraft design file, a STEP file (.step/.stp: a new design whose bodies come from the file, as an \
Import base feature) or a 3MF/STL mesh (.3mf/.stl: mesh bodies). Replaces the current design; insert into it \
instead with the file.insert_step (STEP) or file.insert_mesh (3MF/STL) commands.",
obj(json!({"path": string("Path to a .solidcraft, .step/.stp, .3mf or .stl file")}), &["path"]),
instead with the file.insert_step (STEP) or file.insert_mesh (3MF/STL) commands. A STEP file from a system \
that has Y up opens lying on its back: `up: \"y\"` turns it a quarter turn about X (Y becomes Z) with a Move \
feature. `names` picks what the bodies are named after when the solids' own names say nothing useful.",
obj(
json!({
"path": string("Path to a .solidcraft, .step/.stp, .3mf or .stl file"),
"up": {"type": "string", "enum": ["z", "y"], "description": "STEP only. z (default) keeps the file's axes; y: the file has Y up, so its bodies are turned to stand Z up"},
"names": {"type": "string", "enum": ["solid", "product", "instance"], "description": "STEP only. What the bodies are named after: solid (default: the solid's own name, else the product's), product (the part's name) or instance (the assembly occurrence's name, else the product's)"},
}),
&["path"],
),
Effect::Files,
),
tool(
@@ -401,7 +410,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
"undo" => exec(b, "edit.undo", json!({})),
"redo" => exec(b, "edit.redo", json!({})),
"new_design" => exec(b, "file.new", pick(a, &["name"])),
"open" => exec(b, "doc.open", pick(a, &["path"])),
"open" => exec(b, "doc.open", pick(a, &["path", "up", "names"])),
"save" => exec(b, "file.save", pick(a, &["path", "format"])),
other => Err(format!("unknown tool `{other}`")),
}
+18 -6
View File
@@ -39,20 +39,29 @@ pub struct Request {
pub reply: Option<Sender<ControlResponse>>,
/// The folders that caller's agent may use: the worker reads the file inside them.
pub scope: Option<solidcraft_engine::io::vfs::PathScope>,
/// What that caller asked of a STEP import besides its path (`up`, `names`): the command
/// gets them as given.
pub options: serde_json::Map<String, Value>,
}
impl Request {
pub fn new(path: &str, kind: Kind) -> Self {
Request { path: path.into(), kind, reply: None, scope: None }
Request { path: path.into(), kind, reply: None, scope: None, options: Default::default() }
}
fn insert(&self) -> bool {
self.kind == Kind::Insert
}
/// The command's parameters: the path and the caller's import options.
fn params(&self) -> Value {
let mut params = self.options.clone();
params.insert("path".into(), json!(self.path));
Value::Object(params)
}
fn command(&self) -> (&'static str, Value) {
match &self.kind {
Kind::Open => ("doc.open", json!({"path": self.path})),
Kind::Open => ("doc.open", self.params()),
Kind::Insert if solidcraft_engine::io::is_mesh_path(&self.path) => ("file.insert_mesh", json!({"path": self.path})),
Kind::Insert => ("file.insert_step", json!({"path": self.path})),
Kind::Insert => ("file.insert_step", self.params()),
Kind::Recover { id, dir } => ("doc.recover", json!({"id": id, "dir": dir})),
}
}
@@ -185,7 +194,8 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
Some("file.insert_step" | "file.insert_mesh") => Kind::Insert,
_ => return Some(req),
};
let Some(path) = req.params.get("params").and_then(|p| p.get("path")).and_then(Value::as_str) else { return Some(req) };
let params = req.params.get("params");
let Some(path) = params.and_then(|p| p.get("path")).and_then(Value::as_str) else { return Some(req) };
let scope = match crate::control::request_scope(app, &req.params) {
Ok(scope) => scope,
Err(e) => {
@@ -193,7 +203,8 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
return None;
}
};
let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope });
let options = ["up", "names"].iter().filter_map(|k| Some((k.to_string(), params?.get(*k).filter(|v| !v.is_null())?.clone()))).collect();
let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope, options });
None
}
@@ -550,7 +561,8 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
}
app.home.open = false;
app.fit_view();
if request.kind == Kind::Open || request.insert() {
// A caller that said which way is up has been answered: no offer to stand the part up.
if (request.kind == Kind::Open || request.insert()) && !request.options.contains_key("up") {
crate::stand_up::offer(app, &request.path, &running.before);
}
let warnings = made.result.get("warnings").and_then(Value::as_array);
+46
View File
@@ -225,3 +225,49 @@ fn a_control_open_answers_after_loading_without_freezing_the_window() {
assert_eq!(w.app.session.doc.features.len(), sync.doc.features.len() + 1, "the edit went into the opened design");
let _ = std::fs::remove_dir_all(&dir);
}
/// A control-channel (MCP) import hands its `up` and `names` to the command, as the engine
/// session takes them, and a caller that said which way is up is not offered to stand the part up.
#[test]
fn a_control_import_passes_up_and_names_on() {
let dir = scratch("import-options");
let path = dir.join("tall_in_y.step").to_string_lossy().to_string();
let mut src = Session::default();
src.execute("solid.box", &json!({"length": 10, "width": 40, "height": 5})).unwrap();
src.execute("file.export", &json!({"path": path})).unwrap();
// The reply, the part's height and whether the offer is up, for an import by this window.
let import = |params: serde_json::Value| {
let mut w = Win::new();
let (tx, rx) = std::sync::mpsc::channel();
w.app.control_rx = Some(rx);
let (req, reply_rx) = crate::control::ControlRequest::new("engine.execute", json!({"command": "file.insert_step", "params": params.clone()}));
tx.send(req).unwrap();
let mut reply = None;
for _ in 0..100_000 {
w.frame(vec![]);
reply = reply_rx.try_recv().ok();
if reply.is_some() {
break;
}
}
let reply = reply.expect("the import was answered");
assert_eq!(reply["ok"], true, "{params}: {reply}");
let mut sync = Session::default();
assert_eq!(reply["result"], sync.execute("file.insert_step", &params).unwrap(), "the window's reply is the session's");
let b = solidcraft_engine::view::bounds(&w.app.session);
(reply, [b.max.x - b.min.x, b.max.y - b.min.y, b.max.z - b.min.z], w.app.stand_up.is_some())
};
// As before: the file's axes, and the offer to stand it up.
let (_, size, offered) = import(json!({"path": path}));
assert!(size[1] > 39.9 && offered, "{size:?} {offered}");
// Y up said by the caller: stood up by the import, nothing left to offer.
let (reply, size, offered) = import(json!({"path": path, "up": "y", "names": "product"}));
assert!(size[2] > 39.9 && !offered, "{size:?} {offered}: {reply}");
assert!(reply["result"]["stand_up"].is_u64(), "{reply}");
// Z up said by the caller: kept as it is, and not asked about either.
let (_, size, offered) = import(json!({"path": path, "up": "z"}));
assert!(size[1] > 39.9 && !offered, "{size:?} {offered}");
let _ = std::fs::remove_dir_all(&dir);
}