fix(ui): edit sketch text from letter interiors (#694)
Edit Text now works from a letter's interior as well as its outline, from the tool and the context menu, and highlights the whole text while editing.
This commit is contained in:
1 parent
d1a773a684
commit
e10b0b7d22
6 files changed
+129
-7
No files matched your search
+3
-1
@@ -81,7 +81,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
|||||||
arguments reject such a name when an older design's construction plane already has it. Use
|
arguments reject such a name when an older design's construction plane already has it. Use
|
||||||
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
||||||
stored references keep their saved meaning. Section Analysis uses the same check.
|
stored references keep their saved meaning. Section Analysis uses the same check.
|
||||||
- Sketch text can be edited from a selected outline's context menu or the Edit Text command.
|
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
|
||||||
|
Interior selection requires every profile boundary to belong to the same text source;
|
||||||
|
the editor highlights the whole source without replacing profile selections.
|
||||||
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
||||||
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
|
||||||
|
|||||||
@@ -646,6 +646,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
|
|||||||
}
|
}
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
if curves.is_empty() && points.is_empty() {
|
if curves.is_empty() && points.is_empty() {
|
||||||
|
if crate::sketch_text::selected_entity(app).is_some() {
|
||||||
|
v.push(cmd(app, "sketch.edit_text", "Edit Text"));
|
||||||
|
}
|
||||||
v.push(cmd(app, "sketch.line", "Line"));
|
v.push(cmd(app, "sketch.line", "Line"));
|
||||||
v.push(cmd(app, "sketch.rectangle.two_point", "Rectangle"));
|
v.push(cmd(app, "sketch.rectangle.two_point", "Rectangle"));
|
||||||
v.push(cmd(app, "sketch.circle.center", "Circle"));
|
v.push(cmd(app, "sketch.circle.center", "Circle"));
|
||||||
@@ -1183,7 +1186,7 @@ pub fn open(app: &mut SolveApp, at: Pos2) {
|
|||||||
} else if app.session.active_sketch.is_some()
|
} else if app.session.active_sketch.is_some()
|
||||||
&& let Some(h) = app.viewport.hover.clone()
|
&& let Some(h) = app.viewport.hover.clone()
|
||||||
&& let Some((_, sel)) = crate::viewport::candidate(app, &[h])
|
&& let Some((_, sel)) = crate::viewport::candidate(app, &[h])
|
||||||
&& matches!(sel, Sel::SketchCurve { .. } | Sel::SketchPoint { .. })
|
&& (matches!(sel, Sel::SketchCurve { .. } | Sel::SketchPoint { .. }) || crate::sketch_text::entity_for_selection(app, &sel).is_some())
|
||||||
&& !app.session.selection.contains(&sel)
|
&& !app.session.selection.contains(&sel)
|
||||||
{
|
{
|
||||||
let _ = app.run("select.set", json!({ "items": [sel] }));
|
let _ = app.run("select.set", json!({ "items": [sel] }));
|
||||||
|
|||||||
@@ -726,6 +726,7 @@ impl SolveApp {
|
|||||||
if let Some(d) = &self.dialog {
|
if let Some(d) = &self.dialog {
|
||||||
v.extend(d.items());
|
v.extend(d.items());
|
||||||
}
|
}
|
||||||
|
v.extend(sketch_text::highlighted(self));
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,14 +34,36 @@ pub struct Tx {
|
|||||||
focus: bool,
|
focus: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve an outline or filled letter only when all its boundaries belong to one text.
|
||||||
|
pub(crate) fn entity_for_selection(app: &SolveApp, selection: &Sel) -> Option<String> {
|
||||||
|
let sid = app.session.active_sketch?;
|
||||||
|
let state = app.session.world_state();
|
||||||
|
let ss = state.sketch(sid)?;
|
||||||
|
let ids = match selection {
|
||||||
|
Sel::SketchCurve { id } => vec![id.clone()],
|
||||||
|
Sel::Profile { sketch, index } if *sketch == sid => ss.profiles.get(*index)?.curve_ids(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let mut link = None;
|
||||||
|
for id in &ids {
|
||||||
|
let curve = ss.sketch.curves.iter().find(|curve| &curve.id == id)?;
|
||||||
|
let source = ss.sketch.link(curve.link.as_deref()?)?;
|
||||||
|
if source.kind != LinkKind::Text || link.is_some_and(|old| old != source.id.as_str()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
link = Some(source.id.as_str());
|
||||||
|
}
|
||||||
|
ids.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
/// Only a complete selection belonging to one text source can seed the editor.
|
/// Only a complete selection belonging to one text source can seed the editor.
|
||||||
pub(crate) fn selected_entity(app: &SolveApp) -> Option<String> {
|
pub(crate) fn selected_entity(app: &SolveApp) -> Option<String> {
|
||||||
let sk = app.session.doc.sketch(app.session.active_sketch?).ok()?;
|
let sk = app.session.doc.sketch(app.session.active_sketch?).ok()?;
|
||||||
let mut selected = None;
|
let mut selected = None;
|
||||||
let mut link = None;
|
let mut link = None;
|
||||||
for selection in &app.session.selection {
|
for selection in &app.session.selection {
|
||||||
let Sel::SketchCurve { id } = selection else { return None };
|
let id = entity_for_selection(app, selection)?;
|
||||||
let curve = sk.curves.iter().find(|curve| &curve.id == id)?;
|
let curve = sk.curves.iter().find(|curve| curve.id == id)?;
|
||||||
let source = sk.link(curve.link.as_deref()?)?;
|
let source = sk.link(curve.link.as_deref()?)?;
|
||||||
if source.kind != LinkKind::Text || link.is_some_and(|old| old != source.id.as_str()) {
|
if source.kind != LinkKind::Text || link.is_some_and(|old| old != source.id.as_str()) {
|
||||||
return None;
|
return None;
|
||||||
@@ -52,6 +74,13 @@ pub(crate) fn selected_entity(app: &SolveApp) -> Option<String> {
|
|||||||
selected
|
selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show every outline affected by the open editor without changing feature selections.
|
||||||
|
pub(crate) fn highlighted(app: &SolveApp) -> Vec<Sel> {
|
||||||
|
let Some(Kind::Text(tx)) = app.dialog.as_ref().map(|dialog| &dialog.kind) else { return Vec::new() };
|
||||||
|
let Ok(sk) = app.session.doc.sketch(tx.sketch) else { return Vec::new() };
|
||||||
|
sk.curves.iter().filter(|curve| curve.link.as_deref() == Some(tx.link.as_str())).map(|curve| Sel::SketchCurve { id: curve.id.clone() }).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// A value shown in a field: rounded so binary noise does not show, with its unit.
|
/// A value shown in a field: rounded so binary noise does not show, with its unit.
|
||||||
fn shown(value: f64, unit: &str) -> String {
|
fn shown(value: f64, unit: &str) -> String {
|
||||||
let text = format!("{value:.6}");
|
let text = format!("{value:.6}");
|
||||||
@@ -242,6 +271,77 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn letter_interior_context_menu_opens_and_highlights_the_whole_text() {
|
||||||
|
let (mut h, link, _) = setup();
|
||||||
|
h.app.run("sketch.edit_text", json!({"link":link,"text":"II"})).unwrap();
|
||||||
|
h.app.session.selection.clear();
|
||||||
|
h.call("ui.view", json!({"view":"top"}));
|
||||||
|
h.app.cam.distance = 25.0;
|
||||||
|
h.frames(3);
|
||||||
|
let sid = h.app.session.active_sketch.unwrap();
|
||||||
|
let at = h.app.session.world_state().sketch(sid).unwrap().profiles[0].centroid;
|
||||||
|
let [x, y] = h.at(&json!({"sketch":[at.x,at.y]})).unwrap();
|
||||||
|
h.call("ui.menu", json!({"x":x,"y":y}));
|
||||||
|
h.frames(3);
|
||||||
|
assert!(matches!(h.app.session.selection.as_slice(), [Sel::Profile { .. }]));
|
||||||
|
let item = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport)
|
||||||
|
.into_iter()
|
||||||
|
.find(|item| item.id == "sketch.edit_text")
|
||||||
|
.unwrap();
|
||||||
|
crate::context_menu::run_item(&mut h.app, &item, egui::Pos2::ZERO);
|
||||||
|
h.frames(3);
|
||||||
|
assert_eq!(tx(&h.app).link, link);
|
||||||
|
let sk = h.app.session.doc.sketch(sid).unwrap();
|
||||||
|
for curve in sk.curves.iter().filter(|curve| curve.link.as_deref() == Some(&link)) {
|
||||||
|
assert!(h.app.highlighted().contains(&Sel::SketchCurve { id: curve.id.clone() }));
|
||||||
|
}
|
||||||
|
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||||
|
assert!(highlighted(&h.app).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_text_tool_accepts_a_letter_interior_without_preselection() {
|
||||||
|
let (mut h, link, _) = setup();
|
||||||
|
h.app.session.selection.clear();
|
||||||
|
h.call("ui.view", json!({"view":"top"}));
|
||||||
|
h.app.cam.distance = 25.0;
|
||||||
|
h.frames(3);
|
||||||
|
let sid = h.app.session.active_sketch.unwrap();
|
||||||
|
let at = h.app.session.world_state().sketch(sid).unwrap().profiles[0].centroid;
|
||||||
|
h.step(&json!({"start":"sketch.edit_text"})).unwrap();
|
||||||
|
h.step(&json!({"click":{"sketch":[at.x,at.y]}})).unwrap();
|
||||||
|
assert_eq!(tx(&h.app).link, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_profile_resolution_checks_holes_and_rejects_mixed_sources() {
|
||||||
|
let (mut h, link, _) = setup();
|
||||||
|
h.app.run("sketch.edit_text", json!({"link":link,"text":"O"})).unwrap();
|
||||||
|
let sid = h.app.session.active_sketch.unwrap();
|
||||||
|
let state = h.app.session.world_state();
|
||||||
|
let profile = state.sketch(sid).unwrap().profiles.iter().position(|p| !p.hole_curves.is_empty()).unwrap();
|
||||||
|
let selection = Sel::Profile { sketch: sid, index: profile };
|
||||||
|
assert!(entity_for_selection(&h.app, &selection).is_some());
|
||||||
|
h.app.run("sketch.text", json!({"text":"I","at":[20,0],"height":10})).unwrap();
|
||||||
|
let profiles = h.app.session.world_state().sketch(sid).unwrap().profiles.len();
|
||||||
|
h.app.session.selection = (0..profiles).map(|index| Sel::Profile { sketch: sid, index }).collect();
|
||||||
|
assert!(selected_entity(&h.app).is_none());
|
||||||
|
assert!(entity_for_selection(&h.app, &Sel::Profile { sketch: sid + 1, index: 0 }).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_cut_by_ordinary_geometry_does_not_identify_text() {
|
||||||
|
let (mut h, _, _) = setup();
|
||||||
|
h.app.run("sketch.line", json!({"points":[[-10,3],[10,3]]})).unwrap();
|
||||||
|
let sid = h.app.session.active_sketch.unwrap();
|
||||||
|
let count = h.app.session.world_state().sketch(sid).unwrap().profiles.len();
|
||||||
|
assert!(count >= 2);
|
||||||
|
for index in 0..count {
|
||||||
|
assert!(entity_for_selection(&h.app, &Sel::Profile { sketch: sid, index }).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn ok(h: &mut Harness) {
|
fn ok(h: &mut Harness) {
|
||||||
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
|
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
|
||||||
h.frames(2);
|
h.frames(2);
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ pub fn hint(id: &str) -> Option<String> {
|
|||||||
"sketch.spline.fit_point" => "Spline: click fit points; click the start to close smoothly, or right-click to finish",
|
"sketch.spline.fit_point" => "Spline: click fit points; click the start to close smoothly, or right-click to finish",
|
||||||
"sketch.spline.control_point" | "sketch.spline.control_point_5" => "Control point spline: click control points; right-click to finish",
|
"sketch.spline.control_point" | "sketch.spline.control_point_5" => "Control point spline: click control points; right-click to finish",
|
||||||
"sketch.text" => "Text: click where the text starts",
|
"sketch.text" => "Text: click where the text starts",
|
||||||
"sketch.edit_text" => "Edit Text: click an outline of the text to edit",
|
"sketch.edit_text" => "Edit Text: click a letter interior or outline",
|
||||||
"sketch.blend_curve" => "Blend curve: click the ends of two curves",
|
"sketch.blend_curve" => "Blend curve: click the ends of two curves",
|
||||||
"sketch.offset" => "Offset: click a curve (its chain is offset), then the side and distance",
|
"sketch.offset" => "Offset: click a curve (its chain is offset), then the side and distance",
|
||||||
"sketch.fillet" => "Fillet: click the corner of two lines",
|
"sketch.fillet" => "Fillet: click the corner of two lines",
|
||||||
@@ -737,11 +737,20 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some((id, _)) = sketch_entity(app, &hits) else { return };
|
|
||||||
if cmd == "sketch.edit_text" {
|
if cmd == "sketch.edit_text" {
|
||||||
crate::sketch_text::open(app, &id);
|
let selection = hits.first().and_then(|hit| match hit {
|
||||||
|
Hit::SketchCurve { sketch, id, .. } if Some(*sketch) == active_sketch(app) => {
|
||||||
|
Some(solidcraft_engine::Sel::SketchCurve { id: id.clone() })
|
||||||
|
}
|
||||||
|
Hit::Profile { sketch, index } => Some(solidcraft_engine::Sel::Profile { sketch: *sketch, index: *index }),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
if let Some(id) = selection.as_ref().and_then(|sel| crate::sketch_text::entity_for_selection(app, sel)) {
|
||||||
|
crate::sketch_text::open(app, &id);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let Some((id, _)) = sketch_entity(app, &hits) else { return };
|
||||||
if cmd == "sketch.centerline" {
|
if cmd == "sketch.centerline" {
|
||||||
let _ = app.run(cmd, json!({"curves": [id]}));
|
let _ = app.run(cmd, json!({"curves": [id]}));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ of a `.ttf` / `.otf` file, and the **Edit Text** dialog has a **Font** field for
|
|||||||
error, and the font is kept with the text in the design, so the text stays editable in it. `SOLIDCRAFT_FONT_DIRS`
|
error, and the font is kept with the text in the design, so the text stays editable in it. `SOLIDCRAFT_FONT_DIRS`
|
||||||
adds folders to the ones searched for installed fonts.
|
adds folders to the ones searched for installed fonts.
|
||||||
|
|
||||||
|
Right-click a letter's filled interior or outline and choose **Edit Text**. You can also
|
||||||
|
start **Edit Text** first and click the letter. All outlines belonging to that text are
|
||||||
|
highlighted while the dialog is open. A profile bounded by multiple text sources or other
|
||||||
|
geometry cannot identify one text: use **Select Other** to choose its outline instead.
|
||||||
|
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.
|
||||||
|
|
||||||
**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,
|
||||||
|
|||||||
Reference in new issue
Block a user