Import FreeCAD and Rhino files through cadmpeg (#1435)
FreeCAD .FCStd and Rhino .3dm files open, insert and drop like STEP and IGES: cadmpeg (Apache-2.0, pinned =0.6.1, native builds only) detects the format, decodes it under its service resource caps, validates the model and writes it as STEP AP214, which becomes an Import feature in a new component named after the file, with the up and names options. Only the fcstd, rhino and step codecs are compiled in (no default features). ACIS is left out: cadmpeg's sat codec is a ShapeManager stream decoder. native_cad.rs is the single importer seam. Self-made FreeCAD and Rhino box fixtures, hostile-input, ZIP-bomb and cap tests, engine tests for the component path; the browser build reports these formats as not supported. A note under design format 23 says an Import's file can name these formats. Co-authored-by: Jacob Oursland <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2b45635d73
commit
78242d67d1
23 files changed
+1585
-328
No files matched your search
@@ -20,11 +20,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .solidcraft design, or a .step/.stp/.igs/.3mf/.stl/.obj/.glb/.gltf file (opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES or glTF file (see file.insert_step and file.insert_mesh); names?: solid|product|instance for a STEP or IGES file (see file.insert_step)"),
|
||||
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: solid|product|instance for a STEP, IGES, FreeCAD or Rhino 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 in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); 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)"),
|
||||
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); 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")
|
||||
@@ -215,7 +215,7 @@ fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.insert_step";
|
||||
let path = path_arg(p, cmd)?;
|
||||
if !solidcraft_io::is_step_path(path) {
|
||||
return Err(bad(cmd, "only STEP files (.step, .stp) can be inserted"));
|
||||
return Err(bad(cmd, "only STEP, IGES, FreeCAD and Rhino files (.step, .stp, .igs, .iges, .fcstd, .3dm) can be inserted"));
|
||||
}
|
||||
let options = step_options(p, cmd)?;
|
||||
add_step(s, path, str_(p, "name"), &options)
|
||||
|
||||
@@ -314,6 +314,8 @@ mod loft_cut;
|
||||
mod loft_ring;
|
||||
mod loft_spacing;
|
||||
mod mirror_planes;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_cad_import;
|
||||
mod path_scope;
|
||||
mod primitive_points;
|
||||
mod rim_cut;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! FreeCAD and Rhino files (read through cadmpeg) go the same way as STEP files: a new rigid
|
||||
//! component named after the file (#1426), with the `up` and `names` options (#1312).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::run;
|
||||
use crate::*;
|
||||
|
||||
const BLOCK_FCSTD: &[u8] = include_bytes!("../../../io/src/native_cad/fixtures/block.FCStd");
|
||||
const BLOCK_3DM: &[u8] = include_bytes!("../../../io/src/native_cad/fixtures/block.3dm");
|
||||
|
||||
/// `bytes` saved as `name` in a fresh temp directory.
|
||||
fn file(tag: &str, name: &str, bytes: &[u8]) -> (std::path::PathBuf, String) {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-native-cad-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
(dir, path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
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 volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
fn body_names(s: &Session) -> Vec<String> {
|
||||
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freecad_and_rhino_files_come_in_as_a_component_named_after_the_file() {
|
||||
for (tag, name, bytes) in [("fc", "block.FCStd", BLOCK_FCSTD), ("rh", "block.3dm", BLOCK_3DM)] {
|
||||
let (dir, path) = file(tag, name, bytes);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0]}));
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
assert_eq!(r["component_name"], "block", "{name}: {r}");
|
||||
let feature = r["feature"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.feature(feature).unwrap().kind.type_name(), "BaseFeature", "{name}");
|
||||
assert_eq!(s.doc.feature(feature).unwrap().component, r["component"].as_u64().unwrap(), "{name}");
|
||||
assert!((volume(&mut s) - (6000.0 + 125.0)).abs() < 1e-6, "{name}");
|
||||
// Rhino keeps the object's name; cadmpeg leaves FreeCAD's label out, so it is the file's.
|
||||
assert!(body_names(&s).iter().any(|n| n == "Block" || n == "block"), "{name}: {:?}", body_names(&s));
|
||||
assert_eq!(s.active_component, 0, "{name}: the active component is unchanged");
|
||||
|
||||
// As written the block spans 10 × 20 × 30; up: y stands it up, (x, y, z) → (x, -z, y).
|
||||
let mut d = Session::default();
|
||||
let r = run(&mut d, "doc.open", json!({"path": path}));
|
||||
assert_eq!((r["features"].clone(), r["component_name"].clone()), (json!(1), json!("block")), "{name}: {r}");
|
||||
assert!(near(extent(&d).1, [10.0, 20.0, 30.0]), "{name}: {:?}", extent(&d));
|
||||
let mut d = Session::default();
|
||||
let r = run(&mut d, "file.insert_step", json!({"path": path, "up": "y"}));
|
||||
assert_eq!(r["stand_up"], r["occurrence"], "{name}: {r}");
|
||||
let (lo, hi) = extent(&d);
|
||||
assert!(near(lo, [0.0, -30.0, 0.0]) && near(hi, [10.0, 0.0, 20.0]), "{name}: stood up: {lo:?} {hi:?}");
|
||||
|
||||
// Every names choice is taken, and the body is named something.
|
||||
for names in ["solid", "product", "instance"] {
|
||||
let mut d = Session::default();
|
||||
run(&mut d, "file.insert_step", json!({"path": path, "names": names}));
|
||||
let got = body_names(&d);
|
||||
assert!(got.len() == 1 && !got[0].is_empty(), "{name} names={names}: {got:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// A broken file is refused with a message naming it, and the design is left as it was.
|
||||
#[test]
|
||||
fn a_broken_freecad_or_rhino_file_is_refused_cleanly() {
|
||||
let mut cut = BLOCK_FCSTD[..BLOCK_FCSTD.len() / 2].to_vec();
|
||||
cut.extend_from_slice(b"garbage");
|
||||
for (tag, name, bytes) in
|
||||
[("bad-fc", "broken.FCStd", cut.as_slice()), ("bad-rh", "broken.3dm", &BLOCK_3DM[..200]), ("acis", "cube.sat", b"700 0 1 0\n")]
|
||||
{
|
||||
let (dir, path) = file(tag, name, bytes);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5}));
|
||||
let before = s.doc.clone();
|
||||
let e: Result<Value> = s.execute("file.insert_step", &json!({"path": path}));
|
||||
let e = e.expect_err(name).to_string();
|
||||
assert!(!e.contains("internal"), "{name}: a clean error, not a caught panic: {e}");
|
||||
assert_eq!(s.doc, before, "{name}: the design is unchanged");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user