STEP: open/insert/drag-drop
Import base feature (FeatureKind::Import, timeline type BaseFeature) holding the STEP text and the file's product tree; bodies named from the file. doc.open accepts .step/.stp (any case) as a new design; Insert STEP (FusionImportCommandFromToolbar) adds one to the current design; File menu, SOLID > INSERT, drag-and-drop (insert into a non-empty design, else open), 'solvecraft part.step', MCP open. Features (fillet, hole, sketch-on-face cut, shell) work on imported bodies. Imports are cached so a file is read once per command. Measure tightens its chord tolerance for small radii. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
ed611ed7ba
commit
f3dbb12b23
18 files changed
+360
-18
No files matched your search
@@ -1,4 +1,4 @@
|
||||
//! Documents and files: new, open, save, export.
|
||||
//! Documents and files: new, open (designs and STEP), insert STEP, save, export.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::Document;
|
||||
@@ -10,7 +10,15 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("NewDocumentCommand", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
|
||||
CommandSpec::new("doc.open", "Open", open).icon("open").key("Ctrl+O").noundo().params("path: .solvecraft design"),
|
||||
CommandSpec::new("doc.open", "Open", open)
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .solvecraft design, or a .step/.stp file (opens as a new design)"),
|
||||
CommandSpec::new("FusionImportCommandFromToolbar", "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?"),
|
||||
CommandSpec::new("SaveDocumentCommand", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
|
||||
CommandSpec::new("SaveDocumentAsCommand", "Save As", save_as).icon("save").noundo().params("path"),
|
||||
CommandSpec::new("ExportCommand", "Export", export)
|
||||
@@ -30,8 +38,56 @@ 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"))
|
||||
}
|
||||
|
||||
/// Read a STEP file as an Import feature.
|
||||
fn read_step(path: &str) -> Result<solvecraft_io::StepFeature> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() > solvecraft_io::MAX_STEP_BYTES as u64 {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
solvecraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
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) {
|
||||
f.body_names = sf.body_names;
|
||||
}
|
||||
s.active_sketch = None;
|
||||
s.refresh();
|
||||
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 fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": sf.warnings}))
|
||||
}
|
||||
|
||||
fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionImportCommandFromToolbar";
|
||||
let path = path_arg(p, cmd)?;
|
||||
if !solvecraft_io::is_step_path(path) {
|
||||
return Err(bad(cmd, "only STEP files (.step, .stp) can be inserted"));
|
||||
}
|
||||
add_step(s, path, str_(p, "name"))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_arg(p, "doc.open")?;
|
||||
if solvecraft_io::is_step_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 = add_step(&mut fresh, path, None)?;
|
||||
fresh.undo.clear();
|
||||
*s = fresh;
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
|
||||
}
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() as usize > solvecraft_io::MAX_DESIGN_BYTES {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
|
||||
@@ -380,4 +380,95 @@ fn timeline_edits_reresolve_references() {
|
||||
run(&mut s, "UndoCommand", json!({}));
|
||||
run(&mut s, "UndoCommand", json!({}));
|
||||
assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0 - f4 - PI * 9.0 * 25.0) < 1e-3);
|
||||
|
||||
/// A 40 × 30 × 20 box exported to a STEP file in a fresh temp directory.
|
||||
fn step_box_file(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-step-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20}));
|
||||
let p = dir.join("Block.step");
|
||||
run(&mut s, "ExportCommand", json!({"path": p.to_string_lossy()}));
|
||||
p
|
||||
}
|
||||
|
||||
/// File → Open of a STEP file gives an Import base feature, and features work on its faces.
|
||||
#[test]
|
||||
fn step_open_then_model_on_it() {
|
||||
let p = step_box_file("open");
|
||||
let mut s = Session::default();
|
||||
let r = run(&mut s, "doc.open", json!({"path": p.to_string_lossy()}));
|
||||
assert_eq!(r["features"], 1, "{r}");
|
||||
assert_eq!(s.doc.name, "Block");
|
||||
assert_eq!(s.doc.features[0].kind.type_name(), "BaseFeature");
|
||||
assert!(matches!(&s.doc.features[0].kind, doc::FeatureKind::Import { file, .. } if file == "Block.step"));
|
||||
assert!(s.is_dirty() && s.path.is_none() && s.undo.is_empty());
|
||||
assert!(rel(volume(&mut s), 24000.0) < 1e-6);
|
||||
let corner = run(&mut s, "MeasureCommand", json!({}))["bodies"][0]["bbox"]["min"].clone();
|
||||
let (x0, y0, z0) = (corner[0].as_f64().unwrap(), corner[1].as_f64().unwrap(), corner[2].as_f64().unwrap());
|
||||
let at = |x: f64, y: f64, z: f64| json!([x0 + x, y0 + y, z0 + z]);
|
||||
|
||||
// Fillet a vertical edge, drill a through hole, cut a pocket sketched on the top face.
|
||||
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [at(0.0, 0.0, 10.0)], "radius": 3}));
|
||||
let mut want = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0;
|
||||
assert!(rel(volume(&mut s), want) < 2e-4);
|
||||
run(&mut s, "FusionHoleCommand", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6}));
|
||||
want -= PI * 9.0 * 20.0;
|
||||
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
|
||||
run(&mut s, "SketchCreate", json!({"plane": {"face": at(10.0, 15.0, 20.0)}}));
|
||||
let c = [x0 + 10.0, y0 + 15.0];
|
||||
// Sketch coordinates on the face are relative to the model origin projected onto it.
|
||||
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "Extrude", json!({"distance": -5, "operation": "cut"}));
|
||||
want -= 64.0 * 5.0;
|
||||
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
|
||||
assert_eq!(s.model.results.iter().filter(|r| r.error.is_some()).count(), 0);
|
||||
|
||||
// The design saves and reopens without the STEP file.
|
||||
let before = volume(&mut s);
|
||||
let design = p.with_extension("solvecraft");
|
||||
run(&mut s, "SaveDocumentAsCommand", json!({"path": design.to_string_lossy()}));
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
let mut s2 = Session::default();
|
||||
run(&mut s2, "doc.open", json!({"path": design.to_string_lossy()}));
|
||||
assert!(rel(volume(&mut s2), before) < 1e-6);
|
||||
}
|
||||
|
||||
/// Shell an imported body; insert STEP into an existing design; extension case; bad files.
|
||||
#[test]
|
||||
fn step_insert_shell_and_errors() {
|
||||
let p = step_box_file("insert");
|
||||
let upper = p.with_file_name("BLOCK.STP");
|
||||
std::fs::copy(&p, &upper).unwrap();
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": upper.to_string_lossy()}));
|
||||
let top = run(&mut s, "MeasureCommand", json!({}))["bodies"][0]["bbox"]["max"].clone();
|
||||
let (x1, y1, z1) = (top[0].as_f64().unwrap(), top[1].as_f64().unwrap(), top[2].as_f64().unwrap());
|
||||
run(&mut s, "FusionShellBodyCommand", json!({"faces": [[x1 - 20.0, y1 - 15.0, z1]], "thickness": 2}));
|
||||
let walls = 24000.0 - 36.0 * 26.0 * 18.0;
|
||||
assert!(rel(volume(&mut s), walls) < 5e-4, "{}", volume(&mut s));
|
||||
|
||||
let mut d = Session::default();
|
||||
run(&mut d, "PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0]}));
|
||||
let r = run(&mut d, "FusionImportCommandFromToolbar", json!({"path": p.to_string_lossy()}));
|
||||
assert_eq!(r["bodies"].as_array().unwrap().len(), 1, "{r}");
|
||||
run(&mut d, "FusionImportCommandFromToolbar", json!({"path": p.to_string_lossy()}));
|
||||
let names: Vec<String> = d.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names.len(), 3);
|
||||
let mut uniq = names.clone();
|
||||
uniq.dedup();
|
||||
assert_eq!(uniq.len(), 3, "{names:?}");
|
||||
assert!(rel(volume(&mut d), 125.0 + 48000.0) < 1e-6);
|
||||
run(&mut d, "UndoCommand", json!({}));
|
||||
assert_eq!(d.model.state().bodies.len(), 2);
|
||||
|
||||
let junk = p.with_file_name("junk.step");
|
||||
std::fs::write(&junk, "ISO-10303-21;\nDATA;\n#1=CARTESIAN_POINT('',(0.,0.,0.));\nENDSEC;\nEND-ISO-10303-21;\n").unwrap();
|
||||
let before = d.doc.clone();
|
||||
assert!(d.execute("FusionImportCommandFromToolbar", &json!({"path": junk.to_string_lossy()})).is_err());
|
||||
assert!(d.execute("doc.open", &json!({"path": junk.to_string_lossy()})).is_err());
|
||||
assert!(d.execute("FusionImportCommandFromToolbar", &json!({"path": "/nonexistent/a.step"})).is_err());
|
||||
assert!(d.execute("FusionImportCommandFromToolbar", &json!({"path": p.with_extension("stl").to_string_lossy()})).is_err());
|
||||
assert_eq!(*d.doc, *before);
|
||||
}
|
||||
Reference in new issue
Block a user