Files
OneCAD/crates/ui-egui/src/sketch_text_handles.rs
T
phanoob dd7790a43f Sketch text: bold and italic, text along a curve, alignment and spacing (#852)
A sketch text gets an optional style: bold and italic faces (a slanted fallback for italic), left/center/right alignment, letter and line spacing, and a path curve of the same sketch for the baseline to follow (reverse takes the other side). Edit Text gets B/I toggles, alignment, spacing fields and a Path chip, all live. The style is saved only when not plain and is noted under format 18.
2026-10-13 19:27:35 -04:00

468 lines
20 KiB
Rust

//! Handles on a selected sketch text: a frame around its outlines, a square on each corner
//! that resizes the text (the opposite corner stays where it is) and a round knob above it
//! that turns the text about its centre (Shift: steps of 15°). Each frame of a drag runs
//! `sketch.edit_text` live; the whole gesture is one undo step and Esc cancels it (the drag
//! shares [`crate::viewport::Viewport::text_drag`] with moving a text, so it ends and cancels
//! the same way).
use std::cell::RefCell;
use egui::{Align2, CursorIcon, FontId, Painter, Pos2, Rect, Shape, Stroke, StrokeKind, vec2};
use serde_json::json;
use solidcraft_engine::geom::Vec2;
use solidcraft_engine::sketch::{LinkKind, LinkSource};
use crate::SolveApp;
use crate::theme::Tokens;
use crate::viewport::Proj;
/// Pixels from a handle's centre that still grab it.
const GRAB_PX: f32 = 8.0;
/// How far above the frame the rotation knob sits (pixels).
const KNOB_PX: f32 = 26.0;
/// Smallest and largest factor one resize may apply, so the text stays a usable size.
const SCALE_RANGE: (f64, f64) = (0.02, 50.0);
/// Rotation steps with Shift held.
const ANGLE_STEP_DEG: f64 = 15.0;
/// A text's frame: its source and the box around its outlines in the text's own frame (x
/// along the baseline from `at`, y up from it).
#[derive(Clone, Debug, PartialEq)]
struct Frame {
link: String,
at: Vec2,
height: f64,
angle: f64,
min: Vec2,
max: Vec2,
}
impl Frame {
/// A point of the text's frame in sketch coordinates.
fn to_sketch(&self, local: Vec2) -> Vec2 {
self.at + rotate(local, self.angle)
}
/// A sketch point in the text's frame.
fn to_local(&self, p: Vec2) -> Vec2 {
rotate(p - self.at, -self.angle)
}
/// The corners, counter-clockwise from the bottom left (text frame).
fn corners(&self) -> [Vec2; 4] {
[self.min, Vec2::new(self.max.x, self.min.y), self.max, Vec2::new(self.min.x, self.max.y)]
}
fn center(&self) -> Vec2 {
(self.min + self.max) * 0.5
}
}
fn rotate(v: Vec2, angle: f64) -> Vec2 {
let (s, c) = angle.sin_cos();
Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c)
}
/// What a drag holds.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Grab {
/// A corner (index into [`Frame::corners`]).
Corner(usize),
Rotate,
}
#[derive(Clone, Debug)]
struct Drag {
grab: Grab,
/// The text as it was when the drag started.
start: Frame,
/// Undo depth at the start: every frame folds into one step.
depth: usize,
/// Where the pointer went down (sketch coordinates).
press: Vec2,
}
thread_local! {
static DRAG: RefCell<Option<Drag>> = const { RefCell::new(None) };
}
/// The text the handles are for: the one selected text, while nothing else (a tool, a
/// dialog) is going on.
fn target(app: &SolveApp) -> Option<String> {
if let Some(d) = DRAG.with(|d| d.borrow().clone()) {
return Some(d.start.link);
}
if app.tool.is_some() || app.dialog.is_some() {
return None;
}
let entity = crate::sketch_text::selected_entity(app)?;
let sk = app.session.doc.sketch(app.session.active_sketch?).ok()?;
sk.curves.iter().find(|c| c.id == entity)?.link.clone()
}
/// The frame of text `link` in the active sketch, as shown (solved).
fn frame(app: &SolveApp, link: &str) -> Option<Frame> {
let sid = app.session.active_sketch?;
let state = app.session.world_state();
let sk = &state.sketch(sid)?.sketch;
let source = sk.link(link).filter(|l| l.kind == LinkKind::Text)?;
let LinkSource::Text { at, height, angle, style, .. } = &source.source else { return None };
// A text on a path follows its curve: it is not placed by hand.
if style.path.is_some() {
return None;
}
let mut f = Frame { link: link.to_owned(), at: *at, height: *height, angle: *angle, min: Vec2::ZERO, max: Vec2::ZERO };
let mut first = true;
for p in sk.link_curves(link).into_iter().flat_map(|ci| sk.polyline(ci)) {
let q = f.to_local(p);
if !q.is_finite() {
continue;
}
if first {
(f.min, f.max, first) = (q, q, false);
} else {
f.min = Vec2::new(f.min.x.min(q.x), f.min.y.min(q.y));
f.max = Vec2::new(f.max.x.max(q.x), f.max.y.max(q.y));
}
}
// A frame needs some width and height to grab (a text of dots or dashes is flat).
let pad = height * 0.05;
if f.max.x - f.min.x < pad {
f.max.x += pad;
f.min.x -= pad;
}
if f.max.y - f.min.y < pad {
f.max.y += pad;
f.min.y -= pad;
}
(!first).then_some(f)
}
/// The handles on screen: the four corners, then the rotation knob and the point of the frame
/// it hangs from.
struct Screen {
corners: [Pos2; 4],
knob: Pos2,
knob_base: Pos2,
}
fn screen(app: &SolveApp, proj: &Proj, f: &Frame) -> Option<Screen> {
let sid = app.session.active_sketch?;
let state = app.session.world_state();
let plane = state.sketch(sid)?.plane;
let to_screen = |local: Vec2| proj.to_screen(plane.to_world(f.to_sketch(local)));
let [a, b, c, d] = f.corners();
let corners = [to_screen(a)?, to_screen(b)?, to_screen(c)?, to_screen(d)?];
let top = Vec2::new((f.min.x + f.max.x) * 0.5, f.max.y);
let knob_base = to_screen(top)?;
// "Up" for the text, as it looks on screen.
let up = to_screen(top + Vec2::new(0.0, f.height.max(1e-3)))? - knob_base;
let up = if up.length() > 1e-3 { up.normalized() } else { vec2(0.0, -1.0) };
Some(Screen { corners, knob: knob_base + up * KNOB_PX, knob_base })
}
/// The handle under a screen position.
fn hit(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Frame, Grab)> {
let f = frame(app, &target(app)?)?;
let s = screen(app, proj, &f)?;
if s.knob.distance(pos) <= GRAB_PX {
return Some((f, Grab::Rotate));
}
let corner = (0..4).find(|&i| s.corners.get(i).is_some_and(|c| c.distance(pos) <= GRAB_PX))?;
Some((f, Grab::Corner(corner)))
}
/// Is a handle under the pointer? (A click there does not change the selection.)
pub(crate) fn over(app: &SolveApp, proj: &Proj, pos: Pos2) -> bool {
hit(app, proj, pos).is_some()
}
/// Where the pointer is on the active sketch's plane.
fn on_plane(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<Vec2> {
let state = app.session.world_state();
let ss = state.sketch(app.session.active_sketch?)?;
let (o, d) = proj.ray(pos);
ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w))
}
/// A drag starting on a handle (true: the handles took it).
pub(crate) fn drag_start(app: &mut SolveApp, proj: &Proj, press: Pos2) -> bool {
let Some((start, grab)) = hit(app, proj, press) else { return false };
let Some(at) = on_plane(app, proj, press) else { return false };
let Ok(result) = app.session.execute("edit.checkpoint", &json!({})) else { return false };
let Some(checkpoint) = result["checkpoint"].as_u64() else { return false };
app.viewport.text_drag = Some((checkpoint, start.link.clone()));
DRAG.with(|d| *d.borrow_mut() = Some(Drag { grab, start, depth: app.session.undo.len(), press: at }));
true
}
/// Is a handle being dragged? A drag the text move machinery ended (Esc, another command, a
/// document switch) is over here too.
pub(crate) fn dragging(app: &SolveApp) -> bool {
let on = DRAG.with(|d| d.borrow().is_some());
if on && app.viewport.text_drag.is_none() {
DRAG.with(|d| *d.borrow_mut() = None);
return false;
}
on
}
/// The text's source after the pointer moved to `to` (sketch coordinates): `at`, `height`
/// and `angle` (radians).
fn follow(d: &Drag, to: Vec2, shift: bool) -> Option<(Vec2, f64, f64)> {
let f = &d.start;
match d.grab {
Grab::Corner(i) => {
let corners = f.corners();
let anchor = *corners.get((i + 2) % 4)?;
let diagonal = *corners.get(i)? - anchor;
let len2 = diagonal.dot(diagonal);
if len2 <= 1e-18 {
return None;
}
// The pointer's progress along the diagonal: the text keeps its proportions.
let s = ((f.to_local(to) - anchor).dot(diagonal) / len2).clamp(SCALE_RANGE.0, SCALE_RANGE.1);
let pivot = f.to_sketch(anchor);
Some((pivot + (f.at - pivot) * s, f.height * s, f.angle))
}
Grab::Rotate => {
let center = f.to_sketch(f.center());
let (a, b) = (d.press - center, to - center);
if a.len() < 1e-9 || b.len() < 1e-9 {
return None;
}
let mut turn = b.y.atan2(b.x) - a.y.atan2(a.x);
if shift {
let step = ANGLE_STEP_DEG.to_radians();
turn = ((f.angle + turn) / step).round() * step - f.angle;
}
Some((center + rotate(f.at - center, turn), f.height, f.angle + turn))
}
}
}
/// Follow the pointer: the text's source changes live; one undo step for the whole drag.
pub(crate) fn drag_to(app: &mut SolveApp, proj: &Proj, pos: Pos2, shift: bool) {
let Some(d) = DRAG.with(|d| d.borrow().clone()) else { return };
let Some(to) = on_plane(app, proj, pos) else { return };
let Some((at, height, angle)) = follow(&d, to, shift) else { return };
let mut params = json!({"link": d.start.link, "at": [at.x, at.y], "height": height, "live": true});
// A resize keeps the exact saved angle.
if d.grab == Grab::Rotate {
params["angle"] = json!(angle.to_degrees());
}
if app.session.execute("sketch.edit_text", &params).is_ok() {
app.session.undo.truncate(d.depth + 1);
}
}
/// The drag is over: the model follows the text once, and the gesture is kept.
pub(crate) fn drag_end(app: &mut SolveApp) {
let Some(d) = DRAG.with(|d| d.borrow_mut().take()) else { return };
let _ = app.session.execute("sketch.drag_end", &json!({}));
app.session.undo.truncate(d.depth + 1);
crate::sketch_text_move::end_drag(app);
}
/// Draw the frame and its handles (and, while dragging, the value being changed).
pub(crate) fn show(app: &SolveApp, ui: &egui::Ui, painter: &Painter, proj: &Proj, hover: Option<Pos2>) {
let Some(link) = target(app) else { return };
let Some(f) = frame(app, &link) else { return };
let Some(s) = screen(app, proj, &f) else { return };
let tk = Tokens::get();
let line = Stroke::new(1.0, tk.accent);
let mut outline = s.corners.to_vec();
outline.push(s.corners[0]);
painter.add(Shape::dashed_line(&outline, line, 5.0, 4.0));
painter.line_segment([s.knob_base, s.knob], line);
let drag = DRAG.with(|d| d.borrow().as_ref().map(|d| d.grab));
let under = hover.and_then(|p| hit(app, proj, p)).map(|(_, g)| g);
let lit = |g: Grab| drag == Some(g) || (drag.is_none() && under == Some(g));
for (i, c) in s.corners.iter().enumerate() {
let r = Rect::from_center_size(*c, vec2(8.0, 8.0));
painter.rect_filled(r, 1.0, if lit(Grab::Corner(i)) { tk.accent } else { tk.panel });
painter.rect_stroke(r, 1.0, Stroke::new(1.5, tk.accent), StrokeKind::Middle);
crate::scenario::publish_handle(&format!("text_corner_{i}"), *c);
}
painter.circle(s.knob, 5.0, if lit(Grab::Rotate) { tk.accent } else { tk.panel }, Stroke::new(1.5, tk.accent));
crate::scenario::publish_handle("text_rotate", s.knob);
match drag.or(under) {
Some(Grab::Rotate) => ui.ctx().set_cursor_icon(if drag.is_some() { CursorIcon::Grabbing } else { CursorIcon::Grab }),
// The corner's diagonal as it looks on screen.
Some(Grab::Corner(i)) => {
let opposite = s.corners[(i + 2) % 4];
let v = s.corners[i] - opposite;
let down_right = (v.x >= 0.0) == (v.y >= 0.0);
ui.ctx().set_cursor_icon(if down_right { CursorIcon::ResizeNwSe } else { CursorIcon::ResizeNeSw });
}
None => {}
}
// While dragging, the value that changes, next to the pointer.
if let (Some(grab), Some(p)) = (drag, hover) {
let label = match grab {
Grab::Corner(_) => {
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
format!("{:.2} {unit}", f.height * scale)
}
Grab::Rotate => format!("{:.1}°", f.angle.to_degrees()),
};
painter.text(p + vec2(14.0, -14.0), Align2::LEFT_BOTTOM, label, FontId::proportional(12.0), tk.accent);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame_at(at: Vec2, angle: f64) -> Frame {
Frame { link: "t".into(), at, height: 10.0, angle, min: Vec2::new(0.0, 0.0), max: Vec2::new(20.0, 10.0) }
}
fn drag(grab: Grab, start: Frame, press: Vec2) -> Drag {
Drag { grab, start, depth: 0, press }
}
#[test]
fn a_corner_scales_about_the_opposite_corner() {
let f = frame_at(Vec2::new(5.0, 5.0), 0.0);
// The top right corner pulled to twice the box: the bottom left stays at `at`.
let d = drag(Grab::Corner(2), f.clone(), Vec2::new(25.0, 15.0));
let (at, height, angle) = follow(&d, Vec2::new(45.0, 25.0), false).unwrap();
assert!(at.dist(Vec2::new(5.0, 5.0)) < 1e-12);
assert!((height - 20.0).abs() < 1e-12);
assert_eq!(angle, 0.0);
// The bottom left pulled in: the top right corner stays put.
let d = drag(Grab::Corner(0), f, Vec2::new(5.0, 5.0));
let (at, height, _) = follow(&d, Vec2::new(15.0, 10.0), false).unwrap();
assert!((height - 5.0).abs() < 1e-12);
let after = Frame { at, height, min: Vec2::ZERO, max: Vec2::new(10.0, 5.0), ..frame_at(at, 0.0) };
assert!(after.to_sketch(after.max).dist(Vec2::new(25.0, 15.0)) < 1e-12);
}
#[test]
fn a_corner_of_a_turned_text_scales_along_its_own_diagonal() {
let f = frame_at(Vec2::new(0.0, 0.0), std::f64::consts::FRAC_PI_2);
let far = f.to_sketch(Vec2::new(30.0, 15.0));
let d = drag(Grab::Corner(2), f, Vec2::ZERO);
let (at, height, angle) = follow(&d, far, false).unwrap();
assert!(at.dist(Vec2::ZERO) < 1e-12);
assert!((height - 15.0).abs() < 1e-12);
assert_eq!(angle, std::f64::consts::FRAC_PI_2);
}
#[test]
fn a_resize_never_flips_or_vanishes_the_text() {
let f = frame_at(Vec2::ZERO, 0.0);
let d = drag(Grab::Corner(2), f, Vec2::new(20.0, 10.0));
for to in [Vec2::new(-50.0, -50.0), Vec2::ZERO, Vec2::new(1e9, 1e9)] {
let (_, height, _) = follow(&d, to, false).unwrap();
assert!(height.is_finite() && height >= 10.0 * SCALE_RANGE.0 && height <= 10.0 * SCALE_RANGE.1);
}
}
#[test]
fn the_knob_turns_about_the_centre_and_snaps_with_shift() {
let f = frame_at(Vec2::ZERO, 0.0);
let center = f.to_sketch(f.center());
let d = drag(Grab::Rotate, f, center + Vec2::new(0.0, 10.0));
// A quarter turn to the left.
let (at, height, angle) = follow(&d, center + Vec2::new(-10.0, 0.0), false).unwrap();
assert!((angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
assert_eq!(height, 10.0);
let turned = Frame { at, angle, ..frame_at(at, angle) };
assert!(turned.to_sketch(turned.center()).dist(center) < 1e-9, "the centre stays put");
// 40° snaps to 45° with Shift.
let to = center + rotate(Vec2::new(0.0, 10.0), 40_f64.to_radians());
let (_, _, snapped) = follow(&d, to, true).unwrap();
assert!((snapped - 45_f64.to_radians()).abs() < 1e-12);
// At the centre itself there is no direction to follow.
assert!(follow(&d, center, false).is_none());
}
use crate::scenario::Harness;
use solidcraft_engine::Sel;
/// A sketch with "II" (10 mm) seen from the top, the text selected: its handles are up.
fn setup() -> (Harness, String) {
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let result = h.app.run("sketch.text", json!({"text":"II","at":[0,0],"height":10})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 60.0;
h.app.session.selection = vec![Sel::SketchCurve { id: result["curves"][0].as_str().unwrap().into() }];
h.frames(3);
(h, result["link"].as_str().unwrap().into())
}
fn source(h: &Harness, link: &str) -> (Vec2, f64, f64) {
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let LinkSource::Text { at, height, angle, .. } = &sk.link(link).unwrap().source else { panic!("text") };
(*at, *height, *angle)
}
#[test]
fn double_clicking_a_letter_opens_its_text_for_editing() {
let (mut h, link) = setup();
h.app.session.selection.clear();
h.frames(2);
let sid = h.app.session.active_sketch.unwrap();
let inside = h.app.session.world_state().sketch(sid).unwrap().profiles[0].centroid;
h.step(&json!({"click":{"sketch":[inside.x,inside.y]},"double":true})).unwrap();
h.frames(2);
match h.app.dialog.as_ref().map(|d| &d.kind) {
Some(crate::dialogs::Kind::Text(tx)) => assert_eq!(tx.link, link),
other => panic!("Edit Text is not open: {other:?}"),
}
}
#[test]
fn a_point_of_a_letter_stands_for_its_text() {
let (mut h, link) = setup();
let sid = h.app.session.active_sketch.unwrap();
let sk = h.app.session.doc.sketch(sid).unwrap();
let point = sk.points.iter().find(|p| p.link.as_deref() == Some(link.as_str())).unwrap().id.clone();
h.app.session.selection = vec![Sel::SketchPoint { id: point }];
h.frames(2);
assert!(crate::sketch_text::selected_entity(&h.app).is_some());
assert!(target(&h.app).is_some_and(|l| l == link), "its handles are up too");
}
#[test]
fn dragging_a_corner_resizes_the_text_in_one_undo_step() {
let (mut h, link) = setup();
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
let f = frame(&h.app, &link).unwrap();
// Pull the top right corner out to twice the frame: the bottom left stays put.
let pivot = f.to_sketch(f.min);
let target = pivot + (f.to_sketch(f.max) - pivot) * 2.0;
h.step(&json!({"drag":[{"handle":"text_corner_2"},{"sketch":[target.x,target.y]}],"steps":6})).unwrap();
h.frames(2);
let (_, height, angle) = source(&h, &link);
assert!((height - 20.0).abs() < 0.5, "height {height}");
assert_eq!(angle, 0.0);
let after = frame(&h.app, &link).unwrap();
assert!(after.to_sketch(after.min).dist(pivot) < 0.3, "the opposite corner stays where it was");
assert!(!h.app.session.model.is_stale(), "the model follows when the drag ends");
assert!(h.app.viewport.text_drag.is_none());
assert_eq!(h.app.session.undo.len(), before.1 + 1);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before.0);
}
#[test]
fn the_rotation_knob_turns_the_text_in_steps_with_shift() {
let (mut h, link) = setup();
let f = frame(&h.app, &link).unwrap();
let center = f.to_sketch(f.center());
// From above the centre to its upper left: about 40°, 45° with Shift.
let to = center + rotate(Vec2::new(0.0, 15.0), 40_f64.to_radians());
h.step(&json!({"drag":[{"handle":"text_rotate"},{"sketch":[to.x,to.y]}],"steps":6,"shift":true})).unwrap();
h.frames(2);
let (_, height, angle) = source(&h, &link);
assert!((angle - 45_f64.to_radians()).abs() < 1e-9, "angle {}", angle.to_degrees());
assert_eq!(height, 10.0);
let after = frame(&h.app, &link).unwrap();
assert!(after.to_sketch(after.center()).dist(center) < 0.05, "it turns about its centre");
}
}