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

+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(),
}