From 96ca741f3587339d1880ef2699ce36e2aea871ec Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:22 +0300 Subject: [PATCH] Align MText properties and column layout --- src/entities/attribute.rs | 3 + src/entities/mtext.rs | 140 +++++++++++++++++++++++++++------- src/entities/multileader.rs | 16 ++++ src/entities/table.rs | 6 ++ src/entities/text_support.rs | 79 +++++++++++++++++-- src/scene/cache/properties.rs | 4 +- 6 files changed, 214 insertions(+), 34 deletions(-) diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index dd5cc5394..47991b80f 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -200,6 +200,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument oblique_angle, is_backward: width_factor < 0.0, is_upside_down: false, + is_vertical: false, }; let layout = layout_mtext(&MTextRenderOpts { // Not an MTEXT: text in a fixed box, never columnar. @@ -213,6 +214,8 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 5c6a46eb9..3dcee115c 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -28,6 +28,9 @@ fn columns_of(t: &MText) -> MTextColumns { count: c.column_count.max(0) as usize, width: c.width as f32, gutter: c.gutter as f32, + flow_reversed: c.flow_reversed, + auto_height: c.auto_height, + heights: c.heights.iter().map(|height| *height as f32).collect(), } } @@ -120,7 +123,14 @@ pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec attach_h_anchor, v_anchor, line_spacing_factor: t.line_spacing_factor as f32, - vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + exact_line_spacing: matches!( + t.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32, + vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom) + || matches!(t.drawing_direction, DrawingDirection::ByStyle) + && resolved_style.is_vertical, want_glyph_boxes: true, columns: columns_of(t), }); @@ -169,7 +179,14 @@ fn to_render(t: &MText, document: &acadrust::CadDocument) -> RenderEntity { attach_h_anchor, v_anchor, line_spacing_factor: t.line_spacing_factor as f32, - vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + exact_line_spacing: matches!( + t.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32, + vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom) + || matches!(t.drawing_direction, DrawingDirection::ByStyle) + && resolved_style.is_vertical, want_glyph_boxes: false, columns: columns_of(t), }); @@ -231,8 +248,43 @@ fn grips(t: &MText) -> Vec { t.insertion_point.z, ); 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)] + let columns_active = t.column_data.column_type != 0 + && t.column_data.column_count > 1 + && t.column_data.width > 0.0; + let block_width = if columns_active { + t.column_data.width * t.column_data.column_count as f64 + + t.column_data.gutter * (t.column_data.column_count - 1) as f64 + } else { + t.rectangle_width.max(0.0) + }; + let width_grip = p + dir * (k * block_width); + let mut result = vec![square_grip(0, p), triangle_grip(1, width_grip)]; + + let height = t + .rectangle_height + .or_else(|| t.column_data.heights.first().copied()) + .unwrap_or(0.0); + if height > 0.0 && !(columns_active && t.column_data.auto_height) { + let (sin, cos) = t.rotation.sin_cos(); + let down = glam::DVec3::new(sin, -cos, 0.0); + let (_, vertical) = attach_anchors(t); + let factor = match vertical { + MTextVAnchor::Top + | MTextVAnchor::MiddleOfTopLine + | MTextVAnchor::BottomOfTopLine => 1.0, + MTextVAnchor::Middle => 0.5, + MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0, + }; + result.push(triangle_grip(2, p + down * (factor * height))); + } + if columns_active { + result.push(triangle_grip(3, p + dir * (k * t.column_data.width))); + result.push(triangle_grip( + 4, + p + dir * (k * (t.column_data.width + t.column_data.gutter)), + )); + } + result } fn columns_str(c: &acadrust::entities::MTextColumnData) -> &'static str { @@ -371,20 +423,6 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { .collect(), }, }, - // Count / width / gutter are live only when columns are on. - num_row( - t!("Column count").as_ref(), - "col_count", - t.column_data.column_count as f64, - col_type != 0, - ), - num_row(t!("Column width").as_ref(), "col_width", t.column_data.width, col_type != 0), - num_row( - t!("Column gutter").as_ref(), - "col_gutter", - t.column_data.gutter, - col_type != 0, - ), Property { label: t!("Text frame").into_owned(), field: "text_frame", @@ -502,21 +540,21 @@ fn apply_geom_prop(t: &mut MText, field: &str, value: &str) { "ins_y" => t.insertion_point.y = v, "ins_z" => t.insertion_point.z = v, "height" if v > 0.0 => t.height = v, - "rect_w" if v > 0.0 => t.rectangle_width = v, - "rect_h" if v > 0.0 => t.rectangle_height = Some(v), + "rect_w" if v >= 0.0 => t.rectangle_width = v, + "rect_h" if v >= 0.0 => t.rectangle_height = (v > 0.0).then_some(v), "rotation" => t.rotation = v.to_radians(), - "line_spacing" if v > 0.0 => t.line_spacing_factor = v, + "line_spacing" if (0.25..=4.0).contains(&v) => t.line_spacing_factor = v, // Editing the absolute distance back-solves the line-spacing factor so // the two stay consistent (distance = height × 5/3 × factor). "line_space_distance" if v > 0.0 => { let denom = t.height * 1.666_666_666_666_667; if denom > 0.0 { - t.line_spacing_factor = v / denom; + let factor = v / denom; + if (0.25..=4.0).contains(&factor) { + t.line_spacing_factor = factor; + } } } - "col_count" if v >= 1.0 => t.column_data.column_count = v.round() as i32, - "col_width" if v > 0.0 => t.column_data.width = v, - "col_gutter" if v >= 0.0 => t.column_data.gutter = v, _ => {} } } @@ -541,7 +579,57 @@ fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) { 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 / k).max(0.01); + let width = (projected / k).max(0.0); + if t.column_data.column_type != 0 && t.column_data.column_count > 1 { + let gaps = (t.column_data.column_count - 1) as f64; + t.column_data.width = + ((width - t.column_data.gutter * gaps) / t.column_data.column_count as f64) + .max(0.01); + } else { + t.rectangle_width = width; + } + } + (2, GripApply::Absolute(p)) => { + let (sin, cos) = t.rotation.sin_cos(); + let down = glam::DVec3::new(sin, -cos, 0.0); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + p.z as f64 - t.insertion_point.z, + ); + let (_, vertical) = attach_anchors(t); + let factor = match vertical { + MTextVAnchor::Top + | MTextVAnchor::MiddleOfTopLine + | MTextVAnchor::BottomOfTopLine => 1.0, + MTextVAnchor::Middle => 0.5, + MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0, + }; + let height = (delta.dot(down) / factor).max(0.01); + t.rectangle_height = Some(height); + if t.column_data.column_type != 0 && !t.column_data.auto_height { + t.column_data.heights = + vec![height; t.column_data.column_count.max(1) as usize]; + } + } + (3, GripApply::Absolute(p)) => { + let (dir, k) = width_grip_axis(t); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + 0.0, + ); + t.column_data.width = (delta.dot(dir) / k).max(0.01); + } + (4, GripApply::Absolute(p)) => { + let (dir, k) = width_grip_axis(t); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + 0.0, + ); + t.column_data.gutter = + (delta.dot(dir) / k - t.column_data.width).max(0.0); } _ => {} } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index ed985a38b..4084a7805 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -195,6 +195,11 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option ([f64; 2], [f64; 3]) { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, }; let layout = layout_mtext(&MTextRenderOpts { columns: Default::default(), @@ -424,6 +430,11 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: ctx.line_spacing_factor as f32, + exact_line_spacing: matches!( + ctx.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: 0.0, vertical_text: vertical, want_glyph_boxes: false, }); @@ -2039,6 +2050,11 @@ impl MultiLeaderTess for MultiLeader { attach_h_anchor: h_anchor, v_anchor, line_spacing_factor: ctx.line_spacing_factor as f32, + exact_line_spacing: matches!( + ctx.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: 0.0, // Vertical flow (top-to-bottom) is stored per-context. vertical_text: matches!( ctx.text_flow_direction, diff --git a/src/entities/table.rs b/src/entities/table.rs index 6660c8c66..0a75045d0 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -1187,6 +1187,7 @@ impl RenderConvertible for Table { oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } }; @@ -1287,6 +1288,8 @@ impl RenderConvertible for Table { attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -1453,6 +1456,7 @@ pub fn tessellate_table( oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } }; @@ -1920,6 +1924,8 @@ pub fn tessellate_table( attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 0ddc32aa7..0ccd98927 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -11,6 +11,7 @@ pub struct ResolvedTextStyle { pub oblique_angle: f32, pub is_backward: bool, pub is_upside_down: bool, + pub is_vertical: bool, } pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle { @@ -76,6 +77,7 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } } @@ -1012,6 +1014,11 @@ pub struct MTextRenderOpts<'a> { pub v_anchor: MTextVAnchor, /// DXF code 44 — multiplier on the default 5/3-em baseline gap. pub line_spacing_factor: f32, + /// `true` fixes every baseline advance to the entity spacing. `false` + /// treats it as a minimum and expands for taller inline runs. + pub exact_line_spacing: bool, + /// User-defined text-box/column height. Zero means content-driven. + pub rectangle_height: 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 @@ -1025,10 +1032,9 @@ pub struct MTextRenderOpts<'a> { /// An MTEXT's column layout, flattened from its `column_data`. /// -/// Content moves to the next column at a `\N` break. Filling a column and -/// spilling into the next on its own is not modelled: `heights` / `auto_height` -/// are not read, so a column runs as long as its content does. -#[derive(Clone, Copy, Debug, Default)] +/// Content moves to the next column at an explicit `\N` break or when the +/// configured manual/automatic column height is exhausted. +#[derive(Clone, Debug, Default)] pub struct MTextColumns { /// Column count. 0 or 1 both mean "no column layout". pub count: usize, @@ -1036,6 +1042,12 @@ pub struct MTextColumns { pub width: f32, /// Gap between two adjacent columns, world units. pub gutter: f32, + /// Whether logical column flow starts at the opposite side. + pub flow_reversed: bool, + /// Dynamic-column height is balanced from content when true. + pub auto_height: bool, + /// Optional manual height for each logical column. + pub heights: Vec, } impl MTextColumns { @@ -1046,7 +1058,12 @@ impl MTextColumns { /// Left edge of column `i`, relative to the text block's own left edge. pub fn offset_of(&self, i: usize) -> f32 { - i as f32 * (self.width + self.gutter) + let physical = if self.flow_reversed { + self.count.saturating_sub(1).saturating_sub(i) + } else { + i + }; + physical as f32 * (self.width + self.gutter) } } @@ -1128,7 +1145,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { line_spacing: Option, } - let cols = opts.columns; + let cols = opts.columns.clone(); // A `\N` past the last column has nowhere to go — keep those lines in the // last one rather than laying them out beyond the block. let last_col = cols.count.saturating_sub(1); @@ -1366,15 +1383,55 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { }; // A paragraph's own `\psm#`/`\pse#` overrides the entity factor for // its lines; `Exact` fixes the baseline gap outright. + let entity_gap = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing(); match s.line_spacing { Some(ParaLineSpacing::Exact(e)) if e > 0.0 => e, Some(ParaLineSpacing::Multiple(m)) if m > 0.0 => { mh * m * (5.0 / 3.0) * base_font.line_spacing() } - _ => mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing(), + _ if opts.exact_line_spacing => entity_gap, + _ => (mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing()) + .max(entity_gap), } }) .collect(); + + // Flow lines into the next column when the active column's configured + // height is exhausted. Explicit `\N` breaks remain authoritative. Dynamic + // auto-height balances the content; static/manual columns use their stored + // height (falling back to the entity rectangle height). + if cols.active() { + let total_advance: f32 = per_line_h.iter().sum(); + let balanced = (total_advance / cols.count.max(1) as f32).max(entity_h); + let mut current_column = 0usize; + let mut used = 0.0_f32; + for (index, line) in sub_lines.iter_mut().enumerate() { + if line.column > current_column { + current_column = line.column.min(cols.count - 1); + used = 0.0; + } + let limit = if cols.auto_height { + balanced + } else { + cols.heights + .get(current_column) + .copied() + .filter(|height| *height > 0.0) + .unwrap_or(opts.rectangle_height) + }; + let advance = per_line_h.get(index).copied().unwrap_or(entity_h); + if limit > 0.0 + && used > 0.0 + && used + advance > limit + && current_column + 1 < cols.count + { + current_column += 1; + used = 0.0; + } + line.column = current_column; + used += advance; + } + } // Per-line text height — the tallest Word on the line, an empty line // inheriting the previous line's height (same rule as `per_line_h`). Unlike // `line_max_h` it has no `entity_h` floor, so it reflects the ACTUAL text @@ -2169,6 +2226,7 @@ mod tests { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, } } @@ -2199,6 +2257,8 @@ mod tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -2222,6 +2282,8 @@ mod tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -2378,6 +2440,7 @@ mod v_anchor_tests { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, }; layout_mtext(&MTextRenderOpts { columns: Default::default(), @@ -2390,6 +2453,8 @@ mod v_anchor_tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: true, }) diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index fe199e881..d60bbfd6a 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -94,7 +94,9 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) { + if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) + || matches!(entity, EntityType::MText(_)) + { section.props.retain(|prop| prop.field != "handle"); }