From 5ad8b3966fce13b906f8ac58c61f85d4aeccafcf Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:26:00 +0200 Subject: [PATCH] fix(ui): preview actual polygon outlines while drawing (#583) Inscribed, circumscribed and edge polygon tools preview their actual outline (side count and orientation) while drawing, with the current line type, instead of only the support circle. Preview work is bounded. Fixes #520. Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com> --- ROADMAP.md | 2 + .../src/sketch_display_preview_tests.rs | 32 ++++++++++++++- crates/ui-egui/src/tools.rs | 40 +++++++++++++++++++ .../tests/scenarios/polygon_preview.json | 25 ++++++++++++ docs/book/src/guide/sketching.md | 4 ++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/scenarios/polygon_preview.json diff --git a/ROADMAP.md b/ROADMAP.md index 709880c2..0ada4897 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,6 +61,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. ## Dimensioning and error reporting +- Inscribed, circumscribed and edge polygon tools preview their actual outline before + placement, including orientation and the selected side count. - Construction (X) preserves the active sketch drawing tool, picked anchors and inline dimension values, so line type can change while a shape is in progress. - Planar sketch construction previews and hover feedback keep the same dashed pattern as diff --git a/crates/ui-egui/src/sketch_display_preview_tests.rs b/crates/ui-egui/src/sketch_display_preview_tests.rs index fef371d4..0fc780c2 100644 --- a/crates/ui-egui/src/sketch_display_preview_tests.rs +++ b/crates/ui-egui/src/sketch_display_preview_tests.rs @@ -1,4 +1,4 @@ -use super::{Tool, preview_curve}; +use super::{Tool, polygon_outline, preview_curve}; use crate::scenario::Harness; use crate::viewport::Proj; use serde_json::{Value, json}; @@ -120,3 +120,33 @@ fn pixel_preview_rejects_degenerate_and_unbounded_input_without_changing_documen let points = vec![[0., 0.]; 500]; assert!(preview_curve(&tool("sketch.spline.fit_point", &points), Vec2::X, None).is_none()); } + +#[test] +fn polygon_preview_squares_have_the_expected_rotation_and_size() { + let cases = [ + ("sketch.polygon.inscribed", [10.0, 0.0], [[10.0, 0.0], [0.0, 10.0], [-10.0, 0.0], [0.0, -10.0], [10.0, 0.0]]), + ("sketch.polygon.circumscribed", [10.0, 0.0], [[10.0, 10.0], [-10.0, 10.0], [-10.0, -10.0], [10.0, -10.0], [10.0, 10.0]]), + ("sketch.polygon.edge", [20.0, 0.0], [[0.0, 0.0], [20.0, 0.0], [20.0, 20.0], [0.0, 20.0], [0.0, 0.0]]), + ]; + for (command, hover, expected) in cases { + let t = tool(command, &[[0.0, 0.0]]); + let outline = polygon_outline(&t, Vec2::new(hover[0], hover[1]), 4.0).unwrap(); + assert_eq!(outline.len(), expected.len()); + for (actual, expected) in outline.iter().zip(expected) { + assert!(actual.dist(Vec2::new(expected[0], expected[1])) < 1e-9, "{command}: {actual:?}"); + } + } +} + +#[test] +fn polygon_preview_rejects_invalid_or_unbounded_input() { + let t = tool("sketch.polygon.inscribed", &[[0.0, 0.0]]); + for sides in [0.0, 2.0, 3.5, 201.0, f64::INFINITY, f64::NAN] { + assert!(polygon_outline(&t, Vec2::X, sides).is_none()); + } + assert!(polygon_outline(&t, Vec2::ZERO, 6.0).is_none()); + assert!(polygon_outline(&t, Vec2::new(f64::NAN, 0.0), 6.0).is_none()); + assert_eq!(polygon_outline(&t, Vec2::X, 200.0).unwrap().len(), 201); + let t = tool("sketch.polygon.inscribed", &[[f64::INFINITY, 0.0]]); + assert!(polygon_outline(&t, Vec2::X, 6.0).is_none()); +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index f49be818..90fc63a2 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -580,6 +580,37 @@ pub(crate) fn preview_curve(t: &Tool, hover: Vec2, sweep: Option) -> Option Some(sk) } +/// The polygon's actual straight edges, in the same orientation as the eventual command. +/// Keep preview work bounded even while a side-count expression is being edited. +pub(crate) fn polygon_outline(t: &Tool, hover: Vec2, sides: f64) -> Option> { + if !hover.is_finite() || !(3.0..=200.0).contains(&sides) || sides.fract() != 0.0 || t.pts.len() != 1 { + return None; + } + let a = t.pts.first()?.0; + let n = sides as usize; + let half = std::f64::consts::PI / sides; + let distance = a.dist(hover); + if !a.is_finite() || !distance.is_finite() || distance <= 1e-9 { + return None; + } + let (centre, radius, start) = match t.cmd { + "sketch.polygon.inscribed" => (a, distance, (hover - a).angle()), + "sketch.polygon.circumscribed" => (a, distance / half.cos(), (hover - a).angle() + half), + "sketch.polygon.edge" => { + let radius = distance / (2.0 * half.sin()); + let centre = (a + hover) * 0.5 + (hover - a).normalized()?.perp() * (radius * half.cos()); + (centre, radius, (a - centre).angle()) + } + _ => return None, + }; + let mut corners: Vec<_> = (0..n).map(|i| centre + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / sides) * radius).collect(); + if corners.iter().any(|p| !p.is_finite()) { + return None; + } + corners.push(*corners.first()?); + Some(corners) +} + pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { if crate::sketch3d::preview(app, t, painter, proj) { return; @@ -607,6 +638,15 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { } let Some((h, _)) = t.hover.clone() else { return }; let h = crate::sketch_dims::effective(app, t, h); + if let Some(corners) = polygon_outline(t, h, crate::sketch_dims::locked_value(app, t, "Sides").unwrap_or(6.0)) { + let outline: Vec<_> = corners.iter().map(|c| ss.plane.to_world(*c)).collect(); + paint_sketch_path(&outline, crate::sketch_palette::mode(app), painter, proj, stroke); + crate::scenario::publish_count("polygon_preview_edges", (corners.len() - 1) as f64); + if t.cmd == "sketch.polygon.edge" { + return; + } + } + if let Some(curve) = preview_curve(t, h, crate::sketch_dims::locked_value(app, t, "Sweep")) { paint_preview_curve(curve, &ss.plane, painter, proj, stroke, crate::sketch_palette::mode(app)); return; diff --git a/crates/ui-egui/tests/scenarios/polygon_preview.json b/crates/ui-egui/tests/scenarios/polygon_preview.json new file mode 100644 index 00000000..da1eb031 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/polygon_preview.json @@ -0,0 +1,25 @@ +[ + {"note":"#520: all polygon drawing variants show their real edge outline before committing, including a typed side count."}, + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"call":"ui.view","params":{"view":"top"}}, + {"start":"sketch.polygon.inscribed"}, + {"click":{"sketch":[0,0]}}, + {"move":{"sketch":[20,8]}}, + {"expect":{"count":{"name":"polygon_preview_edges","value":6},"errors":0}}, + {"text":"10"}, + {"key":"Tab"}, + {"text":"4"}, + {"move":{"sketch":[20,10]}}, + {"expect":{"count":{"name":"polygon_preview_edges","value":4},"errors":0}}, + {"key":"Escape"}, + {"start":"sketch.polygon.circumscribed"}, + {"click":{"sketch":[0,0]}}, + {"move":{"sketch":[20,8]}}, + {"expect":{"count":{"name":"polygon_preview_edges","value":6},"errors":0}}, + {"key":"Escape"}, + {"start":"sketch.polygon.edge"}, + {"click":{"sketch":[0,0]}}, + {"move":{"sketch":[20,8]}}, + {"expect":{"count":{"name":"polygon_preview_edges","value":6},"errors":0}}, + {"key":"Escape"} +] diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 63e96322..eedd669d 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -31,6 +31,10 @@ fit-point and control-point splines, conics, points and text. **Construction** ( into reference geometry that doesn't form profiles. Modify tools include trim (T), extend, break, fillet, chamfer, offset (O), mirror, patterns, move/copy and scale. +Polygon tools show their edge outline while positioning the second point, so the orientation +can be checked before placement. Inscribed and circumscribed polygons retain their support +circle and update the outline when the inline **Sides** value changes. + **Text** is set in the built-in font (Liberation Sans) unless it is given another: `sketch.text` and `sketch.edit_text` take a `font`, the family or full name of an installed font ("DejaVu Sans") or the path of a `.ttf` / `.otf` file, and the **Edit Text** dialog has a **Font** field for it. An unknown font is an