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
|
||||
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
|
||||
|
||||
@@ -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] }));
|
||||
|
||||
@@ -726,6 +726,7 @@ impl SolveApp {
|
||||
if let Some(d) = &self.dialog {
|
||||
v.extend(d.items());
|
||||
}
|
||||
v.extend(sketch_text::highlighted(self));
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user