fix(dimension): harden jogged radius workflow
This commit is contained in:
commit
bc750c6d76
71 changed files with 5830 additions and 856 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 } => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ impl OpenCADStudio {
|
|||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"DIMARC" => {
|
||||
use crate::modules::annotate::arc_length_dim::ArcLengthDimensionCommand;
|
||||
let new_cmd = ArcLengthDimensionCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"DIMORDINATE" => {
|
||||
use crate::modules::annotate::ordinate_dim::OrdinateDimCommand;
|
||||
let new_cmd = OrdinateDimCommand::new();
|
||||
|
|
@ -605,10 +612,7 @@ impl OpenCADStudio {
|
|||
}
|
||||
|
||||
"TOLERANCE" => {
|
||||
use crate::modules::annotate::tolerance_cmd::ToleranceCommand;
|
||||
let cmd = ToleranceCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
self.open_tolerance_dialog(None);
|
||||
}
|
||||
|
||||
"TABLE" => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub(crate) mod settings;
|
|||
mod shortcuts;
|
||||
mod style_ops;
|
||||
mod text_inline;
|
||||
mod tolerance_dialog;
|
||||
mod update;
|
||||
mod view;
|
||||
mod visibility;
|
||||
|
|
@ -399,6 +400,8 @@ pub(super) struct OpenCADStudio {
|
|||
layer_translator: Option<crate::ui::window::layer_translator::State>,
|
||||
/// Working copy of the Drawing Units dialog; `None` while it is closed.
|
||||
drawing_units: Option<crate::ui::window::drawing_units::State>,
|
||||
/// Working copy of the structured feature-control-frame editor.
|
||||
geometric_tolerance: Option<crate::ui::window::geometric_tolerance::State>,
|
||||
/// PICKDRAG (#226): false (default) = press-drag lassoes; true =
|
||||
/// press-drag draws a rectangle marquee.
|
||||
pick_drag_rect: bool,
|
||||
|
|
@ -691,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>,
|
||||
|
|
@ -1588,6 +1595,7 @@ pub enum ModalKind {
|
|||
LayerStateManager,
|
||||
LayerTranslator,
|
||||
DrawingUnits,
|
||||
GeometricTolerance,
|
||||
DraftingSettings,
|
||||
LayerStateEditor,
|
||||
Plot,
|
||||
|
|
@ -2318,6 +2326,14 @@ pub enum Message {
|
|||
DrawingUnitsField(crate::ui::window::drawing_units::Field),
|
||||
/// Drawing Units OK — write the working copy into the drawing.
|
||||
DrawingUnitsApply,
|
||||
/// One structured feature-control-frame field changed.
|
||||
ToleranceDialogField(crate::ui::window::geometric_tolerance::Field),
|
||||
/// One structured feature-control-frame option changed.
|
||||
ToleranceDialogToggle(crate::ui::window::geometric_tolerance::Toggle),
|
||||
/// Apply edits without closing the structured editor.
|
||||
ToleranceDialogApply,
|
||||
/// Commit edits or continue to insertion-point placement.
|
||||
ToleranceDialogOk,
|
||||
/// Toggle the Isolate pill's action menu open/closed.
|
||||
ToggleIsolatePopup,
|
||||
/// Close the Isolate action menu.
|
||||
|
|
@ -2655,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),
|
||||
|
|
@ -3145,6 +3186,7 @@ impl OpenCADStudio {
|
|||
last_layer_translation: None,
|
||||
layer_translator: None,
|
||||
drawing_units: None,
|
||||
geometric_tolerance: None,
|
||||
pick_drag_rect: false,
|
||||
perf_hud: false,
|
||||
cycle_candidates: None,
|
||||
|
|
@ -3246,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();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use super::helpers::{entity_type_key, entity_type_label, title_case_word};
|
|||
use super::{OpenCADStudio, VARIES_LABEL};
|
||||
use crate::io::linetypes;
|
||||
use crate::scene::view::dispatch;
|
||||
use crate::scene::model::object::PropValue;
|
||||
use crate::ui;
|
||||
use crate::t;
|
||||
use acadrust::types::{Transform, Vector3};
|
||||
|
|
@ -1317,11 +1318,88 @@ impl OpenCADStudio {
|
|||
// Feature-control frame: FCF text style is the dimension
|
||||
// style's DIMTXSTY.
|
||||
acadrust::EntityType::Tolerance(tol) => {
|
||||
if let Some(ds) = find_dim_style(doc, &tol.dimension_style_name) {
|
||||
if !ds.dimtxsty.is_empty() {
|
||||
set_row(&mut sections, "tol_text_style", ds.dimtxsty.clone());
|
||||
}
|
||||
use crate::entities::dim_override as dov;
|
||||
let style = crate::entities::tolerance::resolve_dim_style(tol, doc);
|
||||
let style_name = style
|
||||
.map(|entry| entry.name.clone())
|
||||
.unwrap_or_else(|| {
|
||||
if tol.dimension_style_name.trim().is_empty() {
|
||||
"Standard".to_string()
|
||||
} else {
|
||||
tol.dimension_style_name.clone()
|
||||
}
|
||||
});
|
||||
let mut dim_style_names: Vec<String> = doc
|
||||
.dim_styles
|
||||
.iter()
|
||||
.map(|entry| entry.name.clone())
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.collect();
|
||||
if !dim_style_names
|
||||
.iter()
|
||||
.any(|name| name.eq_ignore_ascii_case(&style_name))
|
||||
{
|
||||
dim_style_names.push(style_name.clone());
|
||||
}
|
||||
set_row_value(
|
||||
&mut sections,
|
||||
"tol_dim_style",
|
||||
PropValue::Choice {
|
||||
selected: style_name,
|
||||
options: dim_style_names,
|
||||
},
|
||||
);
|
||||
|
||||
let text_style_name = dov::handle(
|
||||
&tol.common.extended_data,
|
||||
dov::DIMTXSTY,
|
||||
)
|
||||
.and_then(|handle| {
|
||||
doc.text_styles
|
||||
.iter()
|
||||
.find(|entry| entry.handle == handle)
|
||||
})
|
||||
.map(|entry| entry.name.clone())
|
||||
.or_else(|| style.map(|entry| entry.dimtxsty.clone()))
|
||||
.unwrap_or_else(|| "Standard".to_string());
|
||||
let text_height_editable = doc
|
||||
.text_styles
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
entry.name.eq_ignore_ascii_case(&text_style_name)
|
||||
})
|
||||
.is_none_or(|entry| !entry.has_fixed_height());
|
||||
let mut names = text_style_names.clone();
|
||||
if !names
|
||||
.iter()
|
||||
.any(|name| name.eq_ignore_ascii_case(&text_style_name))
|
||||
{
|
||||
names.push(text_style_name.clone());
|
||||
}
|
||||
set_row_value(
|
||||
&mut sections,
|
||||
"tol_text_style",
|
||||
PropValue::Choice {
|
||||
selected: text_style_name,
|
||||
options: names,
|
||||
},
|
||||
);
|
||||
|
||||
let height = dov::real(
|
||||
&tol.common.extended_data,
|
||||
dov::DIMTXT,
|
||||
)
|
||||
.or_else(|| style.map(|entry| entry.dimtxt))
|
||||
.unwrap_or(tol.text_height);
|
||||
set_row_value(
|
||||
&mut sections,
|
||||
"tol_text_height",
|
||||
if text_height_editable {
|
||||
PropValue::EditText(format!("{height:.4}"))
|
||||
} else {
|
||||
PropValue::ReadOnly(format!("{height:.4}"))
|
||||
},
|
||||
);
|
||||
}
|
||||
// MultiLeader: max points + segment-angle constraints
|
||||
// are MLeaderStyle settings, not stored on the entity.
|
||||
|
|
@ -1712,7 +1790,7 @@ impl OpenCADStudio {
|
|||
{
|
||||
// Which entities show an Annotative row, the field it uses,
|
||||
// and — for those that don't already carry the row
|
||||
// (dimension / table) — the existing field to insert it
|
||||
// (dimension / table / tolerance) — the existing field to insert it
|
||||
// after. MLeader uses its editable toggle field.
|
||||
let anno: Option<(&str, Option<&str>)> = match entity {
|
||||
acadrust::EntityType::Text(_)
|
||||
|
|
@ -1726,14 +1804,33 @@ impl OpenCADStudio {
|
|||
acadrust::EntityType::Dimension(_) => {
|
||||
Some(("annotative", Some("style_name")))
|
||||
}
|
||||
acadrust::EntityType::Tolerance(_) => {
|
||||
Some(("annotative", Some("tol_dim_style")))
|
||||
}
|
||||
acadrust::EntityType::Table(_) => {
|
||||
Some(("annotative", Some("tbl_style_handle")))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((anno_field, insert_after)) = anno {
|
||||
let is_anno = crate::scene::annotative::is_annotative(doc, entity);
|
||||
// Dimensions/tables carry no Annotative row yet — add one
|
||||
let is_anno = crate::scene::annotative::is_annotative(doc, entity)
|
||||
|| match entity {
|
||||
acadrust::EntityType::Dimension(
|
||||
acadrust::entities::Dimension::Arc(dimension),
|
||||
) => {
|
||||
crate::scene::annotative::dim_style_is_annotative(
|
||||
doc,
|
||||
&dimension.base.style_name,
|
||||
)
|
||||
}
|
||||
acadrust::EntityType::Tolerance(_) => {
|
||||
crate::scene::annotative::annotation_style_is_annotative(
|
||||
doc, entity,
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
// Dimensions/tables/tolerances carry no Annotative row yet — add one
|
||||
// right after their style row.
|
||||
if let Some(anchor) = insert_after {
|
||||
insert_row_after(
|
||||
|
|
@ -1761,6 +1858,13 @@ impl OpenCADStudio {
|
|||
value: t.is_annotative,
|
||||
},
|
||||
),
|
||||
acadrust::EntityType::Dimension(
|
||||
acadrust::entities::Dimension::Arc(_),
|
||||
) => set_row(
|
||||
&mut sections,
|
||||
"annotative",
|
||||
if is_anno { "Yes" } else { "No" }.to_string(),
|
||||
),
|
||||
acadrust::EntityType::Text(_)
|
||||
| acadrust::EntityType::Insert(_)
|
||||
| acadrust::EntityType::Hatch(_)
|
||||
|
|
@ -1806,6 +1910,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,
|
||||
}
|
||||
|
|
@ -145,6 +148,13 @@ impl super::OpenCADStudio {
|
|||
if self.tabs[i].scene.is_layer_locked(target) {
|
||||
return iced::Task::none();
|
||||
}
|
||||
if matches!(
|
||||
self.tabs[i].scene.document.get_entity(target),
|
||||
Some(EntityType::Tolerance(_))
|
||||
) {
|
||||
self.open_tolerance_dialog(Some(target));
|
||||
return iced::Task::none();
|
||||
}
|
||||
// Snapshot what we need before borrowing `self` mutably to open.
|
||||
let Some(entity) = self.tabs[i].scene.document.get_entity(target) else {
|
||||
return iced::Task::none();
|
||||
|
|
@ -166,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))
|
||||
}
|
||||
}
|
||||
|
|
@ -183,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;
|
||||
|
|
@ -193,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 {
|
||||
|
|
@ -229,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]
|
||||
|
|
@ -244,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();
|
||||
|
|
|
|||
108
src/app/tolerance_dialog.rs
Normal file
108
src/app/tolerance_dialog.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
use acadrust::{EntityType, Handle};
|
||||
|
||||
impl super::OpenCADStudio {
|
||||
pub(super) fn open_tolerance_dialog(&mut self, editing: Option<Handle>) {
|
||||
let i = self.active_tab;
|
||||
if editing.is_some_and(|handle| self.tabs[i].scene.is_layer_locked(handle)) {
|
||||
return;
|
||||
}
|
||||
let text = editing
|
||||
.and_then(|handle| self.tabs[i].scene.document.get_entity(handle))
|
||||
.and_then(|entity| match entity {
|
||||
EntityType::Tolerance(tolerance) => Some(tolerance.text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
self.geometric_tolerance = Some(
|
||||
crate::ui::window::geometric_tolerance::State::from_text(editing, text),
|
||||
);
|
||||
self.active_modal = Some(super::ModalKind::GeometricTolerance);
|
||||
self.modal_offset = iced::Vector::ZERO;
|
||||
self.modal_resize = iced::Vector::ZERO;
|
||||
self.modal_content_size = None;
|
||||
self.modal_drag_last = None;
|
||||
self.modal_dragging = false;
|
||||
self.modal_resizing = false;
|
||||
}
|
||||
|
||||
pub(super) fn apply_tolerance_dialog_edit(&mut self) -> bool {
|
||||
let Some(state) = self.geometric_tolerance.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(handle) = state.editing else {
|
||||
return false;
|
||||
};
|
||||
if !state.is_valid() || self.tabs[self.active_tab].scene.is_layer_locked(handle) {
|
||||
return false;
|
||||
}
|
||||
let text = state.to_text();
|
||||
let i = self.active_tab;
|
||||
let unchanged = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.is_some_and(|entity| {
|
||||
matches!(entity, EntityType::Tolerance(tolerance) if tolerance.text == text)
|
||||
});
|
||||
if unchanged {
|
||||
return true;
|
||||
}
|
||||
self.push_undo_snapshot(i, "TOLERANCE");
|
||||
let Some(EntityType::Tolerance(tolerance)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
tolerance.text = text;
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn begin_tolerance_placement(&mut self) -> bool {
|
||||
let Some(state) = self.geometric_tolerance.take() else {
|
||||
return false;
|
||||
};
|
||||
if !state.is_valid() || state.editing.is_some() {
|
||||
self.geometric_tolerance = Some(state);
|
||||
return false;
|
||||
}
|
||||
let i = self.active_tab;
|
||||
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()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
command.set_working_plane(plane);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
self.active_modal = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -1901,7 +1906,73 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
}
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
|
||||
if field.starts_with("dim_") {
|
||||
if field == "tol_text_style" {
|
||||
use crate::entities::dim_override as dov;
|
||||
use acadrust::xdata::XDataValue;
|
||||
let style_handle = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.text_styles
|
||||
.iter()
|
||||
.find(|entry| entry.name.eq_ignore_ascii_case(&value))
|
||||
.map(|entry| entry.handle);
|
||||
if let Some(style_handle) = style_handle {
|
||||
for &handle in &handles {
|
||||
if self.tabs[i].scene.is_layer_locked(handle)
|
||||
|| !matches!(
|
||||
self.tabs[i].scene.document.get_entity(handle),
|
||||
Some(acadrust::EntityType::Tolerance(_))
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
dov::set(
|
||||
&mut self.tabs[i].scene.document,
|
||||
handle,
|
||||
dov::DIMTXSTY,
|
||||
Some(XDataValue::Handle(style_handle)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if field == "tol_dim_style" {
|
||||
let style = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.dim_styles
|
||||
.iter()
|
||||
.find(|entry| entry.name.eq_ignore_ascii_case(&value))
|
||||
.map(|entry| (entry.handle, entry.name.clone(), entry.annotative));
|
||||
if let Some((style_handle, style_name, annotative)) = style {
|
||||
let scale = self.tabs[i].scene.creation_annotation_scale_handle();
|
||||
for &handle in &handles {
|
||||
if self.tabs[i].scene.is_layer_locked(handle) {
|
||||
continue;
|
||||
}
|
||||
if let Some(acadrust::EntityType::Tolerance(tolerance)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
tolerance.dimension_style_handle = Some(style_handle);
|
||||
tolerance.dimension_style_name = style_name.clone();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
crate::scene::annotative::set_entity_annotative(
|
||||
&mut self.tabs[i].scene.document,
|
||||
handle,
|
||||
annotative,
|
||||
);
|
||||
if annotative {
|
||||
if let Some(scale) = scale {
|
||||
crate::scene::annotative::create_annotation_context(
|
||||
&mut self.tabs[i].scene.document,
|
||||
handle,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if field.starts_with("dim_") {
|
||||
for &handle in &handles {
|
||||
if self.tabs[i].scene.is_layer_locked(handle) {
|
||||
continue;
|
||||
|
|
@ -2552,6 +2623,27 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
continue;
|
||||
}
|
||||
match field {
|
||||
"tol_text_height" => {
|
||||
use crate::entities::dim_override as dov;
|
||||
let trimmed = val.trim();
|
||||
if trimmed.is_empty() {
|
||||
dov::set(
|
||||
&mut self.tabs[i].scene.document,
|
||||
handle,
|
||||
dov::DIMTXT,
|
||||
None,
|
||||
);
|
||||
} else if let Ok(height) = trimmed.parse::<f64>() {
|
||||
if height > 0.0 {
|
||||
dov::set(
|
||||
&mut self.tabs[i].scene.document,
|
||||
handle,
|
||||
dov::DIMTXT,
|
||||
Some(acadrust::xdata::XDataValue::Real(height)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if field.starts_with("dim_") => {
|
||||
if matches!(
|
||||
self.tabs[i].scene.document.get_entity(handle),
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ impl OpenCADStudio {
|
|||
self.attr_editor_selected = 0;
|
||||
self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute;
|
||||
}
|
||||
Some(GeometricTolerance) => self.geometric_tolerance = None,
|
||||
// Closing (✕) discards edits made since the last Apply — matching the
|
||||
// style editors. Committing happens only through the Apply button.
|
||||
Some(Aliases) => self.alias_editor_rows.clear(),
|
||||
|
|
@ -234,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);
|
||||
|
|
@ -278,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).
|
||||
|
|
@ -3706,6 +3731,40 @@ impl OpenCADStudio {
|
|||
self.refresh_properties();
|
||||
Task::none()
|
||||
}
|
||||
Message::ToleranceDialogField(field) => {
|
||||
if let Some(state) = self.geometric_tolerance.as_mut() {
|
||||
state.apply_field(field);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ToleranceDialogToggle(toggle) => {
|
||||
if let Some(state) = self.geometric_tolerance.as_mut() {
|
||||
state.apply_toggle(toggle);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ToleranceDialogApply => {
|
||||
self.apply_tolerance_dialog_edit();
|
||||
Task::none()
|
||||
}
|
||||
Message::ToleranceDialogOk => {
|
||||
let editing = self
|
||||
.geometric_tolerance
|
||||
.as_ref()
|
||||
.and_then(|state| state.editing)
|
||||
.is_some();
|
||||
if editing {
|
||||
if self.apply_tolerance_dialog_edit() {
|
||||
self.geometric_tolerance = None;
|
||||
self.active_modal = None;
|
||||
self.reset_modal_geometry();
|
||||
}
|
||||
} else {
|
||||
self.begin_tolerance_placement();
|
||||
self.reset_modal_geometry();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::SetLinearFormat(code) => {
|
||||
self.units_popup_open = false;
|
||||
let i = self.active_tab;
|
||||
|
|
@ -3880,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) => {
|
||||
|
|
@ -3915,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;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ impl OpenCADStudio {
|
|||
Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"),
|
||||
Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(),
|
||||
Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(),
|
||||
Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(),
|
||||
Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(),
|
||||
Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"),
|
||||
Some(K::Plot) => crate::tr!("modal", "plot"),
|
||||
|
|
@ -234,6 +235,12 @@ impl OpenCADStudio {
|
|||
crate::ui::window::drawing_units::view_window(state, flow)
|
||||
})
|
||||
}
|
||||
super::super::ModalKind::GeometricTolerance => {
|
||||
let state = self.geometric_tolerance.as_ref()?;
|
||||
sized_flow(ex, 670, 530, |flow| {
|
||||
crate::ui::window::geometric_tolerance::view_window(state, flow)
|
||||
})
|
||||
}
|
||||
super::super::ModalKind::LayerStateManager => {
|
||||
let states = self.tabs[self.active_tab].scene.document.layer_states();
|
||||
sized_flow(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -286,6 +286,7 @@ pub fn property_int_code(field: &str) -> Option<i16> {
|
|||
"dim_tolerance_precision" => DIMTDEC,
|
||||
"dim_tolerance_pos_vert" => DIMTOLJ,
|
||||
"dim_tolerance_alignment" => DIMTALN,
|
||||
"dim_arc_symbol" => DIMARCSYM,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -337,6 +338,7 @@ fn inherited_int(doc: &CadDocument, handle: Handle, code: i16) -> i16 {
|
|||
DIMADEC => style.dimadec,
|
||||
DIMTFILL => style.dimtfill,
|
||||
DIMTALN => 0,
|
||||
DIMARCSYM => style.dimarcsym,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
|
@ -703,6 +705,12 @@ pub fn set_property(
|
|||
"move text, no leader" => Some(2),
|
||||
_ => trimmed.parse().ok(),
|
||||
},
|
||||
"dim_arc_symbol" => match trimmed.to_ascii_lowercase().as_str() {
|
||||
"preceding dimension text" => Some(0),
|
||||
"above dimension text" => Some(1),
|
||||
"none" => Some(2),
|
||||
_ => trimmed.parse().ok(),
|
||||
},
|
||||
"dim_alt_format" => match trimmed.to_ascii_lowercase().as_str() {
|
||||
"scientific" => Some(1),
|
||||
"decimal" => Some(2),
|
||||
|
|
|
|||
|
|
@ -146,6 +146,11 @@ fn properties(dim: &Dimension) -> Vec<PropSection> {
|
|||
"jog_angle",
|
||||
d.jog_angle.to_degrees(),
|
||||
),
|
||||
ro(
|
||||
t!("Measurement").as_ref(),
|
||||
"measurement",
|
||||
format!("{:.4}", d.measurement()),
|
||||
),
|
||||
],
|
||||
}];
|
||||
}
|
||||
|
|
@ -216,6 +221,16 @@ fn properties(dim: &Dimension) -> Vec<PropSection> {
|
|||
],
|
||||
}];
|
||||
}
|
||||
if let Dimension::Arc(d) = dim {
|
||||
return vec![PropSection {
|
||||
title: t!("Misc").into_owned(),
|
||||
props: vec![Property {
|
||||
label: t!("Dimension style").into_owned(),
|
||||
field: "style_name",
|
||||
value: PropValue::PlainText(d.base.style_name.clone()),
|
||||
}],
|
||||
}];
|
||||
}
|
||||
let mut props = base_props(dim.base());
|
||||
match dim {
|
||||
Dimension::Aligned(d) => {
|
||||
|
|
@ -2638,6 +2653,33 @@ pub fn style_sections(
|
|||
}
|
||||
}
|
||||
|
||||
if matches!(dimension, Dimension::Arc(_)) {
|
||||
if let Some(lines) = sections
|
||||
.iter_mut()
|
||||
.find(|section| section.title == t!("Lines & Arrows").as_ref())
|
||||
{
|
||||
let symbol = match int(ov::DIMARCSYM, s.dimarcsym) {
|
||||
1 => "Above dimension text",
|
||||
2 => "None",
|
||||
_ => "Preceding dimension text",
|
||||
};
|
||||
lines.props.insert(
|
||||
3,
|
||||
choice(
|
||||
t!("Arc length symbol").as_ref(),
|
||||
"dim_arc_symbol",
|
||||
symbol,
|
||||
&[
|
||||
"Preceding dimension text",
|
||||
"Above dimension text",
|
||||
"None",
|
||||
],
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(dimension, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) {
|
||||
if let Some(text_section) = sections
|
||||
.iter_mut()
|
||||
|
|
@ -2914,7 +2956,7 @@ use crate::scene::convert::tessellate::{
|
|||
add_polyline, add_segment, append_arrow, arrow_from_block_with_deferred_hatch,
|
||||
normalized_or, ArrowKind, DimGeom,
|
||||
};
|
||||
use crate::scene::model::wire_model::{SnapHint, WireModel};
|
||||
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
|
||||
|
||||
fn apply_dimension_breaks(
|
||||
document: &CadDocument,
|
||||
|
|
@ -3078,6 +3120,50 @@ fn tessellate_dimension_inner(
|
|||
view_aabb: Option<[f32; 4]>,
|
||||
world_per_pixel: Option<f32>,
|
||||
) -> Vec<WireModel> {
|
||||
if let Dimension::Arc(arc) = dim {
|
||||
if let Some((local_arc, normal)) = arc_dimension_in_ocs(arc) {
|
||||
let mut wires = tessellate_dimension_inner(
|
||||
document,
|
||||
handle,
|
||||
&Dimension::Arc(local_arc),
|
||||
selected,
|
||||
entity_color,
|
||||
line_weight_px,
|
||||
anno_scale,
|
||||
selected_set,
|
||||
active_viewport,
|
||||
bg_color,
|
||||
None,
|
||||
world_per_pixel,
|
||||
);
|
||||
for wire in &mut wires {
|
||||
map_wire_ocs_to_wcs(wire, normal);
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
if let Dimension::LargeRadial(radial) = dim {
|
||||
if let Some((local_radial, normal)) = large_radial_dimension_in_ocs(radial) {
|
||||
let mut wires = tessellate_dimension_inner(
|
||||
document,
|
||||
handle,
|
||||
&Dimension::LargeRadial(local_radial),
|
||||
selected,
|
||||
entity_color,
|
||||
line_weight_px,
|
||||
anno_scale,
|
||||
selected_set,
|
||||
active_viewport,
|
||||
bg_color,
|
||||
None,
|
||||
world_per_pixel,
|
||||
);
|
||||
for wire in &mut wires {
|
||||
map_wire_ocs_to_wcs(wire, normal);
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
let name = handle.value().to_string();
|
||||
// (Baked-block fast path moved up into scene::tessellate_entity so the
|
||||
// recursive call goes through the LOD ladder, not the kernel path.)
|
||||
|
|
@ -3445,9 +3531,6 @@ fn tessellate_dimension_inner(
|
|||
// DIMUPT governs interactive creation-time text placement; saved
|
||||
// geometry already carries the resulting position.
|
||||
let _ = s.dimupt;
|
||||
// Per-entity arc-symbol and jog-angle values are consumed by their
|
||||
// native dimension variants; the resolved style values remain the
|
||||
// creation defaults.
|
||||
let _ = (s.dimarcsym, s.dimjogang);
|
||||
// DIMUNIT is the obsolete pre-R2000 linear unit format; DIMLUNIT
|
||||
// supersedes it. Read but not honoured.
|
||||
|
|
@ -3674,6 +3757,46 @@ fn tessellate_dimension_inner(
|
|||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
if let Some(symbol) = style.and_then(|style| {
|
||||
arc_length_symbol_points(dim, Some(style), dim_txt, dim_scale, style.dimarcsym)
|
||||
}) {
|
||||
let mut points = Vec::new();
|
||||
add_polyline(&mut points, &symbol);
|
||||
wires.push(WireModel {
|
||||
point_marker: None,
|
||||
taper_widths: Vec::new(),
|
||||
pattern_stations: Vec::new(),
|
||||
world_width: 0.0,
|
||||
depth_override: None,
|
||||
display_visible: true,
|
||||
plot_visible: true,
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
render_instance: None,
|
||||
pick_tris: Vec::new(),
|
||||
pick_tris_low: Vec::new(),
|
||||
dash_from_start: false,
|
||||
dash_align_end: None,
|
||||
text_verts: Vec::new(),
|
||||
name: name.clone(),
|
||||
points,
|
||||
points_low: Vec::new(),
|
||||
color: if selected { WireModel::SELECTED } else { text_color },
|
||||
selected,
|
||||
aci: 0,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// DIMTFILL: 0=none, 1=drawing background (mask), 2=DIMTFILLCLR.
|
||||
if let Some(s) = style {
|
||||
if s.dimtfill == 1 || s.dimtfill == 2 {
|
||||
|
|
@ -3975,6 +4098,57 @@ fn text_fill_rect(
|
|||
let p4 = corner(-hx, hy);
|
||||
Some(vec![p1, p2, p3, p1, p3, p4])
|
||||
}
|
||||
|
||||
fn arc_length_symbol_points(
|
||||
dim: &Dimension,
|
||||
style: Option<&DimStyle>,
|
||||
text_height: f64,
|
||||
dim_scale: f64,
|
||||
symbol_position: i16,
|
||||
) -> Option<Vec<Vec3>> {
|
||||
if !matches!(dim, Dimension::Arc(_)) || symbol_position == 2 {
|
||||
return None;
|
||||
}
|
||||
let value = dimension_text_value(dim, style)?;
|
||||
if value.is_empty() || text_height <= 1.0e-12 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let position = dimension_text_pos_f64(dim, style, text_height, dim_scale);
|
||||
let rotation = dimension_text_rotation(dim, style);
|
||||
let (sin_rotation, cos_rotation) = rotation.sin_cos();
|
||||
let text_width = value.chars().count() as f64 * text_height * 0.6;
|
||||
let symbol_width = text_height * 0.62;
|
||||
let symbol_height = text_height * 0.20;
|
||||
let (center_x, center_y) = if symbol_position == 1 {
|
||||
(0.0, text_height * 0.72)
|
||||
} else {
|
||||
(-(text_width * 0.5 + symbol_width * 0.70), text_height * 0.02)
|
||||
};
|
||||
let transform = |x: f64, y: f64| {
|
||||
let local_x = center_x + x;
|
||||
let local_y = center_y + y;
|
||||
Vec3::new(
|
||||
(position.x + local_x * cos_rotation - local_y * sin_rotation) as f32,
|
||||
(position.y + local_x * sin_rotation + local_y * cos_rotation) as f32,
|
||||
position.z as f32,
|
||||
)
|
||||
};
|
||||
|
||||
let steps = 8usize;
|
||||
Some(
|
||||
(0..=steps)
|
||||
.map(|index| {
|
||||
let t = index as f64 / steps as f64;
|
||||
let x = (t - 0.5) * symbol_width;
|
||||
let normalized = x / (symbol_width * 0.5);
|
||||
let y = symbol_height * (1.0 - normalized * normalized);
|
||||
transform(x, y)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
struct SuppressFlags {
|
||||
ext1: bool,
|
||||
ext2: bool,
|
||||
|
|
@ -4210,6 +4384,7 @@ fn dimension_geometry(
|
|||
}
|
||||
}
|
||||
Dimension::Arc(d) => {
|
||||
let explicit_sweep = arc_dimension_angles(d);
|
||||
append_angular_dimension(
|
||||
&mut g,
|
||||
lv(d.center_point),
|
||||
|
|
@ -4218,10 +4393,7 @@ fn dimension_geometry(
|
|||
lv(d.definition_point),
|
||||
arrow1,
|
||||
arrow2,
|
||||
d.is_partial.then_some((
|
||||
d.arc_start_parameter as f32,
|
||||
d.arc_end_parameter as f32,
|
||||
)),
|
||||
explicit_sweep,
|
||||
params,
|
||||
suppress,
|
||||
);
|
||||
|
|
@ -4633,6 +4805,195 @@ fn two_line_angle_frame(
|
|||
Some((vertex, start, end))
|
||||
}
|
||||
|
||||
pub(crate) fn arc_dimension_angles(dimension: &DimensionArc) -> Option<(f32, f32)> {
|
||||
let raw = dimension.arc_end_parameter - dimension.arc_start_parameter;
|
||||
let mut sweep = raw.rem_euclid(std::f64::consts::TAU);
|
||||
if sweep <= 1.0e-12 && raw.abs() > 1.0e-12 {
|
||||
sweep = std::f64::consts::TAU;
|
||||
}
|
||||
if sweep > 1.0e-12 {
|
||||
let start = dimension.arc_start_parameter as f32;
|
||||
return Some((start, start + sweep as f32));
|
||||
}
|
||||
|
||||
let start = (dimension.first_extension_point.y - dimension.center_point.y)
|
||||
.atan2(dimension.first_extension_point.x - dimension.center_point.x);
|
||||
let end = (dimension.second_extension_point.y - dimension.center_point.y)
|
||||
.atan2(dimension.second_extension_point.x - dimension.center_point.x);
|
||||
let sweep = (end - start).rem_euclid(std::f64::consts::TAU);
|
||||
(sweep > 1.0e-12).then_some((start as f32, (start + sweep) as f32))
|
||||
}
|
||||
|
||||
pub(crate) fn arc_dimension_in_ocs(
|
||||
dimension: &DimensionArc,
|
||||
) -> Option<(DimensionArc, Vector3)> {
|
||||
let length = dimension.base.normal.length();
|
||||
if !length.is_finite() || length <= 1.0e-12 {
|
||||
return None;
|
||||
}
|
||||
let normal = dimension.base.normal / length;
|
||||
if (normal - Vector3::UNIT_Z).length() <= 1.0e-12 {
|
||||
return None;
|
||||
}
|
||||
let normal_tuple = (normal.x, normal.y, normal.z);
|
||||
let to_ocs = |point: Vector3| {
|
||||
let point = crate::scene::view::transform::wcs_point_to_ocs(
|
||||
(point.x, point.y, point.z),
|
||||
normal_tuple,
|
||||
);
|
||||
Vector3::new(point.0, point.1, point.2)
|
||||
};
|
||||
let mut local = dimension.clone();
|
||||
local.center_point = to_ocs(dimension.center_point);
|
||||
let elevation = local.center_point.z;
|
||||
let on_plane = |point: Vector3| {
|
||||
let mut point = to_ocs(point);
|
||||
point.z = elevation;
|
||||
point
|
||||
};
|
||||
local.definition_point = on_plane(dimension.definition_point);
|
||||
local.first_extension_point = on_plane(dimension.first_extension_point);
|
||||
local.second_extension_point = on_plane(dimension.second_extension_point);
|
||||
local.first_leader_point = on_plane(dimension.first_leader_point);
|
||||
local.second_leader_point = on_plane(dimension.second_leader_point);
|
||||
local.base.definition_point = local.definition_point;
|
||||
local.base.text_middle_point = on_plane(dimension.base.text_middle_point);
|
||||
local.base.insertion_point = on_plane(dimension.base.insertion_point);
|
||||
local.base.normal = Vector3::UNIT_Z;
|
||||
Some((local, normal))
|
||||
}
|
||||
|
||||
pub(crate) fn large_radial_dimension_in_ocs(
|
||||
dimension: &DimensionLargeRadial,
|
||||
) -> Option<(DimensionLargeRadial, Vector3)> {
|
||||
let length = dimension.base.normal.length();
|
||||
if !length.is_finite() || length <= 1.0e-12 {
|
||||
return None;
|
||||
}
|
||||
let normal = dimension.base.normal / length;
|
||||
if (normal - Vector3::UNIT_Z).length() <= 1.0e-12 {
|
||||
return None;
|
||||
}
|
||||
let normal_tuple = (normal.x, normal.y, normal.z);
|
||||
let to_ocs = |point: Vector3| {
|
||||
let point = crate::scene::view::transform::wcs_point_to_ocs(
|
||||
(point.x, point.y, point.z),
|
||||
normal_tuple,
|
||||
);
|
||||
Vector3::new(point.0, point.1, point.2)
|
||||
};
|
||||
let mut local = dimension.clone();
|
||||
local.definition_point = to_ocs(dimension.definition_point);
|
||||
let elevation = local.definition_point.z;
|
||||
let on_plane = |point: Vector3| {
|
||||
let mut point = to_ocs(point);
|
||||
point.z = elevation;
|
||||
point
|
||||
};
|
||||
local.chord_point = on_plane(dimension.chord_point);
|
||||
local.override_center = on_plane(dimension.override_center);
|
||||
local.jog_point = on_plane(dimension.jog_point);
|
||||
local.base.definition_point = local.definition_point;
|
||||
local.base.text_middle_point = on_plane(dimension.base.text_middle_point);
|
||||
local.base.insertion_point = on_plane(dimension.base.insertion_point);
|
||||
local.base.normal = Vector3::UNIT_Z;
|
||||
Some((local, normal))
|
||||
}
|
||||
|
||||
fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) {
|
||||
let normal_tuple = (normal.x, normal.y, normal.z);
|
||||
let map = |x: f64, y: f64, z: f64| {
|
||||
crate::scene::view::transform::ocs_point_to_wcs((x, y, z), normal_tuple)
|
||||
};
|
||||
let map_split = |high: &mut Vec<[f32; 3]>, low: &mut Vec<[f32; 3]>| {
|
||||
let mut mapped_high = Vec::with_capacity(high.len());
|
||||
let mut mapped_low = Vec::with_capacity(high.len());
|
||||
for (index, point) in high.iter().enumerate() {
|
||||
if point[0].is_nan() {
|
||||
mapped_high.push(*point);
|
||||
mapped_low.push([0.0; 3]);
|
||||
continue;
|
||||
}
|
||||
let residual = low.get(index).copied().unwrap_or([0.0; 3]);
|
||||
let point = map(
|
||||
point[0] as f64 + residual[0] as f64,
|
||||
point[1] as f64 + residual[1] as f64,
|
||||
point[2] as f64 + residual[2] as f64,
|
||||
);
|
||||
let (xh, xl) = WireModel::split_ds(point.0);
|
||||
let (yh, yl) = WireModel::split_ds(point.1);
|
||||
let (zh, zl) = WireModel::split_ds(point.2);
|
||||
mapped_high.push([xh, yh, zh]);
|
||||
mapped_low.push([xl, yl, zl]);
|
||||
}
|
||||
*high = mapped_high;
|
||||
*low = mapped_low;
|
||||
};
|
||||
|
||||
map_split(&mut wire.points, &mut wire.points_low);
|
||||
map_split(&mut wire.fill_tris, &mut wire.fill_tris_low);
|
||||
map_split(&mut wire.pick_tris, &mut wire.pick_tris_low);
|
||||
wire.text_verts = crate::scene::model::wire_model::map_text_verts(
|
||||
&wire.text_verts,
|
||||
map,
|
||||
);
|
||||
for (point, _) in &mut wire.snap_pts {
|
||||
let mapped = map(point.x, point.y, point.z);
|
||||
*point = DVec3::new(mapped.0, mapped.1, mapped.2);
|
||||
}
|
||||
for point in &mut wire.key_vertices {
|
||||
let mapped = map(point[0], point[1], point[2]);
|
||||
*point = [mapped.0, mapped.1, mapped.2];
|
||||
}
|
||||
if let Some(marker) = &mut wire.point_marker {
|
||||
let origin = map(marker.origin.x, marker.origin.y, marker.origin.z);
|
||||
let vector = |value: DVec3| {
|
||||
let mapped = map(value.x, value.y, value.z);
|
||||
DVec3::new(mapped.0, mapped.1, mapped.2)
|
||||
};
|
||||
marker.origin = DVec3::new(origin.0, origin.1, origin.2);
|
||||
marker.normal = vector(marker.normal).normalize_or(DVec3::Z);
|
||||
marker.axis_x = vector(marker.axis_x).normalize_or(DVec3::X);
|
||||
marker.axis_y = vector(marker.axis_y).normalize_or(DVec3::Y);
|
||||
}
|
||||
for tangent in &mut wire.tangent_geoms {
|
||||
match tangent {
|
||||
TangentGeom::Line { p1, p2 } => {
|
||||
let first = map(p1[0] as f64, p1[1] as f64, p1[2] as f64);
|
||||
let second = map(p2[0] as f64, p2[1] as f64, p2[2] as f64);
|
||||
*p1 = [first.0 as f32, first.1 as f32, first.2 as f32];
|
||||
*p2 = [second.0 as f32, second.1 as f32, second.2 as f32];
|
||||
}
|
||||
TangentGeom::Circle { center, radius } => {
|
||||
let center = map(
|
||||
center[0] as f64,
|
||||
center[1] as f64,
|
||||
center[2] as f64,
|
||||
);
|
||||
let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes(normal_tuple);
|
||||
*tangent = TangentGeom::PlanarCircle {
|
||||
center: [center.0, center.1, center.2],
|
||||
axis_x: [x_axis.0, x_axis.1, x_axis.2],
|
||||
axis_y: [y_axis.0, y_axis.1, y_axis.2],
|
||||
radius: *radius as f64,
|
||||
};
|
||||
}
|
||||
TangentGeom::PlanarCircle { center, axis_x, axis_y, .. }
|
||||
| TangentGeom::Arc { center, axis_x, axis_y, .. } => {
|
||||
let mapped = map(center[0], center[1], center[2]);
|
||||
let map_vector = |axis: [f64; 3]| {
|
||||
let mapped = map(axis[0], axis[1], axis[2]);
|
||||
[mapped.0, mapped.1, mapped.2]
|
||||
};
|
||||
*center = [mapped.0, mapped.1, mapped.2];
|
||||
*axis_x = map_vector(*axis_x);
|
||||
*axis_y = map_vector(*axis_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
wire.aabb = WireModel::UNBOUNDED_AABB;
|
||||
}
|
||||
|
||||
fn angular_dimension_frame(dim: &Dimension) -> Option<(Vec3, f32, f32, f32)> {
|
||||
let (vertex, start, end, arc_point) = match dim {
|
||||
Dimension::Angular2Ln(value) => {
|
||||
|
|
@ -4659,6 +5020,12 @@ fn angular_dimension_frame(dim: &Dimension) -> Option<(Vec3, f32, f32, f32)> {
|
|||
two_line_angle_frame(vertex, first, vertex, second, arc_point)?;
|
||||
(vertex, start, end, arc_point)
|
||||
}
|
||||
Dimension::Arc(value) => {
|
||||
let vertex = vec3_local(value.center_point);
|
||||
let arc_point = vec3_local(value.definition_point);
|
||||
let (start, end) = arc_dimension_angles(value)?;
|
||||
(vertex, start, end, arc_point)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let radius = vertex.distance(arc_point);
|
||||
|
|
@ -5249,7 +5616,7 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 {
|
|||
let dy = d.second_point.y - d.first_point.y;
|
||||
dy.atan2(dx)
|
||||
}
|
||||
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => angular_dimension_frame(dim)
|
||||
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) | Dimension::Arc(_) => angular_dimension_frame(dim)
|
||||
.map(|(_, start, end, _)| {
|
||||
((start + end) * 0.5 + std::f32::consts::FRAC_PI_2) as f64
|
||||
})
|
||||
|
|
@ -6183,6 +6550,45 @@ pub(crate) fn baked_dimension_text_entity(
|
|||
Some(ent)
|
||||
}
|
||||
|
||||
pub(crate) fn baked_arc_length_symbol_points(
|
||||
dim: &Dimension,
|
||||
document: &CadDocument,
|
||||
anno_scale: f64,
|
||||
) -> Vec<Vector3> {
|
||||
if !matches!(dim, Dimension::Arc(_)) {
|
||||
return Vec::new();
|
||||
}
|
||||
let style_name = dim.base().style_name.as_str();
|
||||
let style = document.dim_styles.iter().find(|style| {
|
||||
style.name.eq_ignore_ascii_case(style_name)
|
||||
|| (style_name.trim().is_empty() && style.name.eq_ignore_ascii_case("Standard"))
|
||||
});
|
||||
let dim_scale = style
|
||||
.map(|style| {
|
||||
if style.dimscale > 1.0e-6 {
|
||||
style.dimscale
|
||||
} else {
|
||||
anno_scale
|
||||
}
|
||||
})
|
||||
.unwrap_or(1.0);
|
||||
let text_height = style
|
||||
.map(|style| style.dimtxt * dim_scale)
|
||||
.unwrap_or(2.5 * dim_scale);
|
||||
let symbol_position = crate::entities::dim_override::int(
|
||||
&dim.base().common.extended_data,
|
||||
crate::entities::dim_override::DIMARCSYM,
|
||||
)
|
||||
.or_else(|| style.map(|style| style.dimarcsym))
|
||||
.unwrap_or(0);
|
||||
|
||||
arc_length_symbol_points(dim, style, text_height, dim_scale, symbol_position)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|point| Vector3::new(point.x as f64, point.y as f64, point.z as f64))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn dimension_text_grip_position(
|
||||
dim: &Dimension,
|
||||
document: &CadDocument,
|
||||
|
|
|
|||
|
|
@ -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,8 +3,10 @@ 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::model::object::{GripApply, GripDef, PropSection};
|
||||
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;
|
||||
use crate::scene::view::transform;
|
||||
|
|
@ -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"))?;
|
||||
|
|
@ -144,51 +146,6 @@ fn symbol_font_switch(inner: &str) -> Option<char> {
|
|||
tail.chars().next()
|
||||
}
|
||||
|
||||
/// Per-entity overrides of individual dimension-style variables, carried as
|
||||
/// extended data under the `DSTYLE` application.
|
||||
///
|
||||
/// An entity may keep its style yet override single variables on itself, as
|
||||
/// `(variable group code, value)` pairs. Reading the style but ignoring these
|
||||
/// draws the frame at the style's size rather than its own — which is how this
|
||||
/// one, overriding its text height to a fraction of the style's, came out far
|
||||
/// too large.
|
||||
///
|
||||
/// Only the character height is read — it is the frame's single geometric
|
||||
/// input (see `tessellate_tolerance`); every other variable is left to the style.
|
||||
fn dstyle_overrides(tol: &Tolerance) -> Option<f64> {
|
||||
// The only variable the frame is built from.
|
||||
const DIMTXT: i16 = 140;
|
||||
|
||||
use acadrust::xdata::XDataValue as V;
|
||||
|
||||
// The record belongs to the shared "ACAD" application and names itself in
|
||||
// its FIRST STRING VALUE. There is no record called "DSTYLE" — asking for
|
||||
// one finds nothing and leaves every override silently unread.
|
||||
let rec = tol.common.extended_data.get_record("ACAD")?;
|
||||
let mut vals = rec.values.iter();
|
||||
match vals.next() {
|
||||
Some(V::String(s)) if s == "DSTYLE" => {}
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
let mut txt = None;
|
||||
let mut pending: Option<i16> = None;
|
||||
for v in vals {
|
||||
match v {
|
||||
V::Integer16(code) => pending = Some(*code),
|
||||
V::Real(value) | V::Distance(value) => {
|
||||
if pending.take() == Some(DIMTXT) {
|
||||
txt = Some(*value);
|
||||
}
|
||||
}
|
||||
// Braces and anything else just delimit; a non-numeric value also
|
||||
// ends the pair we were waiting on.
|
||||
_ => pending = None,
|
||||
}
|
||||
}
|
||||
txt
|
||||
}
|
||||
|
||||
/// The tolerance's dimension style — by handle first, then by name.
|
||||
///
|
||||
/// The order matters and is not interchangeable: a DWG records the style's
|
||||
|
|
@ -196,7 +153,7 @@ fn dstyle_overrides(tol: &Tolerance) -> Option<f64> {
|
|||
/// leaves the handle empty. Matching on the name alone — the shape used
|
||||
/// elsewhere for entities that only ever carry one — would silently resolve
|
||||
/// every DWG-read tolerance to "Standard" and pick the wrong metrics.
|
||||
fn resolve_dim_style<'a>(
|
||||
pub(crate) fn resolve_dim_style<'a>(
|
||||
tol: &Tolerance,
|
||||
doc: &'a acadrust::CadDocument,
|
||||
) -> Option<&'a acadrust::tables::DimStyle> {
|
||||
|
|
@ -222,8 +179,20 @@ fn resolve_dim_style<'a>(
|
|||
///
|
||||
/// This is where the pen LANDS, so it carries the font's letter spacing past
|
||||
/// the final glyph — which is what puts the gap between one run and the next.
|
||||
fn run_advance(text: &str, font: &str, height: f32) -> f32 {
|
||||
crate::entities::text_support::text_local_bounds(font, text, height, 1.0, 0.0)
|
||||
fn run_advance(
|
||||
text: &str,
|
||||
font: &str,
|
||||
height: f32,
|
||||
width_factor: f32,
|
||||
oblique: f32,
|
||||
) -> f32 {
|
||||
crate::entities::text_support::text_local_bounds(
|
||||
font,
|
||||
text,
|
||||
height,
|
||||
width_factor,
|
||||
oblique,
|
||||
)
|
||||
.map(|b| b.advance)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
|
@ -232,8 +201,11 @@ fn run_advance(text: &str, font: &str, height: f32) -> f32 {
|
|||
///
|
||||
/// Glyph geometry is authored against a 9-unit cap height, so a font's spacing
|
||||
/// scales with the character height like everything else.
|
||||
fn letter_spacing(font: &str, height: f32) -> f32 {
|
||||
crate::scene::text::font_face::Face::resolve(font).letter_spacing() * height / 9.0
|
||||
fn letter_spacing(font: &str, height: f32, width_factor: f32) -> f32 {
|
||||
crate::scene::text::font_face::Face::resolve(font).letter_spacing()
|
||||
* height
|
||||
/ 9.0
|
||||
* width_factor
|
||||
}
|
||||
|
||||
/// How wide a compartment's content actually draws.
|
||||
|
|
@ -243,36 +215,56 @@ fn letter_spacing(font: &str, height: f32) -> f32 {
|
|||
/// over-measures by exactly one spacing — the gaps between runs are real, the
|
||||
/// one hanging off the end is not — and every compartment came out that much
|
||||
/// too wide.
|
||||
fn content_width(cell: &Cell, height: f32) -> f32 {
|
||||
fn content_width(
|
||||
cell: &Cell,
|
||||
height: f32,
|
||||
text_style: &crate::entities::text_support::ResolvedTextStyle,
|
||||
) -> f32 {
|
||||
let Some(last) = cell.last() else {
|
||||
return 0.0;
|
||||
};
|
||||
let pen: f32 = cell
|
||||
.iter()
|
||||
.map(|r| run_advance(&r.text, r.font, height))
|
||||
.map(|run| {
|
||||
let (font, width, oblique) = if run.font == SYMBOL_FONT {
|
||||
(SYMBOL_FONT, 1.0, 0.0)
|
||||
} else {
|
||||
(
|
||||
text_style.font_name.as_str(),
|
||||
text_style.width_factor,
|
||||
text_style.oblique_angle,
|
||||
)
|
||||
};
|
||||
run_advance(&run.text, font, height, width, oblique)
|
||||
})
|
||||
.sum();
|
||||
(pen - letter_spacing(last.font, height)).max(0.0)
|
||||
let (last_font, last_width) = if last.font == SYMBOL_FONT {
|
||||
(SYMBOL_FONT, 1.0)
|
||||
} else {
|
||||
(text_style.font_name.as_str(), text_style.width_factor)
|
||||
};
|
||||
(pen - letter_spacing(last_font, height, last_width)).max(0.0)
|
||||
}
|
||||
|
||||
/// 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: &'static str,
|
||||
font: String,
|
||||
origin: [f32; 2],
|
||||
strokes: Vec<Vec<[f32; 2]>>,
|
||||
height: f32,
|
||||
rotation: f32,
|
||||
width_factor: f32,
|
||||
oblique: f32,
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
|
@ -296,17 +288,29 @@ fn tessellate_tolerance(
|
|||
// and the frame draws about ten times too small. Falling back to them only
|
||||
// when no style resolves keeps DXF-read entities working.
|
||||
let style = resolve_dim_style(tol, doc);
|
||||
let scale = style
|
||||
.map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 })
|
||||
let xd = &tol.common.extended_data;
|
||||
let scale = crate::entities::dim_override::real(xd, crate::entities::dim_override::DIMSCALE)
|
||||
.or_else(|| style.map(|s| s.dimscale))
|
||||
.map(|value| if value > 1e-6 { value } else { 1.0 })
|
||||
.unwrap_or(1.0);
|
||||
// The entity's own overrides win over its style; the style wins over the
|
||||
// entity's constructed defaults.
|
||||
let h = dstyle_overrides(tol)
|
||||
let h = crate::entities::dim_override::real(xd, crate::entities::dim_override::DIMTXT)
|
||||
.or(style.map(|s| s.dimtxt))
|
||||
.map(|v| v * scale)
|
||||
.unwrap_or(tol.text_height) as f32;
|
||||
let h = if h > 1e-6 { h } else { 2.5_f32 };
|
||||
|
||||
let text_style_name = crate::entities::dim_override::handle(
|
||||
xd,
|
||||
crate::entities::dim_override::DIMTXSTY,
|
||||
)
|
||||
.and_then(|handle| doc.text_styles.iter().find(|entry| entry.handle == handle))
|
||||
.map(|entry| entry.name.as_str())
|
||||
.or_else(|| style.map(|style| style.dimtxsty.as_str()))
|
||||
.unwrap_or("Standard");
|
||||
let text_style = crate::entities::text_support::resolve_text_style(text_style_name, doc);
|
||||
|
||||
// A compartment is a PROPORTION of the character height: it spans -h..+h
|
||||
// about the row's centreline, so it is exactly 2h tall and the h/2 margin
|
||||
// falls out of that rather than being an input.
|
||||
|
|
@ -329,19 +333,14 @@ fn tessellate_tolerance(
|
|||
// (`len()`) would make each one two or three cells wide.
|
||||
// A compartment's runs come from different fonts, so each is measured in its
|
||||
// own before they are summed.
|
||||
let cell_width = |cell: &Cell| -> f32 { (content_width(cell, h) + 2.0 * pad).max(min_cell_w) };
|
||||
let cell_width = |cell: &Cell| -> f32 {
|
||||
(content_width(cell, h, &text_style) + 2.0 * pad).max(min_cell_w)
|
||||
};
|
||||
let row_widths: Vec<Vec<f32>> = rows
|
||||
.iter()
|
||||
.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();
|
||||
|
||||
|
|
@ -364,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],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -379,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.
|
||||
|
|
@ -399,26 +396,41 @@ fn tessellate_tolerance(
|
|||
// Centre the compartment's whole content, then lay its runs out
|
||||
// left to right — each in its own font, each advancing the pen
|
||||
// by what that font actually measures.
|
||||
let mut run_x = cell_x + (cw - content_width(cell, h)) * 0.5;
|
||||
let mut run_x = cell_x + (cw - content_width(cell, h, &text_style)) * 0.5;
|
||||
for run in cell {
|
||||
let (text, font) = (run.text.clone(), run.font);
|
||||
let text = run.text.clone();
|
||||
let (font, width_factor, oblique) = if run.font == SYMBOL_FONT {
|
||||
(SYMBOL_FONT.to_string(), 1.0, 0.0)
|
||||
} else {
|
||||
(
|
||||
text_style.font_name.clone(),
|
||||
text_style.width_factor,
|
||||
text_style.oblique_angle,
|
||||
)
|
||||
};
|
||||
let (local_strokes, _) =
|
||||
lff::tessellate_text_ex([0.0, 0.0], h, 0.0, 1.0, 0.0, font, &text);
|
||||
// Glyph polylines rotated about the run's origin (no origin
|
||||
// translation — the wire-builder adds `origin`).
|
||||
lff::tessellate_text_ex(
|
||||
[0.0, 0.0],
|
||||
h,
|
||||
0.0,
|
||||
width_factor,
|
||||
oblique,
|
||||
&font,
|
||||
&text,
|
||||
);
|
||||
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);
|
||||
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,
|
||||
});
|
||||
run_x += advance;
|
||||
}
|
||||
|
|
@ -430,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 {
|
||||
|
|
@ -446,7 +476,57 @@ 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>| {
|
||||
index.filter(|value| !matches!(value, 0 | 256)).map(|value| {
|
||||
let [red, green, blue, _] = crate::scene::convert::tess_util::aci_to_rgba(
|
||||
&acadrust::types::Color::from_index(value),
|
||||
);
|
||||
[red, green, blue]
|
||||
})
|
||||
};
|
||||
let color_value = |color: &acadrust::types::Color| {
|
||||
let [red, green, blue, _] =
|
||||
crate::scene::convert::tess_util::aci_to_rgba(color);
|
||||
[red, green, blue]
|
||||
};
|
||||
let frame_color = if let Some(index) = crate::entities::dim_override::int(
|
||||
xd,
|
||||
crate::entities::dim_override::DIMCLRD,
|
||||
) {
|
||||
explicit_color(Some(index))
|
||||
} else if let Some(color) =
|
||||
style.and_then(|entry| entry.dimclrd_true_color.as_ref())
|
||||
{
|
||||
Some(color_value(color))
|
||||
} else {
|
||||
explicit_color(style.map(|entry| entry.dimclrd))
|
||||
};
|
||||
let text_color = if let Some(index) = crate::entities::dim_override::int(
|
||||
xd,
|
||||
crate::entities::dim_override::DIMCLRT,
|
||||
) {
|
||||
explicit_color(Some(index))
|
||||
} else if let Some(color) =
|
||||
style.and_then(|entry| entry.dimclrt_true_color.as_ref())
|
||||
{
|
||||
Some(color_value(color))
|
||||
} else {
|
||||
explicit_color(style.map(|entry| entry.dimclrt))
|
||||
};
|
||||
|
||||
// Frame geometry first (run-less → always strokes; also the anchor
|
||||
// group so its origin = the insertion point), then one run-group per
|
||||
|
|
@ -456,9 +536,10 @@ impl RenderConvertible for Tolerance {
|
|||
if !box_strokes.is_empty() {
|
||||
groups.push(TextStroke {
|
||||
strokes: box_strokes,
|
||||
origin: ins,
|
||||
color: None,
|
||||
origin: [ins.x, ins.y],
|
||||
color: frame_color,
|
||||
fill_tris: vec![],
|
||||
plane: Some(plane(ins)),
|
||||
run: None,
|
||||
});
|
||||
}
|
||||
|
|
@ -466,18 +547,23 @@ 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: None,
|
||||
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.to_string(),
|
||||
font: cell.font,
|
||||
height: cell.height,
|
||||
rotation: cell.rotation,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
rotation: 0.0,
|
||||
width_factor: cell.width_factor,
|
||||
oblique: cell.oblique,
|
||||
// `tracking` scales the font's own letter spacing, so 0
|
||||
// collapses the gap between glyphs and the characters run
|
||||
// together. Every other text-bearing entity passes 1.0, and
|
||||
|
|
@ -539,12 +625,19 @@ impl Grippable for Tolerance {
|
|||
// ── PropertyEditable ──────────────────────────────────────────────────────────
|
||||
|
||||
impl PropertyEditable for Tolerance {
|
||||
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
|
||||
fn geometry_properties(&self, text_style_names: &[String]) -> Vec<PropSection> {
|
||||
vec![
|
||||
PropSection {
|
||||
title: t!("Text").into_owned(),
|
||||
props: vec![
|
||||
ro(t!("Text style").as_ref(), "tol_text_style", String::new()),
|
||||
Property {
|
||||
label: t!("Text style").into_owned(),
|
||||
field: "tol_text_style",
|
||||
value: PropValue::Choice {
|
||||
selected: String::new(),
|
||||
options: text_style_names.to_vec(),
|
||||
},
|
||||
},
|
||||
edit(t!("Text height").as_ref(), "tol_text_height", self.text_height),
|
||||
],
|
||||
},
|
||||
|
|
@ -558,20 +651,15 @@ impl PropertyEditable for Tolerance {
|
|||
},
|
||||
PropSection {
|
||||
title: t!("Misc").into_owned(),
|
||||
props: vec![
|
||||
ro(
|
||||
t!("Dimension style").as_ref(),
|
||||
"tol_dim_style",
|
||||
if self.dimension_style_name.is_empty() {
|
||||
"(default)".to_string()
|
||||
} else {
|
||||
self.dimension_style_name.clone()
|
||||
},
|
||||
),
|
||||
edit(t!("Direction X").as_ref(), "tol_dir_x", self.direction.x),
|
||||
edit(t!("Direction Y").as_ref(), "tol_dir_y", self.direction.y),
|
||||
edit(t!("Direction Z").as_ref(), "tol_dir_z", self.direction.z),
|
||||
],
|
||||
props: vec![ro(
|
||||
t!("Dim style").as_ref(),
|
||||
"tol_dim_style",
|
||||
if self.dimension_style_name.is_empty() {
|
||||
"(default)".to_string()
|
||||
} else {
|
||||
self.dimension_style_name.clone()
|
||||
},
|
||||
)],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -585,9 +673,6 @@ impl PropertyEditable for Tolerance {
|
|||
"tol_iy" => self.insertion_point.y = v,
|
||||
"tol_iz" => self.insertion_point.z = v,
|
||||
"tol_text_height" if v > 0.0 => self.text_height = v,
|
||||
"tol_dir_x" => self.direction.x = v,
|
||||
"tol_dir_y" => self.direction.y = v,
|
||||
"tol_dir_z" => self.direction.z = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -923,9 +1008,26 @@ mod tests {
|
|||
fn compartments_are_measured_by_their_ink_not_the_trailing_pen_gap() {
|
||||
// Cap height, so glyph units and world units line up.
|
||||
let h = 9.0_f32;
|
||||
let document = acadrust::CadDocument::new();
|
||||
let text_style = crate::entities::text_support::resolve_text_style("Standard", &document);
|
||||
for src in ["{\\Fgdt;r}", "{\\Fgdt;n}tol{\\Fgdt;m}", "1{\\Fgdt;m}", "A"] {
|
||||
let cell = parse_cell(src);
|
||||
let pen: f32 = cell.iter().map(|r| run_advance(&r.text, r.font, h)).sum();
|
||||
let pen: f32 = cell
|
||||
.iter()
|
||||
.map(|run| {
|
||||
if run.font == SYMBOL_FONT {
|
||||
run_advance(&run.text, SYMBOL_FONT, h, 1.0, 0.0)
|
||||
} else {
|
||||
run_advance(
|
||||
&run.text,
|
||||
&text_style.font_name,
|
||||
h,
|
||||
text_style.width_factor,
|
||||
text_style.oblique_angle,
|
||||
)
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
let ink: f32 = cell
|
||||
.iter()
|
||||
.map(|r| {
|
||||
|
|
@ -934,11 +1036,17 @@ mod tests {
|
|||
.unwrap_or(0.0)
|
||||
})
|
||||
.sum();
|
||||
let got = content_width(&cell, h);
|
||||
let got = content_width(&cell, h, &text_style);
|
||||
|
||||
// Exactly one trailing gap comes off — the gaps BETWEEN runs are
|
||||
// real spacing and must stay.
|
||||
let spacing = letter_spacing(cell.last().unwrap().font, h);
|
||||
let last = cell.last().unwrap();
|
||||
let (font, width) = if last.font == SYMBOL_FONT {
|
||||
(SYMBOL_FONT, 1.0)
|
||||
} else {
|
||||
(text_style.font_name.as_str(), text_style.width_factor)
|
||||
};
|
||||
let spacing = letter_spacing(font, h, width);
|
||||
assert!(
|
||||
(got - (pen - spacing)).abs() < 1e-3,
|
||||
"{src:?}: content {got} should be pen {pen} less one {spacing} gap"
|
||||
|
|
|
|||
623
src/modules/annotate/arc_length_dim.rs
Normal file
623
src/modules/annotate/arc_length_dim.rs
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
use acadrust::entities::{Dimension, DimensionArc};
|
||||
use acadrust::types::{Handle, Vector3};
|
||||
use acadrust::EntityType;
|
||||
use cadkernel::geom2d::tessellate::{arc, DEFAULT_SEGMENTS_PER_RADIAN};
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{
|
||||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput,
|
||||
DimensionAssociationSource, WorkingPlane,
|
||||
};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::dimension_assoc::{
|
||||
polyline_arc_point_marker, RadialSourceGeometry,
|
||||
ARC_DIMENSION_POINT_MARKER, POLYLINE_ARC_CENTER_MARKER,
|
||||
};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub const ICON: IconKind =
|
||||
IconKind::Svg(include_bytes!("../../../assets/icons/dim_angular.svg"));
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "DIMARC",
|
||||
label: "Arc Length",
|
||||
icon: ICON,
|
||||
event: ModuleEvent::Command("DIMARC".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum SourceBinding {
|
||||
Arc,
|
||||
PolylineSegment(i32),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ArcSelection {
|
||||
source: RadialSourceGeometry,
|
||||
binding: SourceBinding,
|
||||
start_angle: f64,
|
||||
end_angle: f64,
|
||||
is_partial: bool,
|
||||
}
|
||||
|
||||
impl ArcSelection {
|
||||
fn new(source: RadialSourceGeometry, binding: SourceBinding) -> Option<Self> {
|
||||
let sweep = positive_sweep(source.start_angle, source.end_angle);
|
||||
(source.limited && source.radius > 1.0e-12 && sweep > 1.0e-12).then_some(Self {
|
||||
source,
|
||||
binding,
|
||||
start_angle: source.start_angle,
|
||||
end_angle: source.start_angle + sweep,
|
||||
is_partial: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn sweep(self) -> f64 {
|
||||
positive_sweep(self.start_angle, self.end_angle)
|
||||
}
|
||||
|
||||
fn point_at(self, angle: f64) -> DVec3 {
|
||||
dvec(self.source.point_at_angle(angle))
|
||||
}
|
||||
|
||||
fn center(self) -> DVec3 {
|
||||
dvec(self.source.center_world())
|
||||
}
|
||||
|
||||
fn project(self, point: DVec3) -> Option<DVec3> {
|
||||
let point = self.source.plane.project(point.to_array())?;
|
||||
Some(DVec3::from_array(self.source.plane.point_at(point)))
|
||||
}
|
||||
|
||||
fn plane(self) -> WorkingPlane {
|
||||
WorkingPlane::new(
|
||||
DVec3::from_array(self.source.plane.origin),
|
||||
DVec3::from_array(self.source.plane.x_axis),
|
||||
DVec3::from_array(self.source.plane.y_axis),
|
||||
)
|
||||
}
|
||||
|
||||
fn clamped_angle(self, point: DVec3) -> f64 {
|
||||
let raw = self.source.angle_at(point.to_array());
|
||||
let relative = (raw - self.start_angle).rem_euclid(std::f64::consts::TAU);
|
||||
let sweep = self.sweep();
|
||||
if relative <= sweep + 1.0e-10 {
|
||||
return self.start_angle + relative.min(sweep);
|
||||
}
|
||||
let start_distance = relative.min(std::f64::consts::TAU - relative);
|
||||
let end_relative = (raw - self.end_angle).rem_euclid(std::f64::consts::TAU);
|
||||
let end_distance = end_relative.min(std::f64::consts::TAU - end_relative);
|
||||
if start_distance <= end_distance {
|
||||
self.start_angle
|
||||
} else {
|
||||
self.end_angle
|
||||
}
|
||||
}
|
||||
|
||||
fn with_partial(self, first: f64, second: f64) -> Option<Self> {
|
||||
let first = (first - self.start_angle)
|
||||
.rem_euclid(std::f64::consts::TAU)
|
||||
.clamp(0.0, self.sweep());
|
||||
let second = (second - self.start_angle)
|
||||
.rem_euclid(std::f64::consts::TAU)
|
||||
.clamp(0.0, self.sweep());
|
||||
let (start, end) = if first <= second {
|
||||
(first, second)
|
||||
} else {
|
||||
(second, first)
|
||||
};
|
||||
(end - start > 1.0e-12).then_some(Self {
|
||||
start_angle: self.start_angle + start,
|
||||
end_angle: self.start_angle + end,
|
||||
is_partial: true,
|
||||
..self
|
||||
})
|
||||
}
|
||||
|
||||
fn association_sources(self, handle: Handle) -> Vec<Option<DimensionAssociationSource>> {
|
||||
let source_sweep = positive_sweep(self.source.start_angle, self.source.end_angle);
|
||||
let parameter = |angle: f64| {
|
||||
((angle - self.source.start_angle) / source_sweep).clamp(0.0, 1.0)
|
||||
};
|
||||
match self.binding {
|
||||
SourceBinding::Arc => vec![
|
||||
Some(DimensionAssociationSource::explicit(handle, -3, 0.0)),
|
||||
Some(DimensionAssociationSource::explicit(
|
||||
handle,
|
||||
ARC_DIMENSION_POINT_MARKER,
|
||||
parameter(self.start_angle),
|
||||
)),
|
||||
Some(DimensionAssociationSource::explicit(
|
||||
handle,
|
||||
ARC_DIMENSION_POINT_MARKER,
|
||||
parameter(self.end_angle),
|
||||
)),
|
||||
],
|
||||
SourceBinding::PolylineSegment(segment) => {
|
||||
let point_marker = polyline_arc_point_marker(segment);
|
||||
vec![
|
||||
Some(DimensionAssociationSource::explicit(
|
||||
handle,
|
||||
POLYLINE_ARC_CENTER_MARKER,
|
||||
segment as f64,
|
||||
)),
|
||||
Some(DimensionAssociationSource::explicit(
|
||||
handle,
|
||||
point_marker,
|
||||
parameter(self.start_angle),
|
||||
)),
|
||||
Some(DimensionAssociationSource::explicit(
|
||||
handle,
|
||||
point_marker,
|
||||
parameter(self.end_angle),
|
||||
)),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Step {
|
||||
SelectObject,
|
||||
DimLine(ArcSelection),
|
||||
PartialFirst(ArcSelection),
|
||||
PartialSecond {
|
||||
selection: ArcSelection,
|
||||
first_angle: f64,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct ArcLengthDimensionCommand {
|
||||
step: Step,
|
||||
picked_entity: Option<EntityType>,
|
||||
source_handle: Option<Handle>,
|
||||
text_override: Option<String>,
|
||||
awaiting_text: bool,
|
||||
text_angle: Option<f64>,
|
||||
awaiting_angle: bool,
|
||||
leader_enabled: bool,
|
||||
}
|
||||
|
||||
impl ArcLengthDimensionCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: Step::SelectObject,
|
||||
picked_entity: None,
|
||||
source_handle: None,
|
||||
text_override: None,
|
||||
awaiting_text: false,
|
||||
text_angle: None,
|
||||
awaiting_angle: false,
|
||||
leader_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_anchor(&self) -> DVec3 {
|
||||
match self.step {
|
||||
Step::SelectObject => DVec3::ZERO,
|
||||
Step::DimLine(selection)
|
||||
| Step::PartialFirst(selection)
|
||||
| Step::PartialSecond { selection, .. } => {
|
||||
selection.point_at(selection.start_angle + selection.sweep() * 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_dimension(&self, selection: ArcSelection, point: DVec3) -> CmdResult {
|
||||
let plane = selection.plane();
|
||||
let center = plane.to_local(selection.center());
|
||||
let first = plane.to_local(selection.point_at(selection.start_angle));
|
||||
let second = plane.to_local(selection.point_at(selection.end_angle));
|
||||
let Some(point) = selection.project(point) else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
let picked = plane.to_local(point);
|
||||
let picked_radius = (picked - center).truncate().length();
|
||||
if !picked_radius.is_finite() || picked_radius <= 1.0e-12 {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
|
||||
let mut dimension = DimensionArc::default();
|
||||
dimension.center_point = v3(center);
|
||||
dimension.first_extension_point = v3(first);
|
||||
dimension.second_extension_point = v3(second);
|
||||
dimension.definition_point = v3(picked);
|
||||
dimension.is_partial = selection.is_partial;
|
||||
dimension.arc_start_parameter = selection.start_angle;
|
||||
dimension.arc_end_parameter = selection.end_angle;
|
||||
dimension.base.definition_point = dimension.definition_point;
|
||||
dimension.base.text_middle_point = dimension.definition_point;
|
||||
dimension.base.insertion_point = dimension.definition_point;
|
||||
dimension.has_leader = self.leader_enabled && selection.sweep() > std::f64::consts::FRAC_PI_2;
|
||||
if dimension.has_leader {
|
||||
let middle = selection.start_angle + selection.sweep() * 0.5;
|
||||
let anchor = DVec3::new(
|
||||
center.x + picked_radius * middle.cos(),
|
||||
center.y + picked_radius * middle.sin(),
|
||||
picked.z,
|
||||
);
|
||||
dimension.first_leader_point = v3(anchor);
|
||||
dimension.second_leader_point = v3(picked);
|
||||
dimension.definition_point = v3(anchor);
|
||||
dimension.base.definition_point = dimension.definition_point;
|
||||
dimension.base.text_user_positioned = true;
|
||||
}
|
||||
dimension.base.actual_measurement = dimension.measurement();
|
||||
crate::entities::dimension::set_dimension_text_override(
|
||||
&mut dimension.base,
|
||||
self.text_override.clone(),
|
||||
);
|
||||
if let Some(angle) = self.text_angle {
|
||||
dimension.base.text_rotation = angle;
|
||||
}
|
||||
|
||||
let association = self.source_handle.map_or_else(
|
||||
|| DimensionAssociationInput::Explicit(Vec::new()),
|
||||
|handle| {
|
||||
DimensionAssociationInput::Explicit(
|
||||
selection.association_sources(handle),
|
||||
)
|
||||
},
|
||||
);
|
||||
CmdResult::CommitDimension {
|
||||
entity: plane.place_entity(EntityType::Dimension(Dimension::Arc(dimension))),
|
||||
association,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for ArcLengthDimensionCommand {
|
||||
fn set_working_plane(&mut self, _plane: WorkingPlane) {}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DIMARC"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.awaiting_text {
|
||||
return "DIMARC Enter dimension text (blank = measured value):".to_string();
|
||||
}
|
||||
if self.awaiting_angle {
|
||||
return "DIMARC Specify text angle (degrees):".to_string();
|
||||
}
|
||||
match self.step {
|
||||
Step::SelectObject => {
|
||||
"DIMARC Select arc or polyline arc segment:".to_string()
|
||||
}
|
||||
Step::DimLine(selection) => {
|
||||
let leader_option = if selection.sweep() > std::f64::consts::FRAC_PI_2 {
|
||||
if self.leader_enabled { "/No Leader" } else { "/Leader" }
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"DIMARC Specify arc length dimension location [Mtext/Text/Angle/Partial{leader_option}]:"
|
||||
)
|
||||
}
|
||||
Step::PartialFirst(_) => {
|
||||
"DIMARC Specify first point of partial arc:".to_string()
|
||||
}
|
||||
Step::PartialSecond { .. } => {
|
||||
"DIMARC Specify second point of partial arc:".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, point: DVec3) -> CmdResult {
|
||||
match self.step {
|
||||
Step::SelectObject => CmdResult::NeedPoint,
|
||||
Step::DimLine(selection) => self.commit_dimension(selection, point),
|
||||
Step::PartialFirst(selection) => {
|
||||
self.step = Step::PartialSecond {
|
||||
selection,
|
||||
first_angle: selection.clamped_angle(point),
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::PartialSecond {
|
||||
selection,
|
||||
first_angle,
|
||||
} => {
|
||||
let second_angle = selection.clamped_angle(point);
|
||||
let Some(partial) = selection.with_partial(first_angle, second_angle) else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
self.leader_enabled = false;
|
||||
self.step = Step::DimLine(partial);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.awaiting_text {
|
||||
self.awaiting_text = false;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if self.awaiting_angle {
|
||||
self.awaiting_angle = false;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
!self.awaiting_text && !self.awaiting_angle
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
self.awaiting_text
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
let Step::DimLine(selection) = self.step else {
|
||||
return Vec::new();
|
||||
};
|
||||
if self.awaiting_text || self.awaiting_angle {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut options = vec![
|
||||
CmdOption::new("MText", "MTEXT"),
|
||||
CmdOption::new("Text", "TEXT"),
|
||||
CmdOption::new("Angle", "ANGLE"),
|
||||
CmdOption::new("Partial", "PARTIAL"),
|
||||
];
|
||||
if selection.sweep() > std::f64::consts::FRAC_PI_2 {
|
||||
options.push(if self.leader_enabled {
|
||||
CmdOption::new("No Leader", "NOLEADER")
|
||||
} else {
|
||||
CmdOption::new("Leader", "LEADER")
|
||||
});
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if self.awaiting_text {
|
||||
let value = text.trim();
|
||||
self.text_override = if value.is_empty() || value == "<>" {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
};
|
||||
self.awaiting_text = false;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if self.awaiting_angle {
|
||||
let value = text.trim();
|
||||
self.text_angle = if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
crate::entities::common::parse_typed_angle(value)
|
||||
};
|
||||
self.awaiting_angle = false;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
let Step::DimLine(selection) = self.step else {
|
||||
return None;
|
||||
};
|
||||
match text.trim().to_ascii_uppercase().as_str() {
|
||||
"M" | "MTEXT" => Some(CmdResult::SuspendForMTextInput {
|
||||
pos: self.editor_anchor(),
|
||||
initial: self.text_override.clone().unwrap_or_default(),
|
||||
height: 2.5,
|
||||
}),
|
||||
"T" | "TEXT" => {
|
||||
self.awaiting_text = true;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"A" | "ANGLE" => {
|
||||
self.awaiting_angle = true;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"P" | "PARTIAL" => {
|
||||
self.step = Step::PartialFirst(selection);
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"L" | "LEADER" if selection.sweep() > std::f64::consts::FRAC_PI_2 => {
|
||||
self.leader_enabled = true;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"N" | "NOLEADER" => {
|
||||
self.leader_enabled = false;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_editor_text(&mut self, value: String) {
|
||||
let value = value.trim();
|
||||
self.text_override = if value.is_empty() || value == "<>" {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
};
|
||||
}
|
||||
|
||||
fn on_editor_closed(&mut self, _committed: bool) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(self.step, Step::SelectObject)
|
||||
}
|
||||
|
||||
fn entity_pick_highlights_hover(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn inject_before_entity_pick(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) {
|
||||
self.picked_entity = Some(entity);
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
|
||||
let Some(entity) = self.picked_entity.take() else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
let Some(source) = crate::scene::dimension_assoc::radial_source_at(
|
||||
&entity,
|
||||
Vector3::new(point.x, point.y, point.z),
|
||||
) else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
if !source.limited {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
let binding = match entity {
|
||||
EntityType::Arc(_) => SourceBinding::Arc,
|
||||
EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => {
|
||||
SourceBinding::PolylineSegment(source.marker)
|
||||
}
|
||||
_ => return CmdResult::NeedPoint,
|
||||
};
|
||||
let Some(selection) = ArcSelection::new(source, binding) else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
self.source_handle = Some(handle);
|
||||
self.step = Step::DimLine(selection);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, point: DVec3) -> Option<WireModel> {
|
||||
match self.step {
|
||||
Step::SelectObject | Step::PartialFirst(_) => None,
|
||||
Step::DimLine(selection) => Some(dimension_preview(
|
||||
selection,
|
||||
point,
|
||||
self.leader_enabled,
|
||||
)),
|
||||
Step::PartialSecond {
|
||||
selection,
|
||||
first_angle,
|
||||
} => {
|
||||
let second_angle = selection.clamped_angle(point);
|
||||
let partial = selection.with_partial(first_angle, second_angle)?;
|
||||
Some(partial_preview(partial))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn positive_sweep(start: f64, end: f64) -> f64 {
|
||||
let raw = end - start;
|
||||
let mut sweep = raw.rem_euclid(std::f64::consts::TAU);
|
||||
if sweep <= 1.0e-12 && raw.abs() > 1.0e-12 {
|
||||
sweep = std::f64::consts::TAU;
|
||||
}
|
||||
sweep
|
||||
}
|
||||
|
||||
fn dimension_preview(
|
||||
selection: ArcSelection,
|
||||
point: DVec3,
|
||||
leader: bool,
|
||||
) -> WireModel {
|
||||
let plane = selection.plane();
|
||||
let center = plane.to_local(selection.center());
|
||||
let first = plane.to_local(selection.point_at(selection.start_angle));
|
||||
let second = plane.to_local(selection.point_at(selection.end_angle));
|
||||
let Some(point) = selection.project(point) else {
|
||||
return preview_wire(Vec::new(), "dimarc_preview");
|
||||
};
|
||||
let point = plane.to_local(point);
|
||||
let radius = (point - center).truncate().length();
|
||||
if radius <= 1.0e-12 {
|
||||
return preview_wire(Vec::new(), "dimarc_preview");
|
||||
}
|
||||
let start_land = DVec3::new(
|
||||
center.x + radius * selection.start_angle.cos(),
|
||||
center.y + radius * selection.start_angle.sin(),
|
||||
point.z,
|
||||
);
|
||||
let end_land = DVec3::new(
|
||||
center.x + radius * selection.end_angle.cos(),
|
||||
center.y + radius * selection.end_angle.sin(),
|
||||
point.z,
|
||||
);
|
||||
let mut points = vec![first, start_land, nan(), second, end_land, nan()];
|
||||
points.extend(
|
||||
arc(
|
||||
[center.x, center.y],
|
||||
radius,
|
||||
selection.start_angle,
|
||||
selection.end_angle,
|
||||
point.z,
|
||||
DEFAULT_SEGMENTS_PER_RADIAN,
|
||||
)
|
||||
.into_iter()
|
||||
.map(DVec3::from_array),
|
||||
);
|
||||
if leader && selection.sweep() > std::f64::consts::FRAC_PI_2 {
|
||||
let middle = selection.start_angle + selection.sweep() * 0.5;
|
||||
points.extend([
|
||||
nan(),
|
||||
DVec3::new(
|
||||
center.x + radius * middle.cos(),
|
||||
center.y + radius * middle.sin(),
|
||||
point.z,
|
||||
),
|
||||
point,
|
||||
]);
|
||||
}
|
||||
preview_wire(
|
||||
points
|
||||
.into_iter()
|
||||
.map(|value| if value.is_nan() { value } else { plane.to_world(value) })
|
||||
.collect(),
|
||||
"dimarc_preview",
|
||||
)
|
||||
}
|
||||
|
||||
fn partial_preview(selection: ArcSelection) -> WireModel {
|
||||
let points = arc(
|
||||
selection.source.center,
|
||||
selection.source.radius,
|
||||
selection.start_angle,
|
||||
selection.end_angle,
|
||||
0.0,
|
||||
DEFAULT_SEGMENTS_PER_RADIAN,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|point| DVec3::from_array(selection.source.plane.point_at([point[0], point[1]])))
|
||||
.collect();
|
||||
preview_wire(points, "dimarc_partial_preview")
|
||||
}
|
||||
|
||||
fn v3(point: DVec3) -> Vector3 {
|
||||
Vector3::new(point.x, point.y, point.z)
|
||||
}
|
||||
|
||||
fn dvec(point: Vector3) -> DVec3 {
|
||||
DVec3::new(point.x, point.y, point.z)
|
||||
}
|
||||
|
||||
fn nan() -> DVec3 {
|
||||
DVec3::splat(f64::NAN)
|
||||
}
|
||||
|
||||
fn preview_wire(points: Vec<DVec3>, name: &str) -> WireModel {
|
||||
WireModel::solid_f64(
|
||||
name.to_string(),
|
||||
points.into_iter().map(|point| point.to_array()).collect(),
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["DIMARC"] });
|
||||
|
|
@ -54,9 +54,12 @@ pub struct JoggedRadiusDimensionCommand {
|
|||
|
||||
impl JoggedRadiusDimensionCommand {
|
||||
pub fn new(defaults: DimensionCreationDefaults, annotation_multiplier: f64) -> Self {
|
||||
let editor_height = defaults.text_height
|
||||
* defaults.scale
|
||||
* annotation_multiplier.max(f64::EPSILON);
|
||||
let display_scale = if defaults.annotative {
|
||||
annotation_multiplier.max(f64::EPSILON)
|
||||
} else {
|
||||
defaults.scale
|
||||
};
|
||||
let editor_height = defaults.text_height * display_scale;
|
||||
Self {
|
||||
step: Step::SelectObject,
|
||||
defaults,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Annotate module — dimension, text, leader, table, and markup tools.
|
||||
|
||||
pub mod aligned_dim;
|
||||
pub mod arc_length_dim;
|
||||
pub mod angular_dim;
|
||||
pub mod data_extract;
|
||||
pub mod data_link;
|
||||
|
|
@ -99,6 +100,11 @@ impl CadModule for AnnotateModule {
|
|||
angular_dim::tool().label,
|
||||
angular_dim::tool().icon,
|
||||
),
|
||||
(
|
||||
arc_length_dim::tool().id,
|
||||
arc_length_dim::tool().label,
|
||||
arc_length_dim::tool().icon,
|
||||
),
|
||||
(
|
||||
radius_dim::tool().id,
|
||||
radius_dim::tool().label,
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
// TOLERANCE command — place a GD&T (geometric dimensioning & tolerancing) frame.
|
||||
//
|
||||
// Workflow:
|
||||
// 1. Text: Enter tolerance string (e.g. "%%v0.05|A" or plain text)
|
||||
// 2. Point: Click insertion point
|
||||
// The structured editor prepares the frame; this command places it.
|
||||
|
||||
use acadrust::entities::Tolerance;
|
||||
use acadrust::types::Vector3;
|
||||
|
|
@ -25,20 +23,17 @@ pub fn tool() -> ToolDef {
|
|||
}
|
||||
}
|
||||
|
||||
enum Step {
|
||||
Text,
|
||||
Insertion { text: String },
|
||||
}
|
||||
|
||||
pub struct ToleranceCommand {
|
||||
step: Step,
|
||||
text: String,
|
||||
preview_strokes: Vec<Vec<[f32; 2]>>,
|
||||
plane: WorkingPlane,
|
||||
}
|
||||
|
||||
impl ToleranceCommand {
|
||||
pub fn new() -> Self {
|
||||
pub fn with_text(text: String, preview_strokes: Vec<Vec<[f32; 2]>>) -> Self {
|
||||
Self {
|
||||
step: Step::Text,
|
||||
text,
|
||||
preview_strokes,
|
||||
plane: WorkingPlane::default(),
|
||||
}
|
||||
}
|
||||
|
|
@ -54,36 +49,18 @@ impl CadCommand for ToleranceCommand {
|
|||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
Step::Text => t!("TOLERANCE Enter tolerance text:").into_owned(),
|
||||
Step::Insertion { text } => {
|
||||
t!("TOLERANCE Specify insertion point [%{text}]:", text = text).into_owned()
|
||||
}
|
||||
}
|
||||
t!("TOLERANCE Specify insertion point:").into_owned()
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.step, Step::Text)
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let t = text.trim().to_string();
|
||||
if t.is_empty() {
|
||||
return Some(CmdResult::Cancel);
|
||||
}
|
||||
self.step = Step::Insertion { text: t };
|
||||
Some(CmdResult::NeedPoint)
|
||||
false
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
if let Step::Insertion { text } = &self.step {
|
||||
let point = self.plane.to_local(pt);
|
||||
let ins = Vector3::new(point.x, point.y, point.z);
|
||||
let tol = Tolerance::with_text(ins, text.clone());
|
||||
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Tolerance(tol)))
|
||||
} else {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
let point = self.plane.to_local(pt);
|
||||
let ins = Vector3::new(point.x, point.y, point.z);
|
||||
let tol = Tolerance::with_text(ins, self.text.clone());
|
||||
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Tolerance(tol)))
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
|
|
@ -91,17 +68,18 @@ impl CadCommand for ToleranceCommand {
|
|||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||||
if !matches!(self.step, Step::Insertion { .. }) {
|
||||
return None;
|
||||
let mut points = Vec::new();
|
||||
for stroke in &self.preview_strokes {
|
||||
if stroke.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if !points.is_empty() {
|
||||
points.push(DVec3::splat(f64::NAN));
|
||||
}
|
||||
points.extend(stroke.iter().map(|[x, y]| {
|
||||
pt + self.plane.x * *x as f64 + self.plane.y * *y as f64
|
||||
}));
|
||||
}
|
||||
let d = 0.15;
|
||||
let points = [
|
||||
pt - self.plane.x * d,
|
||||
pt + self.plane.x * d,
|
||||
DVec3::splat(f64::NAN),
|
||||
pt - self.plane.y * d,
|
||||
pt + self.plane.y * d,
|
||||
];
|
||||
Some(WireModel {
|
||||
point_marker: None,
|
||||
taper_widths: Vec::new(),
|
||||
|
|
@ -119,10 +97,7 @@ impl CadCommand for ToleranceCommand {
|
|||
dash_align_end: None,
|
||||
text_verts: Vec::new(),
|
||||
name: "tolerance_preview".into(),
|
||||
points: points
|
||||
.iter()
|
||||
.map(|point| point.as_vec3().to_array())
|
||||
.collect(),
|
||||
points: points.iter().map(|point| point.as_vec3().to_array()).collect(),
|
||||
points_low: Vec::new(),
|
||||
color: WireModel::CYAN,
|
||||
selected: false,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ use acadrust::tables::BlockRecord;
|
|||
use acadrust::types::Vector3;
|
||||
use acadrust::{CadDocument, EntityType, Handle};
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||||
use crate::entities::curve::lwpolyline_world_xy;
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use glam::DVec3;
|
||||
|
|
@ -909,6 +909,42 @@ fn norm2(dx: f64, dy: f64, fx: f64, fy: f64) -> (f64, f64) {
|
|||
}
|
||||
|
||||
fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
|
||||
if let Dimension::Arc(arc) = dim {
|
||||
if let Some((local_arc, normal)) =
|
||||
crate::entities::dimension::arc_dimension_in_ocs(arc)
|
||||
{
|
||||
let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes((
|
||||
normal.x, normal.y, normal.z,
|
||||
));
|
||||
let plane = WorkingPlane::new(
|
||||
DVec3::ZERO,
|
||||
DVec3::new(x_axis.0, x_axis.1, x_axis.2),
|
||||
DVec3::new(y_axis.0, y_axis.1, y_axis.2),
|
||||
);
|
||||
return explode_dimension(&Dimension::Arc(local_arc), doc)
|
||||
.into_iter()
|
||||
.map(|entity| plane.place_entity(entity))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if let Dimension::LargeRadial(radial) = dim {
|
||||
if let Some((local_radial, normal)) =
|
||||
crate::entities::dimension::large_radial_dimension_in_ocs(radial)
|
||||
{
|
||||
let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes((
|
||||
normal.x, normal.y, normal.z,
|
||||
));
|
||||
let plane = WorkingPlane::new(
|
||||
DVec3::ZERO,
|
||||
DVec3::new(x_axis.0, x_axis.1, x_axis.2),
|
||||
DVec3::new(y_axis.0, y_axis.1, y_axis.2),
|
||||
);
|
||||
return explode_dimension(&Dimension::LargeRadial(local_radial), doc)
|
||||
.into_iter()
|
||||
.map(|entity| plane.place_entity(entity))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
let base = dim.base();
|
||||
let met = dim_metrics(dim, doc);
|
||||
|
|
@ -1190,6 +1226,8 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
|
|||
}
|
||||
}
|
||||
Dimension::Arc(d) => {
|
||||
let explicit_sweep = crate::entities::dimension::arc_dimension_angles(d)
|
||||
.map(|(start, end)| (start as f64, end as f64));
|
||||
result.extend(angular_block_segs(
|
||||
d.center_point,
|
||||
d.first_extension_point,
|
||||
|
|
@ -1198,10 +1236,7 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
|
|||
&met,
|
||||
&ext_c,
|
||||
&dim_c,
|
||||
d.is_partial.then_some((
|
||||
d.arc_start_parameter,
|
||||
d.arc_end_parameter,
|
||||
)),
|
||||
explicit_sweep,
|
||||
));
|
||||
if d.has_leader {
|
||||
result.push(make_seg(
|
||||
|
|
@ -1246,6 +1281,15 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
|
|||
}
|
||||
}
|
||||
|
||||
let symbol_points =
|
||||
crate::entities::dimension::baked_arc_length_symbol_points(dim, doc, 1.0);
|
||||
if symbol_points.len() > 1 {
|
||||
let text_c = dim_common(&base.common, met.dimclrt, -2);
|
||||
for pair in symbol_points.windows(2) {
|
||||
result.push(make_seg(&pair[0], &pair[1], &text_c));
|
||||
}
|
||||
}
|
||||
|
||||
// Measurement text is the live render's own Text/MText entity (value,
|
||||
// position, height, rotation, alignment, text style, MText handling all
|
||||
// shared), so the baked block matches the on-screen dimension and the label
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
src/scene/cache/properties.rs
vendored
10
src/scene/cache/properties.rs
vendored
|
|
@ -36,11 +36,6 @@ pub fn general_section(entity: &EntityType) -> PropSection {
|
|||
let mut section = PropSection {
|
||||
title: t!("General").into_owned(),
|
||||
props: vec![
|
||||
Property {
|
||||
label: t!("Handle").into_owned(),
|
||||
field: "handle",
|
||||
value: PropValue::ReadOnly(common.handle.value().to_string()),
|
||||
},
|
||||
Property {
|
||||
label: t!("Color").into_owned(),
|
||||
field: "color",
|
||||
|
|
@ -94,12 +89,7 @@ pub fn general_section(entity: &EntityType) -> PropSection {
|
|||
],
|
||||
};
|
||||
|
||||
if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) {
|
||||
section.props.retain(|prop| prop.field != "handle");
|
||||
}
|
||||
|
||||
if matches!(entity, EntityType::Point(_)) {
|
||||
section.props.retain(|prop| prop.field != "handle");
|
||||
let hyperlink = section.props.iter().position(|prop| prop.field == "hyperlink");
|
||||
let transparency = section.props.iter().position(|prop| prop.field == "transparency");
|
||||
if let (Some(hyperlink), Some(transparency)) = (hyperlink, transparency) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -416,6 +416,7 @@ fn tessellate_entity_inner(
|
|||
| EntityType::MText(_)
|
||||
| EntityType::Dimension(_)
|
||||
| EntityType::MultiLeader(_)
|
||||
| EntityType::Tolerance(_)
|
||||
) {
|
||||
crate::scene::annotative::effective_annotation_scale_for(
|
||||
document,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -138,10 +138,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 {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,19 @@ use crate::command::DimensionAssociationSource;
|
|||
|
||||
use super::{ChangeKind, Scene};
|
||||
|
||||
pub(crate) const POLYLINE_ARC_CENTER_MARKER: i32 = -4;
|
||||
const POLYLINE_ARC_POINT_MARKER_BASE: i32 = -5;
|
||||
pub(crate) const ARC_DIMENSION_POINT_MARKER: i32 = -1;
|
||||
|
||||
pub(crate) fn polyline_arc_point_marker(segment: i32) -> i32 {
|
||||
POLYLINE_ARC_POINT_MARKER_BASE - segment.max(0)
|
||||
}
|
||||
|
||||
fn polyline_arc_segment_from_point_marker(marker: i32) -> Option<i32> {
|
||||
(marker <= POLYLINE_ARC_POINT_MARKER_BASE)
|
||||
.then_some(POLYLINE_ARC_POINT_MARKER_BASE - marker)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct RadialSourceGeometry {
|
||||
pub plane: Plane,
|
||||
|
|
@ -329,7 +342,21 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option<i32> {
|
|||
fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option<Vector3> {
|
||||
let source = *reference.xrefs.first()?;
|
||||
let entity = scene.document.get_entity(source)?;
|
||||
if reference.main_gs_marker == -4 {
|
||||
if reference.main_gs_marker == ARC_DIMENSION_POINT_MARKER {
|
||||
let radial = radial_source_for_marker(entity, 0)?;
|
||||
let sweep = positive_sweep(radial.start_angle, radial.end_angle);
|
||||
let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0);
|
||||
return Some(radial.point_at_angle(angle));
|
||||
}
|
||||
if let Some(segment) =
|
||||
polyline_arc_segment_from_point_marker(reference.main_gs_marker)
|
||||
{
|
||||
let radial = radial_source_for_marker(entity, segment)?;
|
||||
let sweep = positive_sweep(radial.start_angle, radial.end_angle);
|
||||
let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0);
|
||||
return Some(radial.point_at_angle(angle));
|
||||
}
|
||||
if reference.main_gs_marker == POLYLINE_ARC_CENTER_MARKER {
|
||||
let segment = reference.osnap_distance.round().max(0.0) as usize;
|
||||
return polyline_arc_center(entity, segment);
|
||||
}
|
||||
|
|
@ -422,10 +449,82 @@ fn dimension_reference_points(dimension: &Dimension) -> Vec<Vector3> {
|
|||
angular.definition_point,
|
||||
],
|
||||
Dimension::Ordinate(ordinate) => vec![ordinate.feature_location],
|
||||
Dimension::Arc(arc) => vec![
|
||||
arc.center_point,
|
||||
arc.first_extension_point,
|
||||
arc.second_extension_point,
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn positive_sweep(start: f64, end: f64) -> f64 {
|
||||
let raw = end - start;
|
||||
let mut sweep = raw.rem_euclid(TAU);
|
||||
if sweep <= 1.0e-12 && raw.abs() > 1.0e-12 {
|
||||
sweep = TAU;
|
||||
}
|
||||
sweep
|
||||
}
|
||||
|
||||
fn signed_angle_delta(value: f64) -> f64 {
|
||||
(value + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI
|
||||
}
|
||||
|
||||
fn angle_about_plane(
|
||||
plane: Plane,
|
||||
center: Vector3,
|
||||
point: Vector3,
|
||||
) -> f64 {
|
||||
let delta = [
|
||||
point.x - center.x,
|
||||
point.y - center.y,
|
||||
point.z - center.z,
|
||||
];
|
||||
let dot = |axis: [f64; 3]| {
|
||||
delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2]
|
||||
};
|
||||
dot(plane.y_axis).atan2(dot(plane.x_axis))
|
||||
}
|
||||
|
||||
fn point_on_radial_circle(
|
||||
radial: RadialSourceGeometry,
|
||||
radius: f64,
|
||||
angle: f64,
|
||||
) -> Vector3 {
|
||||
vector3(radial.plane.point_at([
|
||||
radial.center[0] + radius * angle.cos(),
|
||||
radial.center[1] + radius * angle.sin(),
|
||||
]))
|
||||
}
|
||||
|
||||
fn plane_from_normal(origin: Vector3, normal: Vector3) -> Plane {
|
||||
let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes((
|
||||
normal.x, normal.y, normal.z,
|
||||
));
|
||||
Plane::from_axes(
|
||||
[origin.x, origin.y, origin.z],
|
||||
[x_axis.0, x_axis.1, x_axis.2],
|
||||
[y_axis.0, y_axis.1, y_axis.2],
|
||||
)
|
||||
}
|
||||
|
||||
fn remap_plane_offset(
|
||||
old_plane: Plane,
|
||||
new_plane: Plane,
|
||||
old_origin: Vector3,
|
||||
old_point: Vector3,
|
||||
new_origin: Vector3,
|
||||
) -> Vector3 {
|
||||
let old_origin = old_plane.project(dpoint(old_origin)).unwrap_or([0.0; 2]);
|
||||
let old_point = old_plane.project(dpoint(old_point)).unwrap_or(old_origin);
|
||||
let new_origin_uv = new_plane.project(dpoint(new_origin)).unwrap_or([0.0; 2]);
|
||||
vector3(new_plane.point_at([
|
||||
new_origin_uv[0] + old_point[0] - old_origin[0],
|
||||
new_origin_uv[1] + old_point[1] - old_origin[1],
|
||||
]))
|
||||
}
|
||||
|
||||
pub(crate) fn dimension_is_associative(
|
||||
document: &acadrust::CadDocument,
|
||||
dimension: Handle,
|
||||
|
|
@ -807,6 +906,18 @@ impl Scene {
|
|||
let radial = radial_source_for_marker(entity, reference.main_gs_marker)?;
|
||||
Some((radial, reference.osnap_distance))
|
||||
});
|
||||
let arc_source = association.references[0].first().and_then(|reference| {
|
||||
let source = *reference.xrefs.first()?;
|
||||
let entity = self.document.get_entity(source)?;
|
||||
let segment = match reference.main_gs_marker {
|
||||
-3 => 0,
|
||||
POLYLINE_ARC_CENTER_MARKER => {
|
||||
reference.osnap_distance.round().max(0.0) as i32
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
radial_source_for_marker(entity, segment)
|
||||
});
|
||||
if radial_source.is_none() && resolved.iter().all(Option::is_none) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -936,7 +1047,95 @@ impl Scene {
|
|||
}
|
||||
ordinate.refresh_measurement();
|
||||
}
|
||||
_ => continue,
|
||||
Dimension::Arc(arc) => {
|
||||
let Some(radial) = arc_source else {
|
||||
continue;
|
||||
};
|
||||
let center = resolved[0].unwrap_or_else(|| radial.center_world());
|
||||
let Some(first) = resolved[1] else {
|
||||
continue;
|
||||
};
|
||||
let Some(second) = resolved[2] else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let old_center = arc.center_point;
|
||||
let old_definition = arc.definition_point;
|
||||
let old_plane = plane_from_normal(old_center, arc.base.normal);
|
||||
let old_source_radius = old_center.distance(&arc.first_extension_point);
|
||||
let old_dim_radius = old_center.distance(&old_definition);
|
||||
let radial_offset = old_dim_radius - old_source_radius;
|
||||
let old_mid = arc.arc_start_parameter
|
||||
+ positive_sweep(
|
||||
arc.arc_start_parameter,
|
||||
arc.arc_end_parameter,
|
||||
) * 0.5;
|
||||
let old_definition_angle =
|
||||
angle_about_plane(old_plane, old_center, old_definition);
|
||||
let definition_angle_offset =
|
||||
signed_angle_delta(old_definition_angle - old_mid);
|
||||
|
||||
let start = radial.angle_at(dpoint(first));
|
||||
let end_at = radial.angle_at(dpoint(second));
|
||||
let sweep = positive_sweep(start, end_at);
|
||||
let end = start + sweep;
|
||||
let new_radius = center.distance(&first);
|
||||
if !new_radius.is_finite() || new_radius <= 1.0e-12 {
|
||||
continue;
|
||||
}
|
||||
let dim_radius = (new_radius + radial_offset).max(1.0e-9);
|
||||
let middle = start + sweep * 0.5;
|
||||
let definition = point_on_radial_circle(
|
||||
radial,
|
||||
dim_radius,
|
||||
middle + definition_angle_offset,
|
||||
);
|
||||
let text_middle = remap_plane_offset(
|
||||
old_plane,
|
||||
radial.plane,
|
||||
old_definition,
|
||||
arc.base.text_middle_point,
|
||||
definition,
|
||||
);
|
||||
let insertion = remap_plane_offset(
|
||||
old_plane,
|
||||
radial.plane,
|
||||
old_definition,
|
||||
arc.base.insertion_point,
|
||||
definition,
|
||||
);
|
||||
let second_leader = remap_plane_offset(
|
||||
old_plane,
|
||||
radial.plane,
|
||||
old_definition,
|
||||
arc.second_leader_point,
|
||||
definition,
|
||||
);
|
||||
|
||||
arc.center_point = center;
|
||||
arc.first_extension_point = first;
|
||||
arc.second_extension_point = second;
|
||||
arc.arc_start_parameter = start;
|
||||
arc.arc_end_parameter = end;
|
||||
arc.definition_point = definition;
|
||||
arc.base.definition_point = definition;
|
||||
if let Some(normal) = radial.plane.normal() {
|
||||
arc.base.normal = vector3(normal);
|
||||
}
|
||||
if arc.base.text_user_positioned {
|
||||
arc.base.text_middle_point = text_middle;
|
||||
arc.base.insertion_point = insertion;
|
||||
} else {
|
||||
arc.base.text_middle_point = definition;
|
||||
arc.base.insertion_point = definition;
|
||||
}
|
||||
if arc.has_leader {
|
||||
arc.first_leader_point =
|
||||
point_on_radial_circle(radial, dim_radius, middle);
|
||||
arc.second_leader_point = second_leader;
|
||||
}
|
||||
arc.base.actual_measurement = arc.measurement();
|
||||
}
|
||||
}
|
||||
dimension.base_mut().actual_measurement = dimension.measurement();
|
||||
refreshed.push((association.dimension, ChangeKind::Modified));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -4582,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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
567
src/ui/window/geometric_tolerance.rs
Normal file
567
src/ui/window/geometric_tolerance.rs
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
//! Structured feature-control-frame editor.
|
||||
//!
|
||||
//! The entity stores a compact escape string, but users work with named
|
||||
//! symbols, tolerance compartments and datum references. This dialog keeps
|
||||
//! that storage detail behind typed controls and can also reopen existing
|
||||
//! frames without losing their compartment layout.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use acadrust::Handle;
|
||||
use iced::widget::{button, checkbox, column, container, row, text, text_input, Space};
|
||||
use iced::{Border, Element, Fill, Length, Theme};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::t;
|
||||
|
||||
const EMPTY: &str = "";
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ToleranceEntry {
|
||||
pub diameter: bool,
|
||||
pub value: String,
|
||||
pub material: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct DatumEntry {
|
||||
pub value: String,
|
||||
pub material: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct State {
|
||||
pub editing: Option<Handle>,
|
||||
pub symbol: String,
|
||||
pub tolerances: [ToleranceEntry; 2],
|
||||
pub datums: [DatumEntry; 3],
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
editing: None,
|
||||
symbol: String::new(),
|
||||
tolerances: std::array::from_fn(|_| ToleranceEntry::default()),
|
||||
datums: std::array::from_fn(|_| DatumEntry::default()),
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Field {
|
||||
Symbol(String),
|
||||
ToleranceValue(usize, String),
|
||||
ToleranceMaterial(usize, String),
|
||||
DatumValue(usize, String),
|
||||
DatumMaterial(usize, String),
|
||||
ProjectedHeight(String),
|
||||
DatumIdentifier(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Toggle {
|
||||
Diameter(usize, bool),
|
||||
ProjectedZone(bool),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Choice {
|
||||
code: &'static str,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl fmt::Display for Choice {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(crate::i18n::translate(self.label).as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
const SYMBOLS: [(&str, &str); 15] = [
|
||||
("", "None"),
|
||||
("u", "Straightness"),
|
||||
("c", "Flatness"),
|
||||
("e", "Circularity"),
|
||||
("g", "Cylindricity"),
|
||||
("d", "Profile of a surface"),
|
||||
("k", "Profile of a line"),
|
||||
("j", "Position"),
|
||||
("r", "Concentricity"),
|
||||
("i", "Symmetry"),
|
||||
("f", "Parallelism"),
|
||||
("b", "Perpendicularity"),
|
||||
("a", "Angularity"),
|
||||
("h", "Circular runout"),
|
||||
("t", "Total runout"),
|
||||
];
|
||||
|
||||
const MATERIALS: [(&str, &str); 4] = [
|
||||
("", "None"),
|
||||
("m", "Maximum material condition"),
|
||||
("l", "Least material condition"),
|
||||
("s", "Regardless of feature size"),
|
||||
];
|
||||
|
||||
fn choices(values: &'static [(&'static str, &'static str)]) -> Vec<Choice> {
|
||||
values
|
||||
.iter()
|
||||
.map(|(code, label)| Choice { code, label })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn selected(values: &'static [(&'static str, &'static str)], code: &str) -> Option<Choice> {
|
||||
values
|
||||
.iter()
|
||||
.find(|(value, _)| value.eq_ignore_ascii_case(code))
|
||||
.map(|(code, label)| Choice { code, label })
|
||||
}
|
||||
|
||||
fn escape(code: &str) -> String {
|
||||
if code.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{{\\Fgdt;{code}}}")
|
||||
}
|
||||
}
|
||||
|
||||
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) = remove_escape(input, code) {
|
||||
return (value, code.to_string());
|
||||
}
|
||||
}
|
||||
(input.to_string(), String::new())
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn from_text(editing: Option<Handle>, raw: &str) -> Self {
|
||||
let mut state = Self {
|
||||
editing,
|
||||
..Self::default()
|
||||
};
|
||||
let normalized = raw
|
||||
.replace("^J", "\n")
|
||||
.replace("\\P", "\n")
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', "\n");
|
||||
let lines: Vec<&str> = normalized.split('\n').collect();
|
||||
state.original_text = (!raw.is_empty()).then(|| raw.to_string());
|
||||
|
||||
if let Some(frame) = lines.first().copied() {
|
||||
let cells: Vec<&str> = frame.split("%%v").collect();
|
||||
if let Some(cell) = cells.first() {
|
||||
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;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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 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) {
|
||||
let changed = match field {
|
||||
Field::Symbol(value) => {
|
||||
let changed = self.symbol != value;
|
||||
self.symbol = value;
|
||||
changed
|
||||
}
|
||||
Field::ToleranceValue(index, value) if index < 2 => {
|
||||
let changed = self.tolerances[index].value != value;
|
||||
self.tolerances[index].value = value;
|
||||
changed
|
||||
}
|
||||
Field::ToleranceMaterial(index, value) if index < 2 => {
|
||||
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::DatumMaterial(index, value) if index < 3 => {
|
||||
let changed = self.datums[index].material != value;
|
||||
self.datums[index].material = value;
|
||||
changed
|
||||
}
|
||||
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) {
|
||||
let changed = match toggle {
|
||||
Toggle::Diameter(index, value) if index < 2 => {
|
||||
let changed = self.tolerances[index].diameter != value;
|
||||
self.tolerances[index].diameter = value;
|
||||
changed
|
||||
}
|
||||
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 {
|
||||
if !self.dirty {
|
||||
if let Some(original) = &self.original_text {
|
||||
return original.clone();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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.clone();
|
||||
if self.projected_zone {
|
||||
projected.push_str(&escape("p"));
|
||||
}
|
||||
rows.push(projected);
|
||||
}
|
||||
if !self.datum_identifier.trim().is_empty() {
|
||||
if rows.len() == 1 {
|
||||
rows.push(String::new());
|
||||
}
|
||||
rows.push(self.datum_identifier.clone());
|
||||
}
|
||||
rows.extend(self.extra_rows.iter().cloned());
|
||||
rows.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn muted(theme: &Theme) -> iced::widget::text::Style {
|
||||
iced::widget::text::Style {
|
||||
color: Some(theme.palette().background.base.text.scale_alpha(0.65)),
|
||||
}
|
||||
}
|
||||
|
||||
fn panel<'a>(title: String, body: Element<'a, Message>) -> Element<'a, Message> {
|
||||
container(column![text(title).size(11).style(muted), body].spacing(6))
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
border: Border {
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
color: theme.palette().background.strong.color,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn material_picker<'a>(index: usize, datum: bool, code: &str) -> Element<'a, Message> {
|
||||
let options = choices(&MATERIALS);
|
||||
let selected = selected(&MATERIALS, code);
|
||||
iced::widget::pick_list(selected, options, |choice| choice.to_string())
|
||||
.on_select(move |choice| {
|
||||
if datum {
|
||||
Message::ToleranceDialogField(Field::DatumMaterial(
|
||||
index,
|
||||
choice.code.to_string(),
|
||||
))
|
||||
} else {
|
||||
Message::ToleranceDialogField(Field::ToleranceMaterial(
|
||||
index,
|
||||
choice.code.to_string(),
|
||||
))
|
||||
}
|
||||
})
|
||||
.text_size(11)
|
||||
.padding([3, 6])
|
||||
.width(Length::Fixed(190.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn view_window<'a>(
|
||||
state: &State,
|
||||
sizing: crate::ui::modal::ModalSizing,
|
||||
) -> Element<'a, Message> {
|
||||
let symbol_options = choices(&SYMBOLS);
|
||||
let symbol_selected = selected(&SYMBOLS, &state.symbol);
|
||||
let symbol = panel(
|
||||
t!("Geometric characteristic").into_owned(),
|
||||
iced::widget::pick_list(symbol_selected, symbol_options, |choice| choice.to_string())
|
||||
.on_select(|choice| {
|
||||
Message::ToleranceDialogField(Field::Symbol(choice.code.to_string()))
|
||||
})
|
||||
.text_size(12)
|
||||
.padding([4, 6])
|
||||
.width(Fill)
|
||||
.into(),
|
||||
);
|
||||
|
||||
let tolerance_rows = state.tolerances.iter().enumerate().fold(
|
||||
column![].spacing(6),
|
||||
|column, (index, entry)| {
|
||||
column.push(
|
||||
row![
|
||||
text(format!("{} {}", t!("Tolerance"), index + 1))
|
||||
.size(11)
|
||||
.style(muted)
|
||||
.width(Length::Fixed(82.0)),
|
||||
checkbox(entry.diameter)
|
||||
.on_toggle(move |value| Message::ToleranceDialogToggle(
|
||||
Toggle::Diameter(index, value)
|
||||
))
|
||||
.size(14),
|
||||
text(t!("Diameter")).size(11).width(Length::Fixed(62.0)),
|
||||
text_input(EMPTY, &entry.value)
|
||||
.on_input(move |value| Message::ToleranceDialogField(
|
||||
Field::ToleranceValue(index, value)
|
||||
))
|
||||
.size(12)
|
||||
.padding([3, 6])
|
||||
.width(Length::Fixed(125.0)),
|
||||
material_picker(index, false, &entry.material),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
},
|
||||
);
|
||||
let tolerances = panel(t!("Tolerance values").into_owned(), tolerance_rows.into());
|
||||
|
||||
let datum_rows = state.datums.iter().enumerate().fold(
|
||||
column![].spacing(6),
|
||||
|column, (index, entry)| {
|
||||
column.push(
|
||||
row![
|
||||
text(format!("{} {}", t!("Datum"), index + 1))
|
||||
.size(11)
|
||||
.style(muted)
|
||||
.width(Length::Fixed(82.0)),
|
||||
text_input(EMPTY, &entry.value)
|
||||
.on_input(move |value| Message::ToleranceDialogField(
|
||||
Field::DatumValue(index, value)
|
||||
))
|
||||
.size(12)
|
||||
.padding([3, 6])
|
||||
.width(Length::Fixed(212.0)),
|
||||
material_picker(index, true, &entry.material),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
},
|
||||
);
|
||||
let datums = panel(t!("Datum references").into_owned(), datum_rows.into());
|
||||
|
||||
let additions = panel(
|
||||
t!("Additional information").into_owned(),
|
||||
column![
|
||||
row![
|
||||
text(t!("Projected height"))
|
||||
.size(11)
|
||||
.style(muted)
|
||||
.width(Length::Fixed(112.0)),
|
||||
text_input(EMPTY, &state.projected_height)
|
||||
.on_input(|value| Message::ToleranceDialogField(Field::ProjectedHeight(value)))
|
||||
.size(12)
|
||||
.padding([3, 6])
|
||||
.width(Length::Fixed(125.0)),
|
||||
checkbox(state.projected_zone)
|
||||
.on_toggle(|value| Message::ToleranceDialogToggle(
|
||||
Toggle::ProjectedZone(value)
|
||||
))
|
||||
.size(14),
|
||||
text(t!("Projected tolerance zone")).size(11),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
text(t!("Datum identifier"))
|
||||
.size(11)
|
||||
.style(muted)
|
||||
.width(Length::Fixed(112.0)),
|
||||
text_input(EMPTY, &state.datum_identifier)
|
||||
.on_input(|value| Message::ToleranceDialogField(Field::DatumIdentifier(value)))
|
||||
.size(12)
|
||||
.padding([3, 6])
|
||||
.width(Length::Fixed(212.0)),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
]
|
||||
.spacing(6)
|
||||
.into(),
|
||||
);
|
||||
|
||||
let mut actions = row![
|
||||
Space::new().width(Fill),
|
||||
button(text(t!("Cancel")).size(12))
|
||||
.on_press(Message::CloseModal)
|
||||
.padding([4, 12]),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center);
|
||||
if state.editing.is_some() {
|
||||
actions = actions.push(
|
||||
button(text(t!("Apply")).size(12))
|
||||
.on_press_maybe(state.is_valid().then_some(Message::ToleranceDialogApply))
|
||||
.padding([4, 12]),
|
||||
);
|
||||
}
|
||||
actions = actions.push(
|
||||
button(text(t!("OK")).size(12))
|
||||
.on_press_maybe(state.is_valid().then_some(Message::ToleranceDialogOk))
|
||||
.style(button::primary)
|
||||
.padding([4, 12]),
|
||||
);
|
||||
|
||||
column![symbol, tolerances, datums, additions, actions]
|
||||
.spacing(8)
|
||||
.padding(10)
|
||||
.width(sizing.width)
|
||||
.into()
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ pub mod block_palette;
|
|||
pub mod layout_manager;
|
||||
pub mod layer_state_manager;
|
||||
pub mod drawing_units;
|
||||
pub mod geometric_tolerance;
|
||||
pub mod drafting_settings;
|
||||
pub mod layer_translator;
|
||||
pub mod plot;
|
||||
|
|
|
|||
Loading…
Reference in a new issue