forked from 1b-agent/opencadstudio-upstream
feat(mtext): character-level text selection on the preview
The rendered MText preview is now interactive: drag across glyphs to select a character range and apply toolbar formatting (bold, italic, underline, overline, strike, case, alignment) to exactly those characters. The text layout gains an opt-in pass that emits one world-space box per visible character carrying its reading-order index; the editor maps those boxes into the preview frame, hit-tests pointer drags against them, and highlights the selection. Formatting splices the matching inline codes into the raw value at the selected byte range (mapped via a code-aware visible- character scanner). With no preview selection the Edit-box path is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
a07af32714
commit
32136dd9dd
9 files changed
+443
-31
No files matched your search
@@ -740,6 +740,10 @@ pub enum Message {
|
||||
/// Switch the editor body between raw code input (`false`) and the
|
||||
/// rendered preview (`true`).
|
||||
MTextShowPreview(bool),
|
||||
/// Begin a preview text selection at the given visible-character offset.
|
||||
MTextSelStart(usize),
|
||||
/// Extend the preview selection to the given visible-character offset.
|
||||
MTextSelTo(usize),
|
||||
/// Commit the editor: create or update the MText entity.
|
||||
MTextOk,
|
||||
/// Discard the editor without creating / changing the entity.
|
||||
|
||||
+180
-22
@@ -114,6 +114,13 @@ pub struct MTextEditorState {
|
||||
/// When true the panel shows the rendered preview; when false the raw
|
||||
/// code/text input. Toggled so the two never stack.
|
||||
pub show_preview: bool,
|
||||
/// Per-visible-character boxes (world XY, world_offset already removed) for
|
||||
/// click-to-select in the preview, and the current selection as a visible-
|
||||
/// character range `[start, end)`.
|
||||
pub glyph_boxes: Vec<crate::entities::text_support::GlyphBox>,
|
||||
pub sel: Option<(usize, usize)>,
|
||||
/// Anchor offset for an in-progress drag selection.
|
||||
pub sel_anchor: usize,
|
||||
/// Canvas-space anchor where the toolbar + text area are drawn (the
|
||||
/// insertion-point click position).
|
||||
pub screen_anchor: iced::Point,
|
||||
@@ -136,6 +143,9 @@ impl MTextEditorState {
|
||||
char_space: "0".to_string(),
|
||||
preview_wires: Vec::new(),
|
||||
show_preview: true,
|
||||
glyph_boxes: Vec::new(),
|
||||
sel: None,
|
||||
sel_anchor: 0,
|
||||
attachment: AttachmentPoint::TopLeft,
|
||||
line_spacing: 1.0,
|
||||
// Default box ~20 characters wide; overwritten with the entity's
|
||||
@@ -191,9 +201,6 @@ impl MTextEditorState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_entity(&self) -> EntityType {
|
||||
EntityType::MText(self.build_mtext())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a numeric field, returning `Some(v)` only when it differs from the
|
||||
@@ -207,6 +214,97 @@ fn parse_non_default(s: &str, default: f64) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map each visible character of a raw MText value to its byte span
|
||||
/// `(start, end)` in that raw string, in the same reading order the layout
|
||||
/// counts glyph boxes (paragraphs split on `\P`/`\n`/`\N`, leading/trailing
|
||||
/// spaces trimmed per paragraph, inline codes skipped). Lets a preview
|
||||
/// selection (visible-char range) be spliced back into the raw value.
|
||||
pub fn visible_spans(raw: &str) -> Vec<(usize, usize)> {
|
||||
let is_sp = |c: char| c == ' ' || c == '\u{00A0}';
|
||||
let mut result: Vec<(usize, usize)> = Vec::new();
|
||||
let mut para: Vec<(usize, usize, char)> = Vec::new();
|
||||
let mut flush = |para: &mut Vec<(usize, usize, char)>, result: &mut Vec<(usize, usize)>| {
|
||||
let s = para.iter().position(|t| !is_sp(t.2)).unwrap_or(para.len());
|
||||
let e = para.iter().rposition(|t| !is_sp(t.2)).map(|i| i + 1).unwrap_or(s);
|
||||
for t in ¶[s..e] {
|
||||
result.push((t.0, t.1));
|
||||
}
|
||||
para.clear();
|
||||
};
|
||||
let mut it = raw.char_indices().peekable();
|
||||
while let Some((i, ch)) = it.next() {
|
||||
match ch {
|
||||
'\\' => match it.peek().map(|&(_, c)| c) {
|
||||
Some('P') | Some('n') | Some('N') => {
|
||||
it.next();
|
||||
flush(&mut para, &mut result);
|
||||
}
|
||||
Some('~') => {
|
||||
let (j, c) = it.next().unwrap();
|
||||
para.push((i, j + c.len_utf8(), '\u{00A0}'));
|
||||
}
|
||||
Some('\\') | Some('{') | Some('}') => {
|
||||
let (j, c) = it.next().unwrap();
|
||||
para.push((i, j + c.len_utf8(), c));
|
||||
}
|
||||
Some(c) if "LlOoKk".contains(c) => {
|
||||
it.next(); // value-less toggle, no visible glyph
|
||||
}
|
||||
Some(_) => {
|
||||
// Value code (\f… \C… \H… \pxq… \U… etc) — skip to ';'.
|
||||
it.next();
|
||||
while let Some(&(_, c)) = it.peek() {
|
||||
it.next();
|
||||
if c == ';' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
'{' | '}' => { /* group markers — not visible */ }
|
||||
'%' if it.peek().map(|&(_, c)| c) == Some('%') => {
|
||||
it.next(); // second '%'
|
||||
match it.peek().copied() {
|
||||
Some((k, '%')) => {
|
||||
it.next();
|
||||
para.push((i, k + 1, '%'));
|
||||
}
|
||||
Some((_, d)) if d.is_ascii_digit() => {
|
||||
let mut last = i;
|
||||
let mut n = 0;
|
||||
while n < 3 {
|
||||
match it.peek().copied() {
|
||||
Some((m, c)) if c.is_ascii_digit() => {
|
||||
last = m;
|
||||
it.next();
|
||||
n += 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
para.push((i, last + 1, '\u{25A1}'));
|
||||
}
|
||||
Some((m, c)) => {
|
||||
it.next();
|
||||
let g = match c {
|
||||
'd' | 'D' => '°',
|
||||
'c' | 'C' => 'Ø',
|
||||
'p' | 'P' => '±',
|
||||
other => other,
|
||||
};
|
||||
para.push((i, m + c.len_utf8(), g));
|
||||
}
|
||||
None => para.push((i, i + 1, '%')),
|
||||
}
|
||||
}
|
||||
_ => para.push((i, i + ch.len_utf8(), ch)),
|
||||
}
|
||||
}
|
||||
flush(&mut para, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Prefix/suffix inline codes for a character-level toggle. `Uppercase` /
|
||||
/// `Lowercase` return `None` (they transform the selected text instead).
|
||||
pub fn fmt_wrap(kind: MTextFmt) -> Option<(&'static str, &'static str)> {
|
||||
@@ -266,7 +364,8 @@ impl super::OpenCADStudio {
|
||||
pub(super) fn rebuild_mtext_preview(&mut self) {
|
||||
let i = self.active_tab;
|
||||
let Some(ed) = self.mtext_editor.as_ref() else { return };
|
||||
let entity = ed.build_entity();
|
||||
let mt = ed.build_mtext();
|
||||
let entity = EntityType::MText(mt.clone());
|
||||
let woff = self.tabs[i].scene.world_offset;
|
||||
let anno = self.tabs[i].scene.annotation_scale;
|
||||
let wires: Vec<WireModel> = tessellate::tessellate(
|
||||
@@ -281,35 +380,94 @@ impl super::OpenCADStudio {
|
||||
woff,
|
||||
anno,
|
||||
);
|
||||
// Per-character boxes in the same offset-subtracted frame as the
|
||||
// preview wires (tessellate removes world_offset; mirror that).
|
||||
let mut boxes = crate::entities::mtext::glyph_boxes(&mt, &self.tabs[i].scene.document);
|
||||
let (ox, oy) = (woff[0] as f32, woff[1] as f32);
|
||||
for b in &mut boxes {
|
||||
b.xmin -= ox;
|
||||
b.xmax -= ox;
|
||||
b.ymin -= oy;
|
||||
b.ymax -= oy;
|
||||
}
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.preview_wires = wires;
|
||||
ed.glyph_boxes = boxes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a character-format toggle to the current selection (or insert at
|
||||
/// the cursor when there is no selection).
|
||||
/// Splice text around the preview selection (visible-char range) in the
|
||||
/// raw value. `case` optionally transforms the selected slice. Returns
|
||||
/// true when a preview selection was present and applied.
|
||||
fn mtext_splice_sel(&mut self, prefix: &str, suffix: &str, case: Option<bool>) -> bool {
|
||||
let Some(ed) = self.mtext_editor.as_mut() else { return false };
|
||||
let Some((a, b)) = ed.sel else { return false };
|
||||
if a >= b {
|
||||
return false;
|
||||
}
|
||||
let raw = ed.content.text();
|
||||
let raw = raw.strip_suffix('\n').unwrap_or(&raw).to_string();
|
||||
let spans = visible_spans(&raw);
|
||||
if a >= spans.len() || b > spans.len() {
|
||||
return false;
|
||||
}
|
||||
let start = spans[a].0;
|
||||
let end = spans[b - 1].1;
|
||||
let mut s = raw;
|
||||
if let Some(upper) = case {
|
||||
let slice = &s[start..end];
|
||||
let repl = if upper { slice.to_uppercase() } else { slice.to_lowercase() };
|
||||
s.replace_range(start..end, &repl);
|
||||
// Length may change; recompute end for the suffix insert.
|
||||
let new_end = start + repl.len();
|
||||
s.insert_str(new_end, suffix);
|
||||
s.insert_str(start, prefix);
|
||||
} else {
|
||||
s.insert_str(end, suffix);
|
||||
s.insert_str(start, prefix);
|
||||
}
|
||||
ed.content = iced::widget::text_editor::Content::with_text(&s);
|
||||
ed.sel = None;
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply a character-format toggle to the preview selection (preferred) or
|
||||
/// the Edit-box selection.
|
||||
pub(super) fn mtext_apply_fmt(&mut self, kind: MTextFmt) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let sel = ed.content.selection();
|
||||
let text = match kind {
|
||||
MTextFmt::Uppercase => sel.as_deref().unwrap_or("").to_uppercase(),
|
||||
MTextFmt::Lowercase => sel.as_deref().unwrap_or("").to_lowercase(),
|
||||
_ => {
|
||||
let (pre, suf) = fmt_wrap(kind).unwrap();
|
||||
format!("{pre}{}{suf}", sel.as_deref().unwrap_or(""))
|
||||
}
|
||||
};
|
||||
ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text))));
|
||||
let applied = match kind {
|
||||
MTextFmt::Uppercase => self.mtext_splice_sel("", "", Some(true)),
|
||||
MTextFmt::Lowercase => self.mtext_splice_sel("", "", Some(false)),
|
||||
_ => {
|
||||
let (pre, suf) = fmt_wrap(kind).unwrap();
|
||||
self.mtext_splice_sel(pre, suf, None)
|
||||
}
|
||||
};
|
||||
if !applied {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let sel = ed.content.selection();
|
||||
let text = match kind {
|
||||
MTextFmt::Uppercase => sel.as_deref().unwrap_or("").to_uppercase(),
|
||||
MTextFmt::Lowercase => sel.as_deref().unwrap_or("").to_lowercase(),
|
||||
_ => {
|
||||
let (pre, suf) = fmt_wrap(kind).unwrap();
|
||||
format!("{pre}{}{suf}", sel.as_deref().unwrap_or(""))
|
||||
}
|
||||
};
|
||||
ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text))));
|
||||
}
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
}
|
||||
|
||||
/// Prefix the selection with a paragraph-alignment code.
|
||||
/// Prefix the selection (or cursor) with a paragraph-alignment code.
|
||||
pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let sel = ed.content.selection().unwrap_or_default();
|
||||
let text = format!("\\pxq{};{sel}", align.code());
|
||||
ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text))));
|
||||
let code = format!("\\pxq{};", align.code());
|
||||
if !self.mtext_splice_sel(&code, "", None) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let sel = ed.content.selection().unwrap_or_default();
|
||||
let text = format!("{code}{sel}");
|
||||
ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text))));
|
||||
}
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
}
|
||||
|
||||
@@ -3546,6 +3546,20 @@ impl OpenCADStudio {
|
||||
self.rebuild_mtext_preview();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextSelStart(off) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.sel_anchor = off;
|
||||
ed.sel = Some((off, off));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextSelTo(off) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let a = ed.sel_anchor;
|
||||
ed.sel = Some((a.min(off), a.max(off)));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextOk => {
|
||||
self.mtext_commit();
|
||||
Task::none()
|
||||
|
||||
+119
-6
@@ -1346,13 +1346,14 @@ const MTEXT_COLORS: [(&str, u16); 8] = [
|
||||
/// editor's own preview area (never on the drawing). Strokes lie in the
|
||||
/// world XY plane; the program fits + vertically flips them into the box.
|
||||
const MTEXT_PREVIEW_PAD: f32 = 12.0;
|
||||
/// Screen pixels per world unit of text height — fixes the preview to the
|
||||
/// real text size (so it scrolls rather than shrinking to fit).
|
||||
const MTEXT_PREVIEW_PX_PER_UNIT: f32 = 10.0;
|
||||
|
||||
struct MTextPreview {
|
||||
/// Disconnected polylines as (x, y) world points (NaN-split already done).
|
||||
segments: Vec<Vec<(f32, f32)>>,
|
||||
/// Per-visible-character boxes (world frame) for click-to-select.
|
||||
boxes: Vec<crate::entities::text_support::GlyphBox>,
|
||||
/// Current selection as a visible-char range.
|
||||
sel: Option<(usize, usize)>,
|
||||
/// World-space min corner (bbox) and pixels-per-world-unit scale.
|
||||
minx: f32,
|
||||
miny: f32,
|
||||
@@ -1360,11 +1361,93 @@ struct MTextPreview {
|
||||
content_h: f32,
|
||||
}
|
||||
|
||||
impl MTextPreview {
|
||||
/// Visible-char offset (0..=N) nearest the cursor point (bounds-local px).
|
||||
fn offset_at(&self, p: iced::Point) -> usize {
|
||||
if self.boxes.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let wx = self.minx + (p.x - MTEXT_PREVIEW_PAD) / self.scale;
|
||||
let wy = self.miny + (self.content_h - p.y - MTEXT_PREVIEW_PAD) / self.scale;
|
||||
let mut best = 0usize;
|
||||
let mut best_d = f32::MAX;
|
||||
for b in &self.boxes {
|
||||
let dx = if wx < b.xmin {
|
||||
b.xmin - wx
|
||||
} else if wx > b.xmax {
|
||||
wx - b.xmax
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let dy = if wy < b.ymin {
|
||||
b.ymin - wy
|
||||
} else if wy > b.ymax {
|
||||
wy - b.ymax
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let d = dy * 1000.0 + dx; // prefer the correct line first
|
||||
if d < best_d {
|
||||
best_d = d;
|
||||
best = b.vis;
|
||||
// After the glyph centre → caret sits after this char.
|
||||
if wx > (b.xmin + b.xmax) * 0.5 {
|
||||
best = b.vis + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MTextPreviewState {
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
impl iced::widget::canvas::Program<Message> for MTextPreview {
|
||||
type State = ();
|
||||
type State = MTextPreviewState;
|
||||
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut MTextPreviewState,
|
||||
event: &iced::Event,
|
||||
bounds: iced::Rectangle,
|
||||
cursor: iced::mouse::Cursor,
|
||||
) -> Option<iced::widget::canvas::Action<Message>> {
|
||||
use iced::mouse::{Button, Event as Me};
|
||||
use iced::widget::canvas::Action;
|
||||
use iced::Event;
|
||||
match event {
|
||||
Event::Mouse(Me::ButtonPressed(Button::Left)) => {
|
||||
if let Some(p) = cursor.position_in(bounds) {
|
||||
state.dragging = true;
|
||||
let off = self.offset_at(p);
|
||||
return Some(Action::publish(Message::MTextSelStart(off)).and_capture());
|
||||
}
|
||||
}
|
||||
Event::Mouse(Me::CursorMoved { .. }) => {
|
||||
if state.dragging {
|
||||
if let Some(p) = cursor.position_in(bounds) {
|
||||
let off = self.offset_at(p);
|
||||
return Some(Action::publish(Message::MTextSelTo(off)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Mouse(Me::ButtonReleased(Button::Left)) => {
|
||||
if state.dragging {
|
||||
state.dragging = false;
|
||||
return Some(Action::capture());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &(),
|
||||
_state: &MTextPreviewState,
|
||||
renderer: &iced::Renderer,
|
||||
_theme: &Theme,
|
||||
bounds: iced::Rectangle,
|
||||
@@ -1380,6 +1463,20 @@ impl iced::widget::canvas::Program<Message> for MTextPreview {
|
||||
self.content_h - (pad + (y - self.miny) * self.scale),
|
||||
)
|
||||
};
|
||||
// Selection highlight behind the glyphs.
|
||||
if let Some((a, b)) = self.sel {
|
||||
for bx in &self.boxes {
|
||||
if bx.vis >= a && bx.vis < b {
|
||||
let p0 = map(bx.xmin, bx.ymax);
|
||||
let p1 = map(bx.xmax, bx.ymin);
|
||||
let rect = Path::rectangle(
|
||||
iced::Point::new(p0.x.min(p1.x), p0.y.min(p1.y)),
|
||||
iced::Size::new((p1.x - p0.x).abs(), (p1.y - p0.y).abs()),
|
||||
);
|
||||
frame.fill(&rect, Color { r: 0.20, g: 0.42, b: 0.72, a: 0.45 });
|
||||
}
|
||||
}
|
||||
}
|
||||
for seg in &self.segments {
|
||||
if seg.len() < 2 {
|
||||
continue;
|
||||
@@ -1601,6 +1698,14 @@ fn mtext_editor_overlay<'a>(
|
||||
maxy = maxy.max(y);
|
||||
}
|
||||
}
|
||||
// Include glyph boxes so all-whitespace / box-only lines still anchor
|
||||
// the transform (hit-testing relies on minx/miny).
|
||||
for b in &ed.glyph_boxes {
|
||||
minx = minx.min(b.xmin);
|
||||
miny = miny.min(b.ymin);
|
||||
maxx = maxx.max(b.xmax);
|
||||
maxy = maxy.max(b.ymax);
|
||||
}
|
||||
let h_unit = ed.height_value() as f32;
|
||||
// Real text size: fixed pixels per em so more/taller text grows the
|
||||
// canvas (and scrolls) instead of shrinking to fit.
|
||||
@@ -1610,7 +1715,15 @@ fn mtext_editor_overlay<'a>(
|
||||
} else {
|
||||
40.0
|
||||
};
|
||||
let prog = MTextPreview { segments, minx, miny, scale, content_h };
|
||||
let prog = MTextPreview {
|
||||
segments,
|
||||
boxes: ed.glyph_boxes.clone(),
|
||||
sel: ed.sel,
|
||||
minx,
|
||||
miny,
|
||||
scale,
|
||||
content_h,
|
||||
};
|
||||
let cv = canvas(prog).width(Fill).height(iced::Length::Fixed(content_h));
|
||||
container(iced::widget::scrollable(cv).height(iced::Length::Fixed(VIEW_H)))
|
||||
.style(move |_: &Theme| container::Style {
|
||||
|
||||
@@ -208,6 +208,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
|
||||
v_anchor,
|
||||
line_spacing_factor: 1.0,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
let _ = input.line_count;
|
||||
let _ = input.is_multiline;
|
||||
|
||||
+48
-1
@@ -4,7 +4,7 @@ use glam::Vec3;
|
||||
use crate::command::EntityTransform;
|
||||
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip};
|
||||
use crate::entities::text_support::{
|
||||
layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor,
|
||||
layout_mtext, resolve_text_style, GlyphBox, MTextRenderOpts, MTextVAnchor,
|
||||
};
|
||||
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
|
||||
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
|
||||
@@ -74,6 +74,52 @@ fn drawing_dir_str(d: &DrawingDirection) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-visible-character world-space boxes for the MText editor's
|
||||
/// click-to-select preview. Uses the exact same layout opts as `to_truck`
|
||||
/// so the boxes line up with the rendered glyphs.
|
||||
pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec<GlyphBox> {
|
||||
let resolved_style = resolve_text_style(&t.style, document);
|
||||
let attach_h_anchor: f32 = match t.attachment_point {
|
||||
AttachmentPoint::TopCenter
|
||||
| AttachmentPoint::MiddleCenter
|
||||
| AttachmentPoint::BottomCenter => 0.5,
|
||||
AttachmentPoint::TopRight | AttachmentPoint::MiddleRight | AttachmentPoint::BottomRight => {
|
||||
1.0
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
let v_anchor = match t.attachment_point {
|
||||
AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => {
|
||||
MTextVAnchor::Top
|
||||
}
|
||||
AttachmentPoint::MiddleLeft
|
||||
| AttachmentPoint::MiddleCenter
|
||||
| AttachmentPoint::MiddleRight => MTextVAnchor::Middle,
|
||||
AttachmentPoint::BottomLeft
|
||||
| AttachmentPoint::BottomCenter
|
||||
| AttachmentPoint::BottomRight => MTextVAnchor::Bottom,
|
||||
};
|
||||
let rotation = if resolved_style.is_upside_down {
|
||||
t.rotation as f32 + std::f32::consts::PI
|
||||
} else {
|
||||
t.rotation as f32
|
||||
};
|
||||
let layout = layout_mtext(&MTextRenderOpts {
|
||||
value: &t.value,
|
||||
insertion: [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z],
|
||||
height: t.height as f32,
|
||||
rect_w: t.rectangle_width as f32,
|
||||
rotation,
|
||||
style: &resolved_style,
|
||||
attach_h_anchor,
|
||||
v_anchor,
|
||||
line_spacing_factor: t.line_spacing_factor as f32,
|
||||
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
|
||||
want_glyph_boxes: true,
|
||||
});
|
||||
layout.glyph_boxes
|
||||
}
|
||||
|
||||
fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
|
||||
let resolved_style = resolve_text_style(&t.style, document);
|
||||
let attach_h_anchor: f32 = match t.attachment_point {
|
||||
@@ -112,6 +158,7 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
|
||||
v_anchor,
|
||||
line_spacing_factor: t.line_spacing_factor as f32,
|
||||
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
let insertion = Vec3::new(
|
||||
t.insertion_point.x as f32,
|
||||
|
||||
@@ -257,6 +257,7 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
|
||||
v_anchor,
|
||||
line_spacing_factor: ctx.line_spacing_factor as f32,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
for ts in &layout.strokes {
|
||||
let ox = ts.origin[0];
|
||||
@@ -1312,6 +1313,7 @@ impl MultiLeaderTess for MultiLeader {
|
||||
v_anchor,
|
||||
line_spacing_factor: ctx.line_spacing_factor as f32,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
let line_widths = &layout.line_widths;
|
||||
let max_line_w = line_widths.iter().cloned().fold(0.0_f32, f32::max);
|
||||
|
||||
@@ -267,6 +267,7 @@ impl TruckConvertible for Table {
|
||||
v_anchor,
|
||||
line_spacing_factor: 1.0,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
// Flatten TextStroke groups into the table's Lines buffer.
|
||||
// Per-run inline `\C` / `\c` colour is dropped here because the
|
||||
|
||||
@@ -1067,6 +1067,22 @@ pub struct MTextRenderOpts<'a> {
|
||||
pub line_spacing_factor: f32,
|
||||
/// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2).
|
||||
pub vertical_text: bool,
|
||||
/// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with
|
||||
/// one world-space box per visible character (used by the MText editor's
|
||||
/// click-to-select preview). Off in the hot render path.
|
||||
pub want_glyph_boxes: bool,
|
||||
}
|
||||
|
||||
/// One selectable character in the laid-out text: its world-space AABB plus
|
||||
/// the running index of visible characters (in reading order) so the editor
|
||||
/// can map a clicked box back to an offset in the value.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GlyphBox {
|
||||
pub vis: usize,
|
||||
pub xmin: f32,
|
||||
pub xmax: f32,
|
||||
pub ymin: f32,
|
||||
pub ymax: f32,
|
||||
}
|
||||
|
||||
/// Output of [`layout_mtext`]: stroke groups + the geometry the caller
|
||||
@@ -1088,6 +1104,9 @@ pub struct MTextLayout {
|
||||
/// Y of the first sub-line's baseline relative to the insertion point
|
||||
/// (in the entity-local, pre-rotation frame).
|
||||
pub v_offset: f32,
|
||||
/// One world-space AABB per visible character — only populated when
|
||||
/// `MTextRenderOpts::want_glyph_boxes` is set.
|
||||
pub glyph_boxes: Vec<GlyphBox>,
|
||||
}
|
||||
|
||||
/// Lay out and render an MText-formatted value, returning the stroke
|
||||
@@ -1232,6 +1251,18 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
||||
// ── 4. Render each sub-line ──────────────────────────────────────────
|
||||
let mut all_strokes: Vec<TextStroke> = Vec::new();
|
||||
let mut line_widths: Vec<f32> = Vec::with_capacity(sub_lines.len());
|
||||
let mut glyph_boxes: Vec<GlyphBox> = Vec::new();
|
||||
let mut vis: usize = 0;
|
||||
// Transform an entity-local point to world space (mirrors the stroke
|
||||
// origin maths) so glyph boxes line up with the drawn glyphs.
|
||||
let to_world = |line_base_x: f32, line_base_y: f32, lx: f32, ly: f32| -> (f32, f32) {
|
||||
let wdx = lx * cos_r - ly * sin_r;
|
||||
let wdy = lx * sin_r + ly * cos_r;
|
||||
(
|
||||
ins_x as f32 + line_base_x + wdx,
|
||||
ins_y as f32 + line_base_y + wdy,
|
||||
)
|
||||
};
|
||||
for (i, sub) in sub_lines.iter().enumerate() {
|
||||
let li = i as f32;
|
||||
let (line_base_x, line_base_y) = if opts.vertical_text {
|
||||
@@ -1337,12 +1368,52 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
||||
origin,
|
||||
color,
|
||||
});
|
||||
if opts.want_glyph_boxes {
|
||||
// Per-character boxes, advancing exactly as
|
||||
// `measure_word` does so they track the glyphs.
|
||||
let scale = run_scale(&atom.state, entity_h, base_wf);
|
||||
let font = cxf::get_font(font_name);
|
||||
let mut cx = cursor_x;
|
||||
for ch in text.chars() {
|
||||
let adv = match font.glyph(ch) {
|
||||
Some(g) => {
|
||||
(g.advance + font.letter_spacing * tracking) * scale
|
||||
}
|
||||
None => (6.0 + font.letter_spacing * tracking) * scale,
|
||||
};
|
||||
let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly);
|
||||
let (bx, by) = to_world(line_base_x, line_base_y, cx + adv, ly + run_h);
|
||||
glyph_boxes.push(GlyphBox {
|
||||
vis,
|
||||
xmin: ax.min(bx),
|
||||
xmax: ax.max(bx),
|
||||
ymin: ay.min(by),
|
||||
ymax: ay.max(by),
|
||||
});
|
||||
vis += 1;
|
||||
cx += adv;
|
||||
}
|
||||
}
|
||||
cursor_x +=
|
||||
measure_word(text, &atom.state, entity_h, base_wf, &base_font_name);
|
||||
}
|
||||
AtomKind::Space => {
|
||||
cursor_x +=
|
||||
measure_space(&atom.state, entity_h, base_wf, &base_font_name);
|
||||
let adv = measure_space(&atom.state, entity_h, base_wf, &base_font_name);
|
||||
if opts.want_glyph_boxes {
|
||||
let run_h = atom.state.height_mul * entity_h;
|
||||
let (ax, ay) = to_world(line_base_x, line_base_y, cursor_x, 0.0);
|
||||
let (bx, by) =
|
||||
to_world(line_base_x, line_base_y, cursor_x + adv, run_h);
|
||||
glyph_boxes.push(GlyphBox {
|
||||
vis,
|
||||
xmin: ax.min(bx),
|
||||
xmax: ax.max(bx),
|
||||
ymin: ay.min(by),
|
||||
ymax: ay.max(by),
|
||||
});
|
||||
vis += 1;
|
||||
}
|
||||
cursor_x += adv;
|
||||
}
|
||||
AtomKind::Tab => {
|
||||
cursor_x = next_tab_position(
|
||||
@@ -1362,6 +1433,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
||||
line_count: sub_lines.len(),
|
||||
line_height: line_h,
|
||||
v_offset,
|
||||
glyph_boxes,
|
||||
}
|
||||
}
|
||||
pub(crate) fn text_obb_corners_native(
|
||||
|
||||
Reference in new issue
Block a user