diff --git a/crates/engine/src/cmd/component_link.rs b/crates/engine/src/cmd/component_link.rs index dafa9e98..8dd29104 100644 --- a/crates/engine/src/cmd/component_link.rs +++ b/crates/engine/src/cmd/component_link.rs @@ -16,7 +16,7 @@ use crate::params::{bad, str_, vec3}; use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("component.insert_design", "Insert Design", insert_design).icon("component").params( + CommandSpec::new("component.insert_design", "Insert Design", insert_design).at("SOLID", "ASSEMBLE").icon("component").params( "path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \ name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]", ), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2f126f04..adbd0cb4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -679,6 +679,21 @@ impl SolveApp { self.pick_open_command(id, json!({})); return; } + if id == "component.insert_design" { + // Another design becomes a linked component; a STEP or mesh file picked here is + // inserted the way Insert STEP or Insert Mesh would (#1240). + self.pick_open(|app, path| { + let Some(path) = path else { return }; + if is_design_path(&path) { + if app.run("component.insert_design", json!({ "path": path })).is_ok() { + app.fit_view(); + } + } else { + app.insert_path_async(&path); + } + }); + return; + } if matches!(id, "file.insert_step" | "file.insert_mesh") { self.pick_open(|app, path| { if let Some(path) = path { @@ -1419,3 +1434,10 @@ pub fn now_ms() -> f64 { #[cfg(test)] mod picker_test_support; + +/// A SolidCraft design file (`.solidcraft`, or a design saved as JSON), as opposed to a STEP or +/// mesh file. +fn is_design_path(path: &str) -> bool { + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + matches!(ext.as_str(), "solidcraft" | "solvecraft" | "json") +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index caa85a3a..c5110e4c 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -390,6 +390,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { app.start("file.insert_mesh"); close = true; } + if item_if(ui, &format!("{}…", app.tr_label("Insert Design")), "", shown) { + app.start("component.insert_design"); + close = true; + } if item_if(ui, app.tr("ui.save"), "Ctrl+S", shown) { save(app); close = true; diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index 5ff07110..953fc3dc 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -11,6 +11,7 @@ const ASSEMBLE: &[&str] = &[ "component.create", "joint.create", "joint.as_built", + "component.insert_design", "joint.origin", "joint.rigid_group", "joint.drive", @@ -212,4 +213,36 @@ mod tests { assert!(ids("PLASTIC", "MODIFY").contains(&"solid.shell")); assert!(panels("PLASTIC").is_some_and(|p| p.first() == Some(&"SETUP"))); } + + /// #1240: a saved part reaches an assembly from the ASSEMBLE panel (and the File menu) as a + /// linked component, and a STEP file picked there is inserted as Insert STEP would. + #[test] + fn insert_design_is_on_the_assemble_panel_and_inserts_a_saved_part() { + use serde_json::json; + for tab in ["SOLID", "SHEET METAL", "PLASTIC"] { + assert!(ids(tab, "ASSEMBLE").contains(&"component.insert_design"), "{tab}"); + } + assert!(!ids("SOLID", "ASSEMBLE")[..3].contains(&"component.insert_design"), "Component, Joint and As-built Joint keep the big buttons"); + let mut h = crate::scenario::Harness::new(); + let part = h.dir.join("part1.solidcraft"); + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 30})).unwrap(); + s.execute("file.save", &json!({"path": part})).unwrap(); + s.execute("file.export", &json!({"path": h.dir.join("part2.step")})).unwrap(); + h.step(&json!({"choose_file": "part1.solidcraft"})).unwrap(); + h.step(&json!({"start": "component.insert_design"})).unwrap(); + h.frames(3); + let links = h.app.session.execute("component.links", &json!({})).unwrap(); + assert_eq!(links["links"][0]["name"], "part1", "{links}"); + assert_eq!(h.app.session.world_state().bodies.len(), 1); + h.step(&json!({"choose_file": "part2.step"})).unwrap(); + h.step(&json!({"start": "component.insert_design"})).unwrap(); + for _ in 0..600 { + if h.app.session.world_state().bodies.len() == 2 { + break; + } + h.frames(1); + } + assert_eq!(h.app.session.world_state().bodies.len(), 2, "the STEP file's body is inserted"); + } } diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 67d3b9b1..e33e79b0 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -64,8 +64,10 @@ instead, so two origins given at one point with the same z stay as they are. For drive many steps, `quiet: true` leaves the per-part degrees of freedom out of joint answers, and `motion.play` with `quiet: true` gives the poses of only the parts that moved. -**Insert Design** (`component.insert_design {path, translate?}`) adds a component linked to -another saved design, so each part can live in its own document. The part's solid bodies come +**Insert Design** (`component.insert_design {path, translate?}`, on the **ASSEMBLE** panel and +in the **File** menu) adds a component linked to another saved design, so each part can live in +its own document. A STEP or mesh file picked there is inserted as **Insert STEP** or **Insert +Mesh** would. The part's solid bodies come in as they sit in that design, and they are kept in the assembly, so it opens even without the part file. The link is stored relative to the assembly's folder once the assembly is saved. After the part file changes, **Update Links** (`component.update_links`) reads it again.