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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user