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:
1 parent
3669d4b3d0
commit
6e84c03946
18 files changed
+323
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "クリア",
|
||||
|
||||
@@ -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": "최근 항목 지우기",
|
||||
|
||||
@@ -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": "清除",
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,28 @@ fn run(s: &mut Session, id: &str, params: Value) -> Value {
|
||||
s.execute(id, ¶ms).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();
|
||||
|
||||
@@ -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
@@ -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(®ion), 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)]);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¶ms).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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user