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 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
+4 -1
View File
@@ -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] }));
+1
View File
@@ -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
} }
+102 -2
View File
@@ -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);
+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.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;
+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` 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,