Files
solidcraft-upstream/crates/ui-egui/src/dialogs.rs
T
bherbruckandClaude Opus 5.5 5096bf2442 Dialogs for contact sets, motion studies, exploded views and configurations; Drive scenario
Wired to solvecraft-params' contact, motion, explode and configuration commands (verified under Xvfb: a study keyed 0 to 60 deg plays, an explosion previews and OK keeps it, Activate switches configurations, Check collisions lists overlaps):
- Contact Set: components to a new set; the sets with suppress and delete; contact on/off;
  Check collisions (contact.check).
- Motion Study: tracks of keyed joint values on a strip under the steps, keys added and edited
  at the playhead, a step slider with Play; the pose at the step is shown (motion.play on a
  scratch copy, as pending moves) and put back when the dialog closes.
- Exploded View: a scale slider showing the explosion live; OK creates and shows the view.
- Configurations: the table (rows by columns of parameters and suppressed features), cells
  edited in place, Activate per row, rows and columns added and removed.
The ASSEMBLE panel lists the motion and contact commands once they exist. Tests for the pose,
table and study mappings.

Scenario s13: Drive Joints takes typed input without a click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 12:02:55 +00:00

2666 lines
117 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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"];
/// 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 or zero: none).
taper: 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,
},
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<Value>,
of: Vec<Sel>,
},
/// 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,
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<String>,
fail: Vec<String>,
},
/// Joints and components (`dialogs_assembly`).
Assembly(crate::dialogs_assembly::Asm),
/// Sheet metal (`dialogs_sheet`).
Sheet(crate::dialogs_sheet::Sm),
/// Plastic features (`dialogs_plastic`).
Plastic(crate::dialogs_plastic::Pl),
/// Appearance (`dialogs_appearance`).
Appearance(crate::dialogs_appearance::Ap),
/// Contact sets, motion studies, exploded views, configurations (`dialogs_motion`).
Motion(crate::dialogs_motion::Mo),
}
/// 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<SelInput>,
pub active: usize,
pub error: Option<String>,
/// Editing an existing feature (its id, and the timeline marker to restore afterwards).
pub editing: Option<(u64, Option<usize>)>,
/// 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<String, Value>,
}
/// 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 { .. } => false,
}
}
impl Dialog {
pub(crate) fn new(kind: Kind, inputs: Vec<SelInput>) -> Dialog {
Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, collapsed: false, extra: serde_json::Map::new() }
}
pub fn for_command(app: &SolveApp, id: &str) -> Option<Dialog> {
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: "0 deg".into(),
start: String::new(),
all: false,
},
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
),
"Revolve" => Dialog::new(
Kind::Revolve { angle: "360 deg".into(), operation: 0 },
vec![SelInput::new("Profile", 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/Body", 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("Objects", 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("Face", PLANAR_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![],
),
"PrimitiveCoil" => Dialog::new(
Kind::Primitive {
cmd: "PrimitiveCoil",
fields: vec![("diameter", "40".into()), ("revolutions", "4".into()), ("pitch", "8".into()), ("section_size", "3".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![]),
_ => {
let (kind, inputs) = crate::dialogs_assembly::start(app, id)
.or_else(|| crate::dialogs_sheet::start(app, id))
.or_else(|| crate::dialogs_plastic::start(app, id))
.or_else(|| crate::dialogs_appearance::start(app, id))
.or_else(|| crate::dialogs_motion::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::<Vec<_>>()
})
.unwrap_or_else(|| vec![sel.clone()]),
_ => vec![sel.clone()],
};
for x in picked {
if !inp.items.contains(&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 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<String>, fail: Vec<String>) -> 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::Plastic(k) => return k.previews(),
_ => {}
}
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),
Kind::Sheet(k) => return k.primary(&self.inputs),
Kind::Plastic(k) => return k.primary(),
_ => 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<Sel> {
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<Sel> {
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<Sel>, 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<Sel> {
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<Value> {
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);
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) {
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",
"PrimitiveCoil" => "COIL",
_ => "TORUS",
},
Kind::Combine { .. } => "COMBINE",
Kind::Params { .. } => "PARAMETERS",
Kind::EditParam { .. } => "EDIT DIMENSION",
Kind::Rename { .. } => "RENAME",
Kind::ConfirmDelete { .. } => "DELETE FEATURE",
Kind::Assembly(k) => k.title(),
Kind::Sheet(k) => k.title(),
Kind::Plastic(k) => k.title(),
Kind::Appearance(_) => "APPEARANCE",
Kind::Motion(k) => k.title(),
}
}
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"
}
}
/// Width of the label column; longer labels are cut with "…".
const LABEL_W: f32 = 92.0;
/// A row label in the dialog's label column.
pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) {
ui.allocate_ui_with_layout(vec2(LABEL_W, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_width(LABEL_W);
ui.add(egui::Label::new(text).truncate()).on_hover_text(text);
});
}
/// The pattern objects' label: features are picked in the timeline.
fn objects_label(accept: Accept) -> &'static str {
if accept == selection::FEATURES { "Features" } else { "Objects" }
}
fn is_pattern(k: &Kind) -> bool {
matches!(k, Kind::PatternRect { .. } | Kind::PatternCirc { .. } | Kind::PathPattern { .. })
}
/// The Object Type row of a pattern: bodies (picked in the view) or features (picked in the
/// timeline, e.g. a hole).
fn object_type_row(d: &mut Dialog, ui: &mut egui::Ui) {
let Some(inp) = d.inputs.first_mut() else { return };
row_label(ui, "Object Type");
let cur = usize::from(inp.accept == selection::FEATURES);
let mut ty = cur;
combo(ui, "pat_obj", &["Bodies", "Features"], &mut ty);
ui.end_row();
if ty != cur {
inp.accept = if ty == 1 { selection::FEATURES } else { BODIES };
inp.label = objects_label(inp.accept);
inp.items.clear();
d.active = 0;
}
}
/// Rows above the selection inputs: the pattern type and object type, the Hole placement (one
/// face point, or sketch points).
fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) {
let current = match d.kind {
Kind::PatternRect { .. } => Some(0),
Kind::PatternCirc { .. } => Some(1),
Kind::PathPattern { .. } => Some(2),
_ => None,
};
if let Some(cur) = current {
row_label(ui, "Type");
let mut ty = cur;
combo(ui, "pat_type", &["Rectangular", "Circular", "Path"], &mut ty);
ui.end_row();
if ty != cur {
// Same objects, the new pattern's other inputs.
let objects = d.inputs.first().map(|i| i.items.clone()).unwrap_or_default();
let (kind, mut inputs) = match ty {
0 => (
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)],
),
1 => (
Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() },
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Axis", AXES, false)],
),
_ => (
Kind::PathPattern { count: "4".into(), spacing: "20 mm".into() },
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Path", CURVES, true)],
),
};
let accept = d.inputs.first().map(|i| i.accept).unwrap_or(BODIES);
if let Some(i) = inputs.first_mut() {
i.items = objects;
i.accept = accept;
i.label = objects_label(accept);
}
d.kind = kind;
d.inputs = inputs;
d.extra.clear();
d.advance();
}
object_type_row(d, ui);
return;
}
let Kind::Hole { opts, .. } = &mut d.kind else { return };
row_label(ui, "Placement");
let mut m = usize::from(opts.multiple);
combo(ui, "hole_place", &["Single", "Multiple"], &mut m);
ui.end_row();
if (m == 1) != opts.multiple {
opts.multiple = m == 1;
d.inputs = vec![if opts.multiple { SelInput::new("Points", selection::POINTS, true) } else { SelInput::new("Face", PLANAR_FACES, true) }];
d.active = 0;
d.extra.remove("direction");
}
}
/// 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<usize> = None;
let mut activate: Option<usize> = None;
for (i, inp) in d.inputs.iter().enumerate() {
row_label(ui, inp.label);
ui.horizontal(|ui| {
let active = i == d.active;
// A chip: "↖ Select" (outlined) while empty, "N selected" (filled) once picked.
let filled = !inp.items.is_empty();
let text = if filled {
RichText::new(format!("↖ {} selected", inp.items.len())).color(Color32::WHITE)
} else {
RichText::new("↖ Select").color(t.text)
};
let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) };
// Not in the Tab order: Tab moves between values.
let b = egui::Button::new(text)
.fill(if filled { t.chip } else { Color32::TRANSPARENT })
.stroke(stroke)
.min_size(vec2(0.0, 22.0))
.sense(egui::Sense::CLICK);
if ui.add(b).on_hover_text(format!("{} (click to pick into this input)", hint(inp))).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 = 252.0;
const DIALOG_MAX_W: f32 = 380.0;
/// Width of value fields and choice boxes in a dialog.
const FIELD_W: f32 = 124.0;
/// Measure again when the picked items changed.
fn update_measure(app: &SolveApp, d: &mut Dialog) {
let items: Vec<Sel> = 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 applied = false;
let mut cancel = false;
let mut enter = false;
let wide = matches!(d.kind, Kind::Params { .. }) || matches!(&d.kind, Kind::Motion(k) if k.wide());
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())
.fill(t.dialog_bg)
.stroke(Stroke::new(1.0, t.dialog_border))
.corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 })
.shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) });
// OK is offered once the inputs make a feature.
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences);
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
egui::Window::new(RichText::new(heading.clone()).strong().size(13.0))
.id(egui::Id::new("sc_dialog"))
.title_bar(false)
.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);
// Header: "−" folds the dialog, the command name, "»" at the right.
ui.horizontal(|ui| {
let fold = if d.collapsed { "+" } else { "−" };
if ui
.add(egui::Button::new(RichText::new(fold).size(14.0)).frame(false))
.on_hover_text(if d.collapsed { "Expand" } else { "Collapse" })
.clicked()
{
d.collapsed = !d.collapsed;
}
ui.label(RichText::new(heading.as_str()).strong().size(12.5));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new("»").color(t.text_dim));
});
});
if d.collapsed {
return;
}
ui.add_space(2.0);
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
placement_row(&mut d, ui);
input_rows(&mut d, ui);
match &mut d.kind {
Kind::Sketch => {}
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, start, all } => {
row_label(ui, "Start");
let mut st = usize::from(!start.is_empty());
combo(ui, "ex_start", &["Profile Plane", "Offset"], &mut st);
if st == 1 && start.is_empty() {
*start = "5 mm".into();
} else if st == 0 {
start.clear();
}
ui.end_row();
if !start.is_empty() {
row_label(ui, "Offset");
enter |= field(ui, start);
ui.end_row();
}
row_label(ui, "Direction");
// One Side, Two Sides, Symmetric (a flip is a negative distance).
let order = [0usize, TWO_SIDES, 2];
let mut pos = order.iter().position(|o| o == direction).unwrap_or(0);
combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos);
*direction = order.get(pos).copied().unwrap_or(0);
ui.end_row();
row_label(ui, "Extent Type");
let mut ext = usize::from(*all);
combo(ui, "ex_ext", &["Distance", "All"], &mut ext);
*all = ext == 1;
ui.end_row();
if !*all {
row_label(ui, "Distance");
enter |= field(ui, distance);
ui.end_row();
if *direction == TWO_SIDES {
row_label(ui, "Distance 2");
enter |= field(ui, distance2);
ui.end_row();
}
}
if *direction < 2 {
row_label(ui, "Taper Angle");
enter |= field(ui, taper);
ui.end_row();
}
row_label(ui, "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 } => {
row_label(ui, "Angle");
enter |= field(ui, angle);
ui.end_row();
row_label(ui, "Operation");
combo(ui, "rv_op", &OP_LABELS, operation);
ui.end_row();
}
Kind::Fillet { radius, chamfer, chain } => {
row_label(ui, if *chamfer { "Distance" } else { "Radius" });
enter |= field(ui, radius);
ui.end_row();
row_label(ui, "Tangent Chain");
ui.checkbox(chain, "");
ui.end_row();
}
Kind::Shell { thickness } => {
row_label(ui, "Inside Thickness");
enter |= field(ui, thickness);
ui.end_row();
}
Kind::Draft { angle } => {
row_label(ui, "Angle");
enter |= field(ui, angle);
ui.end_row();
}
Kind::Mirror => {
row_label(ui, "Operation");
let mut op = usize::from(d.extra.get("combine").and_then(Value::as_bool).unwrap_or(false));
combo(ui, "mi_op", &["New Body", "Join"], &mut op);
d.extra.insert("combine".into(), json!(op == 1));
ui.end_row();
}
Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()),
Kind::Preferences => preferences_rows(app, ui),
Kind::OffsetPlane { offset } => {
row_label(ui, "Distance");
enter |= field(ui, offset);
ui.end_row();
}
Kind::AnglePlane { angle } => {
row_label(ui, "Angle");
enter |= field(ui, angle);
ui.end_row();
}
Kind::Split | Kind::ReplaceFace | Kind::Remove => {}
Kind::Rib { thickness, depth, flip, .. } => {
row_label(ui, "Thickness");
enter |= field(ui, thickness);
ui.end_row();
row_label(ui, "Depth");
let r = ui.add(egui::TextEdit::singleline(depth).hint_text("to the next face"));
crate::params_dialog::complete(ui, &r, depth);
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.end_row();
row_label(ui, "Flip");
ui.checkbox(flip, "");
ui.end_row();
}
Kind::Emboss { depth, deboss } => {
row_label(ui, "Depth");
enter |= field(ui, depth);
ui.end_row();
row_label(ui, "Type");
let mut i = usize::from(*deboss);
combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i);
*deboss = i == 1;
ui.end_row();
}
Kind::Align { flip } => {
row_label(ui, "Flip");
ui.checkbox(flip, "");
ui.end_row();
}
Kind::PathPattern { count, spacing } => {
for (l, v) in [("Quantity", count), ("Spacing", spacing)] {
row_label(ui, l);
enter |= field(ui, v);
ui.end_row();
}
}
Kind::Pipe { diameter, wall } => {
row_label(ui, "Diameter");
enter |= field(ui, diameter);
ui.end_row();
row_label(ui, "Wall");
let r = ui.add(egui::TextEdit::singleline(wall).hint_text("solid"));
crate::params_dialog::complete(ui, &r, wall);
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.end_row();
}
Kind::Stock { margin } => {
row_label(ui, "Margin");
enter |= field(ui, margin);
ui.end_row();
}
Kind::Scale { factor } => {
row_label(ui, "Scale factor");
enter |= field(ui, factor);
ui.end_row();
}
Kind::OffsetFaces { distance } => {
row_label(ui, "Distance");
enter |= field(ui, distance);
ui.end_row();
}
Kind::Thread { designation, length } => {
row_label(ui, "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();
row_label(ui, "Length");
let r = ui.add(egui::TextEdit::singleline(length).hint_text("whole face"));
crate::params_dialog::complete(ui, &r, length);
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();
row_label(ui, "Material");
combo(ui, "mat", &names, index);
ui.end_row();
}
Kind::Section { offset, flip } => {
row_label(ui, "Distance");
enter |= field(ui, offset);
ui.end_row();
row_label(ui, "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)] {
row_label(ui, l);
enter |= field(ui, v);
ui.end_row();
}
}
Kind::PatternCirc { count, angle } => {
for (l, v) in [("Quantity", count), ("Total angle", angle)] {
row_label(ui, l);
enter |= field(ui, v);
ui.end_row();
}
}
Kind::Loft { operation } => {
row_label(ui, "Operation");
combo(ui, "lf_op", &OP_LABELS, operation);
ui.end_row();
}
Kind::Sweep { operation } => {
row_label(ui, "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)] {
row_label(ui, l);
enter |= field(ui, v);
ui.end_row();
}
}
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => {
row_label(ui, "Extents");
let mut ext = usize::from(opts.all);
combo(ui, "hole_ext", &["Distance", "All"], &mut ext);
opts.all = ext == 1;
ui.end_row();
row_label(ui, "Hole Type");
combo(ui, "hole_kind", &HOLE_LABELS, kind);
ui.end_row();
row_label(ui, "Hole Tap Type");
let mut tap = usize::from(opts.tapped);
combo(ui, "hole_tap", &["Simple", "Tapped"], &mut tap);
opts.tapped = tap == 1;
ui.end_row();
if opts.tapped {
row_label(ui, "Size");
let r = ui.add(egui::TextEdit::singleline(&mut opts.thread).hint_text("M5"));
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.end_row();
}
if *kind == 0 {
row_label(ui, "Drill Point");
let mut tip = usize::from(opts.angled);
combo(ui, "hole_tip", &["Flat", "Angle"], &mut tip);
opts.angled = tip == 1;
ui.end_row();
}
if !opts.all {
row_label(ui, "Depth");
enter |= field(ui, depth);
ui.end_row();
}
if *kind == 0 && opts.angled {
row_label(ui, "Tip Angle");
enter |= field(ui, &mut opts.tip_angle);
ui.end_row();
}
row_label(ui, "Diameter");
enter |= field(ui, diameter);
ui.end_row();
if *kind == 1 {
row_label(ui, "Counterbore Ø");
enter |= field(ui, cb_diameter);
ui.end_row();
row_label(ui, "Counterbore depth");
enter |= field(ui, cb_depth);
ui.end_row();
}
if *kind == 2 {
row_label(ui, "Countersink Ø");
enter |= field(ui, cs_diameter);
ui.end_row();
row_label(ui, "Countersink angle");
enter |= field(ui, cs_angle);
ui.end_row();
}
}
Kind::Primitive { fields, operation, .. } => {
for (k, v) in fields.iter_mut() {
row_label(ui, k);
enter |= field(ui, v);
ui.end_row();
}
row_label(ui, "Operation");
combo(ui, "pr_op", &OP_LABELS, operation);
ui.end_row();
}
Kind::Combine { operation, keep_tools } => {
row_label(ui, "Operation");
let mut op = operation.saturating_sub(1);
combo(ui, "cb_op", &OP_LABELS[1..], &mut op);
*operation = op + 1;
ui.end_row();
row_label(ui, "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);
crate::params_dialog::complete(ui, &r, 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, .. } => {
row_label(ui, "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::Assembly(k) => enter |= crate::dialogs_assembly::rows(app, ui, k, &mut d.inputs, &mut d.active),
Kind::Sheet(k) => enter |= crate::dialogs_sheet::rows(app, ui, k, &mut d.inputs),
Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs),
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
Kind::ConfirmDelete { with, fail, .. } => {
if !with.is_empty() {
row_label(ui, "Also deletes");
ui.label(with.join(", "));
ui.end_row();
}
if !fail.is_empty() {
row_label(ui, "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()) {
row_label(ui, "");
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
ui.end_row();
}
});
ui.add_space(6.0);
// Footer: an info glyph at the left, OK and Cancel at the right.
ui.horizontal(|ui| {
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover());
ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(1.2, t.text_dim));
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "i", egui::FontId::proportional(11.0), t.text_dim);
resp.on_hover_text(heading.as_str());
// The spinner's place is always taken: a widget that comes and goes would shift
// the automatic ids of OK and Cancel, and a click pressed in one frame and
// released in the next would be lost (an animating preview toggles it often).
let (sr, sresp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
if app.preview.busy {
egui::Spinner::new().size(14.0).paint_at(ui, sr);
sresp.on_hover_text("Updating the preview");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
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(64.0, 24.0))).clicked() {
cancel = true;
}
if has_ok {
let label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" };
let b = egui::Button::new(RichText::new(label).color(if valid { Color32::WHITE } else { t.text_dim }))
.fill(if valid { t.accent } else { Color32::TRANSPARENT })
.min_size(vec2(64.0, 24.0));
if ui.add_enabled(valid, b).clicked() {
ok = true;
}
}
});
});
});
// 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 && !app.esc_handled {
app.esc_handled = true;
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;
applied = true;
}
Err(e) => d.error = Some(e),
}
}
if !(keep && !cancel) {
crate::dialogs_motion::closed(app, &mut d, applied);
}
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(mut d) = app.dialog.take() {
crate::dialogs_motion::closed(app, &mut d, false);
app.dialog = Some(d);
}
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: "10 mm".into(),
kind: 0,
cb_diameter: "9 mm".into(),
cb_depth: "3 mm".into(),
cs_diameter: "10 mm".into(),
cs_angle: "90 deg".into(),
opts: HoleOpts::default(),
}
}
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::<String>(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::<String>(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<Vec<(String, Value)>, 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 the live preview runs: OK's, with a joint's motion while it animates.
pub fn preview_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
let mut cmds = apply_commands(app, d)?;
if let Kind::Assembly(k) = &d.kind {
crate::dialogs_assembly::animate(app, k, &mut cmds);
}
Ok(cmds)
}
/// The commands (id, parameters) a dialog's OK runs.
fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, 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::<Vec<_>>()))
};
let body_names =
|i: usize| -> Vec<String> { 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<Value> {
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, start, all, .. } => {
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")});
let tapered = s.doc.eval(taper, ValueKind::Angle).map(|v| v.abs() > 1e-12).unwrap_or(!taper.trim().is_empty());
if *direction == TWO_SIDES {
common["distance2"] = json!(distance2);
} else if *direction < 2 && tapered {
common["taper"] = json!(taper);
}
if !start.trim().is_empty() {
common["start_offset"] = json!(start);
}
if *all {
common["through_all"] = json!(true);
}
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<Vec3> = 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<Vec3> = Vec::new();
for p in pts {
if !uniq.iter().any(|q| q.dist(p) < 1e-9) {
uniq.push(p);
}
}
let edges: Vec<Value> = 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": pattern_features(s, sels(d, 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": pattern_features(s, sels(d, 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<usize>)> = 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<Value> = 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<String> = 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")?;
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 => d.extra.get("axis").cloned().ok_or("pick an axis")?,
};
("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")?;
let mut p = json!({"path_sketch": sketch, "path": path, "count": count, "spacing": spacing});
if sels(d, 0).iter().any(|x| matches!(x, Sel::Feature { .. })) {
p["features"] = json!(pattern_features(s, sels(d, 0)));
} else {
p["bodies"] = json!(body_names(0));
}
("PatternOnPath", p)
}
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, opts } => {
need(0, if opts.multiple { "sketch points" } else { "a face position" })?;
let ty = match *kind {
0 if opts.angled => "drilled",
k => HOLE_TYPES.get(k).copied().unwrap_or("simple"),
};
// One hole per picked face position, or one Hole feature per sketch for sketch points.
let mut places: Vec<Value> = Vec::new();
if opts.multiple {
let mut by_sketch: Vec<(u64, Vec<String>)> = Vec::new();
for x in sels(d, 0) {
if let Sel::SketchPoint { id } = x
&& let Some((sk, pid)) = id.split_once(':')
&& let Ok(sk) = sk.parse::<u64>()
{
match by_sketch.iter_mut().find(|(s, _)| *s == sk) {
Some((_, v)) => v.push(pid.to_string()),
None => by_sketch.push((sk, vec![pid.to_string()])),
}
}
}
places.extend(by_sketch.into_iter().map(|(sk, ids)| json!({"sketch": sk, "points": ids})));
} else {
places.extend(face_points(0).into_iter().map(|p| json!({ "position": p })));
}
let mut out = Vec::new();
for place in places {
let mut params = place;
params["diameter"] = json!(diameter);
params["type"] = json!(ty);
if !opts.all {
params["depth"] = json!(depth);
}
if ty == "drilled" {
params["tip_angle"] = json!(opts.tip_angle);
}
if opts.tapped && !opts.thread.trim().is_empty() {
params["thread"] = json!(opts.thread.trim());
}
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<String> = 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()),
Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs),
Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra),
Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra),
Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)),
Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs),
};
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<String> {
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).
/// The features a pattern repeats: picked features as they are, picked bodies by the features
/// that made them.
fn pattern_features(s: &Session, objects: &[Sel]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let bodies: Vec<String> = objects.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
for x in objects {
if let Sel::Feature { id } = x
&& let Some(f) = s.doc.feature(*id)
&& !out.contains(&f.name)
{
out.push(f.name.clone());
}
}
for f in source_features(s, &bodies) {
if !out.contains(&f) {
out.push(f);
}
}
out
}
fn source_features(s: &Session, bodies: &[String]) -> Vec<String> {
let st = s.model.state();
let mut features: Vec<String> = 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<u64>) -> Option<u64> {
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() || ss.sketch.wire_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.
pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &ProfileSel) -> Vec<usize> {
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.
pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
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).
pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
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<Sel> {
match pl {
PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }),
PlaneRef::Custom { plane } | PlaneRef::Face { 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<String> {
[("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<usize>) -> Option<Dialog> {
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_else(|| "0 deg".into()),
start: extent.start_offset.clone().unwrap_or_default(),
all: extent.through_all,
};
// The dialog has no "flip": a flipped extrude is a negative distance.
if let Kind::Extrude { direction, distance, .. } = &mut d.kind
&& *direction == 1
{
*direction = 0;
*distance = format!("-({distance})");
}
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 [("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, points, thread } => {
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, opts } = &mut k {
*dia = diameter.clone();
opts.all = depth.is_none();
opts.angled = matches!(hole, HoleKind::Drilled { .. });
opts.multiple = points.is_some();
if let Some(th) = thread {
opts.tapped = true;
opts.thread = th.clone();
}
if let Some(dd) = depth {
*dep = dd.clone();
}
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 } => {
opts.tip_angle = tip_angle.clone();
}
HoleKind::Simple => {}
}
}
d.kind = k;
match points {
Some(sp) => {
d.inputs = vec![SelInput::new("Points", selection::POINTS, true)];
if let Some(inp) = d.inputs.first_mut() {
inp.items = sp.ids.iter().map(|id| Sel::SketchPoint { id: format!("{}:{id}", sp.sketch) }).collect();
}
}
None => {
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<u64> = 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<u64> = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect();
let bodies: Vec<Sel> = 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,
};
// Features that made no body of their own (a hole, a fillet) were picked as features.
let as_features = ids.iter().any(|id| !st.bodies.iter().any(|b| b.feature == *id));
if let Some(inp) = d.inputs.first_mut() {
if as_features {
inp.accept = selection::FEATURES;
inp.label = objects_label(inp.accept);
inp.items = ids.iter().map(|id| Sel::Feature { id: *id }).collect();
} else {
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::ConstructionPlane { plane: PlaneRef::Offset { base, distance } } => {
let mut d = start("ConstructionPlaneOffsetFromPlaneCommand")?;
d.kind = Kind::OffsetPlane { offset: distance.clone() };
if let Some(inp) = d.inputs.first_mut() {
inp.items = plane_sel(s, base).into_iter().collect();
}
d
}
FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } } => {
let mut d = start("ConstructionPlaneAtAngleCommand")?;
d.kind = Kind::AnglePlane { angle: angle.clone() };
if let Some(inp) = d.inputs.first_mut() {
inp.items = plane_sel(s, base).into_iter().collect();
}
match world_axis(*axis_dir).filter(|_| axis_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(*axis_origin), "dir": pt3(*axis_dir)}));
}
}
d
}
FeatureKind::Split { body, plane } => {
let mut d = start("FusionSplitBodyCommand")?;
if let Some(inp) = d.inputs.first_mut() {
inp.items = vec![Sel::Body { name: body.clone() }];
}
if let Some(inp) = d.inputs.get_mut(1) {
inp.items = plane_sel(s, plane).into_iter().collect();
}
d
}
FeatureKind::Scale { bodies, origin, factor, factors } => {
let mut d = start("ModifyScale")?;
d.kind = Kind::Scale { factor: factor.clone() };
if let Some(inp) = d.inputs.first_mut() {
inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect();
}
d.extra.insert("origin".into(), pt3(*origin));
if let Some(f) = factors {
d.extra.insert("factors".into(), json!(f));
}
d
}
FeatureKind::OffsetFace { faces, distance, body } => {
let mut d = start("FusionOffsetFacesCommand")?;
d.kind = Kind::OffsetFaces { distance: distance.clone() };
if let Some(inp) = d.inputs.first_mut() {
inp.items = faces.iter().filter_map(|p| face_sel(s, *p)).collect();
}
if let Some(b) = body {
d.extra.insert("body".into(), json!(b));
}
d
}
FeatureKind::Thread { face, designation, length } => {
let mut d = start("FusionThreadCommand")?;
d.kind = Kind::Thread { designation: designation.clone().unwrap_or_default(), length: length.clone().unwrap_or_default() };
if let Some(inp) = d.inputs.first_mut() {
inp.items = face_sel(s, *face).into_iter().collect();
}
d
}
FeatureKind::BoundingSolid { bodies, margin } => {
let mut d = start("StockModelCommand")?;
d.kind = Kind::Stock { margin: margin.clone() };
if let Some(inp) = d.inputs.first_mut() {
inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect();
}
d
}
FeatureKind::Pipe { path, diameter, wall, operation, targets, .. } => {
let mut d = start("PrimitivePipe")?;
d.kind = Kind::Pipe { diameter: diameter.clone(), wall: wall.clone().unwrap_or_default() };
if let Some(inp) = d.inputs.first_mut() {
inp.items = path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
}
d.extra.insert("operation".into(), json!(OPS.get(op_index(operation)).copied().unwrap_or("new")));
if !targets.is_empty() {
d.extra.insert("targets".into(), json!(targets));
}
d
}
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => {
let mut d = start("EmbossCmd")?;
d.kind = Kind::Emboss { depth: depth.clone(), deboss: *deboss };
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();
}
if !targets.is_empty() {
d.extra.insert("targets".into(), json!(targets));
}
d
}
FeatureKind::Rib { curves, thickness, depth, flip, web, .. } => {
let mut d = start(if *web { "FusionWebCommand" } else { "FusionRibCommand" })?;
d.kind = Kind::Rib { web: *web, thickness: thickness.clone(), depth: depth.clone().unwrap_or_default(), flip: *flip };
if let Some(inp) = d.inputs.first_mut() {
inp.items = curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
}
d
}
FeatureKind::ReplaceFace { faces, target, body } => {
let mut d = start("FusionReplaceFaceCommand")?;
if let Some(inp) = d.inputs.first_mut() {
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, target).into_iter().collect();
}
if let Some(b) = body {
d.extra.insert("body".into(), json!(b));
}
d
}
FeatureKind::Remove { bodies } => {
let mut d = start("SoftDeleteCommand")?;
if let Some(inp) = d.inputs.first_mut() {
inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect();
}
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
}
other => {
let (kind, inputs, extra) = crate::dialogs_sheet::for_feature(app, other).or_else(|| crate::dialogs_plastic::for_feature(app, other))?;
let mut d = Dialog::new(kind, inputs);
d.extra = extra;
d
}
};
d.editing = Some((id, marker));
d.active = 0;
d.error = None;
Some(d)
}