Files
OneCAD/crates/ui-egui/src/dialogs.rs
T
lucas-aguilar89andClaude Opus 5.5 8eda982489 Pattern and Mirror dialogs copy picked bodies, fillets included (#193)
With Object Type = Bodies, the Mirror, Rectangular Pattern and Circular
Pattern dialogs sent the feature that created each picked body
({"features": ["Extrude1"]}), so the copies replayed only that feature and
lost everything done to the body afterwards (a fillet, a chamfer, a cut).
They now send the bodies themselves ({"bodies": [...]}), which the commands
already support and the Path Pattern dialog already did. Picking Features
is unchanged.

Editing a body Mirror or Pattern from the timeline now reopens the dialog
with its bodies selected.

Copies made this way take the body-copy names ("Body1 (2)"), as the
commands already give; pattern_direction_edge now looks up "Body1 (3)".

Tests: UI scenarios mirror_body_keeps_fillets and pattern_body_keeps_fillets
(filleted cube, copy and edit through the dialogs); both fail without the
fix with the copies' sharp 8000 mm3 volume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:06:18 -04:00

3674 lines
168 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, 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<Value>,
},
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<Sel>,
},
/// 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<Value>,
of: Vec<Sel>,
},
/// 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<String>,
fail: Vec<String>,
},
/// 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),
}
/// 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>)>,
/// 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<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 { .. }
| Sel::Occurrence { .. }
| Sel::ExternalSketchCurve { .. }
| Sel::ExternalSketchPoint { .. } => false,
}
}
impl Dialog {
pub(crate) fn new(kind: Kind, inputs: Vec<SelInput>) -> 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<Dialog> {
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))?;
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()]),
(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<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::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<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> {
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<Sel>, 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<Sel> {
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<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);
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:<label>` handles).
static ROW: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
fn tr(text: &str) -> &str {
crate::i18n::tr_label(text)
}
/// A dialog row's widget, for scenarios and demos: the handle `dialog:<row label>`.
fn publish_row(r: &egui::Response) {
ROW.with(|row| crate::scenario::publish_handle(&format!("dialog:{}", row.borrow()), r.rect.center()));
}
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::OffsetPlane { .. } => "OFFSET PLANE",
Kind::Rib { web: false, .. } => "RIB",
Kind::Rib { web: true, .. } => "WEB",
Kind::Emboss { .. } => "EMBOSS",
Kind::ReplaceFace => "REPLACE FACE",
Kind::Align { .. } => "ALIGN",
Kind::BoundaryFill { .. } => "BOUNDARY FILL",
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 {
"solid.box" => "BOX",
"solid.cylinder" => "CYLINDER",
"solid.sphere" => "SPHERE",
"solid.coil" => "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::Surface(k) => k.title(),
Kind::Plastic(k) => k.title(),
Kind::Appearance(_) => "APPEARANCE",
Kind::Motion(k) => k.title(),
Kind::Part(_) => "INSERT PART",
}
}
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;
/// The label column grows to fit its labels up to this, then long labels wrap.
const LABEL_MAX_W: f32 = 150.0;
/// A row label in the dialog's label column (the grid sizes the column to its widest label).
pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) {
ROW.with(|r| *r.borrow_mut() = text.to_string());
let display_text = tr(text);
let full = egui::WidgetText::from(display_text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let w = full.clamp(LABEL_W, LABEL_MAX_W);
ui.allocate_ui_with_layout(vec2(w, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_width(w);
ui.add(egui::Label::new(display_text).wrap());
});
}
thread_local! {
/// Faces already traced to their feature, by (revision, body, face).
static FACE_FEATURE: std::cell::RefCell<(u64, std::collections::HashMap<(String, usize), Option<u64>>)> = Default::default();
}
/// The feature that made a face: the latest feature before which the face's point lay on no
/// face of the model.
fn feature_of_face(s: &Session, body: &str, index: usize, p: Vec3) -> Option<u64> {
let key = (body.to_string(), index);
if let Some(hit) = FACE_FEATURE.with(|c| {
let c = c.borrow();
if c.0 == s.revision { c.1.get(&key).copied() } else { None }
}) {
return hit;
}
let on_model = |st: &solvecraft_engine::doc::ModelState| {
st.bodies.iter().any(|b| {
let m = b.mesh();
let bb = m.bounds();
let tol = 1e-4 * (1.0 + bb.diagonal());
p.x >= bb.min.x - tol
&& p.y >= bb.min.y - tol
&& p.z >= bb.min.z - tol
&& p.x <= bb.max.x + tol
&& p.y <= bb.max.y + tol
&& p.z <= bb.max.z + tol
&& m.triangles.iter().filter_map(|t| m.tri(t)).any(|t| point_tri_dist(p, t) < tol)
})
};
let found = s
.doc
.features
.iter()
.rev()
.filter(|f| !matches!(f.kind, FeatureKind::Sketch { .. }) && s.model.result(f.id).is_some_and(|r| r.error.is_none()))
.find(|f| !on_model(&s.model.state_before(f.id)))
.map(|f| f.id);
FACE_FEATURE.with(|c| {
let mut c = c.borrow_mut();
if c.0 != s.revision {
*c = (s.revision, std::collections::HashMap::new());
}
c.1.insert(key, found);
});
found
}
/// Distance from a point to a triangle.
pub(crate) fn point_tri_dist(p: Vec3, [a, b, c]: [Vec3; 3]) -> f64 {
let n = (b - a).cross(c - a);
let nn = n.dot(n);
if nn > 1e-24 {
let q = p - n * ((p - a).dot(n) / nn);
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= 0.0);
if inside {
return (p - q).len();
}
}
p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a))
}
/// 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 { .. } | Kind::Mirror)
}
/// 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) {
if matches!(d.kind, Kind::Mirror) {
object_type_row(d, ui);
return;
}
if matches!(d.kind, Kind::Move { .. }) {
crate::dialogs_move::type_row(d, ui);
return;
}
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 | EDGES, false),
SelInput::new("Direction 2", AXES | EDGES, 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 custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. });
let filled = !inp.items.is_empty() || custom_axis;
let text = if filled {
RichText::new(if custom_axis {
crate::i18n::tr("dialog.custom_axis").into()
} else {
crate::i18n::format("dialog.selected_count", &[("count", &inp.items.len().to_string())])
})
.color(Color32::WHITE)
} else {
RichText::new(crate::i18n::tr("dialog.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);
let chip = ui.add(b);
publish_row(&chip);
let tooltip = if custom_axis {
if let Kind::Revolve { axis: Some(axis), .. } = &d.kind {
format!(
"Custom axis\nOrigin: {}\nDirection: {}\nClick to select a replacement axis",
axis.get("origin").map(ToString::to_string).unwrap_or_default(),
axis.get("dir").map(ToString::to_string).unwrap_or_default()
)
} else {
hint(inp).to_string()
}
} else {
format!("{} (click to pick into this input)", hint(inp))
};
if chip.on_hover_text(tooltip).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();
if i == 0 {
d.profile_selection_changed = true;
}
d.active = i;
}
}
/// Width limits of a docked dialog: shrink-wrapped to its content within these.
use crate::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W};
/// Width of value fields and choice boxes in a dialog.
const FIELD_W: f32 = 124.0;
/// Find the Boundary Fill cells again when the tool bodies changed (keeping the choices of
/// cells that are still there).
fn update_fill_cells(app: &SolveApp, d: &mut Dialog) {
let tools: Vec<Sel> = sels(d, 0).to_vec();
let Kind::BoundaryFill { cells, of, .. } = &mut d.kind else { return };
if *of == tools {
return;
}
let names: Vec<String> = tools.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
// Editing: the tools as they were before this feature (it may have removed them).
let st = match d.editing {
Some((id, _)) => app.session.model.state_before(id),
None => app.session.world_state(),
};
let found = fill_cells(&st, &names);
let kept: Vec<String> = cells.iter().filter(|c| c.2).map(|c| c.1.clone()).collect();
*cells = found.into_iter().map(|(p, label)| (p, label.clone(), kept.is_empty() || kept.contains(&label))).collect();
*of = tools;
}
/// The cells a set of tool bodies makes: each region inside some of them and outside the rest,
/// as a point inside it and which tools hold it. Found by sampling a grid over the tools.
pub(crate) fn fill_cells(st: &solvecraft_engine::doc::ModelState, tools: &[String]) -> Vec<(Vec3, String)> {
let bodies: Vec<_> = tools.iter().filter_map(|n| st.body(n).map(|b| (n.clone(), b.mesh()))).take(16).collect();
if bodies.is_empty() {
return Vec::new();
}
let indexes: Vec<_> = bodies.iter().map(|(_, m)| m.inside_index()).collect();
let bb = bodies.iter().fold(solvecraft_engine::geom::Aabb3::EMPTY, |b, (_, m)| b.union(&m.bounds()));
let n = 20;
let at = |i: usize, lo: f64, hi: f64| lo + (hi - lo) * (i as f64 + 0.5) / n as f64;
// Signature (which tools hold the point) → the points with it.
let mut groups: Vec<(u32, Vec<Vec3>)> = Vec::new();
for i in 0..n {
for j in 0..n {
for k in 0..n {
let p = Vec3::new(at(i, bb.min.x, bb.max.x), at(j, bb.min.y, bb.max.y), at(k, bb.min.z, bb.max.z));
let sig = indexes.iter().enumerate().fold(0u32, |acc, (t, ix)| if ix.contains(p) { acc | (1 << t) } else { acc });
if sig == 0 {
continue;
}
match groups.iter_mut().find(|(g, _)| *g == sig) {
Some((_, v)) => v.push(p),
None => groups.push((sig, vec![p])),
}
}
}
}
groups.sort_by_key(|(sig, _)| *sig);
groups
.into_iter()
.filter_map(|(sig, pts)| {
let c = pts.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / pts.len() as f64);
// The sample nearest the middle (the middle itself may be outside a curved cell).
let rep = pts.iter().min_by(|a, b| a.dist(c).total_cmp(&b.dist(c))).copied()?;
let inside: Vec<&str> = bodies.iter().enumerate().filter(|(t, _)| sig & (1 << t) != 0).map(|(_, (name, _))| name.as_str()).collect();
Some((rep, format!("Inside {}", inside.join(", "))))
})
.collect()
}
/// 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;
}
}
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 axis view, sized to its content.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.viewport.dialog_rect = None;
let Some(mut d) = app.dialog.take() else { return };
let _language = crate::i18n::scope(&app.preferences.language);
auto_operation(app, &mut d);
update_measure(app, &mut d);
update_fill_cells(app, &mut d);
let t = Tokens::get();
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
let top = (vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE).min((vp.bottom() - 360.0).max(vp.top()));
let anchor = egui::pos2(vp.right(), top);
let mut keep = true;
let mut ok = false;
let mut applied = false;
let mut cancel = false;
let mut enter = false;
let wide = d.is_wide();
let raw_title = title(&d.kind);
let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).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) });
// The area includes the border as well as the inner margins; reserve all of it so a
// horizontally scrolling table cannot push a wide dialog two pixels past its limit.
let horizontal_frame = frame.inner_margin.leftf() + frame.inner_margin.rightf() + 2.0 * frame.stroke.width;
// OK is offered once the inputs make a feature.
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. });
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal };
let wkey = egui::Id::new(("sc_dialog_w", wide));
let response = crate::frame::docked(
RichText::new(heading.clone()).strong().size(13.0),
egui::Id::new(("sc_dialog", title(&d.kind))),
anchor,
vp.bottom() - anchor.y - 8.0,
width,
)
.frame(frame)
.open(&mut keep)
.show(ctx, |ui| {
ui.spacing_mut().text_edit_width = FIELD_W;
// Long texts (errors, notes) wrap instead of widening the dialog.
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
let margin = horizontal_frame;
let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin;
ui.set_min_width(min_w);
ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin);
// Rows that end at the right edge line up with last frame's content width.
let total = crate::frame::content_width(ui, wkey, min_w);
// 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));
let chevron_width = ui.spacing().interact_size.x;
crate::frame::right_aligned(ui, total, chevron_width, |ui| {
let chevron = if d.collapsed { "«" } else { "»" };
if ui
.add(egui::Button::new(RichText::new(chevron).color(t.text_dim)).frame(false).min_size(vec2(chevron_width, 0.0)))
.on_hover_text(if d.collapsed { "Expand" } else { "Collapse" })
.clicked()
{
d.collapsed = !d.collapsed;
}
});
});
if d.collapsed {
return;
}
ui.add_space(2.0);
// A sizing pass initially offers almost no height to an auto-sized window.
// Give only the fields a viewport-derived scroll budget; header and actions
// stay visible, while short forms still shrink to their natural height.
let vertical_frame = frame.inner_margin.topf() + frame.inner_margin.bottomf() + 2.0 * frame.stroke.width;
let footer_height = ui.spacing().interact_size.y.max(24.0) + 6.0 + ui.spacing().item_spacing.y;
let fields_height = (vp.bottom() - anchor.y - 8.0 - vertical_frame - ui.min_rect().height() - footer_height).max(1.0);
egui::ScrollArea::vertical().id_salt("feature_fields").min_scrolled_height(fields_height).max_height(fields_height).show(ui, |ui| {
// Configuration tables own their column widths and scrolling; the two-column form
// grid would squeeze their row names and action buttons into a label-sized cell.
if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs);
}
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) {
return;
}
placement_row(&mut d, ui);
input_rows(&mut d, ui);
match &mut d.kind {
Kind::Sketch => {
if d.error.is_some()
&& let Some(selection @ Sel::Face { .. }) = d.inputs.first().and_then(|i| i.items.first())
{
row_label(ui, "Attachment");
ui.label("This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.");
ui.end_row();
row_label(ui, "");
if ui.button("Use fixed plane").clicked()
&& let Some(mut plane) = crate::sketch_attachment_ui::face_plane(app, selection)
{
plane["fixed"] = json!(true);
match app.run("sketch.create", json!({"plane":plane})) {
Ok(_) => {
app.pre_sketch_cam = Some(app.cam);
look_at_sketch(app);
cancel = true;
}
Err(error) => d.error = Some(error),
}
}
ui.end_row();
}
}
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => {
row_label(ui, "Start");
let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) };
let mut st = old;
combo(ui, "ex_start", &["Profile Plane", "Offset", "From Object"], &mut st);
if st != old {
set_object(&mut d.inputs, &mut d.active, FROM_OBJECT, st == 2);
if st == 1 && start.is_empty() {
*start = "5 mm".into();
}
if st == 0 {
start.clear();
}
}
ui.end_row();
if st != 0 {
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);
if pos != order.iter().position(|o| o == direction).unwrap_or(0) {
*direction = order.get(pos).copied().unwrap_or(0);
}
ui.end_row();
if *direction < 2 {
row_label(ui, "Reverse");
let mut reverse = *direction == 1;
if ui.checkbox(&mut reverse, "Reverse Direction").changed() {
*direction = usize::from(reverse);
}
ui.end_row();
}
row_label(ui, "Extent Type");
let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(*all) * 2 };
let mut ext = cur;
combo(ui, "ex_ext", &["Distance", "To Object", "All"], &mut ext);
*all = ext == 2;
set_to_object(&mut d.inputs, &mut d.active, ext == 1);
ui.end_row();
if ext == 0 {
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 ext == 1 {
row_label(ui, "To Offset");
enter |= field(ui, to_offset);
ui.end_row();
}
if *direction < 2 {
row_label(ui, "Taper Angle");
let before = taper.clone();
enter |= field(ui, taper);
if *taper != before {
*taper_present = !taper.trim().is_empty();
}
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, "Type");
let cur = usize::from(to_object_on(&d.inputs));
let mut ty = cur;
combo(ui, "rv_type", &["Angle", "To Object"], &mut ty);
set_to_object(&mut d.inputs, &mut d.active, ty == 1);
ui.end_row();
if ty == 0 {
// One Side, Two Sides (a second angle), Symmetric (the angle split).
row_label(ui, "Direction");
let cur = if d.extra.contains_key("angle2") {
1
} else if d.extra.get("direction").is_some() {
2
} else {
0
};
let mut dir = cur;
combo(ui, "rv_dir", &["One Side", "Two Sides", "Symmetric"], &mut dir);
if dir != cur {
d.extra.remove("angle2");
d.extra.remove("direction");
match dir {
1 => {
d.extra.insert("angle2".into(), json!("90 deg"));
}
2 => {
d.extra.insert("direction".into(), json!("symmetric"));
}
_ => {}
}
}
ui.end_row();
row_label(ui, "Angle");
enter |= field(ui, angle);
ui.end_row();
if dir == 1 {
row_label(ui, "Angle 2");
enter |= extra_field(ui, &mut d.extra, "angle2", "90 deg");
ui.end_row();
}
}
row_label(ui, "Operation");
combo(ui, "rv_op", &OP_LABELS, operation);
ui.end_row();
}
Kind::Fillet { radius, chamfer, chain, ctype, distance2, angle, flip } => {
if *chamfer {
row_label(ui, "Chamfer Type");
combo(ui, "ch_type", &CHAMFER_TYPES, ctype);
ui.end_row();
}
let mut rtype = 0;
if !*chamfer {
row_label(ui, "Radius Type");
rtype = extra_choice(
ui,
&mut d.extra,
"fi_type",
"type",
&[("Constant", "constant"), ("Chord Length", "chord"), ("Variable", "variable")],
);
ui.end_row();
}
row_label(
ui,
if !*chamfer {
match rtype {
1 => "Chord Length",
2 => "Start Radius",
_ => "Radius",
}
} else if *ctype == 0 {
"Distance"
} else {
"Distance 1"
},
);
enter |= field(ui, radius);
ui.end_row();
if *chamfer && *ctype == 1 {
row_label(ui, "Distance 2");
enter |= field(ui, distance2);
ui.end_row();
}
if *chamfer && *ctype == 2 {
row_label(ui, "Angle");
enter |= field(ui, angle);
ui.end_row();
}
if *chamfer && *ctype > 0 {
row_label(ui, "Flip");
ui.checkbox(flip, "");
ui.end_row();
}
if !*chamfer && rtype == 2 {
row_label(ui, "End Radius");
enter |= extra_field(ui, &mut d.extra, "radius2", "1 mm");
ui.end_row();
// Which end of the first edge takes the start radius.
row_label(ui, "Swap Ends");
extra_check(ui, &mut d.extra, "swap_ends");
ui.end_row();
// Radii between the ends: position along the edge (from the start end)
// and radius.
let mut mids = mid_points(&d.extra);
let mut remove = None;
for (k, (t, r)) in mids.iter_mut().enumerate() {
row_label(ui, "Mid Radius");
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(t).speed(0.01).range(0.01..=0.99).fixed_decimals(2))
.on_hover_text("Position along the edge, 0 at the start end, 1 at the other");
enter |= field(ui, r);
if ui.small_button("×").on_hover_text("Remove this point").clicked() {
remove = Some(k);
}
});
ui.end_row();
}
if let Some(k) = remove {
mids.remove(k);
}
row_label(ui, "");
if ui.button("Add Mid Radius").clicked() && mids.len() < 20 {
let t = if mids.is_empty() { 0.5 } else { (mids.iter().map(|m| m.0).fold(0.0, f64::max) + 1.0) * 0.5 };
mids.push((t.min(0.99), radius.clone()));
}
ui.end_row();
d.extra.insert("mid".into(), json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
if !mids.is_empty() {
row_label(ui, "Smooth");
extra_check(ui, &mut d.extra, "smooth");
ui.end_row();
}
}
row_label(ui, "Tangent Chain");
ui.checkbox(chain, "");
ui.end_row();
}
Kind::Shell { thickness } => {
row_label(ui, "Tangent Chain");
extra_check(ui, &mut d.extra, "tangent_chain");
ui.end_row();
let dir = d.extra.get("direction").and_then(Value::as_str).unwrap_or("inside").to_string();
row_label(
ui,
match dir.as_str() {
"outside" => "Outside Thickness",
"both" => "Thickness",
_ => "Inside Thickness",
},
);
enter |= field(ui, thickness);
ui.end_row();
row_label(ui, "Direction");
extra_choice(ui, &mut d.extra, "sh_dir", "direction", &[("Inside", "inside"), ("Outside", "outside"), ("Both", "both")]);
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::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();
ui.label("");
ui.add(
egui::Label::new(
"Orthogonal line profiles tangent to one supported cylinder wrap automatically. Pick a wall to choose a target.",
)
.wrap(),
);
ui.end_row();
}
Kind::Align { flip } => {
// Where on the picked face or edge to snap: as picked (a face turns to meet
// the other, an edge at its middle), its centre, or a round one's centre.
for (label, key) in [("From Snap", "from_snap"), ("To Snap", "to_snap")] {
row_label(ui, label);
extra_choice(ui, &mut d.extra, key, key, &[("As Picked", "picked"), ("Centre", "center"), ("Circle Centre", "circle")]);
ui.end_row();
}
row_label(ui, "Flip");
ui.checkbox(flip, "");
ui.end_row();
}
Kind::BoundaryFill { operation, cells, .. } => {
row_label(ui, "Cells");
if cells.is_empty() {
ui.label(RichText::new("Pick the tool bodies").color(Tokens::get().text_dim));
}
ui.end_row();
for (i, (_, label, on)) in cells.iter_mut().enumerate() {
row_label(ui, &format!("Cell {}", i + 1));
ui.checkbox(on, label.as_str());
ui.end_row();
}
row_label(ui, "Operation");
combo(ui, "bf_op", &OP_LABELS, operation);
ui.end_row();
row_label(ui, "Remove Tools");
extra_check(ui, &mut d.extra, "remove_tools");
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 } => {
// Fusion's order: Modeled, Full Length, then the size; a length only when not
// full.
row_label(ui, "Modeled");
extra_check(ui, &mut d.extra, "modeled");
ui.end_row();
row_label(ui, "Full Length");
let mut full = length.trim().is_empty();
if ui.checkbox(&mut full, "").changed() {
*length = if full { String::new() } else { "10 mm".into() };
}
ui.end_row();
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();
if !full {
row_label(ui, "Length");
enter |= field(ui, length);
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();
}
k @ Kind::Move { .. } => enter |= crate::dialogs_move::rows(ui, k),
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => {
row_label(ui, "Extents");
let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(opts.all) * 2 };
let mut ext = cur;
combo(ui, "hole_ext", &["Distance", "To Object", "All"], &mut ext);
opts.all = ext == 2;
set_to_object(&mut d.inputs, &mut d.active, ext == 1);
ui.end_row();
if opts.multiple {
// Every point of the picked points' sketches (standalone points and
// circle centres), not just the picked ones.
row_label(ui, "All Sketch Points");
extra_check(ui, &mut d.extra, "all_points");
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, "New Component");
extra_check(ui, &mut d.extra, "new_component");
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::Surface(k) => enter |= crate::dialogs_surface::rows(ui, k),
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::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &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()).cloned() {
row_label(ui, "");
ui.vertical(|ui| {
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
let response = ui.button("Create Issue…");
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
if response.clicked() {
crate::issue_report::begin(app, ctx, Some(&d), Some(&e));
}
});
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");
}
let raw_label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" };
let label = tr(raw_label);
let raw_close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
let close = tr(raw_close);
let button_width = |text: &str| {
let galley = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button);
(galley.size().x + 2.0 * ui.spacing().button_padding.x).max(64.0)
};
let (ok_width, close_width) = (button_width(label), button_width(close));
let buttons = close_width + if has_ok { ok_width + ui.spacing().item_spacing.x } else { 0.0 };
crate::frame::right_aligned(ui, total, buttons, |ui| {
if has_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 })
.wrap_mode(egui::TextWrapMode::Extend)
.min_size(vec2(ok_width, 24.0));
let r = ui.add_enabled(valid, b);
crate::scenario::publish_handle(&format!("dialog:{raw_label}"), r.rect.center());
if r.clicked() {
ok = true;
}
}
if ui.add(egui::Button::new(close).wrap_mode(egui::TextWrapMode::Extend).min_size(vec2(close_width, 24.0))).clicked() {
cancel = true;
}
});
});
crate::frame::remember_width(ui, wkey);
});
app.viewport.dialog_rect = response.map(|r| r.response.rect);
// 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")));
// Tables (configurations, a study's keys) take Enter for their cells.
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. })
&& !matches!(&d.kind, Kind::Motion(k) if k.wide());
let report_active = app.issue_report.draft.is_some();
if !report_active && enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) {
ok = true;
}
if !report_active && 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)
&& d.error.is_none()
&& let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned()
{
match start_sketch(app, &sel) {
Ok(()) => cancel = true,
Err(error) => d.error = Some(error),
}
}
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 _ = roll_marker(app, marker);
}
}
/// 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 _ = roll_marker(app, marker);
}
}
/// Temporary rollback used by an editor is view state. Preserve both history stacks,
/// including redo and a history already at the undo limit.
pub(crate) fn roll_marker(app: &mut SolveApp, marker: Option<usize>) -> Result<(), String> {
let undo = std::mem::take(&mut app.session.undo);
let redo = std::mem::take(&mut app.session.redo);
let selection = std::mem::take(&mut app.session.selection);
let result = app.run("timeline.roll_to", marker.map(|m| json!({"position":m})).unwrap_or_else(|| json!({})));
app.session.undo = undo;
app.session.redo = redo;
app.session.selection = selection;
result.map(|_| ())
}
/// The pick input an extent of To Object adds (a face, vertex or point to go to).
const TO_OBJECT: &str = "To Object";
const FROM_OBJECT: &str = "From Object";
fn object_on(inputs: &[SelInput], label: &str) -> bool {
inputs.iter().any(|i| i.label == label)
}
fn to_object_on(inputs: &[SelInput]) -> bool {
inputs.iter().any(|i| i.label == TO_OBJECT)
}
/// Add or remove the To Object input; the active input moves to it when it is added.
fn set_to_object(inputs: &mut Vec<SelInput>, active: &mut usize, on: bool) {
set_object(inputs, active, TO_OBJECT, on);
}
fn set_object(inputs: &mut Vec<SelInput>, active: &mut usize, label: &'static str, on: bool) {
if on && !object_on(inputs, label) {
inputs.push(SelInput::new(label, FACES | selection::VERTICES, false));
*active = inputs.len() - 1;
} else if !on && object_on(inputs, label) {
inputs.retain(|i| i.label != label);
*active = (*active).min(inputs.len().saturating_sub(1));
}
}
/// The picked To Object place.
fn to_object_point(inputs: &[SelInput]) -> Option<Value> {
object_point(inputs, TO_OBJECT)
}
pub(crate) fn object_point(inputs: &[SelInput], label: &str) -> Option<Value> {
inputs.iter().find(|i| i.label == label)?.items.first().and_then(|x| match x {
Sel::Face { point, .. } | Sel::Vertex { point, .. } | Sel::Edge { point, .. } => Some(pt(*point)),
_ => None,
})
}
/// A To Object input filled from a stored place (editing).
fn with_to_object(d: &mut Dialog, s: &Session, to: Option<Vec3>) {
with_object(d, s, TO_OBJECT, to);
}
fn with_object(d: &mut Dialog, s: &Session, label: &'static str, to: Option<Vec3>) {
if let Some(p) = to {
set_object(&mut d.inputs, &mut d.active, label, true);
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == label) {
inp.items = vec![face_sel(s, p).unwrap_or(Sel::Vertex { body: String::new(), point: p })];
}
}
}
/// A choice row stored in the dialog's extra parameters as `key: value` (the first choice, the
/// command's default, is stored as no key at all).
fn extra_choice(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, id: &str, key: &str, choices: &[(&str, &str)]) -> usize {
let cur = extra.get(key).and_then(Value::as_str).and_then(|v| choices.iter().position(|(_, x)| *x == v)).unwrap_or(0);
let mut i = cur;
let labels: Vec<&str> = choices.iter().map(|(l, _)| *l).collect();
combo(ui, id, &labels, &mut i);
if i != cur {
match choices.get(i) {
Some((_, v)) if i > 0 => {
extra.insert(key.into(), json!(v));
}
_ => {
extra.remove(key);
}
}
}
i
}
/// A checkbox stored in the dialog's extra parameters.
fn extra_check(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, key: &str) {
let mut on = extra.get(key).and_then(Value::as_bool).unwrap_or(false);
if ui.checkbox(&mut on, "").changed() {
extra.insert(key.into(), json!(on));
}
}
/// A value field stored in the dialog's extra parameters (`default` until edited).
fn extra_field(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, key: &str, default: &str) -> bool {
let mut v = extra.get(key).and_then(Value::as_str).unwrap_or(default).to_string();
let enter = field(ui, &mut v);
extra.insert(key.into(), json!(v));
enter
}
/// A variable fillet's middle radii as (position, radius expression).
fn mid_points(extra: &serde_json::Map<String, Value>) -> Vec<(f64, String)> {
extra
.get("mid")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|m| {
let t = m.get(0)?.as_f64()?;
let r = match m.get(1)? {
Value::String(x) => x.clone(),
Value::Number(n) => n.to_string(),
_ => return None,
};
Some((t, r))
})
.collect()
}
const CHAMFER_TYPES: [&str; 3] = ["Equal Distance", "Two Distances", "Distance and Angle"];
/// A new Fillet or Chamfer dialog's values.
fn fillet_kind(size: &str, chamfer: bool) -> Kind {
Kind::Fillet { radius: size.into(), chamfer, chain: true, ctype: 0, distance2: size.into(), angle: "45 deg".into(), flip: false }
}
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("parameters.change", 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("parameters.change", 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) -> Result<(), String> {
let plane = match sel {
Sel::Plane { name } => json!(name),
Sel::Face { .. } => crate::sketch_attachment_ui::face_plane(app, sel).ok_or("Selected face placement no longer exists")?,
_ => return Err("Select an origin plane, construction plane or planar body face".into()),
};
let before = app.cam;
app.run("sketch.create", json!({ "plane": plane }))?;
app.pre_sketch_cam = Some(before);
look_at_sketch(app);
// On a face: look at the picked point, not the plane's origin (a corner of the face),
// which left the face filling the view off-centre like a big square.
if let Sel::Face { point, .. } = sel
&& let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to)
{
to.target = *point;
app.animate_to(to);
}
Ok(())
}
/// Animate the camera to look straight at the active sketch plane.
pub fn look_at_sketch(app: &mut SolveApp) {
let st = app.session.world_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> {
let commands = apply_commands(app, d)?;
if let Some((_, marker)) = d.editing {
// Record the actual pre-edit timeline state in the command's undo snapshot.
roll_marker(app, marker)?;
}
for (cmd, params) in commands {
if let Err(error) = app.run(&cmd, params) {
if let Some((id, _)) = d.editing {
let position = app.session.doc.feature_index(id);
let _ = roll_marker(app, position);
}
return Err(error);
}
}
Ok(())
}
/// Placement used to edit a definition in the selected authoring occurrence.
pub(crate) fn feature_frame(s: &Session, component: u64) -> solvecraft_engine::doc::Mat {
if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) }
}
/// The feature dialog's current operation name for a reviewed issue draft.
pub(crate) fn report_title(d: &Dialog) -> String {
title(&d.kind).to_string()
}
/// 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, mut params) = cmds.into_iter().next().ok_or("nothing to apply")?;
if let Some(feature) = app.session.doc.feature(id) {
let original_all = match &feature.kind {
FeatureKind::Extrude { sketch, profiles: ProfileSel::All, .. }
| FeatureKind::Revolve { sketch, profiles: ProfileSel::All, .. }
| FeatureKind::Emboss { sketch, profiles: ProfileSel::All, .. } => Some(*sketch),
_ => None,
};
if let Some(sketch) = original_all
&& !d.profile_selection_changed
&& let Some(ss) = app.session.world_state().sketch(sketch)
{
let mut selected: Vec<usize> = sels(d, 0)
.iter()
.filter_map(|item| match item {
Sel::Profile { sketch: id, index } if *id == sketch => Some(*index),
_ => None,
})
.collect();
selected.sort_unstable();
selected.dedup();
if selected == (0..ss.profiles.len()).collect::<Vec<_>>() && sels(d, 0).len() == selected.len() {
params["profiles"] = json!("all");
}
}
}
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)
}
/// A wrapped Emboss edit keeps its profile guide in the saved author occurrence.
/// An invalid saved context must not draw the guide at a different occurrence.
pub(crate) fn emboss_profile_plane(app: &SolveApp, sketch: u64) -> Result<Option<solvecraft_engine::geom::Plane>, String> {
let Some(dialog) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Emboss { .. })) else { return Ok(None) };
let Some((feature, _)) = dialog.editing else { return Ok(None) };
let Some(feature) = app.session.doc.feature(feature) else { return Err("the edited feature is missing".into()) };
let FeatureKind::Emboss { sketch: source, face: Some(face), .. } = &feature.kind else { return Ok(None) };
if *source != sketch {
return Ok(None);
}
let model = app.session.model.state();
let sketch = model.sketch(sketch).ok_or("the Emboss sketch is missing")?;
let body = model.body(&face.body).ok_or("the Emboss target body is missing")?;
let owner = app.session.doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
let plane = sketch.plane;
let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body).map_err(|error| error.to_string())?;
solvecraft_engine::geom::Plane::new(
solvecraft_engine::doc::apply_point(&frame, plane.origin),
solvecraft_engine::doc::apply_vector(&frame, plane.x),
solvecraft_engine::doc::apply_vector(&frame, plane.y),
)
.map(Some)
.ok_or_else(|| "the Emboss author frame is degenerate".into())
}
/// 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, taper_present, to_offset, 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 && (*taper_present || tapered) {
common["taper"] = json!(taper);
}
if !start.trim().is_empty() {
common["start_offset"] = json!(start);
}
if *all {
common["through_all"] = json!(true);
}
if to_object_on(&d.inputs) {
common["to"] = to_object_point(&d.inputs).ok_or("select the object to extrude to")?;
if !to_offset.trim().is_empty() {
common["to_offset"] = json!(to_offset);
}
}
if object_on(&d.inputs, FROM_OBJECT) {
common["from"] = object_point(&d.inputs, FROM_OBJECT).ok_or("select the object to extrude from")?;
}
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(("solid.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(("solid.extrude".to_string(), with(json!({ "face": p }))));
}
return Ok(out);
}
Kind::Revolve { angle, operation, axis: stored_axis } => {
need(0, "profiles")?;
let axis = match sels(d, 1).first() {
Some(Sel::Axis { name }) => json!(name),
Some(Sel::SketchCurve { id }) => json!(id),
_ => stored_axis.clone().ok_or("select an axis")?,
};
let (sketch, idx) = profiles();
let mut p =
json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")});
if to_object_on(&d.inputs) {
p["to"] = to_object_point(&d.inputs).ok_or("select the object to turn to")?;
}
("solid.revolve", p)
}
Kind::Fillet { radius, chamfer, ctype, distance2, angle, flip, .. } => {
need(0, "edges")?;
// Faces stand for all their edges.
let st = s.world_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 {
let mut p = json!({"edges": edges, "distance": radius});
match *ctype {
1 => p["distance2"] = json!(distance2),
2 => p["angle"] = json!(angle),
_ => {}
}
if *ctype > 0 && *flip {
p["flip"] = json!(true);
}
("solid.chamfer", p)
} else {
let mut p = json!({"edges": edges, "radius": radius});
if d.extra.get("type").and_then(Value::as_str) == Some("variable") {
// The start radius goes at one end of the first edge (Swap Ends: the other).
let ends = sels(d, 0).iter().find_map(|x| match x {
Sel::Edge { body, index, .. } => {
st.body(body).and_then(|b| b.mesh().edges.get(*index).and_then(|e| Some((*e.first()?, *e.last()?))))
}
_ => None,
});
let swap = d.extra.get("swap_ends").and_then(Value::as_bool).unwrap_or(false);
if let Some((a, b)) = ends {
p["start"] = pt(if swap { b } else { a });
}
let mids = mid_points(&d.extra);
if !mids.is_empty() {
p["mid"] = json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>());
if d.extra.get("smooth").and_then(Value::as_bool).unwrap_or(false) {
p["smooth"] = json!(true);
}
}
}
("solid.fillet", p)
}
}
Kind::Shell { thickness } => {
need(0, "faces to remove")?;
("solid.shell", 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")?;
("solid.draft", json!({"faces": face_points(0), "angle": angle, "neutral": neutral}))
}
Kind::Mirror => {
need(0, "objects")?;
need(1, "the mirror plane")?;
let plane = plane_value(s, sels(d, 1).first()).ok_or("the mirror plane must be a plane or a planar face")?;
let mut p = json!({"plane": plane});
pattern_objects(s, sels(d, 0), &mut p);
("solid.mirror", p)
}
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, a sketch line or a straight edge")?.1,
(None, Some(k)) => k,
(None, None) => return Err("select a direction (an axis, a sketch line or a straight edge) first".into()),
};
let mut p = json!({"dir1": pt(d1), "count1": count, "spacing1": spacing});
pattern_objects(s, sels(d, 0), &mut p);
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);
}
("solid.pattern.rectangular", 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()),
};
let mut p = json!({"axis": axis, "count": count, "angle": angle});
pattern_objects(s, sels(d, 0), &mut p);
("solid.pattern.circular", p)
}
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();
("solid.loft", 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")?;
(
"solid.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 }) => json!(name),
Some(face @ Sel::Face { .. }) => {
crate::sketch_attachment_ui::face_plane(app, face).ok_or("the picked face is no longer in the model")?
}
_ => return Err("pick an origin or construction plane, or a planar face".into()),
};
("construct.plane.offset", 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")?,
};
("construct.plane.at_angle", 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 { "solid.web" } else { "solid.rib" }, p)
}
Kind::Emboss { depth, deboss } => {
need(0, "profiles")?;
let (sketch, idx) = profiles();
let mut p = json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }});
if let Some(Sel::Face { body, point, .. }) = sels(d, 1).first() {
p["face"] = pt(*point);
p["targets"] = json!([body]);
}
("solid.emboss", p)
}
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()),
}
("solid.replace_face", 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")] {
let snap = d.extra.get(&format!("{key}_snap")).and_then(Value::as_str).unwrap_or("picked");
match (sels(d, i).first(), snap) {
(Some(Sel::Face { point, .. }), "center") => p[key] = json!({"snap": "face_center", "at": pt(*point)}),
(Some(Sel::Face { point, .. } | Sel::Edge { point, .. }), "circle") => {
p[key] = json!({"snap": "circle_center", "at": pt(*point)})
}
(Some(Sel::Face { point, .. }), _) => p[format!("{key}_face")] = pt(*point),
(Some(Sel::Edge { point, .. }), _) => p[key] = json!({"snap": "edge_mid", "at": pt(*point)}),
(Some(Sel::Vertex { point, .. }), _) => p[key] = pt(*point),
_ => return Err("pick faces, edges or vertices".into()),
}
}
("solid.align", p)
}
Kind::Remove => {
need(0, "bodies")?;
("solid.remove", 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});
pattern_objects(s, sels(d, 0), &mut p);
("solid.pattern.path", 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);
}
("solid.pipe", p)
}
Kind::Stock { margin } => {
let mut p = json!({"margin": margin});
if !sels(d, 0).is_empty() {
p["bodies"] = json!(body_names(0));
}
("solid.bounding_solid", 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();
// A plane, a planar face's plane, or any other face (its surface extended).
let p = match sels(d, 1).first() {
Some(Sel::Plane { name }) => json!({"body": body, "plane": name}),
Some(Sel::Face { body: b, index, point }) => match planar_face(s, b, *index) {
Some((_, n)) => json!({"body": body, "plane": {"origin": pt(*point), "normal": pt(n)}}),
None => json!({"body": body, "tool": {"body": b, "point": pt(*point)}}),
},
_ => return Err("pick a plane or a face".into()),
};
("solid.split_body", p)
}
Kind::Scale { factor } => {
need(0, "bodies")?;
("solid.scale", json!({"bodies": body_names(0), "factor": factor}))
}
Kind::OffsetFaces { distance } => {
need(0, "planar faces")?;
("solid.offset_face", 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);
}
("solid.thread", p)
}
Kind::Material { index } => {
need(0, "bodies")?;
let name = solvecraft_engine::doc::MATERIALS.get(*index).map(|(n, _)| *n).unwrap_or("Default");
("material.assign", 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()),
};
("inspect.section", json!({"plane": plane, "offset": offset, "flip": flip}))
}
Kind::Move { .. } => return Ok(vec![("solid.move".into(), crate::dialogs_move::params(app, d)?)]),
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()])),
}
}
}
let all_points = d.extra.get("all_points").and_then(Value::as_bool).unwrap_or(false);
places.extend(
by_sketch.into_iter().map(|(sk, ids)| if all_points { json!({ "sketch": sk }) } else { 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 to_object_on(&d.inputs) {
params["to"] = to_object_point(&d.inputs).ok_or("select the object to drill to")?;
} else 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(("solid.hole".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::BoundaryFill { operation, cells, .. } => {
need(0, "the tool bodies")?;
let picked: Vec<Value> = cells.iter().filter(|c| c.2).map(|c| pt(c.0)).collect();
if picked.is_empty() {
return Err("tick the cells to fill".into());
}
("SurfaceSculpt", json!({"tools": body_names(0), "cells": picked, "operation": OPS.get(*operation).copied().unwrap_or("new")}))
}
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();
(
"solid.combine",
json!({"target": target, "tools": tools, "operation": OPS.get(*operation).copied().unwrap_or("join"), "keep_tools": keep_tools}),
)
}
Kind::EditParam { name, expr } => ("parameters.change", json!({"name": name, "expression": expr})),
Kind::Rename { feature, name } => ("timeline.rename", json!({"feature": feature, "name": name})),
Kind::ConfirmDelete { feature, .. } => ("timeline.delete", json!({ "features": [feature.to_string()] })),
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } => 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::Surface(k) => return crate::dialogs_surface::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),
Kind::Part(k) => return crate::dialogs_parts::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.
/// What a pattern or mirror copies: picked features are replayed; picked bodies are copied as
/// they are now, with every feature that shaped them (a fillet after the extrude included).
fn pattern_objects(s: &Session, objects: &[Sel], p: &mut Value) {
if objects.iter().any(|x| matches!(x, Sel::Feature { .. })) {
p["features"] = json!(pattern_features(s, objects));
} else {
let bodies: Vec<&str> = objects.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.as_str()) } else { None }).collect();
p["bodies"] = json!(bodies);
}
}
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.world_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.world_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 } => match name.as_str() {
"X" => Some((Vec3::ZERO, Vec3::X)),
"Y" => Some((Vec3::ZERO, Vec3::Y)),
"Z" => Some((Vec3::ZERO, Vec3::Z)),
// A construction axis.
n => solvecraft_engine::view::construction_axes(&app.session).into_iter().find(|(_, an, _, _)| an == n).map(|(_, _, o, d)| (o, d)),
},
Sel::SketchCurve { id } => {
let st = app.session.world_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()?))
}
// A straight model edge, along it.
Sel::Edge { body, index, .. } => {
let b = app.session.world_state().body(body).cloned()?;
let m = b.mesh();
let e = m.edges.get(*index)?;
let (a, z) = (*e.first()?, *e.last()?);
let dir = (z - a).normalized()?;
let tol = 1e-6 * a.dist(z).max(1.0);
if !e.iter().all(|p| (*p - a).cross(dir).len() < tol) {
return None;
}
// An edge along an origin axis points the way the axis does (as picking the axis).
let axis = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().find(|x| dir.dot(*x).abs() > 1.0 - 1e-9);
Some((a, axis.unwrap_or(dir)))
}
_ => 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::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
solvecraft_engine::doc::profile_selection_indices(ss, sel).unwrap_or_default()
}
ProfileSel::Curves { loops } => loops
.iter()
.filter_map(|l| {
solvecraft_engine::doc::profile_selection_indices(ss, &ProfileSel::Curves { loops: vec![l.clone()] }).ok()?.first().copied()
})
.collect(),
ProfileSel::Points { points } => points.iter().filter_map(|q| solvecraft_engine::doc::sketch::profile_at_point(ps, *q)).collect(),
}
}
thread_local! {
/// A dialog is being filled from a feature: its points are in component frames.
static REFILL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// While filling a dialog from a feature (dropped at the end).
struct Refill;
impl Refill {
fn start() -> Refill {
REFILL.with(|r| r.set(true));
Refill
}
}
impl Drop for Refill {
fn drop(&mut self) {
REFILL.with(|r| r.set(false));
}
}
/// A point stored in a feature, as it shows on world body `body`: features keep their picks in
/// the frame of the body they were on, so each body's placement takes the point to the world
/// (a moved component, a pasted instance). Other points are the world already.
fn stored_on(frames: &Option<Vec<(String, solvecraft_engine::doc::Mat)>>, body: &str, p: Vec3) -> Vec3 {
frames.as_ref().and_then(|f| f.iter().find(|(n, _)| n == body)).map(|(_, m)| solvecraft_engine::doc::apply_point(m, p)).unwrap_or(p)
}
/// Body placements while filling a dialog from a feature (when anything is placed).
fn stored_frames(s: &Session) -> Option<Vec<(String, solvecraft_engine::doc::Mat)>> {
if !REFILL.with(std::cell::Cell::get) || !solvecraft_engine::frames::placed(s) {
return None;
}
let st = s.model.state();
Some(s.doc.body_frames(&st).into_iter().map(|(w, _, _, m)| (w, m)).collect())
}
/// The edge of a visible body through (or nearest) a point.
pub(crate) fn edge_sel(s: &Session, p0: Vec3) -> Option<Sel> {
let st = s.world_state();
let frames = stored_frames(s);
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let p = stored_on(&frames, &b.name, p0);
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, p0: Vec3) -> Option<Sel> {
let st = s.world_state();
let frames = stored_frames(s);
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let p = stored_on(&frames, &b.name, p0);
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 * p0.len()).map(|x| x.1)
}
fn emboss_face_sel(s: &Session, component: u64, face: &solvecraft_engine::doc::EmbossFace, persistent: &str) -> Option<Sel> {
let source = s.model.state();
let body = source.body(&face.body)?;
let index = solvecraft_engine::doc::naming::face_names(body).iter().position(|name| name == persistent)?;
let owner = s.doc.model_body_component(body);
let world_name = s.doc.world_body_name(&source, &face.body, owner, &face.path);
let author = solvecraft_engine::doc::EmbossFace::component_frame(&s.doc, component, &face.author_path).ok()?;
let point = solvecraft_engine::doc::apply_point(&author, face.at);
s.world_state().body(&world_name)?;
Some(Sel::Face { body: world_name, index, point })
}
pub(crate) 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,
}
}
pub(crate) 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 point of expressions: numbers as numbers, expressions as strings.
fn pt_expr(p: &solvecraft_engine::doc::PointExpr) -> Value {
Value::Array(p.iter().map(|e| e.parse::<f64>().ok().filter(|x| x.is_finite()).map_or_else(|| json!(e), |x| json!(x))).collect())
}
/// 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 _refill = Refill::start();
let s = &app.session;
let f = s.doc.feature(id)?.clone();
let st = s.world_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("solid.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()),
taper_present: extent.taper.is_some(),
to_offset: extent.to_offset.clone().unwrap_or_default(),
start: extent.start_offset.clone().unwrap_or_default(),
all: extent.through_all,
};
// Extent references belong to the extrude's authoring frame, including free
// points. They are not body-local picks (a reference may be on another body).
let frame = feature_frame(s, f.component);
for (label, point) in [(TO_OBJECT, extent.to), (FROM_OBJECT, extent.from)] {
if let Some(point) = point {
set_object(&mut d.inputs, &mut d.active, label, true);
if let Some(input) = d.inputs.iter_mut().find(|i| i.label == label) {
input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, point) }];
}
}
}
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::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, convention, to } => {
let mut d = start("solid.revolve")?;
let frame = feature_frame(s, f.component);
let stored_axis = match axis {
AxisRef::Line { origin, dir } => Some(json!({
"origin": pt(solvecraft_engine::doc::apply_point(&frame, *origin)),
"dir": pt(solvecraft_engine::doc::apply_vector(&frame, *dir))
})),
_ => None,
};
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation), axis: stored_axis };
d.extra.insert("convention".into(), json!(convention));
if let Some(a2) = angle2 {
d.extra.insert("angle2".into(), json!(a2));
}
if *symmetric {
d.extra.insert("direction".into(), json!("symmetric"));
}
if let Some(point) = to {
set_to_object(&mut d.inputs, &mut d.active, true);
if let Some(input) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) {
input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, *point) }];
}
}
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::SketchAxis { axis } => Some(Sel::Axis { name: axis.to_ascii_lowercase() }),
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 { "solid.chamfer" } else { "solid.fillet" })?;
d.kind = fillet_kind(radius, chamfer);
if let Kind::Fillet { chain, ctype, distance2, angle, flip, .. } = &mut d.kind {
*chain = false;
// An unequal chamfer: two distances, or a distance and an angle.
if let FeatureKind::Chamfer { distance2: d2, angle: a, flip: f, .. } = &f.kind {
if let Some(v) = d2 {
*ctype = 1;
*distance2 = v.clone();
} else if let Some(v) = a {
*ctype = 2;
*angle = v.clone();
}
*flip = *f;
}
}
// A chord-length or variable fillet.
if let FeatureKind::Fillet { style, .. } = &f.kind {
match style {
FilletStyle::Constant => {}
FilletStyle::Chord => {
d.extra.insert("type".into(), json!("chord"));
}
FilletStyle::Variable { radius2, start, mid, smooth } => {
d.extra.insert("type".into(), json!("variable"));
d.extra.insert("radius2".into(), json!(radius2));
d.extra.insert("mid".into(), json!(mid.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
d.extra.insert("smooth".into(), json!(smooth));
// The start was the first edge's last point: ends swapped.
let first_end = edges.first().and_then(|p| edge_sel(s, *p)).and_then(|x| match x {
Sel::Edge { body, index, .. } => {
s.world_state().body(&body).and_then(|b| b.mesh().edges.get(index).and_then(|e| e.last().copied()))
}
_ => None,
});
let swapped = first_end.is_some_and(|e| e.dist(*start) < 1e-6);
d.extra.insert("swap_ends".into(), json!(swapped));
}
}
}
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, direction, tangent_chain, .. } => {
let mut d = start("solid.shell")?;
d.kind = Kind::Shell { thickness: thickness.clone() };
d.extra.insert("tangent_chain".into(), json!(tangent_chain));
if !direction.is_empty() && direction != "inside" {
d.extra.insert("direction".into(), json!(direction));
}
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("solid.draft")?;
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, to } => {
let mut d = start("solid.hole")?;
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));
}
}
if to.is_some() {
if let Kind::Hole { opts, .. } = &mut d.kind {
opts.all = false;
}
with_to_object(&mut d, s, *to);
}
// A hole at every point of a sketch.
if points.as_ref().is_some_and(|sp| sp.ids.is_empty()) {
d.extra.insert("all_points".into(), json!(true));
}
d
}
FeatureKind::Box { corner, length, width, height, operation } => {
let mut d = start("solid.box")?;
d.kind = Kind::Primitive {
cmd: "solid.box",
fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())],
operation: op_index(operation),
};
d.extra.insert("corner".into(), pt_expr(corner));
d
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
let mut d = start("solid.cylinder")?;
d.kind = Kind::Primitive {
cmd: "solid.cylinder",
fields: vec![("radius", radius.clone()), ("height", height.clone())],
operation: op_index(operation),
};
d.extra.insert("base".into(), pt_expr(base));
d.extra.insert("axis".into(), pt3(*axis));
d
}
FeatureKind::Sphere { center, radius, operation } => {
let mut d = start("solid.sphere")?;
d.kind = Kind::Primitive { cmd: "solid.sphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) };
d.extra.insert("center".into(), pt_expr(center));
d
}
FeatureKind::Torus { center, major, minor, operation } => {
let mut d = start("solid.torus")?;
d.kind = Kind::Primitive {
cmd: "solid.torus",
fields: vec![("major", major.clone()), ("minor", minor.clone())],
operation: op_index(operation),
};
d.extra.insert("center".into(), pt_expr(center));
d
}
FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => {
let mut d = start("SurfaceSculpt")?;
// The cells again, ticked where a stored cell point lies in the same cell.
let before = s.model.state_before(f.id);
let found = fill_cells(&before, tools);
let filled: Vec<String> =
cells.iter().filter_map(|c| found.iter().min_by(|a, b| a.0.dist(*c).total_cmp(&b.0.dist(*c))).map(|x| x.1.clone())).collect();
let items: Vec<Sel> = tools.iter().map(|n| Sel::Body { name: n.clone() }).collect();
d.kind = Kind::BoundaryFill {
operation: op_index(operation),
cells: found.into_iter().map(|(p, l)| (p, l.clone(), filled.contains(&l))).collect(),
of: items.clone(),
};
if let Some(inp) = d.inputs.first_mut() {
inp.items = items;
}
if *remove_tools {
d.extra.insert("remove_tools".into(), json!(true));
}
d
}
FeatureKind::Combine { target, tools, operation, keep_tools } => {
let mut d = start("solid.combine")?;
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, bodies, .. } => {
let mut d = start("solid.mirror")?;
let ids: Vec<u64> = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect();
// Features that made no body of their own (a hole) 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.get_mut(0) {
if !bodies.is_empty() {
inp.items = bodies.iter().map(|name| Sel::Body { name: name.clone() }).collect();
} else 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 = 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, bodies: copied } => {
let ids: Vec<u64> = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect();
let bodies: Vec<Sel> = if copied.is_empty() {
st.bodies.iter().filter(|b| ids.contains(&b.feature)).map(|b| Sel::Body { name: b.name.clone() }).collect()
} else {
copied.iter().map(|name| Sel::Body { name: name.clone() }).collect()
};
let mut d = match pattern {
PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 } => {
let mut d = start("solid.pattern.rectangular")?;
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("solid.pattern.circular")?;
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 = copied.is_empty() && 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("solid.loft")?;
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("solid.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("construct.plane.offset")?;
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("construct.plane.at_angle")?;
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, tool } => {
let mut d = start("solid.split_body")?;
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 = match tool {
Some(t) => face_sel(s, t.point).into_iter().collect(),
None => plane_sel(s, plane).into_iter().collect(),
};
}
d
}
FeatureKind::Scale { bodies, origin, factor, factors } => {
let mut d = start("solid.scale")?;
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("solid.offset_face")?;
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, modeled } => {
let mut d = start("solid.thread")?;
if *modeled {
d.extra.insert("modeled".into(), json!(true));
}
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("solid.bounding_solid")?;
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("solid.pipe")?;
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, face } => {
let mut d = start("solid.emboss")?;
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));
}
if let Some(face) = face
&& let Some(input) = d.inputs.get_mut(1)
{
input.items = vec![emboss_face_sel(s, f.component, face, f.face_names.first()?)?];
}
d
}
FeatureKind::Rib { curves, thickness, depth, flip, web, .. } => {
let mut d = start(if *web { "solid.web" } else { "solid.rib" })?;
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("solid.replace_face")?;
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("solid.remove")?;
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("solid.move")?;
crate::dialogs_move::for_feature(&mut d, bodies, translate, *rotate_axis, angle.as_ref());
d
}
other => {
let (kind, inputs, extra) = crate::dialogs_sheet::for_feature(app, other)
.or_else(|| crate::dialogs_plastic::for_feature(app, other))
.or_else(|| crate::dialogs_surface::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)
}