//! 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 solvecraft_engine::Sel; use solvecraft_engine::Session; use solvecraft_engine::doc::expr::Kind as ValueKind; use solvecraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, 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; 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; 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, }, 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, }, /// 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, }, 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, }, /// 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(crate::sketch_text::Tx), } /// 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, } /// 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, Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } | Sel::Occurrence { .. } | Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => false, } } impl Dialog { 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(), } } 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, }, vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, 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)], ), "SurfaceSculpt" => { 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.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", PLANAR_FACES, true)]), "solid.box" => Dialog::new( Kind::Primitive { cmd: "solid.box", fields: vec![("length", "20".into()), ("width", "20".into()), ("height", "20".into())], operation: 0, }, vec![], ), "solid.cylinder" => Dialog::new( Kind::Primitive { cmd: "solid.cylinder", fields: vec![("diameter", "20".into()), ("height", "20".into())], operation: 0 }, vec![], ), "solid.sphere" => Dialog::new(Kind::Primitive { cmd: "solid.sphere", fields: vec![("diameter", "20".into())], operation: 0 }, vec![]), "solid.torus" => { Dialog::new(Kind::Primitive { cmd: "solid.torus", fields: vec![("major", "20".into()), ("minor", "5".into())], operation: 0 }, vec![]) } "solid.coil" => Dialog::new( Kind::Primitive { cmd: "solid.coil", fields: vec![("diameter", "40".into()), ("revolutions", "4".into()), ("pitch", "8".into()), ("section_size", "3".into())], operation: 0, }, 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))?; 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. 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; } // 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); } } } } // 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 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 { match &self.kind { Kind::Assembly(k) => return k.previews(), Kind::Sheet(k) => return k.previews(), Kind::Surface(_) => return true, 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::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, .. } => (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 { x, .. } => ("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::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(), _ => 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 { 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, }; } inp.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) { 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::grouped_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 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) && !inp.items.iter().any(|y| selection::same_item(y, &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.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)); 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.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)) } 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); } thread_local! { /// The label of the dialog row being laid out (its widgets publish `dialog: