doc, engine, kernel: solid.fillet and solid.chamfer round each picked edge's tangent chain by default

The kernel blends exactly the edges given; the feature's tangent_chain (default true, saved
only when false: pending format note blend-tangent-chain) expands each pick to its tangent
chain (kernel::tangent_chain) before the kernel call. A variable or spline round takes its
edge alone. Scripts and MCP passing one pick get the chain again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-22 14:26:48 -04:00
parent 50bc9f302d
commit 84bd2daadc
18 changed files with 214 additions and 40 deletions
+7 -1
View File
@@ -187,7 +187,13 @@ mod tests {
}
assert!(std::sync::Arc::ptr_eq(&reference, &mb.mesh()));
assert_eq!(mb.display_plan_at(DisplayQuality::Normal).unwrap().vertex_count(), {
let plan = mb.body.mesh_plan(&solidcraft_kernel::MeshTolerance { chord: (mb.body.size() * 1e-3).max(1e-3), angle: Some(solidcraft_kernel::DISPLAY_ANGLE) }).unwrap();
let plan = mb
.body
.mesh_plan(&solidcraft_kernel::MeshTolerance {
chord: (mb.body.size() * 1e-3).max(1e-3),
angle: Some(solidcraft_kernel::DISPLAY_ANGLE),
})
.unwrap();
plan.vertex_count()
});
}
+7
View File
@@ -657,6 +657,10 @@ pub enum FeatureKind {
/// (default) or a G2 patch repeating each blend up to the second order.
#[serde(default, skip_serializing_if = "FilletCorner::is_rolling")]
corner: FilletCorner,
/// Round each picked edge's tangent chain with it (the default: edges continuing it
/// smoothly at its ends, as far as they go), or only the picked edges.
#[serde(default = "yes", skip_serializing_if = "is_yes")]
tangent_chain: bool,
},
Chamfer {
edges: Vec<Position>,
@@ -675,6 +679,9 @@ pub enum FeatureKind {
flip: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
/// Cut each picked edge's tangent chain with it (the default), or only the picked edges.
#[serde(default = "yes", skip_serializing_if = "is_yes")]
tangent_chain: bool,
},
Box {
#[serde(with = "point_expr")]
+20 -3
View File
@@ -301,7 +301,10 @@ impl ModelBody {
if k == 0 {
return Some((self.display_plan_at(q)?, self.plan_mesh_at(q)?));
}
let plan = match self.body.mesh_plan(&solidcraft_kernel::MeshTolerance { chord: q.refined_chord(self.body.size(), k), angle: Some(q.refined_angle(k)) }) {
let plan = match self
.body
.mesh_plan(&solidcraft_kernel::MeshTolerance { chord: q.refined_chord(self.body.size(), k), angle: Some(q.refined_angle(k)) })
{
Ok(plan) => plan,
Err(e) => {
log::debug!("{}: no display plan refined {k} steps ({e})", self.name);
@@ -6089,8 +6092,10 @@ fn trim_to_body(tools: Vec<Traced>, target: &ModelBody, dist_to_start: impl Fn(V
let lineages = crate::lineage::Lineage::lumps(&cut.body, cut.lineage.as_ref(), &lumps);
for (lump, lineage) in lumps.into_iter().zip(lineages) {
let tol = (lump.size() * 1e-4).max(1e-6);
let touches =
lump.mesh(&solidcraft_kernel::MeshTolerance::chord((lump.size() * 1e-2).max(1e-2))).map(|m| m.positions.iter().any(|q| dist_to_start(*q) < tol)).unwrap_or(false);
let touches = lump
.mesh(&solidcraft_kernel::MeshTolerance::chord((lump.size() * 1e-2).max(1e-2)))
.map(|m| m.positions.iter().any(|q| dist_to_start(*q) < tol))
.unwrap_or(false);
if touches {
out.push(Traced { body: lump, lineage });
}
@@ -6847,11 +6852,22 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
let (mut found, mut notes, mut fitted, mut tapered_all) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
let mut reduced = false;
// The kernel blends exactly the edges given: the feature adds each one's tangent
// chain when it asks for it. A radius law runs along one edge, so a variable or
// spline round takes its edge alone.
let chain = match &f.kind {
FeatureKind::Fillet { tangent_chain, style, .. } => {
*tangent_chain && !matches!(style, crate::FilletStyle::Variable { .. } | crate::FilletStyle::Spline { .. })
}
FeatureKind::Chamfer { tangent_chain, .. } => *tangent_chain,
_ => false,
};
let mut blended = Vec::new();
for (ti, idx) in &groups {
let Some(mb) = st.bodies.get(*ti) else { return Err(DocError::Invalid("body".into())) };
let mut w = None;
let edges = blend_edges_on(mb, f, edges, idx, &mut w)?;
let edges = if chain { kernel::tangent_chain(&mb.body, &edges)? } else { edges };
if let Some(w) = w {
notes.push(w);
}
@@ -7864,6 +7880,7 @@ mod chain_tests {
style: crate::FilletStyle::Constant,
fit: Default::default(),
corner: Default::default(),
tangent_chain: true,
},
None,
)
@@ -0,0 +1,4 @@
//! A fillet or chamfer saves `tangent_chain: false` when it blends only its picked edges; absent,
//! the default, it blends each picked edge's tangent chain too, as designs before this change were
//! built (SMLib followed the chains itself). An older design has none and keeps its chains. The
//! last release refuses a design with this change (the newer format number).
+4 -2
View File
@@ -322,8 +322,10 @@ fn profile_points(doc: &Document, st: &ModelState, p: &CamProfile, cam: &super::
}
// Each body sampled to its own size (a large part beside the cam leaves the cam fine).
// Each body's edges with the faces beside them.
let meshes: Vec<solidcraft_geom::Mesh> =
bodies.iter().map(|b| b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tolerance(b.body.size())))).collect::<std::result::Result<_, _>>()?;
let meshes: Vec<solidcraft_geom::Mesh> = bodies
.iter()
.map(|b| b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tolerance(b.body.size()))))
.collect::<std::result::Result<_, _>>()?;
let edges: Vec<(usize, &Vec<Vec3>, &[u32])> = meshes
.iter()
.enumerate()
+2
View File
@@ -262,6 +262,7 @@ fn box_fillet_cut_timeline() {
style: Default::default(),
fit: Default::default(),
corner: Default::default(),
tangent_chain: true,
},
None,
)
@@ -334,6 +335,7 @@ fn errors_are_reported_not_fatal() {
style: Default::default(),
fit: Default::default(),
corner: Default::default(),
tangent_chain: true,
},
None,
)
+29 -7
View File
@@ -55,7 +55,16 @@ fn intersection_never_keeps_material_outside_the_trimming_cylinder() {
let before = volume(&s, "Base");
let r = s.execute("solid.cylinder", &json!({"radius": 16.65, "height": 14.59, "operation": "intersect", "targets": ["Base"]}));
let state = s.model.state();
let reach = state.body("Base").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3)).unwrap().positions.iter().map(|p| p.x.hypot(p.y)).fold(0.0, f64::max);
let reach = state
.body("Base")
.unwrap()
.body
.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3))
.unwrap()
.positions
.iter()
.map(|p| p.x.hypot(p.y))
.fold(0.0, f64::max);
match r {
Ok(_) => assert!(reach < 16.65 + 1e-2, "kept material out to radius {reach}"),
Err(e) => assert!((volume(&s, "Base") - before).abs() < 1e-9 && reach > 16.9, "{e}"),
@@ -544,7 +553,8 @@ fn joined_loft_frames_plan_their_display_at_every_quality() {
let body = &state.body(name).unwrap().body;
for q in DisplayQuality::ALL {
for k in 0..=MAX_REFINE {
let plan = body.mesh_plan(&solidcraft_kernel::MeshTolerance { chord: q.refined_chord(body.size(), k), angle: Some(q.refined_angle(k)) });
let plan =
body.mesh_plan(&solidcraft_kernel::MeshTolerance { chord: q.refined_chord(body.size(), k), angle: Some(q.refined_angle(k)) });
assert!(plan.is_ok(), "{name} at {} + {k}: {:?}", q.id(), plan.err());
}
}
@@ -595,7 +605,10 @@ fn oblique_cuts_through_a_chamfered_ring_are_exact() {
// Faces meshed coarsely keep to their surfaces: chords cut a little area off round faces,
// and nothing adds any (triangles fanning across a chamfer from the notch's hole or from the
// cuts' trims, folded over each other, added a few mm² each).
let (coarse, fine) = (body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.05)).unwrap().measure().area, body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-5)).unwrap().measure().area);
let (coarse, fine) = (
body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.05)).unwrap().measure().area,
body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-5)).unwrap().measure().area,
);
assert!(coarse < fine && coarse > fine - 2.0, "area meshed at 0.05: {coarse} vs {fine}");
println!(
"ring {whole:.6} / {ring:.6} mm³; cuts {cut1:.6}, {cut2:.6}; notch {:.6} / {notch:.6}; area meshed at 0.05 {coarse:.4} / {fine:.4}",
@@ -611,7 +624,8 @@ fn notch_into_a_ring_chamfer_is_cut_exactly() {
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/oblique_cuts_through_chamfered_ring.json")).unwrap();
let steps = script.get("commands").and_then(Value::as_array).unwrap();
let mut s = Session::default();
let measure = |s: &Session| s.model.state().body("R").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-5)).unwrap().measure().volume;
let measure =
|s: &Session| s.model.state().body("R").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-5)).unwrap().measure().volume;
s.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
let before = measure(&s);
// The notch's sketch and its cut.
@@ -942,7 +956,10 @@ fn imported_rotated_face_extrusions_preserve_other_operations_and_bodies() {
let reference = expected.body(&body.name).unwrap();
// Fixed fine chord tolerance: cap subdivision can change the default
// adaptive mesh's cylinder faceting without changing its analytic surface.
let (a, b) = (body.body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-5)).unwrap().measure().volume, reference.body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-5)).unwrap().measure().volume);
let (a, b) = (
body.body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-5)).unwrap().measure().volume,
reference.body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-5)).unwrap().measure().volume,
);
assert!((a - b).abs() < b.max(1.) * 1e-6, "{operation}: {} {a} vs {b}", body.name);
assert!(body.body.validity().is_empty(), "{operation}: {}", body.name);
let (a, b) = (body.mesh(), reference.mesh());
@@ -957,7 +974,11 @@ fn imported_rotated_face_extrusions_preserve_other_operations_and_bodies() {
let unchanged = &actual.body("Other").unwrap().body;
assert_eq!(unchanged.face_count(), other.face_count());
assert_eq!(unchanged.color(), other.color());
assert_eq!(unchanged.mesh(&solidcraft_kernel::MeshTolerance::chord(0.01)).unwrap().positions, other.mesh(&solidcraft_kernel::MeshTolerance::chord(0.01)).unwrap().positions, "unaffected imported body");
assert_eq!(
unchanged.mesh(&solidcraft_kernel::MeshTolerance::chord(0.01)).unwrap().positions,
other.mesh(&solidcraft_kernel::MeshTolerance::chord(0.01)).unwrap().positions,
"unaffected imported body"
);
}
}
}
@@ -1135,7 +1156,8 @@ fn cut_up_to_the_plate_top_face_opens_the_hole() {
// The same design without the cut.
script["commands"].as_array_mut().unwrap().pop();
let before = replay_value(&script);
let fine = |s: &Session| s.model.state().body("Body7").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-5)).unwrap().measure().volume;
let fine =
|s: &Session| s.model.state().body("Body7").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-5)).unwrap().measure().volume;
let (removed, want) = (fine(&before) - fine(&s), std::f64::consts::PI * 3.04 * 3.04 * 1.2);
assert!((removed - want).abs() < 0.01, "removed {removed} mm³, want {want}");
}
+5 -3
View File
@@ -161,9 +161,11 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
// Meshed within a quarter of the tolerance (the kernel goes no finer than a millionth of a
// body's size), so two meshes of one surface agree well within it.
let chord = tolerance / 4.0;
let mut done = solidcraft_kernel::map_on_threads(&[ba, bb], 2, |b| (super::inspect::measure_json(b), b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(chord))))
.into_iter()
.zip([&na, &nb]);
let mut done = solidcraft_kernel::map_on_threads(&[ba, bb], 2, |b| {
(super::inspect::measure_json(b), b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(chord)))
})
.into_iter()
.zip([&na, &nb]);
let mut next = || -> Result<(Value, solidcraft_geom::Mesh)> {
let Some((r, name)) = done.next() else { return Err(bad(cmd, "no such body")) };
let (m, mesh) = r.ok_or_else(|| bad(cmd, format!("`{name}`: measuring it failed")))?;
+16 -5
View File
@@ -489,16 +489,26 @@ fn transferred_frozen_face_paint_ignores_consumed_legacy_offset() {
fn boundary_material_proof_requires_resolvable_stock_and_void() {
use solidcraft_geom::Vec3;
let a = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3)).unwrap();
let b = solidcraft_kernel::box_solid(Vec3::new(1e-5, 0., 0.), Vec3::new(10.00001, 10., 10.)).unwrap().mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3)).unwrap();
let b = solidcraft_kernel::box_solid(Vec3::new(1e-5, 0., 0.), Vec3::new(10.00001, 10., 10.))
.unwrap()
.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3))
.unwrap();
let p = Vec3::new(5e-6, 5., 5.);
assert_ne!(a.contains(p), b.contains(p));
assert!((super::mesh_distance(&a, p) - 5e-6).abs() < 1e-12);
assert!((super::mesh_distance(&b, p) - 5e-6).abs() < 1e-12);
assert!(super::material_probe_matches(&a, &b, p, 1e-3, 1e-3));
let shifted = solidcraft_kernel::box_solid(Vec3::new(1., 0., 0.), Vec3::new(11., 10., 10.)).unwrap().mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3)).unwrap();
let shifted = solidcraft_kernel::box_solid(Vec3::new(1., 0., 0.), Vec3::new(11., 10., 10.))
.unwrap()
.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3))
.unwrap();
assert!(!super::material_probe_matches(&a, &shifted, p, 1e-3, 1e-3));
let thin = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 1e-4)).unwrap().mesh(&solidcraft_kernel::MeshTolerance::chord(1e-6)).unwrap();
let thin_shifted = solidcraft_kernel::box_solid(Vec3::new(0., 0., 1e-4), Vec3::new(10., 10., 2e-4)).unwrap().mesh(&solidcraft_kernel::MeshTolerance::chord(1e-6)).unwrap();
let thin =
solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 1e-4)).unwrap().mesh(&solidcraft_kernel::MeshTolerance::chord(1e-6)).unwrap();
let thin_shifted = solidcraft_kernel::box_solid(Vec3::new(0., 0., 1e-4), Vec3::new(10., 10., 2e-4))
.unwrap()
.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-6))
.unwrap();
// Every resolvable replacement is void: thin ambiguous material is refused.
assert!(!super::material_probe_matches(&thin, &thin_shifted, Vec3::new(5., 5., 5e-5), 1e-3, 1e-3));
assert!(!super::material_probe_matches(&a, &b, p, f64::INFINITY, 1e-3));
@@ -519,7 +529,8 @@ fn frozen_bore_boundary_copy_preserves_stock_void_and_reopen() {
// cardinal angle, so its distance is bounded by the requested sag tolerance.
assert!(super::mesh_distance(&source, Vec3::new(6., 10., 5.)) <= 1e-3);
let shifted = bore(11.).model.state().body("Drilled").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3)).unwrap();
let lost = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(30., 20., 10.)).unwrap().mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3)).unwrap();
let lost =
solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(30., 20., 10.)).unwrap().mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3)).unwrap();
assert!(!super::material_probe_matches(&source, &shifted, Vec3::new(6.5, 10., 5.), 1e-3, 1e-3));
assert!(!super::material_probe_matches(&source, &lost, Vec3::new(10., 10., 5.), 1e-3, 1e-3));
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Drilled"]}));
@@ -203,7 +203,11 @@ fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
}
let reopened = Session::new(solidcraft_doc::Document::from_json(&legacy.to_string()).unwrap());
assert!(reopened.model.result(feature_id).unwrap().error.is_none());
assert!((reopened.model.state().body("Sleeve").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-4)).unwrap().measure().volume - volume).abs() < 1e-8);
assert!(
(reopened.model.state().body("Sleeve").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-4)).unwrap().measure().volume - volume)
.abs()
< 1e-8
);
}
#[test]
+4 -2
View File
@@ -49,8 +49,8 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}] (exactly these edges are rounded: a tangent chain is not followed, so list every edge of it); radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); 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); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); 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}] (exactly these edges are cut: a tangent chain is not followed, so list every edge of it); 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?"),
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); 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); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); 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; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); 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)
.at("SOLID", "CREATE")
@@ -1635,6 +1635,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
style,
fit,
corner,
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
},
)?;
// The radii the fillet used, when `fit: reduce` reduced it.
@@ -1676,6 +1677,7 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
angle,
flip,
body: str_(p, "body").map(BodyName::from),
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
},
)
}
+33 -11
View File
@@ -630,7 +630,13 @@ fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() {
.world_state()
.bodies
.iter()
.find(|b| b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.001)).unwrap().contains(solidcraft_geom::Vec3::new(40.5 + pick_offset, 0., 7.5)))
.find(|b| {
b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.001)).unwrap().contains(solidcraft_geom::Vec3::new(
40.5 + pick_offset,
0.,
7.5,
))
})
.unwrap()
.name
.clone();
@@ -654,7 +660,13 @@ fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() {
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
let world = s.world_state();
for x in [39.5, 119.5] {
assert!(world.bodies.iter().any(|b| b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.001)).unwrap().contains(solidcraft_geom::Vec3::new(x, 0., 7.5))));
assert!(
world.bodies.iter().any(|b| b
.body
.mesh(&solidcraft_kernel::MeshTolerance::chord(0.001))
.unwrap()
.contains(solidcraft_geom::Vec3::new(x, 0., 7.5)))
);
}
let mut loaded = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
loaded.refresh();
@@ -802,7 +814,15 @@ fn check_body_offset(auto: bool) {
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
assert_eq!(s.doc.body_offsets["Sleeve"], offset);
assert_eq!(s.doc.body_component("Sleeve", s.model.state().body("Sleeve").unwrap().feature), holder);
assert!(s.world_state().body("Sleeve").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.001)).unwrap().contains(solidcraft_geom::Vec3::new(0., 10.5, 7.5)));
assert!(
s.world_state()
.body("Sleeve")
.unwrap()
.body
.mesh(&solidcraft_kernel::MeshTolerance::chord(0.001))
.unwrap()
.contains(solidcraft_geom::Vec3::new(0., 10.5, 7.5))
);
}
#[test]
@@ -984,7 +1004,11 @@ fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs()
let body = &state.body("Sleeve").unwrap().body;
assert_eq!(body.shell_count(), 1);
assert_eq!(
body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.001)).unwrap().contains(Vec3::new(0., if mode == "emboss" { 10.5 } else { 9.5 }, 7.5)),
body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.001)).unwrap().contains(Vec3::new(
0.,
if mode == "emboss" { 10.5 } else { 9.5 },
7.5
)),
mode == "emboss"
);
run(&mut s, "edit.undo", json!({}));
@@ -1022,7 +1046,10 @@ fn cylinder_auto_inference_ignores_remote_finite_tangent_walls_in_each_body_orde
let remote = s.model.state().body("Remote").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-4)).unwrap().measure().volume;
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1}));
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
assert_eq!(s.model.state().body("Remote").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-4)).unwrap().measure().volume, remote);
assert_eq!(
s.model.state().body("Remote").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-4)).unwrap().measure().volume,
remote
);
}
}
@@ -1865,12 +1892,7 @@ fn a_ring_cut_round_a_hole_takes_in_the_hole_it_is_drawn_round() {
let before = volume(&mut s);
// Profile 2: the ring between the hole's wall and the larger circle.
let r = s.execute("solid.extrude", &json!({"direction":"negative","distance":"3","operation":"cut","profiles":[2]}));
let clean = r
.as_ref()
.ok()
.and_then(|v| v["feature"].as_u64())
.and_then(|id| s.model.result(id))
.is_some_and(|f| f.error.is_none());
let clean = r.as_ref().ok().and_then(|v| v["feature"].as_u64()).and_then(|id| s.model.result(id)).is_some_and(|f| f.error.is_none());
(r.is_ok(), before - volume(&mut s), clean)
};
let taken = std::f64::consts::PI * (36.0 - 9.0) * 3.0;
+4 -1
View File
@@ -195,7 +195,10 @@ pub fn collisions(s: &Session) -> Vec<(String, String, f64)> {
/// Edges are tested for crossing the other's surface too: plates crossing face to face have
/// no sample point inside (#3323).
fn overlap(a: &Body, b: &Body) -> Option<f64> {
let (ma, mb) = (a.mesh(&solidcraft_kernel::MeshTolerance::chord((a.size() * 1e-3).max(1e-3))).ok()?, b.mesh(&solidcraft_kernel::MeshTolerance::chord((b.size() * 1e-3).max(1e-3))).ok()?);
let (ma, mb) = (
a.mesh(&solidcraft_kernel::MeshTolerance::chord((a.size() * 1e-3).max(1e-3))).ok()?,
b.mesh(&solidcraft_kernel::MeshTolerance::chord((b.size() * 1e-3).max(1e-3))).ok()?,
);
mesh_overlap(&ma, &mb, 1e-4 * a.size().max(b.size()).max(1.0))
}
@@ -662,7 +662,9 @@ fn a_rectangle_put_onto_a_projected_edge_stays_a_closed_profile() {
// Picked by a point inside, it extrudes to a 20 x 15 x 10 block of its own.
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": id, "profiles": [{"point": [15, 7.5]}], "distance": 10, "operation": "new", "body_name": "Pad"}));
let volume = |s: &Session, name: &str| s.model.state().body(name).unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-4)).unwrap().measure().volume;
let volume = |s: &Session, name: &str| {
s.model.state().body(name).unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-4)).unwrap().measure().volume
};
assert!((volume(&s, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&s, "Pad"));
assert!((volume(&s, &block) - 24000.0).abs() < 1e-6, "{}", volume(&s, &block));
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
+6 -1
View File
@@ -41,7 +41,12 @@ fn enclosure() -> Session {
}
fn bodies(s: &Session) -> Vec<(String, f64)> {
s.model.state().bodies.iter().map(|b| (b.name.clone(), b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-2)).unwrap().measure().volume)).collect()
s.model
.state()
.bodies
.iter()
.map(|b| (b.name.clone(), b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-2)).unwrap().measure().volume))
.collect()
}
#[test]
+2
View File
@@ -1048,6 +1048,7 @@ mod tests {
style: Default::default(),
fit: Default::default(),
corner: Default::default(),
tangent_chain: true,
},
None,
)
@@ -1133,6 +1134,7 @@ mod tests {
style: Default::default(),
fit: Default::default(),
corner: Default::default(),
tangent_chain: true,
},
None,
)
+62 -1
View File
@@ -2,7 +2,7 @@
//! ([`crate::blend_smlib`]): the blend faces, the corners where blends meet (a sphere, a patch, a
//! bevel or a mitre, as its fillet corners choose) and the end faces rebuilt round them. A blend
//! whose contact lines would leave the faces it rolls on is SMLib's refusal, reported as
//! [`KernelError::TooLarge`]. A tangent chain is the caller's to select.
//! [`KernelError::TooLarge`]. A tangent chain is the caller's to select ([`tangent_chain`]).
//!
//! The helpers here read a body's schema 4 record: planar faces' outward normals, the direction
//! into a face across an edge, an edge's tangent at its ends.
@@ -389,6 +389,67 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl
crate::blend_smlib::chamfer_sides(body, edges, d1, side, flip)
}
/// The picked edges' tangent chains, for a blend that is to run along them: `picks` (each the
/// nearest edge of `body` to it), then a point on every other edge that continues one of them
/// tangentially (within 0.5° at a shared vertex), until no more do. An edge only one face uses
/// ends a chain. Picks that find no edge are refused.
pub fn tangent_chain(body: &Body, picks: &[Vec3]) -> Result<Vec<Vec3>> {
/// Edges meeting at an angle below this continue one another.
const TANGENT_DEGREES: f64 = 0.5;
if picks.is_empty() || picks.len() > 1000 || picks.iter().any(|p| !p.is_finite()) {
return Err(KernelError::Invalid("select 1…1000 edges by finite points".into()));
}
let rec = Faces::of(body)?;
let size = body.size();
let mut chain: Vec<usize> = Vec::new();
for p in picks {
let (e, dist) = body.nearest_edge(*p, (size * 2e-3).max(1e-3))?.ok_or_else(|| KernelError::Invalid("the body has no edges".into()))?;
if dist > size * 0.05 + 1e-3 {
return Err(KernelError::Invalid(format!("no edge near the pick at ({:.3}, {:.3}, {:.3})", p.x, p.y, p.z)));
}
if !chain.contains(&e) {
chain.push(e);
}
}
let picked = chain.len();
// The unit direction edge `e` leaves its end `v` in.
let leaving = |e: usize, v: u32| -> Option<Vec3> {
let [a, b] = rec.rec.edges.get(e)?.vertices;
if a == b {
return None;
}
if a == v { rec.tangent(e, false) } else { rec.tangent(e, true).map(|t| t * -1.0) }
};
let cos = TANGENT_DEGREES.to_radians().cos();
let mut k = 0;
while k < chain.len() {
let Some(&e) = chain.get(k) else { break };
k += 1;
let Some(edge) = rec.rec.edges.get(e) else { continue };
for v in edge.vertices {
let Some(d) = leaving(e, v) else { continue };
for (f, other) in rec.rec.edges.iter().enumerate() {
if chain.contains(&f) || other.uses.len() < 2 || !other.vertices.contains(&v) {
continue;
}
if leaving(f, v).is_some_and(|g| d.dot(g) < -cos) {
chain.push(f);
}
}
}
}
let mut out = picks.to_vec();
for e in chain.into_iter().skip(picked) {
let edge = rec.rec.edges.get(e).ok_or_else(|| KernelError::Failed("a chain edge".into()))?;
let mid = CurveEval::new(&edge.curve)
.ok()
.and_then(|c| c.point(0.5 * (edge.interval[0] + edge.interval[1])))
.ok_or_else(|| KernelError::Failed("a point on a chain edge".into()))?;
out.push(mid);
}
Ok(out)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
+1 -1
View File
@@ -150,7 +150,7 @@ mod validity;
pub mod watch;
mod whole_face;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each};
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each, tangent_chain};
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, DisplayShare, EdgeInfo, FaceInfo, FacePaint, FaceSource, MeshTolerance, Paint};