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();
|
||||
|
||||
Reference in new issue
Block a user