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.
|
||||
@@ -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`)"));
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(¶m) } else { ¶m };
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -655,6 +655,7 @@ impl Sketch {
|
||||
link: Some(id.to_string()),
|
||||
centerline: false,
|
||||
fixed: false,
|
||||
infinite: false,
|
||||
});
|
||||
}
|
||||
for li in &lay.lone {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 { .. }) {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user