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