//! Command dialogs (shown at the right of the viewport). A dialog collects values and selection //! inputs (geometry picked in the viewport, never typed), then runs its command; it never //! changes the design itself. Things selected before the command starts become its input. use std::hash::{Hash, Hasher}; use egui::{Color32, RichText, 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::{AxisRef, Direction, FeatureKind, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel}; use solvecraft_engine::geom::Vec3; use crate::SolveApp; use crate::selection::{self, AXES, Accept, BODIES, CURVES, EDGES, FACES, PLANAR_FACES, PLANES, PROFILES, SelInput}; use crate::theme::Tokens; use crate::viewport::Hit; const OPS: [&str; 4] = ["new", "join", "cut", "intersect"]; const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"]; const DIRS: [&str; 4] = ["positive", "negative", "symmetric", "positive"]; const DIR_LABELS: [&str; 4] = ["One side", "Flip", "Symmetric", "Two sides"]; /// The "Two sides" direction (a second distance the other way). pub const TWO_SIDES: usize = 3; const HOLE_TYPES: [&str; 3] = ["simple", "counterbore", "countersink"]; const HOLE_LABELS: [&str; 3] = ["Simple", "Counterbore", "Countersink"]; #[derive(Clone, Debug)] pub enum Kind { /// Pick a plane or planar face; the sketch starts on it at once. Sketch, Extrude { distance: String, direction: usize, operation: usize, /// The operation follows the geometry (into a body: cut, out of one: join, else new) /// until the user picks one. auto_op: bool, /// The other side's distance (two sides). distance2: String, /// Taper angle (empty: none). taper: String, }, Revolve { angle: String, operation: usize, }, Fillet { radius: String, chamfer: bool, chain: bool, }, Shell { thickness: String, }, Draft { angle: String, }, Mirror, /// Rectangular pattern of bodies: along one direction, optionally a second. PatternRect { count: String, spacing: String, count2: String, spacing2: String, }, /// Circular pattern of bodies around an axis. PatternCirc { count: String, angle: String, }, /// Loft through profiles of different sketches, in pick order. Loft { operation: usize, }, /// Sweep a profile along a path of sketch curves. Sweep { operation: usize, }, /// A construction plane offset from a plane. OffsetPlane { offset: String, }, /// A construction plane through an axis at an angle to a plane. AnglePlane { angle: String, }, /// Split a body by a plane. Split, /// Scale bodies uniformly. Scale { factor: String, }, /// Move planar faces along their normals. OffsetFaces { distance: String, }, /// A cosmetic thread on a cylindrical face. Thread { designation: String, length: String, }, /// A rib (one wall along open curves) or a web (a wall per curve). Rib { web: bool, thickness: String, depth: String, flip: bool, }, /// Raise or sink profiles of a sketch on a face. Emboss { depth: String, deboss: bool, }, /// Move planar faces onto a target plane or face. ReplaceFace, /// Move bodies so a face or point meets another. Align { flip: bool, }, /// Remove bodies from here on in the timeline. Remove, /// Pattern bodies along a path of sketch curves. PathPattern { count: String, spacing: String, }, /// A pipe (round, optionally hollow) along sketch curves. Pipe { diameter: String, wall: String, }, /// A stock block around bodies. Stock { margin: String, }, /// Application preferences (applied as they change; kept between runs). Preferences, /// Physical material (and with it the appearance) of bodies. Material { index: usize, }, /// Section Analysis: cut the view by a plane, moved along its normal. Section { offset: String, flip: bool, }, /// Measure one or two picked items (vertices, edges, faces): the result of the last /// measurement and the items it was for. Measure { result: Option, of: Vec, }, /// Move bodies by a distance along X, Y and Z. Move { x: String, y: String, z: String, }, Hole { diameter: String, depth: String, kind: usize, cb_diameter: String, cb_depth: String, cs_diameter: String, cs_angle: String, }, Primitive { cmd: &'static str, fields: Vec<(&'static str, String)>, operation: usize, }, Combine { operation: usize, keep_tools: bool, }, Params { new_name: String, new_expr: String, }, EditParam { name: String, expr: String, }, Rename { feature: u64, name: String, }, /// Delete a feature that others depend on. ConfirmDelete { feature: u64, with: Vec, fail: Vec, }, } #[derive(Clone, Debug)] pub struct Dialog { pub kind: Kind, /// Selection inputs, in order; clicks in the viewport go to the active one. pub inputs: Vec, pub active: usize, pub error: Option, /// Editing an existing feature (its id, and the timeline marker to restore afterwards). pub editing: Option<(u64, Option)>, /// Give the on-canvas value box the keyboard (and select its text) on the next frame. pub focus: bool, /// Parameters the dialog doesn't show but the command needs (kept when editing). pub extra: serde_json::Map, } /// Can a selection go into an input that accepts `a`? (Planar-ness is checked when picking.) fn fits(a: Accept, s: &Sel) -> bool { match s { Sel::Profile { .. } => a & PROFILES != 0, Sel::Edge { .. } => a & EDGES != 0, Sel::Face { .. } => a & (FACES | PLANAR_FACES) != 0, Sel::Body { .. } => a & BODIES != 0, Sel::Plane { .. } => a & PLANES != 0, Sel::Axis { .. } => a & AXES != 0, Sel::SketchCurve { .. } => a & (AXES | CURVES) != 0, Sel::Vertex { .. } => a & selection::VERTICES != 0, Sel::Feature { .. } | Sel::SketchPoint { .. } => false, } } impl Dialog { fn new(kind: Kind, inputs: Vec) -> Dialog { Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, extra: serde_json::Map::new() } } pub fn for_command(app: &SolveApp, id: &str) -> Option { let s = &app.session; let has_bodies = !s.model.state().bodies.is_empty(); let mut d = match id { "SketchCreate" => Dialog::new(Kind::Sketch, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]), "Extrude" => Dialog::new( Kind::Extrude { distance: "10 mm".into(), direction: 0, operation: usize::from(has_bodies), auto_op: true, distance2: "10 mm".into(), taper: String::new(), }, vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)], ), "Revolve" => Dialog::new( Kind::Revolve { angle: "360 deg".into(), operation: 0 }, vec![SelInput::new("Profiles", PROFILES, true), SelInput::new("Axis", AXES, false)], ), "FusionFilletEdgesCommand" => { Dialog::new(Kind::Fillet { radius: "2 mm".into(), chamfer: false, chain: true }, vec![SelInput::new("Edges", EDGES | FACES, true)]) } "FusionChamferCommand" => { Dialog::new(Kind::Fillet { radius: "1 mm".into(), chamfer: true, chain: true }, vec![SelInput::new("Edges", EDGES | FACES, true)]) } "FusionShellBodyCommand" => Dialog::new(Kind::Shell { thickness: "2 mm".into() }, vec![SelInput::new("Faces", FACES, true)]), "FusionDraftCommand" => Dialog::new( Kind::Draft { angle: "5 deg".into() }, vec![SelInput::new("Faces", FACES, true), SelInput::new("Neutral plane", PLANES | PLANAR_FACES, false)], ), "MirrorCommand" => { Dialog::new(Kind::Mirror, vec![SelInput::new("Bodies", BODIES, true), SelInput::new("Mirror plane", PLANES | PLANAR_FACES, false)]) } "FusionRibCommand" | "FusionWebCommand" => Dialog::new( Kind::Rib { web: id == "FusionWebCommand", thickness: "2 mm".into(), depth: String::new(), flip: false }, vec![SelInput::new("Curves", CURVES, true)], ), "EmbossCmd" => Dialog::new(Kind::Emboss { depth: "1 mm".into(), deboss: false }, vec![SelInput::new("Profiles", PROFILES, true)]), "FusionReplaceFaceCommand" => Dialog::new( Kind::ReplaceFace, vec![SelInput::new("Faces", PLANAR_FACES, true), SelInput::new("Target", PLANES | PLANAR_FACES, false)], ), "AlignCmd" => Dialog::new( Kind::Align { flip: false }, vec![ SelInput::new("Bodies", BODIES, true), SelInput::new("From", FACES | selection::VERTICES, false), SelInput::new("To", FACES | selection::VERTICES, false), ], ), "SoftDeleteCommand" => Dialog::new(Kind::Remove, vec![SelInput::new("Bodies", BODIES, true)]), "PatternOnPath" => Dialog::new( Kind::PathPattern { count: "4".into(), spacing: "20 mm".into() }, vec![SelInput::new("Objects", BODIES, true), SelInput::new("Path", CURVES, true)], ), "PrimitivePipe" => Dialog::new(Kind::Pipe { diameter: "5 mm".into(), wall: String::new() }, vec![SelInput::new("Path", CURVES, true)]), "StockModelCommand" => Dialog::new(Kind::Stock { margin: "2 mm".into() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)]), "ConstructionPlaneOffsetFromPlaneCommand" => { Dialog::new(Kind::OffsetPlane { offset: "10 mm".into() }, vec![SelInput::new("Plane", PLANES, false)]) } "ConstructionPlaneAtAngleCommand" => Dialog::new( Kind::AnglePlane { angle: "45 deg".into() }, vec![SelInput::new("Plane", PLANES, false), SelInput::new("Axis", AXES, false)], ), "FusionSplitBodyCommand" => Dialog::new( Kind::Split, vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting plane", PLANES | PLANAR_FACES, false)], ), "ModifyScale" => Dialog::new(Kind::Scale { factor: "2".into() }, vec![SelInput::new("Bodies", BODIES, true)]), "FusionOffsetFacesCommand" => { Dialog::new(Kind::OffsetFaces { distance: "2 mm".into() }, vec![SelInput::new("Faces", PLANAR_FACES, true)]) } "FusionThreadCommand" => { Dialog::new(Kind::Thread { designation: String::new(), length: String::new() }, vec![SelInput::new("Cylindrical face", FACES, false)]) } "PhysicalMaterialCommand" => Dialog::new(Kind::Material { index: 1 }, vec![SelInput::new("Bodies", BODIES, true)]), "FusionHalfSectionViewCommand" => { Dialog::new(Kind::Section { offset: "0 mm".into(), flip: false }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]) } "MeasureCommand" => { Dialog::new(Kind::Measure { result: None, of: Vec::new() }, vec![SelInput::new("Items", FACES | EDGES | selection::VERTICES, true)]) } "PatternRectangular" => Dialog::new( Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() }, vec![SelInput::new("Objects", BODIES, true), SelInput::new("Direction", AXES, false), SelInput::new("Direction 2", AXES, false)], ), "PatternCircular" => Dialog::new( Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() }, vec![SelInput::new("Objects", BODIES, true), SelInput::new("Axis", AXES, false)], ), "SolidLoft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]), "Sweep" => Dialog::new( Kind::Sweep { operation: usize::from(has_bodies) }, vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)], ), "FusionMoveCommand" => { Dialog::new(Kind::Move { x: "0 mm".into(), y: "0 mm".into(), z: "10 mm".into() }, vec![SelInput::new("Bodies", BODIES, true)]) } "FusionHoleCommand" => Dialog::new(hole_defaults(), vec![SelInput::new("Position", FACES, true)]), "PrimitiveBox" => Dialog::new( Kind::Primitive { cmd: "PrimitiveBox", fields: vec![("length", "20".into()), ("width", "20".into()), ("height", "20".into())], operation: 0, }, vec![], ), "PrimitiveCylinder" => Dialog::new( Kind::Primitive { cmd: "PrimitiveCylinder", fields: vec![("diameter", "20".into()), ("height", "20".into())], operation: 0 }, vec![], ), "PrimitiveSphere" => { Dialog::new(Kind::Primitive { cmd: "PrimitiveSphere", fields: vec![("diameter", "20".into())], operation: 0 }, vec![]) } "PrimitiveTorus" => Dialog::new( Kind::Primitive { cmd: "PrimitiveTorus", fields: vec![("major", "20".into()), ("minor", "5".into())], operation: 0 }, vec![], ), "FusionCombineCommand" => Dialog::new( Kind::Combine { operation: 1, keep_tools: false }, 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, }; // 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. for sel in &s.selection { let as_body = match sel { Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(Sel::Body { name: body.clone() }), _ => None, }; if let Some(b) = as_body && let Some(inp) = d.inputs.iter_mut().find(|i| i.accept == BODIES && (i.multi || i.items.is_empty())) { if !inp.items.contains(&b) { inp.items.push(b); } continue; } if let Some(inp) = d.inputs.iter_mut().find(|i| fits(i.accept, sel) && (i.multi || i.items.is_empty())) { inp.items.push(sel.clone()); } } // With nothing pre-selected, a single profile in the sketch being used is taken. if matches!(d.kind, Kind::Extrude { .. } | Kind::Revolve { .. }) && let Some(inp) = d.inputs.first_mut() && inp.items.is_empty() { inp.items = default_profiles(s); } d.advance(); Some(d) } pub fn preferences() -> Dialog { Dialog::new(Kind::Preferences, vec![]) } pub fn rename(feature: u64, name: &str) -> Dialog { Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![]) } pub fn confirm_delete(feature: u64, with: Vec, fail: Vec) -> Dialog { Dialog::new(Kind::ConfirmDelete { feature, with, fail }, vec![]) } pub fn edit_param(s: &Session, name: &str) -> Dialog { Dialog::new(Kind::EditParam { name: name.into(), expr: s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default() }, vec![]) } /// Make the first single input still empty (after the filled ones) active. fn advance(&mut self) { if let Some(i) = self.inputs.iter().position(|i| i.items.is_empty()) { self.active = i; } else { self.active = self.active.min(self.inputs.len().saturating_sub(1)); } } /// Does this dialog make a feature that can be previewed live? pub fn previews(&self) -> bool { matches!( self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Fillet { .. } | Kind::Shell { .. } | Kind::Draft { .. } | Kind::Mirror | Kind::Split | Kind::Rib { .. } | Kind::Emboss { .. } | Kind::ReplaceFace | Kind::Align { .. } | Kind::Remove | Kind::PathPattern { .. } | Kind::Pipe { .. } | Kind::Stock { .. } | Kind::Scale { .. } | Kind::OffsetFaces { .. } | Kind::PatternRect { .. } | Kind::PatternCirc { .. } | Kind::Loft { .. } | Kind::Sweep { .. } | Kind::Move { .. } | Kind::Hole { .. } | Kind::Primitive { .. } | Kind::Combine { .. } ) } /// The main value (shown on the canvas too): label, kind and the expression. pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> { Some(match &mut self.kind { Kind::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance), Kind::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle), Kind::Fillet { radius, chamfer, .. } => (if *chamfer { "Distance" } else { "Radius" }, ValueKind::Length, radius), Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness), Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle), Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter), Kind::Move { z, .. } => ("Z", ValueKind::Length, z), Kind::PatternRect { spacing, .. } => ("Spacing", ValueKind::Length, spacing), Kind::PatternCirc { angle, .. } => ("Angle", ValueKind::Angle, angle), Kind::Section { offset, .. } => ("Distance", ValueKind::Length, offset), Kind::OffsetPlane { offset } => ("Distance", ValueKind::Length, offset), Kind::Rib { thickness, .. } => ("Thickness", ValueKind::Length, thickness), Kind::Emboss { depth, .. } => ("Depth", ValueKind::Length, depth), Kind::PathPattern { spacing, .. } => ("Spacing", ValueKind::Length, spacing), Kind::Pipe { diameter, .. } => ("Diameter", ValueKind::Length, diameter), Kind::Stock { margin } => ("Margin", ValueKind::Length, margin), Kind::AnglePlane { angle } => ("Angle", ValueKind::Angle, angle), Kind::Scale { factor } => ("Scale", ValueKind::Unitless, factor), Kind::OffsetFaces { distance } => ("Distance", ValueKind::Length, distance), _ => return None, }) } pub fn wants_picks(&self) -> bool { !self.inputs.is_empty() } pub fn active_input(&self) -> Option<&SelInput> { self.inputs.get(self.active) } /// Everything the inputs hold (for highlighting). pub fn items(&self) -> Vec { self.inputs.iter().flat_map(|i| i.items.iter().cloned()).collect() } pub fn highlight_key(&self) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); serde_json::to_string(&self.items()).unwrap_or_default().hash(&mut h); self.active.hash(&mut h); h.finish() } /// The selection a hit would add to the active input. pub fn candidate(&self, s: &Session, h: &Hit) -> Option { self.active_input()?.accepts(h, |body, face| planar_face(s, body, face).is_some()) } /// A pick in the viewport (already accepted by the active input). pub fn pick(&mut self, s: &Session, sel: Sel) { let chain = matches!(self.kind, Kind::Fillet { chain: true, .. }); let mut picked = vec![sel.clone()]; // Tangent chain: an edge brings the edges that continue it smoothly. if chain && let Sel::Edge { body, index, .. } = &sel && let Some(b) = s.model.state().body(body) { let m = b.mesh(); picked = m .tangent_chain(*index, 2f64.to_radians()) .into_iter() .filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) })) .collect(); } // Extrude and revolve take profiles of one sketch. if let Sel::Profile { sketch, .. } = &sel && !matches!(self.kind, Kind::Loft { .. }) && let Some(inp) = self.inputs.get_mut(self.active) { inp.items.retain(|x| !matches!(x, Sel::Profile { sketch: s2, .. } if s2 != sketch)); } let Some(inp) = self.inputs.get_mut(self.active) else { return }; inp.toggle(picked); self.focus = true; // A measurement is between two items: a third pick starts over from the last one. if matches!(self.kind, Kind::Measure { .. }) && inp.items.len() > 2 { let keep = inp.items.split_off(inp.items.len() - 1); inp.items = keep; } if !inp.multi && !inp.items.is_empty() && self.active + 1 < self.inputs.len() { self.advance(); } self.error = None; } /// Box selection result: replace (or add to) the active input. pub fn take_box(&mut self, sels: Vec, add: bool) { let Some(inp) = self.inputs.get_mut(self.active) else { return }; if !add { inp.items.clear(); } for x in sels { if fits(inp.accept, &x) && !inp.items.contains(&x) { inp.items.push(x); } } } } /// Profiles of the sketch a feature would use (active, else the last), when there is just one. fn default_profiles(s: &Session) -> Vec { let st = s.model.state(); let sid = s.active_sketch.or_else(|| s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id)); match sid.and_then(|id| st.sketch(id).map(|ss| (id, ss.profiles.len()))) { Some((id, 1)) => vec![Sel::Profile { sketch: id, index: 0 }], _ => Vec::new(), } } /// The plane of a planar body face: (point on it, unit normal). pub fn planar_face(s: &Session, body: &str, face: usize) -> Option<(Vec3, Vec3)> { let st = s.model.state(); let b = st.body(body)?; let tol = (b.body.size() * 1e-3).max(1e-3); let f = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == face)?; Some((f.centroid, f.plane_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() } fn pt(v: Vec3) -> Value { json!([v.x, v.y, v.z]) } /// A plane input as a command parameter: an origin/construction plane name or a face plane. fn plane_value(s: &Session, sel: Option<&Sel>) -> Option { match sel? { Sel::Plane { name } => Some(json!(name)), Sel::Face { body, index, point } => { let (_, n) = planar_face(s, body, *index)?; Some(json!({"origin": pt(*point), "normal": pt(n)})) } _ => None, } } /// A value field; true when Enter was pressed in it. fn field(ui: &mut egui::Ui, s: &mut String) -> bool { let r = ui.text_edit_singleline(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) { 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); } }); } fn title(k: &Kind) -> &'static str { match k { Kind::Sketch => "CREATE SKETCH", Kind::Extrude { .. } => "EXTRUDE", Kind::Revolve { .. } => "REVOLVE", Kind::Fillet { chamfer: false, .. } => "FILLET", Kind::Fillet { chamfer: true, .. } => "CHAMFER", Kind::Shell { .. } => "SHELL", Kind::Draft { .. } => "DRAFT", Kind::Mirror => "MIRROR", Kind::Measure { .. } => "MEASURE", Kind::Material { .. } => "PHYSICAL MATERIAL", Kind::Preferences => "PREFERENCES", Kind::OffsetPlane { .. } => "OFFSET PLANE", Kind::Rib { web: false, .. } => "RIB", Kind::Rib { web: true, .. } => "WEB", Kind::Emboss { .. } => "EMBOSS", Kind::ReplaceFace => "REPLACE FACE", Kind::Align { .. } => "ALIGN", Kind::Remove => "REMOVE", Kind::PathPattern { .. } => "PATTERN ON PATH", Kind::Pipe { .. } => "PIPE", Kind::Stock { .. } => "STOCK", Kind::AnglePlane { .. } => "PLANE AT ANGLE", Kind::Split => "SPLIT BODY", Kind::Scale { .. } => "SCALE", Kind::OffsetFaces { .. } => "OFFSET FACE", Kind::Thread { .. } => "THREAD", Kind::Section { .. } => "SECTION ANALYSIS", Kind::PatternRect { .. } => "RECTANGULAR PATTERN", Kind::PatternCirc { .. } => "CIRCULAR PATTERN", Kind::Loft { .. } => "LOFT", Kind::Sweep { .. } => "SWEEP", Kind::Move { .. } => "MOVE", Kind::Hole { .. } => "HOLE", Kind::Primitive { cmd, .. } => match *cmd { "PrimitiveBox" => "BOX", "PrimitiveCylinder" => "CYLINDER", "PrimitiveSphere" => "SPHERE", _ => "TORUS", }, Kind::Combine { .. } => "COMBINE", Kind::Params { .. } => "PARAMETERS", Kind::EditParam { .. } => "EDIT DIMENSION", Kind::Rename { .. } => "RENAME", Kind::ConfirmDelete { .. } => "DELETE FEATURE", } } fn hint(inp: &SelInput) -> &'static str { let a = inp.accept; if a & PROFILES != 0 { "click profiles in the view" } else if a & EDGES != 0 { "click edges or faces" } else if a & PLANES != 0 { "click a plane or planar face" } else if a & AXES != 0 { "click an axis or sketch line" } else if a & CURVES != 0 { "click sketch curves" } else if a & BODIES != 0 { "click bodies" } else { "click faces" } } /// Selection input rows: label, "N selected" (or a hint) and a clear button; clicking a row /// makes it the active input. fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) { let t = Tokens::get(); let mut clear: Option = None; let mut activate: Option = None; for (i, inp) in d.inputs.iter().enumerate() { ui.label(inp.label); ui.horizontal(|ui| { let active = i == d.active; let text = if inp.items.is_empty() { RichText::new(hint(inp)).color(if active { t.warning } else { t.text_dim }) } else { RichText::new(format!("{} selected", inp.items.len())).color(t.text) }; // Not in the Tab order: Tab moves between values. let b = egui::Button::new(text) .fill(if active { t.accent_soft } else { Color32::TRANSPARENT }) .min_size(vec2(FIELD_W, 22.0)) .sense(egui::Sense::CLICK); if ui.add(b).on_hover_text("Click to make this the input that viewport picks go to").clicked() { activate = Some(i); } if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() { clear = Some(i); } }); ui.end_row(); } if let Some(i) = activate { d.active = i; } if let Some(i) = clear && let Some(inp) = d.inputs.get_mut(i) { inp.items.clear(); d.active = i; } } /// Width limits of a docked dialog: shrink-wrapped to its content within these. const DIALOG_MIN_W: f32 = 260.0; const DIALOG_MAX_W: f32 = 380.0; /// Width of value fields and choice boxes in a dialog. const FIELD_W: f32 = 140.0; /// Measure again when the picked items changed. fn update_measure(app: &SolveApp, d: &mut Dialog) { let items: Vec = sels(d, 0).to_vec(); if let Kind::Measure { result, of } = &mut d.kind && *of != items { *result = if items.is_empty() { None } else { Some(app.session.measure_items(&items)) }; *of = items; } } /// The preferences, applied as they change. fn preferences_rows(app: &mut SolveApp, ui: &mut egui::Ui) { ui.label("Theme"); let mut theme = usize::from(!app.ui.dark); combo(ui, "pref_theme", &["Dark", "Light"], &mut theme); app.ui.dark = theme == 0; ui.end_row(); ui.label("Grid"); ui.checkbox(&mut app.ui.show_grid, ""); ui.end_row(); ui.label("Perspective view"); ui.checkbox(&mut app.ui.perspective, ""); ui.end_row(); ui.label("Project face edges into new sketches"); let mut auto = app.session.auto_project; if ui.checkbox(&mut auto, "").changed() { let _ = app.run("sketch.auto_project", json!({ "value": auto })); } ui.end_row(); ui.label("Click selects whole bodies"); ui.checkbox(&mut app.ui.pick_bodies, ""); ui.end_row(); } fn fmt_mm(v: &Value) -> String { v.as_f64().map(|x| format!("{x:.3} mm")).unwrap_or_default() } /// The measurement in the dialog. fn measure_rows(ui: &mut egui::Ui, r: Option<&Value>) { let t = Tokens::get(); let Some(r) = r else { ui.label(""); ui.label(RichText::new("pick one or two vertices, edges or faces").color(t.text_dim)); ui.end_row(); return; }; if let Some(e) = r.get("error").and_then(Value::as_str) { ui.label(""); ui.label(RichText::new(e).color(t.error)); ui.end_row(); return; } for (i, it) in r["items"].as_array().into_iter().flatten().enumerate() { let what = match it["type"].as_str() { Some("edge") => format!("Length {}", fmt_mm(&it["length_mm"])), Some("face") => format!("Area {:.3} mm²", it["area_mm2"].as_f64().unwrap_or(0.0)), Some("vertex") => { let p = &it["position"]; format!("At {:.3}, {:.3}, {:.3}", p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0), p[2].as_f64().unwrap_or(0.0)) } _ => String::new(), }; ui.label(format!("Item {}", i + 1)); ui.label(what); ui.end_row(); } if r.get("distance_mm").is_some() { ui.label(RichText::new("Distance").strong()); ui.label(RichText::new(fmt_mm(&r["distance_mm"])).strong()); ui.end_row(); let d = &r["delta"]; for (k, l) in ["ΔX", "ΔY", "ΔZ"].iter().enumerate() { ui.label(*l); ui.label(format!("{:.3} mm", d[k].as_f64().unwrap_or(0.0).abs())); ui.end_row(); } } if let Some(a) = r["angle_deg"].as_f64() { ui.label(RichText::new("Angle").strong()); ui.label(RichText::new(format!("{a:.3}°")).strong()); ui.end_row(); } } /// Keep an automatic extrude operation in step with the geometry. fn auto_operation(app: &SolveApp, d: &mut Dialog) { if !matches!(d.kind, Kind::Extrude { auto_op: true, .. }) { return; } let Some(op) = dialog_commands(app, d).ok().and_then(|c| c.into_iter().next()).and_then(|(_, p)| solvecraft_engine::auto_operation(&app.session, &p)) else { return; }; if let Kind::Extrude { operation, .. } = &mut d.kind && let Some(i) = OPS.iter().position(|o| *o == op) { *operation = i; } } /// Show the active dialog (if any), docked flush to the right edge of the viewport just below /// the view cube, sized to its content. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; auto_operation(app, &mut d); update_measure(app, &mut d); let t = Tokens::get(); let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect()); let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE); let mut keep = true; let mut ok = false; let mut cancel = false; let mut enter = false; let wide = matches!(d.kind, Kind::Params { .. }); let heading = if d.editing.is_some() { format!("EDIT {}", title(&d.kind)) } else { title(&d.kind).to_string() }; let frame = egui::Frame::window(&ctx.global_style()).corner_radius(egui::CornerRadius { nw: 6, sw: 6, ne: 0, se: 0 }).shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40), }); egui::Window::new(RichText::new(heading).strong().size(13.0)) .id(egui::Id::new("sc_dialog")) .frame(frame) .pivot(egui::Align2::RIGHT_TOP) .fixed_pos(anchor) .constrain(false) .auto_sized() .scroll([false, true]) .collapsible(false) .max_height((vp.bottom() - anchor.y - 8.0).max(120.0)) .open(&mut keep) .show(ctx, |ui| { ui.spacing_mut().text_edit_width = FIELD_W; let margin = 2.0 * ui.style().spacing.window_margin.leftf(); ui.set_min_width(if wide { 480.0 } else { DIALOG_MIN_W } - margin); ui.set_max_width(if wide { 560.0 } else { DIALOG_MAX_W } - margin); egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { input_rows(&mut d, ui); match &mut d.kind { Kind::Sketch => {} Kind::Extrude { distance, direction, operation, auto_op, distance2, taper } => { ui.label("Direction"); combo(ui, "ex_dir", &DIR_LABELS, direction); ui.end_row(); ui.label("Distance"); enter |= field(ui, distance); ui.end_row(); if *direction == TWO_SIDES { ui.label("Distance 2"); enter |= field(ui, distance2); ui.end_row(); } else if *direction < 2 { ui.label("Taper angle"); let r = ui.add(egui::TextEdit::singleline(taper).hint_text("0 deg")); enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); } ui.label("Operation"); let before = *operation; combo(ui, "ex_op", &OP_LABELS, operation); // A choice made by hand sticks. if *operation != before { *auto_op = false; } ui.end_row(); } Kind::Revolve { angle, operation } => { ui.label("Angle"); enter |= field(ui, angle); ui.end_row(); ui.label("Operation"); combo(ui, "rv_op", &OP_LABELS, operation); ui.end_row(); } Kind::Fillet { radius, chamfer, chain } => { ui.label(if *chamfer { "Distance" } else { "Radius" }); enter |= field(ui, radius); ui.end_row(); ui.label("Tangent chain"); ui.checkbox(chain, ""); ui.end_row(); } Kind::Shell { thickness } => { ui.label("Inside thickness"); enter |= field(ui, thickness); ui.end_row(); } Kind::Draft { angle } => { ui.label("Angle"); enter |= field(ui, angle); ui.end_row(); } Kind::Mirror => {} Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()), Kind::Preferences => preferences_rows(app, ui), Kind::OffsetPlane { offset } => { ui.label("Distance"); enter |= field(ui, offset); ui.end_row(); } Kind::AnglePlane { angle } => { ui.label("Angle"); enter |= field(ui, angle); ui.end_row(); } Kind::Split | Kind::ReplaceFace | Kind::Remove => {} Kind::Rib { thickness, depth, flip, .. } => { ui.label("Thickness"); enter |= field(ui, thickness); ui.end_row(); ui.label("Depth"); let r = ui.add(egui::TextEdit::singleline(depth).hint_text("to the next face")); enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); ui.label("Flip"); ui.checkbox(flip, ""); ui.end_row(); } Kind::Emboss { depth, deboss } => { ui.label("Depth"); enter |= field(ui, depth); ui.end_row(); ui.label("Type"); let mut i = usize::from(*deboss); combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i); *deboss = i == 1; ui.end_row(); } Kind::Align { flip } => { ui.label("Flip"); ui.checkbox(flip, ""); ui.end_row(); } Kind::PathPattern { count, spacing } => { for (l, v) in [("Quantity", count), ("Spacing", spacing)] { ui.label(l); enter |= field(ui, v); ui.end_row(); } } Kind::Pipe { diameter, wall } => { ui.label("Diameter"); enter |= field(ui, diameter); ui.end_row(); ui.label("Wall"); let r = ui.add(egui::TextEdit::singleline(wall).hint_text("solid")); enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); } Kind::Stock { margin } => { ui.label("Margin"); enter |= field(ui, margin); ui.end_row(); } Kind::Scale { factor } => { ui.label("Scale factor"); enter |= field(ui, factor); ui.end_row(); } Kind::OffsetFaces { distance } => { ui.label("Distance"); enter |= field(ui, distance); ui.end_row(); } Kind::Thread { designation, length } => { ui.label("Size"); let r = ui.add(egui::TextEdit::singleline(designation).hint_text("fits the face, e.g. M8")); enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); ui.label("Length"); let r = ui.add(egui::TextEdit::singleline(length).hint_text("whole face")); enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); } Kind::Material { index } => { let names: Vec<&str> = solvecraft_engine::doc::MATERIALS.iter().map(|(n, _)| *n).collect(); ui.label("Material"); combo(ui, "mat", &names, index); ui.end_row(); } Kind::Section { offset, flip } => { ui.label("Distance"); enter |= field(ui, offset); ui.end_row(); ui.label("Flip"); ui.checkbox(flip, ""); ui.end_row(); } Kind::PatternRect { count, spacing, count2, spacing2 } => { for (l, v) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] { ui.label(l); enter |= field(ui, v); ui.end_row(); } } Kind::PatternCirc { count, angle } => { for (l, v) in [("Quantity", count), ("Total angle", angle)] { ui.label(l); enter |= field(ui, v); ui.end_row(); } } Kind::Loft { operation } => { ui.label("Operation"); combo(ui, "lf_op", &OP_LABELS, operation); ui.end_row(); } Kind::Sweep { operation } => { ui.label("Operation"); combo(ui, "sw_op", &OP_LABELS, operation); ui.end_row(); } Kind::Move { x, y, z } => { for (l, v) in [("X distance", x), ("Y distance", y), ("Z distance", z)] { ui.label(l); enter |= field(ui, v); ui.end_row(); } } Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } => { ui.label("Type"); combo(ui, "hole_kind", &HOLE_LABELS, kind); ui.end_row(); ui.label("Diameter"); enter |= field(ui, diameter); ui.end_row(); ui.label("Depth"); let r = ui.add(egui::TextEdit::singleline(depth).hint_text("through all")); enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); if *kind == 1 { ui.label("Counterbore Ø"); enter |= field(ui, cb_diameter); ui.end_row(); ui.label("Counterbore depth"); enter |= field(ui, cb_depth); ui.end_row(); } if *kind == 2 { ui.label("Countersink Ø"); enter |= field(ui, cs_diameter); ui.end_row(); ui.label("Countersink angle"); enter |= field(ui, cs_angle); ui.end_row(); } } Kind::Primitive { fields, operation, .. } => { for (k, v) in fields.iter_mut() { ui.label(*k); enter |= field(ui, v); ui.end_row(); } ui.label("Operation"); combo(ui, "pr_op", &OP_LABELS, operation); ui.end_row(); } Kind::Combine { operation, keep_tools } => { ui.label("Operation"); let mut op = operation.saturating_sub(1); combo(ui, "cb_op", &OP_LABELS[1..], &mut op); *operation = op + 1; ui.end_row(); ui.label("Keep tools"); ui.checkbox(keep_tools, ""); ui.end_row(); } Kind::Params { new_name, new_expr } => params_table(app, ui, new_name, new_expr), Kind::EditParam { name, expr } => { ui.label(name.as_str()); let r = ui.text_edit_singleline(expr); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { ok = true; } r.request_focus(); ui.end_row(); } Kind::Rename { name, .. } => { ui.label("Name"); let r = ui.text_edit_singleline(name); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { ok = true; } r.request_focus(); ui.end_row(); } Kind::ConfirmDelete { with, fail, .. } => { if !with.is_empty() { ui.label("Also deletes"); ui.label(with.join(", ")); ui.end_row(); } if !fail.is_empty() { ui.label("Will fail"); ui.label(RichText::new(fail.join(", ")).color(t.warning)); ui.end_row(); } } } if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()) { ui.label(""); ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap()); ui.end_row(); } }); ui.add_space(6.0); ui.horizontal(|ui| { if !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences) && ui .add( egui::Button::new( RichText::new(if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" }).color(Color32::WHITE), ) .fill(t.accent) .min_size(vec2(70.0, 24.0)), ) .clicked() { ok = true; } let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences) { "Close" } else { "Cancel" }; if ui.add(egui::Button::new(close).min_size(vec2(70.0, 24.0))).clicked() { cancel = true; } if app.preview.busy { ui.add(egui::Spinner::new().size(14.0)).on_hover_text("Updating the preview"); } }); }); // Keyboard: Enter applies (from a value field, or with nothing focused), Esc cancels. let (enter_free, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); let nothing_focused = ctx.memory(|m| m.focused().is_none()); let canvas_enter = enter_free && ctx.memory(|m| m.had_focus_last_frame(egui::Id::new("sc_canvas_value"))); let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. } | Kind::Preferences); if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) { ok = true; } if esc { cancel = true; } // A plane picked for a new sketch starts it right away. if matches!(d.kind, Kind::Sketch) && let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned() { start_sketch(app, &sel); cancel = true; } if ok { match run_dialog(app, &d) { Ok(()) => cancel = true, Err(e) => d.error = Some(e), } } if keep && !cancel { app.dialog = Some(d); } else if let Some((_, marker)) = d.editing { // Editing done: put the timeline marker back where it was. let _ = app.run("timeline.rollTo", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); } } /// Close the dialog without applying it (Esc, Cancel). An edit puts the timeline marker back. pub fn cancel(app: &mut SolveApp) { if let Some(d) = app.dialog.take() && let Some((_, marker)) = d.editing { let _ = app.run("timeline.rollTo", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); } } fn hole_defaults() -> Kind { Kind::Hole { diameter: "5 mm".into(), depth: String::new(), kind: 0, cb_diameter: "9 mm".into(), cb_depth: "3 mm".into(), cs_diameter: "10 mm".into(), cs_angle: "90 deg".into(), } } fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, new_expr: &mut String) { let t = Tokens::get(); ui.label(RichText::new("Name").strong()); ui.label(RichText::new("Expression · value").strong()); ui.end_row(); let (vals, errs) = app.session.doc.param_values(); let params = app.session.doc.params.clone(); for p in params { ui.label(if p.model { RichText::new(&p.name).color(t.text_dim) } else { RichText::new(&p.name) }); ui.horizontal(|ui| { let id = egui::Id::new(("pexpr", &p.name)); let mut e = ui.data(|dd| dd.get_temp::(id)).unwrap_or_else(|| p.expr.clone()); let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0)); if r.changed() { ui.data_mut(|dd| dd.insert_temp(id, e.clone())); } if r.lost_focus() && e != p.expr { let _ = app.run("ChangeParameterCommand", json!({"name": p.name, "expression": e})); ui.data_mut(|dd| dd.remove::(id)); } let v = vals.get(&p.name).map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) }); match (v, errs.get(&p.name)) { (_, Some(e)) => ui.label(RichText::new(e).color(t.error)), (Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)), _ => ui.label(""), }; }); ui.end_row(); } ui.add(egui::TextEdit::singleline(new_name).hint_text("new name").desired_width(110.0)); ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(new_expr).hint_text("expression, e.g. 25 mm").desired_width(160.0)); if ui.button("Add").clicked() && !new_name.is_empty() && app.run("ChangeParameterCommand", json!({"name": new_name, "expression": new_expr})).is_ok() { new_name.clear(); new_expr.clear(); } }); ui.end_row(); } /// Start a sketch on a picked plane or planar face and turn the view to face it. pub fn start_sketch(app: &mut SolveApp, sel: &Sel) { let plane = match sel { Sel::Plane { name } => json!(name), Sel::Face { point, .. } => json!({"face": pt(*point)}), _ => return, }; let before = app.cam; if app.run("SketchCreate", json!({ "plane": plane })).is_ok() { app.pre_sketch_cam = Some(before); look_at_sketch(app); } } /// Animate the camera to look straight at the active sketch plane. pub fn look_at_sketch(app: &mut SolveApp) { let st = app.session.model.state(); if let Some(ss) = app.session.active_sketch.and_then(|id| st.sketch(id)) { let mut to = app.cam.looking_from(ss.plane.normal()); // Keep the sketch's own x axis to the right when looking straight down or up. if ss.plane.normal().z.abs() > 0.999 { let x = ss.plane.x; to.yaw = if ss.plane.normal().z > 0.0 { (-x.y).atan2(x.x) } else { x.y.atan2(x.x) }; } to.target = ss.plane.origin; app.animate_to(to); } } fn sels(d: &Dialog, i: usize) -> &[Sel] { d.inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[]) } fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { for (cmd, params) in apply_commands(app, d)? { app.run(&cmd, params)?; } Ok(()) } /// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same. pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result, String> { let cmds = dialog_commands(app, d)?; if let Some((id, _)) = d.editing { let (cmd, params) = cmds.into_iter().next().ok_or("nothing to apply")?; return Ok(vec![("timeline.redefine".into(), json!({"feature": id, "command": cmd, "params": params}))]); } Ok(cmds) } /// The commands (id, parameters) a dialog's OK runs. fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, String> { let s = &app.session; let need = |i: usize, what: &str| -> Result<(), String> { if sels(d, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) } }; let profiles = || -> (Value, Value) { let ps: Vec<(u64, usize)> = sels(d, 0).iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect(); (ps.first().map(|x| json!(x.0)).unwrap_or(Value::Null), json!(ps.iter().map(|x| x.1).collect::>())) }; let body_names = |i: usize| -> Vec { sels(d, i).iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect() }; let face_points = |i: usize| -> Vec { sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect() }; let (cmd, params): (&str, Value) = match &d.kind { Kind::Extrude { distance, direction, operation, distance2, taper, .. } => { need(0, "profiles or a planar face")?; let mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")}); if *direction == TWO_SIDES { common["distance2"] = json!(distance2); } else if *direction < 2 && !taper.trim().is_empty() { common["taper"] = json!(taper); } let with = |extra: Value| -> Value { let mut p = common.clone(); if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) { m.extend(e); for (k, v) in &d.extra { m.entry(k.clone()).or_insert_with(|| v.clone()); } } p }; let mut out = Vec::new(); let (sketch, idx) = profiles(); if !sketch.is_null() { out.push(("Extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx})))); } // Each planar body face extrudes on its own. for p in face_points(0) { out.push(("Extrude".to_string(), with(json!({ "face": p })))); } return Ok(out); } Kind::Revolve { angle, operation } => { need(0, "profiles")?; need(1, "an axis")?; let axis = match sels(d, 1).first() { Some(Sel::Axis { name }) => json!(name), Some(Sel::SketchCurve { id }) => json!(id), _ => return Err("select an axis".into()), }; let (sketch, idx) = profiles(); ( "Revolve", json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")}), ) } Kind::Fillet { radius, chamfer, .. } => { need(0, "edges")?; // Faces stand for all their edges. let st = s.model.state(); let mut pts: Vec = Vec::new(); for x in sels(d, 0) { match x { Sel::Edge { point, .. } => pts.push(*point), Sel::Face { body, index, .. } => { if let Some(b) = st.body(body) { let m = b.mesh(); for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) { if !m.seams.get(e).copied().unwrap_or(false) && let Some(p) = m.edges.get(e) { pts.push(polyline_mid(p)); } } } } _ => {} } } let mut uniq: Vec = Vec::new(); for p in pts { if !uniq.iter().any(|q| q.dist(p) < 1e-9) { uniq.push(p); } } let edges: Vec = uniq.into_iter().map(pt).collect(); if *chamfer { ("FusionChamferCommand", json!({"edges": edges, "distance": radius})) } else { ("FusionFilletEdgesCommand", json!({"edges": edges, "radius": radius})) } } Kind::Shell { thickness } => { need(0, "faces to remove")?; ("FusionShellBodyCommand", json!({"faces": face_points(0), "thickness": thickness})) } Kind::Draft { angle } => { need(0, "faces")?; need(1, "the neutral plane")?; let neutral = plane_value(s, sels(d, 1).first()).ok_or("the neutral plane must be a plane or a planar face")?; ("FusionDraftCommand", json!({"faces": face_points(0), "angle": angle, "neutral": neutral})) } Kind::Mirror => { need(0, "bodies")?; need(1, "the mirror plane")?; let features = source_features(s, &body_names(0)); let plane = plane_value(s, sels(d, 1).first()).ok_or("the mirror plane must be a plane or a planar face")?; ("MirrorCommand", json!({"features": features, "plane": plane})) } Kind::PatternRect { count, spacing, count2, spacing2 } => { need(0, "objects")?; let kept = d.extra.get("dir1").and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok()).map(|a| Vec3::new(a[0], a[1], a[2])); let d1 = match (sels(d, 1).first(), kept) { (Some(x), _) => axis_of(app, x).ok_or("the direction must be an axis or a sketch line")?.1, (None, Some(k)) => k, (None, None) => return Err("select a direction (an axis or a sketch line) first".into()), }; let mut p = json!({"features": source_features(s, &body_names(0)), "dir1": pt(d1), "count1": count, "spacing1": spacing}); if let Some((_, d2)) = sels(d, 2).first().and_then(|x| axis_of(app, x)) { p["dir2"] = pt(d2); p["count2"] = json!(count2); p["spacing2"] = json!(spacing2); } ("PatternRectangular", p) } Kind::PatternCirc { count, angle } => { need(0, "objects")?; let axis = match sels(d, 1).first() { None if d.extra.contains_key("axis") => d.extra.get("axis").cloned().unwrap_or(Value::Null), Some(Sel::Axis { name }) => json!(name), Some(x) => { let (o, dir) = axis_of(app, x).ok_or("the axis must be an origin axis or a sketch line")?; json!({"origin": pt(o), "dir": pt(dir)}) } None => return Err("select an axis".into()), }; ("PatternCircular", json!({"features": source_features(s, &body_names(0)), "axis": axis, "count": count, "angle": angle})) } Kind::Loft { operation } => { need(0, "profiles of two or more sketches")?; // One section per sketch, in the order the sketches were first picked. let mut sections: Vec<(u64, Vec)> = Vec::new(); for x in sels(d, 0) { if let Sel::Profile { sketch, index } = x { match sections.iter_mut().find(|(sk, _)| sk == sketch) { Some((_, v)) => v.push(*index), None => sections.push((*sketch, vec![*index])), } } } if sections.len() < 2 { return Err("pick profiles in two or more sketches".into()); } let sections: Vec = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect(); ("SolidLoft", json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")})) } Kind::Sweep { operation } => { need(0, "a profile")?; need(1, "the path")?; let (sketch, idx) = profiles(); let path: Vec = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); let path_sketch = curves_sketch(app, &path, sketch.as_u64()).ok_or("the path curves must be in one sketch")?; ( "Sweep", json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}), ) } Kind::OffsetPlane { offset } => { need(0, "a plane")?; let base = match sels(d, 0).first() { Some(Sel::Plane { name }) => name.clone(), _ => return Err("pick an origin or construction plane".into()), }; ("ConstructionPlaneOffsetFromPlaneCommand", json!({"base": base, "offset": offset})) } Kind::AnglePlane { angle } => { need(0, "a plane")?; need(1, "an axis")?; let base = match sels(d, 0).first() { Some(Sel::Plane { name }) => name.clone(), _ => return Err("pick an origin or construction plane".into()), }; let axis = match sels(d, 1).first() { Some(Sel::Axis { name }) => json!(name), Some(x) => { let (o, dir) = axis_of(app, x).ok_or("the axis must be an origin axis or a sketch line")?; json!({"origin": pt(o), "dir": pt(dir)}) } None => return Err("pick an axis".into()), }; ("ConstructionPlaneAtAngleCommand", json!({"base": base, "axis": axis, "angle": angle})) } Kind::Rib { web, thickness, depth, flip } => { need(0, "open sketch curves")?; let curves = curve_ids(d, 0); let sketch = curves_sketch(app, &curves, None).ok_or("the curves must be in one sketch")?; let mut p = json!({"sketch": sketch, "curves": curves, "thickness": thickness, "flip": flip}); if !depth.trim().is_empty() { p["depth"] = json!(depth); } (if *web { "FusionWebCommand" } else { "FusionRibCommand" }, p) } Kind::Emboss { depth, deboss } => { need(0, "profiles")?; let (sketch, idx) = profiles(); ("EmbossCmd", json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }})) } Kind::ReplaceFace => { need(0, "faces to move")?; need(1, "the target")?; let mut p = json!({"faces": face_points(0)}); match sels(d, 1).first() { Some(Sel::Plane { name }) => p["target"] = json!(name), Some(Sel::Face { point, .. }) => p["target_face"] = pt(*point), _ => return Err("pick a plane or planar face".into()), } ("FusionReplaceFaceCommand", p) } Kind::Align { flip } => { need(0, "bodies")?; need(1, "what to move from")?; need(2, "where to move it to")?; let mut p = json!({"bodies": body_names(0), "flip": flip}); for (i, key) in [(1, "from"), (2, "to")] { match sels(d, i).first() { Some(Sel::Face { point, .. }) => p[format!("{key}_face")] = pt(*point), Some(Sel::Vertex { point, .. }) => p[key] = pt(*point), _ => return Err("pick faces or vertices".into()), } } ("AlignCmd", p) } Kind::Remove => { need(0, "bodies")?; ("SoftDeleteCommand", json!({"bodies": body_names(0)})) } Kind::PathPattern { count, spacing } => { need(0, "objects")?; need(1, "the path")?; let path = curve_ids(d, 1); let sketch = curves_sketch(app, &path, None).ok_or("the path curves must be in one sketch")?; ("PatternOnPath", json!({"bodies": body_names(0), "path_sketch": sketch, "path": path, "count": count, "spacing": spacing})) } Kind::Pipe { diameter, wall } => { need(0, "the path")?; let path = curve_ids(d, 0); let sketch = curves_sketch(app, &path, None).ok_or("the path curves must be in one sketch")?; let mut p = json!({"path_sketch": sketch, "path": path, "diameter": diameter}); if !wall.trim().is_empty() { p["wall"] = json!(wall); } ("PrimitivePipe", p) } Kind::Stock { margin } => { let mut p = json!({"margin": margin}); if !sels(d, 0).is_empty() { p["bodies"] = json!(body_names(0)); } ("StockModelCommand", p) } Kind::Split => { need(0, "the body to split")?; need(1, "the splitting plane")?; let body = body_names(0).into_iter().next().unwrap_or_default(); let plane = match sels(d, 1).first() { Some(Sel::Plane { name }) => json!(name), Some(Sel::Face { body, index, point }) => { let (_, n) = planar_face(s, body, *index).ok_or("the face must be planar")?; json!({"origin": pt(*point), "normal": pt(n)}) } _ => return Err("pick a plane or a planar face".into()), }; ("FusionSplitBodyCommand", json!({"body": body, "plane": plane})) } Kind::Scale { factor } => { need(0, "bodies")?; ("ModifyScale", json!({"bodies": body_names(0), "factor": factor})) } Kind::OffsetFaces { distance } => { need(0, "planar faces")?; ("FusionOffsetFacesCommand", json!({"faces": face_points(0), "distance": distance})) } Kind::Thread { designation, length } => { need(0, "a cylindrical face")?; let mut p = json!({"face": face_points(0).into_iter().next().unwrap_or(Value::Null)}); if !designation.trim().is_empty() { p["designation"] = json!(designation.trim()); } if !length.trim().is_empty() { p["length"] = json!(length); } ("FusionThreadCommand", p) } Kind::Material { index } => { need(0, "bodies")?; let name = solvecraft_engine::doc::MATERIALS.get(*index).map(|(n, _)| *n).unwrap_or("Default"); ("PhysicalMaterialCommand", json!({"bodies": body_names(0), "material": name})) } Kind::Section { offset, flip } => { need(0, "a plane or planar face")?; let plane = match sels(d, 0).first() { Some(Sel::Plane { name }) if matches!(name.as_str(), "XY" | "XZ" | "YZ") => json!(name), Some(Sel::Face { body, index, point }) => { let (_, n) = planar_face(s, body, *index).ok_or("the face must be planar")?; json!({"origin": pt(*point), "normal": pt(n)}) } Some(Sel::Plane { name }) => { let pl = solvecraft_engine::view::construction_planes(s) .into_iter() .find(|(_, n, _)| n == name) .map(|(_, _, pl)| pl) .ok_or("unknown plane")?; json!({"origin": pt(pl.origin), "normal": pt(pl.normal())}) } _ => return Err("pick a plane or a planar face".into()), }; ("FusionHalfSectionViewCommand", json!({"plane": plane, "offset": offset, "flip": flip})) } Kind::Move { x, y, z } => { need(0, "bodies")?; ("FusionMoveCommand", json!({"bodies": body_names(0), "translate": [x, y, z]})) } Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } => { need(0, "a face position")?; let ty = HOLE_TYPES.get(*kind).copied().unwrap_or("simple"); // One hole per picked position. let mut out = Vec::new(); for p in face_points(0) { let mut params = json!({"position": p, "diameter": diameter, "type": ty}); if !depth.trim().is_empty() { params["depth"] = json!(depth); } match *kind { 1 => { params["cb_diameter"] = json!(cb_diameter); params["cb_depth"] = json!(cb_depth); } 2 => { params["cs_diameter"] = json!(cs_diameter); params["cs_angle"] = json!(cs_angle); } _ => {} } out.push(("FusionHoleCommand".to_string(), params)); } return Ok(out); } Kind::Primitive { cmd, fields, operation } => { let mut p = serde_json::Map::new(); for (k, v) in fields { p.insert((*k).into(), json!(v)); } p.insert("operation".into(), json!(OPS.get(*operation).copied().unwrap_or("new"))); (cmd, Value::Object(p)) } Kind::Combine { operation, keep_tools } => { need(0, "the target body")?; need(1, "tool bodies")?; let target = body_names(0).into_iter().next().unwrap_or_default(); let tools: Vec = body_names(1).into_iter().filter(|n| *n != target).collect(); ( "FusionCombineCommand", json!({"target": target, "tools": tools, "operation": OPS.get(*operation).copied().unwrap_or("join"), "keep_tools": keep_tools}), ) } Kind::EditParam { name, expr } => ("ChangeParameterCommand", json!({"name": name, "expression": expr})), 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()), }; let mut params = params; if let Value::Object(m) = &mut params { for (k, v) in &d.extra { m.entry(k.clone()).or_insert_with(|| v.clone()); } } Ok(vec![(cmd.to_string(), params)]) } /// Sketch curve ids picked in an input. fn curve_ids(d: &Dialog, i: usize) -> Vec { sels(d, i).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect() } /// The features that made these bodies (what patterns and mirrors copy). fn source_features(s: &Session, bodies: &[String]) -> Vec { let st = s.model.state(); let mut features: Vec = Vec::new(); for n in bodies { if let Some(f) = st.body(n).and_then(|b| s.doc.feature(b.feature)).map(|f| f.name.clone()) && !features.contains(&f) { features.push(f); } } features } /// The sketch holding all these curve ids (the active sketch first, never `not`). fn curves_sketch(app: &SolveApp, ids: &[String], not: Option) -> Option { let st = app.session.model.state(); let has = |sid: u64| st.sketch(sid).is_some_and(|ss| ids.iter().all(|id| ss.sketch.curve_index(id).is_some())); if let Some(a) = app.session.active_sketch && Some(a) != not && has(a) { return Some(a); } st.sketches.iter().rev().map(|ss| ss.feature).find(|sid| Some(*sid) != not && has(*sid)) } /// An axis selection as a line: a point on it and its unit direction (origin axes, or a straight /// sketch line). pub fn axis_of(app: &SolveApp, sel: &Sel) -> Option<(Vec3, Vec3)> { match sel { Sel::Axis { name } => Some(( Vec3::ZERO, match name.as_str() { "X" => Vec3::X, "Y" => Vec3::Y, _ => Vec3::Z, }, )), Sel::SketchCurve { id } => { let st = app.session.model.state(); let sid = curves_sketch(app, std::slice::from_ref(id), None)?; let ss = st.sketch(sid)?; let c = ss.sketch.curves.get(ss.sketch.curve_index(id)?)?; let solvecraft_engine::sketch::CurveKind::Line { a, b } = c.kind else { return None }; let (pa, pb) = (ss.plane.to_world(ss.sketch.point(a)?), ss.plane.to_world(ss.sketch.point(b)?)); Some((pa, (pb - pa).normalized()?)) } _ => None, } } /// Which profiles of a sketch a profile selection means. fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &ProfileSel) -> Vec { let ps = &ss.profiles; match sel { ProfileSel::All => (0..ps.len()).collect(), ProfileSel::Indices { indices } => indices.iter().copied().filter(|i| *i < ps.len()).collect(), ProfileSel::Curves { loops } => loops .iter() .filter_map(|l| { let mut want: Vec<&String> = l.iter().collect(); want.sort(); ps.iter().position(|p| { let mut have: Vec<&String> = p.outer_curves.iter().collect(); have.sort(); have == want }) }) .collect(), ProfileSel::Points { points } => points.iter().filter_map(|q| ps.iter().position(|p| p.region.contains(*q))).collect(), } } /// The edge of a visible body through (or nearest) a point. 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 { let m = b.mesh(); for (i, e) in m.edges.iter().enumerate() { let d = e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); if best.as_ref().is_none_or(|(bd, _)| d < *bd) { best = Some((d, Sel::Edge { body: b.name.clone(), index: i, point: p })); } } } best.map(|x| x.1) } /// The body face containing a point (nearest triangle). 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 { let m = b.mesh(); for (t, f) in m.triangles.iter().zip(&m.tri_face) { let Some([a, bb, c]) = m.tri(t) else { continue }; let d = crate::preview::point_triangle_dist(p, a, bb, c); if best.as_ref().is_none_or(|(bd, _)| d < *bd) { best = Some((d, Sel::Face { body: b.name.clone(), index: *f as usize, point: p })); } } } best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1) } fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { match pl { PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }), PlaneRef::Custom { plane } => face_sel(s, plane.origin), _ => None, } } fn op_index(o: &Operation) -> usize { match o { Operation::NewBody => 0, Operation::Join => 1, Operation::Cut => 2, Operation::Intersect => 3, } } /// The origin axis a unit direction runs along (either way), if any. fn world_axis(d: Vec3) -> Option { [("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)] .into_iter() .find(|(_, a)| d.cross(*a).len() < 1e-9 && d.dot(*a) > 0.0) .map(|(n, _)| n.to_string()) } fn pt3(v: Vec3) -> Value { json!([v.x, v.y, v.z]) } /// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just /// before the feature, so its references show on the geometry they refer to. pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { let s = &app.session; let f = s.doc.feature(id)?.clone(); let st = s.model.state(); let start = |cmd: &str| Dialog::for_command(app, cmd); let mut d = match &f.kind { FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => { let mut d = start("Extrude")?; d.kind = Kind::Extrude { distance: extent.distance.clone(), direction: match (extent.direction, &extent.distance2) { (_, Some(_)) => TWO_SIDES, (Direction::Positive, None) => 0, (Direction::Negative, None) => 1, (Direction::Symmetric, None) => 2, }, operation: op_index(operation), auto_op: false, distance2: extent.distance2.clone().unwrap_or_else(|| "10 mm".into()), taper: extent.taper.clone().unwrap_or_default(), }; let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); if let Some(inp) = d.inputs.first_mut() { inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); } for (k, v) in [ ("start_offset", extent.start_offset.as_ref().map(|x| json!(x))), ("through_all", extent.through_all.then_some(json!(true))), ("targets", (!targets.is_empty()).then(|| json!(targets))), ] { if let Some(v) = v { d.extra.insert(k.into(), v); } } d } FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets } => { let mut d = start("Revolve")?; d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) }; let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); if let Some(inp) = d.inputs.get_mut(0) { inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); } let ax = match axis { AxisRef::World { axis } => Some(Sel::Axis { name: axis.to_ascii_uppercase() }), AxisRef::SketchLine { curve } => Some(Sel::SketchCurve { id: curve.clone() }), _ => None, }; if let Some(inp) = d.inputs.get_mut(1) { inp.items = ax.into_iter().collect(); } if !targets.is_empty() { d.extra.insert("targets".into(), json!(targets)); } d } FeatureKind::Fillet { edges, radius, .. } | FeatureKind::Chamfer { edges, distance: radius, .. } => { let chamfer = matches!(f.kind, FeatureKind::Chamfer { .. }); let mut d = start(if chamfer { "FusionChamferCommand" } else { "FusionFilletEdgesCommand" })?; d.kind = Kind::Fillet { radius: radius.clone(), chamfer, chain: false }; if let Some(inp) = d.inputs.first_mut() { inp.items = edges.iter().filter_map(|p| edge_sel(s, *p)).collect(); } d } FeatureKind::Shell { faces, thickness, .. } => { let mut d = start("FusionShellBodyCommand")?; d.kind = Kind::Shell { thickness: thickness.clone() }; if let Some(inp) = d.inputs.first_mut() { inp.items = faces.iter().filter_map(|p| face_sel(s, *p)).collect(); } d } FeatureKind::Draft { faces, angle, neutral, pull, .. } => { let mut d = start("FusionDraftCommand")?; d.kind = Kind::Draft { angle: angle.clone() }; if let Some(inp) = d.inputs.get_mut(0) { inp.items = faces.iter().filter_map(|p| face_sel(s, *p)).collect(); } if let Some(inp) = d.inputs.get_mut(1) { inp.items = plane_sel(s, neutral).into_iter().collect(); } d.extra.insert("pull".into(), pt3(*pull)); d } FeatureKind::Hole { position, direction, diameter, depth, hole, .. } => { let mut d = start("FusionHoleCommand")?; let mut k = hole_defaults(); if let Kind::Hole { diameter: dia, depth: dep, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } = &mut k { *dia = diameter.clone(); *dep = depth.clone().unwrap_or_default(); match hole { HoleKind::Counterbore { cb_diameter: a, cb_depth: b } => { *kind = 1; *cb_diameter = a.clone(); *cb_depth = b.clone(); } HoleKind::Countersink { cs_diameter: a, cs_angle: b } => { *kind = 2; *cs_diameter = a.clone(); *cs_angle = b.clone(); } HoleKind::Drilled { tip_angle } => { d.extra.insert("tip_angle".into(), json!(tip_angle)); } HoleKind::Simple => {} } } d.kind = k; if let Some(inp) = d.inputs.first_mut() { inp.items = vec![face_sel(s, *position).unwrap_or(Sel::Face { body: String::new(), index: 0, point: *position })]; } d.extra.insert("direction".into(), pt3(*direction)); d } FeatureKind::Box { corner, length, width, height, operation } => { let mut d = start("PrimitiveBox")?; d.kind = Kind::Primitive { cmd: "PrimitiveBox", fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())], operation: op_index(operation), }; d.extra.insert("corner".into(), pt3(*corner)); d } FeatureKind::Cylinder { base, axis, radius, height, operation } => { let mut d = start("PrimitiveCylinder")?; d.kind = Kind::Primitive { cmd: "PrimitiveCylinder", fields: vec![("radius", radius.clone()), ("height", height.clone())], operation: op_index(operation), }; d.extra.insert("base".into(), pt3(*base)); d.extra.insert("axis".into(), pt3(*axis)); d } FeatureKind::Sphere { center, radius, operation } => { let mut d = start("PrimitiveSphere")?; d.kind = Kind::Primitive { cmd: "PrimitiveSphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) }; d.extra.insert("center".into(), pt3(*center)); d } FeatureKind::Torus { center, major, minor, operation } => { let mut d = start("PrimitiveTorus")?; d.kind = Kind::Primitive { cmd: "PrimitiveTorus", fields: vec![("major", major.clone()), ("minor", minor.clone())], operation: op_index(operation), }; d.extra.insert("center".into(), pt3(*center)); d } FeatureKind::Combine { target, tools, operation, keep_tools } => { let mut d = start("FusionCombineCommand")?; d.kind = Kind::Combine { operation: op_index(operation).max(1), keep_tools: *keep_tools }; if let Some(inp) = d.inputs.get_mut(0) { inp.items = vec![Sel::Body { name: target.clone() }]; } if let Some(inp) = d.inputs.get_mut(1) { inp.items = tools.iter().map(|n| Sel::Body { name: n.clone() }).collect(); } d } FeatureKind::Mirror { features, plane, .. } => { let mut d = start("MirrorCommand")?; let ids: Vec = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect(); if let Some(inp) = d.inputs.get_mut(0) { inp.items = st.bodies.iter().filter(|b| ids.contains(&b.feature)).map(|b| Sel::Body { name: b.name.clone() }).collect(); } if let Some(inp) = d.inputs.get_mut(1) { inp.items = plane_sel(s, plane).into_iter().collect(); } d } FeatureKind::Pattern { features, pattern, .. } => { let ids: Vec = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect(); let bodies: Vec = st.bodies.iter().filter(|b| ids.contains(&b.feature)).map(|b| Sel::Body { name: b.name.clone() }).collect(); let mut d = match pattern { PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 } => { let mut d = start("PatternRectangular")?; d.kind = Kind::PatternRect { count: count1.clone(), spacing: spacing1.clone(), count2: count2.clone().unwrap_or_else(|| "1".into()), spacing2: spacing2.clone().unwrap_or_else(|| "20 mm".into()), }; // Directions along the origin axes show as those axes; others are kept as is. for (i, (key, dir)) in [("dir1", Some(*dir1)), ("dir2", *dir2)].into_iter().enumerate() { let Some(dir) = dir else { continue }; match world_axis(dir) { Some(name) => { if let Some(inp) = d.inputs.get_mut(i + 1) { inp.items = vec![Sel::Axis { name }]; } } None => { d.extra.insert(key.into(), pt3(dir)); } } } d } PatternKind::Circular { origin, axis, count, angle } => { let mut d = start("PatternCircular")?; d.kind = Kind::PatternCirc { count: count.clone(), angle: angle.clone() }; match world_axis(*axis).filter(|_| origin.len() < 1e-9) { Some(name) => { if let Some(inp) = d.inputs.get_mut(1) { inp.items = vec![Sel::Axis { name }]; } } None => { d.extra.insert("axis".into(), json!({"origin": pt3(*origin), "dir": pt3(*axis)})); } } d } // No dialog for a path pattern yet: edit it with timeline.redefine. PatternKind::Path { .. } => return None, }; if let Some(inp) = d.inputs.first_mut() { inp.items = bodies; } d } FeatureKind::Loft { sections, operation, targets } => { let mut d = start("SolidLoft")?; d.kind = Kind::Loft { operation: op_index(operation) }; if let Some(inp) = d.inputs.first_mut() { inp.items = sections .iter() .flat_map(|sec| { let idx = st.sketch(sec.sketch).map(|ss| profile_indices(ss, &sec.profiles)).unwrap_or_default(); idx.into_iter().map(move |index| Sel::Profile { sketch: sec.sketch, index }) }) .collect(); } if !targets.is_empty() { d.extra.insert("targets".into(), json!(targets)); } d } FeatureKind::Sweep { sketch, profiles, path, operation, targets, .. } => { let mut d = start("Sweep")?; d.kind = Kind::Sweep { operation: op_index(operation) }; let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); if let Some(inp) = d.inputs.get_mut(0) { inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); } if let Some(inp) = d.inputs.get_mut(1) { inp.items = path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(); } if !targets.is_empty() { d.extra.insert("targets".into(), json!(targets)); } d } FeatureKind::Move { bodies, translate, rotate_axis, angle } => { let mut d = start("FusionMoveCommand")?; let [x, y, z] = translate.clone(); d.kind = Kind::Move { x, y, z }; if let Some(inp) = d.inputs.first_mut() { inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect(); } if let Some(a) = rotate_axis { d.extra.insert("axis".into(), pt3(*a)); } if let Some(a) = angle { d.extra.insert("angle".into(), json!(a)); } d } _ => return None, }; d.editing = Some((id, marker)); d.active = 0; d.error = None; Some(d) }