forked from 1b-agent/fieldcraft-upstream
mesh: a screw hole's ring takes in the fillet of its rounded corner; frames centred on the hole (#402)
The projection layout gave every fillet a blend layer along the sides it runs between, so a screw hole close to a rounded corner sat inside the layer and the plan fell back to frame cuts and swept cells, which fan into the hole. A corner fillet that lies inside the square from the hole to the two sides is now taken in by the hole's O-grid ring: the frame reaches both sides, its outer corner projects onto the middle of the fillet, and no layer is needed. An O-grid's frame is centred on its hole (a near side is the frame, the far side as far), and the plan reports the layout's frames and drops the frame-cut notes when it does not cut. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
1d71a9f8af
commit
92d49debec
3 files changed
+181
-36
No files matched your search
@@ -818,6 +818,8 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
|
||||
}
|
||||
let mut cuts = Vec::new();
|
||||
let mut rings = Vec::new();
|
||||
// Where the frame cuts could not go: the swept cells' business only.
|
||||
let mut frame_notes = Vec::new();
|
||||
if let Some(d) = direction {
|
||||
let planes = surfaces
|
||||
.iter()
|
||||
@@ -843,7 +845,7 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
|
||||
.collect();
|
||||
let body = Clearance { corners, planes, curved, gap: 0.5 * h };
|
||||
imprint_cuts(&imprints, d, &body, &mut cuts);
|
||||
notes.extend(frame_cuts(&circle_list, d, &body, &mut cuts));
|
||||
frame_notes = frame_cuts(&circle_list, d, &body, &mut cuts);
|
||||
rings = plan_rings(&circle_list, d, &body, &cuts, shape, &surfaces, &features);
|
||||
}
|
||||
// The tessellation: the meshers' default, finer where the body has small curves (a hundredth
|
||||
@@ -884,10 +886,21 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
|
||||
Some(Ok(layout)) => {
|
||||
plan.mesher = PlanMesher::Project;
|
||||
plan.cuts.clear();
|
||||
// The rings' frames are the layout's.
|
||||
if let Some(d) = plan.direction {
|
||||
for r in &mut plan.rings {
|
||||
if let Some(half) = layout.frame_of(r.center, d).filter(|_| r.kind == RingKind::OGrid) {
|
||||
r.frame = half;
|
||||
}
|
||||
}
|
||||
}
|
||||
plan.notes.push(layout.summary());
|
||||
}
|
||||
Some(Err(why)) => plan.notes.push(format!("no projection layout ({why}): the frame cuts box the rings and the cells are swept")),
|
||||
None => {}
|
||||
Some(Err(why)) => {
|
||||
plan.notes.extend(frame_notes);
|
||||
plan.notes.push(format!("no projection layout ({why}): the frame cuts box the rings and the cells are swept"));
|
||||
}
|
||||
None => plan.notes.extend(frame_notes),
|
||||
}
|
||||
plan.check()?;
|
||||
Ok(plan)
|
||||
@@ -1516,6 +1529,36 @@ mod acceptance {
|
||||
accept(&mut s, &r, 0.5, 10.0);
|
||||
}
|
||||
|
||||
/// An enclosure lid: a plate with rounded corners, a screw hole in each corner close to the
|
||||
/// fillet and a square opening. Each hole's ring reaches the plate's sides and takes the
|
||||
/// corner fillet in (no blend layer, no fan into the hole), its frame centred on the hole.
|
||||
#[test]
|
||||
fn a_lid_with_screw_holes_in_its_rounded_corners_rings_each_hole_to_the_fillet() {
|
||||
let (_d, mut s) = open(json!([
|
||||
{"command": "solid.box", "params": {"length": 128, "width": 98, "height": 4, "body_name": "Lid"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[0, 0, 2], [128, 0, 2], [128, 98, 2], [0, 98, 2]], "radius": 8}},
|
||||
{"command": "solid.hole", "params": {"position": [7, 7, 4], "diameter": 3.5, "depth": 4}},
|
||||
{"command": "solid.hole", "params": {"position": [121, 7, 4], "diameter": 3.5, "depth": 4}},
|
||||
{"command": "solid.hole", "params": {"position": [7, 91, 4], "diameter": 3.5, "depth": 4}},
|
||||
{"command": "solid.hole", "params": {"position": [121, 91, 4], "diameter": 3.5, "depth": 4}},
|
||||
{"command": "solid.box", "params": {"length": 22, "width": 22, "height": 4, "corner": [41, 40, 0], "operation": "cut"}},
|
||||
]));
|
||||
let plan = s.execute("mesh.plan", &json!({"body": "Lid", "size": 1.5, "layers": 2})).unwrap();
|
||||
assert_eq!(plan["mesher"], "project", "{plan}");
|
||||
// Every frame is the same square, centred on its hole and reaching the plate's sides.
|
||||
let rings = plan["rings"].as_array().unwrap();
|
||||
assert_eq!(rings.len(), 4, "{plan}");
|
||||
for r in rings {
|
||||
assert!((r["frame"].as_f64().unwrap() - 7.0).abs() < 1e-6, "{r}");
|
||||
}
|
||||
let notes = plan["notes"].as_array().unwrap();
|
||||
assert!(notes.iter().all(|n| !n.as_str().unwrap().contains("frame cut")), "{plan}");
|
||||
let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
|
||||
assert!(r["layout"].as_str().unwrap().contains("4 circle(s) ringed"), "{r}");
|
||||
assert_eq!(r["blend_layers"], json!([]), "the rings take the corner fillets in: {r}");
|
||||
accept(&mut s, &r, 0.6, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filleted_box_meshes_structured_round_its_corners() {
|
||||
let (_d, mut s) = open(json!([
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
//! - **Rings.** The frame cell round each circle (concentric circles together) is O-gridded
|
||||
//! through every level, square inside square: the frame, then a square with its corners at
|
||||
//! 45° on each circle, then a core. Corners on a circle sit on it before projection, so the
|
||||
//! ring's radial block edges meet the cylinder square on.
|
||||
//! ring's radial block edges meet the cylinder square on. A hole's frame is centred on it: a
|
||||
//! side of the body near the hole is the frame there and the far side is as far; a fillet
|
||||
//! round a corner of the outline that holds a hole (a screw hole in a rounded corner) is taken
|
||||
//! in by the hole's ring, its frame reaching the two sides and its outer corner projected onto
|
||||
//! the fillet's middle, so the corner needs no blend layer.
|
||||
//! - **Blend layer.** A side of the body a fillet or chamfer runs along, or that a shell's wall
|
||||
//! stands on, gets a layer of blocks between the grid (on the body shrunk by the blend's
|
||||
//! depth or the wall's thickness) and the side, one block per boundary face of the grid, the
|
||||
@@ -52,9 +56,22 @@ pub(crate) struct Layout {
|
||||
pub(crate) layers: Vec<(String, f64, &'static str)>,
|
||||
/// Grid planes per axis of the frame (across, across, along the sweep).
|
||||
pub(crate) planes: [usize; 3],
|
||||
/// The frame round each circle group: its axis point (the sweep's coordinate 0) and the
|
||||
/// smallest half-side of the frame.
|
||||
pub(crate) frames: Vec<(V3, f64)>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// The half-side of the frame round the circle about the axis through `center` along the
|
||||
/// sweep `direction`.
|
||||
pub(crate) fn frame_of(&self, center: V3, direction: V3) -> Option<f64> {
|
||||
self.frames.iter().find_map(|(p, half)| {
|
||||
let off = sub(center, *p);
|
||||
let along = dot(off, direction);
|
||||
(dot(off, off) - along * along < 1e-12 * dot(center, center).max(1.0)).then_some(*half)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn summary(&self) -> String {
|
||||
let layers: Vec<String> = self.layers.iter().map(|(s, d, w)| format!("{s} {d:.4} mm ({w})")).collect();
|
||||
format!(
|
||||
@@ -141,6 +158,9 @@ struct Group {
|
||||
circles: Vec<Circle>,
|
||||
/// The frame's extent per local axis (0, 1): [low, high].
|
||||
frame: [[f64; 2]; 2],
|
||||
/// The frame's half-side per local axis when a corner blend it takes in sets it: the
|
||||
/// distance from the centre to the body's side the blend runs along.
|
||||
forced: [Option<f64>; 2],
|
||||
}
|
||||
|
||||
/// Plan the block topology of the body.
|
||||
@@ -221,7 +241,25 @@ pub(crate) fn layout(inp: &Input) -> Result<Layout, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// The blend layer: per side, the depth of the blends along it, or the shell's wall.
|
||||
// Circles, concentric ones grouped (radii descending).
|
||||
let mut groups: Vec<Group> = Vec::new();
|
||||
for c in &circles {
|
||||
match groups.iter_mut().find(|g| (g.c[0] - c.c[0]).hypot(g.c[1] - c.c[1]) < 1e-6 * c.r.max(1.0)) {
|
||||
Some(g) => {
|
||||
if !g.circles.iter().any(|x| (x.r - c.r).abs() < 1e-6 * c.r.max(1.0)) {
|
||||
g.circles.push(*c);
|
||||
}
|
||||
}
|
||||
None => groups.push(Group { c: c.c, circles: vec![*c], frame: [[0.0; 2]; 2], forced: [None; 2] }),
|
||||
}
|
||||
}
|
||||
for g in &mut groups {
|
||||
g.circles.sort_by(|a, b| b.r.total_cmp(&a.r));
|
||||
}
|
||||
|
||||
// The blend layer: per side, the depth of the blends along it, or the shell's wall. A blend
|
||||
// round a corner of the outline that a hole's ring can frame (a screw hole in a rounded
|
||||
// corner) is taken in by the ring instead.
|
||||
let mut ring = [[0.0f64; 2]; 3];
|
||||
let mut what = [[""; 2]; 3];
|
||||
for &f in &blend_faces {
|
||||
@@ -229,6 +267,12 @@ pub(crate) fn layout(inp: &Input) -> Result<Layout, String> {
|
||||
if pts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((g, half)) = corner_ring(&groups, pts, lo, hi, eps)
|
||||
&& let Some(g) = groups.get_mut(g)
|
||||
{
|
||||
g.forced = half.map(Some);
|
||||
continue;
|
||||
}
|
||||
let mut touched = false;
|
||||
for a in 0..3 {
|
||||
for s in 0..2 {
|
||||
@@ -291,21 +335,8 @@ pub(crate) fn layout(inp: &Input) -> Result<Layout, String> {
|
||||
fixed[a].dedup_by(|x, y| (*x - *y).abs() < eps);
|
||||
}
|
||||
|
||||
// Circles, concentric ones grouped, and their frames.
|
||||
let mut groups: Vec<Group> = Vec::new();
|
||||
for c in &circles {
|
||||
match groups.iter_mut().find(|g| (g.c[0] - c.c[0]).hypot(g.c[1] - c.c[1]) < 1e-6 * c.r.max(1.0)) {
|
||||
Some(g) => {
|
||||
if !g.circles.iter().any(|x| (x.r - c.r).abs() < 1e-6 * c.r.max(1.0)) {
|
||||
g.circles.push(*c);
|
||||
}
|
||||
}
|
||||
None => groups.push(Group { c: c.c, circles: vec![*c], frame: [[0.0; 2]; 2] }),
|
||||
}
|
||||
}
|
||||
let snap = (0.3 * inp.size).min(0.25 * circles.iter().map(|c| c.r).fold(f64::INFINITY, f64::min));
|
||||
for g in &mut groups {
|
||||
g.circles.sort_by(|a, b| b.r.total_cmp(&a.r));
|
||||
let outer = g.circles.first().copied().ok_or("an empty circle group")?;
|
||||
let r0 = outer.r;
|
||||
for a in 0..2 {
|
||||
@@ -313,22 +344,15 @@ pub(crate) fn layout(inp: &Input) -> Result<Layout, String> {
|
||||
if fixed[a].iter().any(|v| (v - c).abs() < r0 - eps) {
|
||||
return Err(format!("a face of the body runs through the circle of radius {r0:.4}"));
|
||||
}
|
||||
}
|
||||
let half = match outer.kind {
|
||||
RingKind::Butterfly => [[r0 / 2f64.sqrt(); 2]; 2],
|
||||
RingKind::OGrid => o_grid_frame(g, &fixed, eps)?,
|
||||
};
|
||||
for a in 0..2 {
|
||||
let c = g.c[a];
|
||||
for s in 0..2 {
|
||||
let h = match outer.kind {
|
||||
RingKind::Butterfly => r0 / 2f64.sqrt(),
|
||||
RingKind::OGrid => {
|
||||
let dist = fixed[a].iter().map(|v| if s == 0 { c - v } else { v - c }).filter(|x| *x > 0.0).fold(f64::INFINITY, f64::min);
|
||||
if !dist.is_finite() {
|
||||
return Err(format!("the hole of radius {r0:.4} has no side of the body round it"));
|
||||
}
|
||||
if dist < 1.05 * r0 {
|
||||
return Err(format!("the hole of radius {r0:.4} is {dist:.4} mm from a face: too close for a ring"));
|
||||
}
|
||||
// The side itself frames the ring when a strip narrower than the radius
|
||||
// would be left between them.
|
||||
if dist <= 3.0 * r0 + eps { dist } else { 2.0 * r0 }
|
||||
}
|
||||
};
|
||||
let h = half[a][s];
|
||||
if outer.kind == RingKind::Butterfly && fixed[a].iter().any(|v| (v - c).abs() < h - eps) {
|
||||
return Err(format!("a face of the body runs through the square inside the circle of radius {r0:.4}"));
|
||||
}
|
||||
@@ -555,7 +579,84 @@ pub(crate) fn layout(inp: &Input) -> Result<Layout, String> {
|
||||
.filter(|&(a, s)| ring[a][s] > 0.0)
|
||||
.map(|(a, s)| (SIDE_NAMES[perm[a]][s].to_string(), ring[a][s], what[a][s]))
|
||||
.collect();
|
||||
Ok(Layout { topology: t, rings, layers, planes: planes.each_ref().map(Vec::len) })
|
||||
let frames = groups
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let half = (0..2).flat_map(|a| [g.c[a] - g.frame[a][0], g.frame[a][1] - g.c[a]]).fold(f64::INFINITY, f64::min);
|
||||
(world([g.c[0], g.c[1], 0.0]), half)
|
||||
})
|
||||
.collect();
|
||||
Ok(Layout { topology: t, rings, layers, planes: planes.each_ref().map(Vec::len), frames })
|
||||
}
|
||||
|
||||
/// The ring that takes in the blend with points `pts` (local frame): a blend round a corner of
|
||||
/// the outline, touching the body's side along both in-plane axes, that lies inside the frame of
|
||||
/// an O-grid ring reaching from the hole to those sides, the hole centred in it. Returns the
|
||||
/// group and the frame's half-side per axis (the hole's distance to the side). A frame wider
|
||||
/// than 6 radii (or narrower than 1.2) does not take a blend in: the hole is not the corner's.
|
||||
fn corner_ring(groups: &[Group], pts: &[V3], lo: V3, hi: V3, eps: f64) -> Option<(usize, [f64; 2])> {
|
||||
let (mut bl, mut bh) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
|
||||
for p in pts {
|
||||
for a in 0..2 {
|
||||
bl[a] = bl[a].min(p[a]);
|
||||
bh[a] = bh[a].max(p[a]);
|
||||
}
|
||||
}
|
||||
let tol = 1e3 * eps;
|
||||
groups.iter().enumerate().find_map(|(i, g)| {
|
||||
let outer = g.circles.first()?;
|
||||
if outer.kind != RingKind::OGrid {
|
||||
return None;
|
||||
}
|
||||
let mut half = [0.0; 2];
|
||||
for a in 0..2 {
|
||||
let h = if (bl[a] - lo[a]).abs() < tol {
|
||||
g.c[a] - lo[a]
|
||||
} else if (bh[a] - hi[a]).abs() < tol {
|
||||
hi[a] - g.c[a]
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if !(1.2 * outer.r..=6.0 * outer.r).contains(&h) || bl[a] < g.c[a] - h - tol || bh[a] > g.c[a] + h + tol {
|
||||
return None;
|
||||
}
|
||||
half[a] = h;
|
||||
}
|
||||
Some((i, half))
|
||||
})
|
||||
}
|
||||
|
||||
/// The half-sides of an O-grid ring's frame, per local axis and side: the hole centred in it.
|
||||
/// A side of the body within 3 radii (or a corner blend the ring takes in) is the frame on that
|
||||
/// side, and the frame's other side is as far from the centre unless the body's side is nearer
|
||||
/// there too; an axis with no side near takes the half-side of the other axis's (a square
|
||||
/// frame) when that is 1.5 to 3 radii, else 2 radii.
|
||||
fn o_grid_frame(g: &Group, fixed: &[Vec<f64>; 3], eps: f64) -> Result<[[f64; 2]; 2], String> {
|
||||
let r0 = g.circles.first().map_or(0.0, |c| c.r);
|
||||
let mut half: [Option<[f64; 2]>; 2] = [None; 2];
|
||||
for a in 0..2 {
|
||||
let c = g.c[a];
|
||||
let mut dist = [0.0; 2];
|
||||
for (s, d) in dist.iter_mut().enumerate() {
|
||||
*d = fixed[a].iter().map(|v| if s == 0 { c - v } else { v - c }).filter(|x| *x > 0.0).fold(f64::INFINITY, f64::min);
|
||||
if !d.is_finite() {
|
||||
return Err(format!("the hole of radius {r0:.4} has no side of the body round it"));
|
||||
}
|
||||
if *d < 1.05 * r0 {
|
||||
return Err(format!("the hole of radius {r0:.4} is {d:.4} mm from a face: too close for a ring"));
|
||||
}
|
||||
}
|
||||
let near = dist[0].min(dist[1]);
|
||||
half[a] = match g.forced[a] {
|
||||
Some(h) => Some([h; 2]),
|
||||
// The side itself frames the ring when a strip narrower than the radius would be
|
||||
// left between them; the other side keeps the frame centred unless it is near too.
|
||||
None if near <= 3.0 * r0 + eps => Some(dist.map(|d| if d <= 3.0 * r0 + eps { d } else { near })),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
let square = |b: usize| half[b].map(|h| h[0].min(h[1])).filter(|h| (1.5 * r0 - eps..=3.0 * r0 + eps).contains(h)).unwrap_or(2.0 * r0);
|
||||
Ok([half[0].unwrap_or([square(1); 2]), half[1].unwrap_or([square(0); 2])])
|
||||
}
|
||||
|
||||
/// The six faces of a hex8 block, as corner cycles.
|
||||
|
||||
@@ -28,8 +28,9 @@ not guessed. Read it before you plan anything:
|
||||
`mesh.plan.apply {"plan": "Plan-<body>"}`. When the plan has rings or blends and the body
|
||||
fits, its `mesher` is `project`: one block topology over the whole body (a grid on the
|
||||
body's planar faces, an O-grid square-inside-square round every circle, concentric ones
|
||||
nested: a boss and its hole; a blend layer along fillets, chamfers and shell walls at their
|
||||
stored depth) projected and smoothed — no partitions, one conforming mesh, one division
|
||||
nested: a boss and its hole, frames centred on the hole; a screw hole in a rounded corner
|
||||
takes the corner's fillet into its ring; a blend layer along fillets, chamfers and shell walls
|
||||
at their stored depth) projected and smoothed — no partitions, one conforming mesh, one division
|
||||
count per sheet of blocks. Read `layout` and `blend_layers`; the plan's `notes` say why a
|
||||
body did not fit (a plane at an angle, a curved face that is not a ring or a blend, two
|
||||
circles in one frame), and then the frame cuts and swept cells are used instead (`pending`
|
||||
|
||||
Reference in new issue
Block a user