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
@@ -29,7 +29,10 @@ storytold "craft" family.
|
||||
changed feature; it can be rolled back, and features can be suppressed, renamed, edited and
|
||||
deleted.
|
||||
- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`).
|
||||
- **Files**: designs as JSON (`.solvecraft`), export to STEP, STL (binary/ASCII) and OBJ.
|
||||
- **Files**: designs as JSON (`.solvecraft`), export to STEP, STL (binary/ASCII) and OBJ;
|
||||
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) as a base feature
|
||||
that later features build on — open a `.step`/`.stp` file, insert one into a design, or drop it
|
||||
on the window ([docs/step-import.md](docs/step-import.md)).
|
||||
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
|
||||
view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
control channel so agents and tests can drive it.
|
||||
@@ -45,6 +48,8 @@ cargo run --release -p solvecraft -- --sample # desktop app with a samp
|
||||
cargo run --release -p solvecraft-cli -- commands # the command registry
|
||||
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solvecraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solvecraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
|
||||
```
|
||||
|
||||
@@ -83,7 +88,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
|
||||
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and CPU rasterizer |
|
||||
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP export |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP export, STEP import features |
|
||||
| L4 | `engine` | the session and the command registry (everything is a command) |
|
||||
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
|
||||
| apps | `solvecraft`, `solvecraft-cli` | desktop app, headless CLI |
|
||||
|
||||
+5
-2
@@ -23,7 +23,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
| M1 | Sketch depth, oracle sketch cases (arcs, taper, two-sided, sketch modify tools) | in progress | 16 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
|
||||
| M4 | Files: STEP import, mesh import, DXF sketches, 3MF | planned | 16 |
|
||||
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), mesh import, DXF sketches, 3MF | in progress | 10 |
|
||||
| M5 | Persistent naming of faces and edges | planned | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), live previews, measure tool, section view, drag-solve | in progress | 16 |
|
||||
| M7 | Sweep, loft, rib, web, emboss, thread, coil, pipe | planned | 24 |
|
||||
@@ -40,7 +40,10 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
- Booleans of bodies with coincident faces fail unless they come from extrudes (where the tool is
|
||||
extended automatically); fully internal voids are not supported.
|
||||
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative).
|
||||
- No STEP import yet; no persistent naming (fillet edges are re-found by position).
|
||||
- STEP import reads solids (B-rep with analytic and B-spline geometry), units, product names,
|
||||
colours and assemblies; offset surfaces and pcurve-only edges are not read yet, and assembly
|
||||
components are flattened into bodies (the tree is kept for M9).
|
||||
- No persistent naming (fillet edges are re-found by position).
|
||||
- Shell and draft work on convex bodies with planar faces; loft is ruled (no tangency end conditions).
|
||||
- Dialogs have no live preview of the result yet.
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! SolveCraft desktop app.
|
||||
//!
|
||||
//! Usage: `solvecraft [--control <port>] [--sample] [design.solvecraft]`
|
||||
//! Usage: `solvecraft [--control <port>] [--sample] [design.solvecraft | part.step]`
|
||||
//!
|
||||
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature).
|
||||
//!
|
||||
//! `--control <port>` (or `SOLVECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"engine.execute","params":{"command":"PrimitiveBox","params":{…}}}`.
|
||||
@@ -35,7 +37,9 @@ fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Designs and STEP", &["solvecraft", "step", "stp", "STEP", "STP"])
|
||||
.add_filter("SolveCraft design", &["solvecraft"])
|
||||
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
||||
.add_filter("All files", &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -63,7 +67,7 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft]");
|
||||
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft | part.step]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
|
||||
@@ -280,6 +280,17 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
angle: Option<String>,
|
||||
},
|
||||
/// A non-parametric base feature: bodies imported from a STEP file. The STEP text is kept
|
||||
/// in the design (it is the B-rep), so the design reopens without the original file.
|
||||
Import {
|
||||
/// Source file name (display only).
|
||||
file: String,
|
||||
/// The STEP (ISO 10303-21) text.
|
||||
step: String,
|
||||
/// The file's product/assembly tree, kept for components.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
components: Vec<solvecraft_kernel::StepNode>,
|
||||
},
|
||||
}
|
||||
|
||||
/// One loft section: profiles of a sketch.
|
||||
@@ -379,6 +390,7 @@ impl FeatureKind {
|
||||
FeatureKind::Draft { .. } => "DraftFeature",
|
||||
FeatureKind::Split { .. } => "SplitBodyFeature",
|
||||
FeatureKind::Move { .. } => "MoveFeature",
|
||||
FeatureKind::Import { .. } => "BaseFeature",
|
||||
}
|
||||
}
|
||||
/// Default name prefix (`Extrude` → `Extrude1`).
|
||||
@@ -405,6 +417,7 @@ impl FeatureKind {
|
||||
FeatureKind::Draft { .. } => "Draft",
|
||||
FeatureKind::Split { .. } => "Split",
|
||||
FeatureKind::Move { .. } => "Move",
|
||||
FeatureKind::Import { .. } => "Import",
|
||||
}
|
||||
}
|
||||
/// Every expression the feature uses.
|
||||
@@ -465,6 +478,7 @@ impl FeatureKind {
|
||||
v.push(a);
|
||||
}
|
||||
}
|
||||
FeatureKind::Import { .. } => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -1100,5 +1100,29 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Import { step, .. } => {
|
||||
let imp = kernel::step_import_shared(step)?;
|
||||
for (k, b) in imp.bodies.iter().enumerate() {
|
||||
let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
|
||||
let name = unique_body_name(st, base);
|
||||
st.bodies.push(ModelBody::new(name, b.body.clone(), f.id));
|
||||
}
|
||||
if !imp.warnings.is_empty() {
|
||||
let n = imp.warnings.len();
|
||||
let shown: Vec<&str> = imp.warnings.iter().take(3).map(String::as_str).collect();
|
||||
let more = if n > 3 { format!(" (+{} more)", n - 3) } else { String::new() };
|
||||
*warning = Some(format!("{}{more}", shown.join("; ")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `base`, or `base (2)`, `base (3)`… if a body already has that name.
|
||||
fn unique_body_name(st: &ModelState, base: &str) -> String {
|
||||
let base = if base.trim().is_empty() { "Body" } else { base.trim() };
|
||||
if st.body(base).is_none() {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} ({i})")).find(|n| st.body(n).is_none()).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+55
-1
@@ -1,5 +1,6 @@
|
||||
//! File formats. Designs are saved as JSON (`.solvecraft`): the parametric document only, the
|
||||
//! model is recomputed on load. Bodies export as STL (binary or ASCII), OBJ and STEP.
|
||||
//! model is recomputed on load. Bodies export as STL (binary or ASCII), OBJ and STEP; STEP files
|
||||
//! import as a base feature (see [`step_import_feature`]).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -27,6 +28,52 @@ pub enum IoError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, IoError>;
|
||||
|
||||
/// Largest STEP file we read.
|
||||
pub const MAX_STEP_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Is this a STEP file name (`.step` / `.stp`, any case)?
|
||||
pub fn is_step_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("step") || e.eq_ignore_ascii_case("stp"))
|
||||
}
|
||||
|
||||
/// 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: solvecraft_doc::FeatureKind,
|
||||
/// Body names from the file (solid names, else product names).
|
||||
pub body_names: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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)));
|
||||
}
|
||||
// STEP is 7-bit text with escapes; tolerate stray 8-bit bytes.
|
||||
let text = String::from_utf8_lossy(bytes).into_owned();
|
||||
let imp = solvecraft_kernel::step_import_shared(&text)?;
|
||||
let stem = std::path::Path::new(file)
|
||||
.file_stem()
|
||||
.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 name = match imp.tree.as_slice() {
|
||||
[root] if !generic(&root.name) => root.name.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(),
|
||||
kind: solvecraft_doc::FeatureKind::Import { file: file_name, step: text, components: imp.tree.clone() },
|
||||
warnings: imp.warnings.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Export formats.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
@@ -239,6 +286,13 @@ mod tests {
|
||||
assert!(read_design(b"\xff\xfe").is_err());
|
||||
assert!(matches!(export(&m.state(), &["nope".into()], Format::Step, "t"), Err(IoError::Empty)));
|
||||
assert_eq!(Format::from_name("a.STP").unwrap(), Format::Step);
|
||||
assert!(is_step_path("/x/Part.STEP") && is_step_path("a.stp") && !is_step_path("a.step.json") && !is_step_path("step"));
|
||||
let f = step_import_feature(s.as_bytes(), "/tmp/My Part.step").unwrap();
|
||||
assert_eq!(f.name, "My Part");
|
||||
assert_eq!(f.body_names.len(), 1);
|
||||
assert!(matches!(&f.kind, FeatureKind::Import { file, .. } if file == "My Part.step"));
|
||||
assert!(step_import_feature(b"ISO-10303-21;", "x.step").is_err());
|
||||
assert!(step_import_feature(&[0xff, 0xfe, 0x00], "x.step").is_err());
|
||||
assert!(Format::from_name("a.dwg").is_err());
|
||||
assert!(read_stl(&[0u8; 10]).is_err());
|
||||
let mut fake = vec![0u8; 84];
|
||||
|
||||
@@ -31,7 +31,7 @@ pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
|
||||
pub use polybool::planar_boolean;
|
||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, shell};
|
||||
pub use step::step_export;
|
||||
pub use step_in::{ImportedBody, StepImport, StepNode, step_import};
|
||||
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
|
||||
@@ -35,13 +35,38 @@ fn face_sums(m: &Mesh, nf: usize) -> Vec<(f64, f64, Vec3)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Measure a body from a fine tessellation (chord tolerance 5e-5 of the body size). Faces whose
|
||||
/// fine tessellation disagrees with a coarse one (a meshing failure) use the coarse result.
|
||||
/// Smallest radius of curvature along the mesh's edge polylines (straight edges ignored).
|
||||
fn min_edge_radius(m: &Mesh) -> Option<f64> {
|
||||
let mut best: Option<f64> = None;
|
||||
for e in &m.edges {
|
||||
for w in e.windows(3) {
|
||||
let (Some(&a), Some(&b), Some(&c)) = (w.first(), w.get(1), w.get(2)) else { continue };
|
||||
let (ab, bc, ca) = (b - a, c - b, a - c);
|
||||
let cross = ab.cross(bc).len();
|
||||
if !(cross > 1e-12 * ab.len() * bc.len()) {
|
||||
continue;
|
||||
}
|
||||
let r = ab.len() * bc.len() * ca.len() / (2.0 * cross);
|
||||
if r.is_finite() && r > 0.0 && best.is_none_or(|x| r < x) {
|
||||
best = Some(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Measure a body from a fine tessellation (chord tolerance 5e-5 of the body size, finer for
|
||||
/// small radii down to 5e-6). Faces whose fine tessellation disagrees with a coarse one (a
|
||||
/// meshing failure) use the coarse result.
|
||||
pub fn measure(b: &Body) -> Result<BodyMeasure> {
|
||||
let size = b.size();
|
||||
let fine = b.tessellate((size * 5e-5).max(1e-4))?;
|
||||
let medium = b.tessellate((size * 2.5e-4).max(2e-4))?;
|
||||
let coarse = b.tessellate((size * 1e-3).max(1e-3))?;
|
||||
// Chord error relative to a radius sets the volume error: keep it near 3e-4 of the
|
||||
// smallest radius (thin tubes, small fillets).
|
||||
let tight = min_edge_radius(&coarse).map(|r| r * 3e-4).unwrap_or(f64::INFINITY);
|
||||
let fine_tol = (size * 5e-5).min(tight).max(size * 5e-6).max(1e-4);
|
||||
let fine = b.tessellate(fine_tol)?;
|
||||
let medium = b.tessellate((fine_tol * 5.0).max(2e-4))?;
|
||||
let nf = b.face_count();
|
||||
let (sf, sm, sc) = (face_sums(&fine, nf), face_sums(&medium, nf), face_sums(&coarse, nf));
|
||||
let close = |x: f64, y: f64| (x - y).abs() <= 0.01 * x.abs().max(y.abs()) + 1e-9;
|
||||
|
||||
@@ -567,6 +567,32 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recent imports, so a file read for a command and then evaluated in the timeline is only
|
||||
/// read once.
|
||||
static RECENT: std::sync::Mutex<Vec<(u64, usize, std::sync::Arc<StepImport>)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// [`step_import`] through a small cache of recent results (keyed by the text).
|
||||
pub fn step_import_shared(text: &str) -> Result<std::sync::Arc<StepImport>> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut h);
|
||||
let key = (h.finish(), text.len());
|
||||
if let Ok(cache) = RECENT.lock()
|
||||
&& let Some((_, _, imp)) = cache.iter().find(|(k, n, _)| (*k, *n) == key)
|
||||
{
|
||||
return Ok(imp.clone());
|
||||
}
|
||||
let imp = std::sync::Arc::new(step_import(text)?);
|
||||
if let Ok(mut cache) = RECENT.lock() {
|
||||
cache.retain(|(k, n, _)| (*k, *n) != key);
|
||||
cache.push((key.0, key.1, imp.clone()));
|
||||
if cache.len() > 4 {
|
||||
cache.remove(0);
|
||||
}
|
||||
}
|
||||
Ok(imp)
|
||||
}
|
||||
|
||||
/// Read a STEP file: its solids (as bodies, placed by the assembly structure) and the tree.
|
||||
pub fn step_import(text: &str) -> Result<StepImport> {
|
||||
let ex = p21::parse(text).map_err(KernelError::Invalid)?;
|
||||
|
||||
@@ -158,6 +158,10 @@ fn agent_builds_box_fillet_hole() {
|
||||
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 0);
|
||||
tool(&mut s, "open", json!({"path": design.to_string_lossy()}));
|
||||
assert!(rel(volume(&mut s), expect) < 5e-4);
|
||||
// A STEP file opens as a new design with the same body.
|
||||
let r = tool(&mut s, "open", json!({"path": step.to_string_lossy()}));
|
||||
assert_eq!(r["features"], 1, "{r}");
|
||||
assert!(rel(volume(&mut s), expect) < 5e-4);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -190,8 +190,10 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"open",
|
||||
"Open a design",
|
||||
"Open a .solvecraft design file (replaces the current design).",
|
||||
obj(json!({"path": string("Path to a .solvecraft file")}), &["path"]),
|
||||
"Open a .solvecraft design file, or a STEP file (.step/.stp: a new design whose bodies come from the file, as an \
|
||||
Import base feature; insert into the current design with the FusionImportCommandFromToolbar command). Replaces \
|
||||
the current design.",
|
||||
obj(json!({"path": string("Path to a .solvecraft or .step/.stp file")}), &["path"]),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
|
||||
@@ -361,6 +361,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink);
|
||||
pen.arrow((10.0, 14.0), (20.0, 4.0), a);
|
||||
}
|
||||
"import" => {
|
||||
pen.iso_box(4.0, 12.0, 12.0, 7.0, 7.0);
|
||||
pen.arrow((20.0, 3.0), (12.0, 11.0), a);
|
||||
}
|
||||
"home" => {
|
||||
pen.poly(&[(4.0, 12.0), (12.0, 4.0), (20.0, 12.0)], pen.fill, ink);
|
||||
pen.poly(&[(6.0, 12.0), (18.0, 12.0), (18.0, 20.0), (6.0, 20.0)], pen.fill, ink);
|
||||
|
||||
@@ -166,6 +166,12 @@ impl SolveApp {
|
||||
/// that have them, otherwise run it with defaults.
|
||||
pub fn start(&mut self, id: &str) {
|
||||
self.tool = None;
|
||||
if id == "FusionImportCommandFromToolbar" {
|
||||
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
self.insert_path(&p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if id == "SketchStop" {
|
||||
let r = self.run(id, json!({}));
|
||||
if r.is_ok()
|
||||
@@ -240,6 +246,25 @@ impl SolveApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a STEP file's bodies into the current design (an Import base feature).
|
||||
pub fn insert_path(&mut self, path: &str) {
|
||||
if let Ok(r) = self.run("FusionImportCommandFromToolbar", json!({ "path": path })) {
|
||||
self.fit_view();
|
||||
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
|
||||
self.set_status(format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A dropped file: a STEP file joins a design that has features, anything else opens.
|
||||
pub fn drop_path(&mut self, path: &str) {
|
||||
if solvecraft_engine::io::is_step_path(path) && !self.session.doc.features.is_empty() {
|
||||
self.insert_path(path);
|
||||
} else {
|
||||
self.open_path(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame the model at once (programmatic use; the toolbar animates).
|
||||
pub fn fit_view(&mut self) {
|
||||
self.cam_anim = None;
|
||||
@@ -321,7 +346,7 @@ impl SolveApp {
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
let p = f.path().to_string_lossy().to_string();
|
||||
if !p.is_empty() {
|
||||
self.open_path(&p);
|
||||
self.drop_path(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
FeatureKind::Draft { .. } => "draft",
|
||||
FeatureKind::Split { .. } => "split",
|
||||
FeatureKind::Move { .. } => "move",
|
||||
FeatureKind::Import { .. } => "import",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Insert STEP…", "") {
|
||||
app.start("FusionImportCommandFromToolbar");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Save", "Ctrl+S") {
|
||||
save(app);
|
||||
close = true;
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app:
|
||||
| `export` | `path`, `format?` (step, stl, stla, obj), `bodies?` | STEP or mesh export. |
|
||||
| `undo`, `redo` | — | Undo / redo one change. |
|
||||
| `new_design` | `name?` | Start an empty design. |
|
||||
| `open` | `path` | Open a `.solvecraft` design. |
|
||||
| `open` | `path` | Open a `.solvecraft` design, or a STEP file (`.step`/`.stp`) as a new design holding its bodies (an Import base feature). |
|
||||
| `save` | `path?` | Save the design (to its current file without `path`). |
|
||||
|
||||
Resources: `solvecraft://design` (inspect JSON with measurements) and `solvecraft://commands`
|
||||
|
||||
Reference in new issue
Block a user