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"]);
|
||||
}
|
||||
+111
-6
@@ -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()
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}`")),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", ¶ms).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);
|
||||
}
|
||||
+2
-2
@@ -181,10 +181,10 @@ The `camera` of `screenshot` (the object `view.apply` and `view.save` take too)
|
||||
|
||||
To look closely at one part of a large model, such as a STEP assembly, work in a headless session. These steps change only the open design in memory:
|
||||
|
||||
1. Open the model with `solidcraft-cli mcp --in assembly.step` (or the `open` tool). `inspect_design` lists the bodies with their names and bounding boxes.
|
||||
1. Open the model with `solidcraft-cli mcp --in assembly.step` (or the `open` tool, which also takes `up` and `names`, see step 4 and [STEP import](step-import.md)). `inspect_design` lists the bodies with their names and bounding boxes.
|
||||
2. Remove what is in the way with `solid.remove`, e.g. `{"bodies": ["Cover"]}`.
|
||||
3. Cut the region out of the rest with `solid.box`, e.g. `{"corner": [40, 0, -45], "length": 20, "width": 50, "height": 45, "operation": "intersect", "targets": ["Housing", "Plate"]}`. Each listed body is trimmed to the box, and one that lies wholly outside the box is removed. The box itself is not kept.
|
||||
4. If the model lies on its back (many STEP files have +Y up), stand it Z up with `solid.move`, listing every body you keep, e.g. `{"bodies": ["Housing", "Plate"], "axis": [1, 0, 0], "angle": 90}`. It turns the bodies about the world origin, and a bare angle is in degrees. A turn of +90° about X takes +Y to +Z.
|
||||
4. If the model lies on its back (many STEP files have +Y up), open it with `up: "y"` (the `open` tool, `doc.open`, `file.insert_step`), which turns the file's bodies a quarter turn about X (Y becomes Z). One already open is stood Z up with `solid.move`, listing every body you keep, e.g. `{"bodies": ["Housing", "Plate"], "axis": [1, 0, 0], "angle": 90}`. It turns the bodies about the world origin, and a bare angle is in degrees. A turn of +90° about X takes +Y to +Z.
|
||||
5. Colour the parts that matter with `appearance.assign`, e.g. `{"bodies": ["Housing"], "color": "#3a7bd5"}`, and hide helper bodies or sketches with `browser.visibility`, e.g. `{"items": ["Plate"], "visible": false}`.
|
||||
6. Cut a section with `inspect.section`, e.g. `{"plane": {"origin": [50, 30, 0], "normal": [0, 1, 0]}}` or `{"plane": "XY", "at": 5}`.
|
||||
7. Take the picture with `screenshot`: a `camera` whose `target` is the centre of the region and whose eye is on the cut side, `source: "model"`, and `path`, `width` and `height` as needed. An orthographic picture is 0.8 × `distance` high, so a `distance` of about 1.5 times the region's size frames it. Look at the image, adjust `yaw`, `pitch` and `distance`, and shoot again. Headless, the picture is always a render of the model; bridged to the app, `source: "model"` asks for that render instead of the app's window.
|
||||
|
||||
+12
-1
@@ -6,7 +6,18 @@ SolidCraft reads STEP (ISO 10303-21; AP203, AP214 and AP242 geometry) with its o
|
||||
- **Insert** into the current design: File → Insert STEP… or SOLID → INSERT (`file.insert_step {path}`).
|
||||
- **Drag and drop** a STEP file on the window: inserted when the design has features, opened otherwise.
|
||||
|
||||
Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the solid's name, else the product's); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature for the components milestone. Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges.
|
||||
Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the solid's name, else the product's; `names` below chooses); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature for the components milestone. Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges.
|
||||
|
||||
## Files from other CAD systems
|
||||
|
||||
Files from different systems differ in which way is up and in what they call their solids. `doc.open`, `file.insert_step` and the MCP `open` tool take two options for STEP and IGES files; without them a file comes in as written.
|
||||
|
||||
- **`up`**: `"z"` (the default) keeps the file's axes. Many systems write Y up, so such a part lies on its back in SolidCraft's Z-up space. `"up": "y"` turns the file's bodies a quarter turn about X (+90°: Y becomes Z) with one Move feature right after the Import, the one the window's "Stand it up" offer adds. The import and the turn are one undo step, the reply names the Move as `stand_up`, and only the bodies of the file turn, not the ones already in the design. The window offers to stand a part up after a STEP file opens unless the call said which way is up (`up: "z"` as well as `"y"`).
|
||||
- **`names`**: what the bodies are called. `"solid"` (the default) is the name of the solid in the file, and the product's name where the solid has none. Some systems fill a solid's name with the last feature that made it (`Cut-Extrude3`), so every body of an assembly has a name that says nothing about its part. `"product"` names each body after the product (the part) that holds it, and `"instance"` after the assembly occurrence (`Post-2`), or the product where the body is not in an occurrence. A name the file leaves out, or fills with a placeholder, falls back to the next one (instance, product, solid). Repeated names are numbered as any body names are (`Post`, `Post (2)`). The names are stored with the Import feature, so the design reopens with them. The default stays the solid's name because SolidCraft's own STEP export writes a body's name there.
|
||||
|
||||
```json
|
||||
{"command": "file.insert_step", "params": {"path": "frame.step", "up": "y", "names": "instance"}}
|
||||
```
|
||||
|
||||
## Imported appearances
|
||||
|
||||
|
||||
Reference in new issue
Block a user