From 66fa6fe584dda5da9c370179e721ac9a76d0e25d Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 07:13:37 +0000 Subject: [PATCH] Dialogs for joints, sheet metal and plastic ASSEMBLE panel (SOLID, SHEET METAL, PLASTIC): New Component, Joint, As-built Joint, Joint Origin, Rigid Group, Drive Joints, Motion Link, Interference. Joint origins are picked by hovering: faces snap to their centre (round faces to the centre of the nearer end), edges to their middle (round edges to their centre), vertices to themselves, shown as glyphs with their z axis. The Joint dialog has the motion type, angle, offsets X/Y/Z, flip, per-value limits and Animate (the preview plays the motion); joints and drives preview on the placed model (world states). Drive Joints moves live with a slider. Interference lists the overlaps and shows them red. Joints appear in the timeline and in a Browser Joints folder (edit, drive, suppress, delete). SHEET METAL tab in Fusion's layout: Flange (base, edge or contour, from the picks; height arrow on the canvas), Hem, Unfold, Refold, Convert to Sheet Metal, Sheet Metal Rules (rule manager), Create Flat Pattern (flat view with up/down bend lines, bend table, DXF export). PLASTIC tab with SETUP: Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Rib, Web and the shared solid commands on the tab. Every feature dialog has live preview, Edit Feature and pre-selection; icons drawn in code. Toolbar layout per tab in workspace.rs; tests for the dialog to command mapping. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- ROADMAP.md | 4 +- crates/engine/src/cmd/joints.rs | 12 +- crates/engine/src/cmd/plastic.rs | 4 +- crates/engine/src/cmd/sheet.rs | 10 +- crates/ui-egui/src/browser.rs | 1 + crates/ui-egui/src/canvas.rs | 11 + crates/ui-egui/src/dialogs.rs | 60 +- crates/ui-egui/src/dialogs_assembly.rs | 984 +++++++++++++++++++ crates/ui-egui/src/dialogs_assembly_tests.rs | 165 ++++ crates/ui-egui/src/dialogs_plastic.rs | 528 ++++++++++ crates/ui-egui/src/dialogs_plastic_tests.rs | 104 ++ crates/ui-egui/src/dialogs_sheet.rs | 667 +++++++++++++ crates/ui-egui/src/dialogs_sheet_tests.rs | 108 ++ crates/ui-egui/src/icons.rs | 122 +++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/preview.rs | 6 +- crates/ui-egui/src/timeline.rs | 3 +- crates/ui-egui/src/toolbar.rs | 10 +- crates/ui-egui/src/workspace.rs | 159 +++ 19 files changed, 2934 insertions(+), 28 deletions(-) create mode 100644 crates/ui-egui/src/dialogs_assembly.rs create mode 100644 crates/ui-egui/src/dialogs_assembly_tests.rs create mode 100644 crates/ui-egui/src/dialogs_plastic.rs create mode 100644 crates/ui-egui/src/dialogs_plastic_tests.rs create mode 100644 crates/ui-egui/src/dialogs_sheet.rs create mode 100644 crates/ui-egui/src/dialogs_sheet_tests.rs create mode 100644 crates/ui-egui/src/workspace.rs diff --git a/ROADMAP.md b/ROADMAP.md index 283b6213..629c5616 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,8 +48,8 @@ headline counts in-scope tabs only and lists the deferred ones separately. | 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), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 | | M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, thicken (needs surfaces), split face, modelled threads | in progress | 12 | | M8 | Surface workspace | deferred | — | -| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, contact sets later; interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches | in progress | 32 | -| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry | in progress | 36 | +| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, contact sets later; interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | +| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index b6db903e..12e848c0 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -13,30 +13,30 @@ use crate::params::{bad, bool_, num, str_, string_list, vec3}; use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("JointOrigin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint").params( + CommandSpec::new("JointOrigin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params( "name?, occurrence?: id|name (default: from the pick), face: [x,y,z] (centre of a planar face) | circle: [x,y,z] (centre of a round end) | point: [x,y,z], z?: [x,y,z]", ), CommandSpec::new("JointAssembleCmdNew", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params( "type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|point: [x,y,z] in world} | origin name); \ values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool; offset?; angle?; name?", ), - CommandSpec::new("JointAsBuiltCmd", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("joint").params( + CommandSpec::new("JointAsBuiltCmd", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params( "type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame", ), - CommandSpec::new("RigidGroupCmd", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("joint").params("occurrences: [ids or names] (move together, as they are)"), - CommandSpec::new("FusionMoveJointsCommand", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("joint").params( + CommandSpec::new("RigidGroupCmd", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"), + CommandSpec::new("FusionMoveJointsCommand", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params( "joint: id|name; value | values: [angles in deg or expressions, distances] (clamped to the limits)", ), CommandSpec::new("FusionMotionRelationshipCommand", "Motion Link", motion_link) .at("SOLID", "ASSEMBLE") - .icon("joint") + .icon("motion_link") .params("a, b: joints; ratio (b = ratio · a + offset); offset?; ia?, ib?: value indices (default 0)"), CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"), CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"), CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"), CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"), CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"), - CommandSpec::new("InterferenceCheckCommand", "Interference", interference).at("SOLID", "INSPECT").icon("measure").noundo().params( + CommandSpec::new("InterferenceCheckCommand", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params( "bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre", ), ]; diff --git a/crates/engine/src/cmd/plastic.rs b/crates/engine/src/cmd/plastic.rs index 75eefdfd..c01c7bba 100644 --- a/crates/engine/src/cmd/plastic.rs +++ b/crates/engine/src/cmd/plastic.rs @@ -26,12 +26,12 @@ pub static COMMANDS: &[CommandSpec] = &[ "position: [x,y,z] on a face (the rest's centre); direction? (default: out of the face); width (round: the diameter); length? (a rectangle, along `along`); along?: [x,y,z]; \ height; draft? (angle; default: the body's plastic rule); thickness? (wall of a hollow rest); body?", ), - CommandSpec::new("FusionManagePlasticRuleCommand", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("params").params( + CommandSpec::new("FusionManagePlasticRuleCommand", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("plastic_rule").params( "name (library or new rule; a new one starts from `from`, default ABS (1.5mm)); from?; material?; thickness?, nominal_radius?, clearance?, knife_edge?, \ reveal_height?, thickness_variation?, draft?, max_thickness?, min_thickness?, min_draft? (expressions; `Thickness` is the rule's); active?: bool (rule for bodies without one); \ delete?: bool (a design rule; a library rule goes back to the library values)", ), - CommandSpec::new("FusionAssignPlasticRuleCommand", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("params").params( + CommandSpec::new("FusionAssignPlasticRuleCommand", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params( "bodies: [names]; rule: name (empty: no rule) — plastic features on these bodies take its draft and clearance as defaults", ), CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"), diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index dd0bafbc..8440f799 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -20,15 +20,15 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("FusionSheetMetalHemFlangeCommand", "Hem", hem) .at("SHEET METAL", "CREATE") - .icon("flange") + .icon("hem") .params("edges: [[x,y,z] on sheet edges]; length (to the outer face); gap? (inner radius, default: rule); flip?; body?"), - CommandSpec::new("FusionSheetmetalUnfoldCommand", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("flat").params("body? (default: the last sheet body) — all bends"), - CommandSpec::new("sheet.refold", "Refold", refold).at("SHEET METAL", "MODIFY").icon("flat").params("body? (default: the last unfolded sheet)"), + CommandSpec::new("FusionSheetmetalUnfoldCommand", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("unfold").params("body? (default: the last sheet body) — all bends"), + CommandSpec::new("sheet.refold", "Refold", refold).at("SHEET METAL", "MODIFY").icon("refold").params("body? (default: the last unfolded sheet)"), CommandSpec::new("ConvertToSheetMetalCmd", "Convert to Sheet Metal", convert) .at("SHEET METAL", "CREATE") - .icon("flat") + .icon("convert_sheet") .params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"), - CommandSpec::new("FusionSheetMetalRulesCommand", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("params").params( + CommandSpec::new("FusionSheetMetalRulesCommand", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params( "name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool", ), CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"), diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index eaf5b326..c9fcff81 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -392,6 +392,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { .show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; component_rows(app, ui, 0, 0, &mut acts); + crate::dialogs_assembly::browser_joints(app, ui); }); }); app.viewport.hover_bodies = acts.hover_bodies; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0e7c8149..8b4be8bc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -53,6 +53,8 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { // Shell thickness grows into the body. Kind::Shell { .. } => (at, normal.map(|n| -n)), Kind::Move { .. } => (at, Some(Vec3::Z)), + Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)), + Kind::Plastic(_) => (at, normal), Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)), _ => (at, None), }) @@ -80,6 +82,15 @@ fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { /// Draw the manipulator and the value box (called by the viewport after the model is drawn). pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) { let Some(mut d) = app.dialog.take() else { return }; + if matches!(d.kind, Kind::Assembly(_)) { + crate::dialogs_assembly::overlay(app, painter, proj, &mut d); + app.dialog = Some(d); + return; + } + if crate::dialogs_sheet::overlay(painter, proj.rect, &d) { + app.dialog = Some(d); + return; + } if let Kind::Measure { result: Some(r), .. } = &d.kind { measure_line(painter, proj, r); app.dialog = Some(d); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b0d315cf..57c67d25 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -198,6 +198,12 @@ pub enum Kind { with: Vec, fail: Vec, }, + /// Joints and components (`dialogs_assembly`). + Assembly(crate::dialogs_assembly::Asm), + /// Sheet metal (`dialogs_sheet`). + Sheet(crate::dialogs_sheet::Sm), + /// Plastic features (`dialogs_plastic`). + Plastic(crate::dialogs_plastic::Pl), } /// The rest of the Hole dialog: placement, extents, tap type and drill point. @@ -254,7 +260,7 @@ fn fits(a: Accept, s: &Sel) -> bool { } impl Dialog { - fn new(kind: Kind, inputs: Vec) -> Dialog { + pub(crate) fn new(kind: Kind, inputs: Vec) -> Dialog { Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, collapsed: false, extra: serde_json::Map::new() } } @@ -392,7 +398,12 @@ impl Dialog { vec![SelInput::new("Target Body", BODIES, false), SelInput::new("Tool Bodies", BODIES, true)], ), "ChangeParameterCommand" => Dialog::new(Kind::Params { new_name: String::new(), new_expr: String::new() }, vec![]), - _ => return None, + _ => { + let (kind, inputs) = crate::dialogs_assembly::start(app, id) + .or_else(|| crate::dialogs_sheet::start(app, id)) + .or_else(|| crate::dialogs_plastic::start(app, id))?; + Dialog::new(kind, inputs) + } }; // Pre-selection: what is selected now becomes the input (first input that takes it); a // face or edge stands for its body where bodies are wanted. @@ -479,6 +490,12 @@ impl Dialog { /// Does this dialog make a feature that can be previewed live? pub fn previews(&self) -> bool { + match &self.kind { + Kind::Assembly(k) => return k.previews(), + Kind::Sheet(k) => return k.previews(), + Kind::Plastic(k) => return k.previews(), + _ => {} + } matches!( self.kind, Kind::Extrude { .. } @@ -531,6 +548,8 @@ impl Dialog { Kind::AnglePlane { angle } => ("Angle", ValueKind::Angle, angle), Kind::Scale { factor } => ("Scale", ValueKind::Unitless, factor), Kind::OffsetFaces { distance } => ("Distance", ValueKind::Length, distance), + Kind::Sheet(k) => return k.primary(&self.inputs), + Kind::Plastic(k) => return k.primary(), _ => return None, }) } @@ -660,13 +679,13 @@ fn plane_value(s: &Session, sel: Option<&Sel>) -> Option { } /// A value field; true when Enter was pressed in it. -fn field(ui: &mut egui::Ui, s: &mut String) -> bool { +pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool { let r = ui.text_edit_singleline(s); crate::params_dialog::complete(ui, &r, s); r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) } -fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) { +pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) { egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| { for (i, l) in labels.iter().enumerate() { ui.selectable_value(sel, i, *l); @@ -721,6 +740,9 @@ fn title(k: &Kind) -> &'static str { Kind::EditParam { .. } => "EDIT DIMENSION", Kind::Rename { .. } => "RENAME", Kind::ConfirmDelete { .. } => "DELETE FEATURE", + Kind::Assembly(k) => k.title(), + Kind::Sheet(k) => k.title(), + Kind::Plastic(k) => k.title(), } } @@ -747,7 +769,7 @@ fn hint(inp: &SelInput) -> &'static str { const LABEL_W: f32 = 92.0; /// A row label in the dialog's label column. -fn row_label(ui: &mut egui::Ui, text: &str) { +pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) { ui.allocate_ui_with_layout(vec2(LABEL_W, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_width(LABEL_W); ui.add(egui::Label::new(text).truncate()).on_hover_text(text); @@ -1335,6 +1357,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { r.request_focus(); ui.end_row(); } + Kind::Assembly(k) => enter |= crate::dialogs_assembly::rows(app, ui, k, &mut d.inputs, &mut d.active), + Kind::Sheet(k) => enter |= crate::dialogs_sheet::rows(app, ui, k, &mut d.inputs), + Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs), Kind::ConfirmDelete { with, fail, .. } => { if !with.is_empty() { row_label(ui, "Also deletes"); @@ -1529,6 +1554,15 @@ pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result Ok(cmds) } +/// The commands the live preview runs: OK's, with a joint's motion while it animates. +pub fn preview_commands(app: &SolveApp, d: &Dialog) -> Result, String> { + let mut cmds = apply_commands(app, d)?; + if let Kind::Assembly(k) = &d.kind { + crate::dialogs_assembly::animate(app, k, &mut cmds); + } + Ok(cmds) +} + /// The commands (id, parameters) a dialog's OK runs. fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, String> { let s = &app.session; @@ -1935,6 +1969,9 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S Kind::Rename { feature, name } => ("FusionRenameTimelineEntryCommand", json!({"feature": feature, "name": name})), Kind::ConfirmDelete { feature, .. } => ("FusionDeleteCommand", json!({ "features": [feature.to_string()] })), Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences => return Ok(Vec::new()), + Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs), + Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra), + Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra), }; let mut params = params; if let Value::Object(m) = &mut params { @@ -2003,7 +2040,7 @@ pub fn axis_of(app: &SolveApp, sel: &Sel) -> Option<(Vec3, Vec3)> { } /// Which profiles of a sketch a profile selection means. -fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &ProfileSel) -> Vec { +pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &ProfileSel) -> Vec { let ps = &ss.profiles; match sel { ProfileSel::All => (0..ps.len()).collect(), @@ -2025,7 +2062,7 @@ fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &ProfileSel) } /// The edge of a visible body through (or nearest) a point. -fn edge_sel(s: &Session, p: Vec3) -> Option { +pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option { let st = s.model.state(); let mut best: Option<(f64, Sel)> = None; for b in &st.bodies { @@ -2041,7 +2078,7 @@ fn edge_sel(s: &Session, p: Vec3) -> Option { } /// The body face containing a point (nearest triangle). -fn face_sel(s: &Session, p: Vec3) -> Option { +pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option { let st = s.model.state(); let mut best: Option<(f64, Sel)> = None; for b in &st.bodies { @@ -2511,7 +2548,12 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option return None, + other => { + let (kind, inputs, extra) = crate::dialogs_sheet::for_feature(app, other).or_else(|| crate::dialogs_plastic::for_feature(app, other))?; + let mut d = Dialog::new(kind, inputs); + d.extra = extra; + d + } }; d.editing = Some((id, marker)); d.active = 0; diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs new file mode 100644 index 00000000..05aa650d --- /dev/null +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -0,0 +1,984 @@ +//! Assembly dialogs: New Component, Joint, As-built Joint, Joint Origin, Rigid Group, Drive +//! Joints, Motion Link and Interference. Joint origins are picked by hovering geometry: a face +//! snaps to its centre (or a round face to the centre of its nearer end), an edge to its middle +//! (a round edge to its centre), a vertex to itself; the snap shows as a glyph with its z axis +//! before the click. Joints and drives preview on the placed (world) model: the moving +//! component shows where it goes, and with Animate the joint plays its motion. + +use egui::{Color32, Pos2, RichText, Stroke, vec2}; +use serde_json::{Value, json}; +use solvecraft_engine::Sel; +use solvecraft_engine::Session; +use solvecraft_engine::doc::expr::Kind as ValueKind; +use solvecraft_engine::doc::joints::JointKind; +use solvecraft_engine::geom::{Aabb3, Mesh, Vec3}; + +use crate::SolveApp; +use crate::dialogs::{Dialog, Kind, combo, field, row_label}; +use crate::preview::{Built, Colors}; +use crate::selection::{BODIES, EDGES, FACES, SelInput, VERTICES}; +use crate::theme::Tokens; +use crate::viewport::Proj; + +/// Joint types in the order the Type box lists them (command names). +pub const TYPES: [&str; 7] = ["rigid", "revolute", "slider", "cylindrical", "pin_slot", "planar", "ball"]; +const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "Pin-slot", "Planar", "Ball"]; +/// What a joint origin input accepts (snaps on faces, edges and vertices). +const SNAPS: u16 = FACES | EDGES | VERTICES; +/// Commands whose effect is where occurrences sit (previewed on the placed model). +const MOVES: [&str; 6] = ["JointAssembleCmdNew", "JointAsBuiltCmd", "FusionMoveJointsCommand", "joint.edit", "joint.limits", "RigidGroupCmd"]; + +#[derive(Clone, Debug)] +pub enum Asm { + NewComponent { name: String, activate: bool }, + Joint(Box), + JointOrigin { name: String, hover: Hover }, + RigidGroup, + Drive { joint: usize, values: Vec }, + MotionLink { a: usize, b: usize, ia: usize, ib: usize, ratio: String, offset: String }, + Interference { result: Option, overlaps: Vec, of: Vec }, +} + +/// The snap under the cursor, remembered for the selection it was found for. +pub type Hover = Option<(Sel, Option)>; + +/// The Joint and As-built Joint dialogs. +#[derive(Clone, Debug)] +pub struct JointForm { + pub as_built: bool, + /// Editing this joint (its origins stay; type, alignment and limits change). + pub editing: Option, + pub kind: usize, + pub flip: bool, + pub angle: String, + pub offset: [String; 3], + /// Per joint value: limited, minimum, maximum. + pub limits: Vec<(bool, String, String)>, + pub animate: bool, + /// Animation phase, 0…1. + pub phase: f64, + pub hover: Hover, +} + +impl JointForm { + fn new(as_built: bool) -> JointForm { + JointForm { + as_built, + editing: None, + kind: 0, + flip: false, + angle: "0 deg".into(), + offset: ["0 mm".into(), "0 mm".into(), "0 mm".into()], + limits: Vec::new(), + animate: false, + phase: 0.0, + hover: None, + } + } + + fn joint_kind(&self) -> JointKind { + TYPES.get(self.kind).and_then(|t| JointKind::parse(t)).unwrap_or(JointKind::Rigid) + } + + /// Limits rows sized to the joint's values. + fn fit_limits(&mut self) { + let k = self.joint_kind(); + let n = k.dofs().len(); + while self.limits.len() < n { + let i = self.limits.len(); + self.limits.push(if k.is_angle(i) { (false, "-90 deg".into(), "90 deg".into()) } else { (false, "0 mm".into(), "20 mm".into()) }); + } + self.limits.truncate(n); + } +} + +impl Asm { + pub fn title(&self) -> &'static str { + match self { + Asm::NewComponent { .. } => "NEW COMPONENT", + Asm::Joint(j) if j.editing.is_some() => "EDIT JOINT", + Asm::Joint(j) if j.as_built => "AS-BUILT JOINT", + Asm::Joint(_) => "JOINT", + Asm::JointOrigin { .. } => "JOINT ORIGIN", + Asm::RigidGroup => "RIGID GROUP", + Asm::Drive { .. } => "DRIVE JOINTS", + Asm::MotionLink { .. } => "MOTION LINK", + Asm::Interference { .. } => "INTERFERENCE", + } + } + + pub fn previews(&self) -> bool { + matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup) + } +} + +/// The dialog for an assembly command: its state and selection inputs. +pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { + let s = &app.session; + let (asm, inputs) = match id { + "FusionCreateNewComponentCommand" => { + (Asm::NewComponent { name: String::new(), activate: true }, vec![SelInput::new("Bodies (optional)", BODIES, true)]) + } + "JointAssembleCmdNew" => ( + Asm::Joint(Box::new(JointForm::new(false))), + vec![SelInput::new("Component 1", SNAPS, false), SelInput::new("Component 2", SNAPS, false)], + ), + "JointAsBuiltCmd" => { + (Asm::Joint(Box::new(JointForm::new(true))), vec![SelInput::new("Components", BODIES, true), SelInput::new("Position", SNAPS, false)]) + } + "JointOrigin" => (Asm::JointOrigin { name: String::new(), hover: None }, vec![SelInput::new("Snap", SNAPS, false)]), + "RigidGroupCmd" => (Asm::RigidGroup, vec![SelInput::new("Components", BODIES, true)]), + "FusionMoveJointsCommand" => { + let joint = movable(s).first().copied().unwrap_or(0); + (Asm::Drive { joint, values: current_values(s, joint) }, vec![]) + } + "FusionMotionRelationshipCommand" => { + let b = usize::from(movable(s).len() > 1); + (Asm::MotionLink { a: 0, b, ia: 0, ib: 0, ratio: "1".into(), offset: "0".into() }, vec![]) + } + "InterferenceCheckCommand" => { + (Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)]) + } + _ => return None, + }; + Some((Kind::Assembly(asm), inputs)) +} + +/// Indices (into the design's joints) of joints that move (not rigid). +fn movable(s: &Session) -> Vec { + s.doc.assembly.joints.iter().enumerate().filter(|(_, j)| j.kind != JointKind::Rigid).map(|(i, _)| i).collect() +} + +/// A joint's values as the Drive dialog shows them (degrees, millimetres). +fn current_values(s: &Session, index: usize) -> Vec { + let Some(j) = s.doc.assembly.joints.get(index) else { return Vec::new() }; + (0..j.kind.dofs().len()) + .map(|i| { + let v = j.values.get(i).copied().unwrap_or(0.0); + if j.kind.is_angle(i) { num(v.to_degrees(), "deg") } else { num(v, "mm") } + }) + .collect() +} + +/// A value with its unit, without trailing zeros ("12 mm", "37.5 deg"). +fn num(v: f64, unit: &str) -> String { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + format!("{} {unit}", if s == "-0" { "0" } else { s }) +} + +/// The Edit Joint dialog for a joint (by id). +pub fn edit_joint(app: &SolveApp, id: u64) -> Option { + let j = app.session.doc.assembly.joints.iter().find(|j| j.id == id)?; + let mut f = JointForm::new(false); + f.editing = Some(id); + f.kind = TYPES.iter().position(|t| JointKind::parse(t) == Some(j.kind)).unwrap_or(0); + f.flip = j.flip; + f.angle = num(j.angle.to_degrees(), "deg"); + f.offset[2] = num(j.offset, "mm"); + f.fit_limits(); + for (i, l) in f.limits.iter_mut().enumerate() { + if let Some(Some((lo, hi))) = j.limits.get(i) { + let fmt = |v: f64| if j.kind.is_angle(i) { num(v.to_degrees(), "deg") } else { num(v, "mm") }; + *l = (true, fmt(*lo), fmt(*hi)); + } + } + Some(Dialog::new(Kind::Assembly(Asm::Joint(Box::new(f))), vec![])) +} + +/// The Drive Joints dialog on a joint (by id). +pub fn drive_joint(app: &SolveApp, id: u64) -> Option { + let joint = app.session.doc.assembly.joints.iter().position(|j| j.id == id)?; + Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint) }), vec![])) +} + +// ---- snaps ---- + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SnapKind { + FaceCenter, + CircleCenter, + EdgeMid, + Vertex, +} + +/// A joint origin snap (world): where it sits, its z axis, and the command's origin parameter. +#[derive(Clone, Debug)] +pub struct SnapPt { + pub at: Vec3, + pub z: Vec3, + pub kind: SnapKind, + pub param: Value, +} + +fn pt(v: Vec3) -> Value { + json!([v.x, v.y, v.z]) +} + +/// The occurrence placing a body (0: the root design), when the body is its primary instance. +fn occurrence_of_body(s: &Session, body: &str) -> Option { + let st = s.model.state(); + let b = st.body(body)?; + let comp = s.doc.body_component(&b.name, b.feature); + if comp == 0 { Some(0) } else { s.doc.occurrence_of(comp).map(|o| o.id) } +} + +/// Centre and unit normal of the circle through a polyline's points, if it lies on one. +pub fn circle_of(pts: &[Vec3]) -> Option<(Vec3, Vec3)> { + let n = pts.len(); + if n < 4 { + return None; + } + let (a, b, c) = (*pts.first()?, *pts.get(n / 3)?, *pts.get(2 * n / 3)?); + let (ab, ac) = (b - a, c - a); + let nn = ab.cross(ac); + let l2 = nn.len2(); + if l2 < 1e-18 { + return None; + } + let center = a + (nn.cross(ab) * ac.len2() + ac.cross(nn) * ab.len2()) / (2.0 * l2); + let r = center.dist(a); + if !r.is_finite() || r > 1e6 { + return None; + } + let tol = (r * 2e-3).max(1e-6); + let normal = nn.normalized()?; + pts.iter().all(|p| (p.dist(center) - r).abs() < tol && (*p - center).dot(normal).abs() < tol).then_some((center, normal)) +} + +fn polyline_mid(pts: &[Vec3]) -> Vec3 { + let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); + let mut acc = 0.0; + for w in pts.windows(2) { + let l = w[0].dist(w[1]); + if acc + l >= len * 0.5 && l > 0.0 { + return w[0].lerp(w[1], (len * 0.5 - acc) / l); + } + acc += l; + } + pts.first().copied().unwrap_or_default() +} + +/// Normal of the mesh face `f` near `p`. +fn face_normal_near(m: &Mesh, f: u32, p: Vec3) -> Option { + m.triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, tf)| **tf == f) + .filter_map(|(t, _)| m.tri(t)) + .min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(p).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(p))) + .and_then(|[a, b, c]| (b - a).cross(c - a).normalized()) +} + +/// The snap a picked item stands for (world coordinates, as the viewport shows them). +pub fn snap_of(s: &Session, sel: &Sel) -> Option { + let st = s.world_state(); + let (body, mut snap) = match sel { + Sel::Face { body, index, point } => { + let b = st.body(body)?; + let tol = (b.body.size() * 2e-3).max(1e-3); + let f = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == *index)?; + match f.plane_normal { + Some(n) => (body, SnapPt { at: f.centroid, z: n, kind: SnapKind::FaceCenter, param: json!({"face": pt(*point)}) }), + None => { + let c = solvecraft_engine::kernel::cylinder_face_at(&b.body, *point)?; + let sv = (*point - c.axis_point).dot(c.axis); + let (at, dir) = if (sv - c.start).abs() <= (c.end - sv).abs() { (c.start, -1.0) } else { (c.end, 1.0) }; + ( + body, + SnapPt { + at: c.axis_point + c.axis * at, + z: c.axis * dir, + kind: SnapKind::CircleCenter, + param: json!({"circle": pt(*point)}), + }, + ) + } + } + } + Sel::Edge { body, index, point } => { + let b = st.body(body)?; + let m = b.mesh(); + let e = m.edges.get(*index)?; + match circle_of(e) { + Some((c, n)) => { + // The axis points out of the material: away from the faces' side. + let faces = m.edge_faces.get(*index).cloned().unwrap_or_default(); + let out = faces.iter().filter_map(|f| face_normal_near(&m, *f, *point)).fold(Vec3::ZERO, |a, x| a + x); + let z = if out.dot(n) < 0.0 { -n } else { n }; + (body, SnapPt { at: c, z, kind: SnapKind::CircleCenter, param: json!({"point": pt(c), "z": pt(z)}) }) + } + None => { + let mid = polyline_mid(e); + let z = m.edge_faces.get(*index).and_then(|fs| fs.first()).and_then(|f| face_normal_near(&m, *f, mid)).unwrap_or(Vec3::Z); + (body, SnapPt { at: mid, z, kind: SnapKind::EdgeMid, param: json!({"point": pt(mid), "z": pt(z)}) }) + } + } + } + Sel::Vertex { body, point } => (body, SnapPt { at: *point, z: Vec3::Z, kind: SnapKind::Vertex, param: json!({"point": pt(*point)}) }), + _ => return None, + }; + if let (Some(occ), Value::Object(m)) = (occurrence_of_body(s, body), &mut snap.param) { + m.insert("occurrence".into(), json!(occ)); + } + Some(snap) +} + +/// A snap moved across its own plane by (x, y) (the joint's offset), as an explicit frame. +fn shifted(s: &SnapPt, x: f64, y: f64) -> Value { + let ax = s.z.any_perp(); + let ay = s.z.cross(ax); + let mut p = json!({"frame": {"origin": pt(s.at + ax * x + ay * y), "z": pt(s.z), "x": pt(ax)}}); + if let Some(o) = s.param.get("occurrence") { + p["occurrence"] = o.clone(); + } + p +} + +// ---- rows ---- + +/// The dialog's own rows (after the selection inputs); true when Enter was pressed in a value. +pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [SelInput], active: &mut usize) -> bool { + let t = Tokens::get(); + let mut enter = false; + match k { + Asm::NewComponent { name, activate } => { + row_label(ui, "Name"); + let r = ui.add(egui::TextEdit::singleline(name).hint_text("Component1")); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + row_label(ui, "Activate"); + ui.checkbox(activate, ""); + ui.end_row(); + } + Asm::Joint(f) => { + // As-built: two components picked, the position comes next. + if f.as_built && *active == 0 && inputs.first().is_some_and(|i| i.items.len() >= 2) { + *active = 1; + } + row_label(ui, "Motion"); + ui.label(""); + ui.end_row(); + row_label(ui, "Type"); + combo(ui, "jt_type", &TYPE_LABELS, &mut f.kind); + ui.end_row(); + f.fit_limits(); + if !f.as_built { + row_label(ui, "Angle"); + enter |= field(ui, &mut f.angle); + ui.end_row(); + for (l, v) in ["Offset X", "Offset Y", "Offset Z"].iter().zip(f.offset.iter_mut()) { + if f.editing.is_some() && *l != "Offset Z" { + continue; + } + row_label(ui, l); + enter |= field(ui, v); + ui.end_row(); + } + row_label(ui, "Flip"); + ui.checkbox(&mut f.flip, ""); + ui.end_row(); + } + let kind = f.joint_kind(); + if kind != JointKind::Rigid { + row_label(ui, "Animate"); + let label = if f.animate { "■ Stop" } else { "▶ Play" }; + if ui.button(label).on_hover_text("Play the joint's motion in the view").clicked() { + f.animate = !f.animate; + } + ui.end_row(); + for (i, (on, lo, hi)) in f.limits.iter_mut().enumerate() { + let name = kind.dofs().get(i).copied().unwrap_or("value"); + row_label(ui, &format!("{} limits", capital(name))); + ui.checkbox(on, ""); + ui.end_row(); + if *on { + row_label(ui, " Minimum"); + enter |= field(ui, lo); + ui.end_row(); + row_label(ui, " Maximum"); + enter |= field(ui, hi); + ui.end_row(); + } + } + } else { + f.animate = false; + } + if f.animate { + let now = ui.input(|i| i.time); + // 48 steps over 3 s: each one a preview. + f.phase = ((now / 3.0).fract() * 48.0).floor() / 48.0; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); + } + } + Asm::JointOrigin { name, .. } => { + row_label(ui, "Name"); + let r = ui.add(egui::TextEdit::singleline(name).hint_text("Joint Origin1")); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + } + Asm::RigidGroup => {} + Asm::Drive { joint, values } => { + let s = &app.session; + let list = movable(s); + if list.is_empty() { + row_label(ui, ""); + ui.label(RichText::new("no joint that moves yet").color(t.text_dim)); + ui.end_row(); + return false; + } + let labels: Vec = + list.iter().filter_map(|i| s.doc.assembly.joints.get(*i)).map(|j| format!("{} ({:?})", j.name, j.kind)).collect(); + let refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let mut pos = list.iter().position(|i| i == joint).unwrap_or(0); + row_label(ui, "Joint"); + combo(ui, "drv_joint", &refs, &mut pos); + ui.end_row(); + let pick = list.get(pos).copied().unwrap_or(0); + if pick != *joint || values.is_empty() { + *joint = pick; + *values = current_values(s, pick); + } + let Some(j) = s.doc.assembly.joints.get(*joint).cloned() else { return false }; + for (i, v) in values.iter_mut().enumerate() { + let angle = j.kind.is_angle(i); + let unit_kind = if angle { ValueKind::Angle } else { ValueKind::Length }; + let name = j.kind.dofs().get(i).copied().unwrap_or("value"); + row_label(ui, &capital(name)); + enter |= field(ui, v); + ui.end_row(); + // A slider over the limits (or a turn, or ±100 mm). + let (lo, hi) = match j.limits.get(i).copied().flatten() { + Some((a, b)) => (a.min(b), a.max(b)), + None if angle => (-std::f64::consts::PI, std::f64::consts::PI), + None => (-100.0, 100.0), + }; + let cur = s.doc.eval(v, unit_kind).ok().filter(|x| x.is_finite()).unwrap_or(0.0); + let (mut shown, scale) = if angle { (cur.to_degrees(), 1f64.to_degrees()) } else { (cur, 1.0) }; + row_label(ui, ""); + ui.spacing_mut().slider_width = 124.0; + let r = ui.add(egui::Slider::new(&mut shown, lo * scale..=hi * scale).show_value(false)); + if r.changed() { + *v = if angle { format!("{shown:.1} deg") } else { format!("{shown:.2} mm") }; + } + ui.end_row(); + } + } + Asm::MotionLink { a, b, ia, ib, ratio, offset } => { + let s = &app.session; + let joints = &s.doc.assembly.joints; + if joints.len() < 2 { + row_label(ui, ""); + ui.label(RichText::new("needs two joints").color(t.text_dim)); + ui.end_row(); + return false; + } + let labels: Vec<&str> = joints.iter().map(|j| j.name.as_str()).collect(); + for (l, sel, idx, id) in [("Joint 1", &mut *a, &mut *ia, "ml_a"), ("Joint 2", &mut *b, &mut *ib, "ml_b")] { + row_label(ui, l); + combo(ui, id, &labels, sel); + ui.end_row(); + let dofs = joints.get(*sel).map(|j| j.kind.dofs()).unwrap_or(&[]); + if dofs.len() > 1 { + row_label(ui, " Value"); + let caps: Vec = dofs.iter().map(|d| capital(d)).collect(); + let refs: Vec<&str> = caps.iter().map(String::as_str).collect(); + combo(ui, &format!("{id}_i"), &refs, idx); + ui.end_row(); + } else { + *idx = 0; + } + } + row_label(ui, "Ratio"); + enter |= field(ui, ratio); + ui.end_row(); + row_label(ui, "Offset"); + enter |= field(ui, offset); + ui.end_row(); + } + Asm::Interference { result, overlaps, of } => { + let picked: Vec = inputs.first().map(|i| i.items.clone()).unwrap_or_default(); + row_label(ui, ""); + let stale = result.is_some() && *of != picked; + if ui.button(if stale { "Compute again" } else { "Compute" }).clicked() { + let (r, o) = interference(app, &picked); + *result = Some(r); + *overlaps = o; + *of = picked; + } + ui.end_row(); + if let Some(r) = result.as_ref() { + interference_rows(ui, r); + } + } + } + enter +} + +fn capital(s: &str) -> String { + let mut c = s.chars(); + c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() +} + +/// Run the check and find the overlapping volumes (world placement) to show in red. +fn interference(app: &mut SolveApp, picked: &[Sel]) -> (Value, Vec) { + let names: Vec = picked.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect(); + let r = match app.run("InterferenceCheckCommand", json!({ "bodies": names })) { + Ok(v) => v, + Err(e) => return (json!({ "error": e }), Vec::new()), + }; + let st = app.session.world_state(); + let mut meshes = Vec::new(); + for hit in r["interferences"].as_array().into_iter().flatten() { + let (Some(a), Some(b)) = (hit["a"].as_str().and_then(|n| st.body(n)), hit["b"].as_str().and_then(|n| st.body(n))) else { continue }; + if let Ok(Some(o)) = solvecraft_engine::kernel::boolean(&a.body, &b.body, solvecraft_engine::kernel::BoolOp::Intersect) + && let Ok(m) = o.tessellate((o.size() * 2e-3).max(1e-3)) + { + meshes.push(m); + } + } + (r, meshes) +} + +fn interference_rows(ui: &mut egui::Ui, r: &Value) { + let t = Tokens::get(); + if let Some(e) = r.get("error").and_then(Value::as_str) { + row_label(ui, ""); + ui.label(RichText::new(e).color(t.error)); + ui.end_row(); + return; + } + let hits = r["interferences"].as_array().cloned().unwrap_or_default(); + row_label(ui, "Checked"); + ui.label(format!("{} bodies", r["checked"].as_u64().unwrap_or(0))); + ui.end_row(); + if hits.is_empty() { + row_label(ui, "Result"); + ui.label(RichText::new("no interference").color(t.text_dim)); + ui.end_row(); + return; + } + row_label(ui, "Interferes"); + ui.label(RichText::new("Volume").strong()); + ui.end_row(); + for h in hits { + row_label(ui, &format!("{} · {}", h["a"].as_str().unwrap_or(""), h["b"].as_str().unwrap_or(""))); + ui.label(RichText::new(format!("{:.3} mm³", h["volume_mm3"].as_f64().unwrap_or(0.0))).color(t.error)); + ui.end_row(); + } +} + +// ---- commands ---- + +fn sel_items(inputs: &[SelInput], i: usize) -> &[Sel] { + inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[]) +} + +/// The occurrences of picked bodies (deduplicated, in pick order). +fn occurrences(s: &Session, items: &[Sel]) -> Vec { + let mut out = Vec::new(); + for x in items { + if let Sel::Body { name } = x + && let Some(o) = occurrence_of_body(s, name) + && !out.contains(&o) + { + out.push(o); + } + } + out +} + +/// The commands the dialog's OK runs. +pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result, String> { + let s = &app.session; + let snap = |i: usize, what: &str| -> Result { + sel_items(inputs, i).first().and_then(|x| snap_of(s, x)).ok_or_else(|| format!("select {what} first")) + }; + let eval = |e: &str, kind: ValueKind| -> Result { + if e.trim().is_empty() { Ok(0.0) } else { s.doc.eval(e, kind).map_err(|e| e.to_string()) } + }; + let cmd = |id: &str, p: Value| vec![(id.to_string(), p)]; + Ok(match k { + Asm::NewComponent { name, activate } => { + let bodies: Vec = + sel_items(inputs, 0).iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect(); + if bodies.is_empty() { + let mut p = json!({ "activate": activate }); + if !name.trim().is_empty() { + p["name"] = json!(name.trim()); + } + cmd("FusionCreateNewComponentCommand", p) + } else { + cmd("FusionCreateComponentsFromBodiesCommand", json!({ "bodies": bodies })) + } + } + Asm::Joint(f) => { + let kind = TYPES.get(f.kind).copied().unwrap_or("rigid"); + let jk = f.joint_kind(); + let limits: Vec = + f.limits.iter().take(jk.dofs().len()).map(|(on, lo, hi)| if *on { json!([lo, hi]) } else { Value::Null }).collect(); + if let Some(id) = f.editing { + let mut out = cmd("joint.edit", json!({"joint": id, "type": kind, "flip": f.flip, "offset": f.offset[2], "angle": f.angle})); + for (i, l) in limits.iter().enumerate() { + let p = match l.as_array() { + Some(a) => json!({"joint": id, "index": i, "min": a.first(), "max": a.get(1)}), + None => json!({"joint": id, "index": i}), + }; + out.push(("joint.limits".into(), p)); + } + return Ok(out); + } + if f.as_built { + let occs = occurrences(s, sel_items(inputs, 0)); + if occs.len() != 2 { + return Err("select two components".into()); + } + let mut p = json!({"type": kind, "a": occs.first(), "b": occs.get(1), "limits": limits}); + if let Some(x) = sel_items(inputs, 1).first().and_then(|x| snap_of(s, x)) { + p["at"] = x.param; + } + return Ok(cmd("JointAsBuiltCmd", p)); + } + let (a, b) = (snap(0, "component 1's joint origin")?, snap(1, "component 2's joint origin")?); + if a.param.get("occurrence").is_some() && a.param.get("occurrence") == b.param.get("occurrence") { + return Err("the two origins are on the same component".into()); + } + let (x, y) = (eval(&f.offset[0], ValueKind::Length)?, eval(&f.offset[1], ValueKind::Length)?); + let a_param = if x.abs() > 1e-12 || y.abs() > 1e-12 { shifted(&a, x, y) } else { a.param }; + let p = json!({"type": kind, "a": a_param, "b": b.param, "flip": f.flip, "offset": f.offset[2], "angle": f.angle, "limits": limits}); + cmd("JointAssembleCmdNew", p) + } + Asm::JointOrigin { name, .. } => { + let mut p = snap(0, "a snap")?.param; + if !name.trim().is_empty() { + p["name"] = json!(name.trim()); + } + cmd("JointOrigin", p) + } + Asm::RigidGroup => { + let occs = occurrences(s, sel_items(inputs, 0)); + if occs.len() < 2 { + return Err("select two or more components".into()); + } + cmd("RigidGroupCmd", json!({ "occurrences": occs })) + } + Asm::Drive { joint, values } => { + let j = s.doc.assembly.joints.get(*joint).ok_or("no joint that moves yet")?; + cmd("FusionMoveJointsCommand", json!({"joint": j.id, "values": values})) + } + Asm::MotionLink { a, b, ia, ib, ratio, offset } => { + let js = &s.doc.assembly.joints; + let (Some(ja), Some(jb)) = (js.get(*a), js.get(*b)) else { return Err("needs two joints".into()) }; + let num = |e: &str| eval(e, ValueKind::Unitless); + cmd("FusionMotionRelationshipCommand", json!({"a": ja.id, "b": jb.id, "ia": ia, "ib": ib, "ratio": num(ratio)?, "offset": num(offset)?})) + } + // The check runs from its Compute button; OK only closes. + Asm::Interference { .. } => Vec::new(), + }) +} + +/// The preview's commands: OK's, with the joint's values playing its motion while animating. +pub fn animate(app: &SolveApp, k: &Asm, cmds: &mut Vec<(String, Value)>) { + let Asm::Joint(f) = k else { return }; + if !f.animate { + return; + } + let kind = f.joint_kind(); + let tau = std::f64::consts::TAU; + let wave = (f.phase * tau).sin(); + let values: Vec = (0..kind.dofs().len()) + .map(|i| { + let limited = f.limits.get(i).filter(|l| l.0); + let angle = kind.is_angle(i); + let vk = if angle { ValueKind::Angle } else { ValueKind::Length }; + match limited.and_then(|(_, lo, hi)| Some((app.session.doc.eval(lo, vk).ok()?, app.session.doc.eval(hi, vk).ok()?))) { + // Back and forth between the limits. + Some((lo, hi)) => { + let v = lo + (hi - lo) * (0.5 - 0.5 * (f.phase * tau).cos()); + if angle { format!("{} deg", v.to_degrees()) } else { format!("{v} mm") } + } + None if angle && i == 0 => format!("{} deg", f.phase * 360.0), + None if angle => format!("{} deg", 30.0 * wave), + None => format!("{} mm", 20.0 * wave), + } + }) + .collect(); + let drive = match f.editing { + Some(id) => Some(id), + None => match cmds.iter_mut().find(|(c, _)| c == "JointAssembleCmdNew") { + Some((_, p)) => { + p["values"] = json!(values); + None + } + // As-built joints start at zero: drive the new one after it is made. + None => Some(next_joint_id(&app.session)), + }, + }; + if let Some(id) = drive { + cmds.push(("FusionMoveJointsCommand".into(), json!({"joint": id, "values": values}))); + } +} + +fn next_joint_id(s: &Session) -> u64 { + s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0) + 1 +} + +/// Joint and drive previews: the commands run on a scratch copy and the placed model before and +/// after is compared, so the components that move show where they go (`None`: not such +/// commands). +pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Option> { + if cmds.is_empty() || !cmds.iter().all(|(c, _)| MOVES.contains(&c.as_str())) { + return None; + } + let mut scratch = s.scratch(); + for (id, p) in cmds { + if let Err(e) = scratch.execute(id, p) { + return Some(Err(e.to_string())); + } + } + let (before, after) = (s.world_state(), scratch.world_state()); + // A moved component looks like itself (no new surface), with its old place as the ghost. + let colors = Colors { added: colors.body, ..colors }; + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::preview::build(&before, &after, colors))); + Some(match built { + Ok(mut b) => { + b.bounds = after.bodies.iter().fold(Aabb3::EMPTY, |acc, x| acc.union(&x.mesh().bounds())); + Ok(b) + } + Err(_) => Err("the preview failed".into()), + }) +} + +// ---- overlay ---- + +/// Snap glyphs under the cursor and at the picked origins; the overlap of an interference. +pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dialog) { + let t = Tokens::get(); + let Kind::Assembly(k) = &mut d.kind else { return }; + if let Asm::Interference { overlaps, .. } = k { + // Front faces only, shaded by how they face the view. + let back = proj.cam.back(); + for m in overlaps.iter() { + let mut em = egui::Mesh::default(); + for tri in &m.triangles { + let Some(ps) = m.tri(tri) else { continue }; + let Some(n) = (ps[1] - ps[0]).cross(ps[2] - ps[0]).normalized() else { continue }; + let facing = n.dot(back); + if facing <= 0.0 { + continue; + } + let k = 0.55 + 0.45 * facing; + let fill = Color32::from_rgba_unmultiplied((235.0 * k) as u8, (45.0 * k) as u8, (40.0 * k) as u8, 210); + let sp: Vec = ps.iter().filter_map(|p| proj.to_screen(*p)).collect(); + if sp.len() == 3 { + let base = em.vertices.len() as u32; + for p in sp { + em.colored_vertex(p, fill); + } + em.add_triangle(base, base + 1, base + 2); + } + } + painter.add(egui::Shape::mesh(em)); + } + return; + } + let hover = match k { + Asm::Joint(f) => Some(&mut f.hover), + Asm::JointOrigin { hover, .. } => Some(hover), + _ => None, + }; + let Some(hover) = hover else { return }; + // The picked origins: Component 1 in the accent colour, Component 2 in orange. + let picked: Vec<(SnapPt, Color32)> = d + .inputs + .iter() + .enumerate() + .filter(|(_, i)| i.accept == SNAPS) + .filter_map(|(n, i)| i.items.first().and_then(|x| snap_of(&app.session, x)).map(|s| (s, if n == 0 { t.accent } else { t.warning }))) + .collect(); + for (s, c) in &picked { + glyph(painter, proj, s, *c, app.cam.half_height()); + } + // The snap under the cursor (found again only when the hovered item changes). + let accepting = d.inputs.get(d.active).is_some_and(|i| i.accept == SNAPS); + let under = app.viewport.hover.as_ref().and_then(crate::viewport::hit_sel).filter(|_| accepting); + match under { + Some(sel) => { + let fresh = !matches!(hover, Some((s, _)) if *s == sel); + if fresh { + *hover = Some((sel.clone(), snap_of(&app.session, &sel))); + } + if let Some((_, Some(s))) = hover.as_ref() { + glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height()); + } + } + None => *hover = None, + } +} + +/// A snap: its kind's marker at the point and the z axis as an arrow. +fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64) { + let Some(c) = proj.to_screen(s.at) else { return }; + let len = half_height * 2.0 * 0.06; + if let Some(tip) = proj.to_screen(s.at + s.z * len) { + painter.line_segment([c, tip], Stroke::new(2.0, col)); + let d = (tip - c).normalized(); + let n = vec2(-d.y, d.x); + painter.add(egui::Shape::convex_polygon(vec![tip, tip - d * 8.0 + n * 4.0, tip - d * 8.0 - n * 4.0], col, Stroke::NONE)); + } + let white = Stroke::new(1.5, Color32::WHITE); + match s.kind { + SnapKind::FaceCenter => { + painter.rect(egui::Rect::from_center_size(c, vec2(10.0, 10.0)), 1.0, col, white, egui::StrokeKind::Middle); + } + SnapKind::CircleCenter => { + painter.circle(c, 6.0, col, white); + painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.2, Color32::WHITE)); + painter.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.2, Color32::WHITE)); + } + SnapKind::EdgeMid => { + painter.add(egui::Shape::convex_polygon(vec![c + vec2(0.0, -7.0), c + vec2(6.5, 5.0), c + vec2(-6.5, 5.0)], col, white)); + } + SnapKind::Vertex => { + painter.add(egui::Shape::convex_polygon( + vec![c + vec2(0.0, -6.5), c + vec2(6.5, 0.0), c + vec2(0.0, 6.5), c + vec2(-6.5, 0.0)], + col, + white, + )); + } + } +} + +// ---- timeline and browser ---- + +/// A joint's menu (timeline and browser): Edit, Drive, Suppress, Delete. +fn joint_menu( + app: &SolveApp, + ui: &mut egui::Ui, + (id, kind, suppressed): (u64, JointKind, bool), + open: &mut Option, + run: &mut Option<(&'static str, Value)>, +) { + if ui.button("Edit Joint").clicked() { + *open = edit_joint(app, id); + ui.close(); + } + if kind != JointKind::Rigid && ui.button("Drive Joint").clicked() { + *open = drive_joint(app, id); + ui.close(); + } + if ui.button(if suppressed { "Unsuppress" } else { "Suppress" }).clicked() { + *run = Some(("joint.edit", json!({"joint": id, "suppressed": !suppressed}))); + ui.close(); + } + ui.separator(); + if ui.button("Delete").clicked() { + *run = Some(("joint.delete", json!({ "joint": id }))); + ui.close(); + } +} + +/// Joints after the features in the timeline: double-click edits, the menu drives, suppresses +/// or deletes. Returns the x after them. +pub fn timeline_joints(app: &mut SolveApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, r: egui::Rect) -> f32 { + let t = Tokens::get(); + let joints: Vec<(u64, String, JointKind, bool)> = + app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect(); + if joints.is_empty() { + return x; + } + x += 8.0; + let mut open: Option = None; + let mut run: Option<(&'static str, Value)> = None; + for (id, name, kind, suppressed) in joints { + let br = egui::Rect::from_min_size(egui::pos2(x, r.top() + 6.0), vec2(28.0, 28.0)); + let resp = ui.interact(br, ui.id().with(("joint", id)), egui::Sense::click()); + let bg = if resp.hovered() { t.hover } else { t.timeline_item }; + p.rect(br, 3.0, bg, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + crate::icons::paint(p, br.shrink(4.0), "joint", if suppressed { t.border } else { t.icon }, t.icon_fill, t.accent); + if suppressed { + p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim)); + } + let resp = resp.on_hover_text(format!("{name} ({kind:?} joint)\nDouble-click to edit · right-click for more")); + if resp.double_clicked() { + open = edit_joint(app, id); + } + resp.context_menu(|ui| joint_menu(app, ui, (id, kind, suppressed), &mut open, &mut run)); + x += 32.0; + } + if let Some((c, prm)) = run { + let _ = app.run(c, prm); + } + if let Some(d) = open { + app.tool = None; + app.dialog = Some(d); + } + x +} + +/// The Joints folder at the end of the Browser: a row per joint (double-click edits, the menu +/// edits, drives, suppresses or deletes). +pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let joints: Vec<(u64, String, JointKind, bool)> = + app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect(); + if joints.is_empty() { + return; + } + let fold_id = egui::Id::new("sc_browser_joints_open"); + let mut open = ui.data(|d| d.get_temp::(fold_id)).unwrap_or(true); + let row = |ui: &mut egui::Ui, depth: f32, icon: &str, label: &str, dim: bool, fold: Option| -> egui::Response { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), egui::Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let x = r.left() + 2.0 + depth * 14.0; + let cy = r.center().y; + if let Some(o) = fold { + let c = egui::pos2(x + 6.0, cy); + let pts = if o { + vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] + } else { + vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); + } + let ir = egui::Rect::from_center_size(egui::pos2(x + 26.0, cy), vec2(16.0, 16.0)); + crate::icons::paint(ui.painter(), ir, icon, if dim { t.border } else { t.icon }, t.icon_fill, t.accent); + ui.painter().text( + egui::pos2(x + 38.0, cy), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + if dim { t.text_dim } else { t.text }, + ); + resp + }; + if row(ui, 1.0, "folder", "Joints", false, Some(open)).clicked() { + open = !open; + ui.data_mut(|d| d.insert_temp(fold_id, open)); + } + if !open { + return; + } + let mut dialog: Option = None; + let mut run: Option<(&'static str, Value)> = None; + for (id, name, kind, suppressed) in joints { + let resp = row(ui, 2.0, "joint", &name, suppressed, None).on_hover_text(format!("{kind:?} joint")); + if resp.double_clicked() { + dialog = edit_joint(app, id); + } + resp.context_menu(|ui| joint_menu(app, ui, (id, kind, suppressed), &mut dialog, &mut run)); + } + if let Some((c, p)) = run { + let _ = app.run(c, p); + } + if let Some(d) = dialog { + app.tool = None; + app.dialog = Some(d); + } +} + +#[cfg(test)] +#[path = "dialogs_assembly_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs new file mode 100644 index 00000000..d0059812 --- /dev/null +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -0,0 +1,165 @@ +use serde_json::json; +use solvecraft_engine::Session; + +use super::*; +use crate::Services; +use crate::dialogs::{apply_commands, face_sel, preview_commands}; + +/// Components A (grounded) and B: a 20 x 20 x 10 box each, B 30 mm along X. +fn two_boxes() -> SolveApp { + let mut s = Session::default(); + for (name, x) in [("A", 0), ("B", 30)] { + s.execute("component.activate", &json!({"component": "root"})).unwrap(); + s.execute("FusionCreateNewComponentCommand", &json!({ "name": name })).unwrap(); + s.execute("PrimitiveBox", &json!({"length": 20, "width": 20, "height": 10, "corner": [x, 0, 0]})).unwrap(); + } + s.execute("component.activate", &json!({"component": "root"})).unwrap(); + s.execute("occurrence.ground", &json!({"occurrence": "A:1", "grounded": true})).unwrap(); + SolveApp::new(s, Services::default()) +} + +fn occ(app: &SolveApp, name: &str) -> u64 { + let c = app.session.doc.find_component(name).unwrap(); + app.session.doc.occurrence_of(c).unwrap().id +} + +/// Two face picks make a joint between the components that own them; offsets across the face +/// turn component 1's origin into a frame; limits follow the type. +#[test] +fn joint_from_two_snaps() { + let mut app = two_boxes(); + app.start("JointAssembleCmdNew"); + let mut d = app.dialog.clone().unwrap(); + let (fa, fb) = (face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap(), face_sel(&app.session, Vec3::new(40.0, 10.0, 0.0)).unwrap()); + assert!(apply_commands(&app, &d).is_err(), "no snaps yet"); + d.pick(&app.session, fa); + assert_eq!(d.active, 1, "component 2 is next"); + d.pick(&app.session, fb); + if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind { + f.kind = 1; + f.fit_limits(); + f.limits[0] = (true, "-45 deg".into(), "45 deg".into()); + } + let c = apply_commands(&app, &d).unwrap(); + let (id, p) = &c[0]; + assert_eq!(id, "JointAssembleCmdNew"); + assert_eq!(p["type"], "revolute"); + assert_eq!(p["a"]["occurrence"], occ(&app, "A")); + assert_eq!(p["b"]["occurrence"], occ(&app, "B")); + assert!(p["a"]["face"].is_array() && p["b"]["face"].is_array(), "{p}"); + assert_eq!(p["limits"], json!([["-45 deg", "45 deg"]])); + // The bottom face of B snaps at its centre, z down. + let sb = snap_of(&app.session, &d.inputs[1].items[0]).unwrap(); + assert!(sb.at.dist(Vec3::new(40.0, 10.0, 0.0)) < 1e-6 && sb.z.dist(-Vec3::Z) < 1e-9, "{sb:?}"); + // Offset X: an explicit frame 5 mm along the face. + if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind { + f.offset[0] = "5 mm".into(); + } + let p = apply_commands(&app, &d).unwrap()[0].1.clone(); + let o = &p["a"]["frame"]["origin"]; + let at = Vec3::new(o[0].as_f64().unwrap(), o[1].as_f64().unwrap(), o[2].as_f64().unwrap()); + assert!((at.dist(Vec3::new(10.0, 10.0, 10.0)) - 5.0).abs() < 1e-9, "{p}"); + // OK makes it. + for (id, p) in apply_commands(&app, &d).unwrap() { + app.run(&id, p).unwrap(); + } + assert_eq!(app.session.doc.assembly.joints.len(), 1); +} + +/// The preview moves B where the joint puts it and plays the motion while animating. +#[test] +fn joint_and_drive_preview_on_the_placed_model() { + let mut app = two_boxes(); + app.start("JointAssembleCmdNew"); + let mut d = app.dialog.clone().unwrap(); + d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap()); + d.pick(&app.session, face_sel(&app.session, Vec3::new(40.0, 10.0, 0.0)).unwrap()); + let colors = crate::preview::Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }; + let cmds = preview_commands(&app, &d).unwrap(); + let b = world_preview(&app.session, &cmds, colors).unwrap().unwrap(); + assert_eq!(b.replaced, vec!["Body2".to_string()], "B moves"); + assert!(app.session.doc.assembly.joints.is_empty(), "the preview changes nothing"); + if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind { + f.kind = 1; + f.animate = true; + f.phase = 0.25; + } + let cmds = preview_commands(&app, &d).unwrap(); + assert_eq!(cmds[0].1["values"], json!(["90 deg"]), "a quarter turn into the animation"); + assert!(apply_commands(&app, &d).unwrap()[0].1.get("values").is_none(), "OK makes the joint at rest"); + // Drive: the dialog's value turns the joint, the preview shows it. + for (id, p) in apply_commands(&app, &d).unwrap() { + app.run(&id, p).unwrap(); + } + app.start("FusionMoveJointsCommand"); + let mut d = app.dialog.clone().unwrap(); + if let Kind::Assembly(Asm::Drive { values, .. }) = &mut d.kind { + assert_eq!(values, &vec!["0 deg".to_string()]); + values[0] = "30 deg".into(); + } + let cmds = apply_commands(&app, &d).unwrap(); + assert_eq!(cmds[0].0, "FusionMoveJointsCommand"); + let b = world_preview(&app.session, &cmds, colors).unwrap().unwrap(); + assert_eq!(b.replaced, vec!["Body2".to_string()]); + assert!(world_preview(&app.session, &[("Extrude".into(), json!({}))], colors).is_none(), "features preview as before"); +} + +/// Edit Joint changes type, alignment and limits of the joint in place. +#[test] +fn edit_joint_runs_edit_and_limits() { + let mut app = two_boxes(); + let (a, b) = (occ(&app, "A"), occ(&app, "B")); + app.run( + "JointAssembleCmdNew", + json!({"type": "slider", "a": {"occurrence": a, "face": [10, 10, 10]}, "b": {"occurrence": b, "face": [40, 10, 0]}}), + ) + .unwrap(); + let id = app.session.doc.assembly.joints[0].id; + let mut d = edit_joint(&app, id).unwrap(); + assert!(d.inputs.is_empty()); + if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind { + assert_eq!(TYPES[f.kind], "slider"); + f.limits[0] = (true, "0 mm".into(), "15 mm".into()); + f.offset[2] = "2 mm".into(); + } + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c.iter().map(|x| x.0.as_str()).collect::>(), ["joint.edit", "joint.limits"]); + for (id, p) in c { + app.run(&id, p).unwrap(); + } + let j = &app.session.doc.assembly.joints[0]; + assert_eq!(j.limits[0], Some((0.0, 15.0))); + assert!((j.offset - 2.0).abs() < 1e-12); +} + +/// As-built joints and rigid groups take components from body picks; New Component makes one +/// from bodies when bodies are picked. +#[test] +fn component_picks_become_occurrences() { + let mut app = two_boxes(); + app.session.selection = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Body2".into() }]; + app.start("JointAsBuiltCmd"); + let d = app.dialog.clone().unwrap(); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["a"], occ(&app, "A")); + assert_eq!(c[0].1["b"], occ(&app, "B")); + app.session.selection = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Body2".into() }]; + app.start("RigidGroupCmd"); + let c = apply_commands(&app, app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(c[0].1["occurrences"], json!([occ(&app, "A"), occ(&app, "B")])); + app.dialog = None; + app.start("FusionCreateNewComponentCommand"); + let c = apply_commands(&app, app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(c[0].0, "FusionCreateNewComponentCommand"); +} + +/// Round edges snap to their centre; straight ones are not circles. +#[test] +fn circles_from_edge_polylines() { + let pts: Vec = + (0..=24).map(|i| f64::from(i) / 24.0 * std::f64::consts::TAU).map(|t| Vec3::new(5.0 + 3.0 * t.cos(), 2.0 + 3.0 * t.sin(), 7.0)).collect(); + let (c, n) = circle_of(&pts).unwrap(); + assert!(c.dist(Vec3::new(5.0, 2.0, 7.0)) < 1e-9 && n.cross(Vec3::Z).len() < 1e-9); + let line: Vec = (0..10).map(|i| Vec3::new(f64::from(i), 0.0, 0.0)).collect(); + assert!(circle_of(&line).is_none()); +} diff --git a/crates/ui-egui/src/dialogs_plastic.rs b/crates/ui-egui/src/dialogs_plastic.rs new file mode 100644 index 00000000..7aa8ff28 --- /dev/null +++ b/crates/ui-egui/src/dialogs_plastic.rs @@ -0,0 +1,528 @@ +//! Plastic dialogs: Boss, Lip / Groove, Snap Fit and Rest (placed by a pick on a face), Manage +//! Plastic Rules and Assign Plastic Rule. Values left empty take the body's plastic rule. + +use serde_json::{Map, Value, json}; +use solvecraft_engine::Sel; +use solvecraft_engine::doc::FeatureKind; +use solvecraft_engine::doc::expr::Kind as ValueKind; +use solvecraft_engine::doc::plastic::PlasticRule; +use solvecraft_engine::geom::Vec3; + +use crate::SolveApp; +use crate::dialogs::{Kind, combo, face_sel, field, row_label}; +use crate::selection::{BODIES, FACES, PLANAR_FACES, SelInput}; + +#[derive(Clone, Debug)] +pub enum Pl { + Boss { + diameter: String, + height: String, + hole_diameter: String, + hole_depth: String, + draft: String, + fillet: String, + ribs: String, + rib_thickness: String, + rib_length: String, + rib_offset: String, + }, + Lip { + groove: bool, + width: String, + height: String, + gap: String, + outside: bool, + }, + SnapFit { + hook: usize, + length: String, + thickness: String, + width: String, + catch_depth: String, + catch_length: String, + }, + Rest { + round: bool, + width: String, + length: String, + height: String, + draft: String, + thickness: String, + }, + /// The rule manager: the rule shown (index into the rules, past the end for a new one). + Rules { + pick: usize, + name: String, + values: Vec, + active: bool, + loaded: Option, + }, + Assign { + rule: usize, + }, +} + +impl Pl { + pub fn title(&self) -> &'static str { + match self { + Pl::Boss { .. } => "BOSS", + Pl::Lip { groove: false, .. } => "LIP", + Pl::Lip { .. } => "GROOVE", + Pl::SnapFit { .. } => "SNAP FIT", + Pl::Rest { .. } => "REST", + Pl::Rules { .. } => "MANAGE PLASTIC RULES", + Pl::Assign { .. } => "ASSIGN PLASTIC RULE", + } + } + + pub fn previews(&self) -> bool { + !matches!(self, Pl::Rules { .. } | Pl::Assign { .. }) + } + + pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> { + Some(match self { + Pl::Boss { height, .. } | Pl::Lip { height, .. } | Pl::Rest { height, .. } => ("Height", ValueKind::Length, height), + Pl::SnapFit { length, .. } => ("Length", ValueKind::Length, length), + _ => return None, + }) + } +} + +fn boss() -> Pl { + Pl::Boss { + diameter: "6 mm".into(), + height: "10 mm".into(), + hole_diameter: "3 mm".into(), + hole_depth: String::new(), + draft: String::new(), + fillet: String::new(), + ribs: "0".into(), + rib_thickness: "1 mm".into(), + rib_length: "3 mm".into(), + rib_offset: "1 mm".into(), + } +} + +fn snap_fit() -> Pl { + Pl::SnapFit { + hook: 0, + length: "10 mm".into(), + thickness: "1.5 mm".into(), + width: "5 mm".into(), + catch_depth: "1 mm".into(), + catch_length: "2 mm".into(), + } +} + +fn rest() -> Pl { + Pl::Rest { round: true, width: "10 mm".into(), length: "15 mm".into(), height: "2 mm".into(), draft: String::new(), thickness: String::new() } +} + +fn lip(groove: bool) -> Pl { + Pl::Lip { groove, width: "1.5 mm".into(), height: "2 mm".into(), gap: String::new(), outside: false } +} + +/// The dialog for a plastic command. +pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { + let (pl, inputs) = match id { + "FusionBossCommand" => (boss(), vec![SelInput::new("Position", PLANAR_FACES, false)]), + "FusionLipCommand" => (lip(false), vec![SelInput::new("Rim face", FACES, false)]), + "FusionSnapFitCommand" => (snap_fit(), vec![SelInput::new("Position", PLANAR_FACES, false)]), + "FusionRestCommand" => (rest(), vec![SelInput::new("Position", PLANAR_FACES, false)]), + "FusionManagePlasticRuleCommand" => { + let rules = app.session.doc.plastic_rules(); + let pick = app.session.doc.plastic.active.as_ref().and_then(|a| rules.iter().position(|r| &r.name == a)).unwrap_or(0); + (Pl::Rules { pick, name: String::new(), values: Vec::new(), active: false, loaded: None }, vec![]) + } + "FusionAssignPlasticRuleCommand" => (Pl::Assign { rule: 1 }, vec![SelInput::new("Bodies", BODIES, true)]), + _ => return None, + }; + Some((Kind::Plastic(pl), inputs)) +} + +/// A value row whose empty value means "from the rule" (or another default). +fn opt_field(ui: &mut egui::Ui, label: &str, v: &mut String, hint: &str) -> bool { + row_label(ui, label); + let r = ui.add(egui::TextEdit::singleline(v).hint_text(hint)); + crate::params_dialog::complete(ui, &r, v); + ui.end_row(); + r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) +} + +fn value_row(ui: &mut egui::Ui, label: &str, v: &mut String) -> bool { + row_label(ui, label); + let e = field(ui, v); + ui.end_row(); + e +} + +/// Directions across a face (with normal `n`) a snap fit's catch can point: two axes and their +/// opposites, named after the world axis they run along. +pub fn hook_dirs(n: Vec3) -> Vec<(String, Vec3)> { + let u = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().filter_map(|a| (a - n * a.dot(n)).normalized()).next().unwrap_or_else(|| n.any_perp()); + let v = n.cross(u); + let name = |d: Vec3| -> String { + let (axis, c) = [("X", d.x), ("Y", d.y), ("Z", d.z)].into_iter().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap_or(("X", 0.0)); + format!("{}{axis}", if c < 0.0 { "−" } else { "+" }) + }; + [u, -u, v, -v].into_iter().map(|d| (name(d), d)).collect() +} + +fn face_normal(app: &SolveApp, inputs: &[SelInput]) -> Option { + match inputs.first()?.items.first()? { + Sel::Face { body, index, .. } => crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n), + _ => None, + } +} + +/// The dialog's own rows (after the selection inputs); true when Enter was pressed in a value. +pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pl, inputs: &[SelInput]) -> bool { + let mut enter = false; + match k { + Pl::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset } => { + enter |= value_row(ui, "Diameter", diameter); + enter |= value_row(ui, "Height", height); + enter |= opt_field(ui, "Hole Diameter", hole_diameter, "no hole"); + enter |= opt_field(ui, "Hole Depth", hole_depth, "to the face"); + enter |= opt_field(ui, "Draft Angle", draft, "from the rule"); + enter |= opt_field(ui, "Root Fillet", fillet, "none"); + enter |= value_row(ui, "Ribs", ribs); + if app.session.doc.eval(ribs, ValueKind::Unitless).is_ok_and(|n| n >= 1.0) { + enter |= value_row(ui, " Rib Thickness", rib_thickness); + enter |= value_row(ui, " Rib Length", rib_length); + enter |= value_row(ui, " Rib Offset", rib_offset); + } + } + Pl::Lip { groove, width, height, gap, outside } => { + row_label(ui, "Type"); + let mut ty = usize::from(*groove); + combo(ui, "pl_lip", &["Lip", "Groove"], &mut ty); + *groove = ty == 1; + ui.end_row(); + row_label(ui, "Rim Edge"); + let mut side = usize::from(*outside); + combo(ui, "pl_side", &["Inside", "Outside"], &mut side); + *outside = side == 1; + ui.end_row(); + enter |= value_row(ui, "Width", width); + enter |= value_row(ui, "Height", height); + if *groove { + enter |= opt_field(ui, "Clearance", gap, "from the rule"); + } + } + Pl::SnapFit { hook, length, thickness, width, catch_depth, catch_length } => { + let dirs = face_normal(app, inputs).map(hook_dirs).unwrap_or_default(); + if !dirs.is_empty() { + let names: Vec<&str> = dirs.iter().map(|(n, _)| n.as_str()).collect(); + row_label(ui, "Catch Direction"); + combo(ui, "pl_hook", &names, hook); + ui.end_row(); + } + enter |= value_row(ui, "Length", length); + enter |= value_row(ui, "Thickness", thickness); + enter |= value_row(ui, "Width", width); + enter |= value_row(ui, "Catch Depth", catch_depth); + enter |= value_row(ui, "Catch Length", catch_length); + } + Pl::Rest { round, width, length, height, draft, thickness } => { + row_label(ui, "Shape"); + let mut sh = usize::from(!*round); + combo(ui, "pl_rest", &["Round", "Rectangular"], &mut sh); + *round = sh == 0; + ui.end_row(); + enter |= value_row(ui, if *round { "Diameter" } else { "Width" }, width); + if !*round { + enter |= value_row(ui, "Length", length); + } + enter |= value_row(ui, "Height", height); + enter |= opt_field(ui, "Draft Angle", draft, "from the rule"); + enter |= opt_field(ui, "Wall Thickness", thickness, "solid"); + } + Pl::Rules { pick, name, values, active, loaded } => { + let rules = app.session.doc.plastic_rules(); + let mut names: Vec = rules.iter().map(|r| r.name.clone()).collect(); + names.push("New rule…".into()); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + row_label(ui, "Rule"); + combo(ui, "pl_rules", &refs, pick); + ui.end_row(); + if *loaded != Some(*pick) { + let base = rules.get(*pick).or(rules.first()).cloned(); + if let Some(b) = base { + *values = std::iter::once(b.material.clone()).chain(b.fields().iter().map(|(_, e, _)| (*e).clone())).collect(); + *active = app.session.doc.plastic.active.as_deref() == Some(b.name.as_str()) && *pick < rules.len(); + } + if *pick >= rules.len() { + *name = format!("Plastic Rule{}", app.session.doc.plastic.rules.len() + 1); + } + *loaded = Some(*pick); + } + if *pick >= rules.len() { + row_label(ui, "Name"); + ui.text_edit_singleline(name); + ui.end_row(); + } + let keys = rule_keys(); + for (i, v) in values.iter_mut().enumerate() { + let label = if i == 0 { "Material".to_string() } else { keys.get(i - 1).map(|k| label_of(k)).unwrap_or_default() }; + row_label(ui, &label); + enter |= if i == 0 { ui.text_edit_singleline(v).lost_focus() && ui.input(|x| x.key_pressed(egui::Key::Enter)) } else { field(ui, v) }; + ui.end_row(); + } + row_label(ui, "Active rule"); + ui.checkbox(active, ""); + ui.end_row(); + } + Pl::Assign { rule } => { + let mut names: Vec = vec!["(no rule)".into()]; + names.extend(app.session.doc.plastic_rules().into_iter().map(|r| r.name)); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + row_label(ui, "Rule"); + combo(ui, "pl_assign", &refs, rule); + ui.end_row(); + } + } + enter +} + +/// The rule's value fields, in the order `PlasticRule::fields` gives them. +fn rule_keys() -> Vec<&'static str> { + PlasticRule::library().first().map(|r| r.fields().iter().map(|(k, _, _)| *k).collect()).unwrap_or_default() +} + +fn label_of(key: &str) -> String { + key.split('_') + .map(|w| { + let mut c = w.chars(); + c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() + }) + .collect::>() + .join(" ") +} + +fn pt(v: Vec3) -> Value { + json!([v.x, v.y, v.z]) +} + +/// Put non-empty optional values into the parameters. +fn put(p: &mut Value, list: &[(&str, &String)]) { + for (k, v) in list { + if !v.trim().is_empty() { + p[*k] = json!(v); + } + } +} + +/// The commands the dialog's OK runs (`extra`: parameters kept from the feature being edited). +pub fn commands(app: &SolveApp, k: &Pl, inputs: &[SelInput], extra: &Map) -> Result, String> { + let face = inputs + .first() + .and_then(|i| i.items.first()) + .and_then(|x| if let Sel::Face { body, point, .. } = x { Some((body.clone(), *point)) } else { None }); + let placed = |what: &str| -> Result { + let (body, at) = face.clone().ok_or_else(|| format!("select {what} first"))?; + Ok(json!({"position": pt(at), "body": body})) + }; + let (cmd, mut p): (&str, Value) = match k { + Pl::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset } => { + let mut p = placed("a point on a face")?; + p["diameter"] = json!(diameter); + p["height"] = json!(height); + put(&mut p, &[("hole_diameter", hole_diameter), ("hole_depth", hole_depth), ("draft", draft), ("fillet", fillet)]); + if app.session.doc.eval(ribs, ValueKind::Unitless).is_ok_and(|n| n >= 1.0) { + put(&mut p, &[("ribs", ribs), ("rib_thickness", rib_thickness), ("rib_length", rib_length), ("rib_offset", rib_offset)]); + } + ("FusionBossCommand", p) + } + Pl::Lip { groove, width, height, gap, outside } => { + let (body, at) = face.clone().ok_or("select the rim face first")?; + let mut p = json!({"face": pt(at), "body": body, "width": width, "height": height, + "type": if *groove { "groove" } else { "lip" }, "side": if *outside { "outside" } else { "inside" }}); + if *groove { + put(&mut p, &[("gap", gap)]); + } + ("FusionLipCommand", p) + } + Pl::SnapFit { hook, length, thickness, width, catch_depth, catch_length } => { + let mut p = placed("a point on a face")?; + let n = extra + .get("direction") + .and_then(|v| Some(Vec3::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?, v.get(2)?.as_f64()?))) + .or(face_normal(app, inputs)); + let dir = n.map(hook_dirs).and_then(|d| d.get(*hook).map(|x| x.1)).ok_or("pick a planar face")?; + p["hook"] = pt(dir); + for (key, v) in + [("length", length), ("thickness", thickness), ("width", width), ("catch_depth", catch_depth), ("catch_length", catch_length)] + { + p[key] = json!(v); + } + ("FusionSnapFitCommand", p) + } + Pl::Rest { round, width, length, height, draft, thickness } => { + let mut p = placed("a point on a face")?; + p["width"] = json!(width); + p["height"] = json!(height); + if !*round { + p["length"] = json!(length); + } + put(&mut p, &[("draft", draft), ("thickness", thickness)]); + ("FusionRestCommand", p) + } + Pl::Rules { pick, name, values, active, .. } => { + let rules = app.session.doc.plastic_rules(); + let n = match rules.get(*pick) { + Some(r) => r.name.clone(), + None => name.trim().to_string(), + }; + if n.is_empty() { + return Err("give the rule a name".into()); + } + let mut p = json!({"name": n, "active": active}); + if let Some(m) = values.first().filter(|m| !m.trim().is_empty()) { + p["material"] = json!(m); + } + for (key, v) in rule_keys().iter().zip(values.iter().skip(1)) { + if !v.trim().is_empty() { + p[*key] = json!(v); + } + } + ("FusionManagePlasticRuleCommand", p) + } + Pl::Assign { rule } => { + let bodies: Vec = inputs + .first() + .into_iter() + .flat_map(|i| &i.items) + .filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }) + .collect(); + if bodies.is_empty() { + return Err("select bodies first".into()); + } + let name = rule.checked_sub(1).and_then(|i| app.session.doc.plastic_rules().get(i).map(|r| r.name.clone())).unwrap_or_default(); + ("FusionAssignPlasticRuleCommand", json!({"bodies": bodies, "rule": name})) + } + }; + if let Value::Object(m) = &mut p { + for (key, v) in extra { + m.entry(key.clone()).or_insert_with(|| v.clone()); + } + } + Ok(vec![(cmd.to_string(), p)]) +} + +fn s(o: &Option) -> String { + o.clone().unwrap_or_default() +} + +/// The dialog editing a plastic feature, filled from it. +pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec, Map)> { + let ss = &app.session; + let mut extra = Map::new(); + let (pl, id, at): (Pl, &str, Vec3) = match kind { + FeatureKind::Boss { + position, + direction, + diameter, + height, + hole_diameter, + hole_depth, + draft, + fillet, + ribs, + rib_thickness, + rib_length, + rib_offset, + body, + } => { + extra.insert("direction".into(), pt(*direction)); + if let Some(b) = body { + extra.insert("body".into(), json!(b)); + } + let mut b = boss(); + if let Pl::Boss { + diameter: d, + height: h, + hole_diameter: hd, + hole_depth: hp, + draft: dr, + fillet: f, + ribs: r, + rib_thickness: rt, + rib_length: rl, + rib_offset: ro, + } = &mut b + { + *d = diameter.clone(); + *h = height.clone(); + *hd = s(hole_diameter); + *hp = s(hole_depth); + *dr = s(draft); + *f = s(fillet); + *r = ribs.clone().unwrap_or_else(|| "0".into()); + for (slot, v) in [(rt, rib_thickness), (rl, rib_length), (ro, rib_offset)] { + if let Some(v) = v { + *slot = v.clone(); + } + } + } + (b, "FusionBossCommand", *position) + } + FeatureKind::Lip { face, width, height, groove, gap, outside, body } => { + if let Some(b) = body { + extra.insert("body".into(), json!(b)); + } + (Pl::Lip { groove: *groove, width: width.clone(), height: height.clone(), gap: s(gap), outside: *outside }, "FusionLipCommand", *face) + } + FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => { + extra.insert("direction".into(), pt(*direction)); + if let Some(b) = body { + extra.insert("body".into(), json!(b)); + } + let h = hook_dirs(*direction).iter().position(|(_, d)| d.dist(*hook) < 1e-6); + if h.is_none() { + extra.insert("hook".into(), pt(*hook)); + } + let f = Pl::SnapFit { + hook: h.unwrap_or(0), + length: length.clone(), + thickness: thickness.clone(), + width: width.clone(), + catch_depth: catch_depth.clone(), + catch_length: catch_length.clone(), + }; + (f, "FusionSnapFitCommand", *position) + } + FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => { + extra.insert("direction".into(), pt(*direction)); + if let Some(a) = along { + extra.insert("along".into(), pt(*a)); + } + if let Some(b) = body { + extra.insert("body".into(), json!(b)); + } + let r = Pl::Rest { + round: length.is_none(), + width: width.clone(), + length: length.clone().unwrap_or_else(|| "15 mm".into()), + height: height.clone(), + draft: s(draft), + thickness: s(thickness), + }; + (r, "FusionRestCommand", *position) + } + _ => return None, + }; + let (_, mut inputs) = start(app, id)?; + if let Some(i) = inputs.first_mut() { + i.items = face_sel(ss, at).into_iter().collect(); + } + // A pick that no longer finds its face keeps the stored point. + if let Some(i) = inputs.first_mut().filter(|i| i.items.is_empty()) { + i.items = vec![Sel::Face { body: extra.get("body").and_then(Value::as_str).unwrap_or_default().to_string(), index: 0, point: at }]; + } + Some((Kind::Plastic(pl), inputs, extra)) +} + +#[cfg(test)] +#[path = "dialogs_plastic_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs_plastic_tests.rs b/crates/ui-egui/src/dialogs_plastic_tests.rs new file mode 100644 index 00000000..5bbb4ad9 --- /dev/null +++ b/crates/ui-egui/src/dialogs_plastic_tests.rs @@ -0,0 +1,104 @@ +use serde_json::json; +use solvecraft_engine::Session; + +use super::*; +use crate::Services; +use crate::dialogs::{apply_commands, face_sel, for_feature as edit_dialog}; + +fn app_with(cmds: &[(&str, Value)]) -> SolveApp { + let mut s = Session::default(); + for (id, p) in cmds { + s.execute(id, p).unwrap(); + } + SolveApp::new(s, Services::default()) +} + +fn run_all(app: &mut SolveApp, c: Vec<(String, Value)>) { + for (id, p) in c { + app.run(&id, p).unwrap(); + } +} + +/// A face pick places a boss; optional values left empty take the rule; ribs only with a count; +/// Edit Feature keeps the direction and refills the values. +#[test] +fn boss_from_a_face_pick() { + let mut app = app_with(&[("PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}))]); + app.session.selection = face_sel(&app.session, Vec3::new(30.0, 30.0, 3.0)).into_iter().collect(); + app.start("FusionBossCommand"); + let mut d = app.dialog.clone().unwrap(); + assert_eq!(d.primary().map(|p| p.0), Some("Height")); + let c = apply_commands(&app, &d).unwrap(); + let p = &c[0].1; + assert_eq!(p["position"], json!([30.0, 30.0, 3.0])); + assert_eq!(p["hole_diameter"], "3 mm"); + assert!(p.get("draft").is_none() && p.get("ribs").is_none(), "{p}"); + if let Kind::Plastic(Pl::Boss { ribs, .. }) = &mut d.kind { + *ribs = "4".into(); + } + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["ribs"], "4"); + run_all(&mut app, c); + let f = app.session.doc.features.last().unwrap().clone(); + let e = edit_dialog(&app, f.id, None).unwrap(); + assert!(matches!(&e.kind, Kind::Plastic(Pl::Boss { ribs, .. }) if ribs == "4")); + let c = apply_commands(&app, &e).unwrap(); + assert_eq!(c[0].0, "timeline.redefine"); + assert_eq!(c[0].1["params"]["direction"], json!([0.0, 0.0, 1.0])); +} + +/// A snap fit's catch points across the face; a groove takes its clearance; rests can be round +/// or rectangular. +#[test] +fn snap_lip_rest() { + for n in [Vec3::Z, Vec3::X, Vec3::new(0.0, -1.0, 0.0)] { + let dirs = hook_dirs(n); + assert_eq!(dirs.len(), 4); + assert!(dirs.iter().all(|(_, d)| d.dot(n).abs() < 1e-12 && (d.len() - 1.0).abs() < 1e-12)); + } + let mut app = app_with(&[ + ("PrimitiveBox", json!({"length": 80, "width": 50, "height": 30})), + ("FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2})), + ]); + app.session.selection = face_sel(&app.session, Vec3::new(1.0, 25.0, 30.0)).into_iter().collect(); + app.start("FusionLipCommand"); + let mut d = app.dialog.clone().unwrap(); + if let Kind::Plastic(Pl::Lip { groove, gap, .. }) = &mut d.kind { + *groove = true; + *gap = "0.2 mm".into(); + } + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["type"], "groove"); + assert_eq!(c[0].1["gap"], "0.2 mm"); + run_all(&mut app, c); + let mut app = app_with(&[("PrimitiveBox", json!({"length": 40, "width": 40, "height": 4}))]); + app.session.selection = face_sel(&app.session, Vec3::new(20.0, 20.0, 4.0)).into_iter().collect(); + app.start("FusionSnapFitCommand"); + let c = apply_commands(&app, app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(c[0].1["hook"], json!([1.0, 0.0, 0.0])); + run_all(&mut app, c); + app.dialog = None; + app.session.selection = face_sel(&app.session, Vec3::new(5.0, 5.0, 4.0)).into_iter().collect(); + app.start("FusionRestCommand"); + let mut d = app.dialog.clone().unwrap(); + assert!(apply_commands(&app, &d).unwrap()[0].1.get("length").is_none(), "round"); + if let Kind::Plastic(Pl::Rest { round, .. }) = &mut d.kind { + *round = false; + } + assert_eq!(apply_commands(&app, &d).unwrap()[0].1["length"], "15 mm"); +} + +/// The rule manager edits a library rule into the design; Assign gives bodies a rule. +#[test] +fn plastic_rules_manage_and_assign() { + let mut app = app_with(&[("PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}))]); + app.start("FusionManagePlasticRuleCommand"); + let d = app.dialog.clone().unwrap(); + assert!(apply_commands(&app, &d).is_ok()); + app.session.selection = vec![Sel::Body { name: "Body1".into() }]; + app.start("FusionAssignPlasticRuleCommand"); + let c = apply_commands(&app, app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(c[0].1["bodies"], json!(["Body1"])); + assert_eq!(c[0].1["rule"], "ABS (1.5mm)"); + run_all(&mut app, c); +} diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs new file mode 100644 index 00000000..900a29dd --- /dev/null +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -0,0 +1,667 @@ +//! Sheet metal dialogs: Flange (base from profiles, edge flanges from picked edges, contour from +//! open sketch lines; the picks decide which), Hem, Unfold and Refold, Convert to Sheet Metal, +//! Sheet Metal Rules (the rule manager) and Create Flat Pattern (a flat view of the part with +//! its bend lines, the bend table and DXF export). + +use egui::{Align2, Color32, FontId, Pos2, Rect, RichText, Stroke, pos2, vec2}; +use serde_json::{Map, Value, json}; +use solvecraft_engine::Sel; +use solvecraft_engine::doc::expr::Kind as ValueKind; +use solvecraft_engine::doc::sheet::SheetRule; +use solvecraft_engine::doc::{FeatureKind, ProfileSel}; +use solvecraft_engine::geom::Vec3; + +use crate::SolveApp; +use crate::dialogs::{Dialog, Kind, combo, edge_sel, face_sel, field, profile_indices, row_label}; +use crate::selection::{BODIES, CURVES, EDGES, PLANAR_FACES, PROFILES, SelInput}; +use crate::theme::Tokens; + +const FLANGE_TYPES: [&str; 3] = ["Base", "Edge", "Contour"]; +const POSITIONS: [&str; 3] = ["inside", "outside", "middle"]; +const POSITION_LABELS: [&str; 3] = ["Inside", "Outside", "Middle"]; +/// Rule fields the manager edits (parameter name, label). +const RULE_FIELDS: [(&str, &str); 8] = [ + ("thickness", "Thickness"), + ("k_factor", "K Factor"), + ("bend_radius", "Bend Radius"), + ("relief_width", "Relief Width"), + ("relief_depth", "Relief Depth"), + ("corner_relief", "Corner Relief"), + ("hem_gap", "Hem Gap"), + ("gap", "Flange Gap"), +]; + +#[derive(Clone, Debug)] +pub enum Sm { + /// Base, edge or contour flange (`ty` follows the picks: profiles, edges, sketch lines). + Flange { + ty: usize, + height: String, + angle: String, + radius: String, + position: usize, + flip: bool, + distance: String, + reverse: bool, + rule: usize, + }, + Hem { + length: String, + gap: String, + flip: bool, + }, + Unfold { + refold: bool, + }, + Convert { + rule: usize, + }, + /// The rule manager: the rule shown (index into the rules, or past the end for a new one). + Rules { + pick: usize, + name: String, + values: Vec, + active: bool, + loaded: Option, + }, + /// The flat pattern of the picked (or last) sheet body. + FlatPattern { + data: Option>, + key: Option<(u64, Option)>, + status: Option, + }, +} + +impl Sm { + pub fn title(&self) -> &'static str { + match self { + Sm::Flange { .. } => "FLANGE", + Sm::Hem { .. } => "HEM", + Sm::Unfold { refold: false } => "UNFOLD", + Sm::Unfold { refold: true } => "REFOLD", + Sm::Convert { .. } => "CONVERT TO SHEET METAL", + Sm::Rules { .. } => "SHEET METAL RULES", + Sm::FlatPattern { .. } => "FLAT PATTERN", + } + } + + pub fn previews(&self) -> bool { + !matches!(self, Sm::FlatPattern { .. }) + } + + /// The value on the canvas (the flange's follows its picks). + pub fn primary(&mut self, inputs: &[SelInput]) -> Option<(&'static str, ValueKind, &mut String)> { + if let Sm::Flange { ty, .. } = self { + *ty = flange_type(inputs); + } + Some(match self { + Sm::Flange { ty: 1, height, .. } => ("Height", ValueKind::Length, height), + Sm::Flange { ty: 2, distance, .. } => ("Distance", ValueKind::Length, distance), + Sm::Hem { length, .. } => ("Length", ValueKind::Length, length), + _ => return None, + }) + } +} + +fn new_flange(ty: usize) -> Sm { + Sm::Flange { + ty, + height: "10 mm".into(), + angle: "90 deg".into(), + radius: String::new(), + position: 0, + flip: false, + distance: "20 mm".into(), + reverse: false, + rule: 0, + } +} + +/// The dialog for a sheet metal command. +pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { + let (sm, inputs) = match id { + "FusionSheetMetalFlangeCommand" => (new_flange(0), vec![SelInput::new("Profile / Edges", PROFILES | EDGES | CURVES, true)]), + "FusionSheetMetalHemFlangeCommand" => { + (Sm::Hem { length: "5 mm".into(), gap: String::new(), flip: false }, vec![SelInput::new("Edges", EDGES, true)]) + } + "FusionSheetmetalUnfoldCommand" => (Sm::Unfold { refold: false }, vec![SelInput::new("Body (last if none)", BODIES, false)]), + "sheet.refold" => (Sm::Unfold { refold: true }, vec![SelInput::new("Body (last if none)", BODIES, false)]), + "ConvertToSheetMetalCmd" => (Sm::Convert { rule: 0 }, vec![SelInput::new("Face", PLANAR_FACES, false)]), + "FusionSheetMetalRulesCommand" => { + let active = app.session.doc.sheet.active.clone(); + let rules = rule_list(app); + let pick = active.and_then(|a| rules.iter().position(|r| r.name == a)).unwrap_or(0); + (Sm::Rules { pick, name: String::new(), values: Vec::new(), active: true, loaded: None }, vec![]) + } + "FusionSheetMetalFlatPatternCmd" => { + (Sm::FlatPattern { data: None, key: None, status: None }, vec![SelInput::new("Body (last if none)", BODIES, false)]) + } + _ => return None, + }; + Some((Kind::Sheet(sm), inputs)) +} + +/// The design's rules (the default steel rule when it has none). +fn rule_list(app: &SolveApp) -> Vec { + let r = &app.session.doc.sheet.rules; + if r.is_empty() { vec![SheetRule::steel()] } else { r.clone() } +} + +/// "(active rule)" and the rule names, for a rule box. +fn rule_names(app: &SolveApp) -> Vec { + std::iter::once("(active rule)".to_string()).chain(rule_list(app).into_iter().map(|r| r.name)).collect() +} + +fn rule_combo(app: &SolveApp, ui: &mut egui::Ui, id: &str, rule: &mut usize) { + let names = rule_names(app); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + row_label(ui, "Rule"); + combo(ui, id, &refs, rule); + ui.end_row(); +} + +/// The flange type the picks make: edges an edge flange, sketch lines a contour, else base. +fn flange_type(inputs: &[SelInput]) -> usize { + let items = inputs.first().map(|i| i.items.as_slice()).unwrap_or(&[]); + if items.iter().any(|x| matches!(x, Sel::Edge { .. })) { + 1 + } else if items.iter().any(|x| matches!(x, Sel::SketchCurve { .. })) { + 2 + } else { + 0 + } +} + +/// The dialog's own rows (after the selection inputs); true when Enter was pressed in a value. +pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [SelInput]) -> bool { + let t = Tokens::get(); + let mut enter = false; + match k { + Sm::Flange { ty, height, angle, radius, position, flip, distance, reverse, rule } => { + *ty = flange_type(inputs); + row_label(ui, "Type"); + ui.label(FLANGE_TYPES.get(*ty).copied().unwrap_or("")); + ui.end_row(); + match *ty { + 1 => { + row_label(ui, "Height"); + enter |= field(ui, height); + ui.end_row(); + row_label(ui, "Angle"); + enter |= field(ui, angle); + ui.end_row(); + row_label(ui, "Bend Position"); + combo(ui, "sm_pos", &POSITION_LABELS, position); + ui.end_row(); + row_label(ui, "Bend Radius"); + let r = ui.add(egui::TextEdit::singleline(radius).hint_text("from the rule")); + crate::params_dialog::complete(ui, &r, radius); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + } + 2 => { + row_label(ui, "Distance"); + enter |= field(ui, distance); + ui.end_row(); + row_label(ui, "Other side"); + ui.checkbox(reverse, ""); + ui.end_row(); + rule_combo(app, ui, "sm_rule", rule); + } + _ => rule_combo(app, ui, "sm_rule", rule), + } + row_label(ui, "Flip"); + ui.checkbox(flip, ""); + ui.end_row(); + } + Sm::Hem { length, gap, flip } => { + row_label(ui, "Type"); + ui.label("Flat"); + ui.end_row(); + row_label(ui, "Length"); + enter |= field(ui, length); + ui.end_row(); + row_label(ui, "Gap"); + let r = ui.add(egui::TextEdit::singleline(gap).hint_text("from the rule")); + crate::params_dialog::complete(ui, &r, gap); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + row_label(ui, "Flip"); + ui.checkbox(flip, ""); + ui.end_row(); + } + Sm::Unfold { .. } => {} + Sm::Convert { rule } => rule_combo(app, ui, "sm_conv_rule", rule), + Sm::Rules { pick, name, values, active, loaded } => { + let rules = rule_list(app); + let mut names: Vec = rules.iter().map(|r| r.name.clone()).collect(); + names.push("New rule…".into()); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + row_label(ui, "Rule"); + combo(ui, "sm_rules", &refs, pick); + ui.end_row(); + if *loaded != Some(*pick) { + // Show the picked rule (a new one starts from the one shown before). + let base = rules.get(*pick).cloned().unwrap_or_else(|| rules.first().cloned().unwrap_or_else(SheetRule::steel)); + *values = vec![ + base.thickness.clone(), + base.k_factor.clone(), + base.bend_radius.clone(), + base.relief_width.clone(), + base.relief_depth.clone(), + base.corner_relief.clone(), + base.hem_gap.clone(), + base.gap.clone(), + ]; + *active = app.session.doc.sheet_rule(None).name == base.name || *pick >= rules.len(); + if *pick >= rules.len() { + *name = format!("Rule{}", rules.len() + 1); + } + *loaded = Some(*pick); + } + if *pick >= rules.len() { + row_label(ui, "Name"); + ui.text_edit_singleline(name); + ui.end_row(); + } + for ((_, label), v) in RULE_FIELDS.iter().zip(values.iter_mut()) { + row_label(ui, label); + enter |= field(ui, v); + ui.end_row(); + } + row_label(ui, "Active rule"); + ui.checkbox(active, ""); + ui.end_row(); + if *pick < rules.len() && !app.session.doc.sheet.rules.is_empty() { + row_label(ui, ""); + if ui.button("Delete rule").clicked() { + let n = rules.get(*pick).map(|r| r.name.clone()).unwrap_or_default(); + match app.run("FusionSheetMetalRulesCommand", json!({"name": n, "delete": true})) { + Ok(_) => { + *pick = 0; + *loaded = None; + } + Err(e) => app.set_status(e, true), + } + } + ui.end_row(); + } + } + Sm::FlatPattern { data, key, status } => { + let body = inputs.first().and_then(|i| i.items.first()).and_then(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }); + let want = (app.session.revision, body.clone()); + if key.as_ref() != Some(&want) { + let mut p = json!({}); + if let Some(b) = &body { + p["body"] = json!(b); + } + *data = Some(app.session.execute("FusionSheetMetalFlatPatternCmd", &p).map_err(|e| e.to_string())); + *key = Some(want); + } + match data { + Some(Ok(v)) => { + let size = &v["flat_size_mm"]; + row_label(ui, "Body"); + ui.label(v["body"].as_str().unwrap_or("")); + ui.end_row(); + row_label(ui, "Flat size"); + ui.label(format!( + "{:.2} × {:.2} × {:.2} mm", + size[0].as_f64().unwrap_or(0.0), + size[1].as_f64().unwrap_or(0.0), + size[2].as_f64().unwrap_or(0.0) + )); + ui.end_row(); + let bends = v["bends"].as_array().cloned().unwrap_or_default(); + row_label(ui, "Bends"); + ui.label(format!("{}", bends.len())); + ui.end_row(); + for b in bends.iter().take(50) { + let up = b["direction"] == "up"; + let (up_col, down_col) = bend_colors(); + row_label(ui, &format!(" Bend {}", b["bend"].as_u64().unwrap_or(0))); + ui.label( + RichText::new(format!( + "{} {:.1}° R{:.2}", + if up { "Up" } else { "Down" }, + b["angle_deg"].as_f64().unwrap_or(0.0), + b["radius"].as_f64().unwrap_or(0.0) + )) + .color(if up { up_col } else { down_col }), + ); + ui.end_row(); + } + row_label(ui, ""); + if ui.button("Export DXF…").clicked() { + *status = Some(export_dxf(app, v["body"].as_str().unwrap_or(""))); + } + ui.end_row(); + if let Some(s) = status { + row_label(ui, ""); + ui.add(egui::Label::new(RichText::new(s.as_str()).color(t.text_dim)).wrap()); + ui.end_row(); + } + } + Some(Err(e)) => { + row_label(ui, ""); + ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap()); + ui.end_row(); + } + None => {} + } + } + } + enter +} + +fn export_dxf(app: &mut SolveApp, body: &str) -> String { + let name = format!("{}-{body}.dxf", app.session.doc.name); + let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &["dxf"])) else { + return "Export cancelled".into(); + }; + match app.run("sheet.export_dxf", json!({"body": body, "path": path})) { + Ok(_) => { + app.set_status(format!("Exported {path}"), false); + format!("Saved {path}") + } + Err(e) => e, + } +} + +// ---- commands ---- + +fn rule_value(app: &SolveApp, rule: usize) -> Option { + rule.checked_sub(1).and_then(|i| rule_list(app).get(i).map(|r| r.name.clone())) +} + +fn items(inputs: &[SelInput], i: usize) -> &[Sel] { + inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[]) +} + +fn pt(v: Vec3) -> Value { + json!([v.x, v.y, v.z]) +} + +fn edge_points(inputs: &[SelInput]) -> Vec { + items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect() +} + +/// The commands the dialog's OK runs (`extra`: parameters kept from the feature being edited). +pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map) -> Result, String> { + let s = &app.session; + let (cmd, mut p): (&str, Value) = match k { + Sm::Flange { height, angle, radius, position, flip, distance, reverse, rule, .. } => { + let mut p = match flange_type(inputs) { + 1 => { + let mut p = json!({"type": "edge", "edges": edge_points(inputs), "height": height, "angle": angle, + "position": POSITIONS.get(*position).copied().unwrap_or("inside")}); + if !radius.trim().is_empty() { + p["radius"] = json!(radius); + } + p + } + 2 => { + let ids: Vec = + items(inputs, 0).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); + let st = s.model.state(); + let sketch = st + .sketches + .iter() + .rev() + .find(|ss| ids.iter().all(|id| ss.sketch.curve_index(id).is_some())) + .map(|ss| ss.feature) + .ok_or("pick lines of one sketch")?; + json!({"type": "contour", "sketch": sketch, "curves": ids, "distance": distance, "reverse": reverse}) + } + _ => { + let ps: Vec<(u64, usize)> = items(inputs, 0) + .iter() + .filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }) + .collect(); + let sketch = ps.first().map(|x| x.0).ok_or("select a profile, edges or sketch lines first")?; + let idx: Vec = ps.iter().filter(|x| x.0 == sketch).map(|x| x.1).collect(); + json!({"type": "base", "sketch": sketch, "profiles": idx}) + } + }; + p["flip"] = json!(flip); + if let Some(r) = rule_value(app, *rule).filter(|_| p["type"] != "edge") { + p["rule"] = json!(r); + } + ("FusionSheetMetalFlangeCommand", p) + } + Sm::Hem { length, gap, flip } => { + let edges = edge_points(inputs); + if edges.is_empty() { + return Err("select sheet edges first".into()); + } + let mut p = json!({"edges": edges, "length": length, "flip": flip}); + if !gap.trim().is_empty() { + p["gap"] = json!(gap); + } + ("FusionSheetMetalHemFlangeCommand", p) + } + Sm::Unfold { refold } => { + let mut p = json!({}); + if let Some(Sel::Body { name }) = items(inputs, 0).first() { + p["body"] = json!(name); + } + (if *refold { "sheet.refold" } else { "FusionSheetmetalUnfoldCommand" }, p) + } + Sm::Convert { rule } => { + let Some(Sel::Face { body, point, .. }) = items(inputs, 0).first() else { return Err("select the plate's large face first".into()) }; + let mut p = json!({"body": body, "face": pt(*point)}); + if let Some(r) = rule_value(app, *rule) { + p["rule"] = json!(r); + } + ("ConvertToSheetMetalCmd", p) + } + Sm::Rules { pick, name, values, active, .. } => { + let rules = rule_list(app); + let n = match rules.get(*pick) { + Some(r) => r.name.clone(), + None => name.trim().to_string(), + }; + if n.is_empty() { + return Err("give the rule a name".into()); + } + let mut p = json!({"name": n, "active": active}); + for ((k, _), v) in RULE_FIELDS.iter().zip(values) { + if !v.trim().is_empty() { + p[*k] = json!(v); + } + } + ("FusionSheetMetalRulesCommand", p) + } + Sm::FlatPattern { .. } => return Ok(Vec::new()), + }; + if let Value::Object(m) = &mut p { + for (k, v) in extra { + m.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + Ok(vec![(cmd.to_string(), p)]) +} + +/// The dialog editing a sheet metal feature, filled from it. +pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec, Map)> { + let s = &app.session; + let st = s.model.state(); + let rule_index = |r: &Option| r.as_ref().and_then(|n| rule_list(app).iter().position(|x| &x.name == n)).map_or(0, |i| i + 1); + let mut extra = Map::new(); + let (k, items): (Sm, Vec) = match kind { + FeatureKind::SheetBase { sketch, profiles, rule, flip } => { + let ps = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); + let mut f = new_flange(0); + if let Sm::Flange { flip: fl, rule: r, .. } = &mut f { + *fl = *flip; + *r = rule_index(rule); + } + (f, ps.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect()) + } + FeatureKind::SheetContour { curves, distance, rule, flip, reverse, .. } => { + let mut f = new_flange(2); + if let Sm::Flange { flip: fl, rule: r, distance: d, reverse: rv, .. } = &mut f { + *fl = *flip; + *r = rule_index(rule); + *d = distance.clone(); + *rv = *reverse; + } + (f, curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()) + } + FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => { + if let Some(b) = body { + extra.insert("body".into(), json!(b)); + } + let f = Sm::Flange { + ty: 1, + height: height.clone(), + angle: angle.clone(), + radius: radius.clone().unwrap_or_default(), + position: POSITIONS.iter().position(|p| p == position).unwrap_or(0), + flip: *flip, + distance: "20 mm".into(), + reverse: false, + rule: 0, + }; + (f, edges.iter().filter_map(|p| edge_sel(s, *p)).collect()) + } + FeatureKind::SheetHem { edges, length, gap, flip, body } => { + if let Some(b) = body { + extra.insert("body".into(), json!(b)); + } + ( + Sm::Hem { length: length.clone(), gap: gap.clone().unwrap_or_default(), flip: *flip }, + edges.iter().filter_map(|p| edge_sel(s, *p)).collect(), + ) + } + FeatureKind::SheetUnfold { body, refold } => (Sm::Unfold { refold: *refold }, body.iter().map(|n| Sel::Body { name: n.clone() }).collect()), + FeatureKind::SheetConvert { face, rule, .. } => (Sm::Convert { rule: rule_index(rule) }, face_sel(s, *face).into_iter().collect()), + _ => return None, + }; + let id = match &k { + Sm::Flange { .. } => "FusionSheetMetalFlangeCommand", + Sm::Hem { .. } => "FusionSheetMetalHemFlangeCommand", + Sm::Unfold { refold: false } => "FusionSheetmetalUnfoldCommand", + Sm::Unfold { .. } => "sheet.refold", + _ => "ConvertToSheetMetalCmd", + }; + let (_, mut inputs) = start(app, id)?; + if let Some(i) = inputs.first_mut() { + i.items = items; + } + // Profiles stay a profile selection (`All` keeps them all). + if let (FeatureKind::SheetBase { profiles: ProfileSel::All, .. }, Some(i)) = (kind, inputs.first()) + && i.items.is_empty() + { + extra.insert("profiles".into(), json!("all")); + } + Some((Kind::Sheet(k), inputs, extra)) +} + +// ---- view ---- + +/// The direction an edge flange or hem grows from its first edge: out of the sheet's larger +/// neighbouring face (flipped with Flip). +pub fn arrow(app: &SolveApp, d: &Dialog) -> Option { + let Kind::Sheet(k) = &d.kind else { return None }; + let flip = match k { + Sm::Flange { ty: 1, flip, .. } | Sm::Hem { flip, .. } => *flip, + _ => return None, + }; + let Sel::Edge { body, index, point } = d.inputs.first()?.items.first()? else { return None }; + let st = app.session.model.state(); + let m = st.body(body)?.mesh(); + let faces = m.edge_faces.get(*index)?; + let area = |f: u32| -> f64 { + m.triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, tf)| **tf == f) + .filter_map(|(t, _)| m.tri(t)) + .map(|[a, b, c]| (b - a).cross(c - a).len() * 0.5) + .sum() + }; + let f = faces.iter().copied().max_by(|a, b| area(*a).total_cmp(&area(*b)))?; + let n = m + .triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, tf)| **tf == f) + .filter_map(|(t, _)| m.tri(t)) + .min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(*point).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(*point))) + .and_then(|[a, b, c]| (b - a).cross(c - a).normalized())?; + Some(if flip { -n } else { n }) +} + +/// Bend line colours: up and down (legible on either theme). +fn bend_colors() -> (Color32, Color32) { + if crate::theme::is_dark() { + (Color32::from_rgb(110, 200, 120), Color32::from_rgb(240, 150, 80)) + } else { + (Color32::from_rgb(30, 140, 50), Color32::from_rgb(200, 90, 20)) + } +} + +/// The flat pattern view: the outline, cut-outs and bend lines (up and down in their colours) +/// drawn flat over the viewport, fitted to it. True when the dialog is a flat pattern. +pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { + let Kind::Sheet(Sm::FlatPattern { data, .. }) = &d.kind else { return false }; + let t = Tokens::get(); + let (up_col, down_col) = bend_colors(); + painter.rect_filled(rect, 0.0, t.panel); + let Some(Ok(v)) = data else { + painter.text(rect.center(), Align2::CENTER_CENTER, "no sheet metal body", FontId::proportional(14.0), t.text_dim); + return true; + }; + let read = |p: &Value| -> Option<(f64, f64)> { Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?)) }; + let loops: Vec> = + v["outline"].as_array().into_iter().flatten().map(|l| l.as_array().into_iter().flatten().filter_map(read).collect()).collect(); + let holes: Vec> = + v["cutouts"].as_array().into_iter().flatten().map(|l| l.as_array().into_iter().flatten().filter_map(read).collect()).collect(); + let mut lo = (f64::INFINITY, f64::INFINITY); + let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in loops.iter().flatten() { + lo = (lo.0.min(p.0), lo.1.min(p.1)); + hi = (hi.0.max(p.0), hi.1.max(p.1)); + } + if !(lo.0.is_finite() && hi.0 > lo.0 && hi.1 > lo.1) { + return true; + } + // Fit with a margin, leaving the dialog's side free. + let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), rect.max - vec2(320.0, 60.0)); + let area = if area.width() < 100.0 { rect.shrink(40.0) } else { area }; + let scale = (f64::from(area.width()) / (hi.0 - lo.0)).min(f64::from(area.height()) / (hi.1 - lo.1)); + let ox = f64::from(area.center().x) - (lo.0 + hi.0) * 0.5 * scale; + let oy = f64::from(area.center().y) + (lo.1 + hi.1) * 0.5 * scale; + let to = |p: (f64, f64)| -> Pos2 { pos2((ox + p.0 * scale) as f32, (oy - p.1 * scale) as f32) }; + for l in &loops { + let pts: Vec = l.iter().map(|p| to(*p)).collect(); + if pts.len() >= 3 { + painter.add(egui::Shape::closed_line(pts, Stroke::new(1.8, t.text))); + } + } + for h in &holes { + let pts: Vec = h.iter().map(|p| to(*p)).collect(); + if pts.len() >= 2 { + painter.add(egui::Shape::closed_line(pts, Stroke::new(1.4, t.text))); + } + } + for b in v["bends"].as_array().into_iter().flatten() { + let (Some(a), Some(e)) = (read(&b["start"]), read(&b["end"])) else { continue }; + let col = if b["direction"] == "up" { up_col } else { down_col }; + painter.extend(egui::Shape::dashed_line(&[to(a), to(e)], Stroke::new(1.6, col), 8.0, 5.0)); + } + // Legend. + let mut y = rect.top() + 14.0; + painter.text(pos2(rect.left() + 14.0, y), Align2::LEFT_TOP, "FLAT PATTERN", FontId::proportional(13.0), t.text); + for (label, col) in [("Bend up", up_col), ("Bend down", down_col)] { + y += 20.0; + painter.line_segment([pos2(rect.left() + 14.0, y + 7.0), pos2(rect.left() + 38.0, y + 7.0)], Stroke::new(2.0, col)); + painter.text(pos2(rect.left() + 44.0, y), Align2::LEFT_TOP, label, FontId::proportional(12.0), t.text_dim); + } + true +} + +#[cfg(test)] +#[path = "dialogs_sheet_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs new file mode 100644 index 00000000..30a1c138 --- /dev/null +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -0,0 +1,108 @@ +use serde_json::json; +use solvecraft_engine::Session; + +use super::*; +use crate::Services; +use crate::dialogs::{apply_commands, for_feature as edit_dialog}; + +/// A 100 x 60 base flange on XY (2.5 mm steel). +fn plate() -> SolveApp { + let mut s = Session::default(); + s.execute("SketchCreate", &json!({"plane": "XY", "name": "Base"})).unwrap(); + s.execute("ShapeRectangleTwoPoint", &json!({"p0": [0, 0], "p1": [100, 60]})).unwrap(); + s.execute("SketchStop", &json!({})).unwrap(); + SolveApp::new(s, Services::default()) +} + +fn run_all(app: &mut SolveApp, c: Vec<(String, Value)>) { + for (id, p) in c { + app.run(&id, p).unwrap(); + } +} + +/// The picks decide the flange: a profile makes the base, edges then edge flanges with the +/// dialog's height, angle and bend position; Edit Feature brings the edges back. +#[test] +fn flange_follows_the_picks() { + let mut app = plate(); + let sk = app.session.doc.features[0].id; + app.session.selection = vec![Sel::Profile { sketch: sk, index: 0 }]; + app.start("FusionSheetMetalFlangeCommand"); + let d = app.dialog.clone().unwrap(); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["type"], "base"); + assert_eq!(c[0].1["profiles"], json!([0])); + run_all(&mut app, c); + assert_eq!(app.session.model.state().sheets.len(), 1); + // Four edge flanges at once, 20 high, outside. + let edges: Vec = [[50.0, 0.0, 2.5], [50.0, 60.0, 2.5], [0.0, 30.0, 2.5], [100.0, 30.0, 2.5]] + .iter() + .filter_map(|p| edge_sel(&app.session, Vec3::new(p[0], p[1], p[2]))) + .collect(); + assert_eq!(edges.len(), 4); + app.session.selection = edges; + app.start("FusionSheetMetalFlangeCommand"); + let mut d = app.dialog.clone().unwrap(); + if let Kind::Sheet(Sm::Flange { height, position, .. }) = &mut d.kind { + *height = "20 mm".into(); + *position = 1; + } + assert_eq!(d.primary().map(|p| p.0), Some("Height"), "the arrow drives the height"); + assert!(arrow(&app, &d).is_some_and(|n| n.dist(Vec3::Z) < 1e-9), "edge flanges rise out of the sheet"); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["type"], "edge"); + assert_eq!(c[0].1["edges"].as_array().map(Vec::len), Some(4)); + assert_eq!(c[0].1["position"], "outside"); + run_all(&mut app, c); + let f = app.session.doc.features.last().unwrap().clone(); + let e = edit_dialog(&app, f.id, None).unwrap(); + assert_eq!(e.inputs[0].items.len(), 4); + assert!(matches!(&e.kind, Kind::Sheet(Sm::Flange { ty: 1, position: 1, .. }))); + // Rebuilt in place. + let c = apply_commands(&app, &e).unwrap(); + assert_eq!(c[0].0, "timeline.redefine"); + assert_eq!(c[0].1["params"]["height"], "20 mm"); + // Flat pattern: four bends; Unfold previews. + let flat = app.session.execute("FusionSheetMetalFlatPatternCmd", &json!({})).unwrap(); + assert_eq!(flat["bends"].as_array().map(Vec::len), Some(4), "{flat}"); + app.dialog = None; + app.start("FusionSheetmetalUnfoldCommand"); + assert_eq!(apply_commands(&app, app.dialog.as_ref().unwrap()).unwrap()[0].0, "FusionSheetmetalUnfoldCommand"); +} + +/// Hem takes edges and an optional gap; rules make and pick a rule with its expressions. +#[test] +fn hem_and_rules() { + let mut app = plate(); + app.run("FusionSheetMetalFlangeCommand", json!({"sketch": "Base"})).unwrap(); + app.session.selection = edge_sel(&app.session, Vec3::new(50.0, 0.0, 2.5)).into_iter().collect(); + app.start("FusionSheetMetalHemFlangeCommand"); + let d = app.dialog.clone().unwrap(); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["length"], "5 mm"); + assert!(c[0].1.get("gap").is_none(), "the rule's gap"); + run_all(&mut app, c); + app.dialog = None; + app.start("FusionSheetMetalRulesCommand"); + let mut d = app.dialog.clone().unwrap(); + if let Kind::Sheet(Sm::Rules { pick, name, values, loaded, .. }) = &mut d.kind { + *pick = 1; + *loaded = Some(1); + *name = "Alu".into(); + *values = vec![ + "1.5 mm".into(), + "0.4".into(), + "Thickness".into(), + "Thickness".into(), + "Thickness".into(), + "Thickness".into(), + "Thickness".into(), + "Thickness".into(), + ]; + } + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["name"], "Alu"); + assert_eq!(c[0].1["thickness"], "1.5 mm"); + run_all(&mut app, c); + assert!(app.session.doc.sheet.rules.iter().any(|r| r.name == "Alu")); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index bf85924f..51ee36b5 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -712,6 +712,128 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.dot(8.0, 15.0, ink); pen.dot(16.0, 9.0, ink); } + // Assembly: joints (accent: the joint itself). + "joint" => { + pen.iso_box(2.0, 13.0, 7.0, 5.0, 5.0); + pen.iso_box(12.0, 4.0, 7.0, 5.0, 5.0); + pen.line(&[(9.0, 15.0), (15.0, 9.0)], a); + pen.circle(12.0, 12.0, 2.6, pen.fill, a); + } + "as_built" => { + pen.iso_box(1.0, 10.0, 8.0, 5.0, 8.0); + pen.iso_box(10.0, 10.0, 8.0, 5.0, 8.0); + pen.circle(13.0, 15.0, 2.6, pen.fill, a); + } + "joint_origin" => { + pen.arrow((8.0, 17.0), (20.0, 17.0), ink); + pen.arrow((8.0, 17.0), (14.0, 11.0), ink); + pen.arrow((8.0, 17.0), (8.0, 3.0), a); + pen.circle(8.0, 17.0, 2.4, pen.fill, a); + } + "rigid_group" => { + pen.iso_box(3.0, 13.0, 5.0, 3.0, 4.0); + pen.iso_box(13.0, 13.0, 5.0, 3.0, 4.0); + pen.iso_box(8.0, 4.0, 5.0, 3.0, 4.0); + pen.closed(&[(2.0, 3.0), (22.0, 3.0), (22.0, 21.0), (2.0, 21.0)], a); + } + "drive" => { + pen.circle(12.0, 12.0, 4.0, pen.fill, ink); + pen.dot(12.0, 12.0, ink); + pen.arc(12.0, 12.0, 8.5, 0.3, 4.6, a); + pen.arrow((11.0, 20.4), (14.5, 20.3), a); + } + "motion_link" => { + pen.circle(7.0, 14.0, 4.5, pen.fill, ink); + pen.circle(17.5, 9.0, 3.0, pen.fill, ink); + pen.line(&[(7.0, 14.0), (17.5, 9.0)], a); + pen.dot(7.0, 14.0, a); + pen.dot(17.5, 9.0, a); + } + "interference" => { + pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink); + pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], Color32::from_rgb(225, 70, 60), Color32::from_rgb(225, 70, 60)); + pen.closed(&[(9.0, 10.0), (21.0, 10.0), (21.0, 20.0), (9.0, 20.0)], ink); + } + // Sheet metal: thin plates (fill), the made or changed part in the accent. + "flange" => { + pen.poly(&[(2.0, 17.0), (12.0, 13.0), (18.0, 16.0), (8.0, 20.0)], pen.fill, ink); + pen.poly(&[(12.0, 13.0), (12.0, 5.0), (18.0, 8.0), (18.0, 16.0)], pen.fill.gamma_multiply(0.8), a); + pen.arrow((20.5, 15.0), (20.5, 4.0), a); + } + "hem" => { + pen.line(&[(2.0, 16.0), (16.0, 16.0)], ink); + pen.line(&[(2.0, 13.0), (16.0, 13.0)], ink); + pen.arc(16.0, 11.5, 4.5, -1.57, 1.57, a); + pen.line(&[(16.0, 7.0), (8.0, 7.0)], a); + } + "unfold" => { + pen.line(&[(3.0, 18.0), (12.0, 18.0), (12.0, 7.0)], ink); + pen.line(&[(12.0, 18.0), (22.0, 18.0)], a); + pen.arc(12.0, 18.0, 7.0, 0.3, 1.4, a); + pen.arrow((17.5, 14.0), (19.0, 16.0), a); + } + "refold" => { + pen.line(&[(3.0, 18.0), (12.0, 18.0), (21.0, 18.0)], ink); + pen.line(&[(12.0, 18.0), (12.0, 6.0)], a); + pen.arc(12.0, 18.0, 7.0, 0.3, 1.4, a); + pen.arrow((15.0, 11.5), (13.5, 10.5), a); + } + "flat" => { + pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink); + for x in [9.0, 15.0] { + for k in 0..3 { + let y = 7.5 + k as f32 * 4.0; + pen.line(&[(x, y), (x, y + 2.2)], a); + } + } + } + "convert_sheet" => { + pen.iso_box(2.0, 9.0, 6.0, 4.0, 6.0); + pen.arrow((11.0, 12.0), (15.0, 12.0), a); + pen.poly(&[(15.0, 15.0), (21.0, 12.0), (23.0, 13.0), (17.0, 16.0)], pen.fill, a); + } + "sheet_rules" => { + pen.poly(&[(2.0, 15.0), (11.0, 11.0), (17.0, 14.0), (8.0, 18.0)], pen.fill, ink); + for y in [5.0, 9.0, 13.0] { + pen.line(&[(14.0, y), (22.0, y)], a); + } + } + // Plastic: a wall or plate (fill) with the feature in the accent. + "boss" => { + pen.poly(&[(2.0, 16.0), (13.0, 12.0), (22.0, 16.0), (11.0, 20.0)], pen.fill, ink); + pen.poly(&[(9.0, 7.0), (15.0, 7.0), (15.0, 16.0), (9.0, 16.0)], pen.fill.gamma_multiply(0.85), a); + pen.circle(12.0, 7.0, 3.0, pen.fill, a); + pen.circle(12.0, 7.0, 1.2, ink, ink); + } + "lip" => { + pen.poly(&[(4.0, 21.0), (4.0, 9.0), (10.0, 9.0), (10.0, 21.0)], pen.fill, ink); + pen.poly(&[(4.0, 9.0), (4.0, 4.0), (7.0, 4.0), (7.0, 9.0)], pen.fill, a); + pen.poly(&[(14.0, 21.0), (14.0, 6.0), (20.0, 6.0), (20.0, 21.0)], pen.fill, ink); + pen.closed(&[(17.0, 6.0), (17.0, 11.0), (20.0, 11.0), (20.0, 6.0)], a); + } + "snap" => { + pen.poly(&[(2.0, 20.0), (2.0, 16.0), (22.0, 16.0), (22.0, 20.0)], pen.fill, ink); + pen.poly(&[(9.0, 16.0), (9.0, 4.0), (12.0, 4.0), (12.0, 16.0)], pen.fill, a); + pen.poly(&[(12.0, 4.0), (16.0, 8.0), (12.0, 8.0)], a, a); + } + "rest" => { + pen.poly(&[(2.0, 16.0), (13.0, 12.0), (22.0, 16.0), (11.0, 20.0)], pen.fill, ink); + pen.poly(&[(7.0, 14.0), (13.0, 11.5), (17.0, 13.5), (11.0, 16.0)], a, a); + pen.line(&[(7.0, 14.0), (7.0, 11.5), (13.0, 9.0), (17.0, 11.0), (17.0, 13.5)], a); + } + "plastic_rule" => { + pen.circle(7.0, 8.0, 3.2, pen.fill, a); + pen.circle(11.0, 13.0, 3.2, pen.fill, a); + pen.circle(5.5, 16.0, 3.2, pen.fill, a); + for y in [6.0, 11.0, 16.0] { + pen.line(&[(15.0, y), (22.0, y)], ink); + } + } + "plastic_assign" => { + pen.iso_box(2.0, 12.0, 7.0, 5.0, 6.0); + pen.arrow((19.0, 6.0), (13.0, 11.0), a); + pen.circle(19.0, 6.0, 3.0, pen.fill, a); + } "warning" => { pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], Color32::from_rgb(250, 200, 60), ink); pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 07781cbf..dacd2c70 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,9 @@ pub mod context_menu; pub mod control; pub mod delete; pub mod dialogs; +pub mod dialogs_assembly; +pub mod dialogs_plastic; +pub mod dialogs_sheet; pub mod dim_view; pub mod gpu; pub mod icons; @@ -36,6 +39,7 @@ pub mod titlebar; pub mod toolbar; pub mod tools; pub mod viewport; +pub mod workspace; use std::sync::mpsc::Receiver; diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index e3ec6deb..59e9bca1 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -108,7 +108,7 @@ impl PreviewState { /// Keep the preview in step with the open dialog: start a computation when its inputs changed, /// take finished results. Called once per frame. pub fn update(app: &mut SolveApp, ctx: &egui::Context) { - let cmds = match app.dialog.as_ref().filter(|d| d.previews()).map(|d| crate::dialogs::apply_commands(app, d)) { + let cmds = match app.dialog.as_ref().filter(|d| d.previews()).map(|d| crate::dialogs::preview_commands(app, d)) { Some(Ok(c)) if !c.is_empty() => c, // A parameter being edited in Change Parameters. _ if app.params.as_ref().and_then(|p| p.preview_commands()).is_some() => { @@ -176,6 +176,10 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V /// show their result in place of the bodies they change. When the feature fails (a boolean the /// kernel can't do yet), the tool body is still shown, with the error. pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job { + // Joints and drives move occurrences: shown on the placed model. + if let Some(job) = crate::dialogs_assembly::world_preview(s, cmds, colors) { + return job; + } let real = s.preview(cmds); let guard = |f: &dyn Fn() -> Built| std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| "the preview failed".to_string()); match as_new_body(cmds) { diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 43e557af..7953fa56 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -31,7 +31,7 @@ fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Split { .. } => "split", FeatureKind::Move { .. } => "move", FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => "import", - _ => "feature", + other => crate::workspace::feature_icon(other).unwrap_or("feature"), } } @@ -241,6 +241,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { p.rect_filled(mr, 1.0, col); resp.on_hover_text("History marker: drag to roll the model back; new features go here"); } + crate::dialogs_assembly::timeline_joints(app, ui, &p, end + 10.0, r); // Drag feedback, and the drop. if let (Some(dg), Some(pp)) = (drag, pointer) { let slot = slot_at(&xs, end, pp.x); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index c89997ec..4fbbff99 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -11,6 +11,9 @@ pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC" /// Panels shown on each tab (in order). Commands come from the registry by (tab, panel). fn panels(tab: &str) -> &'static [&'static str] { + if let Some(p) = crate::workspace::panels(tab) { + return p; + } match tab { "SOLID" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"], "SKETCH" => &["CREATE", "MODIFY", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"], @@ -261,8 +264,11 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let mut px = r.left() + 112.0; let top = r.top() + 26.0; for panel in panels(&tab) { - let cmds: Vec<_> = specs.iter().filter(|c| c.tab == tab && c.panel == *panel).collect(); - let n = promoted(&tab, panel).min(cmds.len()).max(if cmds.is_empty() { 1 } else { 0 }); + let (cmds, promote) = match crate::workspace::layout(&tab, panel, &specs) { + Some(l) => l, + None => (specs.iter().filter(|c| c.tab == tab && c.panel == *panel).copied().collect(), promoted(&tab, panel)), + }; + let n = promote.min(cmds.len()).max(if cmds.is_empty() { 1 } else { 0 }); let width = (n as f32 * 40.0).max(64.0) + 8.0; let enabled_panel = !cmds.is_empty() || *panel == "SELECT"; for (i, c) in cmds.iter().take(n).enumerate() { diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs new file mode 100644 index 00000000..7a86629e --- /dev/null +++ b/crates/ui-egui/src/workspace.rs @@ -0,0 +1,159 @@ +//! Toolbar layout of the tabs that share commands with others (Fusion's layout): the SOLID +//! tab's ASSEMBLE panel, SHEET METAL and PLASTIC. A command is registered on one tab and panel; +//! these panels list the commands they show in order (the first few get big buttons), so Extrude +//! or Joint appear on several tabs. Commands registered on such a panel but not listed follow at +//! the end. + +use solvecraft_engine::CommandSpec; +use solvecraft_engine::doc::FeatureKind; + +const ASSEMBLE: &[&str] = &[ + "FusionCreateNewComponentCommand", + "JointAssembleCmdNew", + "JointAsBuiltCmd", + "JointOrigin", + "RigidGroupCmd", + "FusionMoveJointsCommand", + "FusionMotionRelationshipCommand", + "InterferenceCheckCommand", +]; + +const SHEET_CREATE: &[&str] = &[ + "SketchCreate", + "FusionSheetMetalFlangeCommand", + "FusionSheetMetalFlatPatternCmd", + "FusionSheetMetalHemFlangeCommand", + "ConvertToSheetMetalCmd", + "FusionCreateNewComponentCommand", + "Extrude", + "FusionHoleCommand", + "FusionThreadCommand", + "PatternRectangular", + "PatternCircular", + "PatternOnPath", + "MirrorCommand", + "JointOrigin", +]; + +const SHEET_MODIFY: &[&str] = &[ + "FusionSheetMetalRulesCommand", + "FusionSheetmetalUnfoldCommand", + "sheet.refold", + "FusionFilletEdgesCommand", + "FusionChamferCommand", + "FusionMoveCommand", + "AlignCmd", + "FusionDeleteCommand", + "SoftDeleteCommand", + "PhysicalMaterialCommand", + "ChangeParameterCommand", +]; + +const PLASTIC_SETUP: &[&str] = &["FusionManagePlasticRuleCommand", "FusionAssignPlasticRuleCommand"]; + +const PLASTIC_CREATE: &[&str] = &[ + "SketchCreate", + "FusionBossCommand", + "FusionSnapFitCommand", + "FusionLipCommand", + "FusionRestCommand", + "FusionCreateNewComponentCommand", + "Extrude", + "Revolve", + "Sweep", + "SolidLoft", + "FusionRibCommand", + "FusionWebCommand", + "EmbossCmd", + "FusionHoleCommand", + "FusionThreadCommand", + "PrimitiveBox", + "PrimitiveCylinder", + "PrimitiveSphere", + "PrimitiveTorus", + "PrimitiveCoil", + "PrimitivePipe", + "PatternRectangular", + "PatternCircular", + "PatternOnPath", + "MirrorCommand", + "JointOrigin", +]; + +/// Panels of a tab, when this module lays the tab out. +pub fn panels(tab: &str) -> Option<&'static [&'static str]> { + match tab { + "PLASTIC" => Some(&["SETUP", "CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"]), + _ => None, + } +} + +/// The commands a panel shows, in order, and how many get a big button; `None` leaves the panel +/// to the registry. +pub fn layout<'a>(tab: &str, panel: &str, specs: &[&'a CommandSpec]) -> Option<(Vec<&'a CommandSpec>, usize)> { + let (ids, promoted, own_tab): (&[&str], usize, &str) = match (tab, panel) { + ("SOLID" | "SHEET METAL" | "PLASTIC", "ASSEMBLE") => (ASSEMBLE, 3, tab), + ("SHEET METAL", "CREATE") => (SHEET_CREATE, 3, tab), + ("SHEET METAL", "MODIFY") => (SHEET_MODIFY, 2, tab), + ("PLASTIC", "SETUP") => (PLASTIC_SETUP, 2, tab), + ("PLASTIC", "CREATE") => (PLASTIC_CREATE, 5, tab), + // The solid tab's panels. + ("PLASTIC", "MODIFY") => (&[], 4, "SOLID"), + ("SHEET METAL" | "PLASTIC", "CONSTRUCT" | "INSPECT" | "INSERT") => (&[], 2, "SOLID"), + _ => return None, + }; + let mut out: Vec<&CommandSpec> = ids.iter().filter_map(|id| specs.iter().find(|c| c.id == *id).copied()).collect(); + for c in specs.iter().filter(|c| (c.tab == tab || c.tab == own_tab) && c.panel == panel) { + if !out.iter().any(|o| o.id == c.id) { + out.push(*c); + } + } + Some((out, promoted)) +} + +/// Timeline and browser icon of the sheet metal and plastic features (`None`: not one of them). +pub fn feature_icon(k: &FeatureKind) -> Option<&'static str> { + Some(match k { + FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetFlange { .. } => "flange", + FeatureKind::SheetHem { .. } => "hem", + FeatureKind::SheetUnfold { refold: false, .. } => "unfold", + FeatureKind::SheetUnfold { .. } => "refold", + FeatureKind::SheetConvert { .. } => "convert_sheet", + FeatureKind::Boss { .. } => "boss", + FeatureKind::Lip { .. } => "lip", + FeatureKind::SnapFit { .. } => "snap", + FeatureKind::Rest { .. } => "rest", + FeatureKind::Rib { .. } => "rib", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(tab: &str, panel: &str) -> Vec<&'static str> { + let specs = solvecraft_engine::command_specs(); + layout(tab, panel, &specs).map(|(v, _)| v.iter().map(|c| c.id).collect()).unwrap_or_default() + } + + /// Fusion's layout: joints on every tab's ASSEMBLE panel, sheet metal and plastic tabs + /// reach the shared solid commands, the plastic rules sit on SETUP. + #[test] + fn tabs_share_commands_in_fusion_order() { + for tab in ["SOLID", "SHEET METAL", "PLASTIC"] { + let a = ids(tab, "ASSEMBLE"); + assert_eq!(a.get(1).copied(), Some("JointAssembleCmdNew"), "{tab}: {a:?}"); + assert!(a.contains(&"FusionMoveJointsCommand") && a.contains(&"FusionCreateNewComponentCommand")); + } + let c = ids("SHEET METAL", "CREATE"); + assert_eq!(c.get(1).copied(), Some("FusionSheetMetalFlangeCommand")); + assert!(c.contains(&"Extrude") && c.contains(&"FusionSheetMetalFlatPatternCmd")); + assert!(ids("SHEET METAL", "MODIFY").contains(&"FusionSheetmetalUnfoldCommand")); + assert!(ids("PLASTIC", "SETUP").contains(&"FusionManagePlasticRuleCommand")); + let p = ids("PLASTIC", "CREATE"); + assert!(p.contains(&"FusionBossCommand") && p.contains(&"FusionRibCommand") && p.contains(&"FusionLipCommand")); + assert!(ids("PLASTIC", "MODIFY").contains(&"FusionShellBodyCommand")); + assert!(panels("PLASTIC").is_some_and(|p| p.first() == Some(&"SETUP"))); + } +}