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

@@ -0,0 +1,5 @@
//! A sketch line can be `infinite` (#1359): construction geometry through its two points, drawn
//! across the whole view, a boundary for trim and extend along its whole line. Saved only when
//! set: older designs need no transformation, and a line without it is the line it always was;
//! 0.13.x (which reads up to 24) asks for an update instead of drawing such a line between its
//! two points.
+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::*;
+1
View File
@@ -655,6 +655,7 @@ impl Sketch {
link: Some(id.to_string()),
centerline: false,
fixed: false,
infinite: false,
});
}
for li in &lay.lone {
+9 -1
View File
@@ -169,6 +169,11 @@ pub struct Curve {
/// Fixed (locked): its points and radius are constants for the solver.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub fixed: bool,
/// Infinite line type (#1359), for lines only: construction geometry drawn across the whole
/// view, through its two points, which fix where it lies and which way it runs. Never part of
/// a profile (it is construction too).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub infinite: bool,
}
/// Geometric constraints and dimensions. Indices refer to `Sketch::points` (`p`, `q`) or
@@ -735,6 +740,7 @@ impl Sketch {
link: None,
centerline: false,
fixed: false,
infinite: false,
});
Ok(self.curves.len() - 1)
}
@@ -772,6 +778,7 @@ impl Sketch {
link: None,
centerline: false,
fixed: false,
infinite: false,
});
Ok(self.curves.len() - 1)
}
@@ -806,6 +813,7 @@ impl Sketch {
link: None,
centerline: false,
fixed: false,
infinite: false,
});
Ok(self.curves.len() - 1)
}
@@ -1376,7 +1384,7 @@ impl Sketch {
}
};
let id = self.curve_id(id, prefix)?;
self.curves.push(Curve { id, kind, construction: false, reversed: false, link: None, centerline: false, fixed: false });
self.curves.push(Curve { id, kind, construction: false, reversed: false, link: None, centerline: false, fixed: false, infinite: false });
Ok(self.curves.len() - 1)
}
+11 -1
View File
@@ -27,13 +27,21 @@ pub struct OffsetRoleFlags {
pub centerline: bool,
pub reversed: bool,
pub fixed: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub infinite: bool,
}
impl OffsetRoleFlags {
fn is_default(&self) -> bool {
*self == Self::default()
}
fn of(curve: &Curve) -> Self {
Self { construction: curve.construction, centerline: curve.centerline, reversed: curve.reversed, fixed: curve.fixed }
Self {
construction: curve.construction,
centerline: curve.centerline,
reversed: curve.reversed,
fixed: curve.fixed,
infinite: curve.infinite,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -614,6 +622,7 @@ impl Sketch {
link: None,
centerline: false,
fixed: false,
infinite: false,
});
outputs.push(output);
}
@@ -767,6 +776,7 @@ impl Sketch {
link: None,
centerline: role.flags.centerline,
fixed: role.flags.fixed,
infinite: role.flags.infinite,
});
}
outputs.push(role.output.clone());
+1 -1
View File
@@ -558,7 +558,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec<Profile> {
let mut whole_graph_complete = true;
let mut expected = std::collections::HashMap::<usize, Vec<usize>>::new();
for (ci, c) in sk.curves.iter().enumerate() {
if c.construction || c.centerline {
if c.construction || c.centerline || c.infinite {
continue;
}
if matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. } | CurveKind::Circle { .. }) {
+14
View File
@@ -781,6 +781,20 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
if !curves.is_empty() {
v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves })));
}
// Infinite lines (#1359): the selected lines switch together.
let lines: Vec<String> = curves
.iter()
.filter(|id| {
ss.sketch
.curve_index(id)
.and_then(|i| ss.sketch.curves.get(i))
.is_some_and(|c| matches!(c.kind, solidcraft_engine::sketch::CurveKind::Line { .. }))
})
.cloned()
.collect();
if !lines.is_empty() {
v.push(cmd(app, "sketch.infinite", "Infinite Line").with(json!({ "curves": lines })));
}
if curves.len() == 1 && ss.sketch.freeform_offsets.iter().any(|offset| offset.output == curves[0]) {
v.push(cmd(app, "sketch.offset.break", "Break Offset Association").with(json!({"curve":curves[0]})));
}
@@ -0,0 +1,61 @@
//! An infinite line (#1359) is drawn across the whole view and picked along its whole line,
//! and the Sketch Palette's Infinite mode draws one.
use serde_json::json;
use crate::scenario::Harness;
use crate::viewport::{Hit, pick, projection};
/// An XY sketch with a short line from (0, 0) to (2, 0) made infinite, the view zoomed out.
fn short_infinite_line() -> Harness {
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.app.run("sketch.line", json!({"points": [[0, 0], [2, 0]], "ids": ["inf"]})).unwrap();
h.app.run("sketch.infinite", json!({"curves": ["inf"]})).unwrap();
h.app.cam.distance = 400.0;
h.settle();
h
}
#[test]
fn it_is_drawn_from_edge_to_edge_of_the_view() {
let h = short_infinite_line();
let rect = h.app.viewport.rect.unwrap();
let proj = projection(&h.app, rect);
let st = h.app.session.world_state();
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let i = ss.sketch.curve_index("inf").unwrap();
let parts = super::curve_parts(&ss.sketch, i, &ss.plane, &proj, 1.0);
let xs: Vec<f32> = parts.iter().flatten().filter_map(|p| proj.to_screen(*p)).map(|p| p.x).collect();
let (lo, hi) = xs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x)));
// Its two points are a few pixels apart; drawn, it spans the view.
assert!(lo <= rect.left() + 2.0 && hi >= rect.right() - 2.0, "drawn from {lo} to {hi} in {rect:?}");
}
#[test]
fn it_is_picked_far_beyond_its_two_points() {
let h = short_infinite_line();
let rect = h.app.viewport.rect.unwrap();
let proj = projection(&h.app, rect);
let ss_plane = h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().plane;
let far = proj.to_screen(ss_plane.to_world(solidcraft_engine::geom::Vec2::new(150.0, 0.0))).unwrap();
assert!(rect.contains(far));
let hits = pick(&h.app, &proj, far);
assert!(hits.iter().any(|h| matches!(h, Hit::SketchCurve { id, .. } if id == "inf")), "{hits:?}");
}
#[test]
fn infinite_mode_draws_infinite_lines() {
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.settle();
crate::sketch_palette::set_mode(&mut h.app, 3);
let r = h.app.run("sketch.line", json!({"points": [[0, 0], [10, 5]]})).unwrap();
crate::sketch_palette::apply_mode(&mut h.app, "sketch.line", &r);
let id = r["curves"][0].as_str().unwrap().to_string();
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let c = sk.curves.get(sk.curve_index(&id).unwrap()).unwrap();
assert!(c.infinite && c.construction, "{c:?}");
}
+33 -2
View File
@@ -26,8 +26,35 @@ pub fn pixel_view(proj: &Proj, ppp: f32) -> PixelCurveView {
PixelCurveView { vp: proj.vp, width_px: f64::from(proj.rect.width() * ppp), height_px: f64::from(proj.rect.height() * ppp) }
}
/// The pieces a curve is drawn and picked from. An infinite line (#1359) reaches across the whole
/// view: through the point of its line nearest the view's centre, far past the view each way
/// (the sampler clips it to the view). Only the display changes: the line keeps its two points.
fn display_segs(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj) -> Vec<solidcraft_engine::geom::Seg2> {
use solidcraft_engine::geom::Seg2;
let segs = sketch.exact_segs(index).unwrap_or_else(|| sketch.segs(index));
if !sketch.curves.get(index).is_some_and(|c| c.infinite) {
return segs;
}
let target = proj.cam.target - plane.origin;
let centre = Vec2::new(target.dot(plane.x), target.dot(plane.y));
let reach = 4.0 * (proj.cam.distance.abs() + proj.cam.half_height().abs()) + 1.0;
segs.into_iter()
.map(|s| match s {
Seg2::Line { a, b } => match (b - a).normalized() {
Some(dir) => {
let mid = a + dir * (centre - a).dot(dir);
let reach = reach + mid.dist(centre);
Seg2::Line { a: mid - dir * reach, b: mid + dir * reach }
}
None => s,
},
other => other,
})
.collect()
}
pub fn curve_with_budget(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj, ppp: f32, budget: &mut SamplingBudget) -> SampledPath {
let segments = sketch.exact_segs(index).unwrap_or_else(|| sketch.segs(index));
let segments = display_segs(sketch, index, plane, proj);
let mut local = SamplingBudget::new(budget.remaining_vertices.min(4096), budget.remaining_nodes.min(65_536));
let initial = local.clone();
let mut out = SampledPath { parts: Vec::new(), quality: SamplingQuality::Complete, vertex_count: 0 };
@@ -47,7 +74,7 @@ pub fn curve_with_budget(sketch: &Sketch, index: usize, plane: &Plane, proj: &Pr
pub fn curve_unclipped(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> SampledPath {
let mut budget = SamplingBudget::new(4096, 65_536);
let mut out = SampledPath { parts: Vec::new(), quality: SamplingQuality::Complete, vertex_count: 0 };
for segment in sketch.exact_segs(index).unwrap_or_else(|| sketch.segs(index)) {
for segment in display_segs(sketch, index, plane, proj) {
let sampled = sample_segment_unclipped(&segment, plane, &pixel_view(proj, ppp), &mut budget);
if sampled.quality != SamplingQuality::Complete {
out.quality = sampled.quality;
@@ -244,3 +271,7 @@ pub fn curve_sample(app: &SolveApp, sketch: u64, index: usize, plane: &Plane, pr
)),
}
}
#[cfg(test)]
#[path = "infinite_line_display_tests.rs"]
mod infinite_line_display_tests;
+31 -14
View File
@@ -83,7 +83,7 @@ pub fn dof_text(dof: usize, ok: bool) -> String {
}
/// The line type new curves get in the sketch being edited (0 normal, 1 construction,
/// 2 centerline); a new sketch starts normal.
/// 2 centerline, 3 infinite); a new sketch starts normal.
pub fn mode(app: &SolveApp) -> u8 {
let (m, sk) = MODE.with(Cell::get);
if sk.is_some() && sk == app.session.active_sketch { m } else { 0 }
@@ -95,6 +95,7 @@ pub fn set_mode(app: &mut SolveApp, m: u8) {
let hint = match m {
1 => "Construction mode: new curves are construction geometry (X or the palette's Normal to leave)",
2 => "Centerline mode: new lines are centerlines",
3 => "Infinite mode: new lines are infinite construction lines",
_ => "Normal mode",
};
app.set_status(hint, false);
@@ -124,7 +125,7 @@ pub fn apply_mode(app: &mut SolveApp, id: &str, result: &Value) {
if curves.is_empty() {
return;
}
let lines: Vec<String> = if m == 2 {
let lines: Vec<String> = if m == 2 || m == 3 {
let st = app.session.model.state();
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return };
curves
@@ -144,7 +145,8 @@ pub fn apply_mode(app: &mut SolveApp, id: &str, result: &Value) {
// Part of the drawing's own undo step.
let depth = app.session.undo.len();
if !lines.is_empty() {
let _ = app.session.execute("sketch.centerline", &json!({ "curves": lines, "value": true }));
let line_type = if m == 3 { "sketch.infinite" } else { "sketch.centerline" };
let _ = app.session.execute(line_type, &json!({ "curves": lines, "value": true }));
}
if !rest.is_empty() {
let _ = app.session.execute("sketch.construction", &json!({ "curves": rest, "value": true }));
@@ -158,9 +160,10 @@ fn selected_curves(app: &SolveApp) -> Vec<String> {
}
/// The line types among the selected curves of the active sketch, indexed like the palette's
/// buttons (0 normal, 1 construction, 2 centerline); a mixed selection marks each of its types.
fn selected_linetypes(app: &SolveApp) -> [bool; 3] {
let mut types = [false; 3];
/// buttons (0 normal, 1 construction, 2 centerline, 3 infinite); a mixed selection marks each of
/// its types.
fn selected_linetypes(app: &SolveApp) -> [bool; 4] {
let mut types = [false; 4];
let curves = selected_curves(app);
if curves.is_empty() {
return types;
@@ -168,7 +171,9 @@ fn selected_linetypes(app: &SolveApp) -> [bool; 3] {
let st = app.session.world_state();
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return types };
for c in curves.iter().filter_map(|id| ss.sketch.curve_index(id).and_then(|i| ss.sketch.curves.get(i))) {
let k = if c.centerline {
let k = if c.infinite {
3
} else if c.centerline {
2
} else if c.construction {
1
@@ -182,7 +187,8 @@ fn selected_linetypes(app: &SolveApp) -> [bool; 3] {
types
}
/// Set the line type of the selected curves: 0 normal, 1 construction, 2 centerline (lines).
/// Set the line type of the selected curves: 0 normal, 1 construction, 2 centerline, 3 infinite
/// (the last two for lines).
pub fn set_linetype(app: &mut SolveApp, kind: u8) {
let curves = selected_curves(app);
if curves.is_empty() {
@@ -210,7 +216,14 @@ pub fn set_linetype(app: &mut SolveApp, kind: u8) {
}
app.run("sketch.construction", json!({ "curves": curves, "value": false }))
}
1 => app.run("sketch.construction", json!({ "curves": curves, "value": true })),
1 => {
if !lines.is_empty() {
let _ = app.run("sketch.infinite", json!({ "curves": lines, "value": false }));
}
app.run("sketch.construction", json!({ "curves": curves, "value": true }))
}
3 if lines.is_empty() => Err("an infinite line must be a line".to_string()),
3 => app.run("sketch.infinite", json!({ "curves": lines, "value": true })),
_ if lines.is_empty() => Err("a centerline must be a line".to_string()),
_ => app.run("sketch.centerline", json!({ "curves": lines, "value": true })),
};
@@ -430,7 +443,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
// Centerline need a round dot and a clear gap to read apart. 30 by
// 20 px left too little square room for either.
let btn_w = 36.0;
let group_w = 3.0 * btn_w + 2.0 * ui.spacing().item_spacing.x;
let group_w = 4.0 * btn_w + 3.0 * ui.spacing().item_spacing.x;
crate::frame::right_aligned(ui, width, group_w, |ui| {
ui.horizontal(|ui| {
// With a selection: its line type, and a click converts it;
@@ -442,10 +455,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
// construction orange. Normal's icon never changes with the
// accent colour. It stays neutral in every case.
let current = mode(app);
for (i, (label, icon, tip)) in
[("—", "normal", "Normal"), ("- -", "construction", "Construction"), ("-·-", "centerline", "Centerline")]
.iter()
.enumerate()
for (i, (label, icon, tip)) in [
("—", "normal", "Normal"),
("- -", "construction", "Construction"),
("-·-", "centerline", "Centerline"),
("↔", "infinite", "Infinite"),
]
.iter()
.enumerate()
{
let on = if has_curves { selected_types.get(i).copied().unwrap_or(false) } else { current == i as u8 };
let tr_tip = app.tr_label(tip);
+3 -1
View File
@@ -533,7 +533,9 @@ pub(crate) fn paint_preview_curve(
mode: u8,
) {
for curve in &mut sketch.curves {
curve.centerline = mode == 2 && matches!(curve.kind, solidcraft_engine::sketch::CurveKind::Line { .. });
let line = matches!(curve.kind, solidcraft_engine::sketch::CurveKind::Line { .. });
curve.centerline = mode == 2 && line;
curve.infinite = mode == 3 && line;
curve.construction = mode != 0 && !curve.centerline;
}
paint_curve(&sketch, 0, plane, painter, proj, stroke, &mut solidcraft_engine::render::SamplingBudget::new(4096, 65_536));
+2 -1
View File
@@ -807,7 +807,8 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &std::sync::Arc<solidcra
/// Sketch line types share the same world-space dash phase in the scene and overlays.
pub(crate) fn sketch_segments(pts: &[Vec3], mode: u8, unit: f64) -> Vec<(Vec3, Vec3)> {
match mode {
1 => dashes(pts, &[0.3 * unit, 0.2 * unit]),
// Construction, and infinite lines (#1359), which are construction too.
1 | 3 => dashes(pts, &[0.3 * unit, 0.2 * unit]),
2 => dashes(pts, &[0.6 * unit, 0.15 * unit, 0.05 * unit, 0.15 * unit]),
_ => pts.windows(2).map(|w| (w[0], w[1])).collect(),
}