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:
1 parent
2c4d197bf4
commit
11084a5963
14 files changed
+469
-9
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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, ¶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::<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, ¶ms).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user