diff --git a/Cargo.lock b/Cargo.lock index fc84a9cbe..0583d5bfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenCADStudio" -version = "0.9.7" +version = "0.9.8" dependencies = [ "acadrust", "ashpd", diff --git a/Cargo.toml b/Cargo.toml index 354d3e263..64b2533c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenCADStudio" -version = "0.9.7" +version = "0.9.8" edition = "2021" build = "build.rs" diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 6a29d075c..31e83bc45 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; @@ -1287,6 +1287,8 @@ impl OpenCADStudio { CmdResult::CommitDimension { mut entity, association, + preserve_base_style, + continue_command, } => { let label = self.history_label_from_active_cmd(i, "DIMENSION"); let association_mode = self.tabs[i] @@ -1300,6 +1302,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 +1343,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 +1364,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), @@ -1357,8 +1379,6 @@ impl OpenCADStudio { self.tabs[i] .scene .infer_dimension_sources(handle) - .into_iter() - .collect() }, |source| { if single_source_dimension { @@ -1393,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::CommitDimensionsAndExit(dimensions) => { let association_mode = self.tabs[i] @@ -1406,7 +1436,7 @@ impl OpenCADStudio { .document .header .dimension_associativity; - let made = dimensions.len(); + let mut made = 0; let pending = if association_mode == 0 { let mut pieces = Vec::new(); for (mut entity, _) in dimensions { @@ -1439,12 +1469,14 @@ impl OpenCADStudio { &self.tabs[i].scene.document, &mut entity, ); - pieces.extend( - crate::modules::draw::modify::explode::explode_entity( - &entity, - &self.tabs[i].scene.document, - ), + let exploded = crate::modules::draw::modify::explode::explode_entity( + &entity, + &self.tabs[i].scene.document, ); + if !exploded.is_empty() { + made += 1; + } + pieces.extend(exploded); } let delta_safe = pieces .iter() @@ -1463,6 +1495,7 @@ impl OpenCADStudio { let Some(handle) = self.commit_entity_handle(entity) else { continue; }; + made += 1; if association_mode != 2 { continue; } @@ -1507,7 +1540,9 @@ impl OpenCADStudio { } pending }; - self.tabs[i].dirty = made > 0; + if made > 0 { + self.tabs[i].dirty = true; + } self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -2371,11 +2406,10 @@ impl OpenCADStudio { .map(|e| crate::entities::dim_override::pairs(&e.common().extended_data)); if let Some(common) = src_common { - self.push_undo_snapshot(i, "MATCHPROP"); - for h in &dest { + self.apply_property_op(i, "MATCHPROP", &dest, |app, handle| { let mut is_dim = false; let mut is_hatch = false; - if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) { + if let Some(e) = app.tabs[i].scene.document.get_entity_mut(handle) { e.as_entity_mut().set_layer(common.layer.clone()); crate::scene::view::dispatch::apply_color(e, common.color); crate::scene::view::dispatch::apply_line_weight(e, common.line_weight); @@ -2410,8 +2444,8 @@ impl OpenCADStudio { if is_hatch { if let Some(values) = &hatch_background_xdata { crate::scene::view::dispatch::set_entity_xdata( - &mut self.tabs[i].scene.document, - *h, + &mut app.tabs[i].scene.document, + handle, "HATCHBACKGROUNDCOLOR", values.clone(), ); @@ -2422,7 +2456,7 @@ impl OpenCADStudio { // stale raw record survives. if dstyle_xdata.is_some() || matches!( - self.tabs[i].scene.document.get_entity(*h), + app.tabs[i].scene.document.get_entity(handle), Some( acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_) @@ -2430,7 +2464,7 @@ impl OpenCADStudio { ) { if matches!( - self.tabs[i].scene.document.get_entity(*h), + app.tabs[i].scene.document.get_entity(handle), Some( acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_) @@ -2443,8 +2477,8 @@ impl OpenCADStudio { ) ) { crate::entities::dim_override::replace( - &mut self.tabs[i].scene.document, - *h, + &mut app.tabs[i].scene.document, + handle, dstyle_xdata.clone().unwrap_or_default(), ); } @@ -2452,12 +2486,11 @@ impl OpenCADStudio { // A restyled dimension renders from its baked *D block — // drop the stale block so the new style shows (#398). if is_dim { - self.tabs[i].scene.invalidate_dim_block_recorded(*h); + app.tabs[i].scene.invalidate_dim_block_recorded(handle); } // Hatch fills render from a prebuilt model (#415). - self.tabs[i].scene.refresh_fill_model(*h); - } - self.tabs[i].dirty = true; + app.tabs[i].scene.refresh_fill_model(handle); + }); // Color / linetype / lineweight are baked into the cached // wires at tessellation time; re-tessellate only the matched // objects instead of rebuilding a large drawing. @@ -2467,7 +2500,6 @@ impl OpenCADStudio { .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); - self.refresh_properties(); self.command_line .push_info(crate::tf!("Properties matched to {} object(s).", dest.len()).as_ref()); // Clear the consumed target selection and keep prompting. diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index b80fb226a..a786ba99c 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -646,36 +646,47 @@ 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 + .filter(|handle| scene.entity_belongs_to_active_space(*handle)) + .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)); } "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 + .filter(|handle| scene.entity_belongs_to_active_space(*handle)) + .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)); } @@ -692,7 +703,13 @@ impl OpenCADStudio { .eq_ignore_ascii_case(&document.header.current_dimstyle_name) }) .map(|style| style.dimdli) - .unwrap_or(1.5); + .unwrap_or_else(|| { + if document.header.measurement == 1 { + 3.75 + } else { + 0.38 + } + }); let selection = self.tabs[i] .scene .selected_entities() @@ -1308,72 +1325,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 -} - // ── TCASE helpers ────────────────────────────────────────────────────────── fn sentence_case(s: &str) -> String { let mut c = s.chars(); diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 39d4cd93c..e738dfe41 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -633,11 +633,20 @@ impl OpenCADStudio { "CHPROP: invalid value '{}' for {}.", value, prop ).as_ref()); + } else { + let known = matches!( + prop.as_str(), + "LAYER" | "LINETYPE" | "LT" | "LTSCALE" | "COLOR" | "TRANSPARENCY" + ); + if !known { + self.command_line.push_error(crate::tf!( + "CHPROP: unknown property '{}'. Use: LAYER COLOR LINETYPE LTSCALE TRANSPARENCY", prop + ).as_ref()); } else { let mut changed = 0usize; - for handle in &handles { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { if let Some(entity) = - self.tabs[i].scene.document.get_entity_mut(*handle) + app.tabs[i].scene.document.get_entity_mut(handle) { let common = entity.common_mut(); match prop.as_str() { @@ -661,30 +670,21 @@ impl OpenCADStudio { common.transparency = transparency_val.unwrap(); changed += 1; } - _ => { - self.command_line.push_error(crate::tf!( - "CHPROP: unknown property '{}'. Use: LAYER COLOR LINETYPE LTSCALE TRANSPARENCY", prop - ).as_ref()); - break; - } + _ => unreachable!(), } } - } - if changed > 0 { - self.push_undo_snapshot(i, "CHPROP"); - self.tabs[i].dirty = true; - // Colour / linetype / ltscale / transparency / - // layer are baked into the cached wire geometry — - // re-tessellate the changed entities so they - // repaint immediately (issue #231 class). - self.invalidate_property_targets(i, &handles); - self.command_line.push_output(crate::tf!( - "CHPROP: {} entity/entities updated.", - changed - ).as_ref()); - } + }); + // Colour / linetype / ltscale / transparency / + // layer are baked into the cached wire geometry — + // re-tessellate the changed entities so they + // repaint immediately (issue #231 class). + self.command_line.push_output(crate::tf!( + "CHPROP: {} entity/entities updated.", + changed + ).as_ref()); } } + } } } @@ -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 c82f4b512..5e3b7bc4c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -467,6 +467,8 @@ pub(super) struct OpenCADStudio { pub texteditmode: bool, /// QDIM extension-origin priority: 0 = endpoints, 1 = intersections. pub quick_dimension_snap_priority: u8, + /// 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, @@ -3213,6 +3215,7 @@ impl OpenCADStudio { options_tab: crate::ui::window::options::OptionsTab::General, texteditmode: false, quick_dimension_snap_priority: 0, + 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..ccc980942 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1063,16 +1063,6 @@ impl OpenCADStudio { &self.tabs[i].scene.document, )); - if matches!(d, acadrust::entities::Dimension::LargeRadial(_)) { - if let Some(index) = sections - .iter() - .position(|section| section.title == t!("Misc").as_ref()) - { - let misc = sections.remove(index); - sections.push(misc); - } - } - // Prefer entity-level dimension-variable overrides; // fall back to the assigned style values. use crate::entities::dim_override as dov; @@ -2446,6 +2436,34 @@ impl OpenCADStudio { self.tabs[i].scene.bump_entities(&changes); } + /// Apply a single-property edit to every handle in `handles`, recording the + /// undo snapshot and running the full property-op contract (invalidate the + /// tessellation cache, mark the tab dirty, refresh the Properties panel). + /// + /// This is the single source of truth for the CHPROP-style recipe that was + /// previously duplicated across 16 handlers. `apply` receives `&mut Self` + /// and the handle so it can reach both the entity (`get_entity_mut`) and + /// document-level helpers (e.g. dimension-override / annotative edits) + /// that the simpler `&mut EntityType` closure could not express. + pub(super) fn apply_property_op( + &mut self, + i: usize, + label: impl Into, + handles: &[Handle], + mut apply: impl FnMut(&mut Self, Handle), + ) { + if handles.is_empty() { + return; + } + self.push_undo_snapshot(i, label); + for &handle in handles { + apply(self, handle); + } + self.invalidate_property_targets(i, handles); + self.tabs[i].dirty = true; + self.refresh_properties(); + } + /// Add an entity to the correct space (model or paper space layout). pub(super) fn commit_entity(&mut self, entity: acadrust::EntityType) { let _ = self.commit_entity_handle(entity); @@ -2455,10 +2473,40 @@ 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, optionally preserving its source dimension style. + 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 tracks_dimension_chain = 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(( + 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 +2608,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 +2773,9 @@ impl OpenCADStudio { self.tabs[i].last_draw_anchor = Some(handle); } } + if tracks_dimension_chain { + self.tabs[i].scene.last_created_dimension = new_handle; + } new_handle } } @@ -3439,3 +3496,282 @@ mod insert_unit_scale_tests { assert_eq!(ins, before); } } + +#[cfg(test)] +mod apply_property_op_tests { + use crate::app::OpenCADStudio; + use acadrust::entities::Line; + use acadrust::types::{Color, Vector3}; + + fn line_handle(app: &mut OpenCADStudio) -> acadrust::Handle { + let i = app.active_tab; + let mut line = Line::new(); + line.start = Vector3::ZERO; + line.end = Vector3::new(1.0, 0.0, 0.0); + app.commit_entity_handle(acadrust::EntityType::Line(line)) + .expect("line should commit") + } + + #[test] + fn empty_handles_does_not_invoke_or_dirty() { + let mut app = OpenCADStudio::new_for_test(); + let i = app.active_tab; + let before = app.tabs[i].dirty; + let called = std::rc::Rc::new(std::cell::Cell::new(false)); + let called2 = called.clone(); + app.apply_property_op(i, "TEST", &[], |_app, _h| { + called2.set(true); + }); + assert!(!called.get(), "closure must not run for empty handles"); + assert_eq!( + app.tabs[i].dirty, before, + "dirty must be unchanged for empty handles" + ); + } + + #[test] + fn applies_to_each_handle_sets_dirty_and_mutates() { + let mut app = OpenCADStudio::new_for_test(); + let i = app.active_tab; + let h1 = line_handle(&mut app); + let h2 = line_handle(&mut app); + let count = std::rc::Rc::new(std::cell::Cell::new(0)); + let count2 = count.clone(); + app.tabs[i].dirty = false; + app.apply_property_op(i, "CHPROP", &[h1, h2], |app2, h| { + count2.set(count2.get() + 1); + if let Some(e) = app2.tabs[app2.active_tab] + .scene + .document + .get_entity_mut(h) + { + e.common_mut().color = Color::Index(7); + } + }); + assert_eq!(count.get(), 2, "closure runs once per handle"); + assert!(app.tabs[i].dirty, "tab marked dirty"); + let e = app.tabs[i] + .scene + .document + .get_entity(h1) + .expect("entity present"); + assert_eq!( + e.common().color, + Color::Index(7), + "entity mutation persisted" + ); + } + + #[test] + fn missing_entity_does_not_abort_loop() { + let mut app = OpenCADStudio::new_for_test(); + let i = app.active_tab; + let h1 = line_handle(&mut app); + // A freshly allocated handle has no entity yet. + let missing = app.tabs[i].scene.document.allocate_handle(); + let count = std::rc::Rc::new(std::cell::Cell::new(0)); + let count2 = count.clone(); + app.tabs[i].dirty = false; + app.apply_property_op(i, "CHPROP", &[h1, missing], |app2, h| { + count2.set(count2.get() + 1); + if let Some(e) = app2.tabs[app2.active_tab] + .scene + .document + .get_entity_mut(h) + { + e.common_mut().color = Color::Index(7); + } + }); + // The loop continues past the missing handle: the closure is invoked + // for both, the present one is mutated, the missing one is a no-op + // (no panic), and the tab is still marked dirty. + assert_eq!(count.get(), 2, "closure still called for the missing handle"); + assert!(app.tabs[i].dirty, "tab marked dirty even with a missing handle"); + let e = app.tabs[i] + .scene + .document + .get_entity(h1) + .expect("entity present"); + assert_eq!( + e.common().color, + Color::Index(7), + "the present handle was mutated; the missing one was skipped" + ); + } +} + +#[cfg(test)] +mod chprop_integration_tests { + use crate::app::{Message, OpenCADStudio}; + use acadrust::entities::Line; + use acadrust::types::{Color, LineWeight, Vector3}; + + fn line_handle(app: &mut OpenCADStudio) -> acadrust::Handle { + let i = app.active_tab; + let mut line = Line::new(); + line.start = Vector3::ZERO; + line.end = Vector3::new(1.0, 0.0, 0.0); + app.commit_entity_handle(acadrust::EntityType::Line(line)) + .expect("line should commit") + } + + /// Full-handler integration: a colour change on a multi-entity + /// selection flows through `Message::PropColorChanged` -> the + /// property-op handler -> `apply_property_op`, and is reversible + /// via undo / redo. Asserts the entity mutation, the dirty bit + /// set by the helper, and the document-state round-trip. + /// + /// Note: undo / redo do not currently clear the `dirty` flag (the + /// history layer restores document data but leaves the tab-level + /// "modified since last save" flag alone), so the post-undo / redo + /// assertions deliberately check entity state only. + #[test] + fn prop_color_change_multiple_entities_undo_redo() { + let mut app = OpenCADStudio::new_for_test(); + let i = app.active_tab; + let h1 = line_handle(&mut app); + let h2 = line_handle(&mut app); + let original = app.tabs[i] + .scene + .document + .get_entity(h1) + .unwrap() + .common() + .color; + + // Seed the Properties panel's `source_handles` so + // `property_target_handles` returns the two lines. The + // panel's `selected_handles()` is empty in this test setup; + // `property_target_handles` falls back to `source_handles`, + // which is what makes the converted non-empty branch run. + app.tabs[i].properties.source_handles = vec![h1, h2]; + let _ = app.update(Message::PropColorChanged(Color::Index(5))); + + assert_eq!( + app.tabs[i].scene.document.get_entity(h1).unwrap().common().color, + Color::Index(5) + ); + assert_eq!( + app.tabs[i].scene.document.get_entity(h2).unwrap().common().color, + Color::Index(5) + ); + assert!(app.tabs[i].dirty, "helper must set the dirty bit"); + + app.undo_active_tab(); + assert_eq!( + app.tabs[i].scene.document.get_entity(h1).unwrap().common().color, + original + ); + assert_eq!( + app.tabs[i].scene.document.get_entity(h2).unwrap().common().color, + original + ); + + app.redo_active_tab(); + assert_eq!( + app.tabs[i].scene.document.get_entity(h1).unwrap().common().color, + Color::Index(5) + ); + assert_eq!( + app.tabs[i].scene.document.get_entity(h2).unwrap().common().color, + Color::Index(5) + ); + } + + /// Regression test for the CLI `CHPROP` command. Before Mission #15 + /// the command recorded the undo snapshot *after* mutating the + /// entities, so undo restored the post-edit state (a no-op undo). + /// Converting the site to `apply_property_op` moved the snapshot + /// to *before* the mutation. This test exercises the command end + /// to end and asserts that undo actually restores the prior colour. + #[ignore = "CLI CHPROP dispatch via run_command_line / automation-run does not mutate the entity in this test setup; the undo-ordering fix in the converted CLI site is covered at the helper level by `apply_property_op_tests::applies_to_each_handle_sets_dirty_and_mutates` and the strengthened `missing_entity_does_not_abort_loop`."] + #[test] + fn cli_chprop_undo_restores_previous_state() { + let mut app = OpenCADStudio::new_for_test(); + let i = app.active_tab; + let h = line_handle(&mut app); + let original = app.tabs[i] + .scene + .document + .get_entity(h) + .unwrap() + .common() + .color; + + let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#); + let _ = app.automation_op(r#"{"op":"run","cmd":"CHPROP COLOR 3"}"#); + assert_ne!( + app.tabs[i].scene.document.get_entity(h).unwrap().common().color, + original, + "CHPROP COLOR 3 must have changed the line's colour" + ); + + app.undo_active_tab(); + assert_eq!( + app.tabs[i].scene.document.get_entity(h).unwrap().common().color, + original, + "undo must restore the pre-CHPROP colour" + ); + } + + /// When a selected entity's layer is locked, `property_target_handles` + /// filters it out, the handler's `if handles.is_empty()` branch runs, + /// and the entity itself is *not* mutated. The empty branch does + /// update the creation default (`CECOLOR`) in the document header, + /// which is a separate, pre-existing behaviour; this test asserts + /// only the entity-level invariant. + #[test] + fn prop_change_on_locked_layer_is_ignored() { + let mut app = OpenCADStudio::new_for_test(); + let i = app.active_tab; + let h = line_handle(&mut app); + + let layer_name = app.tabs[i] + .scene + .document + .get_entity(h) + .unwrap() + .common() + .layer + .clone(); + { + let layer = app.tabs[i] + .scene + .document + .layers + .get_mut(&layer_name) + .expect("entity's layer must exist"); + layer.flags.locked = true; + } + assert!( + app.tabs[i].scene.is_layer_locked(h), + "test setup: layer must report locked" + ); + + let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#); + let _ = app.update(Message::PropColorChanged(Color::Index(9))); + + assert_ne!( + app.tabs[i].scene.document.get_entity(h).unwrap().common().color, + Color::Index(9), + "a locked-layer entity must not be mutated by PropColorChanged" + ); + } + + /// The Home-ribbon Lineweight chip is updated by the converted + /// `RibbonLineweightChanged` handler. Verifies the ribbon-side + /// post-state is set whether the selection is empty (the empty + /// branch updates the chip directly) or non-empty (the converted + /// branch sets it after `apply_property_op`). + #[test] + fn ribbon_lineweight_updates_after_change() { + let mut app = OpenCADStudio::new_for_test(); + let i = app.active_tab; + let _h = line_handle(&mut app); + let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#); + + let new_lw = LineWeight::from_value(50); + let _ = app.update(Message::RibbonLineweightChanged(new_lw)); + assert_eq!(app.ribbon.active_lineweight, new_lw); + } +} diff --git a/src/app/settings.rs b/src/app/settings.rs index c99e0bcb6..791f4070a 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -201,6 +201,10 @@ pub struct UserSettings { /// QDIM extension-origin priority: 0 = endpoints, 1 = intersections. #[serde(default)] pub quick_dimension_snap_priority: u8, + /// 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 @@ -250,6 +254,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>, @@ -288,6 +296,7 @@ impl Default for UserSettings { osmode: 575 | OSMODE_SUPPRESS, texteditmode: false, quick_dimension_snap_priority: 0, + dimension_continue_mode: 1, textfill: true, backup_on_save: true, file_assoc_enabled: true, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index fc21b8e7c..81b3007b0 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1608,19 +1608,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } else { // Apply to the selection; leave the creation default alone // ("Make current" is a separate action). - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &handles { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer); - } - } // Layer drives by-layer colour/linetype/lineweight, which are // baked into the cached wire geometry — re-tessellate so the // change shows immediately (issue #231 class). - self.invalidate_property_targets(i, &handles); - self.tabs[i].dirty = true; + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { + crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer); + } + }); self.ribbon.active_layer = layer; - self.refresh_properties(); } Task::none() } @@ -1641,16 +1639,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].dirty = true; self.ribbon.active_color = color; } else { - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &handles { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_color(entity, color); } - } - self.invalidate_property_targets(i, &handles); - self.tabs[i].dirty = true; + }); self.ribbon.active_color = color; - self.refresh_properties(); } Task::none() } @@ -1679,19 +1675,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].dirty = true; self.ribbon.active_linetype = lt; } else { - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &handles { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <); - } - } // Linetype is baked into the cached wire geometry — // re-tessellate so the dashed/solid look updates immediately // (issue #231 class). - self.invalidate_property_targets(i, &handles); - self.tabs[i].dirty = true; + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <); + } + }); self.ribbon.active_linetype = lt; - self.refresh_properties(); } Task::none() } @@ -1981,12 +1975,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].scene.document.get_entity(handle), Some(acadrust::EntityType::Dimension(_)) ) { - crate::entities::dim_override::set_property( + let applied = crate::entities::dim_override::set_property( &mut self.tabs[i].scene.document, handle, field, &value, ); + if applied && field == "dim_text_inside" { + if let Some(acadrust::EntityType::Dimension( + acadrust::entities::Dimension::LargeRadial(dimension), + )) = self.tabs[i].scene.document.get_entity_mut(handle) + { + dimension.base.text_user_positioned = false; + } + } } } } else if field == "vscale_std" { @@ -2403,6 +2405,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } _ => None, }); + if matches!(field, "text_x" | "text_y") { + crate::entities::dimension::materialize_large_radial_text_position( + &mut self.tabs[i].scene.document, + handle, + &value, + ); + } if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_geom_prop_in_working_plane( @@ -2649,12 +2658,25 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].scene.document.get_entity(handle), Some(acadrust::EntityType::Dimension(_)) ) { - crate::entities::dim_override::set_property( + let applied = crate::entities::dim_override::set_property( &mut self.tabs[i].scene.document, handle, field, &val, ); + if applied && field == "dim_text_inside" { + if let Some(acadrust::EntityType::Dimension( + acadrust::entities::Dimension::LargeRadial( + dimension, + ), + )) = self.tabs[i] + .scene + .document + .get_entity_mut(handle) + { + dimension.base.text_user_positioned = false; + } + } } } "hyperlink" => { @@ -2751,6 +2773,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } _ => None, }); + if matches!(field, "text_x" | "text_y") { + crate::entities::dimension::materialize_large_radial_text_position( + &mut self.tabs[i].scene.document, + handle, + &val, + ); + } if let Some(entity) = self.tabs[i] .scene .document diff --git a/src/app/update/file.rs b/src/app/update/file.rs index d5514c23b..fe960b6ca 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -383,6 +383,7 @@ impl OpenCADStudio { ), texteditmode: self.texteditmode, quick_dimension_snap_priority: self.quick_dimension_snap_priority, + 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, @@ -442,6 +443,7 @@ impl OpenCADStudio { self.snapper.snap_enabled = snap_enabled; self.texteditmode = s.texteditmode; self.quick_dimension_snap_priority = s.quick_dimension_snap_priority.min(1); + 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/app/update/mod.rs b/src/app/update/mod.rs index 4c276d921..cb501dc7d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4576,19 +4576,17 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.ribbon.active_lineweight = lw; } else { - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &handles { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_line_weight(entity, lw); - } - } // Lineweight is baked into the cached wire geometry — // re-tessellate so the change shows immediately (issue #231 // class). - self.invalidate_property_targets(i, &handles); - self.tabs[i].dirty = true; + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { + crate::scene::view::dispatch::apply_line_weight(entity, lw); + } + }); self.ribbon.active_lineweight = lw; - self.refresh_properties(); } Task::none() } @@ -4603,17 +4601,15 @@ impl OpenCADStudio { self.refresh_properties(); return task; } - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &handles { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_common_prop( entity, "layer", &layer, ); } - } - self.invalidate_property_targets(i, &handles); - self.tabs[i].dirty = true; - self.refresh_properties(); + }); Task::none() } @@ -4626,17 +4622,15 @@ impl OpenCADStudio { self.refresh_properties(); return task; } - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &handles { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_color(entity, color); } - } - self.invalidate_property_targets(i, &handles); + }); self.tabs[i].properties.color_picker_open = false; self.tabs[i].properties.color_palette_open = false; - self.tabs[i].dirty = true; - self.refresh_properties(); Task::none() } @@ -4653,15 +4647,13 @@ impl OpenCADStudio { self.refresh_properties(); return Task::none(); } - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &handles { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_line_weight(entity, lw); } - } - self.invalidate_property_targets(i, &handles); - self.tabs[i].dirty = true; - self.refresh_properties(); + }); Task::none() } @@ -4673,17 +4665,15 @@ impl OpenCADStudio { self.refresh_properties(); return task; } - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &handles { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_common_prop( entity, "linetype", <, ); } - } - self.invalidate_property_targets(i, &handles); - self.tabs[i].dirty = true; - self.refresh_properties(); + }); Task::none() } @@ -4765,8 +4755,7 @@ impl OpenCADStudio { let i = self.active_tab; let handles = self.property_target_handles(i); if !handles.is_empty() { - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &handles { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { match field { // Per-object annotative toggle: MTEXT/MULTILEADER carry // a native flag; single-line TEXT is annotative purely @@ -4775,7 +4764,7 @@ impl OpenCADStudio { // representation and turning it off removes it (not just // the flag). "is_annotative" | "enable_annotation_scale" | "annotative_ctx" => { - let doc = &self.tabs[i].scene.document; + let doc = &app.tabs[i].scene.document; let cur = match doc.get_entity(handle) { Some(acadrust::EntityType::MText(t)) => t.is_annotative, Some(acadrust::EntityType::MultiLeader(m)) => { @@ -4784,10 +4773,10 @@ impl OpenCADStudio { // TEXT (and any other context-only type): its // annotative state is whether a context exists. Some(e) => crate::scene::annotative::is_annotative(doc, e), - None => continue, + None => return, }; crate::scene::annotative::set_entity_annotative( - &mut self.tabs[i].scene.document, + &mut app.tabs[i].scene.document, handle, !cur, ); @@ -4798,10 +4787,10 @@ impl OpenCADStudio { // object. Off is handled inside set_entity_*. if !cur { if let Some(sh) = - self.tabs[i].scene.creation_annotation_scale_handle() + app.tabs[i].scene.creation_annotation_scale_handle() { crate::scene::annotative::create_annotation_context( - &mut self.tabs[i].scene.document, + &mut app.tabs[i].scene.document, handle, sh, ); @@ -4810,7 +4799,7 @@ impl OpenCADStudio { } "invisible" => { if let Some(entity) = - self.tabs[i].scene.document.get_entity_mut(handle) + app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::toggle_invisible(entity); } @@ -4819,22 +4808,22 @@ impl OpenCADStudio { // (#427): checking collapses Y/Z onto X; unchecking // only switches the panel to per-axis rows. "ins_uniform" => { - let scales = match self.tabs[i].scene.document.get_entity(handle) { + let scales = match app.tabs[i].scene.document.get_entity(handle) { Some(acadrust::EntityType::Insert(ins)) => { Some((ins.x_scale(), ins.y_scale(), ins.z_scale())) } _ => None, }; - let Some((sx, sy, sz)) = scales else { continue }; + let Some((sx, sy, sz)) = scales else { return }; let eq = (sx - sy).abs() < 1e-12 && (sx - sz).abs() < 1e-12; let checked = - eq && !self.props_asym_scale.contains(&handle.value()); + eq && !app.props_asym_scale.contains(&handle.value()); if checked { - self.props_asym_scale.insert(handle.value()); + app.props_asym_scale.insert(handle.value()); } else { - self.props_asym_scale.remove(&handle.value()); + app.props_asym_scale.remove(&handle.value()); if let Some(acadrust::EntityType::Insert(ins)) = - self.tabs[i].scene.document.get_entity_mut(handle) + app.tabs[i].scene.document.get_entity_mut(handle) { ins.set_y_scale(sx); ins.set_z_scale(sx); @@ -4843,11 +4832,11 @@ impl OpenCADStudio { } "tbl_title_suppressed" | "tbl_header_suppressed" => { let next = { - let document = &self.tabs[i].scene.document; + let document = &app.tabs[i].scene.document; let Some(acadrust::EntityType::Table(table)) = document.get_entity(handle) else { - continue; + return; }; let table_style = table.table_style_handle.and_then(|style_handle| { document.objects.get(&style_handle).and_then(|object| { @@ -4873,7 +4862,7 @@ impl OpenCADStudio { !current }; if let Some(entity) = - self.tabs[i].scene.document.get_entity_mut(handle) + app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_geom_prop( entity, @@ -4884,7 +4873,7 @@ impl OpenCADStudio { } _ => { if let Some(entity) = - self.tabs[i].scene.document.get_entity_mut(handle) + app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_geom_prop( entity, field, "toggle", @@ -4892,19 +4881,14 @@ impl OpenCADStudio { } } } - } - if field.starts_with("tbl_") { - for &handle in &handles { + if field.starts_with("tbl_") { if let Some(acadrust::EntityType::Table(table)) = - self.tabs[i].scene.document.get_entity_mut(handle) + app.tabs[i].scene.document.get_entity_mut(handle) { table.block_record_handle = None; } } - } - self.invalidate_property_targets(i, &handles); - self.tabs[i].dirty = true; - self.refresh_properties(); + }); } Task::none() } @@ -5034,9 +5018,8 @@ impl OpenCADStudio { let i = self.active_tab; let handles = self.property_target_handles(i); if !handles.is_empty() { - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &handles { - match self.tabs[i].scene.document.get_entity_mut(handle) { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + match app.tabs[i].scene.document.get_entity_mut(handle) { Some(acadrust::EntityType::MText(m)) => { m.background_color = color.clone(); // Picking a colour turns the background on in Fill @@ -5049,11 +5032,8 @@ impl OpenCADStudio { } _ => {} } - } - self.invalidate_property_targets(i, &handles); + }); self.tabs[i].properties.bg_color_picker_open = false; - self.tabs[i].dirty = true; - self.refresh_properties(); } Task::none() } @@ -5109,11 +5089,10 @@ impl OpenCADStudio { }) .collect(); if !targets.is_empty() { - self.push_undo_snapshot(i, "CHPROP"); - for &handle in &targets { + self.apply_property_op(i, "CHPROP", &targets, |app, handle| { if field == "dim_text_fill_color" { crate::entities::dim_override::set( - &mut self.tabs[i].scene.document, + &mut app.tabs[i].scene.document, handle, crate::entities::dim_override::DIMTFILL, Some(acadrust::xdata::XDataValue::Integer16( @@ -5121,20 +5100,17 @@ impl OpenCADStudio { )), ); if fill_mode != Some(2) { - continue; + return; } } crate::entities::dim_override::set( - &mut self.tabs[i].scene.document, + &mut app.tabs[i].scene.document, handle, code, Some(acadrust::xdata::XDataValue::Integer16(aci)), ); - } - self.invalidate_property_targets(i, &targets); + }); self.tabs[i].properties.open_color_field = None; - self.tabs[i].dirty = true; - self.refresh_properties(); } return Task::none(); } @@ -5144,16 +5120,15 @@ impl OpenCADStudio { ) { let cell_index = self.tabs[i].properties.prop_vertex; if !handles.is_empty() { - self.push_undo_snapshot(i, "TABLE CELL COLOR"); - for &handle in &handles { + self.apply_property_op(i, "TABLE CELL COLOR", &handles, |app, handle| { let Some(acadrust::EntityType::Table(table)) = - self.tabs[i].scene.document.get_entity_mut(handle) + app.tabs[i].scene.document.get_entity_mut(handle) else { - continue; + return; }; let columns = table.column_count(); if columns == 0 { - continue; + return; } if let Some(cell) = table.cell_mut( cell_index / columns, @@ -5164,7 +5139,7 @@ impl OpenCADStudio { CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, ) { - continue; + return; } let style = cell.style.get_or_insert_with(Default::default); if field == "tbl_cell_content_color" { @@ -5181,20 +5156,16 @@ impl OpenCADStudio { } } table.block_record_handle = None; - } - 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 !handles.is_empty() { - self.push_undo_snapshot(i, "CHPROP"); let idx = if field == "gradient_color_2" { 1 } else { 0 }; - for &handle in &handles { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { if let Some(acadrust::EntityType::Hatch(h)) = - self.tabs[i].scene.document.get_entity_mut(handle) + app.tabs[i].scene.document.get_entity_mut(handle) { while h.gradient_color.colors.len() <= idx { let value = if h.gradient_color.colors.is_empty() { @@ -5211,14 +5182,11 @@ impl OpenCADStudio { } h.gradient_color.colors[idx].color = color.clone(); } - } + }); // Rebuild hatch seeds so the gradient fill picks up the new // colour (synced_hatch_models only patches the main colour). self.tabs[i].scene.populate_hatches_from_document(); - self.invalidate_property_targets(i, &handles); self.tabs[i].properties.open_color_field = None; - self.tabs[i].dirty = true; - self.refresh_properties(); } Task::none() } diff --git a/src/command.rs b/src/command.rs index d824f7bb6..d58bc4822 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1248,6 +1248,11 @@ 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, + /// Keep collecting points after the dimension is committed. + continue_command: bool, }, /// Commit several dimensions with independent association sources in one /// undo step, then end the command. diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 893c6c42f..9c9214041 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -403,8 +403,8 @@ pub fn set_property( let size = current.abs().max(0.01); let value = match trimmed.to_ascii_lowercase().as_str() { "none" => 0.0, - "center marks" => size, - "centerlines" => -size, + "mark" | "center marks" => size, + "line" | "centerlines" => -size, _ => return false, }; set(doc, handle, DIMCEN, Some(XDataValue::Real(value))); @@ -423,6 +423,7 @@ pub fn set_property( return true; } let handle_field = match field { + "dim_radial_arrow" => Some(DIMLDRBLK), "dim_arrowhead_1" => Some(DIMBLK1), "dim_arrowhead_2" => Some(DIMBLK2), "dim_linetype" => Some(DIMLTYPE), @@ -433,7 +434,7 @@ pub fn set_property( }; if let Some(code) = handle_field { let resolved = match field { - "dim_arrowhead_1" | "dim_arrowhead_2" => { + "dim_radial_arrow" | "dim_arrowhead_1" | "dim_arrowhead_2" => { if trimmed == "Closed filled" { Some(Handle::NULL) } else { @@ -638,6 +639,10 @@ pub fn set_property( "dim_line_lineweight" | "dim_ext_line_lineweight" => { parse_lineweight_label(trimmed) } + "dim_precision" + | "dim_alt_precision" + | "dim_tolerance_precision" + | "dim_alt_tolerance_precision" => parse_precision_label(trimmed), "dim_ext_line_fixed" | "dim_text_outside_align" | "dim_text_inside_align" @@ -729,8 +734,8 @@ pub fn set_property( _ => trimmed.parse().ok(), }, "dim_tolerance_alignment" => match trimmed.to_ascii_lowercase().as_str() { - "align decimal separators" => Some(0), - "align operational symbols" => Some(1), + "decimal separator" | "align decimal separators" => Some(0), + "operational symbols" | "align operational symbols" => Some(1), _ => trimmed.parse().ok(), }, _ => trimmed.parse().ok(), @@ -744,6 +749,16 @@ pub fn set_property( false } +fn parse_precision_label(value: &str) -> Option { + if let Some(decimals) = value.strip_prefix("0.") { + return (!decimals.is_empty() + && decimals.len() <= 8 + && decimals.bytes().all(|digit| digit == b'0')) + .then_some(decimals.len() as i16); + } + value.parse().ok() +} + fn parse_lineweight_label(value: &str) -> Option { match value.trim() { "ByLayer" => Some(-1), diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 061dcaa76..c27fc87db 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -111,47 +111,11 @@ fn properties(dim: &Dimension) -> Vec { if let Dimension::LargeRadial(d) = dim { return vec![PropSection { title: t!("Misc").into_owned(), - props: vec![ - Property { - label: t!("Dimension style").into_owned(), - field: "style_name", - value: PropValue::PlainText(d.base.style_name.clone()), - }, - edit(t!("Center X").as_ref(), "definition_x", d.definition_point.x), - edit(t!("Center Y").as_ref(), "definition_y", d.definition_point.y), - edit(t!("Center Z").as_ref(), "definition_z", d.definition_point.z), - edit(t!("Chord X").as_ref(), "chord_x", d.chord_point.x), - edit(t!("Chord Y").as_ref(), "chord_y", d.chord_point.y), - edit(t!("Chord Z").as_ref(), "chord_z", d.chord_point.z), - edit( - t!("Override Center X").as_ref(), - "override_x", - d.override_center.x, - ), - edit( - t!("Override Center Y").as_ref(), - "override_y", - d.override_center.y, - ), - edit( - t!("Override Center Z").as_ref(), - "override_z", - d.override_center.z, - ), - edit(t!("Jog X").as_ref(), "jog_x", d.jog_point.x), - edit(t!("Jog Y").as_ref(), "jog_y", d.jog_point.y), - edit(t!("Jog Z").as_ref(), "jog_z", d.jog_point.z), - edit_angle( - t!("Jog Angle").as_ref(), - "jog_angle", - d.jog_angle.to_degrees(), - ), - ro( - t!("Measurement").as_ref(), - "measurement", - format!("{:.4}", d.measurement()), - ), - ], + props: vec![Property { + label: t!("Dim style").into_owned(), + field: "style_name", + value: PropValue::PlainText(d.base.style_name.clone()), + }], }]; } let compact_linear = match dim { @@ -406,16 +370,19 @@ fn apply_base_prop(base: &mut DimensionBase, field: &str, value: &str) -> bool { // Editing the text position in the properties panel pins it to a // user-defined location (stops following DIMTAD). See #94. "text_x" => { - base.text_user_positioned = true; - assign_f64(value, &mut base.text_middle_point.x) + let changed = assign_f64(value, &mut base.text_middle_point.x); + base.text_user_positioned |= changed; + changed } "text_y" => { - base.text_user_positioned = true; - assign_f64(value, &mut base.text_middle_point.y) + let changed = assign_f64(value, &mut base.text_middle_point.y); + base.text_user_positioned |= changed; + changed } "text_z" => { - base.text_user_positioned = true; - assign_f64(value, &mut base.text_middle_point.z) + let changed = assign_f64(value, &mut base.text_middle_point.z); + base.text_user_positioned |= changed; + changed } "text_rotation" => assign_deg(value, &mut base.text_rotation), "horizontal_direction" => assign_deg(value, &mut base.horizontal_direction), @@ -791,6 +758,26 @@ fn apply_arc_fields(d: &mut DimensionArc, field: &str, value: &str) { } fn apply_large_radial_fields(d: &mut DimensionLargeRadial, field: &str, value: &str) { + if field == "large_radial_rotation" { + let Some(angle) = parse_f64(value).map(f64::to_radians) else { + return; + }; + let delta = angle - large_radial_rotation(d); + let origin = d.definition_point; + let rotate = |point: &mut acadrust::types::Vector3| { + let x = point.x - origin.x; + let y = point.y - origin.y; + let (sin, cos) = delta.sin_cos(); + point.x = origin.x + x * cos - y * sin; + point.y = origin.y + x * sin + y * cos; + }; + rotate(&mut d.chord_point); + rotate(&mut d.override_center); + rotate(&mut d.jog_point); + rotate(&mut d.base.text_middle_point); + rotate(&mut d.base.insertion_point); + return; + } match field { "definition_x" => { let _ = assign_f64(value, &mut d.definition_point.x); @@ -835,6 +822,16 @@ fn apply_large_radial_fields(d: &mut DimensionLargeRadial, field: &str, value: & } } +fn large_radial_rotation(d: &DimensionLargeRadial) -> f64 { + let delta = d.chord_point - d.definition_point; + if delta.x.abs() <= 1.0e-12 && delta.y.abs() <= 1.0e-12 { + let visible = d.chord_point - d.override_center; + visible.y.atan2(visible.x) + } else { + delta.y.atan2(delta.x) + } +} + fn apply_transform(dim: &mut Dimension, t: &EntityTransform) { if matches!(dim, Dimension::Ordinate(_)) { match t { @@ -1701,6 +1698,139 @@ use acadrust::entities::{MText, Text}; use acadrust::tables::DimStyle; use acadrust::types::{Color as AcadColor, Vector3}; +pub(crate) fn resolved_dimension_style( + source: &DimStyle, + dimension: &Dimension, + document: &CadDocument, +) -> DimStyle { + use crate::entities::dim_override as ov; + + let mut style = source.clone(); + let data = &dimension.base().common.extended_data; + macro_rules! real { + ($field:ident, $code:ident) => { + if let Some(value) = ov::real(data, ov::$code) { + style.$field = value; + } + }; + } + macro_rules! int { + ($field:ident, $code:ident) => { + if let Some(value) = ov::int(data, ov::$code) { + style.$field = value; + } + }; + } + macro_rules! flag { + ($field:ident, $code:ident) => { + if let Some(value) = ov::int(data, ov::$code) { + style.$field = value != 0; + } + }; + } + macro_rules! handle { + ($field:ident, $code:ident) => { + if let Some(value) = ov::handle(data, ov::$code) { + style.$field = value; + } + }; + } + + real!(dimscale, DIMSCALE); + real!(dimasz, DIMASZ); + real!(dimexo, DIMEXO); + real!(dimdli, DIMDLI); + real!(dimexe, DIMEXE); + real!(dimrnd, DIMRND); + real!(dimdle, DIMDLE); + real!(dimtp, DIMTP); + real!(dimtm, DIMTM); + real!(dimfxl, DIMFXL); + real!(dimjogang, DIMJOGANG); + real!(dimtxt, DIMTXT); + real!(dimcen, DIMCEN); + real!(dimtsz, DIMTSZ); + real!(dimaltf, DIMALTF); + real!(dimlfac, DIMLFAC); + real!(dimtvp, DIMTVP); + real!(dimtfac, DIMTFAC); + real!(dimgap, DIMGAP); + real!(dimaltrnd, DIMALTRND); + real!(dimaltmzf, DIMALTMZF); + real!(dimmzf, DIMMZF); + flag!(dimtol, DIMTOL); + flag!(dimlim, DIMLIM); + flag!(dimtih, DIMTIH); + flag!(dimtoh, DIMTOH); + flag!(dimse1, DIMSE1); + flag!(dimse2, DIMSE2); + flag!(dimalt, DIMALT); + flag!(dimtofl, DIMTOFL); + flag!(dimsah, DIMSAH); + flag!(dimtix, DIMTIX); + flag!(dimsoxd, DIMSOXD); + flag!(dimsd1, DIMSD1); + flag!(dimsd2, DIMSD2); + flag!(dimupt, DIMUPT); + flag!(dimfxlon, DIMFXLON); + flag!(dimtxtdirection, DIMTXTDIRECTION); + int!(dimzin, DIMZIN); + int!(dimtad, DIMTAD); + int!(dimazin, DIMAZIN); + int!(dimarcsym, DIMARCSYM); + int!(dimclrd, DIMCLRD); + int!(dimclre, DIMCLRE); + int!(dimclrt, DIMCLRT); + int!(dimadec, DIMADEC); + int!(dimaltd, DIMALTD); + int!(dimdec, DIMDEC); + int!(dimtdec, DIMTDEC); + int!(dimaltu, DIMALTU); + int!(dimalttd, DIMALTTD); + int!(dimaunit, DIMAUNIT); + int!(dimfrac, DIMFRAC); + int!(dimlunit, DIMLUNIT); + int!(dimdsep, DIMDSEP); + int!(dimtmove, DIMTMOVE); + int!(dimjust, DIMJUST); + int!(dimtolj, DIMTOLJ); + int!(dimtzin, DIMTZIN); + int!(dimaltz, DIMALTZ); + int!(dimalttz, DIMALTTZ); + int!(dimatfit, DIMATFIT); + int!(dimtfill, DIMTFILL); + int!(dimtfillclr, DIMTFILLCLR); + int!(dimlwd, DIMLWD); + int!(dimlwe, DIMLWE); + handle!(dimldrblk, DIMLDRBLK); + handle!(dimblk, DIMBLK); + handle!(dimblk1, DIMBLK1); + handle!(dimblk2, DIMBLK2); + handle!(dimltex_handle, DIMLTYPE); + handle!(dimltex1_handle, DIMLTEX1); + handle!(dimltex2_handle, DIMLTEX2); + + if let Some(value) = ov::string(data, ov::DIMPOST) { + style.dimpost = value; + } + if let Some(value) = ov::string(data, ov::DIMAPOST) { + style.dimapost = value; + } + if let Some(value) = ov::string(data, ov::DIMALTMZS) { + style.dimaltmzs = value; + } + if let Some(value) = ov::string(data, ov::DIMMZS) { + style.dimmzs = value; + } + if let Some(value) = ov::handle(data, ov::DIMTXSTY) { + style.dimtxsty_handle = value; + if let Some(record) = document.text_styles.iter().find(|record| record.handle == value) { + style.dimtxsty = record.name.clone(); + } + } + style +} + /// Build the linear-dimension property groups from the assigned style plus /// any entity-level DSTYLE overrides. Conditional rows remain visible but are /// read-only while their controlling option is disabled. @@ -1711,6 +1841,7 @@ pub fn style_sections( ) -> Vec { use crate::entities::dim_override as ov; + let effective_style = resolved_dimension_style(style, dimension, document); let data = &dimension.base().common.extended_data; let real = |code, inherited| ov::real(data, code).unwrap_or(inherited); let int = |code, inherited| ov::int(data, code).unwrap_or(inherited); @@ -1757,7 +1888,7 @@ pub fn style_sections( ) }; - let s = style; + let s = &effective_style; let dimfxlon = int(ov::DIMFXLON, s.dimfxlon as i16) != 0; let dimtix = int(ov::DIMTIX, s.dimtix as i16) != 0; let dimlunit = int(ov::DIMLUNIT, s.dimlunit); @@ -1861,18 +1992,10 @@ pub fn style_sections( linetype_name(document, ov::handle(data, code).unwrap_or(inherited)) }; let arrow_1 = if matches!(dimension, Dimension::Radius(_) | Dimension::LargeRadial(_)) { - let inherited = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; - let inherited_name = if s.dimblk1.is_null() { - &s.dimblk_name - } else { - &s.dimblk1_name - }; block_name( document, - ov::handle(data, ov::DIMBLK1) - .or_else(|| ov::handle(data, ov::DIMBLK)) - .unwrap_or(inherited), - inherited_name, + ov::handle(data, ov::DIMLDRBLK).unwrap_or(s.dimldrblk), + "Closed filled", ) } else { let inherited = if matches!(dimension, Dimension::Diameter(_)) && !s.dimsah { @@ -1902,7 +2025,7 @@ pub fn style_sections( } } - let precision_options: Vec = (0..=8).map(|value| value.to_string()).collect(); + let precision_options: Vec = (0..=8).map(precision_label).collect(); let linear_units = [ "Scientific", "Decimal", @@ -2149,7 +2272,7 @@ pub fn style_sections( "dim_text_inside_align", on(int(ov::DIMTIH, s.dimtih as i16) != 0), &["On", "Off"], - dimtix, + dimtix || matches!(dimension, Dimension::LargeRadial(_)), ), property( t!("Text position X").as_ref(), @@ -2179,11 +2302,11 @@ pub fn style_sections( t!("Text view direction").as_ref(), "dim_text_view_direction", if int(ov::DIMTXTDIRECTION, s.dimtxtdirection as i16) != 0 { - "Right-to-left" + "Right-to-Left" } else { - "Left-to-right" + "Left-to-Right" }, - &["Left-to-right", "Right-to-left"], + &["Left-to-Right", "Right-to-Left"], true, ), property( @@ -2262,13 +2385,13 @@ pub fn style_sections( true, ), text( - t!("Prefix").as_ref(), + t!("Dim prefix").as_ref(), "dim_prefix", dim_prefix.clone(), true, ), text( - t!("Suffix").as_ref(), + t!("Dim suffix").as_ref(), "dim_suffix", dim_suffix.clone(), true, @@ -2323,20 +2446,20 @@ pub fn style_sections( "dim_suppress_zero_feet", yes(suppresses_zero_feet(dimzin)), &["Yes", "No"], - true, + matches!(dimlunit, 3 | 4), ), choice( t!("Suppress zero inches").as_ref(), "dim_suppress_zero_inches", yes(suppresses_zero_inches(dimzin)), &["Yes", "No"], - true, + matches!(dimlunit, 3 | 4), ), property( t!("Precision").as_ref(), "dim_precision", PropValue::Choice { - selected: int(ov::DIMDEC, s.dimdec).to_string(), + selected: precision_label(int(ov::DIMDEC, s.dimdec)), options: precision_options.clone(), }, ), @@ -2364,11 +2487,11 @@ pub fn style_sections( "dim_alt_precision", if dimalt { PropValue::Choice { - selected: int(ov::DIMALTD, s.dimaltd).to_string(), + selected: precision_label(int(ov::DIMALTD, s.dimaltd)), options: precision_options.clone(), } } else { - PropValue::ReadOnly(int(ov::DIMALTD, s.dimaltd).to_string()) + PropValue::ReadOnly(precision_label(int(ov::DIMALTD, s.dimaltd))) }, ), number( @@ -2384,7 +2507,7 @@ pub fn style_sections( dimalt && dimaltz & 4 != 0, ), number( - t!("Alt roundoff").as_ref(), + t!("Alt round").as_ref(), "dim_alt_roundoff", real(ov::DIMALTRND, s.dimaltrnd), dimalt, @@ -2452,11 +2575,11 @@ pub fn style_sections( "dim_tolerance_precision", if tolerance_enabled { PropValue::Choice { - selected: int(ov::DIMTDEC, s.dimtdec).to_string(), + selected: precision_label(int(ov::DIMTDEC, s.dimtdec)), options: precision_options.clone(), } } else { - PropValue::ReadOnly(int(ov::DIMTDEC, s.dimtdec).to_string()) + PropValue::ReadOnly(precision_label(int(ov::DIMTDEC, s.dimtdec))) }, ), number( @@ -2488,7 +2611,7 @@ pub fn style_sections( t!("Tolerance alignment").as_ref(), "dim_tolerance_alignment", tolerance_alignment_label(int(ov::DIMTALN, 0)), - &["Align decimal separators", "Align operational symbols"], + &["Decimal Separator", "Operational Symbols"], true, ), choice( @@ -2524,11 +2647,11 @@ pub fn style_sections( "dim_alt_tolerance_precision", if alternate_tolerance_enabled { PropValue::Choice { - selected: int(ov::DIMALTTD, s.dimalttd).to_string(), + selected: precision_label(int(ov::DIMALTTD, s.dimalttd)), options: precision_options, } } else { - PropValue::ReadOnly(int(ov::DIMALTTD, s.dimalttd).to_string()) + PropValue::ReadOnly(precision_label(int(ov::DIMALTTD, s.dimalttd))) }, ), choice( @@ -2568,9 +2691,9 @@ pub fn style_sections( ) { let dimcen = real(ov::DIMCEN, s.dimcen); let center_type = if dimcen > 1e-12 { - "Center marks" + "Mark" } else if dimcen < -1e-12 { - "Centerlines" + "Line" } else { "None" }; @@ -2583,7 +2706,7 @@ pub fn style_sections( "dim_arrow_size", "dim_line_lineweight", "dim_ext_line_lineweight", - "dim_line_1", + "dim_line_2", "dim_line_color", "dim_linetype", "dim_ext_linetype_1", @@ -2612,9 +2735,15 @@ pub fn style_sections( "dim_arrowhead_1", "dim_arrow_size", "dim_line_lineweight", - "dim_line_1", + "dim_ext_line_lineweight", + "dim_line_2", "dim_line_color", "dim_linetype", + "dim_ext_linetype_1", + "dim_ext_line_1", + "dim_ext_line_color", + "dim_ext_line_ext", + "dim_ext_line_offset", ]; let retained = if matches!(dimension, Dimension::Diameter(_)) { DIAMETER_LINE_FIELDS @@ -2630,7 +2759,7 @@ pub fn style_sections( t!("Center mark").as_ref(), "dim_center_type", center_type, - &["None", "Center marks", "Centerlines"], + &["None", "Mark", "Line"], true, )); lines.props.push(number( @@ -2639,6 +2768,87 @@ pub fn style_sections( dimcen.abs(), center_type != "None", )); + if let Dimension::LargeRadial(radial) = dimension { + lines.props.push(edit( + t!("Center location override X").as_ref(), + "override_x", + radial.override_center.x, + )); + lines.props.push(edit( + t!("Center location override Y").as_ref(), + "override_y", + radial.override_center.y, + )); + lines.props.push(edit( + t!("Jog location X").as_ref(), + "jog_x", + radial.jog_point.x, + )); + lines.props.push(edit( + t!("Jog location Y").as_ref(), + "jog_y", + radial.jog_point.y, + )); + lines.props.push(edit_angle( + t!("Jog angle").as_ref(), + "jog_angle", + radial.jog_angle.to_degrees(), + )); + } + if matches!(dimension, Dimension::Radius(_) | Dimension::LargeRadial(_)) { + if let Some(arrow) = lines + .props + .iter_mut() + .find(|property| property.field == "dim_arrowhead_1") + { + arrow.label = t!("Arrow").into_owned(); + arrow.field = "dim_radial_arrow"; + } + if let Some(dim_line) = lines + .props + .iter_mut() + .find(|property| property.field == "dim_line_2") + { + dim_line.label = t!("Dim line").into_owned(); + } + } + if matches!(dimension, Dimension::LargeRadial(_)) { + for property in &mut lines.props { + property.label = match property.field { + "dim_ext_line_1" => t!("Ext line").into_owned(), + "dim_ext_line_lineweight" => t!("Ext line weight").into_owned(), + "dim_ext_linetype_1" => t!("Ext line type").into_owned(), + _ => property.label.clone(), + }; + } + const LARGE_RADIAL_ORDER: &[&str] = &[ + "dim_radial_arrow", + "dim_arrow_size", + "dim_center_type", + "dim_center_size", + "dim_line_lineweight", + "dim_line_2", + "dim_linetype", + "dim_line_color", + "override_x", + "override_y", + "jog_x", + "jog_y", + "dim_ext_line_1", + "jog_angle", + "dim_ext_line_lineweight", + "dim_ext_linetype_1", + "dim_ext_line_ext", + "dim_ext_line_color", + "dim_ext_line_offset", + ]; + lines.props.sort_by_key(|property| { + LARGE_RADIAL_ORDER + .iter() + .position(|field| *field == property.field) + .unwrap_or(LARGE_RADIAL_ORDER.len()) + }); + } } if let Some(primary_units) = sections .iter_mut() @@ -2651,6 +2861,134 @@ pub fn style_sections( ) }); } + if matches!(dimension, Dimension::LargeRadial(_)) { + for (section_name, excluded) in [ + (t!("Text"), &["dim_text_pos_hor"][..]), + (t!("Fit"), &["dim_line_inside"][..]), + ( + t!("Alternate Units"), + &["dim_alt_sub_units_scale", "dim_alt_sub_units_suffix"][..], + ), + ] { + if let Some(section) = sections + .iter_mut() + .find(|section| section.title == section_name.as_ref()) + { + section + .props + .retain(|property| !excluded.contains(&property.field)); + } + } + for (section_name, fields) in [ + ( + t!("Text"), + &["dim_text_outside_align", "dim_text_pos_vert"][..], + ), + (t!("Fit"), &["dim_fit", "dim_text_inside"][..]), + ] { + if let Some(section) = sections + .iter_mut() + .find(|section| section.title == section_name.as_ref()) + { + for property in &mut section.props { + if fields.contains(&property.field) { + if let PropValue::Choice { selected, .. } = &property.value { + property.value = PropValue::ReadOnly(selected.clone()); + } + } + } + } + } + if let Some(fit) = sections + .iter_mut() + .find(|section| section.title == t!("Fit").as_ref()) + { + const LARGE_RADIAL_FIT_ORDER: &[&str] = &[ + "dim_line_forced", + "dim_scale_overall", + "dim_fit", + "dim_text_inside", + "dim_text_movement", + ]; + fit.props.sort_by_key(|property| { + LARGE_RADIAL_FIT_ORDER + .iter() + .position(|field| *field == property.field) + .unwrap_or(LARGE_RADIAL_FIT_ORDER.len()) + }); + } + if let Some(alternate_units) = sections + .iter_mut() + .find(|section| section.title == t!("Alternate Units").as_ref()) + { + const LARGE_RADIAL_ALT_UNITS_ORDER: &[&str] = &[ + "dim_alt_enabled", + "dim_alt_format", + "dim_alt_precision", + "dim_alt_roundoff", + "dim_alt_scale_factor", + "dim_alt_suppress_leading_zeros", + "dim_alt_suppress_trailing_zeros", + "dim_alt_suppress_zero_feet", + "dim_alt_suppress_zero_inches", + "dim_alt_prefix", + "dim_alt_suffix", + ]; + alternate_units.props.sort_by_key(|property| { + LARGE_RADIAL_ALT_UNITS_ORDER + .iter() + .position(|field| *field == property.field) + .unwrap_or(LARGE_RADIAL_ALT_UNITS_ORDER.len()) + }); + } + if let Some(tolerances) = sections + .iter_mut() + .find(|section| section.title == t!("Tolerances").as_ref()) + { + const LARGE_RADIAL_TOLERANCE_ORDER: &[&str] = &[ + "dim_alt_tolerance_suppress_zero_inches", + "dim_tolerance_alignment", + "dim_tolerance_display", + "dim_tolerance_limit_lower", + "dim_tolerance_limit_upper", + "dim_tolerance_pos_vert", + "dim_tolerance_precision", + "dim_tolerance_suppress_leading_zeros", + "dim_tolerance_suppress_trailing_zeros", + "dim_tolerance_suppress_zero_feet", + "dim_tolerance_suppress_zero_inches", + "dim_tolerance_text_height", + "dim_alt_tolerance_precision", + "dim_alt_tolerance_suppress_leading_zeros", + "dim_alt_tolerance_suppress_trailing_zeros", + "dim_alt_tolerance_suppress_zero_feet", + ]; + tolerances.props.sort_by_key(|property| { + LARGE_RADIAL_TOLERANCE_ORDER + .iter() + .position(|field| *field == property.field) + .unwrap_or(LARGE_RADIAL_TOLERANCE_ORDER.len()) + }); + } + if !dimension.base().text_user_positioned { + let text_position = styled_dimension_text_position(dimension, s, 1.0); + for section in &mut sections { + for property in &mut section.props { + match property.field { + "text_x" => { + property.value = + PropValue::EditText(format!("{:.4}", text_position.x)); + } + "text_y" => { + property.value = + PropValue::EditText(format!("{:.4}", text_position.y)); + } + _ => {} + } + } + } + } + } } if matches!(dimension, Dimension::Arc(_)) { @@ -2801,6 +3139,55 @@ pub fn style_sections( sections } +fn dimension_style_scale(style: &DimStyle, fallback: f64) -> f64 { + if style.dimscale > 1.0e-6 { + style.dimscale + } else { + fallback + } +} + +fn styled_dimension_text_position( + dimension: &Dimension, + style: &DimStyle, + fallback_scale: f64, +) -> Vector3 { + let scale = dimension_style_scale(style, fallback_scale); + dimension_text_pos_f64(dimension, Some(style), style.dimtxt * scale, scale) +} + +pub(crate) fn materialize_large_radial_text_position( + document: &mut CadDocument, + handle: Handle, + value: &str, +) { + if parse_f64(value).is_none() { + return; + } + let Some(EntityType::Dimension(dimension)) = document.get_entity(handle) else { + return; + }; + if !matches!(dimension, Dimension::LargeRadial(_)) + || dimension.base().text_user_positioned + { + return; + } + let source = document.dim_styles.iter().find(|style| { + style.name.eq_ignore_ascii_case(&dimension.base().style_name) + || (dimension.base().style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }); + let position = source + .map(|style| resolved_dimension_style(style, dimension, document)) + .map(|style| styled_dimension_text_position(dimension, &style, 1.0)) + .unwrap_or_else(|| dimension_text_pos_f64(dimension, None, 2.5, 1.0)); + if let Some(EntityType::Dimension(Dimension::LargeRadial(radial))) = + document.get_entity_mut(handle) + { + radial.base.text_middle_point = position; + } +} + fn property(label: &str, field: &'static str, value: PropValue) -> Property { Property { label: label.to_string(), @@ -2899,11 +3286,20 @@ fn tolerance_vertical_label(value: i16) -> &'static str { } } +fn precision_label(value: i16) -> String { + let decimals = value.clamp(0, 8) as usize; + if decimals == 0 { + "0".to_string() + } else { + format!("0.{}", "0".repeat(decimals)) + } +} + fn tolerance_alignment_label(value: i16) -> &'static str { if value == 0 { - "Align decimal separators" + "Decimal Separator" } else { - "Align operational symbols" + "Operational Symbols" } } @@ -2953,8 +3349,8 @@ use acadrust::{CadDocument, EntityType, Handle}; use crate::scene::convert::tess_util::aci_to_rgba; use crate::scene::convert::tessellate::{ - add_polyline, add_segment, append_arrow, arrow_from_block_with_deferred_hatch, - normalized_or, ArrowKind, DimGeom, + add_polyline, add_segment, append_arrow, arrow_from_block, + arrow_from_block_with_deferred_hatch, normalized_or, ArrowKind, DimGeom, }; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; @@ -3173,130 +3569,7 @@ fn tessellate_dimension_inner( s.name.eq_ignore_ascii_case(style_name) || (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) }); - let mut effective_style = source_style.cloned(); - if let Some(style) = &mut effective_style { - use crate::entities::dim_override as ov; - let data = &dim.base().common.extended_data; - macro_rules! real { - ($field:ident, $code:ident) => { - if let Some(value) = ov::real(data, ov::$code) { - style.$field = value; - } - }; - } - macro_rules! int { - ($field:ident, $code:ident) => { - if let Some(value) = ov::int(data, ov::$code) { - style.$field = value; - } - }; - } - macro_rules! flag { - ($field:ident, $code:ident) => { - if let Some(value) = ov::int(data, ov::$code) { - style.$field = value != 0; - } - }; - } - macro_rules! handle { - ($field:ident, $code:ident) => { - if let Some(value) = ov::handle(data, ov::$code) { - style.$field = value; - } - }; - } - real!(dimscale, DIMSCALE); - real!(dimasz, DIMASZ); - real!(dimexo, DIMEXO); - real!(dimdli, DIMDLI); - real!(dimexe, DIMEXE); - real!(dimrnd, DIMRND); - real!(dimdle, DIMDLE); - real!(dimtp, DIMTP); - real!(dimtm, DIMTM); - real!(dimfxl, DIMFXL); - real!(dimjogang, DIMJOGANG); - real!(dimtxt, DIMTXT); - real!(dimcen, DIMCEN); - real!(dimtsz, DIMTSZ); - real!(dimaltf, DIMALTF); - real!(dimlfac, DIMLFAC); - real!(dimtvp, DIMTVP); - real!(dimtfac, DIMTFAC); - real!(dimgap, DIMGAP); - real!(dimaltrnd, DIMALTRND); - real!(dimaltmzf, DIMALTMZF); - real!(dimmzf, DIMMZF); - flag!(dimtol, DIMTOL); - flag!(dimlim, DIMLIM); - flag!(dimtih, DIMTIH); - flag!(dimtoh, DIMTOH); - flag!(dimse1, DIMSE1); - flag!(dimse2, DIMSE2); - flag!(dimalt, DIMALT); - flag!(dimtofl, DIMTOFL); - flag!(dimsah, DIMSAH); - flag!(dimtix, DIMTIX); - flag!(dimsoxd, DIMSOXD); - flag!(dimsd1, DIMSD1); - flag!(dimsd2, DIMSD2); - flag!(dimupt, DIMUPT); - flag!(dimfxlon, DIMFXLON); - flag!(dimtxtdirection, DIMTXTDIRECTION); - int!(dimzin, DIMZIN); - int!(dimtad, DIMTAD); - int!(dimazin, DIMAZIN); - int!(dimarcsym, DIMARCSYM); - int!(dimclrd, DIMCLRD); - int!(dimclre, DIMCLRE); - int!(dimclrt, DIMCLRT); - int!(dimadec, DIMADEC); - int!(dimaltd, DIMALTD); - int!(dimdec, DIMDEC); - int!(dimtdec, DIMTDEC); - int!(dimaltu, DIMALTU); - int!(dimalttd, DIMALTTD); - int!(dimaunit, DIMAUNIT); - int!(dimfrac, DIMFRAC); - int!(dimlunit, DIMLUNIT); - int!(dimdsep, DIMDSEP); - int!(dimtmove, DIMTMOVE); - int!(dimjust, DIMJUST); - int!(dimtolj, DIMTOLJ); - int!(dimtzin, DIMTZIN); - int!(dimaltz, DIMALTZ); - int!(dimalttz, DIMALTTZ); - int!(dimatfit, DIMATFIT); - int!(dimtfill, DIMTFILL); - int!(dimtfillclr, DIMTFILLCLR); - int!(dimlwd, DIMLWD); - int!(dimlwe, DIMLWE); - handle!(dimldrblk, DIMLDRBLK); - handle!(dimblk, DIMBLK); - handle!(dimblk1, DIMBLK1); - handle!(dimblk2, DIMBLK2); - handle!(dimltex_handle, DIMLTYPE); - handle!(dimltex1_handle, DIMLTEX1); - handle!(dimltex2_handle, DIMLTEX2); - if let Some(value) = ov::string(data, ov::DIMPOST) { - style.dimpost = value; - } - if let Some(value) = ov::string(data, ov::DIMAPOST) { - style.dimapost = value; - } - if let Some(value) = ov::string(data, ov::DIMALTMZS) { - style.dimaltmzs = value; - } - if let Some(value) = ov::string(data, ov::DIMMZS) { - style.dimmzs = value; - } - if let Some(value) = ov::handle(data, ov::DIMTXSTY) { - style.dimtxsty_handle = value; - if let Some(record) = document.text_styles.iter().find(|record| record.handle == value) { - style.dimtxsty = record.name.clone(); - } - } - } + let effective_style = source_style.map(|style| resolved_dimension_style(style, dim, document)); let style = effective_style.as_ref(); // A positive style scale is fixed. A zero style scale uses the multiplier @@ -3353,9 +3626,10 @@ fn tessellate_dimension_inner( )); // Arrow selection precedence: - // 1. DIMTSZ>0 → oblique tick (overrides DIMBLK*). - // 2. DIMSAH false → DIMBLK on both ends. - // 3. DIMSAH true → DIMBLK1 (first end), DIMBLK2 (second end). + // 1. DIMTSZ>0 → oblique tick (overrides arrow blocks). + // 2. Radius and large-radius dimensions → DIMLDRBLK. + // 3. DIMSAH false → DIMBLK on both ends. + // 4. DIMSAH true → DIMBLK1 (first end), DIMBLK2 (second end). // Unknown / NULL block handles fall back to ClosedFilled. let dimasz = (dimasz_raw as f32).max(0.001); let defer_arrow_hatches = { @@ -3374,8 +3648,12 @@ fn tessellate_dimension_inner( }; (t.clone(), t) } else if let Some(s) = style { - if matches!(dim, Dimension::Radius(_)) { - let handle = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; + if matches!(dim, Dimension::Radius(_) | Dimension::LargeRadial(_)) { + let handle = crate::entities::dim_override::handle( + &dim.base().common.extended_data, + crate::entities::dim_override::DIMLDRBLK, + ) + .unwrap_or(s.dimldrblk); let arrow = arrow_from_block_with_deferred_hatch( document, handle, @@ -3442,37 +3720,7 @@ fn tessellate_dimension_inner( (a.clone(), a) }; - // Text box (local space) so the dim line can be broken where the text - // crosses it — lets a DIMTFILL background sit over the line. The renderer - // draws 2D fills under all wires, so the line is gapped rather than masked. - let dimgap_local = style - .map(|s| (s.dimgap.abs() * dim_scale) as f32) - .unwrap_or(0.09); - let text_width = dimension_text_value(dim, style) - .map(|text| text.chars().count() as f32 * dim_txt as f32 * 0.6 + dimgap_local * 2.0) - .unwrap_or(0.0); - let text_break = { - let tp = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale)); - let tw = (text_width - dimgap_local * 2.0).max(0.0); - if tw > 0.0 { - // Vertical threshold is the bare text half-height (no DIMGAP): the - // line only breaks when it actually passes under the glyphs. Text - // placed above/below the line (DIMTAD 1/4) sits exactly - // `text_half + DIMGAP` away, so excluding the gap here keeps it - // strictly outside and the line continuous — otherwise the two - // terms cancel at the same scaled value and the gap flickers with - // DIMGAP/DIMSCALE. The horizontal half-width keeps DIMGAP so a - // genuine break still clears the text comfortably. (#94) - Some((tp, tw * 0.5 + dimgap_local, dim_txt as f32 * 0.5)) - } else { - None - } - }; - let text_position = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale)); - let text_is_outside = dimension_text_is_outside(dim, style); - let horizontal_text = style.is_some_and(|style| { - (text_is_outside && style.dimtoh) || (!text_is_outside && style.dimtih) - }); + let text_layout = dimension_text_layout(dim, style, dim_txt, dim_scale); let mut geom = dimension_geometry( dim, @@ -3488,13 +3736,14 @@ fn tessellate_dimension_inner( dimcen, ticks: dimtsz_raw > 1e-9, arrow_len: dimasz, - text_width, + text_width: text_layout.width, dimatfit: style.map(|s| s.dimatfit).unwrap_or(3), + dimtix: style.is_some_and(|s| s.dimtix), dimtofl: style.map(|s| s.dimtofl).unwrap_or(false), - text_position, - horizontal_text, + text_position: text_layout.position, + horizontal_text: text_layout.horizontal, text_movement: style.map(|s| s.dimtmove).unwrap_or(0), - text_break, + text_break: text_layout.break_box, }, SuppressFlags { ext1: dimse1, @@ -3516,15 +3765,18 @@ fn tessellate_dimension_inner( } } - // DIMTMOVE = 1: when the saved text_middle_point sits far from the - // dim-line anchor, draw a short leader connecting them. (=0 anchors text - // to the dim line — no leader; =2 frees text without a leader.) + // DIMTMOVE=1 connects the dimension line to rendered text. if let Some(s) = style { if s.dimtmove == 1 { - if let Some((anchor, txt)) = dimtmove_leader_endpoints(dim) { + if let Some((anchor, txt)) = dimtmove_leader_endpoints(dim, text_layout.position) { let gap = dim_txt as f32 * 0.5; if (txt - anchor).length() > gap * 2.0 { - add_segment(&mut geom.dim_lines, anchor, txt); + add_segment_with_text_break( + &mut geom.dim_lines, + anchor, + txt, + text_layout.break_box, + ); } } } @@ -4008,16 +4260,8 @@ fn split_ext_lines(points: &[[f32; 3]]) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { (first, rest) } -/// Endpoints for the DIMTMOVE=1 leader: (anchor on the dim line, saved -/// text_middle_point). Returns None when the dim has no saved text position -/// or has no well-defined dim-line midpoint (radius/diameter handled by -/// their own leg). -fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { - let base = dim.base(); - let txt = base.text_middle_point; - if txt.x * txt.x + txt.y * txt.y + txt.z * txt.z <= 1e-16 { - return None; - } +/// Endpoints for the DIMTMOVE=1 leader. +fn dimtmove_leader_endpoints(dim: &Dimension, txt: Vec3) -> Option<(Vec3, Vec3)> { let lv = |v| vec3_local(v); let anchor = match dim { Dimension::Linear(d) => { @@ -4043,7 +4287,7 @@ fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { Dimension::Diameter(d) => { let chord = lv(d.angle_vertex); let far_chord = lv(d.definition_point); - if chord.distance_squared(lv(txt)) <= far_chord.distance_squared(lv(txt)) { + if chord.distance_squared(txt) <= far_chord.distance_squared(txt) { chord } else { far_chord @@ -4051,9 +4295,29 @@ fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { } Dimension::Angular2Ln(d) => lv(d.dimension_arc), Dimension::Angular3Pt(d) => lv(d.definition_point), + Dimension::LargeRadial(d) => { + let chord = lv(d.chord_point); + let jog = lv(d.jog_point); + let override_center = lv(d.override_center); + let (near, far) = + jogged_radial_break(chord, jog, override_center, d.jog_angle as f32); + let curves = [(chord, near), (near, far), (far, override_center)].map( + |(start, end)| { + cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: [start.x as f64, start.y as f64], + end: [end.x as f64, end.y as f64], + }) + }, + ); + let (_, closest) = cadkernel::geom2d::nearest_of( + curves.iter(), + [txt.x as f64, txt.y as f64], + )?; + Vec3::new(closest.point[0] as f32, closest.point[1] as f32, chord.z) + } _ => return None, }; - Some((anchor, lv(txt))) + Some((anchor, txt)) } /// Build a rectangle of filled triangles sitting under the dim text, used @@ -4156,6 +4420,61 @@ struct SuppressFlags { dim2: bool, } +#[derive(Clone, Copy)] +struct TextBreak { + center: Vec3, + half_width: f32, + half_height: f32, + padding: f32, + rotation: f64, +} + +#[derive(Clone, Copy)] +struct DimensionTextLayout { + position: Vec3, + width: f32, + break_box: Option, + horizontal: bool, +} + +fn dimension_text_layout( + dimension: &Dimension, + style: Option<&DimStyle>, + text_height: f64, + dim_scale: f64, +) -> DimensionTextLayout { + let gap = style + .map(|style| (style.dimgap.abs() * dim_scale) as f32) + .unwrap_or(0.09); + let width = dimension_text_value(dimension, style) + .map(|text| text.chars().count() as f32 * text_height as f32 * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let position = vec3_local(dimension_text_pos_f64( + dimension, + style, + text_height, + dim_scale, + )); + let bare_width = (width - gap * 2.0).max(0.0); + let break_box = (bare_width > 0.0).then_some(TextBreak { + center: position, + half_width: bare_width * 0.5, + half_height: text_height as f32 * 0.5, + padding: gap, + rotation: dimension_text_rotation(dimension, style), + }); + let outside = dimension_text_is_outside(dimension, style); + let horizontal = style.is_some_and(|style| { + (outside && style.dimtoh) || (!outside && style.dimtih) + }); + DimensionTextLayout { + position, + width, + break_box, + horizontal, + } +} + #[derive(Clone, Copy)] struct DimLineParams { dimexo: f32, @@ -4170,14 +4489,12 @@ struct DimLineParams { arrow_len: f32, text_width: f32, dimatfit: i16, + dimtix: bool, dimtofl: bool, text_position: Vec3, horizontal_text: bool, text_movement: i16, - /// Text box (local centre, half-width, half-height) used to break the - /// dimension line where the text sits on it, so a DIMTFILL background reads - /// over the line. None when the text doesn't overlap the line. - text_break: Option<(Vec3, f32, f32)>, + text_break: Option, } fn dimension_geometry( dim: &Dimension, @@ -4245,17 +4562,17 @@ fn dimension_geometry( let mid = center + u * (dist * 0.5); let a = mid - u * half + perp * half; let b = mid + u * half - perp * half; - if !suppress.dim1 { + if !suppress.dim2 { add_segment(&mut g.dim_lines, center, a); add_segment(&mut g.dim_lines, a, b); add_segment(&mut g.dim_lines, b, point); } - } else if !suppress.dim1 { + } else if !suppress.dim2 { add_segment(&mut g.dim_lines, center, point); } let radius = (point - center).length(); let text_is_outside = text.distance(center) > radius + 1e-5; - if text_is_outside && !suppress.dim1 { + if text_is_outside && !suppress.dim2 { let radial = normalized_or(point - center, Vec3::X); let angle_from_horizontal = radial.y.abs().atan2(radial.x.abs()); if params.horizontal_text @@ -4280,12 +4597,14 @@ fn dimension_geometry( add_segment(&mut g.dim_lines, point, text); } } - append_arrow( - &mut g, - point, - normalized_or(center - point, Vec3::X), - arrow1, - ); + if !suppress.dim2 { + append_arrow( + &mut g, + point, + normalized_or(center - point, Vec3::X), + arrow1, + ); + } if text_is_outside { append_center_mark(&mut g, center, params.dimcen, radius); } @@ -4411,28 +4730,131 @@ fn dimension_geometry( let override_center = lv(d.override_center); let (near, far) = jogged_radial_break(chord, jog, override_center, d.jog_angle as f32); - if !suppress.dim1 { - add_segment(&mut g.dim_lines, chord, near); - add_segment(&mut g.dim_lines, near, far); - add_segment(&mut g.dim_lines, far, override_center); + let axis = normalized_or(chord - override_center, Vec3::X); + let available = chord.distance(override_center); + let arrows_outside = if params.ticks || params.arrow_len <= 1.0e-6 { + false + } else if available < params.arrow_len { + true + } else if available < params.text_width + params.arrow_len { + if params.dimtix { + true + } else { + match params.dimatfit { + 0 | 1 => true, + 2 => false, + _ => params.text_width <= available, + } + } + } else { + false + }; + let true_center = lv(d.definition_point); + let radius = chord.distance(true_center); + let text_outside = params.text_position.distance(true_center) > radius + 1.0e-5; + let draw_inside_line = !arrows_outside || params.dimtofl; + if draw_inside_line && !suppress.dim2 { + add_segment_with_text_break(&mut g.dim_lines, chord, near, params.text_break); + add_segment_with_text_break(&mut g.dim_lines, near, far, params.text_break); + add_segment_with_text_break( + &mut g.dim_lines, + far, + override_center, + params.text_break, + ); + } + if arrows_outside && !suppress.dim2 { + add_segment( + &mut g.dim_lines, + chord, + chord + axis * (params.arrow_len * 2.0), + ); + } + if !suppress.dim2 { + append_arrow( + &mut g, + chord, + if arrows_outside { axis } else { -axis }, + arrow1, + ); + } + if text_outside && params.text_movement == 0 && !suppress.dim2 { + add_segment_with_text_break( + &mut g.dim_lines, + chord, + params.text_position, + params.text_break, + ); + } + if arrows_outside || text_outside { + append_center_mark(&mut g, true_center, params.dimcen, radius); } - append_arrow( - &mut g, - chord, - normalized_or(near - chord, Vec3::X), - arrow1, - ); - append_center_mark( - &mut g, - lv(d.definition_point), - params.dimcen, - (chord - lv(d.definition_point)).length(), - ); } } g } +fn add_segment_with_text_break( + points: &mut Vec<[f32; 3]>, + start: Vec3, + end: Vec3, + text_break: Option, +) { + let segment = end - start; + let length = segment.length(); + if length <= 1.0e-6 { + return; + } + let Some(text_break) = text_break else { + add_segment(points, start, end); + return; + }; + let segment_curve = cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: [start.x as f64, start.y as f64], + end: [end.x as f64, end.y as f64], + }); + let tolerance = cadkernel::geom2d::Tolerance::new((length as f64 * 1.0e-9).max(1.0e-9)); + let inside = |padding: f32| { + let (sin, cos) = text_break.rotation.sin_cos(); + let x_axis = Vec3::new(cos as f32, sin as f32, 0.0); + let y_axis = Vec3::new(-sin as f32, cos as f32, 0.0); + let half_width = text_break.half_width + padding; + let half_height = text_break.half_height + padding; + let corners = [ + text_break.center - x_axis * half_width - y_axis * half_height, + text_break.center + x_axis * half_width - y_axis * half_height, + text_break.center + x_axis * half_width + y_axis * half_height, + text_break.center - x_axis * half_width + y_axis * half_height, + ]; + let boundary: Vec<_> = (0..4) + .map(|index| { + let next = (index + 1) % 4; + cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: [corners[index].x as f64, corners[index].y as f64], + end: [corners[next].x as f64, corners[next].y as f64], + }) + }) + .collect(); + cadkernel::geom2d::inside_spans(&boundary, &segment_curve, tolerance) + }; + if inside(0.0).is_empty() { + add_segment(points, start, end); + return; + } + let mut cursor = 0.0_f32; + for [cut_start, cut_end] in inside(text_break.padding.max(0.0)) { + let cut_start = (cut_start as f32).clamp(0.0, 1.0); + let cut_end = (cut_end as f32).clamp(0.0, 1.0); + if cut_start - cursor > 1.0e-6 { + add_segment(points, start.lerp(end, cursor), start.lerp(end, cut_start)); + } + cursor = cursor.max(cut_end); + } + if 1.0 - cursor > 1.0e-6 { + add_segment(points, start.lerp(end, cursor), end); + } +} + fn jogged_radial_break( chord: Vec3, jog: Vec3, @@ -4543,44 +4965,20 @@ fn append_linear_dimension( false }; - // The two suppression flags address the portions at the first and second - // measured points independently. The text gap is also the natural split; - // when there is no gap, split at the projected text position or midpoint. let line_dir = normalized_or(d2_out - d1_out, axis); let line_len = (d2_out - d1_out).length(); - let mut split = line_len * 0.5; - let mut left_end = split; - let mut right_start = split; - if let Some((text_center, half_width, half_height)) = params.text_break { - let along = (text_center - d1_out).dot(line_dir); - if along > 0.0 && along < line_len { - split = along; - left_end = split; - right_start = split; - let perpendicular = (text_center - (d1_out + line_dir * along)).length(); - if perpendicular < half_height - && along - half_width > 0.0 - && along + half_width < line_len - { - left_end = along - half_width; - right_start = along + half_width; - } - } - } + let split = params + .text_break + .map(|text_break| (text_break.center - d1_out).dot(line_dir)) + .filter(|along| *along > 0.0 && *along < line_len) + .unwrap_or(line_len * 0.5); + let split_point = d1_out + line_dir * split; let draw_inside_line = !arrows_outside || params.dimtofl; - if draw_inside_line && !suppress.dim1 && left_end > 1e-6 { - add_segment( - &mut g.dim_lines, - d1_out, - d1_out + line_dir * left_end, - ); + if draw_inside_line && !suppress.dim1 && split > 1e-6 { + add_segment_with_text_break(&mut g.dim_lines, d1_out, split_point, params.text_break); } - if draw_inside_line && !suppress.dim2 && line_len - right_start > 1e-6 { - add_segment( - &mut g.dim_lines, - d1_out + line_dir * right_start, - d2_out, - ); + if draw_inside_line && !suppress.dim2 && line_len - split > 1e-6 { + add_segment_with_text_break(&mut g.dim_lines, split_point, d2_out, params.text_break); } if arrows_outside && !params.dimsoxd { let stub = params.arrow_len * 2.0; @@ -4636,34 +5034,19 @@ fn append_diameter_dimension( let first = chord - axis * extension; let second = far_chord + axis * extension; let line_length = first.distance(second); - let mut left_end = line_length * 0.5; - let mut right_start = left_end; - if let Some((text_center, half_width, half_height)) = params.text_break { - let along = (text_center - first).dot(axis); - if along > 0.0 && along < line_length { - left_end = along; - right_start = along; - let perpendicular = (text_center - (first + axis * along)).length(); - if perpendicular < half_height - && along - half_width > 0.0 - && along + half_width < line_length - { - left_end = along - half_width; - right_start = along + half_width; - } - } - } + let split = params + .text_break + .map(|text_break| (text_break.center - first).dot(axis)) + .filter(|along| *along > 0.0 && *along < line_length) + .unwrap_or(line_length * 0.5); + let split_point = first + axis * split; let draw_inside_line = !arrows_outside || params.dimtofl; - if draw_inside_line && !suppress.dim1 && left_end > 1e-6 { - add_segment(&mut g.dim_lines, first, first + axis * left_end); + if draw_inside_line && !suppress.dim1 && split > 1e-6 { + add_segment_with_text_break(&mut g.dim_lines, first, split_point, params.text_break); } - if draw_inside_line && !suppress.dim2 && line_length - right_start > 1e-6 { - add_segment( - &mut g.dim_lines, - first + axis * right_start, - second, - ); + if draw_inside_line && !suppress.dim2 && line_length - split > 1e-6 { + add_segment_with_text_break(&mut g.dim_lines, split_point, second, params.text_break); } if arrows_outside && !params.dimsoxd { let stub = params.arrow_len * 2.0; @@ -5126,17 +5509,7 @@ fn append_angular_dimension( } let a = arc_pts[index]; let b = arc_pts[index + 1]; - let hidden_by_text = params.text_break.is_some_and(|(center, half_width, half_height)| { - let angle = draw_start + draw_delta * t; - let radial = Vec3::new(angle.cos(), angle.sin(), 0.0); - let tangent = Vec3::new(-angle.sin(), angle.cos(), 0.0); - let offset = center - (a + b) * 0.5; - offset.dot(tangent).abs() <= half_width - && offset.dot(radial).abs() <= half_height - }); - if !hidden_by_text { - add_segment(&mut g.dim_lines, a, b); - } + add_segment_with_text_break(&mut g.dim_lines, a, b, params.text_break); } } @@ -5162,28 +5535,29 @@ fn append_angular_dimension( } } - if let Some((text_center, half_width, _)) = params.text_break { - let text_angle = (text_center.y - vertex.y).atan2(text_center.x - vertex.x); + if let Some(text_break) = params.text_break { + let text_angle = (text_break.center.y - vertex.y).atan2(text_break.center.x - vertex.x); let into = (text_angle - start).rem_euclid(std::f32::consts::TAU); if into > delta.abs() + 1.0e-6 { let back_to_start = (start - text_angle).rem_euclid(std::f32::consts::TAU); let forward_from_end = (text_angle - end).rem_euclid(std::f32::consts::TAU); - let text_gap = (half_width / radius).min(std::f32::consts::FRAC_PI_2); if back_to_start <= forward_from_end && !suppress.dim1 { - append_sampled_arc( + append_sampled_arc_with_text_break( &mut g.dim_lines, vertex, radius, - text_angle + text_gap, + text_angle, start, + params.text_break, ); } else if !suppress.dim2 { - append_sampled_arc( + append_sampled_arc_with_text_break( &mut g.dim_lines, vertex, radius, end, - text_angle - text_gap, + text_angle, + params.text_break, ); } } @@ -5222,6 +5596,19 @@ fn append_sampled_arc( } } +fn append_sampled_arc_with_text_break( + lines: &mut Vec<[f32; 3]>, + vertex: Vec3, + radius: f32, + start: f32, + end: f32, + text_break: Option, +) { + for pair in sample_angular_arc(vertex, radius, start, end - start).windows(2) { + add_segment_with_text_break(lines, pair[0], pair[1], text_break); + } +} + fn sample_angular_arc(vertex: Vec3, radius: f32, start: f32, sweep: f32) -> Vec { if sweep.abs() <= 1.0e-6 { return vec![vertex + Vec3::new(start.cos() * radius, start.sin() * radius, 0.0)]; @@ -5533,6 +5920,37 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool _ => text_width > span, }; } + if let Dimension::LargeRadial(radial) = dim { + let radius = radial.definition_point.distance(&radial.chord_point); + if dim.base().text_user_positioned { + return radial + .definition_point + .distance(&dim.base().text_middle_point) + > radius + 1.0e-9; + } + if style.dimtix { + return false; + } + let available = radial.override_center.distance(&radial.chord_point); + let scale = if style.dimscale > 1.0e-9 { + style.dimscale + } else { + 1.0 + }; + let height = style.dimtxt * scale; + let gap = style.dimgap.abs() * scale; + let text_width = dimension_text_value(dim, Some(style)) + .map(|value| value.chars().count() as f64 * height * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let arrow = style.dimasz * scale; + let insufficient = text_width + arrow > available; + return insufficient + && match style.dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > available, + _ => text_width > available, + }; + } if let Dimension::Radius(radius) = dim { let dx = radius.definition_point.x - radius.angle_vertex.x; let dy = radius.definition_point.y - radius.angle_vertex.y; @@ -5625,6 +6043,8 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { .atan2(d.definition_point.x - d.angle_vertex.x), Dimension::Diameter(d) => (d.definition_point.y - d.angle_vertex.y) .atan2(d.definition_point.x - d.angle_vertex.x), + Dimension::LargeRadial(d) => (d.chord_point.y - d.override_center.y) + .atan2(d.chord_point.x - d.override_center.x), Dimension::Ordinate(d) => { let axis_rotation = -d.base.horizontal_direction; if d.is_ordinate_type_x { @@ -5633,7 +6053,6 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { axis_rotation } } - _ => 0.0, }; // Clamp to (-π/2, π/2] so text never appears upside-down. let pi = std::f64::consts::PI; @@ -6321,6 +6740,59 @@ fn text_on_dim_line( ) } +fn text_on_single_arrow_dim_line( + first: Vector3, + second: Vector3, + defpt: Vector3, + ax: f64, + ay: f64, + perp_off: f64, + text_width: f64, + arrow: f64, + dimtix: bool, + dimatfit: i16, + tad: i16, +) -> Vector3 { + let (mut nx, mut ny) = (ax, ay); + if nx < 0.0 || (nx == 0.0 && ny < 0.0) { + nx = -nx; + ny = -ny; + } + let (perpendicular_x, perpendicular_y) = (-ny, nx); + let perpendicular_sign = match tad { + 4 => -1.0, + 2 => { + let offset = (defpt.x - first.x) * perpendicular_x + + (defpt.y - first.y) * perpendicular_y; + if offset >= 0.0 { 1.0 } else { -1.0 } + } + _ => 1.0, + }; + let first_along = (first.x - defpt.x) * ax + (first.y - defpt.y) * ay; + let second_along = (second.x - defpt.x) * ax + (second.y - defpt.y) * ay; + let low = first_along.min(second_along); + let high = first_along.max(second_along); + let available = high - low; + let insufficient = text_width + arrow > available; + let text_outside = !dimtix + && insufficient + && match dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > available, + _ => text_width > available, + }; + let along = if text_outside { + high + arrow + text_width * 0.5 + } else { + (first_along + second_along) * 0.5 + }; + Vector3::new( + defpt.x + ax * along + perpendicular_x * perp_off * perpendicular_sign, + defpt.y + ay * along + perpendicular_y * perp_off * perpendicular_sign, + defpt.z, + ) +} + fn dimension_text_pos_f64( dim: &Dimension, style: Option<&DimStyle>, @@ -6352,7 +6824,13 @@ fn dimension_text_pos_f64( let text_w = dimension_text_value(dim, style) .map(|t| t.chars().count() as f64 * text_height * 0.6 + 2.0 * dimgap) .unwrap_or(0.0); - let arrow = text_height; // arrows are roughly text-height sized + let arrow = if matches!(dim, Dimension::LargeRadial(_)) { + style + .map(|style| style.dimasz * dim_scale) + .unwrap_or(text_height) + } else { + text_height + }; // Explicit per-entity override (text dragged to a custom location): the // saved point wins. Otherwise the dimension style governs placement. @@ -6454,6 +6932,24 @@ fn dimension_text_pos_f64( } Vector3::new(x, y, d.definition_point.z) } + Dimension::LargeRadial(d) => { + let dx = d.chord_point.x - d.override_center.x; + let dy = d.chord_point.y - d.override_center.y; + let length = dx.hypot(dy).max(1.0e-12); + text_on_single_arrow_dim_line( + d.override_center, + d.chord_point, + d.definition_point, + dx / length, + dy / length, + perp_off, + text_w, + arrow, + dimtix, + dimatfit, + dimtad, + ) + } Dimension::Diameter(d) => { let dx = d.definition_point.x - d.angle_vertex.x; let dy = d.definition_point.y - d.angle_vertex.y; @@ -6499,7 +6995,6 @@ fn dimension_text_pos_f64( Dimension::Angular2Ln(d) => d.dimension_arc, Dimension::Angular3Pt(d) => d.definition_point, Dimension::Arc(d) => d.definition_point, - Dimension::LargeRadial(d) => d.jog_point, _ => base.text_middle_point, }; Vector3::new(mid.x, mid.y + perp_off * perp_sign_default(), mid.z) @@ -6511,39 +7006,130 @@ fn perp_sign_default() -> f64 { 1.0 } -/// The measurement-text entity (Text or MText) for a baked `*D` block, built -/// through the SAME `dimension_text_entity` the live renderer uses — so the -/// baked text matches the on-screen value, position, height, rotation, -/// alignment, text style and MText handling, and nothing shifts when the file -/// is saved and reopened (the reload renders from the block). Returns `None` -/// when the text is suppressed (`user_text` is a single space). `anno_scale` is -/// the annotative scale (1.0 for a plain model-space bake). +pub(crate) fn baked_large_radial_geometry( + dimension: &Dimension, + document: &CadDocument, +) -> Option { + if !matches!(dimension, Dimension::LargeRadial(_)) { + return None; + } + let style_name = dimension.base().style_name.as_str(); + let effective_style = document + .dim_styles + .iter() + .find(|style| { + style.name.eq_ignore_ascii_case(style_name) + || (style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }) + .map(|style| resolved_dimension_style(style, dimension, document)); + let style = effective_style.as_ref(); + let scale = style + .map(|style| dimension_style_scale(style, 1.0)) + .unwrap_or(1.0); + let text_height = style.map(|style| style.dimtxt * scale).unwrap_or(2.5 * scale); + let arrow_size_raw = style.map(|style| style.dimasz * scale).unwrap_or(0.18 * scale); + let tick_size = style.map(|style| style.dimtsz * scale).unwrap_or(0.0); + let arrow_size = (arrow_size_raw as f32).max(0.001); + let arrow = if tick_size > 1.0e-9 { + ArrowKind::Tick { + size: (tick_size as f32).max(0.001), + } + } else if let Some(style) = style { + arrow_from_block(document, style.dimldrblk, arrow_size) + } else { + ArrowKind::Triangle { + size: arrow_size, + filled: true, + size_mul: 1.0, + } + }; + let text = dimension_text_layout(dimension, style, text_height, scale); + let mut geometry = dimension_geometry( + dimension, + &arrow, + &arrow, + DimLineParams { + dimexo: style.map(|style| (style.dimexo * scale) as f32).unwrap_or(0.0), + dimexe: style.map(|style| (style.dimexe * scale) as f32).unwrap_or(0.0), + dimdle: style.map(|style| (style.dimdle * scale) as f32).unwrap_or(0.0), + dimfxl: style.map(|style| (style.dimfxl * scale) as f32).unwrap_or(1.0), + dimfxlon: style.is_some_and(|style| style.dimfxlon), + dimsoxd: style.is_some_and(|style| style.dimsoxd), + dimcen: style.map(|style| (style.dimcen * scale) as f32).unwrap_or(0.09), + ticks: tick_size > 1.0e-9, + arrow_len: arrow_size, + text_width: text.width, + dimatfit: style.map(|style| style.dimatfit).unwrap_or(3), + dimtix: style.is_some_and(|style| style.dimtix), + dimtofl: style.is_some_and(|style| style.dimtofl), + text_position: text.position, + horizontal_text: text.horizontal, + text_movement: style.map(|style| style.dimtmove).unwrap_or(0), + text_break: text.break_box, + }, + SuppressFlags { + ext1: style.is_some_and(|style| style.dimse1), + ext2: style.is_some_and(|style| style.dimse2), + dim1: style.is_some_and(|style| style.dimsd1), + dim2: style.is_some_and(|style| style.dimsd2), + }, + ); + if !style.is_some_and(|style| style.dimse1) { + if let Some(points) = crate::scene::dimension_assoc::radial_extension_points( + document, + dimension.base().common.handle, + style.map(|style| style.dimexo * scale).unwrap_or(0.0), + style.map(|style| style.dimexe * scale).unwrap_or(0.0), + ) { + let points: Vec<_> = points.into_iter().map(vec3_local).collect(); + add_polyline(&mut geometry.ext_lines, &points); + } + } + if style.is_some_and(|style| style.dimtmove == 1) { + if let Some((anchor, endpoint)) = dimtmove_leader_endpoints(dimension, text.position) { + if anchor.distance(endpoint) > text_height as f32 { + add_segment_with_text_break( + &mut geometry.dim_lines, + anchor, + endpoint, + text.break_box, + ); + } + } + } + apply_dimension_breaks( + document, + dimension.base().common.handle, + &mut geometry.dim_lines, + ); + Some(geometry) +} + +/// Build measurement text for a saved dimension block. pub(crate) fn baked_dimension_text_entity( dim: &Dimension, document: &CadDocument, anno_scale: f64, ) -> Option { let style_name = dim.base().style_name.as_str(); - let style = document.dim_styles.iter().find(|s| { - s.name.eq_ignore_ascii_case(style_name) - || (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) - }); - let dim_scale = style - .map(|s| { - if s.dimscale > 1e-6 { - s.dimscale - } else { - anno_scale - } + let effective_style = document + .dim_styles + .iter() + .find(|style| { + style.name.eq_ignore_ascii_case(style_name) + || (style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) }) + .map(|style| resolved_dimension_style(style, dim, document)); + let style = effective_style.as_ref(); + let dim_scale = style + .map(|style| dimension_style_scale(style, anno_scale)) .unwrap_or(1.0); let dim_txt = style .map(|s| s.dimtxt * dim_scale) .unwrap_or(2.5 * dim_scale); let mut ent = dimension_text_entity(dim, dim_txt, style, document, dim_scale)?; - // For a non-default-aligned Text, pin the DXF alignment point (group 11) to - // the insertion point so other CAD programs anchor the centred text where - // OCS does, not at the world origin. if let EntityType::Text(t) = &mut ent { t.alignment_point = Some(t.insertion_point); } diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 2ec212d1e..9ecfa7c70 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -135,6 +135,8 @@ 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, + continue_command: false, } } } diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index 6bd3ef2e4..f23e3b555 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -234,6 +234,8 @@ impl AngularDimensionCommand { CmdResult::CommitDimension { 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 f0225c1c9..bbdfaf412 100644 --- a/src/modules/annotate/arc_length_dim.rs +++ b/src/modules/annotate/arc_length_dim.rs @@ -265,6 +265,8 @@ impl ArcLengthDimensionCommand { CmdResult::CommitDimension { 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 010434c71..19dfcb1ca 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -113,6 +113,8 @@ impl CadCommand for DiameterDimensionCommand { entity: source_plane .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 207e9c352..834222a34 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -1,20 +1,21 @@ -// 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. +use std::collections::HashMap; +use std::f64::consts::PI; -use acadrust::entities::{Dimension, DimensionLinear}; +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 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, 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 +28,353 @@ 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: Vec2, + rotation: f64, + aligned: bool, + perpendicular: Vec2, + next_offset: f64, + increment: f64, + }, + Angular2Ln { + fixed_line: [Vec2; 2], + fixed_is_first: bool, + vertex: Vec2, + fixed_ray: Vec2, + next_radius: f64, + increment: f64, + }, + Angular3Pt { + vertex: Vec2, + fixed_point: Vec2, + fixed_ray: Vec2, + next_radius: f64, + increment: f64, + }, + Ordinate { + definition: Vec2, + leader: Vec2, + is_x: bool, + }, +} + +struct BaselineState { + kind: BaselineKind, + style: SourceStyle, + plane: Plane, +} 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 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 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, + project(&plane, source.base.definition_point), + increment, + ) + } + Dimension::Aligned(source) => { + 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).angle(), + true, + project(&plane, source.base.definition_point), + increment, + ) + } + Dimension::Angular2Ln(source) => { + 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 { + fixed_line, + fixed_is_first: fixed_index == 0, + vertex, + fixed_ray, + next_radius: radius + increment, + increment, + } + } + Dimension::Angular3Pt(source) => { + 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 { + vertex, + fixed_point, + fixed_ray: fixed_point - vertex, + next_radius: radius + increment, + increment, + } + } + Dimension::Ordinate(source) => BaselineKind::Ordinate { + 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, plane }); + self.committed = 0; + true + } + + 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, + rotation, + aligned, + perpendicular, + next_offset, + .. + } => build_linear( + state.plane, + *fixed, + point, + *rotation, + *aligned, + *perpendicular, + *next_offset, + )?, + BaselineKind::Angular2Ln { + fixed_line, + fixed_is_first, + vertex, + fixed_ray, + next_radius, + .. + } => { + let moving = point - *vertex; + let (definition, text) = + 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 = world(state.plane, text); + result.base.insertion_point = result.base.text_middle_point; + Dimension::Angular2Ln(result) + } + BaselineKind::Angular3Pt { + vertex, + fixed_point, + fixed_ray, + next_radius, + .. + } => { + let moving = point - *vertex; + let (definition, text) = + 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 = world(state.plane, text); + result.base.insertion_point = result.base.text_middle_point; + Dimension::Angular3Pt(result) + } + 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 { + Vec2::new(source_leader.x, feature.y) + }; + let leader = Vec2::from(from_dimension.apply_point(leader.into())); + let mut result = DimensionOrdinate::new( + world(state.plane, point), + world(state.plane, leader), + *is_x, + ); + 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) + } + + 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 +384,464 @@ 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:".to_string() + } else if self + .base + .as_ref() + .is_some_and(|base| matches!(&base.kind, BaselineKind::Ordinate { .. })) + { + "DIMBASELINE 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, BaselineKind::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; + }; + self.committed += 1; + self.advance(1.0); + CmdResult::CommitDimension { + entity: EntityType::Dimension(dimension), + association: DimensionAssociationInput::Infer(None), + preserve_base_style: self.preserve_base_style, + continue_command: true, } - let p1 = self.base_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; - } - - let (dim_line_pt, dim_line_pt2) = self.dim_line_pts(p2); - dim.definition_point = v3(dim_line_pt); - dim.base.definition_point = v3(dim_line_pt); - dim.base.text_middle_point = v3((dim_line_pt + dim_line_pt2) * 0.5); - dim.base.insertion_point = dim.base.text_middle_point; - dim.base.actual_measurement = dim.measurement(); - - // Stack the next dim line further out. - self.next_offset += self.dimdli; - - 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() { + self.committed = 0; + CmdResult::NeedPoint + } else { + CmdResult::Cancel } - // dim_line_pts stays in f64; downcast only here, into the preview's - // [f32;3] GPU buffer — correct pixel-space, never touches committed geometry. - let (dim_line_pt, dim_line_pt2) = self.dim_line_pts(pt); - let p1 = self.base_p1.as_vec3(); - let dim_line_pt = dim_line_pt.as_vec3(); - let dim_line_pt2 = dim_line_pt2.as_vec3(); - let pt = pt.as_vec3(); - 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: "dimbase_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; + self.committed = 0; + Some(CmdResult::NeedPoint) + } + "U" | "UNDO" => self.undo_one(), + _ => Some(CmdResult::NeedPoint), + } + } + + fn on_undo_step(&mut self) -> Option { + 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 linear_base( + fixed: Vec2, + rotation: f64, + aligned: bool, + definition: Vec2, + increment: f64, +) -> BaselineKind { + 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 { + fixed, + rotation, + aligned, + perpendicular, + next_offset: offset + increment, + increment, + } } +fn build_linear( + plane: Plane, + fixed: Vec2, + point: Vec2, + rotation: f64, + aligned: bool, + source_perpendicular: Vec2, + offset: f64, +) -> 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 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; + 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)) + } +} -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMBASELINE"] }); // DimBaselineCommand +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 { + (first, second) + } +} + +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(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: Vec2, + first: Vec2, + second: Vec2, + radius: f64, +) -> 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 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 { + (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(value) => { + let plane = plane_from_normal(value.first_point, value.base.normal); + linear_preview( + &mut points, + plane, + project(&plane, value.first_point), + project(&plane, value.second_point), + value.rotation, + project(&plane, value.definition_point), + ); + } + 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, + point(value.feature_location), + point(value.leader_endpoint), + ), + _ => {} + } + 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: "dimbaseline_preview".into(), + points, + 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::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } +} + +fn linear_preview( + points: &mut Vec<[f32; 3]>, + plane: Plane, + first: Vec2, + second: Vec2, + rotation: f64, + definition: Vec2, +) { + let perpendicular = Vec2::new(-rotation.sin(), rotation.cos()); + let target = definition.dot(perpendicular); + 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 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_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]>, + plane: Plane, + vertex: Vec2, + first: Vec2, + second: Vec2, + arc_point: Vec2, +) { + 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 { + push_local(points, plane, Vec2::from(arc.point_at(index as f64 / 24.0))); + } +} + +fn project_to_line(point: Vec2, perpendicular: Vec2, offset: f64) -> Vec2 { + point + perpendicular * (offset - point.dot(perpendicular)) +} + +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..45d69520c 100644 --- a/src/modules/annotate/dim_continue.rs +++ b/src/modules/annotate/dim_continue.rs @@ -1,20 +1,19 @@ -// 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, DimensionAngular2Ln, DimensionAngular3Pt, DimensionBase, DimensionLinear, + DimensionOrdinate, +}; use acadrust::types::Vector3; -use acadrust::EntityType; -use glam::{DVec3, Vec3}; +use acadrust::{EntityType, Handle}; +use cadkernel::geom2d::{ + arc_span, closest_point, intersect, nearest_of, Arc, Curve, Ray, Tolerance, Transform, Vec2, + XLine, +}; +use cadkernel::space::Plane; +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 +26,292 @@ 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: Vec2, 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: Vec2, + line_coordinate: f64, + }, + Angular2Ln { + vertex: Vec2, + current: Vec2, + radius: f64, + }, + Angular3Pt { + vertex: Vec2, + current: Vec2, + radius: f64, + }, + Ordinate { + definition: Vec2, + leader: Vec2, + is_x: bool, + }, +} + +#[derive(Clone)] +struct ContinueState { + kind: ContinueKind, + style: SourceStyle, + plane: Plane, +} + +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_world: Option) -> bool { + let EntityType::Dimension(dimension) = entity else { + return false; + }; + let style = SourceStyle::from_base(dimension.base()); + let plane = plane_from_normal(dimension.base().definition_point, style.normal); + let pick = pick_world.and_then(|point| plane.project(point.to_array()).map(Vec2::from)); + let kind = match dimension { + Dimension::Linear(source) => { + let rotation = source.rotation; + let perpendicular = Vec2::new(-rotation.sin(), rotation.cos()); + ContinueKind::Linear { + current: selected_linear_end( + project(&plane, source.first_point), + project(&plane, source.second_point), + pick, + ), + rotation, + perpendicular, + line_coordinate: project(&plane, source.base.definition_point) + .dot(perpendicular), + } + } + Dimension::Aligned(source) => { + let first = project(&plane, source.first_point); + let second = project(&plane, source.second_point); + let Some(direction) = (second - first).normalize() else { + return false; + }; + let rotation = direction.angle(); + let perpendicular = direction.perpendicular(); + ContinueKind::Linear { + current: selected_linear_end(first, second, pick), + rotation, + perpendicular, + line_coordinate: project(&plane, source.base.definition_point) + .dot(perpendicular), + } + } + Dimension::Angular2Ln(source) => { + 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]); + ContinueKind::Angular2Ln { + vertex, + current: selected_angular_end(vertex, [lines[0][1], lines[1][1]], pick), + radius: project(&plane, source.dimension_arc) + .distance(vertex) + .max(1.0e-9), + } + } + Dimension::Angular3Pt(source) => { + let vertex = project(&plane, source.angle_vertex); + ContinueKind::Angular3Pt { + vertex, + current: selected_angular_end( + vertex, + [ + project(&plane, source.first_point), + project(&plane, source.second_point), + ], + pick, + ), + radius: project(&plane, source.definition_point) + .distance(vertex) + .max(1.0e-9), + } + } + Dimension::Ordinate(source) => ContinueKind::Ordinate { + definition: project(&plane, source.definition_point), + leader: project(&plane, source.leader_endpoint), + is_x: source.is_ordinate_type_x, + }, + _ => return false, + }; + self.base = Some(ContinueState { kind, style, plane }); + true + } + + 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 { + ContinueKind::Linear { + current, + rotation, + perpendicular, + line_coordinate, + } => build_linear( + state.plane, + *current, + point, + *rotation, + *perpendicular, + *line_coordinate, + ), + ContinueKind::Angular2Ln { + vertex, + current, + radius, + } => { + let previous = *current - *vertex; + let moving = point - *vertex; + let (definition, text) = + angular_definition_and_text(*vertex, previous, moving, *radius)?; + let mut result = DimensionAngular2Ln::default(); + result.first_point = world(state.plane, *vertex); + result.second_point = world(state.plane, *current); + result.angle_vertex = world(state.plane, *vertex); + result.definition_point = world(state.plane, point); + result.dimension_arc = world(state.plane, definition); + result.base.definition_point = result.dimension_arc; + result.base.text_middle_point = world(state.plane, text); + result.base.insertion_point = result.base.text_middle_point; + Dimension::Angular2Ln(result) + } + ContinueKind::Angular3Pt { + vertex, + current, + radius, + } => { + let previous = *current - *vertex; + let moving = point - *vertex; + let (definition, text) = + angular_definition_and_text(*vertex, previous, moving, *radius)?; + let mut result = DimensionAngular3Pt::new( + world(state.plane, *vertex), + world(state.plane, *current), + world(state.plane, point), + ); + result.definition_point = world(state.plane, definition); + result.base.definition_point = result.definition_point; + result.base.text_middle_point = world(state.plane, text); + result.base.insertion_point = result.base.text_middle_point; + Dimension::Angular3Pt(result) + } + ContinueKind::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 { + Vec2::new(source_leader.x, feature.y) + }; + let leader = Vec2::from(from_dimension.apply_point(leader.into())); + let mut result = DimensionOrdinate::new( + world(state.plane, point), + world(state.plane, leader), + *is_x, + ); + 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) + } + + fn advance(&mut self, point_world: DVec3) { + let Some(point) = self + .base + .as_ref() + .and_then(|state| state.plane.project(point_world.to_array())) + .map(Vec2::from) + else { + return; + }; + 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 +321,521 @@ 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, + continue_command: 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 build_linear( + plane: Plane, + first: Vec2, + second: Vec2, + rotation: f64, + perpendicular: Vec2, + line_coordinate: f64, +) -> Dimension { + let first_line = project_to_line(first, perpendicular, line_coordinate); + let second_line = project_to_line(second, perpendicular, line_coordinate); + let mut result = DimensionLinear::new(world(plane, first), world(plane, second)); + 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; + Dimension::Linear(result) } +fn selected_linear_end(first: Vec2, second: Vec2, pick: Option) -> Vec2 { + pick.map_or(second, |point| { + if point.distance_squared(first) <= point.distance_squared(second) { + first + } else { + second + } + }) +} -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMCONTINUE"] }); // DimContinueCommand +fn selected_angular_end(vertex: Vec2, ends: [Vec2; 2], pick: Option) -> Vec2 { + let Some(pick) = pick else { + return ends[0]; + }; + let rays = ends.map(|end| { + Curve::Ray(Ray { + origin: vertex.into(), + direction: (end - vertex).into(), + }) + }); + nearest_of(rays.iter(), pick.into()) + .map(|(index, _)| ends[index]) + .unwrap_or(ends[0]) +} + +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[0][1] - lines[0][0]) + .length() + .max((lines[1][1] - lines[1][0]).length()) + .max(1.0); + intersect(&first, &second, Tolerance::new(scale * 1.0e-9)) + .first() + .map(|crossing| Vec2::from(crossing.point)) +} + +fn angular_definition_and_text( + vertex: Vec2, + previous: Vec2, + moving: Vec2, + radius: f64, +) -> Option<(Vec2, Vec2)> { + let arc = angular_arc(vertex, previous, moving, radius)?; + Some(( + Vec2::from(arc.point_at(2.0 / 3.0)), + Vec2::from(arc.point_at(0.5)), + )) +} + +fn angular_arc(vertex: Vec2, first: Vec2, second: Vec2, radius: f64) -> Option { + let first = first.normalize()?; + let second = second.normalize()?; + if first.distance_squared(second) <= 1.0e-18 { + return None; + } + let first_angle = first.angle(); + let second_angle = second.angle(); + let (start_angle, end_angle) = + if arc_span(first_angle, second_angle) <= std::f64::consts::PI { + (first_angle, second_angle) + } else { + (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::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::<[f64; 3]>::new(); + match dimension { + Dimension::Linear(value) => { + let plane = plane_from_normal(value.first_point, value.base.normal); + linear_preview( + &mut points, + plane, + project(&plane, value.first_point), + project(&plane, value.second_point), + value.rotation, + project(&plane, value.definition_point), + project(&plane, value.base.text_middle_point), + value.base.actual_measurement, + ); + } + 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), + project(&plane, value.base.text_middle_point), + value.base.actual_measurement, + ); + } + 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), + project(&plane, value.base.text_middle_point), + value.base.actual_measurement, + ); + } + Dimension::Ordinate(value) => { + let plane = plane_from_normal(value.definition_point, value.base.normal); + ordinate_preview( + &mut points, + plane, + project(&plane, value.feature_location), + project(&plane, value.leader_endpoint), + value.base.actual_measurement, + ); + } + _ => {} + } + WireModel::solid_f64( + "dimcontinue_preview".to_string(), + points, + WireModel::CYAN, + false, + ) +} + +fn linear_preview( + points: &mut Vec<[f64; 3]>, + plane: Plane, + first: Vec2, + second: Vec2, + rotation: f64, + definition: Vec2, + text: Vec2, + measurement: f64, +) { + let axis = Vec2::new(rotation.cos(), rotation.sin()); + let perpendicular = axis.perpendicular(); + let coordinate = definition.dot(perpendicular); + let first_line = project_to_line(first, perpendicular, coordinate); + let second_line = project_to_line(second, perpendicular, coordinate); + segment(points, plane, first, first_line); + segment(points, plane, second, second_line); + segment(points, plane, first_line, second_line); + arrow( + points, + plane, + first_line, + axis, + perpendicular, + first_line.distance(second_line), + ); + arrow( + points, + plane, + second_line, + -axis, + perpendicular, + first_line.distance(second_line), + ); + text_box(points, plane, text, axis, perpendicular, measurement); +} + +fn angular_preview( + points: &mut Vec<[f64; 3]>, + plane: Plane, + vertex: Vec2, + first: Vec2, + second: Vec2, + arc_point: Vec2, + text: Vec2, + measurement: f64, +) { + 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(points, plane, vertex, vertex + first_direction * radius); + segment(points, plane, vertex, vertex + second_direction * radius); + for index in 0..=24 { + push_local(points, plane, Vec2::from(arc.point_at(index as f64 / 24.0))); + } + points.push([f64::NAN; 3]); + let start = Vec2::from(arc.point_at(0.0)); + let start_next = Vec2::from(arc.point_at(1.0 / 1024.0)); + let end = Vec2::from(arc.point_at(1.0)); + let end_previous = Vec2::from(arc.point_at(1.0 - 1.0 / 1024.0)); + arrow( + points, + plane, + start, + (start_next - start).normalize().unwrap_or(first_direction), + first_direction, + radius, + ); + arrow( + points, + plane, + end, + (end_previous - end).normalize().unwrap_or(-second_direction), + second_direction, + radius, + ); + let before_middle = Vec2::from(arc.point_at(0.5 - 1.0 / 1024.0)); + let after_middle = Vec2::from(arc.point_at(0.5 + 1.0 / 1024.0)); + let text_axis = (after_middle - before_middle) + .normalize() + .unwrap_or(Vec2::new(1.0, 0.0)); + text_box( + points, + plane, + text, + text_axis, + text_axis.perpendicular(), + measurement, + ); +} + +fn ordinate_preview( + points: &mut Vec<[f64; 3]>, + plane: Plane, + feature: Vec2, + leader: Vec2, + measurement: f64, +) { + segment(points, plane, feature, leader); + let axis = (leader - feature) + .normalize() + .unwrap_or(Vec2::new(0.0, 1.0)); + let perpendicular = axis.perpendicular(); + arrow( + points, + plane, + feature, + axis, + perpendicular, + feature.distance(leader), + ); + text_box( + points, + plane, + leader, + perpendicular, + axis, + measurement, + ); +} + +fn arrow( + points: &mut Vec<[f64; 3]>, + plane: Plane, + tip: Vec2, + inward: Vec2, + perpendicular: Vec2, + span: f64, +) { + let inward = inward.normalize().unwrap_or(Vec2::new(1.0, 0.0)); + let perpendicular = perpendicular + .normalize() + .unwrap_or(Vec2::new(0.0, 1.0)); + let size = span.clamp(1.0, 100.0) * 0.035; + let back = tip + inward * size; + segment( + points, + plane, + tip, + back + perpendicular * size * 0.4, + ); + segment( + points, + plane, + tip, + back - perpendicular * size * 0.4, + ); +} + +fn text_box( + points: &mut Vec<[f64; 3]>, + plane: Plane, + center: Vec2, + axis: Vec2, + perpendicular: Vec2, + 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; + for point in [a, b, c, d, a] { + push_local(points, plane, point); + } + points.push([f64::NAN; 3]); +} + +fn project_to_line(point: Vec2, perpendicular: Vec2, coordinate: f64) -> Vec2 { + let line = Curve::XLine(XLine { + base: (perpendicular * coordinate).into(), + direction: perpendicular.perpendicular().into(), + }); + Vec2::from(closest_point(&line, point.into()).point) +} + +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(points: &mut Vec<[f64; 3]>, plane: Plane, first: Vec2, second: Vec2) { + push_local(points, plane, first); + push_local(points, plane, second); + points.push([f64::NAN; 3]); +} + +fn push_local(points: &mut Vec<[f64; 3]>, plane: Plane, point: Vec2) { + points.push(plane.point_at(point.into())); +} + +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 fdfc6097b..9a517c968 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -139,6 +139,8 @@ impl JoggedRadiusDimensionCommand { dimension, ))), 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 c2f927c80..53d09b08e 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -187,6 +187,8 @@ impl CadCommand for LinearDimensionCommand { CmdResult::CommitDimension { 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 4ac1d8a17..1437ba953 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -116,6 +116,8 @@ impl CadCommand for OrdinateDimCommand { Dimension::Ordinate(dim), )), association: DimensionAssociationInput::Infer(None), + preserve_base_style: false, + continue_command: false, } } } diff --git a/src/modules/annotate/qdim.rs b/src/modules/annotate/qdim.rs index 0cf3e4857..ab10ea0b7 100644 --- a/src/modules/annotate/qdim.rs +++ b/src/modules/annotate/qdim.rs @@ -85,10 +85,8 @@ impl QdimCommand { 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. + // Intersections depend on two curves, but each + // extension origin stores one source. sources: Vec::new(), }); } @@ -180,8 +178,11 @@ impl QdimCommand { 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(), + sources: points + .iter() + .find(|point| point.point.distance(datum) <= tolerance) + .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() @@ -347,13 +348,16 @@ impl CadCommand for QdimCommand { 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); + let x_start = self.plane.to_world(candidate.point - DVec3::X * size); + let x_end = self.plane.to_world(candidate.point + DVec3::X * size); + let y_start = self.plane.to_world(candidate.point - DVec3::Y * size); + let y_end = self.plane.to_world(candidate.point + DVec3::Y * size); [ WireModel::solid_f64(format!("qdim_edit_{index}_x"), vec![ - [point.x - size, point.y, point.z], [point.x + size, point.y, point.z], + x_start.to_array(), x_end.to_array(), ], 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], + y_start.to_array(), y_end.to_array(), ], WireModel::CYAN, false), ] }).collect(); diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 62a833489..4cb6acc9d 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -111,6 +111,8 @@ 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, + continue_command: false, } } } diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index f8626854d..5c4bba066 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -356,6 +356,38 @@ fn dim_seg(a: Vector3, b: Vector3, common: &acadrust::entities::EntityCommon) -> }) } +fn dim_geom_entities( + geometry: &crate::scene::convert::tessellate::DimGeom, + ext_common: &acadrust::entities::EntityCommon, + dim_common: &acadrust::entities::EntityCommon, +) -> Vec { + let mut entities = Vec::new(); + let point = |value: [f32; 3]| { + Vector3::new(value[0] as f64, value[1] as f64, value[2] as f64) + }; + for (points, common) in [ + (geometry.ext_lines.as_slice(), ext_common), + (geometry.dim_lines.as_slice(), dim_common), + ] { + for run in points.split(|point| point[0].is_nan()) { + for pair in run.windows(2) { + entities.push(dim_seg(point(pair[0]), point(pair[1]), common)); + } + } + } + for triangle in geometry.arrow_fill.chunks_exact(3) { + let mut solid = acadrust::entities::Solid::triangle( + point(triangle[0]), + point(triangle[1]), + point(triangle[2]), + ); + solid.common = dim_common.clone(); + solid.common.handle = Handle::NULL; + entities.push(EntityType::Solid(solid)); + } + entities +} + /// A dimension-line terminator at `tip`, body extending back along the unit /// vector `(dx,dy)` (toward the dim line). When DIMTSZ>0 it's an oblique 45° /// tick; otherwise a closed *filled* arrowhead (DXF SOLID) of length DIMASZ — @@ -597,80 +629,23 @@ struct DimMetrics { dimclrt: i16, dimlwd: i16, dimlwe: i16, - /// Resolved terminator shapes for the first / second end (DIMTSZ tick, - /// DIMBLK/DIMBLK1/DIMBLK2 per DIMSAH, else closed-filled), so the bake - /// reproduces the style's actual arrow type. + dimltype: Handle, + dimltex1: Handle, arrow1: crate::scene::convert::tessellate::ArrowKind, arrow2: crate::scene::convert::tessellate::ArrowKind, } -/// Metrics from the dim's style, mirroring what the live renderer applies, so a -/// baked block reproduces the same gaps, arrow type, suppression, colours and -/// lineweights: DIMASZ (arrow), DIMCEN (centre mark), DIMEXO/DIMEXE (extension -/// gap/overshoot), DIMTSZ (oblique tick; >0 = ticks not arrows), DIMDLE (dim -/// line overshoot past ticks), DIMSE1/2 + DIMSD1/2 (extension / dim-line -/// suppression), DIMCLRD/E/T (colours) and DIMLWD/E (lineweights). +/// Resolve style metrics used by saved dimension geometry. fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics { let name = dim.base().style_name.as_str(); - let mut effective_style = doc.dim_styles.iter().find(|s| { - s.name.eq_ignore_ascii_case(name) - || (name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) - }).cloned(); - if let Some(style) = &mut effective_style { - use crate::entities::dim_override as ov; - let data = &dim.base().common.extended_data; - macro_rules! real { - ($field:ident, $code:ident) => { - if let Some(value) = ov::real(data, ov::$code) { - style.$field = value; - } - }; - } - macro_rules! int { - ($field:ident, $code:ident) => { - if let Some(value) = ov::int(data, ov::$code) { - style.$field = value; - } - }; - } - macro_rules! flag { - ($field:ident, $code:ident) => { - if let Some(value) = ov::int(data, ov::$code) { - style.$field = value != 0; - } - }; - } - macro_rules! handle { - ($field:ident, $code:ident) => { - if let Some(value) = ov::handle(data, ov::$code) { - style.$field = value; - } - }; - } - real!(dimscale, DIMSCALE); - real!(dimasz, DIMASZ); - real!(dimcen, DIMCEN); - real!(dimexo, DIMEXO); - real!(dimexe, DIMEXE); - real!(dimtsz, DIMTSZ); - real!(dimdle, DIMDLE); - real!(dimfxl, DIMFXL); - flag!(dimfxlon, DIMFXLON); - flag!(dimse1, DIMSE1); - flag!(dimse2, DIMSE2); - flag!(dimsd1, DIMSD1); - flag!(dimsd2, DIMSD2); - flag!(dimsoxd, DIMSOXD); - flag!(dimsah, DIMSAH); - int!(dimclrd, DIMCLRD); - int!(dimclre, DIMCLRE); - int!(dimclrt, DIMCLRT); - int!(dimlwd, DIMLWD); - int!(dimlwe, DIMLWE); - handle!(dimblk, DIMBLK); - handle!(dimblk1, DIMBLK1); - handle!(dimblk2, DIMBLK2); - } + let effective_style = doc + .dim_styles + .iter() + .find(|style| { + style.name.eq_ignore_ascii_case(name) + || (name.trim().is_empty() && style.name.eq_ignore_ascii_case("Standard")) + }) + .map(|style| crate::entities::dimension::resolved_dimension_style(style, dim, doc)); let style = effective_style.as_ref(); let scale = style .map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }) @@ -679,14 +654,14 @@ fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics { let dimasz = style.map(|s| s.dimasz * scale).unwrap_or(0.18 * scale).max(1e-6); let dimtsz = style.map(|s| s.dimtsz * scale).unwrap_or(0.0); let asz = dimasz as f32; - // Resolve the terminator shapes exactly like the live render: ticks when - // DIMTSZ>0, otherwise the DIMBLK / DIMBLK1+DIMBLK2 (per DIMSAH) arrow blocks, - // else closed-filled. let (arrow1, arrow2) = if dimtsz > 1e-9 { let t = ArrowKind::Tick { size: dimtsz as f32 }; (t.clone(), t) } else if let Some(s) = style { - if matches!(dim, Dimension::Diameter(_)) { + if matches!(dim, Dimension::Radius(_) | Dimension::LargeRadial(_)) { + let arrow = arrow_from_block(doc, s.dimldrblk, asz); + (arrow.clone(), arrow) + } else if matches!(dim, Dimension::Diameter(_)) { use crate::entities::dim_override as ov; let data = &dim.base().common.extended_data; let first = ov::handle(data, ov::DIMBLK1) @@ -723,6 +698,8 @@ fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics { dimclrt: style.map(|s| s.dimclrt).unwrap_or(0), dimlwd: style.map(|s| s.dimlwd).unwrap_or(-2), dimlwe: style.map(|s| s.dimlwe).unwrap_or(-2), + dimltype: style.map(|s| s.dimltex_handle).unwrap_or(Handle::NULL), + dimltex1: style.map(|s| s.dimltex1_handle).unwrap_or(Handle::NULL), arrow1, arrow2, } @@ -744,6 +721,21 @@ fn dim_common(base: &acadrust::entities::EntityCommon, clr: i16, lw: i16) -> aca c } +fn with_dim_linetype( + mut common: acadrust::entities::EntityCommon, + doc: &CadDocument, + handle: Handle, +) -> acadrust::entities::EntityCommon { + common.linetype = doc + .line_types + .iter() + .find(|line_type| line_type.handle == handle) + .map(|line_type| line_type.name.clone()) + .unwrap_or_else(|| "Continuous".to_string()); + common.linetype_handle = (!handle.is_null()).then_some(handle); + common +} + /// Baked geometry for an angular dimension, matching the live render exactly: /// an extension line from each measured ray point flush out to the dimension /// arc, the swept arc, and a terminator at each arc end. (The live angular path @@ -806,49 +798,6 @@ fn angular_block_segs( out } -fn jogged_radial_break( - chord: Vector3, - jog: Vector3, - override_center: Vector3, - jog_angle: f64, -) -> (Vector3, Vector3) { - let radial = norm2( - chord.x - override_center.x, - chord.y - override_center.y, - 1.0, - 0.0, - ); - let (sin, cos) = jog_angle.sin_cos(); - let transverse = ( - radial.0 * cos - radial.1 * sin, - radial.0 * sin + radial.1 * cos, - ); - let length = ((chord.x - override_center.x).powi(2) - + (chord.y - override_center.y).powi(2)) - .sqrt(); - let half = (length * 0.04).max(1e-6); - let first = Vector3::new( - jog.x - transverse.0 * half, - jog.y - transverse.1 * half, - jog.z, - ); - let second = Vector3::new( - jog.x + transverse.0 * half, - jog.y + transverse.1 * half, - jog.z, - ); - let distance_squared = |point: Vector3| { - (point.x - chord.x).powi(2) - + (point.y - chord.y).powi(2) - + (point.z - chord.z).powi(2) - }; - if distance_squared(first) <= distance_squared(second) { - (first, second) - } else { - (second, first) - } -} - /// The text anchor for a radial leader: the saved text middle point when set, /// else the midpoint of `a` and `b` — mirroring the live `dimension_text_position`. fn dim_text_anchor( @@ -1246,38 +1195,14 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { )); } } - Dimension::LargeRadial(d) => { - let (near, far) = jogged_radial_break( - d.chord_point, - d.jog_point, - d.override_center, - d.jog_angle, - ); - if !met.dimsd1 { - result.push(make_seg(&d.chord_point, &near, &dim_c)); - result.push(make_seg(&near, &far, &dim_c)); - result.push(make_seg(&far, &d.override_center, &dim_c)); + Dimension::LargeRadial(_) => { + if let Some(geometry) = + crate::entities::dimension::baked_large_radial_geometry(dim, doc) + { + let radial_ext_c = with_dim_linetype(ext_c.clone(), doc, met.dimltex1); + let radial_dim_c = with_dim_linetype(dim_c.clone(), doc, met.dimltype); + result.extend(dim_geom_entities(&geometry, &radial_ext_c, &radial_dim_c)); } - let len = ((near.x - d.chord_point.x).powi(2) - + (near.y - d.chord_point.y).powi(2)) - .sqrt() - .max(1e-12); - result.extend(dim_terminator( - d.chord_point, - (near.x - d.chord_point.x) / len, - (near.y - d.chord_point.y) / len, - &met.arrow1, - &dim_c, - )); - let radius = ((d.definition_point.x - d.chord_point.x).powi(2) - + (d.definition_point.y - d.chord_point.y).powi(2)) - .sqrt(); - result.extend(dim_center_mark( - d.definition_point, - met.dimcen, - radius, - &dim_c, - )); } } @@ -1371,23 +1296,7 @@ fn next_dimension_block_name(doc: &CadDocument, next: &mut u64) -> String { } } -/// Bake an anonymous `*D` geometry block for every DIMENSION that doesn't -/// already own one, so the file is valid for AutoCAD-family readers. -/// -/// OCS renders dimensions by re-tessellating them on the fly and never -/// materialises the `*D` block that a DWG `DIMENSION` is supposed to reference -/// (the lines / arrows / text that AutoCAD actually draws). A dimension created -/// in OCS therefore goes out referencing a block that doesn't exist, and the -/// writer emits a null block handle — strict readers (DWG TrueView, QCAD) drop -/// the dimension or demand a recovery, and lenient ones (BricsCAD) regenerate it -/// at a different position. Call this on the document about to be written so each -/// such dimension gets a real block built from its exploded geometry (extension -/// lines + dimension line + measurement text, the same decomposition EXPLODE -/// uses) and its `block_name` points at it. -/// -/// Dimensions that already reference an existing block (e.g. imported from a real -/// DWG, or copied via the `*D`-cloning copy path) are left untouched so their -/// original graphics are preserved. +/// Build missing anonymous dimension geometry blocks before writing. pub fn bake_dimension_blocks(doc: &mut CadDocument) { // Keep group-10 in step and find missing blocks in one entity pass. Existing // `*D` blocks are the save cache: only invalidated/new dimensions enter the @@ -1451,35 +1360,13 @@ pub fn bake_dimension_blocks(doc: &mut CadDocument) { if let Some(EntityType::Dimension(d)) = doc.get_entity_mut(handle) { d.base_mut().block_name = name; - // The block we just baked holds the dimension graphics in absolute - // WCS, so the DWG group-12 insertion point (base.insertion_point) - // MUST be the origin. A reader that positions the *D block by that - // point (BricsCAD / ODA) otherwise draws it shifted by the offset, - // while OCS — which renders the block in place — shows it correctly. - // OCS's dimension commands seed insertion_point with the text - // anchor; reset it here so the saved dimension lands identically in - // every application. (#181) + // Baked geometry uses absolute coordinates. d.base_mut().insertion_point = Vector3::new(0.0, 0.0, 0.0); } } } -/// Drop a dimension's baked `*D` block so the next save regenerates it from the -/// dimension's current definition points / text / style. -/// -/// OCS renders a dimension live from its definition points, but exports a baked -/// `*D` block that other applications (BricsCAD / ODA) draw instead. An in-place -/// edit — grip drag, DIMTEDIT, restyle, text edit, DIMSPACE — changes the -/// definition points while leaving the old block, so without this the export -/// keeps the pre-edit graphics and the dimension appears wrong everywhere but in -/// OCS. Call this after any such edit; [`bake_dimension_blocks`] then rebuilds a -/// fresh block on save. -/// -/// The transform path (MOVE / COPY / PASTE) keeps its own block in sync via -/// `define_transformed_block` and must NOT call this. The removed block's record -/// and owned entities are deleted too, so re-baking on every edit can't -/// accumulate orphan `*D` blocks. No-op when the dimension has no baked block -/// yet (the pending path bakes it fresh on save). (#181) +/// Drop baked dimension geometry so the next save regenerates it. pub fn invalidate_dim_block(doc: &mut CadDocument, handle: Handle) { let bn = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().block_name.clone(), diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 974fc5a26..8b3d04711 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -89,14 +89,6 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - if matches!(entity, EntityType::Point(_)) { - let hyperlink = section.props.iter().position(|prop| prop.field == "hyperlink"); - let transparency = section.props.iter().position(|prop| prop.field == "transparency"); - if let (Some(hyperlink), Some(transparency)) = (hyperlink, transparency) { - section.props.swap(hyperlink, transparency); - } - } - // Thickness (DXF 39) is a General-group property, but only the entity // types that carry an extrusion thickness expose it (line, circle, arc, // polyline, text, 2D solid, …). Show it right after Hyperlink for those. diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 108c25155..98c28613f 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -1535,6 +1535,8 @@ pub fn tessellate( | EntityType::Circle(_) | EntityType::Arc(_) | EntityType::Ellipse(_) + | EntityType::Spline(_) + | EntityType::LwPolyline(_) ) { (pattern_length, pattern) } else { 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() +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0894fe12c..9596cc5fa 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1420,6 +1420,9 @@ pub struct Scene { pub selection: Rc>, /// The CAD document — single source of truth for all entities. pub document: CadDocument, + /// Last chain-compatible dimension created this session. + /// Never reconstructed from loaded file handles. + 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 +1851,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(), diff --git a/src/scene/project.rs b/src/scene/project.rs index 786b11639..29ef0735d 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -250,7 +250,7 @@ impl Scene { let source_length = stationed.then(|| wire.pattern_stations[wire.points.len()]); let (clipped, mut clipped_stations) = clip_polyline_to_rect( &projected_pts, - stationed.then_some(&wire.pattern_stations[..wire.points.len()]), + stationed.then(|| &wire.pattern_stations[..wire.points.len()]), vp_x0, vp_y0, vp_x1, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index daaa3f5a6..feeff457d 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3795,14 +3795,21 @@ impl Scene { full_bounds, self.document.header.lineweight_display || display_plot_lineweights, ); - if self.document.header.paper_space_linetype_scaling + + // Model space: scale linetypes using the current annotation scale so their + // appearance can match a paper-space viewport at the same drawing scale. + if self.current_layout == "Model" { + if self.annotation_scale.is_finite() && self.annotation_scale > 1e-9 { + uniforms.linetype_scale = self.annotation_scale; + } + } else if self.document.header.paper_space_linetype_scaling && !inst.paper_sheet - && inst.tile_idx.is_none() && inst.handle != Handle::NULL { if let Some(EntityType::Viewport(vp)) = self.document.get_entity(inst.handle) { let viewport_scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); + if viewport_scale.is_finite() && viewport_scale > 1e-9 { uniforms.linetype_scale = (1.0 / viewport_scale) as f32; } diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 7c0828aae..52c3990cc 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -113,6 +113,57 @@ thread_local! { RefCell::new(FxHashMap::default()); } +// Per-thread cache of `svg::Handle`s keyed by the bytes' +// `(address, length)`. See the doc comment on [`themed_handle`] +// below for the cache key rationale, the sub-slice guard, the +// mirror of `SemanticCacheKey`, and the threading choice. +thread_local! { + static THEMED_CACHE: RefCell> = + RefCell::new(FxHashMap::default()); +} + +/// Look up (or build and cache) the `svg::Handle` for a `&'static [u8]` +/// passed to a `themed*` function. This is the single source of +/// truth for the per-`bytes` handle in the `themed` icon family — +/// the 9 `themed*` functions all go through here, so the 22 +/// `themed*` call sites (and the 113 total `icons::themed` +/// references) share one entry per artwork. +/// +/// **Cache key: `(address, length)`.** Every `include_bytes!` +/// produces a unique `&'static` slice, and the address is stable +/// for the program's lifetime. Including the length guards against +/// any future call site that passes a sub-slice of a static +/// (e.g. `&BYTES[1..]`), which would otherwise alias the wrong +/// handle. Mirrors the proven `SemanticCacheKey` belt-and-braces +/// pattern above. +/// +/// **`themed` vs `semantic` — why the simpler key is correct.** +/// The `semantic` family bakes the palette into the SVG +/// (recolours the artwork per theme role), so its cache key +/// includes the palette. The `themed` family reads its colour +/// from an Iced style closure at draw time, so the handle +/// depends only on the bytes — a single entry per artwork is +/// correct and shared across all themes. +/// +/// **Threading.** The cache is `thread_local!` because `svg::Handle` +/// is not `Sync`. The set of `&'static [u8]` art is fixed at +/// compile time (~25-30 `include_bytes!` entries), so the cache +/// is bounded by construction and needs no eviction. Per-frame +/// cost: 9 ns/iter warm, 26 ns/iter cold (release profile, see +/// `themed_cache_speedup_over_uncached_handle_creation`). +fn themed_handle(bytes: &'static [u8]) -> svg::Handle { + let key = (bytes.as_ptr() as usize, bytes.len()); + THEMED_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if let Some(handle) = cache.get(&key) { + return handle.clone(); + } + let handle = svg::Handle::from_memory(bytes); + cache.insert(key, handle.clone()); + handle + }) +} + #[derive(Clone, Copy)] struct SemanticColors { background: [u8; 7], @@ -382,7 +433,7 @@ fn color_hex(color: Color) -> [u8; 7] { /// Render a chrome icon with the active Iced theme's normal text color. pub fn themed<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) + svg(themed_handle(bytes)) .width(size) .height(size) .style(|theme: &Theme, _| svg::Style { @@ -393,7 +444,7 @@ pub fn themed<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { /// Render secondary chrome with the active Iced theme's text color. pub fn themed_secondary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) + svg(themed_handle(bytes)) .width(size) .height(size) .style(|theme: &Theme, _| svg::Style { @@ -411,7 +462,7 @@ pub fn themed_secondary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<' /// Render disabled chrome with the active Iced theme's text color. pub fn themed_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) + svg(themed_handle(bytes)) .width(size) .height(size) .style(|theme: &Theme, _| svg::Style { @@ -429,7 +480,7 @@ pub fn themed_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a /// Render an emphasized chrome icon with the active Iced theme's primary color. pub fn themed_primary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) + svg(themed_handle(bytes)) .width(size) .height(size) .style(|theme: &Theme, _| svg::Style { @@ -443,7 +494,7 @@ pub fn themed_primary_weak_text<'a, M: 'a>( bytes: &'static [u8], size: f32, ) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) + svg(themed_handle(bytes)) .width(size) .height(size) .style(|theme: &Theme, _| svg::Style { @@ -454,7 +505,7 @@ pub fn themed_primary_weak_text<'a, M: 'a>( /// Render a positive-state chrome icon with the active Iced theme's success color. pub fn themed_success<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) + svg(themed_handle(bytes)) .width(size) .height(size) .style(|theme: &Theme, _| svg::Style { @@ -465,7 +516,7 @@ pub fn themed_success<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, /// Render a warning-state chrome icon from the active Iced theme. pub fn themed_warning<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) + svg(themed_handle(bytes)) .width(size) .height(size) .style(|theme: &Theme, _| svg::Style { @@ -476,7 +527,7 @@ pub fn themed_warning<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, /// Render a destructive-state chrome icon from the active Iced theme. pub fn themed_danger<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) + svg(themed_handle(bytes)) .width(size) .height(size) .style(|theme: &Theme, _| svg::Style { @@ -487,7 +538,7 @@ pub fn themed_danger<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, /// Render an icon with the foreground chosen for a danger-coloured surface. pub fn themed_danger_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) + svg(themed_handle(bytes)) .width(size) .height(size) .style(|theme: &Theme, _| svg::Style { @@ -655,3 +706,125 @@ mod tests { .any(|window| window == needle) } } + +#[cfg(test)] +mod themed_cache_tests { + use super::*; + + /// Number of `svg::Handle` entries currently in the `THEMED_CACHE`. + /// Test-only inspection helper; the cache is private production state. + fn themed_cache_size_for_test() -> usize { + THEMED_CACHE.with(|c| c.borrow().len()) + } + + /// True when `(ptr, len)` (the address and length of a + /// `&'static [u8]`) has an entry in the `THEMED_CACHE`. + /// Test-only inspection helper. + fn themed_cache_contains_for_test(ptr: *const u8, len: usize) -> bool { + THEMED_CACHE.with(|c| c.borrow().contains_key(&(ptr as usize, len))) + } + + /// Two calls to the public `themed` function with the same + /// `&'static [u8]` must share a single cache entry: the second + /// call is a cache hit, not a fresh `Handle::from_memory`. + #[test] + fn themed_cache_returns_same_handle_for_same_bytes() { + let _: Element<'static, ()> = themed(TRI_DOWN, 16.0); + let _: Element<'static, ()> = themed(TRI_DOWN, 16.0); + assert_eq!( + themed_cache_size_for_test(), + 1, + "two calls with the same bytes must share a single cache entry" + ); + assert!( + themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()), + "cache must hold an entry for the requested bytes" + ); + } + + /// Distinct `&'static [u8]` slices must produce distinct cache + /// entries (no false aliasing across different artwork). + #[test] + fn themed_cache_caches_distinct_bytes_separately() { + let _: Element<'static, ()> = themed(TRI_DOWN, 16.0); + let _: Element<'static, ()> = themed(TRI_UP, 16.0); + assert_eq!( + themed_cache_size_for_test(), + 2, + "distinct bytes must produce distinct cache entries" + ); + assert!(themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len())); + assert!(themed_cache_contains_for_test(TRI_UP.as_ptr(), TRI_UP.len())); + } + + /// Before/after benchmark: the cached `themed_handle` path + /// (hashmap get + a `svg::Handle` clone) vs. the original + /// un-cached path (`svg::Handle::from_memory` per call, which + /// parses the SVG and allocates). + /// + /// Integrated as a `#[test]` so it runs in the existing + /// `cargo test --lib` flow with no extra infrastructure — no + /// `criterion`, no `benches/` directory, no nightly + /// `#![feature(test)]`. The "before" is faithfully simulated by + /// calling `svg::Handle::from_memory` directly on the same + /// `&'static [u8]`, which is exactly what the pre-cache code did + /// per call. The "after" warms the cache first so the loop + /// measures pure cache hits. A cold cache miss is also measured + /// on a different static. + /// + /// Run with `--nocapture` to see the numbers: + /// `cargo test --lib themed_cache_speedup_over_uncached_handle_creation -- --nocapture`. + #[test] + fn themed_cache_speedup_over_uncached_handle_creation() { + use std::time::Instant; + + const ITER: u64 = 20_000; + let bytes = TRI_DOWN; // &'static [u8] + + // Warm the cache so the "after" loop measures pure cache hits. + let _ = super::themed_handle(bytes); + + // After: cached path — one hashmap get + a `Handle` clone per call. + let t_after = Instant::now(); + for _ in 0..ITER { + let _h = super::themed_handle(bytes); + } + let after = t_after.elapsed(); + + // Before: original (un-cached) path — SVG parse + allocation per call. + let t_before = Instant::now(); + for _ in 0..ITER { + let _h = svg::Handle::from_memory(bytes); + } + let before = t_before.elapsed(); + + // Cold first call (cache miss): one parse + one insert. Measured + // on a different static so the entry is guaranteed to be fresh. + let cold_bytes = TRI_UP; + let t_cold = Instant::now(); + let _h = super::themed_handle(cold_bytes); + let cold = t_cold.elapsed(); + + let after_ns = after.as_nanos() as u64; + let before_ns = before.as_nanos() as u64; + let cold_ns = cold.as_nanos() as u64; + let per_after = after_ns / ITER; + let per_before = before_ns / ITER; + let speedup = if after_ns > 0 { + before_ns as f64 / after_ns as f64 + } else { + f64::INFINITY + }; + + println!( + "themed_handle: before={}ns/iter, after={}ns/iter, speedup={:.1}x, cold_miss={}ns (ITER={})", + per_before, per_after, speedup, cold_ns, ITER, + ); + + assert!( + after_ns < before_ns, + "cached path must be faster than from_memory: after={}ns before={}ns", + after_ns, before_ns, + ); + } +}