mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* 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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "両側",
|
||||
|
||||
@@ -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": "작업",
|
||||
|
||||
@@ -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": "双侧",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user