One tool window for sketch Fillet and the three sketch Chamfers (#789)

Sketch Fillet and the three sketch Chamfers share one tool window, with its own icons, labels and a scenario.
This commit is contained in:
William Dunn authored and GitHub committed 2026-10-13 14:32:13 -04:00
1 parent dbfb689f6c
commit bdcfe3ff1d
13 files changed
+567 -70

No files matched your search

+168 -4
View File
@@ -8,17 +8,30 @@
//! right-click, then one more click for the line, with nothing on screen saying what was
//! picked. Move joins them too, which had no toolbar placement, no tool and no dialog: its only
//! route into the UI was the sketch context menu, and that went through the same
//! empty-parameter fallback and reported an engine error. `sketch.fillet`, the three
//! `sketch.chamfer.*` commands and the sketch patterns are the same gap and join this module
//! later, each its own change.
//! empty-parameter fallback and reported an engine error. `Mod::Corner` joins them here,
//! serving `sketch.fillet` and the three `sketch.chamfer.*` commands together, the way
//! `solid.fillet` and `solid.chamfer` already share one window. Before it, a click committed a
//! size guessed at 20 percent of the shorter leg and then opened a bare parameter editor, so
//! the design changed before any number was asked for. `sketch.chamfer.distance_angle` sent a
//! hard-coded 45 degrees and `sketch.chamfer.two_distance` forced its second distance equal to
//! the first, leaving both values unreachable from the UI. The sketch patterns are the same
//! gap and join this module later, each its own change.
//!
//! The Chamfer Type choice is a row of icon buttons rather than a drop-down: the modes differ
//! in shape, and a picture of each shape names it better than a line of text.
use egui::Pos2;
use serde_json::{Map, Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::expr::Kind as ValueKind;
use crate::SolveApp;
use crate::dialogs::{Kind, check, field, row_label};
use crate::dialogs::{CHAMFER_TYPES, Kind, check, field, icon_choice, row_label};
use crate::selection::{AXES, CURVES, POINTS, SelInput};
use crate::viewport::{Hit, Proj};
/// The three chamfer types, drawn as the cut each one makes, in `CHAMFER_TYPES` order.
const CHAMFER_TYPE_ICONS: [&str; 3] = ["sketch_chamfer_equal", "sketch_chamfer_two", "sketch_chamfer_angle"];
/// A sketch modify tool window's values.
#[derive(Clone, Debug, PartialEq)]
@@ -39,6 +52,13 @@ pub enum Mod {
/// turn about the centre, and whether the originals stay behind. The command already
/// carries the copy flag, so the Create Copy row needs no engine change.
Move { x: String, y: String, angle: String, copy: bool },
/// `sketch.fillet` and the three `sketch.chamfer.*` commands. `chamfer` chooses between a
/// fillet and a chamfer and never changes while the window is open; `ctype` chooses which
/// of the three chamfer commands runs (0 equal distance, 1 two distances, 2 distance and
/// angle). `size` is the radius or the first distance, `distance2` the second distance and
/// `angle` the angle from the first line. `flip` names the two lines in the other order, so
/// the first distance lands on the other leg of the corner.
Corner { chamfer: bool, ctype: usize, size: String, distance2: String, angle: String, flip: bool },
}
impl Mod {
@@ -48,6 +68,8 @@ impl Mod {
Mod::Offset { .. } => "OFFSET",
Mod::Mirror => "MIRROR",
Mod::Move { .. } => "MOVE/COPY",
Mod::Corner { chamfer: false, .. } => "FILLET",
Mod::Corner { .. } => "CHAMFER",
}
}
@@ -58,6 +80,8 @@ impl Mod {
Mod::Offset { distance, .. } => Some(("Distance", ValueKind::Length, distance)),
Mod::Move { x, .. } => Some(("X Distance", ValueKind::Length, x)),
Mod::Mirror => None,
Mod::Corner { chamfer: false, size, .. } => Some(("Radius", ValueKind::Length, size)),
Mod::Corner { size, .. } => Some(("Distance", ValueKind::Length, size)),
}
}
}
@@ -69,16 +93,32 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
// AXES is the mask that takes a straight sketch curve (`selection::SelInput::accepts`).
Mod::Mirror => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Mirror line", AXES, false)],
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Center", POINTS, false)],
// A fillet rounds any number of corners to one radius (`points`). Each chamfer command
// cuts one corner, so its input holds one.
Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", POINTS, true)],
Mod::Corner { .. } => vec![SelInput::new("Corner", POINTS, false)],
}
}
/// The dialog for a sketch modify command.
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
let corner = |chamfer: bool, ctype: usize| Mod::Corner {
chamfer,
ctype,
size: "2 mm".into(),
distance2: "2 mm".into(),
angle: "45 deg".into(),
flip: false,
};
let k = match id {
"sketch.scale" => Mod::Scale { factor: "2".into() },
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), chain: true, flip: false },
"sketch.mirror" => Mod::Mirror,
"sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false },
"sketch.fillet" => corner(false, 0),
"sketch.chamfer.equal_distance" => corner(true, 0),
"sketch.chamfer.two_distance" => corner(true, 1),
"sketch.chamfer.distance_angle" => corner(true, 2),
_ => return None,
};
let i = inputs(&k);
@@ -121,10 +161,62 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
check(ui, copy);
ui.end_row();
}
Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => {
if *chamfer {
row_label(ui, "Chamfer Type");
icon_choice(ui, &CHAMFER_TYPES, &CHAMFER_TYPE_ICONS, ctype);
ui.end_row();
}
row_label(ui, size_label(*chamfer, *ctype));
enter |= field(ui, size);
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();
}
// With two equal distances there is nothing to swap.
if *chamfer && *ctype > 0 {
row_label(ui, "Flip");
check(ui, flip);
ui.end_row();
}
}
}
enter
}
/// What the first value means for this corner type.
fn size_label(chamfer: bool, ctype: usize) -> &'static str {
if !chamfer {
"Radius"
} else if ctype == 0 {
"Distance"
} else {
"Distance 1"
}
}
/// The corner under the cursor while the Fillet or Chamfer window is open: the hover hit and
/// the selection it stands for.
///
/// Corners are resolved through `sketch_tools::corner_point` rather than the ordinary
/// nearest-point test. A rectangle drawn from the origin leaves its own endpoint on top of the
/// fixed origin point, and the nearest-point test returns the origin, which ends no two curves.
/// That resolution chooses among the endpoints that actually make a corner instead.
pub(crate) fn corner_hit(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Hit, Sel)> {
let Some(Kind::SketchModify(Mod::Corner { chamfer, .. })) = app.dialog.as_ref().map(|d| &d.kind) else { return None };
let sketch = app.session.active_sketch?;
let (at, id) = crate::sketch_tools::corner_point(app, proj, pos, !*chamfer)?;
let sel = Sel::SketchPoint { id: format!("{sketch}:{id}") };
Some((Hit::SketchPoint { sketch, id, at }, sel))
}
/// What input `i` holds.
fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
@@ -195,6 +287,53 @@ fn signed_length(app: &SolveApp, text: &str, flip: bool) -> Result<f64, String>
Ok(if flip { -v } else { v })
}
/// Corner point ids picked in input `i`, within the active sketch. A `POINTS` pick is stored as
/// "<sketch id>:<point id>"; a point selected without the window carries the bare id.
fn corner_ids(app: &SolveApp, inputs: &[SelInput], i: usize) -> Vec<String> {
let active = app.session.active_sketch;
items(inputs, i)
.iter()
.filter_map(|x| match x {
Sel::SketchPoint { id } => match id.split_once(':') {
Some((sketch, point)) => (sketch.parse::<u64>().ok() == active).then(|| point.to_string()),
None => Some(id.clone()),
},
_ => None,
})
.collect()
}
/// A corner size evaluated against the document's parameters, which every corner command needs
/// to be strictly positive. A bare number means the design's units for a length and degrees for
/// an angle, as every other typed sketch value does. `value` above is the Move window's reading
/// of a field, where an empty row means zero and a negative distance is a direction; neither is
/// a radius or a chamfer leg, so this one is its own.
fn corner_size(app: &SolveApp, label: &str, text: &str, kind: ValueKind) -> Result<f64, String> {
let doc = &app.session.doc;
let v = doc.eval(&doc.with_design_unit(text.trim(), kind), kind).map_err(|e| format!("{label}: {e}"))?;
if !v.is_finite() || v <= 1e-9 {
return Err(format!("{label} must be a positive number"));
}
Ok(v)
}
/// The corner as a chamfer command takes it. Unflipped it is the point itself, and the command
/// resolves the two lines in curve order. Flipped it names those two lines in the other order,
/// so `distance` applies to the other leg.
fn corner_ref(app: &SolveApp, point: &str, flip: bool) -> Result<Value, String> {
if !flip {
return Ok(json!({ "point": point }));
}
let gone = || "that corner is no longer in the sketch".to_string();
let sketch = app.session.active_sketch.ok_or("open a sketch first")?;
let state = app.session.world_state();
let sk = &state.sketch(sketch).ok_or_else(gone)?.sketch;
let index = sk.resolve_point(point).ok_or_else(gone)?;
let (a, b) = crate::sketch_tools::corner_curves(sk, index, false).ok_or("a chamfer needs a corner of exactly two lines")?;
let id = |c: usize| sk.curves.get(c).map(|x| x.id.clone()).ok_or_else(gone);
Ok(json!({"a": id(b)?, "b": id(a)?}))
}
/// The command a sketch modify dialog makes. `sketch.scale` takes a plain evaluated number for
/// its factor, `sketch.offset` one for its distance and `sketch.move` its distances and angle,
/// not expression strings: none has a model parameter to carry one, unlike `sketch.fillet`'s radius.
@@ -254,6 +393,31 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
}
("sketch.move", p)
}
Mod::Corner { chamfer: false, size, .. } => {
let corners = corner_ids(app, inputs, 0);
if corners.is_empty() {
return Err("select the corners to round first".into());
}
// The radius is checked where it is evaluated, against the document's parameters.
("sketch.fillet", json!({"points": corners, "radius": size.trim()}))
}
Mod::Corner { ctype, size, distance2, angle, flip, .. } => {
let point = corner_ids(app, inputs, 0).into_iter().next().ok_or("select the corner to cut first")?;
let mut p = corner_ref(app, &point, *flip && *ctype > 0)?;
p["distance"] = json!(corner_size(app, size_label(true, *ctype), size, ValueKind::Length)?);
match ctype {
1 => {
p["distance2"] = json!(corner_size(app, "Distance 2", distance2, ValueKind::Length)?);
("sketch.chamfer.two_distance", p)
}
2 => {
// The command takes degrees; an evaluated angle is in radians.
p["angle"] = json!(corner_size(app, "Angle", angle, ValueKind::Angle)?.to_degrees());
("sketch.chamfer.distance_angle", p)
}
_ => ("sketch.chamfer.equal_distance", p),
}
}
};
if let Value::Object(m) = &mut params {
for (key, value) in extra {