From 92d49debec77e0674fe67eba6dc9e6c982f55d5c Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:41:15 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/plan.rs | 49 +++++++- crates/engine/src/cmd/plan/blocks.rs | 163 ++++++++++++++++++++++----- skills/fieldcraft-meshing/SKILL.md | 5 +- 3 files changed, 181 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/cmd/plan.rs b/crates/engine/src/cmd/plan.rs index c8db208..849a87f 100644 --- a/crates/engine/src/cmd/plan.rs +++ b/crates/engine/src/cmd/plan.rs @@ -818,6 +818,8 @@ pub(crate) fn build(design: &Design, body: &str, size: Option, 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, 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, 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!([ diff --git a/crates/engine/src/cmd/plan/blocks.rs b/crates/engine/src/cmd/plan/blocks.rs index 0376d0f..b95e1cf 100644 --- a/crates/engine/src/cmd/plan/blocks.rs +++ b/crates/engine/src/cmd/plan/blocks.rs @@ -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 { + 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 = self.layers.iter().map(|(s, d, w)| format!("{s} {d:.4} mm ({w})")).collect(); format!( @@ -141,6 +158,9 @@ struct Group { circles: Vec, /// 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; 2], } /// Plan the block topology of the body. @@ -221,7 +241,25 @@ pub(crate) fn layout(inp: &Input) -> Result { } } - // 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 = 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 { 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 { fixed[a].dedup_by(|x, y| (*x - *y).abs() < eps); } - // Circles, concentric ones grouped, and their frames. - let mut groups: Vec = 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 { 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 { .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; 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. diff --git a/skills/fieldcraft-meshing/SKILL.md b/skills/fieldcraft-meshing/SKILL.md index da34ddb..ac77cc3 100644 --- a/skills/fieldcraft-meshing/SKILL.md +++ b/skills/fieldcraft-meshing/SKILL.md @@ -28,8 +28,9 @@ not guessed. Read it before you plan anything: `mesh.plan.apply {"plan": "Plan-"}`. 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`