Sketch Chamfer: several corners at once, as Fillet does (#8998)

The three sketch chamfers take `points: [corners]` (a point reference or
{a, b} each), as sketch.fillet does: every corner is cut alike in one
edit, and a corner that can't be cut, or is named twice, refuses them all.
The Chamfer window sends every picked corner instead of refusing a second.


Claude-Session: https://claude.ai/code/session_01PVKpSVXR9iiwSM9B25kpvq

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ben Rog-Wilhelm
2026-10-31 19:28:14 +00:00
committed by GitHub
parent eda51873aa
commit 8be56316f6
6 changed files with 152 additions and 38 deletions
@@ -98,6 +98,59 @@ fn fillet_and_chamfer_corners() {
assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s));
}
/// Each chamfer cuts several corners at once (`points`), as a fillet rounds them: every corner
/// alike, in one edit, and a corner that can't be cut, or is named twice, refuses them all.
#[test]
fn chamfers_cut_several_corners() {
let corners = json!(["l1.start", "l1.end", "l2.end", "l3.end"]);
for (cmd, sizes, cut) in [
("sketch.chamfer.equal_distance", json!({"distance": 4}), 8.0),
("sketch.chamfer.two_distance", json!({"distance": 2, "distance2": 6}), 6.0),
("sketch.chamfer.distance_angle", json!({"distance": 3, "angle": 45}), 4.5),
] {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let mut p = sizes.clone();
p["points"] = corners.clone();
let out = run(&mut s, cmd, p);
assert_eq!(curves(&out).len(), 4, "{cmd}: {out}");
assert_eq!(sk(&s).curves.len(), 8, "{cmd}");
assert!(solved(&s), "{cmd}");
assert!((area(&s) - (1200.0 - 4.0 * cut)).abs() < 1e-6, "{cmd}: {}", area(&s));
}
// A corner named by its two lines, beside one named by its point.
let mut s = new_sketch();
let c = curves(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})));
run(&mut s, "sketch.chamfer.equal_distance", json!({"points": [{"a": c[0], "b": c[1]}, "l3.end"], "distance": 4}));
assert!((area(&s) - (1200.0 - 2.0 * 8.0)).abs() < 1e-6, "{}", area(&s));
// Two lines that share no end are brought to where they meet, beside a corner they already
// make; named again by its point once they meet, that corner is listed twice.
let mut s = new_sketch();
let la = curves(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "connect": false})))[0].clone();
let lb = curves(&run(&mut s, "sketch.line", json!({"points": [[20, 5], [20, 20]], "connect": false})))[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{lb}.end"), [0, 20]]}));
let e = s.execute("sketch.chamfer.equal_distance", &json!({"points": [{"a": la, "b": lb}, format!("{la}.end")], "distance": 4}));
assert!(e.unwrap_err().to_string().contains("listed twice"));
assert_eq!(sk(&s).curves.len(), 3);
let out = run(&mut s, "sketch.chamfer.equal_distance", json!({"points": [{"a": la, "b": lb}, format!("{lb}.end")], "distance": 4}));
assert_eq!(curves(&out).len(), 2, "{out}");
assert!(solved(&s));
let refused = [
// 20 mm back from both ends of a 30 mm side: the second corner doesn't fit.
json!({"points": corners, "distance": 20}),
// One corner by both of its lines' ends: cut once, the second name would be a new corner.
json!({"points": ["l1.end", "l2.start"], "distance": 4}),
json!({"points": [], "distance": 1}),
];
for p in refused {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
assert!(s.execute("sketch.chamfer.equal_distance", &p).is_err(), "{p}");
assert_eq!(sk(&s).curves.len(), 4, "{p}");
assert!((area(&s) - 1200.0).abs() < 1e-6, "{p}");
}
}
/// A chamfer where the two lines have no corner is refused, as a fillet there is: two lines
/// lying on each other made a chamfer line of no length between their cut-back ends (found by a
/// local trim fuzzer), and two lines in line with each other have no corner either.
+64 -28
View File
@@ -29,17 +29,17 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params(concat!("point | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance", created_doc!())),
.params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance", created_doc!())),
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer_angle")
.enabled(in_sketch)
.params(concat!("point | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance: along a, angle: deg from a", created_doc!())),
.params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}, and a point's `a` is the first of its two lines in curve order) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance: along a, angle: deg from a", created_doc!())),
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer_two_distances")
.enabled(in_sketch)
.params(concat!("point | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance: along a, distance2: along b", created_doc!())),
.params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}, and a point's `a` is the first of its two lines in curve order) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance: along a, distance2: along b", created_doc!())),
CommandSpec::new("sketch.offset", "Offset", offset)
.at("SKETCH", "MODIFY")
.icon("offset")
@@ -1722,15 +1722,20 @@ fn corner_arg(q: &Value) -> Value {
}
}
/// The corners a fillet or chamfer works on: several at once (`points`), each like a single
/// `point`, or the one corner `point` or `a`, `b` name.
fn corner_list(p: &Value, cmd: &str) -> Result<Vec<Value>> {
match p.get("points").and_then(Value::as_array) {
Some(a) if !a.is_empty() && a.len() <= 500 => Ok(a.iter().map(corner_arg).collect()),
Some(_) => Err(bad(cmd, "`points` must list 1…500 corners")),
None => Ok(vec![p.clone()]),
}
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.fillet";
let r_expr = req_expr(cmd, p, "radius")?;
// Several corners at once (`points`), each like a single `point`.
let corners: Vec<Value> = match p.get("points").and_then(Value::as_array) {
Some(a) if !a.is_empty() && a.len() <= 500 => a.iter().map(corner_arg).collect(),
Some(_) => return Err(bad(cmd, "`points` must list 1…500 corners")),
None => vec![p.clone()],
};
let corners = corner_list(p, cmd)?;
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let r = doc.eval(&r_expr, solidcraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?;
if !(r > 1e-9 && r < 1e8) {
@@ -1938,32 +1943,63 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<
}
}
fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Result<(f64, f64)>) -> Result<Value> {
fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl Fn(f64) -> Result<(f64, f64)>) -> Result<Value> {
let corners = corner_list(p, cmd)?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
refuse_spatial_corner(sk, p, cmd)?;
refuse_infinite_corner(sk, p, cmd)?;
refuse_offset_corner(sk, p, cmd)?;
weld_corner(sk, p);
meet_lines(sk, p, cmd)?;
let (la, lb, c, fa, fb) = corner(sk, p, cmd)?;
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y));
let corner_angle = u.cross(v).atan2(u.dot(v)).abs();
// Lines in line with each other, or lying on each other, have no corner to cut, as a
// fillet refuses them: on two lines lying on each other the chamfer was a line of no
// length between their cut-back ends.
if !(2e-6..=std::f64::consts::PI - 2e-9).contains(&corner_angle) {
return Err(bad(cmd, "the lines are parallel"));
// Every corner is cut alike, in one edit: a corner that can't be cut refuses them all.
// A corner named twice (its point, then its two lines, say) would be cut again where
// the first cut left a new corner, so it is refused. Two lines that share no end are
// brought to where they meet first, so that corner is found by its point too.
let mut seen = std::collections::BTreeSet::new();
for q in &corners {
prepare_corner(sk, q, cmd)?;
if let Ok((la, lb, c, _, _)) = corner(sk, q, cmd)
&& !seen.insert((c, la.min(lb), la.max(lb)))
{
return Err(bad(cmd, "a corner is listed twice"));
}
}
let (da, db) = dists(corner_angle)?;
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
let l = sk.add_line_pts(ta, tb, None)?;
Ok((ids_of(sk, &[l]), kept_json(vec![kept])))
let (mut lines, mut kept) = (Vec::new(), Vec::new());
for q in &corners {
let (l, k) = chamfer_corner(sk, q, cmd, &dists)?;
lines.push(l);
kept.push(k);
}
Ok((ids_of(sk, &lines), kept_json(kept)))
})?;
let (curves, (vc, moved)) = out;
Ok(json!({"curves": curves, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
}
/// Cut one corner (`point` or `a`, `b`) of two lines with a straight line, `dists` giving how
/// far back along each line from the corner's angle: the new line and what became of the corner.
fn chamfer_corner(sk: &mut Sketch, p: &Value, cmd: &str, dists: &impl Fn(f64) -> Result<(f64, f64)>) -> Result<(usize, CornerKept)> {
prepare_corner(sk, p, cmd)?;
let (la, lb, c, fa, fb) = corner(sk, p, cmd)?;
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y));
let corner_angle = u.cross(v).atan2(u.dot(v)).abs();
// Lines in line with each other, or lying on each other, have no corner to cut, as a
// fillet refuses them: on two lines lying on each other the chamfer was a line of no
// length between their cut-back ends.
if !(2e-6..=std::f64::consts::PI - 2e-9).contains(&corner_angle) {
return Err(bad(cmd, "the lines are parallel"));
}
let (da, db) = dists(corner_angle)?;
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
Ok((sk.add_line_pts(ta, tb, None)?, kept))
}
/// The checks and joins before a chamfer cuts a corner. Running them again on a corner already
/// prepared changes nothing.
fn prepare_corner(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<()> {
refuse_spatial_corner(sk, p, cmd)?;
refuse_infinite_corner(sk, p, cmd)?;
refuse_offset_corner(sk, p, cmd)?;
weld_corner(sk, p);
meet_lines(sk, p, cmd)
}
fn chamfer_equal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.chamfer.equal_distance";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
+9 -6
View File
@@ -204,11 +204,10 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
Mod::PatternCirc { .. } => {
vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true), SelInput::new("Center", SKETCH_POINTS, false)]
}
// A fillet rounds any number of corners to one radius (`points`). Each chamfer command
// cuts one corner: its point, or its two lines. Two lines name the corner between them
// where more lines meet at its point (#1824), for both.
Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", SKETCH_POINTS | CURVES, true)],
Mod::Corner { .. } => vec![SelInput::new("Corner", SKETCH_POINTS | CURVES, true)],
// A fillet rounds any number of corners to one radius, and a chamfer cuts any number
// alike (`points`). Two lines name the corner between them where more lines meet at its
// point (#1824), for both.
Mod::Corner { .. } => vec![SelInput::new("Corners", SKETCH_POINTS | CURVES, true)],
Mod::Equation { .. } => Vec::new(),
}
}
@@ -1050,11 +1049,15 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
let flipped = *flip && *ctype > 0;
let mut p = match (corner_ids(app, inputs, 0).as_slice(), line_ids(inputs, 0).as_slice()) {
([point], []) => corner_ref(app, point, flipped)?,
// Several corners are cut alike, each flipped as one alone would be.
(points, []) if !points.is_empty() => {
json!({"points": points.iter().map(|point| corner_ref(app, point, flipped)).collect::<Result<Vec<_>, _>>()?})
}
// Flip swaps the lines, so `distance` applies to the other leg.
([], [a, b]) if flipped => json!({"a": b, "b": a}),
([], [a, b]) => json!({"a": a, "b": b}),
([], []) => return Err("select the corner to cut first".into()),
_ => return Err("select one corner point, or the two lines of one corner".into()),
_ => return Err("select corner points, or the two lines of one corner".into()),
};
p["distance"] = json!(corner_size(app, size_label(true, *ctype), size, ValueKind::Length)?);
match ctype {
@@ -37,6 +37,26 @@
{"key": "Escape"},
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 5}}},
{"note": "Several corners at once: each picked corner is cut alike by one OK, as a fillet rounds several. The window used to refuse a second corner."},
{"start": "sketch.chamfer.equal_distance"},
{"click": {"world": [0, 0, 0]}},
{"click": {"world": [40, 0, 0]}},
{"click": {"handle": "dialog:Distance"}},
{"key": "A", "cmd": true},
{"text": "3"},
{"widget": "OK"},
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 7}}},
{"note": "Both cuts start 3 mm along each side from their corners, (0, 0) and (40, 0)."},
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "type"], "value": "line"}}},
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "start_at", 0], "value": 3}}},
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "end_at", 1], "value": 3}}},
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 6, "type"], "value": "line"}}},
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 6, "start_at", 0], "value": 37}}},
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 6, "end_at", 1], "value": 3}}},
{"note": "Undo takes both cuts back in one step."},
{"key": "Z", "cmd": true},
{"expect": {"sketch_curves": {"own": 5}}},
{"note": "Fillet: the same window with a radius, on a second sketch."},
{"call": "engine.execute", "params": {"command": "sketch.finish", "params": {}}},
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XZ"}}},
+3 -1
View File
@@ -60,7 +60,9 @@ fit-point and control-point splines, conics, points and text. **Construction** (
into reference geometry that doesn't form profiles. Modify tools include trim (T), extend, break,
fillet, chamfer, offset (O), mirror, patterns, move/copy and scale. With trim on, click a
piece to remove it, or press and drag a path across several pieces: the ones it crosses light up
and all go when you let go, in one undo step (`sketch.trim.path`).
and all go when you let go, in one undo step (`sketch.trim.path`). Fillet and chamfer take
several corners at once: pick the corners, set the size once, and every corner is rounded or
cut alike in one undo step.
Line draws a chain: each click adds a segment from the last point, and a right-click or Esc
ends the chain. To draw one separate line with a press, a drag and the release instead, turn on
+3 -3
View File
@@ -31,9 +31,9 @@ to screen coordinates, and `ui.click {x,y}` sends the pick. Cancel between cases
| Rectangular Pattern | same; edits from `sketch_pattern_tree.rs` | S; Start `sketch.pattern.rectangular` | Entities; Direction, Quantity, Spacing, Alignment icons; Direction 2, Quantity 2, Spacing 2, Alignment 2 icons; guidance | OK, Cancel |
| Circular Pattern | same; edits from `sketch_pattern_tree.rs` | S; Start `sketch.pattern.circular` | Entities, Center, Quantity, Total angle, Alignment icons, guidance | OK, Cancel |
| Fillet | same | S; Start `sketch.fillet` | Corners, Radius | OK, Cancel |
| Equal-distance Chamfer | same | S; Start `sketch.chamfer.equal_distance` | Corner, Chamfer Type icons, Distance | OK, Cancel |
| Two-distance Chamfer | same | S; Start `sketch.chamfer.two_distance` | Corner, Chamfer Type icons, Distance 1, Distance 2, Flip | OK, Cancel |
| Distance-angle Chamfer | same | S; Start `sketch.chamfer.distance_angle` | Corner, Chamfer Type icons, Distance 1, Angle, Flip | OK, Cancel |
| Equal-distance Chamfer | same | S; Start `sketch.chamfer.equal_distance` | Corners, Chamfer Type icons, Distance | OK, Cancel |
| Two-distance Chamfer | same | S; Start `sketch.chamfer.two_distance` | Corners, Chamfer Type icons, Distance 1, Distance 2, Flip | OK, Cancel |
| Distance-angle Chamfer | same | S; Start `sketch.chamfer.distance_angle` | Corners, Chamfer Type icons, Distance 1, Angle, Flip | OK, Cancel |
| Text / Edit Text | `sketch_text.rs`, shared window | S; Start `sketch.text`, then `ui.at {"world":[50,20,0]}` and `ui.click` the returned position; editing uses Start `sketch.edit_text`, then pick a text outline | Text (multiline), Font (search/list/file), Style, Alignment, X, Y, Height, Angle, Letter Spacing, Line Spacing, Path, Reverse (on path) | OK, Cancel; live edits collapse into one undo step |
| Move/Copy Text | `sketch_text_move.rs`, shared window | Create text as above; close its editor with OK; select a text outline; Start `sketch.move` | Distance X, Distance Y, Angle, Create Copy | OK, Cancel |
| Edit Dimension (panel form) | `dialogs.rs` | `Dialog::edit_param`; legacy programmatic UI constructor, no current control-channel entry point. Interactive dimensions use the inline editor below | Parameter name, expression | OK, Cancel |