From fc8a5b21cf9816a8119b73a94f0eb25a72635056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20L=C3=B3pez?= Date: Mon, 12 Oct 2026 19:15:30 +0200 Subject: [PATCH] Loop blends: accept straight wall seams that are not lines (#335) * Loop blends accept straight wall seams that are not lines Filleting or chamfering the top outline of two overlapping cylinders whose concave edges were rounded failed with "the wall seams must be straight": the round's contact lines with the cylinders are straight NURBS curves, and the loop blend only accepted seams stored as lines. A seam now counts as straight when every sample lies on its chord; it is trimmed as a line as before. * Fillet and chamfer arrows point into the body The radius arrow pointed out of the body along the edge's bisector, so dragging it away from the edge grew the round. It now points into the material, like the shell's thickness arrow: dragging toward the body grows the radius or distance, dragging out of it shrinks it. --- crates/engine/src/tests.rs | 36 +++++++++++++++++++++++++++ crates/kernel/src/loopblend.rs | 18 +++++++++++++- crates/ui-egui/src/canvas.rs | 10 ++++---- crates/ui-egui/src/preselect_tests.rs | 13 ++++++++++ 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e72c3a8c..e93f5eab 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -209,6 +209,42 @@ fn fillet_the_edges_between_two_overlapping_cylinders() { assert!(rel(v, expect) < 2e-4, "{v} vs {expect}"); } +/// The top outline of two overlapping cylinders whose concave edges were rounded: the walls of +/// the rounds meet the cylinders along the rolling-ball contact lines, straight but not lines. +#[test] +fn fillet_and_chamfer_the_top_loop_of_rounded_overlapping_cylinders() { + use std::f64::consts::{PI, TAU}; + let (big, round, h) = (25.0_f64, 5.0_f64, 10.0); + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": big})); + run(&mut s, "sketch.circle.center", json!({"center": [-big, 0], "radius": big})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": h, "profiles": [0, 1, 2]})); + // Plan view: ball centres at x = −12.5, |y| = yb; each round's arc turns by th, each big arc by + // TAU − 2·a1, and the loop turns by TAU in all. + let half = big / 2.0; + let yb = ((big + round).powi(2) - half * half).sqrt(); + run(&mut s, "solid.fillet", json!({"edges": [[-half, half * 3f64.sqrt(), 5], [-half, -half * 3f64.sqrt(), 5]], "radius": round})); + let (a1, th) = (yb.atan2(half), 2.0 * (half / (big + round)).asin()); + let base = volume(&mut s); + let top = [[big, 0.0, h], [-2.0 * big, 0.0, h], [-half, yb - round, h], [-half, round - yb, h]]; + // Removed volume by Pappus: the corner's section swept along the loop at its centroid's offset. + let removed = |area: f64, xb: f64| area * (2.0 * (TAU - 2.0 * a1) * (big - xb) + 2.0 * th * (round + xb)); + run(&mut s, "solid.fillet", json!({"edges": top, "radius": 2})); + let r: f64 = 2.0; + let expect = base - removed(r * r * (1.0 - PI / 4.0), (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)) * r); + let v = volume(&mut s); + assert!((v - expect).abs() < 1.5, "{v} vs {expect}"); + let m = run(&mut s, "inspect.measure", json!({})); + assert_eq!(m["bodies"][0]["kernel"]["faces"].as_u64(), Some(10), "{m}"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "solid.chamfer", json!({"edges": top, "distance": 2})); + let expect = base - removed(2.0, 2.0 / 3.0); + let v = volume(&mut s); + assert!((v - expect).abs() < 1.5, "{v} vs {expect}"); +} + /// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole. #[test] fn box_fillet_cut_part() { diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs index 31c6ce00..e3653a47 100644 --- a/crates/kernel/src/loopblend.rs +++ b/crates/kernel/src/loopblend.rs @@ -97,6 +97,22 @@ fn seg_of(e: &mt::Edge, tol: f64) -> Option { Some(Seg::Arc { a, c: centre, axis, angle, mid: m }) } +/// Whether an edge is a straight segment: a line, or a curve whose points all lie on the chord +/// (the contact lines of a rolling-ball blend between parallel walls are such curves). +fn is_straight(e: &mt::Edge, tol: f64) -> bool { + use mt::{BoundedCurve, ParametricCurve}; + if matches!(e.curve(), mt::Curve::Line(_)) { + return true; + } + let (a, b) = (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point())); + if a.dist(b) < tol { + return false; + } + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + (0..=16).all(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0)).dist_to_segment(a, b) < tol) +} + /// `p` turned about the line (c, axis) by `ang`. fn turn(p: Vec3, c: Vec3, axis: Vec3, ang: f64) -> Vec3 { let k = axis.normalized().unwrap_or(Vec3::Z); @@ -518,7 +534,7 @@ fn build( } let (fa, fb) = (e.absolute_front().clone(), e.absolute_back().clone()); let other = if &fa == v { &fb } else { &fa }; - if !matches!(e.curve(), mt::Curve::Line(_)) { + if !is_straight(&e, (size * 1e-5).max(1e-6)) { return Err(unsupported("the wall seams must be straight")); } let face_side = on_face(&e); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8a72aba9..a0b2557d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -69,9 +69,9 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? }; (at + n * (from + offset), Some(n)) } - Kind::Fillet { .. } | Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal), - // Shell thickness grows into the body. - Kind::Shell { .. } => (at, normal.map(|n| -n)), + Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal), + // Fillet radii, chamfer distances and shell thickness grow into the body. + Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)), Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)), Kind::Plastic(_) => (at, normal), Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)), @@ -98,8 +98,8 @@ fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solvecraft_ Some(solvecraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p)) } -/// The outward direction between the two faces at an edge (where a fillet's radius arrow -/// points). +/// The outward direction between the two faces at an edge (a fillet's radius arrow points the +/// other way, into the body). fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { let faces = m.edge_faces.get(index)?; let mut sum = Vec3::ZERO; diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index b0343591..4553fa61 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -470,6 +470,19 @@ fn a_hole_is_mirrored_as_a_feature() { assert_eq!(cmds[0].1["features"], json!(["Hole1"])); } +/// The radius arrow points into the body: dragging toward the material grows the round. +#[test] +fn fillet_and_chamfer_arrows_point_into_the_body() { + for id in ["solid.fillet", "solid.chamfer"] { + let mut app = boxed(); + let e = edge(&app, Vec3::new(20.0, 0.0, 20.0)); + let d = start(&mut app, vec![e], id); + let (_, dir) = crate::canvas::anchor(&app, &d).unwrap(); + let dir = dir.unwrap(); + assert!(dir.y > 0.5 && dir.z < -0.5, "{id}: {dir:?}"); + } +} + #[test] fn chamfer_types_send_their_values() { let mut app = boxed();