diff --git a/ROADMAP.md b/ROADMAP.md index 0c432c68..76521aa6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 472f7012..146d5623 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -646,6 +646,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { } 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] })); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a12174a5..3c447f63 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -726,6 +726,7 @@ impl SolveApp { if let Some(d) = &self.dialog { v.extend(d.items()); } + v.extend(sketch_text::highlighted(self)); v } diff --git a/crates/ui-egui/src/sketch_text.rs b/crates/ui-egui/src/sketch_text.rs index ec3b6b67..49fc0473 100644 --- a/crates/ui-egui/src/sketch_text.rs +++ b/crates/ui-egui/src/sketch_text.rs @@ -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 { + 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 { 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 { selected } +/// Show every outline affected by the open editor without changing feature selections. +pub(crate) fn highlighted(app: &SolveApp) -> Vec { + 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); diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 6284d348..2c22921d 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -413,7 +413,7 @@ pub fn hint(id: &str) -> Option { "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; diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index d386dbf9..e843f83a 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -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,