From 187ffd0a036067f449730d9e0ba7e39ed1cc9575 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 04:19:30 +0000 Subject: [PATCH] =?UTF-8?q?Sketch=20dimensions=20drawn=20as=20dimensions:?= =?UTF-8?q?=20extension=20lines,=20dimension=20lines=20and=20arcs=20with?= =?UTF-8?q?=20arrowheads=20(flipped=20outside=20when=20tight),=20the=20val?= =?UTF-8?q?ue=20centred=20on=20the=20line=20(fx:=20for=20expressions),=20R?= =?UTF-8?q?/=C3=98=20leaders,=20angle=20arcs,=20arc-length=20mark,=20drive?= =?UTF-8?q?n=20ones=20in=20parentheses=20in=20a=20muted=20colour;=20click?= =?UTF-8?q?=20selects,=20Delete=20removes,=20drag=20moves=20the=20text=20(?= =?UTF-8?q?sketch.dimension=5Ftext,=20kept=20relative=20to=20the=20dimensi?= =?UTF-8?q?on),=20double-click=20edits=20the=20value=20in=20place=20with?= =?UTF-8?q?=20parameter=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/sketch.rs | 41 ++- crates/engine/src/cmd/sketch_ux_tests.rs | 112 +++++++ crates/sketch/src/dims.rs | 369 +++++++++++++++++++++++ crates/sketch/src/lib.rs | 2 + crates/sketch/src/model.rs | 6 +- crates/ui-egui/src/control.rs | 27 +- crates/ui-egui/src/dim_view.rs | 346 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/theme.rs | 11 + crates/ui-egui/src/toolbar.rs | 1 + crates/ui-egui/src/viewport.rs | 84 ++---- docs/control-protocol.md | 3 +- 12 files changed, 946 insertions(+), 57 deletions(-) create mode 100644 crates/engine/src/cmd/sketch_ux_tests.rs create mode 100644 crates/sketch/src/dims.rs create mode 100644 crates/ui-egui/src/dim_view.rs diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 7b020715..bd20cce8 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -154,6 +154,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .enabled(in_sketch) .params("entities: [curve, point or constraint ids]"), CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"), + CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text) + .enabled(in_sketch) + .params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"), CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd) .enabled(in_sketch) .noundo() @@ -1132,6 +1135,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result { // A reference dimension: measures, no parameter. let name = k.name(); let id = sk.add_constraint(k, None)?; + place_text(sk, &id, text_at); if let Some(c) = sk.constraints.iter_mut().find(|c| c.id == id) { c.driven = true; } @@ -1149,7 +1153,8 @@ fn dimension(s: &mut Session, p: &Value) -> Result { } let _ = vals; let name = k.name(); - sk.add_constraint(k, Some(pname.clone()))?; + let id = sk.add_constraint(k, Some(pname.clone()))?; + place_text(sk, &id, text_at); Ok((pname, name, cur)) })?; if driven { @@ -1162,6 +1167,36 @@ fn dimension(s: &mut Session, p: &Value) -> Result { Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info})) } +/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the +/// dimension so it follows the geometry. +fn place_text(sk: &mut Sketch, id: &str, at: Option) { + let Some(i) = sk.constraints.iter().position(|c| c.id == id) else { return }; + let text = match (at, sk.constraints.get(i).and_then(|c| solvecraft_sketch::dim_frame(sk, &c.kind))) { + (Some(at), Some(f)) => Some(solvecraft_sketch::encode_text(&f, at)), + _ => None, + }; + if let Some(c) = sk.constraints.get_mut(i) { + c.text = text.filter(|t| t.is_finite() && t.x.abs() < 1e9 && t.y.abs() < 1e9); + } +} + +fn dimension_text(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.dimension_text"; + let key = str_(p, "dimension").ok_or_else(|| bad(cmd, "`dimension` must be a dimension id or parameter name"))?.to_string(); + let at = if bool_(p, "reset").unwrap_or(false) { None } else { Some(req_vec2(cmd, p, "at")?) }; + let (id, info) = edit(s, p, cmd, false, |sk, _| { + let c = sk + .constraints + .iter() + .find(|c| c.kind.is_dimension() && (c.id == key || c.param.as_deref() == Some(key.as_str()))) + .ok_or_else(|| bad(cmd, format!("no dimension `{key}` in this sketch")))?; + let id = c.id.clone(); + place_text(sk, &id, at); + Ok(id) + })?; + Ok(json!({"dimension": id, "sketch": info})) +} + /// Dimension a point (or a parallel line) to line `l` as a diameter: when `l` is a centerline /// (unless a plain distance was asked for) or when a diameter was asked for. fn diameter_about(sk: &Sketch, l: usize, ty: &str) -> bool { @@ -1305,3 +1340,7 @@ fn solve_cmd(s: &mut Session, p: &Value) -> Result { #[cfg(test)] #[path = "sketch_rect_tests.rs"] mod rect_tests; + +#[cfg(test)] +#[path = "sketch_ux_tests.rs"] +mod ux_tests; diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs new file mode 100644 index 00000000..b32de950 --- /dev/null +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -0,0 +1,112 @@ +//! Sketch interaction tests: dimension placement, Fix/Unfix, the construction toggle, and +//! which entity moves when a two-entity constraint is applied. + +use serde_json::{Value, json}; +use solvecraft_sketch::{DimFrame, Sketch, dim_frame, dim_layout}; + +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 { + let id = s.active_sketch.expect("a sketch is being edited"); + s.doc.sketch(id).expect("the sketch").clone() +} + +fn new_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + s +} + +// --------------------------------------------------------------------------------------------- +// Dimensions + +#[test] +fn every_dimension_kind_has_a_drawable_frame() { + let mut s = new_sketch(); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [60, 40]})); + run(&mut s, "CircleCenterRadius", json!({"center": [30, 20], "radius": 8})); + run(&mut s, "ArcCenterTwoPoint", json!({"center": [100, 0], "start": [110, 0], "end": [100, 10]})); + run(&mut s, "DrawPolyline", json!({"points": [[-40, 0], [-10, 0]]})); + run(&mut s, "DrawPolyline", json!({"points": [[-40, 0], [-20, 25]]})); + for (ents, ty) in [ + (json!(["l1"]), "auto"), + (json!(["l2"]), "vertical"), + (json!(["l1.start", "c1.center"]), "horizontal"), + (json!(["c1"]), "auto"), + (json!(["a1"]), "radius"), + (json!(["a1"]), "arc_length"), + (json!(["l5", "l6"]), "angle"), + ] { + run(&mut s, "SketchDimension", json!({"entities": ents, "type": ty})); + } + run(&mut s, "SketchDimension", json!({"entities": ["c1.center", "l2"], "driven": true})); + let sk = sketch(&s); + let dims: Vec<_> = sk.constraints.iter().filter(|c| c.kind.is_dimension()).collect(); + assert_eq!(dims.len(), 8); + let mut kinds = Vec::new(); + for c in dims { + let f = dim_frame(&sk, &c.kind).unwrap_or_else(|| panic!("no frame for {c:?}")); + let l = dim_layout(&f, None, 0.1, solvecraft_geom::Vec2::new(1.0, 0.5)); + assert!(!l.lines.is_empty() && !l.arrows.is_empty(), "{c:?}"); + assert!(l.text.is_finite()); + kinds.push(std::mem::discriminant(&f)); + } + for f in [ + DimFrame::Linear { p0: Default::default(), p1: Default::default(), dir: Default::default() }, + DimFrame::Radial { center: Default::default(), radius: 1.0, diameter: true }, + DimFrame::Angular { vertex: Default::default(), d0: Default::default(), sweep: 1.0 }, + DimFrame::ArcLength { center: Default::default(), radius: 1.0, start: 0.0, sweep: 1.0 }, + ] { + assert!(kinds.contains(&std::mem::discriminant(&f))); + } +} + +#[test] +fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() { + let mut s = new_sketch(); + run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0]]})); + let d = run(&mut s, "SketchDimension", json!({"entities": ["l1"], "text_at": [20, -8]})); + let param = d["param"].as_str().unwrap_or_default().to_string(); + let text = |s: &Session| { + let sk = sketch(s); + let c = sk.constraints.iter().find(|c| c.kind.is_dimension()).cloned().expect("dimension"); + let f = dim_frame(&sk, &c.kind).expect("frame"); + dim_layout(&f, c.text, 0.1, Default::default()).text + }; + assert!(text(&s).dist(solvecraft_geom::Vec2::new(20.0, -8.0)) < 1e-9); + // Moved by the parameter name; then the line moves and the text goes with it. + run(&mut s, "sketch.dimension_text", json!({"dimension": param, "at": [30, 12]})); + assert!(text(&s).dist(solvecraft_geom::Vec2::new(30.0, 12.0)) < 1e-9); + run(&mut s, "sketch.move_point", json!({"point": "l1.start", "to": [0, 10]})); + run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [40, 10]})); + // Still 10 along and 12 across from the middle of the (moved) line. + let sk = sketch(&s); + let Some(DimFrame::Linear { p0, p1, dir }) = sk.constraints.iter().find_map(|c| dim_frame(&sk, &c.kind)) else { panic!("linear frame") }; + assert!(p0.y > 5.0, "the line moved: {p0:?}"); + let off = text(&s) - (p0 + p1) * 0.5; + assert!((off.dot(dir) - 10.0).abs() < 1e-9 && (off.dot(dir.perp()) - 12.0).abs() < 1e-9, "{off:?}"); + // Reset puts it back to the default place; undo restores the drag. + run(&mut s, "sketch.dimension_text", json!({"dimension": param, "reset": true})); + assert!(sketch(&s).constraints.iter().all(|c| c.text.is_none())); + assert!(s.execute("sketch.dimension_text", &json!({"dimension": "nope", "at": [0, 0]})).is_err()); + assert!(s.execute("sketch.dimension_text", &json!({"dimension": param, "at": [f64::NAN, 0]})).is_err()); +} + +#[test] +fn deleting_a_dimension_frees_its_parameter() { + let mut s = new_sketch(); + run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5})); + let d = run(&mut s, "SketchDimension", json!({"entities": ["c1"]})); + let param = d["param"].as_str().unwrap_or_default().to_string(); + let id = sketch(&s).constraints.iter().find(|c| c.param.as_deref() == Some(param.as_str())).map(|c| c.id.clone()).expect("dim"); + run(&mut s, "sketch.delete", json!({"entities": [id]})); + assert!(sketch(&s).constraints.iter().all(|c| !c.kind.is_dimension())); + assert!(s.doc.param(¶m).is_none()); +} diff --git a/crates/sketch/src/dims.rs b/crates/sketch/src/dims.rs new file mode 100644 index 00000000..164ca739 --- /dev/null +++ b/crates/sketch/src/dims.rs @@ -0,0 +1,369 @@ +//! Where a sketch dimension is drawn: extension lines from the measured geometry, the dimension +//! line (or arc) with its arrows, and the value text. Shared by the UI (drawing and hit testing) +//! and the engine (storing a dragged text position), all in sketch coordinates. +//! +//! A dimension's text position is kept relative to its own frame ([`Constraint::text`]), so it +//! follows the geometry: for a linear dimension it is (along, across) from the middle of the +//! measured points, for the others an offset from the centre or the vertex. +//! +//! [`Constraint::text`]: crate::Constraint::text + +use solvecraft_geom::Vec2; + +use crate::model::{ConstraintKind, CurveKind, Sketch}; + +/// What a dimension measures, in a form that can be drawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DimFrame { + /// The distance from `p0` to `p1` along the unit direction `dir` (aligned, horizontal or + /// vertical). + Linear { p0: Vec2, p1: Vec2, dir: Vec2 }, + /// Radius or diameter of a circle or arc. + Radial { center: Vec2, radius: f64, diameter: bool }, + /// The angle at `vertex` from the unit ray `d0`, counter-clockwise by `sweep` radians. + Angular { vertex: Vec2, d0: Vec2, sweep: f64 }, + /// Length along an arc around `center` from angle `start` by `sweep`. + ArcLength { center: Vec2, radius: f64, start: f64, sweep: f64 }, +} + +/// A drawn dimension (sketch coordinates). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DimLayout { + /// Extension lines, dimension lines and arcs as polylines. + pub lines: Vec>, + /// Arrowheads: tip and the unit direction they point in. + pub arrows: Vec<(Vec2, Vec2)>, + /// Centre of the value text. + pub text: Vec2, +} + +fn line_ends(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> { + match sk.curves.get(l)?.kind { + CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)), + _ => None, + } +} + +/// The frame of a dimension constraint (None for geometric constraints). +pub fn dim_frame(sk: &Sketch, k: &ConstraintKind) -> Option { + use ConstraintKind::*; + let unit = |v: Vec2, or: Vec2| v.normalized().unwrap_or(or); + Some(match *k { + Distance { p, q, .. } => { + let (a, b) = (sk.point(p)?, sk.point(q)?); + DimFrame::Linear { p0: a, p1: b, dir: unit(b - a, Vec2::X) } + } + DistanceX { p, q, .. } => DimFrame::Linear { p0: sk.point(p)?, p1: sk.point(q)?, dir: Vec2::X }, + DistanceY { p, q, .. } => DimFrame::Linear { p0: sk.point(p)?, p1: sk.point(q)?, dir: Vec2::Y }, + Length { l, .. } => { + let (a, b) = line_ends(sk, l)?; + DimFrame::Linear { p0: a, p1: b, dir: unit(b - a, Vec2::X) } + } + PointLineDistance { p, l, .. } | LinearDiameter { p, l, .. } => { + let (a, b) = line_ends(sk, l)?; + let q = sk.point(p)?; + let d = unit(b - a, Vec2::X); + let foot = a + d * (q - a).dot(d); + let p0 = if matches!(k, LinearDiameter { .. }) { foot * 2.0 - q } else { foot }; + DimFrame::Linear { p0, p1: q, dir: unit(q - foot, d.perp()) } + } + Radius { c, .. } | Diameter { c, .. } => { + let radius = match sk.curves.get(c)?.kind { + CurveKind::Ellipse { r, .. } => r, + _ => sk.radius(c)?, + }; + DimFrame::Radial { center: sk.center(c)?, radius, diameter: matches!(k, Diameter { .. }) } + } + ArcLength { c, .. } => match sk.segs(c).first()? { + solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => { + DimFrame::ArcLength { center: *center, radius: *radius, start: *start, sweep: *sweep } + } + _ => return None, + }, + Angle { a, b, flip, .. } => { + let (a0, a1) = line_ends(sk, a)?; + let (b0, b1) = line_ends(sk, b)?; + let (da, mut db) = (unit(a1 - a0, Vec2::X), unit(b1 - b0, Vec2::Y)); + if flip { + db = -db; + } + let den = da.cross(db); + let (ma, mb) = ((a0 + a1) * 0.5, (b0 + b1) * 0.5); + let vertex = if den.abs() < 1e-12 { (ma + mb) * 0.5 } else { a0 + da * ((b0 - a0).cross(db) / den) }; + // The angle from `da` to `db` equals the one from `-da` to `-db`: draw the pair of + // rays that point at the lines themselves. + let score = |s: f64| (ma - vertex).dot(da * s) + (mb - vertex).dot(db * s); + let s = if score(1.0) >= score(-1.0) { 1.0 } else { -1.0 }; + let (d0, d1) = (da * s, db * s); + DimFrame::Angular { vertex, d0, sweep: d0.cross(d1).atan2(d0.dot(d1)).rem_euclid(std::f64::consts::TAU) } + } + _ => return None, + }) +} + +/// The stored text position for text centred at `at`. +pub fn encode_text(f: &DimFrame, at: Vec2) -> Vec2 { + match *f { + DimFrame::Linear { p0, p1, dir } => { + let m = (p0 + p1) * 0.5; + Vec2::new((at - m).dot(dir), (at - m).dot(dir.perp())) + } + DimFrame::Radial { center, .. } | DimFrame::ArcLength { center, .. } => at - center, + DimFrame::Angular { vertex, .. } => at - vertex, + } +} + +/// Per point: the centre of the connected geometry it belongs to (points joined by curves), +/// which a dimension's default place keeps away from. +pub fn chain_centres(sk: &Sketch) -> Vec { + let n = sk.points.len(); + let mut parent: Vec = (0..n).collect(); + fn root(p: &mut [usize], mut i: usize) -> usize { + while let Some(&q) = p.get(i) { + if q == i { + break; + } + let g = p.get(q).copied().unwrap_or(q); + if let Some(s) = p.get_mut(i) { + *s = g; + } + i = q; + } + i + } + for c in &sk.curves { + let ids = c.kind.point_ids(); + for w in ids.windows(2) { + let (a, b) = (root(&mut parent, w[0]), root(&mut parent, w[1])); + if let Some(s) = parent.get_mut(a) { + *s = b; + } + } + } + let mut sum = vec![(Vec2::ZERO, 0usize); n]; + let roots: Vec = (0..n).map(|i| root(&mut parent, i)).collect(); + for (i, r) in roots.iter().enumerate() { + if let (Some(s), Some(p)) = (sum.get_mut(*r), sk.points.get(i)) { + s.0 += p.pos; + s.1 += 1; + } + } + roots.iter().map(|r| sum.get(*r).map(|(s, k)| *s / (*k).max(1) as f64).unwrap_or_default()).collect() +} + +/// The default text place of a dimension: a linear one on the side of its points away from +/// `away` (the centre of the geometry it measures), the others where [`dim_layout`] puts them. +pub fn default_text(f: &DimFrame, away: Vec2, px: f64) -> Option { + match *f { + DimFrame::Linear { p0, p1, dir } => { + let side = if ((p0 + p1) * 0.5 - away).dot(dir.perp()) < -1e-9 { -1.0 } else { 1.0 }; + Some(Vec2::new(0.0, side * 22.0 * px)) + } + _ => None, + } +} + +/// Points along an arc around `c` (counter-clockwise from `a0` by `sweep`). +fn arc_pts(c: Vec2, r: f64, a0: f64, sweep: f64) -> Vec { + let n = ((sweep.abs() / 0.08).ceil() as usize).clamp(2, 200); + (0..=n).map(|i| c + Vec2::from_angle(a0 + sweep * i as f64 / n as f64) * r).collect() +} + +/// Lay a dimension out. `text` is the stored text position (None: the default place), `px` +/// the sketch length of one screen pixel and `half` the half size of the text box (sketch +/// units), which the dimension line leaves a gap for. +pub fn dim_layout(f: &DimFrame, text: Option, px: f64, half: Vec2) -> DimLayout { + let px = if px.is_finite() && px > 0.0 { px } else { 1.0 }; + let arrow = 9.0 * px; + let gap = 3.0 * px; + let over = 4.0 * px; + let mut out = DimLayout::default(); + match *f { + DimFrame::Linear { p0, p1, dir } => { + let n = dir.perp(); + let m = (p0 + p1) * 0.5; + let t = text.unwrap_or(Vec2::new(0.0, 22.0 * px)); + let (s, h) = (t.x, t.y); + // The dimension line sits `h` across from the middle of the measured points. + let q0 = p0 + n * (h - (p0 - m).dot(n)); + let q1 = p1 + n * (h - (p1 - m).dot(n)); + for (p, q) in [(p0, q0), (p1, q1)] { + let v = q - p; + if let Some(u) = v.normalized() { + out.lines.push(vec![p + u * gap.min(v.len()), q + u * over]); + } + } + out.text = m + n * h + dir * s; + let len = (q1 - q0).dot(dir); + let (lo, hi) = if len >= 0.0 { (q0, q1) } else { (q1, q0) }; + let span = len.abs(); + // Half the text along the line (it is drawn level on screen). + let along = (half.x * dir.x.abs() + half.y * dir.y.abs()) + gap; + let inside = s.abs() + along <= span * 0.5; + if span > 2.0 * arrow + 2.0 * along && inside { + // Arrows inside, pointing out, the line broken around the text. + out.arrows.push((lo, -dir)); + out.arrows.push((hi, dir)); + let tc = m + n * h + dir * s; + out.lines.push(vec![lo, tc - dir * along]); + out.lines.push(vec![tc + dir * along, hi]); + } else { + // Too tight (or the text pulled out): arrows outside pointing in, short tails. + out.arrows.push((lo, dir)); + out.arrows.push((hi, -dir)); + let tail = arrow * 1.8; + out.lines.push(vec![lo - dir * tail, hi + dir * tail]); + // A text outside the extension lines gets a leader along the line. + let tc = m + n * h + dir * s; + let reach = (tc - m - n * h).dot(dir); + if reach.abs() > span * 0.5 + tail { + let end = if reach > 0.0 { hi + dir * tail } else { lo - dir * tail }; + out.lines.push(vec![end, tc - dir * (along * reach.signum())]); + } + } + } + DimFrame::Radial { center, radius, diameter } => { + let t = text.unwrap_or_else(|| Vec2::new(1.0, 1.0) * ((radius + 18.0 * px) * std::f64::consts::FRAC_1_SQRT_2)); + let u = t.normalized().unwrap_or(Vec2::new(1.0, 1.0) * std::f64::consts::FRAC_1_SQRT_2); + let d = t.len(); + let at = center + u * radius; + out.text = center + t; + let rim = half.x * u.x.abs() + half.y * u.y.abs() + gap; + let from = if diameter { center - u * radius } else { center }; + if d > radius { + // Leader from the far side (or centre) out to the text, arrows on the rim. + out.lines.push(vec![from, center + u * (d - rim).max(radius)]); + out.arrows.push((at, u)); + if diameter { + out.arrows.push((center - u * radius, -u)); + } + } else { + // Text inside: the line runs rim to rim (or centre to rim) around it. + let end = center + u * radius; + let t0 = (d - rim).max(0.0); + let start_d = if diameter { -radius } else { 0.0 }; + if t0 > start_d { + out.lines.push(vec![from, center + u * t0]); + } + if d + rim < radius { + out.lines.push(vec![center + u * (d + rim), end]); + } + out.arrows.push((at, u)); + if diameter { + out.arrows.push((center - u * radius, -u)); + } + } + } + DimFrame::Angular { vertex, d0, sweep } => { + let a0 = d0.angle(); + let mid = a0 + sweep * 0.5; + let t = text.unwrap_or_else(|| Vec2::from_angle(mid) * (48.0 * px)); + let r = t.len().max(arrow); + out.text = vertex + t; + // Extension rays from the vertex out past the arc. + for a in [a0, a0 + sweep] { + let u = Vec2::from_angle(a); + out.lines.push(vec![vertex + u * (r * 0.15).min(gap * 3.0), vertex + u * (r + over)]); + } + let arc_len = r * sweep; + if arc_len > 2.5 * arrow { + out.lines.push(arc_pts(vertex, r, a0, sweep)); + out.arrows.push((vertex + Vec2::from_angle(a0) * r, -Vec2::from_angle(a0).perp())); + out.arrows.push((vertex + Vec2::from_angle(a0 + sweep) * r, Vec2::from_angle(a0 + sweep).perp())); + } else { + // A narrow angle: arrows outside the sector pointing in. + let tail = 2.0 * arrow / r; + out.lines.push(arc_pts(vertex, r, a0 - tail, sweep + 2.0 * tail)); + out.arrows.push((vertex + Vec2::from_angle(a0) * r, Vec2::from_angle(a0).perp())); + out.arrows.push((vertex + Vec2::from_angle(a0 + sweep) * r, -Vec2::from_angle(a0 + sweep).perp())); + } + // Text beyond the sector: the arc reaches round to it. + let ta = (t.angle() - a0).rem_euclid(std::f64::consts::TAU); + if ta > sweep { + let (from, by) = + if ta - sweep < std::f64::consts::TAU - ta { (a0 + sweep, ta - sweep) } else { (t.angle(), std::f64::consts::TAU - ta) }; + out.lines.push(arc_pts(vertex, r, from, by)); + } + } + DimFrame::ArcLength { center, radius, start, sweep } => { + let mid = start + sweep * 0.5; + let t = text.unwrap_or_else(|| Vec2::from_angle(mid) * (radius + 20.0 * px)); + let r = t.len().max(arrow); + out.text = center + t; + for a in [start, start + sweep] { + let u = Vec2::from_angle(a); + let (from, to) = if r >= radius { (radius + gap, r + over) } else { (radius - gap, r - over) }; + out.lines.push(vec![center + u * from, center + u * to]); + } + out.lines.push(arc_pts(center, r, start, sweep)); + out.arrows.push((center + Vec2::from_angle(start) * r, -Vec2::from_angle(start).perp())); + out.arrows.push((center + Vec2::from_angle(start + sweep) * r, Vec2::from_angle(start + sweep).perp())); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: Vec2, b: Vec2) -> bool { + a.dist(b) < 1e-9 + } + + #[test] + fn linear_dimension_has_extension_lines_and_inward_arrows() { + let f = DimFrame::Linear { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(100.0, 0.0), dir: Vec2::X }; + let l = dim_layout(&f, Some(Vec2::new(0.0, 10.0)), 0.1, Vec2::new(2.0, 1.0)); + assert!(close(l.text, Vec2::new(50.0, 10.0))); + assert_eq!(l.arrows.len(), 2); + // Arrows at the dimension line ends, pointing outwards. + assert!(close(l.arrows[0].0, Vec2::new(0.0, 10.0)) && close(l.arrows[0].1, -Vec2::X)); + assert!(close(l.arrows[1].0, Vec2::new(100.0, 10.0)) && close(l.arrows[1].1, Vec2::X)); + // Two extension lines going up from the points, then the line broken around the text. + assert_eq!(l.lines.len(), 4); + assert!(l.lines[0][0].y > 0.0 && l.lines[0][1].y > 10.0); + assert!(l.lines[2][1].x < 48.0 && l.lines[3][0].x > 52.0); + } + + #[test] + fn short_linear_dimension_flips_arrows_outside() { + let f = DimFrame::Linear { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(2.0, 0.0), dir: Vec2::X }; + let l = dim_layout(&f, None, 0.1, Vec2::new(2.0, 1.0)); + assert!(close(l.arrows[0].1, Vec2::X) && close(l.arrows[1].1, -Vec2::X)); + } + + #[test] + fn text_position_round_trips_through_the_frame() { + let f = DimFrame::Linear { p0: Vec2::new(1.0, 2.0), p1: Vec2::new(31.0, 42.0), dir: Vec2::new(0.6, 0.8) }; + let at = Vec2::new(7.0, 30.0); + let enc = encode_text(&f, at); + assert!(close(dim_layout(&f, Some(enc), 0.1, Vec2::ZERO).text, at)); + let r = DimFrame::Radial { center: Vec2::new(5.0, 5.0), radius: 3.0, diameter: true }; + assert!(close(dim_layout(&r, Some(encode_text(&r, at)), 0.1, Vec2::ZERO).text, at)); + } + + #[test] + fn diameter_has_arrows_on_both_sides() { + let r = DimFrame::Radial { center: Vec2::ZERO, radius: 10.0, diameter: true }; + let l = dim_layout(&r, Some(Vec2::new(20.0, 0.0)), 0.1, Vec2::new(2.0, 1.0)); + assert_eq!(l.arrows.len(), 2); + assert!(close(l.arrows[0].0, Vec2::new(10.0, 0.0)) && close(l.arrows[1].0, Vec2::new(-10.0, 0.0))); + let r = DimFrame::Radial { center: Vec2::ZERO, radius: 10.0, diameter: false }; + assert_eq!(dim_layout(&r, None, 0.1, Vec2::ZERO).arrows.len(), 1); + } + + #[test] + fn angle_frame_uses_the_rays_toward_the_lines() { + let mut sk = Sketch::new(); + let a = sk.add_line(Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), None, None, None).unwrap_or(0); + let b = sk.add_line(Vec2::new(0.0, 0.0), Vec2::new(0.0, 10.0), Some(1), None, None).unwrap_or(0); + let k = ConstraintKind::Angle { a, b, value: std::f64::consts::FRAC_PI_2, flip: false }; + let Some(DimFrame::Angular { vertex, d0, sweep }) = dim_frame(&sk, &k) else { panic!("no frame") }; + assert!(close(vertex, Vec2::ZERO) && close(d0, Vec2::X)); + assert!((sweep - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + let l = dim_layout(&DimFrame::Angular { vertex, d0, sweep }, None, 0.1, Vec2::ZERO); + assert_eq!(l.arrows.len(), 2); + assert!(l.text.x > 0.0 && l.text.y > 0.0); + } +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 349a2362..eaa43b5e 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] mod curves; +mod dims; mod linalg; mod link; mod model; @@ -21,6 +22,7 @@ mod wire; pub use curves::{ MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, spline_end_tangent, spline_point, spline_polyline, }; +pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError}; pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 4aa71ea2..45ba5fd8 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -421,6 +421,10 @@ pub struct Constraint { /// A driven (reference) dimension: it measures, it does not constrain. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub driven: bool, + /// For dimensions: where the value text was placed, relative to the dimension's frame + /// (see [`crate::dim_frame`]); None for the default place. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, } /// A 2D sketch. Point 0 is always the fixed sketch origin (id `origin`). @@ -597,7 +601,7 @@ impl Sketch { } self.validate(&kind)?; let id = self.fresh("k"); - self.constraints.push(Constraint { id: id.clone(), kind, param, driven: false }); + self.constraints.push(Constraint { id: id.clone(), kind, param, driven: false, text: None }); Ok(id) } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 086e1863..9c79421a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -8,7 +8,8 @@ //! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog //! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?, hold?}` (box selection, navigation; //! `hold` keeps the button down that many frames before moving); -//! `ui.selection` (selection, dialog inputs, hover); `ui.editFeature {feature}` (edit dialog) +//! `ui.selection` (selection, dialog inputs, hover, drawn sketch dimensions); +//! `ui.sketchToScreen {points: [[x,y]…]}` (screen points of active-sketch coordinates); `ui.editFeature {feature}` (edit dialog) //! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit, animate?: bool}` (snaps unless animate) //! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs) //! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real @@ -201,7 +202,31 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> "selection": app.session.selection, "dialog": app.dialog.as_ref().map(|d| d.inputs.iter().map(|i| json!({"label": i.label, "items": i.items})).collect::>()), "hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")), + "dimensions": crate::dim_view::drawn().into_iter().map(|(id, p)| json!({"id": id, "x": p.x, "y": p.y})).collect::>(), + "dimension_selected": crate::dim_view::selected(), + "dimension_editing": crate::dim_view::editing(), })), + "ui.sketchToScreen" => { + // Screen points of active-sketch coordinates (for driving sketch interaction). + let st = app.session.model.state(); + let (Some(rect), Some(ss)) = (app.viewport.rect, app.session.active_sketch.and_then(|s| st.sketch(s))) else { + return err("no active sketch in a drawn viewport"); + }; + let proj = crate::viewport::projection(app, rect); + let pts: Vec = p + .get("points") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(1000) + .map(|v| { + let c = |i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(0.0); + let q = solvecraft_engine::geom::Vec2::new(c(0), c(1)); + proj.to_screen(ss.plane.to_world(q)).map_or(Value::Null, |p| json!([p.x, p.y])) + }) + .collect(); + ok(json!(pts)) + } "ui.scroll" => { let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") }; app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x as f32, y as f32))); diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs new file mode 100644 index 00000000..468514b7 --- /dev/null +++ b/crates/ui-egui/src/dim_view.rs @@ -0,0 +1,346 @@ +//! Sketch dimensions drawn as dimensions: extension lines from the measured geometry, a +//! dimension line (or arc) with arrowheads, and the value centred on it; radius and diameter +//! leaders with R / Ø, angles as an arc between the two lines, arc lengths with the arc mark, +//! driven (reference) dimensions in parentheses in a muted colour. The geometry comes from +//! `solvecraft_sketch::dim_layout`. +//! +//! A click selects a dimension (Delete removes it), a drag moves its text (and so the offset of +//! its dimension line), a double-click edits its value in place with parameter completion. +//! Everything acts through commands: `sketch.dimension_text`, `sketch.delete`, +//! `ChangeParameterCommand`. + +use std::cell::RefCell; + +use egui::{FontId, Pos2, Rect, Shape, Stroke, vec2}; +use serde_json::json; +use solvecraft_engine::geom::Vec2; +use solvecraft_engine::sketch::{ConstraintKind, DimFrame, chain_centres, default_text, dim_frame, dim_layout}; + +use crate::SolveApp; +use crate::theme::Tokens; +use crate::viewport::Proj; + +/// A dimension as drawn this frame (screen space), for hit testing. +#[derive(Clone, Debug)] +struct Drawn { + id: String, + param: Option, + text: Rect, + segs: Vec<[Pos2; 2]>, + /// Text centre in sketch coordinates. + at: Vec2, +} + +/// The inline value editor: parameter, expression, where it sits, first frame. +#[derive(Clone, Debug)] +struct Edit { + param: String, + expr: String, + at: Pos2, + /// Frames left in which the box (re)takes the keyboard: the rest of the double-click + /// that opened it must not close it. + fresh: u8, +} + +thread_local! { + static DRAWN: RefCell> = const { RefCell::new(Vec::new()) }; + static SELECTED: RefCell> = const { RefCell::new(None) }; + /// A text being dragged: dimension id, undo depth at the start, grab offset (sketch). + static DRAG: RefCell> = const { RefCell::new(None) }; + static EDIT: RefCell> = const { RefCell::new(None) }; +} + +/// Frames the inline editor holds on to the keyboard after opening. +const FRESH: u8 = 3; + +/// Pixels from a dimension line or the text box that still count as on it. +const HIT_PX: f32 = 4.0; + +fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 { + let ab = b - a; + let t = if ab.length_sq() > 0.0 { ((p - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(a + ab * t) +} + +/// The dimension under a screen position (its id). +pub fn hit(pos: Pos2) -> Option { + DRAWN.with(|d| { + let d = d.borrow(); + // Text boxes first (they sit on top of the lines), then the lines. + d.iter() + .find(|x| x.text.expand(2.0).contains(pos)) + .or_else(|| d.iter().find(|x| x.segs.iter().any(|s| seg_dist(pos, s[0], s[1]) <= HIT_PX))) + .map(|x| x.id.clone()) + }) +} + +/// The selected dimension (constraint id). +pub fn selected() -> Option { + SELECTED.with(|s| s.borrow().clone()) +} + +/// A click without a tool: select the dimension under it (true: the click was used). A click +/// elsewhere drops the dimension selection. +pub fn click(app: &mut SolveApp, pos: Pos2) -> bool { + if app.session.active_sketch.is_none() { + return false; + } + let h = hit(pos); + SELECTED.with(|s| *s.borrow_mut() = h.clone()); + if let Some(id) = &h { + let _ = app.run("select.clear", json!({})); + app.set_status(format!("Dimension {id} selected: drag to move, double-click to edit, Delete removes it"), false); + } + h.is_some() +} + +/// Drop the dimension selection (true when there was one). +pub fn deselect() -> bool { + SELECTED.with(|s| s.borrow_mut().take()).is_some() +} + +/// Delete the selected dimension (true when one was selected). +pub fn delete_selected(app: &mut SolveApp) -> bool { + let Some(id) = SELECTED.with(|s| s.borrow_mut().take()) else { return false }; + let _ = app.run("sketch.delete", json!({"entities": [id]})); + true +} + +/// A double-click on a dimension opens its value for editing (true: it was on one). +pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool { + let Some(d) = DRAWN.with(|d| { + let id = hit(pos)?; + d.borrow().iter().find(|x| x.id == id).cloned() + }) else { + return false; + }; + SELECTED.with(|s| *s.borrow_mut() = Some(d.id.clone())); + let Some(param) = d.param else { + app.set_status("A driven dimension measures the sketch; it has no value to edit", false); + return true; + }; + let expr = app.session.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default(); + EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: d.text.center(), fresh: FRESH })); + true +} + +/// Start dragging the text of the dimension under `press` (true: a dimension took the drag). +pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2) -> bool { + let Some(d) = DRAWN.with(|d| { + let id = hit(press)?; + d.borrow().iter().find(|x| x.id == id).cloned() + }) else { + return false; + }; + SELECTED.with(|s| *s.borrow_mut() = Some(d.id.clone())); + DRAG.with(|g| *g.borrow_mut() = Some((d.id, app.session.undo.len(), d.at - press_sketch))); + true +} + +/// Is a dimension text being dragged? +pub fn dragging() -> bool { + DRAG.with(|g| g.borrow().is_some()) +} + +/// Move the dragged text so it follows the pointer (at `to`, sketch coordinates); one undo +/// step for the whole drag. +pub fn drag_to(app: &mut SolveApp, to: Vec2) { + let Some((id, depth, off)) = DRAG.with(|g| g.borrow().clone()) else { return }; + let at = to + off; + if app.session.execute("sketch.dimension_text", &json!({"dimension": id, "at": [at.x, at.y]})).is_ok() { + app.session.undo.truncate(depth + 1); + } +} + +pub fn drag_end() { + DRAG.with(|g| *g.borrow_mut() = None); +} + +/// The text shown for a dimension: the value (R / Ø for radius and diameter, degrees for +/// angles), "fx: value" when an expression drives it, in parentheses when driven. +pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool) -> String { + let v = k.value().unwrap_or(0.0); + let num = if k.is_angle() { format!("{:.1}°", v.to_degrees()) } else { format!("{v:.2}") }; + let num = match k { + ConstraintKind::Radius { .. } => format!("R{num}"), + ConstraintKind::Diameter { .. } | ConstraintKind::LinearDiameter { .. } => format!("Ø{num}"), + _ => num, + }; + let plain = expr.is_none_or(|e| { + let e = e.trim(); + let e = e.strip_suffix("mm").or_else(|| e.strip_suffix("deg")).or_else(|| e.strip_suffix('°')).unwrap_or(e); + e.trim().parse::().is_ok() + }); + let text = if plain { num } else { format!("fx: {num}") }; + if driven { format!("({text})") } else { text } +} + +/// Draw the active sketch's dimensions and remember where they are for picking. +pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) { + let mut drawn = Vec::new(); + let st = app.session.model.state(); + let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { + DRAWN.with(|d| d.borrow_mut().clear()); + SELECTED.with(|s| *s.borrow_mut() = None); + EDIT.with(|e| *e.borrow_mut() = None); + return; + }; + let sk = &ss.sketch; + let tk = Tokens::get(); + let sel = selected(); + // A selected dimension that was deleted (undo, another command) is forgotten. + if sel.as_ref().is_some_and(|id| !sk.constraints.iter().any(|c| &c.id == id)) { + SELECTED.with(|s| *s.borrow_mut() = None); + } + let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| proj.rect.contains(*p)).and_then(hit); + let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); + let font = FontId::proportional(12.0); + let centres = chain_centres(sk); + for c in &sk.constraints { + let Some(frame) = dim_frame(sk, &c.kind) else { continue }; + let anchor = match frame { + DimFrame::Linear { p0, p1, .. } => (p0 + p1) * 0.5, + DimFrame::Radial { center, .. } | DimFrame::ArcLength { center, .. } => center, + DimFrame::Angular { vertex, .. } => vertex, + }; + // Millimetres per pixel at the dimension. + let (Some(a), Some(b)) = (to(anchor), to(anchor + Vec2::X)) else { continue }; + let px = 1.0 / f64::from(a.distance(b)).max(1e-9); + if !px.is_finite() { + continue; + } + let expr = c.param.as_ref().and_then(|p| app.session.doc.param(p)).map(|p| p.expr.as_str()); + let failing = ss.report.failing.contains(&c.id); + let col = if failing { + tk.error + } else if sel.as_deref() == Some(c.id.as_str()) || hover.as_deref() == Some(c.id.as_str()) { + tk.accent + } else if c.driven { + tk.dimension_driven + } else { + tk.dimension + }; + let galley = painter.layout_no_wrap(label(&c.kind, expr, c.driven), font.clone(), col); + let size = galley.size() + vec2(6.0, 2.0); + let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px); + // Unplaced linear dimensions sit outside the shape they measure. + let away = first_point(sk, &c.kind).and_then(|p| centres.get(p).copied()).unwrap_or(anchor); + let lay = dim_layout(&frame, c.text.or_else(|| default_text(&frame, away, px)), px, half); + let stroke = Stroke::new(1.0, col); + let mut segs = Vec::new(); + for line in &lay.lines { + let pts: Vec = line.iter().filter_map(|q| to(*q)).collect(); + for w in pts.windows(2) { + segs.push([w[0], w[1]]); + } + painter.add(Shape::line(pts, stroke)); + } + for (tip, dir) in &lay.arrows { + if let (Some(t), Some(back)) = (to(*tip), to(*tip - *dir * px)) { + let d = (t - back).normalized(); + let n = vec2(-d.y, d.x); + painter.add(Shape::convex_polygon(vec![t, t - d * 9.0 + n * 3.0, t - d * 9.0 - n * 3.0], col, Stroke::NONE)); + } + } + let Some(tc) = to(lay.text) else { continue }; + let r = Rect::from_center_size(tc, size); + painter.rect_filled(r, 2.0, tk.viewport_top.gamma_multiply(0.85)); + painter.galley(r.min + vec2(3.0, 1.0), galley, col); + if matches!(frame, DimFrame::ArcLength { .. }) { + // The arc-length mark over the value. + let c0 = pos2_mid_top(r); + let pts: Vec = (0..=12) + .map(|i| c0 + vec2(5.0 * (std::f32::consts::PI * i as f32 / 12.0).cos(), -3.0 * (std::f32::consts::PI * i as f32 / 12.0).sin())) + .collect(); + painter.add(Shape::line(pts, stroke)); + } + drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text }); + } + DRAWN.with(|d| *d.borrow_mut() = drawn); + edit_box(app, ui.ctx()); +} + +/// A point a dimension measures (for the shape it belongs to). +fn first_point(sk: &solvecraft_engine::sketch::Sketch, k: &ConstraintKind) -> Option { + use ConstraintKind::*; + match *k { + Length { l, .. } => sk.curves.get(l).and_then(|c| c.kind.point_ids().first().copied()), + Distance { p, .. } | DistanceX { p, .. } | DistanceY { p, .. } | PointLineDistance { p, .. } | LinearDiameter { p, .. } => Some(p), + _ => None, + } +} + +fn pos2_mid_top(r: Rect) -> Pos2 { + Pos2::new(r.center().x, r.top() - 1.0) +} + +/// The inline value editor: Enter applies, Esc (or a click elsewhere) cancels. +fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { + let Some(mut e) = EDIT.with(|x| x.borrow_mut().take()) else { return }; + let id = egui::Id::new("sc_dim_edit"); + let mut done: Option = None; + egui::Area::new(egui::Id::new("sc_dim_edit_area")).fixed_pos(e.at - vec2(45.0, 11.0)).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| { + let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(90.0)); + crate::params_dialog::complete(ui, &r, &mut e.expr); + if e.fresh == FRESH { + // The whole value is selected, so typing replaces it. + let n = e.expr.chars().count(); + let mut s = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); + s.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + s.store(ui.ctx(), id); + } + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + if r.lost_focus() && enter { + done = Some(true); + } else if e.fresh > 0 { + if !r.has_focus() { + r.request_focus(); + } + } else if r.lost_focus() { + done = Some(false); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + done = Some(false); + } + }); + }); + e.fresh = e.fresh.saturating_sub(1); + match done { + Some(true) => { + if let Err(err) = app.run("ChangeParameterCommand", json!({"name": e.param, "expression": e.expr})) { + app.set_status(err, true); + EDIT.with(|x| *x.borrow_mut() = Some(Edit { fresh: FRESH, ..e })); + } + } + Some(false) => {} + None => EDIT.with(|x| *x.borrow_mut() = Some(e)), + } +} + +/// Is the inline editor open (it has the keyboard)? +pub fn editing() -> bool { + EDIT.with(|e| e.borrow().is_some()) +} + +/// Dimensions drawn last frame: (id, text centre on screen), for tests and the control channel. +pub fn drawn() -> Vec<(String, Pos2)> { + DRAWN.with(|d| d.borrow().iter().map(|x| (x.id.clone(), x.text.center())).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_show_the_value_or_fx() { + let k = ConstraintKind::Length { l: 0, value: 25.0 }; + assert_eq!(label(&k, Some("25 mm"), false), "25.00"); + assert_eq!(label(&k, Some("width * 2"), false), "fx: 25.00"); + assert_eq!(label(&k, None, true), "(25.00)"); + assert_eq!(label(&ConstraintKind::Radius { c: 0, value: 5.0 }, Some("5 mm"), false), "R5.00"); + assert_eq!(label(&ConstraintKind::Diameter { c: 0, value: 10.0 }, Some("10"), false), "Ø10.00"); + let a = ConstraintKind::Angle { a: 0, b: 1, value: std::f64::consts::FRAC_PI_4, flip: false }; + assert_eq!(label(&a, Some("45 deg"), false), "45.0°"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c276a936..c0e7172b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod canvas; pub mod context_menu; pub mod control; pub mod dialogs; +pub mod dim_view; pub mod gpu; pub mod icons; #[cfg(test)] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index d0d2f41e..d4d829d3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -80,6 +80,11 @@ pub struct Tokens { /// Sketch curves: under-constrained, fully constrained (and finished sketches). pub sketch: Color32, pub sketch_fixed: Color32, + /// Fixed (locked) sketch geometry. + pub sketch_locked: Color32, + /// Sketch dimensions: driving, driven (reference). + pub dimension: Color32, + pub dimension_driven: Color32, /// Sketch points: fully constrained, free. pub sketch_point: Color32, pub sketch_point_free: Color32, @@ -168,6 +173,9 @@ impl Tokens { sketch: Color32::from_rgb(30, 90, 200), sketch_fixed: Color32::from_rgb(20, 20, 20), sketch_point: Color32::BLACK, + sketch_locked: Color32::from_rgb(0, 140, 60), + dimension: Color32::from_rgb(36, 40, 48), + dimension_driven: Color32::from_rgb(120, 126, 138), sketch_point_free: Color32::from_rgb(30, 90, 200), section_cap: Color32::from_rgb(214, 128, 72), profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70), @@ -220,6 +228,9 @@ impl Tokens { t.sketch = Color32::from_rgb(92, 150, 255); t.sketch_fixed = Color32::from_rgb(232, 234, 238); t.sketch_point = Color32::from_rgb(232, 234, 238); + t.sketch_locked = Color32::from_rgb(96, 204, 116); + t.dimension = Color32::from_rgb(224, 228, 234); + t.dimension_driven = Color32::from_rgb(142, 150, 164); t.sketch_point_free = Color32::from_rgb(92, 150, 255); t.profile_fill = Color32::from_rgba_unmultiplied_const(120, 150, 200, 40); t.glyph_bg = Color32::from_rgb(44, 50, 60); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 1c04231c..4a56cae9 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -410,6 +410,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { crate::tools::finish(app); } else if app.dialog.is_some() { crate::dialogs::cancel(app); + } else if crate::dim_view::deselect() { } else if !app.session.selection.is_empty() || app.session.active_sketch.is_none() { let _ = app.run("select.clear", json!({})); } else { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index eedaa19e..730f069f 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -806,7 +806,7 @@ pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2, } pub fn delete_selection(app: &mut SolveApp) { - if crate::sketch_tools::delete_glyph(app) { + if crate::dim_view::delete_selected(app) || crate::sketch_tools::delete_glyph(app) { return; } let sel = app.session.selection.clone(); @@ -973,13 +973,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.dialog = Some(d); } } else { - if !crate::sketch_tools::click_glyph(app, &proj, p) { + if !crate::dim_view::click(app, p) && !crate::sketch_tools::click_glyph(app, &proj, p) { select(app, cand.and_then(|c| c.1), add); } } } + // Double-click a dimension: edit its value in place. + let dim_edit = resp.double_clicked() && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p); // Double-click an edge: select the edges that continue it smoothly (the loop). if resp.double_clicked() + && !dim_edit && app.tool.is_none() && app.dialog.is_none() && let Some(Hit::Edge { body, index, .. }) = app.viewport.hover.clone() @@ -1003,7 +1006,26 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } // Dragging a sketch point of the active sketch moves it; the solver keeps the constraints. if app.tool.is_none() && app.dialog.is_none() && app.viewport.nav.is_none() && app.session.active_sketch.is_some() { + let on_plane = |app: &SolveApp, p: Pos2| { + let ss = app.session.active_sketch.and_then(|sid| app.session.model.state().sketch(sid).cloned())?; + let (o, d) = proj.ray(p); + ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w)) + }; + // A dimension's text follows a drag that starts on it. if resp.drag_started_by(egui::PointerButton::Primary) + && let Some(a) = ui.input(|i| i.pointer.press_origin()) + && let Some(q) = on_plane(app, a) + { + crate::dim_view::drag_start(app, a, q); + } + if crate::dim_view::dragging() + && (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped()) + && let Some(q) = hover.and_then(|p| on_plane(app, p)) + { + crate::dim_view::drag_to(app, q); + } + if resp.drag_started_by(egui::PointerButton::Primary) + && !crate::dim_view::dragging() && let Some(a) = ui.input(|i| i.pointer.press_origin()) && let Some(Hit::SketchPoint { id, .. }) = pick(app, &proj, a).into_iter().next() { @@ -1025,10 +1047,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } if resp.drag_stopped() { app.viewport.point_drag = None; + crate::dim_view::drag_end(); } } // Box selection: a primary drag on the model when no navigation mode is on. - if app.tool.is_none() && app.viewport.nav.is_none() && app.viewport.point_drag.is_none() { + if app.tool.is_none() && app.viewport.nav.is_none() && app.viewport.point_drag.is_none() && !crate::dim_view::dragging() { let origin = ui.input(|i| i.pointer.press_origin()); if resp.dragged_by(egui::PointerButton::Primary) && let (Some(a), Some(b)) = (origin, hover) @@ -1045,7 +1068,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } else { app.viewport.boxsel = None; } - overlays(app, ui, &painter, &proj); + overlays(app, &painter, &proj); + crate::dim_view::show(app, ui, &painter, &proj); hover_highlight(app, &painter, &proj); points_2d(app, &painter, &proj); if let Some(bx) = app.viewport.boxsel { @@ -1235,7 +1259,7 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { } /// Sketch points, dimension labels and constraint glyphs of the active sketch. -fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) { +fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let t = Tokens::get(); let st = app.session.model.state(); let Some(sid) = app.session.active_sketch else { return }; @@ -1254,31 +1278,8 @@ fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c); } } - let mut edit: Option = None; - for c in &sk.constraints { - let Some(param) = &c.param else { - glyph(painter, proj, ss, &c.kind); - continue; - }; - let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default(); - let anchor = dim_anchor(sk, &c.kind); - let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { continue }; - let text = if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.' || ch == ' ' || ch == 'm' || ch == 'd' || ch == 'e' || ch == 'g') { - expr.replace(" mm", "").replace(" deg", "°") - } else { - format!("{param}: {}", expr) - }; - let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text); - let r = Rect::from_center_size(a, galley.size() + vec2(8.0, 4.0)); - painter.rect(r, 2.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - painter.galley(r.min + vec2(4.0, 2.0), galley, t.text); - let resp = ui.interact(r, ui.id().with(("dim", param.as_str())), Sense::click()); - if resp.double_clicked() || resp.clicked() && app.tool.is_none() { - edit = Some(param.clone()); - } - } - if let Some(p) = edit { - app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, &p)); + for c in sk.constraints.iter().filter(|c| !c.kind.is_dimension()) { + glyph(painter, proj, ss, &c.kind); } // Sketch status. if let Some(r) = app.viewport.rect { @@ -1301,29 +1302,6 @@ fn line_mid(sk: &solvecraft_engine::sketch::Sketch, l: usize) -> Option<(Vec2, V } } -/// Where a dimension label goes (sketch coordinates). -fn dim_anchor(sk: &solvecraft_engine::sketch::Sketch, k: &ConstraintKind) -> Option { - use ConstraintKind::*; - match *k { - Length { l, .. } | PointLineDistance { l, .. } => { - let (a, b) = line_mid(sk, l)?; - let n = (b - a).perp().normalized().unwrap_or(Vec2::Y); - Some((a + b) * 0.5 + n * ((b - a).len() * 0.08 + 1.0)) - } - Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((sk.point(p)? + sk.point(q)?) * 0.5), - Radius { c, .. } | Diameter { c, .. } => { - let r = sk.radius(c)?; - Some(sk.center(c)? + Vec2::new(std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2) * r) - } - Angle { a, b, .. } => { - let (a0, a1) = line_mid(sk, a)?; - let (b0, b1) = line_mid(sk, b)?; - Some((a0 + a1 + b0 + b1) * 0.25) - } - _ => None, - } -} - /// Small constraint markers next to the constrained geometry. fn glyph(painter: &egui::Painter, proj: &Proj, ss: &solvecraft_engine::doc::SolvedSketch, k: &ConstraintKind) { use ConstraintKind::*; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e447425f..7304dc4e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -31,7 +31,8 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled) | | `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does | | `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened | -| `ui.selection` | | the selection, the open dialog's inputs and the hovered item | +| `ui.selection` | | the selection, the open dialog's inputs, the hovered item, and the active sketch's drawn dimensions (`dimensions`: id and text centre, `dimension_selected`, `dimension_editing`) | +| `ui.sketchToScreen` | `points`: `[[x, y]…]` in active-sketch coordinates | their screen points (null when behind the camera) | | `ui.editFeature` | `feature` (id or name) | what double-clicking a timeline item does: rolls back to it and opens its dialog filled in (sketches open in sketch mode) | | `ui.scroll` | `x`, `y`, `delta?` | wheel zoom at the cursor | | `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | |