Sketch: infinite construction lines (#1359) (#1547)

Thanks @DuBearte.
This commit is contained in:
DuBearte authored and GitHub committed 2026-10-15 22:54:16 -04:00
1 parent bd17eeef0c
commit 987f0be21e
27 files changed
+435 -28

No files matched your search

+2 -1
View File
@@ -593,7 +593,8 @@ fn thin_param(s: &Session, p: &Value, sketch: u64, cmd: &str) -> Result<ThinWall
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
let mut curves = string_list(p, "curves");
if curves.is_empty() {
curves = ss.sketch.curves.iter().filter(|c| !c.construction && !c.centerline && c.link.is_none()).map(|c| c.id.clone()).collect();
curves =
ss.sketch.curves.iter().filter(|c| !c.construction && !c.centerline && !c.infinite && c.link.is_none()).map(|c| c.id.clone()).collect();
}
if curves.is_empty() || curves.len() > 1000 {
return Err(bad(cmd, "a thin extrude needs 1…1000 sketch curves (`curves`)"));
+7 -1
View File
@@ -2244,11 +2244,14 @@ fn construction(s: &mut Session, p: &Value) -> Result<Value> {
let mut n = 0;
for c in cs {
if let Some(cu) = sk.curves.get_mut(c)
&& (cu.construction != to || to && cu.centerline)
&& (cu.construction != to || to && cu.centerline || !to && cu.infinite)
{
cu.construction = to;
if to {
cu.centerline = false;
} else {
// A normal line is a finite one.
cu.infinite = false;
}
n += 1;
}
@@ -2512,6 +2515,9 @@ mod drag_live_tests;
#[path = "sketch_ids_tests.rs"]
mod ids_tests;
#[cfg(test)]
#[path = "sketch_infinite_tests.rs"]
mod infinite_tests;
#[cfg(test)]
#[path = "sketch_redundant_tests.rs"]
mod redundant_tests;
#[cfg(test)]
+3 -1
View File
@@ -36,6 +36,7 @@ pub struct ClipCurve {
pub construction: bool,
pub centerline: bool,
pub reversed: bool,
pub infinite: bool,
}
/// A constraint or dimension of the clipboard, over clipboard points and curves. A dimension
@@ -133,7 +134,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
kind.map_points(&|q| used.iter().find(|(o, _)| *o == q).map_or(q, |(_, n)| *n));
cmap.insert(c, clip.curves.len());
// Projected geometry comes in as ordinary geometry.
clip.curves.push(ClipCurve { kind, construction: cu.construction, centerline: cu.centerline, reversed: cu.reversed });
clip.curves.push(ClipCurve { kind, construction: cu.construction, centerline: cu.centerline, reversed: cu.reversed, infinite: cu.infinite });
}
for &q in &ps {
let known = pmap.contains_key(&q);
@@ -276,6 +277,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
cu.construction = c.construction;
cu.centerline = c.centerline;
cu.reversed = c.reversed;
cu.infinite = c.infinite;
}
made.push(i);
}
+28
View File
@@ -94,6 +94,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("centerline")
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line)
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"),
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \
font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ",
@@ -436,6 +439,7 @@ fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
cu.centerline = want.unwrap_or(!cu.centerline);
if cu.centerline {
cu.construction = false;
cu.infinite = false;
}
}
Ok(ids.len())
@@ -443,6 +447,30 @@ fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"changed": n, "sketch": info}))
}
fn infinite_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.infinite";
let ids = string_list(p, "curves");
if ids.is_empty() {
return Err(bad(cmd, "`curves` must list lines"));
}
let want = crate::params::bool_(p, "value");
let (n, info) = edit(s, p, cmd, false, |sk, _| {
for id in &ids {
let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
let cu = sk.curves.get_mut(c).ok_or_else(|| bad(cmd, "curve"))?;
if !matches!(cu.kind, CurveKind::Line { .. }) {
return Err(bad(cmd, format!("`{id}` is not a line")));
}
// Infinite lines are construction geometry (never in a profile); off, a normal line.
cu.infinite = want.unwrap_or(!cu.infinite);
cu.construction = cu.infinite;
cu.centerline = false;
}
Ok(ids.len())
})?;
Ok(json!({"changed": n, "sketch": info}))
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.ellipse";
let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?);
@@ -0,0 +1,137 @@
//! Infinite lines (#1359): a line type through two points, construction geometry that reaches
//! across the whole view, a boundary along its whole line, never trimmed, filleted or in a
//! profile.
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{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 curve<'a>(sk: &'a Sketch, id: &str) -> &'a solidcraft_sketch::Curve {
sk.curves.get(sk.curve_index(id).unwrap()).unwrap()
}
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
fn profiles(s: &Session) -> usize {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().profiles.len()
}
#[test]
fn the_line_type_switches_on_and_off_and_stays_out_of_profiles() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
run(&mut s, "sketch.line", json!({"points": [[20, -10], [20, 30]], "ids": ["axis"]}));
assert_eq!(profiles(&s), 2, "a normal line cuts the rectangle in two");
run(&mut s, "sketch.infinite", json!({"curves": ["axis"]}));
let sk = sketch(&s);
assert!(curve(&sk, "axis").infinite && curve(&sk, "axis").construction && !curve(&sk, "axis").centerline);
assert_eq!(profiles(&s), 1, "an infinite line is construction: no profile");
// Back to a normal line, by toggling or by Normal/Construction.
run(&mut s, "sketch.infinite", json!({"curves": ["axis"]}));
let sk = sketch(&s);
assert!(!curve(&sk, "axis").infinite && !curve(&sk, "axis").construction);
run(&mut s, "sketch.infinite", json!({"curves": ["axis"], "value": true}));
run(&mut s, "sketch.construction", json!({"curves": ["axis"], "value": false}));
assert!(!curve(&sketch(&s), "axis").infinite, "a normal line is a finite one");
// A centerline is not an infinite line.
run(&mut s, "sketch.infinite", json!({"curves": ["axis"], "value": true}));
run(&mut s, "sketch.centerline", json!({"curves": ["axis"], "value": true}));
let sk = sketch(&s);
assert!(curve(&sk, "axis").centerline && !curve(&sk, "axis").infinite);
// Only lines.
run(&mut s, "sketch.circle.center", json!({"center": [60, 0], "radius": 5, "id": "c"}));
assert!(s.execute("sketch.infinite", &json!({"curves": ["c"]})).is_err());
}
#[test]
fn it_is_saved_and_a_line_without_it_reads_finite() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 5]], "ids": ["a"]}));
run(&mut s, "sketch.line", json!({"points": [[0, 10], [10, 15]], "ids": ["b"]}));
run(&mut s, "sketch.infinite", json!({"curves": ["a"]}));
let text = s.doc.to_json();
let back = solidcraft_doc::Document::from_json(&text).unwrap();
let sk = back.sketch(s.active_sketch.unwrap()).unwrap();
assert!(curve(sk, "a").infinite);
assert!(!curve(sk, "b").infinite);
assert_eq!(text.matches("\"infinite\"").count(), 1, "saved only where it is set");
}
#[test]
fn trim_and_extend_use_the_whole_line_but_never_trim_it() {
// An infinite vertical line at x = 25 whose two points lie far above the other line.
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[25, 50], [25, 60]], "ids": ["inf"]}));
run(&mut s, "sketch.infinite", json!({"curves": ["inf"]}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]], "ids": ["l"]}));
// Trimming the line's right part stops at the infinite line's crossing, x = 25.
run(&mut s, "sketch.trim", json!({"curve": "l", "at": [35, 0]}));
let sk = sketch(&s);
let CurveKind::Line { a, b } = curve(&sk, "l").kind else { panic!("a line") };
let xs = [sk.point(a).unwrap().x, sk.point(b).unwrap().x];
assert!(xs.iter().any(|x| (x - 25.0).abs() < 1e-6) && xs.iter().all(|x| *x <= 25.0 + 1e-6), "{xs:?}");
// The infinite line itself is not trimmed.
let e = s.execute("sketch.trim", &json!({"curve": "inf", "at": [25, 55]})).unwrap_err().to_string();
assert!(e.contains("infinite"), "{e}");
// A short line extends to it, beyond its two points.
run(&mut s, "sketch.line", json!({"points": [[0, 10], [10, 10]], "ids": ["short"]}));
run(&mut s, "sketch.extend", json!({"curve": "short", "at": [10, 10]}));
let sk = sketch(&s);
let CurveKind::Line { b, .. } = curve(&sk, "short").kind else { panic!("a line") };
assert!(sk.point(b).unwrap().dist(Vec2::new(25.0, 10.0)) < 1e-6, "{:?}", sk.point(b));
}
#[test]
fn fillets_and_chamfers_refuse_it_and_mirrors_copy_it() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0], [20, 20]], "ids": ["a", "b"]}));
run(&mut s, "sketch.infinite", json!({"curves": ["a"]}));
let before = s.doc.to_json();
for (cmd, p) in [
("sketch.fillet", json!({"point": "a.end", "radius": 2})),
("sketch.fillet", json!({"a": "a", "b": "b", "radius": 2})),
("sketch.chamfer.equal_distance", json!({"point": "a.end", "distance": 2})),
] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("infinite"), "{cmd}: {e}");
}
assert_eq!(s.doc.to_json(), before);
run(&mut s, "sketch.line", json!({"points": [[-10, -5], [-10, 5]], "ids": ["m"]}));
let r = run(&mut s, "sketch.mirror", json!({"entities": ["a"], "line": "m"}));
let made = r["curves"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>();
let sk = sketch(&s);
assert!(made.iter().any(|id| curve(&sk, id).infinite), "{r}");
}
#[test]
fn an_offset_of_it_is_infinite_too() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "ids": ["inf"]}));
run(&mut s, "sketch.infinite", json!({"curves": ["inf"]}));
let r = run(&mut s, "sketch.offset", json!({"curves": ["inf"], "distance": 5, "side": [5, 5]}));
let made = r["curves"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>();
let sk = sketch(&s);
assert!(!made.is_empty() && made.iter().all(|id| curve(&sk, id).infinite), "{r}");
// It stays infinite when the offset follows a new distance.
let param = r["param"].as_str().unwrap().to_string();
run(&mut s, "parameters.change", json!({"name": param, "expression": "8 mm"}));
let sk = sketch(&s);
assert!(made.iter().all(|id| curve(&sk, id).infinite));
}
+39 -3
View File
@@ -147,8 +147,10 @@ fn cut_points(sk: &Sketch, ci: usize) -> Vec<(f64, Vec2, usize)> {
{
pts.extend([other.point(0.0), other.point(sweep)]);
}
// An infinite line (#1359) cuts wherever its line crosses, beyond its two points too.
let other_infinite = sk.curves.get(oi).is_some_and(|c| c.infinite);
for q in pts {
if on_bounded(&other, q).is_some()
if (other_infinite || on_bounded(&other, q).is_some())
&& let Some(t) = on_bounded(&sh, q)
&& !out.iter().any(|(_, x, _)| x.dist(q) < TOL * 10.0)
{
@@ -197,7 +199,7 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec<usize>, Vec<u
let mut pieces = vec![ci];
let mut push = |sk: &mut Sketch, kind: CurveKind, prefix: &str| -> Result<()> {
let id = sk.fresh(prefix)?;
sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false });
sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false, infinite: false });
pieces.push(sk.curves.len() - 1);
Ok(())
};
@@ -286,6 +288,9 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
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"));
}
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
let r = super::sketch_freeform::trim_or_break(sk, ci, at, true, cmd)?;
doc.prune_model_params();
@@ -418,7 +423,11 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
if oi == ci {
continue;
}
let pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi));
let mut pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi));
// An infinite line (#1359) is a boundary along its whole line.
if sk.curves.get(oi).is_some_and(|c| c.infinite) {
pieces = pieces.iter().map(|s| support::endless(s, sh.point(sh.param(at)))).collect();
}
segment_count += pieces.len();
if segment_count > 16_384 {
return Err(bad(cmd, "too many exact curve pieces to extend safely"));
@@ -528,6 +537,16 @@ fn weld_corner(sk: &mut Sketch, p: &Value) {
}
}
/// Fillets and chamfers refuse a corner on an infinite line (#1359): it has no end there.
fn refuse_infinite_corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<()> {
let infinite = |c: Option<usize>| c.and_then(|c| sk.curves.get(c)).is_some_and(|c| c.infinite);
let at_corner = match str_(p, "point").and_then(|r| sk.resolve_point(r)) {
Some(q) => sk.curves.iter().any(|c| c.infinite && c.kind.uses(q)),
None => ["a", "b"].iter().any(|k| infinite(str_(p, k).and_then(|i| sk.curve_index(i)))),
};
if at_corner { Err(bad(cmd, "an infinite line has no corner to round or cut: make it a normal line first")) } else { Ok(()) }
}
/// A corner of two lines: (line a, line b, corner point, far end of a, far end of b).
fn corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize, usize, usize, usize)> {
let ends = |l: usize| match sk.curves.get(l).map(|c| &c.kind) {
@@ -745,6 +764,7 @@ fn kept_json(kept: Vec<CornerKept>) -> (Vec<String>, Vec<String>) {
/// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves:
/// the arc, the tangent constraints and what became of the corner point.
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>, CornerKept)> {
refuse_infinite_corner(sk, p, cmd)?;
weld_corner(sk, p);
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
Ok(x) => x,
@@ -854,6 +874,7 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<
fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Result<(f64, f64)>) -> Result<Value> {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
refuse_infinite_corner(sk, p, cmd)?;
weld_corner(sk, p);
let (la, lb, c, fa, fb) = corner(sk, p, cmd)?;
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
@@ -1343,6 +1364,18 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
.map(|o| o.sources.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>())
.or_else(|| sk.freeform_offsets.iter().find(|o| o.output == *output).map(|o| vec![o.source.clone()]))
.ok_or_else(|| bad(cmd, "offset association is missing"))?;
// An offset of infinite lines is infinite lines too (#1359).
let infinite = sources.iter().all(|id| sk.curve_index(id).and_then(|c| sk.curves.get(c)).is_some_and(|c| c.infinite));
if infinite {
for id in group.iter().flat_map(|side| side.curves.iter()) {
if let Some(cu) = sk.curve_index(id).and_then(|c| sk.curves.get_mut(c))
&& matches!(cu.kind, CurveKind::Line { .. })
{
cu.infinite = true;
cu.construction = true;
}
}
}
for (index, side) in group.iter_mut().enumerate() {
let name = if index == 1 { param2.as_ref().unwrap_or(&param) } else { &param };
let output = side.curves.first().ok_or_else(|| bad(cmd, "offset produced no curves"))?;
@@ -1631,6 +1664,8 @@ fn copy_through(
}
};
let id = sk.fresh(prefix)?;
// A mirrored or patterned infinite line is an infinite line too (#1359).
let infinite = cu.infinite && matches!(kind, CurveKind::Line { .. });
sk.curves.push(Curve {
id,
kind,
@@ -1639,6 +1674,7 @@ fn copy_through(
link: None,
centerline: false,
fixed: false,
infinite,
});
made.push(sk.curves.len() - 1);
}
+14
View File
@@ -154,6 +154,20 @@ pub(super) fn hits(support: &Shape, seg: &Seg2, budget: &mut usize) -> Result<Ve
Ok(out)
}
/// How far an infinite line (#1359) reaches each way from `near`, as a boundary: well past any
/// sketch geometry.
const ENDLESS: f64 = 1e7;
/// An infinite line's piece `seg` as a boundary: the line through it, as a segment reaching
/// [`ENDLESS`] each way from the point of the line nearest `near`. Other pieces are returned as
/// they are.
pub(super) fn endless(seg: &Seg2, near: Vec2) -> Seg2 {
let Seg2::Line { a, b } = *seg else { return *seg };
let Some(dir) = (b - a).normalized() else { return *seg };
let mid = a + dir * (near - a).dot(dir);
Seg2::Line { a: mid - dir * ENDLESS, b: mid + dir * ENDLESS }
}
#[cfg(test)]
mod tests {
use super::*;