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 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 use the document units captured when editing starts; generated outlines remain associated
with their text source, and untouched inputs preserve their saved internal values. 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 - 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 waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation. 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 (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 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}`")))?; 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). // The held solves work on the dimensions' current values (a solved step applies them).
doc.apply_dimension_values(&doc.param_values().0, sk)?; doc.apply_dimension_values(&doc.param_values().0, sk)?;
let (d, left) = drag_entity(sk, e, from, to, live); 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::params::{bad, num, req_vec2, str_, string_list};
use crate::{Result, Session}; use crate::{Result, Session};
#[cfg(test)]
#[path = "sketch_text_move_tests.rs"]
mod text_move_tests;
pub static COMMANDS: &[CommandSpec] = &[ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line) CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE") .at("SKETCH", "CREATE")
@@ -133,6 +137,39 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"link": l, "curves": curves})) 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> { fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.edit_text"; let cmd = "sketch.edit_text";
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?; 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"), .params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy) CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
.enabled(in_sketch) .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) CommandSpec::new("sketch.mirror", "Mirror", mirror)
.at("SKETCH", "CREATE") .at("SKETCH", "CREATE")
.icon("sketch_mirror") .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> { fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.move"; let cmd = "sketch.move";
let t = p.get("translate").and_then(vec2).unwrap_or(Vec2::ZERO); let t = if p.get("translate").is_some() { req_vec2(cmd, p, "translate")? } else { Vec2::ZERO };
let ang = num(p, "angle").unwrap_or(0.0).to_radians(); 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 copy = bool_(p, "copy").unwrap_or(false);
let (out, info) = edit(s, p, cmd, false, |sk, _| { 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 c = if p.get("center").is_some() { point_or_ref(sk, p, "center", cmd)? } else { Vec2::ZERO };
let rot = rotate(c, ang); let rot = rotate(c, ang);
let f = move |q: Vec2| rot(q) + t; 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 { 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)); return Ok(ids_of(sk, &m));
} }
transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?; transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?;
cs.extend(text_curves);
Ok(ids_of(sk, &cs)) Ok(ids_of(sk, &cs))
})?; })?;
Ok(json!({"curves": out, "sketch": info})) 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), Part(crate::dialogs_parts::Pt),
/// Edit Text: a sketch text's content and placement (`sketch_text`). /// Edit Text: a sketch text's content and placement (`sketch_text`).
Text(crate::sketch_text::Tx), Text(crate::sketch_text::Tx),
TextMove(crate::sketch_text_move::TextMove),
} }
/// The rest of the Hole dialog: placement, extents, tap type and drill point. /// 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_appearance::start(app, id))
.or_else(|| crate::dialogs_motion::start(app, id)) .or_else(|| crate::dialogs_motion::start(app, id))
.or_else(|| crate::dialogs_parts::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) Dialog::new(kind, inputs)
} }
}; };
@@ -933,6 +935,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Motion(k) => k.title(), Kind::Motion(k) => k.title(),
Kind::Part(_) => "INSERT PART", Kind::Part(_) => "INSERT PART",
Kind::Text(_) => "EDIT TEXT", Kind::Text(_) => "EDIT TEXT",
Kind::TextMove(_) => "MOVE/COPY TEXT",
} }
} }
@@ -1354,6 +1357,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return; return;
} }
let _language = crate::i18n::scope(&app.preferences.language); 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); auto_operation(app, &mut d);
update_measure(app, &mut d); update_measure(app, &mut d);
update_fill_cells(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::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::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs),
Kind::Text(k) => enter |= crate::sketch_text::rows(ui, k), 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, .. } => { Kind::ConfirmDelete { with, fail, .. } => {
if !with.is_empty() { if !with.is_empty() {
row_label(ui, "Also deletes"); 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); crate::dialogs_motion::closed(app, &mut d, applied);
} }
// The edited text's outlines are new curves: the old selection would name stale ids. // 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!({})); let _ = app.session.execute("select.clear", &json!({}));
} }
if keep && !cancel { 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::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs),
Kind::Part(k) => return crate::dialogs_parts::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::Text(k) => return crate::sketch_text::commands(app, k),
Kind::TextMove(k) => return crate::sketch_text_move::commands(app, k),
}; };
let mut params = params; let mut params = params;
if let Value::Object(m) = &mut 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. /// Put `slot` into the app and return what was there.
fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
// Whatever was going on in the design leaves with it. // Whatever was going on in the design leaves with it.
crate::sketch_text_move::cancel_drag(app);
crate::dialogs::cancel(app); crate::dialogs::cancel(app);
crate::dim_view::cancel(); crate::dim_view::cancel();
app.tool = None; app.tool = None;
@@ -196,6 +197,7 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
app.docs.closing = None; app.docs.closing = None;
if n <= 1 { if n <= 1 {
// The last design: a blank one takes its place. // The last design: a blank one takes its place.
crate::sketch_text_move::cancel_drag(app);
let rev = app.session.revision; let rev = app.session.revision;
crate::dialogs::cancel(app); crate::dialogs::cancel(app);
app.tool = None; app.tool = None;
+4
View File
@@ -601,6 +601,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
cancel_capture(app); cancel_capture(app);
// Pointer release may have happened during review; never finalize it against // Pointer release may have happened during review; never finalize it against
// the report's last cursor position when CAD interaction resumes. // 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.point_drag = None;
app.viewport.boxsel = None; app.viewport.boxsel = None;
app.viewport.axis_drag = None; app.viewport.axis_drag = None;
+6 -2
View File
@@ -103,6 +103,7 @@ mod sketch_edit_tests;
mod sketch_families; mod sketch_families;
pub mod sketch_palette; pub mod sketch_palette;
pub(crate) mod sketch_text; pub(crate) mod sketch_text;
mod sketch_text_move;
pub mod sketch_tools; pub mod sketch_tools;
pub mod stand_up; pub mod stand_up;
pub mod theme; pub mod theme;
@@ -526,7 +527,8 @@ impl SolveApp {
return; return;
} }
// Another command ends Edit Text (its dialog is not a feature edit to keep). // 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); dialogs::cancel(self);
} }
// A component moved without Capture Position: ask first (Capture, Revert or Cancel). // 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 /// Finish the active sketch: stop editing, restore the history marker Edit Sketch moved and
/// return to the view from before the sketch. /// return to the view from before the sketch.
pub fn finish_sketch(&mut self) { 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); dialogs::cancel(self);
} }
dim_view::cancel(); dim_view::cancel();
@@ -737,6 +740,7 @@ impl SolveApp {
v.extend(d.items()); v.extend(d.items());
} }
v.extend(sketch_text::highlighted(self)); v.extend(sketch_text::highlighted(self));
v.extend(sketch_text_move::highlighted(self));
v v
} }
@@ -6,6 +6,52 @@ use solidcraft_engine::geom::Vec2;
use crate::scenario::Harness; 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) { fn run(h: &mut Harness, id: &str, p: Value) {
if let Err(e) = h.app.run(id, p.clone()) { if let Err(e) = h.app.run(id, p.clone()) {
panic!("{id} {p}: {e}"); 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 { match k {
Key::Escape => { Key::Escape => {
app.esc_handled = true; app.esc_handled = true;
if crate::sketch_text_move::cancel_drag(app) {
continue;
}
if crate::select_other::is_open(app) { if crate::select_other::is_open(app) {
crate::select_other::close(app); crate::select_other::close(app);
} else if app.tool.is_some() { } 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 /// 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). /// 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>)>, 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. /// 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() if let Some((id, depth, grab)) = app.viewport.point_drag.clone()
&& resp.dragged_by(egui::PointerButton::Primary) && resp.dragged_by(egui::PointerButton::Primary)
@@ -2252,6 +2276,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
} }
crate::drag_snap::clear(); crate::drag_snap::clear();
app.viewport.point_drag = None; app.viewport.point_drag = None;
crate::sketch_text_move::end_drag(app);
crate::dim_view::drag_end(); crate::dim_view::drag_end();
crate::sketch3d::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; app.viewport.point_drag = None;
crate::drag_snap::clear(); crate::drag_snap::clear();
let _ = app.session.execute("sketch.drag_end", &json!({})); 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. // Box selection: a primary drag on the model when no navigation mode is on.
if app.tool.is_none() 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** 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. 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 **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. follows later changes. Sketching on a face projects its edges automatically.
For Project, select visible source curves or points from another sketch, model edges, For Project, select visible source curves or points from another sketch, model edges,