Curvature-continuous (G2) fillets for straight planar edges (#862)

Fillet gets a Curvature (G2) radius type (type: "curvature", alias "g2"): a quintic blend whose size is the equivalent circular setback, for convex and concave straight edges between planar faces with a perpendicular planar end face at each end. Unsupported cases are refused rather than falling back to a circular fillet, and a blend that would cross a loop in its end face is refused. Persisted as a fillet style under format 19; labels in en/de/ja/ko/zh.

Co-authored-by: midasdf <[email protected]>
This commit is contained in:
midasdfandmidasdf authored and GitHub committed 2026-10-14 04:26:53 -04:00
1 parent 3669d4b3d0
commit 6e84c03946
18 files changed
+323 -13

No files matched your search

+3
View File
@@ -207,6 +207,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- G2 fillets: exact quintic blends of straight edges between planar faces, with one perpendicular
planar end face at each end. The UI and `solid.fillet` expose `curvature` (alias `g2`); the size
is an equivalent circular setback. Curved faces/edges, sloped ends and corner junctions remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
+2
View File
@@ -56,6 +56,8 @@
"ui.chamfer_type": "Fasentyp",
"ui.change_workspace": "Arbeitsbereich wechseln",
"ui.chord_length": "Sehnenlänge",
"ui.curvature_g2": "Krümmungsstetig (G2)",
"ui.equivalent_radius": "Äquivalenter Radius",
"ui.circle": "Kreis",
"ui.circular_pattern": "Radiales Muster",
"ui.clear_recent": "Zuletzt verwendet leeren",
+2
View File
@@ -328,6 +328,8 @@
"ui.chamfer_type": "Chamfer Type",
"ui.change_workspace": "Change workspace",
"ui.chord_length": "Chord Length",
"ui.curvature_g2": "Curvature (G2)",
"ui.equivalent_radius": "Equivalent Radius",
"ui.circle": "Circle",
"ui.circular_pattern": "Circular Pattern",
"ui.clear": "Clear",
+2
View File
@@ -328,6 +328,8 @@
"ui.chamfer_type": "面取りタイプ",
"ui.change_workspace": "ワークスペースの変更",
"ui.chord_length": "弦長",
"ui.curvature_g2": "曲率連続 (G2)",
"ui.equivalent_radius": "等価半径",
"ui.circle": "円",
"ui.circular_pattern": "円形パターン",
"ui.clear": "クリア",
+2
View File
@@ -56,6 +56,8 @@
"ui.chamfer_type": "모따기 유형",
"ui.change_workspace": "작업 공간 변경",
"ui.chord_length": "현 길이",
"ui.curvature_g2": "곡률 연속 (G2)",
"ui.equivalent_radius": "등가 반지름",
"ui.circle": "원",
"ui.circular_pattern": "원형 패턴",
"ui.clear_recent": "최근 항목 지우기",
+2
View File
@@ -327,6 +327,8 @@
"ui.chamfer_type": "倒角类型",
"ui.change_workspace": "更改工作区",
"ui.chord_length": "弦长",
"ui.curvature_g2": "曲率连续 (G2)",
"ui.equivalent_radius": "等效半径",
"ui.circle": "圆",
"ui.circular_pattern": "圆形阵列",
"ui.clear": "清除",
+4 -1
View File
@@ -1059,6 +1059,9 @@ pub enum FilletStyle {
Constant,
/// `radius` is the chord: the width across the blend.
Chord,
/// G2 quintic blend of planar faces along a straight edge. `radius` sets the
/// equivalent circular setback, not a constant curvature radius.
Curvature,
/// `radius` at the end of each edge nearest `start`, `radius2` at the other; `mid` radii at
/// positions between (0 to 1 from the start end); straight between them, or `smooth`.
Variable {
@@ -1653,7 +1656,7 @@ impl FeatureKind {
v.extend(mid.iter().map(|(_, r)| r.as_str()));
}
FilletStyle::Spline { radii, .. } => v.extend(radii.iter().map(String::as_str)),
FilletStyle::Constant | FilletStyle::Chord => {}
FilletStyle::Constant | FilletStyle::Chord | FilletStyle::Curvature => {}
}
}
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
+1
View File
@@ -4144,6 +4144,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let input = mb.traced();
// The blended body, and the lineage of what the last blend started from.
let (nb, from) = match &f.kind {
FeatureKind::Fillet { style: crate::FilletStyle::Curvature, .. } => (kernel::fillet_g2(&mb.body, &edges, r)?, input),
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => (kernel::fillet_chord(&mb.body, &edges, r)?, input),
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => {
let r2 = val(vals, radius2, Kind::Length)?;
+3
View File
@@ -135,6 +135,9 @@
//! - 19: editable associative offset-distance dimensions. Older offsets retain their saved
//! fixed distances; migration does not invent dimensions or change geometry.
//! Under the same version, a fillet's style can be `curvature` (a G2 quintic blend; `radius`
//! is the equivalent circular setback). Fillets of the other styles save as before; 0.9.0
//! would refuse the unknown style.
use serde_json::Value;
+1
View File
@@ -77,6 +77,7 @@ impl FeatureKind {
}
FeatureKind::Fillet { radius, style, .. } => match style {
crate::FilletStyle::Constant => v.push(("Radius", radius, L)),
crate::FilletStyle::Curvature => v.push(("Equivalent radius", radius, L)),
crate::FilletStyle::Chord => v.push(("Chord length", radius, L)),
crate::FilletStyle::Variable { radius2, mid, .. } => {
v.push(("Start radius", radius, L));
+1 -1
View File
@@ -821,7 +821,7 @@ fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> {
*key = format!("fillet.control.{i}");
}
}
solidcraft_doc::FilletStyle::Constant | solidcraft_doc::FilletStyle::Chord => {}
solidcraft_doc::FilletStyle::Constant | solidcraft_doc::FilletStyle::Chord | solidcraft_doc::FilletStyle::Curvature => {}
}
}
keys
+22
View File
@@ -8,6 +8,28 @@ fn run(s: &mut Session, id: &str, params: Value) -> Value {
s.execute(id, &params).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
}
#[test]
fn g2_fillet_survives_edit_undo_and_reopen() {
use solidcraft_doc::{Document, FeatureKind, FilletStyle};
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "name": "Base"}));
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 2, "type": "g2", "name": "Smooth"}));
assert!(matches!(&s.doc.find_feature("Smooth").unwrap().kind, FeatureKind::Fillet { style: FilletStyle::Curvature, .. }));
let before = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
run(&mut s, "timeline.edit", json!({"feature": "Smooth", "set": {"radius": "3"}}));
let after = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(after < before);
run(&mut s, "edit.undo", json!({}));
let undo = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
assert!((undo - before).abs() < 1e-6);
run(&mut s, "edit.redo", json!({}));
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
assert!(matches!(&reopened.doc.find_feature("Smooth").unwrap().kind, FeatureKind::Fillet { style: FilletStyle::Curvature, .. }));
let volume = run(&mut reopened, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
assert!((volume - after).abs() < 1e-6);
assert!(s.execute("solid.fillet", &json!({"edges": [[0, 0, 10]], "radius": 2, "type": "curvature", "fit": "reduce"})).is_err());
}
#[test]
fn redefining_a_blend_replaces_persistent_edge_selection_and_survives_resize() {
let mut s = Session::default();
+3 -2
View File
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one perpendicular planar end face at each end; radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
@@ -857,6 +857,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("constant") | Some("rolling_ball") => solidcraft_doc::FilletStyle::Constant,
Some("chord") | Some("chord_length") => solidcraft_doc::FilletStyle::Chord,
Some("curvature") | Some("g2") => solidcraft_doc::FilletStyle::Curvature,
Some("variable") => {
let radius2 = req_expr(cmd, p, "radius2")?;
check_expr(s, &radius2, Kind::Length, cmd, "radius2")?;
@@ -906,7 +907,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
}
solidcraft_doc::FilletStyle::Spline { start, knots, radii }
}
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord, variable or spline)"))),
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord, variable, spline or curvature)"))),
};
let fit = match str_(p, "fit").map(str::to_ascii_lowercase).as_deref() {
None | Some("exact") => solidcraft_doc::FilletFit::Exact,
+208 -5
View File
@@ -21,6 +21,7 @@ use crate::{KernelError, Result, guard};
enum Shape {
Round,
Flat,
Curvature,
}
pub(crate) fn unsupported(msg: &str) -> KernelError {
@@ -186,7 +187,7 @@ fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) ->
/// Distance along each face from the edge to the blend boundary.
fn setback(r: f64, phi: f64, shape: Shape) -> f64 {
match shape {
Shape::Round => r / (phi / 2.0).tan(),
Shape::Round | Shape::Curvature => r / (phi / 2.0).tan(),
Shape::Flat => r,
}
}
@@ -462,6 +463,11 @@ fn blend_geometry(
[x, y] if shared(i1, y).is_some() && shared(i2, x).is_some() => (y, x),
_ => return Err(unsupported("each end of the edge must touch exactly one more face")),
};
if shape == Shape::Curvature
&& (others.len() != 1 || faces.get(ga).and_then(|f| plane_normal(f)).is_none_or(|n| n.dot(d).abs() < 1.0 - 1e-12))
{
return Err(unsupported("G2 fillets require one planar end face perpendicular to the edge at each end"));
}
// An end face that is not a plane, or meets the side faces in curves: the rails end where
// they reach its surface, and the blend's end is where its surface meets the end face
// (the cross-section through `p` slid along d onto it, point by point).
@@ -485,6 +491,9 @@ fn blend_geometry(
&& let (Some(e1), Some(e2)) = (shared(i1, ga), shared(i2, gb))
&& (multi || faces.get(ga).and_then(|f| plane_normal(f)).is_none() || !is_line(&e1) || !is_line(&e2))
{
if shape == Shape::Curvature {
return Err(unsupported("G2 fillets require straight edges around each planar end face"));
}
let g = faces.get(ga).ok_or_else(|| KernelError::Failed("face".into()))?;
let surf = g.surface();
let surf_b = faces.get(gb).ok_or_else(|| KernelError::Failed("face".into()))?.surface();
@@ -496,7 +505,8 @@ fn blend_geometry(
let profile = |f: f64| -> Vec3 {
match shape {
Shape::Flat => sa + (sb - sa) * f,
Shape::Round => c0 + ((sa - c0) * ((1.0 - f) * theta).sin() + (sb - c0) * (f * theta).sin()) / theta.sin(),
// G2 rejects curved end faces before entering this branch.
Shape::Round | Shape::Curvature => c0 + ((sa - c0) * ((1.0 - f) * theta).sin() + (sb - c0) * (f * theta).sin()) / theta.sin(),
}
};
// A rail's end: on the side edge, else on its continuation within the side face.
@@ -523,7 +533,7 @@ fn blend_geometry(
let x = perp(x);
match shape {
Shape::Flat => (x - sa).dot(sb - sa) / (sb - sa).len2().max(1e-300),
Shape::Round => {
Shape::Round | Shape::Curvature => {
let (u, w) = (sa - c0, x - c0);
let ang = (u.dot(w) / (u.len() * w.len()).max(1e-300)).clamp(-1.0, 1.0).acos();
ang / theta_ab.max(1e-300)
@@ -537,7 +547,7 @@ fn blend_geometry(
if multi {
let gap = |x: Vec3| -> f64 {
match shape {
Shape::Round => (perp(x) - c0).len() - r,
Shape::Round | Shape::Curvature => (perp(x) - c0).len() - r,
Shape::Flat => (sb - sa).cross(d).normalized().map_or(f64::NAN, |m| (x - sa).dot(m)),
}
};
@@ -663,7 +673,7 @@ fn blend_geometry(
let q = from_p3(bs.subs((i as f64 + 0.5) / n as f64));
let on_g = surf.search_nearest_parameter(p3(q), None, 100).is_some_and(|(u, v)| from_p3(surf.subs(u, v)).dist(q) <= on_tol);
let on_blend = match shape {
Shape::Round => {
Shape::Round | Shape::Curvature => {
let w = q - c0;
((w - d * w.dot(d)).len() - r).abs() <= on_tol
}
@@ -859,6 +869,10 @@ fn blend_geometry(
if shape == Shape::Flat {
return Ok(builder::line(va, vb));
}
if shape == Shape::Curvature {
let controls = g2_controls(p, t1, t2, s);
return Ok(builder::bezier(va, vb, controls.into_iter().skip(1).take(4).map(p3).collect()));
}
let c = centre(p);
let mid = c + ((from - c) + (to - c)).normalized().unwrap_or(-n1) * r;
let (o, n) = plane;
@@ -996,6 +1010,19 @@ fn blend_geometry(
let wire: mt::Wire = edges_out.into();
let blend_face = match shape {
Shape::Flat => builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("chamfer face: {e}")))?,
Shape::Curvature => {
let controls = g2_controls(p0, t1, t2, s);
let ctrl = controls.into_iter().map(|p| vec![p3(p), p3(p + p1 - p0)]).collect();
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(5), mt::KnotVec::bezier_knot(1)), ctrl);
let mut surf = mt::Surface::BSplineSurface(bsp);
use mt::ParametricSurface3D;
let nn = surf.normal(0.5, 0.5);
let outward = (t1 + t2) * side;
if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 {
surf = mt::Invertible::inverse(&surf);
}
mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("G2 fillet face: {e}")))?
}
Shape::Round => {
// The cylinder, from a cross-section before the first end to one past the second, so
// it holds the end sections wherever the end planes slide them.
@@ -1036,6 +1063,50 @@ fn blend_geometry(
Ok((solid, rec))
}
/// Quintic Bezier cross-section. The first/last three controls are collinear and
/// equally spaced, so both endpoint second derivatives vanish. Sweeping along a
/// straight edge therefore gives zero normal curvature at both planar side faces.
fn g2_controls(p: Vec3, t1: Vec3, t2: Vec3, setback: f64) -> [Vec3; 6] {
[
p + t1 * setback,
p + t1 * (setback * 2.0 / 3.0),
p + t1 * (setback / 3.0),
p + t2 * (setback / 3.0),
p + t2 * (setback * 2.0 / 3.0),
p + t2 * setback,
]
}
/// Curvature-continuous (G2) fillets between planar faces along straight edges.
/// `radius` sets the same setback as a circular fillet; the profile is not circular.
/// Each end must meet one planar face perpendicular to the edge. Unsupported
/// configurations are refused, never replaced with a G1 circular fillet.
pub fn fillet_g2(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
body.require_brep("G2 fillet")?;
if !(radius.is_finite() && radius > 1e-6 && radius < 1e6) {
return Err(KernelError::Invalid("G2 fillet size must be positive and finite".into()));
}
if edges.is_empty() || edges.len() > 1000 || edges.iter().any(|p| ![p.x, p.y, p.z].into_iter().all(f64::is_finite)) {
return Err(KernelError::Invalid("G2 fillet needs 1 to 1000 finite edge points".into()));
}
let counts = record::counts(&[body]);
let mut cur = crate::boolean_record::healed(body)?;
for p in edges {
let size = cur.size();
let Some((idx, dist)) = cur.nearest_edge(*p, (size * 2e-3).max(1e-3))? else {
return Err(KernelError::Invalid("the body has no edges".into()));
};
if dist > size * 0.05 + 1e-3 {
return Err(KernelError::Invalid("no edge near the G2 fillet pick".into()));
}
let solid = cur.deep_copy();
let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?;
let (made, rec) = guard("G2 fillet", || blend_one(&solid, &edge, size, radius, Shape::Curvature))?;
cur = record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts);
}
closed(cur)
}
/// Most edges whose other orders are tried when the order given is refused.
const MAX_REORDERED: usize = 4;
@@ -2079,6 +2150,138 @@ mod tests {
use super::*;
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
#[test]
fn g2_fillet_has_zero_curvature_at_both_planar_rails() {
use mt::{InnerSpace, ParametricCurve, ParametricSurface};
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
let out = fillet_g2(&body, &[Vec3::new(20.0, 0.0, 10.0)], 2.0).unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
assert_eq!(out.face_count(), body.face_count() + 1);
let solid = out.deep_copy();
let face = solid.boundaries().iter().flat_map(|s| s.face_iter()).find(|f| matches!(f.surface(), mt::Surface::BSplineSurface(_))).unwrap();
let surf = face.surface();
// Inversion may swap the surface parameters. At each rail, curvature
// must vanish in *every* direction, while the two tangent vectors stay regular.
let mut rails = 0;
for (u, v) in [(0.0, 0.5), (1.0, 0.5), (0.5, 0.0), (0.5, 1.0)] {
let p = from_p3(surf.subs(u, v));
if (p.x - 20.0).abs() > 1e-8 && p.y.abs() > 1e-8 {
continue;
}
let (du, dv) = (surf.uder(u, v), surf.vder(u, v));
let n = du.cross(dv);
assert!(n.magnitude2() > 1e-8);
let expected = if p.y.abs() < 1e-8 { mt::Vector3::unit_y() } else { mt::Vector3::unit_x() };
assert!(n.cross(expected).magnitude2() < 1e-12);
for second in [surf.uuder(u, v), surf.uvder(u, v), surf.vvder(u, v)] {
assert!(second.dot(n).abs() < 1e-8);
}
rails += 1;
}
assert_eq!(rails, 2);
// End sections are exact quintics, with regular tangents and zero endpoint curvature.
let mut sections = 0;
for edge in face.edge_iter() {
if let mt::Curve::BSplineCurve(c) = edge.curve() {
for t in [0.0, 1.0] {
let d = c.der(t);
assert!(d.magnitude2() > 1e-8);
assert!(d.cross(c.der2(t)).magnitude2() < 1e-12);
}
sections += 1;
}
}
assert_eq!(sections, 2);
// Independent analytic area: integrating x(t) y'(t) for the quintic
// gives 113/756 times setback² for a right-angle corner.
let volume = out.tessellate(0.0001).unwrap().measure().volume;
let expected = 8000.0 - (113.0 / 756.0) * 4.0 * 20.0;
assert!((volume - expected).abs() < 0.02, "{volume} vs {expected}");
}
#[test]
fn g2_fillet_handles_concave_edges_and_separate_edges() {
let lp = Loop2::polygon(&[
Vec2::new(0.0, 0.0),
Vec2::new(20.0, 0.0),
Vec2::new(20.0, 10.0),
Vec2::new(10.0, 10.0),
Vec2::new(10.0, 20.0),
Vec2::new(0.0, 20.0),
]);
let body = crate::build::extrude(&Plane::XY, &[Region2 { outer: lp, holes: vec![] }], 0.0, 20.0).unwrap().remove(0);
let out = fillet_g2(&body, &[Vec3::new(10.0, 10.0, 10.0)], 2.0).unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
let volume = out.tessellate(0.0001).unwrap().measure().volume;
let expected = 6000.0 + (113.0 / 756.0) * 4.0 * 20.0;
assert!((volume - expected).abs() < 0.02, "{volume} vs {expected}");
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
let out = fillet_g2(&body, &[Vec3::new(20.0, 0.0, 10.0), Vec3::new(0.0, 20.0, 10.0)], 2.0).unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
assert_eq!(out.face_count(), body.face_count() + 2);
}
#[test]
fn g2_fillet_refuses_sections_crossing_a_cap_hole() {
// The hole lies inside the setback without touching either side face.
// The larger quintic crosses its loop on both perpendicular end faces,
// even though the rebuilt shell is closed according to topology alone.
let region = Region2 {
outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(20.0, 0.0), Vec2::new(20.0, 20.0), Vec2::new(0.0, 20.0)]),
holes: vec![Loop2::circle(Vec2::new(0.5, 0.5), 0.15)],
};
for plane in [Plane::XY, Plane::XZ] {
let body = crate::extrude(&plane, std::slice::from_ref(&region), 0.0, 20.0).unwrap().remove(0);
let pick = plane.to_world(Vec2::ZERO) + plane.normal() * 10.0;
assert_eq!(body.crossed_face_loops().unwrap(), 0);
let out = fillet_g2(&body, &[pick], 0.5).unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
assert_eq!(out.crossed_face_loops().unwrap(), 0);
assert_eq!(out.face_count(), body.face_count() + 1);
let result = fillet_g2(&body, &[pick], 2.0);
if let Ok(out) = &result {
eprintln!("accepted G2 result: validity={:?}, crossed_faces={}", out.validity(), out.crossed_face_loops().unwrap());
}
assert!(matches!(
result,
Err(KernelError::Failed(message)) if message.contains("rounds would leave openings")
));
}
}
#[test]
fn g2_fillet_refuses_invalid_and_unsupported_geometry() {
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
let edge = [Vec3::new(20.0, 0.0, 10.0)];
for radius in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e7] {
assert!(fillet_g2(&body, &edge, radius).is_err());
}
assert!(fillet_g2(&body, &[], 2.0).is_err());
assert!(fillet_g2(&body, &[Vec3::new(f64::NAN, 0.0, 0.0)], 2.0).is_err());
assert!(fillet_g2(&body, &[Vec3::new(1000.0, 0.0, 0.0)], 2.0).is_err());
assert!(fillet_g2(&body, &edge, 25.0).is_err());
let rounded = fillet(&body, &edge, 2.0).unwrap();
assert!(fillet_g2(&rounded, &[Vec3::new(20.0, 2.0, 10.0)], 1.0).is_err());
let boss = box_and_boss();
assert!(fillet_g2(&boss, &[Vec3::new(40.0, 4.0, 0.0)], 0.5).is_err());
// The ordinary blend supports the boss's pieced end wall; G2 must still refuse it.
assert!(matches!(
fillet_g2(&boss, &[Vec3::new(40.0, 4.0, 20.0)], 0.8),
Err(KernelError::Failed(message)) if message.contains("G2 fillets require one planar end face")
));
for slope in [-0.5, 0.5] {
let region = Region2 {
outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(40.0, 0.0), Vec2::new(40.0 - slope * 20.0, 20.0), Vec2::new(slope * 20.0, 20.0)]),
holes: vec![],
};
let sloped = crate::extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
assert!(matches!(
fillet_g2(&sloped, &[Vec3::new(20.0, 0.0, 20.0)], 2.0),
Err(KernelError::Failed(message)) if message.contains("G2 fillets require one planar end face")
));
}
}
#[test]
fn chamfer_extension_cannot_cross_a_hole_and_reenter_the_face() {
let rectangle = |a, b, c, d| Loop2::polygon(&[Vec2::new(a, b), Vec2::new(c, b), Vec2::new(c, d), Vec2::new(a, d)]);
+1 -1
View File
@@ -94,7 +94,7 @@ mod tspline;
#[cfg(test)]
mod untouched_faces_tests;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_max, fillet_max_each};
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_max, fillet_max_each};
pub use blend_corner::fillet_radii;
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
+22 -3
View File
@@ -689,7 +689,16 @@ impl Dialog {
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::Fillet { radius, chamfer, .. } => {
let label = if *chamfer {
"Distance"
} else if matches!(self.extra.get("type").and_then(Value::as_str), Some("curvature" | "g2")) {
"Equivalent Radius"
} else {
"Radius"
};
(label, ValueKind::Length, radius)
}
Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness),
Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle),
Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter),
@@ -2059,7 +2068,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
&mut d.extra,
"fi_type",
"type",
&[("Constant", "constant"), ("Chord Length", "chord"), ("Variable", "variable"), ("Spline", "spline")],
&[
("Constant", "constant"),
("Chord Length", "chord"),
("Variable", "variable"),
("Spline", "spline"),
("Curvature (G2)", "curvature"),
],
);
ui.end_row();
}
@@ -2069,6 +2084,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
match rtype {
1 => "Chord Length",
2 | 3 => "Start Radius",
4 => "Equivalent Radius",
_ => "Radius",
}
} else if *ctype == 0 {
@@ -4027,6 +4043,9 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
if let FeatureKind::Fillet { style, .. } = &f.kind {
match style {
FilletStyle::Constant => {}
FilletStyle::Curvature => {
d.extra.insert("type".into(), json!("curvature"));
}
FilletStyle::Chord => {
d.extra.insert("type".into(), json!("chord"));
}
@@ -4043,7 +4062,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
d.extra.insert("knots".into(), json!(knots));
d.extra.insert("radii".into(), json!(radii));
}
FilletStyle::Constant | FilletStyle::Chord => {}
FilletStyle::Constant | FilletStyle::Chord | FilletStyle::Curvature => {}
}
// 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 {
+29
View File
@@ -2,6 +2,7 @@
//! type, whether the command starts from a shortcut, the toolbar or a menu.
use serde_json::json;
use solidcraft_engine::doc::expr::Kind as ValueKind;
use solidcraft_engine::geom::Vec3;
use solidcraft_engine::{Sel, Session};
@@ -561,6 +562,34 @@ fn a_hole_is_mirrored_as_a_feature() {
assert_eq!(cmds[0].1["features"], json!(["Hole1"]));
}
/// The canvas names the G2 size correctly and edits the same value when the feature is reopened.
#[test]
fn curvature_canvas_value_uses_equivalent_radius_when_created_and_reopened() {
for style in ["curvature", "g2"] {
let mut app = boxed();
let e = edge(&app, Vec3::new(20.0, 0.0, 20.0));
let mut d = start(&mut app, vec![e], "solid.fillet");
d.extra.insert("type".into(), json!(style));
let (label, kind, value) = d.primary().unwrap();
assert_eq!((label, kind), ("Equivalent Radius", ValueKind::Length));
*value = "3 mm".into();
let (command, params) = apply_commands(&app, &d).unwrap().remove(0);
assert_eq!(params["radius"], json!("3 mm"), "the canvas edits the actual command value");
app.session.execute(&command, &params).unwrap();
let feature = app.session.doc.features.last().unwrap().id;
let mut reopened = crate::dialogs::for_feature(&app, feature, None).unwrap();
let (label, kind, value) = reopened.primary().unwrap();
assert_eq!((label, kind), ("Equivalent Radius", ValueKind::Length));
assert_eq!(value.as_str(), "3 mm");
}
for (command, label) in [("solid.fillet", "Radius"), ("solid.chamfer", "Distance")] {
let mut app = boxed();
let e = edge(&app, Vec3::new(20.0, 0.0, 20.0));
let mut d = start(&mut app, vec![e], command);
assert_eq!(d.primary().unwrap().0, label);
}
}
/// The radius arrow points into the body: dragging toward the material grows the round.
#[test]
fn fillet_and_chamfer_arrows_point_into_the_body() {
+15
View File
@@ -63,6 +63,21 @@ Fillets and chamfers handle straight edges between planar faces, whole smooth lo
neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling
ball). See the roadmap's known limitations for the rest.
**Curvature (G2)** in the Fillet dialog's **Radius Type** blends straight edges between two
planar faces with an exact quintic section. The tangent plane and curvature match each side
face: curvature goes to zero at both contact rails, unlike a circular G1 fillet. Each end
must meet one planar face perpendicular to the edge. Curved edges/faces, sloped or multi-face
ends, and intersecting corner blends are not supported; they return an error, not a G1 fallback.
**Equivalent Radius** sets the same contact setback as a circular fillet (for a right angle,
setback equals the entered value); it is not a constant curvature radius. Reduce/Taper to Fit
are unavailable for this type.
Scripts and MCP use the same command, for example on a 20 mm box:
```json
{"command": "solid.fillet", "params": {"edges": [[20, 0, 10]], "radius": 2, "type": "curvature"}}
```
`type: "g2"` is an alias. Existing fillets still default to circular, constant-radius G1 blends.
Feature references to faces and edges are stored by name, so an upstream edit that moves geometry
keeps them on the right face (see [Persistent naming](../dev/naming.md)). When a reference can't
be found, the feature warns instead of silently picking another face.