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 <[email protected]>
This commit is contained in:
1 parent
a07af32714
commit
32136dd9dd
9 files changed
+443
-31
No files matched your search
@@ -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