diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ddc80117..a93d8be3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,7 @@ mod inspect; mod sketch; mod sketch_constraints; mod sketch_create; +mod sketch_import; mod sketch_modify; mod sketch_project; @@ -112,6 +113,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(sketch::COMMANDS.iter()); v.extend(sketch_create::COMMANDS.iter()); v.extend(sketch_constraints::COMMANDS.iter()); + v.extend(sketch_import::COMMANDS.iter()); v.extend(sketch_modify::COMMANDS.iter()); v.extend(sketch_project::COMMANDS.iter()); v.extend(features::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/sketch_constraints.rs b/crates/engine/src/cmd/sketch_constraints.rs index c160bbdb..3fad7313 100644 --- a/crates/engine/src/cmd/sketch_constraints.rs +++ b/crates/engine/src/cmd/sketch_constraints.rs @@ -7,7 +7,7 @@ use solvecraft_sketch::{ConstraintKind, CurveKind, Sketch}; use super::sketch::{add_c, dof_now, edit, reject_redundant}; use super::{CommandSpec, in_sketch}; -use crate::params::{bad, num, req_vec2, string_list, vec2}; +use crate::params::{bad, num, req_vec2, str_, string_list, vec2}; use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ @@ -21,6 +21,21 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("c_polygon") .enabled(in_sketch) .params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"), + CommandSpec::new("SketchAutoConstraintAndDimCmd", "AutoConstrain", auto_constrain) + .at("SKETCH", "CONSTRAINTS") + .icon("auto_constrain") + .enabled(in_sketch) + .params("tolerance?: mm (default 0.01), angle_tolerance?: deg (default 1), dimensions?: bool (default true): infer constraints, then dimension until fully constrained"), + CommandSpec::new("SketchAutoConstraintAndDimFromDatumCmd", "AutoConstrain from datum", auto_constrain_datum) + .at("SKETCH", "CONSTRAINTS") + .icon("auto_constrain") + .enabled(in_sketch) + .params("datum: point ref (default origin); like AutoConstrain with positions measured from the datum"), + CommandSpec::new("SketchAutoConstrainAndFinish", "Finish with AutoConstrain", auto_constrain_finish) + .at("SKETCH", "FINISH SKETCH") + .icon("finish") + .enabled(in_sketch) + .params("like AutoConstrain, then Finish Sketch"), CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs) .enabled(in_sketch) .noundo() @@ -92,6 +107,161 @@ fn c_polygon(s: &mut Session, p: &Value) -> Result { Ok(json!({"constraints": out, "sketch": info})) } +/// Most constraints AutoConstrain tries. +const MAX_TRIALS: usize = 2000; + +/// Add `k` if it removes a degree of freedom and the sketch still solves; else leave the +/// sketch as it was. Returns the new dof when kept. +fn try_add(sk: &mut Sketch, doc: &mut solvecraft_doc::Document, k: ConstraintKind, dof: usize, dim: bool) -> Option { + let saved = sk.clone(); + let param = if dim { + let v = k.value()?; + let e = if k.is_angle() { format!("{} deg", (v.to_degrees() * 1e6).round() / 1e6) } else { format!("{} mm", (v * 1e6).round() / 1e6) }; + Some(doc.new_model_param(&e, if k.is_angle() { "deg" } else { "mm" })) + } else { + None + }; + if sk.add_constraint(k, param.clone()).is_err() { + if let Some(pn) = ¶m { + let _ = doc.remove_param(pn); + } + return None; + } + let mut trial = sk.clone(); + let rep = solvecraft_sketch::solve(&mut trial); + if rep.ok() && rep.dof < dof { + *sk = trial; + return Some(rep.dof); + } + *sk = saved; + if let Some(pn) = ¶m { + let _ = doc.remove_param(pn); + } + None +} + +fn run_auto(sk: &mut Sketch, doc: &mut solvecraft_doc::Document, p: &Value, datum: usize) -> (Vec, usize) { + use ConstraintKind::*; + let tol = num(p, "tolerance").filter(|t| *t > 0.0).unwrap_or(0.01); + let atol = num(p, "angle_tolerance").filter(|t| *t > 0.0).unwrap_or(1.0).to_radians(); + let dims = crate::params::bool_(p, "dimensions").unwrap_or(true); + let n0 = sk.constraints.len(); + let mut dof = dof_now(sk); + let mut trials = 0; + let mut attempt = |sk: &mut Sketch, doc: &mut solvecraft_doc::Document, k: ConstraintKind, dim: bool, dof: &mut usize| { + if *dof == 0 || trials >= MAX_TRIALS { + return; + } + trials += 1; + if let Some(d) = try_add(sk, doc, k, *dof, dim) { + *dof = d; + } + }; + // Coincident points. + let np = sk.points.len(); + for i in 0..np { + for j in (i + 1)..np { + let (Some(a), Some(b)) = (sk.point(i), sk.point(j)) else { continue }; + if a.dist(b) <= tol { + attempt(sk, doc, Coincident { p: i, q: j }, false, &mut dof); + } + } + } + // Horizontal / vertical lines, tangent line–arc and arc–arc joints. + let nc = sk.curves.len(); + for i in 0..nc { + if let Some(CurveKind::Line { a, b }) = sk.curves.get(i).map(|c| c.kind.clone()) + && let (Some(pa), Some(pb)) = (sk.point(a), sk.point(b)) + { + let ang = (pb - pa).angle(); + let off_h = ang.sin().abs().asin(); + let off_v = ang.cos().abs().asin(); + if off_h <= atol { + attempt(sk, doc, Horizontal { l: i }, false, &mut dof); + } else if off_v <= atol { + attempt(sk, doc, Vertical { l: i }, false, &mut dof); + } + } + } + for i in 0..nc { + for j in (i + 1)..nc { + let (Some(ci), Some(cj)) = (sk.curves.get(i), sk.curves.get(j)) else { continue }; + let round = |k: &CurveKind| matches!(k, CurveKind::Arc { .. } | CurveKind::Circle { .. }); + if !(round(&ci.kind) || round(&cj.kind)) { + continue; + } + let (Some((a0, a1)), Some((b0, b1))) = (ci.kind.ends(), cj.kind.ends()) else { continue }; + let Some(sh) = [a0, a1].into_iter().find(|x| *x == b0 || *x == b1) else { continue }; + let dir = |c: usize| -> Option { + let k = sk.curves.get(c)?.kind.clone(); + let q = sk.point(sh)?; + match k { + CurveKind::Line { a, b } => (sk.point(b)? - sk.point(a)?).normalized(), + CurveKind::Arc { c, .. } => (q - sk.point(c)?).perp().normalized(), + _ => None, + } + }; + if let (Some(u), Some(v)) = (dir(i), dir(j)) + && u.cross(v).abs() <= atol.sin() + { + attempt(sk, doc, Tangent { a: i, b: j }, false, &mut dof); + } + } + } + if dims { + for i in 0..nc { + let k = match sk.curves.get(i).map(|c| (c.kind.clone(), c.construction)) { + Some((CurveKind::Circle { r, .. }, false)) => Diameter { c: i, value: 2.0 * r }, + Some((CurveKind::Arc { .. }, false)) => Radius { c: i, value: sk.radius(i).unwrap_or(1.0) }, + Some((CurveKind::Line { a, b }, false)) => { + Length { l: i, value: sk.point(a).zip(sk.point(b)).map(|(x, y)| x.dist(y)).unwrap_or(1.0) } + } + _ => continue, + }; + attempt(sk, doc, k, true, &mut dof); + } + // Positions from the datum. + let d = sk.point(datum).unwrap_or_default(); + for i in 0..sk.points.len() { + if i == datum { + continue; + } + let Some(q) = sk.point(i) else { continue }; + attempt(sk, doc, DistanceX { p: datum, q: i, value: (q.x - d.x).abs() }, true, &mut dof); + attempt(sk, doc, DistanceY { p: datum, q: i, value: (q.y - d.y).abs() }, true, &mut dof); + } + } + let added = sk.constraints.iter().skip(n0).map(|c| c.id.clone()).collect(); + (added, dof) +} + +fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result { + let ((added, dof), info) = edit(s, p, cmd, false, |sk, doc| { + let datum = match str_(p, "datum") { + Some(r) => sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))?, + None => 0, + }; + // Values of existing dimensions must be applied before judging degrees of freedom. + let (vals, _) = doc.param_values(); + doc.apply_dimension_values(&vals, sk)?; + Ok(run_auto(sk, doc, p, datum)) + })?; + Ok(json!({"added": added, "dof": dof, "sketch": info})) +} + +fn auto_constrain(s: &mut Session, p: &Value) -> Result { + auto(s, p, "SketchAutoConstraintAndDimCmd") +} +fn auto_constrain_datum(s: &mut Session, p: &Value) -> Result { + auto(s, p, "SketchAutoConstraintAndDimFromDatumCmd") +} +fn auto_constrain_finish(s: &mut Session, p: &Value) -> Result { + let r = auto(s, p, "SketchAutoConstrainAndFinish")?; + s.active_sketch = None; + s.revision += 1; + Ok(r) +} + /// Where a constraint's glyph goes (sketch coordinates): beside the middle of its first curve, /// or at its point. fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option { diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index 18f2fc64..dae6b894 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -111,3 +111,25 @@ fn glyphs_and_snaps() { let sn = run(&mut s, "sketch.snap", json!({"at": [40, 10.4], "from": [30, 10], "radius": 1})); assert!(sn["snaps"].as_array().unwrap().iter().any(|x| x["type"] == "horizontal"), "{sn}"); } + +#[test] +fn auto_constrain_fully_constrains_a_rough_sketch() { + let mut s = new_sketch(); + // A slightly crooked closed rectangle and a circle inside. + run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0.004], [40.003, 30], [0, 30.002]], "closed": true})); + run(&mut s, "CircleCenterRadius", json!({"center": [20, 15], "radius": 5})); + let r = run(&mut s, "SketchAutoConstraintAndDimCmd", json!({})); + assert_eq!(r["dof"], 0, "{r}"); + let si = run(&mut s, "sketch.inspect", json!({})); + assert_eq!(si["fully_constrained"], true, "{si}"); + let names: Vec = si["constraints"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap().to_string()).collect(); + assert!(names.iter().filter(|n| *n == "Horizontal").count() >= 2, "{names:?}"); + assert!(names.iter().any(|n| n == "DiameterDimension"), "{names:?}"); + // From a datum, and finishing. + let mut s = new_sketch(); + run(&mut s, "DrawPoint", json!({"point": [5, 5], "id": "d"})); + run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [20, 10]]})); + let r = run(&mut s, "SketchAutoConstrainAndFinish", json!({"datum": "d"})); + assert!(s.active_sketch.is_none()); + assert!(r["added"].as_array().unwrap().len() >= 3, "{r}"); +} diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs new file mode 100644 index 00000000..80952625 --- /dev/null +++ b/crates/engine/src/cmd/sketch_import.rs @@ -0,0 +1,220 @@ +//! Sketch drawing exchange: Insert DXF, Insert SVG and sketch DXF export. + +use std::collections::HashMap; + +use serde_json::{Value, json}; +use solvecraft_geom::Vec2; +use solvecraft_io::{Geom2, MAX_DRAWING_BYTES}; +use solvecraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch}; + +use super::CommandSpec; +use super::sketch::{edit, ids_of}; +use crate::params::{bad, num, str_, vec2}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("ImportDxfFileCommand", "Insert DXF", insert_dxf) + .at("SKETCH", "INSERT") + .icon("dxf") + .params("path | text: ASCII DXF; at?: [x,y] offset, scale?; plane?: (when no sketch is active, a new sketch on it; default XY)"), + CommandSpec::new("SketchImportSVG", "Insert SVG", insert_svg) + .at("SKETCH", "INSERT") + .icon("svg") + .params("path | text: SVG; at?: [x,y] offset, scale?; plane?: (when no sketch is active, a new sketch on it; default XY)"), + CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf) + .noundo() + .params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"), +]; + +fn source_text(p: &Value, cmd: &str) -> Result { + if let Some(t) = str_(p, "text") { + if t.len() > MAX_DRAWING_BYTES { + return Err(bad(cmd, "the drawing is too large")); + } + return Ok(t.to_string()); + } + let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `text`"))?; + let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta.len() as usize > MAX_DRAWING_BYTES { + return Err(EngineError::Other(format!("{path}: file too large"))); + } + let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(String::from_utf8_lossy(&bytes).to_string()) +} + +/// Add imported geometry to a sketch, sharing end points that meet. +fn add_geometry(sk: &mut Sketch, geom: &[Geom2], f: &dyn Fn(Vec2) -> Vec2, k: f64) -> Result<(Vec, Vec)> { + let q = 1e-6; + let mut map: HashMap<(i64, i64), usize> = HashMap::new(); + let mut pt = |sk: &mut Sketch, p: Vec2| -> Result { + let p = f(p); + let key = ((p.x / q).round() as i64, (p.y / q).round() as i64); + if let Some(i) = map.get(&key) { + return Ok(*i); + } + let i = sk.add_point(p, None)?; + map.insert(key, i); + Ok(i) + }; + let mut curves = Vec::new(); + let mut texts = Vec::new(); + for g in geom { + let made = match g { + Geom2::Point(p) => { + pt(sk, *p)?; + None + } + Geom2::Line(a, b) => { + let (a, b) = (pt(sk, *a)?, pt(sk, *b)?); + (a != b).then_some(CurveKind::Line { a, b }) + } + Geom2::Circle(c, r) => Some(CurveKind::Circle { c: pt(sk, *c)?, r: r * k }), + Geom2::Arc { c, a, b } => { + let (c, a, b) = (pt(sk, *c)?, pt(sk, *a)?, pt(sk, *b)?); + (a != b && c != a).then_some(CurveKind::Arc { c, a, b }) + } + Geom2::Ellipse { c, major, minor } => Some(CurveKind::Ellipse { c: pt(sk, *c)?, m: pt(sk, *major)?, r: minor * k }), + Geom2::Spline { pts, control, degree } => { + let mut idx = Vec::new(); + for p in pts.iter().take(solvecraft_sketch::MAX_SPLINE_POINTS) { + let i = pt(sk, *p)?; + if idx.last() != Some(&i) { + idx.push(i); + } + } + (idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree }) + } + Geom2::Conic { a, apex, b, rho } => { + let (a, x, b) = (pt(sk, *a)?, pt(sk, *apex)?, pt(sk, *b)?); + (a != b && x != a && x != b).then_some(CurveKind::Conic { a, b, apex: x, rho: *rho }) + } + Geom2::Text { text, at, height, angle } => { + texts.push(Geom2::Text { text: text.clone(), at: f(*at), height: height * k, angle: *angle }); + None + } + }; + if let Some(kind) = made + && let Ok(c) = sk.add_curve(kind, None) + { + curves.push(c); + } + } + Ok((curves, texts)) +} + +fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result { + let text = source_text(p, cmd)?; + let geom = if svg { solvecraft_io::read_svg(&text) } else { solvecraft_io::read_dxf(&text) }.map_err(|e| bad(cmd, e.to_string()))?; + if geom.is_empty() { + return Err(bad(cmd, "the drawing has no geometry SolveCraft reads")); + } + let k = num(p, "scale").filter(|k| *k > 1e-9 && *k < 1e6).unwrap_or(1.0); + let off = p.get("at").and_then(vec2).unwrap_or(Vec2::ZERO); + let mut created = None; + if s.active_sketch.is_none() { + let mut cp = json!({"plane": p.get("plane").cloned().unwrap_or(json!("XY")), "project_edges": false}); + if let Some(n) = str_(p, "name") { + cp["name"] = json!(n); + } + let r = (super::find_command("SketchCreate").ok_or_else(|| bad(cmd, "SketchCreate"))?.run)(s, &cp)?; + created = r.get("sketch").and_then(Value::as_u64); + } + let f = move |q: Vec2| q * k + off; + let ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| { + let (cs, texts) = add_geometry(sk, &geom, &f, k)?; + Ok((ids_of(sk, &cs), texts)) + })?; + // Text becomes sketch text. + let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch"))?; + let mut n_text = 0; + for t in texts { + if let Geom2::Text { text, at, height, angle } = t { + let mut doc = (*s.doc).clone(); + let mut sk = doc.sketch(id)?.clone(); + if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle }).is_ok() { + *doc.sketch_mut(id)? = sk; + *s.doc_mut() = doc; + n_text += 1; + } + } + } + Ok(json!({"sketch": id, "created_sketch": created, "curves": curves.len(), "texts": n_text, "info": info})) +} + +fn insert_dxf(s: &mut Session, p: &Value) -> Result { + insert(s, p, "ImportDxfFileCommand", false) +} + +fn insert_svg(s: &mut Session, p: &Value) -> Result { + insert(s, p, "SketchImportSVG", true) +} + +fn export_dxf(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.export_dxf"; + let id = match p.get("sketch") { + Some(Value::Number(n)) => n.as_u64(), + Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id), + _ => s.active_sketch, + } + .ok_or_else(|| bad(cmd, "no such sketch (and none is active)"))?; + let st = s.model.state(); + let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?; + let sk = &ss.sketch; + let mut geom = Vec::new(); + for (i, c) in sk.curves.iter().enumerate() { + if c.construction { + continue; + } + let pt = |k: usize| sk.point(k).unwrap_or_default(); + match &c.kind { + CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))), + CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)), + CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }), + _ => { + let poly = sk.polyline(i); + geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1]))); + } + } + } + let text = solvecraft_io::write_dxf(&geom); + if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) { + std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + return Ok(json!({"path": path, "entities": geom.len()})); + } + Ok(json!({"dxf": text, "entities": geom.len()})) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn dxf_and_svg_round_trip_into_sketches() { + let mut s = Session::default(); + s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap(); + s.execute("ShapeRectangleTwoPoint", &json!({"p0": [0, 0], "p1": [30, 20]})).unwrap(); + s.execute("CircleCenterRadius", &json!({"center": [10, 10], "radius": 4})).unwrap(); + let out = s.execute("sketch.export_dxf", &json!({})).unwrap(); + let dxf = out["dxf"].as_str().unwrap().to_string(); + s.execute("SketchStop", &json!({})).unwrap(); + // Insert it again as a new sketch, offset: same profiles. + let r = s.execute("ImportDxfFileCommand", &json!({"text": dxf, "at": [100, 0]})).unwrap(); + assert_eq!(r["curves"], 5, "{r}"); + let id = r["sketch"].as_u64().unwrap(); + let ss = s.model.state().sketch(id).unwrap().clone(); + let mut areas: Vec = ss.profiles.iter().map(|p| p.area).collect(); + areas.sort_by(|a, b| a.total_cmp(b)); + assert_eq!(areas.len(), 2, "{areas:?}"); + assert!((areas[1] - (600.0 - std::f64::consts::PI * 16.0)).abs() < 1e-6, "{areas:?}"); + // SVG with a path and text, into the active sketch. + let svg = + r#"Hi"#; + let r = s.execute("SketchImportSVG", &json!({"text": svg})).unwrap(); + assert_eq!(r["curves"], 4, "{r}"); + assert_eq!(r["texts"], 1, "{r}"); + assert!(s.execute("SketchImportSVG", &json!({"text": ""})).is_err()); + assert!(s.execute("ImportDxfFileCommand", &json!({"path": "/nonexistent.dxf"})).is_err()); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7f2edd82..b3e5877b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -285,10 +285,15 @@ fn hostile_params_never_panic() { "lines", "driven", "from", + "datum", + "tolerance", ]; let mut internal = Vec::new(); for spec in command_specs() { - if matches!(spec.id, "doc.open" | "SaveDocumentCommand" | "SaveDocumentAsCommand" | "ExportCommand" | "FusionSaveAsSTLCommand") { + if matches!( + spec.id, + "doc.open" | "SaveDocumentCommand" | "SaveDocumentAsCommand" | "ExportCommand" | "FusionSaveAsSTLCommand" | "sketch.export_dxf" + ) { continue; // file system side effects are covered by their own tests } for v in hostile_values() { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index a9f89a2c..542792d9 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -90,6 +90,15 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool out } +/// Should sketch point `i` be drawn? Text outlines' points are not (they are not picked or +/// constrained individually). +pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool { + match sk.points.get(i).and_then(|p| p.link.as_deref()) { + Some(l) => sk.link(l).is_none_or(|l| l.kind != solvecraft_sketch::LinkKind::Text), + None => true, + } +} + /// The scene for a headless render. pub fn scene(s: &Session, cam: &Camera) -> Scene { let st = s.world_state(); diff --git a/crates/io/src/dxf.rs b/crates/io/src/dxf.rs new file mode 100644 index 00000000..f9d62c54 --- /dev/null +++ b/crates/io/src/dxf.rs @@ -0,0 +1,315 @@ +//! ASCII DXF (the public AutoCAD DXF reference): the ENTITIES section's LINE, CIRCLE, ARC, +//! LWPOLYLINE, POLYLINE/VERTEX, POINT, ELLIPSE, SPLINE, TEXT and MTEXT, scaled to millimetres by +//! `$INSUNITS`. Blocks are not expanded. The writer emits an R12-style file of lines, circles, +//! arcs and points (free-form curves as polylines). + +use solvecraft_geom::Vec2; + +use crate::sketch2d::{Geom2, MAX_DRAWING_BYTES, MAX_ENTITIES, bulge_arc}; +use crate::{IoError, Result}; + +/// Group code / value pairs. +fn pairs(text: &str) -> Vec<(i32, String)> { + let mut out = Vec::new(); + let mut lines = text.lines(); + while let (Some(c), Some(v)) = (lines.next(), lines.next()) { + let Ok(code) = c.trim().parse::() else { continue }; + out.push((code, v.trim().to_string())); + } + out +} + +fn units_scale(p: &[(i32, String)]) -> f64 { + // $INSUNITS: 1 in, 2 ft, 4 mm, 5 cm, 6 m, 0 unitless (taken as mm). + let i = p.iter().position(|(c, v)| *c == 9 && v == "$INSUNITS"); + match i.and_then(|i| p.get(i + 1)).and_then(|(_, v)| v.parse::().ok()) { + Some(1) => 25.4, + Some(2) => 304.8, + Some(5) => 10.0, + Some(6) => 1000.0, + _ => 1.0, + } +} + +#[derive(Default)] +struct Ent { + kind: String, + f: Vec<(i32, f64)>, + s: Vec<(i32, String)>, +} + +impl Ent { + fn get(&self, code: i32) -> Option { + self.f.iter().find(|(c, _)| *c == code).map(|(_, v)| *v) + } + fn all(&self, code: i32) -> Vec { + self.f.iter().filter(|(c, _)| *c == code).map(|(_, v)| *v).collect() + } + fn text(&self, code: i32) -> Option<&str> { + self.s.iter().find(|(c, _)| *c == code).map(|(_, v)| v.as_str()) + } +} + +/// Read the entities of an ASCII DXF file. +pub fn read_dxf(text: &str) -> Result> { + if text.len() > MAX_DRAWING_BYTES { + return Err(IoError::Invalid("the DXF file is too large".into())); + } + if text.starts_with("AutoCAD Binary DXF") { + return Err(IoError::Invalid("binary DXF is not supported; save as ASCII DXF".into())); + } + let p = pairs(text); + let k = units_scale(&p); + // Entities of the ENTITIES section. + let start = p.iter().position(|(c, v)| *c == 2 && v == "ENTITIES").ok_or_else(|| IoError::Invalid("no ENTITIES section".into()))?; + let mut ents: Vec = Vec::new(); + for (c, v) in p.iter().skip(start + 1) { + if *c == 0 { + if v == "ENDSEC" || v == "EOF" { + break; + } + ents.push(Ent { kind: v.clone(), ..Default::default() }); + if ents.len() > MAX_ENTITIES { + return Err(IoError::Invalid("too many entities".into())); + } + continue; + } + let Some(e) = ents.last_mut() else { continue }; + let numeric = matches!(*c, 10..=59 | 60..=99 | 140..=147 | 210..=239); + match v.parse::() { + Ok(x) if numeric && x.is_finite() && x.abs() < 1e12 => e.f.push((*c, x)), + _ => e.s.push((*c, v.clone())), + } + } + let pt = |x: f64, y: f64| Vec2::new(x * k, y * k); + let mut out = Vec::new(); + let mut i = 0; + while i < ents.len() { + let Some(e) = ents.get(i) else { break }; + i += 1; + match e.kind.as_str() { + "LINE" => { + if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (e.get(10), e.get(20), e.get(11), e.get(21)) { + out.push(Geom2::Line(pt(x0, y0), pt(x1, y1))); + } + } + "POINT" => { + if let (Some(x), Some(y)) = (e.get(10), e.get(20)) { + out.push(Geom2::Point(pt(x, y))); + } + } + "CIRCLE" => { + if let (Some(x), Some(y), Some(r)) = (e.get(10), e.get(20), e.get(40)) { + out.push(Geom2::Circle(pt(x, y), r * k)); + } + } + "ARC" => { + if let (Some(x), Some(y), Some(r), Some(a0), Some(a1)) = (e.get(10), e.get(20), e.get(40), e.get(50), e.get(51)) { + let c = pt(x, y); + let at = |deg: f64| c + Vec2::from_angle(deg.to_radians()) * (r * k); + out.push(Geom2::Arc { c, a: at(a0), b: at(a1) }); + } + } + "LWPOLYLINE" => { + let ys = e.all(20); + // Bulges follow their vertex: walk the pairs in order. + let mut verts: Vec<(Vec2, f64)> = Vec::new(); + let mut xi = 0; + for (c, v) in &e.f { + match *c { + 10 => { + if let Some(y) = ys.get(xi) { + verts.push((pt(*v, *y), 0.0)); + } + xi += 1; + } + 42 => { + if let Some(last) = verts.last_mut() { + last.1 = *v; + } + } + _ => {} + } + } + let closed = e.get(70).is_some_and(|f| (f as i64) & 1 == 1); + polyline(&verts, closed, &mut out); + } + "POLYLINE" => { + let closed = e.get(70).is_some_and(|f| (f as i64) & 1 == 1); + let mut verts = Vec::new(); + while let Some(v) = ents.get(i) { + if v.kind != "VERTEX" { + break; + } + if let (Some(x), Some(y)) = (v.get(10), v.get(20)) { + verts.push((pt(x, y), v.get(42).unwrap_or(0.0))); + } + i += 1; + } + if ents.get(i).is_some_and(|x| x.kind == "SEQEND") { + i += 1; + } + polyline(&verts, closed, &mut out); + } + "ELLIPSE" => { + if let (Some(x), Some(y), Some(mx), Some(my), Some(ratio)) = (e.get(10), e.get(20), e.get(11), e.get(21), e.get(40)) { + let c = pt(x, y); + let major = c + Vec2::new(mx, my) * k; + let a = Vec2::new(mx, my).len() * k; + let (t0, t1) = (e.get(41).unwrap_or(0.0), e.get(42).unwrap_or(std::f64::consts::TAU)); + if (t1 - t0 - std::f64::consts::TAU).abs() < 1e-9 || (t1 - t0).abs() < 1e-12 { + out.push(Geom2::Ellipse { c, major, minor: a * ratio }); + } else { + // Partial ellipse: a fit spline through samples. + let u = (major - c) / a.max(1e-12); + let v = u.perp() * (a * ratio); + let mut sweep = t1 - t0; + while sweep <= 0.0 { + sweep += std::f64::consts::TAU; + } + let n = 16; + let pts = (0..=n) + .map(|j| { + let t = t0 + sweep * j as f64 / n as f64; + c + u * (a * t.cos()) + v * t.sin() + }) + .collect(); + out.push(Geom2::Spline { pts, control: false, degree: 3 }); + } + } + } + "SPLINE" => { + let degree = e.get(71).unwrap_or(3.0).clamp(1.0, 7.0) as u8; + let (fx, fy) = (e.all(11), e.all(21)); + let (cx, cy) = (e.all(10), e.all(20)); + if fx.len() >= 2 && fx.len() == fy.len() { + out.push(Geom2::Spline { pts: fx.iter().zip(&fy).map(|(x, y)| pt(*x, *y)).collect(), control: false, degree: 3 }); + } else if cx.len() >= 2 && cx.len() == cy.len() { + out.push(Geom2::Spline { pts: cx.iter().zip(&cy).map(|(x, y)| pt(*x, *y)).collect(), control: true, degree }); + } + } + "TEXT" | "MTEXT" => { + if let (Some(x), Some(y), Some(h), Some(t)) = (e.get(10), e.get(20), e.get(40), e.text(1)) { + // MTEXT paragraph codes: keep the plain text. + let t = t.replace("\\P", "\n"); + out.push(Geom2::Text { text: t, at: pt(x, y), height: h * k, angle: e.get(50).unwrap_or(0.0).to_radians() }); + } + } + _ => {} + } + } + Ok(out) +} + +fn polyline(verts: &[(Vec2, f64)], closed: bool, out: &mut Vec) { + let n = verts.len(); + let segs = if closed { n } else { n.saturating_sub(1) }; + for j in 0..segs { + let (Some((p0, b)), Some((p1, _))) = (verts.get(j), verts.get((j + 1) % n)) else { continue }; + if p0.dist(*p1) < 1e-12 { + continue; + } + if let Some(g) = bulge_arc(*p0, *p1, *b) { + out.push(g); + } + } +} + +/// Write 2D geometry (millimetres) as an R12-style ASCII DXF. +pub fn write_dxf(geom: &[Geom2]) -> String { + let mut s = String::new(); + let mut w = |c: i32, v: &str| { + s.push_str(&format!("{c}\n{v}\n")); + }; + w(0, "SECTION"); + w(2, "HEADER"); + w(9, "$INSUNITS"); + w(70, "4"); + w(0, "ENDSEC"); + w(0, "SECTION"); + w(2, "ENTITIES"); + let f = |x: f64| format!("{x:.9}"); + for g in geom { + match g { + Geom2::Point(p) => { + w(0, "POINT"); + w(8, "0"); + w(10, &f(p.x)); + w(20, &f(p.y)); + } + Geom2::Line(a, b) => { + w(0, "LINE"); + w(8, "0"); + w(10, &f(a.x)); + w(20, &f(a.y)); + w(11, &f(b.x)); + w(21, &f(b.y)); + } + Geom2::Circle(c, r) => { + w(0, "CIRCLE"); + w(8, "0"); + w(10, &f(c.x)); + w(20, &f(c.y)); + w(40, &f(*r)); + } + Geom2::Arc { c, a, b } => { + w(0, "ARC"); + w(8, "0"); + w(10, &f(c.x)); + w(20, &f(c.y)); + w(40, &f(c.dist(*a))); + w(50, &f((*a - *c).angle().to_degrees())); + w(51, &f((*b - *c).angle().to_degrees())); + } + Geom2::Text { text, at, height, angle } => { + w(0, "TEXT"); + w(8, "0"); + w(10, &f(at.x)); + w(20, &f(at.y)); + w(40, &f(*height)); + w(50, &f(angle.to_degrees())); + w(1, &text.replace('\n', " ")); + } + // Free-form curves are written by the caller as polylines (lines). + Geom2::Ellipse { .. } | Geom2::Spline { .. } | Geom2::Conic { .. } => {} + } + } + w(0, "ENDSEC"); + w(0, "EOF"); + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_and_polyline_bulges() { + let g = vec![ + Geom2::Line(Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)), + Geom2::Circle(Vec2::new(5.0, 5.0), 2.0), + Geom2::Arc { c: Vec2::ZERO, a: Vec2::new(3.0, 0.0), b: Vec2::new(0.0, 3.0) }, + ]; + let back = read_dxf(&write_dxf(&g)).unwrap(); + assert_eq!(back.len(), 3); + match &back[2] { + Geom2::Arc { c, a, b } => { + assert!(c.dist(Vec2::ZERO) < 1e-6 && a.dist(Vec2::new(3.0, 0.0)) < 1e-6 && b.dist(Vec2::new(0.0, 3.0)) < 1e-6); + } + x => panic!("{x:?}"), + } + // A closed LWPOLYLINE slot: two lines and two half circles (bulge 1), in inches. + let dxf = "0\nSECTION\n2\nHEADER\n9\n$INSUNITS\n70\n1\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n90\n4\n70\n1\n10\n0\n20\n0\n10\n2\n20\n0\n42\n1\n10\n2\n20\n1\n10\n0\n20\n1\n42\n1\n0\nENDSEC\n0\nEOF\n"; + let g = read_dxf(dxf).unwrap(); + assert_eq!(g.len(), 4, "{g:?}"); + match &g[1] { + Geom2::Arc { c, a, .. } => { + assert!(c.dist(Vec2::new(50.8, 12.7)) < 1e-9, "{c:?}"); + assert!((c.dist(*a) - 12.7).abs() < 1e-9); + } + x => panic!("{x:?}"), + } + assert!(read_dxf("garbage").is_err()); + assert!(read_dxf("AutoCAD Binary DXF\r\n").is_err()); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 5180c60a..be87d961 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -5,8 +5,14 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod dxf; +mod sketch2d; +mod svg; mod threemf; +pub use dxf::{read_dxf, write_dxf}; +pub use sketch2d::{Geom2, MAX_DRAWING_BYTES}; +pub use svg::read_svg; pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, read_3mf, weld, write_3mf}; use solvecraft_doc::{Document, ModelState}; diff --git a/crates/io/src/sketch2d.rs b/crates/io/src/sketch2d.rs new file mode 100644 index 00000000..3edf811c --- /dev/null +++ b/crates/io/src/sketch2d.rs @@ -0,0 +1,70 @@ +//! 2D drawing exchange for sketches: DXF (ASCII, read and write) and SVG (read). Readers produce +//! plain 2D geometry in millimetres; the engine turns it into sketch curves. + +use solvecraft_geom::Vec2; + +/// One imported 2D entity (millimetres, y up). +#[derive(Clone, Debug, PartialEq)] +pub enum Geom2 { + Point(Vec2), + Line(Vec2, Vec2), + Circle(Vec2, f64), + /// Counter-clockwise arc around `c` from `a` to `b`. + Arc { + c: Vec2, + a: Vec2, + b: Vec2, + }, + /// Full ellipse: centre, end of the major axis, minor radius. + Ellipse { + c: Vec2, + major: Vec2, + minor: f64, + }, + /// Spline through (`control == false`) or over (`control`) the points. + Spline { + pts: Vec, + control: bool, + degree: u8, + }, + /// Quadratic Bézier / conic. + Conic { + a: Vec2, + apex: Vec2, + b: Vec2, + rho: f64, + }, + Text { + text: String, + at: Vec2, + height: f64, + angle: f64, + }, +} + +/// Most entities one file may produce. +pub const MAX_ENTITIES: usize = 200_000; +/// Largest drawing file we read. +pub const MAX_DRAWING_BYTES: usize = 64 << 20; + +/// Counter-clockwise arc from `p0` to `p1` with DXF bulge `b` (tan of a quarter of the +/// included angle, negative = clockwise). +pub(crate) fn bulge_arc(p0: Vec2, p1: Vec2, b: f64) -> Option { + if b.abs() < 1e-12 || !b.is_finite() { + return Some(Geom2::Line(p0, p1)); + } + let chord = p1 - p0; + let l = chord.len(); + if l < 1e-12 { + return None; + } + let theta = 4.0 * b.atan(); + let r = l / (2.0 * (theta / 2.0).sin()).abs(); + // Centre on the perpendicular bisector, to the left for counter-clockwise bulges. + let mid = (p0 + p1) * 0.5; + let h = r * (theta / 2.0).cos().abs(); + let left = chord.perp() / l; + let sign = if theta.abs() > std::f64::consts::PI { -1.0 } else { 1.0 }; + let c = if b > 0.0 { mid + left * (h * sign) } else { mid - left * (h * sign) }; + Some(if b > 0.0 { Geom2::Arc { c, a: p0, b: p1 } } else { Geom2::Arc { c, a: p1, b: p0 } }) +} diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs new file mode 100644 index 00000000..6206914e --- /dev/null +++ b/crates/io/src/svg.rs @@ -0,0 +1,521 @@ +//! SVG (W3C SVG 1.1) geometry for sketches: `path` (all commands), `line`, `rect`, `circle`, +//! `ellipse`, `polyline`, `polygon` and `text`, with `transform`s on elements and groups. User +//! units are CSS pixels (1/96 in) unless the root's `width`/`height` carry units with a +//! `viewBox`; y is flipped so the drawing reads upright in the sketch. + +use quick_xml::Reader; +use quick_xml::events::{BytesStart, Event}; +use solvecraft_geom::Vec2; + +use crate::sketch2d::{Geom2, MAX_DRAWING_BYTES, MAX_ENTITIES}; +use crate::{IoError, Result}; + +const MAX_DEPTH: usize = 256; +const PX_MM: f64 = 25.4 / 96.0; + +/// Affine map [a c e; b d f]. +#[derive(Clone, Copy, Debug)] +struct Tf([f64; 6]); + +impl Tf { + const ID: Tf = Tf([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + fn mul(self, o: Tf) -> Tf { + let [a, b, c, d, e, f] = self.0; + let [a2, b2, c2, d2, e2, f2] = o.0; + Tf([a * a2 + c * b2, b * a2 + d * b2, a * c2 + c * d2, b * c2 + d * d2, a * e2 + c * f2 + e, b * e2 + d * f2 + f]) + } + fn apply(self, p: Vec2) -> Vec2 { + let [a, b, c, d, e, f] = self.0; + Vec2::new(a * p.x + c * p.y + e, b * p.x + d * p.y + f) + } + fn vec(self, v: Vec2) -> Vec2 { + let [a, b, c, d, ..] = self.0; + Vec2::new(a * v.x + c * v.y, b * v.x + d * v.y) + } + /// Uniform scale (with any rotation or mirror) and its factor. + fn uniform(self) -> Option { + let (u, v) = (self.vec(Vec2::X), self.vec(Vec2::Y)); + ((u.len() - v.len()).abs() < 1e-9 * u.len().max(1e-12) && u.dot(v).abs() < 1e-9 * u.len2().max(1e-24)).then_some(u.len()) + } + fn flips(self) -> bool { + let [a, b, c, d, ..] = self.0; + a * d - b * c < 0.0 + } +} + +fn numbers(s: &str) -> Vec { + // Numbers separated by commas/space, also "1-2" and "1.5.5" forms. + let mut out = Vec::new(); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() && out.len() < 1_000_000 { + while i < b.len() && !(b[i] == b'-' || b[i] == b'+' || b[i] == b'.' || b[i].is_ascii_digit()) { + i += 1; + } + let st = i; + let mut seen_dot = false; + let mut seen_e = false; + if i < b.len() && (b[i] == b'-' || b[i] == b'+') { + i += 1; + } + while i < b.len() { + let ch = b[i]; + if ch.is_ascii_digit() { + i += 1; + } else if ch == b'.' && !seen_dot && !seen_e { + seen_dot = true; + i += 1; + } else if (ch == b'e' || ch == b'E') && !seen_e { + seen_e = true; + i += 1; + if i < b.len() && (b[i] == b'-' || b[i] == b'+') { + i += 1; + } + } else { + break; + } + } + if let Some(x) = s.get(st..i).and_then(|t| t.parse::().ok()).filter(|x| x.is_finite()) { + out.push(x); + } else if i == st { + i += 1; + } + } + out +} + +fn parse_transform(s: &str) -> Tf { + let mut t = Tf::ID; + for part in s.split(')') { + let Some((name, args)) = part.split_once('(') else { continue }; + let n = numbers(args); + let g = |i: usize, d: f64| n.get(i).copied().unwrap_or(d); + let m = match name.trim().trim_start_matches(',').trim() { + "matrix" if n.len() >= 6 => Tf([n[0], n[1], n[2], n[3], n[4], n[5]]), + "translate" => Tf([1.0, 0.0, 0.0, 1.0, g(0, 0.0), g(1, 0.0)]), + "scale" => Tf([g(0, 1.0), 0.0, 0.0, g(1, g(0, 1.0)), 0.0, 0.0]), + "rotate" => { + let (s, c) = g(0, 0.0).to_radians().sin_cos(); + let r = Tf([c, s, -s, c, 0.0, 0.0]); + let (cx, cy) = (g(1, 0.0), g(2, 0.0)); + Tf([1.0, 0.0, 0.0, 1.0, cx, cy]).mul(r).mul(Tf([1.0, 0.0, 0.0, 1.0, -cx, -cy])) + } + "skewX" => Tf([1.0, 0.0, g(0, 0.0).to_radians().tan(), 1.0, 0.0, 0.0]), + "skewY" => Tf([1.0, g(0, 0.0).to_radians().tan(), 0.0, 1.0, 0.0, 0.0]), + _ => Tf::ID, + }; + t = t.mul(m); + } + t +} + +fn attrs(e: &BytesStart) -> Vec<(String, String)> { + e.attributes() + .flatten() + .map(|a| { + let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string(); + let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string()); + (k, v) + }) + .collect() +} + +fn attr<'a>(a: &'a [(String, String)], k: &str) -> Option<&'a str> { + a.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()) +} + +fn num_attr(a: &[(String, String)], k: &str) -> f64 { + attr(a, k).map(numbers).and_then(|v| v.first().copied()).unwrap_or(0.0) +} + +/// Length with a unit, in millimetres (None without a unit or for %). +fn length_mm(s: &str) -> Option { + let v = numbers(s).first().copied()?; + let u = s.trim().trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E'); + Some(match u.trim() { + "mm" => v, + "cm" => v * 10.0, + "in" => v * 25.4, + "pt" => v * 25.4 / 72.0, + "pc" => v * 25.4 / 6.0, + "px" | "" => v * PX_MM, + _ => return None, + }) +} + +struct Out { + g: Vec, +} + +impl Out { + fn push(&mut self, g: Geom2) -> Result<()> { + if self.g.len() >= MAX_ENTITIES { + return Err(IoError::Invalid("too many SVG elements".into())); + } + self.g.push(g); + Ok(()) + } + fn line(&mut self, t: Tf, a: Vec2, b: Vec2) -> Result<()> { + let (a, b) = (t.apply(a), t.apply(b)); + if a.dist(b) > 1e-12 { self.push(Geom2::Line(a, b)) } else { Ok(()) } + } + fn cubic(&mut self, t: Tf, p: [Vec2; 4]) -> Result<()> { + self.push(Geom2::Spline { pts: p.iter().map(|q| t.apply(*q)).collect(), control: true, degree: 3 }) + } + fn quad(&mut self, t: Tf, a: Vec2, c: Vec2, b: Vec2) -> Result<()> { + let (a, c, b) = (t.apply(a), t.apply(c), t.apply(b)); + if (b - a).cross(c - a).abs() < 1e-12 { self.push(Geom2::Line(a, b)) } else { self.push(Geom2::Conic { a, apex: c, b, rho: 0.5 }) } + } + /// Ellipse `c + rx·cos t·u + ry·sin t·v` (u at angle `phi`) from `t0` sweeping `dt`. + fn elliptic(&mut self, t: Tf, c: Vec2, rx: f64, ry: f64, phi: f64, t0: f64, dt: f64) -> Result<()> { + let u = Vec2::from_angle(phi); + let v = u.perp(); + let at = |a: f64| c + u * (rx * a.cos()) + v * (ry * a.sin()); + let circle = t.uniform().filter(|_| (rx - ry).abs() < 1e-9 * rx.max(ry)); + if let Some(k) = circle { + let (p0, p1) = (t.apply(at(t0)), t.apply(at(t0 + dt))); + let cc = t.apply(c); + let ccw = (dt > 0.0) != t.flips(); + if dt.abs() >= std::f64::consts::TAU - 1e-9 { + return self.push(Geom2::Circle(cc, rx * k)); + } + return self.push(if ccw { Geom2::Arc { c: cc, a: p0, b: p1 } } else { Geom2::Arc { c: cc, a: p1, b: p0 } }); + } + if dt.abs() >= std::f64::consts::TAU - 1e-9 { + // Full ellipse: centre, major end, minor radius after the transform (affine images + // of ellipses are ellipses; sample to find the axes). + let pts: Vec = (0..720).map(|i| t.apply(at(i as f64 / 720.0 * std::f64::consts::TAU))).collect(); + let cc = t.apply(c); + let (mut far, mut near) = (cc, f64::INFINITY); + for q in &pts { + if q.dist(cc) > far.dist(cc) { + far = *q; + } + near = near.min(q.dist(cc)); + } + return self.push(Geom2::Ellipse { c: cc, major: far, minor: near }); + } + // Elliptic arc: cubic Béziers of at most 90 degrees each. + let n = ((dt.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).max(1); + let h = dt / n as f64; + let k = 4.0 / 3.0 * (h / 4.0).tan(); + let d = |a: f64| u * (-rx * a.sin()) + v * (ry * a.cos()); + for i in 0..n { + let (a0, a1) = (t0 + h * i as f64, t0 + h * (i + 1) as f64); + self.cubic(t, [at(a0), at(a0) + d(a0) * k, at(a1) - d(a1) * k, at(a1)])?; + } + Ok(()) + } +} + +/// SVG endpoint arc → centre form (SVG 1.1 implementation notes F.6.5). +#[allow(clippy::too_many_arguments)] +fn arc_to(out: &mut Out, t: Tf, p0: Vec2, mut rx: f64, mut ry: f64, phi_deg: f64, large: bool, sweep: bool, p1: Vec2) -> Result<()> { + if p0.dist(p1) < 1e-12 { + return Ok(()); + } + rx = rx.abs(); + ry = ry.abs(); + if rx < 1e-12 || ry < 1e-12 { + return out.line(t, p0, p1); + } + let phi = phi_deg.to_radians(); + let (s, c) = phi.sin_cos(); + let d = (p0 - p1) * 0.5; + let x1 = Vec2::new(c * d.x + s * d.y, -s * d.x + c * d.y); + let lam = (x1.x / rx).powi(2) + (x1.y / ry).powi(2); + if lam > 1.0 { + rx *= lam.sqrt(); + ry *= lam.sqrt(); + } + let num = (rx * rx * ry * ry - rx * rx * x1.y * x1.y - ry * ry * x1.x * x1.x).max(0.0); + let den = rx * rx * x1.y * x1.y + ry * ry * x1.x * x1.x; + let mut co = if den > 0.0 { (num / den).sqrt() } else { 0.0 }; + if large == sweep { + co = -co; + } + let cx1 = Vec2::new(co * rx * x1.y / ry, -co * ry * x1.x / rx); + let mid = (p0 + p1) * 0.5; + let cc = Vec2::new(c * cx1.x - s * cx1.y, s * cx1.x + c * cx1.y) + mid; + let ang = |u: Vec2, v: Vec2| u.cross(v).atan2(u.dot(v)); + let u = Vec2::new((x1.x - cx1.x) / rx, (x1.y - cx1.y) / ry); + let v = Vec2::new((-x1.x - cx1.x) / rx, (-x1.y - cx1.y) / ry); + let t0 = ang(Vec2::X, u); + let mut dt = ang(u, v); + if !sweep && dt > 0.0 { + dt -= std::f64::consts::TAU; + } else if sweep && dt < 0.0 { + dt += std::f64::consts::TAU; + } + out.elliptic(t, cc, rx, ry, phi, t0, dt) +} + +fn path(out: &mut Out, t: Tf, d: &str) -> Result<()> { + // Tokenise into commands and their numbers. + let mut cmds: Vec<(char, Vec)> = Vec::new(); + let mut cur: Option<(char, String)> = None; + for ch in d.chars() { + if ch.is_ascii_alphabetic() && ch != 'e' && ch != 'E' { + if let Some((c, s)) = cur.take() { + cmds.push((c, numbers(&s))); + } + cur = Some((ch, String::new())); + } else if let Some((_, s)) = cur.as_mut() { + s.push(ch); + } + } + if let Some((c, s)) = cur.take() { + cmds.push((c, numbers(&s))); + } + let (mut p, mut start) = (Vec2::ZERO, Vec2::ZERO); + let mut last_c: Option = None; // last cubic control (for S) + let mut last_q: Option = None; // last quadratic control (for T) + for (c, n) in cmds { + let rel = c.is_ascii_lowercase(); + let base = |p: Vec2| if rel { p } else { Vec2::ZERO }; + let arity = match c.to_ascii_uppercase() { + 'M' | 'L' | 'T' => 2, + 'H' | 'V' => 1, + 'C' => 6, + 'S' | 'Q' => 4, + 'A' => 7, + _ => 0, + }; + if c.eq_ignore_ascii_case(&'Z') { + out.line(t, p, start)?; + p = start; + last_c = None; + last_q = None; + continue; + } + if arity == 0 { + continue; + } + for (k, a) in n.chunks(arity).enumerate() { + if a.len() < arity { + break; + } + let o = base(p); + let v = |i: usize| Vec2::new(a[i], a[i + 1]) + o; + match c.to_ascii_uppercase() { + 'M' => { + let q = v(0); + if k == 0 { + p = q; + start = q; + } else { + out.line(t, p, q)?; + p = q; + } + last_c = None; + last_q = None; + } + 'L' => { + let q = v(0); + out.line(t, p, q)?; + p = q; + last_c = None; + last_q = None; + } + 'H' => { + let q = Vec2::new(a[0] + if rel { p.x } else { 0.0 }, p.y); + out.line(t, p, q)?; + p = q; + last_c = None; + last_q = None; + } + 'V' => { + let q = Vec2::new(p.x, a[0] + if rel { p.y } else { 0.0 }); + out.line(t, p, q)?; + p = q; + last_c = None; + last_q = None; + } + 'C' => { + let (c1, c2, q) = (v(0), v(2), v(4)); + out.cubic(t, [p, c1, c2, q])?; + last_c = Some(c2); + last_q = None; + p = q; + } + 'S' => { + let c1 = last_c.map(|lc| p * 2.0 - lc).unwrap_or(p); + let (c2, q) = (v(0), v(2)); + out.cubic(t, [p, c1, c2, q])?; + last_c = Some(c2); + last_q = None; + p = q; + } + 'Q' => { + let (c1, q) = (v(0), v(2)); + out.quad(t, p, c1, q)?; + last_q = Some(c1); + last_c = None; + p = q; + } + 'T' => { + let c1 = last_q.map(|lq| p * 2.0 - lq).unwrap_or(p); + let q = v(0); + out.quad(t, p, c1, q)?; + last_q = Some(c1); + last_c = None; + p = q; + } + 'A' => { + let q = Vec2::new(a[5], a[6]) + o; + arc_to(out, t, p, a[0], a[1], a[2], a[3] != 0.0, a[4] != 0.0, q)?; + last_c = None; + last_q = None; + p = q; + } + _ => {} + } + } + } + Ok(()) +} + +fn points_list(s: &str) -> Vec { + numbers(s).chunks(2).filter(|c| c.len() == 2).map(|c| Vec2::new(c[0], c[1])).collect() +} + +/// Read the geometry of an SVG document (millimetres, y up). `scale` multiplies the result. +pub fn read_svg(text: &str) -> Result> { + if text.len() > MAX_DRAWING_BYTES { + return Err(IoError::Invalid("the SVG file is too large".into())); + } + let mut rd = Reader::from_reader(text.as_bytes()); + rd.config_mut().trim_text(true); + let mut buf = Vec::new(); + let mut stack: Vec = Vec::new(); + let mut out = Out { g: Vec::new() }; + let mut root: Option = None; + let mut pending_text: Option<(Tf, Vec2, f64)> = None; + loop { + let ev = rd.read_event_into(&mut buf).map_err(|e| IoError::Invalid(format!("SVG: {e}")))?; + let (e, empty) = match &ev { + Event::Start(e) => (Some(e.clone()), false), + Event::Empty(e) => (Some(e.clone()), true), + Event::End(_) => { + stack.pop(); + buf.clear(); + continue; + } + Event::Text(tx) => { + if let Some((tf, at, h)) = pending_text.take() { + let s = String::from_utf8_lossy(tx.as_ref()).trim().to_string(); + if !s.is_empty() { + let k = tf.uniform().unwrap_or(1.0); + let dir = tf.vec(Vec2::X); + out.push(Geom2::Text { text: s, at: tf.apply(at), height: h * 0.7 * k, angle: dir.angle() })?; + } + } + buf.clear(); + continue; + } + Event::Eof => break, + _ => (None, false), + }; + let Some(e) = e else { + buf.clear(); + continue; + }; + let a = attrs(&e); + let parent = stack.last().copied().or(root).unwrap_or(Tf::ID); + let name = String::from_utf8_lossy(e.local_name().as_ref()).to_string(); + let mut tf = match attr(&a, "transform") { + Some(s) => parent.mul(parse_transform(s)), + None => parent, + }; + if name == "svg" && root.is_none() { + // Root: user units to millimetres, y flipped. + let vb = attr(&a, "viewBox").map(numbers).filter(|v| v.len() == 4); + let (sx, sy, ox, oy) = match (vb, attr(&a, "width").and_then(length_mm), attr(&a, "height").and_then(length_mm)) { + (Some(v), Some(w), Some(h)) if v[2] > 0.0 && v[3] > 0.0 => (w / v[2], h / v[3], v[0], v[1]), + (Some(v), _, _) => (PX_MM, PX_MM, v[0], v[1]), + _ => (PX_MM, PX_MM, 0.0, 0.0), + }; + let r = Tf([sx, 0.0, 0.0, -sy, -ox * sx, oy * sy]); + root = Some(r); + tf = r; + } + match name.as_str() { + "path" => path(&mut out, tf, attr(&a, "d").unwrap_or(""))?, + "line" => out.line(tf, Vec2::new(num_attr(&a, "x1"), num_attr(&a, "y1")), Vec2::new(num_attr(&a, "x2"), num_attr(&a, "y2")))?, + "rect" => { + let (x, y, w, h) = (num_attr(&a, "x"), num_attr(&a, "y"), num_attr(&a, "width"), num_attr(&a, "height")); + if w > 0.0 && h > 0.0 { + let c = [Vec2::new(x, y), Vec2::new(x + w, y), Vec2::new(x + w, y + h), Vec2::new(x, y + h)]; + for i in 0..4 { + out.line(tf, c[i], c[(i + 1) % 4])?; + } + } + } + "circle" => { + let r = num_attr(&a, "r"); + if r > 0.0 { + out.elliptic(tf, Vec2::new(num_attr(&a, "cx"), num_attr(&a, "cy")), r, r, 0.0, 0.0, std::f64::consts::TAU)?; + } + } + "ellipse" => { + let (rx, ry) = (num_attr(&a, "rx"), num_attr(&a, "ry")); + if rx > 0.0 && ry > 0.0 { + out.elliptic(tf, Vec2::new(num_attr(&a, "cx"), num_attr(&a, "cy")), rx, ry, 0.0, 0.0, std::f64::consts::TAU)?; + } + } + "polyline" | "polygon" => { + let pts = points_list(attr(&a, "points").unwrap_or("")); + for w in pts.windows(2) { + out.line(tf, w[0], w[1])?; + } + if name == "polygon" + && let (Some(f), Some(l)) = (pts.first(), pts.last()) + { + out.line(tf, *l, *f)?; + } + } + "text" => { + let size = attr(&a, "font-size").map(numbers).and_then(|v| v.first().copied()).unwrap_or(16.0); + pending_text = Some((tf, Vec2::new(num_attr(&a, "x"), num_attr(&a, "y")), size)); + } + _ => {} + } + if !empty { + stack.push(tf); + if stack.len() > MAX_DEPTH { + return Err(IoError::Invalid("SVG nested too deeply".into())); + } + } + buf.clear(); + } + Ok(out.g) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shapes_paths_and_units() { + let svg = r#" + + + + + "#; + let g = read_svg(svg).unwrap(); + // rect 4 lines, circle, cubic, quad, arc, closing line, ellipse + assert_eq!(g.len(), 4 + 1 + 4 + 1, "{g:?}"); + // y flipped: the circle centre is at (50, -25) in mm. + assert!(g.iter().any(|x| matches!(x, Geom2::Circle(c, r) if c.dist(Vec2::new(50.0, -25.0)) < 1e-9 && (r - 5.0).abs() < 1e-9))); + assert!(g.iter().any(|x| matches!(x, Geom2::Spline { control: true, .. }))); + assert!(g.iter().any(|x| matches!(x, Geom2::Conic { .. }))); + assert!(g.iter().any(|x| matches!(x, Geom2::Arc { .. }))); + assert!(g.iter().any(|x| matches!(x, Geom2::Ellipse { minor, .. } if (minor - 3.0).abs() < 1e-3))); + assert!(read_svg("").is_ok()); + assert!(read_svg(""#).unwrap(); + assert!(matches!(g[0], Geom2::Line(a, b) if (a.dist(b) - 25.4).abs() < 1e-9)); + } +} diff --git a/docs/parity.md b/docs/parity.md index 8e6b2a9d..c8470999 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,9 +2,9 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. -**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 139 / 541 commands live (26%).** SOLID + SKETCH: 102 / 291 (35%). Registered commands: 145. +**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 148 / 541 commands live (27%).** SOLID + SKETCH: 109 / 291 (37%). Registered commands: 151. -Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 225 / 1175 (19%). +Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 242 / 1175 (21%). | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| @@ -14,25 +14,25 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SOLID | CONFIGURE | 0 | 3 | 0% | | SOLID | CONSTRUCT | 2 | 20 | 10% | | SOLID | INSPECT | 1 | 25 | 4% | -| SOLID | INSERT | 1 | 11 | 9% | +| SOLID | INSERT | 3 | 11 | 27% | | SOLID | SELECT | 0 | 15 | 0% | | SOLID | POSITION | 2 | 2 | 100% | | SKETCH | CREATE | 35 | 41 | 85% | | SKETCH | MODIFY | 12 | 12 | 100% | -| SKETCH | CONSTRAINTS | 13 | 15 | 87% | +| SKETCH | CONSTRAINTS | 15 | 15 | 100% | | SKETCH | CONFIGURE | 0 | 3 | 0% | | SKETCH | INSPECT | 1 | 25 | 4% | -| SKETCH | INSERT | 2 | 13 | 15% | +| SKETCH | INSERT | 4 | 13 | 31% | | SKETCH | ASSEMBLE | 0 | 1 | 0% | | SKETCH | SELECT | 0 | 15 | 0% | -| SKETCH | FINISH SKETCH | 1 | 2 | 50% | +| SKETCH | FINISH SKETCH | 2 | 2 | 100% | | SHEET METAL | CREATE | 8 | 19 | 42% | | SHEET METAL | MODIFY | 7 | 21 | 33% | | SHEET METAL | ASSEMBLE | 1 | 20 | 5% | | SHEET METAL | CONFIGURE | 0 | 3 | 0% | | SHEET METAL | CONSTRUCT | 2 | 20 | 10% | | SHEET METAL | INSPECT | 1 | 25 | 4% | -| SHEET METAL | INSERT | 1 | 11 | 9% | +| SHEET METAL | INSERT | 3 | 11 | 27% | | SHEET METAL | SELECT | 0 | 15 | 0% | | SHEET METAL | REFOLD | 0 | 1 | 0% | | SHEET METAL | POSITION | 2 | 2 | 100% | @@ -59,7 +59,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SURFACE | CONFIGURE | 0 | 3 | 0% | | SURFACE | CONSTRUCT | 2 | 20 | 10% | | SURFACE | INSPECT | 1 | 25 | 4% | -| SURFACE | INSERT | 1 | 11 | 9% | +| SURFACE | INSERT | 3 | 11 | 27% | | SURFACE | SELECT | 0 | 15 | 0% | | SURFACE | POSITION | 2 | 2 | 100% | | MESH | CREATE | 2 | 5 | 40% | @@ -69,7 +69,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | MESH | CONFIGURE | 0 | 3 | 0% | | MESH | CONSTRUCT | 2 | 20 | 10% | | MESH | INSPECT | 1 | 25 | 4% | -| MESH | INSERT | 0 | 10 | 0% | +| MESH | INSERT | 2 | 10 | 20% | | MESH | SELECT | 0 | 10 | 0% | | MESH | EXPORT | 0 | 1 | 0% | | MESH | POSITION | 2 | 2 | 100% | @@ -79,7 +79,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | FORM | UTILITIES | 0 | 5 | 0% | | FORM | CONSTRUCT | 2 | 20 | 10% | | FORM | INSPECT | 1 | 25 | 4% | -| FORM | INSERT | 2 | 13 | 15% | +| FORM | INSERT | 4 | 13 | 31% | | FORM | ASSEMBLE | 0 | 1 | 0% | | FORM | SELECT | 0 | 15 | 0% | | FORM | FINISH FORM | 0 | 1 | 0% | @@ -90,7 +90,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | PLASTIC | CONFIGURE | 0 | 3 | 0% | | PLASTIC | CONSTRUCT | 2 | 20 | 10% | | PLASTIC | INSPECT | 1 | 26 | 4% | -| PLASTIC | INSERT | 1 | 11 | 9% | +| PLASTIC | INSERT | 3 | 11 | 27% | | PLASTIC | SELECT | 0 | 15 | 0% | | PLASTIC | POSITION | 2 | 2 | 100% | | MANAGE | ASSIGN | 0 | 1 | 0% | @@ -145,6 +145,8 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › CONSTRUCT › Offset Plane (`ConstructionPlaneOffsetFromPlaneCommand`) - SOLID › CONSTRUCT › Plane at Angle (`ConstructionPlaneAtAngleCommand`) - SOLID › INSPECT › Measure (`MeasureCommand`) +- SOLID › INSERT › Insert SVG (`SketchImportSVG`) +- SOLID › INSERT › Insert DXF (`ImportDxfFileCommand`) - SOLID › INSERT › Insert Mesh (`ParaMeshInsertAlignCommand`) - SOLID › POSITION › Capture Position (`SnapshotCmd`) - SOLID › POSITION › Revert Position (`AsBuiltPositionsCmd`) @@ -195,6 +197,8 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SKETCH › MODIFY › Sketch Scale (`SketchScaleCmd`) - SKETCH › MODIFY › Move/Copy (`FusionMoveCommand`) - SKETCH › MODIFY › Change Parameters (`ChangeParameterCommand`) +- SKETCH › CONSTRAINTS › AutoConstrain (`SketchAutoConstraintAndDimCmd`) +- SKETCH › CONSTRAINTS › AutoConstrain from datum (`SketchAutoConstraintAndDimFromDatumCmd`) - SKETCH › CONSTRAINTS › Horizontal/Vertical (`ConstraintHorizontalVertical`) - SKETCH › CONSTRAINTS › Coincident (`ConstraintCoincident`) - SKETCH › CONSTRAINTS › Tangent (`ConstraintTangent`) @@ -209,9 +213,12 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SKETCH › CONSTRAINTS › Curvature (`ConstraintSmooth`) - SKETCH › CONSTRAINTS › Polygon (`SketchPolygonConstraintCmd`) - SKETCH › INSPECT › Measure (`MeasureCommand`) +- SKETCH › INSERT › Insert SVG (`SketchImportSVG`) +- SKETCH › INSERT › Insert DXF (`ImportDxfFileCommand`) - SKETCH › INSERT › Insert Mesh (`ParaMeshInsertAlignCommand`) - SKETCH › INSERT › Insert Component (`FusionImportCommandFromToolbar`) - SKETCH › FINISH SKETCH › Finish Sketch (`SketchStop`) +- SKETCH › FINISH SKETCH › Finish with AutoConstrain (`SketchAutoConstrainAndFinish`) ## Not yet (SOLID, SKETCH) @@ -318,8 +325,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › INSERT › Decal (`FusionAddEditDecalCommand`) - SOLID › INSERT › Canvas (`FusionAddCanvasCommand`) - SOLID › INSERT › Insert T-Spline (`TSplineImportCommand`) -- SOLID › INSERT › Insert SVG (`SketchImportSVG`) -- SOLID › INSERT › Insert DXF (`ImportDxfFileCommand`) - SOLID › INSERT › Insert Fastener (`FusionFastenersCommand`) - SOLID › INSERT › Insert Derive (`PullDeriveCommand`) - SOLID › INSERT › Insert McMaster-Carr Component (`InsertMcMasterCarrComponentCommand`) @@ -347,8 +352,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SKETCH › CREATE › Project To Surface (`ProjectToSurface`) - SKETCH › CREATE › Intersection Curve (`IntersectionCurve`) - SKETCH › CREATE › Isoparametric Curve (`SketchIsoparametricCurve`) -- SKETCH › CONSTRAINTS › AutoConstrain (`SketchAutoConstraintAndDimCmd`) -- SKETCH › CONSTRAINTS › AutoConstrain from datum (`SketchAutoConstraintAndDimFromDatumCmd`) - SKETCH › CONFIGURE › Configure (`FusionStartDesignConfigModeCmd`) - SKETCH › CONFIGURE › Display Configuration Table (`FusionShowDesignConfigPanelCmd`) - SKETCH › CONFIGURE › Create Configuration Rules (`FusionConfigurationRulesCmd`) @@ -379,8 +382,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SKETCH › INSERT › Decal (`FusionAddEditDecalCommand`) - SKETCH › INSERT › Canvas (`FusionAddCanvasCommand`) - SKETCH › INSERT › Insert T-Spline (`TSplineImportCommand`) -- SKETCH › INSERT › Insert SVG (`SketchImportSVG`) -- SKETCH › INSERT › Insert DXF (`ImportDxfFileCommand`) - SKETCH › INSERT › Insert Fastener (`FusionFastenersCommand`) - SKETCH › INSERT › Duplicate With Joints (`FusionDuplicateWithJointsCommand`) - SKETCH › INSERT › Insert Derive (`PullDeriveCommand`) @@ -403,4 +404,3 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SKETCH › SELECT › Select Edge Priority (`SelectEdgePriorityCommand`) - SKETCH › SELECT › Select Component Priority (`SelectComponentPriorityCommand`) - SKETCH › SELECT › Selection Filters (`SelectionFilterCommand`) -- SKETCH › FINISH SKETCH › Finish with AutoConstrain (`SketchAutoConstrainAndFinish`)