fix(tolerance): complete workflow integration
Sync current main and companion codec changes before preserving unsupported frame rows, rendering in the entity plane, and sharing final layout metrics with placement preview.
This commit is contained in:
parent
66bf4b1d5f
commit
81e7add1dd
65 changed files with 3703 additions and 929 deletions
|
|
@ -193,6 +193,17 @@ fn entity_json(e: &acadrust::EntityType) -> Value {
|
|||
impl OpenCADStudio {
|
||||
/// Handle one JSON request line and return the JSON response.
|
||||
pub(crate) fn automation_op(&mut self, line: &str) -> Value {
|
||||
let res = self.automation_op_inner(line);
|
||||
// Most automation ops mutate `Scene::selected` directly rather than
|
||||
// going through `update()` (`select` calls `deselect_all` /
|
||||
// `select_entity`, and `run` can erase the selected entities), so the
|
||||
// selection check has to run on this path too.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.notify_plugins_selection_changed();
|
||||
res
|
||||
}
|
||||
|
||||
fn automation_op_inner(&mut self, line: &str) -> Value {
|
||||
let req: Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return err(format!("invalid JSON: {e}")),
|
||||
|
|
@ -201,6 +212,7 @@ impl OpenCADStudio {
|
|||
"new" => {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.document = acadrust::CadDocument::new();
|
||||
self.tabs[i].scene.deselect_all();
|
||||
self.tabs[i].current_path = None;
|
||||
// The headless session starts on the welcome (Start) tab, which
|
||||
// blocks drawing commands; turn it into a real drawing.
|
||||
|
|
@ -224,6 +236,7 @@ impl OpenCADStudio {
|
|||
Ok(doc) => {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.document = doc;
|
||||
self.tabs[i].scene.deselect_all();
|
||||
crate::app::style_ops::ensure_standard_styles(
|
||||
&mut self.tabs[i].scene.document,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -3748,6 +3749,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 } => {
|
||||
|
|
|
|||
|
|
@ -1268,11 +1268,16 @@ 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 (defaults, styles, annotation_multiplier) = {
|
||||
let scene = &self.tabs[i].scene;
|
||||
let annotation_multiplier = scene.creation_annotation_multiplier();
|
||||
let defaults =
|
||||
crate::scene::creation_style::current_text_defaults(&scene.document);
|
||||
let styles = scene.document.text_styles.iter().cloned().collect();
|
||||
(defaults, styles, annotation_multiplier)
|
||||
};
|
||||
let new_cmd =
|
||||
TextCommand::with_defaults(defaults, styles, annotation_multiplier);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
|
@ -1307,7 +1312,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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -943,7 +943,12 @@ impl OpenCADStudio {
|
|||
|
||||
"ALIGN" => {
|
||||
use crate::modules::draw::modify::align::AlignCommand;
|
||||
let cmd = AlignCommand::new();
|
||||
|
||||
let selected: Vec<acadrust::Handle> =
|
||||
self.tabs[i].scene.selected.iter().copied().collect();
|
||||
|
||||
let cmd = AlignCommand::with_selection(selected);
|
||||
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -827,30 +827,8 @@ impl OpenCADStudio {
|
|||
}
|
||||
}
|
||||
|
||||
// A viewport frames the model, so its framing is measured in model
|
||||
// units and has to follow them. Left alone, every layout would suddenly
|
||||
// look at a region a thousand times too large. The paper side of the
|
||||
// ratio is untouched — the sheet is still the same sheet — so the ratio
|
||||
// itself moves the other way.
|
||||
//
|
||||
// `transform_viewport` covers the rectangle and the target but not the
|
||||
// framing, so the framing is done here for every viewport, and the
|
||||
// target only for the ones the scale above did not already reach.
|
||||
// Not every viewport frames the model, though. Each layout's sheet
|
||||
// viewport frames the sheet — its view is the paper, measured in paper
|
||||
// units — so scaling it by the model's factor would leave the drawing
|
||||
// frame the same size as before while the view of it changed by a
|
||||
// thousand, which is the boundary appearing to break.
|
||||
let sheets: std::collections::HashSet<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|object| match object {
|
||||
acadrust::objects::ObjectType::Layout(layout) => Some(layout.viewport),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Model framing follows model units; sheet framing stays in paper units.
|
||||
let sheets = self.tabs[i].scene.sheet_viewport_handles();
|
||||
let scaled: std::collections::HashSet<acadrust::Handle> = handles.iter().copied().collect();
|
||||
let mut reframed = 0usize;
|
||||
for entity in self.tabs[i].scene.document.entities_mut() {
|
||||
|
|
@ -858,14 +836,7 @@ impl OpenCADStudio {
|
|||
let acadrust::entities::EntityType::Viewport(vp) = entity else {
|
||||
continue;
|
||||
};
|
||||
// Only a paper-space viewport can be a sheet — the scale above
|
||||
// reached everything in model space, and what it reached frames the
|
||||
// model by definition. Among the rest, the layout names its sheet
|
||||
// outright, and a file that arrives without that link still gives
|
||||
// itself away by sitting at the paper origin, where only the sheet
|
||||
// sits.
|
||||
let is_sheet = !scaled.contains(&handle)
|
||||
&& (sheets.contains(&handle) || !crate::scene::Scene::is_content_viewport(vp));
|
||||
let is_sheet = !scaled.contains(&handle) && sheets.contains(&handle);
|
||||
if is_sheet {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -694,6 +694,10 @@ pub(super) struct OpenCADStudio {
|
|||
/// keep their manifest listed but drop their ribbon tab and command
|
||||
/// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`].
|
||||
disabled_plugins: rustc_hash::FxHashSet<String>,
|
||||
/// `(tab id, selection fingerprint)` last broadcast to V4 plugins, so
|
||||
/// `SelectionChangedV4` fires once per real change rather than per message.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_plugin_selection: Option<(u64, u64)>,
|
||||
/// External add-on packages found in the plugins folder, refreshed when the
|
||||
/// Plugin Manager opens.
|
||||
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
|
||||
|
|
@ -2667,6 +2671,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),
|
||||
|
|
@ -3259,6 +3288,8 @@ impl OpenCADStudio {
|
|||
attr_editor_tab: crate::ui::window::attribute_editor::AttrTab::Attribute,
|
||||
attr_editor_selected: 0,
|
||||
disabled_plugins: rustc_hash::FxHashSet::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_plugin_selection: None,
|
||||
external_plugins: Vec::new(),
|
||||
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
|
||||
plugin_load_errors: rustc_hash::FxHashMap::default(),
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
redo: Vec<String>,
|
||||
/// `Some` when editing an existing entity; `None` for a fresh MText.
|
||||
pub editing: Option<Handle>,
|
||||
/// 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,54 @@ 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::<f64>().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 = crate::entities::text_support::clamp_mtext_column_count(
|
||||
self.column_count
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.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::<f64>() {
|
||||
if width > 0.0 {
|
||||
mt.column_data.width = width;
|
||||
}
|
||||
}
|
||||
if let Ok(gutter) = self.column_gutter.trim().parse::<f64>() {
|
||||
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 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 +684,27 @@ 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 = crate::entities::text_support::clamp_mtext_column_count(
|
||||
m.column_data.column_count,
|
||||
)
|
||||
.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 +715,7 @@ impl super::OpenCADStudio {
|
|||
handle: Option<Handle>,
|
||||
initial: &str,
|
||||
height: f64,
|
||||
template: Option<MText>,
|
||||
) {
|
||||
if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) {
|
||||
return;
|
||||
|
|
@ -614,20 +725,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 +739,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 +763,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 +850,322 @@ 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::<f64>().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::<f64>().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::<Vec<_>>();
|
||||
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<char> = ed.find_text.chars().collect();
|
||||
if needle.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cells = doc_to_cells(&ed.doc);
|
||||
let chars: Vec<Option<char>> = cells
|
||||
.iter()
|
||||
.map(|cell| match cell {
|
||||
Cell::Char(ch, _) => Some(*ch),
|
||||
Cell::Break(..) => Some('\n'),
|
||||
Cell::Stack { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
if needle.len() > chars.len() {
|
||||
return;
|
||||
}
|
||||
let start = ed.caret.min(chars.len());
|
||||
let last_start = chars.len() - needle.len();
|
||||
let found = (start..=last_start)
|
||||
.chain(0..start.min(last_start + 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<char> = 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 +1218,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 +1303,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 +1355,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 +1385,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 +1409,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 +1529,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 +1581,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 +1644,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 +1668,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 +1686,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 +1730,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 +1767,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 +1788,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 +1805,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 +1849,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 +1865,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();
|
||||
|
|
|
|||
|
|
@ -1884,6 +1884,69 @@ impl OpenCADStudio {
|
|||
}
|
||||
}
|
||||
|
||||
// Single-line text height rows depend on both the
|
||||
// justification and the active annotation scale. Aligned
|
||||
// text derives its paper height from the two endpoints,
|
||||
// while annotative text exposes that paper height and a
|
||||
// separate calculated model height.
|
||||
if let acadrust::EntityType::Text(text) = entity {
|
||||
let aligned = matches!(
|
||||
text.horizontal_alignment,
|
||||
acadrust::entities::TextHorizontalAlignment::Aligned
|
||||
);
|
||||
let annotative = crate::scene::annotative::is_annotative(doc, entity);
|
||||
let model_factor = if annotative {
|
||||
annotation_scale_handle
|
||||
.and_then(|handle| match doc.objects.get(&handle) {
|
||||
Some(acadrust::objects::ObjectType::Scale(scale)) => Some(
|
||||
scale.inverse_factor()
|
||||
/ self.tabs[i].scene.annotation_scale_unit_factor(),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(self.tabs[i].scene.annotation_scale as f64)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let paper_height = if aligned {
|
||||
crate::entities::text::text_run_placement_at_scale(
|
||||
text,
|
||||
doc,
|
||||
model_factor as f32,
|
||||
)
|
||||
.height as f64
|
||||
} else {
|
||||
text.height
|
||||
};
|
||||
for section in sections.iter_mut() {
|
||||
if let Some(row) =
|
||||
section.props.iter_mut().find(|row| row.field == "height")
|
||||
{
|
||||
if annotative {
|
||||
row.label = t!("Paper text height").into_owned();
|
||||
}
|
||||
if aligned {
|
||||
row.value = crate::scene::model::object::PropValue::ReadOnly(
|
||||
crate::entities::common::format_length(paper_height),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if annotative {
|
||||
insert_row_after(
|
||||
&mut sections,
|
||||
"height",
|
||||
crate::entities::common::ro_prop(
|
||||
t!("Model text height").as_ref(),
|
||||
"model_text_height",
|
||||
crate::entities::common::format_length(
|
||||
paper_height * model_factor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !group_names.is_empty() {
|
||||
let label = group_names.join(", ");
|
||||
if let Some(general) = sections.first_mut() {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ pub struct TextInlineState {
|
|||
pub editing: Option<Handle>,
|
||||
/// 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<Text>,
|
||||
/// Canvas-space anchor where the field is drawn (the insertion-point click).
|
||||
pub screen_anchor: iced::Point,
|
||||
}
|
||||
|
|
@ -173,10 +176,10 @@ 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);
|
||||
self.open_text_inline(pos, Some(target), &value, height, field, None);
|
||||
iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID))
|
||||
}
|
||||
}
|
||||
|
|
@ -190,6 +193,7 @@ impl super::OpenCADStudio {
|
|||
initial: &str,
|
||||
height: f64,
|
||||
field: TextEntityField,
|
||||
creation: Option<Text>,
|
||||
) {
|
||||
if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) {
|
||||
return;
|
||||
|
|
@ -200,6 +204,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 {
|
||||
|
|
@ -236,12 +241,18 @@ 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 command_creation = ed.creation.is_some();
|
||||
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]
|
||||
|
|
@ -251,24 +262,48 @@ impl super::OpenCADStudio {
|
|||
.current_text_style_name
|
||||
.clone();
|
||||
if !cur_style.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");
|
||||
let handle = self.commit_entity_handle(plane.place_entity(EntityType::Text(t)));
|
||||
if annotative {
|
||||
let scale = self.tabs[i].scene.current_annotation_scale_handle();
|
||||
if let (Some(handle), Some(scale)) = (handle, scale) {
|
||||
crate::scene::annotative::create_annotation_context(
|
||||
&mut self.tabs[i].scene.document,
|
||||
handle,
|
||||
scale,
|
||||
);
|
||||
if t.style.trim().is_empty() {
|
||||
t.style = cur_style;
|
||||
}
|
||||
}
|
||||
if command_creation {
|
||||
let annotation_multiplier = if crate::scene::annotative::text_style_is_annotative(
|
||||
&self.tabs[i].scene.document,
|
||||
&t.style,
|
||||
) {
|
||||
self.tabs[i].scene.creation_annotation_multiplier()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let display_height = crate::entities::text::text_run_placement_at_scale(
|
||||
&t,
|
||||
&self.tabs[i].scene.document,
|
||||
annotation_multiplier as f32,
|
||||
)
|
||||
.height as f64
|
||||
* annotation_multiplier;
|
||||
if let Some(command) = self.tabs[i].suspended_cmd.as_mut() {
|
||||
command.on_editor_display_height(display_height);
|
||||
}
|
||||
}
|
||||
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 _ = self.commit_entity_handle(plane.place_entity(EntityType::Text(t)));
|
||||
self.tabs[i].dirty = true;
|
||||
}
|
||||
self.refresh_properties();
|
||||
|
|
|
|||
|
|
@ -71,8 +71,28 @@ impl super::OpenCADStudio {
|
|||
return false;
|
||||
}
|
||||
let i = self.active_tab;
|
||||
let mut command =
|
||||
crate::modules::annotate::tolerance_cmd::ToleranceCommand::with_text(state.to_text());
|
||||
let text = state.to_text();
|
||||
let mut preview_entity = EntityType::Tolerance(
|
||||
acadrust::entities::Tolerance::with_text(
|
||||
acadrust::types::Vector3::ZERO,
|
||||
text.clone(),
|
||||
),
|
||||
);
|
||||
crate::scene::creation_style::apply_current_creation_styles(
|
||||
&self.tabs[i].scene.document,
|
||||
&mut preview_entity,
|
||||
);
|
||||
let preview_strokes = match &preview_entity {
|
||||
EntityType::Tolerance(tolerance) => crate::entities::tolerance::preview_strokes(
|
||||
tolerance,
|
||||
&self.tabs[i].scene.document,
|
||||
),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut command = crate::modules::annotate::tolerance_cmd::ToleranceCommand::with_text(
|
||||
text,
|
||||
preview_strokes,
|
||||
);
|
||||
use crate::command::CadCommand;
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
|
|
|
|||
|
|
@ -1162,6 +1162,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
if matches!(
|
||||
item.action,
|
||||
GripMenuAction::Stretch
|
||||
| GripMenuAction::MoveWithText
|
||||
| GripMenuAction::MoveWithDimLine
|
||||
| GripMenuAction::MoveWithLeader
|
||||
| GripMenuAction::MoveIndependent
|
||||
|
|
@ -1212,7 +1213,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
{
|
||||
(crate::entities::multileader::MOVE_ALL_GRIP, true)
|
||||
} else {
|
||||
(popup.grip_id, if is_dimension { false } else { g.is_midpoint })
|
||||
(
|
||||
popup.grip_id,
|
||||
matches!(item.action, GripMenuAction::MoveWithText)
|
||||
|| (!is_dimension && g.is_midpoint),
|
||||
)
|
||||
};
|
||||
self.tabs[i].active_grip = Some(GripEdit::single(
|
||||
popup.handle,
|
||||
|
|
|
|||
|
|
@ -204,7 +204,14 @@ fn plot_scene_content(
|
|||
|| !crate::scene::Scene::handle_from_wire_name(&wire.name)
|
||||
.and_then(|handle| scene.document.get_entity(handle))
|
||||
.is_some_and(|entity| {
|
||||
matches!(entity, acadrust::EntityType::Viewport(viewport) if crate::scene::Scene::is_content_viewport(viewport))
|
||||
matches!(
|
||||
entity,
|
||||
acadrust::EntityType::Viewport(viewport)
|
||||
if !crate::scene::Scene::is_sheet_viewport(
|
||||
&scene.document,
|
||||
viewport,
|
||||
)
|
||||
)
|
||||
}))
|
||||
});
|
||||
model_wires.retain(|wire| wire.plot_visible);
|
||||
|
|
|
|||
|
|
@ -235,6 +235,25 @@ impl OpenCADStudio {
|
|||
}
|
||||
}
|
||||
|
||||
/// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection
|
||||
/// set actually changed since the last broadcast.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn notify_plugins_selection_changed(&mut self) {
|
||||
if self.active_tab >= self.tabs.len() {
|
||||
return;
|
||||
}
|
||||
let i = self.active_tab;
|
||||
let tab_id = self.tabs[i].id;
|
||||
let fingerprint = self.tabs[i].scene.selection_fingerprint();
|
||||
let key = (tab_id, fingerprint);
|
||||
if self.last_plugin_selection == Some(key) {
|
||||
return;
|
||||
}
|
||||
self.last_plugin_selection = Some(key);
|
||||
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||
crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles);
|
||||
}
|
||||
|
||||
pub fn update(&mut self, msg: Message) -> Task<Message> {
|
||||
let perf_started = crate::perf::enabled().then(Instant::now);
|
||||
let perf_label = perf_message_label(&msg);
|
||||
|
|
@ -279,6 +298,11 @@ impl OpenCADStudio {
|
|||
// The block panel watches the drawing's block list and rebuilds its
|
||||
// thumbnails whenever the names change (BLOCK define, file open, …).
|
||||
self.refresh_block_palette_if_stale();
|
||||
// Let V4 plugins observe selection changes that happened while handling
|
||||
// this message (picking, window select, QSELECT, SELECTALL, grip edits,
|
||||
// and plugin request draining).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.notify_plugins_selection_changed();
|
||||
// OTRACK acquires tracking points only while a command or grip drag is
|
||||
// running; drop them once neither is active so the temporary tracking
|
||||
// points / vectors disappear when the command ends (issue #64).
|
||||
|
|
@ -3915,10 +3939,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) => {
|
||||
|
|
@ -3950,26 +3971,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;
|
||||
|
|
|
|||
|
|
@ -4620,7 +4620,10 @@ impl OpenCADStudio {
|
|||
if let Some(AcadEntityType::Viewport(vp)) =
|
||||
self.tabs[i].scene.document.get_entity(h)
|
||||
{
|
||||
if Scene::is_content_viewport(vp) {
|
||||
if !Scene::is_sheet_viewport(
|
||||
&self.tabs[i].scene.document,
|
||||
vp,
|
||||
) {
|
||||
Some(h)
|
||||
} else {
|
||||
None
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>(),
|
||||
|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),
|
||||
|
|
|
|||
|
|
@ -1427,6 +1427,11 @@ pub enum CmdResult {
|
|||
handle: Option<Handle>,
|
||||
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<Box<acadrust::MText>>,
|
||||
},
|
||||
/// Collect rich text without creating an MText entity.
|
||||
SuspendForMTextInput {
|
||||
|
|
@ -1443,6 +1448,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,
|
||||
|
|
@ -1861,6 +1874,8 @@ pub trait CadCommand: Send {
|
|||
/// Resume the command with collected rich text.
|
||||
fn on_editor_text(&mut self, _value: String) {}
|
||||
|
||||
fn on_editor_display_height(&mut self, _height: f64) {}
|
||||
|
||||
/// Called when the user clicks and `needs_entity_pick()` is true.
|
||||
/// `handle` is the nearest wire's entity handle (Handle::NULL if nothing found).
|
||||
fn on_entity_pick(&mut self, _handle: Handle, _pt: DVec3) -> CmdResult {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -304,6 +307,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument
|
|||
origin,
|
||||
color: None,
|
||||
fill_tris,
|
||||
plane: None,
|
||||
// Carry a GlyphRun so the attribute renders as SDF glyph quads
|
||||
// (same as TEXT); the strokes above are the historical fallback and
|
||||
// are suppressed by the per-group SDF path.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ use crate::entities::common::{
|
|||
edit_angle_prop as edit_angle, edit_prop as edit, num_prop as num_row, ro_prop as ro, square_grip, triangle_grip,
|
||||
};
|
||||
use crate::entities::text_support::{
|
||||
layout_mtext, resolve_text_style, GlyphBox, MTextColumns, MTextRenderOpts, MTextVAnchor,
|
||||
clamp_mtext_column_count, layout_mtext, resolve_text_style, GlyphBox, MTextColumns,
|
||||
MTextRenderOpts, MTextVAnchor,
|
||||
};
|
||||
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
|
||||
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
|
||||
|
|
@ -24,10 +25,19 @@ fn columns_of(t: &MText) -> MTextColumns {
|
|||
if c.column_type == 0 {
|
||||
return MTextColumns::default();
|
||||
}
|
||||
let count = clamp_mtext_column_count(c.column_count) as usize;
|
||||
MTextColumns {
|
||||
count: c.column_count.max(0) as usize,
|
||||
count,
|
||||
width: c.width as f32,
|
||||
gutter: c.gutter as f32,
|
||||
flow_reversed: c.flow_reversed,
|
||||
auto_height: c.auto_height,
|
||||
heights: c
|
||||
.heights
|
||||
.iter()
|
||||
.take(count)
|
||||
.map(|height| *height as f32)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +130,14 @@ pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec<GlyphBox>
|
|||
attach_h_anchor,
|
||||
v_anchor,
|
||||
line_spacing_factor: t.line_spacing_factor as f32,
|
||||
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
|
||||
exact_line_spacing: matches!(
|
||||
t.line_spacing_style,
|
||||
acadrust::entities::LineSpacingStyle::Exactly
|
||||
),
|
||||
rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32,
|
||||
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom)
|
||||
|| matches!(t.drawing_direction, DrawingDirection::ByStyle)
|
||||
&& resolved_style.is_vertical,
|
||||
want_glyph_boxes: true,
|
||||
columns: columns_of(t),
|
||||
});
|
||||
|
|
@ -169,7 +186,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 +255,43 @@ fn grips(t: &MText) -> Vec<GripDef> {
|
|||
t.insertion_point.z,
|
||||
);
|
||||
let (dir, k) = width_grip_axis(t);
|
||||
let width_grip = p + dir * (k * t.rectangle_width.max(0.0));
|
||||
vec![square_grip(0, p), triangle_grip(1, width_grip)]
|
||||
let column_count = clamp_mtext_column_count(t.column_data.column_count);
|
||||
let columns_active =
|
||||
t.column_data.column_type != 0 && column_count > 1 && t.column_data.width > 0.0;
|
||||
let block_width = if columns_active {
|
||||
t.column_data.width * column_count as f64
|
||||
+ t.column_data.gutter * (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 {
|
||||
|
|
@ -248,10 +307,9 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
|
|||
// 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 {
|
||||
|
|
@ -353,7 +411,7 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
|
|||
.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",
|
||||
|
|
@ -371,20 +429,6 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
|
|||
.collect(),
|
||||
},
|
||||
},
|
||||
// Count / width / gutter are live only when columns are on.
|
||||
num_row(
|
||||
t!("Column count").as_ref(),
|
||||
"col_count",
|
||||
t.column_data.column_count as f64,
|
||||
col_type != 0,
|
||||
),
|
||||
num_row(t!("Column width").as_ref(), "col_width", t.column_data.width, col_type != 0),
|
||||
num_row(
|
||||
t!("Column gutter").as_ref(),
|
||||
"col_gutter",
|
||||
t.column_data.gutter,
|
||||
col_type != 0,
|
||||
),
|
||||
Property {
|
||||
label: t!("Text frame").into_owned(),
|
||||
field: "text_frame",
|
||||
|
|
@ -502,21 +546,20 @@ 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_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 +584,60 @@ 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);
|
||||
let column_count = clamp_mtext_column_count(t.column_data.column_count);
|
||||
if t.column_data.column_type != 0 && column_count > 1 {
|
||||
t.column_data.column_count = column_count;
|
||||
let gaps = (column_count - 1) as f64;
|
||||
t.column_data.width =
|
||||
((width - t.column_data.gutter * gaps) / 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 {
|
||||
let column_count = clamp_mtext_column_count(t.column_data.column_count);
|
||||
t.column_data.column_count = column_count;
|
||||
t.column_data.heights = vec![height; column_count 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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,11 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<Rende
|
|||
},
|
||||
),
|
||||
line_spacing_factor: ctx.line_spacing_factor as f32,
|
||||
exact_line_spacing: matches!(
|
||||
ctx.line_spacing_style,
|
||||
acadrust::entities::LineSpacingStyle::Exactly
|
||||
),
|
||||
rectangle_height: 0.0,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
}))
|
||||
|
|
@ -412,6 +417,7 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) {
|
|||
oblique_angle: 0.0,
|
||||
is_backward: false,
|
||||
is_upside_down: false,
|
||||
is_vertical: false,
|
||||
};
|
||||
let layout = layout_mtext(&MTextRenderOpts {
|
||||
columns: Default::default(),
|
||||
|
|
@ -424,6 +430,11 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) {
|
|||
attach_h_anchor: 0.0,
|
||||
v_anchor: MTextVAnchor::Top,
|
||||
line_spacing_factor: ctx.line_spacing_factor as f32,
|
||||
exact_line_spacing: matches!(
|
||||
ctx.line_spacing_style,
|
||||
acadrust::entities::LineSpacingStyle::Exactly
|
||||
),
|
||||
rectangle_height: 0.0,
|
||||
vertical_text: vertical,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
|
|
@ -2039,6 +2050,11 @@ impl MultiLeaderTess for MultiLeader {
|
|||
attach_h_anchor: h_anchor,
|
||||
v_anchor,
|
||||
line_spacing_factor: ctx.line_spacing_factor as f32,
|
||||
exact_line_spacing: matches!(
|
||||
ctx.line_spacing_style,
|
||||
acadrust::entities::LineSpacingStyle::Exactly
|
||||
),
|
||||
rectangle_height: 0.0,
|
||||
// Vertical flow (top-to-bottom) is stored per-context.
|
||||
vertical_text: matches!(
|
||||
ctx.text_flow_direction,
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ impl RenderConvertible for RasterImage {
|
|||
origin: ins,
|
||||
color: None,
|
||||
fill_tris: vec![],
|
||||
plane: None,
|
||||
run: None,
|
||||
}];
|
||||
|
||||
|
|
@ -218,6 +219,7 @@ impl RenderConvertible for RasterImage {
|
|||
origin,
|
||||
color: None,
|
||||
fill_tris,
|
||||
plane: None,
|
||||
run: Some(GlyphRun {
|
||||
text: path.to_string(),
|
||||
font,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,7 +43,24 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) {
|
|||
(HA::Left, VA::Baseline)
|
||||
);
|
||||
if needs_alignment_point {
|
||||
if t.alignment_point.is_none() {
|
||||
if matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) {
|
||||
let point = t.alignment_point.unwrap_or(t.insertion_point);
|
||||
let dx = point.x - t.insertion_point.x;
|
||||
let dy = point.y - t.insertion_point.y;
|
||||
if dx.hypot(dy) <= 1.0e-9 {
|
||||
let span = t.height.max(1.0e-6)
|
||||
* t.width_factor.abs().max(0.01)
|
||||
* t.value.chars().count().max(1) as f64
|
||||
* 0.6;
|
||||
t.alignment_point = Some(acadrust::types::Vector3::new(
|
||||
t.insertion_point.x + t.rotation.cos() * span,
|
||||
t.insertion_point.y + t.rotation.sin() * span,
|
||||
t.insertion_point.z,
|
||||
));
|
||||
} else {
|
||||
t.alignment_point = Some(point);
|
||||
}
|
||||
} else if t.alignment_point.is_none() {
|
||||
t.alignment_point = Some(t.insertion_point);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -51,6 +68,21 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) {
|
|||
}
|
||||
}
|
||||
|
||||
fn two_point_span(t: &Text) -> Option<(f64, f64)> {
|
||||
if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) {
|
||||
return None;
|
||||
}
|
||||
let point = t.alignment_point?;
|
||||
let dx = point.x - t.insertion_point.x;
|
||||
let dy = point.y - t.insertion_point.y;
|
||||
let distance = dx.hypot(dy);
|
||||
(distance > 1.0e-9).then(|| (distance, dy.atan2(dx)))
|
||||
}
|
||||
|
||||
fn displayed_rotation(t: &Text) -> f64 {
|
||||
two_point_span(t).map_or(t.rotation, |(_, angle)| angle)
|
||||
}
|
||||
|
||||
/// Resolved placement of a TEXT run: the baseline-anchored run origin (WCS xy)
|
||||
/// plus every parameter needed to lay the glyphs out. Shared by `to_render` (the
|
||||
/// stroke path) and the SDF-quad text collector so both place text identically.
|
||||
|
|
@ -98,8 +130,12 @@ pub(crate) fn acad_text_encode(value: &str) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity {
|
||||
let p = text_run_placement(t, document);
|
||||
pub(crate) fn to_render_at_scale(
|
||||
t: &Text,
|
||||
document: &acadrust::CadDocument,
|
||||
annotation_scale: f32,
|
||||
) -> RenderEntity {
|
||||
let p = text_run_placement_at_scale(t, document, annotation_scale);
|
||||
let snap_pt = glam::DVec3::new(p.wcs_insertion[0], p.wcs_insertion[1], p.wcs_insertion[2]);
|
||||
// Parse `%%` codes via acadrust, re-encoded for the stroke tessellator.
|
||||
let value = acad_text_encode(&p.value);
|
||||
|
|
@ -120,6 +156,7 @@ fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity {
|
|||
origin: p.origin,
|
||||
color: None,
|
||||
fill_tris,
|
||||
plane: None,
|
||||
run: Some(GlyphRun {
|
||||
text: value,
|
||||
font: p.font.clone(),
|
||||
|
|
@ -140,7 +177,16 @@ fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity {
|
|||
|
||||
/// Compute a TEXT entity's run placement (origin + layout params). Extracted
|
||||
/// from `to_render` verbatim so the stroke and SDF-quad paths agree exactly.
|
||||
pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPlacement {
|
||||
pub fn text_run_placement_at_scale(
|
||||
t: &Text,
|
||||
document: &acadrust::CadDocument,
|
||||
annotation_scale: f32,
|
||||
) -> TextPlacement {
|
||||
let annotation_scale = if annotation_scale.is_finite() && annotation_scale > 1.0e-9 {
|
||||
annotation_scale
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let normal = (t.normal.x, t.normal.y, t.normal.z);
|
||||
let (wsx, wsy, wsz) = crate::scene::view::transform::ocs_point_to_wcs(
|
||||
(
|
||||
|
|
@ -152,10 +198,9 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
|
|||
);
|
||||
let resolved_style = resolve_text_style(&t.style, document);
|
||||
let font_name = resolved_style.font_name;
|
||||
// AutoCAD text geometry rule: the entity stores the FINAL width factor /
|
||||
// oblique angle, copied from the style at creation and persisting through
|
||||
// style edits. Use it as-is. Only fall back to the style when the entity
|
||||
// value is missing (the parser reports 0.0 for default-omitted fields).
|
||||
// The entity stores the final width factor and oblique angle copied from
|
||||
// its style at creation. Only fall back to the style when an omitted field
|
||||
// was read as zero.
|
||||
let base_wf = if t.width_factor.abs() > 1e-9 {
|
||||
(t.width_factor as f32).clamp(0.01, 100.0)
|
||||
} else {
|
||||
|
|
@ -168,17 +213,44 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
|
|||
// mirror, and XOR keeps a double mirror an involution.
|
||||
let eff_backward = resolved_style.is_backward ^ (t.generation_flags & 0x2 != 0);
|
||||
let eff_upside = resolved_style.is_upside_down ^ (t.generation_flags & 0x4 != 0);
|
||||
let width_factor = if eff_backward { -base_wf } else { base_wf };
|
||||
let rotation = if eff_upside {
|
||||
t.rotation as f32 + std::f32::consts::PI
|
||||
} else {
|
||||
t.rotation as f32
|
||||
};
|
||||
let mut width_factor = if eff_backward { -base_wf } else { base_wf };
|
||||
let oblique_angle = if t.oblique_angle.abs() > 1e-9 {
|
||||
t.oblique_angle as f32
|
||||
} else {
|
||||
resolved_style.oblique_angle
|
||||
};
|
||||
let value_for_bounds = resolve_dxf_special_chars(&t.value);
|
||||
let mut height = t.height.max(1.0e-9) as f32;
|
||||
let mut base_rotation = t.rotation as f32;
|
||||
|
||||
// Aligned and Fit are true two-point modes. Both derive their baseline
|
||||
// direction from the endpoints. Aligned scales height uniformly; Fit keeps
|
||||
// the height and changes only the horizontal factor.
|
||||
if let Some((span, angle)) = two_point_span(t) {
|
||||
base_rotation = angle as f32;
|
||||
if let Some(base_bounds) = text_local_bounds(
|
||||
&font_name,
|
||||
&value_for_bounds,
|
||||
height,
|
||||
width_factor,
|
||||
oblique_angle,
|
||||
) {
|
||||
if base_bounds.advance > 1.0e-6 {
|
||||
let scale =
|
||||
(span as f32 / annotation_scale / base_bounds.advance).max(1.0e-6);
|
||||
if matches!(t.horizontal_alignment, HA::Aligned) {
|
||||
height *= scale;
|
||||
} else {
|
||||
width_factor *= scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let rotation = if eff_upside {
|
||||
base_rotation + std::f32::consts::PI
|
||||
} else {
|
||||
base_rotation
|
||||
};
|
||||
// Anchor stays f64: large coordinates (UTM etc.) lose ~0.5 units of
|
||||
// precision when cast to f32, which snaps text baselines onto a coarse
|
||||
// grid and makes adjacent rows collide. Only the small local offsets
|
||||
|
|
@ -188,17 +260,16 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
|
|||
&t.vertical_alignment,
|
||||
&t.alignment_point,
|
||||
) {
|
||||
(HA::Aligned | HA::Middle | HA::Fit, _, Some(a)) => [a.x, a.y],
|
||||
(HA::Aligned | HA::Fit, _, _) => [t.insertion_point.x, t.insertion_point.y],
|
||||
(HA::Middle, _, Some(a)) => [a.x, a.y],
|
||||
(HA::Center | HA::Right, _, Some(a)) => [a.x, a.y],
|
||||
(_, VA::Bottom | VA::Middle | VA::Top, Some(a)) => [a.x, a.y],
|
||||
_ => [t.insertion_point.x, t.insertion_point.y],
|
||||
};
|
||||
// Strip %%u/%%o for bounds (they add no width); resolve %%d/%%c/%%p for correct advance.
|
||||
let value_for_bounds = resolve_dxf_special_chars(&t.value);
|
||||
let bounds = text_local_bounds(
|
||||
&font_name,
|
||||
&value_for_bounds,
|
||||
t.height as f32,
|
||||
height,
|
||||
width_factor,
|
||||
oblique_angle,
|
||||
);
|
||||
|
|
@ -213,7 +284,8 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
|
|||
let ax = match t.horizontal_alignment {
|
||||
HA::Left => 0.0,
|
||||
HA::Center | HA::Middle => b.advance * 0.5 * sign,
|
||||
HA::Right | HA::Aligned | HA::Fit => b.advance * sign,
|
||||
HA::Right => b.advance * sign,
|
||||
HA::Aligned | HA::Fit => 0.0,
|
||||
};
|
||||
// Vertical anchor uses the inked extent (cap / baseline geometry).
|
||||
let ay = match t.vertical_alignment {
|
||||
|
|
@ -236,7 +308,7 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
|
|||
];
|
||||
TextPlacement {
|
||||
origin,
|
||||
height: t.height as f32,
|
||||
height,
|
||||
rotation,
|
||||
width_factor,
|
||||
oblique_angle,
|
||||
|
|
@ -247,12 +319,19 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
|
|||
}
|
||||
|
||||
fn grips(t: &Text) -> Vec<GripDef> {
|
||||
let p = glam::DVec3::new(
|
||||
let insertion = glam::DVec3::new(
|
||||
t.insertion_point.x,
|
||||
t.insertion_point.y,
|
||||
t.insertion_point.z,
|
||||
);
|
||||
vec![square_grip(0, p)]
|
||||
let mut grips = vec![square_grip(0, insertion)];
|
||||
if let Some(point) = t.alignment_point {
|
||||
grips.push(square_grip(
|
||||
1,
|
||||
glam::DVec3::new(point.x, point.y, point.z),
|
||||
));
|
||||
}
|
||||
grips
|
||||
}
|
||||
|
||||
fn properties(t: &Text, text_style_names: &[String]) -> Vec<PropSection> {
|
||||
|
|
@ -263,15 +342,25 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec<PropSection> {
|
|||
// both points are live.
|
||||
let is_plain_left = matches!(t.horizontal_alignment, HA::Left)
|
||||
&& matches!(t.vertical_alignment, VA::Baseline);
|
||||
let pos_editable = is_plain_left || matches!(t.horizontal_alignment, HA::Aligned | HA::Fit);
|
||||
let align_editable = !is_plain_left;
|
||||
// The alignment point is meaningless (reset to the origin) for plain-Left text.
|
||||
let is_aligned = matches!(t.horizontal_alignment, HA::Aligned);
|
||||
let is_two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit);
|
||||
// The visible Properties palette exposes Text alignment as calculated
|
||||
// coordinates and Position as the editable placement point. For aligned
|
||||
// and fit text Position targets the first endpoint; for every other
|
||||
// non-left mode it targets the alignment anchor that actually places the
|
||||
// glyph run.
|
||||
let position_uses_alignment = !is_plain_left && !is_two_point;
|
||||
let ap = t.alignment_point.unwrap_or(t.insertion_point);
|
||||
let (ax, ay, az) = if align_editable {
|
||||
let (ax, ay, az) = if !is_plain_left {
|
||||
(ap.x, ap.y, ap.z)
|
||||
} else {
|
||||
(0.0, 0.0, 0.0)
|
||||
};
|
||||
let position = if position_uses_alignment {
|
||||
ap
|
||||
} else {
|
||||
t.insertion_point
|
||||
};
|
||||
vec![
|
||||
PropSection {
|
||||
title: t!("Text").into_owned(),
|
||||
|
|
@ -331,22 +420,31 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec<PropSection> {
|
|||
t!("Height").as_ref(),
|
||||
"height",
|
||||
t.height,
|
||||
crate::entities::common::style_fixed_height(&t.style).is_none(),
|
||||
!is_aligned
|
||||
&& crate::entities::common::style_fixed_height(&t.style).is_none(),
|
||||
),
|
||||
edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees()),
|
||||
if is_two_point {
|
||||
ro(
|
||||
t!("Rotation").as_ref(),
|
||||
"rotation",
|
||||
crate::entities::common::format_angle(displayed_rotation(t)),
|
||||
)
|
||||
} else {
|
||||
edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees())
|
||||
},
|
||||
edit(t!("Width factor").as_ref(), "width_factor", t.width_factor),
|
||||
edit_angle(t!("Obliquing").as_ref(), "oblique_angle", t.oblique_angle.to_degrees()),
|
||||
num_row(t!("Text alignment X").as_ref(), "align_x", ax, align_editable),
|
||||
num_row(t!("Text alignment Y").as_ref(), "align_y", ay, align_editable),
|
||||
num_row(t!("Text alignment Z").as_ref(), "align_z", az, align_editable),
|
||||
num_row(t!("Text alignment X").as_ref(), "align_x", ax, false),
|
||||
num_row(t!("Text alignment Y").as_ref(), "align_y", ay, false),
|
||||
num_row(t!("Text alignment Z").as_ref(), "align_z", az, false),
|
||||
],
|
||||
},
|
||||
PropSection {
|
||||
title: t!("Geometry").into_owned(),
|
||||
props: vec![
|
||||
num_row(t!("Position X").as_ref(), "ins_x", t.insertion_point.x, pos_editable),
|
||||
num_row(t!("Position Y").as_ref(), "ins_y", t.insertion_point.y, pos_editable),
|
||||
num_row(t!("Position Z").as_ref(), "ins_z", t.insertion_point.z, pos_editable),
|
||||
num_row(t!("Position X").as_ref(), "ins_x", position.x, true),
|
||||
num_row(t!("Position Y").as_ref(), "ins_y", position.y, true),
|
||||
num_row(t!("Position Z").as_ref(), "ins_z", position.z, true),
|
||||
],
|
||||
},
|
||||
PropSection {
|
||||
|
|
@ -439,37 +537,64 @@ fn apply_geom_prop(t: &mut Text, field: &str, value: &str) {
|
|||
return;
|
||||
};
|
||||
match field {
|
||||
"ins_x" => t.insertion_point.x = v,
|
||||
"ins_y" => t.insertion_point.y = v,
|
||||
"ins_z" => t.insertion_point.z = v,
|
||||
"align_x" | "align_y" | "align_z" => {
|
||||
let ins = t.insertion_point;
|
||||
let ap = t.alignment_point.get_or_insert(ins);
|
||||
"ins_x" | "ins_y" | "ins_z" => {
|
||||
let plain_left = matches!(t.horizontal_alignment, HA::Left)
|
||||
&& matches!(t.vertical_alignment, VA::Baseline);
|
||||
let two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit);
|
||||
let target = if !plain_left && !two_point {
|
||||
let insertion = t.insertion_point;
|
||||
t.alignment_point.get_or_insert(insertion)
|
||||
} else {
|
||||
&mut t.insertion_point
|
||||
};
|
||||
match field {
|
||||
"align_x" => ap.x = v,
|
||||
"align_y" => ap.y = v,
|
||||
_ => ap.z = v,
|
||||
"ins_x" => target.x = v,
|
||||
"ins_y" => target.y = v,
|
||||
_ => target.z = v,
|
||||
}
|
||||
}
|
||||
"height" if v > 0.0 => t.height = v,
|
||||
"rotation" => t.rotation = v.to_radians(),
|
||||
"align_x" | "align_y" | "align_z" => {
|
||||
// Calculated display rows are intentionally not writable.
|
||||
return;
|
||||
}
|
||||
"height"
|
||||
if v > 0.0 && !matches!(t.horizontal_alignment, HA::Aligned) =>
|
||||
{
|
||||
t.height = v
|
||||
}
|
||||
"rotation" if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) => {
|
||||
t.rotation = v.to_radians()
|
||||
}
|
||||
"width_factor" if v > 0.0 => t.width_factor = v,
|
||||
"oblique_angle" => t.oblique_angle = v.to_radians(),
|
||||
"oblique_angle" if (-85.0..=85.0).contains(&v) => {
|
||||
t.oblique_angle = v.to_radians()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_grip(t: &mut Text, _grip_id: usize, apply: GripApply) {
|
||||
fn apply_grip(t: &mut Text, grip_id: usize, apply: GripApply) {
|
||||
match apply {
|
||||
GripApply::Absolute(p) => {
|
||||
t.insertion_point.x = p.x as f64;
|
||||
t.insertion_point.y = p.y as f64;
|
||||
t.insertion_point.z = p.z as f64;
|
||||
let target = if grip_id == 1 {
|
||||
let insertion = t.insertion_point;
|
||||
t.alignment_point.get_or_insert(insertion)
|
||||
} else {
|
||||
&mut t.insertion_point
|
||||
};
|
||||
target.x = p.x;
|
||||
target.y = p.y;
|
||||
target.z = p.z;
|
||||
}
|
||||
GripApply::Translate(d) => {
|
||||
t.insertion_point.x += d.x as f64;
|
||||
t.insertion_point.y += d.y as f64;
|
||||
t.insertion_point.z += d.z as f64;
|
||||
t.insertion_point.x += d.x;
|
||||
t.insertion_point.y += d.y;
|
||||
t.insertion_point.z += d.z;
|
||||
if let Some(point) = t.alignment_point.as_mut() {
|
||||
point.x += d.x;
|
||||
point.y += d.y;
|
||||
point.z += d.z;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -495,7 +620,7 @@ fn apply_transform(t: &mut Text, tr: &EntityTransform) {
|
|||
|
||||
impl RenderConvertible for Text {
|
||||
fn to_render(&self, document: &acadrust::CadDocument) -> Option<RenderEntity> {
|
||||
Some(to_render(self, document))
|
||||
Some(to_render_at_scale(self, document, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -551,7 +676,9 @@ impl Grippable for Text {
|
|||
value: f64,
|
||||
) {
|
||||
use crate::scene::model::object::GripMenuAction as A;
|
||||
if matches!(action, A::RotateText) {
|
||||
if matches!(action, A::RotateText)
|
||||
&& !matches!(self.horizontal_alignment, HA::Aligned | HA::Fit)
|
||||
{
|
||||
self.rotation = value.to_radians();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,15 @@ 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.
|
||||
pub(crate) const MAX_MTEXT_COLUMNS: i32 = 256;
|
||||
|
||||
pub(crate) fn clamp_mtext_column_count(count: i32) -> i32 {
|
||||
count.clamp(1, MAX_MTEXT_COLUMNS)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MTextColumns {
|
||||
/// Column count. 0 or 1 both mean "no column layout".
|
||||
pub count: usize,
|
||||
|
|
@ -1036,6 +1048,12 @@ pub struct MTextColumns {
|
|||
pub width: f32,
|
||||
/// Gap between two adjacent columns, world units.
|
||||
pub gutter: f32,
|
||||
/// Whether logical column flow starts at the opposite side.
|
||||
pub flow_reversed: bool,
|
||||
/// Dynamic-column height is balanced from content when true.
|
||||
pub auto_height: bool,
|
||||
/// Optional manual height for each logical column.
|
||||
pub heights: Vec<f32>,
|
||||
}
|
||||
|
||||
impl MTextColumns {
|
||||
|
|
@ -1046,7 +1064,17 @@ 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)
|
||||
}
|
||||
|
||||
fn total_width(&self) -> f32 {
|
||||
self.width * self.count as f32
|
||||
+ self.gutter.max(0.0) * self.count.saturating_sub(1) as f32
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1118,6 +1146,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
indent_right: f32,
|
||||
tab_stops: Vec<TabStop>,
|
||||
is_first_in_paragraph: bool,
|
||||
starts_column: bool,
|
||||
/// Which column this line lives in; always 0 without a column layout.
|
||||
column: usize,
|
||||
/// Extra gap above (paragraph's first wrapped line only) / below (last
|
||||
|
|
@ -1128,7 +1157,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
line_spacing: Option<ParaLineSpacing>,
|
||||
}
|
||||
|
||||
let cols = opts.columns;
|
||||
let cols = opts.columns.clone();
|
||||
// A `\N` past the last column has nowhere to go — keep those lines in the
|
||||
// last one rather than laying them out beyond the block.
|
||||
let last_col = cols.count.saturating_sub(1);
|
||||
|
|
@ -1255,6 +1284,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
indent_right: para.indent_right,
|
||||
tab_stops: para.tab_stops.clone(),
|
||||
is_first_in_paragraph: idx == 0,
|
||||
starts_column: idx == 0 && para.starts_column,
|
||||
column,
|
||||
// The gap above rides the first wrapped line; the gap below the
|
||||
// last, so an interior wrap keeps normal single spacing.
|
||||
|
|
@ -1277,6 +1307,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
indent_right: 0.0,
|
||||
tab_stops: Vec::new(),
|
||||
is_first_in_paragraph: true,
|
||||
starts_column: false,
|
||||
column: 0,
|
||||
space_before: 0.0,
|
||||
space_after: 0.0,
|
||||
|
|
@ -1366,15 +1397,86 @@ 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 mut pending_after = 0.0_f32;
|
||||
let total_advance: f32 = sub_lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, line)| {
|
||||
if line.starts_column {
|
||||
pending_after = 0.0;
|
||||
}
|
||||
let spacing = if line.is_first_in_paragraph {
|
||||
line.space_before + pending_after
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
pending_after = line.space_after;
|
||||
per_line_h.get(index).copied().unwrap_or(entity_h) + spacing
|
||||
})
|
||||
.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;
|
||||
let mut pending_after = 0.0_f32;
|
||||
for (index, line) in sub_lines.iter_mut().enumerate() {
|
||||
if line.starts_column {
|
||||
current_column = (current_column + 1).min(cols.count - 1);
|
||||
used = 0.0;
|
||||
pending_after = 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 line_advance = per_line_h.get(index).copied().unwrap_or(entity_h);
|
||||
let spacing = if line.is_first_in_paragraph {
|
||||
line.space_before + pending_after
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut advance = line_advance + spacing;
|
||||
if limit > 0.0
|
||||
&& used > 0.0
|
||||
&& used + advance > limit
|
||||
&& current_column + 1 < cols.count
|
||||
{
|
||||
current_column += 1;
|
||||
used = 0.0;
|
||||
advance = line_advance
|
||||
+ if line.is_first_in_paragraph {
|
||||
line.space_before
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
line.column = current_column;
|
||||
used += advance;
|
||||
pending_after = line.space_after;
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
|
@ -1442,7 +1544,12 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
MTextVAnchor::BottomOfTopLine => 0.0,
|
||||
};
|
||||
let attach_h_anchor = opts.attach_h_anchor;
|
||||
let box_left = -attach_h_anchor * rect_w;
|
||||
let block_width = if cols.active() {
|
||||
cols.total_width()
|
||||
} else {
|
||||
rect_w
|
||||
};
|
||||
let box_left = -attach_h_anchor * block_width;
|
||||
let rot = opts.rotation;
|
||||
let (cos_r, sin_r) = (rot.cos(), rot.sin());
|
||||
let ins_x = opts.insertion[0];
|
||||
|
|
@ -1734,6 +1841,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
origin,
|
||||
color,
|
||||
fill_tris,
|
||||
plane: None,
|
||||
run: Some(GlyphRun {
|
||||
text: s,
|
||||
font: font_name.to_string(),
|
||||
|
|
@ -1806,6 +1914,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
origin,
|
||||
color,
|
||||
fill_tris,
|
||||
plane: None,
|
||||
run: Some(GlyphRun {
|
||||
text: text.clone(),
|
||||
font: font_name.to_string(),
|
||||
|
|
@ -1840,6 +1949,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
origin,
|
||||
color,
|
||||
fill_tris: vec![],
|
||||
plane: None,
|
||||
run: None,
|
||||
});
|
||||
}
|
||||
|
|
@ -1938,6 +2048,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
origin,
|
||||
color,
|
||||
fill_tris,
|
||||
plane: None,
|
||||
run: Some(GlyphRun {
|
||||
text: text.to_string(),
|
||||
font: font_name.to_string(),
|
||||
|
|
@ -1953,6 +2064,49 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
emit(denominator, valign_dy);
|
||||
emit(numerator, valign_dy + run_h * STACK_RAISE);
|
||||
|
||||
if opts.want_glyph_boxes {
|
||||
let slots = numerator.chars().count()
|
||||
+ denominator.chars().count()
|
||||
+ usize::from(!denominator.is_empty());
|
||||
if slots > 0 {
|
||||
let top = valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE);
|
||||
for index in 0..slots {
|
||||
let x0 = cursor_x + slot_w * index as f32 / slots as f32;
|
||||
let x1 = cursor_x + slot_w * (index + 1) as f32 / slots as f32;
|
||||
let corners = [
|
||||
to_world(line_base_x, line_base_y, x0, valign_dy),
|
||||
to_world(line_base_x, line_base_y, x1, valign_dy),
|
||||
to_world(line_base_x, line_base_y, x0, top),
|
||||
to_world(line_base_x, line_base_y, x1, top),
|
||||
];
|
||||
let xmin = corners
|
||||
.iter()
|
||||
.map(|p| p.0)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let xmax = corners
|
||||
.iter()
|
||||
.map(|p| p.0)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let ymin = corners
|
||||
.iter()
|
||||
.map(|p| p.1)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let ymax = corners
|
||||
.iter()
|
||||
.map(|p| p.1)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
glyph_boxes.push(GlyphBox {
|
||||
vis,
|
||||
xmin,
|
||||
xmax,
|
||||
ymin,
|
||||
ymax,
|
||||
});
|
||||
vis += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *bar && slot_w > 0.0 {
|
||||
// The rule is plain geometry, not a glyph, so it rides a
|
||||
// run-less group — those keep their strokes, while a
|
||||
|
|
@ -1972,9 +2126,15 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
origin: [ox, oy],
|
||||
color,
|
||||
fill_tris: Vec::new(),
|
||||
plane: None,
|
||||
run: None,
|
||||
});
|
||||
}
|
||||
local_bounds[0] = local_bounds[0].min(cursor_x);
|
||||
local_bounds[1] = local_bounds[1].min(line_ly + valign_dy);
|
||||
local_bounds[2] = local_bounds[2].max(cursor_x + slot_w);
|
||||
local_bounds[3] = local_bounds[3]
|
||||
.max(line_ly + valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE));
|
||||
cursor_x += slot_w;
|
||||
}
|
||||
AtomKind::Space => {
|
||||
|
|
@ -2041,6 +2201,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
origin,
|
||||
color,
|
||||
fill_tris: vec![],
|
||||
plane: None,
|
||||
run: None,
|
||||
});
|
||||
}
|
||||
|
|
@ -2169,6 +2330,7 @@ mod tests {
|
|||
oblique_angle: 0.0,
|
||||
is_backward: false,
|
||||
is_upside_down: false,
|
||||
is_vertical: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2199,6 +2361,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 +2386,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 +2544,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 +2557,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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ use acadrust::entities::Tolerance;
|
|||
use crate::command::EntityTransform;
|
||||
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip};
|
||||
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
|
||||
use crate::scene::convert::acad_to_render::{GlyphRun, TextStroke, RenderEntity, RenderObject};
|
||||
use crate::scene::convert::acad_to_render::{
|
||||
GlyphRun, RenderEntity, RenderObject, TextPlane, TextStroke,
|
||||
};
|
||||
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
|
||||
use crate::scene::model::wire_model::SnapHint;
|
||||
use crate::scene::text::lff;
|
||||
|
|
@ -131,7 +133,7 @@ fn parse_cell(s: &str) -> Cell {
|
|||
/// (`\Fgdt.shx|b0|i0|c134|p6;j`), so match the name and skip to the `;` rather
|
||||
/// than demanding the bare `\Fgdt;` form — the parameterised spelling is legal
|
||||
/// and would otherwise drop the symbol silently.
|
||||
fn symbol_font_switch(inner: &str) -> Option<char> {
|
||||
pub(crate) fn symbol_font_switch(inner: &str) -> Option<char> {
|
||||
let rest = inner
|
||||
.strip_prefix("\\F")
|
||||
.or_else(|| inner.strip_prefix("\\f"))?;
|
||||
|
|
@ -247,15 +249,13 @@ fn content_width(
|
|||
/// One text run of a feature-control frame, ready to become a `TextStroke`
|
||||
/// with a `GlyphRun` so the run can render as SDF glyph quads (or, when
|
||||
/// SDF is off, from `strokes`). `origin` is relative to the tolerance insertion
|
||||
/// point (already rotated); `strokes` are the glyph polylines rotated about the
|
||||
/// origin (no origin translation — the wire-builder adds the origin).
|
||||
/// point; `strokes` remain in the frame's local plane.
|
||||
struct TolCell {
|
||||
text: String,
|
||||
font: String,
|
||||
origin: [f32; 2],
|
||||
strokes: Vec<Vec<[f32; 2]>>,
|
||||
height: f32,
|
||||
rotation: f32,
|
||||
width_factor: f32,
|
||||
oblique: f32,
|
||||
}
|
||||
|
|
@ -263,8 +263,8 @@ struct TolCell {
|
|||
/// Tessellate a Tolerance entity's feature-control frame.
|
||||
///
|
||||
/// Returns (`box_strokes`, `cells`):
|
||||
/// - `box_strokes` — the outer border, row separators and column dividers as
|
||||
/// 2-D polylines (rotated; run-less, so they always render as strokes)
|
||||
/// - `box_strokes` — the outer border, row separators and column dividers in
|
||||
/// frame-local coordinates
|
||||
/// - `cells` — one [`TolCell`] per non-empty cell, carrying its text + a
|
||||
/// `GlyphRun` so the cell renders as SDF text (frame stays geometry).
|
||||
fn tessellate_tolerance(
|
||||
|
|
@ -341,13 +341,6 @@ fn tessellate_tolerance(
|
|||
.map(|row| row.iter().map(|c| cell_width(c)).collect())
|
||||
.collect();
|
||||
|
||||
// ── Transform helpers (local space — translation applied in tessellate.rs) ──
|
||||
let angle = (tol.direction.y as f32).atan2(tol.direction.x as f32);
|
||||
let (sa, ca) = angle.sin_cos();
|
||||
|
||||
// Rotate only; origin is kept as f64 and applied later with full precision.
|
||||
let rot = |x: f32, y: f32| -> [f32; 2] { [x * ca - y * sa, x * sa + y * ca] };
|
||||
|
||||
let mut box_out: Vec<Vec<[f32; 2]>> = Vec::new();
|
||||
let mut cells: Vec<TolCell> = Vec::new();
|
||||
|
||||
|
|
@ -370,11 +363,11 @@ fn tessellate_tolerance(
|
|||
let y0 = row_bottom(ri);
|
||||
let y1 = y0 + cell_h;
|
||||
box_out.push(vec![
|
||||
rot(0.0, y0),
|
||||
rot(rw, y0),
|
||||
rot(rw, y1),
|
||||
rot(0.0, y1),
|
||||
rot(0.0, y0),
|
||||
[0.0, y0],
|
||||
[rw, y0],
|
||||
[rw, y1],
|
||||
[0.0, y1],
|
||||
[0.0, y0],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -385,15 +378,13 @@ fn tessellate_tolerance(
|
|||
let mut x_cursor = 0.0_f32;
|
||||
for w in widths.iter().take(widths.len().saturating_sub(1)) {
|
||||
x_cursor += w;
|
||||
box_out.push(vec![rot(x_cursor, y0), rot(x_cursor, y1)]);
|
||||
box_out.push(vec![[x_cursor, y0], [x_cursor, y1]]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text content per cell ─────────────────────────────────────────────
|
||||
// Each cell becomes a TolCell: its origin is the (rotated) cell position
|
||||
// relative to the insertion point; its `strokes` are the glyph polylines
|
||||
// rotated about that origin (used only when SDF is off). The GlyphRun the
|
||||
// caller attaches lets the cell render as SDF text.
|
||||
// Each cell stays in frame-local coordinates; the renderer maps the frame
|
||||
// into its world-space plane.
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
// The compartment is 2h tall and the character h, so an h/2 margin
|
||||
// centres the text on the row's centreline.
|
||||
|
|
@ -427,21 +418,17 @@ fn tessellate_tolerance(
|
|||
&font,
|
||||
&text,
|
||||
);
|
||||
// Glyph polylines rotated about the run's origin (no origin
|
||||
// translation — the wire-builder adds `origin`).
|
||||
let strokes: Vec<Vec<[f32; 2]>> = local_strokes
|
||||
.into_iter()
|
||||
.map(|pl| pl.into_iter().map(|[px, py]| rot(px, py)).collect())
|
||||
.filter(|pl: &Vec<[f32; 2]>| !pl.is_empty())
|
||||
.collect();
|
||||
let advance = run_advance(&text, &font, h, width_factor, oblique);
|
||||
cells.push(TolCell {
|
||||
text,
|
||||
font,
|
||||
origin: rot(run_x, row_y),
|
||||
origin: [run_x, row_y],
|
||||
strokes,
|
||||
height: h,
|
||||
rotation: angle,
|
||||
width_factor,
|
||||
oblique,
|
||||
});
|
||||
|
|
@ -455,6 +442,24 @@ fn tessellate_tolerance(
|
|||
(box_out, cells)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_strokes(
|
||||
tol: &Tolerance,
|
||||
doc: &acadrust::CadDocument,
|
||||
) -> Vec<Vec<[f32; 2]>> {
|
||||
tessellate_tolerance(tol, doc).0
|
||||
}
|
||||
|
||||
fn frame_basis(tol: &Tolerance) -> (glam::DVec3, glam::DVec3) {
|
||||
let normal = glam::DVec3::new(tol.normal.x, tol.normal.y, tol.normal.z)
|
||||
.normalize_or(glam::DVec3::Z);
|
||||
let direction = glam::DVec3::new(tol.direction.x, tol.direction.y, tol.direction.z);
|
||||
let fallback = crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)).0;
|
||||
let fallback = glam::DVec3::new(fallback.0, fallback.1, fallback.2);
|
||||
let x_axis = (direction - normal * direction.dot(normal)).normalize_or(fallback);
|
||||
let y_axis = normal.cross(x_axis).normalize_or(glam::DVec3::Y);
|
||||
(x_axis, y_axis)
|
||||
}
|
||||
|
||||
// ── RenderConvertible ──────────────────────────────────────────────────────────
|
||||
|
||||
impl RenderConvertible for Tolerance {
|
||||
|
|
@ -471,7 +476,18 @@ impl RenderConvertible for Tolerance {
|
|||
|
||||
// Build the feature-control frame in local space; origin stored as f64.
|
||||
let (box_strokes, cells) = tessellate_tolerance(self, document);
|
||||
let ins = [self.insertion_point.x, self.insertion_point.y];
|
||||
let ins = glam::DVec3::new(
|
||||
self.insertion_point.x,
|
||||
self.insertion_point.y,
|
||||
self.insertion_point.z,
|
||||
);
|
||||
let (x_axis, y_axis) = frame_basis(self);
|
||||
let plane = |origin: glam::DVec3| TextPlane {
|
||||
origin: origin.to_array(),
|
||||
scale_origin: ins.to_array(),
|
||||
x_axis: x_axis.to_array(),
|
||||
y_axis: y_axis.to_array(),
|
||||
};
|
||||
let style = resolve_dim_style(self, document);
|
||||
let xd = &self.common.extended_data;
|
||||
let explicit_color = |index: Option<i16>| {
|
||||
|
|
@ -520,9 +536,10 @@ impl RenderConvertible for Tolerance {
|
|||
if !box_strokes.is_empty() {
|
||||
groups.push(TextStroke {
|
||||
strokes: box_strokes,
|
||||
origin: ins,
|
||||
origin: [ins.x, ins.y],
|
||||
color: frame_color,
|
||||
fill_tris: vec![],
|
||||
plane: Some(plane(ins)),
|
||||
run: None,
|
||||
});
|
||||
}
|
||||
|
|
@ -530,16 +547,21 @@ impl RenderConvertible for Tolerance {
|
|||
groups.push(TextStroke {
|
||||
strokes: cell.strokes,
|
||||
origin: [
|
||||
ins[0] + cell.origin[0] as f64,
|
||||
ins[1] + cell.origin[1] as f64,
|
||||
ins.x + x_axis.x * cell.origin[0] as f64
|
||||
+ y_axis.x * cell.origin[1] as f64,
|
||||
ins.y + x_axis.y * cell.origin[0] as f64
|
||||
+ y_axis.y * cell.origin[1] as f64,
|
||||
],
|
||||
color: text_color,
|
||||
fill_tris: vec![],
|
||||
plane: Some(plane(
|
||||
ins + x_axis * cell.origin[0] as f64 + y_axis * cell.origin[1] as f64,
|
||||
)),
|
||||
run: Some(GlyphRun {
|
||||
text: cell.text,
|
||||
font: cell.font,
|
||||
height: cell.height,
|
||||
rotation: cell.rotation,
|
||||
rotation: 0.0,
|
||||
width_factor: cell.width_factor,
|
||||
oblique: cell.oblique,
|
||||
// `tracking` scales the font's own letter spacing, so 0
|
||||
|
|
|
|||
|
|
@ -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,180 @@ 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<DVec3>,
|
||||
plane: WorkingPlane,
|
||||
height: f64,
|
||||
style: String,
|
||||
attachment: AttachmentPoint,
|
||||
line_spacing: f64,
|
||||
rotation: f64,
|
||||
width: Option<f64>,
|
||||
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<CmdOption> {
|
||||
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_geometry(&self, opposite: DVec3) -> (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 picked_horizontal = local.x * cos + local.y * sin;
|
||||
let vertical = -local.x * sin + local.y * cos;
|
||||
let width = self.width.unwrap_or(picked_horizontal.abs());
|
||||
let horizontal = if self.width.is_some() {
|
||||
width.copysign(if picked_horizontal.abs() > 1e-12 {
|
||||
picked_horizontal
|
||||
} else {
|
||||
1.0
|
||||
})
|
||||
} else {
|
||||
picked_horizontal
|
||||
};
|
||||
let (xmin, xmax) = (horizontal.min(0.0), horizontal.max(0.0));
|
||||
let (ymin, ymax) = (vertical.min(0.0), vertical.max(0.0));
|
||||
let horizontal_anchor = match self.attachment {
|
||||
AttachmentPoint::TopLeft
|
||||
| AttachmentPoint::MiddleLeft
|
||||
| AttachmentPoint::BottomLeft => 0.0,
|
||||
AttachmentPoint::TopCenter
|
||||
| AttachmentPoint::MiddleCenter
|
||||
| AttachmentPoint::BottomCenter => 0.5,
|
||||
AttachmentPoint::TopRight
|
||||
| AttachmentPoint::MiddleRight
|
||||
| AttachmentPoint::BottomRight => 1.0,
|
||||
};
|
||||
let vertical_anchor = match self.attachment {
|
||||
AttachmentPoint::TopLeft
|
||||
| AttachmentPoint::TopCenter
|
||||
| AttachmentPoint::TopRight => 1.0,
|
||||
AttachmentPoint::MiddleLeft
|
||||
| AttachmentPoint::MiddleCenter
|
||||
| AttachmentPoint::MiddleRight => 0.5,
|
||||
AttachmentPoint::BottomLeft
|
||||
| AttachmentPoint::BottomCenter
|
||||
| AttachmentPoint::BottomRight => 0.0,
|
||||
};
|
||||
let anchor_x = xmin + (xmax - xmin) * horizontal_anchor;
|
||||
let anchor_y = ymin + (ymax - ymin) * vertical_anchor;
|
||||
let offset = DVec3::new(
|
||||
anchor_x * cos - anchor_y * sin,
|
||||
anchor_x * sin + anchor_y * cos,
|
||||
0.0,
|
||||
);
|
||||
(
|
||||
first + self.plane.vector_to_world(offset),
|
||||
width,
|
||||
vertical.abs(),
|
||||
)
|
||||
}
|
||||
|
||||
fn open_editor(&self, opposite: DVec3) -> CmdResult {
|
||||
let (insertion, width, boundary_height) = self.boundary_geometry(opposite);
|
||||
let mut template = MText::default();
|
||||
template.insertion_point = Vector3::new(insertion.x, insertion.y, insertion.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 =
|
||||
crate::entities::text_support::clamp_mtext_column_count(self.column_count);
|
||||
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: insertion,
|
||||
handle: None,
|
||||
initial: String::new(),
|
||||
height: self.height,
|
||||
template: Some(Box::new(template)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,34 +202,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] <TL>:").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] <None>:").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<CmdOption> {
|
||||
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<CmdResult> {
|
||||
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::<f64>().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::<i32>().ok()?;
|
||||
if !(1..=crate::entities::text_support::MAX_MTEXT_COLUMNS).contains(&value) {
|
||||
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<WireModel> {
|
||||
fn on_mouse_move(&mut self, _point: DVec3) -> Option<WireModel> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); // MTextCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] });
|
||||
|
|
|
|||
|
|
@ -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,434 @@ 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<DVec3>,
|
||||
second_point: Option<DVec3>,
|
||||
horizontal: HA,
|
||||
vertical: VA,
|
||||
style_name: String,
|
||||
styles: Vec<TextStyle>,
|
||||
height: f64,
|
||||
rotation: f64,
|
||||
width_factor: f64,
|
||||
oblique_angle: f64,
|
||||
fixed_height: bool,
|
||||
annotative: bool,
|
||||
annotation_multiplier: f64,
|
||||
last_display_height: Option<f64>,
|
||||
last_entity: Option<Text>,
|
||||
}
|
||||
|
||||
impl TextCommand {
|
||||
pub fn with_height(height: f64) -> Self {
|
||||
Self {
|
||||
step: Step::InsertPoint,
|
||||
height,
|
||||
pub fn with_defaults(
|
||||
defaults: TextCreationDefaults,
|
||||
styles: Vec<TextStyle>,
|
||||
annotation_multiplier: f64,
|
||||
) -> 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,
|
||||
annotation_multiplier: annotation_multiplier.max(1.0e-9),
|
||||
last_display_height: None,
|
||||
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;
|
||||
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<Text> {
|
||||
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.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_display_height = None;
|
||||
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 fallback_height = entity.height.max(1.0e-9)
|
||||
* if self.annotative {
|
||||
self.annotation_multiplier
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let spacing = self
|
||||
.last_display_height
|
||||
.take()
|
||||
.unwrap_or(fallback_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<CmdOption> {
|
||||
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<CmdResult> {
|
||||
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::<f64>().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::<f64>().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_editor_display_height(&mut self, height: f64) {
|
||||
if height.is_finite() && height > 1.0e-9 {
|
||||
self.last_display_height = Some(height);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, _pt: DVec3) -> Option<WireModel> {
|
||||
fn on_mouse_move(&mut self, _point: DVec3) -> Option<WireModel> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); // TextCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] });
|
||||
|
|
|
|||
|
|
@ -25,13 +25,15 @@ pub fn tool() -> ToolDef {
|
|||
|
||||
pub struct ToleranceCommand {
|
||||
text: String,
|
||||
preview_strokes: Vec<Vec<[f32; 2]>>,
|
||||
plane: WorkingPlane,
|
||||
}
|
||||
|
||||
impl ToleranceCommand {
|
||||
pub fn with_text(text: String) -> Self {
|
||||
pub fn with_text(text: String, preview_strokes: Vec<Vec<[f32; 2]>>) -> Self {
|
||||
Self {
|
||||
text,
|
||||
preview_strokes,
|
||||
plane: WorkingPlane::default(),
|
||||
}
|
||||
}
|
||||
|
|
@ -66,48 +68,17 @@ impl CadCommand for ToleranceCommand {
|
|||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||||
let normalized = self
|
||||
.text
|
||||
.replace("^J", "\n")
|
||||
.replace("\\P", "\n")
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', "\n");
|
||||
let rows: Vec<Vec<&str>> = normalized
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| {
|
||||
let mut cells: Vec<&str> = line.split("%%v").collect();
|
||||
while cells.last().is_some_and(|cell| cell.trim().is_empty()) {
|
||||
cells.pop();
|
||||
}
|
||||
cells
|
||||
})
|
||||
.filter(|cells| !cells.is_empty())
|
||||
.collect();
|
||||
let row_height = 0.35;
|
||||
let cell_width = 0.5;
|
||||
let mut points = Vec::new();
|
||||
let mut segment = |a: DVec3, b: DVec3| {
|
||||
for stroke in &self.preview_strokes {
|
||||
if stroke.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if !points.is_empty() {
|
||||
points.push(DVec3::splat(f64::NAN));
|
||||
}
|
||||
points.push(a);
|
||||
points.push(b);
|
||||
};
|
||||
for (row_index, cells) in rows.iter().enumerate() {
|
||||
let count = cells.len().max(1);
|
||||
let y0 = -row_height * (row_index as f64 + 0.5);
|
||||
let y1 = y0 + row_height;
|
||||
let x1 = cell_width * count as f64;
|
||||
let p = |x: f64, y: f64| pt + self.plane.x * x + self.plane.y * y;
|
||||
segment(p(0.0, y0), p(x1, y0));
|
||||
segment(p(x1, y0), p(x1, y1));
|
||||
segment(p(x1, y1), p(0.0, y1));
|
||||
segment(p(0.0, y1), p(0.0, y0));
|
||||
for index in 1..count {
|
||||
let x = cell_width * index as f64;
|
||||
segment(p(x, y0), p(x, y1));
|
||||
}
|
||||
points.extend(stroke.iter().map(|[x, y]| {
|
||||
pt + self.plane.x * *x as f64 + self.plane.y * *y as f64
|
||||
}));
|
||||
}
|
||||
Some(WireModel {
|
||||
point_marker: None,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use glam::DVec3;
|
|||
use crate::t;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, EntityTransform};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct AlignCommand {
|
||||
state: AlignState,
|
||||
|
|
@ -35,10 +36,16 @@ enum AlignState {
|
|||
}
|
||||
|
||||
impl AlignCommand {
|
||||
pub fn new() -> Self {
|
||||
pub fn with_selection(handles: Vec<Handle>) -> Self {
|
||||
let state = if handles.is_empty() {
|
||||
AlignState::Gathering
|
||||
} else {
|
||||
AlignState::Src1
|
||||
};
|
||||
|
||||
Self {
|
||||
state: AlignState::Gathering,
|
||||
handles: vec![],
|
||||
state,
|
||||
handles,
|
||||
src1: None,
|
||||
dst1: None,
|
||||
src2: None,
|
||||
|
|
@ -66,11 +73,26 @@ impl CadCommand for AlignCommand {
|
|||
}
|
||||
AlignState::Dst2 => t!("ALIGN Specify 2nd destination point:").into_owned(),
|
||||
AlignState::AskScale => {
|
||||
t!("ALIGN Scale objects based on alignment points? [Y/N]:").into_owned()
|
||||
t!(
|
||||
"ALIGN Scale objects based on alignment points? [Yes / No]:"
|
||||
)
|
||||
.into_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
use crate::command::CmdOption;
|
||||
|
||||
match self.state {
|
||||
AlignState::AskScale => vec![
|
||||
CmdOption::new(t!("Yes").as_ref(), "Y"),
|
||||
CmdOption::new(t!("No").as_ref(), "N"),
|
||||
],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn is_selection_gathering(&self) -> bool {
|
||||
self.state == AlignState::Gathering
|
||||
}
|
||||
|
|
@ -113,14 +135,17 @@ impl CadCommand for AlignCommand {
|
|||
if self.handles.is_empty() {
|
||||
return CmdResult::Cancel;
|
||||
}
|
||||
|
||||
self.state = AlignState::Src1;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
AlignState::Src2 => {
|
||||
// Only 1 pair — pure translation
|
||||
// One alignment pair only: translation.
|
||||
match (self.src1, self.dst1) {
|
||||
(Some(s), Some(d)) => {
|
||||
let delta = d - s;
|
||||
|
||||
CmdResult::TransformSelected(
|
||||
self.handles.clone(),
|
||||
EntityTransform::Translate(delta),
|
||||
|
|
@ -129,10 +154,10 @@ impl CadCommand for AlignCommand {
|
|||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
AlignState::AskScale => {
|
||||
// No scale (default N)
|
||||
self.compute_align(false)
|
||||
}
|
||||
|
||||
// Default option shown as <No>.
|
||||
AlignState::AskScale => self.compute_align(false),
|
||||
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
|
@ -145,8 +170,76 @@ impl CadCommand for AlignCommand {
|
|||
if self.state != AlignState::AskScale {
|
||||
return None;
|
||||
}
|
||||
let scale = text.trim().to_uppercase().starts_with('Y');
|
||||
Some(self.compute_align(scale))
|
||||
|
||||
match text.trim().to_ascii_lowercase().as_str() {
|
||||
"y" | "yes" | "scale" => {
|
||||
Some(self.compute_align(true))
|
||||
}
|
||||
|
||||
"n" | "no" | "don't scale" | "dont scale" | "noscale" => {
|
||||
Some(self.compute_align(false))
|
||||
}
|
||||
|
||||
_ => Some(CmdResult::NeedPoint),
|
||||
}
|
||||
}
|
||||
fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> {
|
||||
fn line(a: DVec3, b: DVec3, name: &str) -> WireModel {
|
||||
WireModel::solid(
|
||||
name.into(),
|
||||
vec![
|
||||
[a.x as f32, a.y as f32, a.z as f32],
|
||||
[b.x as f32, b.y as f32, b.z as f32],
|
||||
],
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Keep the first completed alignment pair visible while defining
|
||||
// the second pair and while choosing the scale option.
|
||||
if let (Some(src1), Some(dst1)) = (self.src1, self.dst1) {
|
||||
match self.state {
|
||||
AlignState::Src2
|
||||
| AlignState::Dst2
|
||||
| AlignState::AskScale => {
|
||||
out.push(line(src1, dst1, "align_pair_1"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match self.state {
|
||||
// First source has been picked: stretch its reference line
|
||||
// to the cursor until the first destination is chosen.
|
||||
AlignState::Dst1 => {
|
||||
if let Some(src1) = self.src1 {
|
||||
out.push(line(src1, pt, "align_pair_1_preview"));
|
||||
}
|
||||
}
|
||||
|
||||
// Second source has been picked: stretch the second reference
|
||||
// line to the cursor until its destination is chosen.
|
||||
AlignState::Dst2 => {
|
||||
if let Some(src2) = self.src2 {
|
||||
out.push(line(src2, pt, "align_pair_2_preview"));
|
||||
}
|
||||
}
|
||||
|
||||
// Once both pairs are complete, keep both visible while
|
||||
// waiting for the Scale / No Scale decision.
|
||||
AlignState::AskScale => {
|
||||
if let (Some(src2), Some(dst2)) = (self.src2, self.dst2) {
|
||||
out.push(line(src2, dst2, "align_pair_2"));
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -415,19 +415,19 @@ fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> {
|
|||
// ── Command implementation ─────────────────────────────────────────────────
|
||||
|
||||
enum Step {
|
||||
/// Classic first step (#418): type the offset distance, press Enter /
|
||||
/// Space to accept the last one, or choose Through mode.
|
||||
/// Type the offset distance, accept the previous value with Enter,
|
||||
/// choose Through, or pick the first point of a reference distance.
|
||||
Distance,
|
||||
/// Pick the object to offset. `locked == None` is "through" mode: the
|
||||
/// magnitude follows the cursor (perpendicular distance to the object).
|
||||
|
||||
/// First reference point has been picked; the second point defines
|
||||
/// the offset distance.
|
||||
ReferenceSecond { first: DVec3 },
|
||||
|
||||
SelectObject { locked: Option<f64> },
|
||||
|
||||
PickSide {
|
||||
/// The object(s) being offset — one from a pick, or the whole
|
||||
/// pre-selection when OFFSET starts with objects selected (#422).
|
||||
targets: Vec<EntityType>,
|
||||
locked: Option<f64>,
|
||||
/// Keep the side-pick step active and use each new result as the source
|
||||
/// for the next offset.
|
||||
multiple: bool,
|
||||
},
|
||||
}
|
||||
|
|
@ -499,6 +499,18 @@ impl OffsetCommand {
|
|||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn accept_distance(&mut self, distance: f64) -> CmdResult {
|
||||
let distance = distance.abs().max(1e-9);
|
||||
|
||||
defaults::set_offset_dist(distance);
|
||||
self.advance_from_distance(Some(distance));
|
||||
|
||||
let d = format!("{:.4}", distance);
|
||||
|
||||
CmdResult::ReportMeasurement(
|
||||
t!("OFFSET distance = %{d}", d = d).into_owned()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for OffsetCommand {
|
||||
|
|
@ -510,7 +522,15 @@ impl CadCommand for OffsetCommand {
|
|||
match &self.step {
|
||||
Step::Distance => {
|
||||
let d = format!("{:.4}", defaults::get_offset_dist());
|
||||
t!("OFFSET Specify offset distance or [Through] <%{d}>:", d = d).into_owned()
|
||||
t!(
|
||||
"OFFSET Specify offset distance or first reference point [Through] <%{d}>:",
|
||||
d = d
|
||||
)
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
Step::ReferenceSecond { .. } => {
|
||||
t!("OFFSET Specify second reference point:").into_owned()
|
||||
}
|
||||
Step::SelectObject { .. } => {
|
||||
t!("OFFSET Select object to offset (Enter to finish):").into_owned()
|
||||
|
|
@ -623,7 +643,9 @@ impl CadCommand for OffsetCommand {
|
|||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
match self.step {
|
||||
Step::Distance | Step::PickSide { .. } => crate::command::DynField::Scalar,
|
||||
_ => crate::command::DynField::Point,
|
||||
|
||||
Step::ReferenceSecond { .. }
|
||||
| Step::SelectObject { .. } => crate::command::DynField::Point,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -648,9 +670,7 @@ impl CadCommand for OffsetCommand {
|
|||
return Some(self.advance_from_distance(None));
|
||||
}
|
||||
if let Some(d) = crate::entities::common::parse_typed_length(&t) {
|
||||
let d = d.abs().max(1e-9);
|
||||
defaults::set_offset_dist(d);
|
||||
return Some(self.advance_from_distance(Some(d)));
|
||||
return Some(self.accept_distance(d));
|
||||
}
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
|
@ -698,12 +718,32 @@ impl CadCommand for OffsetCommand {
|
|||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
match &self.step {
|
||||
Step::Distance => {
|
||||
self.step = Step::ReferenceSecond { first: pt };
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
|
||||
Step::ReferenceSecond { first } => {
|
||||
let distance = first.distance(pt);
|
||||
|
||||
if distance <= 1e-9 {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
|
||||
return self.accept_distance(distance);
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let (locked, targets, multiple) = match &self.step {
|
||||
Step::PickSide {
|
||||
locked,
|
||||
targets,
|
||||
multiple,
|
||||
} => (*locked, targets.clone(), *multiple),
|
||||
|
||||
_ => return CmdResult::NeedPoint,
|
||||
};
|
||||
// Each target offsets by its own through-distance (or the locked
|
||||
|
|
@ -745,6 +785,26 @@ impl CadCommand for OffsetCommand {
|
|||
}
|
||||
|
||||
fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> {
|
||||
if let Step::ReferenceSecond { first } = &self.step {
|
||||
return vec![WireModel::solid(
|
||||
"offset_reference_distance".into(),
|
||||
vec![
|
||||
[
|
||||
first.x as f32,
|
||||
first.y as f32,
|
||||
first.z as f32,
|
||||
],
|
||||
[
|
||||
pt.x as f32,
|
||||
pt.y as f32,
|
||||
pt.z as f32,
|
||||
],
|
||||
],
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
)];
|
||||
}
|
||||
|
||||
let (locked, targets) = match &self.step {
|
||||
Step::PickSide { locked, targets, .. } => (*locked, targets.clone()),
|
||||
_ => return vec![],
|
||||
|
|
@ -779,7 +839,7 @@ impl CadCommand for OffsetCommand {
|
|||
// the "repeat the same value with just Space" flow (#418).
|
||||
Step::Distance => {
|
||||
let d = defaults::get_offset_dist();
|
||||
self.advance_from_distance(Some(d.abs().max(1e-9)))
|
||||
self.accept_distance(d)
|
||||
}
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ pub fn on_tab_closed(tab_id: u64) {
|
|||
broadcast(HostNotification::DocumentTabClosed { tab_id });
|
||||
}
|
||||
|
||||
/// Broadcast a selection change to V4 plugins.
|
||||
///
|
||||
/// `HostNotification::SelectionChangedV4` carries the active tab id and the
|
||||
/// current selection set. The caller is responsible for only calling this when
|
||||
/// the selection actually changed; see `OpenCADStudio::notify_plugins_selection_changed`.
|
||||
pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec<acadrust::Handle>) {
|
||||
broadcast(HostNotification::SelectionChangedV4 { tab_id, handles });
|
||||
}
|
||||
|
||||
/// V4 notification handler installed on the plugin manager.
|
||||
///
|
||||
/// Forwards REPL status messages to the log; other notifications are handled
|
||||
|
|
|
|||
4
src/scene/cache/properties.rs
vendored
4
src/scene/cache/properties.rs
vendored
|
|
@ -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::Text(_))
|
||||
|| matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline))
|
||||
|| matches!(entity, EntityType::MText(_))
|
||||
|| matches!(entity, EntityType::Tolerance(_))
|
||||
{
|
||||
section.props.retain(|prop| prop.field != "handle");
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub struct TextStroke {
|
|||
pub origin: [f64; 2],
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub fill_tris: Vec<[f32; 2]>,
|
||||
pub plane: Option<TextPlane>,
|
||||
/// Layout inputs to rebuild this run as per-glyph SDF quads (see
|
||||
/// `scene::text::glyph_quads`). `Some` on runs wired for the SDF text
|
||||
/// renderer; `None` leaves the run to the stroke path only. Heights are
|
||||
|
|
@ -32,6 +33,14 @@ pub struct TextStroke {
|
|||
pub run: Option<GlyphRun>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TextPlane {
|
||||
pub origin: [f64; 3],
|
||||
pub scale_origin: [f64; 3],
|
||||
pub x_axis: [f64; 3],
|
||||
pub y_axis: [f64; 3],
|
||||
}
|
||||
|
||||
/// Per-run text-layout inputs needed to reproduce a run as SDF glyph quads.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GlyphRun {
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ fn tessellate_entity_inner(
|
|||
if let EntityType::Viewport(vp) = e {
|
||||
// The sheet viewport (overall/id=1) is never shown — it represents the
|
||||
// paper boundary, not a user-defined content window.
|
||||
if !Scene::is_content_viewport(vp) {
|
||||
if Scene::is_sheet_viewport(document, vp) {
|
||||
return vec![];
|
||||
}
|
||||
let is_active = active_viewport == Some(h);
|
||||
|
|
|
|||
|
|
@ -78,6 +78,81 @@ 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 = crate::entities::text_support::clamp_mtext_column_count(columns.column_count)
|
||||
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;
|
||||
|
|
@ -633,7 +708,14 @@ pub fn tessellate(
|
|||
// stay a roughly constant on-screen size; otherwise the header-driven path.
|
||||
let te = crate::entities::point::relative_render(entity, document, world_per_pixel)
|
||||
.or_else(|| crate::entities::light::relative_render(entity, document, world_per_pixel))
|
||||
.or_else(|| convert(entity, document));
|
||||
.or_else(|| match entity {
|
||||
EntityType::Text(text) => Some(crate::entities::text::to_render_at_scale(
|
||||
text,
|
||||
document,
|
||||
anno_scale,
|
||||
)),
|
||||
_ => convert(entity, document),
|
||||
});
|
||||
if let Some(te) = te {
|
||||
match te.object {
|
||||
// ── Text / MText: pre-tessellated glyph strokes ───────────────
|
||||
|
|
@ -733,9 +815,32 @@ pub fn tessellate(
|
|||
}
|
||||
bin_first[bi] = false;
|
||||
for &[x, y] in stroke {
|
||||
let xv = x as f64 * anno + slx_v;
|
||||
let yv = y as f64 * anno + sly_v;
|
||||
let (h, l) = split_ds_xyz(xv, yv, elev_v);
|
||||
let world = if let Some(plane) = group.plane {
|
||||
let scaled_origin = [
|
||||
plane.scale_origin[0]
|
||||
+ (plane.origin[0] - plane.scale_origin[0]) * anno,
|
||||
plane.scale_origin[1]
|
||||
+ (plane.origin[1] - plane.scale_origin[1]) * anno,
|
||||
plane.scale_origin[2]
|
||||
+ (plane.origin[2] - plane.scale_origin[2]) * anno,
|
||||
];
|
||||
let x = x as f64 * anno;
|
||||
let y = y as f64 * anno;
|
||||
[
|
||||
scaled_origin[0]
|
||||
+ plane.x_axis[0] * x
|
||||
+ plane.y_axis[0] * y,
|
||||
scaled_origin[1]
|
||||
+ plane.x_axis[1] * x
|
||||
+ plane.y_axis[1] * y,
|
||||
scaled_origin[2]
|
||||
+ plane.x_axis[2] * x
|
||||
+ plane.y_axis[2] * y,
|
||||
]
|
||||
} else {
|
||||
[x as f64 * anno + slx_v, y as f64 * anno + sly_v, elev_v]
|
||||
};
|
||||
let (h, l) = split_ds_xyz(world[0], world[1], world[2]);
|
||||
bins[bi].pts.push(h);
|
||||
bins[bi].pts_low.push(l);
|
||||
}
|
||||
|
|
@ -743,9 +848,32 @@ pub fn tessellate(
|
|||
|
||||
// 2. Process fill triangles
|
||||
for &[x, y] in &group.fill_tris {
|
||||
let xv = x as f64 * anno + slx_v;
|
||||
let yv = y as f64 * anno + sly_v;
|
||||
let (h, l) = split_ds_xyz(xv, yv, elev_v);
|
||||
let world = if let Some(plane) = group.plane {
|
||||
let scaled_origin = [
|
||||
plane.scale_origin[0]
|
||||
+ (plane.origin[0] - plane.scale_origin[0]) * anno,
|
||||
plane.scale_origin[1]
|
||||
+ (plane.origin[1] - plane.scale_origin[1]) * anno,
|
||||
plane.scale_origin[2]
|
||||
+ (plane.origin[2] - plane.scale_origin[2]) * anno,
|
||||
];
|
||||
let x = x as f64 * anno;
|
||||
let y = y as f64 * anno;
|
||||
[
|
||||
scaled_origin[0]
|
||||
+ plane.x_axis[0] * x
|
||||
+ plane.y_axis[0] * y,
|
||||
scaled_origin[1]
|
||||
+ plane.x_axis[1] * x
|
||||
+ plane.y_axis[1] * y,
|
||||
scaled_origin[2]
|
||||
+ plane.x_axis[2] * x
|
||||
+ plane.y_axis[2] * y,
|
||||
]
|
||||
} else {
|
||||
[x as f64 * anno + slx_v, y as f64 * anno + sly_v, elev_v]
|
||||
};
|
||||
let (h, l) = split_ds_xyz(world[0], world[1], world[2]);
|
||||
bins[bi].fill_tris.push(h);
|
||||
bins[bi].fill_tris_low.push(l);
|
||||
}
|
||||
|
|
@ -793,14 +921,35 @@ pub fn tessellate(
|
|||
run.bold,
|
||||
&run.text,
|
||||
);
|
||||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||||
&mut sdf_verts,
|
||||
&quads,
|
||||
[slx_v, sly_v, elev_v],
|
||||
anno,
|
||||
gcolor,
|
||||
0.0,
|
||||
);
|
||||
if let Some(plane) = group.plane {
|
||||
let scaled_origin = [
|
||||
plane.scale_origin[0]
|
||||
+ (plane.origin[0] - plane.scale_origin[0]) * anno,
|
||||
plane.scale_origin[1]
|
||||
+ (plane.origin[1] - plane.scale_origin[1]) * anno,
|
||||
plane.scale_origin[2]
|
||||
+ (plane.origin[2] - plane.scale_origin[2]) * anno,
|
||||
];
|
||||
crate::scene::pipeline::text_gpu::push_glyph_vertices_on_plane(
|
||||
&mut sdf_verts,
|
||||
&quads,
|
||||
scaled_origin,
|
||||
plane.x_axis,
|
||||
plane.y_axis,
|
||||
anno,
|
||||
gcolor,
|
||||
0.0,
|
||||
);
|
||||
} else {
|
||||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||||
&mut sdf_verts,
|
||||
&quads,
|
||||
[slx_v, sly_v, elev_v],
|
||||
anno,
|
||||
gcolor,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -849,11 +998,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 +1012,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 +1062,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,
|
||||
|
|
@ -1718,24 +1878,9 @@ pub fn tessellate(
|
|||
// f64 for the WireModel's double-single-era snap buffer.
|
||||
let snap_pts: Vec<(glam::DVec3, SnapHint)> =
|
||||
snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect();
|
||||
// A paper-space viewport is a window, not a wireframe: give it an interior
|
||||
// pick surface so a click anywhere inside the frame selects it. Ranked
|
||||
// below edge and fill hits, so content drawn inside still wins the click.
|
||||
// The sheet ("overall") viewport is the layout's own invisible camera
|
||||
// frame covering the whole page — never pickable, or it would swallow
|
||||
// every click over the real viewports beneath it. It is identified by the
|
||||
// Layout object's viewport link (authoritative — DWG files carry id = 0
|
||||
// and this file class centres the sheet viewport off-origin, so neither
|
||||
// the id nor the geometry heuristic alone is reliable), with
|
||||
// `is_content_viewport` as the fallback classifier.
|
||||
let is_sheet_vp = |vp: &acadrust::entities::Viewport| {
|
||||
let h = vp.common.handle;
|
||||
document.objects.values().any(|obj| {
|
||||
matches!(obj, acadrust::objects::ObjectType::Layout(l) if l.viewport == h)
|
||||
}) || !crate::scene::Scene::is_content_viewport(vp)
|
||||
};
|
||||
// Paper viewports are pickable inside their frames; the sheet viewport is not.
|
||||
let (pick_tris, pick_tris_low) = match entity {
|
||||
EntityType::Viewport(vp) if !is_sheet_vp(vp) => {
|
||||
EntityType::Viewport(vp) if !crate::scene::Scene::is_sheet_viewport(document, vp) => {
|
||||
if let Some(polygon) = clipped_viewport_polygon.as_ref() {
|
||||
points_to_ds(crate::entities::mesh::triangulate_planar(polygon))
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -134,10 +134,6 @@ fn apply_text_defaults(doc: &CadDocument, entity: &mut EntityType) {
|
|||
if text.oblique_angle.abs() <= 1.0e-9 {
|
||||
text.oblique_angle = resolved.oblique_angle;
|
||||
}
|
||||
if text.generation_flags == 0 {
|
||||
text.generation_flags = (if resolved.flags.backward { 2 } else { 0 })
|
||||
| (if resolved.flags.upside_down { 4 } else { 0 });
|
||||
}
|
||||
}
|
||||
EntityType::MText(text) => {
|
||||
if resolved.height > 1.0e-9 {
|
||||
|
|
|
|||
|
|
@ -203,8 +203,11 @@ impl Scene {
|
|||
|
||||
/// If any handle belongs to a selectable group, also select every member.
|
||||
pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) {
|
||||
let previous_len = self.selected.len();
|
||||
self.selected
|
||||
.extend(self.handles_expanded_for_selectable_groups(handles));
|
||||
self.bump_selection();
|
||||
if self.selected.len() != previous_len {
|
||||
self.bump_selection_set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
194
src/scene/mod.rs
194
src/scene/mod.rs
|
|
@ -1486,6 +1486,9 @@ pub struct Scene {
|
|||
/// pick only refreshes the GPU xray overlay (cheap) instead of bumping
|
||||
/// `geometry_epoch` and re-tessellating the whole model.
|
||||
pub selection_generation: u64,
|
||||
/// Cached fingerprint of `selected`, recomputed lazily after mutations.
|
||||
selection_fingerprint_cache: u64,
|
||||
selection_fingerprint_dirty: bool,
|
||||
/// Cached tessellation of all visible entity wires for the current layout.
|
||||
/// Keyed by `(geometry_epoch, camera_generation)` so a camera change
|
||||
/// invalidates the cull-dependent wire list as well as a geometry change.
|
||||
|
|
@ -1865,6 +1868,8 @@ impl Scene {
|
|||
projection_bounds_epoch: std::cell::Cell::new(0),
|
||||
block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
|
||||
selection_generation: 0,
|
||||
selection_fingerprint_cache: 0,
|
||||
selection_fingerprint_dirty: false,
|
||||
wire_cache: RefCell::new(None),
|
||||
interaction_index_cache: RefCell::new(Vec::new()),
|
||||
interaction_index_pending_key: std::cell::Cell::new(None),
|
||||
|
|
@ -2618,6 +2623,11 @@ impl Scene {
|
|||
self.selection_generation = self.selection_generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub(crate) fn bump_selection_set(&mut self) {
|
||||
self.selection_fingerprint_dirty = true;
|
||||
self.bump_selection();
|
||||
}
|
||||
|
||||
/// Milliseconds after the last camera change during which the view counts as
|
||||
/// "actively navigating" for interaction-LOD purposes.
|
||||
const NAV_SETTLE_MS: u128 = 130;
|
||||
|
|
@ -2998,32 +3008,96 @@ impl Scene {
|
|||
crate::io::set_saved_active_layout(&mut self.document, &self.current_layout);
|
||||
}
|
||||
|
||||
/// Returns true if this viewport should display model-space content
|
||||
/// (i.e. it is a user viewport, not the sheet/overall viewport).
|
||||
///
|
||||
/// Rules:
|
||||
/// - id=1 → always the sheet viewport → false
|
||||
/// - id≥2 → always a user viewport → true
|
||||
/// - id=0 or id<0 (DWG reader omits the id; some DXF exporters write -1):
|
||||
/// use geometry: the sheet viewport is centred at the paper origin (0,0)
|
||||
/// with scale≈1.0 (view_height ≈ paper-space height).
|
||||
pub fn is_content_viewport(vp: &acadrust::entities::Viewport) -> bool {
|
||||
if vp.id == 1 {
|
||||
return false;
|
||||
pub(crate) fn layout_sheet_viewport_handle(
|
||||
document: &acadrust::CadDocument,
|
||||
layout: &acadrust::objects::Layout,
|
||||
) -> Handle {
|
||||
let owned_viewport = |handle| match document.get_entity(handle) {
|
||||
Some(EntityType::Viewport(vp)) if vp.common.owner_handle == layout.block_record => {
|
||||
Some(vp)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let listed = layout
|
||||
.viewports
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp)));
|
||||
if let Some((handle, _)) = listed.clone().find(|(_, vp)| vp.id == 1) {
|
||||
return handle;
|
||||
}
|
||||
if vp.id > 1 {
|
||||
if let Some((handle, _)) = listed.into_iter().next() {
|
||||
return handle;
|
||||
}
|
||||
|
||||
let block_handles = document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|block| block.handle == layout.block_record)
|
||||
.map(|block| block.entity_handles.as_slice())
|
||||
.unwrap_or_default();
|
||||
let block_viewports = block_handles
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp)));
|
||||
if let Some((handle, _)) = block_viewports.clone().find(|(_, vp)| vp.id == 1) {
|
||||
return handle;
|
||||
}
|
||||
if let Some((handle, _)) = block_viewports.into_iter().next() {
|
||||
return handle;
|
||||
}
|
||||
|
||||
document
|
||||
.entities()
|
||||
.filter_map(|entity| match entity {
|
||||
EntityType::Viewport(vp) if vp.common.owner_handle == layout.block_record => {
|
||||
Some((
|
||||
vp.common.handle,
|
||||
vp.id == 1,
|
||||
vp.width.abs() * vp.height.abs(),
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.max_by(|a, b| a.1.cmp(&b.1).then_with(|| a.2.total_cmp(&b.2)))
|
||||
.map(|(handle, _, _)| handle)
|
||||
.unwrap_or(Handle::NULL)
|
||||
}
|
||||
|
||||
pub(crate) fn is_sheet_viewport(
|
||||
document: &acadrust::CadDocument,
|
||||
vp: &acadrust::entities::Viewport,
|
||||
) -> bool {
|
||||
if vp.id == 1 {
|
||||
return true;
|
||||
}
|
||||
// id ≤ 0: DWG files never write group-code 69 (viewport id), so all
|
||||
// viewports arrive with id=0.
|
||||
//
|
||||
// In DWG format the sheet ("overall") viewport always has its center at
|
||||
// the paper-space origin (0, 0). Content viewports are placed at their
|
||||
// actual position on the paper and therefore have a non-zero center.
|
||||
// Using center position is more reliable than a scale heuristic because
|
||||
// the sheet viewport's scale is not always exactly 1:1 (observed: 0.8965
|
||||
// in real-world files, which the old 0.02 tolerance missed entirely).
|
||||
vp.center.x.abs() >= 0.5 || vp.center.y.abs() >= 0.5
|
||||
if vp.id > 1 {
|
||||
return false;
|
||||
}
|
||||
document.objects.values().any(|object| {
|
||||
matches!(
|
||||
object,
|
||||
ObjectType::Layout(layout)
|
||||
if layout.block_record == vp.common.owner_handle
|
||||
&& Self::layout_sheet_viewport_handle(document, layout)
|
||||
== vp.common.handle
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn sheet_viewport_handles(&self) -> std::collections::HashSet<Handle> {
|
||||
self.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|object| match object {
|
||||
ObjectType::Layout(layout) => {
|
||||
let handle = Self::layout_sheet_viewport_handle(&self.document, layout);
|
||||
handle.is_valid().then_some(handle)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn current_layout_sheet_viewport_handle(&self) -> Handle {
|
||||
|
|
@ -3035,7 +3109,7 @@ impl Scene {
|
|||
return None;
|
||||
};
|
||||
if layout.name == self.current_layout {
|
||||
Some(layout.viewport)
|
||||
Some(Self::layout_sheet_viewport_handle(&self.document, layout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
|
@ -3043,82 +3117,49 @@ impl Scene {
|
|||
.unwrap_or(Handle::NULL)
|
||||
}
|
||||
|
||||
/// Guarantee that a paper layout has its full-screen overall (`id == 1`)
|
||||
/// sheet viewport. `add_layout` creates it; this is a safety net for layouts
|
||||
/// that arrive without it. The sheet
|
||||
/// viewport is the authoritative paper-space view and the canvas every
|
||||
/// floating viewport overlays.
|
||||
/// Ensure a paper layout has its overall (`id == 1`) viewport.
|
||||
pub fn ensure_sheet_viewport(&mut self, layout_name: &str) {
|
||||
if layout_name == "Model" {
|
||||
return;
|
||||
}
|
||||
// Locate the layout: its object handle, block-record handle, current
|
||||
// sheet-viewport link, and paper limits.
|
||||
// Locate the layout and its paper limits.
|
||||
let info = self.document.objects.iter().find_map(|(h, obj)| {
|
||||
if let ObjectType::Layout(l) = obj {
|
||||
if l.name == layout_name {
|
||||
return Some((*h, l.block_record, l.viewport, l.min_limits, l.max_limits));
|
||||
return Some((
|
||||
*h,
|
||||
l.block_record,
|
||||
Self::layout_sheet_viewport_handle(&self.document, l),
|
||||
l.min_limits,
|
||||
l.max_limits,
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
let Some((layout_handle, block_record, cur_vp, min_lim, max_lim)) = info else {
|
||||
let Some((layout_handle, block_record, sheet, min_lim, max_lim)) = info else {
|
||||
return;
|
||||
};
|
||||
if block_record.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal files carry a valid direct Layout→Viewport link. This O(1)
|
||||
// path is hit on every ordinary layout-tab switch.
|
||||
if cur_vp.is_valid()
|
||||
if sheet.is_valid()
|
||||
&& matches!(
|
||||
self.document.get_entity(cur_vp),
|
||||
self.document.get_entity(sheet),
|
||||
Some(EntityType::Viewport(vp)) if vp.common.owner_handle == block_record
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Already present? Accept either the linked viewport handle or any
|
||||
// `id == 1` viewport owned by the layout block.
|
||||
let has_sheet = self.document.entities().any(|e| {
|
||||
matches!(e, EntityType::Viewport(vp)
|
||||
if vp.common.owner_handle == block_record
|
||||
&& (vp.id == 1 || vp.common.handle == cur_vp))
|
||||
});
|
||||
if has_sheet {
|
||||
// Keep the layout's link in sync if it was missing.
|
||||
if !cur_vp.is_valid() {
|
||||
let h = self.document.entities().find_map(|e| match e {
|
||||
EntityType::Viewport(vp)
|
||||
if vp.common.owner_handle == block_record && vp.id == 1 =>
|
||||
{
|
||||
Some(vp.common.handle)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(h) = h {
|
||||
if let Some(ObjectType::Layout(l)) =
|
||||
self.document.objects.get_mut(&layout_handle)
|
||||
{
|
||||
l.viewport = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the full-screen overall viewport covering the paper limits.
|
||||
let pw = (max_lim.0 - min_lim.0).abs().max(1.0);
|
||||
let ph = (max_lim.1 - min_lim.1).abs().max(1.0);
|
||||
let mut vp = acadrust::entities::Viewport::new();
|
||||
vp.id = 1;
|
||||
vp.status = acadrust::entities::ViewportStatusFlags::default_on();
|
||||
// Paper-space center is a 2D (x, y) point with z = 0. Putting the
|
||||
// paper-height midpoint in z
|
||||
// (with y = 0) left the sheet view centered at y = 0, shifting the whole
|
||||
// layout half a page down. See issue #156.
|
||||
// Paper-space center is an (x, y) point with z = 0.
|
||||
vp.center = acadrust::types::Vector3::new(
|
||||
(min_lim.0 + max_lim.0) / 2.0,
|
||||
(min_lim.1 + max_lim.1) / 2.0,
|
||||
|
|
@ -3126,12 +3167,7 @@ impl Scene {
|
|||
);
|
||||
vp.width = pw;
|
||||
vp.height = ph;
|
||||
// Frame the new layout on the whole sheet: look straight down at the
|
||||
// paper centre with the visible height a touch taller than the page.
|
||||
// Without this the viewport keeps `Viewport::new`'s default view
|
||||
// (target 0,0 / height 210), so the first time a fresh drawing's
|
||||
// layout is opened the camera sits on the paper's bottom-left corner
|
||||
// instead of centring the sheet.
|
||||
// Frame the full sheet with a small margin.
|
||||
vp.view_target = acadrust::types::Vector3::new(
|
||||
(min_lim.0 + max_lim.0) / 2.0,
|
||||
(min_lim.1 + max_lim.1) / 2.0,
|
||||
|
|
@ -3144,7 +3180,8 @@ impl Scene {
|
|||
.add_entity_to_layout(EntityType::Viewport(vp), layout_name)
|
||||
{
|
||||
if let Some(ObjectType::Layout(l)) = self.document.objects.get_mut(&layout_handle) {
|
||||
l.viewport = handle;
|
||||
l.viewports.retain(|candidate| *candidate != handle);
|
||||
l.viewports.insert(0, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3161,7 +3198,7 @@ impl Scene {
|
|||
if sheet_handle.is_valid() {
|
||||
vp.common.handle != sheet_handle
|
||||
} else {
|
||||
Self::is_content_viewport(vp)
|
||||
!Self::is_sheet_viewport(&self.document, vp)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4555,6 +4592,7 @@ impl Scene {
|
|||
.extend(self.selected.iter().copied());
|
||||
self.selected.clear();
|
||||
self.selected_order.clear();
|
||||
self.bump_selection_set();
|
||||
self.bump_entities(&changes);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ impl Scene {
|
|||
&& if sheet.is_valid() {
|
||||
handle != sheet
|
||||
} else {
|
||||
Self::is_content_viewport(vp)
|
||||
!Self::is_sheet_viewport(&self.document, vp)
|
||||
}
|
||||
};
|
||||
let content = if let Some(block) = self
|
||||
|
|
|
|||
|
|
@ -151,6 +151,51 @@ pub fn push_glyph_vertices(
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn push_glyph_vertices_on_plane(
|
||||
out: &mut Vec<TextVertex>,
|
||||
quads: &[GlyphQuad],
|
||||
origin: [f64; 3],
|
||||
x_axis: [f64; 3],
|
||||
y_axis: [f64; 3],
|
||||
anno: f64,
|
||||
color: [f32; 4],
|
||||
draw_depth: f32,
|
||||
) {
|
||||
for q in quads {
|
||||
let mk = |ci: usize, uv: [f32; 2]| -> TextVertex {
|
||||
let c = q.corners[ci];
|
||||
let x = c[0] as f64 * anno;
|
||||
let y = c[1] as f64 * anno;
|
||||
let world = [
|
||||
origin[0] + x_axis[0] * x + y_axis[0] * y,
|
||||
origin[1] + x_axis[1] * x + y_axis[1] * y,
|
||||
origin[2] + x_axis[2] * x + y_axis[2] * y,
|
||||
];
|
||||
let (xh, xl) = split_ds(world[0]);
|
||||
let (yh, yl) = split_ds(world[1]);
|
||||
let (zh, zl) = split_ds(world[2]);
|
||||
TextVertex {
|
||||
pos: [xh, yh, zh],
|
||||
pos_low: [xl, yl, zl],
|
||||
uv,
|
||||
color,
|
||||
draw_depth,
|
||||
}
|
||||
};
|
||||
let bl = [q.uv_min[0], q.uv_max[1]];
|
||||
let br = [q.uv_max[0], q.uv_max[1]];
|
||||
let tr = [q.uv_max[0], q.uv_min[1]];
|
||||
let tl = [q.uv_min[0], q.uv_min[1]];
|
||||
out.push(mk(0, bl));
|
||||
out.push(mk(1, br));
|
||||
out.push(mk(2, tr));
|
||||
out.push(mk(0, bl));
|
||||
out.push(mk(2, tr));
|
||||
out.push(mk(3, tl));
|
||||
}
|
||||
}
|
||||
|
||||
/// Slide every glyph vertex by a world-space delta, re-splitting the
|
||||
/// double-single position. Lets a grip drag move already-shaped text by
|
||||
/// translating the drag-start glyphs each frame instead of re-tessellating
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
impl Scene {
|
||||
// ── Selection ─────────────────────────────────────────────────────────
|
||||
/// Treat a classic LEADER and its attached annotation as one logical object.
|
||||
|
|
@ -37,8 +40,11 @@ impl Scene {
|
|||
}
|
||||
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
|
||||
let handles = self.handles_expanded_for_leader_annotations(&[handle]);
|
||||
let mut changed = false;
|
||||
|
||||
if exclusive {
|
||||
changed = self.selected.len() != handles.len()
|
||||
|| handles.iter().any(|handle| !self.selected.contains(handle));
|
||||
self.selected.clear();
|
||||
self.selected_order.clear();
|
||||
}
|
||||
|
|
@ -46,15 +52,35 @@ impl Scene {
|
|||
for handle in handles {
|
||||
if self.selected.insert(handle) {
|
||||
self.selected_order.push(handle);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
self.bump_selection();
|
||||
if changed {
|
||||
self.bump_selection_set();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deselect_all(&mut self) {
|
||||
if self.selected.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.selected.clear();
|
||||
self.selected_order.clear();
|
||||
self.bump_selection();
|
||||
self.bump_selection_set();
|
||||
}
|
||||
|
||||
pub(crate) fn selection_fingerprint(&mut self) -> u64 {
|
||||
if self.selection_fingerprint_dirty {
|
||||
let mut fingerprint = self.selected.len() as u64;
|
||||
for handle in &self.selected {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
handle.hash(&mut hasher);
|
||||
fingerprint ^= hasher.finish();
|
||||
}
|
||||
self.selection_fingerprint_cache = fingerprint;
|
||||
self.selection_fingerprint_dirty = false;
|
||||
}
|
||||
self.selection_fingerprint_cache
|
||||
}
|
||||
|
||||
pub(crate) fn selected_handles_in_order(&self) -> Vec<Handle> {
|
||||
|
|
@ -103,7 +129,7 @@ impl Scene {
|
|||
order.extend(added);
|
||||
self.selected = selected;
|
||||
self.selected_order = order;
|
||||
self.bump_selection();
|
||||
self.bump_selection_set();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +144,7 @@ impl Scene {
|
|||
}
|
||||
|
||||
if changed {
|
||||
self.bump_selection();
|
||||
self.bump_selection_set();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +225,7 @@ impl Scene {
|
|||
}
|
||||
}
|
||||
if added > 0 {
|
||||
self.bump_selection();
|
||||
self.bump_selection_set();
|
||||
}
|
||||
added
|
||||
}
|
||||
|
|
@ -223,7 +249,9 @@ impl Scene {
|
|||
self.selected_order.push(h);
|
||||
}
|
||||
}
|
||||
self.bump_selection();
|
||||
if self.selected != prev {
|
||||
self.bump_selection_set();
|
||||
}
|
||||
self.selected.len()
|
||||
}
|
||||
|
||||
|
|
@ -709,7 +737,8 @@ impl Scene {
|
|||
|
||||
let mut handle_set: HashSet<Handle> = HashSet::default();
|
||||
let mut erased: Vec<(Handle, ChangeKind)> = Vec::new();
|
||||
let mut highlight_changed = false;
|
||||
let mut selection_changed = false;
|
||||
let mut hover_changed = false;
|
||||
|
||||
for &h in &erase_handles {
|
||||
// Objects on a locked layer can't be erased.
|
||||
|
|
@ -724,11 +753,11 @@ impl Scene {
|
|||
self.delete_solid_history(h);
|
||||
self.remember_removed_cache_categories(h);
|
||||
self.document.remove_entity_arc(h);
|
||||
highlight_changed |= self.selected.remove(&h);
|
||||
selection_changed |= self.selected.remove(&h);
|
||||
self.selected_order.retain(|selected| *selected != h);
|
||||
if self.hover_highlight == Some(h) {
|
||||
self.hover_highlight = None;
|
||||
highlight_changed = true;
|
||||
hover_changed = true;
|
||||
}
|
||||
self.hatches.remove(&h);
|
||||
self.images.remove(&h);
|
||||
|
|
@ -738,7 +767,9 @@ impl Scene {
|
|||
handle_set.insert(h);
|
||||
erased.push((h, ChangeKind::Removed));
|
||||
}
|
||||
if highlight_changed {
|
||||
if selection_changed {
|
||||
self.bump_selection_set();
|
||||
} else if hover_changed {
|
||||
self.bump_selection();
|
||||
}
|
||||
// Capture exactly the group objects that this erase will rewrite, plus
|
||||
|
|
@ -838,3 +869,29 @@ impl Scene {
|
|||
restored
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selection_fingerprint_tracks_final_set_only() {
|
||||
let mut scene = Scene::default();
|
||||
let first = Handle::new(1);
|
||||
let second = Handle::new(2);
|
||||
scene.select_entity(first, false);
|
||||
scene.select_entity(second, false);
|
||||
let fingerprint = scene.selection_fingerprint();
|
||||
assert!(!scene.selection_fingerprint_dirty);
|
||||
|
||||
scene.deselect_all();
|
||||
scene.select_entity(second, false);
|
||||
scene.select_entity(first, false);
|
||||
assert!(scene.selection_fingerprint_dirty);
|
||||
assert_eq!(scene.selection_fingerprint(), fingerprint);
|
||||
|
||||
scene.set_hover_highlight(Some(Handle::new(3)));
|
||||
assert!(!scene.selection_fingerprint_dirty);
|
||||
assert_eq!(scene.selection_fingerprint(), fingerprint);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ pub struct State {
|
|||
pub projected_height: String,
|
||||
pub projected_zone: bool,
|
||||
pub datum_identifier: String,
|
||||
plain_frame: bool,
|
||||
symbol_tail: String,
|
||||
extra_rows: Vec<String>,
|
||||
original_text: Option<String>,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
|
|
@ -50,6 +55,11 @@ impl Default for State {
|
|||
projected_height: String::new(),
|
||||
projected_zone: false,
|
||||
datum_identifier: String::new(),
|
||||
plain_frame: false,
|
||||
symbol_tail: String::new(),
|
||||
extra_rows: Vec::new(),
|
||||
original_text: None,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -130,16 +140,32 @@ fn escape(code: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn strip_escape(input: &str, code: &str) -> Option<String> {
|
||||
let marker = escape(code);
|
||||
input
|
||||
.strip_suffix(&marker)
|
||||
.map(std::string::ToString::to_string)
|
||||
fn remove_escape(input: &str, code: &str) -> Option<String> {
|
||||
let target = code.chars().next()?;
|
||||
for (start, ch) in input.char_indices() {
|
||||
if ch != '{' {
|
||||
continue;
|
||||
}
|
||||
let tail = &input[start + 1..];
|
||||
let Some(relative_end) = tail.find('}') else {
|
||||
break;
|
||||
};
|
||||
let end = start + 1 + relative_end;
|
||||
if crate::entities::tolerance::symbol_font_switch(&input[start + 1..end])
|
||||
== Some(target)
|
||||
{
|
||||
let mut remaining = String::with_capacity(input.len() - (end + 1 - start));
|
||||
remaining.push_str(&input[..start]);
|
||||
remaining.push_str(&input[end + 1..]);
|
||||
return Some(remaining);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_material(input: &str) -> (String, String) {
|
||||
for code in ["m", "l", "s"] {
|
||||
if let Some(value) = strip_escape(input, code) {
|
||||
if let Some(value) = remove_escape(input, code) {
|
||||
return (value, code.to_string());
|
||||
}
|
||||
}
|
||||
|
|
@ -157,125 +183,186 @@ impl State {
|
|||
.replace("\\P", "\n")
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', "\n");
|
||||
let mut lines = normalized.lines();
|
||||
let lines: Vec<&str> = normalized.split('\n').collect();
|
||||
state.original_text = (!raw.is_empty()).then(|| raw.to_string());
|
||||
|
||||
if let Some(frame) = lines.next() {
|
||||
if let Some(frame) = lines.first().copied() {
|
||||
let cells: Vec<&str> = frame.split("%%v").collect();
|
||||
if let Some(cell) = cells.first() {
|
||||
state.symbol = SYMBOLS
|
||||
.iter()
|
||||
.find_map(|(code, _)| {
|
||||
(!code.is_empty() && cell.contains(&escape(code))).then(|| code.to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
for index in 0..2 {
|
||||
if let Some(cell) = cells.get(index + 1) {
|
||||
let (cell, diameter) = if let Some(rest) = cell.strip_prefix(&escape("n")) {
|
||||
(rest, true)
|
||||
} else {
|
||||
(*cell, false)
|
||||
};
|
||||
let (value, material) = parse_material(cell);
|
||||
state.tolerances[index] = ToleranceEntry {
|
||||
diameter,
|
||||
value,
|
||||
material,
|
||||
};
|
||||
let mut remaining = (*cell).to_string();
|
||||
for (code, _) in SYMBOLS.iter().filter(|(code, _)| !code.is_empty()) {
|
||||
if let Some(value) = remove_escape(&remaining, code) {
|
||||
state.symbol = (*code).to_string();
|
||||
remaining = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.symbol_tail = remaining;
|
||||
}
|
||||
for index in 0..3 {
|
||||
if let Some(cell) = cells.get(index + 3) {
|
||||
let (value, material) = parse_material(cell);
|
||||
state.datums[index] = DatumEntry { value, material };
|
||||
state.plain_frame = !raw.is_empty() && cells.len() == 1 && state.symbol.is_empty();
|
||||
if state.plain_frame {
|
||||
state.tolerances[0].value = frame.to_string();
|
||||
state.symbol_tail.clear();
|
||||
} else {
|
||||
for index in 0..2 {
|
||||
if let Some(cell) = cells.get(index + 1) {
|
||||
let (cell, diameter) = remove_escape(cell, "n")
|
||||
.map_or_else(|| ((*cell).to_string(), false), |value| (value, true));
|
||||
let (value, material) = parse_material(&cell);
|
||||
state.tolerances[index] = ToleranceEntry {
|
||||
diameter,
|
||||
value,
|
||||
material,
|
||||
};
|
||||
}
|
||||
}
|
||||
for index in 0..3 {
|
||||
if let Some(cell) = cells.get(index + 3) {
|
||||
let (value, material) = parse_material(cell);
|
||||
state.datums[index] = DatumEntry { value, material };
|
||||
}
|
||||
}
|
||||
}
|
||||
if cells.len() == 1
|
||||
&& state.symbol.is_empty()
|
||||
&& !frame.trim().is_empty()
|
||||
{
|
||||
state.tolerances[0].value = frame.trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(projected) = lines.next() {
|
||||
if let Some(value) = strip_escape(projected, "p") {
|
||||
let mut next = 1;
|
||||
if let Some(projected) = lines.get(next).copied() {
|
||||
if let Some(value) = remove_escape(projected, "p") {
|
||||
state.projected_height = value;
|
||||
state.projected_zone = true;
|
||||
} else {
|
||||
next += 1;
|
||||
} else if lines.get(next + 1).is_some()
|
||||
&& !projected.contains("%%v")
|
||||
&& !lines[next + 1].contains("%%v")
|
||||
{
|
||||
state.projected_height = projected.to_string();
|
||||
next += 1;
|
||||
}
|
||||
}
|
||||
if let Some(identifier) = lines.next() {
|
||||
state.datum_identifier = identifier.to_string();
|
||||
if next > 1 {
|
||||
if let Some(identifier) = lines.get(next).copied() {
|
||||
if !identifier.contains("%%v") {
|
||||
state.datum_identifier = identifier.to_string();
|
||||
next += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.extra_rows = lines[next..].iter().map(|row| (*row).to_string()).collect();
|
||||
state
|
||||
}
|
||||
|
||||
pub fn apply_field(&mut self, field: Field) {
|
||||
match field {
|
||||
Field::Symbol(value) => self.symbol = value,
|
||||
let changed = match field {
|
||||
Field::Symbol(value) => {
|
||||
let changed = self.symbol != value;
|
||||
self.symbol = value;
|
||||
changed
|
||||
}
|
||||
Field::ToleranceValue(index, value) if index < 2 => {
|
||||
self.tolerances[index].value = value
|
||||
let changed = self.tolerances[index].value != value;
|
||||
self.tolerances[index].value = value;
|
||||
changed
|
||||
}
|
||||
Field::ToleranceMaterial(index, value) if index < 2 => {
|
||||
self.tolerances[index].material = value
|
||||
let changed = self.tolerances[index].material != value;
|
||||
self.tolerances[index].material = value;
|
||||
changed
|
||||
}
|
||||
Field::DatumValue(index, value) if index < 3 => {
|
||||
let changed = self.datums[index].value != value;
|
||||
self.datums[index].value = value;
|
||||
changed
|
||||
}
|
||||
Field::DatumValue(index, value) if index < 3 => self.datums[index].value = value,
|
||||
Field::DatumMaterial(index, value) if index < 3 => {
|
||||
self.datums[index].material = value
|
||||
let changed = self.datums[index].material != value;
|
||||
self.datums[index].material = value;
|
||||
changed
|
||||
}
|
||||
Field::ProjectedHeight(value) => self.projected_height = value,
|
||||
Field::DatumIdentifier(value) => self.datum_identifier = value,
|
||||
_ => {}
|
||||
}
|
||||
Field::ProjectedHeight(value) => {
|
||||
let changed = self.projected_height != value;
|
||||
self.projected_height = value;
|
||||
changed
|
||||
}
|
||||
Field::DatumIdentifier(value) => {
|
||||
let changed = self.datum_identifier != value;
|
||||
self.datum_identifier = value;
|
||||
changed
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.dirty |= changed;
|
||||
}
|
||||
|
||||
pub fn apply_toggle(&mut self, toggle: Toggle) {
|
||||
match toggle {
|
||||
let changed = match toggle {
|
||||
Toggle::Diameter(index, value) if index < 2 => {
|
||||
self.tolerances[index].diameter = value
|
||||
let changed = self.tolerances[index].diameter != value;
|
||||
self.tolerances[index].diameter = value;
|
||||
changed
|
||||
}
|
||||
Toggle::ProjectedZone(value) => self.projected_zone = value,
|
||||
_ => {}
|
||||
}
|
||||
Toggle::ProjectedZone(value) => {
|
||||
let changed = self.projected_zone != value;
|
||||
self.projected_zone = value;
|
||||
changed
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.dirty |= changed;
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.symbol.is_empty()
|
||||
|| !self.symbol_tail.is_empty()
|
||||
|| self.tolerances.iter().any(|entry| !entry.value.trim().is_empty())
|
||||
|| self.datums.iter().any(|entry| !entry.value.trim().is_empty())
|
||||
|| !self.projected_height.trim().is_empty()
|
||||
|| !self.datum_identifier.trim().is_empty()
|
||||
|| self.extra_rows.iter().any(|row| !row.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn to_text(&self) -> String {
|
||||
let mut cells = Vec::with_capacity(6);
|
||||
cells.push(escape(&self.symbol));
|
||||
for entry in &self.tolerances {
|
||||
let mut value = String::new();
|
||||
if entry.diameter && !entry.value.trim().is_empty() {
|
||||
value.push_str(&escape("n"));
|
||||
if !self.dirty {
|
||||
if let Some(original) = &self.original_text {
|
||||
return original.clone();
|
||||
}
|
||||
value.push_str(entry.value.trim());
|
||||
if !entry.value.trim().is_empty() {
|
||||
value.push_str(&escape(&entry.material));
|
||||
}
|
||||
|
||||
let only_plain_value = self.plain_frame
|
||||
&& self.symbol.is_empty()
|
||||
&& self.symbol_tail.is_empty()
|
||||
&& self.tolerances[1] == ToleranceEntry::default()
|
||||
&& self.datums.iter().all(|entry| *entry == DatumEntry::default());
|
||||
let frame = if only_plain_value {
|
||||
self.tolerances[0].value.clone()
|
||||
} else {
|
||||
let mut cells = Vec::with_capacity(6);
|
||||
cells.push(format!("{}{}", escape(&self.symbol), self.symbol_tail));
|
||||
for entry in &self.tolerances {
|
||||
let mut value = String::new();
|
||||
if entry.diameter && !entry.value.trim().is_empty() {
|
||||
value.push_str(&escape("n"));
|
||||
}
|
||||
value.push_str(&entry.value);
|
||||
if !entry.value.trim().is_empty() {
|
||||
value.push_str(&escape(&entry.material));
|
||||
}
|
||||
cells.push(value);
|
||||
}
|
||||
cells.push(value);
|
||||
}
|
||||
for entry in &self.datums {
|
||||
let mut value = entry.value.trim().to_string();
|
||||
if !value.is_empty() {
|
||||
value.push_str(&escape(&entry.material));
|
||||
for entry in &self.datums {
|
||||
let mut value = entry.value.clone();
|
||||
if !value.trim().is_empty() {
|
||||
value.push_str(&escape(&entry.material));
|
||||
}
|
||||
cells.push(value);
|
||||
}
|
||||
cells.push(value);
|
||||
}
|
||||
while cells.last().is_some_and(String::is_empty) {
|
||||
cells.pop();
|
||||
}
|
||||
let mut rows = vec![cells.join("%%v")];
|
||||
while cells.last().is_some_and(String::is_empty) {
|
||||
cells.pop();
|
||||
}
|
||||
cells.join("%%v")
|
||||
};
|
||||
let mut rows = vec![frame];
|
||||
if !self.projected_height.trim().is_empty() || self.projected_zone {
|
||||
let mut projected = self.projected_height.trim().to_string();
|
||||
let mut projected = self.projected_height.clone();
|
||||
if self.projected_zone {
|
||||
projected.push_str(&escape("p"));
|
||||
}
|
||||
|
|
@ -285,8 +372,9 @@ impl State {
|
|||
if rows.len() == 1 {
|
||||
rows.push(String::new());
|
||||
}
|
||||
rows.push(self.datum_identifier.trim().to_string());
|
||||
rows.push(self.datum_identifier.clone());
|
||||
}
|
||||
rows.extend(self.extra_rows.iter().cloned());
|
||||
rows.join("\n")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue