Sketch constraints and dimensions: Curvature (G2, ConstraintSmooth) and Polygon constraints; redundant constraints and dimensions are refused as over-constraining (Fusion behaviour), with driven (reference) dimensions that measure instead; arc length and linear diameter (about a centerline) dimensions; sketch.glyphs (constraint glyph anchors, conflicts) and sketch.snap (coincident/midpoint/on-curve/horizontal/vertical inference); solver damping floor keeps weakly seen variables from flying off
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
a1accbf46e
commit
54ee20bef4
10 files changed
+668
-50
No files matched your search
@@ -10,6 +10,7 @@ pub use features::auto_operation;
|
||||
mod file;
|
||||
mod inspect;
|
||||
mod sketch;
|
||||
mod sketch_constraints;
|
||||
mod sketch_create;
|
||||
mod sketch_modify;
|
||||
mod sketch_project;
|
||||
@@ -110,6 +111,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
let mut v: Vec<&'static CommandSpec> = Vec::new();
|
||||
v.extend(sketch::COMMANDS.iter());
|
||||
v.extend(sketch_create::COMMANDS.iter());
|
||||
v.extend(sketch_constraints::COMMANDS.iter());
|
||||
v.extend(sketch_modify::COMMANDS.iter());
|
||||
v.extend(sketch_project::COMMANDS.iter());
|
||||
v.extend(features::COMMANDS.iter());
|
||||
|
||||
@@ -786,13 +786,33 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Constraints
|
||||
|
||||
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> Result<ConstraintKind>) -> Result<Value> {
|
||||
let mut before = None;
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let k = f(sk)?;
|
||||
before = Some(dof_now(sk));
|
||||
add_c(sk, k)
|
||||
})?;
|
||||
reject_redundant(before, &info, cmd)?;
|
||||
Ok(json!({"constraint": id, "sketch": info}))
|
||||
}
|
||||
|
||||
/// Degrees of freedom of a sketch as it is.
|
||||
pub(super) fn dof_now(sk: &Sketch) -> usize {
|
||||
solve(&mut sk.clone()).dof
|
||||
}
|
||||
|
||||
/// A constraint that removes no degree of freedom over-constrains the sketch (Fusion refuses
|
||||
/// it); the caller's edit is rolled back by returning the error.
|
||||
pub(super) fn reject_redundant(before: Option<usize>, info: &Value, cmd: &str) -> Result<()> {
|
||||
let after = info.get("dof").and_then(Value::as_u64).map(|d| d as usize);
|
||||
if let (Some(b), Some(a)) = (before, after)
|
||||
&& a >= b
|
||||
{
|
||||
return Err(bad(cmd, "that would over-constrain the sketch (it is already determined by other constraints)"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ConstraintHorizontalVertical";
|
||||
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
|
||||
@@ -962,7 +982,10 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let value = expr(p, "value");
|
||||
let text_at = p.get("text_at").and_then(vec2);
|
||||
let driven = bool_(p, "driven").unwrap_or(false);
|
||||
let mut before = None;
|
||||
let ((param, kind_name, current), info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
before = Some(dof_now(sk));
|
||||
let ent = |r: &str| -> Result<(Option<usize>, Option<usize>)> {
|
||||
if let Some(c) = sk.curve_index(r) {
|
||||
return Ok((None, Some(c)));
|
||||
@@ -992,8 +1015,12 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => (ConstraintKind::Length { l: c, value: a.dist(b) }, a.dist(b)),
|
||||
}
|
||||
}
|
||||
((None, Some(c)), None) if ty == "arc_length" || ty == "arclength" => {
|
||||
let len = arc_len(sk, c).ok_or_else(|| bad(cmd, "arc length needs an arc"))?;
|
||||
(ConstraintKind::ArcLength { c, value: len }, len)
|
||||
}
|
||||
((None, Some(c)), None) => {
|
||||
let r = sk.radius(c).unwrap_or(1.0);
|
||||
let r = sk.radius(c).ok_or_else(|| bad(cmd, "cannot dimension that curve"))?;
|
||||
let diameter = ty == "diameter" || (ty != "radius" && is_circle(c));
|
||||
if diameter { (ConstraintKind::Diameter { c, value: 2.0 * r }, 2.0 * r) } else { (ConstraintKind::Radius { c, value: r }, r) }
|
||||
}
|
||||
@@ -1009,17 +1036,27 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (a, b) = line_pts(sk, l).unwrap_or_default();
|
||||
let pq = sk.point(q).unwrap_or_default();
|
||||
let d = (b - a).normalized().map(|d| d.cross(pq - a).abs()).unwrap_or(0.0);
|
||||
(ConstraintKind::PointLineDistance { p: q, l, value: d }, d)
|
||||
if diameter_about(sk, l, &ty) {
|
||||
(ConstraintKind::LinearDiameter { p: q, l, value: 2.0 * d }, 2.0 * d)
|
||||
} else {
|
||||
(ConstraintKind::PointLineDistance { p: q, l, value: d }, d)
|
||||
}
|
||||
}
|
||||
((None, Some(a)), Some((None, Some(b)))) if is_line(a) && is_line(b) => {
|
||||
let (a0, a1) = line_pts(sk, a).unwrap_or_default();
|
||||
let (b0, b1) = line_pts(sk, b).unwrap_or_default();
|
||||
let (da, db) = (a1 - a0, b1 - b0);
|
||||
if da.cross(db).abs() < 1e-9 * da.len() * db.len() && ty != "angle" {
|
||||
// Parallel lines: distance between them.
|
||||
// Parallel lines: distance between them (a diameter about a centerline).
|
||||
let (a, b, b0) = if sk.curves.get(a).is_some_and(|c| c.centerline) { (a, b, b0) } else { (b, a, a0) };
|
||||
let (bq, _) = line_ends(sk, b);
|
||||
let d = da.normalized().map(|d| d.cross(b0 - a0).abs()).unwrap_or(0.0);
|
||||
(ConstraintKind::PointLineDistance { p: bq, l: a, value: d }, d)
|
||||
let (a0, a1) = line_pts(sk, a).unwrap_or_default();
|
||||
let d = (a1 - a0).normalized().map(|d| d.cross(b0 - a0).abs()).unwrap_or(0.0);
|
||||
if diameter_about(sk, a, &ty) {
|
||||
(ConstraintKind::LinearDiameter { p: bq, l: a, value: 2.0 * d }, 2.0 * d)
|
||||
} else {
|
||||
(ConstraintKind::PointLineDistance { p: bq, l: a, value: d }, d)
|
||||
}
|
||||
} else {
|
||||
match text_at {
|
||||
Some(t) => angle_by_sector(a, b, (a0, a1), (b0, b1), t).ok_or_else(|| bad(cmd, "cannot place that angle"))?,
|
||||
@@ -1038,6 +1075,16 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(cmd, "cannot dimension that combination of entities")),
|
||||
};
|
||||
let is_angle = k.is_angle();
|
||||
if driven {
|
||||
// A reference dimension: measures, no parameter.
|
||||
let name = k.name();
|
||||
let id = sk.add_constraint(k, None)?;
|
||||
if let Some(c) = sk.constraints.iter_mut().find(|c| c.id == id) {
|
||||
c.driven = true;
|
||||
}
|
||||
before = None;
|
||||
return Ok((String::new(), name, cur));
|
||||
}
|
||||
let (unit, default_expr) =
|
||||
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
|
||||
let e = value.clone().unwrap_or(default_expr);
|
||||
@@ -1052,10 +1099,29 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sk.add_constraint(k, Some(pname.clone()))?;
|
||||
Ok((pname, name, cur))
|
||||
})?;
|
||||
if driven {
|
||||
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info}));
|
||||
}
|
||||
if reject_redundant(before, &info, cmd).is_err() {
|
||||
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
|
||||
}
|
||||
let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
|
||||
}
|
||||
|
||||
/// Dimension a point (or a parallel line) to line `l` as a diameter: when `l` is a centerline
|
||||
/// (unless a plain distance was asked for) or when a diameter was asked for.
|
||||
fn diameter_about(sk: &Sketch, l: usize, ty: &str) -> bool {
|
||||
ty == "diameter" || (ty == "auto" && sk.curves.get(l).is_some_and(|c| c.centerline))
|
||||
}
|
||||
|
||||
fn arc_len(sk: &Sketch, c: usize) -> Option<f64> {
|
||||
match sk.segs(c).first()? {
|
||||
solvecraft_geom::Seg2::Arc { radius, sweep, .. } if matches!(sk.curves.get(c)?.kind, CurveKind::Arc { .. }) => Some(radius * sweep),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle dimension between two lines whose sector contains `t` (the text position): returns
|
||||
/// the constraint and the current value of that sector angle.
|
||||
fn angle_by_sector(a: usize, b: usize, (a0, a1): (Vec2, Vec2), (b0, b1): (Vec2, Vec2), t: Vec2) -> Option<(ConstraintKind, f64)> {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! More sketch constraints (curvature, polygon), constraint glyph anchors and snapping
|
||||
//! inference for interactive tools.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec2;
|
||||
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::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("ConstraintSmooth", "Curvature", c_smooth)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_smooth")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: two curves sharing an end point (curvature continuous, G2)"),
|
||||
CommandSpec::new("SketchPolygonConstraintCmd", "Polygon", c_polygon)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.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("sketch.glyphs", "Constraint Glyphs", glyphs)
|
||||
.enabled(in_sketch)
|
||||
.noundo()
|
||||
.params("sketch?: id|name: every constraint with where to draw its glyph (sketch coordinates)"),
|
||||
CommandSpec::new("sketch.snap", "Snap Inference", snap)
|
||||
.enabled(in_sketch)
|
||||
.noundo()
|
||||
.params("at: [x,y], from?: [x,y] (previous click), radius?: mm (snap distance, default 1): what a click there would snap to and infer"),
|
||||
];
|
||||
|
||||
fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ConstraintSmooth";
|
||||
let mut before = None;
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let a = curve(sk, p.get("a"), cmd)?;
|
||||
let b = curve(sk, p.get("b"), cmd)?;
|
||||
before = Some(dof_now(sk));
|
||||
add_c(sk, ConstraintKind::Smooth { a, b })
|
||||
})?;
|
||||
reject_redundant(before, &info, cmd)?;
|
||||
Ok(json!({"constraint": id, "sketch": info}))
|
||||
}
|
||||
|
||||
fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, "expected a curve id"))?;
|
||||
sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))
|
||||
}
|
||||
|
||||
fn c_polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "SketchPolygonConstraintCmd";
|
||||
let ids = string_list(p, "lines");
|
||||
if ids.len() < 3 || ids.len() > 200 {
|
||||
return Err(bad(cmd, "`lines` must list 3 to 200 lines of a closed chain"));
|
||||
}
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let ls: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
|
||||
let mut corners: Vec<usize> = Vec::new();
|
||||
for l in &ls {
|
||||
match sk.curves.get(*l).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) => {
|
||||
for q in [*a, *b] {
|
||||
if !corners.contains(&q) {
|
||||
corners.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(cmd, "the polygon must be made of lines")),
|
||||
}
|
||||
}
|
||||
if corners.len() != ls.len() {
|
||||
return Err(bad(cmd, "the lines must form one closed chain"));
|
||||
}
|
||||
let pts: Vec<Vec2> = corners.iter().filter_map(|q| sk.point(*q)).collect();
|
||||
let c = pts.iter().fold(Vec2::ZERO, |a, q| a + *q) / pts.len() as f64;
|
||||
let r = pts.iter().map(|q| q.dist(c)).sum::<f64>() / pts.len() as f64;
|
||||
let circ = sk.add_circle(c, r.max(1e-6), None, None)?;
|
||||
if let Some(cu) = sk.curves.get_mut(circ) {
|
||||
cu.construction = true;
|
||||
}
|
||||
let mut cons = Vec::new();
|
||||
for w in ls.windows(2) {
|
||||
cons.push(add_c(sk, ConstraintKind::Equal { a: w[0], b: w[1] })?);
|
||||
}
|
||||
for q in corners {
|
||||
cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: q, c: circ })?);
|
||||
}
|
||||
Ok(cons)
|
||||
})?;
|
||||
Ok(json!({"constraints": out, "sketch": info}))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
use ConstraintKind::*;
|
||||
let mid = |c: usize| -> Option<Vec2> {
|
||||
let poly = sk.polyline(c);
|
||||
poly.get(poly.len() / 2).copied()
|
||||
};
|
||||
let pt = |p: usize| sk.point(p);
|
||||
match *k {
|
||||
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } => pt(p),
|
||||
PointOnCurve { p, .. } => pt(p),
|
||||
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
|
||||
Horizontal { l } | Vertical { l } => mid(l),
|
||||
Parallel { a, .. } | Perpendicular { a, .. } | Collinear { a, .. } | Equal { a, .. } | Concentric { a, .. } | Angle { a, .. } => mid(a),
|
||||
Tangent { a, b } | Smooth { a, b } => {
|
||||
// At the shared end when there is one.
|
||||
let ends = |c: usize| sk.curves.get(c).and_then(|c| c.kind.ends());
|
||||
match (ends(a), ends(b)) {
|
||||
(Some((a0, a1)), Some((b0, b1))) => [a0, a1].into_iter().find(|x| *x == b0 || *x == b1).and_then(pt).or_else(|| mid(a)),
|
||||
_ => mid(a),
|
||||
}
|
||||
}
|
||||
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
|
||||
PointLineDistance { p, .. } | LinearDiameter { p, .. } => pt(p),
|
||||
Length { l, .. } => mid(l),
|
||||
Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } => mid(c),
|
||||
}
|
||||
}
|
||||
|
||||
fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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("sketch.glyphs", "no such sketch"))?;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
|
||||
let failing = &ss.report.failing;
|
||||
let list: Vec<Value> = ss
|
||||
.sketch
|
||||
.constraints
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id,
|
||||
"name": c.kind.name(),
|
||||
"dimension": c.kind.is_dimension(),
|
||||
"driven": c.driven,
|
||||
"value": c.kind.value(),
|
||||
"param": c.param,
|
||||
"at": anchor(&ss.sketch, &c.kind),
|
||||
"failing": failing.contains(&c.id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"sketch": id, "glyphs": list, "dof": ss.report.dof, "fully_constrained": ss.report.fully_constrained(), "conflicts": failing}))
|
||||
}
|
||||
|
||||
/// Snap inference for a click at `at`: the nearest sketch point (coincident), the midpoint of a
|
||||
/// line, a point on a curve, and horizontal/vertical alignment with `from` or with other
|
||||
/// points. Results are ordered by priority; the first one is what a click would use.
|
||||
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.snap";
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
let from = p.get("from").and_then(vec2);
|
||||
let r = num(p, "radius").filter(|r| *r > 0.0).unwrap_or(1.0);
|
||||
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?;
|
||||
let sk = &ss.sketch;
|
||||
let mut out: Vec<(f64, Value)> = Vec::new();
|
||||
// Points (end points, centres, the origin).
|
||||
for q in &sk.points {
|
||||
let d = q.pos.dist(at);
|
||||
if d <= r {
|
||||
out.push((d * 0.5, json!({"type": "coincident", "point": q.id, "at": q.pos})));
|
||||
}
|
||||
}
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if let CurveKind::Line { a, b } = c.kind
|
||||
&& let (Some(pa), Some(pb)) = (sk.point(a), sk.point(b))
|
||||
{
|
||||
let m = (pa + pb) * 0.5;
|
||||
let d = m.dist(at);
|
||||
if d <= r {
|
||||
out.push((d * 0.6, json!({"type": "midpoint", "curve": c.id, "at": m})));
|
||||
}
|
||||
}
|
||||
if let Some(sh) = sk.shape(i) {
|
||||
let q = sh.project(at);
|
||||
if sh.dist(at) <= r && q.dist(at) <= r {
|
||||
out.push((sh.dist(at) + r * 0.5, json!({"type": "on_curve", "curve": c.id, "at": q})));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Alignment with the previous click.
|
||||
if let Some(f) = from {
|
||||
let d = at - f;
|
||||
if d.y.abs() <= r && d.x.abs() > r {
|
||||
out.push((d.y.abs() + r, json!({"type": "horizontal", "at": [at.x, f.y]})));
|
||||
}
|
||||
if d.x.abs() <= r && d.y.abs() > r {
|
||||
out.push((d.x.abs() + r, json!({"type": "vertical", "at": [f.x, at.y]})));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let list: Vec<Value> = out.into_iter().take(8).map(|x| x.1).collect();
|
||||
Ok(json!({"snaps": list}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_constraints_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,113 @@
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec2;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn new_sketch() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
|
||||
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
|
||||
let mut s = new_sketch();
|
||||
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
|
||||
// Already horizontal (inferred).
|
||||
let e = s.execute("ConstraintHorizontalVertical", &json!({"line": l})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain"), "{e}");
|
||||
run(&mut s, "ConstraintCoincident", json!({"a": format!("{l}.start"), "b": "origin"}));
|
||||
run(&mut s, "SketchDimension", json!({"entities": [l], "value": 10}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], 0, "{si}");
|
||||
// A second length on it over-constrains: refused, unless driven.
|
||||
assert!(s.execute("SketchDimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
|
||||
let d = run(&mut s, "SketchDimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
|
||||
assert_eq!(d["driven"], true);
|
||||
// The driven dimension follows the driving one.
|
||||
let pname = si["constraints"].as_array().unwrap().iter().find_map(|c| c["param"].as_str().map(str::to_string)).unwrap();
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "25 mm"}));
|
||||
let g = run(&mut s, "sketch.glyphs", json!({}));
|
||||
let driven = g["glyphs"].as_array().unwrap().iter().find(|x| x["driven"] == true).unwrap().clone();
|
||||
assert!((driven["value"].as_f64().unwrap() - 25.0).abs() < 1e-7, "{driven}");
|
||||
assert_eq!(g["fully_constrained"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_polygon_arc_length_linear_diameter() {
|
||||
let mut s = new_sketch();
|
||||
// A horizontal line ending where a control-point spline starts: G2 keeps the spline's
|
||||
// start straight (zero curvature) and tangent.
|
||||
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
|
||||
let sp = ids(&run(&mut s, "DrawCVMSpline3D", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
|
||||
run(&mut s, "ConstraintSmooth", json!({"a": l, "b": sp}));
|
||||
let sk = sketch(&s);
|
||||
let pts: Vec<Vec2> = sk.curves[sk.curve_index(&sp).unwrap()].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
|
||||
// Tangent: first leg along +x; zero curvature: the second leg too (collinear first three).
|
||||
assert!((pts[1] - pts[0]).y.abs() < 1e-5, "{pts:?}");
|
||||
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3, "{pts:?}");
|
||||
|
||||
// Polygon constraint on a rough quadrilateral: becomes a square-ish regular polygon.
|
||||
let q = run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}));
|
||||
let ql = ids(&q["curves"]);
|
||||
run(&mut s, "SketchPolygonConstraintCmd", json!({"lines": ql}));
|
||||
let sk = sketch(&s);
|
||||
let lens: Vec<f64> = ql.iter().map(|i| sk.polyline(sk.curve_index(i).unwrap())).map(|p| p[0].dist(p[p.len() - 1])).collect();
|
||||
assert!(lens.iter().all(|x| (x - lens[0]).abs() < 1e-6), "{lens:?}");
|
||||
|
||||
// Arc length.
|
||||
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone();
|
||||
run(&mut s, "SketchDimension", json!({"entities": [a], "type": "arc_length", "value": 20}));
|
||||
let sk = sketch(&s);
|
||||
let ai = sk.curve_index(&a).unwrap();
|
||||
let len = match sk.segs(ai)[0] {
|
||||
solvecraft_geom::Seg2::Arc { radius, sweep, .. } => radius * sweep,
|
||||
_ => 0.0,
|
||||
};
|
||||
assert!((len - 20.0).abs() < 1e-6, "{len}");
|
||||
|
||||
// Linear diameter about a centerline.
|
||||
let cl = ids(&run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.centerline", json!({"curves": [cl]}));
|
||||
run(&mut s, "DrawPoint", json!({"point": [207, 10], "id": "rim"}));
|
||||
let d = run(&mut s, "SketchDimension", json!({"entities": ["rim", cl], "value": 30}));
|
||||
assert_eq!(d["type"], "LinearDiameterDimension", "{d}");
|
||||
let sk = sketch(&s);
|
||||
let rim = sk.point(sk.point_index("rim").unwrap()).unwrap();
|
||||
let line = sk.polyline(sk.curve_index(&cl).unwrap());
|
||||
let (a, b) = (line[0], line[line.len() - 1]);
|
||||
let dist = (b - a).normalized().unwrap().cross(rim - a).abs();
|
||||
assert!((dist - 15.0).abs() < 1e-6, "{rim:?} {dist}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyphs_and_snaps() {
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
|
||||
let l = ids(&r["curves"]);
|
||||
let g = run(&mut s, "sketch.glyphs", json!({}));
|
||||
assert_eq!(g["glyphs"].as_array().unwrap().len(), 4);
|
||||
assert!(g["glyphs"][0]["at"].is_array());
|
||||
let sn = run(&mut s, "sketch.snap", json!({"at": [20.3, 0.2], "radius": 1}));
|
||||
assert_eq!(sn["snaps"][0]["type"], "coincident", "{sn}");
|
||||
let sn = run(&mut s, "sketch.snap", json!({"at": [10.2, 0.3], "radius": 1}));
|
||||
assert_eq!(sn["snaps"][0]["type"], "midpoint", "{sn}");
|
||||
assert_eq!(sn["snaps"][0]["curve"], json!(l[0]));
|
||||
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}");
|
||||
}
|
||||
@@ -282,6 +282,9 @@ fn hostile_params_never_panic() {
|
||||
"apex",
|
||||
"rho",
|
||||
"text",
|
||||
"lines",
|
||||
"driven",
|
||||
"from",
|
||||
];
|
||||
let mut internal = Vec::new();
|
||||
for spec in command_specs() {
|
||||
|
||||
+81
-32
@@ -94,38 +94,73 @@ fn hermite(p0: Vec2, p1: Vec2, t0: Vec2, t1: Vec2, s: f64) -> Vec2 {
|
||||
p0 * (2.0 * s3 - 3.0 * s2 + 1.0) + t0 * (s3 - 2.0 * s2 + s) + p1 * (-2.0 * s3 + 3.0 * s2) + t1 * (s3 - s2)
|
||||
}
|
||||
|
||||
/// Clamped uniform B-spline of `degree` over control points `p`, sampled.
|
||||
fn bspline_polyline(p: &[Vec2], degree: usize) -> Vec<Vec2> {
|
||||
/// Clamped uniform B-spline of `degree` over control points `p` at `t` in [0, 1] (de Boor).
|
||||
fn bspline_at(p: &[Vec2], degree: usize, t: f64) -> Vec2 {
|
||||
let n = p.len();
|
||||
let k = degree.min(n.saturating_sub(1)).max(1);
|
||||
// Knots: k+1 zeros, uniform interior, k+1 ones.
|
||||
let spans = n - k;
|
||||
let mut knots = vec![0.0; k + 1];
|
||||
for i in 1..spans {
|
||||
knots.push(i as f64 / spans as f64);
|
||||
}
|
||||
knots.extend(std::iter::repeat_n(1.0, k + 1));
|
||||
let total = SPAN_SAMPLES * spans;
|
||||
(0..=total)
|
||||
.map(|i| {
|
||||
let t = i as f64 / total as f64;
|
||||
// De Boor.
|
||||
let mut s = k;
|
||||
while s + 1 < n && knots.get(s + 1).is_some_and(|x| *x <= t) {
|
||||
s += 1;
|
||||
}
|
||||
let mut d: Vec<Vec2> = (0..=k).map(|j| p.get(j + s - k).copied().unwrap_or_default()).collect();
|
||||
for r in 1..=k {
|
||||
for j in (r..=k).rev() {
|
||||
let i0 = j + s - k;
|
||||
let (lo, hi) = (knots.get(i0).copied().unwrap_or(0.0), knots.get(i0 + k + 1 - r).copied().unwrap_or(1.0));
|
||||
let a = if hi - lo > 1e-15 { (t - lo) / (hi - lo) } else { 0.0 };
|
||||
d[j] = d[j - 1] * (1.0 - a) + d[j] * a;
|
||||
}
|
||||
}
|
||||
d[k]
|
||||
})
|
||||
.collect()
|
||||
let mut s = k;
|
||||
while s + 1 < n && knots.get(s + 1).is_some_and(|x| *x <= t) {
|
||||
s += 1;
|
||||
}
|
||||
let mut d: Vec<Vec2> = (0..=k).map(|j| p.get(j + s - k).copied().unwrap_or_default()).collect();
|
||||
for r in 1..=k {
|
||||
for j in (r..=k).rev() {
|
||||
let i0 = j + s - k;
|
||||
let (lo, hi) = (knots.get(i0).copied().unwrap_or(0.0), knots.get(i0 + k + 1 - r).copied().unwrap_or(1.0));
|
||||
let a = if hi - lo > 1e-15 { (t - lo) / (hi - lo) } else { 0.0 };
|
||||
d[j] = d[j - 1] * (1.0 - a) + d[j] * a;
|
||||
}
|
||||
}
|
||||
d[k]
|
||||
}
|
||||
|
||||
/// Clamped uniform B-spline of `degree` over control points `p`, sampled.
|
||||
fn bspline_polyline(p: &[Vec2], degree: usize) -> Vec<Vec2> {
|
||||
let n = p.len();
|
||||
let k = degree.min(n.saturating_sub(1)).max(1);
|
||||
let total = SPAN_SAMPLES * (n - k);
|
||||
(0..=total).map(|i| bspline_at(p, degree, i as f64 / total as f64)).collect()
|
||||
}
|
||||
|
||||
/// Point of a spline at global parameter `t` in [0, 1].
|
||||
pub fn spline_point(p: &[Vec2], control: bool, degree: u8, t: f64) -> Vec2 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if p.len() < 2 {
|
||||
return p.first().copied().unwrap_or_default();
|
||||
}
|
||||
if control {
|
||||
return bspline_at(p, degree.clamp(1, 7) as usize, t);
|
||||
}
|
||||
let spans = fit_spans(p);
|
||||
let n = spans.len().max(1);
|
||||
let x = t * n as f64;
|
||||
let i = (x.floor() as usize).min(n - 1);
|
||||
match spans.get(i) {
|
||||
Some((p0, p1, t0, t1)) => hermite(*p0, *p1, *t0, *t1, x - i as f64),
|
||||
None => p[0],
|
||||
}
|
||||
}
|
||||
|
||||
/// Curvature vector (towards the centre of curvature, magnitude 1/radius) of a parametric
|
||||
/// curve at an end (`at_end`), by one-sided second-order differences.
|
||||
pub fn end_curvature(f: &dyn Fn(f64) -> Vec2, at_end: bool) -> Vec2 {
|
||||
let h = 1e-3;
|
||||
let (t0, sgn) = if at_end { (1.0, -1.0) } else { (0.0, 1.0) };
|
||||
let (f0, f1, f2, f3) = (f(t0), f(t0 + sgn * h), f(t0 + sgn * 2.0 * h), f(t0 + sgn * 3.0 * h));
|
||||
let d1 = (f0 * -3.0 + f1 * 4.0 - f2) / (2.0 * h) * sgn;
|
||||
let d2 = (f0 * 2.0 - f1 * 5.0 + f2 * 4.0 - f3) / (h * h);
|
||||
let l2 = d1.len2();
|
||||
if l2 < 1e-24 {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let t = d1 / l2.sqrt();
|
||||
(d2 - t * t.dot(d2)) / l2
|
||||
}
|
||||
|
||||
/// Polyline of a spline through (`control == false`) or over (`control`) the points.
|
||||
@@ -161,20 +196,20 @@ pub fn spline_end_tangent(p: &[Vec2], control: bool, at_end: bool) -> Option<Vec
|
||||
if at_end { spans.last().and_then(|s| s.3.normalized()) } else { spans.first().and_then(|s| s.2.normalized()) }
|
||||
}
|
||||
|
||||
/// Distance from `q` to a polyline.
|
||||
pub fn polyline_dist(poly: &[Vec2], q: Vec2) -> f64 {
|
||||
let mut best = f64::INFINITY;
|
||||
/// Signed distance from `q` to a polyline (positive on the left of the nearest segment).
|
||||
pub fn polyline_signed_dist(poly: &[Vec2], q: Vec2) -> f64 {
|
||||
let mut best = (f64::INFINITY, 1.0);
|
||||
for w in poly.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let d = b - a;
|
||||
let l2 = d.len2();
|
||||
let t = if l2 > 0.0 { ((q - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
best = best.min(q.dist(a + d * t));
|
||||
let dist = q.dist(a + d * t);
|
||||
if dist < best.0 {
|
||||
best = (dist, if d.cross(q - a) >= 0.0 { 1.0 } else { -1.0 });
|
||||
}
|
||||
}
|
||||
if poly.len() == 1 {
|
||||
best = poly[0].dist(q);
|
||||
}
|
||||
best
|
||||
best.0 * best.1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -195,6 +230,20 @@ mod tests {
|
||||
assert!((t.x - t.y).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_curvature_of_a_circle_quarter() {
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let rho = w / (1.0 + w);
|
||||
let (a, x, b) = (Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0));
|
||||
let k = end_curvature(&|t| conic_point(a, x, b, rho, t), false);
|
||||
assert!(k.dist(Vec2::new(-0.1, 0.0)) < 1e-5, "{k:?}");
|
||||
let k1 = end_curvature(&|t| conic_point(a, x, b, rho, t), true);
|
||||
assert!(k1.dist(Vec2::new(0.0, -0.1)) < 1e-5, "{k1:?}");
|
||||
// Fit splines have natural (straight) ends.
|
||||
let p = [Vec2::new(0.0, 0.0), Vec2::new(10.0, 5.0), Vec2::new(20.0, 0.0)];
|
||||
assert!(end_curvature(&|t| spline_point(&p, false, 3, t), false).len() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_spline_is_clamped_and_conic_hits_its_ends() {
|
||||
let p = [Vec2::new(0.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(10.0, 10.0), Vec2::new(10.0, 0.0)];
|
||||
|
||||
@@ -17,7 +17,7 @@ mod shape;
|
||||
mod solver;
|
||||
mod text;
|
||||
|
||||
pub use curves::{MAX_SPLINE_POINTS, conic_point, ellipse_point, spline_end_tangent, spline_polyline};
|
||||
pub use curves::{MAX_SPLINE_POINTS, conic_point, ellipse_point, end_curvature, spline_end_tangent, spline_point, spline_polyline};
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
|
||||
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError};
|
||||
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
|
||||
|
||||
@@ -248,6 +248,23 @@ pub enum ConstraintKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
flip: bool,
|
||||
},
|
||||
/// Curvature continuity (G2) where curves `a` and `b` share an end point: tangent and
|
||||
/// equal curvature.
|
||||
Smooth {
|
||||
a: usize,
|
||||
b: usize,
|
||||
},
|
||||
/// Length along an arc.
|
||||
ArcLength {
|
||||
c: usize,
|
||||
value: f64,
|
||||
},
|
||||
/// Twice the distance from point `p` to line `l` (a diameter about a centerline).
|
||||
LinearDiameter {
|
||||
p: usize,
|
||||
l: usize,
|
||||
value: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl ConstraintKind {
|
||||
@@ -264,6 +281,8 @@ impl ConstraintKind {
|
||||
| Length { value, .. }
|
||||
| Radius { value, .. }
|
||||
| Diameter { value, .. }
|
||||
| ArcLength { value, .. }
|
||||
| LinearDiameter { value, .. }
|
||||
| Angle { value, .. } => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
@@ -278,6 +297,8 @@ impl ConstraintKind {
|
||||
| Length { value, .. }
|
||||
| Radius { value, .. }
|
||||
| Diameter { value, .. }
|
||||
| ArcLength { value, .. }
|
||||
| LinearDiameter { value, .. }
|
||||
| Angle { value, .. } => *value = v,
|
||||
_ => {}
|
||||
}
|
||||
@@ -305,6 +326,9 @@ impl ConstraintKind {
|
||||
Radius { .. } => "RadialDimension",
|
||||
Diameter { .. } => "DiameterDimension",
|
||||
Angle { .. } => "AngularDimension",
|
||||
Smooth { .. } => "Smooth",
|
||||
ArcLength { .. } => "ArcLengthDimension",
|
||||
LinearDiameter { .. } => "LinearDiameterDimension",
|
||||
}
|
||||
}
|
||||
fn indices(&self) -> (Vec<usize>, Vec<usize>) {
|
||||
@@ -322,6 +346,9 @@ impl ConstraintKind {
|
||||
Midpoint { p, l } | PointLineDistance { p, l, .. } => (vec![p], vec![l]),
|
||||
Symmetric { p, q, l } => (vec![p, q], vec![l]),
|
||||
Fix { p } => (vec![p], vec![]),
|
||||
Smooth { a, b } => (vec![], vec![a, b]),
|
||||
ArcLength { c, .. } => (vec![], vec![c]),
|
||||
LinearDiameter { p, l, .. } => (vec![p], vec![l]),
|
||||
}
|
||||
}
|
||||
fn remap(&mut self, pmap: &dyn Fn(usize) -> Option<usize>, cmap: &dyn Fn(usize) -> Option<usize>) -> bool {
|
||||
@@ -369,6 +396,15 @@ impl ConstraintKind {
|
||||
fc(l);
|
||||
}
|
||||
Fix { p } => fp(p),
|
||||
Smooth { a, b } => {
|
||||
fc(a);
|
||||
fc(b);
|
||||
}
|
||||
ArcLength { c, .. } => fc(c),
|
||||
LinearDiameter { p, l, .. } => {
|
||||
fp(p);
|
||||
fc(l);
|
||||
}
|
||||
}
|
||||
ok && ok2
|
||||
}
|
||||
@@ -382,6 +418,9 @@ pub struct Constraint {
|
||||
/// For dimensions: the model parameter that drives the value (e.g. `d1`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub param: Option<String>,
|
||||
/// A driven (reference) dimension: it measures, it does not constrain.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub driven: bool,
|
||||
}
|
||||
|
||||
/// A 2D sketch. Point 0 is always the fixed sketch origin (id `origin`).
|
||||
@@ -553,7 +592,7 @@ impl Sketch {
|
||||
}
|
||||
self.validate(&kind)?;
|
||||
let id = self.fresh("k");
|
||||
self.constraints.push(Constraint { id: id.clone(), kind, param });
|
||||
self.constraints.push(Constraint { id: id.clone(), kind, param, driven: false });
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -577,8 +616,27 @@ impl Sketch {
|
||||
let need_line = |i: usize| if is_line(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a line".into())) };
|
||||
let need_round = |i: usize| if is_round(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a circle or arc".into())) };
|
||||
match *k {
|
||||
Horizontal { l } | Vertical { l } | Length { l, .. } | Midpoint { l, .. } | Symmetric { l, .. } | PointLineDistance { l, .. } => {
|
||||
need_line(l)?
|
||||
Horizontal { l }
|
||||
| Vertical { l }
|
||||
| Length { l, .. }
|
||||
| Midpoint { l, .. }
|
||||
| Symmetric { l, .. }
|
||||
| PointLineDistance { l, .. }
|
||||
| LinearDiameter { l, .. } => need_line(l)?,
|
||||
ArcLength { c, .. } => {
|
||||
if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) {
|
||||
return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into()));
|
||||
}
|
||||
}
|
||||
Smooth { a, b } => {
|
||||
let ends = |i: usize| self.curves.get(i).and_then(|c| c.kind.ends());
|
||||
let shared = match (ends(a), ends(b)) {
|
||||
(Some((a0, a1)), Some((b0, b1))) => a0 == b0 || a0 == b1 || a1 == b0 || a1 == b1,
|
||||
_ => false,
|
||||
};
|
||||
if !shared || a == b {
|
||||
return Err(SketchError::Invalid("curvature continuity needs two curves sharing an end point".into()));
|
||||
}
|
||||
}
|
||||
Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Angle { a, b, .. } => {
|
||||
need_line(a)?;
|
||||
|
||||
+123
-5
@@ -157,6 +157,43 @@ impl State<'_> {
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn ends(&self, c: usize) -> Option<(usize, usize)> {
|
||||
self.sk.curves.get(c)?.kind.ends()
|
||||
}
|
||||
/// Length along an arc (counter-clockwise from its start).
|
||||
fn arc_length(&self, c: usize) -> f64 {
|
||||
match self.sk.curves.get(c).map(|c| &c.kind) {
|
||||
Some(CurveKind::Arc { c: ci, a, b }) => {
|
||||
let (cc, pa, pb) = (self.p(*ci), self.p(*a), self.p(*b));
|
||||
let mut sw = (pb - cc).angle() - (pa - cc).angle();
|
||||
while sw <= 1e-12 {
|
||||
sw += std::f64::consts::TAU;
|
||||
}
|
||||
cc.dist(pa) * sw
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
/// Curvature vector of a curve at its point `p` (towards the centre of curvature).
|
||||
fn curvature_at(&self, c: usize, p: usize) -> Vec2 {
|
||||
let Some(kind) = self.sk.curves.get(c).map(|c| &c.kind) else { return Vec2::ZERO };
|
||||
match kind {
|
||||
CurveKind::Line { .. } | CurveKind::Ellipse { .. } => Vec2::ZERO,
|
||||
CurveKind::Circle { .. } | CurveKind::Arc { .. } => {
|
||||
let (cc, r) = self.round(c);
|
||||
(cc - self.p(p)) / (r * r).max(1e-24)
|
||||
}
|
||||
CurveKind::Spline { pts, control, degree } => {
|
||||
let q: Vec<Vec2> = pts.iter().map(|i| self.p(*i)).collect();
|
||||
let at_end = pts.last() == Some(&p) && pts.first() != Some(&p);
|
||||
crate::curves::end_curvature(&|t| crate::curves::spline_point(&q, *control, *degree, t), at_end)
|
||||
}
|
||||
CurveKind::Conic { a, b, apex, rho } => {
|
||||
let (pa, pb, px) = (self.p(*a), self.p(*b), self.p(*apex));
|
||||
crate::curves::end_curvature(&|t| crate::curves::conic_point(pa, px, pb, *rho, t), p == *b)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Unit tangent of any curve at its point `p` (an end point for open free-form curves).
|
||||
fn tangent_at(&self, c: usize, p: usize) -> Option<Vec2> {
|
||||
match self.sk.curves.get(c).map(|c| &c.kind)? {
|
||||
@@ -214,7 +251,7 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
|
||||
PointOnCurve { p, c } => {
|
||||
let pp = s.p(p);
|
||||
if s.is_freeform(c) {
|
||||
out.push(crate::curves::polyline_dist(&s.poly(c), pp));
|
||||
out.push(crate::curves::polyline_signed_dist(&s.poly(c), pp));
|
||||
} else if s.is_line(c) {
|
||||
let (a, b) = s.line(c);
|
||||
out.push(line_dist(pp, a, b));
|
||||
@@ -253,15 +290,17 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
|
||||
// Without a shared end point: the curves touch (closest approach zero).
|
||||
let (f, o) = if s.is_freeform(a) { (a, b) } else { (b, a) };
|
||||
let poly = s.poly(f);
|
||||
// Signed gap at the closest approach (smooth through zero, unlike its size).
|
||||
let closest = |v: &mut dyn Iterator<Item = f64>| v.fold(f64::INFINITY, |m: f64, x| if x.abs() < m.abs() { x } else { m });
|
||||
let d = if s.is_line(o) {
|
||||
let (p0, p1) = s.line(o);
|
||||
poly.iter().map(|q| line_dist(*q, p0, p1).abs()).fold(f64::INFINITY, f64::min)
|
||||
closest(&mut poly.iter().map(|q| line_dist(*q, p0, p1)))
|
||||
} else if s.is_freeform(o) {
|
||||
let other = s.poly(o);
|
||||
poly.iter().map(|q| crate::curves::polyline_dist(&other, *q)).fold(f64::INFINITY, f64::min)
|
||||
closest(&mut poly.iter().map(|q| crate::curves::polyline_signed_dist(&other, *q)))
|
||||
} else {
|
||||
let (cc, r) = s.round(o);
|
||||
poly.iter().map(|q| (q.dist(cc) - r).abs()).fold(f64::INFINITY, f64::min)
|
||||
closest(&mut poly.iter().map(|q| q.dist(cc) - r))
|
||||
};
|
||||
out.push(if d.is_finite() { d } else { 0.0 });
|
||||
} else if la || lb {
|
||||
@@ -327,9 +366,63 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
|
||||
let target = if flip { value + std::f64::consts::PI } else { value };
|
||||
out.push(wrap_angle(ang - target) * scale.max(1e-6));
|
||||
}
|
||||
Smooth { a, b } => {
|
||||
let shared = match (s.ends(a), s.ends(b)) {
|
||||
(Some((a0, a1)), Some((b0, b1))) => [a0, a1].into_iter().find(|x| *x == b0 || *x == b1),
|
||||
_ => None,
|
||||
};
|
||||
let Some(p) = shared else {
|
||||
out.extend([0.0, 0.0, 0.0]);
|
||||
return;
|
||||
};
|
||||
match (s.tangent_at(a, p), s.tangent_at(b, p)) {
|
||||
(Some(u), Some(v)) => out.push(u.cross(v) * 10.0),
|
||||
_ => out.push(0.0),
|
||||
}
|
||||
let k = s.curvature_at(a, p) - s.curvature_at(b, p);
|
||||
out.extend([k.x * 100.0, k.y * 100.0]);
|
||||
}
|
||||
ArcLength { c, value } => out.push(s.arc_length(c) - value),
|
||||
LinearDiameter { p, l, value } => {
|
||||
let (a, b) = s.line(l);
|
||||
out.push(2.0 * line_dist(s.p(p), a, b).abs() - value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measured value of a dimension (for driven dimensions).
|
||||
fn measure(s: &State, k: &ConstraintKind) -> Option<f64> {
|
||||
use ConstraintKind::*;
|
||||
Some(match *k {
|
||||
Distance { p, q, .. } => s.p(p).dist(s.p(q)),
|
||||
DistanceX { p, q, .. } => (s.p(q).x - s.p(p).x).abs(),
|
||||
DistanceY { p, q, .. } => (s.p(q).y - s.p(p).y).abs(),
|
||||
PointLineDistance { p, l, .. } => {
|
||||
let (a, b) = s.line(l);
|
||||
line_dist(s.p(p), a, b).abs()
|
||||
}
|
||||
Length { l, .. } => {
|
||||
let (a, b) = s.line(l);
|
||||
a.dist(b)
|
||||
}
|
||||
Radius { c, .. } => s.round(c).1,
|
||||
Diameter { c, .. } => 2.0 * s.round(c).1,
|
||||
Angle { a, b, flip, .. } => {
|
||||
let (a0, a1) = s.line(a);
|
||||
let (b0, b1) = s.line(b);
|
||||
let (da, db) = (a1 - a0, b1 - b0);
|
||||
let ang = da.cross(db).atan2(da.dot(db)) - if flip { std::f64::consts::PI } else { 0.0 };
|
||||
ang.rem_euclid(std::f64::consts::TAU)
|
||||
}
|
||||
ArcLength { c, .. } => s.arc_length(c),
|
||||
LinearDiameter { p, l, .. } => {
|
||||
let (a, b) = s.line(l);
|
||||
2.0 * line_dist(s.p(p), a, b).abs()
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known
|
||||
/// touch point (better conditioned than the distance form when an end point is on the curve).
|
||||
enum Block<'a> {
|
||||
@@ -445,6 +538,15 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec<usize> {
|
||||
curve(&mut v, l);
|
||||
}
|
||||
Fix { .. } => {}
|
||||
Smooth { a, b } => {
|
||||
curve(&mut v, a);
|
||||
curve(&mut v, b);
|
||||
}
|
||||
ArcLength { c, .. } => curve(&mut v, c),
|
||||
LinearDiameter { p, l, .. } => {
|
||||
pt(&mut v, p);
|
||||
curve(&mut v, l);
|
||||
}
|
||||
},
|
||||
Block::TangentAt(_, k, p) => {
|
||||
pt(&mut v, *p);
|
||||
@@ -553,11 +655,13 @@ fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec<usize>)], vars: &[usize],
|
||||
}
|
||||
}
|
||||
let mut improved = false;
|
||||
// Damping floor: variables the residuals barely see must not take huge steps.
|
||||
let floor = (0..n).map(|i| a.get(i, i)).fold(0.0_f64, f64::max) * 1e-4;
|
||||
for _ in 0..30 {
|
||||
let mut m = a.clone();
|
||||
for i in 0..n {
|
||||
let d = a.get(i, i);
|
||||
m.add(i, i, lambda * (d + 1e-9) + 1e-12);
|
||||
m.add(i, i, lambda * (d.max(floor) + 1e-9) + 1e-12);
|
||||
}
|
||||
let rhs: Vec<f64> = g.iter().map(|v| -v).collect();
|
||||
let Some(step) = lin_solve(m, rhs) else {
|
||||
@@ -625,6 +729,9 @@ pub fn solve(sk: &mut Sketch) -> SolveReport {
|
||||
}
|
||||
}
|
||||
for (i, c) in sk.constraints.iter().enumerate() {
|
||||
if c.driven {
|
||||
continue;
|
||||
}
|
||||
let bl = match touch_point(sk, &c.kind) {
|
||||
Some(p) => Block::TangentAt(i, &c.kind, p),
|
||||
None => Block::User(i, &c.kind),
|
||||
@@ -725,6 +832,17 @@ pub fn solve(sk: &mut Sketch) -> SolveReport {
|
||||
Some(v) => vdet(Some(v)) && vdet(Some(v + 1)),
|
||||
None => true,
|
||||
};
|
||||
// Driven dimensions measure the solved geometry.
|
||||
let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0 };
|
||||
let measured: Vec<Option<f64>> =
|
||||
sk.constraints.iter().map(|c| if c.driven { measure(&State { sk, lay: &fixed_lay, x: &[] }, &c.kind) } else { None }).collect();
|
||||
for (c, m) in sk.constraints.iter_mut().zip(measured) {
|
||||
if let Some(v) = m
|
||||
&& v.is_finite()
|
||||
{
|
||||
c.kind.set_value(v);
|
||||
}
|
||||
}
|
||||
let point_determined: Vec<bool> = (0..sk.points.len()).map(pdet).collect();
|
||||
let curve_determined =
|
||||
sk.curves.iter().enumerate().map(|(i, c)| c.kind.point_ids().into_iter().all(pdet) && vdet(lay.rvar.get(i).copied().flatten())).collect();
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
|
||||
**SOLID + SKETCH: 91 / 291 commands live (31%).** All Design tabs: 184 / 1175 (16%). Registered commands: 119.
|
||||
**SOLID + SKETCH: 93 / 291 commands live (32%).** All Design tabs: 186 / 1175 (16%). Registered commands: 123.
|
||||
|
||||
| Tab | Panel | Live | Total | % |
|
||||
|---|---|---:|---:|---:|
|
||||
@@ -17,7 +17,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
| SOLID | POSITION | 0 | 2 | 0% |
|
||||
| SKETCH | CREATE | 35 | 41 | 85% |
|
||||
| SKETCH | MODIFY | 12 | 12 | 100% |
|
||||
| SKETCH | CONSTRAINTS | 11 | 15 | 73% |
|
||||
| SKETCH | CONSTRAINTS | 13 | 15 | 87% |
|
||||
| SKETCH | CONFIGURE | 0 | 3 | 0% |
|
||||
| SKETCH | INSPECT | 1 | 25 | 4% |
|
||||
| SKETCH | INSERT | 2 | 13 | 15% |
|
||||
@@ -189,6 +189,8 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
- SKETCH › CONSTRAINTS › Concentric (`ConstraintConcentric`)
|
||||
- SKETCH › CONSTRAINTS › Collinear (`ConstraintCollinear`)
|
||||
- SKETCH › CONSTRAINTS › Symmetry (`ConstraintSymmetry`)
|
||||
- SKETCH › CONSTRAINTS › Curvature (`ConstraintSmooth`)
|
||||
- SKETCH › CONSTRAINTS › Polygon (`SketchPolygonConstraintCmd`)
|
||||
- SKETCH › INSPECT › Measure (`MeasureCommand`)
|
||||
- SKETCH › INSERT › Insert Mesh (`ParaMeshInsertAlignCommand`)
|
||||
- SKETCH › INSERT › Insert Component (`FusionImportCommandFromToolbar`)
|
||||
@@ -339,8 +341,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
- SKETCH › CREATE › Isoparametric Curve (`SketchIsoparametricCurve`)
|
||||
- SKETCH › CONSTRAINTS › AutoConstrain (`SketchAutoConstraintAndDimCmd`)
|
||||
- SKETCH › CONSTRAINTS › AutoConstrain from datum (`SketchAutoConstraintAndDimFromDatumCmd`)
|
||||
- SKETCH › CONSTRAINTS › Curvature (`ConstraintSmooth`)
|
||||
- SKETCH › CONSTRAINTS › Polygon (`SketchPolygonConstraintCmd`)
|
||||
- SKETCH › CONFIGURE › Configure (`FusionStartDesignConfigModeCmd`)
|
||||
- SKETCH › CONFIGURE › Display Configuration Table (`FusionShowDesignConfigPanelCmd`)
|
||||
- SKETCH › CONFIGURE › Create Configuration Rules (`FusionConfigurationRulesCmd`)
|
||||
|
||||
Reference in new issue
Block a user