From 4be01774e17f49933e7d8f48e11952b1c0abd4fd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:21 +0300 Subject: [PATCH 1/6] Complete MText boundary creation flow --- src/app/command_driver.rs | 5 +- src/app/commands/draw.rs | 8 +- src/app/text_inline.rs | 2 +- src/command.rs | 5 + src/modules/annotate/mtext.rs | 334 ++++++++++++++++++++++++++++++++-- 5 files changed, 330 insertions(+), 24 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bd57247d..ea345814 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3716,10 +3716,11 @@ impl OpenCADStudio { handle, initial, height, + template, } => { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - self.open_mtext_editor(pos, handle, &initial, height); + self.open_mtext_editor(pos, handle, &initial, height, template.map(|m| *m)); } CmdResult::SuspendForMTextInput { pos, @@ -3732,7 +3733,7 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); self.command_mtext_input = true; self.pending_command_editor_text = None; - self.open_mtext_editor(pos, None, &initial, height); + self.open_mtext_editor(pos, None, &initial, height, None); } CmdResult::OpenTextEditor { pos, diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 065f2e62..1fab3abf 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1307,7 +1307,13 @@ impl OpenCADStudio { &self.tabs[i].scene.document, ) .height; - let new_cmd = MTextCommand::with_height(height); + let style = self.tabs[i] + .scene + .document + .header + .current_text_style_name + .clone(); + let new_cmd = MTextCommand::with_defaults(height, style); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index c1f8b495..5a110088 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -166,7 +166,7 @@ impl super::OpenCADStudio { }; if field.is_rich() { - self.open_mtext_editor(pos, Some(target), &value, height); + self.open_mtext_editor(pos, Some(target), &value, height, None); self.unfocus_widgets() } else { self.open_text_inline(pos, Some(target), &value, height, field); diff --git a/src/command.rs b/src/command.rs index 46cad343..50d17df8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1427,6 +1427,11 @@ pub enum CmdResult { handle: Option, initial: String, height: f64, + /// Optional entity defaults collected by the interactive MTEXT + /// command (boundary, rotation, attachment, spacing and columns). + /// Existing-entity edits leave this as `None` and load the document + /// entity instead. + template: Option>, }, /// Collect rich text without creating an MText entity. SuspendForMTextInput { diff --git a/src/modules/annotate/mtext.rs b/src/modules/annotate/mtext.rs index a6a6d6d6..5a1f838a 100644 --- a/src/modules/annotate/mtext.rs +++ b/src/modules/annotate/mtext.rs @@ -1,7 +1,11 @@ -use crate::command::{CadCommand, CmdResult}; +use acadrust::entities::mtext::AttachmentPoint; +use acadrust::types::Vector3; +use acadrust::MText; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use glam::DVec3; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/mtext.svg")); @@ -15,20 +19,136 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Step { - InsertPoint, + FirstCorner, + OppositeCorner, + Height, + Justify, + LineSpacing, + Rotation, + Style, + Width, + ColumnMode, + ColumnCount, + ColumnWidth, + ColumnGutter, } pub struct MTextCommand { step: Step, + first: Option, + plane: WorkingPlane, height: f64, + style: String, + attachment: AttachmentPoint, + line_spacing: f64, + rotation: f64, + width: Option, + column_type: i16, + column_count: i32, + column_width: f64, + column_gutter: f64, } impl MTextCommand { - pub fn with_height(height: f64) -> Self { + pub fn with_defaults(height: f64, style: String) -> Self { Self { - step: Step::InsertPoint, - height, + step: Step::FirstCorner, + first: None, + plane: WorkingPlane::default(), + height: height.max(1e-6), + style, + attachment: AttachmentPoint::TopLeft, + line_spacing: 1.0, + rotation: 0.0, + width: None, + column_type: 0, + column_count: 2, + column_width: 0.0, + column_gutter: 0.0, + } + } + + fn resume_point_step(&mut self) { + self.step = if self.first.is_some() { + Step::OppositeCorner + } else { + Step::FirstCorner + }; + } + + fn point_options() -> Vec { + vec![ + CmdOption::new("Height", "HEIGHT"), + CmdOption::new("Justify", "JUSTIFY"), + CmdOption::new("Line spacing", "LINESPACING"), + CmdOption::new("Rotation", "ROTATION"), + CmdOption::new("Style", "STYLE"), + CmdOption::new("Width", "WIDTH"), + CmdOption::new("Columns", "COLUMNS"), + ] + } + + fn begin_option(&mut self, keyword: &str) -> bool { + self.step = match keyword { + "H" | "HEIGHT" => Step::Height, + "J" | "JUSTIFY" => Step::Justify, + "L" | "LINESPACING" | "LINE SPACING" => Step::LineSpacing, + "R" | "ROTATION" => Step::Rotation, + "S" | "STYLE" => Step::Style, + "W" | "WIDTH" => Step::Width, + "C" | "COLUMNS" => Step::ColumnMode, + _ => return false, + }; + true + } + + fn boundary_size(&self, opposite: DVec3) -> (f64, f64) { + let first = self.first.unwrap_or(opposite); + let local = self.plane.vector_to_local(opposite - first); + let (sin, cos) = self.rotation.sin_cos(); + let horizontal = local.x * cos + local.y * sin; + let vertical = -local.x * sin + local.y * cos; + (self.width.unwrap_or(horizontal.abs()), vertical.abs()) + } + + fn open_editor(&self, opposite: DVec3) -> CmdResult { + let first = self.first.unwrap_or(opposite); + let (width, boundary_height) = self.boundary_size(opposite); + let mut template = MText::default(); + template.insertion_point = Vector3::new(first.x, first.y, first.z); + template.normal = Vector3::new(self.plane.z.x, self.plane.z.y, self.plane.z.z); + template.height = self.height; + template.rectangle_width = width.max(0.0); + template.rectangle_height = (boundary_height > 1e-9).then_some(boundary_height); + template.rotation = self.rotation; + template.style = self.style.clone(); + template.attachment_point = self.attachment; + template.line_spacing_factor = self.line_spacing; + template.column_data.column_type = self.column_type; + if self.column_type != 0 { + template.column_data.column_count = self.column_count.max(1); + template.column_data.auto_height = self.column_type == 2; + template.column_data.width = if self.column_width > 0.0 { + self.column_width + } else if width > 0.0 { + width + } else { + self.height * 10.0 + }; + template.column_data.gutter = if self.column_gutter > 0.0 { + self.column_gutter + } else { + self.height + }; + } + CmdResult::OpenMTextEditor { + pos: first, + handle: None, + initial: String::new(), + height: self.height, + template: Some(Box::new(template)), } } } @@ -38,34 +158,208 @@ impl CadCommand for MTextCommand { "MTEXT" } + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn prompt(&self) -> String { - match &self.step { - Step::InsertPoint => t!("MTEXT Specify insertion point:").into_owned(), + match self.step { + Step::FirstCorner => t!("MTEXT Specify first corner:").into_owned(), + Step::OppositeCorner => t!("MTEXT Specify opposite corner:").into_owned(), + Step::Height => crate::tf!("MTEXT Specify text height <{}>:", self.height).into_owned(), + Step::Justify => t!("MTEXT Enter justification [TL / TC / TR / ML / MC / MR / BL / BC / BR] :").into_owned(), + Step::LineSpacing => crate::tf!("MTEXT Enter line spacing factor (0.25-4.00) <{}>:", self.line_spacing).into_owned(), + Step::Rotation => crate::tf!("MTEXT Specify rotation angle <{}>:", self.rotation.to_degrees()).into_owned(), + Step::Style => crate::tf!("MTEXT Enter text style <{}>:", self.style).into_owned(), + Step::Width => crate::tf!("MTEXT Specify boundary width <{}>:", self.width.unwrap_or(0.0)).into_owned(), + Step::ColumnMode => t!("MTEXT Columns [None / Static / Dynamic] :").into_owned(), + Step::ColumnCount => crate::tf!("MTEXT Enter column count <{}>:", self.column_count).into_owned(), + Step::ColumnWidth => crate::tf!("MTEXT Enter column width <{}>:", self.column_width).into_owned(), + Step::ColumnGutter => crate::tf!("MTEXT Enter column gutter <{}>:", self.column_gutter).into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - // Hand off to the in-place editor (toolbar + text area + live preview). - CmdResult::OpenMTextEditor { - pos: pt, - handle: None, - initial: String::new(), - height: self.height, + fn options(&self) -> Vec { + match self.step { + Step::FirstCorner | Step::OppositeCorner => Self::point_options(), + Step::Justify => ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"] + .into_iter() + .map(|value| CmdOption::new(value, value)) + .collect(), + Step::ColumnMode => vec![ + CmdOption::new("None", "NONE"), + CmdOption::new("Static", "STATIC"), + CmdOption::new("Dynamic", "DYNAMIC"), + ], + _ => Vec::new(), + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::FirstCorner | Step::OppositeCorner) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn wants_text_with_spaces(&self) -> bool { + matches!(self.step, Step::Style) + } + + fn dyn_field(&self) -> DynField { + match self.step { + Step::Rotation => DynField::Angle, + Step::Height + | Step::LineSpacing + | Step::Width + | Step::ColumnCount + | Step::ColumnWidth + | Step::ColumnGutter => DynField::Scalar, + _ => DynField::Point, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let trimmed = text.trim(); + let upper = trimmed.to_ascii_uppercase(); + if matches!(self.step, Step::FirstCorner | Step::OppositeCorner) + && self.begin_option(&upper) + { + return Some(CmdResult::NeedPoint); + } + + match self.step { + Step::Height => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value <= 0.0 { + return None; + } + self.height = value; + self.resume_point_step(); + } + Step::Justify => { + self.attachment = match upper.as_str() { + "TL" => AttachmentPoint::TopLeft, + "TC" => AttachmentPoint::TopCenter, + "TR" => AttachmentPoint::TopRight, + "ML" => AttachmentPoint::MiddleLeft, + "MC" => AttachmentPoint::MiddleCenter, + "MR" => AttachmentPoint::MiddleRight, + "BL" => AttachmentPoint::BottomLeft, + "BC" => AttachmentPoint::BottomCenter, + "BR" => AttachmentPoint::BottomRight, + _ => return None, + }; + self.resume_point_step(); + } + Step::LineSpacing => { + let value = trimmed.replace(',', ".").parse::().ok()?; + if !(0.25..=4.0).contains(&value) { + return None; + } + self.line_spacing = value; + self.resume_point_step(); + } + Step::Rotation => { + self.rotation = crate::entities::common::parse_typed_direction(trimmed)?; + self.resume_point_step(); + } + Step::Style => { + if trimmed.is_empty() { + return None; + } + self.style = trimmed.to_string(); + self.resume_point_step(); + } + Step::Width => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value < 0.0 { + return None; + } + self.width = Some(value); + self.resume_point_step(); + } + Step::ColumnMode => { + self.column_type = match upper.as_str() { + "N" | "NONE" => 0, + "S" | "STATIC" => 1, + "D" | "DYNAMIC" => 2, + _ => return None, + }; + if self.column_type == 0 { + self.resume_point_step(); + } else { + self.step = Step::ColumnCount; + } + } + Step::ColumnCount => { + let value = trimmed.parse::().ok()?; + if value < 1 { + return None; + } + self.column_count = value; + self.step = Step::ColumnWidth; + } + Step::ColumnWidth => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value <= 0.0 { + return None; + } + self.column_width = value; + self.step = Step::ColumnGutter; + } + Step::ColumnGutter => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value < 0.0 { + return None; + } + self.column_gutter = value; + self.resume_point_step(); + } + Step::FirstCorner | Step::OppositeCorner => return None, + } + Some(CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::FirstCorner => { + self.first = Some(point); + self.step = Step::OppositeCorner; + CmdResult::NeedPoint + } + Step::OppositeCorner => self.open_editor(point), + _ => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Height + | Step::Justify + | Step::LineSpacing + | Step::Rotation + | Step::Style + | Step::Width + | Step::ColumnMode + | Step::ColumnCount + | Step::ColumnWidth + | Step::ColumnGutter => { + self.resume_point_step(); + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + } } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } - fn on_mouse_move(&mut self, _pt: DVec3) -> Option { + fn on_mouse_move(&mut self, _point: DVec3) -> Option { None } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); // MTextCommand +inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); 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 2/6] 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 dd5cc539..47991b80 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 5c6a46eb..3dcee115 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 ed985a38..4084a780 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 6660c8c6..0a75045d 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 0ddc32aa..0ccd9892 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 fe199e88..d60bbfd6 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"); } From af4401ce675480239d3bdb353492c01772f35058 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:23 +0300 Subject: [PATCH 3/6] Complete MText editor controls and persistence --- src/app/mod.rs | 25 ++ src/app/mtext_editor.rs | 500 ++++++++++++++++++++++++++++++++++++++-- src/app/update/mod.rs | 121 +++++++++- src/app/view/overlay.rs | 165 ++++++++++++- 4 files changed, 775 insertions(+), 36 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 8324cf55..7223af75 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2655,6 +2655,31 @@ pub enum Message { MTextWidth(String), /// Toolbar character-spacing field changed. MTextCharSpace(String), + /// Undo / redo editor text and inline-format operations. + MTextUndo, + MTextRedo, + /// Convert the selected numerator/separator/denominator to a stacked run. + MTextStack, + /// Remove inline character formatting from the selection (or all text). + MTextClearFormatting, + /// Insert a predefined symbol or field token at the caret. + MTextInsert(String), + /// Per-object annotation flag edited from the text toolbar. + MTextAnnotative(bool), + /// Column layout controls. + MTextColumnMode(String), + MTextColumnCount(String), + MTextColumnWidth(String), + MTextColumnGutter(String), + MTextColumnHeight(String), + MTextColumnFlowReversed(bool), + /// Paragraph indent/spacing controls. + MTextParagraphNumber(mtext_editor::ParaNumber, String), + MTextFindText(String), + MTextReplaceText(String), + MTextFindNext, + MTextReplaceNext, + MTextReplaceAll, /// Toolbar colour picker (same widget as Properties) — applies to the /// selection, or the whole text when nothing is selected. MTextColorChanged(AcadColor), diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index f5dae78e..5d6c851c 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -8,7 +8,7 @@ use acadrust::entities::mtext::AttachmentPoint; use acadrust::entities::mtext_format::{ parse_mtext, MTextColor, MTextDocument, MTextFont, MTextParagraph, MTextParagraphAlignment, - MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, + MTextScalar, MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, }; use acadrust::types::Vector3; use acadrust::{EntityType, Handle, MText}; @@ -38,6 +38,15 @@ pub enum ParaAlign { Justify, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParaNumber { + FirstIndent, + LeftIndent, + RightIndent, + SpaceBefore, + SpaceAfter, +} + /// `pick_list`-friendly wrapper for the 9 attachment points. #[derive(Clone, Copy, PartialEq, Eq)] pub struct JustifyChoice(pub AttachmentPoint); @@ -107,6 +116,23 @@ pub struct MTextEditorState { /// it is NOT derived from the typed content, so adding characters wraps /// to the next line instead of stretching the box into one long line. pub rect_width: f64, + pub rect_height: String, + pub annotative: bool, + pub column_type: i16, + pub column_count: String, + pub column_auto_height: bool, + pub column_flow_reversed: bool, + pub column_width: String, + pub column_gutter: String, + pub paragraph_first_indent: String, + pub paragraph_left_indent: String, + pub paragraph_right_indent: String, + pub paragraph_space_before: String, + pub paragraph_space_after: String, + pub find_text: String, + pub replace_text: String, + undo: Vec, + redo: Vec, /// `Some` when editing an existing entity; `None` for a fresh MText. pub editing: Option, /// When true the panel shows the rendered preview; when false the raw @@ -167,6 +193,23 @@ impl MTextEditorState { // Default box ~20 characters wide; overwritten with the entity's // own width when editing an existing MText. rect_width: (height * 20.0).max(1.0), + rect_height: "0".to_string(), + annotative: false, + column_type: 0, + column_count: "2".to_string(), + column_auto_height: false, + column_flow_reversed: false, + column_width: (height * 10.0).to_string(), + column_gutter: (height * 5.0).to_string(), + paragraph_first_indent: "0".to_string(), + paragraph_left_indent: "0".to_string(), + paragraph_right_indent: "0".to_string(), + paragraph_space_before: "0".to_string(), + paragraph_space_after: "0".to_string(), + find_text: String::new(), + replace_text: String::new(), + undo: Vec::new(), + redo: Vec::new(), editing, screen_anchor: iced::Point::new(60.0, 90.0), original: None, @@ -274,8 +317,53 @@ impl MTextEditorState { mt.attachment_point = self.attachment; mt.line_spacing_factor = self.line_spacing as f64; mt.style = self.style.clone(); + mt.is_annotative = self.annotative; + let rectangle_height = self.rect_height.trim().parse::().unwrap_or(0.0); + mt.rectangle_height = (rectangle_height > 0.0).then_some(rectangle_height); + mt.column_data.column_type = self.column_type; + if self.column_type == 0 { + mt.column_data.column_count = 0; + mt.column_data.heights.clear(); + } else { + mt.column_data.column_count = self + .column_count + .trim() + .parse::() + .ok() + .filter(|count| *count > 0) + .unwrap_or(1); + mt.column_data.auto_height = self.column_auto_height; + mt.column_data.flow_reversed = self.column_flow_reversed; + if let Ok(width) = self.column_width.trim().parse::() { + if width > 0.0 { + mt.column_data.width = width; + } + } + if let Ok(gutter) = self.column_gutter.trim().parse::() { + if gutter >= 0.0 { + mt.column_data.gutter = gutter; + } + } + if !self.column_auto_height && rectangle_height > 0.0 { + mt.column_data.heights = vec![ + rectangle_height; + mt.column_data.column_count.max(1) as usize + ]; + } + } mt } + + fn record_undo(&mut self) { + let value = self.content.text(); + if self.undo.last().is_none_or(|last| last != &value) { + self.undo.push(value); + if self.undo.len() > 100 { + self.undo.remove(0); + } + } + self.redo.clear(); + } } /// Parse a numeric field, returning `Some(v)` only when it differs from the @@ -595,6 +683,24 @@ fn vertical_caret_target( best.1 } +fn seed_mtext_state(state: &mut MTextEditorState, m: &MText) { + state.attachment = m.attachment_point; + state.line_spacing = m.line_spacing_factor as f32; + state.rect_width = m.rectangle_width.max(0.0); + state.rect_height = m.rectangle_height.unwrap_or(0.0).max(0.0).to_string(); + state.annotative = m.is_annotative; + state.column_type = m.column_data.column_type; + state.column_count = m.column_data.column_count.max(1).to_string(); + state.column_auto_height = m.column_data.auto_height; + state.column_flow_reversed = m.column_data.flow_reversed; + state.column_width = m.column_data.width.max(0.0).to_string(); + state.column_gutter = m.column_data.gutter.max(0.0).to_string(); + if !m.style.trim().is_empty() { + state.style = m.style.clone(); + } + state.original = Some(m.clone()); +} + impl super::OpenCADStudio { /// Open the in-place editor for a new (`handle = None`) or existing MText. /// Open the rich MText editor for a new or existing MText / MultiLeader. @@ -605,6 +711,7 @@ impl super::OpenCADStudio { handle: Option, initial: &str, height: f64, + template: Option, ) { if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { return; @@ -614,20 +721,11 @@ impl super::OpenCADStudio { state.screen_anchor = p; } // Seed attachment / line-spacing / box width from the entity being edited. + let has_template = template.is_some(); if let Some(h) = handle { match self.tabs[self.active_tab].scene.document.get_entity(h) { Some(EntityType::MText(m)) => { - state.attachment = m.attachment_point; - state.line_spacing = m.line_spacing_factor as f32; - if !m.style.trim().is_empty() { - state.style = m.style.clone(); - } - if m.rectangle_width > 0.0 { - state.rect_width = m.rectangle_width; - } - // Keep the whole entity so `build_mtext` preserves the fields - // the toolbar can't edit (columns, rotation, background…). - state.original = Some(m.clone()); + seed_mtext_state(&mut state, m); } Some(EntityType::MultiLeader(ml)) => { state.line_spacing = ml.context.line_spacing_factor as f32; @@ -637,6 +735,8 @@ impl super::OpenCADStudio { } _ => {} } + } else if let Some(m) = template { + seed_mtext_state(&mut state, &m); } else { // New MText inherits the document's current text style (STYLE), // not the "Standard" default. See #92. @@ -659,7 +759,7 @@ impl super::OpenCADStudio { // The preview uses a fixed on-screen text size. Therefore the initial // wrap width is the drawing-unit span that exactly reaches the right // edge of the editor, placing both ruler and slider at their maximum. - if handle.is_none() { + if handle.is_none() && !has_template { let initial_width = self.mtext_editor.as_ref().map(|ed| { super::view::overlay::MTEXT_EDITOR_WRITING_WIDTH / ed.preview_scale() }); @@ -746,6 +846,318 @@ impl super::OpenCADStudio { /// 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. + pub(super) fn mtext_undo(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + if let Some(previous) = ed.undo.pop() { + ed.redo.push(ed.content.text()); + ed.content = text_editor::Content::with_text(&previous); + ed.caret = previous.chars().count(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_redo(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + if let Some(next) = ed.redo.pop() { + ed.undo.push(ed.content.text()); + ed.content = text_editor::Content::with_text(&next); + ed.caret = next.chars().count(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_apply_span_number(&mut self, field: &'static str, value: String) { + let Some(number) = value.trim().replace(',', ".").parse::().ok() else { + return; + }; + let valid = match field { + "height" => number > 0.0, + "oblique" => (-85.0..=85.0).contains(&number), + "width" => number > 0.0, + "tracking" => number.is_finite(), + _ => false, + }; + if !valid { + return; + } + let has_selection = self + .mtext_editor + .as_ref() + .and_then(|editor| editor.sel) + .is_some_and(|(start, end)| start < end); + if let Some(ed) = self.mtext_editor.as_mut() { + if has_selection { + let (start, end) = ed.sel.unwrap(); + ed.record_undo(); + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let end = end.min(cells.len()); + for cell in &mut cells[start.min(end)..end] { + if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { + match field { + "height" => props.height = Some(MTextScalar::Absolute(number)), + "oblique" => props.oblique_angle = (number != 0.0).then_some(number), + "width" => props.width_factor = (number != 1.0).then_some(number), + "tracking" => props.tracking = (number != 0.0).then_some(number), + _ => {} + } + } + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, end)); + ed.caret = end; + } else { + match field { + "height" => ed.height = value, + "oblique" => ed.oblique = value, + "width" => ed.width = value, + "tracking" => ed.char_space = value, + _ => {} + } + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_apply_paragraph_number(&mut self, field: ParaNumber, value: String) { + let Some(number) = value.trim().replace(',', ".").parse::().ok() else { + return; + }; + if !number.is_finite() { + return; + } + if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); + match field { + ParaNumber::FirstIndent => ed.paragraph_first_indent = value, + ParaNumber::LeftIndent => ed.paragraph_left_indent = value, + ParaNumber::RightIndent => ed.paragraph_right_indent = value, + ParaNumber::SpaceBefore => ed.paragraph_space_before = value, + ParaNumber::SpaceAfter => ed.paragraph_space_after = value, + } + let para0 = doc_para0(&ed.doc); + let cells = doc_to_cells(&ed.doc); + let (start, end) = ed + .sel + .filter(|(start, end)| start < end) + .unwrap_or((ed.caret, ed.caret)); + let paragraph_of = |index: usize| { + cells + .iter() + .take(index.min(cells.len())) + .filter(|cell| matches!(cell, Cell::Break(..))) + .count() + }; + let first = paragraph_of(start); + let last = paragraph_of(end.saturating_sub((end > start) as usize)); + let mut doc = cells_to_doc(¶0, &cells); + for paragraph in doc + .paragraphs + .iter_mut() + .skip(first) + .take(last.saturating_sub(first) + 1) + { + let optional = (number != 0.0).then_some(number); + match field { + ParaNumber::FirstIndent => paragraph.properties.first_line_indent = optional, + ParaNumber::LeftIndent => paragraph.properties.left_margin = optional, + ParaNumber::RightIndent => paragraph.properties.right_margin = optional, + ParaNumber::SpaceBefore => paragraph.properties.spacing_before = optional, + ParaNumber::SpaceAfter => paragraph.properties.spacing_after = optional, + } + } + ed.content = text_editor::Content::with_text(&doc.to_mtext_string()); + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_stack_selection(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let Some((start, end)) = ed.sel.filter(|(start, end)| start < end) else { + return; + }; + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let end = end.min(cells.len()); + let plain = cells_to_doc(&ParagraphProperties::default(), &cells[start..end]) + .to_plain_text(); + let Some((split, separator)) = plain + .char_indices() + .find(|(_, ch)| matches!(ch, '/' | '#' | '^')) + else { + return; + }; + let separator_len = separator.len_utf8(); + let data = StackingData { + numerator: plain[..split].to_string(), + denominator: plain[split + separator_len..].to_string(), + stacking_type: StackingType::from_char(separator), + }; + if data.numerator.is_empty() || data.denominator.is_empty() { + return; + } + let props = cells[start..end] + .iter() + .find_map(|cell| match cell { + Cell::Char(_, props) | Cell::Stack { props, .. } => Some(props.clone()), + Cell::Break(..) => None, + }) + .unwrap_or_default(); + ed.record_undo(); + let replacement = flatten_stack(&data) + .chars() + .enumerate() + .map(|(index, _)| Cell::Stack { + data: data.clone(), + props: props.clone(), + head: index == 0, + }) + .collect::>(); + let replacement_len = replacement.len(); + cells.splice(start..end, replacement); + let new_end = start + replacement_len; + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, new_end)); + ed.caret = new_end; + ed.sel_anchor = start; + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_clear_formatting(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let (start, end) = ed + .sel + .filter(|(start, end)| start < end) + .unwrap_or((0, cells.len())); + let end = end.min(cells.len()); + ed.record_undo(); + for cell in &mut cells[start.min(end)..end] { + if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { + *props = SpanProperties::default(); + } + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, end)); + ed.caret = end; + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_find_next(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let needle: Vec = ed.find_text.chars().collect(); + if needle.is_empty() { + return; + } + let cells = doc_to_cells(&ed.doc); + let chars: Vec> = cells + .iter() + .map(|cell| match cell { + Cell::Char(ch, _) => Some(*ch), + Cell::Break(..) => Some('\n'), + Cell::Stack { .. } => None, + }) + .collect(); + let start = ed.caret.min(chars.len()); + let found = (start..=chars.len().saturating_sub(needle.len())) + .chain(0..start.min(chars.len().saturating_sub(needle.len()) + 1)) + .find(|index| { + chars[*index..*index + needle.len()] + .iter() + .zip(&needle) + .all(|(actual, expected)| actual == &Some(*expected)) + }); + if let Some(found) = found { + ed.sel_anchor = found; + ed.sel = Some((found, found + needle.len())); + ed.caret = found + needle.len(); + ed.caret_blink_on = true; + } + } + } + + pub(super) fn mtext_replace_next(&mut self) { + let replacement = self + .mtext_editor + .as_ref() + .map(|editor| editor.replace_text.clone()) + .unwrap_or_default(); + let matches = self.mtext_editor.as_ref().is_some_and(|editor| { + let Some((start, end)) = editor.sel.filter(|(start, end)| start < end) else { + return false; + }; + let cells = doc_to_cells(&editor.doc); + cells_to_doc( + &ParagraphProperties::default(), + &cells[start.min(cells.len())..end.min(cells.len())], + ) + .to_plain_text() + == editor.find_text + }); + if matches { + self.mtext_type(&replacement); + } + self.mtext_find_next(); + } + + pub(super) fn mtext_replace_all(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let needle: Vec = ed.find_text.chars().collect(); + if needle.is_empty() { + return; + } + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let replacement = ed.replace_text.clone(); + let mut matches = Vec::new(); + let mut index = 0usize; + while index + needle.len() <= cells.len() { + let found = cells[index..index + needle.len()] + .iter() + .zip(&needle) + .all(|(cell, expected)| matches!(cell, Cell::Char(actual, _) if actual == expected)); + if found { + matches.push(index); + index += needle.len(); + } else { + index += 1; + } + } + if matches.is_empty() { + return; + } + ed.record_undo(); + for start in matches.into_iter().rev() { + let props = insert_props(&cells, start); + let paragraph = para_props_at(¶0, &cells, start); + let replacement_cells = str_to_cells(&replacement, &props, ¶graph); + cells.splice(start..start + needle.len(), replacement_cells); + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.caret = cells.len(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + self.rebuild_mtext_preview(); + } + /// Toggle a character format over the preview selection, on the structured /// span properties. The stroke-font renderer has no true bold, so Bold /// switches the run to the heavier "Gothic" face; Italic applies a 15° @@ -798,6 +1210,7 @@ impl super::OpenCADStudio { if a >= b { return; } + ed.record_undo(); match kind { MTextFmt::Underline => { let on = !all_have(&cells[a..b], |p| p.underline()); @@ -882,6 +1295,7 @@ impl super::OpenCADStudio { /// Set the alignment of every paragraph the selection (or caret) touches. pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let cells = doc_to_cells(&ed.doc); let (a, b) = ed @@ -933,6 +1347,7 @@ impl super::OpenCADStudio { s }; if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); // Edit the structured document as a flat cell list, then serialize // it back into the raw content the preview/commit still read. let para0 = doc_para0(&ed.doc); @@ -962,6 +1377,7 @@ impl super::OpenCADStudio { /// Delete the selection, or the visible character before the caret. pub(super) fn mtext_backspace(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); @@ -985,6 +1401,7 @@ impl super::OpenCADStudio { /// Delete the selection, or the visible character at the caret. pub(super) fn mtext_delete(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); @@ -1104,6 +1521,7 @@ impl super::OpenCADStudio { let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a < b { + ed.record_undo(); for c in &mut cells[a..b] { if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c { let (bold, italic) = p @@ -1155,6 +1573,7 @@ impl super::OpenCADStudio { let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a < b { + ed.record_undo(); for c in &mut cells[a..b] { if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c { p.color = mcolor.clone(); @@ -1217,7 +1636,8 @@ impl super::OpenCADStudio { } let body_empty = ed.content.text().trim().is_empty(); let mut mt = ed.build_mtext(); - let annotative = ed.editing.is_none() + let annotative = mt.is_annotative + || ed.editing.is_none() && crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &mt.style, @@ -1240,9 +1660,12 @@ impl super::OpenCADStudio { Some(EntityType::MText(t)) => { t.value = mt.value; t.height = mt.height; + t.style = mt.style; t.attachment_point = mt.attachment_point; t.line_spacing_factor = mt.line_spacing_factor; t.rectangle_width = mt.rectangle_width; + t.rectangle_height = mt.rectangle_height; + t.column_data = mt.column_data; } Some(EntityType::MultiLeader(ml)) => { ml.context.text_string = mt.value; @@ -1255,9 +1678,25 @@ impl super::OpenCADStudio { _ => {} } // Keep the displayed annotation context in sync. + if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + h, + annotative, + ); + if annotative { + if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + h, + scale, + ); + } + } + } if matches!( self.tabs[i].scene.document.get_entity(h), - Some(EntityType::MultiLeader(_)) + Some(EntityType::MText(_) | EntityType::MultiLeader(_)) ) { self.tabs[i] .scene @@ -1283,7 +1722,6 @@ impl super::OpenCADStudio { position.y, position.z, ); - mt.rotation = 0.0; self.push_undo_snapshot(i, "MTEXT"); let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt))); if annotative { @@ -1321,7 +1759,8 @@ impl super::OpenCADStudio { ), None => return, }; - let annotative = editing.is_none() + let annotative = mt.is_annotative + || editing.is_none() && crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &mt.style, @@ -1341,9 +1780,12 @@ impl super::OpenCADStudio { Some(EntityType::MText(t)) => { t.value = mt.value; t.height = mt.height; + t.style = mt.style; t.attachment_point = mt.attachment_point; t.line_spacing_factor = mt.line_spacing_factor; t.rectangle_width = mt.rectangle_width; + t.rectangle_height = mt.rectangle_height; + t.column_data = mt.column_data; } Some(EntityType::MultiLeader(ml)) => { ml.context.text_string = mt.value; @@ -1355,9 +1797,25 @@ impl super::OpenCADStudio { } _ => {} } + if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + h, + annotative, + ); + if annotative { + if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + h, + scale, + ); + } + } + } if matches!( self.tabs[i].scene.document.get_entity(h), - Some(EntityType::MultiLeader(_)) + Some(EntityType::MText(_) | EntityType::MultiLeader(_)) ) { self.tabs[i] .scene @@ -1383,7 +1841,6 @@ impl super::OpenCADStudio { position.y, position.z, ); - mt.rotation = 0.0; self.push_undo_snapshot(i, "MTEXT"); let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt))); self.tabs[i].dirty = true; @@ -1400,6 +1857,11 @@ impl super::OpenCADStudio { // Bind the editor to the fresh entity so the next Apply updates it. if let (Some(h), Some(ed)) = (handle, self.mtext_editor.as_mut()) { ed.editing = Some(h); + if let Some(EntityType::MText(mtext)) = + self.tabs[i].scene.document.get_entity(h) + { + ed.original = Some(mtext.clone()); + } } } self.refresh_properties(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a98bb676..4d9b1994 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3880,10 +3880,7 @@ impl OpenCADStudio { Task::none() } Message::MTextHeight(s) => { - if let Some(ed) = self.mtext_editor.as_mut() { - ed.height = s; - } - self.rebuild_mtext_preview(); + self.mtext_apply_span_number("height", s); Task::none() } Message::MTextRectWidth(width) => { @@ -3915,26 +3912,128 @@ impl OpenCADStudio { Task::none() } Message::MTextOblique(s) => { - if let Some(ed) = self.mtext_editor.as_mut() { - ed.oblique = s; - } - self.rebuild_mtext_preview(); + self.mtext_apply_span_number("oblique", s); Task::none() } Message::MTextWidth(s) => { + self.mtext_apply_span_number("width", s); + Task::none() + } + Message::MTextCharSpace(s) => { + self.mtext_apply_span_number("tracking", s); + Task::none() + } + Message::MTextUndo => { + self.mtext_undo(); + Task::none() + } + Message::MTextRedo => { + self.mtext_redo(); + Task::none() + } + Message::MTextStack => { + self.mtext_stack_selection(); + Task::none() + } + Message::MTextClearFormatting => { + self.mtext_clear_formatting(); + Task::none() + } + Message::MTextInsert(value) => { + self.mtext_type(&value); + Task::none() + } + Message::MTextAnnotative(value) => { if let Some(ed) = self.mtext_editor.as_mut() { - ed.width = s; + ed.annotative = value; } self.rebuild_mtext_preview(); Task::none() } - Message::MTextCharSpace(s) => { + Message::MTextColumnMode(mode) => { if let Some(ed) = self.mtext_editor.as_mut() { - ed.char_space = s; + match mode.as_str() { + "Static" => { + ed.column_type = 1; + ed.column_auto_height = false; + } + "Dynamic auto" => { + ed.column_type = 2; + ed.column_auto_height = true; + } + "Dynamic manual" => { + ed.column_type = 2; + ed.column_auto_height = false; + } + _ => ed.column_type = 0, + } } self.rebuild_mtext_preview(); Task::none() } + Message::MTextColumnCount(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_count = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnWidth(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_width = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnGutter(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_gutter = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnHeight(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.rect_height = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnFlowReversed(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_flow_reversed = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextParagraphNumber(field, value) => { + self.mtext_apply_paragraph_number(field, value); + Task::none() + } + Message::MTextFindText(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.find_text = value; + } + Task::none() + } + Message::MTextReplaceText(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.replace_text = value; + } + Task::none() + } + Message::MTextFindNext => { + self.mtext_find_next(); + Task::none() + } + Message::MTextReplaceNext => { + self.mtext_replace_next(); + Task::none() + } + Message::MTextReplaceAll => { + self.mtext_replace_all(); + Task::none() + } Message::MTextJustify(ap) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.attachment = ap; diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index b5782f81..c1c4dac3 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -6,7 +6,8 @@ use iced::advanced::renderer; use iced::advanced::widget; use iced::advanced::{Layout, Shell, Widget}; use iced::widget::{ - button, column, container, mouse_area, row, scrollable, stack, text, text_input, Space, + button, checkbox, column, container, mouse_area, row, scrollable, stack, text, text_input, + Space, }; use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector}; use crate::t; @@ -374,7 +375,7 @@ pub(super) fn mtext_editor_overlay<'a>( iced::widget::Space::new().width(Fill).height(Fill), t!("Text Editor"), content, - Message::MTextCancel, + Message::MTextOk, modal_offset, crate::ui::modal::ModalOptions::STANDARD, ) @@ -450,6 +451,9 @@ fn mtext_editor_content<'a>( .on_select(Message::MTextStyle) .text_size(11) .width(iced::Length::Fixed(96.0)); + let annotative = checkbox(ed.annotative) + .on_toggle(Message::MTextAnnotative) + .size(14); let font_sel = if ed.font.trim().is_empty() { "[Style default]".to_string() } else { @@ -487,6 +491,9 @@ fn mtext_editor_content<'a>( let row1 = row![ style_pl, + row![annotative, text(t!("Annotative")).size(11)] + .spacing(3) + .align_y(iced::Alignment::Center), font_pl, small_input("2.5", &ed.height, Message::MTextHeight, 64.0), iced::widget::Space::new().width(6), @@ -535,6 +542,14 @@ fn mtext_editor_content<'a>( .text_size(11) .width(iced::Length::Fixed(112.0)); let row2 = row![ + button(lbl("↶")) + .on_press(Message::MTextUndo) + .padding(3) + .style(btn_style), + button(lbl("↷")) + .on_press(Message::MTextRedo) + .padding(3) + .style(btn_style), lbl("O"), small_input("0", &ed.oblique, Message::MTextOblique, 48.0), lbl("W"), @@ -573,6 +588,144 @@ fn mtext_editor_content<'a>( .on_press(Message::MTextLineSpacing(2.0)) .padding(3) .style(btn_style), + button(lbl("Stack")) + .on_press(Message::MTextStack) + .padding(3) + .style(btn_style), + button(lbl("Clear")) + .on_press(Message::MTextClearFormatting) + .padding(3) + .style(btn_style), + button(lbl("°")) + .on_press(Message::MTextInsert("°".to_string())) + .padding(3) + .style(btn_style), + button(lbl("±")) + .on_press(Message::MTextInsert("±".to_string())) + .padding(3) + .style(btn_style), + button(lbl("⌀")) + .on_press(Message::MTextInsert("⌀".to_string())) + .padding(3) + .style(btn_style), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .width(width); + + let column_mode = match (ed.column_type, ed.column_auto_height) { + (1, _) => "Static", + (2, true) => "Dynamic auto", + (2, false) => "Dynamic manual", + _ => "No columns", + } + .to_string(); + let column_picker = iced::widget::pick_list( + Some(column_mode), + ["No columns", "Static", "Dynamic auto", "Dynamic manual"] + .into_iter() + .map(str::to_string) + .collect::>(), + |value| value.to_string(), + ) + .on_select(Message::MTextColumnMode) + .text_size(11) + .width(iced::Length::Fixed(120.0)); + let reverse = checkbox(ed.column_flow_reversed) + .on_toggle(Message::MTextColumnFlowReversed) + .size(14); + let row3 = row![ + lbl("Columns"), + column_picker, + lbl("Count"), + small_input("2", &ed.column_count, Message::MTextColumnCount, 42.0), + lbl("Height"), + text_input("0", &ed.rect_height) + .on_input(Message::MTextColumnHeight) + .width(iced::Length::Fixed(58.0)) + .padding(3) + .size(12), + lbl("Width"), + small_input("0", &ed.column_width, Message::MTextColumnWidth, 58.0), + lbl("Gutter"), + small_input("0", &ed.column_gutter, Message::MTextColumnGutter, 58.0), + reverse, + lbl("Reverse"), + iced::widget::Space::new().width(8), + lbl("First"), + text_input("0", &ed.paragraph_first_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::FirstIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Left"), + text_input("0", &ed.paragraph_left_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::LeftIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Right"), + text_input("0", &ed.paragraph_right_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::RightIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Before"), + text_input("0", &ed.paragraph_space_before) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::SpaceBefore, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("After"), + text_input("0", &ed.paragraph_space_after) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::SpaceAfter, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .width(width); + let row4 = row![ + lbl("Find"), + text_input("Find", &ed.find_text) + .on_input(Message::MTextFindText) + .width(iced::Length::Fixed(140.0)) + .padding(3) + .size(12), + button(lbl("Next")) + .on_press(Message::MTextFindNext) + .padding(3) + .style(btn_style), + lbl("Replace"), + text_input("Replace", &ed.replace_text) + .on_input(Message::MTextReplaceText) + .width(iced::Length::Fixed(140.0)) + .padding(3) + .size(12), + button(lbl("Replace")) + .on_press(Message::MTextReplaceNext) + .padding(3) + .style(btn_style), + button(lbl("Replace All")) + .on_press(Message::MTextReplaceAll) + .padding(3) + .style(btn_style), ] .spacing(4) .align_y(iced::Alignment::Center) @@ -710,13 +863,13 @@ fn mtext_editor_content<'a>( .into() }; - // ── Top action bar: Apply on the right, exactly like the style managers' - // toolbar strip. Closing (the modal ✕) without applying discards the - // buffer, so there is no separate Cancel. + // ── Top action bar: Apply keeps editing; Close saves and exits. Escape + // remains the explicit discard path. let action_bar = container( row![ iced::widget::Space::new().width(width), crate::ui::style::style_manager::tb_button(t!("Apply"), Message::MTextApply, true), + crate::ui::style::style_manager::tb_button(t!("Close Text Editor"), Message::MTextOk, true), ] .align_y(iced::Alignment::Center), ) @@ -730,7 +883,7 @@ fn mtext_editor_content<'a>( .padding([5, 8]); container( - column![action_bar, row1, row2, width_slider, body] + column![action_bar, row1, row2, row3, row4, width_slider, body] .spacing(6) .width(width) .height(height), From 8b6724700eca3579b2f563eea2383f014705aca3 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:24 +0300 Subject: [PATCH 4/6] Render MText column masks independently --- src/scene/convert/tessellate.rs | 129 ++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 22 deletions(-) diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d474c5f1..90f7c08d 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -78,6 +78,80 @@ fn oriented_text_corners( ] } +fn oriented_mtext_corner_groups( + verts: &[crate::scene::pipeline::text_gpu::TextVertex], + text: &acadrust::MText, + rotation: f64, + pad: f64, + annotation_scale: f64, +) -> Vec<[[f64; 2]; 4]> { + let columns = &text.column_data; + let count = columns.column_count.max(0) as usize; + if columns.column_type == 0 || count <= 1 || columns.width <= 0.0 { + return vec![oriented_text_corners( + verts, + [text.insertion_point.x, text.insertion_point.y], + rotation, + pad, + )]; + } + + let width = columns.width * annotation_scale; + let gutter = columns.gutter.max(0.0) * annotation_scale; + let total_width = width * count as f64 + gutter * count.saturating_sub(1) as f64; + let anchor = match text.attachment_point { + acadrust::entities::mtext::AttachmentPoint::TopCenter + | acadrust::entities::mtext::AttachmentPoint::MiddleCenter + | acadrust::entities::mtext::AttachmentPoint::BottomCenter => 0.5, + acadrust::entities::mtext::AttachmentPoint::TopRight + | acadrust::entities::mtext::AttachmentPoint::MiddleRight + | acadrust::entities::mtext::AttachmentPoint::BottomRight => 1.0, + _ => 0.0, + }; + let block_left = -anchor * total_width; + let origin = [text.insertion_point.x, text.insertion_point.y]; + let (sin_r, cos_r) = rotation.sin_cos(); + let mut bounds = vec![[f64::MAX, f64::MAX, f64::MIN, f64::MIN]; count]; + for vertex in verts { + let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0]; + let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1]; + let local_x = x * cos_r + y * sin_r; + let local_y = -x * sin_r + y * cos_r; + let stride = width + gutter; + let physical = ((local_x - block_left) / stride) + .floor() + .clamp(0.0, count.saturating_sub(1) as f64) as usize; + bounds[physical][0] = bounds[physical][0].min(local_x); + bounds[physical][1] = bounds[physical][1].min(local_y); + bounds[physical][2] = bounds[physical][2].max(local_x); + bounds[physical][3] = bounds[physical][3].max(local_y); + } + let to_world = |x: f64, y: f64| { + [ + origin[0] + x * cos_r - y * sin_r, + origin[1] + x * sin_r + y * cos_r, + ] + }; + bounds + .into_iter() + .filter(|bounds| bounds[0] <= bounds[2] && bounds[1] <= bounds[3]) + .map(|bounds| { + let [left, bottom, right, top] = [ + bounds[0] - pad, + bounds[1] - pad, + bounds[2] + pad, + bounds[3] + pad, + ]; + [ + to_world(left, bottom), + to_world(right, bottom), + to_world(right, top), + to_world(left, top), + ] + }) + .collect() +} + pub(crate) fn explicit_mtext_background(entity: &EntityType) -> Option<[f32; 4]> { let EntityType::MText(text) = entity else { return None; @@ -849,11 +923,12 @@ pub fn tessellate( .unwrap_or(m.rotation); let text_height = m.height * anno; let pad = (m.background_scale - 1.0).max(0.0) * text_height; - let corners = oriented_text_corners( + let corner_groups = oriented_mtext_corner_groups( &sdf_verts, - [m.insertion_point.x, m.insertion_point.y], + m, text_rotation, pad, + anno, ); // Fill / mask — two triangles behind the glyphs. if has_fill { @@ -862,13 +937,18 @@ pub fn tessellate( } else { color_or_inherit(&m.background_color, bg_color) }; - let mut ft = Vec::with_capacity(6); - let mut ftl = Vec::with_capacity(6); - for &k in &[0usize, 1, 2, 0, 2, 3] { - let (h, lo) = - split_ds_xyz(corners[k][0], corners[k][1], elev_v); - ft.push(h); - ftl.push(lo); + let mut ft = Vec::with_capacity(6 * corner_groups.len()); + let mut ftl = Vec::with_capacity(6 * corner_groups.len()); + for corners in &corner_groups { + for &k in &[0usize, 1, 2, 0, 2, 3] { + let (h, lo) = split_ds_xyz( + corners[k][0], + corners[k][1], + elev_v, + ); + ft.push(h); + ftl.push(lo); + } } wires.push(WireModel { point_marker: None, @@ -907,19 +987,24 @@ pub fn tessellate( // Text frame — a closed rectangle in the text // colour around the same box. if has_frame { - let loop_xy = [ - corners[0], - corners[1], - corners[2], - corners[3], - corners[0], - ]; - let mut fp = Vec::with_capacity(5); - let mut fpl = Vec::with_capacity(5); - for &[x, y] in &loop_xy { - let (h, lo) = split_ds_xyz(x, y, elev_v); - fp.push(h); - fpl.push(lo); + let mut fp = Vec::with_capacity(6 * corner_groups.len()); + let mut fpl = Vec::with_capacity(6 * corner_groups.len()); + for (group_index, corners) in corner_groups.iter().enumerate() { + if group_index > 0 { + fp.push([f32::NAN; 3]); + fpl.push([0.0; 3]); + } + for &[x, y] in &[ + corners[0], + corners[1], + corners[2], + corners[3], + corners[0], + ] { + let (h, lo) = split_ds_xyz(x, y, elev_v); + fp.push(h); + fpl.push(lo); + } } wires.push(WireModel { point_marker: None, From b1df6dddd85964c4e508a8d1ff3dd97d670ac70c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:25 +0300 Subject: [PATCH 5/6] Use MText column persistence revision --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index babff425..32fa742b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9f3cf8e#9f3cf8e26d5a02fb4ad8ea145ca8e04dd6d6f2bc" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=96dee63f476cd449b95dad19ac89faf29cc19043#96dee63f476cd449b95dad19ac89faf29cc19043" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 02b96ca9..d7575f1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } From 1922492a951c3e4012127eaa466a403e59a37596 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:06:51 +0300 Subject: [PATCH 6/6] Make MText defined width read-only --- src/entities/mtext.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 3dcee115..b8f65ec6 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -300,10 +300,9 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { // by the line-spacing factor. let line_space_distance = t.height * 1.666_666_666_666_667 * t.line_spacing_factor; let text_frame_on = (t.background_fill_flags & 0x10) != 0; - // Defined width is only live without columns; defined height is live for - // static columns or manual-height dynamic columns, grayed otherwise. + // Defined width is reported by Properties but is not edited there. Defined + // height remains live for static columns or manual-height dynamic columns. let col_type = t.column_data.column_type; - let width_editable = col_type == 0; let height_editable = col_type == 1 || (col_type == 2 && !t.column_data.auto_height); vec![ PropSection { @@ -405,7 +404,7 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { .collect(), }, }, - num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, width_editable), + num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, false), num_row( t!("Defined height").as_ref(), "rect_h", @@ -540,7 +539,6 @@ 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 = (v > 0.0).then_some(v), "rotation" => t.rotation = v.to_radians(), "line_spacing" if (0.25..=4.0).contains(&v) => t.line_spacing_factor = v,