Context menus follow Fusion's items and order (face, edge, empty space, sketch entities; browser body, sketch, component, Bodies/Sketches folders, Origin): Edit Feature, Edit Profile Sketch, Shell, Physical Material, Save As Mesh, Remove, Export DXF, Look At, Pan/Zoom/Orbit, Show All/Unisolate, Show/Hide Planes and Axes; our extras after them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 08:03:14 +00:00
1 parent 0f4a0e7053
commit 0feb6a7d70
3 files changed
+327 -77

No files matched your search

+43 -5
View File
@@ -121,17 +121,52 @@ pub fn group_items(app: &SolveApp, id: u64) -> Vec<Item> {
/// The menu of a folder row (Bodies, Sketches, Construction).
pub fn folder_items(app: &SolveApp, component: u64, folder: &str) -> Vec<Item> {
let sel: Vec<String> = selected_keys(app, component, folder);
let mut v = vec![Item::action("ui.newGroup", "New Group", "folder").with(json!({ "component": component, "folder": folder }))];
let all = entries(app, component, folder);
let mut v = Vec::new();
if folder == "bodies" {
let bodies: Vec<String> = all.iter().map(|e| e.key.clone()).collect();
v.push(
Item::action("FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies", "component").with(json!({ "bodies": bodies })),
);
}
v.push(
Item::action("ui.newGroup", if folder == "sketches" { "New Sketch Group" } else { "New Group" }, "folder")
.with(json!({ "component": component, "folder": folder })),
);
if sel.len() > 1 {
v.push(Item::action("ui.groupSelected", "Group Selected", "folder").with(json!({ "component": component, "folder": folder, "items": sel })));
}
if folder == "bodies" {
v.push(Item::sep());
v.push(Item::action("ui.showAll", "Show All Bodies", "eye"));
}
v.push(Item::sep());
v.push(Item::action("ui.folderVisible", "Show/Hide", "eye").with(json!({ "component": component, "folder": folder })));
v.push(Item::action("ui.showAll", "Show All", "eye"));
v
}
/// The menu of the Origin folder.
pub fn origin_items(app: &SolveApp) -> Vec<Item> {
let planes = ["XY", "XZ", "YZ"].iter().all(|p| !app.ui.hidden_origin.iter().any(|h| h == p));
let axes = ["X", "Y", "Z"].iter().all(|p| !app.ui.hidden_origin.iter().any(|h| h == p));
vec![
Item::action("ui.origin", "Show/Hide", "eye"),
Item::action("ui.originAll", "Show All", "eye"),
Item::action("ui.originPart", if planes { "Hide Planes" } else { "Show Planes" }, "plane")
.with(json!({ "keys": ["XY", "XZ", "YZ"], "show": !planes })),
Item::action("ui.originPart", if axes { "Hide Axes" } else { "Show Axes" }, "axis").with(json!({ "keys": ["X", "Y", "Z"], "show": !axes })),
]
}
/// Show or hide everything in a component folder.
pub fn toggle_folder(app: &mut SolveApp, component: u64, folder: &str) {
if folder == "sketches" {
app.ui.show_sketches = !app.ui.show_sketches;
return;
}
let all = entries(app, component, folder);
let any = all.iter().any(|e| e.visible);
let refs: Vec<&Entry> = all.iter().collect();
set_visible(app, folder, &refs, !any);
}
/// Selected browser items of the folder of a body or sketch, when more than one (for Group
/// Selected in their menus).
pub fn selection_group(app: &SolveApp, target: &Target) -> Option<Value> {
@@ -534,6 +569,9 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
if r.eye {
app.ui.show_origin = !app.ui.show_origin;
}
if let Some(p) = r.secondary {
crate::context_menu::open_for(app, p, Target::Origin);
}
if !open {
return;
}
+200 -66
View File
@@ -38,6 +38,8 @@ pub enum Target {
Group {
id: u64,
},
/// The Origin folder.
Origin,
/// A canvas (reference image).
Canvas {
id: u64,
@@ -202,7 +204,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
Target::Body { name } => vec![name.clone()],
Target::Component { id } => component_bodies(app, *id),
Target::Group { id } => crate::browser::group_bodies(app, *id),
Target::Sketch { .. } | Target::Folder { .. } | Target::Canvas { .. } => Vec::new(),
Target::Sketch { .. } | Target::Folder { .. } | Target::Canvas { .. } | Target::Origin => Vec::new(),
}
}
@@ -260,6 +262,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
Target::Group { id } => crate::browser::group_items(app, *id),
Target::Folder { component, folder } => crate::browser::folder_items(app, *component, folder),
Target::Canvas { id } => crate::browser::canvas_items(app, *id),
Target::Origin => crate::browser::origin_items(app),
};
// Several items of the folder selected: they can be grouped.
if let Some(p) = crate::browser::selection_group(app, target) {
@@ -269,10 +272,26 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
v
}
/// The feature a body was made by, and that feature's sketch if it has one.
fn body_feature_and_sketch(app: &SolveApp, body: &str) -> (Option<u64>, Option<u64>) {
let f = body_feature(app, body);
let sketch = f
.and_then(|f| app.session.doc.feature(f))
.and_then(|f| serde_json::to_value(&f.kind).ok())
.and_then(|v| v.get("sketch").and_then(Value::as_u64).or_else(|| v.as_object()?.values().find_map(|x| x.get("sketch")?.as_u64())));
(f, sketch)
}
/// The canvas menus follow Fusion's (`plan/fusion/ui/NOTES.md`, feature names only): Repeat on
/// top, then what fits the selection, our extras after a separator.
fn viewport_items(app: &SolveApp) -> Vec<Item> {
let sel = &app.session.selection;
let has = |f: fn(&Sel) -> bool| sel.iter().any(f);
let mut v = Vec::new();
if let Some((_, label)) = &app.last_command {
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo"));
v.push(Item::sep());
}
if app.session.active_sketch.is_some() {
v.extend(sketch_entity_items(app));
v.push(Item::sep());
@@ -280,22 +299,44 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(cmd(app, "Offset", "Offset"));
v.push(Item::sep());
v.push(act("ui.finishSketch", "Finish Sketch", "finish"));
} else if has(|s| matches!(s, Sel::Face { .. })) {
v.push(Item::sep());
v.push(cmd(app, "UndoCommand", "Undo").on(!app.session.undo.is_empty()));
v.push(cmd(app, "RedoCommand", "Redo").on(!app.session.redo.is_empty()));
return v;
}
if has(|s| matches!(s, Sel::Face { .. })) {
let face = sel.iter().find_map(|s| if let Sel::Face { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default();
let (feature, sketch) = body_feature_and_sketch(app, &face);
let hidden = app.ui.hidden_bodies.contains(&face);
v.push(cmd(app, "SketchCreate", "Create Sketch"));
v.push(cmd(app, "ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane"));
v.push(cmd(app, "FusionPressPullCommand", "Press Pull"));
v.push(cmd(app, "Extrude", "Extrude"));
v.push(cmd(app, "MeasureCommand", "Measure"));
v.push(act("ui.appearance", "Appearance…", "").on(false));
v.push(cmd(app, "ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane"));
v.push(cmd(app, "FusionShellBodyCommand", "Shell"));
v.push(act("ui.editFeature", "Edit Feature", "").with(json!({ "feature": feature })).on(feature.is_some()));
v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some()));
v.push(Item::sep());
v.push(act("ui.appearance", "Appearance", "").key("A").on(false));
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] })));
v.push(Item::sep());
v.push(act("ui.delete", "Delete", "delete").key("Del"));
v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").with(json!({ "bodies": [face] })));
v.push(Item::sep());
v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": face })));
v.push(act("ui.findTimeline", "Find in Timeline", "").with(json!({ "body": face })));
v.push(act("ui.hide", "Hide Body", "eye").with(json!({ "bodies": [face] })));
v.push(act("ui.delete", "Delete", "delete").key("Del"));
v.push(Item::sep());
v.push(cmd(app, "FusionPressPullCommand", "Press Pull"));
v.push(cmd(app, "MeasureCommand", "Measure"));
} else if has(|s| matches!(s, Sel::Edge { .. })) {
let body = sel.iter().find_map(|s| if let Sel::Edge { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default();
let hidden = app.ui.hidden_bodies.contains(&body);
v.push(cmd(app, "FusionFilletEdgesCommand", "Fillet"));
v.push(cmd(app, "FusionChamferCommand", "Chamfer"));
v.push(Item::sep());
v.push(act("ui.delete", "Delete", "delete").key("Del"));
v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").with(json!({ "bodies": [body] })));
v.push(Item::sep());
v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": body })));
v.push(Item::sep());
v.push(act("ui.tangentChain", "Select Tangent Chain", ""));
v.push(cmd(app, "MeasureCommand", "Measure"));
} else if has(|s| matches!(s, Sel::Body { .. })) {
@@ -303,36 +344,34 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
} else if has(|s| matches!(s, Sel::Profile { .. })) {
v.push(cmd(app, "Extrude", "Extrude"));
v.push(cmd(app, "Revolve", "Revolve"));
}
if !v.is_empty() {
} else {
// Empty space.
v.push(act("ui.nav", "Pan", "pan").with(json!({ "mode": "pan" })));
v.push(act("ui.nav", "Zoom", "zoom").with(json!({ "mode": "zoom" })));
v.push(act("ui.nav", "Orbit", "orbit").with(json!({ "mode": "orbit" })));
v.push(act("ui.fit", "Fit", "fit"));
v.push(act("ui.home", "Home View", "home"));
v.push(Item::sep());
let anything_hidden = !app.ui.hidden_bodies.is_empty() || !app.ui.hidden_sketches.is_empty();
v.push(act("ui.showAll", "Show All", "eye").on(anything_hidden));
v.push(act("ui.showAll", "Unisolate", "").on(!app.ui.hidden_bodies.is_empty()));
v.push(Item::sep());
v.push(cmd(app, "Extrude", "Extrude"));
v.push(cmd(app, "FusionFilletEdgesCommand", "Fillet"));
v.push(Item::sep());
v.push(cmd(app, "UndoCommand", "Undo").on(!app.session.undo.is_empty()));
v.push(cmd(app, "RedoCommand", "Redo").on(!app.session.redo.is_empty()));
v.push(Item::sep());
if app.session.section.is_some() {
v.push(act("ui.unsection", "Remove Section", "section"));
}
v.push(act("ui.origin", if app.ui.show_origin { "Hide Origin" } else { "Show Origin" }, "origin"));
return v;
}
// Empty space (and always, at the end): undo, view and selection helpers.
if let Some((_, label)) = &app.last_command
&& v.is_empty()
{
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo"));
}
v.push(cmd(app, "UndoCommand", "Undo").on(!app.session.undo.is_empty()));
v.push(cmd(app, "RedoCommand", "Redo").on(!app.session.redo.is_empty()));
v.push(Item::sep());
if app.session.section.is_some() {
v.push(act("ui.unsection", "Remove Section", "section"));
}
if !app.ui.hidden_bodies.is_empty() {
v.push(act("ui.showAll", "Show All Bodies", "eye"));
}
v.push(act("ui.origin", if app.ui.show_origin { "Hide Origin" } else { "Show Origin" }, "origin"));
if !sel.is_empty() {
v.push(Item::sep());
v.push(act("ui.clear", "Clear Selection", "").key("Esc"));
}
v.push(act("ui.fit", "Fit", "fit"));
v.push(act("ui.home", "Home View", "home"));
if app.session.active_sketch.is_some() {
// Finish Sketch is already in the list above.
} else if v.last().is_some_and(Item::is_sep) {
v.pop();
}
v
}
@@ -341,21 +380,25 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec<Item> {
let all_hidden = bodies.iter().all(|b| app.ui.hidden_bodies.contains(b));
let one = bodies.len() == 1;
let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b));
let items = json!(bodies.iter().map(|b| json!({"type": "body", "name": b})).collect::<Vec<_>>());
let mut v = vec![
act("ui.moveBodies", "Move/Copy", "move").key("M").with(json!({ "bodies": names })).on(!locked),
act(if all_hidden { "ui.show" } else { "ui.hide" }, if all_hidden { "Show Body" } else { "Hide Body" }, "eye")
.with(json!({ "bodies": names })),
act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })),
act("ui.rename", "Rename", "").with(json!({ "body": bodies.first() })).on(one),
act("ui.delete", "Delete", "delete")
.key("Del")
.with(json!({ "items": bodies.iter().map(|b| json!({"type": "body", "name": b})).collect::<Vec<_>>() }))
.on(!locked),
act("ui.lock", if locked { "Unlock" } else { "Lock" }, "").with(json!({ "bodies": names })),
Item::sep(),
act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })),
cmd(app, "FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies").with(json!({ "bodies": names })),
Item::sep(),
act("ui.material", "Physical Material", "").with(json!({ "bodies": names })),
act("ui.appearance", "Appearance", "").key("A").on(false),
act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })),
Item::sep(),
act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })),
act("ui.export", "Export…", "export").with(json!({ "bodies": names })),
Item::sep(),
act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": items })).on(!locked),
cmd(app, "SoftDeleteCommand", "Remove").with(json!({ "bodies": names })).on(!locked),
act("ui.rename", "Rename", "").with(json!({ "body": bodies.first() })).on(one),
Item::sep(),
act(if all_hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").with(json!({ "bodies": names })),
act("ui.lock", if locked { "Unlock" } else { "Lock" }, "").with(json!({ "bodies": names })),
act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })),
];
if one {
let b = bodies.first().cloned().unwrap_or_default();
@@ -373,14 +416,21 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec<Item> {
let profiles = !app.ui.hidden_profiles.contains(&id);
let dims = app.ui.shown_dims.contains(&id);
let editing = app.session.active_sketch == Some(id);
let has_profiles = app.session.model.state().sketch(id).is_some_and(|s| !s.profiles.is_empty());
vec![
act("ui.extrudeSketch", "Extrude", "extrude").key("E").with(json!({ "sketch": id })).on(has_profiles && !editing),
Item::sep(),
act("ui.editSketch", "Edit Sketch", "sketch").with(json!({ "sketch": id })).on(!editing),
act("ui.redefineSketch", "Redefine Sketch Plane", "plane").with(json!({ "sketch": id })),
act(if visible { "ui.hideSketch" } else { "ui.showSketch" }, if visible { "Hide" } else { "Show" }, "eye").with(json!({ "sketch": id })),
act("ui.sketchDims", if dims { "Hide Dimensions" } else { "Show Dimensions" }, "dimension").with(json!({ "sketch": id })),
act("ui.sketchProfile", if profiles { "Hide Profile" } else { "Show Profile" }, "").with(json!({ "sketch": id })),
act("ui.rename", "Rename", "").with(json!({ "feature": id })),
act("ui.exportDxf", "Export DXF…", "export").with(json!({ "sketch": id })),
Item::sep(),
act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })),
act("ui.rename", "Rename", "").with(json!({ "feature": id })),
Item::sep(),
act("ui.lookAt", "Look At", "").with(json!({ "sketch": id })),
act("ui.sketchProfile", if profiles { "Hide Profile" } else { "Show Profile" }, "").with(json!({ "sketch": id })),
act("ui.sketchDims", if dims { "Hide Dimension" } else { "Show Dimension" }, "dimension").with(json!({ "sketch": id })),
act(if visible { "ui.hideSketch" } else { "ui.showSketch" }, "Show/Hide", "eye").with(json!({ "sketch": id })),
Item::sep(),
act("ui.findTimeline", "Find in Timeline", "").with(json!({ "feature": id })),
]
@@ -392,33 +442,47 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
let active = app.session.active_component == id;
let bodies = component_bodies(app, id);
let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.ui.hidden_bodies.contains(b));
let mut v = vec![
act("ui.activate", "Activate Component", "component").with(json!({ "component": id })).on(!active),
cmd(app, "FusionCreateNewComponentCommand", "New Component").with(json!({ "parent": id })),
cmd(app, "FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies")
.with(json!({ "bodies": bodies }))
.on(!bodies.is_empty() && id == 0),
act("ui.newGroup", "New Group", "folder").with(json!({ "component": id })),
Item::sep(),
act(if hidden { "ui.show" } else { "ui.hide" }, if hidden { "Show" } else { "Hide" }, "eye")
.with(json!({ "bodies": bodies }))
.on(!bodies.is_empty()),
act("ui.isolate", "Isolate", "").with(json!({ "bodies": bodies })).on(!bodies.is_empty()),
act("ui.rename", "Rename", "").with(json!({ "component": id })),
];
let names = json!(bodies);
let mut v = Vec::new();
if id != 0 {
let grounded = occ.is_some_and(|o| o.grounded);
let oid = occ.map(|o| o.id).unwrap_or(0);
v.push(act("ui.delete", "Delete", "delete").key("Del").with(json!({ "occurrences": [occ.map(|o| o.id)] })).on(occ.is_some()));
v.push(Item::sep());
v.push(act("ui.moveOccurrence", "Move/Copy", "move").with(json!({ "occurrence": oid })).on(!grounded && occ.is_some()));
v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active));
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").key("M").with(json!({ "occurrence": oid })).on(!grounded && occ.is_some()));
v.push(cmd(app, "SnapshotCmd", "Capture Position").on(!app.session.pending_moves.is_empty()));
v.push(cmd(app, "AsBuiltPositionsCmd", "Revert Position").on(!app.session.pending_moves.is_empty()));
v.push(Item::sep());
} else {
v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active));
}
v.push(cmd(app, "FusionCreateNewComponentCommand", "New Component").with(json!({ "parent": id })));
if id == 0 {
v.push(
cmd(app, "FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies")
.with(json!({ "bodies": names }))
.on(!bodies.is_empty()),
);
}
v.push(Item::sep());
v.push(act("ui.material", "Physical Material", "").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(act("ui.appearance", "Appearance", "").key("A").on(false));
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(Item::sep());
v.push(act("ui.export", "Export…", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(Item::sep());
if id != 0 {
v.push(cmd(app, "occurrence.copy", "Paste (Instance)").with(json!({ "component": id, "translate": [10, 10, 0] })));
v.push(cmd(app, "component.paste_new", "Paste New").with(json!({ "component": id, "translate": [10, 10, 0] })));
v.push(act("ui.delete", "Delete", "delete").key("Del").with(json!({ "occurrences": [occ.map(|o| o.id)] })).on(occ.is_some()));
}
v.push(act("ui.rename", "Rename", "").with(json!({ "component": id })));
v.push(Item::sep());
v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(act("ui.showAll", "Show All Bodies", "eye"));
v.push(act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })).on(!bodies.is_empty() && id != 0));
v.push(act("ui.newGroup", "New Group", "folder").with(json!({ "component": id })));
v
}
@@ -462,6 +526,7 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
return v;
}
let ents: Vec<String> = curves.iter().chain(points.iter()).cloned().collect();
v.push(cmd(app, "sketch.move", "Move/Copy").key("M"));
if !curves.is_empty() {
v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves })));
}
@@ -546,7 +611,10 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
}
"ui.show" => app.ui.hidden_bodies.retain(|b| !bodies.contains(b)),
"ui.showAll" => app.ui.hidden_bodies.clear(),
"ui.showAll" => {
app.ui.hidden_bodies.clear();
app.ui.hidden_sketches.clear();
}
"ui.isolate" => {
app.ui.hidden_bodies = app.session.model.state().bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect();
}
@@ -655,6 +723,72 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
}
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
"ui.folderVisible" => {
if let Some(f) = p.get("folder").and_then(Value::as_str) {
crate::browser::toggle_folder(app, id_of(&p, "component").unwrap_or(0), f);
}
}
"ui.originAll" => {
app.ui.show_origin = true;
app.ui.hidden_origin.retain(|h| !["O", "X", "Y", "Z", "XY", "XZ", "YZ"].contains(&h.as_str()));
}
"ui.originPart" => {
let show = p.get("show").and_then(Value::as_bool).unwrap_or(true);
for k in strs(&p, "keys") {
app.ui.hidden_origin.retain(|h| *h != k);
if !show {
app.ui.hidden_origin.push(k);
}
}
}
"ui.editFeature" => {
if let Some(f) = id_of(&p, "feature") {
app.edit_feature(f);
}
}
"ui.material" => {
let items: Vec<Value> = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect();
let _ = app.run("select.set", json!({ "items": items }));
app.start("PhysicalMaterialCommand");
}
"ui.saveMesh" => {
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.stl"), &["stl"])) {
let _ = app.run("FusionSaveAsSTLCommand", json!({ "path": path, "bodies": bodies }));
}
}
"ui.exportDxf" => {
if let Some(id) = id_of(&p, "sketch") {
let name = app.session.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.dxf"), &["dxf"])) {
let _ = app.run("sketch.export_dxf", json!({ "sketch": id, "path": path }));
}
}
}
"ui.extrudeSketch" => {
if let Some(id) = id_of(&p, "sketch") {
let n = app.session.model.state().sketch(id).map_or(0, |s| s.profiles.len());
let items: Vec<Value> = (0..n).map(|i| json!({"type": "profile", "sketch": id, "index": i})).collect();
let _ = app.run("select.set", json!({ "items": items }));
app.start("Extrude");
}
}
"ui.lookAt" => {
let st = app.session.world_state();
if let Some(ss) = id_of(&p, "sketch").and_then(|id| st.sketch(id)) {
let mut to = app.cam.looking_from(ss.plane.normal());
to.target = ss.plane.origin;
app.animate_to(to);
}
}
"ui.nav" => {
app.viewport.nav = match p.get("mode").and_then(Value::as_str) {
Some("pan") => Some(crate::viewport::NavMode::Pan),
Some("zoom") => Some(crate::viewport::NavMode::Zoom),
Some("orbit") => Some(crate::viewport::NavMode::Orbit),
_ => None,
};
}
"ui.editCanvas" | "ui.calibrateCanvas" => {
if let Some(c) = id_of(&p, "canvas") {
app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(app, c, item.id == "ui.calibrateCanvas"));
+84 -6
View File
@@ -54,10 +54,10 @@ fn face_menu_hides_and_finds_the_body() {
for l in ["Create Sketch", "Offset Plane", "Press Pull", "Extrude", "Measure", "Find in Browser", "Find in Timeline"] {
assert!(find(&items, l).enabled, "{l}");
}
assert!(!find(&items, "Appearance…").enabled);
assert!(!find(&items, "Appearance").enabled);
context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0));
assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }]));
context_menu::run_item(&mut app, &find(&items, "Hide Body"), pos2(0.0, 0.0));
context_menu::run_item(&mut app, &find(&items, "Show/Hide"), pos2(0.0, 0.0));
assert_eq!(app.ui.hidden_bodies, vec![b]);
}
@@ -104,11 +104,11 @@ fn sketch_menu_hides_and_moves_to_another_plane() {
let t = Target::Sketch { id };
let items = context_menu::items(&app, &t);
let vis = crate::browser::sketch_visible(&app, id);
context_menu::run_item(&mut app, &find(&items, if vis { "Hide" } else { "Show" }), pos2(0.0, 0.0));
context_menu::run_item(&mut app, &find(&items, "Show/Hide"), pos2(0.0, 0.0));
assert_eq!(crate::browser::sketch_visible(&app, id), !vis);
context_menu::run_item(&mut app, &find(&items, "Show Dimensions"), pos2(0.0, 0.0));
context_menu::run_item(&mut app, &find(&items, "Show Dimension"), pos2(0.0, 0.0));
assert_eq!(app.ui.shown_dims, vec![id]);
assert!(find(&context_menu::items(&app, &t), "Hide Dimensions").enabled);
assert!(find(&context_menu::items(&app, &t), "Hide Dimension").enabled);
context_menu::run_item(&mut app, &find(&items, "Edit Sketch"), pos2(0.0, 0.0));
assert_eq!(app.session.active_sketch, Some(id));
app.finish_sketch();
@@ -144,7 +144,7 @@ fn component_menu_activates_grounds_and_pastes() {
app.run("FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]})).unwrap();
let c = app.session.doc.components[0].id;
let t = Target::Component { id: c };
act(&mut app, &t, "Activate Component");
act(&mut app, &t, "Activate");
assert_eq!(app.session.active_component, c);
act(&mut app, &t, "Ground");
assert!(app.session.doc.occurrence_of(c).unwrap().grounded);
@@ -280,3 +280,81 @@ fn canvas_menu_hides_renames_calibrates_and_deletes() {
crate::delete::delete_selection(&mut app);
assert!(app.session.doc.canvases.is_empty());
}
/// The canvas and browser menus keep Fusion's items in Fusion's order (the ones we have).
#[test]
fn menus_follow_fusion_order() {
let mut app = sample_app();
let b = body(&app);
let order = |items: &[Item], want: &[&str]| {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
let pos: Vec<usize> = want.iter().map(|w| labels.iter().position(|l| l == w).unwrap_or_else(|| panic!("no `{w}` in {labels:?}"))).collect();
assert!(pos.windows(2).all(|p| p[0] < p[1]), "{want:?} out of order in {labels:?}");
};
app.run("select.set", json!({"items": [{"type": "face", "body": b, "index": 0, "point": [0, 0, 0]}]})).unwrap();
let face = context_menu::items(&app, &Target::Viewport);
order(
&face,
&[
"Create Sketch",
"Extrude",
"Offset Plane",
"Shell",
"Edit Feature",
"Edit Profile Sketch",
"Appearance",
"Properties",
"Delete",
"Show/Hide",
"Find in Browser",
],
);
assert_eq!(find(&face, "Extrude").shortcut, "E");
assert!(find(&face, "Edit Profile Sketch").enabled, "the plate's extrude has a sketch");
let body_menu = context_menu::items(&app, &Target::Body { name: b.clone() });
order(
&body_menu,
&[
"Move/Copy",
"Create Components from Bodies",
"Physical Material",
"Appearance",
"Properties",
"Save As Mesh",
"Delete",
"Remove",
"Rename",
"Show/Hide",
"Isolate",
],
);
let id = app.session.doc.features.iter().find(|f| f.name == "Base").unwrap().id;
let sketch = context_menu::items(&app, &Target::Sketch { id });
order(
&sketch,
&[
"Extrude",
"Edit Sketch",
"Redefine Sketch Plane",
"Export DXF…",
"Delete",
"Rename",
"Look At",
"Hide Profile",
"Show Dimension",
"Show/Hide",
"Find in Timeline",
],
);
let folder = context_menu::items(&app, &Target::Folder { component: 0, folder: "bodies".into() });
order(&folder, &["Create Components from Bodies", "New Group", "Show/Hide", "Show All"]);
let origin = context_menu::items(&app, &Target::Origin);
order(&origin, &["Show/Hide", "Show All", "Hide Planes", "Hide Axes"]);
context_menu::run_item(&mut app, &find(&origin, "Hide Planes"), pos2(0.0, 0.0));
assert!(["XY", "XZ", "YZ"].iter().all(|p| app.ui.hidden_origin.iter().any(|h| h == p)));
app.run("select.clear", json!({})).unwrap();
let empty = context_menu::items(&app, &Target::Viewport);
order(&empty, &["Pan", "Zoom", "Orbit", "Show All", "Unisolate", "Extrude", "Fillet"]);
context_menu::run_item(&mut app, &find(&empty, "Pan"), pos2(0.0, 0.0));
assert!(matches!(app.viewport.nav, Some(crate::viewport::NavMode::Pan)));
}