fix(sketch): move and copy editable text as a whole source (#715)

Dragging a text outline now moves the whole editable text, and Move/Copy shifts or rotates it as one piece (a copy stays independently editable). Content, font, height and outline identities are kept; a drag is one undo step and Esc restores the start.
This commit is contained in:
selune authored and GitHub committed 2026-10-13 10:29:02 -04:00
1 parent 2c4d197bf4
commit 11084a5963
14 files changed
+469 -9

No files matched your search

+3
View File
@@ -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.
+5
View File
@@ -2191,6 +2191,11 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
let (d, rep) = edit_solved(s, id, false, |sk, doc| {
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
if let Some(link) = super::sketch_create::text_link(sk, r).map(str::to_owned) {
let moving = sk.link_points(&link).into_iter().filter_map(|i| sk.point(i).map(|p| (i, p))).collect();
super::sketch_create::move_text(sk, &link, to - from, 0.0, Vec2::ZERO, false)?;
return Ok((drag::Dragged { reached: 1.0, moving }, Left::Unsolved));
}
// The held solves work on the dimensions' current values (a solved step applies them).
doc.apply_dimension_values(&doc.param_values().0, sk)?;
let (d, left) = drag_entity(sk, e, from, to, live);
+37
View File
@@ -10,6 +10,10 @@ use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list};
use crate::{Result, Session};
#[cfg(test)]
#[path = "sketch_text_move_tests.rs"]
mod text_move_tests;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE")
@@ -133,6 +137,39 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
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<Vec<usize>> {
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<Value> {
let cmd = "sketch.edit_text";
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
+21 -5
View File
@@ -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<Value> {
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}))
@@ -0,0 +1,108 @@
use super::*;
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
s.execute(cmd, &params).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::<Vec<_>>(), 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::<Vec<_>>();
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, &params).is_err());
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), undo);
}
}
+12 -2
View File
@@ -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<Vec<(String, Value)>, 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 {
+2
View File
@@ -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;
+4
View File
@@ -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;
+6 -2
View File
@@ -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
}
@@ -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}");
+187
View File
@@ -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<SelInput>)> {
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<Vec<(String, Value)>, 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<Sel> {
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);
}
}
+3
View File
@@ -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() {
+26
View File
@@ -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<Vec2>)>,
/// 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()
+9
View File
@@ -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,