diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0a248ba6..51d041f6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod selection; pub mod sketch_dims; #[cfg(test)] mod sketch_edit_tests; +pub mod sketch_tools; pub mod theme; pub mod timeline; pub mod toolbar; diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs new file mode 100644 index 00000000..1333ccbf --- /dev/null +++ b/crates/ui-egui/src/sketch_tools.rs @@ -0,0 +1,610 @@ +//! More interactive sketch tools: projection and other model-pick tools, curve tools (trim, +//! extend, break, offset, fillet, chamfer), multi-pick tools (mirror, patterns, polygon), point +//! tools for free-form curves and slots, text placement, and constraint glyphs. Like `tools`, +//! these are UI state only: every action runs a command. + +use std::cell::{Cell, RefCell}; + +use egui::{Pos2, Stroke}; +use serde_json::{Value, json}; +use solvecraft_engine::geom::Vec2; + +use crate::SolveApp; +use crate::tools::{Kind, Tool}; +use crate::viewport::{Hit, Proj, pick, sketch_point_at}; + +thread_local! { + /// A placed text waiting for its string (position, text so far). + static TEXT: RefCell> = const { RefCell::new(None) }; + /// Shift held this frame. + static SHIFT: Cell = const { Cell::new(false) }; +} + +/// What the extra tools do with clicks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mode { + /// Each click on model geometry runs the command with that reference. + ModelRef, + /// Click a curve of the active sketch where it should change. + CurveAt, + /// Click points; `n` complete the command (0: until right-click). + Points(usize), + /// Pick sketch entities; `n` run the command (0: until right-click). + Entities(usize), + /// Pick entities until right-click, then one more pick or point (mirror line, centre). + EntitiesThen, + /// Model faces / sketch curves in steps (see the command). + Steps, +} + +fn mode(id: &str) -> Option { + Some(match id { + "ProjectNewCmd" | "IntersectCmd" | "Include3DGeometry" | "FitCurvesToSectionCommand" | "SketchIsoparametricCurve" => Mode::ModelRef, + "TrimSketchCmd" | "ExtendSketchCmd" | "BreakSketchCmd" => Mode::CurveAt, + "SketchMidpointLine" => Mode::Points(2), + "ArcTangent" => Mode::Points(2), + "CircleElipse" | "ConicCurveCmd" | "ShapeSlotCenterPoint" => Mode::Points(3), + "ShapeArcSlotThreePoint" | "ShapeArcSlotCenterTwoPoint" => Mode::Points(4), + "DrawSpline" | "DrawCVMSpline3D" | "DrawCVMSpline5D" => Mode::Points(0), + "MTextCmd" => Mode::Points(1), + "BlendG1CurveSketchCmd" => Mode::Points(2), + "Offset" => Mode::Entities(1), + "FilletSketchCmd" | "ChamferSketchEqualDistance" | "ChamferSketchDistanceAngle" | "ChamferSketchDistanceDistance" => Mode::Entities(1), + "ConstraintSmooth" | "CircleTanTanRadius" => Mode::Entities(2), + "CircleThreeTangent" => Mode::Entities(3), + "SketchConstrainer" | "SketchPolygonConstraintCmd" => Mode::Entities(0), + "sketch.centerline" => Mode::Entities(1), + "MirrorSketchCommand" | "CircularSketchPatternCommand" | "RectangularSketchPatternCommand" => Mode::EntitiesThen, + "ProjectToSurface" | "IntersectionCurve" | "SpunProfileCmd" => Mode::Steps, + _ => return None, + }) +} + +/// A tool for one of the extra sketch commands. +pub fn tool_for(id: &str) -> Option { + mode(id)?; + let spec = solvecraft_engine::find_command(id)?; + Some(Tool { + cmd: spec.id, + kind: Kind::Ext, + pts: Vec::new(), + hover: None, + picks: Vec::new(), + dims: Vec::new(), + dims_stage: usize::MAX, + dims_focus: false, + }) +} + +pub fn hint(id: &str) -> Option { + Some( + match id { + "ProjectNewCmd" => "Project: click edges, faces, vertices, other sketches' curves, axes or planes", + "IntersectCmd" => "Intersect: click faces or edges to cut with the sketch plane", + "Include3DGeometry" => "Include 3D Geometry: click edges or vertices", + "FitCurvesToSectionCommand" => "Fit Curves to Mesh Section: click a body", + "SketchIsoparametricCurve" => "Isoparametric Curve: click a point on a face (Shift: along)", + "TrimSketchCmd" => "Trim: click the piece of a curve to remove", + "ExtendSketchCmd" => "Extend: click a curve near the end to extend", + "BreakSketchCmd" => "Break: click a curve where it should split", + "SketchMidpointLine" => "Midpoint line: click the middle, then an end", + "ArcTangent" => "Tangent arc: click the end of a line or arc, then the arc's end", + "CircleElipse" => "Ellipse: click the centre, the end of the major axis, then a point on it", + "ConicCurveCmd" => "Conic: click the start, the end, then the apex", + "ShapeSlotCenterPoint" => "Slot: click the centre, an arc centre, then the width", + "ShapeArcSlotThreePoint" => "Arc slot: click start, a point on the arc, the end, then the width", + "ShapeArcSlotCenterTwoPoint" => "Arc slot: click the centre, the start, the end, then the width", + "DrawSpline" => "Spline: click fit points; right-click to finish", + "DrawCVMSpline3D" | "DrawCVMSpline5D" => "Control point spline: click control points; right-click to finish", + "MTextCmd" => "Text: click where the text starts", + "BlendG1CurveSketchCmd" => "Blend curve: click the ends of two curves", + "Offset" => "Offset: click a curve (its chain is offset), then the side and distance", + "FilletSketchCmd" => "Fillet: click the corner of two lines", + "ChamferSketchEqualDistance" | "ChamferSketchDistanceAngle" | "ChamferSketchDistanceDistance" => "Chamfer: click the corner of two lines", + "ConstraintSmooth" => "Curvature: click two curves that share an end", + "CircleTanTanRadius" => "2-tangent circle: click two curves (the circle goes near the second click)", + "CircleThreeTangent" => "3-tangent circle: click three curves", + "SketchConstrainer" => "Constrain: click one or two entities; right-click to apply", + "SketchPolygonConstraintCmd" => "Polygon: click the lines of a closed chain; right-click to apply", + "sketch.centerline" => "Centerline: click lines", + "MirrorSketchCommand" => "Mirror: click entities, right-click, then click the mirror line", + "CircularSketchPatternCommand" => "Circular pattern: click entities, right-click, then click the centre", + "RectangularSketchPatternCommand" => "Rectangular pattern: click entities, right-click, then click two points (direction and spacing)", + "ProjectToSurface" => "Project to surface: click curves of other sketches, right-click, then click the face", + "IntersectionCurve" => "Intersection curve: click two faces", + "SpunProfileCmd" => "Spun profile: click the body, then the axis line", + _ => return None, + } + .to_string(), + ) +} + +fn active_sketch(app: &SolveApp) -> Option { + app.session.active_sketch +} + +/// A model reference for a hit (for Project and friends). +fn model_ref(app: &SolveApp, h: &Hit) -> Option { + let active = active_sketch(app); + Some(match h { + Hit::Vertex { body, point } => json!({"vertex": [point.x, point.y, point.z], "body": body}), + Hit::Edge { body, mid, .. } => json!({"edge": [mid.x, mid.y, mid.z], "body": body}), + Hit::Face { body, point, .. } => json!({"face": [point.x, point.y, point.z], "body": body}), + Hit::SketchCurve { sketch, id, .. } if Some(*sketch) != active => json!({"sketch": sketch, "curve": id}), + Hit::SketchPoint { sketch, id, .. } if Some(*sketch) != active => json!({"sketch": sketch, "point": id}), + Hit::Axis { name } => json!({"axis": name}), + Hit::Plane { name, .. } => json!({"plane": name}), + _ => return None, + }) +} + +/// The active sketch's curve or point under the cursor. +fn sketch_entity(app: &SolveApp, hits: &[Hit]) -> Option<(String, bool)> { + let active = active_sketch(app)?; + hits.iter().find_map(|h| match h { + Hit::SketchPoint { sketch, id, .. } if *sketch == active => Some((id.clone(), true)), + Hit::SketchCurve { sketch, id, .. } if *sketch == active => Some((id.clone(), false)), + _ => None, + }) +} + +fn xy(p: Vec2) -> Value { + json!([p.x, p.y]) +} + +fn arg(p: &(Vec2, Option)) -> Value { + match &p.1 { + Some(id) => json!(id), + None => xy(p.0), + } +} + +/// Length of a curve (for default sizes). +fn curve_len(app: &SolveApp, id: &str) -> f64 { + let st = app.session.model.state(); + let Some(ss) = active_sketch(app).and_then(|s| st.sketch(s)) else { return 10.0 }; + ss.sketch.curve_index(id).map(|c| ss.sketch.polyline(c).windows(2).map(|w| w[0].dist(w[1])).sum::()).unwrap_or(10.0) +} + +/// Lines meeting at a sketch point. +fn corner_len(app: &SolveApp, point: &str) -> f64 { + let st = app.session.model.state(); + let Some(ss) = active_sketch(app).and_then(|s| st.sketch(s)) else { return 10.0 }; + let sk = &ss.sketch; + let Some(q) = sk.resolve_point(point) else { return 10.0 }; + sk.curves + .iter() + .enumerate() + .filter(|(_, c)| c.kind.uses(q) && matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. })) + .map(|(i, _)| sk.polyline(i).windows(2).map(|w| w[0].dist(w[1])).sum::()) + .fold(f64::INFINITY, f64::min) + .min(1e6) +} + +fn round_nice(x: f64) -> f64 { + if !(x.is_finite() && x > 0.0) { + return 1.0; + } + let p = 10f64.powf(x.log10().floor()); + let m = x / p; + let n = if m < 1.5 { + 1.0 + } else if m < 3.5 { + 2.0 + } else if m < 7.5 { + 5.0 + } else { + 10.0 + }; + n * p +} + +/// A click with an extra tool. +pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { + let Some(m) = mode(tool.cmd) else { return }; + let hits = pick(app, proj, pos); + let at = sketch_point_at(app, proj, pos); + let cmd = tool.cmd; + match m { + Mode::ModelRef => { + let r = hits.iter().find_map(|h| model_ref(app, h)); + let Some(r) = r else { return }; + match cmd { + "FitCurvesToSectionCommand" => { + if let Some(b) = r.get("body").and_then(Value::as_str) { + let _ = app.run(cmd, json!({"body": b})); + } + } + "SketchIsoparametricCurve" => { + if r.get("face").is_some() { + let along = SHIFT.with(Cell::get); + let _ = app.run(cmd, json!({"face": r["face"], "body": r["body"], "direction": if along { "v" } else { "u" }})); + } + } + _ => { + let _ = app.run(cmd, json!({"refs": [r]})); + } + } + } + Mode::CurveAt => { + if let (Some((id, false)), Some((p, _))) = (sketch_entity(app, &hits), at) { + let _ = app.run(cmd, json!({"curve": id, "at": xy(p)})); + } + } + Mode::Points(n) => { + let Some(p) = at else { return }; + tool.pts.push(p); + if n > 0 && tool.pts.len() >= n { + run_points(app, tool); + tool.pts.clear(); + } + } + Mode::Entities(n) => { + if cmd == "Offset" && !tool.picks.is_empty() { + if let Some((p, _)) = at { + offset_side(app, tool, p); + } + return; + } + let Some((id, is_point)) = sketch_entity(app, &hits) else { return }; + if cmd == "Offset" { + // First the curve, then the side point. + if tool.picks.is_empty() && !is_point { + tool.picks.push(id); + } else if let Some((p, _)) = at { + offset_side(app, tool, p); + } + return; + } + if cmd.starts_with("Fillet") || cmd.starts_with("Chamfer") { + if is_point { + run_corner(app, cmd, &id); + } + return; + } + if cmd == "sketch.centerline" { + let _ = app.run(cmd, json!({"curves": [id]})); + return; + } + if !tool.picks.contains(&id) { + tool.picks.push(id); + if let Some((p, _)) = at { + tool.pts.push((p, None)); + } + } + if n > 0 && tool.picks.len() >= n { + run_entities(app, tool); + tool.picks.clear(); + tool.pts.clear(); + } + } + Mode::EntitiesThen => { + // Stage 2 (after a right-click) is marked by a "|" pick. + if tool.picks.last().is_some_and(|x| x == "|") { + let ents: Vec = tool.picks.iter().filter(|x| *x != "|").cloned().collect(); + match cmd { + "MirrorSketchCommand" => { + if let Some((line, false)) = sketch_entity(app, &hits) { + let _ = app.run(cmd, json!({"entities": ents, "line": line})); + tool.picks.clear(); + } + } + "CircularSketchPatternCommand" => { + if let Some(p) = at { + let _ = app.run(cmd, json!({"entities": ents, "center": arg(&p), "count": 6})); + tool.picks.clear(); + } + } + _ => { + if let Some(p) = at { + tool.pts.push(p); + if tool.pts.len() >= 2 { + let d = tool.pts[1].0 - tool.pts[0].0; + if d.len() > 1e-9 { + let _ = app.run(cmd, json!({"entities": ents, "dir": xy(d), "count": 3, "spacing": d.len()})); + } + tool.picks.clear(); + tool.pts.clear(); + } + } + } + } + return; + } + if let Some((id, _)) = sketch_entity(app, &hits) + && !tool.picks.contains(&id) + { + tool.picks.push(id); + } + } + Mode::Steps => match cmd { + "IntersectionCurve" => { + if let Some(r) = hits.iter().find_map(|h| model_ref(app, h)).filter(|r| r.get("face").is_some()) { + tool.picks.push(r.to_string()); + if tool.picks.len() >= 2 { + let a: Value = serde_json::from_str(&tool.picks[0]).unwrap_or(Value::Null); + let b: Value = serde_json::from_str(&tool.picks[1]).unwrap_or(Value::Null); + let _ = app.run(cmd, json!({"a": a, "b": b})); + tool.picks.clear(); + } + } + } + "SpunProfileCmd" => { + if tool.picks.is_empty() { + if let Some(b) = + hits.iter().find_map(|h| model_ref(app, h)).and_then(|r| r.get("body").and_then(Value::as_str).map(str::to_string)) + { + tool.picks.push(b); + } + } else if let Some((line, false)) = sketch_entity(app, &hits) { + let _ = app.run(cmd, json!({"body": tool.picks[0], "axis": line})); + tool.picks.clear(); + } + } + _ => { + // ProjectToSurface: curves of other sketches, then (after a right-click) a face. + let staged = tool.picks.last().is_some_and(|x| x == "|"); + if staged { + if let Some(r) = hits.iter().find_map(|h| model_ref(app, h)).filter(|r| r.get("face").is_some()) { + let curves: Vec = tool.picks.iter().filter(|x| *x != "|").filter_map(|x| serde_json::from_str(x).ok()).collect(); + let _ = app.run(cmd, json!({"curves": curves, "face": r["face"], "body": r["body"]})); + tool.picks.clear(); + } + } else if let Some(r) = hits.iter().find_map(|h| model_ref(app, h)).filter(|r| r.get("curve").is_some()) { + tool.picks.push(r.to_string()); + } + } + }, + } +} + +fn run_points(app: &mut SolveApp, t: &Tool) { + let p = &t.pts; + let width = |a: Vec2, b: Vec2, c: Vec2| -> f64 { 2.0 * (b - a).normalized().map(|d| d.cross(c - a).abs()).unwrap_or(1.0) }; + let params = match (t.cmd, p.as_slice()) { + ("SketchMidpointLine", [m, e]) => json!({"mid": arg(m), "end": xy(e.0)}), + ("ArcTangent", [s, e]) => json!({"start": arg(s), "end": xy(e.0)}), + ("CircleElipse", [c, m, q]) => json!({"center": xy(c.0), "major": xy(m.0), "minor": xy(q.0)}), + ("ConicCurveCmd", [a, b, x]) => json!({"start": arg(a), "end": arg(b), "apex": arg(x)}), + ("ShapeSlotCenterPoint", [c, e, w]) => json!({"center": xy(c.0), "end": xy(e.0), "width": width(c.0, e.0, w.0).max(1e-3)}), + ("ShapeArcSlotThreePoint", [a, th, b, w]) => { + json!({"start": xy(a.0), "through": xy(th.0), "end": xy(b.0), "width": (2.0 * w.0.dist(b.0)).max(1e-3)}) + } + ("ShapeArcSlotCenterTwoPoint", [c, a, b, w]) => { + let r = c.0.dist(a.0); + json!({"center": xy(c.0), "start": xy(a.0), "end": xy(b.0), "width": (2.0 * (w.0.dist(c.0) - r).abs()).max(1e-3)}) + } + ("DrawSpline" | "DrawCVMSpline3D" | "DrawCVMSpline5D", pts) if pts.len() >= 2 => { + json!({"points": pts.iter().map(arg).collect::>()}) + } + ("BlendG1CurveSketchCmd", [a, b]) => match (&a.1, &b.1) { + (Some(x), Some(y)) => json!({"a": x, "b": y}), + _ => return, + }, + ("MTextCmd", [at]) => { + let at = at.0; + TEXT.with(|t| *t.borrow_mut() = Some((at, String::new()))); + return; + } + _ => return, + }; + let _ = app.run(t.cmd, params); +} + +fn run_corner(app: &mut SolveApp, cmd: &str, point: &str) { + let size = round_nice(corner_len(app, point) * 0.2); + let params = match cmd { + "FilletSketchCmd" => json!({"point": point, "radius": size}), + "ChamferSketchDistanceAngle" => json!({"point": point, "distance": size, "angle": 45}), + "ChamferSketchDistanceDistance" => json!({"point": point, "distance": size, "distance2": size}), + _ => json!({"point": point, "distance": size}), + }; + if let Ok(v) = app.run(cmd, params) + && let Some(p) = v["param"].as_str() + { + app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, p)); + } +} + +fn run_entities(app: &mut SolveApp, t: &Tool) { + let k = &t.picks; + let params = match t.cmd { + "ConstraintSmooth" => json!({"a": k[0], "b": k[1]}), + "CircleTanTanRadius" => { + let r = round_nice(curve_len(app, &k[0]).min(curve_len(app, &k[1])) * 0.1); + let near = t.pts.last().map(|p| xy(p.0)).unwrap_or(json!([0, 0])); + json!({"curves": k, "radius": r, "near": near}) + } + "CircleThreeTangent" => { + // Start inside the three picks. + let c = t.pts.iter().fold(Vec2::ZERO, |a, p| a + p.0) / t.pts.len().max(1) as f64; + json!({"curves": k, "near": xy(c)}) + } + "SketchConstrainer" => json!({"entities": k}), + "SketchPolygonConstraintCmd" => json!({"lines": k}), + _ => return, + }; + let _ = app.run(t.cmd, params); +} + +/// Right-click with an extra tool: finish a chain or move to the next stage. Returns false +/// when the tool should be dropped. +pub fn finish(app: &mut SolveApp, tool: &mut Tool) -> bool { + let Some(m) = mode(tool.cmd) else { return false }; + match m { + Mode::Points(0) if tool.pts.len() >= 2 => { + run_points(app, tool); + tool.pts.clear(); + true + } + Mode::Entities(0) if !tool.picks.is_empty() => { + run_entities(app, tool); + tool.picks.clear(); + tool.pts.clear(); + true + } + Mode::EntitiesThen | Mode::Steps if !tool.picks.is_empty() && tool.picks.last().is_some_and(|x| x != "|") => { + tool.picks.push("|".into()); + true + } + _ if !tool.pts.is_empty() || !tool.picks.is_empty() => { + tool.pts.clear(); + tool.picks.clear(); + true + } + _ => false, + } +} + +/// Offset: the second click sets the side and distance. +fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool { + if tool.cmd != "Offset" || tool.picks.is_empty() { + return false; + } + let st = app.session.model.state(); + let d = active_sketch(app) + .and_then(|s| st.sketch(s)) + .and_then(|ss| ss.sketch.curve_index(&tool.picks[0]).and_then(|c| ss.sketch.shape(c))) + .map(|sh| sh.dist(p)) + .unwrap_or(0.0); + if d > 1e-6 { + let _ = app.run("Offset", json!({"curves": [tool.picks[0]], "distance": d, "side": xy(p)})); + } + tool.picks.clear(); + true +} + +/// Rubber band for point tools and pick markers. +pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { + let Some(sid) = active_sketch(app) else { return }; + let st = app.session.model.state(); + let Some(ss) = st.sketch(sid) else { return }; + let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); + let tk = crate::theme::Tokens::get(); + let stroke = Stroke::new(1.5, tk.rubber_band); + if let Some((hp, snap)) = &t.hover + && let Some(sp) = to(*hp) + { + painter.circle_stroke(sp, if snap.is_some() { 6.0 } else { 3.0 }, stroke); + } + let pts: Vec = t.pts.iter().filter_map(|p| to(p.0)).collect(); + for w in pts.windows(2) { + painter.line_segment([w[0], w[1]], Stroke::new(1.0, tk.rubber_band.gamma_multiply(0.6))); + } + if let (Some(last), Some((h, _))) = (pts.last(), t.hover.as_ref()) + && let Some(hs) = to(*h) + { + painter.line_segment([*last, hs], stroke); + } + // Picked curves highlighted. + for id in t.picks.iter().filter(|x| *x != "|") { + if let Some(ci) = ss.sketch.curve_index(id) { + let v: Vec = ss.sketch.polyline(ci).iter().filter_map(|q| to(*q)).collect(); + painter.add(egui::Shape::line(v, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)))); + } + } +} + +/// Constraint glyphs of the active sketch: a small badge per constraint at its anchor +/// (conflicting ones red). +fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + let Some(sid) = active_sketch(app) else { return }; + let st = app.session.model.state(); + let Some(ss) = st.sketch(sid) else { return }; + let tk = crate::theme::Tokens::get(); + let mut placed: Vec = Vec::new(); + for c in &ss.sketch.constraints { + if c.kind.is_dimension() { + continue; + } + let Some(at) = glyph_anchor(&ss.sketch, &c.kind) else { continue }; + let Some(mut sp) = proj.to_screen(ss.plane.to_world(at)) else { continue }; + sp += egui::vec2(9.0, -9.0); + // Keep glyphs at one spot from overlapping. + while placed.iter().any(|q| q.distance(sp) < 12.0) { + sp += egui::vec2(13.0, 0.0); + } + placed.push(sp); + let failing = ss.report.failing.contains(&c.id); + let col = if failing { tk.error } else { tk.text_dim }; + let r = egui::Rect::from_center_size(sp, egui::vec2(12.0, 12.0)); + painter.rect_filled(r, 2.0, tk.panel.gamma_multiply(0.85)); + painter.rect_stroke(r, 2.0, Stroke::new(0.8, col), egui::StrokeKind::Inside); + painter.text(sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(9.0), col); + } +} + +fn glyph_letter(name: &str) -> &'static str { + match name { + "Coincident" => "\u{25cf}", + "Horizontal" => "H", + "Vertical" => "V", + "Parallel" => "//", + "Perpendicular" => "\u{22a5}", + "Collinear" => "\u{2261}", + "Tangent" => "T", + "Smooth" => "G2", + "Equal" => "=", + "Concentric" => "\u{25ce}", + "MidPoint" => "M", + "Symmetry" => "[]", + "Fix" => "\u{1f512}", + _ => "?", + } +} + +fn glyph_anchor(sk: &solvecraft_engine::sketch::Sketch, k: &solvecraft_engine::sketch::ConstraintKind) -> Option { + use solvecraft_engine::sketch::ConstraintKind::*; + let mid = |c: usize| -> Option { + let poly = sk.polyline(c); + poly.get(poly.len() / 2).copied() + }; + match *k { + Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => sk.point(p), + HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((sk.point(p)? + sk.point(q)?) * 0.5), + Horizontal { l } | Vertical { l } => mid(l), + Parallel { a, .. } + | Perpendicular { a, .. } + | Collinear { a, .. } + | Equal { a, .. } + | Concentric { a, .. } + | Tangent { a, .. } + | Smooth { a, .. } => mid(a), + _ => None, + } +} + +/// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box. +pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) { + SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift))); + glyphs(app, painter, proj); + text_entry(app, ui.ctx()); +} + +/// The text-entry box for a placed text. +fn text_entry(app: &mut SolveApp, ctx: &egui::Context) { + let Some((at, mut text)) = TEXT.with(|t| t.borrow_mut().take()) else { return }; + let mut done = None; + egui::Window::new("Text").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)).show(ctx, |ui| { + let r = ui.text_edit_singleline(&mut text); + r.request_focus(); + ui.horizontal(|ui| { + if ui.button("OK").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + done = Some(true); + } + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + done = Some(false); + } + }); + }); + match done { + Some(true) => { + if !text.trim().is_empty() { + let h = round_nice(app.cam.half_height() * 0.1); + let _ = app.run("MTextCmd", json!({"text": text, "at": xy(at), "height": h})); + } + } + Some(false) => {} + None => TEXT.with(|t| *t.borrow_mut() = Some((at, text))), + } +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 64ac7698..88f3ed57 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -15,6 +15,8 @@ pub enum Kind { /// Pick sketch entities; `n` picks run the command. Pick(usize), Dimension, + /// A tool from `sketch_tools` (it handles its own clicks). + Ext, } #[derive(Clone, Debug)] @@ -64,7 +66,7 @@ impl Tool { "ConstraintMidPoint" => ("ConstraintMidPoint", Kind::Pick(2)), "ConstraintSymmetry" => ("ConstraintSymmetry", Kind::Pick(3)), "sketch.construction" => ("sketch.construction", Kind::Pick(1)), - _ => return None, + _ => return crate::sketch_tools::tool_for(id), }; Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new(), dims: Vec::new(), dims_stage: usize::MAX, dims_focus: false }) } @@ -82,7 +84,7 @@ pub fn hint(id: &str) -> String { "SketchDimension" => "Dimension: pick a line, circle or arc (or two points/lines)".into(), "ShapeSlotCenterToCenter" | "ShapeSlotOverall" => "Slot: click both ends, then the width".into(), _ if id.starts_with("Constraint") => "Constraint: pick the sketch entities".into(), - _ => "Click in the sketch".into(), + _ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()), } } @@ -131,6 +133,7 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { tool.picks.clear(); } } + Kind::Ext => crate::sketch_tools::on_click(app, &mut tool, proj, pos), Kind::Dimension => { let hits = pick(app, proj, pos); let hit = hits.iter().find_map(|h| match h { @@ -249,6 +252,12 @@ fn run_pick(app: &mut SolveApp, t: &Tool) { /// Right click / Esc: end the current chain (line) or cancel the tool. pub fn finish(app: &mut SolveApp) { + if let Some(mut t) = app.tool.take_if(|t| t.kind == Kind::Ext) { + if crate::sketch_tools::finish(app, &mut t) { + app.tool = Some(t); + } + return; + } if let Some(t) = app.tool.as_mut() && !t.pts.is_empty() { @@ -261,6 +270,9 @@ pub fn finish(app: &mut SolveApp) { /// Rubber-band preview of the shape being drawn. pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { + if t.kind == Kind::Ext { + return crate::sketch_tools::preview(app, t, painter, proj); + } let Some(sid) = app.session.active_sketch else { return }; let st = app.session.model.state(); let Some(ss) = st.sketch(sid) else { return }; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 35a41062..50e0caa8 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -956,6 +956,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::tools::preview(app, tl, &painter, &proj); } crate::canvas::show(app, ui, &painter, &proj); + crate::sketch_tools::show(app, ui, &painter, &proj); crate::sketch_dims::show(app, ui, &proj); view_cube(app, ui, rect); nav_bar(app, ui, rect); @@ -1133,6 +1134,9 @@ fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj let Some(ss) = st.sketch(sid) else { return }; let sk = &ss.sketch; for (i, p) in sk.points.iter().enumerate() { + if !solvecraft_engine::view::sketch_point_visible(sk, i) { + continue; + } let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) else { continue }; let det = ss.report.point_determined.get(i).copied().unwrap_or(false); let c = if det { t.sketch_point } else { t.sketch_point_free };