AutoConstrain (from datum, and Finish with AutoConstrain): infers coincident, horizontal/vertical and tangent constraints, then dimensions until fully constrained; Insert DXF (ASCII: lines, arcs, circles, bulged polylines, ellipses, splines, text; $INSUNITS) and Insert SVG (paths with all commands, shapes, transforms, units; Béziers kept exact as splines/conics; text) into sketches; sketch.export_dxf; text outline points hidden (view::sketch_point_visible)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
b511ac6124
commit
6f349b79f5
11 files changed
+1360
-20
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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<Value> {
|
||||
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<usize> {
|
||||
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<String>, 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<Vec2> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
auto(s, p, "SketchAutoConstraintAndDimCmd")
|
||||
}
|
||||
fn auto_constrain_datum(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
auto(s, p, "SketchAutoConstraintAndDimFromDatumCmd")
|
||||
}
|
||||
fn auto_constrain_finish(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Vec2> {
|
||||
|
||||
@@ -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<String> = 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}");
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<usize>, Vec<Geom2>)> {
|
||||
let q = 1e-6;
|
||||
let mut map: HashMap<(i64, i64), usize> = HashMap::new();
|
||||
let mut pt = |sk: &mut Sketch, p: Vec2| -> Result<usize> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
insert(s, p, "ImportDxfFileCommand", false)
|
||||
}
|
||||
|
||||
fn insert_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
insert(s, p, "SketchImportSVG", true)
|
||||
}
|
||||
|
||||
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<f64> = 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#"<svg width="40mm" height="40mm" viewBox="0 0 40 40"><path d="M0 0 H40 V40 H0 Z"/><text x="5" y="20" font-size="10">Hi</text></svg>"#;
|
||||
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": "<svg></svg>"})).is_err());
|
||||
assert!(s.execute("ImportDxfFileCommand", &json!({"path": "/nonexistent.dxf"})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::<i32>() 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::<i32>().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<f64> {
|
||||
self.f.iter().find(|(c, _)| *c == code).map(|(_, v)| *v)
|
||||
}
|
||||
fn all(&self, code: i32) -> Vec<f64> {
|
||||
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<Vec<Geom2>> {
|
||||
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<Ent> = 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::<f64>() {
|
||||
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<Geom2>) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Vec2>,
|
||||
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<Geom2> {
|
||||
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 } })
|
||||
}
|
||||
@@ -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<f64> {
|
||||
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<f64> {
|
||||
// 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::<f64>().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<f64> {
|
||||
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<Geom2>,
|
||||
}
|
||||
|
||||
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<Vec2> = (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<f64>)> = 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<Vec2> = None; // last cubic control (for S)
|
||||
let mut last_q: Option<Vec2> = 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<Vec2> {
|
||||
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<Vec<Geom2>> {
|
||||
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<Tf> = Vec::new();
|
||||
let mut out = Out { g: Vec::new() };
|
||||
let mut root: Option<Tf> = 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#"<svg xmlns="http://www.w3.org/2000/svg" width="100mm" height="50mm" viewBox="0 0 100 50">
|
||||
<rect x="10" y="10" width="20" height="10"/>
|
||||
<circle cx="50" cy="25" r="5"/>
|
||||
<g transform="translate(70 0)"><path d="M0 0 C 10 0 10 10 20 10 Q 25 20 30 10 A 5 5 0 0 1 40 10 Z"/></g>
|
||||
<ellipse cx="80" cy="40" rx="6" ry="3"/>
|
||||
</svg>"#;
|
||||
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("<svg><path d='M 1e400 0 L nan 3'/></svg>").is_ok());
|
||||
assert!(read_svg("<svg").is_err() || read_svg("<svg").is_ok());
|
||||
// Plain pixels.
|
||||
let g = read_svg(r#"<svg><line x1="0" y1="0" x2="96" y2="0"/></svg>"#).unwrap();
|
||||
assert!(matches!(g[0], Geom2::Line(a, b) if (a.dist(b) - 25.4).abs() < 1e-9));
|
||||
}
|
||||
}
|
||||
+18
-18
@@ -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`)
|
||||
Reference in new issue
Block a user