Open a linked part from its assembly Browser row (#2348) (#2349)

* claim: #2348

* Open linked designs from assembly browser

* claim: #2348

* Test linked design action with multiple components selected

* Fix linked design lookup in browser storage and translate its menu action

* Add Turn Up Axis to complete UI catalogs

* Keep browser DXF download after linked-design checks
This commit is contained in:
ChrisNo
2026-10-25 21:15:48 +00:00
committed by GitHub
parent 59cad5f0bb
commit a3890b3bd5
12 changed files with 129 additions and 4 deletions
+1
View File
@@ -2031,6 +2031,7 @@
"ui.one_side": "Einseitig",
"ui.open": "Öffnen",
"ui.open_ends_tip": "Berührt nichts",
"ui.open_linked_design": "Verknüpftes Design öffnen",
"ui.open_preferences": "Einstellungen öffnen",
"ui.open_recent": "Zuletzt verwendet",
"ui.operation": "Operation",
+2
View File
@@ -2897,6 +2897,7 @@
"ui.one_side": "One Side",
"ui.open": "Open",
"ui.open_ends_tip": "Touches nothing",
"ui.open_linked_design": "Open Linked Design",
"ui.open_preferences": "Open Preferences",
"ui.open_recent": "Open Recent",
"ui.operation": "Operation",
@@ -3097,6 +3098,7 @@
"ui.total_angle": "Total angle",
"ui.trackpad_pan_tip": "Two-finger scroll pans; in Fusion, Shift + two fingers orbits. Off: scroll zooms. Mouse wheel and pinch zoom; thumb wheel pans sideways.",
"ui.trim": "Trim",
"ui.turn_up_axis": "Turn Up Axis",
"ui.twist": "Twist",
"ui.two_finger_scroll_pans": "Two-finger scroll pans",
"ui.two_sides": "Two Sides",
+2
View File
@@ -2897,6 +2897,7 @@
"ui.one_side": "片側",
"ui.open": "開く",
"ui.open_ends_tip": "どこにも接していない",
"ui.open_linked_design": "リンクされたデザインを開く",
"ui.open_preferences": "環境設定を開く",
"ui.open_recent": "最近使った項目を開く",
"ui.operation": "操作",
@@ -3097,6 +3098,7 @@
"ui.total_angle": "合計角度",
"ui.trackpad_pan_tip": "2本指スクロールでパンします。FusionではShift + 2本指スクロールで回転します。オフの場合はスクロールでズームします。マウスホイールとピンチ操作はズーム、サイドホイールは横方向のパンになります。",
"ui.trim": "トリム",
"ui.turn_up_axis": "上方向の軸を回転",
"ui.twist": "ねじれ",
"ui.two_finger_scroll_pans": "2本指スクロールでパン",
"ui.two_sides": "両側",
+1
View File
@@ -2200,6 +2200,7 @@
"ui.one_side": "한 방향",
"ui.open": "열기",
"ui.open_ends_tip": "아무것과도 닿지 않음",
"ui.open_linked_design": "연결된 디자인 열기",
"ui.open_preferences": "환경 설정 열기",
"ui.open_recent": "최근 항목 열기",
"ui.operation": "작업",
+2
View File
@@ -2897,6 +2897,7 @@
"ui.one_side": "单侧",
"ui.open": "打开",
"ui.open_ends_tip": "未接触任何曲线",
"ui.open_linked_design": "打开链接设计",
"ui.open_preferences": "打开首选项",
"ui.open_recent": "打开最近",
"ui.operation": "操作",
@@ -3097,6 +3098,7 @@
"ui.total_angle": "总角度",
"ui.trackpad_pan_tip": "双指滚动平移;Fusion 预设下,Shift + 双指滚动旋转视图。关闭时滚动缩放。鼠标滚轮和双指捏合缩放,拇指滚轮横向平移。",
"ui.trim": "修剪",
"ui.turn_up_axis": "旋转向上轴",
"ui.twist": "扭转",
"ui.two_finger_scroll_pans": "双指滚动平移",
"ui.two_sides": "双侧",
+5 -3
View File
@@ -113,7 +113,8 @@ fn stored(design: Option<&str>, file: &Path) -> String {
fn resolve(design: Option<&str>, link: &str) -> PathBuf {
let p = Path::new(link);
match base_dir(design) {
Some(d) if p.is_relative() => d.join(p),
// Browser storage keys do not let the OS resolve `..` for us.
Some(d) if p.is_relative() => absolute(&d.join(p)),
_ => p.to_path_buf(),
}
}
@@ -385,7 +386,8 @@ fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
.map(|(c, l)| {
let file = resolve(s.path.as_deref(), &l.path);
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
json!({"component": c, "name": name, "path": l.path, "file": file.display().to_string(), "found": file.is_file()})
let file = file.to_string_lossy();
json!({"component": c, "name": name, "path": l.path, "file": file, "found": solidcraft_io::vfs::is_file(&file)})
})
.collect();
Ok(json!({"links": links}))
@@ -403,7 +405,7 @@ mod tests {
let design = root.join("assembly").join("case.solidcraft");
let design = design.to_string_lossy().into_owned();
assert_eq!(stored(Some(&design), &part), "../parts/plate.solidcraft");
assert_eq!(absolute(&resolve(Some(&design), "../parts/plate.solidcraft")), part);
assert_eq!(resolve(Some(&design), "../parts/plate.solidcraft"), part);
// A design path given relative to the working folder works the same.
assert_eq!(stored(Some("case.solidcraft"), &absolute(Path::new("parts/plate.solidcraft"))), "parts/plate.solidcraft");
// Before the design has a file the path is kept whole; saving it makes it relative.
+12
View File
@@ -46,6 +46,10 @@ mod imp {
pub fn exists(path: &str) -> bool {
check(path).is_ok() && std::path::Path::new(path).exists()
}
/// Whether the path names a regular file the caller may access.
pub fn is_file(path: &str) -> bool {
check(path).is_ok() && std::path::Path::new(path).is_file()
}
pub fn remove(path: &str) -> Result<()> {
check(path)?;
std::fs::remove_file(path)
@@ -96,6 +100,10 @@ mod imp {
pub fn exists(path: &str) -> bool {
FILES.with(|f| f.borrow().contains_key(path))
}
/// Every entry in browser storage is a regular file.
pub fn is_file(path: &str) -> bool {
exists(path)
}
pub fn remove(path: &str) -> Result<()> {
FILES.with(|f| f.borrow_mut().remove(path)).map(|_| ()).ok_or_else(|| missing(path))
}
@@ -451,9 +459,12 @@ mod tests {
let _ = std::fs::create_dir_all(&dir);
let p = dir.join("a.txt").to_string_lossy().to_string();
assert!(!super::exists(&p));
assert!(!super::is_file(&p));
assert!(!super::is_file(&dir.to_string_lossy()));
assert!(super::read(&p, 16).is_err());
super::write(&p, b"hello").unwrap();
assert!(super::exists(&p));
assert!(super::is_file(&p));
assert_eq!(super::len(&p).unwrap(), 5);
assert_eq!(super::read_to_string(&p, 16).unwrap(), "hello");
// The cap is the caller's, not the file's.
@@ -461,6 +472,7 @@ mod tests {
assert_eq!(super::read(&p, 5).unwrap(), b"hello");
super::remove(&p).unwrap();
assert!(!super::exists(&p));
assert!(!super::is_file(&p));
let _ = std::fs::remove_dir_all(&dir);
}
+26
View File
@@ -806,6 +806,9 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
let grounded = occ.is_some_and(|o| o.grounded);
let oid = occ.map(|o| o.id).unwrap_or(0);
v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active));
if doc.links.contains_key(&id) {
v.push(act("ui.openLinkedDesign", "Open Linked Design", "open").with(json!({ "component": id })));
}
v.push(cmd(app, "occurrence.ground", if grounded { "Unground" } else { "Ground" }).with(json!({ "occurrence": oid, "grounded": !grounded })));
v.push(
act("ui.moveOccurrence", "Move/Copy", "move")
@@ -1260,6 +1263,29 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
let _ = app.run("component.activate", p);
}
}
"ui.openLinkedDesign" => {
let Some(component) = id_of(&p, "component") else { return };
let links = match app.session.execute("component.links", &json!({})) {
Ok(links) => links,
Err(e) => {
app.set_status(e.to_string(), true);
return;
}
};
let Some(link) = links["links"].as_array().and_then(|links| links.iter().find(|link| link["component"] == component)) else {
app.set_status("This component is no longer linked to a design", true);
return;
};
let Some(file) = link["file"].as_str() else {
app.set_status("The linked design has no file path", true);
return;
};
if link["found"] != true {
app.set_status(format!("Linked design not found: {file}"), true);
return;
}
app.open_path_async(file);
}
"ui.moveOccurrence" => {
if let Some(o) = id_of(&p, "occurrence") {
let mut movement = crate::browser::OccMove::new(o);
+7
View File
@@ -92,6 +92,13 @@ fn every_menu_label_is_in_the_english_catalog() {
app.session.visibility.hidden_bodies.clear();
take(&app, &mut seen);
// Linked components offer source-file actions absent from the sample design.
let linked = app.session.execute("component.create", &json!({"name": "Linked part"})).expect("linked component");
let component = linked["component"].as_u64().expect("component id");
app.session.doc_mut().links.insert(component, solidcraft_engine::doc::DesignLink { path: "part.solidcraft".into(), feature: 0 });
take(&app, &mut seen);
assert!(seen.contains("Open Linked Design"));
// A menu that stops yielding labels would empty `seen` and turn the
// whole test into a tautology, so the sweep has to stay broad.
assert!(seen.len() > 40, "the sweep only reached {} label(s)", seen.len());
+46
View File
@@ -44,6 +44,52 @@ fn act(app: &mut SolveApp, t: &Target, label: &str) {
context_menu::run_item(app, &item, pos2(0.0, 0.0));
}
#[test]
fn linked_component_opens_its_part_in_a_tab_and_reports_a_missing_source() {
let mut h = crate::scenario::Harness::new();
let part = h.dir.join("part.solidcraft");
let assembly = h.dir.join("assembly.solidcraft");
let mut source = Session::default();
source.execute("solid.box", &json!({"length": 10, "width": 20, "height": 30})).unwrap();
source.execute("file.save", &json!({"path": part})).unwrap();
let inserted = h.app.run("component.insert_design", json!({"path": part})).unwrap();
let component = inserted["component"].as_u64().unwrap();
let occurrence = inserted["occurrence"].as_u64().unwrap();
let other_part = h.dir.join("other-part.solidcraft");
let mut other_source = Session::default();
other_source.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5})).unwrap();
other_source.execute("file.save", &json!({"path": other_part})).unwrap();
let other = h.app.run("component.insert_design", json!({"path": other_part})).unwrap();
let other_component = other["component"].as_u64().unwrap();
h.app.run("file.save", json!({"path": assembly})).unwrap();
h.app.tree.picked_components = vec![component, other_component];
let linked = Target::Component { id: component };
let row = context_menu::items(&h.app, &linked);
assert!(row.iter().any(|item| item.label == "Open Linked Design"));
let placed = context_menu::items(&h.app, &Target::Occurrence { path: vec![occurrence] });
assert!(placed.iter().any(|item| item.label == "Open Linked Design"));
assert!(!context_menu::items(&h.app, &Target::Component { id: 0 }).iter().any(|item| item.label == "Open Linked Design"));
act(&mut h.app, &linked, "Open Linked Design");
for _ in 0..600 {
if h.app.session.path.as_deref() == part.to_str() {
break;
}
h.frame();
}
assert_eq!(h.app.session.path.as_deref(), part.to_str());
assert_eq!(h.app.docs.count(), 2);
crate::documents::switch(&mut h.app, 0);
assert_eq!(h.app.session.path.as_deref(), assembly.to_str());
std::fs::remove_file(&part).unwrap();
act(&mut h.app, &linked, "Open Linked Design");
assert_eq!(h.app.docs.count(), 2);
assert_eq!(h.app.session.path.as_deref(), assembly.to_str());
assert!(h.app.status.as_ref().is_some_and(|(message, _, error)| *error && message.contains("Linked design not found")));
}
fn body(app: &SolveApp) -> String {
app.session.model.state().bodies[0].name.clone()
}
+2
View File
@@ -352,6 +352,8 @@ 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.
To edit the source, right-click a linked component or occurrence in the Browser and choose
**Open Linked Design**. It opens the part in another tab and reports if its file is missing.
**Break Link** (`component.break_link`) keeps the bodies and forgets the file, and
`component.links` lists the links and whether their files are found. A linked component holds
the part's bodies only: its timeline, parameters and sheet-metal data (flat pattern, bend
+23 -1
View File
@@ -198,8 +198,30 @@ const API_TEST: &str = r#"(async () => {
await app.request('ui.inspect');
const [x, y] = await app.request('ui.at', {handle: 'dialog:Export DXF…'});
await app.request('ui.click', {x, y});
// Browser files exist only in the VFS: native filesystem metadata cannot find them.
await app.execute('file.save', {path: '/upload/linked-part.solidcraft'});
await app.execute('file.new');
const linked = await app.execute('component.insert_design', {path: '/upload/linked-part.solidcraft'});
await app.execute('file.save', {path: '/opfs/linked-assembly.solidcraft'});
const links = await app.execute('component.links');
check(links.links[0].found === true, 'linked part is found in browser storage');
check(links.links[0].path === '../upload/linked-part.solidcraft', 'assembly stores a relative link');
check(links.links[0].file === '/upload/linked-part.solidcraft', 'relative link resolves to a browser storage key');
await app.request('ui.menu', {x: 200, y: 300, target: {type: 'occurrence', path: [linked.occurrence]}});
let menu;
for (let frame = 0; frame < 120; frame++) {
menu = await app.request('ui.menu');
if (menu.items?.some(item => item.id === 'ui.openLinkedDesign')) break;
}
check(menu.items?.some(item => item.id === 'ui.openLinkedDesign'), 'occurrence menu offers the source design');
await app.request('ui.menuPick', {item: 'ui.openLinkedDesign'});
const tabs = await app.request('ui.documents');
check(tabs.documents.length === 2 && tabs.active === 1, 'linked source opens in another tab');
check((await app.inspect()).bodies[0].name === 'Browser API box', 'source design is active');
await app.request('ui.documents', {action: 'switch', index: 0});
check((await app.execute('component.links')).links[0].component === linked.component, 'assembly remains in its tab');
return {commands: 'discovered', bodies: [0, 1, 0, 1], errors: 'rejected', queue: '256 completed, 1 rejected',
dxf: 'omit and dashed exported; dialog download checked'};
dxf: 'omit and dashed exported; dialog download checked', linked: 'source opened from browser storage'};
})()"#;
/// Run the test; `required: false` (from `cargo xtask ci`) skips it when Chrome is missing.