Align MText properties and column layout

This commit is contained in:
ramox81 2026-08-26 11:48:22 +03:00
commit 96ca741f35
6 changed files with 214 additions and 34 deletions

View file

@ -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,
});

View file

@ -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<GlyphBox>
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<GripDef> {
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<PropSection> {
.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);
}
_ => {}
}

View file

@ -195,6 +195,11 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<Rende
},
),
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: false,
want_glyph_boxes: false,
}))
@ -412,6 +417,7 @@ fn text_box_geom(ml: &MultiLeader) -> ([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,

View file

@ -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,
});

View file

@ -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<f32>,
}
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<ParaLineSpacing>,
}
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,
})

View file

@ -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");
}