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"))); + } +}