Dialog rows for the newer engine options: Thread Modeled; Shell Tangent Chain (on by default) and Direction (inside, outside, both); Revolve Type (Angle, To Object) and Direction (one side, two sides with Angle 2, symmetric); Extrude and Hole extents To Object; Hole on all points of a sketch; Fillet Radius Type (constant, chord length, variable with an end radius and Swap Ends); Combine into a New Component; Align snaps (as picked, centre, circle centre; edges snap at their middle); Boundary Fill dialog (the tools' cells found by sampling, ticked cells filled, Remove Tools). Edit Feature restores them; tests for each mapping
Fixes #17 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ce61aef4a6
commit
6b146942bd
2 files changed
+517
-31
No files matched your search
+410
-31
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solvecraft_engine::doc::{AxisRef, Direction, FeatureKind, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel};
|
||||
use solvecraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel};
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
@@ -124,6 +124,13 @@ pub enum Kind {
|
||||
},
|
||||
/// 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,
|
||||
@@ -308,7 +315,12 @@ impl Dialog {
|
||||
),
|
||||
"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" => Dialog::new(Kind::Shell { thickness: "2 mm".into() }, vec![SelInput::new("Faces/Body", 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)],
|
||||
@@ -325,12 +337,15 @@ impl Dialog {
|
||||
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 | selection::VERTICES, false),
|
||||
SelInput::new("To", FACES | selection::VERTICES, false),
|
||||
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)]),
|
||||
@@ -546,6 +561,7 @@ impl Dialog {
|
||||
| Kind::Hole { .. }
|
||||
| Kind::Primitive { .. }
|
||||
| Kind::Combine { .. }
|
||||
| Kind::BoundaryFill { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -762,6 +778,7 @@ fn title(k: &Kind) -> &'static str {
|
||||
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",
|
||||
@@ -1042,6 +1059,67 @@ 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();
|
||||
@@ -1151,6 +1229,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
auto_operation(app, &mut d);
|
||||
update_measure(app, &mut d);
|
||||
update_fill_cells(app, &mut d);
|
||||
let t = Tokens::get();
|
||||
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
|
||||
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
|
||||
@@ -1236,11 +1315,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
*direction = order.get(pos).copied().unwrap_or(0);
|
||||
ui.end_row();
|
||||
row_label(ui, "Extent Type");
|
||||
let mut ext = usize::from(*all);
|
||||
combo(ui, "ex_ext", &["Distance", "All"], &mut ext);
|
||||
*all = ext == 1;
|
||||
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 !*all {
|
||||
if ext == 0 {
|
||||
row_label(ui, "Distance");
|
||||
enter |= field(ui, distance);
|
||||
ui.end_row();
|
||||
@@ -1265,9 +1346,47 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Revolve { angle, operation } => {
|
||||
row_label(ui, "Angle");
|
||||
enter |= field(ui, angle);
|
||||
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();
|
||||
@@ -1278,10 +1397,26 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
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 {
|
||||
"Radius"
|
||||
match rtype {
|
||||
1 => "Chord Length",
|
||||
2 => "Start Radius",
|
||||
_ => "Radius",
|
||||
}
|
||||
} else if *ctype == 0 {
|
||||
"Distance"
|
||||
} else {
|
||||
@@ -1305,14 +1440,37 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
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();
|
||||
}
|
||||
row_label(ui, "Tangent Chain");
|
||||
ui.checkbox(chain, "");
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Shell { thickness } => {
|
||||
row_label(ui, "Inside 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");
|
||||
@@ -1363,10 +1521,35 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
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);
|
||||
@@ -1400,7 +1583,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Thread { designation, length } => {
|
||||
// Fusion's order: Full Length, then the size; a length only when not full.
|
||||
// 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() {
|
||||
@@ -1458,10 +1645,19 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
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 mut ext = usize::from(opts.all);
|
||||
combo(ui, "hole_ext", &["Distance", "All"], &mut ext);
|
||||
opts.all = ext == 1;
|
||||
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();
|
||||
@@ -1529,6 +1725,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
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();
|
||||
@@ -1668,6 +1867,78 @@ pub fn cancel(app: &mut SolveApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pick input an extent of To Object adds (a face, vertex or point to go to).
|
||||
const TO_OBJECT: &str = "To Object";
|
||||
|
||||
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) {
|
||||
if on && !to_object_on(inputs) {
|
||||
inputs.push(SelInput::new(TO_OBJECT, FACES | selection::VERTICES, false));
|
||||
*active = inputs.len() - 1;
|
||||
} else if !on && to_object_on(inputs) {
|
||||
inputs.retain(|i| i.label != TO_OBJECT);
|
||||
*active = (*active).min(inputs.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The picked To Object place.
|
||||
fn to_object_point(inputs: &[SelInput]) -> Option<Value> {
|
||||
inputs.iter().find(|i| i.label == TO_OBJECT)?.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>) {
|
||||
if let Some(p) = to {
|
||||
set_to_object(&mut d.inputs, &mut d.active, true);
|
||||
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) {
|
||||
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
|
||||
}
|
||||
|
||||
const CHAMFER_TYPES: [&str; 3] = ["Equal Distance", "Two Distances", "Distance and Angle"];
|
||||
|
||||
/// A new Fillet or Chamfer dialog's values.
|
||||
@@ -1828,6 +2099,9 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
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")?;
|
||||
}
|
||||
let with = |extra: Value| -> Value {
|
||||
let mut p = common.clone();
|
||||
if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) {
|
||||
@@ -1858,10 +2132,14 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
_ => return Err("select an axis".into()),
|
||||
};
|
||||
let (sketch, idx) = profiles();
|
||||
(
|
||||
"solid.revolve",
|
||||
json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||||
)
|
||||
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")?;
|
||||
// A To Object turn has no second side.
|
||||
return Ok(vec![("solid.revolve".into(), p)]);
|
||||
}
|
||||
("solid.revolve", p)
|
||||
}
|
||||
Kind::Fillet { radius, chamfer, ctype, distance2, angle, flip, .. } => {
|
||||
need(0, "edges")?;
|
||||
@@ -1905,7 +2183,21 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
("solid.chamfer", p)
|
||||
} else {
|
||||
("solid.fillet", json!({"edges": edges, "radius": radius}))
|
||||
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 });
|
||||
}
|
||||
}
|
||||
("solid.fillet", p)
|
||||
}
|
||||
}
|
||||
Kind::Shell { thickness } => {
|
||||
@@ -2039,10 +2331,16 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
need(2, "where to move it to")?;
|
||||
let mut p = json!({"bodies": body_names(0), "flip": flip});
|
||||
for (i, key) in [(1, "from"), (2, "to")] {
|
||||
match sels(d, i).first() {
|
||||
Some(Sel::Face { point, .. }) => p[format!("{key}_face")] = pt(*point),
|
||||
Some(Sel::Vertex { point, .. }) => p[key] = pt(*point),
|
||||
_ => return Err("pick faces or vertices".into()),
|
||||
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)
|
||||
@@ -2161,7 +2459,10 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
}
|
||||
}
|
||||
places.extend(by_sketch.into_iter().map(|(sk, ids)| json!({"sketch": sk, "points": ids})));
|
||||
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 })));
|
||||
}
|
||||
@@ -2170,7 +2471,9 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
let mut params = place;
|
||||
params["diameter"] = json!(diameter);
|
||||
params["type"] = json!(ty);
|
||||
if !opts.all {
|
||||
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" {
|
||||
@@ -2202,6 +2505,14 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
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")?;
|
||||
@@ -2434,6 +2745,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
start: extent.start_offset.clone().unwrap_or_default(),
|
||||
all: extent.through_all,
|
||||
};
|
||||
with_to_object(&mut d, s, extent.to);
|
||||
// The dialog has no "flip": a flipped extrude is a negative distance.
|
||||
if let Kind::Extrude { direction, distance, .. } = &mut d.kind
|
||||
&& *direction == 1
|
||||
@@ -2452,9 +2764,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, .. } => {
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, to } => {
|
||||
let mut d = start("solid.revolve")?;
|
||||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) };
|
||||
if let Some(a2) = angle2 {
|
||||
d.extra.insert("angle2".into(), json!(a2));
|
||||
}
|
||||
if *symmetric {
|
||||
d.extra.insert("direction".into(), json!("symmetric"));
|
||||
}
|
||||
with_to_object(&mut d, s, *to);
|
||||
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();
|
||||
@@ -2490,14 +2809,40 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
*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 } => {
|
||||
d.extra.insert("type".into(), json!("variable"));
|
||||
d.extra.insert("radius2".into(), json!(radius2));
|
||||
// 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, .. } => {
|
||||
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();
|
||||
}
|
||||
@@ -2515,7 +2860,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
d.extra.insert("pull".into(), pt3(*pull));
|
||||
d
|
||||
}
|
||||
FeatureKind::Hole { position, direction, diameter, depth, hole, points, thread, .. } => {
|
||||
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 {
|
||||
@@ -2562,6 +2907,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
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 } => {
|
||||
@@ -2601,6 +2956,27 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
d.extra.insert("center".into(), pt3(*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 };
|
||||
@@ -2781,8 +3157,11 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Thread { face, designation, length, .. } => {
|
||||
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();
|
||||
|
||||
@@ -219,3 +219,110 @@ fn chamfer_types_send_their_values() {
|
||||
let e = crate::dialogs::for_feature(&app, ch, None).unwrap();
|
||||
assert!(matches!(&e.kind, Kind::Fillet { chamfer: true, ctype: 2, angle, flip: true, .. } if angle == "30 deg"), "{:?}", e.kind);
|
||||
}
|
||||
|
||||
/// Apply a dialog's commands to the session; the new feature's id.
|
||||
fn apply(app: &mut SolveApp, d: &Dialog) -> u64 {
|
||||
for (id, p) in apply_commands(app, d).unwrap() {
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
}
|
||||
app.session.doc.features.last().unwrap().id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_direction_and_tangent_chain_round_trip() {
|
||||
let mut app = boxed();
|
||||
let top = face(&app, Vec3::new(20.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![top], "solid.shell");
|
||||
// Tangent Chain is on, as in Fusion; Direction Outside.
|
||||
assert_eq!(d.extra.get("tangent_chain"), Some(&json!(true)));
|
||||
d.extra.insert("direction".into(), json!("outside"));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!((p["direction"].clone(), p["tangent_chain"].clone()), (json!("outside"), json!(true)));
|
||||
let id = apply(&mut app, &d);
|
||||
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
|
||||
assert_eq!(e.extra.get("direction"), Some(&json!("outside")));
|
||||
assert_eq!(e.extra.get("tangent_chain"), Some(&json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_fillet_round_trip() {
|
||||
let mut app = boxed();
|
||||
let e0 = edge(&app, Vec3::new(40.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![e0], "solid.fillet");
|
||||
d.inputs[0].items.truncate(1);
|
||||
d.extra.insert("type".into(), json!("variable"));
|
||||
d.extra.insert("radius2".into(), json!("4 mm"));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!((p["type"].clone(), p["radius2"].clone()), (json!("variable"), json!("4 mm")));
|
||||
assert!(p["start"].is_array(), "{p}");
|
||||
let id = apply(&mut app, &d);
|
||||
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
|
||||
assert_eq!(e.extra.get("type"), Some(&json!("variable")));
|
||||
assert_eq!(e.extra.get("radius2"), Some(&json!("4 mm")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_options_reach_their_commands() {
|
||||
let mut app = boxed();
|
||||
// Thread: Modeled.
|
||||
let side = face(&app, Vec3::new(20.0, 0.0, 10.0));
|
||||
let mut d = start(&mut app, vec![side.clone()], "solid.thread");
|
||||
d.extra.insert("modeled".into(), json!(true));
|
||||
assert_eq!(apply_commands(&app, &d).unwrap()[0].1["modeled"], json!(true));
|
||||
// Combine into a new component.
|
||||
app.dialog = None;
|
||||
let mut d = start(&mut app, vec![], "solid.combine");
|
||||
d.extra.insert("new_component".into(), json!(true));
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
|
||||
d.inputs[1].items = vec![Sel::Body { name: "Body2".into() }];
|
||||
assert_eq!(apply_commands(&app, &d).unwrap()[0].1["new_component"], json!(true));
|
||||
// Hole: To Object sends the place to drill to instead of a depth.
|
||||
app.dialog = None;
|
||||
let top = face(&app, Vec3::new(20.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![top], "solid.hole");
|
||||
d.inputs.push(crate::selection::SelInput::new("To Object", crate::selection::FACES | crate::selection::VERTICES, false));
|
||||
d.inputs[1].items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))];
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert!(p["to"].is_array() && p.get("depth").is_none(), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_fill_finds_the_cells_and_fills_the_ticked_ones() {
|
||||
let mut app = app_with(json!([
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20}},
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20, "corner": [10, 10, 0], "operation": "new"}}
|
||||
]));
|
||||
let mut d = start(&mut app, vec![], "SurfaceSculpt");
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Body2".into() }];
|
||||
app.dialog = Some(d);
|
||||
// The dialog finds the cells on its next frame.
|
||||
let ctx = egui::Context::default();
|
||||
let _ = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||||
let mut d = app.dialog.take().unwrap();
|
||||
let Kind::BoundaryFill { cells, .. } = &mut d.kind else { panic!() };
|
||||
// Only A, only B, and both.
|
||||
assert_eq!(cells.len(), 3, "{cells:?}");
|
||||
assert!(cells.iter().any(|c| c.1 == "Inside Body1, Body2"));
|
||||
// Fill just the overlap.
|
||||
for c in cells.iter_mut() {
|
||||
c.2 = c.1 == "Inside Body1, Body2";
|
||||
}
|
||||
let id = apply(&mut app, &d);
|
||||
assert!(app.session.model.result(id).is_some_and(|r| r.error.is_none()));
|
||||
// A new body filling the 10 x 10 x 20 overlap.
|
||||
let vols: Vec<f64> = app.session.model.state().bodies.iter().map(|b| b.mesh().measure().volume).collect();
|
||||
assert!(vols.len() == 3 && vols.iter().any(|v| (v - 2000.0).abs() < 1.0), "{vols:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_snaps_to_centres_and_edge_middles() {
|
||||
let mut app = boxed();
|
||||
let mut d = start(&mut app, vec![], "solid.align");
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
|
||||
d.inputs[1].items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))];
|
||||
d.inputs[2].items = vec![edge(&app, Vec3::new(20.0, 0.0, 20.0))];
|
||||
d.extra.insert("from_snap".into(), json!("center"));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!(p["from"]["snap"], json!("face_center"));
|
||||
assert_eq!(p["to"]["snap"], json!("edge_mid"));
|
||||
}
|
||||
Reference in new issue
Block a user