Sketch: equation-driven curve, a curve from x(t) and y(t) formulas (#8352) (#8353)

* claim: #8352

* Sketch: equation-driven curve, a fit point spline from x(t) and y(t) formulas (#8352)

sketch.equation_curve draws a curve from formulas in the expression
language and the design's parameters: x(t) and y(t) over a range of t, or
y = f(x) over a range of x. The curve is a cubic fit spline through fixed
points sampled from the formulas, as many as keep it within a tolerance
(0.01 mm by default, at most 500 points); a curve that ends where it starts
closes smoothly. The sketch keeps the formulas (one new design-format
file), places the points again before every solve so they follow the
parameters, and sketch.equation_curve.edit changes them, keeping the joined
end points. Both commands have input and output schemas.

* Sketch: Equation Curve window, its Edit menu item, labels and guide (#8352)

The ribbon's Equation Curve opens a window for the form (parametric or
explicit), the formulas, the range and the tolerance; OK runs
sketch.equation_curve. Right-clicking an equation curve offers Edit
Equation Curve, which reopens the window on its formulas and runs
sketch.equation_curve.edit. New labels and help in a pending locale file
(with ar, de, he, ja, ko, vi and zh), the icon, the glossary term and the
sketching guide.

* Equation curve: one if for closing the sampled points (clippy collapsible_if)
This commit is contained in:
Ahmad Khan
2026-10-29 17:42:59 +00:00
committed by GitHub
parent 1c0001af8e
commit a7048f41ab
28 changed files with 1546 additions and 10 deletions
+1 -1
View File
@@ -188,7 +188,7 @@ surface = [
sketch = [
"arc3", "arc_center", "arc_slot", "arc_slot_center", "arc_tangent",
"blend", "circle", "circle2", "circle3", "circle_tt",
"circle_ttt", "conic", "constrainer", "dimension", "ellipse",
"circle_ttt", "conic", "constrainer", "dimension", "ellipse", "equation_curve",
"extend", "finish", "fit_section", "include",
"intersect", "intersection_curve", "iso_curve", "line", "line_mid",
"offset", "offset_two_sides", "offset_symmetric", "point", "polygon", "polygon_circumscribed", "polygon_edge",
@@ -95,6 +95,7 @@ edit false
ellipse false
emboss false
environment_map false
equation_curve false
error false
explode false
export false
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Equation curve</title>
<path d="M12 8L12 56M6 32L58 32" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M12 32C15.42 24.83 18.72 18 22.5 18C26.28 18 29.58 24.83 33 32C36.42 39.17 39.72 46 43.5 46C47.28 46 50.58 39.17 54 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="12" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="12" cy="32" r="3.4" fill="#236ead"/>
<circle cx="54" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="54" cy="32" r="3.4" fill="#236ead"/>
</svg>

After

Width:  |  Height:  |  Size: 666 B

@@ -0,0 +1,114 @@
{
"en": {
"cmd.sketch_equation_curve": "Equation Curve",
"cmd.sketch_equation_curve_edit": "Edit Equation Curve",
"help.sketch.equation_curve": "Draws a curve from formulas: x(t) and y(t), or y = f(x), over a range. The formulas can use parameters and the curve follows them: use it for a sine wave, a spiral or a gear tooth's involute.",
"ui.heading_equation_curve": "EQUATION CURVE",
"ui.equation_form": "Form",
"ui.equation_parametric": "Parametric",
"ui.equation_explicit": "Explicit",
"ui.equation_end": "End",
"ui.equation_note": "The variable is a plain number: sin(t) takes it in radians. A result without a unit is in the design's unit.",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)"
},
"ja": {
"cmd.sketch_equation_curve": "方程式曲線",
"cmd.sketch_equation_curve_edit": "方程式曲線を編集",
"help.sketch.equation_curve": "x(t) と y(t)、または y = f(x) の式から、範囲を指定して曲線を描きます。式にはパラメーターを使え、曲線はその変更に追従します。正弦波、らせん、歯車のインボリュートなどに使います。",
"ui.heading_equation_curve": "方程式曲線",
"ui.equation_form": "形式",
"ui.equation_parametric": "パラメトリック",
"ui.equation_explicit": "陽関数",
"ui.equation_end": "終了",
"ui.equation_note": "変数は単位のない数値です。sin(t) はラジアンとして扱います。単位のない結果はデザインの単位になります。",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)"
},
"zh": {
"cmd.sketch_equation_curve": "方程曲线",
"cmd.sketch_equation_curve_edit": "编辑方程曲线",
"help.sketch.equation_curve": "在一个范围内按公式绘制曲线:x(t) 和 y(t),或 y = f(x)。公式可以使用参数,曲线随参数变化。适用于正弦波、螺旋线或齿轮的渐开线。",
"ui.heading_equation_curve": "方程曲线",
"ui.equation_form": "形式",
"ui.equation_parametric": "参数式",
"ui.equation_explicit": "显式",
"ui.equation_end": "结束",
"ui.equation_note": "变量是不带单位的数:sin(t) 按弧度计算。不带单位的结果采用设计的单位。",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)"
},
"ar": {
"cmd.sketch_equation_curve": "منحنى معادلة",
"cmd.sketch_equation_curve_edit": "تحرير منحنى المعادلة",
"help.sketch.equation_curve": "يرسم منحنى من صيغ على مدى: x(t) و y(t)، أو y = f(x). يمكن أن تستخدم الصيغ المعلمات فيتبعها المنحنى: استخدمه لموجة جيبية أو لولب أو منحنى انفلات لسن ترس.",
"ui.heading_equation_curve": "منحنى معادلة",
"ui.equation_form": "الشكل",
"ui.equation_parametric": "بارامتري",
"ui.equation_explicit": "صريح",
"ui.equation_end": "النهاية",
"ui.equation_note": "المتغير رقم بلا وحدة: تأخذه sin(t) بالراديان. والنتيجة التي بلا وحدة تكون بوحدة التصميم.",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)"
},
"he": {
"cmd.sketch_equation_curve": "עקומת משוואה",
"cmd.sketch_equation_curve_edit": "עריכת עקומת משוואה",
"help.sketch.equation_curve": "משרטט עקומה מנוסחאות על פני טווח: x(t) ו־y(t), או y = f(x). הנוסחאות יכולות להשתמש בפרמטרים והעקומה עוקבת אחריהם: לגל סינוס, לספירלה או לאבולבנטה של שן גלגל שיניים.",
"ui.heading_equation_curve": "עקומת משוואה",
"ui.equation_form": "צורה",
"ui.equation_parametric": "פרמטרית",
"ui.equation_explicit": "מפורשת",
"ui.equation_end": "סוף",
"ui.equation_note": "המשתנה הוא מספר ללא יחידה: sin(t) מקבלת אותו ברדיאנים. תוצאה ללא יחידה היא ביחידה של התכנון.",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)"
},
"vi": {
"cmd.sketch_equation_curve": "Đường cong phương trình",
"cmd.sketch_equation_curve_edit": "Sửa đường cong phương trình",
"help.sketch.equation_curve": "Vẽ một đường cong từ công thức trên một khoảng: x(t) và y(t), hoặc y = f(x). Công thức có thể dùng tham số và đường cong thay đổi theo: dùng cho sóng sin, đường xoắn ốc hay đường thân khai của răng bánh răng.",
"ui.heading_equation_curve": "ĐƯỜNG CONG PHƯƠNG TRÌNH",
"ui.equation_form": "Dạng",
"ui.equation_parametric": "Tham số",
"ui.equation_explicit": "Tường minh",
"ui.equation_end": "Kết thúc",
"ui.equation_note": "Biến là một số không có đơn vị: sin(t) hiểu nó theo radian. Kết quả không có đơn vị dùng đơn vị của thiết kế.",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)"
},
"de": {
"cmd.sketch_equation_curve": "Gleichungskurve",
"cmd.sketch_equation_curve_edit": "Gleichungskurve bearbeiten",
"help.sketch.equation_curve": "Zeichnet eine Kurve aus Formeln über einen Bereich: x(t) und y(t) oder y = f(x). Die Formeln können Parameter verwenden, und die Kurve folgt ihnen: für eine Sinuswelle, eine Spirale oder die Evolvente eines Zahnrads.",
"ui.heading_equation_curve": "GLEICHUNGSKURVE",
"ui.equation_form": "Form",
"ui.equation_parametric": "Parametrisch",
"ui.equation_explicit": "Explizit",
"ui.equation_end": "Ende",
"ui.equation_note": "Die Variable ist eine Zahl ohne Einheit: sin(t) liest sie im Bogenmaß. Ein Ergebnis ohne Einheit ist in der Einheit der Konstruktion.",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)"
},
"ko": {
"cmd.sketch_equation_curve": "방정식 곡선",
"cmd.sketch_equation_curve_edit": "방정식 곡선 편집",
"help.sketch.equation_curve": "범위에 걸쳐 수식으로 곡선을 그립니다: x(t)와 y(t), 또는 y = f(x). 수식에 매개변수를 쓸 수 있으며 곡선이 그 값을 따라갑니다. 사인파, 나선, 기어 이의 인벌류트에 사용합니다.",
"ui.heading_equation_curve": "방정식 곡선",
"ui.equation_form": "형식",
"ui.equation_parametric": "매개변수식",
"ui.equation_explicit": "명시적",
"ui.equation_end": "끝",
"ui.equation_note": "변수는 단위가 없는 수입니다. sin(t)는 라디안으로 읽습니다. 단위가 없는 결과는 디자인 단위입니다.",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)"
}
}
@@ -0,0 +1 @@
| assets/icons/ui/design/svg/model/equation_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
+6
View File
@@ -253,6 +253,12 @@ impl crate::Feature {
for e in sketch.text_exprs_mut() {
*e = clone_expression(e, parameters);
}
// An equation curve's variable is its own: a parameter of that name is not renamed in it.
for (variable, e) in sketch.equation_exprs_mut() {
let others: BTreeMap<String, String> =
parameters.iter().filter(|(old, _)| old.as_str() != variable).map(|(o, n)| (o.clone(), n.clone())).collect();
*e = clone_expression(e, &others);
}
for link in &mut sketch.links {
remap_link(&mut link.source, ids, names, bodies, 0);
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
+3 -2
View File
@@ -3889,10 +3889,11 @@ impl Document {
}
}
/// Apply parameter values to every dimension in a sketch, and to its points placed by
/// parameters (in place).
/// Apply parameter values to every dimension in a sketch, to its points placed by parameters
/// and to its equation curves (in place).
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
self.apply_point_values(vals, sk)?;
self.apply_equation_values(vals, sk)?;
self.apply_pattern_values(vals, sk)?;
for c in &mut sk.constraints {
if let Some(p) = &c.param {
@@ -0,0 +1,5 @@
//! A sketch saves its equation curves (#8352) in `equations`: for each, the fit point spline it
//! places, its formulas (x(t) and y(t), or y = f(x) without `x`), the range of the variable and
//! the tolerance its points were chosen for. An older design has none, and its sketches mean
//! what they meant. The last release refuses a design that has them (it asks for an update)
//! instead of opening the splines as plain fixed splines that no longer follow their formulas.
+1
View File
@@ -71,6 +71,7 @@ pub mod render_settings;
pub mod render_surface;
pub use project3d::iso_grid;
pub mod sheet;
pub mod sketch_equation;
pub mod text_params;
pub use assembly::{
+12
View File
@@ -896,6 +896,18 @@ impl Document {
n += 1;
}
}
// An equation curve's variable hides a parameter of its name: such a parameter is
// not what its formulas refer to.
for (variable, e) in sketch.equation_exprs_mut() {
if variable == old {
continue;
}
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
n += 1;
}
}
for e in sketch.text_word_exprs_mut() {
let r = crate::text_params::rename_reference(e, old, new);
if r != *e {
+363
View File
@@ -0,0 +1,363 @@
//! Equation curves (#8352): the points of a sketch curve worked out from its formulas over the
//! design's parameters. The formulas are evaluated at evenly spaced values of the curve's
//! variable; the sketch draws a cubic fit spline through those points. How many points: as many
//! as it takes for the spline to stay within the curve's tolerance of the formulas, checked
//! between the points, up to the most a spline takes. When the parameters change, the same
//! number of points is placed again (`Document::apply_equation_values`), before every solve.
use std::collections::BTreeMap;
use solidcraft_geom::Vec2;
use solidcraft_sketch::{EquationCurve, MAX_COORD, MAX_EQUATION_TEXT, MAX_SPLINE_POINTS, Sketch, fit_beziers_mode};
use crate::expr::{self, Defaults, Kind, Value};
use crate::{DocError, Result};
/// The tolerance a new equation curve gets (mm).
pub const DEFAULT_TOLERANCE: f64 = 0.01;
/// The tightest and loosest tolerances (mm).
pub const MIN_TOLERANCE: f64 = 1e-4;
pub const MAX_TOLERANCE: f64 = 10.0;
/// The fewest points a fit starts from.
const FIRST_COUNT: usize = 9;
/// The widest range of the variable.
const MAX_RANGE: f64 = 1e6;
/// How many times the point count is raised before the fit gives up.
const MAX_ROUNDS: usize = 12;
/// An equation curve's points and how well the spline through them follows the formulas.
#[derive(Clone, Debug, PartialEq)]
pub struct EquationFit {
/// The spline's points in order; a closed curve's last point is its first one again.
pub points: Vec<Vec2>,
/// The curve ends where it starts: the spline closes smoothly there.
pub closed: bool,
/// The largest distance (mm) found between the spline and the formulas.
pub deviation: f64,
}
/// How bare numbers read in the formulas: angles in radians (the variable is a plain number,
/// `sin(t)` its sine), lengths in the design's unit.
fn defaults(vals: &BTreeMap<String, Value>) -> Defaults {
let len = vals.get(crate::eval::DESIGN_LEN).map(|v| v.v).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
Defaults { len, ang: 1.0 }
}
fn checked_text<'a>(what: &str, e: &'a str) -> Result<&'a str> {
let e = e.trim();
if e.is_empty() {
return Err(DocError::Invalid(format!("{what} is empty")));
}
if e.chars().count() > MAX_EQUATION_TEXT {
return Err(DocError::Invalid(format!("{what} is longer than {MAX_EQUATION_TEXT} characters")));
}
Ok(e)
}
/// A formula's value (mm) where the variable is `at`. The variable hides a parameter of its name.
fn formula(vals: &BTreeMap<String, Value>, e: &str, variable: &str, at: f64, d: Defaults) -> Result<f64> {
let lookup = |n: &str| {
if n == variable { Ok(Value::num(at)) } else { vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))) }
};
let v = expr::eval_with_defaults(e, &lookup, d)?.to_kind_in(Kind::Length, d)?;
if v.is_finite() && v.abs() < MAX_COORD { Ok(v) } else { Err(DocError::Expr("the curve goes out of range".into())) }
}
/// Where the variable starts or ends, as the plain number the formulas see: a number, an angle
/// (in radians) or a length (in the design's unit).
fn range_end(vals: &BTreeMap<String, Value>, e: &str, d: Defaults) -> Result<f64> {
let v = expr::eval_with_defaults(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), d)?;
let out = match v.kind() {
Some(Kind::Unitless | Kind::Angle) => v.v,
Some(Kind::Length) => v.v / d.len,
None => return Err(DocError::Expr(format!("expected a number, an angle or a length, got {}", v.describe()))),
};
if out.is_finite() { Ok(out) } else { Err(DocError::Expr("not a finite number".into())) }
}
/// The variable's range, checked.
fn range(vals: &BTreeMap<String, Value>, eq: &EquationCurve, d: Defaults) -> Result<(f64, f64)> {
let a = range_end(vals, checked_text("the start", &eq.start)?, d).map_err(|e| DocError::Expr(format!("start: {e}")))?;
let b = range_end(vals, checked_text("the end", &eq.end)?, d).map_err(|e| DocError::Expr(format!("end: {e}")))?;
if (b - a).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0) {
return Err(DocError::Invalid("the start and the end of the range must differ".into()));
}
if (b - a).abs() > MAX_RANGE {
return Err(DocError::Invalid(format!("the range is wider than {MAX_RANGE}")));
}
Ok((a, b))
}
/// The curve's point where the variable is `at`.
fn point_at(vals: &BTreeMap<String, Value>, eq: &EquationCurve, at: f64, d: Defaults) -> Result<Vec2> {
let variable = eq.variable();
let x = match &eq.x {
Some(e) => formula(vals, e, variable, at, d).map_err(|e| DocError::Expr(format!("x: {e}")))?,
// The explicit form: x is the variable, in the design's unit.
None => {
let x = at * d.len;
if !(x.is_finite() && x.abs() < MAX_COORD) {
return Err(DocError::Expr("the curve goes out of range".into()));
}
x
}
};
let y = formula(vals, &eq.y, variable, at, d).map_err(|e| DocError::Expr(format!("y: {e}")))?;
Ok(Vec2::new(x, y))
}
/// The formulas' points at `count` evenly spaced values of the variable, from start to end.
fn samples(vals: &BTreeMap<String, Value>, eq: &EquationCurve, (a, b): (f64, f64), count: usize, d: Defaults) -> Result<Vec<Vec2>> {
let last = count.saturating_sub(1).max(1) as f64;
(0..count)
.map(|i| {
let at = a + (b - a) * (i as f64 / last);
point_at(vals, eq, at, d).map_err(|e| DocError::Expr(format!("at {} = {}: {e}", eq.variable(), short(at))))
})
.collect()
}
/// A number short enough for a message.
fn short(v: f64) -> String {
let s = format!("{v:.6}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// The size of the curve: the larger side of its box (for closing tolerances).
fn extent(points: &[Vec2]) -> f64 {
let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY));
for p in points {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
(hi.x - lo.x).max(hi.y - lo.y).max(0.0)
}
/// Does a curve through these samples end where it starts?
fn closes(points: &[Vec2]) -> bool {
match (points.first(), points.last()) {
(Some(a), Some(b)) => points.len() >= 4 && a.dist(*b) <= 1e-7 * extent(points).max(1.0),
_ => false,
}
}
/// Refuse samples a spline cannot pass through: two in a row on the same spot, or all of them.
fn check_samples(points: &[Vec2], closed: bool) -> Result<()> {
let size = extent(points);
if size <= 1e-9 {
return Err(DocError::Invalid("the formulas give the same point all along the range".into()));
}
let spans = if closed { points.len().saturating_sub(1) } else { points.len() };
for w in points.windows(2).take(spans) {
if let [p, q] = w
&& p.dist(*q) <= 1e-9 * size.max(1.0)
{
return Err(DocError::Invalid("the curve stands still between two of its points: narrow the range or use fewer points".into()));
}
}
Ok(())
}
/// Distance from `q` to the cubic Bézier `b`, sampled finely.
fn bezier_distance(b: &[Vec2; 4], q: Vec2) -> f64 {
const STEPS: usize = 32;
let at = |t: f64| {
let s = 1.0 - t;
b[0] * (s * s * s) + b[1] * (3.0 * t * s * s) + b[2] * (3.0 * t * t * s) + b[3] * (t * t * t)
};
let mut best = f64::INFINITY;
let mut prev = at(0.0);
for i in 1..=STEPS {
let next = at(i as f64 / STEPS as f64);
let d = next - prev;
let l2 = d.len2();
let t = if l2 > 0.0 { ((q - prev).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
best = best.min(q.dist(prev + d * t));
prev = next;
}
best
}
/// How far the spline through `points` strays from the formulas: the largest distance from the
/// formulas' points a quarter, a half and three quarters of the way between two samples to the
/// spline's piece between them.
fn deviation(vals: &BTreeMap<String, Value>, eq: &EquationCurve, (a, b): (f64, f64), points: &[Vec2], closed: bool, d: Defaults) -> Result<f64> {
let pieces = fit_beziers_mode(points, closed).map_err(|e| DocError::Invalid(e.to_string()))?;
let step = (b - a) / points.len().saturating_sub(1).max(1) as f64;
let mut worst: f64 = 0.0;
for (i, piece) in pieces.iter().enumerate() {
for k in [0.25, 0.5, 0.75] {
let q = point_at(vals, eq, a + step * (i as f64 + k), d)?;
worst = worst.max(bezier_distance(piece, q));
}
}
Ok(worst)
}
/// Check an equation's text and tolerance before any evaluation.
pub fn check(eq: &EquationCurve) -> Result<()> {
if let Some(x) = &eq.x {
checked_text("x(t)", x)?;
}
checked_text(if eq.x.is_some() { "y(t)" } else { "y(x)" }, &eq.y)?;
checked_text("the start", &eq.start)?;
checked_text("the end", &eq.end)?;
if !(eq.tolerance.is_finite() && (MIN_TOLERANCE..=MAX_TOLERANCE).contains(&eq.tolerance)) {
return Err(DocError::Invalid(format!("the tolerance must be from {MIN_TOLERANCE} mm to {MAX_TOLERANCE} mm")));
}
Ok(())
}
/// The points of `count` samples (a closed curve's last one is its first), whether it closes,
/// and how far the spline through them strays.
pub fn sample(vals: &BTreeMap<String, Value>, eq: &EquationCurve, count: usize, closed: Option<bool>) -> Result<EquationFit> {
check(eq)?;
if !(2..=MAX_SPLINE_POINTS).contains(&count) {
return Err(DocError::Invalid(format!("an equation curve takes 2 to {MAX_SPLINE_POINTS} points")));
}
let d = defaults(vals);
let span = range(vals, eq, d)?;
let mut points = samples(vals, eq, span, count, d)?;
let closed = closed.unwrap_or_else(|| closes(&points)) && count >= 4;
if closed
&& let Some(first) = points.first().copied()
&& let Some(last) = points.last_mut()
{
*last = first;
}
check_samples(&points, closed)?;
let deviation = deviation(vals, eq, span, &points, closed, d)?;
Ok(EquationFit { points, closed, deviation })
}
/// The points of an equation curve, as few as keep the spline within its tolerance of the
/// formulas. Refused when even the most points a spline takes are not close enough.
pub fn fit(vals: &BTreeMap<String, Value>, eq: &EquationCurve) -> Result<EquationFit> {
let mut count = FIRST_COUNT;
for _ in 0..MAX_ROUNDS {
let fit = sample(vals, eq, count, None)?;
if fit.deviation <= eq.tolerance {
return Ok(fit);
}
if count >= MAX_SPLINE_POINTS {
return Err(DocError::Invalid(format!(
"even {MAX_SPLINE_POINTS} points stray {} mm from the formulas, more than the tolerance of {} mm: raise the tolerance or narrow the range",
short(fit.deviation),
short(eq.tolerance)
)));
}
// A cubic fit's error falls with the fourth power of the spacing: aim a little past
// what that asks for, at least half as many points again.
let ratio = (fit.deviation / eq.tolerance).powf(0.25) * 1.2;
let next = (count as f64 * ratio.max(1.5)).ceil();
count = if next.is_finite() && next < MAX_SPLINE_POINTS as f64 { next as usize } else { MAX_SPLINE_POINTS };
}
Err(DocError::Invalid("the equation curve's points could not be fitted".into()))
}
impl crate::Document {
/// Place the points of the sketch's equation curves where their formulas put them now (in
/// place), with the number of points each has. Their points are fixed, so the solve keeps
/// them there. A curve that is gone or is no longer a fit point spline is left as it is.
pub fn apply_equation_values(&self, vals: &BTreeMap<String, Value>, sk: &mut Sketch) -> Result<()> {
for k in 0..sk.equations.len() {
let Some(eq) = sk.equations.get(k).cloned() else { continue };
let Some(ci) = sk.curve_index(&eq.curve) else { continue };
let Some(solidcraft_sketch::CurveKind::Spline { pts, control: false, periodic, .. }) = sk.curves.get(ci).map(|c| &c.kind) else {
continue;
};
let (count, closed) = (pts.len(), *periodic);
let fit = sample(vals, &eq, count, Some(closed)).map_err(|e| DocError::Expr(format!("equation curve {}: {e}", eq.curve)))?;
sk.place_equation_points(ci, &fit.points)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn eq(x: Option<&str>, y: &str, start: &str, end: &str) -> EquationCurve {
EquationCurve {
curve: String::new(),
x: x.map(str::to_string),
y: y.into(),
start: start.into(),
end: end.into(),
tolerance: DEFAULT_TOLERANCE,
}
}
#[test]
fn a_circle_closes_and_stays_within_the_tolerance() {
let vals = BTreeMap::new();
let fit = fit(&vals, &eq(Some("10 * cos(t)"), "10 * sin(t)", "0", "2 * pi")).unwrap();
assert!(fit.closed);
assert!(fit.deviation <= DEFAULT_TOLERANCE, "{}", fit.deviation);
for p in &fit.points {
assert!((p.len() - 10.0).abs() < 1e-9, "{p:?}");
}
assert_eq!(fit.points.first(), fit.points.last());
}
#[test]
fn the_variable_hides_a_parameter_and_parameters_drive_the_curve() {
let mut vals = BTreeMap::new();
vals.insert("t".to_string(), Value::length(1000.0));
vals.insert("amp".to_string(), Value::length(5.0));
let fit = fit(&vals, &eq(None, "amp * sin(x / 10)", "0 mm", "100 mm")).unwrap();
assert!(!fit.closed);
let (first, last) = (fit.points[0], *fit.points.last().unwrap());
assert!((first - Vec2::new(0.0, 0.0)).len() < 1e-9, "{first:?}");
assert!((last.x - 100.0).abs() < 1e-9 && (last.y - 5.0 * 10f64.sin()).abs() < 1e-9, "{last:?}");
assert!(fit.points.iter().all(|p| p.y.abs() <= 5.0 + 1e-9));
}
#[test]
fn a_design_in_inches_reads_bare_numbers_in_inches() {
let mut vals = BTreeMap::new();
vals.insert(crate::eval::DESIGN_LEN.to_string(), Value::num(25.4));
let fit = fit(&vals, &eq(Some("t"), "2", "0", "1")).unwrap();
let last = *fit.points.last().unwrap();
assert!((last - Vec2::new(25.4, 50.8)).len() < 1e-9, "{last:?}");
}
#[test]
fn more_points_where_the_tolerance_is_tighter() {
let vals = BTreeMap::new();
let mut e = eq(Some("t * cos(t)"), "t * sin(t)", "0", "6 * pi");
e.tolerance = 0.1;
let loose = fit(&vals, &e).unwrap();
e.tolerance = 0.005;
let tight = fit(&vals, &e).unwrap();
assert!(tight.points.len() > loose.points.len(), "{} vs {}", tight.points.len(), loose.points.len());
assert!(tight.deviation <= 0.005 && loose.deviation <= 0.1);
}
#[test]
fn hostile_formulas_and_ranges_are_refused() {
let vals = BTreeMap::new();
for (x, y, a, b, tol) in [
(Some("t"), "sqrt(t)", "-1", "1", DEFAULT_TOLERANCE),
(Some("t"), "1 / t", "-1", "1", DEFAULT_TOLERANCE),
(Some("t"), "t", "0", "0", DEFAULT_TOLERANCE),
(Some("t"), "t", "0", "1e300", DEFAULT_TOLERANCE),
(Some("t"), "t", "0", "nan", DEFAULT_TOLERANCE),
(Some("t * 1 deg"), "t", "0", "1", DEFAULT_TOLERANCE),
(Some("1e300 * t"), "t", "0", "1", DEFAULT_TOLERANCE),
(Some("5"), "5", "0", "1", DEFAULT_TOLERANCE),
(Some(""), "t", "0", "1", DEFAULT_TOLERANCE),
(Some("t"), "t", "0", "1", f64::NAN),
(Some("t"), "t", "0", "1", 0.0),
(Some("t"), "unknown_name * t", "0", "1", DEFAULT_TOLERANCE),
(Some("t"), "sin(1e5 * t)", "0", "1000", 1e-4),
] {
let mut e = eq(x, y, a, b);
e.tolerance = tol;
assert!(fit(&vals, &e).is_err(), "{x:?} {y} {a}..{b} {tol}");
}
let long = "t + ".repeat(400) + "t";
assert!(fit(&vals, &eq(Some(&long), "t", "0", "1")).is_err());
}
}
+2
View File
@@ -94,6 +94,7 @@ pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_create;
mod sketch_ends;
mod sketch_equation;
mod sketch_freeform;
mod sketch_import;
mod sketch_inspect;
@@ -286,6 +287,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_equation::COMMANDS.iter());
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_import::COMMANDS.iter());
v.extend(sketch_inspect::COMMANDS.iter());
+159
View File
@@ -0,0 +1,159 @@
//! Equation curves (#8352): a sketch curve drawn from formulas, x(t) and y(t) over a range of t
//! or y = f(x) over a range of x, in the design's expression language and parameters. The curve
//! is a fit point spline through points worked out from the formulas, as many as keep it within
//! a tolerance of them; it keeps its formulas, so it follows the parameters and can be edited.
use serde_json::{Value, json};
use solidcraft_doc::sketch_equation::{self, DEFAULT_TOLERANCE, EquationFit};
use solidcraft_sketch::{EquationCurve, Sketch};
use super::sketch::{edit, mark_construction};
use super::{CommandSpec, in_sketch};
use crate::params::bad;
use crate::schema::sketch_equation::{EquationCurveParams, EquationCurveResult, EquationEditParams, EquationEditResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
#[cfg(test)]
#[path = "sketch_equation_tests.rs"]
mod tests;
const CREATE_CONTRACT: TypedContract<EquationCurveParams> = TypedContract::new();
const EDIT_CONTRACT: TypedContract<EquationEditParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.equation_curve", "Equation Curve", crate::checked!("sketch.equation_curve", create))
.at("SKETCH", "CREATE")
.icon("equation_curve")
.enabled(in_sketch)
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<EquationCurveResult>)
.params(concat!(
"x: x(t), y: y(t): formulas of t in the design's expression language and parameters (\"r * (cos(t) + t * sin(t))\"); or explicit: true and y: f(x) alone (no x); \
start, end: the range of t (or of x): a number or an expression (\"2 * pi\", \"360 deg\" is 2·pi, a length is in the design's unit); \
tolerance?: mm (default 0.01): how far the curve may stray from the formulas (0.0001 to 10); construction?: bool. \
The variable is a plain number: sin(t) reads it in radians; a result without a unit is in the design's length unit. \
Makes a fit point spline through fixed points, as many as the tolerance needs (up to 500), closed smoothly when it ends where it starts; \
the points are placed again from the formulas whenever the parameters change. Returns curve, points (how many), closed, deviation (mm)",
created_doc!()
)),
CommandSpec::new("sketch.equation_curve.edit", "Edit Equation Curve", crate::checked!("sketch.equation_curve.edit", edit_curve))
.icon("equation_curve")
.enabled(in_sketch)
.contract(&EDIT_CONTRACT)
.output(output_schema_for::<EquationEditResult>)
.params(
"curve: an equation curve's id; x?, y?, explicit?, start?, end?, tolerance?: as in sketch.equation_curve, each kept when left out (explicit: true drops x; false needs x). \
The points are chosen again for the new formulas; the curve's start point (and the end point of an open curve) keeps its id, so geometry joined there stays joined. Returns curve, points, closed, deviation",
),
];
/// A formula or range parameter: a string (an expression) or a number.
fn text(p: &Value, cmd: &str, key: &str) -> Result<Option<String>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(s.trim().to_string())),
Some(Value::Number(n)) => n
.as_f64()
.and_then(crate::params::finite)
.map(|v| Some(format!("{v}")))
.ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number"))),
Some(_) => Err(bad(cmd, format!("`{key}` must be a formula (a string) or a number"))),
}
}
fn tolerance(p: &Value, cmd: &str) -> Result<Option<f64>> {
match p.get("tolerance") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|t| t.is_finite() && (sketch_equation::MIN_TOLERANCE..=sketch_equation::MAX_TOLERANCE).contains(t))
.map(Some)
.ok_or_else(|| bad(cmd, "`tolerance` must be a number of mm from 0.0001 to 10")),
}
}
fn explicit(p: &Value, cmd: &str) -> Result<Option<bool>> {
match p.get("explicit") {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(_) => Err(bad(cmd, "`explicit` must be true or false")),
}
}
/// The equation a command's parameters give, over `old` (each left out kept).
fn equation(p: &Value, cmd: &str, old: Option<&EquationCurve>) -> Result<EquationCurve> {
let x = text(p, cmd, "x")?;
let explicit = explicit(p, cmd)?;
let x = match (explicit, x) {
(Some(true), Some(_)) => return Err(bad(cmd, "an explicit curve takes y = f(x) alone: leave out `x`")),
(Some(true), None) => None,
(_, Some(x)) => Some(x),
(Some(false), None) => match old.and_then(|o| o.x.clone()) {
Some(x) => Some(x),
None => return Err(bad(cmd, "a parametric curve needs `x`, a formula of t")),
},
(None, None) => match old {
Some(o) => o.x.clone(),
None => return Err(bad(cmd, "needs `x` and `y` (formulas of t), or explicit: true and `y` (a formula of x)")),
},
};
let pick = |key: &str, kept: Option<&String>| -> Result<String> {
match text(p, cmd, key)? {
Some(v) => Ok(v),
None => kept.cloned().ok_or_else(|| bad(cmd, format!("missing `{key}`"))),
}
};
let eq = EquationCurve {
curve: old.map(|o| o.curve.clone()).unwrap_or_default(),
x,
y: pick("y", old.map(|o| &o.y))?,
start: pick("start", old.map(|o| &o.start))?,
end: pick("end", old.map(|o| &o.end))?,
tolerance: tolerance(p, cmd)?.or(old.map(|o| o.tolerance)).unwrap_or(DEFAULT_TOLERANCE),
};
sketch_equation::check(&eq).map_err(|e| bad(cmd, e.to_string()))?;
Ok(eq)
}
/// The points of an equation, over the design's parameters as features are evaluated with them.
fn fit(doc: &solidcraft_doc::Document, eq: &EquationCurve, cmd: &str) -> Result<EquationFit> {
sketch_equation::fit(&doc.evaluation_values(), eq).map_err(|e| bad(cmd, e.to_string()))
}
fn reply(curve: String, fit: &EquationFit, info: Value) -> Value {
// A closed curve's last point is its first one.
let points = fit.points.len().saturating_sub(usize::from(fit.closed));
json!({"curves": [curve.clone()], "curve": curve, "points": points, "closed": fit.closed, "deviation": fit.deviation, "sketch": info})
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.equation_curve";
let eq = equation(p, cmd, None)?;
let ((curve, fit), info) = edit(s, p, cmd, false, |sk: &mut Sketch, doc| {
let fit = fit(doc, &eq, cmd)?;
let c = sk.add_equation_curve(&fit.points, fit.closed, eq.clone()).map_err(|e| bad(cmd, e.to_string()))?;
mark_construction(sk, &[c], p);
let id = sk.curves.get(c).map(|c| c.id.clone()).ok_or_else(|| bad(cmd, "the new curve is missing"))?;
Ok((id, fit))
})?;
Ok(reply(curve, &fit, info))
}
fn edit_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.equation_curve.edit";
let id = p.get("curve").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`curve` must be an equation curve's id"))?.to_string();
let (fit, info) = edit(s, p, cmd, false, |sk: &mut Sketch, doc| {
let c = sk.curve_index(&id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
let old = sk.equation_of(&id).cloned().ok_or_else(|| bad(cmd, format!("`{id}` is not an equation curve")))?;
let eq = equation(p, cmd, Some(&old))?;
let fit = fit(doc, &eq, cmd)?;
sk.replace_equation_curve(c, &fit.points, fit.closed, eq).map_err(|e| bad(cmd, e.to_string()))?;
Ok(fit)
})?;
let mut out = reply(id, &fit, info);
if let Some(o) = out.as_object_mut() {
o.remove("curves");
}
Ok(out)
}
@@ -0,0 +1,256 @@
//! Equation curves (#8352): a fit point spline from formulas over the design's parameters.
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::CurveKind;
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 volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn no_trouble(s: &Session) {
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
}
fn near(got: f64, want: f64, rel: f64) {
assert!((got - want).abs() <= want.abs() * rel, "got {got}, want {want}");
}
/// The evaluated sketch `S`.
fn solved(s: &Session) -> solidcraft_sketch::Sketch {
let f = s.doc.find_feature("S").unwrap().id;
s.model.state().sketch(f).unwrap().sketch.clone()
}
/// The positions of curve `id`'s points in the evaluated sketch `S`.
fn curve_points(s: &Session, id: &str) -> Vec<Vec2> {
let sk = solved(s);
let c = sk.curves.iter().find(|c| c.id == id).unwrap();
c.kind.point_ids().iter().map(|i| sk.points[*i].pos).collect()
}
/// A wave over a strip: y = h + 5 sin(2 pi x / 100) over x from 0 to 100, closed by three
/// lines down to the x axis. Its area is 100 h (the sine adds as much as it takes away).
fn wave(s: &mut Session) -> String {
run(s, "parameters.add", json!({"name": "h", "expression": "10 mm"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(s, "sketch.equation_curve", json!({"explicit": true, "y": "h + 5 * sin(2 * pi * x / 100)", "start": 0, "end": "100 mm"}));
let id = r["curve"].as_str().unwrap().to_string();
assert_eq!(r["closed"], false, "{r}");
assert!(r["deviation"].as_f64().unwrap() <= 0.01, "{r}");
run(s, "sketch.line", json!({"points": [format!("{id}.end"), [100, 0], [0, 0], format!("{id}.start")]}));
run(s, "sketch.finish", json!({}));
id
}
#[test]
fn a_wave_from_a_formula_extrudes_and_follows_its_parameter() {
let mut s = Session::default();
let id = wave(&mut s);
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10}));
no_trouble(&s);
near(volume(&mut s), 100.0 * 10.0 * 10.0, 1e-4);
let before = curve_points(&s, &id);
assert!(before.iter().all(|p| (p.y - (10.0 + 5.0 * (2.0 * std::f64::consts::PI * p.x / 100.0).sin())).abs() < 1e-9), "{before:?}");
// The curve follows the parameter: the strip is twice as tall.
run(&mut s, "parameters.change", json!({"name": "h", "expression": "20 mm"}));
no_trouble(&s);
near(volume(&mut s), 100.0 * 20.0 * 10.0, 1e-4);
let after = curve_points(&s, &id);
assert_eq!(before.len(), after.len());
assert!(before.iter().zip(&after).all(|(a, b)| (b.y - a.y - 10.0).abs() < 1e-9 && (a.x - b.x).abs() < 1e-9));
// The parameter knows the curve uses it, and a rename reaches the formula.
let users = run(&mut s, "parameters.users", json!({"name": "h"}));
assert!(users.to_string().contains("Curve Y"), "{users}");
run(&mut s, "parameters.rename", json!({"name": "h", "new_name": "height"}));
let f = s.doc.find_feature("S").unwrap().id;
assert_eq!(s.doc.sketch(f).unwrap().equation_of(&id).unwrap().y, "height + 5 * sin(2 * pi * x / 100)");
no_trouble(&s);
near(volume(&mut s), 100.0 * 20.0 * 10.0, 1e-4);
// The design file keeps the formulas, and a reopened design follows them.
assert!(s.doc.to_json().contains("\"equations\""));
let mut r = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
no_trouble(&r);
near(volume(&mut r), 100.0 * 20.0 * 10.0, 1e-4);
}
#[test]
fn the_variable_hides_a_parameter_of_its_name_and_a_rename_leaves_it_alone() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "t", "expression": "1000 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "t * t", "start": -2, "end": 2}));
let id = r["curve"].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
let pts = curve_points(&s, &id);
assert!(pts.iter().all(|p| (p.y - p.x * p.x).abs() < 1e-9 && p.x.abs() <= 2.0 + 1e-9), "{pts:?}");
run(&mut s, "parameters.rename", json!({"name": "t", "new_name": "u"}));
let f = s.doc.find_feature("S").unwrap().id;
let eq = s.doc.sketch(f).unwrap().equation_of(&id).unwrap().clone();
assert_eq!((eq.x.as_deref(), eq.y.as_str()), (Some("t"), "t * t"));
}
#[test]
fn a_full_turn_closes_smoothly_and_extrudes_as_a_disc() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.equation_curve", json!({"x": "20 * cos(t)", "y": "20 * sin(t)", "start": 0, "end": "360 deg"}));
assert_eq!(r["closed"], true, "{r}");
let id = r["curve"].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
let f = s.doc.find_feature("S").unwrap().id;
let sk = s.doc.sketch(f).unwrap();
let Some(CurveKind::Spline { pts, periodic: true, control: false, .. }) = sk.curves.iter().find(|c| c.id == id).map(|c| &c.kind) else {
panic!("not a closed fit spline: {:?}", sk.curves)
};
assert_eq!(pts.first(), pts.last());
assert!(pts.iter().all(|p| sk.points[*p].fixed));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5}));
no_trouble(&s);
near(volume(&mut s), std::f64::consts::PI * 400.0 * 5.0, 1e-3);
}
#[test]
fn a_profile_under_a_curve_revolves() {
// y = 5 + 2 sin(x / 5) over x from 0 to 30, revolved about the x axis:
// V = pi * integral of (5 + 2 sin(x/5))^2 = pi * (25 * 30 + 20 * 5 (1 - cos 6) + 4 (15 - 5/4 sin 12)).
let want = std::f64::consts::PI * (750.0 + 100.0 * (1.0 - 6f64.cos()) + 4.0 * (15.0 - 1.25 * 12f64.sin()));
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.equation_curve", json!({"explicit": true, "y": "5 + 2 * sin(x / 5)", "start": 0, "end": 30}));
let id = r["curve"].as_str().unwrap().to_string();
run(&mut s, "sketch.line", json!({"points": [format!("{id}.end"), [30, 0], [0, 0], format!("{id}.start")]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "S", "axis": "x"}));
no_trouble(&s);
near(volume(&mut s), want, 2e-3);
}
#[test]
fn an_edit_changes_the_formulas_and_keeps_the_joined_ends() {
let mut s = Session::default();
let id = wave(&mut s);
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
let f = s.doc.find_feature("S").unwrap().id;
let before = s.doc.sketch(f).unwrap().clone();
let ends = |sk: &solidcraft_sketch::Sketch| {
let c = sk.curves.iter().find(|c| c.id == id).unwrap();
let (a, b) = c.kind.ends().unwrap();
(sk.points[a].id.clone(), sk.points[b].id.clone())
};
// A tighter tolerance and a bigger wave: more points, the same ends.
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "y": "h + 8 * sin(2 * pi * x / 100)", "tolerance": 0.002}));
assert!(r["deviation"].as_f64().unwrap() <= 0.002, "{r}");
let after = s.doc.sketch(f).unwrap().clone();
assert_eq!(ends(&before), ends(&after));
let count = |sk: &solidcraft_sketch::Sketch| sk.curves.iter().find(|c| c.id == id).unwrap().kind.point_ids().len();
assert!(count(&after) > count(&before), "{} vs {}", count(&after), count(&before));
assert_eq!(r["points"].as_u64(), Some(count(&after) as u64));
let eq = after.equation_of(&id).unwrap();
assert_eq!((eq.y.as_str(), eq.start.as_str(), eq.tolerance), ("h + 8 * sin(2 * pi * x / 100)", "0", 0.002));
// No points left behind: the old in-between points went.
assert_eq!(after.points.len(), before.points.len() - count(&before) + count(&after));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10}));
no_trouble(&s);
near(volume(&mut s), 100.0 * 10.0 * 10.0, 1e-4);
// Undo puts the old formulas back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.sketch(f).unwrap().equation_of(&id).unwrap().y, "h + 5 * sin(2 * pi * x / 100)");
}
#[test]
fn an_edit_can_switch_between_the_two_forms() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.equation_curve", json!({"explicit": true, "y": "x / 2", "start": 0, "end": 10}));
let id = r["curve"].as_str().unwrap().to_string();
// To a closed parametric curve: the old end point goes, the start stays.
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "x": "10 * cos(t)", "y": "10 * sin(t)", "start": 0, "end": "2 * pi"}));
assert_eq!(r["closed"], true, "{r}");
let f = s.doc.find_feature("S").unwrap().id;
let sk = s.doc.sketch(f).unwrap();
assert_eq!(sk.points.len(), 1 + r["points"].as_u64().unwrap() as usize, "the origin and the curve's own points");
// Back to the explicit form, open again.
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "explicit": true, "y": "x * x / 10", "start": -10, "end": 10}));
assert_eq!(r["closed"], false, "{r}");
let sk = s.doc.sketch(f).unwrap();
assert!(sk.equation_of(&id).unwrap().x.is_none());
let (a, b) = sk.curves.iter().find(|c| c.id == id).unwrap().kind.ends().unwrap();
assert_eq!((sk.points[a].pos, sk.points[b].pos), (Vec2::new(-10.0, 10.0), Vec2::new(10.0, 10.0)));
}
#[test]
fn deleting_the_curve_forgets_its_formulas() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "sin(t)", "start": 0, "end": 6}));
let id = r["curve"].as_str().unwrap().to_string();
run(&mut s, "sketch.delete", json!({"entities": [id]}));
let f = s.doc.find_feature("S").unwrap().id;
let sk = s.doc.sketch(f).unwrap();
assert!(sk.equations.is_empty() && sk.curves.is_empty() && sk.points.len() == 1, "{sk:?}");
}
#[test]
fn bad_formulas_ranges_and_values_are_refused_and_change_nothing() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let before = s.doc.to_json();
for p in [
json!({}),
json!({"y": "t", "start": 0, "end": 1}),
json!({"x": "t", "y": "t", "start": 0}),
json!({"x": "t", "explicit": true, "y": "x", "start": 0, "end": 1}),
json!({"x": "t", "y": "sqrt(t)", "start": -1, "end": 1}),
json!({"x": "t", "y": "1 / t", "start": -1, "end": 1}),
json!({"x": "t", "y": "t", "start": 1, "end": 1}),
json!({"x": "t", "y": "t", "start": 0, "end": "1e12"}),
json!({"x": "t * 1 deg", "y": "t", "start": 0, "end": 1}),
json!({"x": "nope", "y": "t", "start": 0, "end": 1}),
json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": 0}),
json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": "big"}),
json!({"x": 5, "y": 5, "start": 0, "end": 1}),
json!({"x": ["t"], "y": "t", "start": 0, "end": 1}),
json!({"x": "t", "y": "sin(1e5 * t)", "start": 0, "end": 1000, "tolerance": 0.0001}),
json!({"x": "1e300 * t", "y": "t", "start": 0, "end": 1}),
] {
assert!(s.execute("sketch.equation_curve", &p).is_err(), "{p} should be refused");
assert_eq!(s.doc.to_json(), before, "{p} changed the design");
}
for p in [json!({"curve": "nope", "y": "t"}), json!({"y": "t"}), json!({"curve": 7})] {
assert!(s.execute("sketch.equation_curve.edit", &p).is_err(), "{p} should be refused");
}
let line = run(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 5]]}))["curves"][0].as_str().unwrap().to_string();
assert!(s.execute("sketch.equation_curve.edit", &json!({"curve": line, "y": "t"})).is_err());
}
#[test]
fn a_curve_its_parameters_break_is_an_error_not_a_crash() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "k", "expression": "1 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "sqrt(k / 1 mm - t)", "start": 0, "end": 0.5}));
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
// Now the square root goes negative inside the range: the sketch reports it.
run(&mut s, "parameters.change", json!({"name": "k", "expression": "0.25 mm"}));
let r = s.model.results.iter().find(|r| r.name == "S").unwrap();
assert!(r.error.as_deref().is_some_and(|e| e.contains("equation curve")), "{:?}", r.error);
run(&mut s, "parameters.change", json!({"name": "k", "expression": "1 mm"}));
no_trouble(&s);
}
+1
View File
@@ -64,6 +64,7 @@ pub mod render_ui;
pub mod sheet;
pub mod sketch_constraints;
pub mod sketch_create;
pub mod sketch_equation;
pub mod view_navigation;
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked, run_checked_as};
+202
View File
@@ -0,0 +1,202 @@
//! Contracts and result types for the commands in `cmd/sketch_equation.rs`.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
use serde_json::Value;
use super::types::{SketchRef, present};
/// A formula or a range end: an expression (text) or a number.
#[derive(Clone, Debug, PartialEq)]
pub struct Formula(pub String);
impl<'de> Deserialize<'de> for Formula {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() => Ok(Formula(s)),
Value::Number(n) => match n.as_f64().and_then(crate::params::finite) {
Some(v) => Ok(Formula(format!("{v}"))),
None => Err(D::Error::custom("must be a finite number")),
},
_ => Err(D::Error::custom("must be a formula (a string) or a number")),
}
}
}
impl JsonSchema for Formula {
fn schema_name() -> Cow<'static, str> {
"Formula".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "An expression in the design's expression language and parameters, such as \"r * cos(t)\" or \"2 * pi\", or a number.",
"anyOf": [
{"type": "number", "exclusiveMinimum": -1e9, "exclusiveMaximum": 1e9},
{"type": "string", "minLength": 1, "maxLength": 1024, "pattern": "\\S"}
]
})
}
}
/// How far (mm) the curve may stray from its formulas.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tolerance(pub f64);
impl<'de> Deserialize<'de> for Tolerance {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?
.as_f64()
.filter(|t| t.is_finite() && (1e-4..=10.0).contains(t))
.map(Tolerance)
.ok_or_else(|| D::Error::custom("must be a number of mm from 0.0001 to 10"))
}
}
impl JsonSchema for Tolerance {
fn schema_name() -> Cow<'static, str> {
"EquationTolerance".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "How far (mm) the curve may stray from its formulas: 0.0001 to 10. Default 0.01.",
"type": "number",
"minimum": 0.0001,
"maximum": 10.0
})
}
}
/// An equation curve's id.
#[derive(Clone, Debug, PartialEq)]
pub struct CurveId(pub String);
impl<'de> Deserialize<'de> for CurveId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(CurveId(s)),
_ => Err(D::Error::custom("must be an equation curve's id")),
}
}
}
impl JsonSchema for CurveId {
fn schema_name() -> Cow<'static, str> {
"EquationCurveId".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "An equation curve's id (a curve id of the sketch).", "type": "string"})
}
}
/// Draw a curve from formulas: x(t) and y(t) over a range of t, or y = f(x) over a range of x.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct EquationCurveParams {
/// x(t), a formula of t. Leave it out with `explicit: true`.
#[serde(default)]
pub x: Option<Formula>,
/// y(t), a formula of t; or f(x), a formula of x, with `explicit: true`. Required.
#[serde(default)]
pub y: Option<Formula>,
/// The explicit form, y = f(x): `y` is a formula of x and `x` is left out. Default false.
#[serde(default)]
pub explicit: Option<bool>,
/// Where the variable starts: a number, an angle ("360 deg" is 2·pi) or a length (in the
/// design's unit). Required.
#[serde(default)]
pub start: Option<Formula>,
/// Where the variable ends, as `start`. Required.
#[serde(default)]
pub end: Option<Formula>,
/// How far (mm) the curve may stray from its formulas. Default 0.01.
#[serde(default)]
pub tolerance: Option<Tolerance>,
/// Make it construction geometry. Default false.
#[serde(default)]
pub construction: Option<bool>,
/// The sketch (default: the one being edited).
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "SketchRef")]
pub sketch: Option<SketchRef>,
}
/// Change an equation curve's formulas, range or tolerance.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct EquationEditParams {
/// The equation curve. Required.
#[serde(default)]
pub curve: Option<CurveId>,
/// x(t). Kept when left out; `explicit: true` drops it.
#[serde(default)]
pub x: Option<Formula>,
/// y(t), or f(x) for an explicit curve. Kept when left out.
#[serde(default)]
pub y: Option<Formula>,
/// The explicit form, y = f(x). Kept when left out; false needs `x` (given or kept).
#[serde(default)]
pub explicit: Option<bool>,
/// Where the variable starts. Kept when left out.
#[serde(default)]
pub start: Option<Formula>,
/// Where the variable ends. Kept when left out.
#[serde(default)]
pub end: Option<Formula>,
/// How far (mm) the curve may stray from its formulas. Kept when left out.
#[serde(default)]
pub tolerance: Option<Tolerance>,
/// The sketch (default: the one being edited).
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "SketchRef")]
pub sketch: Option<SketchRef>,
}
/// What `sketch.equation_curve` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct EquationCurveResult {
/// The new curve's id, as a list (as other drawing commands return it).
pub curves: Vec<String>,
/// The new curve's id.
pub curve: String,
/// How many points the spline goes through (a closed curve's start counted once).
pub points: usize,
/// The curve ends where it starts and closes smoothly there.
pub closed: bool,
/// The largest distance (mm) found between the spline and the formulas.
pub deviation: f64,
/// The sketch's solve: `{solved, dof}`.
pub sketch: Value,
/// What it added: `{points, curves, constraints, dimensions}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Value>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// What `sketch.equation_curve.edit` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct EquationEditResult {
/// The curve's id (unchanged).
pub curve: String,
/// How many points the spline now goes through (a closed curve's start counted once).
pub points: usize,
/// The curve ends where it starts and closes smoothly there.
pub closed: bool,
/// The largest distance (mm) found between the spline and the formulas.
pub deviation: f64,
/// The sketch's solve: `{solved, dof}`.
pub sketch: Value,
/// What the edit added (the new points in between): `{points, curves, constraints, dimensions}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Value>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "sketch_equation_tests.rs"]
mod tests;
@@ -0,0 +1,46 @@
use serde_json::json;
use super::{EquationCurveParams, EquationCurveResult, EquationEditParams, EquationEditResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<EquationCurveParams>(), &["construction", "end", "explicit", "sketch", "start", "tolerance", "x", "y"]);
assert_keys(input_schema_for::<EquationEditParams>(), &["curve", "end", "explicit", "sketch", "start", "tolerance", "x", "y"]);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("sketch.equation_curve", json!({"x": ["t"], "y": "t", "start": 0, "end": 1})),
("sketch.equation_curve", json!({"x": "t", "y": " ", "start": 0, "end": 1})),
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": 0})),
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": "big"})),
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "explicit": 1})),
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1e12})),
("sketch.equation_curve", json!({"y": "t", "start": 0, "end": 1})),
("sketch.equation_curve.edit", json!({"curve": 7})),
("sketch.equation_curve.edit", json!({"curve": "nope", "y": "t"})),
]);
}
#[test]
fn replies_match_their_schemas() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let run = |s: &mut Session, id: &str, p: serde_json::Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let created = output_schema_for::<EquationCurveResult>();
let created = created.as_value();
let r = run(&mut s, "sketch.equation_curve", json!({"x": "t * cos(t)", "y": "t * sin(t)", "start": 0, "end": "4 * pi"}));
assert!(conforms(created, created, &r), "{r} doesn't match {created}");
let id = r["curve"].as_str().unwrap_or_else(|| panic!("{r}")).to_string();
let r = run(&mut s, "sketch.equation_curve", json!({"explicit": true, "y": "x", "start": 0, "end": 1, "unknown": 1}));
assert!(conforms(created, created, &r), "{r} doesn't match {created}");
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
let edited = output_schema_for::<EquationEditResult>();
let edited = edited.as_value();
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "end": "6 * pi"}));
assert!(conforms(edited, edited, &r), "{r} doesn't match {edited}");
}
+188
View File
@@ -0,0 +1,188 @@
//! Equation curves (#8352): a fit point spline whose points come from formulas. The sketch keeps
//! the formulas beside the spline; the design's parameters are worked out in the document layer
//! (`solidcraft_doc::sketch_equation`), which places the points again before every solve. The
//! points are fixed for the solver, so dragging never bends the curve, while other geometry can
//! still share its end points.
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec2;
use crate::model::{CurveKind, Result, Sketch, SketchError};
/// Longest formula or range expression an equation curve takes (characters).
pub const MAX_EQUATION_TEXT: usize = 1024;
/// A curve drawn from formulas: x(t) and y(t) over t from `start` to `end`, or (with `x` left
/// out) the explicit y = f(x) over x from `start` to `end`. The formulas use the design's
/// expression language and parameters; the variable is a plain number, so `sin(t)` reads it in
/// radians, and a result without a unit is in the design's length unit.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EquationCurve {
/// The fit point spline it places (a curve id of the sketch).
pub curve: String,
/// x(t). `None`: the explicit form, where the variable is x itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<String>,
/// y(t), or f(x) in the explicit form.
pub y: String,
/// Where the variable starts and ends.
pub start: String,
pub end: String,
/// How far (mm) the spline may stray from the formulas: the number of points was chosen for
/// it when the curve was made or last edited.
pub tolerance: f64,
}
impl EquationCurve {
/// The name the formulas use for the curve's variable.
pub fn variable(&self) -> &'static str {
if self.x.is_some() { "t" } else { "x" }
}
/// The formulas, each with what it sets (for the parameters a sketch uses).
pub fn list(&self) -> Vec<(&'static str, &String)> {
let mut out = Vec::with_capacity(4);
if let Some(x) = &self.x {
out.push(("Curve X", x));
}
out.extend([("Curve Y", &self.y), ("Curve Start", &self.start), ("Curve End", &self.end)]);
out
}
/// Mutable view of [`EquationCurve::list`], for renaming the parameters they refer to.
pub fn list_mut(&mut self) -> Vec<&mut String> {
let mut out: Vec<&mut String> = self.x.iter_mut().collect();
out.extend([&mut self.y, &mut self.start, &mut self.end]);
out
}
}
impl Sketch {
/// The equation behind a curve, when it is an equation curve.
pub fn equation_of(&self, curve: &str) -> Option<&EquationCurve> {
self.equations.iter().find(|e| e.curve == curve)
}
/// Add an equation curve: a cubic fit spline through `at` (fixed points), closed smoothly
/// when `closed` (then the last position is the first one again and is not repeated), with
/// `equation` kept beside it. Returns the curve's index.
pub fn add_equation_curve(&mut self, at: &[Vec2], closed: bool, mut equation: EquationCurve) -> Result<usize> {
let pts = self.equation_points(at, closed, None)?;
let curve = self.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: closed }, None)?;
equation.curve = self.curves.get(curve).map(|c| c.id.clone()).ok_or_else(|| SketchError::Invalid("the new curve is missing".into()))?;
self.equations.push(equation);
Ok(curve)
}
/// Give an equation curve new points (its formulas were edited): the start point (and the
/// end point of an open curve) keep their ids, so geometry joined there stays joined; the
/// points in between are made again, and the old ones go with whatever referred to them.
pub fn replace_equation_curve(&mut self, curve: usize, at: &[Vec2], closed: bool, equation: EquationCurve) -> Result<()> {
let Some(CurveKind::Spline { pts: old, control: false, .. }) = self.curves.get(curve).map(|c| c.kind.clone()) else {
return Err(SketchError::Invalid("an equation curve must be a fit point spline".into()));
};
let (Some(first), Some(last)) = (old.first().copied(), old.last().copied()) else {
return Err(SketchError::Invalid("the equation curve has no points".into()));
};
let id = self.curves.get(curve).map(|c| c.id.clone()).ok_or_else(|| SketchError::Invalid("unknown curve".into()))?;
let keep_end = (last != first).then_some(last);
let pts = self.equation_points(at, closed, Some((first, keep_end)))?;
let unused: Vec<usize> = old.iter().copied().filter(|p| !pts.contains(p)).collect();
if let Some(c) = self.curves.get_mut(curve) {
c.kind = CurveKind::Spline { pts, control: false, degree: 3, periodic: closed };
}
// The old points no other curve uses (the in-between points, and an end a closed curve
// no longer has) go, with the constraints on them.
let mut gone: Vec<usize> = unused.into_iter().filter(|p| *p != 0 && !self.curves.iter().any(|c| c.kind.uses(*p))).collect();
gone.sort_unstable();
gone.dedup();
self.remove_points(&gone);
match self.equations.iter_mut().find(|e| e.curve == id) {
Some(e) => *e = EquationCurve { curve: id, ..equation },
None => self.equations.push(EquationCurve { curve: id, ..equation }),
}
Ok(())
}
/// The point indices of an equation curve through `at`: new fixed points, or (`reuse`) the
/// given start point and, for an open curve, end point moved to where the curve now starts
/// and ends.
fn equation_points(&mut self, at: &[Vec2], closed: bool, reuse: Option<(usize, Option<usize>)>) -> Result<Vec<usize>> {
let wanted = if closed { 3 } else { 2 };
if at.len() < wanted || at.len() > crate::curves::MAX_SPLINE_POINTS {
return Err(SketchError::Invalid(format!("an equation curve needs {wanted} to {} points", crate::curves::MAX_SPLINE_POINTS)));
}
if self.points.len().saturating_add(at.len()) > crate::model::MAX_POINTS {
return Err(SketchError::TooLarge);
}
let place = |sk: &mut Sketch, existing: Option<usize>, p: Vec2| -> Result<usize> {
let i = match existing {
Some(i) => {
let q = sk.points.get_mut(i).ok_or_else(|| SketchError::Invalid("missing equation curve point".into()))?;
q.pos = Sketch::check_pos(p)?;
i
}
None => sk.add_point(p, None)?,
};
if let Some(q) = sk.points.get_mut(i) {
q.fixed = true;
}
Ok(i)
};
let (first_at, rest) = at.split_first().ok_or_else(|| SketchError::Invalid("no points".into()))?;
let first = place(self, reuse.map(|r| r.0), *first_at)?;
let mut pts = vec![first];
let (middle, end_at) = if closed {
// The last position is the first one again: the curve closes on its first point.
(rest.split_last().map(|(_, m)| m).unwrap_or(&[]), None)
} else {
let (end_at, middle) = rest.split_last().ok_or_else(|| SketchError::Invalid("no end point".into()))?;
(middle, Some(*end_at))
};
for p in middle {
pts.push(place(self, None, *p)?);
}
match end_at {
Some(p) => pts.push(place(self, reuse.and_then(|r| r.1), p)?),
None => pts.push(first),
}
Ok(pts)
}
/// Move an equation curve's points to `at` (one position per point of the spline, in order;
/// a closed curve's last point is its first one, and its position is skipped). Every point
/// is fixed. Used each time the formulas are worked out again.
pub fn place_equation_points(&mut self, curve: usize, at: &[Vec2]) -> Result<()> {
let Some(CurveKind::Spline { pts, control: false, .. }) = self.curves.get(curve).map(|c| &c.kind) else {
return Err(SketchError::Invalid("an equation curve must be a fit point spline".into()));
};
if pts.len() != at.len() {
return Err(SketchError::Invalid("the equation curve's point count changed".into()));
}
let moves: Vec<(usize, Vec2)> = pts.iter().copied().zip(at.iter().copied()).collect();
for (i, p) in moves {
let p = Self::check_pos(p)?;
let q = self.points.get_mut(i).ok_or_else(|| SketchError::Invalid("missing equation curve point".into()))?;
q.pos = p;
q.fixed = true;
}
Ok(())
}
/// The equation curves' formulas, each with the curve's variable: a parameter of that name
/// is hidden from the formulas by it, so a rename of it must not touch them.
pub fn equation_exprs_mut(&mut self) -> Vec<(&'static str, &mut String)> {
self.equations
.iter_mut()
.flat_map(|e| {
let variable = e.variable();
e.list_mut().into_iter().map(move |s| (variable, s))
})
.collect()
}
/// Forget the equations of removed curves.
pub(crate) fn prune_equations(&mut self, removed: &std::collections::BTreeSet<String>) {
self.equations.retain(|e| !removed.contains(&e.curve));
}
}
+2
View File
@@ -21,6 +21,7 @@ mod curves;
mod decompose;
mod dims;
mod ends;
mod equation;
mod fonts;
mod freeform_offset;
pub mod linalg;
@@ -51,6 +52,7 @@ pub use dims::{
DimFrame, DimLayout, chain_centres, default_text, default_text_offset, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text,
};
pub use ends::{END_TOL, EndReport, EndTouch, UntiedEnd, loose_ends};
pub use equation::{EquationCurve, MAX_EQUATION_TEXT};
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check};
pub use freeform_offset::{FreeformOffset, FreeformOffsetOutput, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, TextExprs, sketch_axis_geom};
+7 -3
View File
@@ -551,8 +551,9 @@ impl Sketch {
self.links.iter().find(|l| l.id == id)
}
/// The expressions of the sketch's texts and of its points placed by parameters, each with
/// what it sets (`Text height`, `Point X`…).
/// The expressions of the sketch's texts, of its points placed by parameters and of its
/// equation curves, each with what it sets (`Text height`, `Point X`, `Curve Y`…). An equation
/// curve's own variable (`t` or `x`) is among the names its formulas refer to.
pub fn text_exprs(&self) -> Vec<(&'static str, &String)> {
self.links
.iter()
@@ -562,6 +563,7 @@ impl Sketch {
})
.flatten()
.chain(self.points.iter().filter_map(|p| p.exprs.as_deref()).flat_map(crate::PointExprs::list))
.chain(self.equations.iter().flat_map(crate::EquationCurve::list))
.collect()
}
@@ -587,7 +589,9 @@ impl Sketch {
.collect()
}
/// Mutable view of [`Sketch::text_exprs`], for renaming the parameters they refer to.
/// Mutable view of [`Sketch::text_exprs`], for renaming the parameters they refer to. An
/// equation curve's formulas are not in it: [`Sketch::equation_exprs_mut`] gives them with the
/// variable a rename must leave alone.
pub fn text_exprs_mut(&mut self) -> Vec<&mut String> {
self.links
.iter_mut()
+7 -1
View File
@@ -730,6 +730,9 @@ pub struct Sketch {
/// 3D curves (world coordinates) carried by the sketch.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wires: Vec<crate::wire::Wire>,
/// Curves drawn from formulas (#8352): the fit point splines they place, with their formulas.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub equations: Vec<crate::equation::EquationCurve>,
/// Next number per id prefix (`l` → l1, l2…).
#[serde(default)]
counters: std::collections::BTreeMap<String, u64>,
@@ -759,6 +762,7 @@ impl Sketch {
offsets: Vec::new(),
freeform_offsets: Vec::new(),
wires: Vec::new(),
equations: Vec::new(),
counters: Default::default(),
view: SketchView::default(),
}
@@ -832,7 +836,7 @@ impl Sketch {
self.points.get(i).map(|p| p.pos)
}
fn check_pos(p: Vec2) -> Result<Vec2> {
pub(crate) fn check_pos(p: Vec2) -> Result<Vec2> {
if in_range(p) { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) }
}
@@ -1724,6 +1728,8 @@ impl Sketch {
/// Remove curves only (points stay), remapping constraints.
fn drop_curves(&mut self, which: &[usize]) {
self.prune_offsets_for_removed_curves(which);
let removed: std::collections::BTreeSet<String> = which.iter().filter_map(|i| self.curves.get(*i).map(|c| c.id.clone())).collect();
self.prune_equations(&removed);
let mut cmap = vec![None; self.curves.len()];
let mut n = 0;
for (i, slot) in cmap.iter_mut().enumerate() {
+1
View File
@@ -15,6 +15,7 @@ pub const DOCUMENTED: &[&str] = &[
"occurrence.move",
"parameters.change",
"pointcloud.insert",
"sketch.equation_curve",
"solid.align",
"solid.chamfer",
"solid.combine",
+8
View File
@@ -1128,6 +1128,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
v.push(cmd(app, "sketch.edit_text", "Edit Text"));
v.extend(explode_text(app));
}
if let Some(curve) = crate::sketch_modify_ui::selected_equation(app) {
v.push(act("ui.editEquationCurve", "Edit Equation Curve", "equation_curve").with(json!({ "curve": curve })));
}
v.push(cmd(app, "sketch.move", "Move/Copy").key("M"));
v.push(cmd(app, "sketch.transform", "Transform").key("G"));
if !curves.is_empty() {
@@ -1353,6 +1356,11 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
None => drop(crate::edit_clipboard::paste_into(app, Some(at), item.id == "ui.pasteNew", into)),
}
}
"ui.editEquationCurve" => {
if let Some(id) = p.get("curve").and_then(Value::as_str) {
crate::sketch_modify_ui::edit_equation(app, id);
}
}
"ui.editSketchPattern" => {
if let Some(id) = p.get("pattern").and_then(Value::as_str) {
crate::sketch_modify_ui::edit_pattern(app, id);
+40 -1
View File
@@ -14,7 +14,9 @@ fn fixture() -> Harness {
fn picked(h: &Harness, command: &str) -> Dialog {
let mut d = Dialog::for_command(&h.app, command).unwrap();
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
if let Kind::SketchModify(k) = &d.kind {
if let Kind::SketchModify(k) = &d.kind
&& !d.inputs.is_empty()
{
d.inputs[0].items = vec![if matches!(k, Mod::Corner { .. }) {
Sel::SketchPoint { id: sk.points.iter().find(|p| p.id != "origin").unwrap().id.clone() }
} else {
@@ -87,6 +89,43 @@ form_case!(fillet, "sketch.fillet", ["Radius"]);
form_case!(chamfer_equal, "sketch.chamfer.equal_distance", ["Chamfer Type", "Distance"]);
form_case!(chamfer_two, "sketch.chamfer.two_distance", ["Chamfer Type", "Distance 1", "Distance 2"]);
form_case!(chamfer_angle, "sketch.chamfer.distance_angle", ["Chamfer Type", "Distance 1", "Angle"]);
form_case!(equation_curve, "sketch.equation_curve", ["Form", "x(t)", "y(t)", "Start", "End", "Tolerance"]);
/// Equation Curve (#8352): OK draws the curve from the window's formulas; its context menu
/// reopens the window on them, and OK then edits that curve instead of drawing another.
#[test]
fn equation_curve_window_draws_and_reopens_on_its_formulas() {
let mut h = fixture();
h.app.start("sketch.equation_curve");
h.settle();
let Some(Kind::SketchModify(Mod::Equation { form, y, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("no equation window") };
*form = 1;
*y = "10 + 2 * sin(x / 4)".into();
h.settle();
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
h.settle();
assert!(h.app.dialog.is_none());
let sid = h.app.session.active_sketch.unwrap();
let sk = h.app.session.doc.sketch(sid).unwrap().clone();
let eq = sk.equations.first().cloned().expect("an equation curve");
assert_eq!((eq.x.as_deref(), eq.y.as_str(), eq.end.as_str()), (None, "10 + 2 * sin(x / 4)", "2 * pi"));
h.app.session.selection = vec![Sel::SketchCurve { id: eq.curve.clone(), sketch: None }];
assert!(crate::sketch_modify_ui::selected_equation(&h.app).is_some());
assert!(crate::sketch_modify_ui::edit_equation(&mut h.app, &eq.curve));
h.settle();
let Some(Kind::SketchModify(Mod::Equation { form, y, end, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) else {
panic!("no equation window")
};
assert_eq!((*form, y.as_str()), (1, "10 + 2 * sin(x / 4)"));
*end = "20".into();
let curves = sk.curves.len();
h.settle();
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
h.settle();
let sk = h.app.session.doc.sketch(sid).unwrap();
assert_eq!(sk.curves.len(), curves, "an edit, not another curve");
assert_eq!(sk.equation_of(&eq.curve).map(|e| e.end.as_str()), Some("20"));
}
#[test]
fn create_sketch_uses_card_and_still_starts_immediately_on_plane_pick() {
+84
View File
@@ -20,6 +20,9 @@
//! second count nor either spacing, so a user who wanted five instances got three and deleted
//! two. The circular pattern is the same gap and joins this module later.
//!
//! The equation curve (#8352) has no picks: its window is the form for the formulas, the range and
//! the tolerance, and reopens on an existing equation curve to edit them.
//!
//! The Chamfer Type choice is a row of icon buttons rather than a drop-down: the modes differ
//! in shape, and a picture of each shape names it better than a line of text.
@@ -56,8 +59,13 @@ pub const MODIFIERS: &[ToolModifiers] = &[
ToolModifiers { tool: "sketch.chamfer.equal_distance", scopes: SELECT_ONLY, bindings: &[], audit: Audit::VerifiedNoOp },
ToolModifiers { tool: "sketch.chamfer.two_distance", scopes: SELECT_ONLY, bindings: &[], audit: Audit::VerifiedNoOp },
ToolModifiers { tool: "sketch.chamfer.distance_angle", scopes: SELECT_ONLY, bindings: &[], audit: Audit::VerifiedNoOp },
// Formulas only: no picks and no viewport modifier chords.
ToolModifiers { tool: "sketch.equation_curve", scopes: SELECT_ONLY, bindings: &[], audit: Audit::VerifiedNoOp },
];
/// The equation curve's two forms: x(t) and y(t), or y = f(x).
pub(crate) const EQUATION_FORMS: [&str; 2] = ["Parametric", "Explicit"];
/// The three chamfer types, drawn as the cut each one makes, in `CHAMFER_TYPES` order.
const CHAMFER_TYPE_ICONS: [&str; 3] = ["sketch_chamfer_equal", "sketch_chamfer_two", "sketch_chamfer_angle"];
@@ -131,6 +139,10 @@ pub enum Mod {
/// `angle` the angle from the first line. `flip` names the two lines in the other order, so
/// the first distance lands on the other leg of the corner.
Corner { chamfer: bool, ctype: usize, size: String, distance2: String, angle: String, flip: bool },
/// `sketch.equation_curve` (or `.edit` on the curve the window's `curve` extra names): the
/// form (an index into [`EQUATION_FORMS`]), x(t) and y(t) (or y(x)), the variable's range and
/// how far the curve may stray from the formulas.
Equation { form: usize, x: String, y: String, start: String, end: String, tolerance: String },
}
impl Mod {
@@ -149,6 +161,7 @@ impl Mod {
Mod::PatternCirc { .. } => "CIRCULAR PATTERN",
Mod::Corner { chamfer: false, .. } => "FILLET",
Mod::Corner { .. } => "CHAMFER",
Mod::Equation { .. } => "EQUATION CURVE",
}
}
@@ -165,6 +178,7 @@ impl Mod {
Mod::PatternCirc { angle, .. } => Some(("Angle", ValueKind::Angle, angle)),
Mod::Corner { chamfer: false, size, .. } => Some(("Radius", ValueKind::Length, size)),
Mod::Corner { size, .. } => Some(("Distance", ValueKind::Length, size)),
Mod::Equation { .. } => None,
}
}
}
@@ -191,6 +205,7 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
// where more lines meet at its point (#1824), for both.
Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", SKETCH_POINTS | CURVES, true)],
Mod::Corner { .. } => vec![SelInput::new("Corner", SKETCH_POINTS | CURVES, true)],
Mod::Equation { .. } => Vec::new(),
}
}
@@ -244,12 +259,50 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"sketch.chamfer.equal_distance" => corner(true, 0),
"sketch.chamfer.two_distance" => corner(true, 1),
"sketch.chamfer.distance_angle" => corner(true, 2),
// A sine wave to start from: one period, 40 wide and 10 high in the design's unit.
"sketch.equation_curve" => Mod::Equation {
form: 0,
x: "20 * t / pi".into(),
y: "5 * sin(t)".into(),
start: "0".into(),
end: "2 * pi".into(),
tolerance: "0.01 mm".into(),
},
_ => return None,
};
let i = inputs(&k);
Some((Kind::SketchModify(k), i))
}
/// Reopen the equation curve window on curve `id` of the sketch being edited, with its formulas.
/// OK runs `sketch.equation_curve.edit` on it.
pub fn edit_equation(app: &mut SolveApp, id: &str) -> bool {
let Some(sid) = app.session.active_sketch else { return false };
let state = app.session.world_state();
let Some(eq) = state.sketch(sid).and_then(|s| s.sketch.equation_of(id)).cloned() else { return false };
let kind = Mod::Equation {
form: usize::from(eq.x.is_none()),
x: eq.x.clone().unwrap_or_else(|| "t".into()),
y: eq.y,
start: eq.start,
end: eq.end,
tolerance: format!("{} mm", eq.tolerance),
};
let mut dialog = Dialog::new(Kind::SketchModify(kind), Vec::new());
dialog.extra.insert("curve".into(), json!(id));
app.tool = None;
app.dialog = Some(dialog);
true
}
/// The equation curve the selection is, when it is one curve that has formulas.
pub(crate) fn selected_equation(app: &SolveApp) -> Option<String> {
let [Sel::SketchCurve { id, .. }] = app.session.selection.as_slice() else { return None };
let sid = app.session.active_sketch?;
let state = app.session.world_state();
state.sketch(sid)?.sketch.equation_of(id).map(|e| e.curve.clone())
}
/// Sketch modify inputs accept only the sketch being edited. Origin axes remain valid
/// rectangular-pattern directions, while Hole continues using its separate POINTS mask.
pub fn candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) -> Option<Sel> {
@@ -365,6 +418,7 @@ pub(crate) fn command(k: &Mod) -> &'static str {
Mod::Corner { ctype: 1, .. } => "sketch.chamfer.two_distance",
Mod::Corner { ctype: 2, .. } => "sketch.chamfer.distance_angle",
Mod::Corner { .. } => "sketch.chamfer.equal_distance",
Mod::Equation { .. } => "sketch.equation_curve",
}
}
@@ -482,6 +536,19 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod, doc: &Document) -> bool {
alignment(ui, "Alignment", a, reversed);
pattern_hint(ui);
}
Mod::Equation { form, x, y, start, end, tolerance } => {
ui_kit::choice_row(ui, "Form", "equation_form", form, &EQUATION_FORMS);
if *form == 0 {
enter |= number(ui, "x(t)", x);
enter |= number(ui, "y(t)", y);
} else {
enter |= number(ui, "y(x)", y);
}
enter |= number(ui, "Start", start);
enter |= number(ui, "End", end);
enter |= number(ui, "Tolerance", tolerance);
ui_kit::text(ui, crate::i18n::tr("ui.equation_note"), ui_kit::Text::Note("equation"));
}
Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => {
if *chamfer {
icons(ui, "Chamfer Type", &CHAMFER_TYPES, &CHAMFER_TYPE_ICONS, ctype);
@@ -941,6 +1008,23 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
_ => ("sketch.chamfer.equal_distance", p),
}
}
Mod::Equation { form, x, y, start, end, tolerance } => {
let filled = |label: &str, text: &str| -> Result<String, String> {
let text = text.trim();
if text.is_empty() { Err(format!("{label} is empty")) } else { Ok(text.to_string()) }
};
let tol = crate::length_input::eval(&app.session.doc, tolerance.trim(), ValueKind::Length).map_err(|e| format!("Tolerance: {e}"))?;
if !(tol.is_finite() && (1e-4..=10.0).contains(&tol)) {
return Err("Tolerance must be from 0.0001 mm to 10 mm".into());
}
let mut p = json!({"y": filled(if *form == 0 { "y(t)" } else { "y(x)" }, y)?, "start": filled("Start", start)?, "end": filled("End", end)?, "tolerance": tol});
if *form == 0 {
p["x"] = json!(filled("x(t)", x)?);
} else {
p["explicit"] = json!(true);
}
(if extra.contains_key("curve") { "sketch.equation_curve.edit" } else { "sketch.equation_curve" }, p)
}
};
if let Value::Object(m) = &mut params {
for (key, value) in extra {
+12
View File
@@ -187,6 +187,18 @@ While drawing a fit spline, click its starting point after three distinct sample
it smoothly. Right-click finishes an ordinary natural fit. Offset accepts both ellipse
handles and dense fitted curves; the second click sets the side and distance from the curve.
**Equation Curve** draws a curve from formulas instead of clicks: x(t) and y(t) over a range of
t, or y = f(x) over a range of x. The formulas use the expression language and the design's
parameters, so `x = r * (cos(t) + t * sin(t))`, `y = r * (sin(t) - t * cos(t))` with t from 0 to
1 is the involute of a base circle of radius `r`. The variable is a plain number: `sin(t)` takes
it in radians, and a result without a unit is in the design's unit. The curve is a fit point
spline through points worked out from the formulas, as many as keep it within the tolerance
(0.01 mm unless you set another), up to 500; a curve that ends where it starts closes smoothly.
Its points are fixed, so other curves can start at its ends and close a profile for Extrude or
Revolve, and it can be a sweep path. When a parameter it uses changes, the points follow.
Right-click the curve and choose **Edit Equation Curve** to change the formulas
(`sketch.equation_curve` and `sketch.equation_curve.edit` from scripts and agents).
While drawing in a planar sketch, **Construction** (X) changes the line type without
ending the tool or losing picked points and inline values. The preview and hover feedback
keep construction dashes and centerline dash-dot patterns visible. With curves selected,
+13
View File
@@ -206,6 +206,19 @@ A dimension can drive the sketch, or be driven by it and only show the value.
- **Elsewhere:** dimension, dimensional constraint, driving dimension.
- **For translators:** not a drawing dimension and not a measured value.
### equation curve
A sketch curve whose shape comes from formulas over a range of one variable.
It is a fit point spline through fixed points that the formulas place, and they place them again
when the design's parameters change. Compare *spline*, whose points are placed by hand.
- **In SolidCraft:** `sketch.equation_curve`, `sketch.equation_curve.edit`; the `equations`
design-file key of a sketch.
- **Don't use:** formula curve, function curve, parametric curve (for the command).
- **Elsewhere:** equation-driven curve, curve from equation, function plot.
- **For translators:** "equation" is a mathematical formula, not a balance or an equality constraint.
### feature
One step in the timeline that creates or changes geometry, such as an extrude, a fillet or a
+2 -2
View File
@@ -42,7 +42,7 @@ SKETCH appears during sketch editing; with Preferences › Interface › **Keep
| MANAGE | DESIGN | **`document.units`**, **`timeline.compute_all`**, `edit.checkpoint`, `edit.restore_checkpoint` |
| VIEW | VIEWS | **`view.apply`**, **`view.save`**, `view.rename`, `view.delete`, `view.list`, `view.navigate` |
| VIEW | SELECT | Selection-filter controls (existing UI, no command ids) |
| SKETCH | CREATE | **`sketch.line`**, **`sketch.rectangle.two_point`**, **`sketch.circle.center`**, **`sketch.arc.three_point`**, `sketch.infinite`, `sketch.rectangle.three_point`, `sketch.rectangle.rounded`, `sketch.rectangle.center`, `sketch.circle.two_point`, `sketch.circle.three_point`, `sketch.arc.center_point`, `sketch.polygon.inscribed`, `sketch.polygon.circumscribed`, `sketch.polygon.edge`, `sketch.slot.center_to_center`, `sketch.slot.overall`, `sketch.point`, `sketch.line.midpoint`, `sketch.arc.tangent`, `sketch.circle.two_tangent`, `sketch.circle.three_tangent`, `sketch.slot.center_point`, `sketch.slot.arc_three_point`, `sketch.slot.arc_center`, `sketch.ellipse`, `sketch.spline.fit_point`, `sketch.spline.control_point`, `sketch.spline.control_point_5`, `sketch.conic`, `sketch.centerline`, `sketch.text` |
| SKETCH | CREATE | **`sketch.line`**, **`sketch.rectangle.two_point`**, **`sketch.circle.center`**, **`sketch.arc.three_point`**, `sketch.infinite`, `sketch.rectangle.three_point`, `sketch.rectangle.rounded`, `sketch.rectangle.center`, `sketch.circle.two_point`, `sketch.circle.three_point`, `sketch.arc.center_point`, `sketch.polygon.inscribed`, `sketch.polygon.circumscribed`, `sketch.polygon.edge`, `sketch.slot.center_to_center`, `sketch.slot.overall`, `sketch.point`, `sketch.line.midpoint`, `sketch.arc.tangent`, `sketch.circle.two_tangent`, `sketch.circle.three_tangent`, `sketch.slot.center_point`, `sketch.slot.arc_three_point`, `sketch.slot.arc_center`, `sketch.ellipse`, `sketch.spline.fit_point`, `sketch.spline.control_point`, `sketch.spline.control_point_5`, `sketch.conic`, `sketch.centerline`, `sketch.text`, `sketch.equation_curve` |
| SKETCH | MODIFY | **`sketch.trim`**, **`sketch.offset`**, **`sketch.fillet`**, `sketch.copy`, `sketch.cut`, `sketch.paste`, `sketch.blend_curve`, `sketch.chamfer.equal_distance`, `sketch.chamfer.distance_angle`, `sketch.chamfer.two_distance`, `sketch.extend`, `sketch.break`, `sketch.scale`, `sketch.move` |
| SKETCH | PATTERN | **`sketch.mirror`**, **`sketch.pattern.circular`**, **`sketch.pattern.rectangular`** |
| SKETCH | CONSTRAIN | **`sketch.dimension`**, **`sketch.constraint.horizontal_vertical`**, **`sketch.constraint.coincident`**, **`sketch.constraint.tangent`**, **`sketch.constraint.equal`**, **`sketch.constraint.parallel`**, **`sketch.constraint.perpendicular`**, `sketch.construction`, `sketch.constrainer`, `sketch.constraint.fix`, `sketch.constraint.midpoint`, `sketch.constraint.concentric`, `sketch.constraint.collinear`, `sketch.constraint.symmetry`, `sketch.constraint.curvature`, `sketch.constraint.polygon`, `sketch.auto_constrain`, `sketch.auto_constrain_from_datum` |
@@ -80,7 +80,7 @@ Explicit command-id lists override the default placement mapping. Shared command
| SHEET METAL / MODIFY | SHEET METAL / FLAT | `sheet.unfold`, `sheet.refold` |
| SKETCH / CONSTRAINTS | SKETCH / CONSTRAIN | `sketch.constraint.horizontal_vertical`, `sketch.constraint.coincident`, `sketch.constraint.tangent`, `sketch.constraint.equal`, `sketch.constraint.parallel`, `sketch.constraint.perpendicular`, `sketch.constraint.fix`, `sketch.constraint.midpoint`, `sketch.constraint.concentric`, `sketch.constraint.collinear`, `sketch.constraint.symmetry`, `sketch.constraint.curvature`, `sketch.constraint.polygon`, `sketch.auto_constrain`, `sketch.auto_constrain_from_datum` |
| SKETCH / CREATE | SKETCH / CONSTRAIN | `sketch.dimension`, `sketch.construction`, `sketch.constrainer` |
| SKETCH / CREATE | SKETCH / CREATE | `sketch.line`, `sketch.rectangle.two_point`, `sketch.rectangle.three_point`, `sketch.rectangle.rounded`, `sketch.rectangle.center`, `sketch.circle.center`, `sketch.circle.two_point`, `sketch.circle.three_point`, `sketch.arc.three_point`, `sketch.arc.center_point`, `sketch.polygon.inscribed`, `sketch.polygon.circumscribed`, `sketch.polygon.edge`, `sketch.slot.center_to_center`, `sketch.slot.overall`, `sketch.point`, `sketch.line.midpoint`, `sketch.arc.tangent`, `sketch.circle.two_tangent`, `sketch.circle.three_tangent`, `sketch.slot.center_point`, `sketch.slot.arc_three_point`, `sketch.slot.arc_center`, `sketch.ellipse`, `sketch.spline.fit_point`, `sketch.spline.control_point`, `sketch.spline.control_point_5`, `sketch.conic`, `sketch.centerline`, `sketch.text` |
| SKETCH / CREATE | SKETCH / CREATE | `sketch.line`, `sketch.rectangle.two_point`, `sketch.rectangle.three_point`, `sketch.rectangle.rounded`, `sketch.rectangle.center`, `sketch.circle.center`, `sketch.circle.two_point`, `sketch.circle.three_point`, `sketch.arc.three_point`, `sketch.arc.center_point`, `sketch.polygon.inscribed`, `sketch.polygon.circumscribed`, `sketch.polygon.edge`, `sketch.slot.center_to_center`, `sketch.slot.overall`, `sketch.point`, `sketch.line.midpoint`, `sketch.arc.tangent`, `sketch.circle.two_tangent`, `sketch.circle.three_tangent`, `sketch.slot.center_point`, `sketch.slot.arc_three_point`, `sketch.slot.arc_center`, `sketch.ellipse`, `sketch.spline.fit_point`, `sketch.spline.control_point`, `sketch.spline.control_point_5`, `sketch.conic`, `sketch.centerline`, `sketch.text`, `sketch.equation_curve` |
| SKETCH / CREATE | SKETCH / REFERENCE | `sketch.project`, `sketch.intersect`, `sketch.include_3d`, `sketch.project_to_surface`, `sketch.intersection_curve`, `sketch.spun_profile`, `sketch.isoparametric_curve`, `sketch.fit_curves_to_section` |
| SKETCH / FINISH SKETCH | SKETCH / FINISH SKETCH | `sketch.finish`, `sketch.finish_auto_constrain` |
| SKETCH / INSERT | INSERT / 2D | `sketch.insert_dxf`, `sketch.insert_svg` |