//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities. use std::f64::consts::{PI, TAU}; use serde_json::{Value, json}; use solidcraft_geom::{Seg2, Vec2}; use crate::Session; fn run(s: &mut Session, id: &str, p: Value) -> Value { match s.execute(id, &p) { Ok(v) => v, Err(e) => panic!("{id} {p}: {e}"), } } /// A 100 × 60 base flange (the default 2.5 mm rule) with `curves` drawn in a sketch and cut through it. fn plate_with(curves: &[(&str, Value)]) -> Session { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Base"})); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"})); for (command, params) in curves { run(&mut s, command, params.clone()); } run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"})); s } fn export(s: &mut Session, params: Value) -> String { run(s, "sheet.export_dxf", params)["dxf"].as_str().unwrap_or_default().to_string() } /// The entities of a DXF text: type and group codes. type Entity = (String, Vec<(i32, String)>); fn entities(dxf: &str) -> Vec { let lines: Vec<&str> = dxf.lines().collect(); let pairs: Vec<(i32, &str)> = lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim()))).collect(); let start = pairs.iter().position(|p| *p == (2, "ENTITIES")).unwrap_or(0); let mut out: Vec = Vec::new(); for (code, value) in pairs.iter().skip(start + 1) { match (*code, *value) { (0, "ENDSEC") => break, (0, kind) => out.push((kind.to_string(), Vec::new())), _ => { if let Some(last) = out.last_mut() { last.1.push((*code, value.to_string())); } } } } out } fn values(e: &Entity, code: i32) -> Vec { e.1.iter().filter(|(c, _)| *c == code).filter_map(|(_, v)| v.parse().ok()).collect() } fn count(es: &[Entity], kind: &str) -> usize { es.iter().filter(|e| e.0 == kind).count() } fn point(e: &Entity, code: i32) -> Vec2 { Vec2::new(values(e, code).first().copied().unwrap_or(f64::NAN), values(e, code + 10).first().copied().unwrap_or(f64::NAN)) } /// All loops of the flat pattern (outlines, cut-outs, islands). fn loops(s: &Session) -> Vec { let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default(); regions.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.clone())).collect() } /// The profiles of the DXF read into a new sketch: their areas, largest first. fn reimported_areas(dxf: &str) -> Vec { let mut s = Session::default(); let id = run(&mut s, "sketch.insert_dxf", json!({"text": dxf, "name": "Back"}))["sketch"].as_u64().unwrap_or(0); run(&mut s, "sketch.finish", json!({})); let mut areas: Vec = s.model.state().sketch(id).map(|ss| ss.profiles.iter().map(|p| p.area).collect()).unwrap_or_default(); areas.sort_by(|a, b| b.total_cmp(a)); areas } #[test] fn an_elliptic_cut_out_is_one_ellipse() { let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]); let dxf = export(&mut s, json!({})); let es = entities(&dxf); // The four lines of the outline and one ELLIPSE: no other entity. assert_eq!((count(&es, "LINE"), count(&es, "ELLIPSE"), es.len()), (4, 1, 5), "{:?}", es.iter().map(|e| &e.0).collect::>()); // By hand: centre (40, 30), the major semi-axis 15 along x (to (55, 30) or (25, 30)), the // minor one 8, so the ratio is 8/15; the whole turn runs from parameter 0 to 2π. let e = es.iter().find(|e| e.0 == "ELLIPSE").unwrap(); let (c, major) = (point(e, 10), point(e, 11)); assert!(c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.x.abs() - 15.0).abs() < 1e-9 && major.y.abs() < 1e-9, "{c:?} {major:?}"); assert!((values(e, 40)[0] - 8.0 / 15.0).abs() < 1e-9 && values(e, 41)[0] == 0.0 && (values(e, 42)[0] - TAU).abs() < 1e-9); assert_eq!(values(e, 230), [1.0]); // Our reader gets the same ellipse back, and the same plate: 6000 mm² less π·15·8. let read = solidcraft_io::read_dxf(&dxf).unwrap(); assert!(read.iter().any(|g| matches!(g, solidcraft_io::Geom2::Ellipse { c, major, minor } if c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.dist(*c) - 15.0).abs() < 1e-9 && (minor - 8.0).abs() < 1e-9))); let areas = reimported_areas(&dxf); assert!(areas.len() == 2 && (areas[0] - (6000.0 - PI * 120.0)).abs() < 1e-6 && (areas[1] - PI * 120.0).abs() < 1e-6, "{areas:?}"); } #[test] fn a_spline_cut_out_is_the_splines_it_was() { let points = json!([[20, 15], [40, 45], [65, 40], [85, 20]]); let mut s = plate_with(&[("sketch.spline.fit_point", json!({"points": points})), ("sketch.line", json!({"points": [[85, 20], [20, 15]]}))]); let dxf = export(&mut s, json!({})); let es = entities(&dxf); // The cut-out's segments of the flat pattern, one entity each: its cubic pieces are SPLINEs // of degree 3 over the same four control points, its line a LINE. let hole: Vec = loops(&s).get(1).map(|l| l.segs.clone()).unwrap_or_default(); let cubics: Vec<[Vec2; 4]> = hole.iter().filter_map(|g| if let Seg2::Cubic { p0, p1, p2, p3 } = g { Some([*p0, *p1, *p2, *p3]) } else { None }).collect(); assert!(!cubics.is_empty() && cubics.len() + 1 == hole.len(), "{hole:?}"); assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (5, cubics.len(), 5 + cubics.len())); for (spline, want) in es.iter().filter(|e| e.0 == "SPLINE").zip(&cubics) { assert_eq!((values(spline, 70), values(spline, 71), values(spline, 72), values(spline, 73)), (vec![8.0], vec![3.0], vec![8.0], vec![4.0])); assert_eq!(values(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]); let (xs, ys) = (values(spline, 10), values(spline, 20)); assert!(want.iter().enumerate().all(|(i, p)| (xs[i] - p.x).abs() < 1e-9 && (ys[i] - p.y).abs() < 1e-9), "{xs:?} {ys:?} {want:?}"); } // The same plate comes back. let areas = reimported_areas(&dxf); let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default()[0].area(); assert!((areas[0] - plate).abs() < 1e-6, "{areas:?} {plate}"); } #[test] fn text_is_splines_and_lines_not_chords() { let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]); let dxf = export(&mut s, json!({})); let es = entities(&dxf); // One entity for each segment of the flat pattern: a straight one a LINE, a glyph's // quadratic and cubic curves SPLINEs of degree 2 and 3. let segs: Vec = loops(&s).iter().flat_map(|l| l.segs.clone()).collect(); let n = |f: &dyn Fn(&Seg2) -> bool| segs.iter().filter(|g| f(g)).count(); let (lines, quadratics, cubics) = ( n(&|g| matches!(g, Seg2::Line { .. })), n(&|g| matches!(g, Seg2::Conic { w, .. } if (*w - 1.0).abs() < 1e-12)), n(&|g| matches!(g, Seg2::Cubic { .. })), ); assert_eq!(lines + quadratics + cubics, segs.len(), "only lines and Béziers"); assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (lines, quadratics + cubics, segs.len())); let degree = |d: f64| es.iter().filter(|e| e.0 == "SPLINE" && values(e, 71) == [d]).count(); assert_eq!((degree(2.0), degree(3.0)), (quadratics, cubics)); // The plate with the letters cut out comes back from the file with its area (the largest region; the islands inside the // letters come in no fixed order). let areas = reimported_areas(&dxf); let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default().iter().map(|r| r.area()).fold(0.0, f64::max); assert!((areas[0] - plate).abs() < 1e-3, "{areas:?} {plate}"); } #[test] fn arcs_option_writes_only_lines_and_tangent_arcs_within_a_thousandth() { let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]); let dxf = export(&mut s, json!({"curves": "arcs"})); let es = entities(&dxf); assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", es.iter().map(|e| &e.0).collect::>()); let arcs: Vec<(Vec2, f64, [Vec2; 2], Vec)> = es .iter() .filter(|e| e.0 == "ARC") .map(|e| { let (c, r, (a0, a1)) = (point(e, 10), values(e, 40)[0], (values(e, 50)[0].to_radians(), values(e, 51)[0].to_radians())); let sweep = (a1 - a0).rem_euclid(TAU); let samples = (0..=20).map(|k| c + Vec2::from_angle(a0 + sweep * f64::from(k) / 20.0) * r).collect::>(); (c, r, [c + Vec2::from_angle(a0) * r, c + Vec2::from_angle(a1) * r], samples) }) .collect(); // Far fewer than the 336 chords of 0.001 mm. assert!(arcs.len() >= 8 && arcs.len() < 100, "{} arcs", arcs.len()); // Distance to the ellipse ((x − 40)/15)² + ((y − 30)/8)² = 1, to first order |f| / |∇f|. let off = |p: Vec2| { let (x, y) = (p.x - 40.0, p.y - 30.0); let f = (x / 15.0).powi(2) + (y / 8.0).powi(2) - 1.0; f.abs() / Vec2::new(2.0 * x / 225.0, 2.0 * y / 64.0).len() }; // (The joint of a biarc's two arcs lies within the tolerance of the curve, not on it.) for (_, _, ends, samples) in &arcs { let offs: Vec = samples.iter().map(|p| off(*p)).collect(); assert!(offs.iter().all(|d| *d <= 1e-3 + 1e-6), "{ends:?}: {offs:?}"); } // Tangent: at each joint the radii of the two arcs lie on one line. for (i, (c, r, ends, _)) in arcs.iter().enumerate() { for end in ends { let others: Vec<_> = arcs.iter().enumerate().filter(|(j, (_, _, e, _))| *j != i && e.iter().any(|q| q.dist(*end) < 1e-6)).collect(); assert_eq!(others.len(), 1, "joint {end:?}"); let (_, (c2, r2, _, _)) = others[0]; assert!((*end - *c).cross(*end - *c2).abs() < 1e-6 * r * r2, "arcs are not tangent at {end:?}"); } } // The plate, read back, is the exact one within the perimeter times 0.001 mm. let areas = reimported_areas(&dxf); assert!((areas[0] - (6000.0 - PI * 120.0)).abs() < 0.1, "{areas:?}"); } #[test] fn arcs_option_has_no_splines_for_text_and_the_curves_value_is_checked() { let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]); let es = entities(&export(&mut s, json!({"curves": "ARCS"}))); assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC") && count(&es, "ARC") > 0); let exact = entities(&export(&mut s, json!({"curves": "exact"}))); assert!(count(&exact, "SPLINE") > 0 && count(&exact, "ARC") == 0); assert!(s.execute("sheet.export_dxf", &json!({"curves": "chords"})).is_err()); } #[test] fn bend_lines_and_layers_are_still_there() { let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]); run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})); let out = run(&mut s, "sheet.export_dxf", json!({})); let es = entities(out["dxf"].as_str().unwrap_or_default()); let on = |layer: &str| es.iter().filter(|e| e.1.contains(&(8, layer.to_string()))).count(); assert_eq!(on("BEND_UP") + on("BEND_DOWN"), out["bends"].as_u64().unwrap_or(0) as usize); assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && count(&es, "ELLIPSE") == 1 && on("OUTLINE") == es.len() - on("BEND_UP") - on("BEND_DOWN")); }