Sketch: Divide Curve places N-1 points on a curve at equal spacing (#8969) (#9664)

* claim: #8969

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch: Divide Curve places points at equal lengths along curves (#8969)

A new PointAlong constraint holds a point at its share of a curve's
length (measured along the exact curve), so the points follow edits.
sketch.point.divide places them on lines, arcs, circles, ellipses,
splines and conics; trim and break loosen them to point-on-curve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Divide Curve: use the solidcore_record segment and vector types after the layer split

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Divide Curve: exempt sketch.point.divide from kernel cases and show PointAlong glyphs

PointAlong constraints now get a glyph on their point (the midpoint icon),
light their point and curve, and hold an end against Extend like
PointOnCurve does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-11-06 16:44:37 +00:00
1 parent c36860e773
commit a2218e8a8d
18 files changed
+658 -28

No files matched your search

+1 -1
View File
@@ -89,7 +89,7 @@ pub(crate) fn glyph_icon(name: &str) -> Option<&'static str> {
"Smooth" => "c_smooth",
"Equal" => "c_equal",
"Concentric" => "c_concentric",
"MidPoint" => "c_midpoint",
"MidPoint" | "PointAlong" => "c_midpoint",
"Symmetry" => "c_symmetry",
"Fix" => "c_fix",
_ => return None,
@@ -0,0 +1,114 @@
{
"en": {
"cmd.sketch_point_divide": "Divide Curve",
"help.sketch.point.divide": "Places points that divide the picked curves into equal lengths. The points stay on each curve and keep their spacing when it changes.",
"ui.heading_divide_curve": "DIVIDE CURVE",
"ui.segments": "Segments",
"sketch.divide_hint": "Points divide each curve into equal lengths and keep them as it changes.",
"Select the curves to divide first": "Select the curves to divide first"
},
"ar": {
"cmd.sketch_point_divide": "تقسيم المنحنى",
"help.sketch.point.divide": "يضع نقاطًا تقسّم المنحنيات المختارة إلى أطوال متساوية. تبقى النقاط على كل منحنى وتحافظ على تباعدها عند تغيّره.",
"ui.heading_divide_curve": "تقسيم المنحنى",
"ui.segments": "المقاطع",
"sketch.divide_hint": "تقسّم النقاط كل منحنى إلى أطوال متساوية وتحافظ عليها عند تغيّره.",
"Select the curves to divide first": "حدّد أولًا المنحنيات المراد تقسيمها"
},
"de": {
"cmd.sketch_point_divide": "Kurve teilen",
"help.sketch.point.divide": "Setzt Punkte, die die gewählten Kurven in gleich lange Abschnitte teilen. Die Punkte bleiben auf jeder Kurve und behalten ihren Abstand, wenn sie sich ändert.",
"ui.heading_divide_curve": "KURVE TEILEN",
"ui.segments": "Segmente",
"sketch.divide_hint": "Punkte teilen jede Kurve in gleich lange Abschnitte und behalten sie bei, wenn sie sich ändert.",
"Select the curves to divide first": "Zuerst die zu teilenden Kurven auswählen"
},
"es": {
"cmd.sketch_point_divide": "Dividir curva",
"help.sketch.point.divide": "Coloca puntos que dividen las curvas elegidas en longitudes iguales. Los puntos permanecen en cada curva y mantienen su separación cuando esta cambia.",
"ui.heading_divide_curve": "DIVIDIR CURVA",
"ui.segments": "Segmentos",
"sketch.divide_hint": "Los puntos dividen cada curva en longitudes iguales y las mantienen cuando cambia.",
"Select the curves to divide first": "Seleccione primero las curvas que desea dividir"
},
"fr": {
"cmd.sketch_point_divide": "Diviser la courbe",
"help.sketch.point.divide": "Place des points qui divisent les courbes choisies en longueurs égales. Les points restent sur chaque courbe et gardent leur espacement quand elle change.",
"ui.heading_divide_curve": "DIVISER LA COURBE",
"ui.segments": "Segments",
"sketch.divide_hint": "Les points divisent chaque courbe en longueurs égales et les gardent quand elle change.",
"Select the curves to divide first": "Sélectionner d'abord les courbes à diviser"
},
"he": {
"cmd.sketch_point_divide": "חלוקת עקומה",
"help.sketch.point.divide": "מציב נקודות המחלקות את העקומות שנבחרו לאורכים שווים. הנקודות נשארות על כל עקומה ושומרות על המרווח כשהיא משתנה.",
"ui.heading_divide_curve": "חלוקת עקומה",
"ui.segments": "מקטעים",
"sketch.divide_hint": "הנקודות מחלקות כל עקומה לאורכים שווים ושומרות עליהם כשהיא משתנה.",
"Select the curves to divide first": "יש לבחור תחילה את העקומות לחלוקה"
},
"it": {
"cmd.sketch_point_divide": "Dividi curva",
"help.sketch.point.divide": "Posiziona punti che dividono le curve scelte in lunghezze uguali. I punti restano su ogni curva e ne mantengono la spaziatura quando cambia.",
"ui.heading_divide_curve": "DIVIDI CURVA",
"ui.segments": "Segmenti",
"sketch.divide_hint": "I punti dividono ogni curva in lunghezze uguali e le mantengono quando cambia.",
"Select the curves to divide first": "Selezionare prima le curve da dividere"
},
"ja": {
"cmd.sketch_point_divide": "曲線を分割",
"help.sketch.point.divide": "選択した曲線を等しい長さに分割する点を配置します。点は各曲線上にとどまり、曲線が変わっても間隔を保ちます。",
"ui.heading_divide_curve": "曲線を分割",
"ui.segments": "分割数",
"sketch.divide_hint": "点は各曲線を等しい長さに分割し、曲線が変わってもその分割を保ちます。",
"Select the curves to divide first": "先に分割する曲線を選択してください"
},
"ko": {
"cmd.sketch_point_divide": "곡선 분할",
"help.sketch.point.divide": "선택한 곡선을 같은 길이로 나누는 점을 배치합니다. 점은 각 곡선 위에 남고 곡선이 바뀌어도 간격을 유지합니다.",
"ui.heading_divide_curve": "곡선 분할",
"ui.segments": "세그먼트 수",
"sketch.divide_hint": "점은 각 곡선을 같은 길이로 나누고 곡선이 바뀌어도 이를 유지합니다.",
"Select the curves to divide first": "먼저 분할할 곡선을 선택하세요"
},
"pt-BR": {
"cmd.sketch_point_divide": "Dividir curva",
"help.sketch.point.divide": "Coloca pontos que dividem as curvas escolhidas em comprimentos iguais. Os pontos ficam em cada curva e mantêm o espaçamento quando ela muda.",
"ui.heading_divide_curve": "DIVIDIR CURVA",
"ui.segments": "Segmentos",
"sketch.divide_hint": "Os pontos dividem cada curva em comprimentos iguais e os mantêm quando ela muda.",
"Select the curves to divide first": "Selecione primeiro as curvas a dividir"
},
"ru": {
"cmd.sketch_point_divide": "Разделить кривую",
"help.sketch.point.divide": "Ставит точки, делящие выбранные кривые на равные по длине части. Точки остаются на каждой кривой и сохраняют шаг при её изменении.",
"ui.heading_divide_curve": "РАЗДЕЛИТЬ КРИВУЮ",
"ui.segments": "Сегменты",
"sketch.divide_hint": "Точки делят каждую кривую на равные по длине части и сохраняют их при её изменении.",
"Select the curves to divide first": "Сначала выберите кривые для деления"
},
"vi": {
"cmd.sketch_point_divide": "Chia đường cong",
"help.sketch.point.divide": "Đặt các điểm chia các đường cong đã chọn thành những đoạn dài bằng nhau. Các điểm nằm trên mỗi đường cong và giữ khoảng cách khi đường cong thay đổi.",
"ui.heading_divide_curve": "CHIA ĐƯỜNG CONG",
"ui.segments": "Số đoạn",
"sketch.divide_hint": "Các điểm chia mỗi đường cong thành những đoạn dài bằng nhau và giữ nguyên khi đường cong thay đổi.",
"Select the curves to divide first": "Hãy chọn các đường cong cần chia trước"
},
"zh": {
"cmd.sketch_point_divide": "等分曲线",
"help.sketch.point.divide": "放置将所选曲线分成等长段的点。这些点留在各曲线上,曲线改变时保持间距。",
"ui.heading_divide_curve": "等分曲线",
"ui.segments": "段数",
"sketch.divide_hint": "点将每条曲线分成等长的段,并在曲线改变时保持等分。",
"Select the curves to divide first": "请先选择要等分的曲线"
},
"zh-TW": {
"cmd.sketch_point_divide": "等分曲線",
"help.sketch.point.divide": "放置將所選曲線分成等長段的點。這些點留在各曲線上,曲線改變時保持間距。",
"ui.heading_divide_curve": "等分曲線",
"ui.segments": "段數",
"sketch.divide_hint": "點將每條曲線分成等長的段,並在曲線改變時保持等分。",
"Select the curves to divide first": "請先選擇要等分的曲線"
}
}
@@ -0,0 +1,5 @@
//! A sketch constraint can hold a point at a share of a curve's length (`"type": "point_along"`,
//! with the point `p`, the curve `c` and the share `at` from 0 to 1), as Divide Curve places its
//! points (#8969). Older designs need no transformation: they have no such constraint. The last
//! release refuses a design that has one and asks for an update, instead of failing to read its
//! sketch.
+2
View File
@@ -118,6 +118,7 @@ pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_construction;
mod sketch_create;
mod sketch_divide;
mod sketch_ends;
mod sketch_equation;
mod sketch_freeform;
@@ -315,6 +316,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch::COMMANDS.iter());
v.extend(sketch_create::COMMANDS.iter());
v.extend(sketch_construction::COMMANDS.iter());
v.extend(sketch_divide::COMMANDS.iter());
v.extend(sketch_equation::COMMANDS.iter());
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_import::COMMANDS.iter());
+1 -1
View File
@@ -500,7 +500,7 @@ fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option<Vec2> {
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } => pt(p),
// A pierce is drawn at the point it holds.
Pierce { p, .. } => pt(p),
PointOnCurve { p, .. } => pt(p),
PointOnCurve { p, .. } | PointAlong { p, .. } => pt(p),
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
Horizontal { l } | Vertical { l } => mid(l),
Parallel { a, .. } | Perpendicular { a, .. } | Collinear { a, .. } | Equal { a, .. } | Concentric { a, .. } | Angle { a, .. } => mid(a),
+90
View File
@@ -0,0 +1,90 @@
//! Divide Curve (#8969): points that divide sketch curves into equal lengths.
//!
//! Each point is held by a [`ConstraintKind::PointAlong`] at its share k/N of the curve's length,
//! so it stays on the curve and keeps its share when the curve is edited, dragged or
//! dimensioned. The length is measured along the exact curve (arc length), not its parameter:
//! a spline's points are evenly spaced along it however its fit or control points are spread.
//! The curve itself is left as it is. Curves of 3D sketches and model edges are not divided yet.
use serde_json::{Value, json};
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch, point_along};
use super::sketch::{add_c, edit};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, string_list};
use crate::{Result, Session};
/// Most segments one curve is divided into.
pub(crate) const MAX_SEGMENTS: usize = 500;
/// Most curves one command divides.
const MAX_CURVES: usize = 200;
/// Most points one command adds.
const MAX_POINTS: usize = 2_000;
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("sketch.point.divide", "Divide Curve", divide)
.at("SKETCH", "CREATE")
.icon("point")
.enabled(in_sketch)
.params(concat!(
"curve: id | curves: [ids] (each divided on its own); segments: whole number from 2 to 500. Places points that divide each curve into `segments` pieces of equal length along it (arc length): segments − 1 points on an open curve (line, arc, spline, conic), `segments` points on a closed one (a circle from its point on sketch +X, an ellipse from its major-axis end, a closed spline from its first point). Each point is constrained to its share of the curve's length, so it stays on the curve and keeps equal spacing as the curve changes; the curve is not changed. Infinite lines are refused. Returns points: [ids], per curve in order, and constraints: [ids]",
created_doc!()
))];
/// Whether curve `c` is closed: its points go all the way round, from its start.
fn closed(sk: &Sketch, c: usize) -> bool {
matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. } | CurveKind::Spline { periodic: true, .. }))
}
/// The shares of the length where the points of `segments` equal pieces go.
pub(crate) fn shares(segments: usize, closed: bool) -> Vec<f64> {
let first = usize::from(!closed);
(first..segments).map(|k| k as f64 / segments as f64).collect()
}
fn divide(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.point.divide";
let mut ids = string_list(cmd, p, "curves")?;
ids.extend(string_list(cmd, p, "curve")?);
if ids.is_empty() {
return Err(bad(cmd, "`curve` (or `curves`) must name the curves to divide"));
}
if ids.len() > MAX_CURVES {
return Err(bad(cmd, format!("at most {MAX_CURVES} curves at once")));
}
let n = num(p, "segments")
.filter(|n| n.fract() == 0.0 && *n >= 2.0 && *n <= MAX_SEGMENTS as f64)
.ok_or_else(|| bad(cmd, format!("`segments` must be a whole number from 2 to {MAX_SEGMENTS}")))? as usize;
let ((points, constraints), info) = edit(s, p, cmd, true, |sk, _| {
let mut curves = Vec::new();
for id in &ids {
let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
let curve = sk.curves.get(c).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
if curve.infinite {
return Err(bad(cmd, format!("`{id}` is an infinite line: it has no length to divide")));
}
if !curves.contains(&c) {
curves.push(c);
}
}
let total: usize = curves.iter().map(|c| shares(n, closed(sk, *c)).len()).sum();
if total > MAX_POINTS {
return Err(bad(cmd, format!("that would add {total} points; at most {MAX_POINTS} at once")));
}
let (mut points, mut constraints) = (Vec::new(), Vec::new());
for c in curves {
for at in shares(n, closed(sk, c)) {
let id = sk.curves.get(c).map(|x| x.id.clone()).unwrap_or_default();
let pos = point_along(sk, c, at).ok_or_else(|| bad(cmd, format!("`{id}` has no length to divide")))?;
let q = sk.add_point(pos, None)?;
constraints.push(add_c(sk, ConstraintKind::PointAlong { p: q, c, at })?);
points.push(sk.points.get(q).map(|x| x.id.clone()).unwrap_or_default());
}
}
Ok((points, constraints))
})?;
Ok(json!({"points": points, "constraints": constraints, "sketch": info}))
}
#[cfg(test)]
#[path = "sketch_divide_tests.rs"]
mod tests;
@@ -0,0 +1,212 @@
//! `sketch.point.divide` (#8969): points dividing sketch curves into equal lengths, held at their
//! shares of the length as the curves change.
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_geom::Vec2Geom as _;
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
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 sketch(s: &Session) -> Sketch {
s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone()
}
fn pt(sk: &Sketch, r: &str) -> Vec2 {
sk.point(sk.resolve_point(r).unwrap()).unwrap()
}
fn strs(v: &Value) -> Vec<String> {
v.as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect()
}
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
fn line_ends(sk: &Sketch, l: &str) -> (Vec2, Vec2) {
let Some(CurveKind::Line { a, b }) = sk.curves.get(sk.curve_index(l).unwrap()).map(|c| &c.kind) else { panic!("{l} is not a line") };
(sk.point(*a).unwrap(), sk.point(*b).unwrap())
}
/// Length along `poly` to the point of it nearest `q`, and how far `q` is from it.
fn station(poly: &[Vec2], q: Vec2) -> (f64, f64) {
let mut best = (0.0, f64::INFINITY);
let mut run_len = 0.0;
for w in poly.windows(2) {
let (a, b) = (w[0], w[1]);
let d = b - a;
let t = if d.len2() > 0.0 { ((q - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
let dist = q.dist(a + d * t);
if dist < best.1 {
best = (run_len + d.len() * t, dist);
}
run_len += d.len();
}
best
}
#[test]
fn a_line_divided_in_four_keeps_its_quarters_when_it_grows() {
let mut s = new_sketch();
let l = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0]], "infer": false}))["curves"])[0].clone();
let r = run(&mut s, "sketch.point.divide", json!({"curve": l, "segments": 4}));
let points = strs(&r["points"]);
assert_eq!(points.len(), 3, "{r}");
assert_eq!(strs(&r["created"]["points"]), points, "{r}");
assert_eq!(strs(&r["constraints"]).len(), 3, "{r}");
let sk = sketch(&s);
for (k, id) in points.iter().enumerate() {
let want = Vec2::new(25.0 * (k + 1) as f64, 0.0);
assert!(pt(&sk, id).dist(want) < 1e-9, "{id}: {:?}", pt(&sk, id));
}
// The line itself is unchanged: still one line between the same two points.
assert_eq!(sk.curves.len(), 1);
assert_eq!(sk.constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::PointAlong { .. })).count(), 3);
// Lengthen and turn the line: the points keep their quarters.
run(&mut s, "sketch.dimension", json!({"entities": [l], "type": "length", "value": 200}));
let (_, b) = line_ends(&sketch(&s), &l);
run(&mut s, "sketch.drag", json!({"entity": format!("{l}.end"), "from": [b.x, b.y], "to": [b.x, b.y + 80.0]}));
let sk = sketch(&s);
let (a, b) = line_ends(&sk, &l);
assert!((a.dist(b) - 200.0).abs() < 1e-6, "{a:?} {b:?}");
assert!(b.y.abs() > 1.0, "the drag turned the line: {b:?}");
for (k, id) in points.iter().enumerate() {
let want = a + (b - a) * ((k + 1) as f64 / 4.0);
assert!(pt(&sk, id).dist(want) < 1e-6, "{id}: {:?} want {want:?}", pt(&sk, id));
}
}
#[test]
fn an_arc_is_divided_by_its_length_and_a_circle_all_the_way_round() {
let mut s = new_sketch();
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [20, 0], "sweep": 90, "id": "arc"}));
let r = run(&mut s, "sketch.point.divide", json!({"curve": "arc", "segments": 3}));
let sk = sketch(&s);
let points = strs(&r["points"]);
assert_eq!(points.len(), 2, "{r}");
for (k, id) in points.iter().enumerate() {
let want = Vec2::from_angle(std::f64::consts::FRAC_PI_6 * (k + 1) as f64) * 20.0;
assert!(pt(&sk, id).dist(want) < 1e-6, "{id}: {:?} want {want:?}", pt(&sk, id));
}
run(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": 10, "id": "c"}));
let r = run(&mut s, "sketch.point.divide", json!({"curves": ["c"], "segments": 4}));
let points = strs(&r["points"]);
assert_eq!(points.len(), 4, "a closed curve gets as many points as pieces: {r}");
run(&mut s, "sketch.dimension", json!({"entities": ["c"], "type": "diameter", "value": 30}));
let sk = sketch(&s);
let ci = sk.curve_index("c").unwrap();
let CurveKind::Circle { c, .. } = sk.curves[ci].kind else { panic!() };
let centre = sk.point(c).unwrap();
for (k, id) in points.iter().enumerate() {
let want = centre + Vec2::from_angle(std::f64::consts::FRAC_PI_2 * k as f64) * 15.0;
assert!(pt(&sk, id).dist(want) < 1e-6, "{id}: {:?} want {want:?}", pt(&sk, id));
}
}
#[test]
fn a_spline_is_divided_into_equal_lengths_along_it() {
let mut s = new_sketch();
// Fit points bunched at one end: equal parameter steps would not be equal lengths.
let sp = strs(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [2, 3], [4, 4], [40, 10], [80, -10]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.point.divide", json!({"curve": sp, "segments": 5}));
let points = strs(&r["points"]);
assert_eq!(points.len(), 4, "{r}");
let check = |sk: &Sketch| {
let poly = sk.polyline(sk.curve_index(&sp).unwrap());
let total: f64 = poly.windows(2).map(|w| w[0].dist(w[1])).sum();
for (k, id) in points.iter().enumerate() {
let (along, off) = station(&poly, pt(sk, id));
assert!(off < 0.05, "{id} is {off} off the spline");
let want = total * (k + 1) as f64 / 5.0;
assert!((along - want).abs() < 0.005 * total, "{id} at {along} of {total}, want {want}");
}
};
check(&sketch(&s));
// Move a fit point: the points slide to the new equal lengths.
run(&mut s, "sketch.drag", json!({"entity": format!("{sp}.end"), "from": [80, -10], "to": [60, 30]}));
let sk = sketch(&s);
assert!(pt(&sk, &format!("{sp}.end")).dist(Vec2::new(60.0, 30.0)) < 1e-6);
check(&sk);
}
#[test]
fn several_curves_divide_in_one_undo_step() {
let mut s = new_sketch();
let a = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": false}))["curves"])[0].clone();
let b = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 20], [30, 20]], "infer": false}))["curves"])[0].clone();
let before = sketch(&s).points.len();
let r = run(&mut s, "sketch.point.divide", json!({"curves": [a, b], "segments": 10}));
assert_eq!(strs(&r["points"]).len(), 18, "{r}");
assert_eq!(sketch(&s).points.len(), before + 18);
run(&mut s, "edit.undo", json!({}));
assert_eq!(sketch(&s).points.len(), before);
}
#[test]
fn breaking_a_divided_line_leaves_its_points_on_it() {
let mut s = new_sketch();
let l = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0]], "infer": false}))["curves"])[0].clone();
let points = strs(&run(&mut s, "sketch.point.divide", json!({"curve": l, "segments": 4}))["points"]);
run(&mut s, "sketch.break", json!({"curve": l, "points": [[60, 0]]}));
let sk = sketch(&s);
assert!(sk.constraints.iter().all(|c| !matches!(c.kind, ConstraintKind::PointAlong { .. })), "the shares of the old length no longer hold");
for (k, id) in points.iter().enumerate() {
assert!(pt(&sk, id).dist(Vec2::new(25.0 * (k + 1) as f64, 0.0)) < 1e-6, "{id} moved: {:?}", pt(&sk, id));
}
}
#[test]
fn hostile_divide_parameters_are_refused() {
let mut s = new_sketch();
let l = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0]], "infer": false}))["curves"])[0].clone();
let before = sketch(&s);
for p in [
json!({"curve": l, "segments": 1}),
json!({"curve": l, "segments": 0}),
json!({"curve": l, "segments": -3}),
json!({"curve": l, "segments": 2.5}),
json!({"curve": l, "segments": 1e300}),
json!({"curve": l, "segments": 501}),
json!({"curve": l, "segments": "four"}),
json!({"curve": l}),
json!({"segments": 4}),
json!({"curve": "nope", "segments": 4}),
json!({"curve": 7, "segments": 4}),
json!({"curves": [l, 3], "segments": 4}),
json!({"curves": vec![l.clone(); 201], "segments": 4}),
] {
assert!(s.execute("sketch.point.divide", &p).is_err(), "{p}");
assert_eq!(sketch(&s), before, "{p} changed the sketch");
}
// Five hundred segments on each of five curves is more points than one command adds.
let many: Vec<String> = (0..5)
.map(|i| strs(&run(&mut s, "sketch.line", json!({"points": [[0, i * 5 + 10], [50, i * 5 + 10]], "infer": false}))["curves"])[0].clone())
.collect();
assert!(s.execute("sketch.point.divide", &json!({"curves": many, "segments": 500})).is_err());
}
#[test]
fn a_share_out_of_range_is_not_a_constraint() {
let mut sk = Sketch::new();
let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap();
let b = sk.add_point(Vec2::new(10.0, 0.0), None).unwrap();
let l = sk.add_curve(CurveKind::Line { a, b }, None).unwrap();
let p = sk.add_point(Vec2::new(5.0, 0.0), None).unwrap();
for at in [f64::NAN, f64::INFINITY, -0.1, 1.5] {
assert!(sk.add_constraint(ConstraintKind::PointAlong { p, c: l, at }, None).is_err(), "{at}");
}
assert!(sk.add_constraint(ConstraintKind::PointAlong { p: a, c: l, at: 0.5 }, None).is_err(), "a line's own end");
assert!(sk.add_constraint(ConstraintKind::PointAlong { p, c: l, at: 0.5 }, None).is_ok());
}
+16 -2
View File
@@ -295,6 +295,18 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64], cmd: &str) -> Result<(Vec<us
Ok((pieces, pts))
}
/// Points placed at a share of curve `ci`'s length (Divide Curve) stay on what is left of it, free to
/// slide: a trim or a break changes the length they share, and holding the shares would move them.
pub(super) fn loosen_along(sk: &mut Sketch, ci: usize) {
for k in &mut sk.constraints {
if let ConstraintKind::PointAlong { p, c, .. } = k.kind
&& c == ci
{
k.kind = ConstraintKind::PointOnCurve { p, c };
}
}
}
/// Keep the pieces of a split tied together: lines collinear, arcs on one circle.
fn tie(sk: &mut Sketch, pieces: &[usize]) -> Result<()> {
for w in pieces.windows(2) {
@@ -674,6 +686,7 @@ fn hold_cut(sk: &mut Sketch, before: Option<&Sketch>, midpoints: &[usize], pi: u
/// side, or the whole curve when nothing crosses it. Returns what `sketch.trim` reports and the
/// removed piece as a polyline.
fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Value, Vec<Vec2>)> {
loosen_along(sk, ci);
let id = sk.curves.get(ci).map(|c| c.id.clone()).unwrap_or_default();
if sk.curves.get(ci).is_some_and(|c| c.infinite) {
return Err(bad(cmd, "an infinite line has no ends to trim back to: make it a normal line first"));
@@ -916,6 +929,7 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
separate_end(sk, ci, end)?;
return Ok(ids_of(sk, &[ci]));
}
loosen_along(sk, ci);
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
let r = if let Some(points) = &points {
super::sketch_freeform::break_at_points(sk, ci, points, cmd)?
@@ -1369,7 +1383,7 @@ fn arc_end_held(sk: &Sketch, ci: usize, end: usize) -> bool {
use solidcraft_sketch::ConstraintKind::*;
match c.kind {
Coincident { p, q } => p == end || q == end,
Fix { p } | PointOnCurve { p, .. } | Midpoint { p, .. } => p == end,
Fix { p } | PointOnCurve { p, .. } | PointAlong { p, .. } | Midpoint { p, .. } => p == end,
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => p == end || q == end,
_ => false,
}
@@ -1487,7 +1501,7 @@ fn grow_line(sk: &mut Sketch, ci: usize, aim: LineAim) -> Result<String> {
match c.kind {
Coincident { p, q } => p == end || q == end,
Fix { p } => p == end,
PointOnCurve { p, .. } => p == end,
PointOnCurve { p, .. } | PointAlong { p, .. } => p == end,
Midpoint { p, .. } => p == end,
HorizontalPoints { p, q } | VerticalPoints { p, q } => p == end || q == end,
Symmetric { p, q, .. } => p == end || q == end,
+54
View File
@@ -12,6 +12,60 @@ const SPAN_SAMPLES: usize = 24;
/// Most points a spline may have.
pub const MAX_SPLINE_POINTS: usize = 500;
/// Chords per curved piece when [`point_along`] measures its length.
const ALONG_SAMPLES: usize = 64;
/// The point at share `at` (0 to 1) of the length of the pieces `segs`, measured along them from
/// the first one's start. Lines and arcs are measured exactly; other pieces over
/// [`ALONG_SAMPLES`] chords each. The point is evaluated on its piece, so it lies on the curve.
/// `None` without pieces or for a share that is not a number.
pub fn point_along(segs: &[solidcore_record::Seg2], at: f64) -> Option<Vec2> {
use solidcore_record::Seg2;
let first = segs.first()?;
if !at.is_finite() {
return None;
}
// Per piece: its cumulative lengths at t = i / n.
let tables: Vec<Vec<f64>> = segs
.iter()
.map(|s| match *s {
Seg2::Line { a, b } => vec![0.0, a.dist(b)],
Seg2::Arc { radius, sweep, .. } => vec![0.0, (radius * sweep).abs()],
_ => {
let mut acc = 0.0;
let mut prev = s.point_at(0.0);
let mut t = Vec::with_capacity(ALONG_SAMPLES + 1);
t.push(0.0);
for i in 1..=ALONG_SAMPLES {
let q = s.point_at(i as f64 / ALONG_SAMPLES as f64);
acc += prev.dist(q);
prev = q;
t.push(acc);
}
t
}
})
.collect();
let total: f64 = tables.iter().filter_map(|t| t.last()).sum();
if !(total.is_finite() && total > 1e-12) {
return Some(first.start());
}
let mut left = at.clamp(0.0, 1.0) * total;
for (seg, table) in segs.iter().zip(&tables) {
let len = table.last().copied().unwrap_or(0.0);
if left > len && !std::ptr::eq(seg, segs.last()?) {
left -= len;
continue;
}
let n = table.len().saturating_sub(1).max(1);
let i = table.windows(2).position(|w| matches!(w, [_, b] if left <= *b)).unwrap_or(n - 1);
let (lo, hi) = (table.get(i).copied().unwrap_or(0.0), table.get(i + 1).copied().unwrap_or(len));
let frac = if hi - lo > 1e-300 { ((left - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 0.0 };
return Some(seg.point_at((i as f64 + frac) / n as f64));
}
segs.last().map(|s| s.end())
}
/// Point on an ellipse with centre `c`, major-axis end `m` and minor radius `r` at angle `t`.
pub fn ellipse_point(c: Vec2, m: Vec2, r: f64, t: f64) -> Vec2 {
let u = m - c;
+5 -1
View File
@@ -110,7 +110,11 @@ pub fn loose_ends(sk: &Sketch) -> EndReport {
}
for c in &sk.constraints {
let on = match c.kind {
ConstraintKind::PointOnCurve { p, c } | ConstraintKind::Midpoint { p, l: c } if profile_curve(sk, c) => Some(p),
ConstraintKind::PointOnCurve { p, c } | ConstraintKind::PointAlong { p, c, .. } | ConstraintKind::Midpoint { p, l: c }
if profile_curve(sk, c) =>
{
Some(p)
}
_ => None,
};
if let Some(p) = on {
+1 -1
View File
@@ -75,7 +75,7 @@ pub use shape::{Shape, intersections, intersections_within, nearest_crossing, ne
pub use solver::solve_after_edit_with_analysis;
pub use solver::{
Analysis, CONSISTENT, DrivingValues, Hold, MAX_COMPONENT_VARS, SolveReport, SolveStatus, constraint_residual, determined_by, fillets_kept,
flips_from_stored, measure_dimension, move_line_towards, move_point_towards, solve, solve_after_edit, solve_after_link_move,
flips_from_stored, measure_dimension, move_line_towards, move_point_towards, point_along, solve, solve_after_edit, solve_after_link_move,
solve_after_value_edit, solve_holding, solve_with,
};
pub use text::{MAX_TEXT_CHARS, TextAlign, TextMetrics, TextStyle, text_geometry, text_geometry_in, text_geometry_styled};
+21 -2
View File
@@ -296,6 +296,16 @@ pub enum ConstraintKind {
p: usize,
c: usize,
},
/// Point `p` on curve `c` at `at`, its share (0 to 1) of the curve's length from the curve's
/// start: a line's first point, an arc's start (counter-clockwise), a circle's point on sketch
/// +X (counter-clockwise), an ellipse's major-axis end (counter-clockwise), a spline's or a
/// conic's first point. Divide Curve places its points with it, so they keep equal shares of
/// the curve's length as the curve changes. The length is measured along the exact curve.
PointAlong {
p: usize,
c: usize,
at: f64,
},
Horizontal {
l: usize,
},
@@ -540,6 +550,7 @@ impl ConstraintKind {
match self {
PatternPoint { .. } | PatternRadius { .. } => "Pattern",
Coincident { .. } | PointOnCurve { .. } => "Coincident",
PointAlong { .. } => "PointAlong",
Horizontal { .. } | HorizontalPoints { .. } => "Horizontal",
Vertical { .. } | VerticalPoints { .. } => "Vertical",
Parallel { .. } => "Parallel",
@@ -584,7 +595,7 @@ impl ConstraintKind {
PatternRadius { a, b } => (vec![], vec![a, b]),
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]),
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => (vec![p, q], vec![]),
PointOnCurve { p, c } => (vec![p], vec![c]),
PointOnCurve { p, c } | PointAlong { p, c, .. } => (vec![p], vec![c]),
Horizontal { l } | Vertical { l } | Length { l, .. } | LineAngle { l, .. } => (vec![], vec![l]),
Radius { c, .. } | Diameter { c, .. } | ArcSweep { c, .. } => (vec![], vec![c]),
Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } => {
@@ -642,7 +653,7 @@ impl ConstraintKind {
fp(p);
fp(q);
}
PointOnCurve { p, c } => {
PointOnCurve { p, c } | PointAlong { p, c, .. } => {
fp(p);
fc(c);
}
@@ -1136,6 +1147,14 @@ impl Sketch {
need_line(a)?;
need_line(b)?;
}
PointAlong { p, c, at } => {
if !(at.is_finite() && (0.0..=1.0).contains(&at)) {
return Err(SketchError::Invalid("a point along a curve needs a share of its length from 0 to 1".into()));
}
if self.curves.get(c).is_some_and(|x| x.kind.uses(p)) {
return Err(SketchError::Invalid("a curve's own point cannot be placed along it".into()));
}
}
// On an ellipse, a radius dimension holds its minor radius.
Radius { c, .. } if matches!(self.curves.get(c).map(|x| &x.kind), Some(CurveKind::Ellipse { .. })) => {}
Radius { c, .. } | Diameter { c, .. } => need_round(c)?,
+77 -15
View File
@@ -208,7 +208,7 @@ fn layout(sk: &Sketch, hold: &Hold) -> Layout {
.collect();
let mut slide = vec![false; n];
for c in &sk.constraints {
if let ConstraintKind::PointOnCurve { p, .. } | ConstraintKind::Midpoint { p, .. } = c.kind
if let ConstraintKind::PointOnCurve { p, .. } | ConstraintKind::PointAlong { p, .. } | ConstraintKind::Midpoint { p, .. } = c.kind
&& let Some(Some(v)) = pvar.get(p)
{
for k in [*v, *v + 1] {
@@ -309,11 +309,64 @@ impl State<'_> {
let y = delta.cross(u) / (a * r);
return (x.hypot(y) - 1.0) * a.min(r);
}
Some(CurveKind::Spline { pts, control, degree, periodic, nurbs }) => {
Some(CurveKind::Spline { pts, periodic, .. }) => {
let p = self.spline_points(pts, *periodic);
if p.len() < 2 {
return q.dist(p.first().copied().unwrap_or_default());
}
self.segs(c)
}
Some(CurveKind::Conic { .. }) => self.segs(c),
_ => Vec::new(),
};
segs.iter()
.map(|seg| {
let t = seg.closest_param(q);
let delta = q - seg.point_at(t);
let d = delta.len();
(d, if seg.derivative(t).cross(delta) >= 0.0 { d } else { -d })
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, signed)| signed)
.unwrap_or_else(|| crate::curves::polyline_signed_dist(&self.poly(c), q))
}
/// The exact pieces of any curve with trial values, from its start: a circle from sketch +X
/// and an ellipse from its major-axis end, both counter-clockwise, an ellipse as four exact
/// conic quarters.
fn segs(&self, c: usize) -> Vec<Seg2> {
match self.sk.curves.get(c).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) => vec![Seg2::Line { a: self.p(*a), b: self.p(*b) }],
Some(CurveKind::Circle { .. }) => {
let (center, radius) = self.round(c);
vec![Seg2::Arc { center, radius, start: 0.0, sweep: std::f64::consts::TAU }]
}
Some(CurveKind::Arc { c: ci, a, .. }) => {
let (center, radius) = self.round(c);
let start = (self.p(*a) - self.p(*ci)).angle();
let mut sweep = self.arc_sweep(c).abs();
if sweep <= 1e-12 {
sweep = std::f64::consts::TAU;
}
vec![Seg2::Arc { center, radius, start, sweep }]
}
Some(CurveKind::Ellipse { c: ci, m, .. }) => {
let center = self.p(*ci);
let u = self.p(*m) - center;
let v = u.perp().normalized().unwrap_or(Vec2::ZERO) * self.radius_of(c);
let corner = [u, v, -u, -v, u];
corner
.windows(2)
.filter_map(|w| match w {
[x, y] => Some(Seg2::Conic { a: center + *x, apex: center + *x + *y, b: center + *y, w: std::f64::consts::FRAC_1_SQRT_2 }),
_ => None,
})
.collect()
}
Some(CurveKind::Spline { pts, control, degree, periodic, nurbs }) => {
let p = self.spline_points(pts, *periodic);
if p.len() < 2 {
return Vec::new();
}
if let Some(nurbs) = nurbs {
nurbs.segs(&p, *degree).unwrap_or_default()
} else if *control {
@@ -330,18 +383,12 @@ impl State<'_> {
Some(CurveKind::Conic { a, b, apex, rho }) => {
vec![Seg2::Conic { a: self.p(*a), b: self.p(*b), apex: self.p(*apex), w: rho / (1.0 - rho).max(1e-9) }]
}
_ => Vec::new(),
};
segs.iter()
.map(|seg| {
let t = seg.closest_param(q);
let delta = q - seg.point_at(t);
let d = delta.len();
(d, if seg.derivative(t).cross(delta) >= 0.0 { d } else { -d })
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, signed)| signed)
.unwrap_or_else(|| crate::curves::polyline_signed_dist(&self.poly(c), q))
None => Vec::new(),
}
}
/// Where a point at share `at` of curve `c`'s length lies, with trial values.
fn along(&self, c: usize, at: f64) -> Vec2 {
crate::curves::point_along(&self.segs(c), at).unwrap_or_default()
}
/// Radius of a circle or arc; an ellipse's minor radius.
fn radius_of(&self, c: usize) -> f64 {
@@ -552,6 +599,10 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
let d = s.p(p) - s.p(q);
out.extend([d.x, d.y]);
}
PointAlong { p, c, at } => {
let d = s.p(p) - s.along(c, at);
out.extend([d.x, d.y]);
}
PointOnCurve { p, c } => {
let pp = s.p(p);
if s.is_freeform(c) {
@@ -794,6 +845,17 @@ pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option<f64> {
measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite())
}
/// Where the point at share `at` (0 to 1) of curve `c`'s length lies on the sketch as it stands,
/// measured from the curve's start as [`ConstraintKind::PointAlong`] measures it; `None` for an
/// unknown curve, a share out of range or a curve with no length to share.
pub fn point_along(sk: &Sketch, c: usize, at: f64) -> Option<Vec2> {
if !(at.is_finite() && (0.0..=1.0).contains(&at)) {
return None;
}
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
crate::curves::point_along(&State { sk, lay: &lay, x: &[] }.segs(c), at).filter(|p| p.x.is_finite() && p.y.is_finite())
}
/// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known
/// touch point (better conditioned than the distance form when an end point is on the curve).
enum Block<'a> {
@@ -1029,7 +1091,7 @@ fn block_refs(sk: &Sketch, b: &Block) -> Vec<Ref> {
| Distance { p, q, .. }
| DistanceX { p, q, .. }
| DistanceY { p, q, .. } => vec![P(p), P(q)],
PointOnCurve { p, c } => vec![P(p), C(c)],
PointOnCurve { p, c } | PointAlong { p, c, .. } => vec![P(p), C(c)],
Midpoint { p, l } | PointLineDistance { p, l, .. } | LinearDiameter { p, l, .. } => vec![P(p), L(l)],
LineCircleDistance { l, c, .. } => vec![L(l), R(c)],
PointCircleDistance { p, c, .. } => vec![P(p), R(c)],
+1
View File
@@ -224,6 +224,7 @@ pub const DOCUMENTED: &[&str] = &[
"sketch.pattern.circular",
"sketch.pattern.rectangular",
"sketch.point",
"sketch.point.divide",
"sketch.point3d",
"sketch.polygon.circumscribed",
"sketch.polygon.edge",
+1
View File
@@ -475,6 +475,7 @@ pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
| "sketch.pattern.rectangular"
| "sketch.pattern.circular"
| "sketch.offset"
| "sketch.point.divide"
| "sketch.fillet"
| "sketch.chamfer.equal_distance"
| "sketch.chamfer.two_distance"
+32 -3
View File
@@ -62,6 +62,8 @@ pub const MODIFIERS: &[ToolModifiers] = &[
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 },
// Divide Curve: curves and a count; no viewport modifier chords.
ToolModifiers { tool: "sketch.point.divide", scopes: SELECT_ONLY, bindings: &[], audit: Audit::VerifiedNoOp },
];
/// The equation curve's two forms: x(t) and y(t), or y = f(x).
@@ -72,6 +74,8 @@ const CHAMFER_TYPE_ICONS: [&str; 3] = ["sketch_chamfer_equal", "sketch_chamfer_t
/// The engine's own cap on the copies one sketch pattern may make.
const MAX_COPIES: f64 = 500.0;
/// The engine's own cap on the pieces Divide Curve cuts one curve into.
const DIVIDE_MAX: i64 = 500;
/// The engine's own cap on the offsets `sketch.offset` stacks one behind the other.
const MAX_OFFSET_INSTANCES: i64 = 100;
@@ -150,11 +154,17 @@ pub enum Mod {
/// 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, range_unit: bool },
/// `sketch.point.divide` (#8969): the curves picked are each divided into `count` pieces of equal
/// length by points held at their shares of it.
Divide { count: String },
}
impl Mod {
pub fn previews(&self) -> bool {
matches!(self, Self::Mirror | Self::PatternRect { .. } | Self::PatternCirc { .. } | Self::Offset { .. } | Self::Corner { .. })
matches!(
self,
Self::Mirror | Self::PatternRect { .. } | Self::PatternCirc { .. } | Self::Offset { .. } | Self::Corner { .. } | Self::Divide { .. }
)
}
pub fn title(&self) -> &'static str {
@@ -169,6 +179,7 @@ impl Mod {
Mod::Corner { chamfer: false, .. } => "FILLET",
Mod::Corner { .. } => "CHAMFER",
Mod::Equation { .. } => "EQUATION CURVE",
Mod::Divide { .. } => "DIVIDE CURVE",
}
}
@@ -185,7 +196,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,
Mod::Equation { .. } | Mod::Divide { .. } => None,
}
}
}
@@ -219,6 +230,7 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
// point (#1824), for both.
Mod::Corner { .. } => vec![SelInput::new("Corners", SKETCH_POINTS | CURVES, true)],
Mod::Equation { .. } => Vec::new(),
Mod::Divide { .. } => vec![SelInput::new("Curves", CURVES, true)],
}
}
@@ -283,6 +295,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
tolerance: "0.01 mm".into(),
range_unit: false,
},
"sketch.point.divide" => Mod::Divide { count: "4".into() },
_ => return None,
};
let i = inputs(&k);
@@ -477,12 +490,13 @@ pub(crate) fn command(k: &Mod) -> &'static str {
Mod::Corner { ctype: 2, .. } => "sketch.chamfer.distance_angle",
Mod::Corner { .. } => "sketch.chamfer.equal_distance",
Mod::Equation { .. } => "sketch.equation_curve",
Mod::Divide { .. } => "sketch.point.divide",
}
}
/// Repeatable operations offer Apply for another selection, with the same typed values.
pub(crate) fn repeatable(k: &Mod) -> bool {
matches!(k, Mod::Offset { .. } | Mod::Mirror | Mod::PatternRect { .. } | Mod::PatternCirc { .. })
matches!(k, Mod::Offset { .. } | Mod::Mirror | Mod::PatternRect { .. } | Mod::PatternCirc { .. } | Mod::Divide { .. })
}
/// A labelled value row. The value carries its own unit text ("2 mm", "45 deg").
@@ -597,6 +611,10 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod, doc: &Document) -> bool {
alignment(ui, "Alignment", a, reversed);
pattern_hint(ui);
}
Mod::Divide { count } => {
enter |= quantity(ui, doc, "Segments", count, 2, DIVIDE_MAX);
ui_kit::text(ui, crate::i18n::tr("sketch.divide_hint"), ui_kit::Text::Note("divide"));
}
Mod::Equation { form, x, y, start, end, tolerance, range_unit } => {
ui_kit::choice_row(ui, "Form", "equation_form", form, &EQUATION_FORMS);
if *form == 0 {
@@ -1031,6 +1049,17 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
}
("sketch.pattern.rectangular", p)
}
Mod::Divide { count } => {
let curves = curve_ids(inputs, 0);
if curves.is_empty() {
return Err("Select the curves to divide first".into());
}
let v = app.session.doc.eval(count.trim(), ValueKind::Unitless).map_err(|e| format!("Segments: {e}"))?;
if !v.is_finite() || !(2.0..=DIVIDE_MAX as f64).contains(&v) || (v - v.round()).abs() > 1e-9 {
return Err(format!("Segments must be a whole number from 2 to {DIVIDE_MAX}"));
}
("sketch.point.divide", json!({"curves": curves, "segments": v.round()}))
}
Mod::PatternCirc { count, angle, alignment, reversed } => {
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
+24 -2
View File
@@ -1949,7 +1949,7 @@ fn constraint_entities(k: &solidcraft_engine::sketch::ConstraintKind) -> (Vec<us
PatternRadius { a, b } => (vec![a, b], vec![]),
Fix { p } => (vec![], vec![p]),
Midpoint { p, l } => (vec![l], vec![p]),
PointOnCurve { p, c } => (vec![c], vec![p]),
PointOnCurve { p, c } | PointAlong { p, c, .. } => (vec![c], vec![p]),
Symmetric { p, q, l } => (vec![l], vec![p, q]),
Horizontal { l } | Vertical { l } => (vec![l], vec![]),
Parallel { a, b }
@@ -1983,7 +1983,7 @@ fn glyph_anchors(sk: &solidcraft_engine::sketch::Sketch, k: &solidcraft_engine::
};
let pt = |p: usize| sk.point(p).map(|q| (q, None));
match *k {
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => pt(p).into_iter().collect(),
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } | PointAlong { p, .. } => pt(p).into_iter().collect(),
PatternPoint { p, center, .. } => pt(center.unwrap_or(p)).into_iter().collect(),
PatternRadius { .. } => Vec::new(),
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => {
@@ -2075,6 +2075,28 @@ mod glyph_zoom_tests {
assert!(constraint_glyphs_visible_at_scale(1.5, true, || 500.0 * 1.5));
assert!(constraint_glyphs_visible_at_scale(0.05, true, || MIN_GLYPH_SPAN_PX));
}
/// A Divide Curve point's constraint has a glyph on its point, an icon, and lights its point
/// and curve, as a point-on-curve constraint does (#8969).
#[test]
fn a_point_along_constraint_has_a_glyph_on_its_point() {
use solidcraft_engine::sketch::{ConstraintKind, CurveKind, Sketch};
let mut sk = Sketch::new();
let (Ok(a), Ok(b), Ok(q)) = (
sk.add_point(super::Vec2::new(0.0, 0.0), None),
sk.add_point(super::Vec2::new(10.0, 0.0), None),
sk.add_point(super::Vec2::new(2.5, 0.0), None),
) else {
panic!("points");
};
let Ok(l) = sk.add_curve(CurveKind::Line { a, b }, None) else { panic!("line") };
let k = ConstraintKind::PointAlong { p: q, c: l, at: 0.25 };
assert_eq!(super::constraint_entities(&k), (vec![l], vec![q]));
let anchors = super::glyph_anchors(&sk, &k);
assert_eq!(anchors.len(), 1);
assert!(anchors.first().is_some_and(|(at, dir)| (at.x - 2.5).abs() < 1e-9 && at.y.abs() < 1e-9 && dir.is_none()));
assert_eq!(crate::icons::markers::glyph_icon(k.name()), Some("c_midpoint"));
}
}
#[cfg(test)]
+1
View File
@@ -293,6 +293,7 @@ sketch.offset 2D sketch only (the sketch solver and engine t
sketch.offset.break 2D sketch only (the sketch solver and engine tests); no kernel operation
sketch.paste 2D sketch only (the sketch solver and engine tests); no kernel operation
sketch.pattern.rectangular 2D sketch only (the sketch solver and engine tests); no kernel operation
sketch.point.divide 2D sketch only; crates/engine/src/cmd/sketch_divide_tests.rs: a_line_divided_in_four_keeps_its_quarters_when_it_grows and a_spline_is_divided_into_equal_lengths_along_it
sketch.point3d 2D sketch only (the sketch solver and engine tests); no kernel operation
sketch.polygon.edge 2D sketch only (the sketch solver and engine tests); no kernel operation
sketch.rectangle.three_point 2D sketch only (the sketch solver and engine tests); no kernel operation