Convert to Sheet Metal keeps a plate's round holes round (#977)

The plate check compared the face outline's area (from the tessellation, so each hole was a polygon of chords) with the exact volume, and refused plates with a few small holes. Where the outline runs along a cylinder about the plate's normal it is now an arc of that circle; coaxial pieces of one radius are one wall, so a hole split into two, three or four faces is one circle. The check passes with its tolerance unchanged, and the flat pattern and its DXF carry one CIRCLE per hole (ARC for rounded corners and slot ends).

A round blank now converts too, so unfold_cut_refold_and_convert checks the refusal with a block thicker at one end instead of a rod.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-14 01:47:40 -04:00
1 parent 1ff16cc72b
commit 4687e3b073
4 files changed
+433 -34

No files matched your search

+118 -29
View File
@@ -596,13 +596,18 @@ pub(super) fn sheet_eval(
let ds: Vec<f64> = mesh.positions.iter().map(|p| (*p - *face).dot(n)).collect();
let (lo, hi) = (ds.iter().cloned().fold(f64::MAX, f64::min), ds.iter().cloned().fold(f64::MIN, f64::max));
let t = hi - lo;
// The face outline: boundary edges of the triangles on that face's plane.
// The face outline: boundary edges of the triangles on that face's plane, where it runs
// along a round wall an arc of the wall's circle (not chords: the plate check below
// compares its area with the exact volume).
let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
let mut loops = face_loops(&mesh, &plane, *face, n, lo, tol);
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
let mut walls = std::collections::HashMap::new();
let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| round_wall(&mb.body, &plane, n, f));
let mut loops: Vec<Loop2> =
face_outline(&mesh, &plane, *face, n, lo, tol).iter().map(|l| outline_loop(l, &mut wall, tol * 1e-2)).collect();
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let mut it = loops.into_iter();
let outer = Loop2::polygon(&it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?).ccw();
let region = Region2 { outer, holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() };
let outer = it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?.ccw();
let region = Region2 { outer, holes: it.map(|l| l.ccw().reversed()).collect() };
let area = region.area().abs();
let vol = kernel::measure(&mb.body)?.volume;
if (area * t - vol).abs() > 1e-3 * vol.max(1.0) {
@@ -1032,50 +1037,78 @@ fn sheet_prism_cut(
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
/// `level` from `at` along `n`: the edges used by one of them only, chained.
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
let on: Vec<[u32; 3]> = mesh
.triangles
.iter()
.copied()
.filter(|t| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol)))
.collect();
face_outline(mesh, plane, at, n, level, tol).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect()
}
/// [`face_loops`] with each point's B-rep face across the outline from it to the next point (the
/// wall the outline runs along there): the face off the plane that meets both points.
fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<(Vec2, Option<u32>)>> {
let on_plane = |t: &[u32; 3]| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol));
let on: Vec<[u32; 3]> = mesh.triangles.iter().copied().filter(on_plane).collect();
// Boundary edges by position (meshes may repeat vertices per triangle).
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
let key = |a: Vec3, b: Vec3| {
let (ka, kb) = (q(a), q(b));
(ka.min(kb), ka.max(kb))
};
let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default();
let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect();
for (a, b) in &edges {
let (ka, kb) = (q(*a), q(*b));
*count.entry((ka.min(kb), ka.max(kb))).or_insert(0) += 1;
*count.entry(key(*a, *b)).or_insert(0) += 1;
}
let mut boundary: Vec<(Vec3, Vec3)> = edges
.into_iter()
.filter(|(a, b)| {
let (ka, kb) = (q(*a), q(*b));
count.get(&(ka.min(kb), ka.max(kb))) == Some(&1)
})
.collect();
// The faces off the plane at each point on it, by position to a millionth of a millimetre
// either way (a neighbouring face's mesh may round a shared point differently).
let mut touch: std::collections::HashMap<(i64, i64, i64), Vec<u32>> = Default::default();
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face).filter(|(t, _)| !on_plane(t)) {
for p in t.iter().map(|k| pos(*k)).filter(|p| ((*p - at).dot(n) - level).abs() < tol) {
let faces = touch.entry(q(p)).or_default();
if !faces.contains(f) {
faces.push(*f);
}
}
}
let near = |p: Vec3| {
let (x, y, z) = q(p);
let cells = (-1..=1).flat_map(|i| (-1..=1).flat_map(move |j| (-1..=1).map(move |k| (x + i, y + j, z + k))));
let mut faces: Vec<u32> = cells.filter_map(|c| touch.get(&c)).flatten().copied().collect();
faces.sort_unstable();
faces.dedup();
faces
};
let across = |a: Vec3, b: Vec3| {
let at_b = near(b);
near(a).into_iter().find(|f| at_b.contains(f))
};
let mut boundary: Vec<(Vec3, Vec3)> = edges.into_iter().filter(|(a, b)| count.get(&key(*a, *b)) == Some(&1)).collect();
// Chain into loops (plane coordinates).
let mut loops: Vec<Vec<Vec2>> = Vec::new();
let mut loops: Vec<Vec<(Vec2, Option<u32>)>> = Vec::new();
for _ in 0..10_000 {
let Some((s0, mut cur)) = boundary.pop() else { break };
let mut lp = vec![s0];
let mut lp = vec![(s0, across(s0, cur))];
for _ in 0..boundary.len() + 1 {
if q(cur) == q(s0) {
break;
}
lp.push(cur);
match boundary.iter().position(|(a, _)| q(*a) == q(cur)) {
Some(k) => cur = boundary.remove(k).1,
None => break,
Some(k) => {
let b = boundary.remove(k).1;
lp.push((cur, across(cur, b)));
cur = b;
}
None => {
lp.push((cur, None));
break;
}
}
}
let raw: Vec<Vec2> = lp.iter().map(|p| plane.to_local(*p)).collect();
let raw: Vec<(Vec2, Option<u32>)> = lp.iter().map(|(p, f)| (plane.to_local(*p), *f)).collect();
// Drop points in the middle of straight runs.
let m = raw.len();
let pts: Vec<Vec2> = (0..m)
let pts: Vec<(Vec2, Option<u32>)> = (0..m)
.filter_map(|i| {
let (p, c, n) = (*raw.get((i + m - 1) % m)?, *raw.get(i)?, *raw.get((i + 1) % m)?);
((c - p).cross(n - c).abs() > 1e-9 * (c - p).len().max(1e-9) * (n - c).len().max(1e-9)).then_some(c)
let (p, c, n) = (raw.get((i + m - 1) % m)?.0, *raw.get(i)?, raw.get((i + 1) % m)?.0);
((c.0 - p).cross(n - c.0).abs() > 1e-9 * (c.0 - p).len().max(1e-9) * (n - c.0).len().max(1e-9)).then_some(c)
})
.collect();
if pts.len() >= 3 {
@@ -1085,6 +1118,62 @@ fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
loops
}
/// The circle (centre in `plane` coordinates, radius) of face `f` when it is a round wall
/// standing square on the plane: a cylinder about the plane's normal `n`.
fn round_wall(body: &Body, plane: &Plane, n: Vec3, f: u32) -> Option<(Vec2, f64)> {
match kernel::face_surface(body, f as usize)? {
kernel::FaceSurface::Cylinder { origin, axis, radius } if axis.cross(n).len() < 1e-6 => Some((plane.to_local(origin), radius)),
_ => None,
}
}
/// A loop of [`face_outline`] as segments: each run along one round wall (`wall` gives a face's
/// circle) is an arc of that circle, the rest stays straight. Coaxial cylinders of one radius are
/// one wall, however many faces it comes in (many systems write a round hole's wall as two
/// half-cylinders), so such a hole is one circle. `eps`: how far off its circle a point may be.
fn outline_loop(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Loop2 {
use std::f64::consts::{PI, TAU};
let m = pts.len();
let point = |i: usize| pts.get(i % m).map(|x| x.0).unwrap_or_default();
// The circle each segment runs along: its wall's, when both its ends lie on it.
let circles: Vec<Option<(Vec2, f64)>> = (0..m)
.map(|i| {
let (c, r) = wall(pts.get(i)?.1?)?;
((point(i).dist(c) - r).abs() <= eps && (point(i + 1).dist(c) - r).abs() <= eps).then_some((c, r))
})
.collect();
let circle = |i: usize| circles.get(i % m).copied().flatten();
let same = |i: usize, (c, r): (Vec2, f64)| circle(i).is_some_and(|(d, s)| d.dist(c) <= eps && (s - r).abs() <= eps);
// Start where no arc runs on across the start (anywhere when the whole loop is one circle).
let start = (0..m).find(|i| circle(*i + m - 1).is_none_or(|c| !same(*i, c))).unwrap_or(0);
let mut segs = Vec::new();
let mut i = 0;
while i < m {
let k = start + i;
let Some((c, r)) = circle(k) else {
segs.push(Seg2::Line { a: point(k), b: point(k + 1) });
i += 1;
continue;
};
let mut j = i + 1;
while j < m && same(start + j, (c, r)) {
j += 1;
}
let sweep: f64 = (i..j)
.map(|s| {
let d = (point(start + s + 1) - c).angle() - (point(start + s) - c).angle();
(d + PI).rem_euclid(TAU) - PI
})
.sum();
// Arcs of up to half a turn each.
let pieces = ((sweep.abs() / PI - 1e-9).ceil() as usize).clamp(1, 4);
let (a0, step) = ((point(k) - c).angle(), sweep / pieces as f64);
segs.extend((0..pieces).map(|p| Seg2::Arc { center: c, radius: r, start: a0 + step * p as f64, sweep: step }));
i = j;
}
Loop2 { segs }
}
/// The body a placed plastic feature joins: by name, else the one under the point.
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
let i = body_at(st, body, &[p])?;
+301 -1
View File
@@ -1,4 +1,5 @@
//! Convert to Sheet Metal with bend edges: solid and shelled bodies.
//! Convert to Sheet Metal: solid and shelled bodies with bend edges, and plates read from STEP
//! files whose walls come in pieces.
use std::f64::consts::{FRAC_PI_2, PI, SQRT_2};
@@ -445,3 +446,302 @@ fn conversions_that_cannot_work_say_why() {
// Nothing changed.
assert!(s.model.state().sheets.is_empty() && c.model.state().sheets.is_empty());
}
/// One side face of a plate written to STEP: flat from `a` to `b`, or a piece of a round wall
/// about `c` from `a` to `b` (counter-clockwise seen from +z when `ccw`).
#[derive(Clone, Copy)]
enum Wall {
Flat([f64; 2], [f64; 2]),
Round([f64; 2], [f64; 2], [f64; 2], bool),
}
impl Wall {
fn ends(&self) -> ([f64; 2], [f64; 2]) {
match *self {
Wall::Flat(a, b) | Wall::Round(a, b, _, _) => (a, b),
}
}
}
/// A round hole of radius `r` about `c` (clockwise seen from +z), its wall in `pieces` cylinder
/// faces, the first seam at `seam` degrees.
fn hole(c: [f64; 2], r: f64, pieces: usize, seam: f64) -> Vec<Wall> {
let at = |i: usize| {
let a = (seam - 360.0 * i as f64 / pieces as f64).to_radians();
[c[0] + r * a.cos(), c[1] + r * a.sin()]
};
(0..pieces).map(|i| Wall::Round(at(i), at(i + 1), c, false)).collect()
}
/// A rectangle (counter-clockwise), its bottom side split in two faces when `split`.
fn rectangle(w: f64, h: f64, split: bool) -> Vec<Wall> {
let mut walls = if split {
vec![Wall::Flat([0.0, 0.0], [w / 2.0, 0.0]), Wall::Flat([w / 2.0, 0.0], [w, 0.0])]
} else {
vec![Wall::Flat([0.0, 0.0], [w, 0.0])]
};
walls.extend([Wall::Flat([w, 0.0], [w, h]), Wall::Flat([w, h], [0.0, h]), Wall::Flat([0.0, h], [0.0, 0.0])]);
walls
}
/// The entities of a STEP file, numbered as they are added; vertices and vertical edges once.
#[derive(Default)]
struct Step {
lines: Vec<String>,
vertices: Vec<([i64; 3], usize)>,
verticals: Vec<([i64; 2], usize)>,
}
impl Step {
fn add(&mut self, entity: String) -> usize {
self.lines.push(format!("#{}={entity};", self.lines.len() + 1));
self.lines.len()
}
fn point(&mut self, p: [f64; 3]) -> usize {
self.add(format!("CARTESIAN_POINT('',({:.12},{:.12},{:.12}))", p[0], p[1], p[2]))
}
fn frame(&mut self, o: [f64; 3], z: [f64; 3], x: [f64; 3]) -> usize {
let o = self.point(o);
let z = self.add(format!("DIRECTION('',({:.12},{:.12},{:.12}))", z[0], z[1], z[2]));
let x = self.add(format!("DIRECTION('',({:.12},{:.12},{:.12}))", x[0], x[1], x[2]));
self.add(format!("AXIS2_PLACEMENT_3D('',#{o},#{z},#{x})"))
}
fn vertex(&mut self, p: [f64; 2], z: f64) -> usize {
let key = [p[0], p[1], z].map(|v| (v * 1e9).round() as i64);
if let Some((_, v)) = self.vertices.iter().find(|(k, _)| *k == key) {
return *v;
}
let point = self.point([p[0], p[1], z]);
let v = self.add(format!("VERTEX_POINT('',#{point})"));
self.vertices.push((key, v));
v
}
fn line(&mut self, a: [f64; 3], b: [f64; 3], va: usize, vb: usize) -> usize {
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let p = self.point(a);
let dir = self.add(format!("DIRECTION('',({:.12},{:.12},{:.12}))", d[0] / l, d[1] / l, d[2] / l));
let v = self.add(format!("VECTOR('',#{dir},{l:.12})"));
let line = self.add(format!("LINE('',#{p},#{v})"));
self.add(format!("EDGE_CURVE('',#{va},#{vb},#{line},.T.)"))
}
/// The wall's edge at height `z`.
fn edge(&mut self, wall: Wall, z: f64) -> usize {
let (a, b) = wall.ends();
let (va, vb) = (self.vertex(a, z), self.vertex(b, z));
match wall {
Wall::Flat(..) => self.line([a[0], a[1], z], [b[0], b[1], z], va, vb),
Wall::Round(_, _, c, ccw) => {
let frame = self.frame([c[0], c[1], z], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let r = (a[0] - c[0]).hypot(a[1] - c[1]);
let circle = self.add(format!("CIRCLE('',#{frame},{r:.12})"));
self.add(format!("EDGE_CURVE('',#{va},#{vb},#{circle},{})", if ccw { ".T." } else { ".F." }))
}
}
}
fn vertical(&mut self, p: [f64; 2], t: f64) -> usize {
let key = p.map(|v| (v * 1e9).round() as i64);
if let Some((_, e)) = self.verticals.iter().find(|(k, _)| *k == key) {
return *e;
}
let (v0, v1) = (self.vertex(p, 0.0), self.vertex(p, t));
let e = self.line([p[0], p[1], 0.0], [p[0], p[1], t], v0, v1);
self.verticals.push((key, e));
e
}
fn edge_loop(&mut self, edges: &[(usize, bool)]) -> usize {
let oriented: Vec<String> = edges
.iter()
.map(|(e, forward)| format!("#{}", self.add(format!("ORIENTED_EDGE('',*,*,#{e},{})", if *forward { ".T." } else { ".F." }))))
.collect();
self.add(format!("EDGE_LOOP('',({}))", oriented.join(",")))
}
}
/// STEP text of a plate `t` thick standing on z = 0, its outline `loops` (the outer one
/// counter-clockwise, holes clockwise, seen from +z) with one side face per wall: a plane, or a
/// cylinder piece. Hand-written, the way exporters that split closed faces write them.
fn plate_step(loops: &[Vec<Wall>], t: f64) -> String {
let mut s = Step::default();
let bottom: Vec<Vec<usize>> = loops.iter().map(|l| l.iter().map(|w| s.edge(*w, 0.0)).collect()).collect();
let top: Vec<Vec<usize>> = loops.iter().map(|l| l.iter().map(|w| s.edge(*w, t)).collect()).collect();
let mut faces = Vec::new();
for (i, walls) in loops.iter().enumerate() {
for (j, wall) in walls.iter().enumerate() {
let (a, b) = wall.ends();
let (va, vb) = (s.vertical(a, t), s.vertical(b, t));
let lp = s.edge_loop(&[(bottom[i][j], true), (vb, true), (top[i][j], false), (va, false)]);
let bound = s.add(format!("FACE_OUTER_BOUND('',#{lp},.T.)"));
let (surface, sense) = match *wall {
Wall::Flat(a, b) => {
let l = (b[0] - a[0]).hypot(b[1] - a[1]);
let (dx, dy) = ((b[0] - a[0]) / l, (b[1] - a[1]) / l);
let frame = s.frame([a[0], a[1], 0.0], [dy, -dx, 0.0], [dx, dy, 0.0]);
(s.add(format!("PLANE('',#{frame})")), true)
}
Wall::Round(a, _, c, ccw) => {
let frame = s.frame([c[0], c[1], 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let r = (a[0] - c[0]).hypot(a[1] - c[1]);
(s.add(format!("CYLINDRICAL_SURFACE('',#{frame},{r:.12})")), ccw)
}
};
faces.push(s.add(format!("ADVANCED_FACE('',(#{bound}),#{surface},{})", if sense { ".T." } else { ".F." })));
}
}
for (z, edges, up) in [(t, &top, true), (0.0, &bottom, false)] {
let mut bounds = Vec::new();
for (i, es) in edges.iter().enumerate() {
let walk: Vec<(usize, bool)> = if up { es.iter().map(|e| (*e, true)).collect() } else { es.iter().rev().map(|e| (*e, false)).collect() };
let lp = s.edge_loop(&walk);
bounds.push(format!("#{}", s.add(format!("{}('',#{lp},.T.)", if i == 0 { "FACE_OUTER_BOUND" } else { "FACE_BOUND" }))));
}
let frame = s.frame([0.0, 0.0, z], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let plane = s.add(format!("PLANE('',#{frame})"));
faces.push(s.add(format!("ADVANCED_FACE('',({}),#{plane},{})", bounds.join(","), if up { ".T." } else { ".F." })));
}
let faces: Vec<String> = faces.iter().map(|f| format!("#{f}")).collect();
let shell = s.add(format!("CLOSED_SHELL('',({}))", faces.join(",")));
let brep = s.add(format!("MANIFOLD_SOLID_BREP('Plate',#{shell})"));
s.add(format!("SHAPE_REPRESENTATION('',(#{brep}),$)"));
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('plate','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN {{ 1 0 10303 214 1 1 1 1 }}'));\nENDSEC;\nDATA;\n{}\nENDSEC;\nEND-ISO-10303-21;\n",
s.lines.join("\n")
)
}
/// The plate inserted from a STEP file and converted, picked on its top face at `pick`.
fn convert_step_plate(tag: &str, loops: &[Vec<Wall>], t: f64, pick: [f64; 2]) -> Session {
let path = std::env::temp_dir().join(format!("solidcraft-split-walls-{tag}-{}.step", std::process::id()));
std::fs::write(&path, plate_step(loops, t)).unwrap();
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_file(&path);
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [pick[0], pick[1], t]}));
s
}
/// The flat pattern's cut-outs that are whole circles, as (radius, centre).
fn flat_circles(s: &mut Session) -> Vec<(f64, [f64; 2])> {
let shapes = flat(s)["cutout_shapes"].as_array().cloned().unwrap_or_default();
let c = |v: &Value| [v["center"][0].as_f64().unwrap_or(f64::NAN), v["center"][1].as_f64().unwrap_or(f64::NAN)];
shapes.iter().filter_map(|x| x.get("circle")).map(|x| (x["radius"].as_f64().unwrap_or(f64::NAN), c(x))).collect()
}
/// How many entities of each kind the flat pattern's DXF has.
fn dxf_entities(s: &mut Session) -> (usize, usize, usize) {
let dxf = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or("").replace("\r\n", "\n");
let n = |kind: &str| dxf.matches(&format!("\n0\n{kind}\n")).count();
(n("LINE"), n("ARC"), n("CIRCLE"))
}
/// A plate whose round holes' walls are two half-cylinders each (as many exporters write a hole)
/// converts with the holes as circles. It was refused as "not a plate of even thickness": its
/// holes came out as polygons, whose area missed the holes' by more than the plate check allows.
#[test]
fn a_plate_whose_hole_walls_are_half_cylinders_converts_with_round_holes() {
let (w, t, r) = (30.0, 2.0, 2.0);
let centres = [[6.0, 6.0], [24.0, 6.0], [24.0, 24.0], [6.0, 24.0]];
let mut loops = vec![rectangle(w, w, false)];
loops.extend(centres.iter().map(|c| hole(*c, r, 2, 90.0)));
let mut s = convert_step_plate("halves", &loops, t, [15.0, 15.0]);
let want = (w * w - 4.0 * PI * r * r) * t;
assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s));
let circles = flat_circles(&mut s);
assert_eq!(circles.len(), 4, "{circles:?}");
assert!(circles.iter().all(|(radius, _)| (radius - r).abs() < 1e-9), "{circles:?}");
// The holes keep their places: 18 apart along the sides, 18·√2 across.
let d = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).hypot(a[1] - b[1]);
let mut gaps: Vec<f64> = (0..4).flat_map(|i| (i + 1..4).map(move |j| (i, j))).map(|(i, j)| d(circles[i].1, circles[j].1)).collect();
gaps.sort_by(f64::total_cmp);
assert!(gaps[..4].iter().all(|g| (g - 18.0).abs() < 1e-9) && gaps[4..].iter().all(|g| (g - 18.0 * SQRT_2).abs() < 1e-9), "{gaps:?}");
// The DXF: the outline's four lines and one circle per hole.
assert_eq!(dxf_entities(&mut s), (4, 0, 4));
}
/// Holes of any radius whose walls come in two, three or four pieces, seams anywhere: each is one
/// circle.
#[test]
fn hole_walls_in_any_number_of_pieces_each_make_one_circle() {
let (t, holes) = (2.0, [([20.0, 30.0], 5.0, 2, 0.0), ([50.0, 30.0], 3.0, 3, 37.0), ([80.0, 30.0], 8.0, 4, 10.0)]);
let mut loops = vec![rectangle(100.0, 60.0, false)];
loops.extend(holes.iter().map(|(c, r, pieces, seam)| hole(*c, *r, *pieces, *seam)));
let mut s = convert_step_plate("pieces", &loops, t, [35.0, 10.0]);
let want = (6000.0 - PI * (25.0 + 9.0 + 64.0)) * t;
assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s));
let mut radii: Vec<f64> = flat_circles(&mut s).iter().map(|c| c.0).collect();
radii.sort_by(f64::total_cmp);
assert!(radii.len() == 3 && radii.iter().zip([3.0, 5.0, 8.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{radii:?}");
assert_eq!(dxf_entities(&mut s), (4, 0, 3));
}
/// Other walls such files split: rounded corners in two pieces each, a slot whose round ends are
/// in two pieces each, a straight side in two faces. Each corner and slot end is one arc, the
/// side one line.
#[test]
fn rounded_corners_slot_ends_and_split_sides_convert_as_single_arcs_and_lines() {
let (t, rc, rs, rh) = (2.0, 6.0, 4.0, 5.0);
let quarter = |c: [f64; 2], from: f64, pieces: usize| -> Vec<Wall> {
let at = |i: usize| {
let a = (from + 90.0 * i as f64 / pieces as f64).to_radians();
[c[0] + rc * a.cos(), c[1] + rc * a.sin()]
};
(0..pieces).map(|i| Wall::Round(at(i), at(i + 1), c, true)).collect()
};
// 100 x 60, corners rounded by 6, the bottom side in two faces.
let mut outer = vec![Wall::Flat([rc, 0.0], [50.0, 0.0]), Wall::Flat([50.0, 0.0], [100.0 - rc, 0.0])];
outer.extend(quarter([100.0 - rc, rc], -90.0, 2));
outer.push(Wall::Flat([100.0, rc], [100.0, 60.0 - rc]));
outer.extend(quarter([100.0 - rc, 60.0 - rc], 0.0, 2));
outer.push(Wall::Flat([100.0 - rc, 60.0], [rc, 60.0]));
outer.extend(quarter([rc, 60.0 - rc], 90.0, 2));
outer.push(Wall::Flat([0.0, 60.0 - rc], [0.0, rc]));
outer.extend(quarter([rc, rc], 180.0, 2));
// A slot 30 between its end centres, 8 wide, each end in two pieces (clockwise).
let (a, b) = ([45.0, 30.0], [75.0, 30.0]);
let slot = vec![
Wall::Flat([a[0], a[1] + rs], [b[0], b[1] + rs]),
Wall::Round([b[0], b[1] + rs], [b[0] + rs, b[1]], b, false),
Wall::Round([b[0] + rs, b[1]], [b[0], b[1] - rs], b, false),
Wall::Flat([b[0], b[1] - rs], [a[0], a[1] - rs]),
Wall::Round([a[0], a[1] - rs], [a[0] - rs, a[1]], a, false),
Wall::Round([a[0] - rs, a[1]], [a[0], a[1] + rs], a, false),
];
let loops = vec![outer, slot, hole([20.0, 30.0], rh, 2, 90.0)];
let mut s = convert_step_plate("sides", &loops, t, [20.0, 50.0]);
let area = 6000.0 - (4.0 - PI) * rc * rc - (30.0 * 2.0 * rs + PI * rs * rs) - PI * rh * rh;
assert!((volume(&mut s) - area * t).abs() < 1e-4 * area * t, "{} vs {}", volume(&mut s), area * t);
// The outline: four lines and four quarter arcs; the slot: two lines and two half arcs; the
// hole: a circle.
assert_eq!(dxf_entities(&mut s), (6, 6, 1));
let shapes = flat(&mut s)["cutout_shapes"].as_array().cloned().unwrap_or_default();
let slot = shapes.iter().find_map(|x| x["segments"].as_array().cloned()).unwrap_or_default();
let sweeps: Vec<f64> = slot.iter().filter_map(|x| x["arc"]["sweep_deg"].as_f64()).map(f64::abs).collect();
assert!(slot.len() == 4 && sweeps.len() == 2 && sweeps.iter().all(|x| (x - 180.0).abs() < 1e-6), "{slot:?}");
}
/// Holes drilled here come out as circles too (their walls are half-cylinders as well), and a
/// plate with many small ones converts: nine 4 mm holes in a 30 x 30 x 2 plate were refused too.
#[test]
fn a_plate_with_holes_drilled_here_converts_with_round_holes() {
let (w, t, r) = (30.0, 2.0, 2.0);
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": w, "width": w, "height": t, "body_name": "Plate"}));
for x in [6.0, 15.0, 24.0] {
for y in [6.0, 15.0, 24.0] {
run(&mut s, "solid.hole", json!({"position": [x, y, t], "diameter": 2.0 * r}));
}
}
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [10.5, 10.5, t]}));
let want = (w * w - 9.0 * PI * r * r) * t;
assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s));
let circles = flat_circles(&mut s);
assert!(circles.len() == 9 && circles.iter().all(|(radius, _)| (radius - r).abs() < 1e-9), "{circles:?}");
assert_eq!(dxf_entities(&mut s), (4, 0, 9));
}
+9 -1
View File
@@ -494,9 +494,17 @@ fn unfold_cut_refold_and_convert() {
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2.5]}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
// A rod picked on its end is a round plate as thick as the rod is long (as a 20 x 20 x 30 bar
// is a square one); a block thicker at one end is no plate.
let mut t = Session::default();
run(&mut t, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
assert!(t.execute("sheet.convert", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
run(&mut t, "sheet.convert", json!({"body": "Rod", "face": [0, 0, 30]}));
let f = flat(&mut t);
assert!(rel(f["thickness"].as_f64().unwrap_or(0.0), 30.0) < 1e-9 && rel(f["flat_size_mm"][0].as_f64().unwrap_or(0.0), 20.0) < 1e-4, "{f}");
let mut t = Session::default();
run(&mut t, "solid.box", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Block"}));
run(&mut t, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 60, "height": 10, "operation": "join"}));
assert!(t.execute("sheet.convert", &json!({"body": "Block", "face": [50, 30, 2.5]})).is_err());
}
/// A sketch line across a 100 x 60 plate's top face at x = 40.
+5 -3
View File
@@ -130,9 +130,11 @@ edge between two faces of the sheet that is no bend is cut open, each side half
**gap** from the plane halfway between the two, like the corners of a tray. The result is an
ordinary sheet body: edge flanges, cuts, Unfold, the flat pattern and its exports work on it,
and it follows the solid when a parameter changes (the edges and the fixed face are found by
their names). Without bend edges Convert expects a plate, as before. Not converted yet: curved
faces (rolled sheet, a filleted edge as a bend), faces with holes or curved edges (cut them
after converting) and bends that close a ring.
their names). Without bend edges Convert expects a plate, as before; its round walls (holes, slot
ends, rounded corners) stay circles and arcs in the flat pattern and the DXF, also where a STEP
file writes one in pieces. Not converted yet: curved faces (rolled sheet, a filleted edge as a
bend), faces with holes or curved edges in a body with bend edges (cut them after converting)
and bends that close a ring.
```json
{"command":"solid.box","params":{"length":100,"width":60,"height":30,"body_name":"Tray"}}