diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 65aa9c8f..f15d5d1a 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -596,13 +596,18 @@ pub(super) fn sheet_eval( let ds: Vec = 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 = + 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> { - 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)>> { + 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> = 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 = 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::new(); + let mut loops: Vec)>> = 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 = lp.iter().map(|p| plane.to_local(*p)).collect(); + let raw: Vec<(Vec2, Option)> = 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 = (0..m) + let pts: Vec<(Vec2, Option)> = (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)], 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> = (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, p: Vec3) -> Result { let i = body_at(st, body, &[p])?; diff --git a/crates/engine/src/cmd/sheet_convert_tests.rs b/crates/engine/src/cmd/sheet_convert_tests.rs index 1271aa0f..13e73adc 100644 --- a/crates/engine/src/cmd/sheet_convert_tests.rs +++ b/crates/engine/src/cmd/sheet_convert_tests.rs @@ -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 { + 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 { + 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, + 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 = 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], t: f64) -> String { + let mut s = Step::default(); + let bottom: Vec> = loops.iter().map(|l| l.iter().map(|w| s.edge(*w, 0.0)).collect()).collect(); + let top: Vec> = 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 = 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], 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 = (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 = 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 { + 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 = 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)); +} diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 84a2161c..cd010861 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -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. diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 509eafb8..5adb0e8c 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -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"}}