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:
selune authored and GitHub committed 2026-10-13 09:28:05 -04:00
1 parent d1a773a684
commit e10b0b7d22
6 files changed
+129 -7

No files matched your search

+3 -1
View File
@@ -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
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.
- 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;
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
+4 -1
View File
@@ -646,6 +646,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
}
let mut v = Vec::new();
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.rectangle.two_point", "Rectangle"));
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()
&& let Some(h) = app.viewport.hover.clone()
&& 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)
{
let _ = app.run("select.set", json!({ "items": [sel] }));
+1
View File
@@ -726,6 +726,7 @@ impl SolveApp {
if let Some(d) = &self.dialog {
v.extend(d.items());
}
v.extend(sketch_text::highlighted(self));
v
}
+102 -2
View File
@@ -34,14 +34,36 @@ pub struct Tx {
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.
pub(crate) fn selected_entity(app: &SolveApp) -> Option<String> {
let sk = app.session.doc.sketch(app.session.active_sketch?).ok()?;
let mut selected = None;
let mut link = None;
for selection in &app.session.selection {
let Sel::SketchCurve { id } = selection else { return None };
let curve = sk.curves.iter().find(|curve| &curve.id == id)?;
let id = entity_for_selection(app, selection)?;
let curve = sk.curves.iter().find(|curve| curve.id == id)?;
let source = sk.link(curve.link.as_deref()?)?;
if source.kind != LinkKind::Text || link.is_some_and(|old| old != source.id.as_str()) {
return None;
@@ -52,6 +74,13 @@ pub(crate) fn selected_entity(app: &SolveApp) -> Option<String> {
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.
fn shown(value: f64, unit: &str) -> String {
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) {
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
h.frames(2);
+12 -3
View File
@@ -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.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.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.offset" => "Offset: click a curve (its chain is offset), then the side and distance",
"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;
}
let Some((id, _)) = sketch_entity(app, &hits) else { return };
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;
}
let Some((id, _)) = sketch_entity(app, &hits) else { return };
if cmd == "sketch.centerline" {
let _ = app.run(cmd, json!({"curves": [id]}));
return;
+7
View File
@@ -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`
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
follows later changes. Sketching on a face projects its edges automatically.
For Project, select visible source curves or points from another sketch, model edges,