mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* 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:
@@ -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 |
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
@@ -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::{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub const DOCUMENTED: &[&str] = &[
|
||||
"occurrence.move",
|
||||
"parameters.change",
|
||||
"pointcloud.insert",
|
||||
"sketch.equation_curve",
|
||||
"solid.align",
|
||||
"solid.chamfer",
|
||||
"solid.combine",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user