feat(modify): TXTEXP turns text into its character outlines

TXTEXP replaces each selected TEXT and MTEXT with one polyline per
glyph contour, on the text's layer and with its properties: a
lightweight polyline for flat text, a 3D polyline for tilted text.
The outlines come from the same glyph tessellation the text renders
with, so stroke fonts give their strokes and outline fonts their
closed contours.

Refs D#1665

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-13 15:27:28 +03:00
1 parent 57eb7fe4db
commit dd45ac4039
28 files changed
+147 -1

No files matched your search

+32
View File
@@ -1303,6 +1303,38 @@ impl OpenCADStudio {
}
}
// TXTEXP — text becomes the polylines of its character outlines.
"TXTEXP" => {
let selected: Vec<_> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle))
.collect();
if selected.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let cmd = SelectObjectsCommand::new("TXTEXP");
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
} else {
use crate::modules::draw::modify::explode::apply_explode_replacements;
use crate::modules::draw::modify::txtexp::plan_text_explode;
let replacements = plan_text_explode(&selected, &self.tabs[i].scene.document);
let exploded = replacements.len();
if exploded > 0 {
self.push_undo_snapshot(i, "TXTEXP");
apply_explode_replacements(&mut self.tabs[i].scene, replacements);
self.tabs[i].dirty = true;
self.refresh_properties();
self.command_line
.push_output(crate::tf!("{exploded} object(s) exploded.").as_ref());
} else {
self.command_line
.push_info(crate::t!("TXTEXP: no text selected.").as_ref());
}
}
}
"OFFSET" => {
use crate::modules::draw::modify::offset::{is_offsettable, OffsetCommand};
let all_entities: Vec<_> = self.tabs[i]
+2
View File
@@ -277,6 +277,7 @@ fn command_category(name: &str) -> &'static str {
"BREAK",
"JOIN",
"EXPLODE",
"TXTEXP",
"STRETCH",
"ARRAY",
"PEDIT",
@@ -349,6 +350,7 @@ fn command_selection_policy(name: &str) -> &'static str {
"CUTCLIP",
"ERASE",
"EXPLODE",
"TXTEXP",
"GROUP",
"LAYFRZ",
"LAYLCK",
+1
View File
@@ -3697,6 +3697,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Width factor" => Some(("common", "width-factor-sentence-case")),
"Width: %{value}" => Some(("ui", "width-value")),
"Window" => Some(("common", "window")),
"TXTEXP: no text selected." => Some(("common", "txtexp-no-text-selected")),
"Windows desktop" => Some(("styles", "windows-desktop")),
"Wipeout" => Some(("common", "wipeout")),
"Wireframe" => Some(("common", "wireframe")),
+1
View File
@@ -30,3 +30,4 @@ pub mod stretch;
pub mod torient;
pub mod translate;
pub mod trim;
pub mod txtexp;
+87
View File
@@ -0,0 +1,87 @@
// TXTEXP — replace text with the outlines of its characters.
//
// Each glyph contour of a TEXT or MTEXT becomes a polyline on the text's
// layer and with its properties: a lightweight polyline when the text lies
// flat, a 3D polyline when it is tilted.
use codec::entities::{LwPolyline, LwVertex, Polyline3D, Vertex3DPolyline};
use codec::types::Vector2;
use codec::{CadDocument, EntityType, Handle};
use crate::scene::convert::acad_to_render::{convert, RenderObject};
/// The outlines replacing each explodable text of `selected`, as
/// (text, polylines) pairs ready for `apply_explode_replacements`.
pub fn plan_text_explode(
selected: &[(Handle, &EntityType)],
document: &CadDocument,
) -> Vec<(Handle, Vec<EntityType>)> {
selected
.iter()
.filter_map(|(handle, entity)| {
let outlines = text_outlines(entity, document);
(!outlines.is_empty()).then_some((*handle, outlines))
})
.collect()
}
/// The character outlines of a TEXT or MTEXT; empty for anything else.
pub fn text_outlines(entity: &EntityType, document: &CadDocument) -> Vec<EntityType> {
let (common, elevation) = match entity {
EntityType::Text(text) => (&text.common, text.insertion_point.z),
EntityType::MText(text) => (&text.common, text.insertion_point.z),
_ => return Vec::new(),
};
let Some(RenderObject::Text(groups)) = convert(entity, document).map(|render| render.object)
else {
return Vec::new();
};
let mut common = common.clone();
common.handle = Handle::NULL;
let mut outlines = Vec::new();
for group in &groups {
for stroke in group.strokes.iter().filter(|stroke| stroke.len() >= 2) {
let mut points: Vec<[f64; 3]> = stroke
.iter()
.map(|&[x, y]| {
let (x, y) = (x as f64, y as f64);
match group.plane {
Some(plane) => std::array::from_fn(|axis| {
plane.origin[axis] + plane.x_axis[axis] * x + plane.y_axis[axis] * y
}),
None => [group.origin[0] + x, group.origin[1] + y, elevation],
}
})
.collect();
let closed = points.len() > 2 && {
let (first, last) = (points[0], points[points.len() - 1]);
(0..3).all(|axis| (first[axis] - last[axis]).abs() <= 1e-9)
};
if closed {
points.pop();
}
let z = points[0][2];
if points.iter().all(|point| (point[2] - z).abs() <= 1e-9) {
let mut polyline = LwPolyline::new();
polyline.common = common.clone();
polyline.is_closed = closed;
polyline.elevation = z;
polyline.vertices = points
.iter()
.map(|point| LwVertex::new(Vector2::new(point[0], point[1])))
.collect();
outlines.push(EntityType::LwPolyline(polyline));
} else {
let mut polyline = Polyline3D::new();
polyline.common = common.clone();
polyline.flags.closed = closed;
polyline.vertices = points
.iter()
.map(|point| Vertex3DPolyline::from_xyz(point[0], point[1], point[2]))
.collect();
outlines.push(EntityType::Polyline3D(polyline));
}
}
}
outlines
}
+2 -1
View File
@@ -360,6 +360,7 @@ inventory::submit!(crate::command::CommandRegistration {
"MOVE",
"ROTATE",
"SCALE",
"STRETCH"
"STRETCH",
"TXTEXP"
]
}); // SelectObjectsCommand