From 6e225e068cdf22e03cbc2813b007dcff64757591 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 23 Jul 2026 02:58:02 +0300 Subject: [PATCH] feat(text): real top-to-bottom layout; shared flow-aware width grips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vertical (top-to-bottom) text used to reuse the horizontal line stack — every line landed on the same baseline and overlapped. The layout now stacks each character in its own 1.4h cell down the column, one column per paragraph (1.2h apart), with word-boundary column wrap against the defined width (which measures COLUMN LENGTH for vertical flow — the horizontal pre-wrap is disabled there). Justification anchors place the column block from wrap-accurate metrics: the pre-pass simulates the render's word wrap exactly, so right/centre/bottom-attached blocks stay put as columns grow. Layouts also report pre-rotation local_bounds so frames and fills can box what was actually drawn. Width grips route through one shared helper, flow_grip_axis(): the flow axis (rotated baseline, or down the column) plus an attachment-side factor — a right-attached text forms LEFT of its insertion, so its grip sits there too; centre attachments use ±half. MTEXT's grip and drag projection both use it, covering all 9 attachments × both flows × any rotation from a single table. Co-Authored-By: Claude Fable 5 --- src/entities/mtext.rs | 50 ++++++- src/entities/text_support.rs | 252 +++++++++++++++++++++++++++++++++-- 2 files changed, 287 insertions(+), 15 deletions(-) diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index ec65fa9f6..ef0219b82 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -187,14 +187,50 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity { } } +/// The MTEXT's attachment anchors in the shared layout vocabulary. +fn attach_anchors(t: &MText) -> (f32, MTextVAnchor) { + let h: 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 = 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, + }; + (h, v) +} + +/// Flow axis + attachment-side factor, from the shared text-grip helper. +fn width_grip_axis(t: &MText) -> (glam::DVec3, f64) { + let (h, v) = attach_anchors(t); + crate::entities::text_support::flow_grip_axis( + t.rotation, + matches!(t.drawing_direction, DrawingDirection::TopToBottom), + h, + v, + ) +} + fn grips(t: &MText) -> Vec { let p = glam::DVec3::new( t.insertion_point.x, t.insertion_point.y, t.insertion_point.z, ); - let dir = glam::DVec3::new(t.rotation.cos(), t.rotation.sin(), 0.0); - let width_grip = p + dir * t.rectangle_width.max(0.0); + let (dir, k) = width_grip_axis(t); + let width_grip = p + dir * (k * t.rectangle_width.max(0.0)); vec![square_grip(0, p), triangle_grip(1, width_grip)] } @@ -491,12 +527,14 @@ fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) { t.insertion_point.z += d.z as f64; } (1, GripApply::Absolute(p)) => { - let dir_x = t.rotation.cos(); - let dir_y = t.rotation.sin(); + // Project onto the flow axis the grip is drawn on, undoing the + // attachment-side factor so dragging the grip on either side of + // the insertion widens the same box. + let (dir, k) = width_grip_axis(t); let dx = p.x as f64 - t.insertion_point.x; let dy = p.y as f64 - t.insertion_point.y; - let projected = dx * dir_x + dy * dir_y; - t.rectangle_width = projected.max(0.01); + let projected = dx * dir.x + dy * dir.y; + t.rectangle_width = (projected / k).max(0.01); } _ => {} } diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 257b43322..10d3ae3b2 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -922,6 +922,49 @@ pub enum MTextVAnchor { BottomOfTopLine, } +/// Flow axis + signed attachment-side factor for a text box's width/wrap +/// grip. ONE place for every text-bearing entity (MTEXT, MLEADER, …): +/// +/// - `dir`: the flow axis — the rotated baseline for horizontal text, DOWN +/// the column for top-to-bottom text. +/// - `k`: which side of the insertion the box actually forms on, from the +/// attachment that governs the flow axis: start-attached `+1`, centre +/// `±half`, end-attached `-1` (a right-attached horizontal text forms LEFT +/// of its insertion; a bottom-anchored vertical text forms UP). +/// +/// Grip position = `insertion + dir · (k · width)`; a drag re-derives +/// `width = proj(dir) / k`, so pulling the grip on either side widens the +/// same box. +pub fn flow_grip_axis( + rotation: f64, + vertical: bool, + h_anchor: f32, + v_anchor: MTextVAnchor, +) -> (glam::DVec3, f64) { + let (c, s) = (rotation.cos(), rotation.sin()); + let dir = if vertical { + glam::DVec3::new(s, -c, 0.0) + } else { + glam::DVec3::new(c, s, 0.0) + }; + let k = if vertical { + match v_anchor { + MTextVAnchor::Top + | MTextVAnchor::MiddleOfTopLine + | MTextVAnchor::BottomOfTopLine => 1.0, + MTextVAnchor::Middle => 0.5, + MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0, + } + } else if h_anchor >= 0.75 { + -1.0 + } else if h_anchor >= 0.25 { + 0.5 + } else { + 1.0 + }; + (dir, k) +} + /// Render inputs for [`layout_mtext`]. The caller resolves the text style /// once and feeds the entity's geometry; the helper handles the entire /// parse → wrap → render pipeline and returns both the rendered strokes and @@ -1021,6 +1064,11 @@ pub struct MTextLayout { /// One world-space AABB per visible character — only populated when /// `MTextRenderOpts::want_glyph_boxes` is set. pub glyph_boxes: Vec, + /// Pre-rotation glyph bounds in entity-local coordinates relative to the + /// insertion point: [min_x, min_y, max_x, max_y]. Inverted (min > max) + /// when the layout produced no glyphs. Valid for horizontal AND vertical + /// flow, so frame/background boxes can wrap what was actually drawn. + pub local_bounds: [f32; 4], } /// Lay out and render an MText-formatted value, returning the stroke @@ -1156,8 +1204,16 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { // Wrap to the column the text actually flows down, not to the block: // measuring against the full width would let a line run across the - // gutter and over its neighbour. - let wrap_w = if cols.active() { cols.width } else { rect_w }; + // gutter and over its neighbour. Vertical flow never pre-wraps + // horizontally — there rect_w is the COLUMN length, applied by the + // render's own down-the-column word wrap. + let wrap_w = if opts.vertical_text { + 0.0 + } else if cols.active() { + cols.width + } else { + rect_w + }; let wrapped = wrap_paragraph( atoms, wrap_w, @@ -1386,6 +1442,74 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { // ── 4. Render each sub-line ────────────────────────────────────────── let mut all_strokes: Vec = Vec::new(); + let mut local_bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + // Vertical-flow block metrics: each sub-line is a COLUMN (1.2h step), each + // atom cell 1.4h tall. The justification anchors place this block the way + // the horizontal path places its line stack. + // With a defined width (the flow grip, measuring DOWN the column for + // vertical text), a column wraps: characters past the limit spill into a + // fresh column to the right. + let v_cell = entity_h * 1.4; + let v_col_limit: usize = if opts.vertical_text && rect_w > 0.0 { + ((rect_w / v_cell).floor() as usize).max(1) + } else { + usize::MAX + }; + let (v_block_w, v_block_h) = if opts.vertical_text { + // Mirror the render's WORD-boundary column wrap exactly (a word that + // would overrun moves whole to the next column; an over-long word + // overflows its own column; a space at a column start/break is + // consumed) — a char-count estimate here under-counts columns and + // over/under-sizes the block, which drags every non-start-anchored + // text away from its attachment. + let sim_line = |sub: &SubLine| -> (usize, usize) { + let mut cols = 1usize; + let mut cur = 0usize; + let mut tallest = 0usize; + for a in &sub.atoms { + match &a.kind { + AtomKind::Word(t) => { + let w = t.chars().count(); + if v_col_limit != usize::MAX && cur > 0 && cur + w > v_col_limit { + cols += 1; + cur = 0; + } + cur += w; + tallest = tallest.max(cur); + } + _ => { + if cur == 0 { + continue; + } + if v_col_limit != usize::MAX && cur + 1 > v_col_limit { + cols += 1; + cur = 0; + } else { + cur += 1; + tallest = tallest.max(cur); + } + } + } + } + (cols, tallest.max(1)) + }; + let mut total_cols = 0usize; + let mut tallest = 0usize; + for sub in &sub_lines { + let (c, t) = sim_line(sub); + total_cols += c; + tallest = tallest.max(t); + } + ( + total_cols.max(1) as f32 * entity_h * 1.2, + tallest as f32 * v_cell, + ) + } else { + (0.0, 0.0) + }; + // Running column index across sub-lines (vertical flow): overflow columns + // shift every later paragraph's column right. + let mut v_col: usize = 0; let mut line_widths: Vec = Vec::with_capacity(sub_lines.len()); let mut glyph_boxes: Vec = Vec::new(); let mut vis: usize = 0; @@ -1408,16 +1532,32 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { } else { (box_left, rect_w) }; - let (line_base_x, line_base_y) = if opts.vertical_text { - let col_offset = li * entity_h * 1.2; + if opts.vertical_text && i > 0 { + // Each paragraph starts a fresh column after the previous + // paragraph's (possibly wrapped) columns. + v_col += 1; + } + let v_col_start = v_col; + let (line_lx, line_ly) = if opts.vertical_text { + // Anchor the column block: X by the attachment's horizontal + // anchor over the block width, Y so the block hangs below / + // straddles / sits above the insertion per the vertical anchor + // (columns run DOWN from their line_ly). + let top_y = match opts.v_anchor { + MTextVAnchor::Top | MTextVAnchor::MiddleOfTopLine => 0.0, + MTextVAnchor::BottomOfTopLine => entity_h * 1.4, + MTextVAnchor::Middle => v_block_h * 0.5, + MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => v_block_h, + }; ( - col_offset * cos_r + v_offset * (-sin_r), - col_offset * sin_r + v_offset * cos_r, + -attach_h_anchor * v_block_w + v_col as f32 * entity_h * 1.2, + top_y, ) } else { - let ly = line_y_offset[i] + v_offset; - (ly * (-sin_r), ly * cos_r) + (0.0, line_y_offset[i] + v_offset) }; + let line_base_x = line_lx * cos_r - line_ly * sin_r; + let line_base_y = line_lx * sin_r + line_ly * cos_r; let content_left = if rect_w > 0.0 { box_left @@ -1530,7 +1670,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { } }; - let mut cursor_x = cursor_start; + let mut cursor_x = if opts.vertical_text { 0.0 } else { cursor_start }; for (ai, atom) in sub.atoms.iter().enumerate() { match &atom.kind { AtomKind::Word(text) => { @@ -1539,6 +1679,76 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { base_wf.signum() * atom.state.width_mul * base_wf.abs(); let oblique = base_oblique + atom.state.oblique_rad; let font_name = resolve_font(&atom.state, &base_font_name); + if opts.vertical_text { + // Vertical (top-to-bottom) text: every character sits + // in its own cell stacked DOWN the column — the line + // cursor runs down, not across. Each char becomes its + // own single-glyph run so the SDF path draws it at its + // cell origin; 1.4×height is the classic vertical cell + // advance. + let color = + atom.state.color.as_ref().and_then(resolve_inline_color); + let cell = run_h * 1.4; + // Word wrap, vertically: a word that would overrun the + // column limit moves WHOLE to the next column (the + // vertical analogue of space-boundary wrapping). A word + // longer than the limit overflows its own column, like + // a long word overflows a narrow horizontal box. + let word_len = text.chars().count() as f32 * cell; + if v_col_limit != usize::MAX + && cursor_x > 0.0 + && cursor_x + word_len > v_col_limit as f32 * v_cell + 1e-3 + { + v_col += 1; + cursor_x = 0.0; + } + for ch in text.chars() { + let col_dx = (v_col - v_col_start) as f32 * entity_h * 1.2; + let s = ch.to_string(); + let (strokes, fill_tris) = lff::tessellate_text_run( + [0.0, 0.0], + run_h, + rot, + signed_wf, + oblique, + atom.state.tracking, + &font_name, + &s, + ); + let ly = -(cursor_x + cell); + let world_dx = col_dx * cos_r - ly * sin_r; + let world_dy = col_dx * sin_r + ly * cos_r; + let origin: [f64; 2] = [ + ins_x + (line_base_x + world_dx) as f64, + ins_y + (line_base_y + world_dy) as f64, + ]; + all_strokes.push(TextStroke { + strokes, + origin, + color, + fill_tris, + run: Some(GlyphRun { + text: s, + font: font_name.to_string(), + height: run_h, + rotation: rot, + width_factor: signed_wf, + oblique, + tracking: atom.state.tracking, + bold: atom.state.bold, + }), + }); + let gx = line_lx + col_dx; + let (gy0, gy1) = (line_ly + ly, line_ly + ly + run_h); + local_bounds[0] = local_bounds[0].min(gx); + local_bounds[1] = local_bounds[1].min(gy0); + local_bounds[2] = local_bounds[2].max(gx + run_h); + local_bounds[3] = local_bounds[3].max(gy1); + vis += 1; + cursor_x += cell; + } + continue; + } // Distribute widens letter tracking so each glyph advances an // extra `gap`; `tracking` scales the font's letter_spacing, so // invert that to land the exact world gap. A zero-spacing font @@ -1655,6 +1865,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { } // Advance by the same tracking the glyphs drew with, so a // distributed word's letters and the atoms after it stay put. + let word_x0 = lx; if tracking != atom.state.tracking { let mut st = atom.state.clone(); st.tracking = tracking; @@ -1663,6 +1874,10 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { cursor_x += measure_word(text, &atom.state, entity_h, base_wf, &base_font_name); } + local_bounds[0] = local_bounds[0].min(word_x0); + local_bounds[1] = local_bounds[1].min(line_ly + ly); + local_bounds[2] = local_bounds[2].max(cursor_x); + local_bounds[3] = local_bounds[3].max(line_ly + ly + run_h); } AtomKind::Stack { numerator, @@ -1756,6 +1971,24 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { cursor_x += slot_w; } AtomKind::Space => { + if opts.vertical_text { + // One empty cell down the column; a space that lands + // on a column break is consumed by it (no leading + // blank cell at the top of a column). + let cell = atom.state.height_mul * entity_h * 1.4; + if cursor_x <= 1e-6 { + continue; + } + if v_col_limit != usize::MAX + && cursor_x + cell > v_col_limit as f32 * v_cell + 1e-3 + { + v_col += 1; + cursor_x = 0.0; + } else { + cursor_x += cell; + } + continue; + } // Justify / distribute widen every word gap by `gap`. let adv = measure_space(&atom.state, entity_h, base_wf, &base_font_name) + gap; @@ -1914,6 +2147,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { line_height: line_h, v_offset, glyph_boxes, + local_bounds, } }