diff --git a/ROADMAP.md b/ROADMAP.md index 318df773..7db4afa6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,6 +97,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. cancelling or rejecting an invalid value preserves the sketch. Position and height fields use the document units captured when editing starts; generated outlines remain associated with their text source, and untouched inputs preserve their saved internal values. + Dragging a text outline moves the entire source in one undo step; Escape restores the + gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation + about its baseline; copies keep an independent editable text source. - Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool waits for a distinct point without a degenerate-shape error or history entry. Typed input and programmatic commands retain their validation. diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 33906216..9cfc3049 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -2191,6 +2191,11 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option Result { Ok(json!({"link": l, "curves": curves})) } +/// The text source owning an outline or one of its linked points. +pub(super) fn text_link<'a>(sk: &'a Sketch, entity: &str) -> Option<&'a str> { + let link = sk + .curve_index(entity) + .and_then(|i| sk.curves.get(i)) + .and_then(|c| c.link.as_deref()) + .or_else(|| sk.resolve_point(entity).and_then(|i| sk.points.get(i)).and_then(|p| p.link.as_deref()))?; + sk.link(link).filter(|source| source.kind == LinkKind::Text).map(|source| source.id.as_str()) +} + +/// Transform a text source as a unit; generated outlines remain linked and editable. +pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation: f64, center: Vec2, copy: bool) -> Result> { + let Some(LinkSource::Text { text, at, height, angle, font }) = sk.link(link).map(|l| l.source.clone()) else { + return Err(bad("sketch.move", "the selected source is not text")); + }; + let q = at - center; + let (sin, cos) = rotation.sin_cos(); + let at = center + Vec2::new(q.x * cos - q.y * sin, q.x * sin + q.y * cos) + translate; + let angle = angle + rotation; + let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), &text, at, height, angle)?; + let source = LinkSource::Text { text, at, height, angle, font }; + let link = if copy { + sk.add_link(LinkKind::Text, source, &geom)? + } else { + sk.set_link_source(link, source); + if sk.update_link(link, &geom)? { + return Err(bad("sketch.move", "moving text must preserve its outline topology; edit the text source first")); + } + link.to_owned() + }; + Ok(sk.link_curves(&link)) +} + fn edit_text(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.edit_text"; let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?; diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index a36a85d1..37bfc301 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -69,7 +69,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"), CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy) .enabled(in_sketch) - .params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool", created_doc!())), + .params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())), CommandSpec::new("sketch.mirror", "Mirror", mirror) .at("SKETCH", "CREATE") .icon("sketch_mirror") @@ -1458,19 +1458,35 @@ fn transform_in_place(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Ve fn move_copy(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.move"; - let t = p.get("translate").and_then(vec2).unwrap_or(Vec2::ZERO); - let ang = num(p, "angle").unwrap_or(0.0).to_radians(); + let t = if p.get("translate").is_some() { req_vec2(cmd, p, "translate")? } else { Vec2::ZERO }; + let ang = if p.get("angle").is_some() { num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be finite"))? } else { 0.0 }.to_radians(); let copy = bool_(p, "copy").unwrap_or(false); let (out, info) = edit(s, p, cmd, false, |sk, _| { - let (cs, ps) = entities(sk, p, cmd)?; + let (mut cs, mut ps) = entities(sk, p, cmd)?; let c = if p.get("center").is_some() { point_or_ref(sk, p, "center", cmd)? } else { Vec2::ZERO }; let rot = rotate(c, ang); let f = move |q: Vec2| rot(q) + t; + let mut links = Vec::new(); + for entity in string_list(p, "entities") { + if let Some(link) = super::sketch_create::text_link(sk, &entity) + && !links.iter().any(|old| old == link) + { + links.push(link.to_owned()); + } + } + cs.retain(|&i| !sk.curves.get(i).and_then(|c| c.link.as_ref()).is_some_and(|link| links.contains(link))); + ps.retain(|&i| !sk.points.get(i).and_then(|p| p.link.as_ref()).is_some_and(|link| links.contains(link))); + let mut text_curves = Vec::new(); + for link in links { + text_curves.extend(super::sketch_create::move_text(sk, &link, t, ang, c, copy)?); + } if copy { - let (m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?; + let (mut m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?; + m.extend(text_curves); return Ok(ids_of(sk, &m)); } transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?; + cs.extend(text_curves); Ok(ids_of(sk, &cs)) })?; Ok(json!({"curves": out, "sketch": info})) diff --git a/crates/engine/src/cmd/sketch_text_move_tests.rs b/crates/engine/src/cmd/sketch_text_move_tests.rs new file mode 100644 index 00000000..becbaf4b --- /dev/null +++ b/crates/engine/src/cmd/sketch_text_move_tests.rs @@ -0,0 +1,108 @@ +use super::*; + +fn run(s: &mut Session, cmd: &str, params: Value) -> Value { + s.execute(cmd, ¶ms).unwrap() +} + +fn setup() -> (Session, String, String) { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + let result = run(&mut s, "sketch.text", json!({"text":"II","at":[3,4],"height":10,"angle":15})); + (s, result["link"].as_str().unwrap().into(), result["curves"][0].as_str().unwrap().into()) +} + +#[test] +fn text_move_updates_the_source_once_preserves_ids_and_roundtrips() { + let (mut s, link, curve) = setup(); + let before = s.doc.to_json(); + let ids: Vec<_> = s.doc.sketch(1).unwrap().curves.iter().map(|c| c.id.clone()).collect(); + let undo = s.undo.len(); + run(&mut s, "sketch.move", json!({"entities":[curve,ids[1]],"translate":[10,-2],"center":[3,4],"angle":30})); + let sk = s.doc.sketch(1).unwrap(); + assert_eq!(sk.curves.iter().map(|c| c.id.clone()).collect::>(), ids); + let LinkSource::Text { at, angle, text, height, font } = &sk.link(&link).unwrap().source else { panic!() }; + assert!(at.dist(Vec2::new(13.0, 2.0)) < 1e-9); + assert!((*angle - 45_f64.to_radians()).abs() < 1e-12); + assert_eq!((text.as_str(), *height, font), ("II", 10.0, &None)); + assert_eq!(s.undo.len(), undo + 1); + let moved = s.doc.to_json(); + let reopened = solidcraft_doc::Document::from_json(&moved).unwrap(); + assert_eq!(reopened.to_json(), moved); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), moved); +} + +#[test] +fn text_copy_is_a_new_editable_source_and_original_is_unchanged() { + let (mut s, link, curve) = setup(); + let original = s.doc.sketch(1).unwrap().link(&link).unwrap().source.clone(); + let result = run(&mut s, "sketch.move", json!({"entities":[curve],"translate":[20,0],"copy":true})); + let copied_curve = result["curves"][0].as_str().unwrap(); + let new_link = text_link(s.doc.sketch(1).unwrap(), copied_curve).unwrap().to_owned(); + assert_ne!(new_link, link); + run(&mut s, "sketch.edit_text", json!({"link":new_link,"text":"O"})); + assert_eq!(s.doc.sketch(1).unwrap().link(&link).unwrap().source, original); +} + +#[test] +fn text_drag_moves_all_letters_and_rebuilds_downstream_on_release() { + let (mut s, link, curve) = setup(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":3})); + run(&mut s, "sketch.edit", json!({"sketch":1})); + let sk = s.doc.sketch(1).unwrap(); + let before = sk.link_points(&link).into_iter().map(|i| (i, sk.point(i).unwrap())).collect::>(); + for frame in 0..3 { + let result = run(&mut s, "sketch.drag", json!({"entity":curve,"from":[frame,0],"to":[frame+1,1],"live":true})); + assert_eq!(result["reached"], 1.0); + assert!(s.model.is_stale()); + } + run(&mut s, "sketch.drag_end", json!({})); + assert!(!s.model.is_stale()); + assert!(s.model.result(2).is_some_and(|r| r.error.is_none())); + let sk = s.doc.sketch(1).unwrap(); + for (i, old) in before { + assert!(sk.point(i).unwrap().dist(old + Vec2::new(3.0, 3.0)) < 1e-8); + } + assert!(matches!(sk.link(&link).unwrap().source, LinkSource::Text { .. })); +} + +#[test] +fn text_move_in_a_rotated_component_preserves_font_and_local_coordinates() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Label"})); + run(&mut s, "occurrence.move", json!({"occurrence":component["occurrence"],"translate":[30,20,10],"axis":[0,0,1],"angle":90})); + let created = run(&mut s, "sketch.create", json!({"plane":"XY"})); + let sid = created["sketch"].as_u64().unwrap(); + let result = run(&mut s, "sketch.text", json!({"text":"I","at":[0,0],"height":10,"font":solidcraft_sketch::DEFAULT_FONT_FAMILY})); + let link = result["link"].as_str().unwrap(); + let before = s.world_state().sketch(sid).unwrap().plane; + run(&mut s, "sketch.move", json!({"entities":[result["curves"][0]],"translate":[5,2]})); + let sk = s.doc.sketch(sid).unwrap(); + let LinkSource::Text { at, font, .. } = &sk.link(link).unwrap().source else { panic!() }; + assert!(at.dist(Vec2::new(5.0, 2.0)) < 1e-9); + assert_eq!(font.as_deref(), Some(solidcraft_sketch::DEFAULT_FONT_FAMILY)); + let state = s.world_state(); + assert!(state.sketch(sid).unwrap().plane.to_world(*at).dist(before.to_world(Vec2::new(5.0, 2.0))) < 1e-9); + let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_eq!(reopened.doc.sketch(sid).unwrap().link(link).unwrap().source, sk.link(link).unwrap().source); +} + +#[test] +fn text_move_rejects_malformed_values_without_changing_history() { + let (mut s, _, curve) = setup(); + let before = s.doc.to_json(); + let undo = s.undo.len(); + for params in [ + json!({"entities":[curve],"translate":[1]}), + json!({"entities":[curve],"angle":"invalid"}), + json!({"entity":curve,"from":[0,0],"to":["invalid",2]}), + ] { + let cmd = if params.get("entity").is_some() { "sketch.drag" } else { "sketch.move" }; + assert!(s.execute(cmd, ¶ms).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), undo); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9c1e0d30..c2a83fdd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -247,6 +247,7 @@ pub enum Kind { Part(crate::dialogs_parts::Pt), /// Edit Text: a sketch text's content and placement (`sketch_text`). Text(crate::sketch_text::Tx), + TextMove(crate::sketch_text_move::TextMove), } /// The rest of the Hole dialog: placement, extents, tap type and drill point. @@ -503,7 +504,8 @@ impl Dialog { .or_else(|| crate::dialogs_appearance::start(app, id)) .or_else(|| crate::dialogs_motion::start(app, id)) .or_else(|| crate::dialogs_parts::start(app, id)) - .or_else(|| crate::sketch_text::start(app, id))?; + .or_else(|| crate::sketch_text::start(app, id)) + .or_else(|| crate::sketch_text_move::start(app, id))?; Dialog::new(kind, inputs) } }; @@ -933,6 +935,7 @@ fn title(k: &Kind) -> &'static str { Kind::Motion(k) => k.title(), Kind::Part(_) => "INSERT PART", Kind::Text(_) => "EDIT TEXT", + Kind::TextMove(_) => "MOVE/COPY TEXT", } } @@ -1354,6 +1357,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { return; } let _language = crate::i18n::scope(&app.preferences.language); + if let Kind::TextMove(k) = &d.kind + && !crate::sketch_text_move::valid(app, k) + { + return; + } auto_operation(app, &mut d); update_measure(app, &mut d); update_fill_cells(app, &mut d); @@ -2061,6 +2069,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs), Kind::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs), Kind::Text(k) => enter |= crate::sketch_text::rows(ui, k), + Kind::TextMove(k) => enter |= crate::sketch_text_move::rows(ui, k), Kind::ConfirmDelete { with, fail, .. } => { if !with.is_empty() { row_label(ui, "Also deletes"); @@ -2171,7 +2180,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { crate::dialogs_motion::closed(app, &mut d, applied); } // The edited text's outlines are new curves: the old selection would name stale ids. - if applied && matches!(d.kind, Kind::Text(_)) { + if applied && matches!(d.kind, Kind::Text(_) | Kind::TextMove(_)) { let _ = app.session.execute("select.clear", &json!({})); } if keep && !cancel { @@ -3067,6 +3076,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs), Kind::Part(k) => return crate::dialogs_parts::commands(app, k, &d.inputs), Kind::Text(k) => return crate::sketch_text::commands(app, k), + Kind::TextMove(k) => return crate::sketch_text_move::commands(app, k), }; let mut params = params; if let Value::Object(m) = &mut params { diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index e87c5d5d..62e33962 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -80,6 +80,7 @@ fn put_view(app: &mut SolveApp, v: DocView) { /// Put `slot` into the app and return what was there. fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { // Whatever was going on in the design leaves with it. + crate::sketch_text_move::cancel_drag(app); crate::dialogs::cancel(app); crate::dim_view::cancel(); app.tool = None; @@ -196,6 +197,7 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) { app.docs.closing = None; if n <= 1 { // The last design: a blank one takes its place. + crate::sketch_text_move::cancel_drag(app); let rev = app.session.revision; crate::dialogs::cancel(app); app.tool = None; diff --git a/crates/ui-egui/src/issue_report.rs b/crates/ui-egui/src/issue_report.rs index 7b91c80b..7ddad3d9 100644 --- a/crates/ui-egui/src/issue_report.rs +++ b/crates/ui-egui/src/issue_report.rs @@ -601,6 +601,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { cancel_capture(app); // Pointer release may have happened during review; never finalize it against // the report's last cursor position when CAD interaction resumes. + if app.viewport.text_drag.is_some() { + let _ = app.session.execute("sketch.drag_end", &serde_json::json!({})); + crate::sketch_text_move::end_drag(app); + } app.viewport.point_drag = None; app.viewport.boxsel = None; app.viewport.axis_drag = None; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 523e7866..767de4fa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -103,6 +103,7 @@ mod sketch_edit_tests; mod sketch_families; pub mod sketch_palette; pub(crate) mod sketch_text; +mod sketch_text_move; pub mod sketch_tools; pub mod stand_up; pub mod theme; @@ -526,7 +527,8 @@ impl SolveApp { return; } // Another command ends Edit Text (its dialog is not a feature edit to keep). - if sketch_text::editing(self) { + sketch_text_move::cancel_drag(self); + if sketch_text::editing(self) || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::TextMove(_))) { dialogs::cancel(self); } // A component moved without Capture Position: ask first (Capture, Revert or Cancel). @@ -712,7 +714,8 @@ impl SolveApp { /// Finish the active sketch: stop editing, restore the history marker Edit Sketch moved and /// return to the view from before the sketch. pub fn finish_sketch(&mut self) { - if sketch_text::editing(self) { + sketch_text_move::cancel_drag(self); + if sketch_text::editing(self) || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::TextMove(_))) { dialogs::cancel(self); } dim_view::cancel(); @@ -737,6 +740,7 @@ impl SolveApp { v.extend(d.items()); } v.extend(sketch_text::highlighted(self)); + v.extend(sketch_text_move::highlighted(self)); v } diff --git a/crates/ui-egui/src/sketch_drag_ui_tests.rs b/crates/ui-egui/src/sketch_drag_ui_tests.rs index 89e6b326..85c12117 100644 --- a/crates/ui-egui/src/sketch_drag_ui_tests.rs +++ b/crates/ui-egui/src/sketch_drag_ui_tests.rs @@ -6,6 +6,52 @@ use solidcraft_engine::geom::Vec2; use crate::scenario::Harness; +#[test] +fn text_move_drag_is_one_editable_unit_and_escape_restores_the_gesture() { + for cancel in [false, true] { + let mut h = Harness::new(); + h.app.home.open = false; + run(&mut h, "sketch.create", json!({"plane":"XY"})); + run(&mut h, "sketch.text", json!({"text":"II","at":[0,0],"height":10})); + run(&mut h, "sketch.line", json!({"points":[[15,0],[15,10]],"connect":false})); + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 35.0; + h.frames(3); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let sk = h.app.session.doc.sketch(1).unwrap(); + let line = sk.curves.last().unwrap().id.clone(); + let neighbor = point(&h, &format!("{line}.start")); + let polyline = sk.polyline(0); + let at = (polyline[0] + polyline[1]) * 0.5; + let a = screen(&mut h, at.x, at.y); + let b = screen(&mut h, at.x + 3.0, at.y + 2.0); + let mut path = vec![a]; + path.extend(steps(a, b, 6)); + press_and_move(&mut h, &path); + assert!(h.app.viewport.text_drag.is_some()); + assert_ne!(h.app.session.doc.to_json(), before); + if cancel { + h.step(&json!({"key":"Escape"})).unwrap(); + } + release(&mut h, b); + assert!(h.app.viewport.text_drag.is_none()); + assert_eq!(h.app.session.active_sketch, Some(1)); + assert!(point(&h, &format!("{line}.start")).dist(neighbor) < 1e-9); + if cancel { + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + } else { + assert_eq!(h.app.session.undo.len(), undo + 1); + let sk = h.app.session.doc.sketch(1).unwrap(); + let solidcraft_engine::sketch::LinkSource::Text { at, .. } = &sk.links[0].source else { panic!() }; + assert!(at.dist(Vec2::new(3.0, 2.0)) < 0.02, "{at:?}"); + run(&mut h, "edit.undo", json!({})); + assert_eq!(h.app.session.doc.to_json(), before); + } + } +} + fn run(h: &mut Harness, id: &str, p: Value) { if let Err(e) = h.app.run(id, p.clone()) { panic!("{id} {p}: {e}"); diff --git a/crates/ui-egui/src/sketch_text_move.rs b/crates/ui-egui/src/sketch_text_move.rs new file mode 100644 index 00000000..33c12cdc --- /dev/null +++ b/crates/ui-egui/src/sketch_text_move.rs @@ -0,0 +1,187 @@ +//! Move/Copy changes the text source, never independent generated outlines. +use crate::{SolveApp, dialogs::Kind, selection::SelInput}; +use serde_json::{Value, json}; +use solidcraft_engine::{Sel, doc::expr::Kind as ValueKind, sketch::LinkKind}; + +#[derive(Clone, Debug)] +pub struct TextMove { + sketch: u64, + link: String, + values: [String; 3], + copy: bool, +} + +pub(crate) fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { + if id != "sketch.move" { + return None; + } + let entity = crate::sketch_text::selected_entity(app)?; + let sketch = app.session.active_sketch?; + let sk = app.session.doc.sketch(sketch).ok()?; + let link = sk.curves.iter().find(|c| c.id == entity)?.link.clone()?; + let (_, unit) = crate::prefs::unit_scale(&app.session.doc.units); + Some((Kind::TextMove(TextMove { sketch, link, values: [format!("0 {unit}"), format!("0 {unit}"), "0 deg".into()], copy: false }), vec![])) +} + +pub(crate) fn valid(app: &SolveApp, k: &TextMove) -> bool { + app.session.active_sketch == Some(k.sketch) + && app.session.doc.sketch(k.sketch).ok().and_then(|sk| sk.link(&k.link)).is_some_and(|l| l.kind == LinkKind::Text) +} + +pub(crate) fn rows(ui: &mut egui::Ui, k: &mut TextMove) -> bool { + let mut enter = false; + for (label, value) in ["Distance X", "Distance Y", "Angle"].iter().zip(&mut k.values) { + crate::dialogs::row_label(ui, label); + enter |= crate::dialogs::field(ui, value); + ui.end_row(); + } + crate::dialogs::row_label(ui, "Create Copy"); + let response = ui.checkbox(&mut k.copy, ""); + crate::scenario::publish_handle("dialog:Create Copy", response.rect.center()); + ui.end_row(); + enter +} + +pub(crate) fn commands(app: &SolveApp, k: &TextMove) -> Result, String> { + if !valid(app, k) { + return Err("The text is no longer in the active sketch".into()); + } + let doc = &app.session.doc; + let mut values = [0.0; 3]; + for (i, (input, value)) in k.values.iter().zip(&mut values).enumerate() { + let kind = if i == 2 { ValueKind::Angle } else { ValueKind::Length }; + *value = doc.eval(&doc.with_design_unit(input.trim(), kind), kind).map_err(|e| e.to_string())?; + if !value.is_finite() { + return Err("Move values must be finite".into()); + } + } + let sk = doc.sketch(k.sketch).map_err(|e| e.to_string())?; + let curve = sk.curves.iter().find(|c| c.link.as_deref() == Some(&k.link)).ok_or("The text has no outlines")?; + let Some(solidcraft_engine::sketch::LinkSource::Text { at, .. }) = sk.link(&k.link).map(|l| &l.source) else { + return Err("The source is no longer text".into()); + }; + Ok(vec![( + "sketch.move".into(), + json!({"entities":[curve.id],"translate":[values[0],values[1]], + "angle":values[2].to_degrees(),"center":[at.x,at.y],"copy":k.copy}), + )]) +} + +pub(crate) fn highlighted(app: &SolveApp) -> Vec { + let link = match app.dialog.as_ref().map(|d| &d.kind) { + Some(Kind::TextMove(k)) => Some(k.link.as_str()), + _ => app.viewport.text_drag.as_ref().map(|(_, link)| link.as_str()), + }; + let Some(link) = link else { + return vec![]; + }; + let Some(sk) = app.session.active_sketch.and_then(|sid| app.session.doc.sketch(sid).ok()) else { + return vec![]; + }; + sk.curves.iter().filter(|c| c.link.as_deref() == Some(link)).map(|c| Sel::SketchCurve { id: c.id.clone() }).collect() +} + +/// Esc restores the document and both history stacks to the start of this gesture. +pub(crate) fn cancel_drag(app: &mut SolveApp) -> bool { + let Some((checkpoint, _)) = app.viewport.text_drag.take() else { + return false; + }; + let _ = app.session.execute("edit.restore_checkpoint", &json!({"checkpoint":checkpoint})); + app.viewport.point_drag = None; + crate::drag_snap::clear(); + true +} + +pub(crate) fn end_drag(app: &mut SolveApp) { + if let Some((checkpoint, _)) = app.viewport.text_drag.take() { + let _ = app.session.execute("edit.restore_checkpoint", &json!({"checkpoint":checkpoint,"forget":true})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenario::Harness; + use solidcraft_engine::sketch::LinkSource; + + 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":[3,4],"height":10})).unwrap(); + h.app.session.selection = vec![Sel::SketchCurve { id: result["curves"][0].as_str().unwrap().into() }]; + h.app.start("sketch.move"); + h.frames(3); + assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::TextMove(_)))); + (h, result["link"].as_str().unwrap().into()) + } + + fn replace(h: &mut Harness, field: &str, value: &str) { + h.step(&json!({"click":{"handle":field}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":value})).unwrap(); + } + + #[test] + fn text_move_dialog_uses_units_and_one_undo_and_preserves_cancel() { + let (mut h, link) = setup(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + replace(&mut h, "dialog:Distance X", "1 in"); + replace(&mut h, "dialog:Distance Y", "-2 mm"); + replace(&mut h, "dialog:Angle", "90 deg"); + assert_eq!(h.app.session.doc.to_json(), before); + h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap(); + h.frames(2); + assert!(h.app.dialog.is_none()); + let sk = h.app.session.doc.sketch(1).unwrap(); + let LinkSource::Text { at, angle, text, .. } = &sk.link(&link).unwrap().source else { panic!() }; + assert!(at.dist(solidcraft_engine::geom::Vec2::new(28.4, 2.0)) < 1e-8); + assert!((*angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + assert_eq!(text, "II"); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + let curve = h.app.session.doc.sketch(1).unwrap().curves[0].id.clone(); + h.app.session.selection = vec![Sel::SketchCurve { id: curve }]; + h.app.start("sketch.move"); + h.frames(3); + replace(&mut h, "dialog:Distance X", "17"); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + } + + #[test] + fn text_move_dialog_copies_an_editable_source() { + let (mut h, link) = setup(); + let original = h.app.session.doc.sketch(1).unwrap().link(&link).unwrap().source.clone(); + replace(&mut h, "dialog:Distance X", "20"); + h.step(&json!({"click":{"handle":"dialog:Create Copy"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap(); + h.frames(2); + let sk = h.app.session.doc.sketch(1).unwrap(); + assert_eq!(sk.links.len(), 2); + assert_eq!(sk.link(&link).unwrap().source, original); + let new_link = sk.links.iter().find(|l| l.id != link).unwrap().id.clone(); + h.app.run("sketch.edit_text", json!({"link":new_link,"text":"O"})).unwrap(); + assert_eq!(h.app.session.doc.sketch(1).unwrap().link(&link).unwrap().source, original); + } + + #[test] + fn text_move_cancels_before_switching_documents() { + let (mut h, link) = setup(); + crate::dialogs::cancel(&mut h.app); + let before = h.app.session.doc.to_json(); + let checkpoint = h.app.session.execute("edit.checkpoint", &json!({})).unwrap()["checkpoint"].as_u64().unwrap(); + let curve = h.app.session.doc.sketch(1).unwrap().curves[0].id.clone(); + h.app.session.execute("sketch.drag", &json!({"entity":curve,"from":[0,0],"to":[2,3],"live":true})).unwrap(); + h.app.viewport.text_drag = Some((checkpoint, link)); + h.app.viewport.point_drag = Some((curve, 0, None)); + crate::documents::open_tab(&mut h.app, solidcraft_engine::Session::default()).unwrap(); + assert!(h.app.viewport.text_drag.is_none()); + assert!(h.app.viewport.point_drag.is_none()); + crate::documents::switch(&mut h.app, 0); + assert_eq!(h.app.session.doc.to_json(), before); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 2219062a..0f8a75b9 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1187,6 +1187,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { match k { Key::Escape => { app.esc_handled = true; + if crate::sketch_text_move::cancel_drag(app) { + continue; + } if crate::select_other::is_open(app) { crate::select_other::close(app); } else if app.tool.is_some() { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 1159a0c2..1b4415c3 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -97,6 +97,8 @@ pub struct ViewportState { /// A sketch point or curve being dragged (its id, the undo depth when the drag began: the /// whole drag is one undo step, and for a curve where it was last under the cursor). pub point_drag: Option<(String, usize, Option)>, + /// Checkpoint and source for a cancellable whole-text drag. + pub(crate) text_drag: Option<(u64, String)>, } /// Something under the cursor. @@ -2202,6 +2204,28 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } _ => {} } + if let Some((id, _, _)) = &app.viewport.point_drag { + let link = app.session.active_sketch.and_then(|sid| app.session.doc.sketch(sid).ok()).and_then(|sk| { + let link = sk + .curve_index(id) + .and_then(|i| sk.curves.get(i)) + .and_then(|c| c.link.as_deref()) + .or_else(|| sk.resolve_point(id).and_then(|i| sk.points.get(i)).and_then(|p| p.link.as_deref()))?; + sk.link(link).filter(|l| l.kind == solidcraft_engine::sketch::LinkKind::Text).map(|l| l.id.clone()) + }); + if let Some(link) = link + && let Some(q) = on_plane(app, a) + { + if let Some((_, _, grab)) = &mut app.viewport.point_drag { + *grab = Some(q); + } + if let Ok(result) = app.session.execute("edit.checkpoint", &json!({})) + && let Some(checkpoint) = result["checkpoint"].as_u64() + { + app.viewport.text_drag = Some((checkpoint, link)); + } + } + } } if let Some((id, depth, grab)) = app.viewport.point_drag.clone() && resp.dragged_by(egui::PointerButton::Primary) @@ -2252,6 +2276,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } crate::drag_snap::clear(); app.viewport.point_drag = None; + crate::sketch_text_move::end_drag(app); crate::dim_view::drag_end(); crate::sketch3d::drag_end(); } @@ -2264,6 +2289,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.viewport.point_drag = None; crate::drag_snap::clear(); let _ = app.session.execute("sketch.drag_end", &json!({})); + crate::sketch_text_move::end_drag(app); } // Box selection: a primary drag on the model when no navigation mode is on. if app.tool.is_none() diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index e843f83a..9bfbb54d 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -48,6 +48,15 @@ geometry cannot identify one text: use **Select Other** to choose its outline in Change the text, font, baseline X/Y, height or angle, then confirm with **OK**; **Cancel** keeps the original text. Position and height use the document's units. +Drag a text outline to move all its letters together. Let go to finish in one undo step, +or press **Esc** during the drag to restore the original text. The text stays editable. +For an exact displacement, select an outline and choose **Move/Copy** from its context menu. +Enter **Distance X**, **Distance Y** and an optional **Angle** about the text's baseline; +lengths accept document units or explicit units such as `1 in`. Enable **Create Copy** to +leave the original in place and create a separately editable text. **Cancel** leaves the +design unchanged. Projected geometry remains protected from ordinary dragging. + + **Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that follows later changes. Sketching on a face projects its edges automatically. For Project, select visible source curves or points from another sketch, model edges,