From c6f6c00220863b24e7151f3d2a58d79c4e49b057 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:49:32 +0300 Subject: [PATCH 01/16] Add dimension continuation creation policy --- src/app/command_driver.rs | 23 +++++++++++++++- src/app/commands/styleprops.rs | 25 +++++++++++++++++- src/app/mod.rs | 3 +++ src/app/properties.rs | 32 +++++++++++++++++++++++ src/app/settings.rs | 9 +++++++ src/app/update/file.rs | 2 ++ src/command.rs | 3 +++ src/modules/annotate/aligned_dim.rs | 1 + src/modules/annotate/angular_dim.rs | 1 + src/modules/annotate/arc_length_dim.rs | 1 + src/modules/annotate/diameter_dim.rs | 1 + src/modules/annotate/jogged_radius_dim.rs | 1 + src/modules/annotate/linear_dim.rs | 1 + src/modules/annotate/ordinate_dim.rs | 1 + src/modules/annotate/radius_dim.rs | 1 + src/scene/mod.rs | 6 +++++ 16 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 09eb09718..d33b27233 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1287,6 +1287,7 @@ impl OpenCADStudio { CmdResult::CommitDimension { mut entity, association, + preserve_base_style, } => { let label = self.history_label_from_active_cmd(i, "DIMENSION"); let association_mode = self.tabs[i] @@ -1300,6 +1301,17 @@ impl OpenCADStudio { acadrust::entities::Dimension::Ordinate(_) ) ); + let inherited_dimension = if preserve_base_style { + match &entity { + acadrust::EntityType::Dimension(dimension) => Some(( + dimension.base().common.layer.clone(), + dimension.base().style_name.clone(), + )), + _ => None, + } + } else { + None + }; let pending = if association_mode == 0 { let layer = self.tabs[i].active_layer.clone(); if layer != "0" || entity.as_entity().layer().is_empty() { @@ -1330,6 +1342,12 @@ impl OpenCADStudio { &self.tabs[i].scene.document, &mut entity, ); + if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) = + (inherited_dimension, &mut entity) + { + dimension.base_mut().common.layer = layer; + dimension.base_mut().style_name = style_name; + } let pieces = crate::modules::draw::modify::explode::explode_entity( &entity, &self.tabs[i].scene.document, @@ -1345,7 +1363,10 @@ impl OpenCADStudio { } else { let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - if let Some(handle) = self.commit_entity_handle(entity) { + if let Some(handle) = self.commit_entity_handle_with_dimension_policy( + entity, + preserve_base_style, + ) { if association_mode == 2 { let mut changes = vec![ (handle, crate::scene::ChangeKind::Modified), diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 39d4cd93c..31f72f161 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -931,6 +931,7 @@ impl OpenCADStudio { | "CENTERCROSSSIZE" | "CENTERCROSSGAP" | "CENTERMARKEXE" + | "DIMCONTINUEMODE" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -953,7 +954,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { let frame_kind = crate::scene::frame::kind_for_name(&name); @@ -1069,6 +1070,28 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if name == "DIMCONTINUEMODE" { + let current = self.dimension_continue_mode; + if let Some(value) = &value { + match value.parse::() { + Ok(mode @ 0..=1) => { + self.dimension_continue_mode = mode; + self.persist_settings_if_changed(); + self.command_line + .push_output(&format!("DIMCONTINUEMODE = {mode}")); + } + _ => self.command_line.push_error( + "SETVAR: DIMCONTINUEMODE requires 0 or 1.", + ), + } + } else { + self.command_line.push_output(crate::tf!( + "Enter new value for {name} <{current}>:" + ).as_ref()); + self.pending_setvar = Some(name.clone()); + } + return Some(self.finish_dispatch(cmd)); + } if matches!(name.as_str(), "DONUTID" | "DONUTOD") { let current = if name == "DONUTID" { crate::modules::draw::defaults::get_donut_inner_diameter() diff --git a/src/app/mod.rs b/src/app/mod.rs index b18e53012..e4f0fcb84 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -465,6 +465,8 @@ pub(super) struct OpenCADStudio { options_tab: crate::ui::window::options::OptionsTab, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// Creation-style policy used by continuing and baseline dimensions. + pub dimension_continue_mode: i16, /// When true (default), saving over an existing file first writes a `.bak` /// copy of it for recovery (#205). Toggle with the ISAVEBAK command. pub backup_on_save: bool, @@ -3210,6 +3212,7 @@ impl OpenCADStudio { dyn_input: true, options_tab: crate::ui::window::options::OptionsTab::General, texteditmode: false, + dimension_continue_mode: 1, backup_on_save: true, file_assoc_enabled: true, savetime_min: 10, diff --git a/src/app/properties.rs b/src/app/properties.rs index 006877cc4..6129dd8b8 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2455,10 +2455,33 @@ impl OpenCADStudio { /// (or `None` if it could not be added). Lets callers follow up — e.g. /// open the in-place text editor on a freshly created MultiLeader. pub(super) fn commit_entity_handle( + &mut self, + entity: acadrust::EntityType, + ) -> Option { + self.commit_entity_handle_with_dimension_policy(entity, false) + } + + /// Commit an entity while optionally retaining a dimension's source layer + /// and dimension style. DIMCONTINUEMODE=1 uses this path; ordinary + /// creation continues to receive the current ribbon/style defaults. + pub(super) fn commit_entity_handle_with_dimension_policy( &mut self, mut entity: acadrust::EntityType, + preserve_dimension_layer_and_style: bool, ) -> Option { let i = self.active_tab; + let is_dimension = matches!(&entity, acadrust::EntityType::Dimension(_)); + let inherited_dimension = if preserve_dimension_layer_and_style { + match &entity { + acadrust::EntityType::Dimension(dimension) => Some(( + dimension.base().common.layer.clone(), + dimension.base().style_name.clone(), + )), + _ => None, + } + } else { + None + }; if let acadrust::EntityType::Table(table) = &mut entity { const PREFIX: &str = "__OPENCAD_LINK_PENDING__"; if let Some(path) = table.name.strip_prefix(PREFIX).map(str::to_string) { @@ -2560,6 +2583,12 @@ impl OpenCADStudio { &self.tabs[i].scene.document, &mut entity, ); + if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) = + (inherited_dimension, &mut entity) + { + dimension.base_mut().common.layer = layer; + dimension.base_mut().style_name = style_name; + } // Smart centre objects carry their own drawing-level creation style. // Apply it after the generic ribbon style so ordinary LINE entities @@ -2719,6 +2748,9 @@ impl OpenCADStudio { self.tabs[i].last_draw_anchor = Some(handle); } } + if is_dimension { + self.tabs[i].scene.last_created_dimension = new_handle; + } new_handle } } diff --git a/src/app/settings.rs b/src/app/settings.rs index 8b65c73e5..8caa5f994 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -198,6 +198,10 @@ pub struct UserSettings { pub osmode: i32, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// DIMCONTINUEMODE: 1 inherits the base dimension's layer/style; 0 uses + /// the current creation layer/style. Registry-style, app-level preference. + #[serde(default = "default_dimension_continue_mode")] + pub dimension_continue_mode: i16, /// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false). pub textfill: bool, /// When true, saving over an existing file first copies it to a sibling @@ -247,6 +251,10 @@ fn default_clipromptlines() -> i32 { 3 } +fn default_dimension_continue_mode() -> i16 { + 1 +} + fn deserialize_clipromptlines<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, @@ -284,6 +292,7 @@ impl Default for UserSettings { // off (suppress bit 16384). osmode: 575 | OSMODE_SUPPRESS, texteditmode: false, + dimension_continue_mode: 1, textfill: true, backup_on_save: true, file_assoc_enabled: true, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bbf906eae..0e85da3ce 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -382,6 +382,7 @@ impl OpenCADStudio { self.snapper.snap_enabled, ), texteditmode: self.texteditmode, + dimension_continue_mode: self.dimension_continue_mode, textfill: crate::scene::text::sdf_atlas::textfill(), backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, @@ -440,6 +441,7 @@ impl OpenCADStudio { self.snapper.enabled = modes.into_iter().collect(); self.snapper.snap_enabled = snap_enabled; self.texteditmode = s.texteditmode; + self.dimension_continue_mode = s.dimension_continue_mode.clamp(0, 1); crate::scene::text::sdf_atlas::set_textfill(s.textfill); self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; diff --git a/src/command.rs b/src/command.rs index d4a2312b3..e8bf675e8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1248,6 +1248,9 @@ pub enum CmdResult { CommitDimension { entity: EntityType, association: DimensionAssociationInput, + /// Retain the source dimension's layer and style instead of replacing + /// them with the current creation defaults (DIMCONTINUEMODE=1). + preserve_base_style: bool, }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 2ec212d1e..dafc2183b 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -135,6 +135,7 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::CommitDimension { entity: self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim))), association: DimensionAssociationInput::Infer(self.source_handle), + preserve_base_style: false, } } } diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index 6bd3ef2e4..f1c00059f 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -234,6 +234,7 @@ impl AngularDimensionCommand { CmdResult::CommitDimension { entity, association: DimensionAssociationInput::Explicit(self.source_refs.clone()), + preserve_base_style: false, } } diff --git a/src/modules/annotate/arc_length_dim.rs b/src/modules/annotate/arc_length_dim.rs index f0225c1c9..434eabbe6 100644 --- a/src/modules/annotate/arc_length_dim.rs +++ b/src/modules/annotate/arc_length_dim.rs @@ -265,6 +265,7 @@ impl ArcLengthDimensionCommand { CmdResult::CommitDimension { entity: plane.place_entity(EntityType::Dimension(Dimension::Arc(dimension))), association, + preserve_base_style: false, } } } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 010434c71..946cde255 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -113,6 +113,7 @@ impl CadCommand for DiameterDimensionCommand { entity: source_plane .place_entity(EntityType::Dimension(Dimension::Diameter(dim))), association: DimensionAssociationInput::Infer(self.source_handle), + preserve_base_style: false, } } } diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index fdfc6097b..2363082b1 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -139,6 +139,7 @@ impl JoggedRadiusDimensionCommand { dimension, ))), association, + preserve_base_style: false, } } } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index c2f927c80..fd6d6d6c5 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -187,6 +187,7 @@ impl CadCommand for LinearDimensionCommand { CmdResult::CommitDimension { entity, association: DimensionAssociationInput::Infer(self.source_handle), + preserve_base_style: false, } } } diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 4ac1d8a17..221523c7f 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -116,6 +116,7 @@ impl CadCommand for OrdinateDimCommand { Dimension::Ordinate(dim), )), association: DimensionAssociationInput::Infer(None), + preserve_base_style: false, } } } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 62a833489..10681bad8 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -111,6 +111,7 @@ impl CadCommand for RadiusDimensionCommand { CmdResult::CommitDimension { entity: source_plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))), association: DimensionAssociationInput::Infer(self.source_handle), + preserve_base_style: false, } } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0894fe12c..161d2a454 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1420,6 +1420,11 @@ pub struct Scene { pub selection: Rc>, /// The CAD document — single source of truth for all entities. pub document: CadDocument, + /// Last dimension created during this application session. This is + /// intentionally not reconstructed from file handles: continuation and + /// baseline commands must not treat an old, merely high-handle dimension + /// loaded from disk as the user's most recently created dimension. + pub last_created_dimension: Option, /// File-open-prepared index for non-graphical semantic object lookups. pub(crate) object_data_cache: crate::entities::object_data::ObjectDataCache, /// Native AcDbLight/Sun inputs, separated by rendered block and viewport @@ -1848,6 +1853,7 @@ impl Scene { model_pane_min_px: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)), selection: Rc::new(RefCell::new(SelectionState::default())), document: CadDocument::new(), + last_created_dimension: None, object_data_cache: crate::entities::object_data::ObjectDataCache::default(), lighting_cache: RefCell::new(HashMap::default()), selected: HashSet::default(), From 980a7d611bf5d2bc88046ede30a2706e0374174d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:49:38 +0300 Subject: [PATCH 02/16] Complete baseline dimension workflow --- src/app/commands/dim.rs | 37 +- src/modules/annotate/dim_baseline.rs | 806 +++++++++++++++++++++------ 2 files changed, 646 insertions(+), 197 deletions(-) diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 22ee0d96e..9d84edcb6 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -659,23 +659,30 @@ impl OpenCADStudio { "DIMBASELINE" => { use crate::modules::annotate::dim_baseline::DimBaselineCommand; - let cmd = if let Some((p1, p2, dp, rot, trot)) = - find_last_linear_dim(&self.tabs[i].scene) - { - let doc = &self.tabs[i].scene.document; - let dimdli = doc - .dim_styles - .iter() - .find(|s| { - s.name - .eq_ignore_ascii_case(&doc.header.current_dimstyle_name) - }) - .map(|s| s.dimdli as f32) - .unwrap_or(1.5); - DimBaselineCommand::from_base(p1, p2, dp, rot, trot, dimdli) + let scene = &self.tabs[i].scene; + let recent = scene + .last_created_dimension + .and_then(|handle| scene.document.get_entity(handle)) + .cloned(); + let dimdli_by_style = scene + .document + .dim_styles + .iter() + .map(|style| (style.name.to_ascii_lowercase(), style.dimdli)) + .collect(); + let fallback_dimdli = if scene.document.header.measurement == 1 { + 3.75 } else { - DimBaselineCommand::new() + 0.38 }; + let current_style_name = scene.document.header.current_dimstyle_name.clone(); + let cmd = DimBaselineCommand::new( + recent, + dimdli_by_style, + current_style_name, + fallback_dimdli, + self.dimension_continue_mode == 1, + ); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 207e9c352..a2ae3df3a 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -1,20 +1,18 @@ -// DIMBASELINE command — stacked baseline dimensions all measured from the same origin. -// -// Each new point becomes the second extension line origin of a new dimension. -// The first extension line is always the same base origin point. -// Each new dimension line is placed further from the baseline by DIMDLI (increment). -// -// Constructed from commands.rs after finding the last placed linear/aligned dimension. +//! Repeating baseline dimensions from a session base or an explicitly picked base. -use acadrust::entities::{Dimension, DimensionLinear}; +use std::collections::HashMap; + +use acadrust::entities::{ + Dimension, DimensionAligned, DimensionAngular2Ln, DimensionAngular3Pt, DimensionBase, + DimensionLinear, DimensionOrdinate, +}; use acadrust::types::Vector3; -use acadrust::EntityType; -use glam::{DVec3, Vec3}; +use acadrust::{EntityType, Handle}; +use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, DimensionAssociationInput}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_baseline.svg")); @@ -27,100 +25,302 @@ pub fn tool() -> ToolDef { } } -/// Fallback stacking increment (world units) when no DimStyle is available. -const DEFAULT_DIMDLI: f64 = 1.5; +#[derive(Clone)] +struct SourceStyle { + layer: String, + style_name: String, + normal: Vector3, + text_rotation: f64, + horizontal_direction: f64, +} + +impl SourceStyle { + fn from_base(base: &DimensionBase) -> Self { + Self { + layer: base.common.layer.clone(), + style_name: base.style_name.clone(), + normal: base.normal, + text_rotation: base.text_rotation, + horizontal_direction: base.horizontal_direction, + } + } + + fn apply(&self, base: &mut DimensionBase) { + base.common.layer = self.layer.clone(); + base.style_name = self.style_name.clone(); + base.normal = self.normal; + base.text_rotation = self.text_rotation; + base.horizontal_direction = self.horizontal_direction; + } +} + +enum BaselineKind { + Linear { + fixed: DVec3, + rotation: f64, + aligned: bool, + perpendicular: DVec3, + next_offset: f64, + increment: f64, + }, + Angular2Ln { + source: DimensionAngular2Ln, + vertex: DVec3, + fixed_ray: DVec3, + next_radius: f64, + increment: f64, + }, + Angular3Pt { + source: DimensionAngular3Pt, + vertex: DVec3, + fixed_ray: DVec3, + next_radius: f64, + increment: f64, + }, + Ordinate { + source: DimensionOrdinate, + leader_delta: DVec3, + }, +} + +struct BaselineState { + kind: BaselineKind, + style: SourceStyle, +} pub struct DimBaselineCommand { - /// Fixed first-extension-line origin (never changes). - base_p1: DVec3, - /// Direction along the measurement direction (0.0 = horizontal, PI/2 = vertical). - rotation: f64, - /// Text reading rotation inherited from the base dim (keeps a UCS-aligned - /// chain's text consistent with the originating DIMLINEAR). 0 = natural. - text_rotation: f64, - /// Unit vector perpendicular to the dimension axis, pointing toward the dim line side. - perp: DVec3, - /// Perpendicular distance of the NEXT dimension line from the extension-line axis. - next_offset: f64, - /// Stacking increment from the active DimStyle (DIMDLI). - dimdli: f64, - /// True once we have a base dimension loaded. - ready: bool, + base: Option, + injected: Option, + dimdli_by_style: HashMap, + current_style_name: String, + fallback_dimdli: f64, + preserve_base_style: bool, + committed: usize, } impl DimBaselineCommand { - /// No base dim found — cancel immediately. - pub fn new() -> Self { - Self { - base_p1: DVec3::ZERO, - rotation: 0.0, - text_rotation: 0.0, - perp: DVec3::Y, - next_offset: DEFAULT_DIMDLI, - dimdli: DEFAULT_DIMDLI, - ready: false, - } - } - - /// Build from the last placed dimension. - /// - /// `p1` — first extension line origin (fixed baseline). - /// `p2` — second extension line origin of the base dim (unused for placement, kept for context). - /// `definition_point` — dim-line position of the base dim (defines perpendicular side). - /// `rotation` — 0.0 = horizontal, PI/2 = vertical. - /// `dimdli` — DimStyle stacking increment (use [`DEFAULT_DIMDLI`] when no style is active). - pub fn from_base( - p1: Vec3, - _p2: Vec3, - definition_point: Vec3, - rotation: f64, - text_rotation: f64, - dimdli: f32, + pub fn new( + recent: Option, + dimdli_by_style: HashMap, + current_style_name: String, + fallback_dimdli: f64, + preserve_base_style: bool, ) -> Self { - // The base dim's points enter as f32 from the caller; promote to f64 so - // all committed-coordinate math downstream stays exact. - let p1 = p1.as_dvec3(); - let definition_point = definition_point.as_dvec3(); - // Measurement axis = the base dim's rotation angle (any angle, incl. a - // UCS-aligned one), not a world horizontal/vertical. - let axis = DVec3::new(rotation.cos(), rotation.sin(), 0.0); - let perp = DVec3::new(-axis.y, axis.x, 0.0); - let base_offset = (definition_point - p1).dot(perp); - let dimdli = dimdli as f64; - let dimdli = if dimdli.abs() < 1e-6 { - DEFAULT_DIMDLI - } else { - dimdli.abs() + let mut command = Self { + base: None, + injected: None, + dimdli_by_style, + current_style_name, + fallback_dimdli, + preserve_base_style, + committed: 0, }; - // Stack each baseline outward — in the SIGN direction of the base dim's - // own offset. When the base dim sits on the -perp side (below / left of - // the points) a positive increment would march the stack back toward and - // across the points; the signed increment keeps it moving away. (#181) - let dir = if base_offset >= 0.0 { 1.0 } else { -1.0 }; - let dimdli = dimdli * dir; - let next_offset = base_offset + dimdli; - Self { - base_p1: p1, - rotation, - text_rotation, - perp, - next_offset, - dimdli, - ready: true, + if let Some(entity) = recent { + let _ = command.install_base(&entity, None); + } + command + } + + fn effective_increment(&self, style_name: &str) -> f64 { + let style_name = if self.preserve_base_style { + style_name + } else { + &self.current_style_name + }; + self.dimdli_by_style + .get(&style_name.to_ascii_lowercase()) + .copied() + .filter(|value| value.is_finite() && value.abs() > 1.0e-9) + .unwrap_or(self.fallback_dimdli) + .abs() + } + + fn install_base(&mut self, entity: &EntityType, pick: Option) -> bool { + let EntityType::Dimension(dimension) = entity else { + return false; + }; + let style = SourceStyle::from_base(dimension.base()); + let increment = self.effective_increment(&style.style_name); + let kind = match dimension { + Dimension::Linear(source) => { + let (fixed, _) = nearest_end(source.first_point, source.second_point, pick); + linear_base( + fixed, + source.rotation, + false, + dv(source.base.definition_point), + increment, + ) + } + Dimension::Aligned(source) => { + let (fixed, other) = nearest_end(source.first_point, source.second_point, pick); + linear_base( + fixed, + (other - fixed).y.atan2((other - fixed).x), + true, + dv(source.base.definition_point), + increment, + ) + } + Dimension::Angular2Ln(source) => { + let vertex = angular2_vertex(source); + let first = dv(source.second_point) - vertex; + let second = dv(source.definition_point) - vertex; + let fixed_ray = nearest_direction(first, second, vertex, pick); + let radius = (dv(source.dimension_arc) - vertex).length().max(1.0e-9); + BaselineKind::Angular2Ln { + source: source.clone(), + vertex, + fixed_ray, + next_radius: radius + increment, + increment, + } + } + Dimension::Angular3Pt(source) => { + let vertex = dv(source.angle_vertex); + let first = dv(source.first_point) - vertex; + let second = dv(source.second_point) - vertex; + let fixed_ray = nearest_direction(first, second, vertex, pick); + let radius = (dv(source.definition_point) - vertex).length().max(1.0e-9); + BaselineKind::Angular3Pt { + source: source.clone(), + vertex, + fixed_ray, + next_radius: radius + increment, + increment, + } + } + Dimension::Ordinate(source) => BaselineKind::Ordinate { + source: source.clone(), + leader_delta: dv(source.leader_endpoint) - dv(source.feature_location), + }, + _ => return false, + }; + self.base = Some(BaselineState { kind, style }); + self.committed = 0; + true + } + + fn build_dimension(&self, point: DVec3) -> Option { + let state = self.base.as_ref()?; + let mut dimension = match &state.kind { + BaselineKind::Linear { + fixed, + rotation, + aligned, + perpendicular, + next_offset, + .. + } => build_linear( + *fixed, + point, + *rotation, + *aligned, + *perpendicular, + *next_offset, + ), + BaselineKind::Angular2Ln { + source, + vertex, + fixed_ray, + next_radius, + .. + } => { + let moving = point - *vertex; + if moving.length_squared() <= 1.0e-18 { + return None; + } + let mut result = source.clone(); + result.first_point = v3(*vertex); + result.second_point = v3(*vertex + normalized_or(*fixed_ray, DVec3::X)); + result.angle_vertex = v3(*vertex); + result.definition_point = v3(point); + result.dimension_arc = v3( + *vertex + angular_bisector(*fixed_ray, moving) * *next_radius, + ); + result.base.definition_point = result.dimension_arc; + result.base.text_middle_point = result.dimension_arc; + result.base.insertion_point = result.dimension_arc; + result.base.actual_measurement = result.measurement_degrees(); + Dimension::Angular2Ln(result) + } + BaselineKind::Angular3Pt { + source, + vertex, + fixed_ray, + next_radius, + .. + } => { + let moving = point - *vertex; + if moving.length_squared() <= 1.0e-18 { + return None; + } + let mut result = source.clone(); + result.angle_vertex = v3(*vertex); + result.first_point = v3(*vertex + normalized_or(*fixed_ray, DVec3::X)); + result.second_point = v3(point); + result.definition_point = v3( + *vertex + angular_bisector(*fixed_ray, moving) * *next_radius, + ); + result.base.definition_point = result.definition_point; + result.base.text_middle_point = result.definition_point; + result.base.insertion_point = result.definition_point; + result.base.actual_measurement = result.measurement_degrees(); + Dimension::Angular3Pt(result) + } + BaselineKind::Ordinate { + source, + leader_delta, + } => { + let mut result = DimensionOrdinate::new( + v3(point), + v3(point + *leader_delta), + source.is_ordinate_type_x, + ); + result.definition_point = source.definition_point; + result.base.definition_point = source.base.definition_point; + result.base.text_middle_point = result.leader_endpoint; + result.base.insertion_point = result.leader_endpoint; + Dimension::Ordinate(result) + } + }; + state.style.apply(dimension.base_mut()); + dimension.base_mut().common.handle = Handle::NULL; + dimension.base_mut().block_name.clear(); + Some(dimension) + } + + fn advance(&mut self, direction: f64) { + let Some(state) = self.base.as_mut() else { + return; + }; + match &mut state.kind { + BaselineKind::Linear { + next_offset, + increment, + .. + } => *next_offset += *increment * direction, + BaselineKind::Angular2Ln { + next_radius, + increment, + .. + } + | BaselineKind::Angular3Pt { + next_radius, + increment, + .. + } => *next_radius = (*next_radius + *increment * direction).max(1.0e-9), + BaselineKind::Ordinate { .. } => {} } } -} -impl DimBaselineCommand { - /// The two dimension-line endpoints for the new baseline at `p2`: the fixed - /// base origin and `p2` each projected INDEPENDENTLY onto the dim line (at - /// the current stacking offset). Projecting both keeps the line straight - /// even when the two origins aren't level — a shared offset tilts it. (#181) - fn dim_line_pts(&self, p2: DVec3) -> (DVec3, DVec3) { - let target = self.base_p1.dot(self.perp) + self.next_offset; - let d1 = self.base_p1 + self.perp * (target - self.base_p1.dot(self.perp)); - let d2 = p2 + self.perp * (target - p2.dot(self.perp)); - (d1, d2) + fn undo_one(&mut self) -> Option { + if self.committed == 0 { + return Some(CmdResult::NeedPoint); + } + self.committed -= 1; + self.advance(-1.0); + Some(CmdResult::UndoDocument) } } @@ -130,104 +330,346 @@ impl CadCommand for DimBaselineCommand { } fn prompt(&self) -> String { - if !self.ready { - t!("DIMBASELINE No base dimension found. Place a dimension first.").into_owned() + if self.base.is_none() { + "DIMBASELINE Select base dimension (Enter to exit):".to_string() } else { - t!("DIMBASELINE Specify a second extension line origin (Enter to exit):").into_owned() + "DIMBASELINE Specify next extension origin [Select/Undo] :".to_string() } else { - "DIMBASELINE Specify next extension origin [Select/Undo] :" + .to_string() } } fn options(&self) -> Vec { - self.base.as_ref().map_or_else(Vec::new, |_| { - vec![CmdOption::new("Select", "S"), CmdOption::new("Undo", "U")] + self.base.as_ref().map_or_else(Vec::new, |base| { + if matches!(&base.kind, BaselineKind::Ordinate { .. }) { + vec![CmdOption::new("Undo", "U"), CmdOption::new("Select", "S")] + } else { + vec![CmdOption::new("Select", "S"), CmdOption::new("Undo", "U")] + } }) } @@ -451,8 +463,8 @@ fn build_linear( let second_line = point + perpendicular * (target - point.dot(perpendicular)); if aligned { let mut result = DimensionAligned::new(v3(fixed), v3(point)); - result.definition_point = v3(first_line); - result.base.definition_point = v3(first_line); + result.definition_point = v3(second_line); + result.base.definition_point = v3(second_line); result.base.text_middle_point = v3((first_line + second_line) * 0.5); result.base.insertion_point = result.base.text_middle_point; result.base.actual_measurement = result.measurement(); @@ -460,8 +472,8 @@ fn build_linear( } else { let mut result = DimensionLinear::new(v3(fixed), v3(point)); result.rotation = rotation; - result.definition_point = v3(first_line); - result.base.definition_point = v3(first_line); + result.definition_point = v3(second_line); + result.base.definition_point = v3(second_line); result.base.text_middle_point = v3((first_line + second_line) * 0.5); result.base.insertion_point = result.base.text_middle_point; result.base.actual_measurement = result.measurement(); @@ -508,12 +520,25 @@ fn line_intersection(a: DVec3, b: DVec3, c: DVec3, d: DVec3) -> Option { Some(a + b * ((delta.x * d.y - delta.y * d.x) / cross)) } -fn angular_bisector(first: DVec3, second: DVec3) -> DVec3 { - let first = normalized_or(first, DVec3::X); - normalized_or( - first + normalized_or(second, DVec3::Y), - DVec3::new(-first.y, first.x, 0.0), - ) +fn angular_definition_and_text( + vertex: DVec3, + first: DVec3, + second: DVec3, + radius: f64, +) -> (DVec3, DVec3) { + let start = first.y.atan2(first.x); + let mut sweep = second.y.atan2(second.x) - start; + while sweep <= -std::f64::consts::PI { + sweep += std::f64::consts::TAU; + } + while sweep > std::f64::consts::PI { + sweep -= std::f64::consts::TAU; + } + let point_at = |fraction: f64| { + let angle = start + sweep * fraction; + vertex + DVec3::new(angle.cos(), angle.sin(), 0.0) * radius + }; + (point_at(2.0 / 3.0), point_at(0.5)) } fn normalized_or(value: DVec3, fallback: DVec3) -> DVec3 { From d8e998b5e8528191b963865acbfdb58940c0787c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:22:12 +0300 Subject: [PATCH 04/16] Complete continued dimension workflow --- src/app/command_driver.rs | 12 +- src/app/commands/dim.rs | 16 +- src/app/properties.rs | 13 +- src/command.rs | 2 + src/modules/annotate/aligned_dim.rs | 1 + src/modules/annotate/angular_dim.rs | 1 + src/modules/annotate/arc_length_dim.rs | 1 + src/modules/annotate/diameter_dim.rs | 1 + src/modules/annotate/dim_continue.rs | 771 +++++++++++++++++----- src/modules/annotate/jogged_radius_dim.rs | 1 + src/modules/annotate/linear_dim.rs | 1 + src/modules/annotate/ordinate_dim.rs | 1 + src/modules/annotate/radius_dim.rs | 1 + src/scene/mod.rs | 8 +- 14 files changed, 653 insertions(+), 177 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index d33b27233..3e7e4bafb 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1288,6 +1288,7 @@ impl OpenCADStudio { mut entity, association, preserve_base_style, + keep_active, } => { let label = self.history_label_from_active_cmd(i, "DIMENSION"); let association_mode = self.tabs[i] @@ -1414,9 +1415,16 @@ impl OpenCADStudio { }; self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); + if keep_active { + let prompt = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()); + if let Some(prompt) = prompt { + self.command_line.push_info(&prompt); + } + } else { + self.tabs[i].active_cmd = None; + self.restore_pre_cmd_tangent(); + } if let Some(pd) = pending { self.commit_undo_delta(i, pd); } diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 22ee0d96e..90da46a80 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -646,13 +646,15 @@ impl OpenCADStudio { "DIMCONTINUE" => { use crate::modules::annotate::dim_continue::DimContinueCommand; - let cmd = if let Some((p1, p2, dp, rot, trot)) = - find_last_linear_dim(&self.tabs[i].scene) - { - DimContinueCommand::from_base(p1, p2, dp, rot, trot) - } else { - DimContinueCommand::new() - }; + let scene = &self.tabs[i].scene; + let recent = scene + .last_created_dimension + .and_then(|handle| scene.document.get_entity(handle)) + .cloned(); + let cmd = DimContinueCommand::new( + recent, + self.dimension_continue_mode == 1, + ); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/app/properties.rs b/src/app/properties.rs index 6129dd8b8..302c561ce 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2470,7 +2470,16 @@ impl OpenCADStudio { preserve_dimension_layer_and_style: bool, ) -> Option { let i = self.active_tab; - let is_dimension = matches!(&entity, acadrust::EntityType::Dimension(_)); + let tracks_continuation = matches!( + &entity, + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + | acadrust::entities::Dimension::Angular2Ln(_) + | acadrust::entities::Dimension::Angular3Pt(_) + | acadrust::entities::Dimension::Ordinate(_) + ) + ); let inherited_dimension = if preserve_dimension_layer_and_style { match &entity { acadrust::EntityType::Dimension(dimension) => Some(( @@ -2748,7 +2757,7 @@ impl OpenCADStudio { self.tabs[i].last_draw_anchor = Some(handle); } } - if is_dimension { + if tracks_continuation { self.tabs[i].scene.last_created_dimension = new_handle; } new_handle diff --git a/src/command.rs b/src/command.rs index e8bf675e8..968c9d29e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1251,6 +1251,8 @@ pub enum CmdResult { /// Retain the source dimension's layer and style instead of replacing /// them with the current creation defaults (DIMCONTINUEMODE=1). preserve_base_style: bool, + /// Keep the interactive command active after this dimension is committed. + keep_active: bool, }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index dafc2183b..5765e4759 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -136,6 +136,7 @@ impl CadCommand for AlignedDimensionCommand { entity: self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim))), association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, + keep_active: false, } } } diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index f1c00059f..9e7547b96 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -235,6 +235,7 @@ impl AngularDimensionCommand { entity, association: DimensionAssociationInput::Explicit(self.source_refs.clone()), preserve_base_style: false, + keep_active: false, } } diff --git a/src/modules/annotate/arc_length_dim.rs b/src/modules/annotate/arc_length_dim.rs index 434eabbe6..4ae6ea24d 100644 --- a/src/modules/annotate/arc_length_dim.rs +++ b/src/modules/annotate/arc_length_dim.rs @@ -266,6 +266,7 @@ impl ArcLengthDimensionCommand { entity: plane.place_entity(EntityType::Dimension(Dimension::Arc(dimension))), association, preserve_base_style: false, + keep_active: false, } } } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 946cde255..36286d340 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -114,6 +114,7 @@ impl CadCommand for DiameterDimensionCommand { .place_entity(EntityType::Dimension(Dimension::Diameter(dim))), association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, + keep_active: false, } } } diff --git a/src/modules/annotate/dim_continue.rs b/src/modules/annotate/dim_continue.rs index b7eb336b7..4d4f99b7b 100644 --- a/src/modules/annotate/dim_continue.rs +++ b/src/modules/annotate/dim_continue.rs @@ -1,20 +1,16 @@ -// DIMCONTINUE command — chain linear/aligned dimensions end-to-end. -// -// Each new point becomes the second extension line origin of a new dimension, -// whose first extension line origin is the second extension line of the previous dim. -// The dimension line stays at the same perpendicular offset as the base dimension. -// -// Constructed from commands.rs after finding the last placed linear/aligned dimension. +//! Repeating continued dimensions from the latest session dimension or a picked base. -use acadrust::entities::{Dimension, DimensionLinear}; +use acadrust::entities::{ + Dimension, DimensionAngular2Ln, DimensionAngular3Pt, DimensionBase, DimensionLinear, + DimensionOrdinate, +}; use acadrust::types::Vector3; -use acadrust::EntityType; -use glam::{DVec3, Vec3}; +use acadrust::{EntityType, Handle}; +use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, DimensionAssociationInput}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_continue.svg")); @@ -27,74 +23,262 @@ pub fn tool() -> ToolDef { } } -pub struct DimContinueCommand { - /// Fixed first-extension-line origin for the current step (moves each iteration). - chain_p1: DVec3, - /// Direction along the dimension axis (0.0 = horizontal, PI/2 = vertical). - rotation: f64, - /// Text reading rotation inherited from the base dim so a UCS-aligned chain - /// keeps its text consistent with the originating DIMLINEAR. 0 = natural. +#[derive(Clone)] +struct SourceStyle { + layer: String, + style_name: String, + normal: Vector3, text_rotation: f64, - /// Absolute perpendicular coordinate (dot with `perp`) of the base - /// dimension line. Each continued dim line is projected onto this exact - /// coordinate so the whole chain stays collinear — even when the extension - /// origins sit at different perpendicular positions (extension lines of - /// different lengths). (#151) - dim_line_perp: f64, - /// Direction of "up" perpendicular to the dim axis (points toward the dim line). - perp: DVec3, - /// True once we have a base dimension loaded. - ready: bool, + horizontal_direction: f64, } -impl DimContinueCommand { - /// No base dim found — will show an error prompt and cancel immediately. - pub fn new() -> Self { +impl SourceStyle { + fn from_base(base: &DimensionBase) -> Self { Self { - chain_p1: DVec3::ZERO, - rotation: 0.0, - text_rotation: 0.0, - dim_line_perp: 0.0, - perp: DVec3::Y, - ready: false, + layer: base.common.layer.clone(), + style_name: base.style_name.clone(), + normal: base.normal, + text_rotation: base.text_rotation, + horizontal_direction: base.horizontal_direction, } } - /// Build from the last placed dimension. - /// - /// `p2` — second extension line origin of the base dim (the chain starts - /// here). `p1` is unused — the dim line position comes from - /// `definition_point` alone — but kept for signature parity with the - /// shared `find_last_linear_dim` tuple (DIMBASELINE uses p1). - /// `definition_point` — where the dim line was placed; its perpendicular - /// coordinate is the line the whole chain stays collinear with. - /// `rotation` — 0.0 = horizontal dim, PI/2 = vertical dim. - pub fn from_base( - _p1: Vec3, - p2: Vec3, - definition_point: Vec3, + fn apply(&self, base: &mut DimensionBase) { + base.common.layer = self.layer.clone(); + base.style_name = self.style_name.clone(); + base.normal = self.normal; + base.text_rotation = self.text_rotation; + base.horizontal_direction = self.horizontal_direction; + } +} + +#[derive(Clone)] +enum ContinueKind { + Linear { + current: DVec3, rotation: f64, - text_rotation: f64, - ) -> Self { - // Widen the base-dim points to f64 so the committed chain math runs in - // full precision. (The base points arrive as f32 from the entity, but - // every derived committed coordinate must stay f64 from here on.) - let p2 = p2.as_dvec3(); - let definition_point = definition_point.as_dvec3(); - // Axis unit vector along the measurement direction — the base dim's - // rotation angle (any angle, incl. a UCS-aligned one), not a world H/V. - let axis = DVec3::new(rotation.cos(), rotation.sin(), 0.0); - // Perpendicular unit vector toward the dim line. - let perp = DVec3::new(-axis.y, axis.x, 0.0); - let dim_line_perp = definition_point.dot(perp); - Self { - chain_p1: p2, - rotation, - text_rotation, - dim_line_perp, - perp, - ready: true, + perpendicular: DVec3, + line_coordinate: f64, + }, + Angular2Ln { + source: DimensionAngular2Ln, + vertex: DVec3, + current: DVec3, + radius: f64, + }, + Angular3Pt { + source: DimensionAngular3Pt, + vertex: DVec3, + current: DVec3, + radius: f64, + }, + Ordinate { + source: DimensionOrdinate, + }, +} + +#[derive(Clone)] +struct ContinueState { + kind: ContinueKind, + style: SourceStyle, +} + +pub struct DimContinueCommand { + base: Option, + injected: Option, + history: Vec, + preserve_base_style: bool, +} + +impl DimContinueCommand { + pub fn new(recent: Option, preserve_base_style: bool) -> Self { + let mut command = Self { + base: None, + injected: None, + history: Vec::new(), + preserve_base_style, + }; + if let Some(entity) = recent { + let _ = command.install_base(&entity, None); } + command + } + + fn install_base(&mut self, entity: &EntityType, pick: Option) -> bool { + let EntityType::Dimension(dimension) = entity else { + return false; + }; + let style = SourceStyle::from_base(dimension.base()); + let kind = match dimension { + Dimension::Linear(source) => { + let rotation = source.rotation; + let perpendicular = DVec3::new(-rotation.sin(), rotation.cos(), 0.0); + let current = selected_linear_end( + dv(source.first_point), + dv(source.second_point), + pick, + ); + ContinueKind::Linear { + current, + rotation, + perpendicular, + line_coordinate: dv(source.base.definition_point).dot(perpendicular), + } + } + Dimension::Aligned(source) => { + let first = dv(source.first_point); + let second = dv(source.second_point); + let delta = second - first; + if delta.length_squared() <= 1.0e-18 { + return false; + } + let rotation = delta.y.atan2(delta.x); + let perpendicular = DVec3::new(-rotation.sin(), rotation.cos(), 0.0); + ContinueKind::Linear { + current: selected_linear_end(first, second, pick), + rotation, + perpendicular, + line_coordinate: dv(source.base.definition_point).dot(perpendicular), + } + } + Dimension::Angular2Ln(source) => { + let vertex = angular2_vertex(source); + let first = dv(source.second_point); + let second = dv(source.definition_point); + ContinueKind::Angular2Ln { + source: source.clone(), + vertex, + current: selected_angular_end(first, second, pick), + radius: (dv(source.dimension_arc) - vertex).length().max(1.0e-9), + } + } + Dimension::Angular3Pt(source) => { + let vertex = dv(source.angle_vertex); + ContinueKind::Angular3Pt { + source: source.clone(), + vertex, + current: selected_angular_end( + dv(source.first_point), + dv(source.second_point), + pick, + ), + radius: (dv(source.definition_point) - vertex).length().max(1.0e-9), + } + } + Dimension::Ordinate(source) => ContinueKind::Ordinate { + source: source.clone(), + }, + _ => return false, + }; + self.base = Some(ContinueState { kind, style }); + true + } + + fn build_dimension(&self, point: DVec3) -> Option { + let state = self.base.as_ref()?; + let mut dimension = match &state.kind { + ContinueKind::Linear { + current, + rotation, + perpendicular, + line_coordinate, + } => build_linear( + *current, + point, + *rotation, + *perpendicular, + *line_coordinate, + ), + ContinueKind::Angular2Ln { + source, + vertex, + current, + radius, + } => { + let moving = point - *vertex; + let previous = *current - *vertex; + if moving.length_squared() <= 1.0e-18 || previous.length_squared() <= 1.0e-18 { + return None; + } + let mut result = source.clone(); + result.first_point = v3(*vertex); + result.second_point = v3(point); + result.angle_vertex = v3(*vertex); + result.definition_point = v3(*current); + let (definition, text) = + angular_definition_and_text(*vertex, previous, moving, *radius); + result.dimension_arc = v3(definition); + result.base.definition_point = result.dimension_arc; + result.base.text_middle_point = v3(text); + result.base.insertion_point = result.base.text_middle_point; + result.base.actual_measurement = result.measurement_degrees(); + Dimension::Angular2Ln(result) + } + ContinueKind::Angular3Pt { + source, + vertex, + current, + radius, + } => { + let moving = point - *vertex; + let previous = *current - *vertex; + if moving.length_squared() <= 1.0e-18 || previous.length_squared() <= 1.0e-18 { + return None; + } + let mut result = source.clone(); + result.angle_vertex = v3(*vertex); + result.first_point = v3(point); + result.second_point = v3(*current); + let (definition, text) = + angular_definition_and_text(*vertex, previous, moving, *radius); + result.definition_point = v3(definition); + result.base.definition_point = result.definition_point; + result.base.text_middle_point = v3(text); + result.base.insertion_point = result.base.text_middle_point; + result.base.actual_measurement = result.measurement_degrees(); + Dimension::Angular3Pt(result) + } + ContinueKind::Ordinate { source } => { + let source_leader = dv(source.leader_endpoint); + let leader = if source.is_ordinate_type_x { + DVec3::new(point.x, source_leader.y, source_leader.z) + } else { + DVec3::new(source_leader.x, point.y, source_leader.z) + }; + let mut result = + DimensionOrdinate::new(v3(point), v3(leader), source.is_ordinate_type_x); + result.definition_point = source.definition_point; + result.base.definition_point = source.base.definition_point; + result.base.text_middle_point = v3(leader); + result.base.insertion_point = result.base.text_middle_point; + result.base.actual_measurement = result.measurement(); + Dimension::Ordinate(result) + } + }; + state.style.apply(dimension.base_mut()); + dimension.base_mut().common.handle = Handle::NULL; + dimension.base_mut().block_name.clear(); + Some(dimension) + } + + fn advance(&mut self, point: DVec3) { + let Some(state) = self.base.as_mut() else { + return; + }; + match &mut state.kind { + ContinueKind::Linear { current, .. } + | ContinueKind::Angular2Ln { current, .. } + | ContinueKind::Angular3Pt { current, .. } => *current = point, + ContinueKind::Ordinate { .. } => {} + } + } + + fn undo_one(&mut self) -> CmdResult { + let Some(previous) = self.history.pop() else { + return CmdResult::NeedPoint; + }; + self.base = Some(previous); + CmdResult::UndoDocument } } @@ -104,110 +288,373 @@ impl CadCommand for DimContinueCommand { } fn prompt(&self) -> String { - if !self.ready { - t!("DIMCONTINUE No base dimension found. Place a dimension first.").into_owned() + if self.base.is_none() { + "DIMCONTINUE Select continued dimension:".to_string() + } else if self + .base + .as_ref() + .is_some_and(|base| matches!(&base.kind, ContinueKind::Ordinate { .. })) + { + "DIMCONTINUE Specify feature location [Undo/Select] :" + .to_string() } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if !self.ready { - return CmdResult::Cancel; + fn options(&self) -> Vec { + self.base.as_ref().map_or_else(Vec::new, |base| { + if matches!(&base.kind, ContinueKind::Ordinate { .. }) { + vec![CmdOption::new("Undo", "U"), CmdOption::new("Select", "S")] + } else { + vec![CmdOption::new("Select", "S"), CmdOption::new("Undo", "U")] + } + }) + } + + fn needs_entity_pick(&self) -> bool { + self.base.is_none() + } + + fn entity_pick_highlights_hover(&self) -> bool { + self.base.is_none() + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.injected = Some(entity); + } + + fn on_entity_pick(&mut self, _handle: Handle, point: DVec3) -> CmdResult { + let Some(entity) = self.injected.take() else { + return CmdResult::NeedPoint; + }; + let _ = self.install_base(&entity, Some(point)); + CmdResult::NeedPoint + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let Some(dimension) = self.build_dimension(point) else { + return CmdResult::NeedPoint; + }; + if let Some(base) = self.base.clone() { + self.history.push(base); } - let p1 = self.chain_p1; - let p2 = pt; - - // Build a new linear dimension. - let mut dim = DimensionLinear::new(v3(p1), v3(p2)); - dim.rotation = self.rotation; - if self.text_rotation.abs() > 1e-9 { - dim.base.text_rotation = self.text_rotation; + self.advance(point); + CmdResult::CommitDimension { + entity: EntityType::Dimension(dimension), + association: DimensionAssociationInput::Infer(None), + preserve_base_style: self.preserve_base_style, + keep_active: true, } - - // Dim line stays collinear with the base: project both extension - // origins onto the base dim line's absolute perpendicular coordinate. - // (A fixed offset from p1 drifts off the base line whenever p1 and p2 - // sit at different perpendicular positions.) (#151) - let d1 = p1 + self.perp * (self.dim_line_perp - p1.dot(self.perp)); - let d2 = p2 + self.perp * (self.dim_line_perp - p2.dot(self.perp)); - dim.definition_point = v3(d1); - dim.base.definition_point = v3(d1); - dim.base.text_middle_point = v3((d1 + d2) * 0.5); - dim.base.insertion_point = dim.base.text_middle_point; - dim.base.actual_measurement = dim.measurement(); - - // Advance chain: next dim's P1 = this dim's P2. - self.chain_p1 = p2; - - CmdResult::CommitEntity(EntityType::Dimension(Dimension::Linear(dim))) } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel - } - - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if !self.ready { - return None; + if self.base.take().is_some() { + CmdResult::NeedPoint + } else { + CmdResult::Cancel } - // Preview / rubber-band geometry feeds an f32 GPU buffer, so drop to f32 - // at this screen-only boundary. - let pt = pt.as_vec3(); - let p1 = self.chain_p1.as_vec3(); - let perp = self.perp.as_vec3(); - let dim_line_perp = self.dim_line_perp as f32; - let dim_line_pt = p1 + perp * (dim_line_perp - p1.dot(perp)); - let dim_line_pt2 = pt + perp * (dim_line_perp - pt.dot(perp)); - Some(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: "dimcont_preview".into(), - points: vec![ - [p1.x, p1.y, p1.z], - [dim_line_pt.x, dim_line_pt.y, dim_line_pt.z], - [f32::NAN, 0.0, 0.0], - [pt.x, pt.y, pt.z], - [dim_line_pt2.x, dim_line_pt2.y, dim_line_pt2.z], - [f32::NAN, 0.0, 0.0], - [dim_line_pt.x, dim_line_pt.y, dim_line_pt.z], - [dim_line_pt2.x, dim_line_pt2.y, dim_line_pt2.z], - ], - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) + } + + fn wants_text_input(&self) -> bool { + self.base.is_some() + } + + fn point_step_accepts_keywords(&self) -> bool { + self.base.is_some() + } + + fn on_text_input(&mut self, text: &str) -> Option { + match text.trim().to_ascii_uppercase().as_str() { + "S" | "SELECT" => { + self.base = None; + Some(CmdResult::NeedPoint) + } + "U" | "UNDO" => Some(self.undo_one()), + _ => None, + } + } + + fn on_undo_step(&mut self) -> Option { + if self.history.is_empty() { + None + } else { + Some(self.undo_one()) + } + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + self.build_dimension(point) + .map(|dimension| preview_for_dimension(&dimension)) } } -fn v3(p: DVec3) -> Vector3 { - Vector3::new(p.x, p.y, p.z) +fn selected_linear_end(first: DVec3, second: DVec3, pick: Option) -> DVec3 { + pick.map_or(second, |point| { + if point.distance_squared(first) <= point.distance_squared(second) { + first + } else { + second + } + }) } +fn selected_angular_end(first: DVec3, second: DVec3, pick: Option) -> DVec3 { + pick.map_or(first, |point| { + if point.distance_squared(first) <= point.distance_squared(second) { + first + } else { + second + } + }) +} -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMCONTINUE"] }); // DimContinueCommand +fn build_linear( + first: DVec3, + second: DVec3, + rotation: f64, + perpendicular: DVec3, + line_coordinate: f64, +) -> Dimension { + let first_line = first + perpendicular * (line_coordinate - first.dot(perpendicular)); + let second_line = second + perpendicular * (line_coordinate - second.dot(perpendicular)); + let mut result = DimensionLinear::new(v3(first), v3(second)); + result.rotation = rotation; + result.definition_point = v3(second_line); + result.base.definition_point = v3(second_line); + result.base.text_middle_point = v3((first_line + second_line) * 0.5); + result.base.insertion_point = result.base.text_middle_point; + result.base.actual_measurement = result.measurement(); + Dimension::Linear(result) +} + +fn angular2_vertex(source: &DimensionAngular2Ln) -> DVec3 { + let first = dv(source.first_point); + let first_direction = dv(source.second_point) - first; + let second = dv(source.angle_vertex); + let second_direction = dv(source.definition_point) - second; + line_intersection(first, first_direction, second, second_direction).unwrap_or(second) +} + +fn line_intersection(a: DVec3, b: DVec3, c: DVec3, d: DVec3) -> Option { + let cross = b.x * d.y - b.y * d.x; + if cross.abs() <= 1.0e-12 { + return None; + } + let delta = c - a; + Some(a + b * ((delta.x * d.y - delta.y * d.x) / cross)) +} + +fn angular_definition_and_text( + vertex: DVec3, + previous: DVec3, + moving: DVec3, + radius: f64, +) -> (DVec3, DVec3) { + let start = previous.y.atan2(previous.x); + let mut sweep = moving.y.atan2(moving.x) - start; + while sweep <= -std::f64::consts::PI { + sweep += std::f64::consts::TAU; + } + while sweep > std::f64::consts::PI { + sweep -= std::f64::consts::TAU; + } + let point_at = |fraction: f64| { + let angle = start + sweep * fraction; + vertex + DVec3::new(angle.cos(), angle.sin(), 0.0) * radius + }; + (point_at(2.0 / 3.0), point_at(0.5)) +} + +fn preview_for_dimension(dimension: &Dimension) -> WireModel { + let mut points = Vec::<[f64; 3]>::new(); + match dimension { + Dimension::Linear(dimension) => linear_preview( + &mut points, + dv(dimension.first_point), + dv(dimension.second_point), + dimension.rotation, + dv(dimension.definition_point), + dv(dimension.base.text_middle_point), + dimension.base.actual_measurement, + ), + Dimension::Angular2Ln(dimension) => { + let vertex = angular2_vertex(dimension); + angular_preview( + &mut points, + vertex, + dv(dimension.second_point) - vertex, + dv(dimension.definition_point) - vertex, + dv(dimension.dimension_arc), + dv(dimension.base.text_middle_point), + dimension.base.actual_measurement, + ); + } + Dimension::Angular3Pt(dimension) => angular_preview( + &mut points, + dv(dimension.angle_vertex), + dv(dimension.first_point) - dv(dimension.angle_vertex), + dv(dimension.second_point) - dv(dimension.angle_vertex), + dv(dimension.definition_point), + dv(dimension.base.text_middle_point), + dimension.base.actual_measurement, + ), + Dimension::Ordinate(dimension) => ordinate_preview( + &mut points, + dv(dimension.feature_location), + dv(dimension.leader_endpoint), + dimension.base.actual_measurement, + ), + _ => {} + } + WireModel::solid_f64( + "dimcontinue_preview".to_string(), + points, + WireModel::CYAN, + false, + ) +} + +fn linear_preview( + points: &mut Vec<[f64; 3]>, + first: DVec3, + second: DVec3, + rotation: f64, + definition: DVec3, + text: DVec3, + measurement: f64, +) { + let axis = DVec3::new(rotation.cos(), rotation.sin(), 0.0); + let perpendicular = DVec3::new(-axis.y, axis.x, 0.0); + let coordinate = definition.dot(perpendicular); + let first_line = first + perpendicular * (coordinate - first.dot(perpendicular)); + let second_line = second + perpendicular * (coordinate - second.dot(perpendicular)); + segment(points, first, first_line); + segment(points, second, second_line); + segment(points, first_line, second_line); + arrow(points, first_line, axis, perpendicular, first_line.distance(second_line)); + arrow(points, second_line, -axis, perpendicular, first_line.distance(second_line)); + text_box(points, text, axis, perpendicular, measurement); +} + +fn angular_preview( + points: &mut Vec<[f64; 3]>, + vertex: DVec3, + first: DVec3, + second: DVec3, + arc_point: DVec3, + text: DVec3, + measurement: f64, +) { + let radius = (arc_point - vertex).length().max(1.0e-9); + let start = first.y.atan2(first.x); + let mut sweep = second.y.atan2(second.x) - start; + while sweep <= -std::f64::consts::PI { + sweep += std::f64::consts::TAU; + } + while sweep > std::f64::consts::PI { + sweep -= std::f64::consts::TAU; + } + let first_end = vertex + normalized_or(first, DVec3::X) * radius; + let second_end = vertex + normalized_or(second, DVec3::Y) * radius; + segment(points, vertex, first_end); + segment(points, vertex, second_end); + let arc = cadkernel::geom2d::tessellate::arc( + [vertex.x, vertex.y], + radius, + start, + start + sweep, + vertex.z, + cadkernel::geom2d::tessellate::DEFAULT_SEGMENTS_PER_RADIAN, + ); + points.extend(arc); + points.push([f64::NAN, f64::NAN, f64::NAN]); + let sign = sweep.signum(); + let start_tangent = DVec3::new(-start.sin(), start.cos(), 0.0) * sign; + let end_angle = start + sweep; + let end_tangent = DVec3::new(end_angle.sin(), -end_angle.cos(), 0.0) * sign; + arrow(points, first_end, start_tangent, normalized_or(first, DVec3::X), radius); + arrow(points, second_end, end_tangent, normalized_or(second, DVec3::Y), radius); + let middle = start + sweep * 0.5; + let text_axis = normalized_or(DVec3::new(-middle.sin(), middle.cos(), 0.0), DVec3::X); + let text_perpendicular = DVec3::new(-text_axis.y, text_axis.x, 0.0); + text_box(points, text, text_axis, text_perpendicular, measurement); +} + +fn ordinate_preview( + points: &mut Vec<[f64; 3]>, + feature: DVec3, + leader: DVec3, + measurement: f64, +) { + segment(points, feature, leader); + let axis = normalized_or(leader - feature, DVec3::Y); + let perpendicular = DVec3::new(-axis.y, axis.x, 0.0); + arrow(points, feature, axis, perpendicular, feature.distance(leader)); + text_box(points, leader, perpendicular, axis, measurement); +} + +fn arrow( + points: &mut Vec<[f64; 3]>, + tip: DVec3, + inward: DVec3, + perpendicular: DVec3, + span: f64, +) { + let inward = normalized_or(inward, DVec3::X); + let perpendicular = normalized_or(perpendicular, DVec3::Y); + let size = span.clamp(1.0, 100.0) * 0.035; + let back = tip + inward * size; + segment(points, tip, back + perpendicular * size * 0.4); + segment(points, tip, back - perpendicular * size * 0.4); +} + +fn text_box( + points: &mut Vec<[f64; 3]>, + center: DVec3, + axis: DVec3, + perpendicular: DVec3, + measurement: f64, +) { + let digits = if measurement.abs() < 1.0 { + 1.0 + } else { + measurement.abs().log10().floor() + 1.0 + }; + let half_width = (digits + 2.0) * 0.12; + let half_height = 0.16; + let a = center - axis * half_width - perpendicular * half_height; + let b = center + axis * half_width - perpendicular * half_height; + let c = center + axis * half_width + perpendicular * half_height; + let d = center - axis * half_width + perpendicular * half_height; + points.extend([a.to_array(), b.to_array(), c.to_array(), d.to_array(), a.to_array()]); + points.push([f64::NAN, f64::NAN, f64::NAN]); +} + +fn segment(points: &mut Vec<[f64; 3]>, first: DVec3, second: DVec3) { + points.push(first.to_array()); + points.push(second.to_array()); + points.push([f64::NAN, f64::NAN, f64::NAN]); +} + +fn normalized_or(value: DVec3, fallback: DVec3) -> DVec3 { + if value.length_squared() > 1.0e-18 { + value.normalize() + } else { + fallback + } +} + +fn dv(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + +fn v3(point: DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + +inventory::submit!(crate::command::CommandRegistration { names: &["DIMCONTINUE"] }); diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index 2363082b1..4ce9f8d5d 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -140,6 +140,7 @@ impl JoggedRadiusDimensionCommand { ))), association, preserve_base_style: false, + keep_active: false, } } } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index fd6d6d6c5..7036e0950 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -188,6 +188,7 @@ impl CadCommand for LinearDimensionCommand { entity, association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, + keep_active: false, } } } diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 221523c7f..9343249d9 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -117,6 +117,7 @@ impl CadCommand for OrdinateDimCommand { )), association: DimensionAssociationInput::Infer(None), preserve_base_style: false, + keep_active: false, } } } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 10681bad8..2fa565352 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -112,6 +112,7 @@ impl CadCommand for RadiusDimensionCommand { entity: source_plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))), association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, + keep_active: false, } } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 161d2a454..6262a24ea 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1420,10 +1420,10 @@ pub struct Scene { pub selection: Rc>, /// The CAD document — single source of truth for all entities. pub document: CadDocument, - /// Last dimension created during this application session. This is - /// intentionally not reconstructed from file handles: continuation and - /// baseline commands must not treat an old, merely high-handle dimension - /// loaded from disk as the user's most recently created dimension. + /// Last linear, aligned, angular, or ordinate dimension created during this + /// application session. This is intentionally not reconstructed from file + /// handles: continuation commands must not treat an old, merely high-handle + /// dimension loaded from disk as the user's most recently created dimension. pub last_created_dimension: Option, /// File-open-prepared index for non-graphical semantic object lookups. pub(crate) object_data_cache: crate::entities::object_data::ObjectDataCache, From a5d2ff741cd1d53e48cfef11b5db1bdcc8688185 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:19:23 +0300 Subject: [PATCH 05/16] Complete QDIM dimension workflows --- src/app/command_driver.rs | 126 ++++++++++ src/app/commands/dim.rs | 166 ++----------- src/app/mod.rs | 3 + src/app/settings.rs | 4 + src/app/update/file.rs | 2 + src/command.rs | 6 + src/modules/annotate/qdim.rs | 463 +++++++++++++++++++++++++++-------- 7 files changed, 524 insertions(+), 246 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 09eb09718..6a29d075c 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1400,6 +1400,132 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::CommitDimensionsAndExit(dimensions) => { + let association_mode = self.tabs[i] + .scene + .document + .header + .dimension_associativity; + let made = dimensions.len(); + let pending = if association_mode == 0 { + let mut pieces = Vec::new(); + for (mut entity, _) in dimensions { + let layer = self.tabs[i].active_layer.clone(); + if layer != "0" || entity.as_entity().layer().is_empty() { + entity.as_entity_mut().set_layer(layer); + } + crate::scene::view::dispatch::apply_color( + &mut entity, + self.ribbon.active_color, + ); + crate::scene::view::dispatch::apply_common_prop( + &mut entity, + "linetype", + &self.ribbon.active_linetype.clone(), + ); + crate::scene::view::dispatch::apply_line_weight( + &mut entity, + self.ribbon.active_lineweight, + ); + let celtscale = self.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale; + if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { + entity.common_mut().linetype_scale = celtscale; + } + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + pieces.extend( + crate::modules::draw::modify::explode::explode_entity( + &entity, + &self.tabs[i].scene.document, + ), + ); + } + let delta_safe = pieces + .iter() + .all(|piece| self.delta_add_safe(i, piece)); + let pending = self.begin_undo(i, "QDIM", pieces.len(), delta_safe); + for piece in pieces { + self.tabs[i].scene.add_entity(piece); + } + pending + } else { + let delta_safe = dimensions + .iter() + .all(|(entity, _)| self.delta_add_safe(i, entity)); + let pending = self.begin_undo(i, "QDIM", dimensions.len(), delta_safe); + for (entity, association) in dimensions { + let Some(handle) = self.commit_entity_handle(entity) else { + continue; + }; + if association_mode != 2 { + continue; + } + let sources = match association { + crate::command::DimensionAssociationInput::Infer(source) => { + source.map_or_else( + || { + self.tabs[i] + .scene + .infer_dimension_sources(handle) + .into_iter() + .map(|source| { + source.map( + crate::command::DimensionAssociationSource::inferred, + ) + }) + .collect() + }, + |source| { + vec![Some( + crate::command::DimensionAssociationSource::inferred( + source, + ), + )] + }, + ) + } + crate::command::DimensionAssociationInput::Explicit(sources) => { + sources + } + }; + self.tabs[i] + .scene + .attach_dimension_association_sources(handle, sources.clone()); + let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; + changes.extend(sources.into_iter().flatten().map(|source| { + (source.handle, crate::scene::ChangeKind::Modified) + })); + changes.sort_by_key(|(handle, _)| handle.value()); + changes.dedup_by_key(|(handle, _)| handle.value()); + self.tabs[i].scene.bump_entities(&changes); + } + pending + }; + self.tabs[i].dirty = made > 0; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line + .push_output(crate::tf!("QDIM {made} dimensions created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::SetQuickDimensionSnapPriority(priority) => { + self.quick_dimension_snap_priority = priority.min(1); + self.persist_settings_if_changed(); + let prompt = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()); + if let Some(prompt) = prompt { + self.command_line.push_info(&prompt); + } + } CmdResult::CommitSolid { entity, solid, diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 22ee0d96e..b80fb226a 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -682,102 +682,34 @@ impl OpenCADStudio { "QDIM" => { use crate::modules::annotate::qdim::QdimCommand; - let cmd = QdimCommand::new(); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } - - // QDIM second stage: the front-end relaunched with the picked - // dimension-line point and the gathered geometry now selected. Build - // a continuous chain of linear dimensions across the endpoints. - cmd if cmd.starts_with("QDIM_PLACE ") => { - let nums: Vec = cmd - .split_whitespace() - .skip(1) - .filter_map(|s| s.parse().ok()) - .collect(); - if nums.len() < 3 { - return Some(Task::none()); - } - let plane = if self.tabs[i].editing_model_space() { - self.tabs[i].ucs_xform().working_plane() - } else { - crate::command::WorkingPlane::default() - }; - let place = plane.to_local(glam::DVec3::new(nums[0], nums[1], nums[2])); - let handles: Vec = self.tabs[i] + let document = &self.tabs[i].scene.document; + let dim_spacing = document + .dim_styles + .iter() + .find(|style| { + style + .name + .eq_ignore_ascii_case(&document.header.current_dimstyle_name) + }) + .map(|style| style.dimdli) + .unwrap_or(1.5); + let selection = self.tabs[i] .scene .selected_entities() - .iter() - .map(|(h, _)| *h) + .into_iter() + .map(|(handle, entity)| crate::command::SelectionEntity { + handle, + entity: entity.clone(), + surface_area: None, + }) .collect(); - let mut pts: Vec = Vec::new(); - for h in &handles { - if let Some(e) = self.tabs[i].scene.document.get_entity(*h) { - qdim_collect_points(e, &mut pts); - } - } - for point in &mut pts { - *point = plane.to_local(*point); - } - if pts.len() < 2 { - self.command_line - .push_error(crate::t!("QDIM: no dimensionable endpoints in the selection.").as_ref()); - return Some(Task::none()); - } - // Choose the dimension axis from the points' spread: a wider X - // span dimensions horizontally (ordered by X), else vertically. - let (minx, maxx) = pts - .iter() - .fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.x), b.max(p.x))); - let (miny, maxy) = pts - .iter() - .fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y))); - let horizontal = (maxx - minx) >= (maxy - miny); - if horizontal { - pts.sort_by(|a, b| a.x.total_cmp(&b.x)); - pts.dedup_by(|a, b| (a.x - b.x).abs() < 1e-4); - } else { - pts.sort_by(|a, b| a.y.total_cmp(&b.y)); - pts.dedup_by(|a, b| (a.y - b.y).abs() < 1e-4); - } - if pts.len() < 2 { - self.command_line - .push_error(crate::t!("QDIM: endpoints collapse to a single position.").as_ref()); - return Some(Task::none()); - } - self.push_undo_snapshot(i, "QDIM"); - let v = |p: glam::DVec3| { - acadrust::types::Vector3::new(p.x, p.y, p.z) - }; - let mut made = 0usize; - for w in pts.windows(2) { - let (p1, p2) = (w[0], w[1]); - let mut dim = acadrust::entities::DimensionLinear::new(v(p1), v(p2)); - dim.rotation = if horizontal { - 0.0 - } else { - std::f64::consts::FRAC_PI_2 - }; - // Dim line passes through the picked perpendicular position. - let def = if horizontal { - glam::DVec3::new((p1.x + p2.x) * 0.5, place.y, 0.0) - } else { - glam::DVec3::new(place.x, (p1.y + p2.y) * 0.5, 0.0) - }; - dim.definition_point = v(def); - dim.base.definition_point = v(def); - dim.base.actual_measurement = dim.measurement(); - let entity = acadrust::EntityType::Dimension( - acadrust::entities::Dimension::Linear(dim), - ); - self.commit_entity(plane.place_entity(entity)); - made += 1; - } - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("QDIM {made} dimensions created.").as_ref()); - return Some(Task::none()); + let cmd = QdimCommand::new( + selection, + dim_spacing, + self.quick_dimension_snap_priority, + ); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); } "DIMEDIT" => { @@ -1442,54 +1374,6 @@ fn find_last_linear_dim( result } -/// Collect candidate dimension endpoints from an entity for QDIM — line ends, -/// polyline vertices, arc endpoints — in world space. -fn qdim_collect_points(e: &acadrust::EntityType, out: &mut Vec) { - use acadrust::EntityType as ET; - let p = |v: &acadrust::types::Vector3| glam::DVec3::new(v.x, v.y, v.z); - let ocs = |point: (f64, f64, f64), normal: acadrust::types::Vector3| { - let world = crate::scene::view::transform::ocs_point_to_wcs( - point, - (normal.x, normal.y, normal.z), - ); - glam::DVec3::new(world.0, world.1, world.2) - }; - match e { - ET::Line(l) => { - out.push(p(&l.start)); - out.push(p(&l.end)); - } - ET::LwPolyline(pl) => { - for v in &pl.vertices { - out.push(ocs((v.location.x, v.location.y, pl.elevation), pl.normal)); - } - } - ET::Polyline2D(pl) => { - for v in &pl.vertices { - out.push(ocs((v.location.x, v.location.y, pl.elevation), pl.normal)); - } - } - ET::Polyline(pl) => { - for v in &pl.vertices { - out.push(p(&v.location)); - } - } - ET::Arc(a) => { - for &ang in &[a.start_angle, a.end_angle] { - out.push(ocs( - ( - a.center.x + a.radius * ang.cos(), - a.center.y + a.radius * ang.sin(), - a.center.z, - ), - a.normal, - )); - } - } - _ => {} - } -} - // ── TCASE helpers ────────────────────────────────────────────────────────── fn sentence_case(s: &str) -> String { let mut c = s.chars(); diff --git a/src/app/mod.rs b/src/app/mod.rs index b18e53012..c82f4b512 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -465,6 +465,8 @@ pub(super) struct OpenCADStudio { options_tab: crate::ui::window::options::OptionsTab, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// QDIM extension-origin priority: 0 = endpoints, 1 = intersections. + pub quick_dimension_snap_priority: u8, /// When true (default), saving over an existing file first writes a `.bak` /// copy of it for recovery (#205). Toggle with the ISAVEBAK command. pub backup_on_save: bool, @@ -3210,6 +3212,7 @@ impl OpenCADStudio { dyn_input: true, options_tab: crate::ui::window::options::OptionsTab::General, texteditmode: false, + quick_dimension_snap_priority: 0, backup_on_save: true, file_assoc_enabled: true, savetime_min: 10, diff --git a/src/app/settings.rs b/src/app/settings.rs index 8b65c73e5..c99e0bcb6 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -198,6 +198,9 @@ pub struct UserSettings { pub osmode: i32, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// QDIM extension-origin priority: 0 = endpoints, 1 = intersections. + #[serde(default)] + pub quick_dimension_snap_priority: u8, /// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false). pub textfill: bool, /// When true, saving over an existing file first copies it to a sibling @@ -284,6 +287,7 @@ impl Default for UserSettings { // off (suppress bit 16384). osmode: 575 | OSMODE_SUPPRESS, texteditmode: false, + quick_dimension_snap_priority: 0, textfill: true, backup_on_save: true, file_assoc_enabled: true, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bbf906eae..d5514c23b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -382,6 +382,7 @@ impl OpenCADStudio { self.snapper.snap_enabled, ), texteditmode: self.texteditmode, + quick_dimension_snap_priority: self.quick_dimension_snap_priority, textfill: crate::scene::text::sdf_atlas::textfill(), backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, @@ -440,6 +441,7 @@ impl OpenCADStudio { self.snapper.enabled = modes.into_iter().collect(); self.snapper.snap_enabled = snap_enabled; self.texteditmode = s.texteditmode; + self.quick_dimension_snap_priority = s.quick_dimension_snap_priority.min(1); crate::scene::text::sdf_atlas::set_textfill(s.textfill); self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; diff --git a/src/command.rs b/src/command.rs index d4a2312b3..d824f7bb6 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1249,6 +1249,12 @@ pub enum CmdResult { entity: EntityType, association: DimensionAssociationInput, }, + /// Commit several dimensions with independent association sources in one + /// undo step, then end the command. + CommitDimensionsAndExit(Vec<(EntityType, DimensionAssociationInput)>), + /// Persist the quick-dimension extension-origin priority while keeping the + /// active command at its current prompt (0 = endpoints, 1 = intersections). + SetQuickDimensionSnapPriority(u8), /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded /// rendering). Ends the command. diff --git a/src/modules/annotate/qdim.rs b/src/modules/annotate/qdim.rs index 151cfd4af..0cf3e4857 100644 --- a/src/modules/annotate/qdim.rs +++ b/src/modules/annotate/qdim.rs @@ -1,17 +1,15 @@ -// QDIM command — quick dimension: select objects then pick placement line. -// -// Workflow: -// 1. Select objects (window/click, Enter to finish selection) -// 2. Pick a point that defines the dimension-line position -// -// Creates one linear dimension per detected endpoint pair on the selected objects. +//! Quick dimension creation from a selected set of drawing entities. -use acadrust::entities::{Dimension, DimensionLinear}; +use acadrust::entities::{Dimension, DimensionDiameter, DimensionLinear, DimensionOrdinate, DimensionRadius}; use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; +use cadkernel::geom2d::{characteristic_points, intersect, Curve, SnapKind, Tolerance}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, + EntityTransform, SelectionEntity, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -27,129 +25,384 @@ pub fn tool() -> ToolDef { } } -enum Step { - Gathering, - PlaceLine { handles: Vec }, -} +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { Gathering, Place, Datum, Edit, Settings } + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { Continuous, Staggered, Baseline, Ordinate, Radius, Diameter } + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SnapPriority { Endpoints, Intersections } + +#[derive(Clone)] +struct SourceCurve { handle: Handle, curve: Curve } + +#[derive(Clone)] +struct Candidate { point: DVec3, sources: Vec } pub struct QdimCommand { step: Step, + mode: Mode, + snap_priority: SnapPriority, + plane: WorkingPlane, + selection: Vec, + datum: Option, + excluded: Vec, + dim_spacing: f64, } impl QdimCommand { - pub fn new() -> Self { + pub fn new(selection: Vec, dim_spacing: f64, snap_priority: u8) -> Self { + let step = if selection.is_empty() { Step::Gathering } else { Step::Place }; Self { - step: Step::Gathering, + step, + mode: Mode::Continuous, + snap_priority: if snap_priority == 1 { SnapPriority::Intersections } else { SnapPriority::Endpoints }, + plane: WorkingPlane::default(), + selection, + datum: None, + excluded: Vec::new(), + dim_spacing: dim_spacing.abs().max(1e-9), } } + + fn local_sources(&self) -> Vec { + let transform = EntityTransform::Affine(self.plane.to_local_transform()); + self.selection.iter().filter_map(|selected| { + let mut entity = selected.entity.clone(); + crate::scene::view::dispatch::apply_transform(&mut entity, &transform); + crate::entities::curve::entity_curve_xy(&entity) + .map(|curve| SourceCurve { handle: selected.handle, curve }) + }).collect() + } + + fn candidates(&self) -> Vec { + let curves = self.local_sources(); + let mut candidates = if self.snap_priority == SnapPriority::Intersections { + let mut hits = Vec::new(); + for first in 0..curves.len() { + for second in first + 1..curves.len() { + for crossing in intersect(&curves[first].curve, &curves[second].curve, Tolerance::default()) { + hits.push(Candidate { + point: DVec3::new(crossing.point[0], crossing.point[1], 0.0), + // An intersection depends on two source curves. The + // current association record stores one source per + // extension origin, so do not bind it to an + // unrelated endpoint marker. + sources: Vec::new(), + }); + } + } + } + hits + } else { Vec::new() }; + + if candidates.is_empty() { + for source in &curves { + for point in characteristic_points(&source.curve).into_iter().filter(|point| point.kind == SnapKind::Endpoint) { + candidates.push(Candidate { + point: DVec3::new(point.point[0], point.point[1], 0.0), + sources: vec![DimensionAssociationSource::inferred(source.handle)], + }); + } + } + } + + for selected in &self.selection { + if let EntityType::Dimension(dimension) = &selected.entity { + for point in dimension_reference_points(dimension) { + candidates.push(Candidate { + point: self.plane.to_local(point), + sources: vec![DimensionAssociationSource::inferred(selected.handle)], + }); + } + } + } + + let tolerance = candidate_tolerance(candidates.iter().map(|candidate| candidate.point)); + let mut unique: Vec = Vec::new(); + for candidate in candidates { + if let Some(existing) = unique.iter_mut().find(|existing| existing.point.distance(candidate.point) <= tolerance) { + for source in candidate.sources { + if !existing.sources.iter().any(|current| current.handle == source.handle) { + existing.sources.push(source); + } + } + } else { + unique.push(candidate); + } + } + unique.retain(|candidate| !self.excluded.iter().any(|point| point.distance(candidate.point) <= tolerance)); + unique + } + + fn radial_sources(&self) -> Vec<(Handle, Curve)> { + self.local_sources().into_iter() + .filter(|source| matches!(source.curve, Curve::Circle(_) | Curve::Arc(_))) + .map(|source| (source.handle, source.curve)).collect() + } + + fn orientation(&self, points: &[Candidate], place: DVec3) -> bool { + let min_x = points.iter().map(|point| point.point.x).fold(f64::INFINITY, f64::min); + let max_x = points.iter().map(|point| point.point.x).fold(f64::NEG_INFINITY, f64::max); + let min_y = points.iter().map(|point| point.point.y).fold(f64::INFINITY, f64::min); + let max_y = points.iter().map(|point| point.point.y).fold(f64::NEG_INFINITY, f64::max); + let outside_y = (place.y - max_y).max(min_y - place.y).max(0.0); + let outside_x = (place.x - max_x).max(min_x - place.x).max(0.0); + if (outside_x - outside_y).abs() > 1e-9 { outside_y >= outside_x } else { max_x - min_x >= max_y - min_y } + } + + fn build_dimensions(&self, place_world: DVec3) -> Vec<(EntityType, DimensionAssociationInput)> { + let place = self.plane.to_local(place_world); + if matches!(self.mode, Mode::Radius | Mode::Diameter) { + return self.build_radial_dimensions(place); + } + let mut points = self.candidates(); + if points.is_empty() { return Vec::new(); } + let horizontal = self.orientation(&points, place); + points.sort_by(|first, second| if horizontal { first.point.x.total_cmp(&second.point.x) } else { first.point.y.total_cmp(&second.point.y) }); + let tolerance = candidate_tolerance(points.iter().map(|point| point.point)); + points.dedup_by(|first, second| { + let delta = if horizontal { first.point.x - second.point.x } else { first.point.y - second.point.y }; + delta.abs() <= tolerance + }); + + match self.mode { + Mode::Continuous | Mode::Staggered => points.windows(2).enumerate().map(|(index, pair)| { + let offset = if self.mode == Mode::Staggered { index as f64 * self.dim_spacing } else { 0.0 }; + let direction = if horizontal { + if place.y >= (pair[0].point.y + pair[1].point.y) * 0.5 { 1.0 } else { -1.0 } + } else if place.x >= (pair[0].point.x + pair[1].point.x) * 0.5 { 1.0 } else { -1.0 }; + let shifted = if horizontal { DVec3::new(place.x, place.y + direction * offset, 0.0) } else { DVec3::new(place.x + direction * offset, place.y, 0.0) }; + self.linear_dimension(&pair[0], &pair[1], shifted, horizontal) + }).collect(), + Mode::Baseline => { + let datum = self.datum.unwrap_or(points[0].point); + let datum_candidate = Candidate { + point: datum, + sources: points.iter().min_by(|first, second| first.point.distance(datum).total_cmp(&second.point.distance(datum))) + .map(|point| point.sources.clone()).unwrap_or_default(), + }; + points.iter().filter(|point| point.point.distance(datum) > tolerance) + .map(|point| self.linear_dimension(&datum_candidate, point, place, horizontal)).collect() + } + Mode::Ordinate => points.iter().map(|point| self.ordinate_dimension(point, place, horizontal)).collect(), + Mode::Radius | Mode::Diameter => Vec::new(), + } + } + + fn linear_dimension(&self, first: &Candidate, second: &Candidate, place: DVec3, horizontal: bool) -> (EntityType, DimensionAssociationInput) { + let mut dimension = DimensionLinear::new(v3(first.point), v3(second.point)); + dimension.rotation = if horizontal { 0.0 } else { std::f64::consts::FRAC_PI_2 }; + let definition = if horizontal { DVec3::new(second.point.x, place.y, 0.0) } else { DVec3::new(place.x, second.point.y, 0.0) }; + let text = if horizontal { DVec3::new((first.point.x + second.point.x) * 0.5, place.y, 0.0) } else { DVec3::new(place.x, (first.point.y + second.point.y) * 0.5, 0.0) }; + dimension.definition_point = v3(definition); + dimension.base.definition_point = v3(definition); + dimension.base.text_middle_point = v3(text); + dimension.base.insertion_point = v3(text); + dimension.base.actual_measurement = dimension.measurement(); + let association = DimensionAssociationInput::Explicit(vec![first.sources.first().copied(), second.sources.first().copied()]); + (self.plane.place_entity(EntityType::Dimension(Dimension::Linear(dimension))), association) + } + + fn ordinate_dimension(&self, point: &Candidate, place: DVec3, horizontal: bool) -> (EntityType, DimensionAssociationInput) { + let datum = self.datum.unwrap_or(DVec3::ZERO); + let leader = if horizontal { DVec3::new(point.point.x, place.y, 0.0) } else { DVec3::new(place.x, point.point.y, 0.0) }; + let mut dimension = DimensionOrdinate::new(v3(point.point), v3(leader), horizontal); + dimension.definition_point = v3(datum); + dimension.base.definition_point = v3(datum); + dimension.base.text_middle_point = v3(leader); + dimension.base.insertion_point = v3(leader); + dimension.refresh_measurement(); + (self.plane.place_entity(EntityType::Dimension(Dimension::Ordinate(dimension))), DimensionAssociationInput::Explicit(vec![point.sources.first().copied()])) + } + + fn build_radial_dimensions(&self, place: DVec3) -> Vec<(EntityType, DimensionAssociationInput)> { + self.radial_sources().into_iter().enumerate().map(|(index, (handle, curve))| { + let (center, radius) = match curve { Curve::Circle(circle) => (circle.centre, circle.radius), Curve::Arc(arc) => (arc.centre, arc.radius), _ => unreachable!() }; + let center = DVec3::new(center[0], center[1], 0.0); + let mut direction = place - center; + direction.z = 0.0; + direction = if direction.length_squared() <= f64::EPSILON { DVec3::X } else { direction.normalize() }; + let chord = center + direction * radius; + let text = place + DVec3::new(0.0, index as f64 * self.dim_spacing, 0.0); + let association = DimensionAssociationInput::Explicit(vec![Some(DimensionAssociationSource::inferred(handle))]); + let entity = if self.mode == Mode::Radius { + let mut dimension = DimensionRadius::new(v3(center), v3(chord)); + dimension.base.definition_point = v3(chord); + dimension.base.text_middle_point = v3(text); + dimension.base.insertion_point = v3(text); + dimension.base.text_user_positioned = true; + dimension.leader_length = chord.distance(text); + dimension.base.actual_measurement = dimension.measurement(); + EntityType::Dimension(Dimension::Radius(dimension)) + } else { + let opposite = center - direction * radius; + let mut dimension = DimensionDiameter::new(v3(chord), v3(opposite)); + dimension.base.definition_point = v3(opposite); + dimension.base.text_middle_point = v3(text); + dimension.base.insertion_point = v3(text); + dimension.base.text_user_positioned = true; + dimension.leader_length = chord.distance(text); + dimension.base.actual_measurement = dimension.measurement(); + EntityType::Dimension(Dimension::Diameter(dimension)) + }; + (self.plane.place_entity(entity), association) + }).collect() + } + + fn remove_candidate(&mut self, point_world: DVec3) { + let point = self.plane.to_local(point_world); + let all = self.candidates(); + let Some(nearest) = all.iter().min_by(|first, second| first.point.distance(point).total_cmp(&second.point.distance(point))) else { return; }; + self.excluded.push(nearest.point); + } + + fn preview_dimensions(&self, point: DVec3) -> Vec { + self.build_dimensions(point).into_iter().enumerate().flat_map(|(index, (entity, _))| preview_entity(index, &entity)).collect() + } } impl CadCommand for QdimCommand { - fn name(&self) -> &'static str { - "QDIM" - } + fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn name(&self) -> &'static str { "QDIM" } fn prompt(&self) -> String { - match &self.step { - Step::Gathering => { - t!("QDIM Select geometry to dimension (Enter when done):").into_owned() + match self.step { + Step::Gathering => t!("QDIM Select geometry to dimension (Enter when done):").into_owned(), + Step::Place => t!("QDIM Specify dimension line position or choose an option:").into_owned(), + Step::Datum => t!("QDIM Specify datum point:").into_owned(), + Step::Edit => t!("QDIM Select a point to remove (Enter when done):").into_owned(), + Step::Settings => t!("QDIM Choose extension origin priority [Endpoints/Intersections]:").into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::Place => vec![ + CmdOption::new("Continuous", "CONTINUOUS"), CmdOption::new("Staggered", "STAGGERED"), + CmdOption::new("Baseline", "BASELINE"), CmdOption::new("Ordinate", "ORDINATE"), + CmdOption::new("Radius", "RADIUS"), CmdOption::new("Diameter", "DIAMETER"), + CmdOption::new("Datum point", "DATUM"), CmdOption::new("Edit", "EDIT"), + CmdOption::new("Settings", "SETTINGS"), + ], + Step::Settings => vec![CmdOption::new("Endpoints", "ENDPOINTS"), CmdOption::new("Intersections", "INTERSECTIONS")], + _ => Vec::new(), + } + } + + fn is_selection_gathering(&self) -> bool { self.step == Step::Gathering } + fn selection_forces_add(&self) -> bool { self.step == Step::Gathering } + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { CmdResult::NeedPoint } + fn inject_selection_entities(&mut self, entities: Vec) { self.selection = entities; } + fn wants_text_input(&self) -> bool { matches!(self.step, Step::Place | Step::Settings) } + fn point_step_accepts_keywords(&self) -> bool { self.step == Step::Place } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().to_ascii_uppercase(); + if self.step == Step::Settings { + match keyword.as_str() { + "E" | "ENDPOINT" | "ENDPOINTS" => { self.snap_priority = SnapPriority::Endpoints; self.step = Step::Place; return Some(CmdResult::SetQuickDimensionSnapPriority(0)); } + "I" | "INTERSECTION" | "INTERSECTIONS" => { self.snap_priority = SnapPriority::Intersections; self.step = Step::Place; return Some(CmdResult::SetQuickDimensionSnapPriority(1)); } + _ => return Some(CmdResult::NeedPoint), } - Step::PlaceLine { .. } => t!("QDIM Specify dimension line position:").into_owned(), } - } - - fn is_selection_gathering(&self) -> bool { - matches!(self.step, Step::Gathering) - } - - fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { - if handles.is_empty() { - return CmdResult::Cancel; + match keyword.as_str() { + "C" | "CONTINUOUS" => self.mode = Mode::Continuous, + "S" | "STAGGERED" => self.mode = Mode::Staggered, + "B" | "BASELINE" => self.mode = Mode::Baseline, + "O" | "ORDINATE" => self.mode = Mode::Ordinate, + "R" | "RADIUS" => self.mode = Mode::Radius, + "DI" | "DIAMETER" => self.mode = Mode::Diameter, + "DA" | "DATUM" | "DATUMPOINT" => { self.step = Step::Datum; return Some(CmdResult::NeedPoint); } + "E" | "EDIT" => { self.step = Step::Edit; return Some(CmdResult::NeedPoint); } + "SE" | "SETTINGS" => { self.step = Step::Settings; return Some(CmdResult::NeedPoint); } + _ => return Some(CmdResult::NeedPoint), } - self.step = Step::PlaceLine { handles }; - CmdResult::NeedPoint + Some(CmdResult::NeedPoint) } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Step::PlaceLine { handles } = &self.step { - CmdResult::Relaunch( - // Keep the picked placement point in full f64 precision so the - // committed dimension coordinates are not quantized to f32. - format!("QDIM_PLACE {:.6} {:.6} {:.6}", pt.x, pt.y, pt.z), - handles.clone(), - ) - } else { - CmdResult::NeedPoint + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Gathering | Step::Settings => CmdResult::NeedPoint, + Step::Datum => { self.datum = Some(self.plane.to_local(point)); self.step = Step::Place; CmdResult::NeedPoint } + Step::Edit => { self.remove_candidate(point); CmdResult::NeedPoint } + Step::Place => { + let dimensions = self.build_dimensions(point); + if dimensions.is_empty() { CmdResult::NeedPoint } else { CmdResult::CommitDimensionsAndExit(dimensions) } + } } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel - } - - fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); - if !matches!(self.step, Step::PlaceLine { .. }) { - return None; + match self.step { + Step::Gathering if !self.selection.is_empty() => { self.step = Step::Place; CmdResult::NeedPoint } + Step::Edit | Step::Datum | Step::Settings => { self.step = Step::Place; CmdResult::NeedPoint } + _ => CmdResult::Cancel, } - let d = 0.5_f32; - Some(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: "qdim_preview".into(), - points: vec![[pt.x - d * 3.0, pt.y, pt.z], [pt.x + d * 3.0, pt.y, pt.z]], - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) + } + + fn on_preview_wires(&mut self, point: DVec3) -> Vec { + if self.step == Step::Place { return self.preview_dimensions(point); } + if self.step == Step::Edit { + let size = self.dim_spacing * 0.12; + return self.candidates().into_iter().enumerate().flat_map(|(index, candidate)| { + let point = self.plane.to_world(candidate.point); + [ + WireModel::solid_f64(format!("qdim_edit_{index}_x"), vec![ + [point.x - size, point.y, point.z], [point.x + size, point.y, point.z], + ], WireModel::CYAN, false), + WireModel::solid_f64(format!("qdim_edit_{index}_y"), vec![ + [point.x, point.y - size, point.z], [point.x, point.y + size, point.z], + ], WireModel::CYAN, false), + ] + }).collect(); + } + Vec::new() } } -/// Build a linear dimension between two points at the given dimension-line position. -#[allow(dead_code)] -pub fn make_linear_dim(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> EntityType { - let v = |p: DVec3| Vector3::new(p.x, p.y, p.z); - let mut dim = DimensionLinear::new(v(p1), v(p2)); - // Determine axis: horizontal if Δx > Δz, else vertical - let dx = (p2.x - p1.x).abs(); - let dz = (p2.z - p1.z).abs(); - dim.rotation = if dz > dx { - std::f64::consts::FRAC_PI_2 - } else { - 0.0 - }; - dim.definition_point = v(dim_pt); - dim.base.definition_point = v(dim_pt); - let text_pos = (p1 + p2) * 0.5; - dim.base.text_middle_point = v(text_pos); - dim.base.insertion_point = v(text_pos); - dim.base.actual_measurement = dim.measurement(); - EntityType::Dimension(Dimension::Linear(dim)) +fn v3(point: DVec3) -> Vector3 { Vector3::new(point.x, point.y, point.z) } +fn d3(point: Vector3) -> DVec3 { DVec3::new(point.x, point.y, point.z) } + +fn candidate_tolerance(points: impl Iterator) -> f64 { + let magnitude = points.map(|point| point.x.abs().max(point.y.abs()).max(point.z.abs())).fold(1.0_f64, f64::max); + Tolerance::default().linear().max(magnitude * f64::EPSILON * 64.0) } +fn dimension_reference_points(dimension: &Dimension) -> Vec { + match dimension { + Dimension::Linear(value) => vec![d3(value.first_point), d3(value.second_point)], + Dimension::Aligned(value) => vec![d3(value.first_point), d3(value.second_point)], + Dimension::Radius(value) => vec![d3(value.angle_vertex), d3(value.definition_point)], + Dimension::Diameter(value) => vec![d3(value.angle_vertex), d3(value.definition_point)], + Dimension::Ordinate(value) => vec![d3(value.feature_location)], + Dimension::Angular2Ln(value) => vec![d3(value.first_point), d3(value.second_point), d3(value.angle_vertex), d3(value.definition_point)], + Dimension::Angular3Pt(value) => vec![d3(value.angle_vertex), d3(value.first_point), d3(value.second_point)], + Dimension::Arc(value) => vec![d3(value.center_point), d3(value.first_extension_point), d3(value.second_extension_point)], + Dimension::LargeRadial(value) => vec![d3(value.definition_point), d3(value.chord_point)], + } +} -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["QDIM"] }); // QdimCommand +fn preview_entity(index: usize, entity: &EntityType) -> Vec { + let EntityType::Dimension(dimension) = entity else { return Vec::new(); }; + let mut lines: Vec> = Vec::new(); + match dimension { + Dimension::Linear(value) => { + let first = d3(value.first_point); let second = d3(value.second_point); let definition = d3(value.definition_point); + let horizontal = value.rotation.cos().abs() >= value.rotation.sin().abs(); + let first_line = if horizontal { DVec3::new(first.x, definition.y, definition.z) } else { DVec3::new(definition.x, first.y, definition.z) }; + let second_line = if horizontal { DVec3::new(second.x, definition.y, definition.z) } else { DVec3::new(definition.x, second.y, definition.z) }; + lines.push(vec![first.to_array(), first_line.to_array()]); + lines.push(vec![second.to_array(), second_line.to_array()]); + lines.push(vec![first_line.to_array(), second_line.to_array()]); + } + Dimension::Ordinate(value) => lines.push(vec![d3(value.feature_location).to_array(), d3(value.leader_endpoint).to_array()]), + Dimension::Radius(value) => lines.push(vec![d3(value.angle_vertex).to_array(), d3(value.definition_point).to_array(), d3(value.base.text_middle_point).to_array()]), + Dimension::Diameter(value) => lines.push(vec![d3(value.definition_point).to_array(), d3(value.angle_vertex).to_array(), d3(value.base.text_middle_point).to_array()]), + _ => {} + } + lines.into_iter().enumerate().map(|(part, points)| WireModel::solid_f64(format!("qdim_preview_{index}_{part}"), points, WireModel::CYAN, false)).collect() +} + +inventory::submit!(crate::command::CommandRegistration { names: &["QDIM"] }); From cce25be83afbfb2d8ee2898c093bb44c42f7d8ae Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:35:47 +0300 Subject: [PATCH 06/16] Complete multileader creation and property behavior --- src/app/command_driver.rs | 91 +++- src/app/commands/dim.rs | 24 +- src/app/mod.rs | 5 + src/app/properties.rs | 54 ++- src/app/update/command.rs | 52 ++- src/app/update/mod.rs | 93 ++++ src/entities/multileader.rs | 364 +++++++++++++--- src/modules/annotate/mleader_cmd.rs | 621 ++++++++++++++++++++++++--- src/modules/annotate/mleader_edit.rs | 78 +++- src/scene/annotative.rs | 29 +- src/scene/model/object.rs | 5 + src/scene/selection.rs | 8 +- src/ui/properties.rs | 41 +- 13 files changed, 1306 insertions(+), 159 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 09eb09718..0da5ad8c3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1860,10 +1860,10 @@ impl OpenCADStudio { return Task::none(); } if layer.starts_with("__MLEADERALIGN__") { + self.push_undo_snapshot(i, "MLEADERALIGN"); if let Some(encoded) = layer.strip_prefix("__MLEADERALIGN__") { apply_mleader_align(&mut self.tabs[i].scene, encoded); } - self.push_undo_snapshot(i, "MLEADERALIGN"); self.command_line .push_output(crate::t!("MLEADERALIGN Leaders aligned.").as_ref()); self.tabs[i].dirty = true; @@ -1872,10 +1872,10 @@ impl OpenCADStudio { return Task::none(); } if layer.starts_with("__MLEADERCOLLECT__") { + self.push_undo_snapshot(i, "MLEADERCOLLECT"); if let Some(encoded) = layer.strip_prefix("__MLEADERCOLLECT__") { apply_mleader_collect(&mut self.tabs[i].scene, encoded); } - self.push_undo_snapshot(i, "MLEADERCOLLECT"); self.command_line .push_output(crate::t!("MLEADERCOLLECT Leaders collected.").as_ref()); self.tabs[i].dirty = true; @@ -4460,12 +4460,12 @@ fn apply_mleader_align(scene: &mut crate::scene::Scene, encoded: &str) { } let fx = from_parts[0]; - let fz = from_parts[1]; + let fy = from_parts[1]; let tx = to_parts[0]; - let tz = to_parts[1]; + let ty = to_parts[1]; let dx = tx - fx; - let dz = tz - fz; - let len = (dx * dx + dz * dz).sqrt(); + let dy = ty - fy; + let len = (dx * dx + dy * dy).sqrt(); if len < 1e-9 { return; } @@ -4473,18 +4473,33 @@ fn apply_mleader_align(scene: &mut crate::scene::Scene, encoded: &str) { // Project each multileader's content point onto the alignment line, then // snap it to the line (preserve perpendicular offset from line is discarded; // align along direction through `from`). + let mut changed = Vec::new(); for h in handles { if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(h) { - let cp = &mut ml.context.content_base_point; - // Project onto line from_pt + t * dir: keep t component, set perpendicular = 0 - let rel_x = cp.x - fx; - let rel_z = cp.z - fz; - let t = (rel_x * (dx / len) + rel_z * (dz / len)) / len; - let t = t.clamp(0.0, 1.0); - cp.x = fx + t * dx; - cp.z = fz + t * dz; + let old = ml.context.content_base_point; + let rel_x = old.x - fx; + let rel_y = old.y - fy; + let t = (rel_x * dx + rel_y * dy) / (len * len); + let new_x = fx + t * dx; + let new_y = fy + t * dy; + let shift_x = new_x - old.x; + let shift_y = new_y - old.y; + ml.context.content_base_point.x = new_x; + ml.context.content_base_point.y = new_y; + ml.context.text_location.x += shift_x; + ml.context.text_location.y += shift_y; + ml.context.block_content_location.x += shift_x; + ml.context.block_content_location.y += shift_y; + for root in &mut ml.context.leader_roots { + root.connection_point.x += shift_x; + root.connection_point.y += shift_y; + } + changed.push((h, crate::scene::ChangeKind::Modified)); } } + if !changed.is_empty() { + scene.bump_entities(&changed); + } } // ── MLEADERCOLLECT helper ───────────────────────────────────────────────────── @@ -4505,27 +4520,65 @@ fn apply_mleader_collect(scene: &mut crate::scene::Scene, encoded: &str) { } let px = pos_parts[0]; - let pz = pos_parts[1]; + let py = pos_parts[1]; - // Collect all leader roots from the secondary multileaders. + let Some(acadrust::EntityType::MultiLeader(base)) = scene.document.get_entity(handles[0]) else { + return; + }; + if base.content_type != acadrust::entities::LeaderContentType::Block { + return; + } + let base_block = base.block_content_handle; + let base_style = base.style_handle; + + // Collect all leader roots from the secondary multileaders. Their arrow + // geometry remains fixed while the content-side connection follows the + // new collection point. let mut extra_roots: Vec = Vec::new(); + let mut merged_handles = Vec::new(); for &h in &handles[1..] { if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) { - extra_roots.extend(ml.context.leader_roots.iter().cloned()); + if ml.content_type == acadrust::entities::LeaderContentType::Block + && ml.block_content_handle == base_block + && ml.style_handle == base_style + { + let shift_x = px - ml.context.content_base_point.x; + let shift_y = py - ml.context.content_base_point.y; + extra_roots.extend(ml.context.leader_roots.iter().cloned().map(|mut root| { + root.connection_point.x += shift_x; + root.connection_point.y += shift_y; + root + })); + merged_handles.push(h); + } } } + if merged_handles.is_empty() { + return; + } // Add collected roots to the first multileader and move its content point. if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handles[0]) { + let shift_x = px - ml.context.content_base_point.x; + let shift_y = py - ml.context.content_base_point.y; + for root in &mut ml.context.leader_roots { + root.connection_point.x += shift_x; + root.connection_point.y += shift_y; + } ml.context.content_base_point.x = px; - ml.context.content_base_point.z = pz; + ml.context.content_base_point.y = py; + ml.context.block_content_location.x = px; + ml.context.block_content_location.y = py; + ml.context.text_location.x = px; + ml.context.text_location.y = py; for root in extra_roots { ml.context.leader_roots.push(root); } } // Erase the secondary multileaders. - scene.erase_entities(&handles[1..]); + scene.erase_entities(&merged_handles); + scene.bump_entities(&[(handles[0], crate::scene::ChangeKind::Modified)]); } /// MATCHPROP special properties (#281): copy every STYLE-affecting field from diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 22ee0d96e..ad25ac384 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -604,9 +604,31 @@ impl OpenCADStudio { _ => None, }); let multiplier = self.tabs[i].scene.creation_annotation_multiplier(); + let block_sources = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|block| !block.name.is_empty() && !block.name.starts_with('*')) + .map(|block| (block.name.clone(), block.handle)) + .collect(); + let layers = self.tabs[i] + .scene + .document + .layers + .iter() + .map(|layer| layer.name.clone()) + .collect(); + let text_styles = self.tabs[i] + .scene + .document + .text_styles + .iter() + .map(|style| (style.name.clone(), style.handle)) + .collect(); let new_cmd = style.map_or_else(MLeaderCommand::new, |style| { MLeaderCommand::with_style(style, multiplier) - }); + }).with_drawing_resources(block_sources, layers, text_styles); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/mod.rs b/src/app/mod.rs index b18e53012..2aefc8929 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2398,6 +2398,11 @@ pub enum Message { PropColorChanged(AcadColor), /// User selected a lineweight from the Properties pick_list. PropLwChanged(LineWeight), + /// User selected an object-specific lineweight override. + PropFieldLwChanged { + field: &'static str, + value: LineWeight, + }, /// User selected a linetype from the linetype pick_list. PropLinetypeChanged(String), /// User toggled a boolean property (e.g. Invisible). diff --git a/src/app/properties.rs b/src/app/properties.rs index 006877cc4..e27751321 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -845,6 +845,12 @@ impl OpenCADStudio { .map(|b| b.name.clone()), ) .collect(); + let block_names: Vec = doc + .block_records + .iter() + .map(|block| block.name.clone()) + .filter(|name| !name.is_empty() && !name.starts_with('*')) + .collect(); let tstyle_names = text_style_names.clone(); // Currently selected names. let cur_style = ml @@ -878,6 +884,15 @@ impl OpenCADStudio { doc.line_types.iter().find(|l| l.handle == h).map(|l| l.name.clone()) }) .unwrap_or_else(|| "ByBlock".to_string()); + let cur_block = ml + .block_content_handle + .and_then(|handle| { + doc.block_records + .iter() + .find(|block| block.handle == handle) + .map(|block| block.name.clone()) + }) + .unwrap_or_else(|| "(none)".to_string()); let mut set_choice = |field: &str, selected: String, options: Vec| { for section in sections.iter_mut() { @@ -896,6 +911,9 @@ impl OpenCADStudio { set_choice("text_style_handle", cur_tstyle, tstyle_names); set_choice("arrowhead_handle", cur_arrow, arrow_names); set_choice("line_type_handle", cur_ltype, ltype_names); + if !block_names.is_empty() { + set_choice("block_content_handle", cur_block, block_names); + } } // Inject viewport-only properties that require doc access. @@ -1404,6 +1422,19 @@ impl OpenCADStudio { // MultiLeader: max points + segment-angle constraints // are MLeaderStyle settings, not stored on the entity. acadrust::EntityType::MultiLeader(ml) => { + if ml + .text_style_handle + .and_then(|handle| { + doc.text_styles.iter().find(|style| style.handle == handle) + }) + .is_some_and(|style| style.has_fixed_height()) + { + set_row_value( + &mut sections, + "text_height", + PropValue::ReadOnly(format!("{:.4}", ml.text_height)), + ); + } if let Some(sh) = ml.style_handle { if let Some((mx, a1, a2)) = doc.objects.iter().find_map(|(h, o)| match o { @@ -2514,9 +2545,21 @@ impl OpenCADStudio { | acadrust::EntityType::Polyline2D(_) | acadrust::EntityType::Polyline3D(_) ); - let layer = &self.tabs[i].active_layer; - if layer != "0" || entity.as_entity().layer().is_empty() { - entity.as_entity_mut().set_layer(layer.clone()); + let explicit_mleader_layer = match &entity { + acadrust::EntityType::MultiLeader(leader) => leader + .common + .layer + .strip_prefix("__MLEADER_LAYER__") + .map(str::to_owned), + _ => None, + }; + if let Some(layer) = explicit_mleader_layer { + entity.as_entity_mut().set_layer(layer); + } else { + let layer = &self.tabs[i].active_layer; + if layer != "0" || entity.as_entity().layer().is_empty() { + entity.as_entity_mut().set_layer(layer.clone()); + } } // INSUNITS: when inserting a block whose BlockRecord.units differ @@ -2750,7 +2793,10 @@ fn make_sections_read_only( acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), }, PropValue::ColorVaries | PropValue::LwVaries => VARIES_LABEL.to_string(), - PropValue::LwChoice(lineweight) => { + PropValue::LwChoice(lineweight) + | PropValue::FieldLwChoice { + value: lineweight, .. + } => { ui::properties::LwItem(*lineweight).to_string() } PropValue::BoolToggle { value, .. } => { diff --git a/src/app/update/command.rs b/src/app/update/command.rs index fc21b8e7c..23275c91b 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2051,6 +2051,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { | "text_style_handle" | "arrowhead_handle" | "line_type_handle" + | "block_content_handle" ) { // Resolve a picked name back to the handle the MLEADER // stores. The style/text-style rows keep their existing @@ -2096,6 +2097,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .map(|l| l.handle) } } + "block_content_handle" => doc + .block_records + .iter() + .find(|block| block.name == value) + .map(|block| block.handle), _ => None, }; for &handle in &handles { @@ -2119,10 +2125,52 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { "text_style_handle" => { if let Some(h) = resolved { ml.text_style_handle = Some(h); + ml.context.text_style_handle = Some(h); + ml.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::TEXT_STYLE, + ); + } + } + "arrowhead_handle" => { + ml.arrowhead_handle = resolved; + for root in &mut ml.context.leader_roots { + for line in &mut root.lines { + line.arrowhead_handle = resolved; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::ARROWHEAD, + ); + } + } + ml.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::ARROWHEAD, + ); + } + "line_type_handle" => { + ml.line_type_handle = resolved; + for root in &mut ml.context.leader_roots { + for line in &mut root.lines { + line.line_type_handle = resolved; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::LINE_TYPE, + ); + } + } + ml.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::LEADER_LINE_TYPE, + ); + } + "block_content_handle" => { + if let Some(block_handle) = resolved { + ml.block_content_handle = Some(block_handle); + ml.context.block_content_handle = Some(block_handle); + ml.context.has_block_contents = true; + ml.context.block_content_location = + ml.context.content_base_point; + ml.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT, + ); } } - "arrowhead_handle" => ml.arrowhead_handle = resolved, - "line_type_handle" => ml.line_type_handle = resolved, _ => {} } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4c276d921..cabf95eaf 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4665,6 +4665,42 @@ impl OpenCADStudio { Task::none() } + Message::PropFieldLwChanged { field, value } => { + let i = self.active_tab; + let handles = self.property_target_handles(i); + if handles.is_empty() { + return Task::none(); + } + self.push_undo_snapshot(i, "CHPROP"); + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + if let Some(acadrust::EntityType::MultiLeader(leader)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + if field == "line_weight" { + leader.line_weight = value; + leader.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::LEADER_LINE_WEIGHT, + ); + for root in &mut leader.context.leader_roots { + for line in &mut root.lines { + line.line_weight = value; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::LINE_WEIGHT, + ); + } + } + } + } + } + self.invalidate_property_targets(i, &handles); + self.tabs[i].dirty = true; + self.refresh_properties(); + Task::none() + } + Message::PropLinetypeChanged(lt) => { let i = self.active_tab; let handles = self.property_target_handles(i); @@ -5138,6 +5174,63 @@ impl OpenCADStudio { } return Task::none(); } + if matches!( + field.as_str(), + "line_color" | "text_color" | "block_content_color" + | "background_fill_color" + ) { + if !handles.is_empty() { + self.push_undo_snapshot(i, "CHPROP"); + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + if let Some(acadrust::EntityType::MultiLeader(leader)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + match field.as_str() { + "line_color" => { + leader.line_color = color; + leader.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::LINE_COLOR, + ); + for root in &mut leader.context.leader_roots { + for line in &mut root.lines { + line.line_color = color; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::LINE_COLOR, + ); + } + } + } + "text_color" => { + leader.text_color = color; + leader.context.text_color = color; + leader.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::TEXT_COLOR, + ); + } + "block_content_color" => { + leader.block_content_color = color; + leader.context.block_content_color = color; + leader.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_COLOR, + ); + } + "background_fill_color" => { + leader.context.background_fill_color = color; + } + _ => {} + } + } + } + self.invalidate_property_targets(i, &handles); + self.tabs[i].properties.open_color_field = None; + self.tabs[i].dirty = true; + self.refresh_properties(); + } + return Task::none(); + } if matches!( field.as_str(), "tbl_cell_content_color" | "tbl_cell_background_color" diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 4084a7805..448cc67e0 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -1,6 +1,7 @@ use acadrust::entities::{ - FlowDirectionType, LeaderContentType, LineSpacingStyle, MultiLeader, MultiLeaderPathType, - TextAlignmentType, TextAttachmentDirectionType, TextAttachmentType, + BlockContentConnectionType, FlowDirectionType, LeaderContentType, LineSpacingStyle, + MultiLeader, MultiLeaderPathType, MultiLeaderPropertyOverrideFlags, TextAlignmentType, + TextAttachmentDirectionType, TextAttachmentType, }; use crate::entities::text_support::{ @@ -923,7 +924,10 @@ fn properties(ml: &MultiLeader) -> Vec { Property { label: t!("Leader lineweight").into_owned(), field: "line_weight", - value: PropValue::LwChoice(ml.line_weight), + value: PropValue::FieldLwChoice { + field: "line_weight", + value: ml.line_weight, + }, }, // Arrowhead block name resolved by the panel builder (default "Closed filled"). ro(t!("Arrowhead").as_ref(), "arrowhead_handle", "Closed filled"), @@ -935,7 +939,11 @@ fn properties(ml: &MultiLeader) -> Vec { ml.dogleg_length, ml.enable_dogleg, ), - bool_toggle(t!("Leader extension").as_ref(), "enable_landing", ml.enable_landing), + bool_toggle( + t!("Extend leader to text").as_ref(), + "extend_leader_to_text", + ml.extend_leader_to_text, + ), ], }; @@ -950,6 +958,11 @@ fn properties(ml: &MultiLeader) -> Vec { }, // Text-style name resolved from text_style_handle by the panel builder. ro(t!("Text style").as_ref(), "text_style_handle", "Standard"), + Property { + label: t!("Text color").into_owned(), + field: "text_color", + value: PropValue::ColorChoice(ml.text_color), + }, choice( t!("Justify").as_ref(), "text_alignment", @@ -982,12 +995,58 @@ fn properties(ml: &MultiLeader) -> Vec { "background_fill_enabled", ctx.background_fill_enabled, ), + if ctx.background_fill_enabled { + bool_toggle( + t!("Use drawing background color").as_ref(), + "background_mask_fill_on", + ctx.background_mask_fill_on, + ) + } else { + ro( + t!("Use drawing background color").as_ref(), + "background_mask_fill_on", + if ctx.background_mask_fill_on { "Yes" } else { "No" }, + ) + }, + Property { + label: t!("Background mask color").into_owned(), + field: "background_fill_color", + value: if ctx.background_fill_enabled && !ctx.background_mask_fill_on { + PropValue::ColorChoice(ctx.background_fill_color) + } else { + PropValue::ReadOnly(format!("{:?}", ctx.background_fill_color)) + }, + }, + num_row( + t!("Border offset factor").as_ref(), + "background_scale_factor", + ctx.background_scale_factor, + ctx.background_fill_enabled, + ), + num_row( + t!("Background transparency").as_ref(), + "background_transparency", + ctx.background_transparency as f64, + ctx.background_fill_enabled, + ), choice( t!("Attachment type").as_ref(), "text_attachment_direction", attach_dir_str(&ml.text_attachment_direction), &["Horizontal", "Vertical"], ), + edit( + t!("Landing gap").as_ref(), + "landing_gap", + ctx.landing_gap / ctx.scale_factor.max(1.0e-12), + ), + bool_toggle(t!("Text frame").as_ref(), "text_frame", ml.text_frame), + ], + }; + let mut text = text; + let attachment_insert = text.props.len().saturating_sub(2); + let attachment_rows = match ml.text_attachment_direction { + TextAttachmentDirectionType::Horizontal => vec![ choice( t!("Left Attachment").as_ref(), "text_left_attachment", @@ -1000,10 +1059,23 @@ fn properties(ml: &MultiLeader) -> Vec { attachment_str(&ml.text_right_attachment), &ATTACH_LABELS, ), - edit(t!("Landing gap").as_ref(), "landing_gap", ctx.landing_gap), - bool_toggle(t!("Text frame").as_ref(), "text_frame", ml.text_frame), + ], + TextAttachmentDirectionType::Vertical => vec![ + choice( + t!("Top Attachment").as_ref(), + "text_top_attachment", + attachment_str(&ml.text_top_attachment), + &ATTACH_LABELS, + ), + choice( + t!("Bottom Attachment").as_ref(), + "text_bottom_attachment", + attachment_str(&ml.text_bottom_attachment), + &ATTACH_LABELS, + ), ], }; + text.props.splice(attachment_insert..attachment_insert, attachment_rows); // ── Block ──────────────────────────────────────────────────────────── let block = PropSection { @@ -1014,24 +1086,21 @@ fn properties(ml: &MultiLeader) -> Vec { "block_content_handle", hexh(ml.block_content_handle), ), - ro( + choice( t!("Block connection").as_ref(), "block_connection_type", - format!("{:?}", ml.block_connection_type), - ), - ro( - t!("Block color").as_ref(), - "block_content_color", - format!("{:?}", ml.block_content_color), - ), - ro( - t!("Block scale").as_ref(), - "block_scale", - format!( - "{:.3} × {:.3} × {:.3}", - ml.block_scale.x, ml.block_scale.y, ml.block_scale.z - ), + match ml.block_connection_type { + BlockContentConnectionType::BasePoint => "Insertion point", + BlockContentConnectionType::BlockExtents => "Extents", + }, + &["Extents", "Insertion point"], ), + Property { + label: t!("Block color").into_owned(), + field: "block_content_color", + value: PropValue::ColorChoice(ml.block_content_color), + }, + edit(t!("Block scale").as_ref(), "block_scale", ml.block_scale.x), edit( t!("Block rotation").as_ref(), "block_rotation", @@ -1067,7 +1136,9 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "text_height" => { if let Some(v) = f64(value) { ml.text_height = v; - ml.context.text_height = v; + ml.context.text_height = v * ml.context.scale_factor.max(1.0e-12); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_HEIGHT); } } "text_x" => { @@ -1101,19 +1172,40 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "None" => MultiLeaderPathType::Invisible, _ => MultiLeaderPathType::StraightLineSegments, }; + for root in &mut ml.context.leader_roots { + for line in &mut root.lines { + line.path_type = ml.path_type; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::PATH_TYPE, + ); + } + } + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::PATH_TYPE); } "enable_landing" => { ml.enable_landing = if value == "toggle" { !ml.enable_landing } else { value == "true" - } + }; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::ENABLE_LANDING); } "enable_dogleg" => { ml.enable_dogleg = if value == "toggle" { !ml.enable_dogleg } else { value == "true" + }; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::ENABLE_DOGLEG); + } + "extend_leader_to_text" => { + ml.extend_leader_to_text = if value == "toggle" { + !ml.extend_leader_to_text + } else { + value == "true" } } "enable_annotation_scale" => { @@ -1130,6 +1222,25 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { value == "true" } } + "background_mask_fill_on" => { + ml.context.background_mask_fill_on = if value == "toggle" { + !ml.context.background_mask_fill_on + } else { + value == "true" + } + } + "background_scale_factor" => { + if let Some(v) = f64(value) { + if v > 0.0 { + ml.context.background_scale_factor = v; + } + } + } + "background_transparency" => { + if let Ok(v) = value.trim().parse::() { + ml.context.background_transparency = v.clamp(0, 90); + } + } "dogleg_length" => { if let Some(v) = f64(value) { ml.dogleg_length = v; @@ -1138,31 +1249,101 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "arrowhead_size" => { if let Some(v) = f64(value) { ml.arrowhead_size = v; + ml.context.arrowhead_size = v * ml.context.scale_factor.max(1.0e-12); + for root in &mut ml.context.leader_roots { + for line in &mut root.lines { + line.arrowhead_size = v; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::ARROWHEAD_SIZE, + ); + } + } + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::ARROWHEAD_SIZE); } } "scale_factor" => { if let Some(v) = f64(value) { - ml.scale_factor = v; + if v > 0.0 { + let old = ml.context.scale_factor.max(1.0e-12); + let ratio = v / old; + ml.scale_factor = v; + ml.context.scale_factor = v; + ml.context.arrowhead_size *= ratio; + ml.context.text_height *= ratio; + ml.context.landing_gap *= ratio; + ml.block_scale.x *= ratio; + ml.block_scale.y *= ratio; + ml.block_scale.z *= ratio; + ml.context.block_content_scale = ml.block_scale; + if let Some(root) = ml.context.leader_roots.first() { + let anchor = root.connection_point; + for point in [ + &mut ml.context.content_base_point, + &mut ml.context.text_location, + &mut ml.context.block_content_location, + ] { + point.x = anchor.x + (point.x - anchor.x) * ratio; + point.y = anchor.y + (point.y - anchor.y) * ratio; + point.z = anchor.z + (point.z - anchor.z) * ratio; + } + } + for root in &mut ml.context.leader_roots { + root.landing_distance *= ratio; + } + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::SCALE_FACTOR); + } } } "landing_distance" => { if let Some(v) = f64(value) { ml.dogleg_length = v; - if let Some(root) = ml.context.leader_roots.first_mut() { - root.landing_distance = v; + let display = v * ml.context.scale_factor.max(1.0e-12); + for root in &mut ml.context.leader_roots { + root.landing_distance = display; } + place_content_after_landing(ml); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::LANDING_DISTANCE); } } "landing_gap" => { if let Some(v) = f64(value) { - ml.context.landing_gap = v; + ml.context.landing_gap = v * ml.context.scale_factor.max(1.0e-12); + place_content_after_landing(ml); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::LANDING_GAP); } } "block_rotation" => { if let Some(v) = f64(value) { ml.block_rotation = v.to_radians(); + ml.context.block_rotation = ml.block_rotation; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_ROTATION); } } + "block_scale" => { + if let Some(v) = f64(value) { + if v > 0.0 { + ml.block_scale = acadrust::types::Vector3::new(v, v, v); + ml.context.block_content_scale = ml.block_scale; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_SCALE); + } + } + } + "block_connection_type" => { + ml.block_connection_type = if value == "Insertion point" { + BlockContentConnectionType::BasePoint + } else { + BlockContentConnectionType::BlockExtents + }; + ml.context.block_connection_type = ml.block_connection_type; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_CONNECTION); + } "conn_x" => { if let (Some(v), Some(root)) = (f64(value), ml.context.leader_roots.first_mut()) { root.connection_point.x = v; @@ -1181,18 +1362,26 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "text_left_attachment" => { ml.text_left_attachment = parse_attachment(value); ml.context.text_left_attachment = parse_attachment(value); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_LEFT_ATTACHMENT); } "text_right_attachment" => { ml.text_right_attachment = parse_attachment(value); ml.context.text_right_attachment = parse_attachment(value); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_RIGHT_ATTACHMENT); } "text_top_attachment" => { ml.text_top_attachment = parse_attachment(value); ml.context.text_top_attachment = parse_attachment(value); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_TOP_ATTACHMENT); } "text_bottom_attachment" => { ml.text_bottom_attachment = parse_attachment(value); ml.context.text_bottom_attachment = parse_attachment(value); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_BOTTOM_ATTACHMENT); } "text_alignment" => { ml.text_alignment = match value { @@ -1205,6 +1394,8 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "Right" => TextAlignmentType::Right, _ => TextAlignmentType::Left, }; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_ALIGNMENT); } "text_flow_direction" => { ml.context.text_flow_direction = match value { @@ -1218,6 +1409,11 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "Vertical" => TextAttachmentDirectionType::Vertical, _ => TextAttachmentDirectionType::Horizontal, }; + for root in &mut ml.context.leader_roots { + root.text_attachment_direction = ml.text_attachment_direction; + } + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_ATTACHMENT_DIRECTION); } "line_space_style" => { ml.context.line_spacing_style = match value { @@ -1233,6 +1429,11 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "text_rotation" => { if let Some(v) = f64(value) { ml.context.text_rotation = v.to_radians(); + ml.context.text_direction = acadrust::types::Vector3::new( + ml.context.text_rotation.cos(), + ml.context.text_rotation.sin(), + 0.0, + ); } } "line_spacing" => { @@ -1254,6 +1455,27 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { } } +fn place_content_after_landing(ml: &mut MultiLeader) { + let Some(root) = ml.context.leader_roots.first() else { + return; + }; + let length = (root.direction.x * root.direction.x + root.direction.y * root.direction.y).sqrt(); + let (ux, uy) = if length > 1.0e-12 { + (root.direction.x / length, root.direction.y / length) + } else { + (1.0, 0.0) + }; + let distance = root.landing_distance.max(0.0) + ml.context.landing_gap.max(0.0); + let location = acadrust::types::Vector3::new( + root.connection_point.x + ux * distance, + root.connection_point.y + uy * distance, + root.connection_point.z, + ); + ml.context.content_base_point = location; + ml.context.text_location = location; + ml.context.block_content_location = location; +} + fn parse_attachment(s: &str) -> TextAttachmentType { match s { "Top of top line" => TextAttachmentType::TopOfTopLine, @@ -1785,11 +2007,7 @@ impl MultiLeaderTess for MultiLeader { // for a rotated leader that is the angled baseline, not world // X. Roots without a usable direction keep the legacy // horizontal toward the text side. - let (ux, uy) = ml - .context - .leader_roots - .first() - .map(|r| (r.direction.x, r.direction.y)) + let (ux, uy) = Some((root.direction.x, root.direction.y)) .filter(|(x, y)| (x * x + y * y).sqrt() > 1e-9) .map(|(x, y)| { let l = (x * x + y * y).sqrt(); @@ -1804,6 +2022,15 @@ impl MultiLeaderTess for MultiLeader { points.push(nan); points.push(cp_f); points.push(landing_end); + if ml.extend_leader_to_text { + let gap = ml.context.landing_gap.max(0.0) + * context_scale_correction as f64; + points.push([ + (text_loc_w.x - ux * gap) as f32, + (text_loc_w.y - uy * gap) as f32, + text_loc_w.z as f32, + ]); + } } } @@ -1856,31 +2083,58 @@ impl MultiLeaderTess for MultiLeader { // synthetic Insert supplies that context to the shared scene graph; // every child then uses its normal tessellator. if ml.content_type == LeaderContentType::Block && ml.context.has_block_contents { - let block_name = match ml.block_content_handle { + let block_record = match ml.block_content_handle { Some(h) if !h.is_null() => document .block_records .iter() .find(|br| br.handle == h) - .map(|br| br.name.clone()), + .cloned(), _ => None, }; - if let Some(block_name) = block_name { + if let Some(block_record) = block_record { + let block_name = block_record.name.clone(); let block_color = if selected { line_color } else { color_or_inherit(&ml.block_content_color, entity_color) }; - let mut synth_ins = - acadrust::entities::Insert::new(block_name, ml.context.block_content_location); + let mut insertion = ml.context.block_content_location; + if ml.block_connection_type == BlockContentConnectionType::BlockExtents { + let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + for entity_handle in &block_record.entity_handles { + if let Some(entity) = document.get_entity(*entity_handle) { + let aabb = crate::scene::convert::tess::entity_aabb(entity); + if aabb != WireModel::UNBOUNDED_AABB { + bounds[0] = bounds[0].min(aabb[0]); + bounds[1] = bounds[1].min(aabb[1]); + bounds[2] = bounds[2].max(aabb[2]); + bounds[3] = bounds[3].max(aabb[3]); + } + } + } + if bounds[0] <= bounds[2] && bounds[1] <= bounds[3] { + let from_left = ml + .context + .leader_roots + .first() + .map_or(true, |root| root.direction.x >= 0.0); + let anchor_x = if from_left { bounds[0] } else { bounds[2] } as f64; + let anchor_y = ((bounds[1] + bounds[3]) * 0.5) as f64; + let local_x = + (anchor_x - block_record.base_point.x) * ml.block_scale.x; + let local_y = + (anchor_y - block_record.base_point.y) * ml.block_scale.y; + let (sin_r, cos_r) = ml.block_rotation.sin_cos(); + insertion.x -= local_x * cos_r - local_y * sin_r; + insertion.y -= local_x * sin_r + local_y * cos_r; + } + } + let mut synth_ins = acadrust::entities::Insert::new(block_name, insertion); synth_ins.set_x_scale(ml.block_scale.x); synth_ins.set_y_scale(ml.block_scale.y); synth_ins.set_z_scale(ml.block_scale.z); synth_ins.rotation = ml.block_rotation; synth_ins.common.layer = ml.common.layer.clone(); - // block_connection_type (BlockExtents vs BasePoint) chooses the - // anchor when *creating* the multileader; at render time the - // file's stored leader endpoints already encode that choice. - let _ = ml.block_connection_type; let depths = rustc_hash::FxHashMap::default(); let graph = crate::scene::render_graph::RenderSceneGraph::new( document, @@ -2013,14 +2267,14 @@ impl MultiLeaderTess for MultiLeader { // anchor within the text block (Left/Center/Right) — honour it // instead of guessing from the leader side. use acadrust::entities::multileader::{ - TextAttachmentDirectionType, TextAttachmentPointType, + TextAttachmentDirectionType, }; // The context's attachment point exists in every DWG version; // the entity-level copy only exists from R2010 on. - let h_anchor: f32 = match ctx.text_attachment_point { - TextAttachmentPointType::Left => 0.0, - TextAttachmentPointType::Center => 0.5, - TextAttachmentPointType::Right => 1.0, + let h_anchor: f32 = match ctx.text_alignment { + TextAlignmentType::Left => 0.0, + TextAlignmentType::Center => 0.5, + TextAlignmentType::Right => 1.0, }; // Pick the vertical-anchor attachment based on text_attachment_direction: // Horizontal — leader attaches left/right; use ml.text_left_attachment @@ -2273,14 +2527,9 @@ impl MultiLeaderTess for MultiLeader { // Text frame / background-fill rectangle in local frame, then rotated to WCS. if ml.text_frame || ctx.background_fill_enabled { - // Frame/fill offset from the glyphs: the context's landing gap - // (AutoCAD spaces the box by exactly that), with the old - // quarter-height heuristic as the no-gap fallback. - let pad = if ctx.landing_gap > 0.0 { - ctx.landing_gap as f32 - } else { - height * 0.25 - }; + // Border offset is a scale factor around the laid-out text box. + let pad = height + * ((ctx.background_scale_factor.max(1.0) as f32 - 1.0) * 0.5); // Box the glyphs that were actually laid out (valid for // vertical flow too); the metric-derived box is only the // no-glyph fallback. @@ -2311,11 +2560,16 @@ impl MultiLeaderTess for MultiLeader { // Background fill — emit two triangles; renders under the text strokes. if ctx.background_fill_enabled { - let fill_color = if selected { + let mut fill_color = if selected { line_color + } else if ctx.background_mask_fill_on { + bg_color } else { color_or_inherit(&ctx.background_fill_color, entity_color) }; + fill_color[3] *= + (1.0 - ctx.background_transparency.clamp(0, 90) as f32 / 100.0) + .clamp(0.1, 1.0); let fill_tris: Vec<[f32; 3]> = vec![ wcs_corners[0], wcs_corners[1], diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 5526c6d31..1843272e6 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -1,11 +1,11 @@ // MLEADER: pick the arrow and elbow, then edit the text. -use acadrust::entities::MultiLeader; +use acadrust::entities::{LeaderContentType, MultiLeader, MultiLeaderPathType}; use acadrust::types::Vector3; use acadrust::EntityType; use glam::{DVec3, Mat4, Vec3}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -26,6 +26,47 @@ pub struct MLeaderCommand { plane: WorkingPlane, style: Option, display_scale: f64, + order: CreationOrder, + step: Step, + content_type: LeaderContentType, + path_type: MultiLeaderPathType, + enable_landing: bool, + landing_length: f64, + max_points: usize, + first_angle: Option, + second_angle: Option, + layer: String, + text: String, + picked_entity: Option, + selected_mtext: Option, + block_sources: Vec<(String, acadrust::Handle)>, + block_handle: Option, + layers: Vec, + text_styles: Vec<(String, acadrust::Handle)>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CreationOrder { + ArrowFirst, + LandingFirst, + ContentFirst, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { + PickPoints, + Options, + LeaderType, + Landing, + LandingLength, + ContentType, + MaxPoints, + FirstAngle, + SecondAngle, + Layer, + PreEnterText, + SelectMText, + BlockSource, } impl MLeaderCommand { @@ -35,6 +76,23 @@ impl MLeaderCommand { plane: WorkingPlane::default(), style: None, display_scale: 1.0, + order: CreationOrder::ArrowFirst, + step: Step::PickPoints, + content_type: LeaderContentType::MText, + path_type: MultiLeaderPathType::StraightLineSegments, + enable_landing: true, + landing_length: 2.5, + max_points: 2, + first_angle: None, + second_angle: None, + layer: String::new(), + text: String::new(), + picked_entity: None, + selected_mtext: None, + block_sources: Vec::new(), + block_handle: None, + layers: Vec::new(), + text_styles: Vec::new(), } } @@ -50,10 +108,202 @@ impl MLeaderCommand { Self { verts: Vec::new(), plane: WorkingPlane::default(), - style: Some(style), + style: Some(style.clone()), display_scale, + order: CreationOrder::ArrowFirst, + step: Step::PickPoints, + content_type: (style.content_type as i16).into(), + path_type: (style.path_type as i16).into(), + enable_landing: style.enable_landing, + landing_length: style.landing_distance, + max_points: style.max_leader_points.max(2) as usize, + first_angle: (style.first_segment_angle.abs() > 1.0e-12) + .then_some(style.first_segment_angle), + second_angle: (style.second_segment_angle.abs() > 1.0e-12) + .then_some(style.second_segment_angle), + layer: String::new(), + text: style.default_text.clone(), + picked_entity: None, + selected_mtext: None, + block_sources: Vec::new(), + block_handle: style.block_content_handle, + layers: Vec::new(), + text_styles: Vec::new(), } } + + pub fn with_drawing_resources( + mut self, + block_sources: Vec<(String, acadrust::Handle)>, + layers: Vec, + text_styles: Vec<(String, acadrust::Handle)>, + ) -> Self { + self.block_sources = block_sources; + self.layers = layers; + self.text_styles = text_styles; + self + } + + fn point_limit(&self) -> usize { + if self.order == CreationOrder::ContentFirst { + 2 + } else { + self.max_points.max(2) + } + } + + fn finish(&self) -> CmdResult { + if self.verts.len() < 2 { + return CmdResult::Cancel; + } + let local: Vec = self + .verts + .iter() + .map(|point| self.plane.to_local(*point)) + .collect(); + let (leader_points, content_point) = match self.order { + CreationOrder::ArrowFirst => (local, None), + CreationOrder::LandingFirst => { + let mut points = local; + points.reverse(); + (points, None) + } + CreationOrder::ContentFirst => { + let content = local[0]; + let arrow = local[1]; + let sign = if content.x >= arrow.x { 1.0 } else { -1.0 }; + let elbow = DVec3::new( + content.x - sign * self.landing_length * self.display_scale, + content.y, + content.z, + ); + (vec![arrow, elbow], Some(content)) + } + }; + let mut ml = build_mleader( + &self.text, + &leader_points, + content_point, + Mat4::IDENTITY, + self.style.as_ref(), + self.display_scale, + ); + ml.content_type = self.content_type; + ml.path_type = self.path_type; + ml.enable_landing = self.enable_landing; + ml.enable_dogleg = self.enable_landing; + ml.dogleg_length = self.landing_length; + if self.content_type == LeaderContentType::Block { + ml.block_content_handle = self.block_handle; + } + if !self.layer.trim().is_empty() { + ml.common.layer = format!("__MLEADER_LAYER__{}", self.layer.trim()); + } + for root in &mut ml.context.leader_roots { + root.landing_distance = self.landing_length * self.display_scale; + for line in &mut root.lines { + line.path_type = self.path_type; + } + } + normalize_content_context(&mut ml); + if let Some(source) = &self.selected_mtext { + let scale = self.display_scale.max(1.0e-12); + ml.text_height = source.height / scale; + ml.context.text_height = source.height; + ml.context.text_width = source.rectangle_width; + ml.context.text_rotation = source.rotation; + ml.context.text_direction = source.dwg_x_direction.unwrap_or_else(|| { + Vector3::new(source.rotation.cos(), source.rotation.sin(), 0.0) + }); + ml.context.line_spacing_factor = source.line_spacing_factor; + ml.context.line_spacing_style = source.line_spacing_style; + ml.text_color = source.common.color; + ml.context.text_color = source.common.color; + ml.text_frame = source.background_fill_flags & 0x10 != 0; + ml.context.background_fill_enabled = source.background_fill_flags & 0x01 != 0; + ml.context.background_mask_fill_on = source.background_fill_flags & 0x02 != 0; + ml.context.background_scale_factor = source.background_scale; + ml.context.background_fill_color = source.background_color; + ml.context.background_transparency = source.background_transparency; + ml.context.text_flow_direction = match source.drawing_direction { + acadrust::entities::mtext::DrawingDirection::LeftToRight => { + acadrust::entities::multileader::FlowDirectionType::Horizontal + } + acadrust::entities::mtext::DrawingDirection::TopToBottom => { + acadrust::entities::multileader::FlowDirectionType::Vertical + } + acadrust::entities::mtext::DrawingDirection::ByStyle => { + acadrust::entities::multileader::FlowDirectionType::ByStyle + } + }; + ml.context.text_attachment_point = match source.attachment_point { + acadrust::entities::mtext::AttachmentPoint::TopCenter + | acadrust::entities::mtext::AttachmentPoint::MiddleCenter + | acadrust::entities::mtext::AttachmentPoint::BottomCenter => { + acadrust::entities::multileader::TextAttachmentPointType::Center + } + acadrust::entities::mtext::AttachmentPoint::TopRight + | acadrust::entities::mtext::AttachmentPoint::MiddleRight + | acadrust::entities::mtext::AttachmentPoint::BottomRight => { + acadrust::entities::multileader::TextAttachmentPointType::Right + } + _ => acadrust::entities::multileader::TextAttachmentPointType::Left, + }; + let style_handle = self + .text_styles + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(&source.style)) + .map(|(_, handle)| *handle); + ml.text_style_handle = style_handle; + ml.context.text_style_handle = style_handle; + } + let entity = self.plane.place_entity(EntityType::MultiLeader(ml)); + if self.content_type == LeaderContentType::MText && self.text.is_empty() { + CmdResult::CommitAndEditText(entity) + } else { + CmdResult::CommitAndExit(entity) + } + } + + fn constrained_point(&self, point: DVec3) -> DVec3 { + let Some(previous) = self.verts.last().copied() else { + return point; + }; + let segment_index = self.verts.len() - 1; + let constraint = match (self.order, segment_index) { + (CreationOrder::LandingFirst, 0) => self.second_angle, + (CreationOrder::LandingFirst, 1) => self.first_angle, + (_, 0) => self.first_angle, + (_, 1) => self.second_angle, + _ => None, + }; + let Some(angle) = constraint else { + return point; + }; + let previous_local = self.plane.to_local(previous); + let point_local = self.plane.to_local(point); + let delta = point_local - previous_local; + let length = delta.truncate().length(); + if length <= 1.0e-12 { + return point; + } + let candidates = [angle, -angle, std::f64::consts::PI - angle, angle - std::f64::consts::PI]; + let current = delta.y.atan2(delta.x); + let chosen = candidates + .into_iter() + .min_by(|a, b| { + angle_delta(current, *a) + .partial_cmp(&angle_delta(current, *b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or(angle); + let local = DVec3::new( + previous_local.x + length * chosen.cos(), + previous_local.y + length * chosen.sin(), + point_local.z, + ); + self.plane.to_world(local) + } } impl CadCommand for MLeaderCommand { @@ -66,60 +316,86 @@ impl CadCommand for MLeaderCommand { } fn prompt(&self) -> String { - if self.verts.is_empty() { - t!("MLEADER Specify arrowhead point:").into_owned() - } else { - t!("MLEADER Specify landing point:").into_owned() + match self.step { + Step::Options => t!("MLEADER Options [Leader type/Landing/Content type/Max points/First angle/Second angle/Layer/Exit]:").into_owned(), + Step::LeaderType => t!("MLEADER Leader type [Straight/Spline/None]:").into_owned(), + Step::Landing => t!("MLEADER Leader landing [Yes/No]:").into_owned(), + Step::LandingLength => t!("MLEADER Specify landing distance:").into_owned(), + Step::ContentType => t!("MLEADER Content type [MText/Block/None]:").into_owned(), + Step::MaxPoints => t!("MLEADER Enter maximum leader points:").into_owned(), + Step::FirstAngle => t!("MLEADER First segment angle [Any/15/30/45/60/90]:").into_owned(), + Step::SecondAngle => t!("MLEADER Second segment angle [Any/15/30/45/60/90]:").into_owned(), + Step::Layer => t!("MLEADER Enter layer name:").into_owned(), + Step::PreEnterText => t!("MLEADER Enter text:").into_owned(), + Step::SelectMText => t!("MLEADER Select an MText object:").into_owned(), + Step::BlockSource => t!("MLEADER Enter source block name:").into_owned(), + Step::PickPoints if self.verts.is_empty() => match self.order { + CreationOrder::ArrowFirst => t!("MLEADER Specify arrowhead point or [Landing first/Content first/Text/Select MText/Options]:").into_owned(), + CreationOrder::LandingFirst => t!("MLEADER Specify landing point or [Arrowhead first/Content first/Text/Select MText/Options]:").into_owned(), + CreationOrder::ContentFirst => t!("MLEADER Specify content location or [Arrowhead first/Landing first/Text/Select MText/Options]:").into_owned(), + }, + Step::PickPoints if self.order == CreationOrder::ContentFirst => { + t!("MLEADER Specify arrowhead point:").into_owned() + } + Step::PickPoints => t!("MLEADER Specify next leader point or press Enter to finish:").into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::PickPoints if self.verts.is_empty() => vec![ + CmdOption::new("Arrowhead first", "A"), + CmdOption::new("Landing first", "L"), + CmdOption::new("Content first", "C"), + CmdOption::new("Text", "T"), + CmdOption::new("Select MText", "S"), + CmdOption::new("Options", "O"), + ], + Step::Options => vec![ + CmdOption::new("Leader type", "LT"), + CmdOption::new("Landing", "LD"), + CmdOption::new("Content type", "CT"), + CmdOption::new("Max points", "M"), + CmdOption::new("First angle", "F"), + CmdOption::new("Second angle", "S"), + CmdOption::new("Layer", "LA"), + CmdOption::new("Exit", "X"), + ], + Step::LeaderType => ["Straight", "Spline", "None"].into_iter().map(|v| CmdOption::new(v, v)).collect(), + Step::Landing => ["Yes", "No"].into_iter().map(|v| CmdOption::new(v, v)).collect(), + Step::ContentType => ["MText", "Block", "None"].into_iter().map(|v| CmdOption::new(v, v)).collect(), + Step::BlockSource => self + .block_sources + .iter() + .map(|(name, _)| CmdOption::new(name, name)) + .collect(), + Step::Layer => self + .layers + .iter() + .map(|name| CmdOption::new(name, name)) + .collect(), + Step::FirstAngle | Step::SecondAngle => ["Any", "15", "30", "45", "60", "90"].into_iter().map(|v| CmdOption::new(v, v)).collect(), + _ => Vec::new(), } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - self.verts.push(pt); - - if self.verts.len() < 2 { + if self.step != Step::PickPoints { return CmdResult::NeedPoint; } + self.verts.push(self.constrained_point(pt)); - let local: Vec = self - .verts - .iter() - .map(|point| self.plane.to_local(*point)) - .collect(); - - let ml = build_mleader( - "", - &local, - Mat4::IDENTITY, - self.style.as_ref(), - self.display_scale, - ); - - CmdResult::CommitAndEditText( - self.plane.place_entity(EntityType::MultiLeader(ml)), - ) + if self.verts.len() < self.point_limit() { + return CmdResult::NeedPoint; + } + self.finish() } fn on_enter(&mut self) -> CmdResult { - if self.verts.len() < 2 { + if self.step != Step::PickPoints || self.verts.len() < 2 { return CmdResult::Cancel; } - // Place the leader with empty text, then open the in-place MText editor - // so the user types the annotation into the rich editor. - let local: Vec = self - .verts - .iter() - .map(|point| self.plane.to_local(*point)) - .collect(); - let ml = build_mleader( - "", - &local, - Mat4::IDENTITY, - self.style.as_ref(), - self.display_scale, - ); - CmdResult::CommitAndEditText( - self.plane.place_entity(EntityType::MultiLeader(ml)), - ) + self.finish() } fn on_escape(&mut self) -> CmdResult { @@ -127,16 +403,37 @@ impl CadCommand for MLeaderCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if self.verts.is_empty() { + if self.step != Step::PickPoints || self.verts.is_empty() { return None; } // Preview / rubber-band is GPU screen-space: downcast to f32 here. - let mut pts: Vec = self + let mut local_points: Vec = self .verts .iter() - .map(|point| self.plane.to_local(*point).as_vec3()) + .map(|point| self.plane.to_local(*point)) .collect(); - pts.push(self.plane.to_local(pt).as_vec3()); + local_points.push(self.plane.to_local(self.constrained_point(pt))); + let pts: Vec = match self.order { + CreationOrder::ArrowFirst => local_points, + CreationOrder::LandingFirst => { + local_points.reverse(); + local_points + } + CreationOrder::ContentFirst => { + let content = local_points[0]; + let arrow = *local_points.last().unwrap_or(&content); + let sign = if content.x >= arrow.x { 1.0 } else { -1.0 }; + let elbow = DVec3::new( + content.x - sign * self.landing_length * self.display_scale, + content.y, + content.z, + ); + vec![arrow, elbow] + } + } + .into_iter() + .map(DVec3::as_vec3) + .collect(); let arrow_size = self .style .as_ref() @@ -159,6 +456,178 @@ impl CadCommand for MLeaderCommand { .collect(); Some(preview) } + + fn wants_text_input(&self) -> bool { + true + } + + fn wants_text_with_spaces(&self) -> bool { + matches!(self.step, Step::Layer | Step::PreEnterText) + } + + fn point_step_accepts_keywords(&self) -> bool { + self.step == Step::PickPoints + } + + fn on_text_input(&mut self, input: &str) -> Option { + let text = input.trim(); + let upper = text.to_ascii_uppercase(); + match self.step { + Step::PickPoints if self.verts.is_empty() => match upper.as_str() { + "A" | "ARROWHEAD" | "ARROWHEAD FIRST" => self.order = CreationOrder::ArrowFirst, + "L" | "LANDING" | "LANDING FIRST" => self.order = CreationOrder::LandingFirst, + "C" | "CONTENT" | "CONTENT FIRST" => self.order = CreationOrder::ContentFirst, + "T" | "TEXT" => self.step = Step::PreEnterText, + "S" | "SELECT" | "SELECT MTEXT" => self.step = Step::SelectMText, + "O" | "OPTIONS" => self.step = Step::Options, + _ => return None, + }, + Step::Options => match upper.as_str() { + "LT" | "LEADER TYPE" => self.step = Step::LeaderType, + "LD" | "LANDING" => self.step = Step::Landing, + "CT" | "CONTENT TYPE" => self.step = Step::ContentType, + "M" | "MAX" | "MAX POINTS" => self.step = Step::MaxPoints, + "F" | "FIRST" | "FIRST ANGLE" => self.step = Step::FirstAngle, + "S" | "SECOND" | "SECOND ANGLE" => self.step = Step::SecondAngle, + "LA" | "LAYER" => self.step = Step::Layer, + "X" | "EXIT" => self.step = Step::PickPoints, + _ => return None, + }, + Step::LeaderType => { + self.path_type = match upper.as_str() { + "SPLINE" => MultiLeaderPathType::Spline, + "NONE" => MultiLeaderPathType::Invisible, + "STRAIGHT" => MultiLeaderPathType::StraightLineSegments, + _ => return None, + }; + self.step = Step::Options; + } + Step::Landing => match upper.as_str() { + "Y" | "YES" => { + self.enable_landing = true; + self.step = Step::LandingLength; + } + "N" | "NO" => { + self.enable_landing = false; + self.step = Step::Options; + } + _ => return None, + }, + Step::LandingLength => { + let value = crate::entities::common::parse_typed_length(text)?; + if value <= 0.0 { + return None; + } + self.landing_length = value; + self.step = Step::Options; + } + Step::ContentType => { + self.content_type = match upper.as_str() { + "MTEXT" => LeaderContentType::MText, + "BLOCK" => LeaderContentType::Block, + "NONE" => LeaderContentType::None, + _ => return None, + }; + self.step = if self.content_type == LeaderContentType::Block + && self.block_handle.is_none() + { + Step::BlockSource + } else { + Step::Options + }; + } + Step::MaxPoints => { + let value = text.parse::().ok()?; + if value < 2 { + return None; + } + self.max_points = value; + self.step = Step::Options; + } + Step::FirstAngle | Step::SecondAngle => { + let angle = if upper == "ANY" { + None + } else { + let degrees = text.parse::().ok()?; + if !matches!(degrees as i32, 15 | 30 | 45 | 60 | 90) { + return None; + } + Some(degrees.to_radians()) + }; + if self.step == Step::FirstAngle { + self.first_angle = angle; + } else { + self.second_angle = angle; + } + self.step = Step::Options; + } + Step::Layer => { + if text.is_empty() { + return None; + } + let Some(layer) = self + .layers + .iter() + .find(|layer| layer.eq_ignore_ascii_case(text)) + .cloned() + else { + return None; + }; + self.layer = layer; + self.step = Step::Options; + } + Step::PreEnterText => { + self.text = input.to_string(); + self.content_type = LeaderContentType::MText; + self.step = Step::PickPoints; + } + Step::SelectMText => return None, + Step::BlockSource => { + let Some((_, handle)) = self + .block_sources + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(text)) + else { + return None; + }; + self.block_handle = Some(*handle); + self.content_type = LeaderContentType::Block; + self.step = Step::Options; + } + Step::PickPoints => return None, + } + Some(CmdResult::NeedPoint) + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::SelectMText + } + + 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: acadrust::Handle, _point: DVec3) -> CmdResult { + let Some(EntityType::MText(text)) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + self.verts.clear(); + self.verts.push(DVec3::new( + text.insertion_point.x, + text.insertion_point.y, + text.insertion_point.z, + )); + self.text = text.value.clone(); + self.selected_mtext = Some(text); + self.content_type = LeaderContentType::MText; + self.order = CreationOrder::ContentFirst; + self.step = Step::PickPoints; + CmdResult::NeedPoint + } } // ── Helpers ───────────────────────────────────────────────────────────────── @@ -170,18 +639,24 @@ fn v3(p: DVec3) -> Vector3 { fn build_mleader( text: &str, verts: &[DVec3], + content_point: Option, ucs: Mat4, style: Option<&acadrust::objects::MultiLeaderStyle>, display_scale: f64, ) -> MultiLeader { - // First click is the arrow; second click is the elbow. + // First point is the arrow, last point is the elbow. Intermediate points + // are leader vertices governed by the configured maximum-point count. let arrow_pt = verts[0]; - let elbow_pt = verts[1]; + let elbow_pt = *verts.last().unwrap_or(&verts[0]); - let arrow_v3 = v3(arrow_pt); let elbow_v3 = v3(elbow_pt); - let mut ml = MultiLeader::with_text(text, elbow_v3, vec![arrow_v3]); + let leader_vertices = verts[..verts.len().saturating_sub(1)] + .iter() + .copied() + .map(v3) + .collect(); + let mut ml = MultiLeader::with_text(text, elbow_v3, leader_vertices); if let Some(style) = style { crate::scene::annotative::apply_mleader_style(&mut ml, style); } else { @@ -225,11 +700,13 @@ fn build_mleader( // Place text beyond the landing and gap. let off = landing * (landing_distance + landing_gap) as f32; - let text_location = Vector3::new( - elbow_v3.x + off.x as f64, - elbow_v3.y + off.y as f64, - elbow_v3.z, - ); + let text_location = content_point.map(v3).unwrap_or_else(|| { + Vector3::new( + elbow_v3.x + off.x as f64, + elbow_v3.y + off.y as f64, + elbow_v3.z, + ) + }); ml.context.text_location = text_location; ml.context.content_base_point = text_location; @@ -237,6 +714,34 @@ fn build_mleader( ml } +fn normalize_content_context(ml: &mut MultiLeader) { + match ml.content_type { + LeaderContentType::MText => { + ml.context.has_text_contents = true; + ml.context.has_block_contents = false; + } + LeaderContentType::Block => { + ml.context.has_text_contents = false; + ml.context.has_block_contents = ml.block_content_handle.is_some(); + ml.context.block_content_handle = ml.block_content_handle; + ml.context.block_content_location = ml.context.content_base_point; + ml.context.block_content_scale = ml.block_scale; + ml.context.block_rotation = ml.block_rotation; + ml.context.block_content_color = ml.block_content_color; + ml.context.block_connection_type = ml.block_connection_type; + } + _ => { + ml.context.has_text_contents = false; + ml.context.has_block_contents = false; + } + } +} + +fn angle_delta(a: f64, b: f64) -> f64 { + let two_pi = std::f64::consts::TAU; + ((a - b + std::f64::consts::PI).rem_euclid(two_pi) - std::f64::consts::PI).abs() +} + fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { let mut points: Vec<[f32; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect(); if pts.len() >= 2 { diff --git a/src/modules/annotate/mleader_edit.rs b/src/modules/annotate/mleader_edit.rs index fe4727970..a8d77da59 100644 --- a/src/modules/annotate/mleader_edit.rs +++ b/src/modules/annotate/mleader_edit.rs @@ -122,13 +122,31 @@ impl CadCommand for MLeaderAddCommand { } let h = *handle; if let EntityType::MultiLeader(ref mut ml) = entity { - // Add a new leader root with the collected points let points: Vec = pts .iter() .map(|p| Vector3::new(p.x, p.y, p.z)) .collect(); + let template_root = ml.context.leader_roots.first().cloned(); + let path_type = ml.path_type; + let line_color = ml.line_color; + let line_type_handle = ml.line_type_handle; + let line_weight = ml.line_weight; + let arrowhead_handle = ml.arrowhead_handle; + let arrowhead_size = ml.arrowhead_size; let root = ml.context.add_leader_root(); - root.create_line(points); + if let Some(template) = template_root { + root.connection_point = template.connection_point; + root.direction = template.direction; + root.landing_distance = template.landing_distance; + root.text_attachment_direction = template.text_attachment_direction; + } + let line = root.create_line(points); + line.path_type = path_type; + line.line_color = line_color; + line.line_type_handle = line_type_handle; + line.line_weight = line_weight; + line.arrowhead_handle = arrowhead_handle; + line.arrowhead_size = arrowhead_size; } let updated = std::mem::replace(entity, EntityType::XLine(Default::default())); return CmdResult::ReplaceEntity(h, vec![updated]); @@ -223,24 +241,38 @@ impl CadCommand for MLeaderRemoveCommand { if let Some(mut ent) = entity.take() { let h = *handle; if let EntityType::MultiLeader(ref mut ml) = ent { - // Remove the leader root whose first point is closest to `pt` let pick = Vector3::new(pt.x as f64, pt.y as f64, pt.z as f64); let best = ml .context .leader_roots .iter() .enumerate() - .filter_map(|(i, root)| { - root.lines.first().and_then(|l| l.points.first()).map(|p| { - let dx = p.x - pick.x; - let dz = p.z - pick.z; - (i, dx * dx + dz * dz) + .flat_map(|(root_index, root)| { + root.lines.iter().enumerate().map(move |(line_index, line)| { + let mut distance = line + .points + .windows(2) + .map(|segment| point_segment_distance_sq_xy(pick, segment[0], segment[1])) + .fold(f64::INFINITY, f64::min); + if let Some(last) = line.points.last().copied() { + distance = distance.min(point_segment_distance_sq_xy( + pick, + last, + root.connection_point, + )); + } + (root_index, line_index, distance) }) }) - .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); - if let Some((idx, _)) = best { - if ml.context.leader_roots.len() > 1 { - ml.context.leader_roots.remove(idx); + .min_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); + if let Some((root_index, line_index, _)) = best { + let total_lines = ml.total_leader_line_count(); + if total_lines > 1 { + let root = &mut ml.context.leader_roots[root_index]; + root.lines.remove(line_index); + if root.lines.is_empty() { + ml.context.leader_roots.remove(root_index); + } } } } @@ -348,9 +380,9 @@ impl CadCommand for MLeaderAlignCommand { "__MLEADERALIGN__{};{:.4},{:.4};{:.4},{:.4}", hstr.join(","), f.x, - f.z, + f.y, pt.x, - pt.z + pt.y ); // Use first handle as the "replaced" entity (sentinel) if let Some(&first) = h.first() { @@ -441,7 +473,7 @@ impl CadCommand for MLeaderCollectCommand { "__MLEADERCOLLECT__{};{:.4},{:.4}", hstr.join(","), pt.x, - pt.z + pt.y ); if let Some(&first) = h.first() { return CmdResult::ReplaceEntity(first, vec![EntityType::XLine(xl)]); @@ -496,6 +528,22 @@ fn preview_wire(pts: &[Vec3]) -> WireModel { } } +fn point_segment_distance_sq_xy(point: Vector3, start: Vector3, end: Vector3) -> f64 { + let dx = end.x - start.x; + let dy = end.y - start.y; + let len_sq = dx * dx + dy * dy; + if len_sq <= 1.0e-18 { + let px = point.x - start.x; + let py = point.y - start.y; + return px * px + py * py; + } + let t = (((point.x - start.x) * dx + (point.y - start.y) * dy) / len_sq) + .clamp(0.0, 1.0); + let px = point.x - (start.x + t * dx); + let py = point.y - (start.y + t * dy); + px * px + py * py +} + // Silence unused-import warning for MultiLeader and LeaderLine if not used in all paths fn _uses_ml_types(_ml: &MultiLeader, _ll: &LeaderLine) {} diff --git a/src/scene/annotative.rs b/src/scene/annotative.rs index 8e3af638c..7d176bb96 100644 --- a/src/scene/annotative.rs +++ b/src/scene/annotative.rs @@ -1551,16 +1551,42 @@ pub fn apply_mleader_style( entity.context.block_content_scale = entity.block_scale; entity.context.scale_factor = style.scale_factor; entity.enable_annotation_scale = style.is_annotative; + match entity.content_type { + acadrust::entities::LeaderContentType::MText => { + entity.context.has_text_contents = true; + entity.context.has_block_contents = false; + } + acadrust::entities::LeaderContentType::Block => { + entity.context.has_text_contents = false; + entity.context.has_block_contents = entity.block_content_handle.is_some(); + entity.context.block_content_location = entity.context.content_base_point; + } + _ => { + entity.context.has_text_contents = false; + entity.context.has_block_contents = false; + } + } for root in &mut entity.context.leader_roots { root.landing_distance = style.landing_distance; root.text_attachment_direction = entity.text_attachment_direction; for line in &mut root.lines { - if line.override_flags.is_empty() { + use acadrust::entities::LeaderLinePropertyOverrideFlags as F; + if !line.override_flags.contains(F::PATH_TYPE) { line.path_type = entity.path_type; + } + if !line.override_flags.contains(F::LINE_COLOR) { line.line_color = style.line_color; + } + if !line.override_flags.contains(F::LINE_TYPE) { line.line_type_handle = style.line_type_handle; + } + if !line.override_flags.contains(F::LINE_WEIGHT) { line.line_weight = style.line_weight; + } + if !line.override_flags.contains(F::ARROWHEAD) { line.arrowhead_handle = style.arrowhead_handle; + } + if !line.override_flags.contains(F::ARROWHEAD_SIZE) { line.arrowhead_size = style.arrowhead_size; } } @@ -1854,4 +1880,3 @@ pub fn is_annotative(doc: &CadDocument, entity: &EntityType) -> bool { _ => false, } } - diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 613a82f35..054c6ab3a 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -34,6 +34,11 @@ pub enum PropValue { ColorVaries, /// Line weight — rendered as a combo_box. LwChoice(LineWeight), + /// Object-specific line weight routed by field name. + FieldLwChoice { + field: &'static str, + value: LineWeight, + }, /// Lineweight varies across the current multi-selection. LwVaries, /// Linetype name — rendered as a combo_box. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index aa951bdd8..8efd4413d 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -539,7 +539,8 @@ impl Scene { "ByLayer".into(), "ByBlock".into(), ]), - PropValue::LwChoice(_) => QSelectValueEditor::Choice(vec![ + PropValue::LwChoice(_) + | PropValue::FieldLwChoice { .. } => QSelectValueEditor::Choice(vec![ "ByLayer".into(), "ByBlock".into(), "Default".into(), @@ -696,7 +697,10 @@ impl Scene { PropValue::Choice { selected, .. } => selected, PropValue::EditChoice { value, .. } => value, PropValue::ColorChoice(c) => Self::format_color(c), - PropValue::LwChoice(lw) => Self::format_lineweight(lw), + PropValue::LwChoice(lw) + | PropValue::FieldLwChoice { value: lw, .. } => { + Self::format_lineweight(lw) + } PropValue::LinetypeChoice(s) => s, PropValue::HatchPatternChoice(s) => s, PropValue::BoolToggle { value, .. } => value.to_string(), diff --git a/src/ui/properties.rs b/src/ui/properties.rs index b6d129710..88ee200b3 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -763,6 +763,9 @@ impl PropertiesPanel { PropValue::ColorVaries => self.render_color_varies_row(label), PropValue::LayerChoice(layer) => self.render_layer_row(label, layer), PropValue::LwChoice(lw) => self.render_lw_row(label, *lw), + PropValue::FieldLwChoice { field, value } => { + self.render_field_lw_row(label, field, *value) + } PropValue::LwVaries => self.render_lw_varies_row(label), PropValue::LinetypeChoice(lt) => self.render_linetype_row(label, lt), PropValue::Choice { selected, options } => { @@ -861,6 +864,10 @@ impl PropertiesPanel { | "dim_ext_line_color" | "dim_text_color" | "dim_text_fill_color" + | "line_color" + | "text_color" + | "block_content_color" + | "background_fill_color" ) { let open = self.open_color_field.as_deref() == Some(field); let fsel = field.to_string(); @@ -871,7 +878,9 @@ impl PropertiesPanel { background: true, ..Default::default() } - } else if field.starts_with("dim_") { + } else if field.starts_with("dim_") + || matches!(field, "line_color" | "text_color" | "block_content_color") + { crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, @@ -998,6 +1007,36 @@ impl PropertiesPanel { prop_row_widget(label, combo.into()) } + fn render_field_lw_row<'a>( + &'a self, + label: &'a str, + field: &'static str, + lw: LineWeight, + ) -> Element<'a, Message> { + let selected = LwItem(lw); + let combo = combo_box( + &self.lineweight_combo, + "", + Some(&selected), + move |item: LwItem| Message::PropFieldLwChanged { + field, + value: item.0, + }, + ) + .size(FONT_SZ) + .padding(Padding { + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, + left: 6.0, + right: 6.0, + }) + .input_style(combo_input_style) + .on_open(Message::PropColorPickerClose) + .width(Length::Fill); + + prop_row_widget(label, combo.into()) + } + fn render_lw_varies_row<'a>(&'a self, label: &'a str) -> Element<'a, Message> { let combo = combo_box( &self.lineweight_combo, From 1d58082e0ac2a6cb1305875ca362726bcb9b6b5b Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 28 Aug 2026 08:01:44 +0300 Subject: [PATCH 07/16] fix(dimension): repair baseline workflows --- src/app/command_driver.rs | 21 +- src/app/commands/dim.rs | 87 +--- src/command.rs | 2 + src/modules/annotate/aligned_dim.rs | 1 + src/modules/annotate/angular_dim.rs | 1 + src/modules/annotate/arc_length_dim.rs | 1 + src/modules/annotate/diameter_dim.rs | 1 + src/modules/annotate/dim_baseline.rs | 591 ++++++++++++++-------- src/modules/annotate/dim_continue.rs | 308 ++++++----- src/modules/annotate/jogged_radius_dim.rs | 1 + src/modules/annotate/linear_dim.rs | 1 + src/modules/annotate/ordinate_dim.rs | 1 + src/modules/annotate/radius_dim.rs | 1 + src/scene/dimension_assoc.rs | 73 ++- 14 files changed, 640 insertions(+), 450 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index d33b27233..cf4a960a3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -856,7 +856,7 @@ impl OpenCADStudio { .infer_dimension_sources(handle); self.tabs[i] .scene - .attach_dimension_association(handle, sources.to_vec()); + .attach_dimension_association(handle, sources); } } self.tabs[i].dirty = true; @@ -1269,7 +1269,7 @@ impl OpenCADStudio { .infer_dimension_sources(handle); self.tabs[i] .scene - .attach_dimension_association(handle, sources.to_vec()); + .attach_dimension_association(handle, sources); } } self.tabs[i].dirty = true; @@ -1288,6 +1288,7 @@ impl OpenCADStudio { mut entity, association, preserve_base_style, + continue_command, } => { let label = self.history_label_from_active_cmd(i, "DIMENSION"); let association_mode = self.tabs[i] @@ -1378,8 +1379,6 @@ impl OpenCADStudio { self.tabs[i] .scene .infer_dimension_sources(handle) - .into_iter() - .collect() }, |source| { if single_source_dimension { @@ -1414,12 +1413,22 @@ impl OpenCADStudio { }; self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); } + if continue_command { + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|cmd| cmd.prompt()) + { + self.command_line.push_info(&prompt); + } + } else { + self.tabs[i].active_cmd = None; + self.restore_pre_cmd_tangent(); + } } CmdResult::CommitSolid { entity, diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 9d84edcb6..9c16bf312 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -646,13 +646,19 @@ impl OpenCADStudio { "DIMCONTINUE" => { use crate::modules::annotate::dim_continue::DimContinueCommand; - let cmd = if let Some((p1, p2, dp, rot, trot)) = - find_last_linear_dim(&self.tabs[i].scene) - { - DimContinueCommand::from_base(p1, p2, dp, rot, trot) - } else { - DimContinueCommand::new() - }; + let scene = &self.tabs[i].scene; + let preserve_base_style = self.dimension_continue_mode == 1; + let cmd = scene + .last_created_dimension + .filter(|handle| scene.entity_belongs_to_active_space(*handle)) + .and_then(|handle| scene.document.get_entity(handle)) + .and_then(|entity| match entity { + acadrust::EntityType::Dimension(dimension) => Some( + DimContinueCommand::from_dimension(dimension, preserve_base_style), + ), + _ => None, + }) + .unwrap_or_else(|| DimContinueCommand::new(preserve_base_style)); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -662,6 +668,7 @@ impl OpenCADStudio { let scene = &self.tabs[i].scene; let recent = scene .last_created_dimension + .filter(|handle| scene.entity_belongs_to_active_space(*handle)) .and_then(|handle| scene.document.get_entity(handle)) .cloned(); let dimdli_by_style = scene @@ -1383,72 +1390,6 @@ impl OpenCADStudio { } } -/// Find the last placed linear or aligned dimension in the document. -/// Returns `(first_point, second_point, definition_point, rotation_rad)` in world-space. -fn find_last_linear_dim( - scene: &crate::scene::Scene, -) -> Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64, f64)> { - use acadrust::entities::Dimension; - let mut best_handle: u64 = 0; - let mut result: Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64, f64)> = None; - - for entity in scene.document.entities() { - if let acadrust::EntityType::Dimension(dim) = entity { - let h = entity.common().handle.value(); - if h <= best_handle { - continue; - } - let item = match dim { - Dimension::Linear(d) => { - let p1 = glam::Vec3::new( - d.first_point.x as f32, - d.first_point.y as f32, - d.first_point.z as f32, - ); - let p2 = glam::Vec3::new( - d.second_point.x as f32, - d.second_point.y as f32, - d.second_point.z as f32, - ); - let dp = glam::Vec3::new( - d.base.definition_point.x as f32, - d.base.definition_point.y as f32, - d.base.definition_point.z as f32, - ); - Some((p1, p2, dp, d.rotation, d.base.text_rotation)) - } - Dimension::Aligned(d) => { - let p1 = glam::Vec3::new( - d.first_point.x as f32, - d.first_point.y as f32, - d.first_point.z as f32, - ); - let p2 = glam::Vec3::new( - d.second_point.x as f32, - d.second_point.y as f32, - d.second_point.z as f32, - ); - let dp = glam::Vec3::new( - d.base.definition_point.x as f32, - d.base.definition_point.y as f32, - d.base.definition_point.z as f32, - ); - let dx = (d.second_point.x - d.first_point.x) as f32; - let dy = (d.second_point.y - d.first_point.y) as f32; - let rot = dy.atan2(dx) as f64; - Some((p1, p2, dp, rot, d.base.text_rotation)) - } - _ => None, - }; - if let Some(data) = item { - best_handle = h; - result = Some(data); - } - } - } - result -} - /// Collect candidate dimension endpoints from an entity for QDIM — line ends, /// polyline vertices, arc endpoints — in world space. fn qdim_collect_points(e: &acadrust::EntityType, out: &mut Vec) { diff --git a/src/command.rs b/src/command.rs index e8bf675e8..cd7544513 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1251,6 +1251,8 @@ pub enum CmdResult { /// Retain the source dimension's layer and style instead of replacing /// them with the current creation defaults (DIMCONTINUEMODE=1). preserve_base_style: bool, + /// Keep collecting points after the dimension is committed. + continue_command: bool, }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index dafc2183b..9ecfa7c70 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -136,6 +136,7 @@ impl CadCommand for AlignedDimensionCommand { entity: self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim))), association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, + continue_command: false, } } } diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index f1c00059f..f23e3b555 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -235,6 +235,7 @@ impl AngularDimensionCommand { entity, association: DimensionAssociationInput::Explicit(self.source_refs.clone()), preserve_base_style: false, + continue_command: false, } } diff --git a/src/modules/annotate/arc_length_dim.rs b/src/modules/annotate/arc_length_dim.rs index 434eabbe6..bbdfaf412 100644 --- a/src/modules/annotate/arc_length_dim.rs +++ b/src/modules/annotate/arc_length_dim.rs @@ -266,6 +266,7 @@ impl ArcLengthDimensionCommand { entity: plane.place_entity(EntityType::Dimension(Dimension::Arc(dimension))), association, preserve_base_style: false, + continue_command: false, } } } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 946cde255..19dfcb1ca 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -114,6 +114,7 @@ impl CadCommand for DiameterDimensionCommand { .place_entity(EntityType::Dimension(Dimension::Diameter(dim))), association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, + continue_command: false, } } } diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 1656432d8..834222a34 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -1,6 +1,5 @@ -//! Repeating baseline dimensions from a session base or an explicitly picked base. - use std::collections::HashMap; +use std::f64::consts::PI; use acadrust::entities::{ Dimension, DimensionAligned, DimensionAngular2Ln, DimensionAngular3Pt, DimensionBase, @@ -8,6 +7,10 @@ use acadrust::entities::{ }; use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; +use cadkernel::geom2d::{ + arc_span, intersect, nearest_of, Arc, Curve, Line, Tolerance, Transform, Vec2, XLine, +}; +use cadkernel::space::Plane; use glam::DVec3; use crate::command::{CadCommand, CmdOption, CmdResult, DimensionAssociationInput}; @@ -56,35 +59,39 @@ impl SourceStyle { enum BaselineKind { Linear { - fixed: DVec3, + fixed: Vec2, rotation: f64, aligned: bool, - perpendicular: DVec3, + perpendicular: Vec2, next_offset: f64, increment: f64, }, Angular2Ln { - source: DimensionAngular2Ln, - vertex: DVec3, - fixed_ray: DVec3, + fixed_line: [Vec2; 2], + fixed_is_first: bool, + vertex: Vec2, + fixed_ray: Vec2, next_radius: f64, increment: f64, }, Angular3Pt { - source: DimensionAngular3Pt, - vertex: DVec3, - fixed_ray: DVec3, + vertex: Vec2, + fixed_point: Vec2, + fixed_ray: Vec2, next_radius: f64, increment: f64, }, Ordinate { - source: DimensionOrdinate, + definition: Vec2, + leader: Vec2, + is_x: bool, }, } struct BaselineState { kind: BaselineKind, style: SourceStyle, + plane: Plane, } pub struct DimBaselineCommand { @@ -140,35 +147,59 @@ impl DimBaselineCommand { }; let style = SourceStyle::from_base(dimension.base()); let increment = self.effective_increment(&style.style_name); + let plane = plane_from_normal(dimension.base().definition_point, style.normal); + let pick = pick.and_then(|point| plane.project(point.to_array()).map(Vec2::from)); let kind = match dimension { Dimension::Linear(source) => { - let (fixed, _) = nearest_end(source.first_point, source.second_point, pick); + let first = project(&plane, source.first_point); + let second = project(&plane, source.second_point); + let (fixed, _) = nearest_end(first, second, pick); linear_base( fixed, source.rotation, false, - dv(source.base.definition_point), + project(&plane, source.base.definition_point), increment, ) } Dimension::Aligned(source) => { - let (fixed, other) = nearest_end(source.first_point, source.second_point, pick); + let first = project(&plane, source.first_point); + let second = project(&plane, source.second_point); + let (fixed, other) = nearest_end(first, second, pick); linear_base( fixed, - (other - fixed).y.atan2((other - fixed).x), + (other - fixed).angle(), true, - dv(source.base.definition_point), + project(&plane, source.base.definition_point), increment, ) } Dimension::Angular2Ln(source) => { - let vertex = angular2_vertex(source); - let first = dv(source.second_point) - vertex; - let second = dv(source.definition_point) - vertex; - let fixed_ray = nearest_direction(first, second, vertex, pick); - let radius = (dv(source.dimension_arc) - vertex).length().max(1.0e-9); + let lines = [ + [ + project(&plane, source.first_point), + project(&plane, source.second_point), + ], + [ + project(&plane, source.angle_vertex), + project(&plane, source.definition_point), + ], + ]; + let vertex = angular2_vertex(lines).unwrap_or(lines[1][0]); + let fixed_index = nearest_line(lines, pick); + let fixed_line = lines[fixed_index]; + let fixed_end = if fixed_index == 0 { + lines[0][1] + } else { + lines[1][1] + }; + let fixed_ray = fixed_end - vertex; + let radius = project(&plane, source.dimension_arc) + .distance(vertex) + .max(1.0e-9); BaselineKind::Angular2Ln { - source: source.clone(), + fixed_line, + fixed_is_first: fixed_index == 0, vertex, fixed_ray, next_radius: radius + increment, @@ -176,31 +207,42 @@ impl DimBaselineCommand { } } Dimension::Angular3Pt(source) => { - let vertex = dv(source.angle_vertex); - let first = dv(source.first_point) - vertex; - let second = dv(source.second_point) - vertex; - let fixed_ray = nearest_direction(first, second, vertex, pick); - let radius = (dv(source.definition_point) - vertex).length().max(1.0e-9); + let vertex = project(&plane, source.angle_vertex); + let points = [ + project(&plane, source.first_point), + project(&plane, source.second_point), + ]; + let fixed_index = nearest_line( + [[vertex, points[0]], [vertex, points[1]]], + pick, + ); + let fixed_point = points[fixed_index]; + let radius = project(&plane, source.definition_point) + .distance(vertex) + .max(1.0e-9); BaselineKind::Angular3Pt { - source: source.clone(), vertex, - fixed_ray, + fixed_point, + fixed_ray: fixed_point - vertex, next_radius: radius + increment, increment, } } Dimension::Ordinate(source) => BaselineKind::Ordinate { - source: source.clone(), + definition: project(&plane, source.definition_point), + leader: project(&plane, source.leader_endpoint), + is_x: source.is_ordinate_type_x, }, _ => return false, }; - self.base = Some(BaselineState { kind, style }); + self.base = Some(BaselineState { kind, style, plane }); self.committed = 0; true } - fn build_dimension(&self, point: DVec3) -> Option { + fn build_dimension(&self, point_world: DVec3) -> Option { let state = self.base.as_ref()?; + let point = state.plane.project(point_world.to_array()).map(Vec2::from)?; let mut dimension = match &state.kind { BaselineKind::Linear { fixed, @@ -210,82 +252,93 @@ impl DimBaselineCommand { next_offset, .. } => build_linear( + state.plane, *fixed, point, *rotation, *aligned, *perpendicular, *next_offset, - ), + )?, BaselineKind::Angular2Ln { - source, + fixed_line, + fixed_is_first, vertex, fixed_ray, next_radius, .. } => { let moving = point - *vertex; - if moving.length_squared() <= 1.0e-18 { - return None; - } - let mut result = source.clone(); - result.first_point = v3(*vertex); - result.second_point = v3(*vertex + normalized_or(*fixed_ray, DVec3::X)); - result.angle_vertex = v3(*vertex); - result.definition_point = v3(point); let (definition, text) = - angular_definition_and_text(*vertex, *fixed_ray, moving, *next_radius); - result.dimension_arc = v3(definition); + angular_definition_and_text(*vertex, *fixed_ray, moving, *next_radius)?; + let mut result = DimensionAngular2Ln::default(); + if *fixed_is_first { + result.first_point = world(state.plane, fixed_line[0]); + result.second_point = world(state.plane, fixed_line[1]); + result.angle_vertex = world(state.plane, *vertex); + result.definition_point = world(state.plane, point); + } else { + result.first_point = world(state.plane, *vertex); + result.second_point = world(state.plane, point); + result.angle_vertex = world(state.plane, fixed_line[0]); + result.definition_point = world(state.plane, fixed_line[1]); + } + result.dimension_arc = world(state.plane, definition); result.base.definition_point = result.dimension_arc; - result.base.text_middle_point = v3(text); + result.base.text_middle_point = world(state.plane, text); result.base.insertion_point = result.base.text_middle_point; - result.base.actual_measurement = result.measurement_degrees(); Dimension::Angular2Ln(result) } BaselineKind::Angular3Pt { - source, vertex, + fixed_point, fixed_ray, next_radius, .. } => { let moving = point - *vertex; - if moving.length_squared() <= 1.0e-18 { - return None; - } - let mut result = source.clone(); - result.angle_vertex = v3(*vertex); - result.first_point = v3(*vertex + normalized_or(*fixed_ray, DVec3::X)); - result.second_point = v3(point); let (definition, text) = - angular_definition_and_text(*vertex, *fixed_ray, moving, *next_radius); - result.definition_point = v3(definition); + angular_definition_and_text(*vertex, *fixed_ray, moving, *next_radius)?; + let mut result = DimensionAngular3Pt::new( + world(state.plane, *vertex), + world(state.plane, *fixed_point), + world(state.plane, point), + ); + result.definition_point = world(state.plane, definition); result.base.definition_point = result.definition_point; - result.base.text_middle_point = v3(text); + result.base.text_middle_point = world(state.plane, text); result.base.insertion_point = result.base.text_middle_point; - result.base.actual_measurement = result.measurement_degrees(); Dimension::Angular3Pt(result) } - BaselineKind::Ordinate { source } => { - let source_leader = dv(source.leader_endpoint); - let leader = if source.is_ordinate_type_x { - DVec3::new(point.x, source_leader.y, source_leader.z) + BaselineKind::Ordinate { + definition, + leader, + is_x, + } => { + let to_dimension = Transform::rotation(-state.style.horizontal_direction); + let from_dimension = Transform::rotation(state.style.horizontal_direction); + let feature = Vec2::from(to_dimension.apply_point(point.into())); + let source_leader = Vec2::from(to_dimension.apply_point((*leader).into())); + let leader = if *is_x { + Vec2::new(feature.x, source_leader.y) } else { - DVec3::new(source_leader.x, point.y, source_leader.z) + Vec2::new(source_leader.x, feature.y) }; + let leader = Vec2::from(from_dimension.apply_point(leader.into())); let mut result = DimensionOrdinate::new( - v3(point), - v3(leader), - source.is_ordinate_type_x, + world(state.plane, point), + world(state.plane, leader), + *is_x, ); - result.definition_point = source.definition_point; - result.base.definition_point = source.base.definition_point; - result.base.text_middle_point = v3(leader); + result.definition_point = world(state.plane, *definition); + result.base.definition_point = result.definition_point; + result.base.text_middle_point = result.leader_endpoint; result.base.insertion_point = result.base.text_middle_point; Dimension::Ordinate(result) } }; state.style.apply(dimension.base_mut()); + refresh_measurement(&mut dimension, state.plane); dimension.base_mut().common.handle = Handle::NULL; dimension.base_mut().block_name.clear(); Some(dimension) @@ -389,6 +442,7 @@ impl CadCommand for DimBaselineCommand { entity: EntityType::Dimension(dimension), association: DimensionAssociationInput::Infer(None), preserve_base_style: self.preserve_base_style, + continue_command: true, } } @@ -426,18 +480,19 @@ impl CadCommand for DimBaselineCommand { } fn on_mouse_move(&mut self, point: DVec3) -> Option { - self.build_dimension(point).map(|dimension| preview_for_dimension(&dimension)) + self.build_dimension(point) + .map(|dimension| preview_for_dimension(&dimension)) } } fn linear_base( - fixed: DVec3, + fixed: Vec2, rotation: f64, aligned: bool, - definition: DVec3, + definition: Vec2, increment: f64, ) -> BaselineKind { - let perpendicular = DVec3::new(-rotation.sin(), rotation.cos(), 0.0); + let perpendicular = Vec2::new(-rotation.sin(), rotation.cos()); let offset = (definition - fixed).dot(perpendicular); let increment = increment * if offset >= 0.0 { 1.0 } else { -1.0 }; BaselineKind::Linear { @@ -451,39 +506,48 @@ fn linear_base( } fn build_linear( - fixed: DVec3, - point: DVec3, + plane: Plane, + fixed: Vec2, + point: Vec2, rotation: f64, aligned: bool, - perpendicular: DVec3, + source_perpendicular: Vec2, offset: f64, -) -> Dimension { - let target = fixed.dot(perpendicular) + offset; - let first_line = fixed + perpendicular * (target - fixed.dot(perpendicular)); - let second_line = point + perpendicular * (target - point.dot(perpendicular)); - if aligned { - let mut result = DimensionAligned::new(v3(fixed), v3(point)); - result.definition_point = v3(second_line); - result.base.definition_point = v3(second_line); - result.base.text_middle_point = v3((first_line + second_line) * 0.5); - result.base.insertion_point = result.base.text_middle_point; - result.base.actual_measurement = result.measurement(); - Dimension::Aligned(result) +) -> Option { + let (first_line, second_line) = if aligned { + let mut perpendicular = (point - fixed).normalize()?.perpendicular(); + let source_side = source_perpendicular * offset.signum(); + if perpendicular.dot(source_side) < 0.0 { + perpendicular = -perpendicular; + } + let distance = offset.abs(); + (fixed + perpendicular * distance, point + perpendicular * distance) } else { - let mut result = DimensionLinear::new(v3(fixed), v3(point)); - result.rotation = rotation; - result.definition_point = v3(second_line); - result.base.definition_point = v3(second_line); - result.base.text_middle_point = v3((first_line + second_line) * 0.5); + let target = fixed.dot(source_perpendicular) + offset; + ( + project_to_line(fixed, source_perpendicular, target), + project_to_line(point, source_perpendicular, target), + ) + }; + if aligned { + let mut result = DimensionAligned::new(world(plane, fixed), world(plane, point)); + result.definition_point = world(plane, second_line); + result.base.definition_point = result.definition_point; + result.base.text_middle_point = world(plane, first_line.lerp(second_line, 0.5)); result.base.insertion_point = result.base.text_middle_point; - result.base.actual_measurement = result.measurement(); - Dimension::Linear(result) + Some(Dimension::Aligned(result)) + } else { + let mut result = DimensionLinear::new(world(plane, fixed), world(plane, point)); + result.rotation = rotation; + result.definition_point = world(plane, second_line); + result.base.definition_point = result.definition_point; + result.base.text_middle_point = world(plane, first_line.lerp(second_line, 0.5)); + result.base.insertion_point = result.base.text_middle_point; + Some(Dimension::Linear(result)) } } -fn nearest_end(first: Vector3, second: Vector3, pick: Option) -> (DVec3, DVec3) { - let first = dv(first); - let second = dv(second); +fn nearest_end(first: Vec2, second: Vec2, pick: Option) -> (Vec2, Vec2) { if pick.is_some_and(|point| point.distance_squared(second) < point.distance_squared(first)) { (second, first) } else { @@ -491,104 +555,156 @@ fn nearest_end(first: Vector3, second: Vector3, pick: Option) -> (DVec3, } } -fn nearest_direction(first: DVec3, second: DVec3, vertex: DVec3, pick: Option) -> DVec3 { - if let Some(point) = pick { - let direction = normalized_or(point - vertex, DVec3::X); - if normalized_or(second, DVec3::Y).dot(direction) - > normalized_or(first, DVec3::X).dot(direction) - { - return second; - } - } - first +fn nearest_line(lines: [[Vec2; 2]; 2], pick: Option) -> usize { + let Some(pick) = pick else { + return 0; + }; + let curves = lines.map(|line| { + Curve::Line(Line { + start: line[0].into(), + end: line[1].into(), + }) + }); + nearest_of(curves.iter(), pick.into()) + .map(|(index, _)| index) + .unwrap_or(0) } -fn angular2_vertex(source: &DimensionAngular2Ln) -> DVec3 { - let first = dv(source.first_point); - let first_direction = dv(source.second_point) - first; - let second = dv(source.angle_vertex); - let second_direction = dv(source.definition_point) - second; - line_intersection(first, first_direction, second, second_direction).unwrap_or(second) -} - -fn line_intersection(a: DVec3, b: DVec3, c: DVec3, d: DVec3) -> Option { - let cross = b.x * d.y - b.y * d.x; - if cross.abs() <= 1.0e-12 { - return None; - } - let delta = c - a; - Some(a + b * ((delta.x * d.y - delta.y * d.x) / cross)) +fn angular2_vertex(lines: [[Vec2; 2]; 2]) -> Option { + let first = Curve::XLine(XLine { + base: lines[0][0].into(), + direction: (lines[0][1] - lines[0][0]).into(), + }); + let second = Curve::XLine(XLine { + base: lines[1][0].into(), + direction: (lines[1][1] - lines[1][0]).into(), + }); + let scale = lines + .into_iter() + .flatten() + .map(Vec2::length) + .fold(1.0, f64::max); + intersect(&first, &second, Tolerance::new(scale * 1.0e-9)) + .first() + .map(|crossing| Vec2::from(crossing.point)) } fn angular_definition_and_text( - vertex: DVec3, - first: DVec3, - second: DVec3, + vertex: Vec2, + first: Vec2, + second: Vec2, radius: f64, -) -> (DVec3, DVec3) { - let start = first.y.atan2(first.x); - let mut sweep = second.y.atan2(second.x) - start; - while sweep <= -std::f64::consts::PI { - sweep += std::f64::consts::TAU; - } - while sweep > std::f64::consts::PI { - sweep -= std::f64::consts::TAU; - } - let point_at = |fraction: f64| { - let angle = start + sweep * fraction; - vertex + DVec3::new(angle.cos(), angle.sin(), 0.0) * radius - }; - (point_at(2.0 / 3.0), point_at(0.5)) +) -> Option<(Vec2, Vec2)> { + let curve = angular_arc(vertex, first, second, radius)?; + Some(( + Vec2::from(curve.point_at(2.0 / 3.0)), + Vec2::from(curve.point_at(0.5)), + )) } -fn normalized_or(value: DVec3, fallback: DVec3) -> DVec3 { - if value.length_squared() > 1.0e-18 { - value.normalize() +fn angular_arc(vertex: Vec2, first: Vec2, second: Vec2, radius: f64) -> Option { + let first = first.normalize()?; + let second = second.normalize()?; + let first_angle = first.angle(); + let second_angle = second.angle(); + let (start_angle, end_angle) = if arc_span(first_angle, second_angle) <= PI { + (first_angle, second_angle) } else { - fallback + (second_angle, first_angle) + }; + Some(Curve::Arc(Arc { + centre: vertex.into(), + radius, + start_angle, + end_angle, + })) +} + +fn refresh_measurement(dimension: &mut Dimension, plane: Plane) { + match dimension { + Dimension::Linear(value) => { + let first = project(&plane, value.first_point); + let second = project(&plane, value.second_point); + let axis = Vec2::new(value.rotation.cos(), value.rotation.sin()); + value.base.actual_measurement = (second - first).dot(axis).abs(); + } + Dimension::Aligned(value) => value.base.actual_measurement = value.measurement(), + Dimension::Angular2Ln(value) => { + value.base.actual_measurement = value.measurement_degrees() + } + Dimension::Angular3Pt(value) => { + value.base.actual_measurement = value.measurement_degrees() + } + Dimension::Ordinate(value) => value.refresh_measurement(), + _ => {} } } fn preview_for_dimension(dimension: &Dimension) -> WireModel { let mut points = Vec::<[f32; 3]>::new(); match dimension { - Dimension::Linear(dimension) => linear_preview( - &mut points, - dv(dimension.first_point), - dv(dimension.second_point), - dimension.rotation, - dv(dimension.definition_point), - ), - Dimension::Aligned(dimension) => { - let first = dv(dimension.first_point); - let second = dv(dimension.second_point); - let delta = second - first; + Dimension::Linear(value) => { + let plane = plane_from_normal(value.first_point, value.base.normal); linear_preview( &mut points, - first, - second, - delta.y.atan2(delta.x), - dv(dimension.definition_point), + plane, + project(&plane, value.first_point), + project(&plane, value.second_point), + value.rotation, + project(&plane, value.definition_point), ); } - Dimension::Angular2Ln(dimension) => angular_preview( + Dimension::Aligned(value) => { + let plane = plane_from_normal(value.first_point, value.base.normal); + let first = project(&plane, value.first_point); + let second = project(&plane, value.second_point); + linear_preview( + &mut points, + plane, + first, + second, + (second - first).angle(), + project(&plane, value.definition_point), + ); + } + Dimension::Angular2Ln(value) => { + let plane = plane_from_normal(value.first_point, value.base.normal); + let lines = [ + [ + project(&plane, value.first_point), + project(&plane, value.second_point), + ], + [ + project(&plane, value.angle_vertex), + project(&plane, value.definition_point), + ], + ]; + let vertex = angular2_vertex(lines).unwrap_or(lines[1][0]); + angular_preview( + &mut points, + plane, + vertex, + lines[0][1] - vertex, + lines[1][1] - vertex, + project(&plane, value.dimension_arc), + ); + } + Dimension::Angular3Pt(value) => { + let plane = plane_from_normal(value.angle_vertex, value.base.normal); + let vertex = project(&plane, value.angle_vertex); + angular_preview( + &mut points, + plane, + vertex, + project(&plane, value.first_point) - vertex, + project(&plane, value.second_point) - vertex, + project(&plane, value.definition_point), + ); + } + Dimension::Ordinate(value) => segment_world( &mut points, - angular2_vertex(dimension), - dv(dimension.second_point) - angular2_vertex(dimension), - dv(dimension.definition_point) - angular2_vertex(dimension), - dv(dimension.dimension_arc), - ), - Dimension::Angular3Pt(dimension) => angular_preview( - &mut points, - dv(dimension.angle_vertex), - dv(dimension.first_point) - dv(dimension.angle_vertex), - dv(dimension.second_point) - dv(dimension.angle_vertex), - dv(dimension.definition_point), - ), - Dimension::Ordinate(dimension) => segment( - &mut points, - dv(dimension.feature_location), - dv(dimension.leader_endpoint), + point(value.feature_location), + point(value.leader_endpoint), ), _ => {} } @@ -629,72 +745,103 @@ fn preview_for_dimension(dimension: &Dimension) -> WireModel { fn linear_preview( points: &mut Vec<[f32; 3]>, - first: DVec3, - second: DVec3, + plane: Plane, + first: Vec2, + second: Vec2, rotation: f64, - definition: DVec3, + definition: Vec2, ) { - let perpendicular = DVec3::new(-rotation.sin(), rotation.cos(), 0.0); + let perpendicular = Vec2::new(-rotation.sin(), rotation.cos()); let target = definition.dot(perpendicular); - let first_line = first + perpendicular * (target - first.dot(perpendicular)); - let second_line = second + perpendicular * (target - second.dot(perpendicular)); - segment(points, first, first_line); - segment(points, second, second_line); - segment(points, first_line, second_line); - append_arrows(points, first_line, second_line); + let first_line = project_to_line(first, perpendicular, target); + let second_line = project_to_line(second, perpendicular, target); + segment_local(points, plane, first, first_line); + segment_local(points, plane, second, second_line); + segment_local(points, plane, first_line, second_line); + append_arrows(points, plane, first_line, second_line); } -fn segment(points: &mut Vec<[f32; 3]>, first: DVec3, second: DVec3) { - points.push(first.as_vec3().to_array()); - points.push(second.as_vec3().to_array()); - points.push([f32::NAN, 0.0, 0.0]); -} - -fn append_arrows(points: &mut Vec<[f32; 3]>, first: DVec3, second: DVec3) { - let axis = normalized_or(second - first, DVec3::X); - let normal = DVec3::new(-axis.y, axis.x, 0.0); +fn append_arrows(points: &mut Vec<[f32; 3]>, plane: Plane, first: Vec2, second: Vec2) { + let Some(axis) = (second - first).normalize() else { + return; + }; + let normal = axis.perpendicular(); let size = first.distance(second).clamp(1.0, 100.0) * 0.035; for (tip, inward) in [(first, axis), (second, -axis)] { let back = tip + inward * size; - segment(points, tip, back + normal * size * 0.4); - segment(points, tip, back - normal * size * 0.4); + segment_local(points, plane, tip, back + normal * size * 0.4); + segment_local(points, plane, tip, back - normal * size * 0.4); } } fn angular_preview( points: &mut Vec<[f32; 3]>, - vertex: DVec3, - first: DVec3, - second: DVec3, - arc_point: DVec3, + plane: Plane, + vertex: Vec2, + first: Vec2, + second: Vec2, + arc_point: Vec2, ) { - let radius = (arc_point - vertex).length().max(1.0e-9); - let start = first.y.atan2(first.x); - let mut sweep = second.y.atan2(second.x) - start; - while sweep <= -std::f64::consts::PI { - sweep += std::f64::consts::TAU; - } - while sweep > std::f64::consts::PI { - sweep -= std::f64::consts::TAU; - } - segment(points, vertex, vertex + normalized_or(first, DVec3::X) * radius); - segment(points, vertex, vertex + normalized_or(second, DVec3::Y) * radius); + let radius = arc_point.distance(vertex).max(1.0e-9); + let Some(first_direction) = first.normalize() else { + return; + }; + let Some(second_direction) = second.normalize() else { + return; + }; + let Some(arc) = angular_arc(vertex, first, second, radius) else { + return; + }; + segment_local(points, plane, vertex, vertex + first_direction * radius); + segment_local(points, plane, vertex, vertex + second_direction * radius); for index in 0..=24 { - let angle = start + sweep * index as f64 / 24.0; - points.push( - (vertex + DVec3::new(angle.cos(), angle.sin(), 0.0) * radius) - .as_vec3() - .to_array(), - ); + push_local(points, plane, Vec2::from(arc.point_at(index as f64 / 24.0))); } } -fn dv(point: Vector3) -> DVec3 { - DVec3::new(point.x, point.y, point.z) +fn project_to_line(point: Vec2, perpendicular: Vec2, offset: f64) -> Vec2 { + point + perpendicular * (offset - point.dot(perpendicular)) } -fn v3(point: DVec3) -> Vector3 { - Vector3::new(point.x, point.y, point.z) +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( + point(origin), + [x_axis.0, x_axis.1, x_axis.2], + [y_axis.0, y_axis.1, y_axis.2], + ) +} + +fn project(plane: &Plane, value: Vector3) -> Vec2 { + Vec2::from(plane.project(point(value)).unwrap_or([0.0; 2])) +} + +fn world(plane: Plane, value: Vec2) -> Vector3 { + let value = plane.point_at(value.into()); + Vector3::new(value[0], value[1], value[2]) +} + +fn point(value: Vector3) -> [f64; 3] { + [value.x, value.y, value.z] +} + +fn segment_local(points: &mut Vec<[f32; 3]>, plane: Plane, first: Vec2, second: Vec2) { + segment_world(points, plane.point_at(first.into()), plane.point_at(second.into())); +} + +fn segment_world(points: &mut Vec<[f32; 3]>, first: [f64; 3], second: [f64; 3]) { + points.push(float3(first)); + points.push(float3(second)); + points.push([f32::NAN, 0.0, 0.0]); +} + +fn push_local(points: &mut Vec<[f32; 3]>, plane: Plane, point: Vec2) { + points.push(float3(plane.point_at(point.into()))); +} + +fn float3(value: [f64; 3]) -> [f32; 3] { + [value[0] as f32, value[1] as f32, value[2] as f32] } inventory::submit!(crate::command::CommandRegistration { names: &["DIMBASELINE"] }); diff --git a/src/modules/annotate/dim_continue.rs b/src/modules/annotate/dim_continue.rs index b7eb336b7..fe5a255d9 100644 --- a/src/modules/annotate/dim_continue.rs +++ b/src/modules/annotate/dim_continue.rs @@ -1,17 +1,10 @@ -// DIMCONTINUE command — chain linear/aligned dimensions end-to-end. -// -// Each new point becomes the second extension line origin of a new dimension, -// whose first extension line origin is the second extension line of the previous dim. -// The dimension line stays at the same perpendicular offset as the base dimension. -// -// Constructed from commands.rs after finding the last placed linear/aligned dimension. - -use acadrust::entities::{Dimension, DimensionLinear}; +use acadrust::entities::{Dimension, DimensionBase, DimensionLinear}; use acadrust::types::Vector3; use acadrust::EntityType; -use glam::{DVec3, Vec3}; +use cadkernel::space::Plane; +use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdResult, DimensionAssociationInput}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -27,75 +20,112 @@ pub fn tool() -> ToolDef { } } -pub struct DimContinueCommand { - /// Fixed first-extension-line origin for the current step (moves each iteration). - chain_p1: DVec3, - /// Direction along the dimension axis (0.0 = horizontal, PI/2 = vertical). - rotation: f64, - /// Text reading rotation inherited from the base dim so a UCS-aligned chain - /// keeps its text consistent with the originating DIMLINEAR. 0 = natural. +#[derive(Clone)] +struct SourceStyle { + layer: String, + style_name: String, + normal: Vector3, text_rotation: f64, - /// Absolute perpendicular coordinate (dot with `perp`) of the base - /// dimension line. Each continued dim line is projected onto this exact - /// coordinate so the whole chain stays collinear — even when the extension - /// origins sit at different perpendicular positions (extension lines of - /// different lengths). (#151) - dim_line_perp: f64, - /// Direction of "up" perpendicular to the dim axis (points toward the dim line). - perp: DVec3, - /// True once we have a base dimension loaded. - ready: bool, + horizontal_direction: f64, } -impl DimContinueCommand { - /// No base dim found — will show an error prompt and cancel immediately. - pub fn new() -> Self { +impl SourceStyle { + fn from_base(base: &DimensionBase) -> Self { Self { - chain_p1: DVec3::ZERO, - rotation: 0.0, - text_rotation: 0.0, - dim_line_perp: 0.0, - perp: DVec3::Y, - ready: false, + layer: base.common.layer.clone(), + style_name: base.style_name.clone(), + normal: base.normal, + text_rotation: base.text_rotation, + horizontal_direction: base.horizontal_direction, } } - /// Build from the last placed dimension. - /// - /// `p2` — second extension line origin of the base dim (the chain starts - /// here). `p1` is unused — the dim line position comes from - /// `definition_point` alone — but kept for signature parity with the - /// shared `find_last_linear_dim` tuple (DIMBASELINE uses p1). - /// `definition_point` — where the dim line was placed; its perpendicular - /// coordinate is the line the whole chain stays collinear with. - /// `rotation` — 0.0 = horizontal dim, PI/2 = vertical dim. - pub fn from_base( - _p1: Vec3, - p2: Vec3, - definition_point: Vec3, - rotation: f64, - text_rotation: f64, - ) -> Self { - // Widen the base-dim points to f64 so the committed chain math runs in - // full precision. (The base points arrive as f32 from the entity, but - // every derived committed coordinate must stay f64 from here on.) - let p2 = p2.as_dvec3(); - let definition_point = definition_point.as_dvec3(); - // Axis unit vector along the measurement direction — the base dim's - // rotation angle (any angle, incl. a UCS-aligned one), not a world H/V. - let axis = DVec3::new(rotation.cos(), rotation.sin(), 0.0); - // Perpendicular unit vector toward the dim line. - let perp = DVec3::new(-axis.y, axis.x, 0.0); - let dim_line_perp = definition_point.dot(perp); + fn apply(&self, base: &mut DimensionBase) { + base.common.layer = self.layer.clone(); + base.style_name = self.style_name.clone(); + base.normal = self.normal; + base.text_rotation = self.text_rotation; + base.horizontal_direction = self.horizontal_direction; + } +} + +struct ContinueState { + chain_p1: [f64; 2], + rotation: f64, + dim_line_perp: f64, + perpendicular: [f64; 2], + plane: Plane, + style: SourceStyle, +} + +pub struct DimContinueCommand { + state: Option, + preserve_base_style: bool, +} + +impl DimContinueCommand { + pub fn new(preserve_base_style: bool) -> Self { Self { - chain_p1: p2, - rotation, - text_rotation, - dim_line_perp, - perp, - ready: true, + state: None, + preserve_base_style, } } + + pub fn from_dimension(dimension: &Dimension, preserve_base_style: bool) -> Self { + let (first, second, definition, rotation) = match dimension { + Dimension::Linear(source) => ( + source.first_point, + source.second_point, + source.base.definition_point, + source.rotation, + ), + Dimension::Aligned(source) => { + let plane = plane_from_normal(source.first_point, source.base.normal); + let first = plane.project(point(source.first_point)).unwrap_or([0.0; 2]); + let second = plane.project(point(source.second_point)).unwrap_or(first); + let delta = [second[0] - first[0], second[1] - first[1]]; + ( + source.first_point, + source.second_point, + source.base.definition_point, + delta[1].atan2(delta[0]), + ) + } + _ => return Self::new(preserve_base_style), + }; + let plane = plane_from_normal(first, dimension.base().normal); + let second = plane.project(point(second)).unwrap_or([0.0; 2]); + let definition = plane.project(point(definition)).unwrap_or(second); + let perpendicular = [-rotation.sin(), rotation.cos()]; + let dim_line_perp = dot(definition, perpendicular); + Self { + state: Some(ContinueState { + chain_p1: second, + rotation, + dim_line_perp, + perpendicular, + plane, + style: SourceStyle::from_base(dimension.base()), + }), + preserve_base_style, + } + } + + fn placement(&self, point_world: DVec3) -> Option<([f64; 2], [f64; 2], [f64; 2])> { + let state = self.state.as_ref()?; + let second = state.plane.project(point_world.to_array())?; + let first_line = project_to_line( + state.chain_p1, + state.perpendicular, + state.dim_line_perp, + ); + let second_line = project_to_line( + second, + state.perpendicular, + state.dim_line_perp, + ); + Some((second, first_line, second_line)) + } } impl CadCommand for DimContinueCommand { @@ -104,61 +134,61 @@ impl CadCommand for DimContinueCommand { } fn prompt(&self) -> String { - if !self.ready { + if self.state.is_none() { t!("DIMCONTINUE No base dimension found. Place a dimension first.").into_owned() } else { t!("DIMCONTINUE Specify a second extension line origin (Enter to exit):").into_owned() } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if !self.ready { + fn on_point(&mut self, point_world: DVec3) -> CmdResult { + let Some((second, first_line, second_line)) = self.placement(point_world) else { return CmdResult::Cancel; + }; + let state = self.state.as_mut().expect("continue state"); + let mut dimension = DimensionLinear::new( + vector3(state.plane.point_at(state.chain_p1)), + vector3(state.plane.point_at(second)), + ); + dimension.rotation = state.rotation; + state.style.apply(&mut dimension.base); + dimension.definition_point = vector3(state.plane.point_at(first_line)); + dimension.base.definition_point = dimension.definition_point; + dimension.base.text_middle_point = vector3(state.plane.point_at([ + (first_line[0] + second_line[0]) * 0.5, + (first_line[1] + second_line[1]) * 0.5, + ])); + dimension.base.insertion_point = dimension.base.text_middle_point; + let axis = [state.rotation.cos(), state.rotation.sin()]; + dimension.base.actual_measurement = dot( + [ + second[0] - state.chain_p1[0], + second[1] - state.chain_p1[1], + ], + axis, + ) + .abs(); + state.chain_p1 = second; + + CmdResult::CommitDimension { + entity: EntityType::Dimension(Dimension::Linear(dimension)), + association: DimensionAssociationInput::Infer(None), + preserve_base_style: self.preserve_base_style, + continue_command: true, } - let p1 = self.chain_p1; - let p2 = pt; - - // Build a new linear dimension. - let mut dim = DimensionLinear::new(v3(p1), v3(p2)); - dim.rotation = self.rotation; - if self.text_rotation.abs() > 1e-9 { - dim.base.text_rotation = self.text_rotation; - } - - // Dim line stays collinear with the base: project both extension - // origins onto the base dim line's absolute perpendicular coordinate. - // (A fixed offset from p1 drifts off the base line whenever p1 and p2 - // sit at different perpendicular positions.) (#151) - let d1 = p1 + self.perp * (self.dim_line_perp - p1.dot(self.perp)); - let d2 = p2 + self.perp * (self.dim_line_perp - p2.dot(self.perp)); - dim.definition_point = v3(d1); - dim.base.definition_point = v3(d1); - dim.base.text_middle_point = v3((d1 + d2) * 0.5); - dim.base.insertion_point = dim.base.text_middle_point; - dim.base.actual_measurement = dim.measurement(); - - // Advance chain: next dim's P1 = this dim's P2. - self.chain_p1 = p2; - - CmdResult::CommitEntity(EntityType::Dimension(Dimension::Linear(dim))) } fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if !self.ready { - return None; - } - // Preview / rubber-band geometry feeds an f32 GPU buffer, so drop to f32 - // at this screen-only boundary. - let pt = pt.as_vec3(); - let p1 = self.chain_p1.as_vec3(); - let perp = self.perp.as_vec3(); - let dim_line_perp = self.dim_line_perp as f32; - let dim_line_pt = p1 + perp * (dim_line_perp - p1.dot(perp)); - let dim_line_pt2 = pt + perp * (dim_line_perp - pt.dot(perp)); + fn on_mouse_move(&mut self, point_world: DVec3) -> Option { + let (second, first_line, second_line) = self.placement(point_world)?; + let state = self.state.as_ref()?; + let first = state.plane.point_at(state.chain_p1); + let second = state.plane.point_at(second); + let first_line = state.plane.point_at(first_line); + let second_line = state.plane.point_at(second_line); Some(WireModel { point_marker: None, taper_widths: Vec::new(), @@ -177,14 +207,14 @@ impl CadCommand for DimContinueCommand { text_verts: Vec::new(), name: "dimcont_preview".into(), points: vec![ - [p1.x, p1.y, p1.z], - [dim_line_pt.x, dim_line_pt.y, dim_line_pt.z], + float3(first), + float3(first_line), [f32::NAN, 0.0, 0.0], - [pt.x, pt.y, pt.z], - [dim_line_pt2.x, dim_line_pt2.y, dim_line_pt2.z], + float3(second), + float3(second_line), [f32::NAN, 0.0, 0.0], - [dim_line_pt.x, dim_line_pt.y, dim_line_pt.z], - [dim_line_pt2.x, dim_line_pt2.y, dim_line_pt2.z], + float3(first_line), + float3(second_line), ], points_low: Vec::new(), color: WireModel::CYAN, @@ -192,22 +222,50 @@ impl CadCommand for DimContinueCommand { pattern_length: 0.0, pattern: [0.0; 8], line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), aci: 0, - key_vertices: vec![], + key_vertices: Vec::new(), aabb: WireModel::UNBOUNDED_AABB, plinegen: true, - fill_tris: vec![], + fill_tris: Vec::new(), fill_tris_low: Vec::new(), }) } } -fn v3(p: DVec3) -> Vector3 { - Vector3::new(p.x, p.y, p.z) +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( + point(origin), + [x_axis.0, x_axis.1, x_axis.2], + [y_axis.0, y_axis.1, y_axis.2], + ) } +fn project_to_line(point: [f64; 2], perpendicular: [f64; 2], offset: f64) -> [f64; 2] { + let distance = offset - dot(point, perpendicular); + [ + point[0] + perpendicular[0] * distance, + point[1] + perpendicular[1] * distance, + ] +} -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMCONTINUE"] }); // DimContinueCommand +fn dot(first: [f64; 2], second: [f64; 2]) -> f64 { + first[0] * second[0] + first[1] * second[1] +} + +fn point(value: Vector3) -> [f64; 3] { + [value.x, value.y, value.z] +} + +fn vector3(value: [f64; 3]) -> Vector3 { + Vector3::new(value[0], value[1], value[2]) +} + +fn float3(value: [f64; 3]) -> [f32; 3] { + [value[0] as f32, value[1] as f32, value[2] as f32] +} + +inventory::submit!(crate::command::CommandRegistration { names: &["DIMCONTINUE"] }); diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index 2363082b1..9a517c968 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -140,6 +140,7 @@ impl JoggedRadiusDimensionCommand { ))), association, preserve_base_style: false, + continue_command: false, } } } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index fd6d6d6c5..53d09b08e 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -188,6 +188,7 @@ impl CadCommand for LinearDimensionCommand { entity, association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, + continue_command: false, } } } diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 221523c7f..1437ba953 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -117,6 +117,7 @@ impl CadCommand for OrdinateDimCommand { )), association: DimensionAssociationInput::Infer(None), preserve_base_style: false, + continue_command: false, } } } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 10681bad8..4cb6acc9d 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -112,6 +112,7 @@ impl CadCommand for RadiusDimensionCommand { entity: source_plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))), association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, + continue_command: false, } } } diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 9e752c68b..82359b26c 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -408,12 +408,23 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti .copied() } -fn dimension_inference_points(dimension: &Dimension) -> Option<[Option; 2]> { +fn dimension_inference_points(dimension: &Dimension) -> Vec> { match dimension { - Dimension::Linear(linear) => Some([Some(linear.first_point), Some(linear.second_point)]), - Dimension::Aligned(aligned) => Some([Some(aligned.first_point), Some(aligned.second_point)]), - Dimension::Ordinate(ordinate) => Some([Some(ordinate.feature_location), None]), - _ => None, + Dimension::Linear(linear) => vec![Some(linear.first_point), Some(linear.second_point)], + Dimension::Aligned(aligned) => vec![Some(aligned.first_point), Some(aligned.second_point)], + Dimension::Angular3Pt(angular) => vec![ + Some(angular.angle_vertex), + Some(angular.first_point), + Some(angular.second_point), + ], + Dimension::Angular2Ln(angular) => vec![ + Some(angular.first_point), + Some(angular.second_point), + Some(angular.angle_vertex), + Some(angular.definition_point), + ], + Dimension::Ordinate(ordinate) => vec![Some(ordinate.feature_location)], + _ => Vec::new(), } } @@ -800,10 +811,10 @@ impl Scene { pub(crate) fn infer_dimension_sources( &self, dimension: Handle, - ) -> [Option; 2] { + ) -> Vec> { let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { - return [None, None]; + return Vec::new(); }; let radial_data = match entity { Dimension::Radius(radius) => Some(( @@ -845,23 +856,25 @@ impl Scene { }) .min_by(|first, second| first.0.total_cmp(&second.0)) .map(|(_, handle)| handle); - return [source, None]; + return vec![source]; } - let Some(points) = dimension_inference_points(entity) else { - return [None, None]; - }; - points.map(|point| { - point.and_then(|point| self.document - .entities() - .filter(|entity| entity.common().handle != dimension) - .filter_map(|entity| { - source_distance_squared(entity, point) - .map(|distance| (distance, entity.common().handle)) + dimension_inference_points(entity) + .into_iter() + .map(|point| { + point.and_then(|point| { + self.document + .entities() + .filter(|entity| entity.common().handle != dimension) + .filter_map(|entity| { + source_distance_squared(entity, point) + .map(|distance| (distance, entity.common().handle)) + }) + .filter(|(distance, _)| *distance <= 1e-16) + .min_by(|first, second| first.0.total_cmp(&second.0)) + .map(|(_, handle)| handle) }) - .filter(|(distance, _)| *distance <= 1e-16) - .min_by(|first, second| first.0.total_cmp(&second.0)) - .map(|(_, handle)| handle)) - }) + }) + .collect() } pub(crate) fn refresh_associative_dimensions( @@ -970,7 +983,7 @@ impl Scene { if let Some(point) = resolved[3] { angular.definition_point = point; } - angular.base.definition_point = angular.definition_point; + angular.base.definition_point = angular.dimension_arc; } Dimension::Radius(radius) => { let Some((radial, angle)) = radial_source else { @@ -1137,9 +1150,21 @@ impl Scene { arc.base.actual_measurement = arc.measurement(); } } - dimension.base_mut().actual_measurement = dimension.measurement(); + let measurement = match &*dimension { + Dimension::Linear(linear) => linear_measurement(linear), + _ => dimension.measurement(), + }; + dimension.base_mut().actual_measurement = measurement; refreshed.push((association.dimension, ChangeKind::Modified)); } refreshed } } + +fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 { + let plane = plane_from_normal(linear.first_point, linear.base.normal); + let first = plane.project(dpoint(linear.first_point)).unwrap_or([0.0; 2]); + let second = plane.project(dpoint(linear.second_point)).unwrap_or(first); + let axis = [linear.rotation.cos(), linear.rotation.sin()]; + ((second[0] - first[0]) * axis[0] + (second[1] - first[1]) * axis[1]).abs() +} From ef7f26be68b723d1574c34823ef8df9d5b37d982 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:59:23 +0300 Subject: [PATCH 08/16] Align multileader property visibility --- locales/en-US/opencadstudio.ftl | 2 + locales/tr-TR/opencadstudio.ftl | 2 + src/entities/multileader.rs | 81 +++++++++------------------------ src/locale_catalog.rs | 2 + 4 files changed, 27 insertions(+), 60 deletions(-) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 03332480c..f4c81c0a9 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2447,6 +2447,8 @@ properties = .associative = Associative .at-least = At least .attachment-type = Attachment type + .attachment = Attachment + .arrowhead-size = Arrowhead Size .attenuation = Attenuation .attribute-definition = Attribute Definition .attributedefinition = AttributeDefinition diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 32ac287e0..51d0db147 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2387,6 +2387,8 @@ properties = .associative = İlişkisel .at-least = En az .attachment-type = Bağlantı türü + .attachment = Bağlantı + .arrowhead-size = Ok Ucu Boyutu .attenuation = Zayıflama .attribute-definition = Öznitelik Tanımı .attributedefinition = Öznitelik Tanımı diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 448cc67e0..4e80f01b8 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -905,9 +905,7 @@ fn properties(ml: &MultiLeader) -> Vec { // ── Leaders ────────────────────────────────────────────────────────── // Landing rows are folded in here: the standalone "Leader Structure" group // is a style-dialog tab, not a palette group. - let leaders = PropSection { - title: t!("Leaders").into_owned(), - props: vec![ + let mut leader_props = vec![ choice( t!("Leader type").as_ref(), "path_type", @@ -931,20 +929,27 @@ fn properties(ml: &MultiLeader) -> Vec { }, // Arrowhead block name resolved by the panel builder (default "Closed filled"). ro(t!("Arrowhead").as_ref(), "arrowhead_handle", "Closed filled"), - edit(t!("Arrowhead size").as_ref(), "arrowhead_size", ml.arrowhead_size), + edit(t!("Arrowhead Size").as_ref(), "arrowhead_size", ml.arrowhead_size), bool_toggle(t!("Horizontal Landing").as_ref(), "enable_dogleg", ml.enable_dogleg), num_row( t!("Landing distance").as_ref(), "landing_distance", - ml.dogleg_length, + if ml.enable_dogleg { ml.dogleg_length } else { 0.0 }, ml.enable_dogleg, ), - bool_toggle( - t!("Extend leader to text").as_ref(), + ]; + // The extension is an MText-only landing option. It is absent for block + // content and whenever the horizontal landing itself is disabled. + if ml.content_type == LeaderContentType::MText && ml.enable_dogleg { + leader_props.push(bool_toggle( + t!("Leader extension").as_ref(), "extend_leader_to_text", ml.extend_leader_to_text, - ), - ], + )); + } + let leaders = PropSection { + title: t!("Leaders").into_owned(), + props: leader_props, }; // ── Text (shown only for MText content) ────────────────────────────── @@ -958,11 +963,6 @@ fn properties(ml: &MultiLeader) -> Vec { }, // Text-style name resolved from text_style_handle by the panel builder. ro(t!("Text style").as_ref(), "text_style_handle", "Standard"), - Property { - label: t!("Text color").into_owned(), - field: "text_color", - value: PropValue::ColorChoice(ml.text_color), - }, choice( t!("Justify").as_ref(), "text_alignment", @@ -995,40 +995,6 @@ fn properties(ml: &MultiLeader) -> Vec { "background_fill_enabled", ctx.background_fill_enabled, ), - if ctx.background_fill_enabled { - bool_toggle( - t!("Use drawing background color").as_ref(), - "background_mask_fill_on", - ctx.background_mask_fill_on, - ) - } else { - ro( - t!("Use drawing background color").as_ref(), - "background_mask_fill_on", - if ctx.background_mask_fill_on { "Yes" } else { "No" }, - ) - }, - Property { - label: t!("Background mask color").into_owned(), - field: "background_fill_color", - value: if ctx.background_fill_enabled && !ctx.background_mask_fill_on { - PropValue::ColorChoice(ctx.background_fill_color) - } else { - PropValue::ReadOnly(format!("{:?}", ctx.background_fill_color)) - }, - }, - num_row( - t!("Border offset factor").as_ref(), - "background_scale_factor", - ctx.background_scale_factor, - ctx.background_fill_enabled, - ), - num_row( - t!("Background transparency").as_ref(), - "background_transparency", - ctx.background_transparency as f64, - ctx.background_fill_enabled, - ), choice( t!("Attachment type").as_ref(), "text_attachment_direction", @@ -1087,7 +1053,7 @@ fn properties(ml: &MultiLeader) -> Vec { hexh(ml.block_content_handle), ), choice( - t!("Block connection").as_ref(), + t!("Attachment").as_ref(), "block_connection_type", match ml.block_connection_type { BlockContentConnectionType::BasePoint => "Insertion point", @@ -1095,17 +1061,7 @@ fn properties(ml: &MultiLeader) -> Vec { }, &["Extents", "Insertion point"], ), - Property { - label: t!("Block color").into_owned(), - field: "block_content_color", - value: PropValue::ColorChoice(ml.block_content_color), - }, - edit(t!("Block scale").as_ref(), "block_scale", ml.block_scale.x), - edit( - t!("Block rotation").as_ref(), - "block_rotation", - ml.block_rotation.to_degrees(), - ), + edit(t!("Scale").as_ref(), "block_scale", ml.block_scale.x), ], }; @@ -1409,6 +1365,11 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "Vertical" => TextAttachmentDirectionType::Vertical, _ => TextAttachmentDirectionType::Horizontal, }; + if ml.text_attachment_direction == TextAttachmentDirectionType::Vertical { + ml.enable_dogleg = false; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::ENABLE_DOGLEG); + } for root in &mut ml.context.leader_roots { root.text_attachment_direction = ml.text_attachment_direction; } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 92147a70e..f86fcc48e 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -325,6 +325,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Arrow size (DIMASZ)" => Some(("styles", "arrow-size-dimasz")), "Arrowhead" => Some(("common", "arrowhead")), "Arrowhead (DIMBLK)" => Some(("styles", "arrowhead-dimblk")), + "Arrowhead Size" => Some(("properties", "arrowhead-size")), "Arrowhead block:" => Some(("styles", "arrowhead-block")), "Arrowhead size" => Some(("common", "arrowhead-size")), "Arrowhead size:" => Some(("styles", "arrowhead-size")), @@ -332,6 +333,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Arrows out first" => Some(("styles", "arrows-out-first")), "As displayed" => Some(("common", "as-displayed")), "Associative" => Some(("properties", "associative")), + "Attachment" => Some(("properties", "attachment")), "At first ext" => Some(("styles", "at-first-ext")), "At least" => Some(("properties", "at-least")), "At second ext" => Some(("styles", "at-second-ext")), From e37491c52395f1392198ed25b2e3fae827a58e36 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:12:35 +0300 Subject: [PATCH 09/16] Align legacy leader property behavior --- src/app/properties.rs | 31 +++++++++++++++++++++++++++---- src/entities/leader.rs | 2 +- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index d884905a7..097220718 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1003,6 +1003,22 @@ impl OpenCADStudio { } } + // Legacy leaders expose the same General tail as the + // reference palette: Handle is omitted and associativity is + // reported as a calculated, read-only value. + if matches!(entity, acadrust::EntityType::Leader(_)) { + if let Some(general) = sections.first_mut() { + general.props.retain(|property| property.field != "handle"); + general.props.push(crate::scene::model::object::Property { + label: t!("Associative").into_owned(), + field: "associative", + value: crate::scene::model::object::PropValue::ReadOnly( + "No".to_string(), + ), + }); + } + } + // Inject DimStyle picker + style-derived groups for Dimensions. if let acadrust::EntityType::Dimension(d) = entity { if let Some(general) = sections.first_mut() { @@ -1204,7 +1220,8 @@ impl OpenCADStudio { // scale and dim-line lineweight drive the render; text // offset / vertical position round-trip to file but // don't change the leader glyph here (its annotation - // is a separate entity). Dim-line colour is read-only. + // is a separate entity). Dim-line colour is stored as + // a per-object override as well. if let Some(ds) = find_dim_style(doc, &ld.dimension_style) { use crate::entities::dim_override as dov; use crate::scene::model::object::PropValue; @@ -1250,7 +1267,7 @@ impl OpenCADStudio { set_row_value( &mut sections, "arrow_size", - PropValue::EditText(format!("{asz:.4}")), + PropValue::EditText(asz.to_string()), ); let lwd = dov::int(xd, dov::DIMLWD).unwrap_or(ds.dimlwd); @@ -1284,7 +1301,7 @@ impl OpenCADStudio { set_row_value( &mut sections, "text_offset", - PropValue::EditText(format!("{gap:.4}")), + PropValue::EditText(gap.to_string()), ); let tad = dov::int(xd, dov::DIMTAD).unwrap_or(ds.dimtad); @@ -1301,7 +1318,7 @@ impl OpenCADStudio { set_row_value( &mut sections, "dim_scale_overall", - PropValue::EditText(format!("{scl:.4}")), + PropValue::EditText(scl.to_string()), ); } } @@ -1818,6 +1835,11 @@ impl OpenCADStudio { doc, entity, ) } + acadrust::EntityType::Leader(_) => { + crate::scene::annotative::annotation_style_is_annotative( + doc, entity, + ) + } _ => false, }; // Dimensions/tables/tolerances carry no Annotative row yet — add one @@ -1857,6 +1879,7 @@ impl OpenCADStudio { ), acadrust::EntityType::Text(_) | acadrust::EntityType::Insert(_) + | acadrust::EntityType::Leader(_) | acadrust::EntityType::Hatch(_) | acadrust::EntityType::Dimension(_) => set_row_value( &mut sections, diff --git a/src/entities/leader.rs b/src/entities/leader.rs index 58937cda6..de3c93d84 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -269,7 +269,7 @@ fn properties(leader: &Leader) -> Vec { let vertex_label = if n == 0 { "—".to_string() } else { - format!("{} / {}", vi + 1, n) + (vi + 1).to_string() }; // Geometry sits right after General — a leader owns editable path vertices, From 3ca959a34b625233d2c2bf51085737a7564d80ff Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:21:03 +0300 Subject: [PATCH 10/16] Fix 3D polyline behavior and properties --- locales/en-US/opencadstudio.ftl | 2 +- src/app/properties.rs | 3 + src/app/update/mod.rs | 3 + src/app/view/mod.rs | 1 + src/entities/polyline.rs | 326 ++++++++++++++++++++++++++++---- src/modules/draw/draw/poly3d.rs | 10 +- src/ui/properties.rs | 3 + 7 files changed, 305 insertions(+), 43 deletions(-) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 03332480c..ca26cd5b4 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2829,7 +2829,7 @@ properties = .polyfacemesh = PolyfaceMesh .polygonmesh = PolygonMesh .polyline2d = Polyline2D - .polyline3d = Polyline3D + .polyline3d = 3D Polyline .predefined = Predefined .property-bag = Property Bag .proxy-entity = Proxy Entity diff --git a/src/app/properties.rs b/src/app/properties.rs index d884905a7..141701366 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -121,6 +121,9 @@ impl OpenCADStudio { .and_then(|entity| match entity { acadrust::EntityType::LwPolyline(polyline) => Some(polyline.vertices.len()), acadrust::EntityType::Polyline2D(polyline) => Some(polyline.vertices.len()), + acadrust::EntityType::Polyline3D(polyline) => Some( + crate::entities::polyline::polyline3d_control_vertex_count(polyline), + ), acadrust::EntityType::Leader(leader) => Some(leader.vertices.len()), acadrust::EntityType::Spline(spline) => { Some(if crate::entities::spline::shows_fit_points(spline) { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index cb501dc7d..84a89215d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4901,6 +4901,9 @@ impl OpenCADStudio { match self.tabs[i].scene.document.get_entity(handles[0]) { Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(), Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), + Some(acadrust::EntityType::Polyline3D(p)) => { + crate::entities::polyline::polyline3d_control_vertex_count(p) + } Some(acadrust::EntityType::Table(table)) => { table.row_count().saturating_mul(table.column_count()) } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index a9c104fa1..36d15d5f1 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -432,6 +432,7 @@ impl OpenCADStudio { let indexed = match tab.scene.document.get_entity(h) { Some(acadrust::EntityType::LwPolyline(_)) | Some(acadrust::EntityType::Polyline2D(_)) + | Some(acadrust::EntityType::Polyline3D(_)) | Some(acadrust::EntityType::Spline(_)) => true, _ => false, }; diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index 54d98ab77..3a8faf0b9 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -3,12 +3,14 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ - edit_prop as edit, parse_f64, ro_prop as ro, square_grip, stepper_prop as stepper, + edit_prop as edit, format_length, parse_f64, ro_prop as ro, square_grip, + stepper_prop as stepper, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::model::wire_model::TangentGeom; +use cadkernel::space::NurbsCurve3; // ── Polyline (old-style 3D heavy polyline) ──────────────────────────────────── @@ -783,6 +785,159 @@ impl Transformable for Polyline2D { // ── Polyline3D ──────────────────────────────────────────────────────────────── +const POLY3D_SPLINE_POINT: i32 = 8; +const POLY3D_SPLINE_CONTROL: i32 = 16; +const POLY3D_VERTEX: i32 = 32; + +fn polyline3d_control_indices(pl: &Polyline3D) -> Vec { + if pl + .vertices + .iter() + .any(|vertex| vertex.flags & POLY3D_SPLINE_CONTROL != 0) + { + return pl + .vertices + .iter() + .enumerate() + .filter_map(|(index, vertex)| { + (vertex.flags & POLY3D_SPLINE_CONTROL != 0).then_some(index) + }) + .collect(); + } + + let controls: Vec = pl + .vertices + .iter() + .enumerate() + .filter_map(|(index, vertex)| { + (vertex.flags & POLY3D_SPLINE_POINT == 0).then_some(index) + }) + .collect(); + if controls.is_empty() { + (0..pl.vertices.len()).collect() + } else { + controls + } +} + +pub(crate) fn polyline3d_control_vertex_count(pl: &Polyline3D) -> usize { + polyline3d_control_indices(pl).len() +} + +fn polyline3d_controls(pl: &Polyline3D) -> Vec { + polyline3d_control_indices(pl) + .into_iter() + .filter_map(|index| pl.vertices.get(index).cloned()) + .collect() +} + +fn polyline3d_wire_points(pl: &Polyline3D) -> Vec<[f64; 3]> { + let curve: Vec<[f64; 3]> = pl + .vertices + .iter() + .filter(|vertex| vertex.flags & POLY3D_SPLINE_POINT != 0) + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect(); + if !curve.is_empty() { + curve + } else { + polyline3d_controls(pl) + .iter() + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect() + } +} + +fn polyline3d_length(pl: &Polyline3D) -> f64 { + let points = polyline3d_wire_points(pl); + let mut length = points + .windows(2) + .map(|pair| { + let dx = pair[1][0] - pair[0][0]; + let dy = pair[1][1] - pair[0][1]; + let dz = pair[1][2] - pair[0][2]; + (dx * dx + dy * dy + dz * dz).sqrt() + }) + .sum::(); + if pl.is_closed() && points.len() >= 2 { + let first = points[0]; + let last = points[points.len() - 1]; + let dx = first[0] - last[0]; + let dy = first[1] - last[1]; + let dz = first[2] - last[2]; + length += (dx * dx + dy * dy + dz * dz).sqrt(); + } + length +} + +fn polyline3d_spline_samples( + controls: &[[f64; 3]], + degree: usize, + closed: bool, +) -> Option> { + let curve = if closed { + let mut periodic_controls = controls.to_vec(); + periodic_controls.extend(controls.iter().take(degree).copied()); + let knots = (0..periodic_controls.len() + degree + 1) + .map(|index| index as f64) + .collect(); + NurbsCurve3::new(degree, periodic_controls, knots, None)?.with_periodicity(true) + } else { + NurbsCurve3::new(degree, controls.to_vec(), Vec::new(), None)? + }; + Some(curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)) +} + +fn rebuild_polyline3d_fit(pl: &mut Polyline3D) -> bool { + use acadrust::entities::polyline3d::SmoothSurfaceType as SST; + + let mut controls = polyline3d_controls(pl); + if pl.smooth_type == SST::None { + for vertex in &mut controls { + vertex.flags = POLY3D_VERTEX; + } + pl.vertices = controls; + pl.flags.spline_fit = false; + return true; + } + + let degree = match pl.smooth_type { + SST::QuadraticBSpline => 2, + SST::CubicBSpline | SST::Bezier => 3, + SST::None => unreachable!(), + }; + if controls.len() <= degree { + return false; + } + let points: Vec<[f64; 3]> = controls + .iter() + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect(); + let Some(samples) = polyline3d_spline_samples(&points, degree, pl.is_closed()) else { + return false; + }; + + for vertex in &mut controls { + vertex.flags = POLY3D_VERTEX | POLY3D_SPLINE_CONTROL; + } + let vertex_layer = controls + .first() + .map(|vertex| vertex.layer.clone()) + .unwrap_or_else(|| "0".to_string()); + let curve_vertices = samples.into_iter().map(|point| { + let mut vertex = acadrust::entities::Vertex3DPolyline::from_xyz( + point[0], point[1], point[2], + ); + vertex.layer = vertex_layer.clone(); + vertex.flags = POLY3D_VERTEX | POLY3D_SPLINE_POINT; + vertex + }); + controls.extend(curve_vertices); + pl.vertices = controls; + pl.flags.spline_fit = true; + true +} + fn tessellate_polyline3d(pl: &Polyline3D) -> RenderEntity { let to_pt = |v: &acadrust::entities::Vertex3DPolyline| -> [f64; 3] { [v.position.x, v.position.y, v.position.z] @@ -791,8 +946,16 @@ fn tessellate_polyline3d(pl: &Polyline3D) -> RenderEntity { // DXF vertex flags: 8 = spline-fit curve point, 16 = spline frame control point. // When spline-fit vertices are present use them for the wire and control points for snap; // otherwise treat all vertices uniformly. - let spline_curve: Vec<_> = pl.vertices.iter().filter(|v| v.flags & 8 != 0).collect(); - let ctrl_pts: Vec<_> = pl.vertices.iter().filter(|v| v.flags & 16 != 0).collect(); + let spline_curve: Vec<_> = pl + .vertices + .iter() + .filter(|v| v.flags & POLY3D_SPLINE_POINT != 0) + .collect(); + let ctrl_pts: Vec<_> = pl + .vertices + .iter() + .filter(|v| v.flags & POLY3D_SPLINE_CONTROL != 0) + .collect(); let (wire_pts, key_verts) = if !spline_curve.is_empty() { let wire: Vec<[f64; 3]> = spline_curve.iter().map(|v| to_pt(v)).collect(); @@ -826,8 +989,9 @@ impl RenderConvertible for Polyline3D { impl Grippable for Polyline3D { fn grips(&self) -> Vec { - self.vertices - .iter() + polyline3d_control_indices(self) + .into_iter() + .filter_map(|index| self.vertices.get(index)) .enumerate() .map(|(i, v)| { square_grip( @@ -839,7 +1003,8 @@ impl Grippable for Polyline3D { } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { - if let Some(v) = self.vertices.get_mut(grip_id) { + let raw_index = polyline3d_control_indices(self).get(grip_id).copied(); + if let Some(v) = raw_index.and_then(|index| self.vertices.get_mut(index)) { match apply { GripApply::Translate(d) => { v.position.x += d.x as f64; @@ -852,6 +1017,11 @@ impl Grippable for Polyline3D { v.position.z = p.z as f64; } } + if self.smooth_type + != acadrust::entities::polyline3d::SmoothSurfaceType::None + { + let _ = rebuild_polyline3d_fit(self); + } } } @@ -875,31 +1045,44 @@ impl Grippable for Polyline3D { fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) { use crate::scene::model::object::GripMenuAction as A; - let n = self.vertices.len(); + use acadrust::entities::polyline3d::SmoothSurfaceType as SST; + + let mut controls = polyline3d_controls(self); + let n = controls.len(); match action { A::AddVertex if grip_id < n => { if grip_id == n - 1 && !self.is_closed() { - let mut new_v = self.vertices[grip_id].clone(); + let mut new_v = controls[grip_id].clone(); new_v.handle = acadrust::Handle::NULL; - self.vertices.push(new_v); - return; + controls.push(new_v); + } else { + let i1 = (grip_id + 1) % n; + let v0 = &controls[grip_id]; + let v1 = &controls[i1]; + let mx = (v0.position.x + v1.position.x) * 0.5; + let my = (v0.position.y + v1.position.y) * 0.5; + let mz = (v0.position.z + v1.position.z) * 0.5; + let mut new_v = v0.clone(); + new_v.position.x = mx; + new_v.position.y = my; + new_v.position.z = mz; + new_v.handle = acadrust::Handle::NULL; + controls.insert(grip_id + 1, new_v); } - let i1 = (grip_id + 1) % n; - let v0 = &self.vertices[grip_id]; - let v1 = &self.vertices[i1]; - let mx = (v0.position.x + v1.position.x) * 0.5; - let my = (v0.position.y + v1.position.y) * 0.5; - let mz = (v0.position.z + v1.position.z) * 0.5; - let mut new_v = v0.clone(); - new_v.position.x = mx; - new_v.position.y = my; - new_v.position.z = mz; - new_v.handle = acadrust::Handle::NULL; - let insert_at = (grip_id + 1).min(self.vertices.len()); - self.vertices.insert(insert_at, new_v); + self.vertices = controls; + let _ = rebuild_polyline3d_fit(self); } - A::RemoveVertex if grip_id < n && n > 2 => { - self.vertices.remove(grip_id); + A::RemoveVertex if grip_id < n => { + let minimum = match self.smooth_type { + SST::None => 2, + SST::QuadraticBSpline => 3, + SST::CubicBSpline | SST::Bezier => 4, + }; + if n > minimum { + controls.remove(grip_id); + self.vertices = controls; + let _ = rebuild_polyline3d_fit(self); + } } _ => {} } @@ -909,31 +1092,64 @@ impl Grippable for Polyline3D { impl PropertyEditable for Polyline3D { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { use acadrust::entities::polyline3d::SmoothSurfaceType as SST; - let n = self.vertices.len(); - let v0 = self.vertices.first(); - let vertex_x = v0.map(|v| v.position.x).unwrap_or_default(); - let vertex_y = v0.map(|v| v.position.y).unwrap_or_default(); - let vertex_z = v0.map(|v| v.position.z).unwrap_or_default(); + let control_indices = polyline3d_control_indices(self); + let n = control_indices.len(); + let vi = if n == 0 { + 0 + } else { + crate::scene::view::dispatch::prop_current_vertex().min(n - 1) + }; + let vertex = control_indices + .get(vi) + .and_then(|index| self.vertices.get(*index)); + let vertex_x = vertex.map(|v| v.position.x).unwrap_or_default(); + let vertex_y = vertex.map(|v| v.position.y).unwrap_or_default(); + let vertex_z = vertex.map(|v| v.position.z).unwrap_or_default(); + let vertex_label = if n == 0 { + "—".to_string() + } else { + format!("{} / {}", vi + 1, n) + }; let fit_smooth = match self.smooth_type { SST::None => "None", SST::QuadraticBSpline => "Quadratic", - SST::CubicBSpline => "Cubic", - SST::Bezier => "Bezier", + SST::CubicBSpline | SST::Bezier => "Cubic", }; vec![ PropSection { title: t!("Geometry").into_owned(), props: vec![ - ro(t!("Vertex").as_ref(), "pl3_vertex", if n > 0 { "1" } else { "" }), + stepper( + t!("Current Vertex").as_ref(), + "pl3_current_vertex", + vertex_label, + ), edit(t!("Vertex X").as_ref(), "pl3_vertex_x", vertex_x), edit(t!("Vertex Y").as_ref(), "pl3_vertex_y", vertex_y), edit(t!("Vertex Z").as_ref(), "pl3_vertex_z", vertex_z), + ro( + t!("Length").as_ref(), + "pl3_length", + format_length(polyline3d_length(self)), + ), ], }, PropSection { title: t!("Misc").into_owned(), props: vec![ + Property { + label: t!("Fit/Smooth").into_owned(), + field: "pl3_smooth", + value: PropValue::Choice { + selected: fit_smooth.to_string(), + options: vec![ + "None".to_string(), + "Quadratic".to_string(), + "Cubic".to_string(), + ], + }, + }, Property { label: t!("Closed").into_owned(), field: "pl3_closed", @@ -942,7 +1158,6 @@ impl PropertyEditable for Polyline3D { value: self.is_closed(), }, }, - ro(t!("Fit/Smooth").as_ref(), "pl3_smooth", fit_smooth), ], }, ] @@ -961,20 +1176,57 @@ impl PropertyEditable for Polyline3D { } else { self.open(); } + if self.smooth_type + != acadrust::entities::polyline3d::SmoothSurfaceType::None + { + let _ = rebuild_polyline3d_fit(self); + } + } + "pl3_smooth" => { + use acadrust::entities::polyline3d::SmoothSurfaceType as SST; + let next = match value { + "None" => SST::None, + "Quadratic" => SST::QuadraticBSpline, + "Cubic" => SST::CubicBSpline, + _ => return, + }; + let previous = self.smooth_type; + self.smooth_type = next; + if !rebuild_polyline3d_fit(self) { + self.smooth_type = previous; + } } "pl3_vertex_x" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) { + let vi = crate::scene::view::dispatch::prop_current_vertex(); + let raw_index = polyline3d_control_indices(self).get(vi).copied(); + if let (Some(v), Some(vert)) = ( + parse_f64(value), + raw_index.and_then(|index| self.vertices.get_mut(index)), + ) { vert.position.x = v; + let _ = rebuild_polyline3d_fit(self); } } "pl3_vertex_y" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) { + let vi = crate::scene::view::dispatch::prop_current_vertex(); + let raw_index = polyline3d_control_indices(self).get(vi).copied(); + if let (Some(v), Some(vert)) = ( + parse_f64(value), + raw_index.and_then(|index| self.vertices.get_mut(index)), + ) { vert.position.y = v; + let _ = rebuild_polyline3d_fit(self); } } "pl3_vertex_z" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) { + let vi = crate::scene::view::dispatch::prop_current_vertex(); + let raw_index = polyline3d_control_indices(self).get(vi).copied(); + if let (Some(v), Some(vert)) = ( + parse_f64(value), + raw_index.and_then(|index| self.vertices.get_mut(index)), + ) { vert.position.z = v; + let _ = rebuild_polyline3d_fit(self); } } _ => {} diff --git a/src/modules/draw/draw/poly3d.rs b/src/modules/draw/draw/poly3d.rs index 4891d31f5..923d5d45c 100644 --- a/src/modules/draw/draw/poly3d.rs +++ b/src/modules/draw/draw/poly3d.rs @@ -67,7 +67,7 @@ impl CadCommand for Poly3dCommand { fn prompt(&self) -> String { match self.points.len() { 0 => t!("3DPOLY Specify start point of polyline:").into_owned(), - 1 => t!("3DPOLY Specify next point [Undo]:").into_owned(), + 1 | 2 => t!("3DPOLY Specify next point [Undo]:").into_owned(), _ => t!("3DPOLY Specify next point [Close/Undo]:").into_owned(), } } @@ -98,10 +98,10 @@ impl CadCommand for Poly3dCommand { fn on_text_input(&mut self, text: &str) -> Option { match text.trim().to_uppercase().as_str() { - "C" | "CLOSE" => match self.build(true) { - Some(e) => Some(CmdResult::CommitAndExit(e)), - None => Some(CmdResult::NeedPoint), - }, + "C" | "CLOSE" if self.points.len() >= 3 => self + .build(true) + .map(CmdResult::CommitAndExit), + "C" | "CLOSE" => Some(CmdResult::NeedPoint), "U" | "UNDO" => { self.points.pop(); Some(CmdResult::NeedPoint) diff --git a/src/ui/properties.rs b/src/ui/properties.rs index b6d129710..b6d6d697a 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -1701,6 +1701,9 @@ fn coord_suffix(label: &str) -> Option<(&str, usize)> { /// Length of the coordinate group starting at `idx`: consecutive text rows /// labelled " X", " Y" and optionally " Z". 0/1 = no group. fn coord_group_len(props: &[crate::scene::model::object::Property], idx: usize) -> usize { + if props[idx].field == "pl3_vertex_x" { + return 0; + } let groupable = |p: &crate::scene::model::object::Property| { matches!( p.value, From 1cc7d3cb67b4cd7e9e42f52764582c82818f2ec4 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:54:37 +0300 Subject: [PATCH 11/16] Prevent properties scrollbar overlap --- src/ui/properties.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/ui/properties.rs b/src/ui/properties.rs index b6d129710..2fee5df75 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -31,6 +31,7 @@ const PATTERN_PREVIEW_H: f32 = 58.0; const PATTERN_PICKER_W: f32 = 348.0; const PATTERN_PICKER_H: f32 = 720.0; const LINETYPE_MENU_W: f32 = 220.0; +const PROPERTIES_SCROLLBAR_GUTTER: f32 = 16.0; use crate::app::Message; use crate::scene::model::object::{PropSection, PropValue}; @@ -620,7 +621,13 @@ impl PropertiesPanel { for section in &self.sections { col = col.push(self.render_section(section)); } - scrollable(col).into() + let gutter = Padding { + right: PROPERTIES_SCROLLBAR_GUTTER, + ..Padding::ZERO + }; + scrollable(container(col.width(Length::Fill)).padding(gutter)) + .width(Length::Fill) + .into() }; container(column![header, title_bar, content]) @@ -673,7 +680,15 @@ impl PropertiesPanel { sections = sections.push(self.render_section(section)); } - let content = scrollable(sections).height(Length::Shrink); + let gutter = Padding { + right: PROPERTIES_SCROLLBAR_GUTTER, + ..Padding::ZERO + }; + let content = scrollable( + container(sections.width(Length::Fill)).padding(gutter), + ) + .width(Length::Fill) + .height(Length::Shrink); Some( container(column![title, content].spacing(0)) From 4d0c2b5389541011b8b47365da6a333062f9faaf Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:48:26 +0300 Subject: [PATCH 12/16] Update helix geometry dependencies --- Cargo.lock | 4 ++-- Cargo.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0583d5bfe..5e8d14fdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed#788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5ae4328#5ae4328d6bd084d5437281e832aa5a08028331d1" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b2b1d4b#b2b1d4b0cd7def2d26a0d519991fc89a89f916d3" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4e21836#4e21836f5fb9204c6ca2ec92f8e66a60dcff6511" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 64b2533c9..74f2f2075 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5ae4328", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4e21836", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5ae4328" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } From 1c88bb96badf8f4c1cc016af7577966084289a2f Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:48:33 +0300 Subject: [PATCH 13/16] Create parametric helix entities --- src/modules/draw/defaults.rs | 14 + src/modules/draw/draw/helix.rs | 531 +++++++++++++++++++++------------ 2 files changed, 359 insertions(+), 186 deletions(-) diff --git a/src/modules/draw/defaults.rs b/src/modules/draw/defaults.rs index f21a40f89..6f606c8e5 100644 --- a/src/modules/draw/defaults.rs +++ b/src/modules/draw/defaults.rs @@ -28,6 +28,10 @@ thread_local! { static RECT_FILLET: Cell = Cell::new(0.0); static DONUT_INNER_DIAMETER: Cell = Cell::new(0.5); static DONUT_OUTER_DIAMETER: Cell = Cell::new(1.0); + static HELIX_BASE_RADIUS: Cell = Cell::new(1.0); + static HELIX_HEIGHT: Cell = Cell::new(1.0); + static HELIX_TURNS: Cell = Cell::new(3.0); + static HELIX_COUNTER_CLOCKWISE: Cell = Cell::new(true); } macro_rules! accessors { @@ -70,6 +74,16 @@ accessors!(get_rect_rotation, set_rect_rotation, RECT_ROTATION); accessors!(get_rect_chamfer1, set_rect_chamfer1, RECT_CHAMFER1); accessors!(get_rect_chamfer2, set_rect_chamfer2, RECT_CHAMFER2); accessors!(get_rect_fillet, set_rect_fillet, RECT_FILLET); +accessors!(get_helix_base_radius, set_helix_base_radius, HELIX_BASE_RADIUS); +accessors!(get_helix_height, set_helix_height, HELIX_HEIGHT); +accessors!(get_helix_turns, set_helix_turns, HELIX_TURNS); +pub fn get_helix_counter_clockwise() -> bool { + HELIX_COUNTER_CLOCKWISE.with(|value| value.get()) +} + +pub fn set_helix_counter_clockwise(value: bool) { + HELIX_COUNTER_CLOCKWISE.with(|current| current.set(value)); +} accessors!( get_donut_inner_diameter, set_donut_inner_diameter, diff --git a/src/modules/draw/draw/helix.rs b/src/modules/draw/draw/helix.rs index 486124d24..8329454d7 100644 --- a/src/modules/draw/draw/helix.rs +++ b/src/modules/draw/draw/helix.rs @@ -1,35 +1,18 @@ -// Parametric helix tool — interactive command. -// -// Command: HELIX — build a parametric 3D helix and commit it as a single 3D -// polyline whose straight segments approximate the curve. The user picks a -// base centre point, then types the base radius, the top radius (Enter keeps -// it equal to the base radius for a cylindrical helix), the height, and the -// number of turns (Enter accepts a default of three turns). -// -// The curve is sampled at a fixed angular resolution: with `SEG_PER_TURN` -// samples per turn the helix has `turns * SEG_PER_TURN` segments. For each -// sample the radius interpolates linearly from base to top, the elevation -// interpolates linearly from zero to the height, and the planar position is -// `(radius*cos(angle), radius*sin(angle))` offset from the centre. - -use acadrust::entities::Polyline3D; +use acadrust::entities::{Helix, HelixConstraint, Spline}; use acadrust::types::Vector3; use acadrust::EntityType; -use crate::t; - -use crate::command::{CadCommand, CmdResult, DynField, WorkingPlane}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; +use cadkernel::space::{HelixCurve, HelixDirection}; use glam::DVec3; -/// Angular samples per full turn of the helix. -const SEG_PER_TURN: usize = 36; -/// Default number of turns when the user accepts the prompt with Enter. -const DEFAULT_TURNS: f64 = 3.0; +use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane}; +use crate::modules::draw::defaults; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use crate::t; -// ── Ribbon definition ───────────────────────────────────────────────────── +const EPSILON: f64 = 1.0e-9; -#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +#[allow(dead_code)] pub fn tool() -> ToolDef { ToolDef { id: "HELIX", @@ -39,16 +22,18 @@ pub fn tool() -> ToolDef { } } -// ── Command implementation ──────────────────────────────────────────────── - -/// Which parameter the command is currently collecting. #[derive(Clone, Copy, PartialEq, Eq)] enum Step { Center, BaseRadius, + BaseDiameter, TopRadius, - Height, + TopDiameter, + Final, + AxisEndpoint, Turns, + TurnHeight, + Twist, } pub struct HelixCommand { @@ -58,58 +43,127 @@ pub struct HelixCommand { top_radius: f64, height: f64, turns: f64, + requested_turn_height: Option, + counter_clockwise: bool, + constraint: HelixConstraint, plane: WorkingPlane, } impl HelixCommand { pub fn new() -> Self { + let base_radius = defaults::get_helix_base_radius(); Self { step: Step::Center, center: DVec3::ZERO, - base_radius: 0.0, - top_radius: 0.0, - height: 0.0, - turns: DEFAULT_TURNS, + base_radius, + top_radius: base_radius, + height: defaults::get_helix_height(), + turns: defaults::get_helix_turns(), + requested_turn_height: None, + counter_clockwise: defaults::get_helix_counter_clockwise(), + constraint: HelixConstraint::Turns, plane: WorkingPlane::default(), } } - /// Sample the helix into a list of world points. - fn sample_points(&self, base_radius: f64, top_radius: f64, height: f64, turns: f64) -> Vec { - let turns = if turns <= 0.0 { DEFAULT_TURNS } else { turns }; - let segments = ((turns * SEG_PER_TURN as f64).round() as usize).max(1); - let mut pts = Vec::with_capacity(segments + 1); - for t in 0..=segments { - let frac = t as f64 / segments as f64; - let angle = turns * std::f64::consts::TAU * frac; - let radius = base_radius + (top_radius - base_radius) * frac; - let z = height * frac; - pts.push( - self.center - + self.plane.x * (radius * angle.cos()) - + self.plane.y * (radius * angle.sin()) - + self.plane.z * z, - ); - } - pts - } - - /// Build the committed helix entity from the collected parameters. - fn build(&self) -> Option { - let pts = self.sample_points(self.base_radius, self.top_radius, self.height, self.turns); - if pts.len() < 2 { - return None; - } - let verts: Vec = pts.iter().map(|p| Vector3::new(p.x, p.y, p.z)).collect(); - let mut pl = Polyline3D::from_points(verts); - pl.flags.closed = false; - Some(EntityType::Polyline3D(pl)) - } - - /// Parse a positive distance from typed text; returns `None` on failure. fn parse_value(text: &str) -> Option { crate::entities::common::parse_typed_length(text) } + + fn axis_start_direction(&self, axis: DVec3) -> DVec3 { + let axis = axis.normalize_or(self.plane.z); + let first = self.plane.x - axis * self.plane.x.dot(axis); + if first.length_squared() > EPSILON * EPSILON { + first.normalize() + } else { + (self.plane.y - axis * self.plane.y.dot(axis)).normalize_or(DVec3::X) + } + } + + fn resolved_turns(&self, height: f64) -> f64 { + match self.requested_turn_height { + Some(turn_height) if turn_height > EPSILON => (height.abs() / turn_height).max(EPSILON), + _ => self.turns, + } + } + + fn kernel_curve(&self, height: f64, axis: DVec3) -> Option { + let axis = axis.normalize_or(self.plane.z); + let curve = HelixCurve { + base_center: self.center.to_array(), + axis_direction: axis.to_array(), + start_direction: self.axis_start_direction(axis).to_array(), + base_radius: self.base_radius, + top_radius: self.top_radius, + height, + turns: self.resolved_turns(height), + direction: if self.counter_clockwise { + HelixDirection::CounterClockwise + } else { + HelixDirection::Clockwise + }, + }; + curve.nurbs().map(|_| curve) + } + + fn build(&self, height: f64, axis: DVec3) -> Option { + let curve = self.kernel_curve(height, axis)?; + let nurbs = curve.nurbs()?; + let axis = DVec3::from_array(curve.axis_direction).normalize_or(self.plane.z); + let start_direction = DVec3::from_array(curve.start_direction).normalize_or(self.plane.x); + let mut spline = Spline::new(); + spline.degree = nurbs.degree() as i32; + spline.knots = nurbs.knots().to_vec(); + spline.control_points = nurbs + .control_points() + .iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect(); + spline.flags.planar = false; + spline.flags.rational = nurbs.is_rational(); + if spline.flags.rational { + spline.weights = nurbs.weights().to_vec(); + } + + let mut helix = Helix::new(); + helix.spline = spline; + helix.axis_base_point = Vector3::new(self.center.x, self.center.y, self.center.z); + let start = self.center + start_direction * curve.base_radius; + helix.start_point = Vector3::new(start.x, start.y, start.z); + helix.axis_vector = Vector3::new(axis.x, axis.y, axis.z); + helix.radius = curve.base_radius; + helix.turns = curve.turns; + helix.turn_height = height / curve.turns; + helix.handedness = self.counter_clockwise; + helix.constraint = self.constraint; + Some(EntityType::Helix(helix)) + } + + fn commit(&mut self, height: f64, axis: DVec3) -> CmdResult { + let Some(entity) = self.build(height, axis) else { + return CmdResult::NeedPoint; + }; + self.height = height; + self.turns = self.resolved_turns(height); + defaults::set_helix_base_radius(self.base_radius); + defaults::set_helix_height(height); + defaults::set_helix_turns(self.turns); + defaults::set_helix_counter_clockwise(self.counter_clockwise); + CmdResult::CommitAndExit(entity) + } + + fn preview(&self, height: f64, axis: DVec3) -> Option { + let points = self + .kernel_curve(height, axis)? + .nurbs()? + .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE); + Some(WireModel::solid_f64( + "helix_preview".to_string(), + points, + WireModel::CYAN, + false, + )) + } } impl CadCommand for HelixCommand { @@ -123,65 +177,148 @@ impl CadCommand for HelixCommand { fn prompt(&self) -> String { match self.step { - Step::Center => t!("HELIX Specify centre point of base:").into_owned(), - Step::BaseRadius => t!("HELIX Specify base radius:").into_owned(), - Step::TopRadius => t!("HELIX Specify top radius :").into_owned(), - Step::Height => t!("HELIX Specify helix height:").into_owned(), - Step::Turns => t!("HELIX Enter number of turns <3>:").into_owned(), + Step::Center => crate::tf!( + "HELIX Number of turns = {:.4} Twist = {}\nSpecify center point of base:", + self.turns, + if self.counter_clockwise { "CCW" } else { "CW" } + ) + .into_owned(), + Step::BaseRadius => crate::tf!( + "HELIX Specify base radius or [Diameter] <{:.4}>:", + self.base_radius + ) + .into_owned(), + Step::BaseDiameter => crate::tf!( + "HELIX Specify base diameter <{:.4}>:", + self.base_radius * 2.0 + ) + .into_owned(), + Step::TopRadius => crate::tf!( + "HELIX Specify top radius or [Diameter] <{:.4}>:", + self.base_radius + ) + .into_owned(), + Step::TopDiameter => crate::tf!( + "HELIX Specify top diameter <{:.4}>:", + self.base_radius * 2.0 + ) + .into_owned(), + Step::Final => crate::tf!( + "HELIX Specify helix height or [Axis endpoint/Turns/turn Height/tWist] <{:.4}>:", + self.height + ) + .into_owned(), + Step::AxisEndpoint => t!("HELIX Specify axis endpoint:").into_owned(), + Step::Turns => crate::tf!("HELIX Enter number of turns <{:.4}>:", self.turns).into_owned(), + Step::TurnHeight => crate::tf!( + "HELIX Specify distance between turns <{:.4}>:", + self.height.abs() / self.turns.max(EPSILON) + ) + .into_owned(), + Step::Twist => crate::tf!( + "HELIX Enter twist direction [Clockwise/Counterclockwise] <{}>:", + if self.counter_clockwise { "CCW" } else { "CW" } + ) + .into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { + fn options(&self) -> Vec { + match self.step { + Step::BaseRadius | Step::TopRadius => vec![CmdOption::new(t!("Diameter").as_ref(), "D")], + Step::Final => vec![ + CmdOption::new(t!("Axis endpoint").as_ref(), "A"), + CmdOption::new(t!("Turns").as_ref(), "T"), + CmdOption::new(t!("Turn Height").as_ref(), "H"), + CmdOption::new(t!("Twist").as_ref(), "W"), + ], + Step::Twist => vec![ + CmdOption::new(t!("Clockwise").as_ref(), "CW"), + CmdOption::new(t!("Counterclockwise").as_ref(), "CCW"), + ], + _ => Vec::new(), + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::BaseRadius | Step::TopRadius | Step::Final | Step::Twist) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { match self.step { Step::Center => { - self.center = pt; + self.center = point; self.step = Step::BaseRadius; CmdResult::NeedPoint } - Step::BaseRadius => { - // A point click resolves the base radius as the planar distance - // from the centre to the picked point. - let delta = self.plane.vector_to_local(pt - self.center); - let r = delta.x.hypot(delta.y); - if r > 0.0 { - self.base_radius = r; + Step::BaseRadius | Step::BaseDiameter => { + let local = self.plane.vector_to_local(point - self.center); + let radius = local.x.hypot(local.y); + if radius > EPSILON { + self.base_radius = radius; + self.top_radius = radius; self.step = Step::TopRadius; } CmdResult::NeedPoint } - Step::TopRadius => { - let delta = self.plane.vector_to_local(pt - self.center); - let r = delta.x.hypot(delta.y); - self.top_radius = if r > 0.0 { r } else { self.base_radius }; - self.step = Step::Height; + Step::TopRadius | Step::TopDiameter => { + let local = self.plane.vector_to_local(point - self.center); + self.top_radius = local.x.hypot(local.y); + self.step = Step::Final; CmdResult::NeedPoint } - Step::Height => { - // Use the elevation of the picked point relative to the centre. - let h = (pt - self.center).dot(self.plane.z); - if h.abs() > 0.0 { - self.height = h; - self.step = Step::Turns; + Step::Final => { + let height = (point - self.center).dot(self.plane.z); + if height.abs() <= EPSILON { + CmdResult::NeedPoint + } else { + self.commit(height, self.plane.z) } - CmdResult::NeedPoint } - Step::Turns => CmdResult::NeedPoint, + Step::AxisEndpoint => { + let vector = point - self.center; + let height = vector.length(); + if height <= EPSILON { + CmdResult::NeedPoint + } else { + self.commit(height, vector / height) + } + } + Step::Turns | Step::TurnHeight | Step::Twist => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { match self.step { - Step::TopRadius => { - // Enter keeps the top radius equal to the base radius. + Step::Center => CmdResult::Cancel, + Step::BaseRadius | Step::BaseDiameter => { self.top_radius = self.base_radius; - self.step = Step::Height; + self.step = Step::TopRadius; + CmdResult::NeedPoint + } + Step::TopRadius | Step::TopDiameter => { + self.top_radius = self.base_radius; + self.step = Step::Final; + CmdResult::NeedPoint + } + Step::Final => self.commit(self.height, self.plane.z), + Step::AxisEndpoint => CmdResult::NeedPoint, + Step::Turns => { + self.constraint = HelixConstraint::Turns; + self.requested_turn_height = None; + self.step = Step::Final; + CmdResult::NeedPoint + } + Step::TurnHeight => { + self.constraint = HelixConstraint::TurnHeight; + self.requested_turn_height = Some(self.height.abs() / self.turns.max(EPSILON)); + self.step = Step::Final; + CmdResult::NeedPoint + } + Step::Twist => { + self.step = Step::Final; CmdResult::NeedPoint } - Step::Turns => match self.build() { - Some(e) => CmdResult::CommitAndExit(e), - None => CmdResult::Cancel, - }, - _ => CmdResult::NeedPoint, } } @@ -190,123 +327,145 @@ impl CadCommand for HelixCommand { } fn wants_text_input(&self) -> bool { - // Every step past the centre pick reads a typed numeric value. self.step != Step::Center } fn dyn_field(&self) -> DynField { match self.step { - Step::Center => DynField::Point, - Step::BaseRadius | Step::TopRadius => DynField::Distance, - Step::Height => DynField::Distance, - Step::Turns => DynField::Scalar, + Step::Center | Step::AxisEndpoint => DynField::Point, + Step::BaseRadius | Step::BaseDiameter | Step::TopRadius | Step::TopDiameter | Step::Final | Step::TurnHeight => DynField::Distance, + Step::Turns | Step::Twist => DynField::Scalar, } } fn on_text_input(&mut self, text: &str) -> Option { - let t = text.trim(); + let value = text.trim(); + let keyword = value.to_ascii_uppercase(); match self.step { Step::Center => None, + Step::BaseRadius if matches!(keyword.as_str(), "D" | "DIAMETER") => { + self.step = Step::BaseDiameter; + Some(CmdResult::NeedPoint) + } + Step::TopRadius if matches!(keyword.as_str(), "D" | "DIAMETER") => { + self.step = Step::TopDiameter; + Some(CmdResult::NeedPoint) + } + Step::Final if matches!(keyword.as_str(), "A" | "AXIS" | "AXIS ENDPOINT") => { + self.step = Step::AxisEndpoint; + Some(CmdResult::NeedPoint) + } + Step::Final if matches!(keyword.as_str(), "T" | "TURNS") => { + self.step = Step::Turns; + Some(CmdResult::NeedPoint) + } + Step::Final if matches!(keyword.as_str(), "H" | "HEIGHT" | "TURN HEIGHT") => { + self.step = Step::TurnHeight; + Some(CmdResult::NeedPoint) + } + Step::Final if matches!(keyword.as_str(), "W" | "TWIST") => { + self.step = Step::Twist; + Some(CmdResult::NeedPoint) + } Step::BaseRadius => { - if let Some(v) = Self::parse_value(t) { - if v > 0.0 { - self.base_radius = v; - self.step = Step::TopRadius; - } + let radius = Self::parse_value(value)?; + if radius > EPSILON { + self.base_radius = radius; + self.top_radius = radius; + self.step = Step::TopRadius; + } + Some(CmdResult::NeedPoint) + } + Step::BaseDiameter => { + let diameter = Self::parse_value(value)?; + if diameter > EPSILON { + self.base_radius = diameter * 0.5; + self.top_radius = self.base_radius; + self.step = Step::TopRadius; } Some(CmdResult::NeedPoint) } Step::TopRadius => { - if t.is_empty() { - // Empty entry keeps the top radius equal to the base radius. - self.top_radius = self.base_radius; - self.step = Step::Height; - } else if let Some(v) = Self::parse_value(t) { - self.top_radius = if v > 0.0 { v } else { self.base_radius }; - self.step = Step::Height; + let radius = Self::parse_value(value)?; + if radius >= 0.0 { + self.top_radius = radius; + self.step = Step::Final; } Some(CmdResult::NeedPoint) } - Step::Height => { - if let Some(v) = Self::parse_value(t) { - if v != 0.0 { - self.height = v; - self.step = Step::Turns; - } + Step::TopDiameter => { + let diameter = Self::parse_value(value)?; + if diameter >= 0.0 { + self.top_radius = diameter * 0.5; + self.step = Step::Final; } Some(CmdResult::NeedPoint) } + Step::Final => { + let height = Self::parse_value(value)?; + (height.abs() > EPSILON).then(|| self.commit(height, self.plane.z)) + } + Step::AxisEndpoint => None, Step::Turns => { - self.turns = if t.is_empty() { - DEFAULT_TURNS - } else { - match Self::parse_value(t) { - Some(v) if v > 0.0 => v, - _ => return Some(CmdResult::NeedPoint), - } - }; - match self.build() { - Some(e) => Some(CmdResult::CommitAndExit(e)), - None => Some(CmdResult::Cancel), + let turns = Self::parse_value(value)?; + if turns > EPSILON { + self.turns = turns; + self.requested_turn_height = None; + self.constraint = HelixConstraint::Turns; + self.step = Step::Final; } + Some(CmdResult::NeedPoint) + } + Step::TurnHeight => { + let turn_height = Self::parse_value(value)?; + if turn_height > EPSILON { + self.requested_turn_height = Some(turn_height); + self.constraint = HelixConstraint::TurnHeight; + self.step = Step::Final; + } + Some(CmdResult::NeedPoint) + } + Step::Twist => { + match keyword.as_str() { + "C" | "CW" | "CLOCKWISE" => self.counter_clockwise = false, + "CC" | "CCW" | "COUNTERCLOCKWISE" => self.counter_clockwise = true, + _ => return Some(CmdResult::NeedPoint), + } + self.step = Step::Final; + Some(CmdResult::NeedPoint) } } } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - // Preview the helix once enough parameters are known. Before a base - // radius is fixed, track the cursor distance to size a live ring. + fn on_mouse_move(&mut self, point: DVec3) -> Option { match self.step { Step::Center => None, - Step::BaseRadius => { - let delta = self.plane.vector_to_local(pt - self.center); - let r = delta.x.hypot(delta.y); - if r <= 0.0 { - return None; - } - let pts = self.sample_points(r, r, 0.0, 1.0); - let raw: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect(); - Some(WireModel::solid_f64( - "helix_preview".to_string(), - raw, - WireModel::CYAN, - false, - )) + Step::BaseRadius | Step::BaseDiameter => { + let local = self.plane.vector_to_local(point - self.center); + let old = (self.base_radius, self.top_radius); + self.base_radius = local.x.hypot(local.y).max(EPSILON); + self.top_radius = self.base_radius; + let preview = self.preview(0.0, self.plane.z); + (self.base_radius, self.top_radius) = old; + preview } - _ => { - // Use the parameters fixed so far; for the height step let the - // cursor elevation drive the live height. - let height = if self.step == Step::Height { - (pt - self.center).dot(self.plane.z) - } else { - self.height - }; - let top = if self.step == Step::TopRadius { - let delta = self.plane.vector_to_local(pt - self.center); - let r = delta.x.hypot(delta.y); - if r > 0.0 { - r - } else { - self.base_radius - } - } else { - self.top_radius - }; - let pts = self.sample_points(self.base_radius, top, height, self.turns); - if pts.len() < 2 { - return None; - } - let raw: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect(); - Some(WireModel::solid_f64( - "helix_preview".to_string(), - raw, - WireModel::CYAN, - false, - )) + Step::TopRadius | Step::TopDiameter => { + let local = self.plane.vector_to_local(point - self.center); + let old = self.top_radius; + self.top_radius = local.x.hypot(local.y); + let preview = self.preview(0.0, self.plane.z); + self.top_radius = old; + preview } + Step::Final => self.preview((point - self.center).dot(self.plane.z), self.plane.z), + Step::AxisEndpoint => { + let vector = point - self.center; + let height = vector.length(); + (height > EPSILON).then(|| self.preview(height, vector / height)).flatten() + } + Step::Turns | Step::TurnHeight | Step::Twist => self.preview(self.height, self.plane.z), } } } -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["HELIX"] }); // HelixCommand +inventory::submit!(crate::command::CommandRegistration { names: &["HELIX"] }); From bfb649a8bbe741918a88ac21b583d03357154265 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:48:38 +0300 Subject: [PATCH 14/16] Align helix properties and editing --- src/entities/helix.rs | 397 +++++++++++++++++++++++++++++++++--------- 1 file changed, 316 insertions(+), 81 deletions(-) diff --git a/src/entities/helix.rs b/src/entities/helix.rs index 2e5bf7d1c..36465ba55 100644 --- a/src/entities/helix.rs +++ b/src/entities/helix.rs @@ -1,106 +1,339 @@ -// Helix entity (AcDbHelix): a spline curve plus its generating parameters. -// -// A loaded helix carries a fully evaluated `spline` (the NURBS the file -// stored) alongside the parameters that generated it (base point, axis, -// radius, turns, turn height, twist, constraint). We render and grip the -// entity through its embedded spline, and expose the generating parameters -// as the Geometry property group. - -use acadrust::entities::{Helix, HelixConstraint}; -use crate::t; +use acadrust::entities::{Helix, HelixConstraint, Spline}; +use acadrust::types::Vector3; +use cadkernel::space::{HelixCurve, HelixDirection, NurbsCurve3}; +use glam::DVec3; use crate::command::EntityTransform; -use crate::entities::common::ro_prop as ro; -use crate::entities::traits::{Grippable, Transformable, RenderConvertible}; +use crate::entities::common::{ + center_grip, edit_prop as edit, format_angle, format_length, parse_f64, ro_prop as ro, + square_grip, +}; +use crate::entities::traits::{Grippable, RenderConvertible, Transformable}; use crate::scene::convert::acad_to_render::RenderEntity; -use crate::scene::model::object::{GripApply, GripDef, PropSection}; +use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; +use crate::t; -/// Perpendicular distance from `p` to the helix axis (line through -/// `axis_base_point` along `axis_vector`). Used to recover the top radius -/// from the last defining point of the spline. -fn radius_from_axis(helix: &Helix, p: &acadrust::types::Vector3) -> Option { - let base = &helix.axis_base_point; - let axis = helix.axis_vector.normalize(); - // A zero axis vector cannot define a line. - if axis.length() < 1e-9 { +const EPSILON: f64 = 1.0e-9; + +fn point(vector: Vector3) -> DVec3 { + DVec3::new(vector.x, vector.y, vector.z) +} + +fn vector(value: DVec3) -> Vector3 { + Vector3::new(value.x, value.y, value.z) +} + +fn spline_curve(spline: &Spline) -> Option { + let controls = spline + .control_points + .iter() + .map(|value| [value.x, value.y, value.z]) + .collect(); + let weights = (spline.weights.len() == spline.control_points.len()) + .then(|| spline.weights.clone()); + NurbsCurve3::new(spline.degree.max(1) as usize, controls, spline.knots.clone(), weights) +} + +fn axis_frame(helix: &Helix) -> Option<(DVec3, DVec3, DVec3)> { + let base = point(helix.axis_base_point); + let axis = point(helix.axis_vector).normalize_or_zero(); + if axis.length_squared() <= EPSILON * EPSILON { return None; } - let vx = p.x - base.x; - let vy = p.y - base.y; - let vz = p.z - base.z; - let proj = vx * axis.x + vy * axis.y + vz * axis.z; - let px = vx - proj * axis.x; - let py = vy - proj * axis.y; - let pz = vz - proj * axis.z; - Some((px * px + py * py + pz * pz).sqrt()) + let radial = point(helix.start_point) - base; + let start_direction = (radial - axis * radial.dot(axis)).normalize_or_zero(); + if start_direction.length_squared() <= EPSILON * EPSILON { + return None; + } + Some((base, axis, start_direction)) +} + +fn radius_from_axis(helix: &Helix, value: DVec3) -> Option { + let (base, axis, _) = axis_frame(helix)?; + let delta = value - base; + Some((delta - axis * delta.dot(axis)).length()) +} + +fn top_radius(helix: &Helix) -> f64 { + spline_curve(&helix.spline) + .map(|curve| DVec3::from_array(curve.point_at(1.0))) + .and_then(|endpoint| radius_from_axis(helix, endpoint)) + .unwrap_or(helix.radius) +} + +fn kernel_curve(helix: &Helix, top_radius: f64) -> Option { + let (base, axis, start_direction) = axis_frame(helix)?; + Some(HelixCurve { + base_center: base.to_array(), + axis_direction: axis.to_array(), + start_direction: start_direction.to_array(), + base_radius: helix.radius, + top_radius, + height: helix.turns * helix.turn_height, + turns: helix.turns, + direction: if helix.handedness { + HelixDirection::CounterClockwise + } else { + HelixDirection::Clockwise + }, + }) +} + +fn rebuild(helix: &mut Helix, top_radius: f64) -> bool { + let Some(curve) = kernel_curve(helix, top_radius) else { + return false; + }; + let Some(nurbs) = curve.nurbs() else { + return false; + }; + helix.spline.degree = nurbs.degree() as i32; + helix.spline.knots = nurbs.knots().to_vec(); + helix.spline.control_points = nurbs + .control_points() + .iter() + .map(|value| Vector3::new(value[0], value[1], value[2])) + .collect(); + helix.spline.weights = if nurbs.is_rational() { + nurbs.weights().to_vec() + } else { + Vec::new() + }; + helix.spline.fit_points.clear(); + helix.spline.flags.closed = false; + helix.spline.flags.periodic = false; + helix.spline.flags.rational = nurbs.is_rational(); + helix.spline.flags.planar = false; + helix.spline.flags.linear = false; + true +} + +fn choice(label: &str, field: &'static str, selected: &str, options: &[String]) -> Property { + Property { + label: label.to_string(), + field, + value: PropValue::Choice { + selected: selected.to_string(), + options: options.to_vec(), + }, + } } fn grips(helix: &Helix) -> Vec { - Grippable::grips(&helix.spline) + let Some((base, axis, _)) = axis_frame(helix) else { + return Vec::new(); + }; + let height = helix.turns * helix.turn_height; + let top_center = base + axis * height; + let top_endpoint = spline_curve(&helix.spline) + .map(|curve| DVec3::from_array(curve.point_at(1.0))) + .unwrap_or(top_center); + vec![ + center_grip(0, base), + square_grip(1, point(helix.start_point)), + square_grip(2, top_center), + square_grip(3, top_endpoint), + ] } fn properties(helix: &Helix) -> Vec { - let bp = &helix.axis_base_point; - let turns = helix.turns; - let turn_height = helix.turn_height; - // Overall height is turns × per-turn rise. - let height = turns * turn_height; - let base_radius = helix.radius; - // Top radius: the axial distance of the last defining point. For a - // cylindrical helix this equals the base radius. - let top_radius = helix - .spline - .control_points - .last() - .or_else(|| helix.spline.fit_points.last()) - .and_then(|p| radius_from_axis(helix, p)) - .unwrap_or(base_radius); - // Turn slope: rise angle of a single turn against the base circumference. - let turn_slope = if base_radius.abs() > 1e-9 { - (turn_height / (std::f64::consts::TAU * base_radius)) - .atan() - .to_degrees() - } else { - 0.0 - }; - let twist = if helix.handedness { "CW" } else { "CCW" }; + let base = point(helix.axis_base_point); + let height = helix.turns * helix.turn_height; + let top_radius = top_radius(helix); + let curve = kernel_curve(helix, top_radius); + let turn_slope = curve.as_ref().and_then(HelixCurve::turn_slope).unwrap_or(0.0); + let total_length = curve.as_ref().and_then(HelixCurve::length).unwrap_or(0.0); let constrain = match helix.constraint { - HelixConstraint::TurnHeight => "Turn Height", - HelixConstraint::Turns => "Turns", - HelixConstraint::Height => "Height", + HelixConstraint::TurnHeight => t!("Turn Height").into_owned(), + HelixConstraint::Turns => t!("Turns").into_owned(), + HelixConstraint::Height => t!("Height").into_owned(), }; + let twist = if helix.handedness { + t!("CCW").into_owned() + } else { + t!("CW").into_owned() + }; + let constraint_options = vec![ + t!("Turn Height").into_owned(), + t!("Turns").into_owned(), + t!("Height").into_owned(), + ]; + let twist_options = vec![t!("CW").into_owned(), t!("CCW").into_owned()]; vec![PropSection { title: t!("Geometry").into_owned(), props: vec![ - ro(t!("Base point X").as_ref(), "base_x", format!("{:.4}", bp.x)), - ro(t!("Base point Y").as_ref(), "base_y", format!("{:.4}", bp.y)), - ro(t!("Base point Z").as_ref(), "base_z", format!("{:.4}", bp.z)), - ro(t!("Number of turns").as_ref(), "num_turns", format!("{turns:.4}")), - ro(t!("Turn height").as_ref(), "turn_height", format!("{turn_height:.4}")), - ro(t!("Turns").as_ref(), "turns", format!("{turns:.4}")), - ro(t!("Height").as_ref(), "height", format!("{height:.4}")), - ro(t!("Base radius").as_ref(), "base_radius", format!("{base_radius:.4}")), - ro(t!("Top radius").as_ref(), "top_radius", format!("{top_radius:.4}")), - ro(t!("Turn slope").as_ref(), "turn_slope", format!("{turn_slope:.4}")), - ro(t!("Twist").as_ref(), "twist", twist), - ro(t!("Constrain").as_ref(), "constrain", constrain), + edit(t!("Position X").as_ref(), "position_x", base.x), + edit(t!("Position Y").as_ref(), "position_y", base.y), + edit(t!("Position Z").as_ref(), "position_z", base.z), + choice(t!("Constrain").as_ref(), "constrain", &constrain, &constraint_options), + edit(t!("Height").as_ref(), "height", height), + edit(t!("Turns").as_ref(), "turns", helix.turns), + edit(t!("Turn height").as_ref(), "turn_height", helix.turn_height), + edit(t!("Base radius").as_ref(), "base_radius", helix.radius), + edit(t!("Top radius").as_ref(), "top_radius", top_radius), + choice(t!("Twist").as_ref(), "twist", &twist, &twist_options), + ro(t!("Turn slope").as_ref(), "turn_slope", format_angle(turn_slope)), + ro(t!("Total length").as_ref(), "total_length", format_length(total_length)), ], }] } -/// Helix geometry is derived from the stored spline; the generating -/// parameters are read-only (editing one would require regenerating the -/// spline, which the loaded entity does not carry a recipe for). -fn apply_geom_prop(_helix: &mut Helix, _field: &str, _value: &str) {} - -fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) { - Grippable::apply_grip(&mut helix.spline, grip_id, apply); +fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { + if field == "constrain" { + helix.constraint = if value == t!("Height").as_ref() { + HelixConstraint::Height + } else if value == t!("Turns").as_ref() { + HelixConstraint::Turns + } else { + HelixConstraint::TurnHeight + }; + return; + } + let top = top_radius(helix); + if field == "twist" { + helix.handedness = value == t!("CCW").as_ref(); + let _ = rebuild(helix, top); + return; + } + let Some(number) = parse_f64(value) else { + return; + }; + let old_base = point(helix.axis_base_point); + let old_height = helix.turns * helix.turn_height; + match field { + "position_x" | "position_y" | "position_z" => { + let mut base = old_base; + match field { + "position_x" => base.x = number, + "position_y" => base.y = number, + _ => base.z = number, + } + let delta = base - old_base; + helix.axis_base_point = vector(base); + helix.start_point = vector(point(helix.start_point) + delta); + } + "height" if number.abs() > EPSILON => { + if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON { + helix.turns = (number / helix.turn_height).abs().max(EPSILON); + } else { + helix.turn_height = number / helix.turns.max(EPSILON); + } + } + "turns" if number > EPSILON => { + if helix.constraint == HelixConstraint::TurnHeight { + helix.turns = number; + } else { + helix.turns = number; + helix.turn_height = old_height / number; + } + } + "turn_height" if number.abs() > EPSILON => { + if helix.constraint == HelixConstraint::Turns { + helix.turn_height = number; + } else { + helix.turn_height = number; + helix.turns = (old_height / number).abs().max(EPSILON); + } + } + "base_radius" if number > EPSILON => { + let Some((base, _, start_direction)) = axis_frame(helix) else { + return; + }; + helix.radius = number; + helix.start_point = vector(base + start_direction * number); + } + "top_radius" if number >= 0.0 => { + let _ = rebuild(helix, number); + return; + } + _ => return, + } + let _ = rebuild(helix, top); } -fn apply_transform(helix: &mut Helix, t: &EntityTransform) { - // Move the visible curve, then keep the generating points in step. - Transformable::apply_transform(&mut helix.spline, t); +fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) { + let top = top_radius(helix); + let Some((base, axis, _)) = axis_frame(helix) else { + return; + }; + match (grip_id, apply) { + (0, GripApply::Translate(delta)) => { + helix.axis_base_point = vector(base + delta); + helix.start_point = vector(point(helix.start_point) + delta); + } + (0, GripApply::Absolute(position)) => { + let delta = position - base; + helix.axis_base_point = vector(position); + helix.start_point = vector(point(helix.start_point) + delta); + } + (1, GripApply::Absolute(position)) => { + let radial = position - base - axis * (position - base).dot(axis); + let radius = radial.length(); + if radius <= EPSILON { + return; + } + helix.radius = radius; + helix.start_point = vector(base + radial); + } + (2, GripApply::Absolute(position)) => { + let axis_vector = position - base; + let height = axis_vector.length(); + if height <= EPSILON { + return; + } + helix.axis_vector = vector(axis_vector / height); + if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON { + helix.turns = (height / helix.turn_height).abs().max(EPSILON); + } else { + helix.turn_height = height / helix.turns.max(EPSILON); + } + } + (3, GripApply::Absolute(position)) => { + let delta = position - base; + let height = delta.dot(axis); + let radius = (delta - axis * height).length(); + if height.abs() <= EPSILON { + return; + } + if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON { + helix.turns = (height / helix.turn_height).abs().max(EPSILON); + } else { + helix.turn_height = height / helix.turns.max(EPSILON); + } + let _ = rebuild(helix, radius); + return; + } + _ => return, + } + let _ = rebuild(helix, top); +} + +fn apply_transform(helix: &mut Helix, transform: &EntityTransform) { + match transform { + EntityTransform::Translate(delta) => { + acadrust::Entity::translate(helix, Vector3::new(delta.x, delta.y, delta.z)); + } + EntityTransform::Rotate { center, axis, angle_rad } => { + crate::scene::view::transform::apply_standard_transform( + helix, + *center, + *axis, + *angle_rad, + ); + } + EntityTransform::Scale { center, factor } => { + crate::scene::view::transform::apply_standard_scale(helix, *center, *factor); + } + EntityTransform::Mirror { p1, p2, working_normal } => { + let reflection = crate::scene::view::transform::reflection_about_working_line( + *p1, + *p2, + *working_normal, + ); + acadrust::Entity::apply_transform(helix, &reflection); + } + EntityTransform::Affine(value) => acadrust::Entity::apply_transform(helix, value), + } } impl RenderConvertible for Helix { @@ -113,6 +346,7 @@ impl Grippable for Helix { fn grips(&self) -> Vec { grips(self) } + fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { apply_grip(self, grip_id, apply); } @@ -122,13 +356,14 @@ impl crate::entities::traits::PropertyEditable for Helix { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { properties(self) } + fn apply_geom_prop(&mut self, field: &str, value: &str) { apply_geom_prop(self, field, value); } } impl Transformable for Helix { - fn apply_transform(&mut self, t: &EntityTransform) { - apply_transform(self, t); + fn apply_transform(&mut self, transform: &EntityTransform) { + apply_transform(self, transform); } } From b8fb6f5ddb1324409225a81f9d24838451c88853 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:35:56 +0300 Subject: [PATCH 15/16] Complete 3D face creation and editing --- src/app/commands/draw.rs | 25 ++ src/app/properties.rs | 3 + src/app/update/mod.rs | 3 + src/entities/mesh.rs | 120 +++++--- src/modules/draw/draw/face3d.rs | 509 ++++++++++++++++++++++++++++++++ src/modules/draw/draw/mod.rs | 1 + 6 files changed, 616 insertions(+), 45 deletions(-) create mode 100644 src/modules/draw/draw/face3d.rs diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index d9edbc862..d74a2258f 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -454,6 +454,31 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "3DFACE" => { + use crate::modules::draw::draw::face3d::Face3dCommand; + let new_cmd = Face3dCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "EDGE" => { + use crate::modules::draw::draw::face3d::FaceEdgeCommand; + let faces = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| match entity { + acadrust::EntityType::Face3D(face) => { + Some((face.common.handle, face.clone())) + } + _ => None, + }) + .collect(); + let new_cmd = FaceEdgeCommand::new(faces); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + // 2D filled solid. Reached via SO / SOLID2D — the bare SOLID verb is // currently the shaded-display toggle (token collision tracked). "SOLID" | "SOLID2D" => { diff --git a/src/app/properties.rs b/src/app/properties.rs index d884905a7..58caa3d41 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -122,6 +122,9 @@ impl OpenCADStudio { acadrust::EntityType::LwPolyline(polyline) => Some(polyline.vertices.len()), acadrust::EntityType::Polyline2D(polyline) => Some(polyline.vertices.len()), acadrust::EntityType::Leader(leader) => Some(leader.vertices.len()), + acadrust::EntityType::Face3D(face) => { + Some(if face.is_triangle() { 3 } else { 4 }) + } acadrust::EntityType::Spline(spline) => { Some(if crate::entities::spline::shows_fit_points(spline) { spline.fit_points.len() diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index cb501dc7d..ff4342b82 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4901,6 +4901,9 @@ impl OpenCADStudio { match self.tabs[i].scene.document.get_entity(handles[0]) { Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(), Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), + Some(acadrust::EntityType::Face3D(face)) => { + if face.is_triangle() { 3 } else { 4 } + } Some(acadrust::EntityType::Table(table)) => { table.row_count().saturating_mul(table.column_count()) } diff --git a/src/entities/mesh.rs b/src/entities/mesh.rs index 664ab8757..e4bafc8f4 100644 --- a/src/entities/mesh.rs +++ b/src/entities/mesh.rs @@ -5,7 +5,9 @@ use glam::Vec3; use crate::t; use crate::command::EntityTransform; -use crate::entities::common::{parse_f64, ro_prop as ro, square_grip}; +use crate::entities::common::{ + edit_prop as edit, parse_f64, ro_prop as ro, stepper_prop as stepper, square_grip, +}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; @@ -238,15 +240,6 @@ impl RenderConvertible for Face3D { add_edge(p3, p0); } - if pts.is_empty() { - // All edges invisible — show a tiny cross at centroid. - let cx = (p0[0] + p1[0] + p2[0] + p3[0]) / 4.0; - let cy = (p0[1] + p1[1] + p2[1] + p3[1]) / 4.0; - let cz = (p0[2] + p1[2] + p2[2] + p3[2]) / 4.0; - let s = 0.1_f64; - pts = vec![[cx - s, cy, cz], [cx + s, cy, cz]]; - } - Some(RenderEntity { pick_tris: Vec::new(), object: RenderObject::Lines(pts), @@ -265,15 +258,19 @@ impl RenderConvertible for Face3D { impl Grippable for Face3D { fn grips(&self) -> Vec { - vec![ + let mut grips = vec![ square_grip(0, dvec3(&self.first_corner)), square_grip(1, dvec3(&self.second_corner)), square_grip(2, dvec3(&self.third_corner)), - square_grip(3, dvec3(&self.fourth_corner)), - ] + ]; + if !self.is_triangle() { + grips.push(square_grip(3, dvec3(&self.fourth_corner))); + } + grips } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + let was_triangle = self.is_triangle(); let corner = match grip_id { 0 => &mut self.first_corner, 1 => &mut self.second_corner, @@ -293,57 +290,90 @@ impl Grippable for Face3D { corner.z = p.z as f64; } } + if was_triangle && grip_id == 2 { + self.fourth_corner = self.third_corner; + } } } impl PropertyEditable for Face3D { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { - use crate::entities::common::edit_prop as edit; + let was_triangle = self.is_triangle(); + let vertex_count = if was_triangle { 3 } else { 4 }; + let current = crate::scene::view::dispatch::prop_current_vertex() + .min(vertex_count - 1); + let corner = match current { + 0 => &self.first_corner, + 1 => &self.second_corner, + 2 => &self.third_corner, + _ => &self.fourth_corner, + }; let inv = self.invisible_edges; - let edge = |hidden: bool| if hidden { "Invisible" } else { "Visible" }; + let edge = |label: &str, field: &'static str, hidden: bool| Property { + label: label.to_string(), + field, + value: PropValue::Choice { + selected: if hidden { + t!("Invisible").into_owned() + } else { + t!("Visible").into_owned() + }, + options: vec![t!("Visible").into_owned(), t!("Invisible").into_owned()], + }, + }; vec![PropSection { title: t!("Geometry").into_owned(), props: vec![ - ro(t!("Current vertex").as_ref(), "f3_current", String::new()), - edit(t!("Vertex 1 X").as_ref(), "f3_p1x", self.first_corner.x), - edit(t!("Vertex 1 Y").as_ref(), "f3_p1y", self.first_corner.y), - edit(t!("Vertex 1 Z").as_ref(), "f3_p1z", self.first_corner.z), - edit(t!("Vertex 2 X").as_ref(), "f3_p2x", self.second_corner.x), - edit(t!("Vertex 2 Y").as_ref(), "f3_p2y", self.second_corner.y), - edit(t!("Vertex 2 Z").as_ref(), "f3_p2z", self.second_corner.z), - edit(t!("Vertex 3 X").as_ref(), "f3_p3x", self.third_corner.x), - edit(t!("Vertex 3 Y").as_ref(), "f3_p3y", self.third_corner.y), - edit(t!("Vertex 3 Z").as_ref(), "f3_p3z", self.third_corner.z), - edit(t!("Vertex 4 X").as_ref(), "f3_p4x", self.fourth_corner.x), - edit(t!("Vertex 4 Y").as_ref(), "f3_p4y", self.fourth_corner.y), - edit(t!("Vertex 4 Z").as_ref(), "f3_p4z", self.fourth_corner.z), - ro(t!("Edge 1").as_ref(), "f3_edge1", edge(inv.is_first_invisible())), - ro(t!("Edge 2").as_ref(), "f3_edge2", edge(inv.is_second_invisible())), - ro(t!("Edge 3").as_ref(), "f3_edge3", edge(inv.is_third_invisible())), - ro(t!("Edge 4").as_ref(), "f3_edge4", edge(inv.is_fourth_invisible())), + stepper( + t!("Current vertex").as_ref(), + "current_vertex", + format!("{}", current + 1), + ), + edit(t!("Vertex X").as_ref(), "f3_vertex_x", corner.x), + edit(t!("Vertex Y").as_ref(), "f3_vertex_y", corner.y), + edit(t!("Vertex Z").as_ref(), "f3_vertex_z", corner.z), + edge(t!("Edge 1").as_ref(), "f3_edge1", inv.is_first_invisible()), + edge(t!("Edge 2").as_ref(), "f3_edge2", inv.is_second_invisible()), + edge(t!("Edge 3").as_ref(), "f3_edge3", inv.is_third_invisible()), + edge(t!("Edge 4").as_ref(), "f3_edge4", inv.is_fourth_invisible()), ], }] } fn apply_geom_prop(&mut self, field: &str, value: &str) { - let Ok(v) = value.trim().parse::() else { + let invisible = value == t!("Invisible").as_ref(); + match field { + "f3_edge1" => self.invisible_edges.set_first_invisible(invisible), + "f3_edge2" => self.invisible_edges.set_second_invisible(invisible), + "f3_edge3" => self.invisible_edges.set_third_invisible(invisible), + "f3_edge4" => self.invisible_edges.set_fourth_invisible(invisible), + _ => {} + } + if field.starts_with("f3_edge") { + return; + } + let Some(value) = parse_f64(value) else { return; }; + let was_triangle = self.is_triangle(); + let vertex_count = if was_triangle { 3 } else { 4 }; + let current = crate::scene::view::dispatch::prop_current_vertex() + .min(vertex_count - 1); + let corner = match current { + 0 => &mut self.first_corner, + 1 => &mut self.second_corner, + 2 => &mut self.third_corner, + _ => &mut self.fourth_corner, + }; match field { - "f3_p1x" => self.first_corner.x = v, - "f3_p1y" => self.first_corner.y = v, - "f3_p1z" => self.first_corner.z = v, - "f3_p2x" => self.second_corner.x = v, - "f3_p2y" => self.second_corner.y = v, - "f3_p2z" => self.second_corner.z = v, - "f3_p3x" => self.third_corner.x = v, - "f3_p3y" => self.third_corner.y = v, - "f3_p3z" => self.third_corner.z = v, - "f3_p4x" => self.fourth_corner.x = v, - "f3_p4y" => self.fourth_corner.y = v, - "f3_p4z" => self.fourth_corner.z = v, + "f3_vertex_x" => corner.x = value, + "f3_vertex_y" => corner.y = value, + "f3_vertex_z" => corner.z = value, _ => {} } + if was_triangle && current == 2 { + self.fourth_corner = self.third_corner; + } } } diff --git a/src/modules/draw/draw/face3d.rs b/src/modules/draw/draw/face3d.rs new file mode 100644 index 000000000..9813ba60f --- /dev/null +++ b/src/modules/draw/draw/face3d.rs @@ -0,0 +1,509 @@ +use acadrust::entities::{face3d::InvisibleEdgeFlags, Face3D}; +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle}; +use glam::DVec3; +use std::collections::HashSet; + +use crate::command::{CadCommand, CmdOption, CmdResult}; +use crate::scene::model::wire_model::WireModel; +use crate::t; + +const EPSILON: f64 = 1.0e-9; + +fn vector(point: DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + +fn point(value: Vector3) -> DVec3 { + DVec3::new(value.x, value.y, value.z) +} + +fn set_edge_invisible(flags: &mut InvisibleEdgeFlags, edge: usize, invisible: bool) { + match edge { + 0 => flags.set_first_invisible(invisible), + 1 => flags.set_second_invisible(invisible), + 2 => flags.set_third_invisible(invisible), + 3 => flags.set_fourth_invisible(invisible), + _ => {} + } +} + +fn edge_is_invisible(flags: InvisibleEdgeFlags, edge: usize) -> bool { + match edge { + 0 => flags.is_first_invisible(), + 1 => flags.is_second_invisible(), + 2 => flags.is_third_invisible(), + 3 => flags.is_fourth_invisible(), + _ => false, + } +} + +fn append_segment(points: &mut Vec<[f64; 3]>, start: DVec3, end: DVec3) { + if !points.is_empty() { + points.push([f64::NAN; 3]); + } + points.push(start.to_array()); + points.push(end.to_array()); +} + +pub struct Face3dCommand { + points: Vec, + invisible: [bool; 4], + pending_invisible: bool, +} + +impl Face3dCommand { + pub fn new() -> Self { + Self { + points: Vec::new(), + invisible: [false; 4], + pending_invisible: false, + } + } + + fn build(&self, fourth: DVec3) -> Option { + if self.points.len() != 3 { + return None; + } + let mut face = Face3D::new( + vector(self.points[0]), + vector(self.points[1]), + vector(self.points[2]), + vector(fourth), + ); + for edge in 0..4 { + set_edge_invisible(&mut face.invisible_edges, edge, self.invisible[edge]); + } + Some(EntityType::Face3D(face)) + } + + fn continue_from_last_edge(&mut self, fourth: DVec3) { + let third = self.points[2]; + let shared_edge_invisible = self.invisible[2]; + self.points.clear(); + self.points.push(third); + self.points.push(fourth); + self.invisible = [shared_edge_invisible, false, false, false]; + self.pending_invisible = false; + } + + fn continue_from_triangle(&mut self) { + let second = self.points[1]; + let third = self.points[2]; + let shared_edge_invisible = self.invisible[1]; + self.points.clear(); + self.points.push(second); + self.points.push(third); + self.invisible = [shared_edge_invisible, false, false, false]; + self.pending_invisible = false; + } +} + +impl CadCommand for Face3dCommand { + fn name(&self) -> &'static str { + "3DFACE" + } + + fn prompt(&self) -> String { + match self.points.len() { + 0 => t!("3DFACE Specify first point or [Invisible]:").into_owned(), + 1 => t!("3DFACE Specify second point or [Invisible]:").into_owned(), + 2 => t!("3DFACE Specify third point or [Invisible] :").into_owned(), + _ => t!("3DFACE Specify fourth point or [Invisible] :") + .into_owned(), + } + } + + fn options(&self) -> Vec { + vec![CmdOption::new(t!("Invisible").as_ref(), "I")] + } + + fn on_point(&mut self, picked: DVec3) -> CmdResult { + let point_index = self.points.len(); + if self.pending_invisible && point_index < 4 { + self.invisible[point_index] = true; + } + self.pending_invisible = false; + if self.points.len() == 3 { + let entity = self.build(picked); + self.continue_from_last_edge(picked); + return entity.map_or(CmdResult::Cancel, CmdResult::CommitEntity); + } + self.points.push(picked); + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if self.points.len() != 3 { + return CmdResult::Cancel; + } + if self.pending_invisible { + self.invisible[3] = true; + } + let third = self.points[2]; + let entity = self.build(third); + self.continue_from_triangle(); + entity.map_or(CmdResult::Cancel, CmdResult::CommitEntity) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + true + } + + fn on_text_input(&mut self, value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "I" | "INVISIBLE" => { + self.pending_invisible = true; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + fn on_mouse_move(&mut self, cursor: DVec3) -> Option { + if self.points.is_empty() { + return None; + } + let mut preview = Vec::new(); + for edge in 0..self.points.len().saturating_sub(1) { + if !self.invisible[edge] { + append_segment(&mut preview, self.points[edge], self.points[edge + 1]); + } + } + let pending_edge = self.points.len() - 1; + if !self.invisible[pending_edge] { + append_segment(&mut preview, *self.points.last().unwrap(), cursor); + } + if self.points.len() == 3 && !(self.invisible[3] || self.pending_invisible) { + append_segment(&mut preview, cursor, self.points[0]); + } + Some(WireModel::solid_f64( + "face3d_preview".to_string(), + preview, + WireModel::CYAN, + false, + )) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum EdgeMode { + Toggle, + Display, + DisplaySelect, +} + +pub struct FaceEdgeCommand { + faces: Vec<(Handle, Face3D)>, + mode: EdgeMode, + show_all_hidden: bool, + displayed_faces: HashSet, +} + +impl FaceEdgeCommand { + pub fn new(faces: Vec<(Handle, Face3D)>) -> Self { + Self { + faces, + mode: EdgeMode::Toggle, + show_all_hidden: false, + displayed_faces: HashSet::new(), + } + } + + fn corners(face: &Face3D) -> [DVec3; 4] { + [ + point(face.first_corner), + point(face.second_corner), + point(face.third_corner), + point(face.fourth_corner), + ] + } + + fn nearest_edge(face: &Face3D, picked: DVec3) -> Option { + let corners = Self::corners(face); + let mut best = None; + for edge in 0..4 { + let start = corners[edge]; + let end = corners[(edge + 1) % 4]; + let segment = end - start; + let length_squared = segment.length_squared(); + if length_squared <= EPSILON * EPSILON { + continue; + } + let parameter = ((picked - start).dot(segment) / length_squared).clamp(0.0, 1.0); + let distance_squared = (picked - (start + segment * parameter)).length_squared(); + if best.is_none_or(|(_, distance)| distance_squared < distance) { + best = Some((edge, distance_squared)); + } + } + best.map(|(edge, _)| edge) + } + + fn edge_wire(face: &Face3D, edge: usize, name: &str) -> WireModel { + let corners = Self::corners(face); + WireModel::solid_f64( + name.to_string(), + vec![corners[edge].to_array(), corners[(edge + 1) % 4].to_array()], + WireModel::CYAN, + false, + ) + } + + fn edges_overlap_collinearly( + selected_start: DVec3, + selected_end: DVec3, + candidate_start: DVec3, + candidate_end: DVec3, + ) -> bool { + let selected = selected_end - selected_start; + let selected_length = selected.length(); + let candidate = candidate_end - candidate_start; + if selected_length <= EPSILON || candidate.length() <= EPSILON { + return false; + } + let direction = selected / selected_length; + let scale = selected_length + .max(candidate.length()) + .max(1.0); + let tolerance = EPSILON * scale; + let line_distance = |point: DVec3| { + let offset = point - selected_start; + (offset - direction * offset.dot(direction)).length() + }; + if line_distance(candidate_start) > tolerance + || line_distance(candidate_end) > tolerance + { + return false; + } + let t0 = (candidate_start - selected_start).dot(direction); + let t1 = (candidate_end - selected_start).dot(direction); + let candidate_min = t0.min(t1); + let candidate_max = t0.max(t1); + candidate_max.min(selected_length) - candidate_min.max(0.0) > tolerance + } + + fn collinear_replacements( + &mut self, + selected_face: usize, + selected_edge: usize, + ) -> Vec<(Handle, Vec)> { + let selected_corners = Self::corners(&self.faces[selected_face].1); + let selected_start = selected_corners[selected_edge]; + let selected_end = selected_corners[(selected_edge + 1) % 4]; + let make_invisible = !edge_is_invisible( + self.faces[selected_face].1.invisible_edges, + selected_edge, + ); + let mut replacements = Vec::new(); + for (handle, face) in &mut self.faces { + let corners = Self::corners(face); + let mut updated = face.clone(); + let mut changed = false; + for edge in 0..4 { + if Self::edges_overlap_collinearly( + selected_start, + selected_end, + corners[edge], + corners[(edge + 1) % 4], + ) && edge_is_invisible(updated.invisible_edges, edge) != make_invisible + { + set_edge_invisible(&mut updated.invisible_edges, edge, make_invisible); + changed = true; + } + } + if changed { + *face = updated.clone(); + replacements.push((*handle, vec![EntityType::Face3D(updated)])); + } + } + replacements + } + + fn hidden_edges_wire(&self) -> WireModel { + let mut points = Vec::new(); + for (handle, face) in &self.faces { + if !self.show_all_hidden && !self.displayed_faces.contains(handle) { + continue; + } + let corners = Self::corners(face); + for edge in 0..4 { + if edge_is_invisible(face.invisible_edges, edge) + && (corners[(edge + 1) % 4] - corners[edge]).length_squared() + > EPSILON * EPSILON + { + append_segment(&mut points, corners[edge], corners[(edge + 1) % 4]); + } + } + } + WireModel::solid_f64( + "face3d_hidden_edges".to_string(), + points, + WireModel::CYAN, + false, + ) + } +} + +impl CadCommand for FaceEdgeCommand { + fn name(&self) -> &'static str { + "EDGE" + } + + fn prompt(&self) -> String { + match self.mode { + EdgeMode::Toggle => { + t!("EDGE Select edge of 3D face to toggle visibility or [Display]:").into_owned() + } + EdgeMode::Display => { + t!("EDGE Display invisible edges [All/Select]