From fff811577beb3cbe5de6e52dc7bde63b18d0e2dd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:50:15 +0300 Subject: [PATCH] feat(text): complete single-line creation workflow --- src/app/command_driver.rs | 16 ++ src/app/commands/draw.rs | 9 +- src/app/text_inline.rs | 44 +++- src/command.rs | 8 + src/modules/annotate/text.rs | 407 +++++++++++++++++++++++++++++++++-- 5 files changed, 449 insertions(+), 35 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bd57247d4..8dda12465 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3748,6 +3748,22 @@ impl OpenCADStudio { &initial, height, super::text_inline::TextEntityField::Text, + None, + ); + } + CmdResult::SuspendForTextInput { pos, entity } => { + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + let height = entity.height; + self.open_text_inline( + pos, + None, + "", + height, + super::text_inline::TextEntityField::Text, + Some(entity), ); } CmdResult::EditTextEntity { handle } => { diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 065f2e62e..4c2b3814f 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1268,11 +1268,10 @@ impl OpenCADStudio { // ── Annotate commands ────────────────────────────────────────── "TEXT" => { use crate::modules::annotate::text::TextCommand; - let height = crate::scene::creation_style::current_text_defaults( - &self.tabs[i].scene.document, - ) - .height; - let new_cmd = TextCommand::with_height(height); + let document = &self.tabs[i].scene.document; + let defaults = crate::scene::creation_style::current_text_defaults(document); + let styles = document.text_styles.iter().cloned().collect(); + let new_cmd = TextCommand::with_defaults(defaults, styles); 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 c1f8b495e..b08297fc0 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -95,6 +95,9 @@ pub struct TextInlineState { pub editing: Option, /// Which entity slot this session writes to on commit. pub field: TextEntityField, + /// Fully prepared entity supplied by the interactive TEXT command. Editing + /// existing text and legacy direct-open paths leave this empty. + pub creation: Option, /// Canvas-space anchor where the field is drawn (the insertion-point click). pub screen_anchor: iced::Point, } @@ -169,7 +172,7 @@ impl super::OpenCADStudio { self.open_mtext_editor(pos, Some(target), &value, height); self.unfocus_widgets() } else { - self.open_text_inline(pos, Some(target), &value, height, field); + self.open_text_inline(pos, Some(target), &value, height, field, None); iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID)) } } @@ -183,6 +186,7 @@ impl super::OpenCADStudio { initial: &str, height: f64, field: TextEntityField, + creation: Option, ) { if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { return; @@ -193,6 +197,7 @@ impl super::OpenCADStudio { height: if height > 0.0 { height } else { 0.25 }, editing: handle, field, + creation, screen_anchor: iced::Point::new(60.0, 90.0), }; if let Some(p) = self.tabs[self.active_tab].scene.selection.borrow().last_move_pos { @@ -229,12 +234,17 @@ impl super::OpenCADStudio { } else { crate::command::WorkingPlane::default() }; - let position = plane.to_local(ed.pos); - let mut t = Text::with_value( - &ed.value, - Vector3::new(position.x, position.y, position.z), - ) - .with_height(ed.height); + let mut t = if let Some(mut prepared) = ed.creation { + prepared.value = ed.value.clone(); + prepared + } else { + let position = plane.to_local(ed.pos); + Text::with_value( + &ed.value, + Vector3::new(position.x, position.y, position.z), + ) + .with_height(ed.height) + }; // New text inherits the document's current text style (STYLE), not // the entity default. See #92. let cur_style = self.tabs[i] @@ -244,13 +254,31 @@ impl super::OpenCADStudio { .current_text_style_name .clone(); if !cur_style.is_empty() { - t.style = cur_style; + if t.style.trim().is_empty() { + t.style = cur_style; + } } let annotative = crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &t.style, ); self.push_undo_snapshot(i, "TEXT"); + self.tabs[i].scene.document.header.current_text_style_name = t.style.clone(); + let variable_height = self.tabs[i] + .scene + .document + .text_styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(&t.style)) + .is_none_or(|style| style.height <= 1.0e-9); + if variable_height + && !matches!( + t.horizontal_alignment, + acadrust::entities::TextHorizontalAlignment::Aligned + ) + { + self.tabs[i].scene.document.header.text_height = t.height; + } let handle = self.commit_entity_handle(plane.place_entity(EntityType::Text(t))); if annotative { let scale = self.tabs[i].scene.current_annotation_scale_handle(); diff --git a/src/command.rs b/src/command.rs index 46cad3434..42d818968 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1443,6 +1443,14 @@ pub enum CmdResult { initial: String, height: f64, }, + /// Suspend the active TEXT command while the in-place editor collects one + /// independent line. The prepared entity carries the chosen style, + /// justification, rotation and two-point geometry. When the editor closes, + /// the command resumes so another line can be placed directly below it. + SuspendForTextInput { + pos: DVec3, + entity: acadrust::entities::Text, + }, /// Apply new pattern/scale/angle to an existing hatch entity. HatcheditApply { handle: Handle, diff --git a/src/modules/annotate/text.rs b/src/modules/annotate/text.rs index c00ad9817..041394cb1 100644 --- a/src/modules/annotate/text.rs +++ b/src/modules/annotate/text.rs @@ -1,7 +1,14 @@ -use crate::command::{CadCommand, CmdResult}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; +use acadrust::entities::{ + Text, TextHorizontalAlignment as HA, TextVerticalAlignment as VA, +}; +use acadrust::tables::TextStyle; +use acadrust::types::Vector3; use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::creation_style::TextCreationDefaults; +use crate::scene::model::wire_model::WireModel; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/text.svg")); @@ -15,57 +22,413 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy, PartialEq, Eq)] enum Step { - InsertPoint, + Start, + Justification, + Style, + Height, + Rotation, + SecondPoint, } pub struct TextCommand { step: Step, + plane: WorkingPlane, + first_point: Option, + second_point: Option, + horizontal: HA, + vertical: VA, + style_name: String, + styles: Vec, height: f64, + rotation: f64, + width_factor: f64, + oblique_angle: f64, + fixed_height: bool, + annotative: bool, + generation_flags: i16, + last_entity: Option, } impl TextCommand { - pub fn with_height(height: f64) -> Self { - Self { - step: Step::InsertPoint, - height, + pub fn with_defaults(defaults: TextCreationDefaults, styles: Vec) -> Self { + let current_height = defaults.height; + let mut command = Self { + step: Step::Start, + plane: WorkingPlane::default(), + first_point: None, + second_point: None, + horizontal: HA::Left, + vertical: VA::Baseline, + style_name: defaults.style_name, + styles, + height: defaults.height, + rotation: 0.0, + width_factor: defaults.width_factor, + oblique_angle: defaults.oblique_angle, + fixed_height: false, + annotative: false, + generation_flags: 0, + last_entity: None, + }; + let style = command.style_name.clone(); + command.select_style(&style); + if !command.fixed_height { + command.height = current_height; } + command + } + + fn select_style(&mut self, name: &str) -> bool { + let Some(style) = self + .styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(name)) + .cloned() + else { + return false; + }; + self.style_name = style.name; + self.fixed_height = style.height > 1.0e-9; + if self.fixed_height { + self.height = style.height; + } else if style.last_height > 1.0e-9 { + self.height = style.last_height; + } + self.width_factor = style.width_factor.max(0.01); + self.oblique_angle = style.oblique_angle.clamp( + -85.0_f64.to_radians(), + 85.0_f64.to_radians(), + ); + self.annotative = style.annotative; + self.generation_flags = (if style.flags.backward { 2 } else { 0 }) + | (if style.flags.upside_down { 4 } else { 0 }); + true + } + + fn set_justification(&mut self, value: &str) -> bool { + let normalized = value.trim().to_ascii_uppercase().replace([' ', '-'], ""); + let alignment = match normalized.as_str() { + "L" | "LEFT" => (HA::Left, VA::Baseline), + "C" | "CENTER" => (HA::Center, VA::Baseline), + "R" | "RIGHT" => (HA::Right, VA::Baseline), + "A" | "ALIGNED" | "ALIGN" => (HA::Aligned, VA::Baseline), + "M" | "MIDDLE" => (HA::Middle, VA::Baseline), + "F" | "FIT" => (HA::Fit, VA::Baseline), + "TL" | "TOPLEFT" => (HA::Left, VA::Top), + "TC" | "TOPCENTER" => (HA::Center, VA::Top), + "TR" | "TOPRIGHT" => (HA::Right, VA::Top), + "ML" | "MIDDLELEFT" => (HA::Left, VA::Middle), + "MC" | "MIDDLECENTER" => (HA::Center, VA::Middle), + "MR" | "MIDDLERIGHT" => (HA::Right, VA::Middle), + "BL" | "BOTTOMLEFT" => (HA::Left, VA::Bottom), + "BC" | "BOTTOMCENTER" => (HA::Center, VA::Bottom), + "BR" | "BOTTOMRIGHT" => (HA::Right, VA::Bottom), + _ => return false, + }; + self.horizontal = alignment.0; + self.vertical = alignment.1; + true + } + + fn is_two_point(&self) -> bool { + matches!(self.horizontal, HA::Aligned | HA::Fit) + } + + fn after_first_point(&mut self) -> CmdResult { + if self.is_two_point() { + self.step = Step::SecondPoint; + } else if self.fixed_height { + self.step = Step::Rotation; + } else { + self.step = Step::Height; + } + CmdResult::NeedPoint + } + + fn after_second_point(&mut self) -> CmdResult { + if matches!(self.horizontal, HA::Fit) && !self.fixed_height { + self.step = Step::Height; + CmdResult::NeedPoint + } else { + self.open_editor() + } + } + + fn make_entity(&self) -> Option { + let first = self.plane.to_local(self.first_point?); + let mut text = Text::with_value("", Vector3::new(first.x, first.y, first.z)) + .with_height(self.height.max(1.0e-9)); + text.style = self.style_name.clone(); + text.width_factor = self.width_factor.max(0.01); + text.oblique_angle = self.oblique_angle; + text.rotation = self.rotation; + text.horizontal_alignment = self.horizontal; + text.vertical_alignment = self.vertical; + text.generation_flags = self.generation_flags; + text.alignment_point = if self.is_two_point() { + let second = self.plane.to_local(self.second_point?); + Some(Vector3::new(second.x, second.y, second.z)) + } else if matches!((self.horizontal, self.vertical), (HA::Left, VA::Baseline)) { + None + } else { + Some(Vector3::new(first.x, first.y, first.z)) + }; + Some(text) + } + + fn open_editor(&mut self) -> CmdResult { + let Some(entity) = self.make_entity() else { + return CmdResult::NeedPoint; + }; + let pos = self.first_point.unwrap_or(DVec3::ZERO); + self.last_entity = Some(entity.clone()); + CmdResult::SuspendForTextInput { pos, entity } + } + + fn open_next_line(&mut self) -> CmdResult { + let Some(mut entity) = self.last_entity.take() else { + return CmdResult::Cancel; + }; + let angle = if matches!(entity.horizontal_alignment, HA::Aligned | HA::Fit) { + entity.alignment_point.map_or(entity.rotation, |point| { + (point.y - entity.insertion_point.y) + .atan2(point.x - entity.insertion_point.x) + }) + } else { + entity.rotation + }; + let spacing = entity.height.max(1.0e-9) * 1.666_666_666_7; + let delta = Vector3::new(angle.sin() * spacing, -angle.cos() * spacing, 0.0); + entity.insertion_point = entity.insertion_point + delta; + if let Some(point) = entity.alignment_point.as_mut() { + *point = *point + delta; + } + entity.value.clear(); + let local = DVec3::new( + entity.insertion_point.x, + entity.insertion_point.y, + entity.insertion_point.z, + ); + let pos = self.plane.to_world(local); + self.last_entity = Some(entity.clone()); + CmdResult::SuspendForTextInput { pos, entity } } } impl CadCommand for TextCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn name(&self) -> &'static str { "TEXT" } fn prompt(&self) -> String { - match &self.step { - Step::InsertPoint => t!("TEXT Specify insertion point:").into_owned(), + match self.step { + Step::Start => format!( + "{}\n{}", + crate::tf!( + "TEXT Current style: {}, Height: {}, Annotative: {}", + self.style_name, + self.height, + if self.annotative { "Yes" } else { "No" } + ), + t!("TEXT Specify start point or [Justify/Style]:") + ), + Step::Justification => t!( + "TEXT Enter justification [Left/Center/Right/Aligned/Middle/Fit/TL/TC/TR/ML/MC/MR/BL/BC/BR]:" + ) + .into_owned(), + Step::Style => crate::tf!("TEXT Enter style name <{}>:", self.style_name).into_owned(), + Step::Height if self.annotative => { + crate::tf!("TEXT Specify paper text height <{}>:", self.height).into_owned() + } + Step::Height => crate::tf!("TEXT Specify height <{}>:", self.height).into_owned(), + Step::Rotation => crate::tf!( + "TEXT Specify rotation angle <{}>:", + self.rotation.to_degrees() + ) + .into_owned(), + Step::SecondPoint => t!("TEXT Specify second endpoint:").into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - // Hand off to the in-place plain-text editor anchored at the click. - CmdResult::OpenTextEditor { - pos: pt, - handle: None, - initial: String::new(), - height: self.height, + fn options(&self) -> Vec { + match self.step { + Step::Start => vec![ + CmdOption::new(t!("Justify").as_ref(), "J"), + CmdOption::new(t!("Style").as_ref(), "ST"), + ], + Step::Justification => [ + ("Left", "L"), ("Center", "C"), ("Right", "R"), + ("Aligned", "A"), ("Middle", "M"), ("Fit", "F"), + ("TL", "TL"), ("TC", "TC"), ("TR", "TR"), + ("ML", "ML"), ("MC", "MC"), ("MR", "MR"), + ("BL", "BL"), ("BC", "BC"), ("BR", "BR"), + ] + .into_iter() + .map(|(label, keyword)| CmdOption::new(label, keyword)) + .collect(), + Step::Style => self + .styles + .iter() + .map(|style| CmdOption::new(&style.name, &style.name)) + .collect(), + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + matches!( + self.step, + Step::Justification | Step::Style | Step::Height | Step::Rotation + ) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Start) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let token = text.trim(); + let upper = token.to_ascii_uppercase(); + match self.step { + Step::Start => match upper.as_str() { + "J" | "JUSTIFY" | "JUSTIFICATION" => self.step = Step::Justification, + "S" | "ST" | "STYLE" => self.step = Step::Style, + _ => return None, + }, + Step::Justification => { + if !self.set_justification(token) { + return None; + } + self.step = Step::Start; + } + Step::Style => { + if !self.select_style(token) { + return None; + } + self.step = Step::Start; + } + Step::Height => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() || value <= 1.0e-9 { + return None; + } + self.height = value; + if self.is_two_point() { + return Some(self.open_editor()); + } + self.step = Step::Rotation; + } + Step::Rotation => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() { + return None; + } + self.rotation = value.to_radians(); + return Some(self.open_editor()); + } + Step::SecondPoint => return None, + } + Some(CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Start => { + self.first_point = Some(point); + self.second_point = None; + self.after_first_point() + } + Step::Height => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + let value = self + .plane + .vector_to_local(point - first) + .truncate() + .length(); + if value <= 1.0e-9 { + return CmdResult::NeedPoint; + } + self.height = value; + if self.is_two_point() { + self.open_editor() + } else { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + Step::Rotation => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + let Some(angle) = self.plane.angle(first, point) else { + return CmdResult::NeedPoint; + }; + self.rotation = angle; + self.open_editor() + } + Step::SecondPoint => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + if self + .plane + .vector_to_local(point - first) + .truncate() + .length() + <= 1.0e-9 + { + return CmdResult::NeedPoint; + } + self.second_point = Some(point); + self.after_second_point() + } + Step::Justification | Step::Style => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Start => CmdResult::Cancel, + Step::Justification | Step::Style => { + self.step = Step::Start; + CmdResult::NeedPoint + } + Step::Height => { + if self.is_two_point() { + self.open_editor() + } else { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + Step::Rotation => self.open_editor(), + Step::SecondPoint => CmdResult::NeedPoint, + } } + + fn on_editor_closed(&mut self, committed: bool) -> CmdResult { + if committed { + self.open_next_line() + } else { + 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: &["TEXT"] }); // TextCommand +inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] });