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