//! 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, Stroke, vec2}; use serde_json::{Value, json}; use solidcraft_engine::Sel; use solidcraft_engine::Session; use solidcraft_engine::doc::expr::Kind as ValueKind; use solidcraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel, ThinSide}; use solidcraft_engine::geom::{Aabb3, 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; pub(crate) const OPS: [&str; 4] = ["new", "join", "cut", "intersect"]; pub(crate) const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"]; const DIRS: [&str; 4] = ["positive", "negative", "symmetric", "positive"]; /// The "Two sides" direction (a second distance the other way). pub const TWO_SIDES: usize = 3; /// Thin Extrude wall locations, as `solid.extrude` names them, and their labels. const WALL_SIDES: [&str; 4] = ["center", "inside", "outside", "two_sides"]; const WALL_LABELS: [&str; 4] = ["Center", "Inside", "Outside", "Two Sides"]; /// What an extrude's first input picks: profiles or planar faces, or curves too when thin. const EXTRUDE_ACCEPT: Accept = PROFILES | PLANAR_FACES; const THIN_ACCEPT: Accept = PROFILES | CURVES; 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, /// Into a body: cut; additions: join when a solid exists, otherwise new. /// until the user picks one. auto_op: bool, /// The other side's distance (two sides). distance2: String, /// Taper angle (empty or zero: none). taper: String, /// An explicitly authored zero taper is still a model parameter. taper_present: bool, /// Optional distance beyond the selected end object. to_offset: String, /// Start offset from the profile plane (empty: start on the profile plane). start: String, /// Extent type All (through everything) instead of a distance. all: bool, /// Thin Extrude: a wall along the picked curves (or profiles' outlines). thin: bool, /// The wall thickness (inside, with Two Sides). wall: String, /// Wall location: an index into [`WALL_SIDES`]. wall_side: usize, /// The thickness outside, with Two Sides. wall2: String, }, Revolve { angle: String, operation: usize, /// An arbitrary stored line until the user deliberately picks a replacement. axis: Option, }, Fillet { radius: String, chamfer: bool, chain: bool, /// Chamfer type: 0 equal distance, 1 two distances, 2 distance and angle; with the /// second distance, the angle and which face the first distance is on. ctype: usize, distance2: String, angle: String, flip: 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, }, /// A plane perpendicular to one selected path at a fractional distance. PathPlane { t: String, sketch: Option, }, /// 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, /// Boundary Fill: the cells the tool bodies make (a point inside, a description, filled /// or not), found again when the tools change. BoundaryFill { operation: usize, cells: Vec<(Vec3, String, bool)>, of: Vec, }, /// 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, }, /// 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/Copy (see `dialogs_move`): the move type, X/Y/Z distances (Point to Position: the /// target), X/Y/Z angles (Free Move) and the angle (Rotate). Move { mode: usize, x: String, y: String, z: String, rx: String, ry: String, rz: String, angle: String, }, Hole { diameter: String, depth: String, kind: usize, cb_diameter: String, cb_depth: String, cs_diameter: String, cs_angle: String, opts: HoleOpts, }, Primitive { cmd: &'static str, fields: Vec<(&'static str, String)>, operation: usize, /// Where it goes: a picked point on a face or vertex, with the face's normal when the /// face is planar. `None` keeps the command's own placement (the origin, or the edited /// feature's). at: Option<(Vec3, Option)>, }, 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 { features: Vec, with: Vec, fail: Vec, }, /// Joints and components (`dialogs_assembly`). Assembly(crate::dialogs_assembly::Asm), /// Sheet metal (`dialogs_sheet`). Sheet(crate::dialogs_sheet::Sm), /// Surface tools and Split Face (`dialogs_surface`). Surface(crate::dialogs_surface::Surf), /// Plastic features (`dialogs_plastic`). Plastic(crate::dialogs_plastic::Pl), /// Appearance (`dialogs_appearance`). Appearance(crate::dialogs_appearance::Ap), /// Contact sets, motion studies, exploded views, configurations (`dialogs_motion`). Motion(crate::dialogs_motion::Mo), /// Insert Part from the standard parts library (`dialogs_parts`). Part(crate::dialogs_parts::Pt), /// Edit Text: a sketch text's content and placement (`sketch_text`). Text(Box), TextMove(crate::sketch_text_move::TextMove), /// Sketch-space modify tools: scale and offset, and later fillet, chamfer, the patterns, /// mirror and move (`sketch_modify_ui`). SketchModify(crate::sketch_modify_ui::Mod), } /// The Single hole placement's input: places on planar faces. A pick on or near a sketch point /// that lies on the face (a circle's centre) is taken at that point, as Multiple picks it. /// Holes go on any face (flat or curved; the engine drills against the face's normal there). const HOLE_FACES: Accept = PLANAR_FACES | FACES | selection::POINTS; /// The rest of the Hole dialog: placement, extents, tap type and drill point. #[derive(Clone, Debug, PartialEq)] pub struct HoleOpts { /// Placement at several sketch points instead of one face point. pub multiple: bool, /// Extents: through all instead of a distance. pub all: bool, /// Drill point: an angled tip instead of a flat bottom. pub angled: bool, pub tip_angle: String, /// Tap type: tapped (a cosmetic thread) instead of simple. pub tapped: bool, pub thread: String, } impl Default for HoleOpts { fn default() -> Self { HoleOpts { multiple: false, all: false, angled: true, tip_angle: "118 deg".into(), tapped: false, thread: "M5".into() } } } #[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)>, /// A user pick or clear makes the profile selector explicit, even if all are reselected. pub profile_selection_changed: bool, /// Give the on-canvas value box the keyboard (and select its text) on the next frame. pub focus: bool, /// Folded to its header (the "−" glyph). pub collapsed: bool, /// Parameters the dialog doesn't show but the command needs (kept when editing). pub extra: serde_json::Map, /// What the inputs were when OK was refused: `error` stays only while they still are. pub refused: Option, /// Create Sketch opened by a sketch tool's shortcut: that tool starts once the sketch is open. pub then_start: Option<&'static str>, } /// The commands a dialog's inputs make (or why they make none), its picks, and the design's /// revision (a parameter the commands name can change without changing them). #[derive(Clone, Debug, PartialEq)] pub struct Refused { commands: Result, String>, picks: Vec, revision: u64, } impl Refused { fn now(app: &SolveApp, d: &Dialog) -> Refused { Refused { commands: apply_commands(app, d), picks: d.inputs.clone(), revision: app.session.revision } } } /// 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 { .. } => a & selection::FEATURES != 0, // Sketch points go into a sketch command's own inputs (Entities, a base point), so a // box selection and a pre-selection reach them the way a click already does. Sel::SketchPoint { .. } => a & (selection::POINTS | selection::SKETCH_POINTS) != 0, Sel::SketchConstraint { .. } | Sel::Occurrence { .. } | Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => false, } } impl Dialog { /// Show why OK (or starting the sketch) was refused, for as long as the inputs stay as they are. fn refuse(&mut self, app: &SolveApp, error: String) { self.refused = Some(Refused::now(app, self)); self.error = Some(error); } pub(crate) fn new(kind: Kind, inputs: Vec) -> Dialog { Dialog { kind, inputs, active: 0, error: None, editing: None, profile_selection_changed: false, focus: true, collapsed: false, extra: serde_json::Map::new(), refused: None, then_start: None, } } pub fn for_command(app: &SolveApp, id: &str) -> Option { let s = &app.session; let has_bodies = !s.world_state().bodies.is_empty(); let mut d = match id { "sketch.create" => Dialog::new(Kind::Sketch, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]), "solid.extrude" => Dialog::new( Kind::Extrude { distance: "10 mm".into(), direction: 0, operation: usize::from(has_bodies), auto_op: true, distance2: "10 mm".into(), taper: "0 deg".into(), taper_present: false, to_offset: String::new(), start: String::new(), all: false, thin: false, wall: "2 mm".into(), wall_side: 0, wall2: "1 mm".into(), }, vec![SelInput::new("Profiles", EXTRUDE_ACCEPT, true)], ), "solid.revolve" => Dialog::new( Kind::Revolve { angle: "360 deg".into(), operation: 0, axis: None }, vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Axis", AXES, false)], ), "solid.fillet" => Dialog::new(fillet_kind("2 mm", false), vec![SelInput::new("Edges", EDGES | FACES, true)]), "solid.chamfer" => Dialog::new(fillet_kind("1 mm", true), vec![SelInput::new("Edges", EDGES | FACES, true)]), "solid.shell" => { let mut d = Dialog::new(Kind::Shell { thickness: "2 mm".into() }, vec![SelInput::new("Faces/Body", FACES, true)]); // Fusion opens faces joined smoothly to the picked ones too. d.extra.insert("tangent_chain".into(), json!(true)); d } "solid.draft" => Dialog::new( Kind::Draft { angle: "5 deg".into() }, vec![SelInput::new("Faces", FACES, true), SelInput::new("Neutral plane", PLANES | PLANAR_FACES, false)], ), "solid.mirror" => { Dialog::new(Kind::Mirror, vec![SelInput::new("Objects", BODIES, true), SelInput::new("Mirror Plane", PLANES | PLANAR_FACES, false)]) } "solid.rib" | "solid.web" => Dialog::new( Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false }, vec![SelInput::new("Curves", CURVES, true)], ), "solid.emboss" => Dialog::new( Kind::Emboss { depth: "1 mm".into(), deboss: false }, vec![SelInput::new("Profiles", PROFILES, true), SelInput::new("Target Face (optional)", FACES, false)], ), "solid.replace_face" => Dialog::new( Kind::ReplaceFace, vec![SelInput::new("Faces", PLANAR_FACES, true), SelInput::new("Target", PLANES | PLANAR_FACES, false)], ), "solid.boundary_fill" => { Dialog::new(Kind::BoundaryFill { operation: 0, cells: Vec::new(), of: Vec::new() }, vec![SelInput::new("Tools", BODIES, true)]) } "solid.align" => Dialog::new( Kind::Align { flip: false }, vec![ SelInput::new("Bodies", BODIES, true), SelInput::new("From", FACES | EDGES | selection::VERTICES, false), SelInput::new("To", FACES | EDGES | selection::VERTICES, false), ], ), "solid.remove" => Dialog::new(Kind::Remove, vec![SelInput::new("Bodies", BODIES, true)]), "solid.pattern.path" => Dialog::new( Kind::PathPattern { count: "4".into(), spacing: "20 mm".into() }, vec![SelInput::new("Objects", BODIES, true), SelInput::new("Path", CURVES, true)], ), "solid.pipe" => Dialog::new(Kind::Pipe { diameter: "5 mm".into(), wall: String::new() }, vec![SelInput::new("Path", CURVES, true)]), "solid.bounding_solid" => Dialog::new(Kind::Stock { margin: "2 mm".into() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)]), "construct.plane.offset" => { Dialog::new(Kind::OffsetPlane { offset: "10 mm".into() }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]) } "construct.plane.along_path" => { Dialog::new(Kind::PathPlane { t: "0.5".into(), sketch: None }, vec![SelInput::new("Path", CURVES | EDGES, false)]) } "construct.plane.at_angle" => Dialog::new( Kind::AnglePlane { angle: "45 deg".into() }, vec![SelInput::new("Plane", PLANES, false), SelInput::new("Axis", AXES, false)], ), "solid.split_body" => { Dialog::new(Kind::Split, vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting tool", PLANES | FACES, false)]) } "solid.scale" => Dialog::new(Kind::Scale { factor: "2".into() }, vec![SelInput::new("Bodies", BODIES, true)]), "solid.offset_face" => Dialog::new(Kind::OffsetFaces { distance: "2 mm".into() }, vec![SelInput::new("Faces", PLANAR_FACES, true)]), "solid.thread" => { Dialog::new(Kind::Thread { designation: String::new(), length: String::new() }, vec![SelInput::new("Cylindrical face", FACES, false)]) } "material.assign" => Dialog::new(Kind::Material { index: 1 }, vec![SelInput::new("Bodies", BODIES, true)]), "inspect.section" => { Dialog::new(Kind::Section { offset: "0 mm".into(), flip: false }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]) } "inspect.measure" => { Dialog::new(Kind::Measure { result: None, of: Vec::new() }, vec![SelInput::new("Items", FACES | EDGES | selection::VERTICES, true)]) } "solid.pattern.rectangular" => 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 | EDGES, false), SelInput::new("Direction 2", AXES | EDGES, false), ], ), "solid.pattern.circular" => Dialog::new( Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() }, vec![SelInput::new("Objects", BODIES, true), SelInput::new("Axis", AXES, false)], ), "solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]), "solid.sweep" => Dialog::new( Kind::Sweep { operation: usize::from(has_bodies) }, vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)], ), "solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)), "solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]), "solid.box" => Dialog::new( Kind::Primitive { cmd: "solid.box", fields: vec![("length", length_default(s, "20")), ("width", length_default(s, "20")), ("height", length_default(s, "20"))], operation: 0, at: None, }, vec![], ), "solid.cylinder" => Dialog::new( Kind::Primitive { cmd: "solid.cylinder", fields: vec![("diameter", length_default(s, "20")), ("height", length_default(s, "20"))], operation: 0, at: None, }, vec![], ), "solid.sphere" => Dialog::new( Kind::Primitive { cmd: "solid.sphere", fields: vec![("diameter", length_default(s, "20"))], operation: 0, at: None }, vec![], ), "solid.torus" => Dialog::new( Kind::Primitive { cmd: "solid.torus", fields: vec![("major", length_default(s, "20")), ("minor", length_default(s, "5"))], operation: 0, at: None, }, vec![], ), "solid.coil" => Dialog::new( Kind::Primitive { cmd: "solid.coil", fields: vec![ ("diameter", length_default(s, "40")), ("revolutions", "4".into()), ("pitch", length_default(s, "8")), ("section_size", length_default(s, "3")), ], operation: 0, at: None, }, vec![], ), "solid.combine" => Dialog::new( Kind::Combine { operation: 1, keep_tools: false }, vec![SelInput::new("Target Body", BODIES, false), SelInput::new("Tool Bodies", BODIES, true)], ), "parameters.change" => Dialog::new(Kind::Params { new_name: String::new(), new_expr: String::new() }, vec![]), _ => { let (kind, inputs) = crate::dialogs_assembly::start(app, id) .or_else(|| crate::dialogs_sheet::start(app, id)) .or_else(|| crate::dialogs_surface::start(app, id)) .or_else(|| crate::dialogs_plastic::start(app, id)) .or_else(|| crate::dialogs_appearance::start(app, id)) .or_else(|| crate::dialogs_motion::start(app, id)) .or_else(|| crate::dialogs_parts::start(app, id)) .or_else(|| crate::sketch_text::start(app, id)) .or_else(|| crate::sketch_text_move::start(app, id)) .or_else(|| crate::sketch_modify_ui::start(app, id))?; Dialog::new(kind, inputs) } }; // Patterns of features picked in the timeline: the objects are features. if is_pattern(&d.kind) && s.selection.iter().any(|x| matches!(x, Sel::Feature { .. })) && let Some(inp) = d.inputs.first_mut() { inp.accept = selection::FEATURES; inp.label = objects_label(inp.accept); } // 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. (Edit Text's selection is the // text it edits, not its path.) let preselection: &[Sel] = if matches!(d.kind, Kind::Text(_)) { &[] } else { &s.selection }; for sel in preselection { 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; } // Planar-only inputs take planar faces only. let planar_ok = match sel { Sel::Face { body, index, .. } => planar_face(s, body, *index).is_some(), _ => true, }; let takes = |i: &SelInput| { fits(i.accept, sel) && (i.multi || i.items.is_empty()) && (planar_ok || i.accept & FACES != 0 || !matches!(sel, Sel::Face { .. })) }; if let Some(inp) = d.inputs.iter_mut().find(|i| takes(i)) { // Fillets and chamfers take an edge with its tangent chain, as a pick does. let picked = match (&d.kind, sel) { (Kind::Fillet { chain: true, .. }, Sel::Edge { body, index, .. }) => s .model .state() .body(body) .map(|b| { let m = b.mesh(); 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::>() }) .unwrap_or_else(|| vec![sel.clone()]), (Kind::Measure { .. }, _) => vec![crate::viewport::measurement_selection(s, sel)], _ if inp.multi => crate::viewport::grouped_selection(s, sel), _ => vec![sel.clone()], }; for x in picked { if !inp.items.iter().any(|y| selection::same_item(y, &x)) { inp.items.push(x); } } } } // A primitive goes where a face or vertex is picked, standing on a planar face. if let Kind::Primitive { at, .. } = &mut d.kind { *at = s.selection.iter().find_map(|sel| match sel { Sel::Face { body, index, point } => Some((*point, planar_face(s, body, *index).map(|(_, n)| n))), Sel::Vertex { point, .. } => Some((*point, None)), _ => None, }); } // With nothing pre-selected, a single profile in the sketch being used is taken (#684: // not one a feature already builds from). 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 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::confirm_delete_many(vec![feature], with, fail) } pub fn confirm_delete_many(features: Vec, with: Vec, fail: Vec) -> Dialog { Dialog::new(Kind::ConfirmDelete { features, 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 { match &self.kind { Kind::Assembly(k) => return k.previews(), Kind::Sheet(k) => return k.previews(), Kind::Surface(_) => return true, Kind::SketchModify(k) => return k.previews(), Kind::Plastic(k) => return k.previews(), Kind::Part(_) => return true, _ => {} } 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::PathPlane { .. } | Kind::Move { .. } | Kind::Hole { .. } | Kind::Primitive { .. } | Kind::Combine { .. } | Kind::BoundaryFill { .. } ) } /// 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 { to_offset, .. } if to_object_on(&self.inputs) => ("To Offset", ValueKind::Length, to_offset), Kind::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance), Kind::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle), Kind::Fillet { radius, chamfer, .. } => { let label = if *chamfer { "Distance" } else if matches!(self.extra.get("type").and_then(Value::as_str), Some("curvature" | "g2")) { "Equivalent Radius" } else { "Radius" }; (label, ValueKind::Length, radius) } Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness), Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle), Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter), // A move's main value follows its type: a distance along X for the ones that // translate, the angle for Rotate. Point to Point is two picks with nothing to type. Kind::Move { mode, x, angle, .. } => match *mode { crate::dialogs_move::ROTATE => ("Angle", ValueKind::Angle, angle), crate::dialogs_move::POINT_TO_POINT => return None, _ => ("X", ValueKind::Length, x), }, 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::PathPlane { t, .. } => ("Position (0 to 1)", ValueKind::Unitless, t), 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), Kind::Sheet(k) => return k.primary(&self.inputs), Kind::Surface(k) => return k.primary(), Kind::Plastic(k) => return k.primary(), Kind::SketchModify(k) => return k.primary(), _ => return None, }) } /// Dialogs holding a table get the wide limit. pub fn is_wide(&self) -> bool { matches!(self.kind, Kind::Params { .. }) || matches!(&self.kind, Kind::Motion(k) if k.wide()) } 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.candidate_on(s, h, None) } /// The face under the cursor (the nearest face the pick ray meets, first among `hits`), looked /// up once for all the sketch points among `hits` when the active input snaps face places to /// them (the Single hole's); `None` otherwise. pub(crate) fn face_snap(&self, s: &Session, hits: &[Hit]) -> Option { if !self.active_input().is_some_and(|i| snaps_to_points(i.accept)) { return None; } let Some(Hit::Face { body, index, .. }) = hits.iter().find(|h| matches!(h, Hit::Face { .. })) else { return None }; let st = s.world_state(); let near: Vec = hits .iter() .filter_map(|h| match h { Hit::SketchPoint { sketch, at, .. } => st.sketch(*sketch).map(|ss| ss.plane.to_world(*at)), _ => None, }) .collect(); if near.is_empty() { return None; } Some(FaceSnap::new(s, body, *index, &near)) } /// The selection a hit would add to the active input, given the face under the cursor /// ([`Dialog::face_snap`] of the same hits). A face place picked on or near a sketch point that /// lies on that face (a circle's centre) is taken at the point, as a points input picks it. pub(crate) fn candidate_on(&self, s: &Session, h: &Hit, snap: Option<&FaceSnap>) -> Option { if matches!(self.kind, Kind::SketchModify(_)) { return crate::sketch_modify_ui::candidate(self, s, h); } let inp = self.active_input()?; // Features are also picked on the canvas: a face stands for the feature that made it. if inp.accept == selection::FEATURES { return match h { Hit::Face { body, index, point } => feature_of_face(s, body, *index, *point).map(|id| Sel::Feature { id }), _ => None, }; } if snaps_to_points(inp.accept) && let Hit::SketchPoint { sketch, at, .. } = h { let snap = snap?; return snap.place_at(s.world_state().sketch(*sketch)?.plane.to_world(*at)); } // The snap looked the face under the cursor up already, planar or not. inp.accepts(h, |body, face| snap.and_then(|f| f.planar(body, face)).unwrap_or_else(|| 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) { if let Kind::PathPlane { sketch, .. } = &mut self.kind { *sketch = None; self.extra.remove("path"); } if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) { self.profile_selection_changed = true; } let measure = matches!(self.kind, Kind::Measure { .. }); let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel }; let chain = matches!(self.kind, Kind::Fillet { chain: true, .. }); let mut picked = if !measure && self.active_input().is_some_and(|i| i.multi) { crate::viewport::picked_selection(s, &sel) } else { 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.world_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, s: &Session, sels: Vec, add: bool) { if let Kind::PathPlane { sketch, .. } = &mut self.kind { *sketch = None; self.extra.remove("path"); } if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) { self.profile_selection_changed = true; } let measure = matches!(self.kind, Kind::Measure { .. }); let Some(inp) = self.inputs.get_mut(self.active) else { return }; if !add { inp.items.clear(); } for x in sels { let x = if measure { crate::viewport::measurement_selection(s, &x) } else { x }; if !fits(inp.accept, &x) { continue; } if !inp.multi { inp.items.clear(); inp.items.push(x); break; } if !inp.items.iter().any(|y| selection::same_item(y, &x)) { inp.items.push(x); } } } } /// Profiles of the sketch a feature would use, when there is just one: the active sketch's, else /// the last sketch's while no feature builds from it yet. A used profile (a cube's sketch on its /// bottom face) is only taken when it is picked. fn default_profiles(s: &Session) -> Vec { let st = s.world_state(); let sid = s.active_sketch.or_else(|| { s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id).filter(|&id| !profiles_used(s, 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(), } } /// Does an input take face places that snap to sketch points on the face (the Single hole's)? pub(crate) fn snaps_to_points(accept: Accept) -> bool { accept & selection::POINTS != 0 && accept & PLANAR_FACES != 0 } /// Which of a sketch's points a face place snaps to (the Single hole's): the points of geometry /// the sketch shows. Not generated offset nodes or a text outline's points, which are never drawn or picked on their own /// (as `view::sketch_point_visible` has it), nor projected points while the sketch hides /// projected geometry (Show Projected off), whose curves aren't picked either. Hidden sketches /// offer no points at all. Show Points doesn't count: it only hides the markers of the sketch /// being edited, and a finished sketch's circle centres are snapped to without markers. pub(crate) struct SnapPoints<'a> { /// Show Projected is off: no linked point is taken. hide_linked: bool, shown: solidcraft_engine::view::SketchPointVisibility<'a>, } impl<'a> SnapPoints<'a> { pub(crate) fn of(sk: &'a solidcraft_engine::sketch::Sketch) -> Self { Self { hide_linked: sk.view.hide_projected, shown: solidcraft_engine::view::SketchPointVisibility::new(sk) } } /// Does a face place snap to point `p` of the sketch? pub(crate) fn takes(&self, p: &solidcraft_engine::sketch::SPoint) -> bool { self.shown.takes(p) && !(self.hide_linked && p.link.is_some()) } } /// How near a sketch point must be to a face to lie on it: a micrometre, wherever the part is (a /// planar face's triangles lie in its plane), by the true distance ([`triangle_dist`]). const ON_FACE: f64 = 1e-3; /// The distance from `p` to the triangle `abc`, exactly: from its plane when `p` is over it, /// otherwise from its nearest side. (`preview::point_triangle_dist` takes a point just past a /// side for one over the triangle, with an absolute tolerance that is wide for a sliver.) fn triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { let sides = [(a, b), (b, c), (c, a)]; let to_sides = || sides.iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min); // However small the triangle: only one without area (or not finite) has no plane. let n = (b - a).cross(c - a); let len = n.len(); if !(len > 0.0 && len.is_finite()) { return to_sides(); } let unit = n / len; let h = (p - a).dot(unit); let q = p - unit * h; if sides.iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(unit) >= 0.0) { h.abs() } else { to_sides() } } /// The most point-in-triangle tests one pick makes for the snap. Points past it aren't snapped /// to (the face as picked still is), so no sketch, however dense, can stall the viewport. const SNAP_BUDGET: usize = 1 << 18; #[cfg(test)] thread_local! { /// A smaller [`SNAP_BUDGET`] for a test that runs it out. static TEST_BUDGET: std::cell::Cell> = const { std::cell::Cell::new(None) }; } /// Run the snap with a budget of `n` tests per pick on this thread (`None`: [`SNAP_BUDGET`]). #[cfg(test)] pub(crate) fn set_snap_budget(n: Option) { TEST_BUDGET.with(|b| b.set(n)); } fn snap_budget() -> usize { #[cfg(test)] if let Some(n) = TEST_BUDGET.with(std::cell::Cell::get) { return n; } SNAP_BUDGET } /// The face under the cursor, looked up once for all the sketch points one pick offers a face /// place to (the Single hole's snap): whether it is planar, its plane, and those of its /// triangles near the points on that plane, with their bounding boxes. A dense sketch over a /// finely tessellated part costs one planarity lookup and one pass over the body's triangles per /// pick; then a point off the plane is turned down at once, and a point on it is tested against /// the face's triangles near the cursor, within [`SNAP_BUDGET`] tests in all. pub(crate) struct FaceSnap { body: String, face: usize, /// A point of the plane, its unit normal, and how far a point on the face may be from it /// (the triangles' own distance plus [`ON_FACE`]); `None` when the face isn't planar. plane: Option<(Vec3, Vec3, f64)>, tris: Vec<([Vec3; 3], Aabb3)>, /// Point-in-triangle tests left for this pick. budget: std::cell::Cell, } impl FaceSnap { /// Face `face` of `body`, for sketch points at `near` (in the world). fn new(s: &Session, body: &str, face: usize, near: &[Vec3]) -> FaceSnap { let mut snap = FaceSnap { body: body.to_string(), face, plane: None, tris: Vec::new(), budget: std::cell::Cell::new(snap_budget()) }; let st = s.world_state(); let (Some((origin, normal)), Some(b)) = (planar_face(s, body, face), st.body(body)) else { return snap }; let m = b.mesh(); #[cfg(test)] SNAP_WORK.with(|w| w.set((w.get().0, w.get().1 + m.triangles.len()))); let tris: Vec<[Vec3; 3]> = m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face).filter_map(|(t, _)| m.tri(t)).collect(); // Anything within ON_FACE of a triangle is within this of the plane. let stray = tris.iter().flatten().map(|v| (*v - origin).dot(normal).abs()).fold(0.0, f64::max); let plane = (origin, normal, stray + ON_FACE); let mut region = Aabb3::EMPTY; for p in near.iter().filter(|p| near_plane(plane, **p)) { region.add(*p); } snap.tris = tris .into_iter() .map(|t| { let mut b = Aabb3::EMPTY; t.iter().for_each(|v| b.add(*v)); (t, b) }) .filter(|(_, b)| boxes_meet(b, ®ion, ON_FACE)) .collect(); snap.plane = Some(plane); snap } /// Is face `face` of `body` planar, when it is this snap's face? fn planar(&self, body: &str, face: usize) -> Option { (self.face == face && self.body == body).then_some(self.plane.is_some()) } /// The face place at `p` (one of the points the snap was made for), when `p` lies on the face. fn place_at(&self, p: Vec3) -> Option { let plane = self.plane?; if !(p.is_finite() && near_plane(plane, p)) { return None; } let at = Aabb3 { min: p, max: p }; for ([a, b, c], bb) in &self.tris { let left = self.budget.get(); if left == 0 { return None; } self.budget.set(left - 1); #[cfg(test)] SNAP_WORK.with(|w| w.set((w.get().0, w.get().1 + 1))); if boxes_meet(bb, &at, ON_FACE) && triangle_dist(p, *a, *b, *c) < ON_FACE { return Some(Sel::Face { body: self.body.clone(), index: self.face, point: p }); } } None } } /// Is `p` within reach of the plane (a point of it, unit normal, reach)? fn near_plane((origin, normal, reach): (Vec3, Vec3, f64), p: Vec3) -> bool { (p - origin).dot(normal).abs() <= reach } /// Do boxes `a` and `b` meet once `b` is grown by `pad` on every side? fn boxes_meet(a: &Aabb3, b: &Aabb3, pad: f64) -> bool { a.min.x <= b.max.x + pad && b.min.x - pad <= a.max.x && a.min.y <= b.max.y + pad && b.min.y - pad <= a.max.y && a.min.z <= b.max.z + pad && b.min.z - pad <= a.max.z } #[cfg(test)] thread_local! { /// Planar face lookups (each tessellates the body) and triangles the snap looked at, on this /// thread: tests bound the work of a pick with them. static SNAP_WORK: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) }; } /// (planar face lookups, triangles the snap looked at) on this thread since the last call. #[cfg(test)] pub(crate) fn take_snap_work() -> (usize, usize) { SNAP_WORK.with(|w| w.replace((0, 0))) } #[cfg(test)] mod face_snap_tests { use super::*; #[test] fn a_point_more_than_a_micrometre_off_a_sliver_is_off_the_face() { // A triangle with half-micrometre legs. Points 2 µm below its lower side and 1.6 µm past // its long side get past `preview::point_triangle_dist`'s inside test (an absolute // tolerance); the snap goes by the true distance and turns them down. Points on a side, // or half a micrometre beyond one, are on the face. let (a, b, c) = (Vec3::ZERO, Vec3::new(5e-4, 0.0, 0.0), Vec3::new(0.0, 5e-4, 0.0)); let out = Vec3::new(1.0, 1.0, 0.0) * std::f64::consts::FRAC_1_SQRT_2; let mid = Vec3::new(2.5e-4, 2.5e-4, 0.0); let off = [Vec3::new(2.5e-4, -2e-3, 0.0), Vec3::new(1.4e-3, 1.4e-3, 0.0)]; let on = [mid, mid + out * 5e-4, Vec3::new(1e-4, -5e-4, 0.0), Vec3::new(1e-4, 1e-4, 5e-4)]; let mut bb = Aabb3::EMPTY; [a, b, c].into_iter().for_each(|v| bb.add(v)); let snap = FaceSnap { body: "Body1".into(), face: 0, plane: Some((Vec3::ZERO, Vec3::Z, ON_FACE)), tris: vec![([a, b, c], bb)], budget: std::cell::Cell::new(SNAP_BUDGET), }; for p in off { assert!(crate::preview::point_triangle_dist(p, a, b, c) < ON_FACE, "the inside test alone takes {p:?}"); assert!(triangle_dist(p, a, b, c) > 1.5e-3, "{p:?}"); assert_eq!(snap.place_at(p), None, "{p:?}"); } for p in on { assert_eq!(snap.place_at(p), Some(Sel::Face { body: "Body1".into(), index: 0, point: p }), "{p:?}"); } } #[test] fn the_distance_to_a_tiny_triangle_is_from_its_plane_over_it() { // Legs of 20 nm: the triangle still has a plane, and a point just under a micrometre // above it is that far from it (not from its nearest side), so it is on the face. A // triangle without area is measured from its sides; nothing here panics on non-finite input. let (a, b, c) = (Vec3::ZERO, Vec3::new(2e-5, 0.0, 0.0), Vec3::new(0.0, 2e-5, 0.0)); let p = Vec3::new(5e-6, 5e-6, 0.99999e-3); assert!((triangle_dist(p, a, b, c) - 0.99999e-3).abs() < 1e-12, "{}", triangle_dist(p, a, b, c)); assert!((triangle_dist(Vec3::new(0.0, 3.0, 4.0), a, b, b) - 5.0).abs() < 1e-9, "a triangle without area"); let far = |d: f64| d.is_nan() || d >= ON_FACE; assert!(far(triangle_dist(Vec3::new(f64::NAN, 0.0, 0.0), a, b, c)), "a point that isn't finite is never near"); assert!(far(triangle_dist(p, a, b, Vec3::new(f64::INFINITY, 0.0, 0.0))), "nor is a triangle that isn't finite"); } } /// Does a feature build from this sketch's profiles (extrude, revolve, sweep, loft, emboss…)? fn profiles_used(s: &Session, sketch: u64) -> bool { s.doc.features.iter().any(|f| f.kind.profile_sels().iter().any(|(id, _)| *id == sketch)) } /// 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)> { #[cfg(test)] SNAP_WORK.with(|w| w.set((w.get().0 + 1, w.get().1))); let st = s.world_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. pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool { let r = ui.text_edit_singleline(s); publish_row(&r); crate::params_dialog::complete(ui, &r, s); r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) } /// A checkbox row. Its row handle (`dialog:`) lets a scenario click it by the label /// beside it, the way `field` already publishes one for a value field. pub(crate) fn check(ui: &mut egui::Ui, on: &mut bool) { let r = ui.checkbox(on, ""); publish_row(&r); } pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) { let row = ROW.with(|r| r.borrow().clone()); let selected = labels.get(*sel).copied().map(tr).unwrap_or(""); let r = egui::ComboBox::from_id_salt(id).selected_text(selected).width(FIELD_W).show_ui(ui, |ui| { for (i, l) in labels.iter().enumerate() { let item = ui.selectable_value(sel, i, tr(l)); crate::scenario::publish_handle(&format!("dialog:{row}:{l}"), item.rect.center()); } }); publish_row(&r.response); } /// A mode choice as a row of icon buttons rather than a drop-down: every mode is on screen at /// once, each drawn as the shape it makes, with its name as the tooltip. `labels` and `icons` /// run in step; a mode with no icon gets the fallback square. Publishes the handles a combo /// does, `dialog::