From 6505b2781805c81af082a67b84db879f3735d730 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:55:08 +0300 Subject: [PATCH 1/3] fix(annotate): align annotation ribbon workflows --- src/app/command_driver.rs | 618 +++++++++++++++++++++- src/app/commands/display.rs | 178 ++++++- src/app/commands/mod.rs | 1 + src/command.rs | 15 + src/entities/dimension.rs | 101 +++- src/modules/annotate/aligned_dim.rs | 17 +- src/modules/annotate/angular_dim.rs | 4 +- src/modules/annotate/annotation_scale.rs | 50 ++ src/modules/annotate/arc_length_dim.rs | 4 +- src/modules/annotate/diameter_dim.rs | 4 +- src/modules/annotate/dimbreak.rs | 208 ++++++-- src/modules/annotate/dimedit.rs | 148 ++++-- src/modules/annotate/dimjogline.rs | 238 ++++++--- src/modules/annotate/dimspace.rs | 128 ++++- src/modules/annotate/dimtedit.rs | 279 +++++++--- src/modules/annotate/jogged_radius_dim.rs | 4 +- src/modules/annotate/leader_cmd.rs | 114 +++- src/modules/annotate/linear_dim.rs | 24 +- src/modules/annotate/mleader_cmd.rs | 2 + src/modules/annotate/mod.rs | 57 +- src/modules/annotate/mtext.rs | 2 + src/modules/annotate/ordinate_dim.rs | 4 +- src/modules/annotate/radius_dim.rs | 4 +- src/modules/draw/draw/revcloud.rs | 13 +- src/modules/draw/draw/wipeout.rs | 11 +- src/scene/annotative.rs | 38 ++ 26 files changed, 1921 insertions(+), 345 deletions(-) create mode 100644 src/modules/annotate/annotation_scale.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 01e70881d..016bdfe4b 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2777,6 +2777,33 @@ impl OpenCADStudio { } self.refresh_properties(); } + CmdResult::UpdateEntityAndFinish { handle, entity } => { + if self.reject_locked_edit(i, handle) { + self.tabs[i].active_cmd = None; + return Task::none(); + } + let label = self.history_label_from_active_cmd(i, "EDIT"); + self.push_undo_snapshot(i, label); + let is_dimension = matches!(entity, acadrust::EntityType::Dimension(_)); + if let Some(current) = self.tabs[i].scene.document.get_entity_mut(handle) { + *current = entity; + if is_dimension { + self.tabs[i].scene.invalidate_dim_block_recorded(handle); + } + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + } else { + self.command_line.push_error( + crate::t!("The selected object is no longer available.").as_ref(), + ); + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.refresh_properties(); + } CmdResult::AddSketchConstraint { kind, refs, driving_param, label } => { if let Err(message) = self.tabs[i] .scene @@ -2948,36 +2975,111 @@ impl OpenCADStudio { if new_entities.len() == 1 { if let acadrust::EntityType::XLine(ref xl) = new_entities[0] { let layer = xl.common.layer.clone(); - if layer.starts_with("__DIMBREAK__") - || layer.starts_with("__DIMBREAK_AUTO__") - { - // DIMBREAK needs a break-gap field on the dimension - // model (not yet present) to store and render the gap. - // Report honestly rather than claiming success while - // changing nothing. (#181 / DIM-020) - self.command_line - .push_info(crate::t!("DIMBREAK: not yet implemented — nothing changed.").as_ref()); + if layer.starts_with("__DIMBREAK_") { + self.push_undo_snapshot(i, "DIMBREAK"); + let result = apply_dimbreak(&mut self.tabs[i].scene, &layer); + if result.changed.is_empty() { + self.command_line.push_info(&result.message); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + return Task::none(); + } + for handle in &result.changed { + self.tabs[i].scene.invalidate_dim_block_recorded(*handle); + } + let changes: Vec<_> = result + .changed + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.command_line.push_output(&result.message); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; return Task::none(); } if layer.starts_with("__DIMSPACE__") { + self.push_undo_snapshot(i, "DIMSPACE"); if let Some(encoded) = layer.strip_prefix("__DIMSPACE__") { apply_dimspace(&mut self.tabs[i].scene, encoded); } - self.push_undo_snapshot(i, "DIMSPACE"); self.command_line.push_output(crate::t!("DIMSPACE Spacing adjusted.").as_ref()); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; return Task::none(); } - if layer.starts_with("__DIMJOG__") { - // DIMJOGLINE needs a jog-point field on the dimension - // model (not yet present) to store and render the jog. - // Report honestly rather than faking success. (DIM-019) - self.command_line - .push_info(crate::t!("DIMJOGLINE: not yet implemented — nothing changed.").as_ref()); + if layer.starts_with("__DIMJOG__") + || layer.starts_with("__DIMJOG_REMOVE__") + { + let remove = layer.starts_with("__DIMJOG_REMOVE__"); + let point = layer + .strip_prefix("__DIMJOG__") + .and_then(|encoded| encoded.split_once('|')) + .and_then(|(_, coordinates)| { + let mut values = coordinates + .split(',') + .filter_map(|value| value.parse::().ok()); + Some(acadrust::types::Vector3::new( + values.next()?, + values.next()?, + values.next()?, + )) + }); + let valid = matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + )) + ); + if !valid || (!remove && point.is_none()) { + self.command_line.push_error( + crate::t!("DIMJOGLINE: select a linear or aligned dimension.") + .as_ref(), + ); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + return Task::none(); + } + self.push_undo_snapshot(i, "DIMJOGLINE"); + if remove { + crate::scene::view::dispatch::set_entity_xdata( + &mut self.tabs[i].scene.document, + handle, + "ACAD_DSTYLE_DIMJAG_POSITION", + None, + ); + } else if let Some(point) = point { + use acadrust::xdata::XDataValue; + crate::scene::view::dispatch::set_entity_xdata( + &mut self.tabs[i].scene.document, + handle, + "ACAD_DSTYLE_DIMJAG_POSITION", + Some(vec![ + XDataValue::Integer16(387), + XDataValue::Integer16(3), + XDataValue::Integer16(389), + XDataValue::Point3D(point), + ]), + ); + } + self.tabs[i].scene.invalidate_dim_block_recorded(handle); + self.tabs[i].scene.bump_entities(&[( + handle, + crate::scene::ChangeKind::Modified, + )]); + self.tabs[i].dirty = true; + self.command_line.push_output( + if remove { + crate::t!("DIMJOGLINE: jog removed.") + } else { + crate::t!("DIMJOGLINE: jog added.") + } + .as_ref(), + ); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; return Task::none(); @@ -5621,6 +5723,72 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); } + CmdResult::EditDimensions { + mut handles, + operation, + } => { + handles.retain(|handle| { + !self.tabs[i].scene.is_layer_locked(*handle) + && matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(acadrust::EntityType::Dimension(_)) + ) + }); + if handles.is_empty() { + self.command_line + .push_error(crate::t!("DIMEDIT: no editable dimensions were selected.").as_ref()); + } else { + self.push_undo_snapshot(i, "DIMEDIT"); + for handle in &handles { + if let Some(acadrust::EntityType::Dimension(dimension)) = + self.tabs[i].scene.document.get_entity_mut(*handle) + { + use crate::command::DimensionEditOperation as Operation; + match &operation { + Operation::Home => { + let base = dimension.base_mut(); + base.text_middle_point = acadrust::types::Vector3::ZERO; + base.insertion_point = acadrust::types::Vector3::ZERO; + base.text_user_positioned = false; + base.text_rotation = 0.0; + } + Operation::NewText(text) => { + dimension.base_mut().set_text_override( + (!text.is_empty()).then_some(text.clone()), + ); + } + Operation::Rotate(degrees) => { + dimension.base_mut().text_rotation = degrees.to_radians(); + } + Operation::Oblique(degrees) => match dimension { + acadrust::entities::Dimension::Linear(value) => { + value.ext_line_rotation = degrees.to_radians(); + } + acadrust::entities::Dimension::Aligned(value) => { + value.ext_line_rotation = degrees.to_radians(); + } + _ => {} + }, + } + } + self.tabs[i].scene.invalidate_dim_block_recorded(*handle); + } + let changes: Vec<_> = handles + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!("DIMEDIT: updated {} dimension(s).", handles.len()).as_ref(), + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } CmdResult::DdeditEntity { handle, new_text } => { if self.reject_locked_edit(i, handle) { self.tabs[i].active_cmd = None; @@ -6176,6 +6344,382 @@ fn remap_object( // ── DIMSPACE helper ─────────────────────────────────────────────────────────── +struct DimBreakResult { + changed: Vec, + message: String, +} + +#[derive(Clone, Copy)] +enum DimBreakOperation { + Auto, + Object(Handle), + Manual(glam::DVec3, glam::DVec3), + Remove, +} + +fn parse_dim_break_handles(encoded: &str) -> Vec { + encoded + .split(';') + .filter_map(|value| value.parse::().ok()) + .map(Handle::from) + .filter(|handle| !handle.is_null()) + .collect() +} + +fn parse_dim_break_point(encoded: &str) -> Option { + let mut values = encoded.split(',').map(str::parse::); + Some(glam::DVec3::new( + values.next()?.ok()?, + values.next()?.ok()?, + values.next()?.ok()?, + )) +} + +fn parse_dim_break(encoded: &str) -> Option<(Vec, DimBreakOperation)> { + if let Some(handles) = encoded.strip_prefix("__DIMBREAK_AUTO__") { + return Some((parse_dim_break_handles(handles), DimBreakOperation::Auto)); + } + if let Some(handles) = encoded.strip_prefix("__DIMBREAK_REMOVE__") { + return Some((parse_dim_break_handles(handles), DimBreakOperation::Remove)); + } + if let Some(rest) = encoded.strip_prefix("__DIMBREAK_OBJECT__") { + let (handles, crossing) = rest.split_once('|')?; + return Some(( + parse_dim_break_handles(handles), + DimBreakOperation::Object(Handle::from(crossing.parse::().ok()?)), + )); + } + if let Some(rest) = encoded.strip_prefix("__DIMBREAK_MANUAL__") { + let mut parts = rest.split('|'); + let handles = parse_dim_break_handles(parts.next()?); + let first = parse_dim_break_point(parts.next()?)?; + let second = parse_dim_break_point(parts.next()?)?; + return Some((handles, DimBreakOperation::Manual(first, second))); + } + None +} + +fn wire_segments( + models: &[crate::scene::model::wire_model::WireModel], +) -> Vec<(glam::DVec3, glam::DVec3)> { + let mut output = Vec::new(); + for model in models { + for run in model.points.split(|point| !point[0].is_finite()) { + for pair in run.windows(2) { + let first = glam::DVec3::new( + pair[0][0] as f64, + pair[0][1] as f64, + pair[0][2] as f64, + ); + let second = glam::DVec3::new( + pair[1][0] as f64, + pair[1][1] as f64, + pair[1][2] as f64, + ); + if first.is_finite() + && second.is_finite() + && first.distance_squared(second) > 1.0e-18 + { + output.push((first, second)); + } + } + } + } + output +} + +fn segment_intersection_xy( + first: (glam::DVec3, glam::DVec3), + second: (glam::DVec3, glam::DVec3), +) -> Option<(glam::DVec3, glam::DVec3)> { + let p = first.0; + let r = first.1 - first.0; + let q = second.0; + let s = second.1 - second.0; + let cross = |a: glam::DVec3, b: glam::DVec3| a.x * b.y - a.y * b.x; + let denominator = cross(r, s); + if denominator.abs() <= 1.0e-12 { + return None; + } + let qp = q - p; + let t = cross(qp, s) / denominator; + let u = cross(qp, r) / denominator; + if !(-1.0e-9..=1.0 + 1.0e-9).contains(&t) + || !(-1.0e-9..=1.0 + 1.0e-9).contains(&u) + { + return None; + } + let direction = r.normalize_or_zero(); + if direction.length_squared() <= 1.0e-18 { + return None; + } + let center = p + r * t.clamp(0.0, 1.0); + let half_gap = 0.25_f64.min(first.0.distance(first.1) * 0.2); + Some(( + center - direction * half_gap, + center + direction * half_gap, + )) +} + +fn break_object_handle(document: &acadrust::CadDocument, dimension: Handle) -> Option { + document.objects.iter().find_map(|(handle, object)| { + let acadrust::objects::ObjectType::DataObject(object) = object else { + return None; + }; + let acadrust::objects::DataObjectData::BreakData(data) = &object.data else { + return None; + }; + (data.dimension_reference == dimension).then_some(*handle) + }) +} + +fn remove_dimension_break_data(document: &mut acadrust::CadDocument, dimension: Handle) -> bool { + let handles: Vec<_> = document + .objects + .iter() + .filter_map(|(handle, object)| match object { + acadrust::objects::ObjectType::DataObject(object) => match &object.data { + acadrust::objects::DataObjectData::BreakData(data) + if data.dimension_reference == dimension => Some(*handle), + _ => None, + }, + _ => None, + }) + .collect(); + if handles.is_empty() { + return false; + } + for handle in &handles { + document.objects.remove(handle); + } + if let Some(dictionary_handle) = document.extension_dictionary_handle(dimension) { + if let Some(acadrust::objects::ObjectType::Dictionary(dictionary)) = + document.objects.get_mut(&dictionary_handle) + { + dictionary.entries.retain(|(name, handle)| { + !name.eq_ignore_ascii_case("ACAD_BREAKDATA") && !handles.contains(handle) + }); + dictionary + .hard_owner_entries + .retain(|name| !name.eq_ignore_ascii_case("ACAD_BREAKDATA")); + } + } + true +} + +fn write_dimension_break_data( + document: &mut acadrust::CadDocument, + dimension: Handle, + reserved_reference: Handle, + references: Vec, +) -> bool { + use acadrust::objects::{BreakData, DataObject, DataObjectData, Dictionary, ObjectType}; + + if references.is_empty() { + return remove_dimension_break_data(document, dimension); + } + if let Some(handle) = break_object_handle(document, dimension) { + if let Some(ObjectType::DataObject(object)) = document.objects.get_mut(&handle) { + object.data = DataObjectData::BreakData(BreakData { + version: 0, + dimension_reference: dimension, + reserved_reference, + point_references: references, + }); + return true; + } + } + + let dictionary_handle = match document.extension_dictionary_handle(dimension) { + Some(handle) => handle, + None => { + let handle = document.allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = handle; + dictionary.owner = dimension; + dictionary.hard_owner = true; + document + .objects + .insert(handle, ObjectType::Dictionary(dictionary)); + if let Some(entity) = document.get_entity_mut(dimension) { + entity.common_mut().xdictionary_handle = Some(handle); + } + handle + } + }; + + let object_handle = document.allocate_handle(); + let mut object = DataObject::new(DataObjectData::BreakData(BreakData { + version: 0, + dimension_reference: dimension, + reserved_reference, + point_references: references, + })); + object.handle = object_handle; + object.owner = dictionary_handle; + document + .objects + .insert(object_handle, ObjectType::DataObject(object)); + if let Some(ObjectType::Dictionary(dictionary)) = + document.objects.get_mut(&dictionary_handle) + { + dictionary + .entries + .retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_BREAKDATA")); + dictionary.add_entry("ACAD_BREAKDATA", object_handle); + dictionary.set_entry_hard_owner("ACAD_BREAKDATA", true); + } + true +} + +fn break_reference( + identifier: i32, + reference_type: i32, + first: glam::DVec3, + second: glam::DVec3, +) -> acadrust::objects::BreakPointReference { + acadrust::objects::BreakPointReference { + version: 0, + reserved: 0, + reference_type, + flags: 0, + identifier, + first_point: acadrust::types::Vector3::new(first.x, first.y, first.z), + second_point: acadrust::types::Vector3::new(second.x, second.y, second.z), + trailing_version: 0, + } +} + +fn apply_dimbreak(scene: &mut crate::scene::Scene, encoded: &str) -> DimBreakResult { + let Some((requested, operation)) = parse_dim_break(encoded) else { + return DimBreakResult { + changed: Vec::new(), + message: crate::t!("DIMBREAK: invalid operation data.").into_owned(), + }; + }; + let dimensions: Vec<_> = requested + .into_iter() + .filter(|handle| { + matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Dimension(_)) + ) && scene.locked_layer_name(*handle).is_none() + }) + .collect(); + if dimensions.is_empty() { + return DimBreakResult { + changed: Vec::new(), + message: crate::t!("DIMBREAK: no editable dimensions were selected.").into_owned(), + }; + } + + if matches!(operation, DimBreakOperation::Remove) { + let changed: Vec<_> = dimensions + .into_iter() + .filter(|handle| remove_dimension_break_data(&mut scene.document, *handle)) + .collect(); + return DimBreakResult { + message: crate::tf!("DIMBREAK: removed breaks from {} dimension(s).", changed.len()) + .into_owned(), + changed, + }; + } + + let crossing_handles: Vec = match operation { + DimBreakOperation::Object(handle) => vec![handle], + DimBreakOperation::Auto => scene + .document + .entities() + .map(|entity| entity.common().handle) + .filter(|handle| { + !dimensions.contains(handle) && scene.entity_belongs_to_active_space(*handle) + }) + .collect(), + DimBreakOperation::Manual(_, _) | DimBreakOperation::Remove => Vec::new(), + }; + let crossing_segments: Vec<_> = crossing_handles + .iter() + .flat_map(|handle| wire_segments(&scene.wire_models_for(&[*handle]))) + .collect(); + + let mut pending = Vec::new(); + for dimension in &dimensions { + let existing: Vec<_> = break_object_handle(&scene.document, *dimension) + .and_then(|handle| scene.document.objects.get(&handle)) + .and_then(|object| match object { + acadrust::objects::ObjectType::DataObject(object) => match &object.data { + acadrust::objects::DataObjectData::BreakData(data) => { + Some(data.point_references.clone()) + } + _ => None, + }, + _ => None, + }) + .unwrap_or_default(); + let mut references = if matches!(operation, DimBreakOperation::Manual(_, _)) { + existing + } else { + existing + .into_iter() + .filter(|reference| reference.reference_type == 2) + .collect() + }; + match operation { + DimBreakOperation::Manual(first, second) => { + references.push(break_reference(references.len() as i32, 2, first, second)); + } + DimBreakOperation::Auto | DimBreakOperation::Object(_) => { + let dimension_segments = wire_segments(&scene.wire_models_for(&[*dimension])); + let mut intersections = Vec::new(); + for dim_segment in &dimension_segments { + for crossing_segment in &crossing_segments { + if let Some(points) = + segment_intersection_xy(*dim_segment, *crossing_segment) + { + let center = (points.0 + points.1) * 0.5; + if intersections.iter().all( + |(first, second): &(glam::DVec3, glam::DVec3)| { + center.distance_squared((*first + *second) * 0.5) > 1.0e-8 + }, + ) { + intersections.push(points); + } + } + } + } + let start = references.len() as i32; + references.extend(intersections.into_iter().enumerate().map( + |(index, points)| { + break_reference(start + index as i32, 1, points.0, points.1) + }, + )); + } + DimBreakOperation::Remove => {} + } + pending.push((*dimension, references)); + } + + let reserved = match operation { + DimBreakOperation::Object(handle) => handle, + _ => Handle::NULL, + }; + let changed: Vec<_> = pending + .into_iter() + .filter_map(|(dimension, references)| { + write_dimension_break_data(&mut scene.document, dimension, reserved, references) + .then_some(dimension) + }) + .collect(); + DimBreakResult { + message: if changed.is_empty() { + crate::t!("DIMBREAK: no intersections were found.").into_owned() + } else { + crate::tf!("DIMBREAK: updated {} dimension(s).", changed.len()).into_owned() + }, + changed, + } +} + /// Parse `base_val,h1;h2;...;hN,spacing` and adjust parallel dimension positions. fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) { // Format: ",

;

;...;," @@ -6188,7 +6732,11 @@ fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) { .split(';') .filter_map(|s| s.parse::().ok()) .collect(); - let spacing: f64 = parts[2].parse().unwrap_or(0.0); + let requested_spacing = if parts[2].eq_ignore_ascii_case("AUTO") { + None + } else { + parts[2].parse::().ok() + }; use acadrust::entities::Dimension; let base_h = acadrust::Handle::from(base_val); @@ -6196,25 +6744,27 @@ fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) { // dim line's perpendicular coordinate. Spacing steps each parallel dim along // this perp IN THE DRAWING PLANE — offsetting Z had no effect on the dim // line, which is computed from def·perp with perp.z = 0. (#181 / DIM-021) - let (perp, base_coord) = match scene.document.get_entity(base_h) { - Some(acadrust::EntityType::Dimension(Dimension::Linear(d))) => { + let (perp, base_coord, auto_spacing) = match scene.document.get_entity(base_h) { + Some(acadrust::EntityType::Dimension(dimension @ Dimension::Linear(d))) => { let (s, c) = d.rotation.sin_cos(); let perp = (-s, c); let dp = d.definition_point; - (perp, dp.x * perp.0 + dp.y * perp.1) + let spacing = dimension_auto_spacing(&scene.document, dimension, scene.creation_annotation_multiplier()); + (perp, dp.x * perp.0 + dp.y * perp.1, spacing) } - Some(acadrust::EntityType::Dimension(Dimension::Aligned(d))) => { + Some(acadrust::EntityType::Dimension(dimension @ Dimension::Aligned(d))) => { let dx = d.second_point.x - d.first_point.x; let dy = d.second_point.y - d.first_point.y; let len = (dx * dx + dy * dy).sqrt().max(1e-12); let perp = (-dy / len, dx / len); let dp = d.definition_point; - (perp, dp.x * perp.0 + dp.y * perp.1) + let spacing = dimension_auto_spacing(&scene.document, dimension, scene.creation_annotation_multiplier()); + (perp, dp.x * perp.0 + dp.y * perp.1, spacing) } _ => return, }; - let effective_spacing = if spacing <= 0.0 { 10.0 } else { spacing }; + let effective_spacing = requested_spacing.unwrap_or(auto_spacing); let mut changes = Vec::new(); for (idx, &hv) in other_vals.iter().enumerate() { let h = acadrust::Handle::from(hv); @@ -6256,6 +6806,28 @@ fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) { } } +fn dimension_auto_spacing( + document: &acadrust::CadDocument, + dimension: &acadrust::entities::Dimension, + annotation_multiplier: f64, +) -> f64 { + let style = document + .dim_styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(&dimension.base().style_name)) + .map(|style| crate::entities::dimension::resolved_dimension_style(style, dimension, document)); + style + .map(|style| { + let scale = if style.dimscale > 1.0e-9 { + style.dimscale + } else { + annotation_multiplier.max(1.0e-9) + }; + (style.dimdli.abs() * scale).max(1.0e-6) + }) + .unwrap_or(3.75) +} + fn apply_mleader_align( scene: &mut crate::scene::Scene, handles: &[acadrust::Handle], diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 91cdaca00..7fed79462 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -1041,55 +1041,110 @@ impl OpenCADStudio { return Some(Task::done(Message::PlotDialogOpen)); } - // ── Recognized commands whose full implementation is pending ───────── - // These verbs are surfaced by the ribbon / menus but their feature is - // still being built. Acknowledge them with an honest status so the - // button responds instead of reporting an unknown command; each is - // replaced by its real handler as the feature lands. - // OBJECTSCALE ADD — add the active scale representation to every - // selected object that supports per-scale context data. - "OBJECTSCALE ADD" => { - let handles: Vec = self.tabs[i] + // Add or remove the current representation on the selected + // annotative objects. With no preselection, gather one through the + // regular selection engine before applying the action. + action @ ("OBJECTSCALE ADD" | "OBJECTSCALE DELETE") => { + let selected: Vec = self.tabs[i] .scene .selected_entities() .iter() .map(|(h, _)| *h) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); - if handles.is_empty() { + if selected.is_empty() { + let action = if action.ends_with("ADD") { + "OBJECTSCALE ADD" + } else { + "OBJECTSCALE DELETE" + }; + let command = crate::modules::annotate::annotation_scale::AnnotationScaleSelectionCommand::new( + "OBJECTSCALE", + action, + ); self.command_line - .push_error(crate::t!("OBJECTSCALE: select objects first.").as_ref()); + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); return Some(Task::none()); } - self.push_undo_snapshot(i, "OBJECTSCALE"); let Some(scale) = self.tabs[i].scene.creation_annotation_scale_handle() else { self.command_line .push_error(crate::t!("OBJECTSCALE: the active annotation scale is unavailable.").as_ref()); return Some(Task::none()); }; - let mut n = 0usize; - for h in &handles { - if crate::scene::annotative::create_annotation_context( - &mut self.tabs[i].scene.document, - *h, - scale, - ) { - crate::scene::annotative::set_entity_annotative( - &mut self.tabs[i].scene.document, - *h, - true, + + let adding = action.ends_with("ADD"); + let handles: Vec<_> = selected + .into_iter() + .filter(|handle| { + let memberships = crate::scene::annotative::object_scale_memberships( + &self.tabs[i].scene.document, + *handle, ); - n += 1; + let member = memberships.iter().any(|(_, current)| *current == scale); + if adding { + !member + && self.tabs[i] + .scene + .document + .get_entity(*handle) + .is_some_and(crate::scene::annotative::supports_annotation_context) + } else { + member + } + }) + .collect(); + if handles.is_empty() { + self.command_line.push_info( + if adding { + crate::t!("OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales.") + } else { + crate::t!("OBJECTSCALE: the selected objects do not have the current scale.") + } + .as_ref(), + ); + return Some(Task::none()); + } + + self.push_undo_snapshot(i, "OBJECTSCALE"); + let mut changed = Vec::new(); + for handle in handles { + let ok = if adding { + let created = crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + handle, + scale, + ); + if created { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + handle, + true, + ); + } + created + } else { + crate::scene::annotative::remove_annotation_context_for_scale( + &mut self.tabs[i].scene.document, + handle, + scale, + ) + }; + if ok { + changed.push(handle); } } - let changes: Vec<_> = handles - .into_iter() + let changes: Vec<_> = changed + .iter() + .copied() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!( - "OBJECTSCALE: added the active scale to {n} object(s)." + "OBJECTSCALE: {} the current scale on {} object(s).", + if adding { "added" } else { "removed" }, + changed.len() ).as_ref()); return Some(Task::none()); } @@ -1459,6 +1514,75 @@ impl OpenCADStudio { return Some(Task::done(Message::AnnoObjectScaleOpen)); } + // Reset every alternate scale representation to the position of + // the representation visible at the current annotation scale. + "ANNORESET" => { + let handles: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(handle, _)| *handle) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if handles.is_empty() { + let command = crate::modules::annotate::annotation_scale::AnnotationScaleSelectionCommand::new( + "ANNORESET", + "ANNORESET", + ); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Some(Task::none()); + } + let scale = self.tabs[i].scene.creation_annotation_scale_handle(); + let candidates: Vec<_> = handles + .into_iter() + .filter(|handle| { + crate::scene::annotative::object_scale_memberships( + &self.tabs[i].scene.document, + *handle, + ) + .len() + > 1 + }) + .collect(); + if candidates.is_empty() { + self.command_line.push_info( + crate::t!("ANNORESET: no selected object has alternate scale representations.") + .as_ref(), + ); + return Some(Task::none()); + } + self.push_undo_snapshot(i, "ANNORESET"); + let mut changed = Vec::new(); + for handle in candidates { + if crate::scene::annotative::reset_annotation_context_positions( + &mut self.tabs[i].scene.document, + handle, + scale, + ) { + changed.push(handle); + } + } + if !changed.is_empty() { + let changes: Vec<_> = changed + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + } + self.command_line.push_output( + crate::tf!( + "ANNORESET: synchronized {} object(s) from the current scale.", + changed.len() + ) + .as_ref(), + ); + return Some(Task::none()); + } + // DATALINK — create a persistent linked table. "DATALINK" => { use crate::command::ValuePromptCommand; diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index c847a1d03..ed8ffb5ef 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -400,6 +400,7 @@ inventory::submit!(crate::command::CommandRegistration { "ANNOUPDATE", "SCALELISTEDIT", "OBJECTSCALE", + "ANNORESET", // Import CSV into a table + LandXML survey points. "DATALINK", "DATALINKUPDATE", diff --git a/src/command.rs b/src/command.rs index a6761926a..b806cce34 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1314,6 +1314,14 @@ impl Default for LoftOptions { } } +#[derive(Clone, Debug)] +pub enum DimensionEditOperation { + Home, + NewText(String), + Rotate(f64), + Oblique(f64), +} + /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { @@ -1412,6 +1420,8 @@ pub enum CmdResult { BatchCopy(Vec, Vec), /// Erase `handle` and replace with new entities; command stays active. ReplaceEntity(Handle, Vec), + /// Update one entity in place, preserve its handle, and end the command. + UpdateEntityAndFinish { handle: Handle, entity: EntityType }, /// Replace / delete multiple entities and add new ones; command ends. /// Each pair: (handle_to_erase, replacement_entities) — empty vec = delete only. ReplaceMany(Vec<(Handle, Vec)>, Vec), @@ -1592,6 +1602,11 @@ pub enum CmdResult { QuickPrint(Vec), /// Replace the text content of a Text/MText entity in-place. DdeditEntity { handle: Handle, new_text: String }, + /// Apply one DIMEDIT operation to every selected dimension. + EditDimensions { + handles: Vec, + operation: DimensionEditOperation, + }, /// Open the in-place editor (plain box or rich MText editor, per type) for /// a text-bearing entity picked by a command such as DDEDIT. EditTextEntity { handle: Handle }, diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index c2f704fa2..1d6278cf9 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3450,6 +3450,93 @@ fn apply_dimension_breaks( *lines = output; } +fn dimension_jog_point(dimension: &Dimension) -> Option { + if !matches!(dimension, Dimension::Linear(_) | Dimension::Aligned(_)) { + return None; + } + dimension + .base() + .common + .extended_data + .get_record("ACAD_DSTYLE_DIMJAG_POSITION") + .and_then(|record| { + record.values.iter().rev().find_map(|value| match value { + acadrust::xdata::XDataValue::Point3D(point) => Some(vec3_local(*point)), + _ => None, + }) + }) +} + +fn apply_dimension_jog(lines: &mut Vec<[f32; 3]>, requested: Vec3, size: f32, angle: f32) { + if lines.len() < 2 { + return; + } + let mut best: Option<(usize, Vec3, f32)> = None; + for (index, segment) in lines.windows(2).enumerate() { + if segment[0][0].is_nan() || segment[1][0].is_nan() { + continue; + } + let first = Vec3::from_array(segment[0]); + let second = Vec3::from_array(segment[1]); + let delta = second - first; + let length_squared = delta.length_squared(); + if length_squared <= 1.0e-12 { + continue; + } + let parameter = ((requested - first).dot(delta) / length_squared).clamp(0.0, 1.0); + let point = first + delta * parameter; + let distance = point.distance_squared(requested); + if best.is_none_or(|(_, _, current)| distance < current) { + best = Some((index, point, distance)); + } + } + let Some((target, center, _)) = best else { + return; + }; + let first = Vec3::from_array(lines[target]); + let second = Vec3::from_array(lines[target + 1]); + let Some(direction) = (second - first).try_normalize() else { + return; + }; + let perpendicular = Vec3::new(-direction.y, direction.x, 0.0); + let half = size.max(1.0e-3).min(first.distance(second) * 0.2); + let amplitude = (half * (angle * 0.5).tan().abs()).clamp(half * 0.35, half * 1.5); + let jog_start = center - direction * half; + let jog_end = center + direction * half; + let jog = [ + jog_start, + center - direction * half * 0.25 + perpendicular * amplitude, + center + direction * half * 0.25 - perpendicular * amplitude, + jog_end, + ]; + + let mut output = Vec::with_capacity(lines.len() + 4); + output.extend_from_slice(&lines[..target]); + if !output.is_empty() && !output.last().is_some_and(|point| point[0].is_nan()) { + output.push([f32::NAN; 3]); + } + if first.distance_squared(jog_start) > 1.0e-12 { + output.push(first.to_array()); + output.push(jog_start.to_array()); + output.push([f32::NAN; 3]); + } + output.extend(jog.into_iter().map(|point| point.to_array())); + if jog_end.distance_squared(second) > 1.0e-12 { + output.push([f32::NAN; 3]); + output.push(jog_end.to_array()); + output.push(second.to_array()); + } + if target + 2 < lines.len() { + if !output.last().is_some_and(|point| point[0].is_nan()) + && !lines[target + 2][0].is_nan() + { + output.push([f32::NAN; 3]); + } + output.extend_from_slice(&lines[target + 2..]); + } + *lines = output; +} + pub trait DimensionTess { fn tessellate( &self, @@ -3783,12 +3870,19 @@ fn tessellate_dimension_inner( // DIMUPT governs interactive creation-time text placement; saved // geometry already carries the resulting position. let _ = s.dimupt; - let _ = (s.dimarcsym, s.dimjogang); + let _ = s.dimarcsym; // DIMUNIT is the obsolete pre-R2000 linear unit format; DIMLUNIT // supersedes it. Read but not honoured. let _ = s.dimunit; } + if let Some(point) = dimension_jog_point(dim) { + let jog_angle = style + .map(|style| style.dimjogang as f32) + .unwrap_or(std::f32::consts::FRAC_PI_4); + apply_dimension_jog(&mut geom.dim_lines, point, dim_txt as f32 * 0.6, jog_angle); + } apply_dimension_breaks(document, handle, &mut geom.dim_lines); + apply_dimension_breaks(document, handle, &mut geom.ext_lines); // Dimension entity fields that the render path doesn't yet use but are // preserved on save: // - base.insertion_point: legacy anchor reference; render uses @@ -7229,6 +7323,11 @@ pub(crate) fn baked_large_radial_geometry( dimension.base().common.handle, &mut geometry.dim_lines, ); + apply_dimension_breaks( + document, + dimension.base().common.handle, + &mut geometry.ext_lines, + ); Some(geometry) } diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index d6eb177a3..872a00232 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -6,7 +6,7 @@ use acadrust::EntityType; use glam::DVec3; use crate::command::{ - CadCommand, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -164,11 +164,24 @@ impl CadCommand for AlignedDimensionCommand { fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText - } else { + } else if self.awaiting_angle { InputKind::SingleToken + } else { + InputKind::Point } } + fn options(&self) -> Vec { + if !matches!(self.step, Step::DimLine { .. }) || self.awaiting_text || self.awaiting_angle { + return Vec::new(); + } + vec![ + CmdOption::new("MText", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + ] + } + fn point_step_accepts_keywords(&self) -> bool { // While entering the override text or angle it is a value, not a point // step. diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index ae736581b..b5d3bf417 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -489,8 +489,10 @@ impl CadCommand for AngularDimensionCommand { fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText - } else { + } else if self.awaiting_angle || self.awaiting_quadrant { InputKind::SingleToken + } else { + InputKind::Point } } diff --git a/src/modules/annotate/annotation_scale.rs b/src/modules/annotate/annotation_scale.rs new file mode 100644 index 000000000..40fc8a748 --- /dev/null +++ b/src/modules/annotate/annotation_scale.rs @@ -0,0 +1,50 @@ +use acadrust::Handle; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; + +/// Selection front-end used by the annotation-scale ribbon actions. The +/// action itself runs only after the normal selection engine completes, so +/// window/crossing selection, removal, Escape and Previous all keep their +/// usual behavior. +pub struct AnnotationScaleSelectionCommand { + name: &'static str, + action: &'static str, +} + +impl AnnotationScaleSelectionCommand { + pub fn new(name: &'static str, action: &'static str) -> Self { + Self { name, action } + } +} + +impl CadCommand for AnnotationScaleSelectionCommand { + fn name(&self) -> &'static str { + self.name + } + + fn prompt(&self) -> String { + crate::tf!("{} Select annotative objects:", self.name).into_owned() + } + + fn is_selection_gathering(&self) -> bool { + true + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + if handles.is_empty() { + CmdResult::Cancel + } else { + CmdResult::Relaunch(self.action.to_string(), handles) + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + diff --git a/src/modules/annotate/arc_length_dim.rs b/src/modules/annotate/arc_length_dim.rs index 2e7b3ef5f..4797c70c9 100644 --- a/src/modules/annotate/arc_length_dim.rs +++ b/src/modules/annotate/arc_length_dim.rs @@ -353,8 +353,10 @@ impl CadCommand for ArcLengthDimensionCommand { fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText - } else { + } else if self.awaiting_angle { InputKind::SingleToken + } else { + InputKind::Point } } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index de60b43b4..1f712ff5e 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -139,8 +139,10 @@ impl CadCommand for DiameterDimensionCommand { fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText - } else { + } else if self.awaiting_angle { InputKind::SingleToken + } else { + InputKind::Point } } diff --git a/src/modules/annotate/dimbreak.rs b/src/modules/annotate/dimbreak.rs index e37cdbe4e..49011dea6 100644 --- a/src/modules/annotate/dimbreak.rs +++ b/src/modules/annotate/dimbreak.rs @@ -1,18 +1,9 @@ -// DIMBREAK command — insert a break in a dimension/extension line where -// another object crosses it. -// -// Simplified workflow: -// 1. Select the dimension to break -// 2. Select the object that crosses it (or Enter for AUTO mode on all crossings) -// -// Because breaking a dimension line requires deep geometric intersection logic -// that depends on the renderer, this implementation records the target handles -// and emits a ReplaceEntity sentinel that commands.rs intercepts to apply the break. +// DIMBREAK — create automatic, object-driven or manual gaps in dimensions. -use acadrust::Handle; +use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::t; @@ -28,20 +19,54 @@ pub fn tool() -> ToolDef { } enum Step { - PickDim, - PickCrossing { dim_handle: Handle }, + PickDimension, + PickDimensions(Vec), + PickCrossing(Vec), + ManualFirst(Vec), + ManualSecond(Vec, DVec3), } pub struct DimBreakCommand { step: Step, + picked_entity: Option, } impl DimBreakCommand { pub fn new() -> Self { Self { - step: Step::PickDim, + step: Step::PickDimension, + picked_entity: None, } } + + fn marker(layer: String, primary: Handle) -> CmdResult { + use acadrust::entities::XLine; + let mut marker = XLine::default(); + marker.common.layer = layer; + CmdResult::ReplaceEntity(primary, vec![EntityType::XLine(marker)]) + } + + fn handles(handles: &[Handle]) -> String { + handles + .iter() + .map(|handle| handle.value().to_string()) + .collect::>() + .join(";") + } + + fn automatic(handles: &[Handle]) -> CmdResult { + Self::marker( + format!("__DIMBREAK_AUTO__{}", Self::handles(handles)), + handles[0], + ) + } + + fn remove(handles: &[Handle]) -> CmdResult { + Self::marker( + format!("__DIMBREAK_REMOVE__{}", Self::handles(handles)), + handles[0], + ) + } } impl CadCommand for DimBreakCommand { @@ -51,54 +76,151 @@ impl CadCommand for DimBreakCommand { fn prompt(&self) -> String { match &self.step { - Step::PickDim => t!("DIMBREAK Select dimension to break:").into_owned(), - Step::PickCrossing { .. } => { - t!("DIMBREAK Select object to break at, or Enter for Auto:").into_owned() + Step::PickDimension => { + t!("DIMBREAK Select dimension to add/remove break or [Multiple]:").into_owned() + } + Step::PickDimensions(handles) => crate::tf!( + "DIMBREAK Select dimensions ({} selected, press Enter when done):", + handles.len() + ) + .into_owned(), + Step::PickCrossing(_) => t!( + "DIMBREAK Select object to break dimension or [Auto/Manual/Remove] :" + ) + .into_owned(), + Step::ManualFirst(_) => t!("DIMBREAK Specify first break point:").into_owned(), + Step::ManualSecond(_, _) => { + t!("DIMBREAK Specify second break point:").into_owned() } } } + fn options(&self) -> Vec { + match self.step { + Step::PickDimension => vec![CmdOption::new("Multiple", "MULTIPLE")], + Step::PickCrossing(_) => vec![ + CmdOption::new("Auto", "AUTO"), + CmdOption::new("Manual", "MANUAL"), + CmdOption::new("Remove", "REMOVE"), + ], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + InputKind::Point + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::PickDimension | Step::PickCrossing(_)) + } + fn needs_entity_pick(&self) -> bool { - true + matches!( + self.step, + Step::PickDimension | Step::PickDimensions(_) | Step::PickCrossing(_) + ) } - fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + fn inject_before_entity_pick(&self) -> bool { + matches!(self.step, Step::PickDimension | Step::PickDimensions(_)) + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } - match &self.step { - Step::PickDim => { - self.step = Step::PickCrossing { dim_handle: handle }; + match &mut self.step { + Step::PickDimension => { + if !matches!(self.picked_entity.take(), Some(EntityType::Dimension(_))) { + return CmdResult::ReportError( + t!("DIMBREAK: select a dimension.").into_owned(), + ); + } + self.step = Step::PickCrossing(vec![handle]); CmdResult::NeedPoint } - Step::PickCrossing { dim_handle } => { - let dim_h = *dim_handle; - // Emit sentinel: the actual break logic is in commands.rs - use acadrust::entities::XLine; - let mut xl = XLine::default(); - xl.common.layer = format!("__DIMBREAK__{},{}", dim_h.value(), handle.value()); - CmdResult::ReplaceEntity(dim_h, vec![acadrust::EntityType::XLine(xl)]) + Step::PickDimensions(handles) => { + if !matches!(self.picked_entity.take(), Some(EntityType::Dimension(_))) { + return CmdResult::ReportError( + t!("DIMBREAK: select a dimension.").into_owned(), + ); + } + if !handles.contains(&handle) { + handles.push(handle); + } + CmdResult::NeedPoint } + Step::PickCrossing(handles) => Self::marker( + format!( + "__DIMBREAK_OBJECT__{}|{}", + Self::handles(handles), + handle.value() + ), + handles[0], + ), + Step::ManualFirst(_) | Step::ManualSecond(_, _) => CmdResult::NeedPoint, } } - fn on_point(&mut self, _pt: DVec3) -> CmdResult { - CmdResult::NeedPoint + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().to_ascii_uppercase(); + match &mut self.step { + Step::PickDimension if matches!(keyword.as_str(), "M" | "MULTIPLE") => { + self.step = Step::PickDimensions(Vec::new()); + Some(CmdResult::NeedPoint) + } + Step::PickCrossing(handles) if matches!(keyword.as_str(), "A" | "AUTO") => { + Some(Self::automatic(handles)) + } + Step::PickCrossing(handles) if matches!(keyword.as_str(), "M" | "MANUAL") => { + self.step = Step::ManualFirst(handles.clone()); + Some(CmdResult::NeedPoint) + } + Step::PickCrossing(handles) if matches!(keyword.as_str(), "R" | "REMOVE") => { + Some(Self::remove(handles)) + } + _ => None, + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match &self.step { + Step::ManualFirst(handles) => { + self.step = Step::ManualSecond(handles.clone(), point); + CmdResult::NeedPoint + } + Step::ManualSecond(handles, first) => Self::marker( + format!( + "__DIMBREAK_MANUAL__{}|{:.15},{:.15},{:.15}|{:.15},{:.15},{:.15}", + Self::handles(handles), + first.x, + first.y, + first.z, + point.x, + point.y, + point.z + ), + handles[0], + ), + _ => CmdResult::NeedPoint, + } } fn on_enter(&mut self) -> CmdResult { - // Auto mode: apply to all crossings - if let Step::PickCrossing { dim_handle } = &self.step { - let dim_h = *dim_handle; - use acadrust::entities::XLine; - let mut xl = XLine::default(); - xl.common.layer = format!("__DIMBREAK_AUTO__{}", dim_h.value()); - return CmdResult::ReplaceEntity(dim_h, vec![acadrust::EntityType::XLine(xl)]); + match &self.step { + Step::PickDimensions(handles) if !handles.is_empty() => { + self.step = Step::PickCrossing(handles.clone()); + CmdResult::NeedPoint + } + Step::PickCrossing(handles) => Self::automatic(handles), + _ => CmdResult::Cancel, } - CmdResult::Cancel } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMBREAK"] }); // DimBreakCommand +inventory::submit!(crate::command::CommandRegistration { names: &["DIMBREAK"] }); diff --git a/src/modules/annotate/dimedit.rs b/src/modules/annotate/dimedit.rs index 036200505..948ca53c9 100644 --- a/src/modules/annotate/dimedit.rs +++ b/src/modules/annotate/dimedit.rs @@ -1,13 +1,11 @@ -// DIMEDIT command — edit dimension text or reset it to the measured value. -// -// Workflow: -// 1. Pick a dimension entity -// 2. Enter new text (blank = reset to auto-measured value, "<>" = measured value placeholder) +// DIMEDIT — edit dimension text, rotation, extension-line obliquing or home position. use acadrust::Handle; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionEditOperation, InputKind, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::t; @@ -23,8 +21,11 @@ pub fn tool() -> ToolDef { } enum Step { - PickDim, - EnterText { handle: Handle }, + Choose, + NewText, + Rotate, + Oblique, + Select(DimensionEditOperation), } pub struct DimEditCommand { @@ -33,9 +34,12 @@ pub struct DimEditCommand { impl DimEditCommand { pub fn new() -> Self { - Self { - step: Step::PickDim, - } + Self { step: Step::Choose } + } + + fn select(&mut self, operation: DimensionEditOperation) -> CmdResult { + self.step = Step::Select(operation); + CmdResult::NeedPoint } } @@ -45,48 +49,106 @@ impl CadCommand for DimEditCommand { } fn prompt(&self) -> String { - match &self.step { - Step::PickDim => t!("DIMEDIT Select dimension:").into_owned(), - Step::EnterText { .. } => { - t!("DIMEDIT Enter text override (blank = reset to measured):").into_owned() + match self.step { + Step::Choose => { + t!("DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] :") + .into_owned() + } + Step::NewText => t!("DIMEDIT Enter new dimension text :").into_owned(), + Step::Rotate => t!("DIMEDIT Specify angle for dimension text:").into_owned(), + Step::Oblique => { + t!("DIMEDIT Enter obliquing angle (press Enter for none):").into_owned() + } + Step::Select(_) => t!("DIMEDIT Select objects:").into_owned(), + } + } + + fn options(&self) -> Vec { + if !matches!(self.step, Step::Choose) { + return Vec::new(); + } + vec![ + CmdOption::new("Home", "HOME"), + CmdOption::new("New", "NEW"), + CmdOption::new("Rotate", "ROTATE"), + CmdOption::new("Oblique", "OBLIQUE"), + ] + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::NewText => InputKind::FreeText, + Step::Select(_) => InputKind::Point, + Step::Choose | Step::Rotate | Step::Oblique => InputKind::SingleToken, + } + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.step, Step::Select(_)) + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + let Step::Select(operation) = &self.step else { + return CmdResult::Cancel; + }; + if handles.is_empty() { + CmdResult::Cancel + } else { + CmdResult::EditDimensions { + handles, + operation: operation.clone(), } } } - fn needs_entity_pick(&self) -> bool { - matches!(self.step, Step::PickDim) - } - - fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { - if handle.is_null() { - return CmdResult::NeedPoint; - } - self.step = Step::EnterText { handle }; - CmdResult::NeedPoint - } - - fn wants_text_input(&self) -> bool { - matches!(self.step, Step::EnterText { .. }) - } - fn on_text_input(&mut self, text: &str) -> Option { - let handle = match &self.step { - Step::EnterText { handle } => *handle, - _ => return None, - }; - // Empty = reset to auto-measured, "<>" = keep measured, else override - let new_text = text.trim().to_string(); - Some(CmdResult::DdeditEntity { handle, new_text }) + let value = text.trim(); + let keyword = value.to_ascii_uppercase(); + match self.step { + Step::Choose => match keyword.as_str() { + "H" | "HOME" => Some(self.select(DimensionEditOperation::Home)), + "N" | "NEW" => { + self.step = Step::NewText; + Some(CmdResult::NeedPoint) + } + "R" | "ROTATE" => { + self.step = Step::Rotate; + Some(CmdResult::NeedPoint) + } + "O" | "OBLIQUE" => { + self.step = Step::Oblique; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + Step::NewText => Some(self.select(DimensionEditOperation::NewText( + if value == "<>" { String::new() } else { text.to_string() }, + ))), + Step::Rotate => value + .parse::() + .ok() + .map(|degrees| self.select(DimensionEditOperation::Rotate(degrees))), + Step::Oblique => value + .parse::() + .ok() + .filter(|degrees| (-85.0..=85.0).contains(degrees)) + .map(|degrees| self.select(DimensionEditOperation::Oblique(degrees))), + Step::Select(_) => None, + } } - fn on_point(&mut self, _pt: DVec3) -> CmdResult { + fn on_point(&mut self, _point: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Choose => self.select(DimensionEditOperation::Home), + Step::NewText => self.select(DimensionEditOperation::NewText(String::new())), + Step::Oblique => self.select(DimensionEditOperation::Oblique(0.0)), + Step::Rotate | Step::Select(_) => CmdResult::Cancel, + } } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMEDIT"] }); // DimEditCommand +inventory::submit!(crate::command::CommandRegistration { names: &["DIMEDIT"] }); diff --git a/src/modules/annotate/dimjogline.rs b/src/modules/annotate/dimjogline.rs index 02fbd90b3..43e166965 100644 --- a/src/modules/annotate/dimjogline.rs +++ b/src/modules/annotate/dimjogline.rs @@ -1,13 +1,10 @@ -// DIMJOGLINE command — add a jog (zigzag) symbol to a linear or aligned dimension. -// -// Workflow: -// 1. Pick the dimension -// 2. Click the position on the dimension line where the jog should appear +// DIMJOGLINE — add or remove a jog on a linear/aligned dimension. -use acadrust::Handle; -use glam::DVec3; +use acadrust::entities::Dimension; +use acadrust::{EntityType, Handle}; +use glam::{DVec3, Vec3}; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -30,12 +27,76 @@ enum Step { pub struct DimJogLineCommand { step: Step, + remove: bool, + picked_entity: Option, + dimension: Option, } impl DimJogLineCommand { pub fn new() -> Self { Self { step: Step::PickDim, + remove: false, + picked_entity: None, + dimension: None, + } + } + + fn result(handle: Handle, point: Option) -> CmdResult { + use acadrust::entities::XLine; + let mut marker = XLine::default(); + marker.common.layer = match point { + Some(point) => format!( + "__DIMJOG__{}|{:.15},{:.15},{:.15}", + handle.value(), point.x, point.y, point.z + ), + None => format!("__DIMJOG_REMOVE__{}", handle.value()), + }; + CmdResult::ReplaceEntity(handle, vec![EntityType::XLine(marker)]) + } + + fn default_position(dimension: &Dimension) -> Option { + let (first, second, definition, axis) = match dimension { + Dimension::Linear(value) => ( + value.first_point, + value.second_point, + value.definition_point, + DVec3::new(value.rotation.cos(), value.rotation.sin(), 0.0), + ), + Dimension::Aligned(value) => { + let first = DVec3::new(value.first_point.x, value.first_point.y, value.first_point.z); + let second = DVec3::new(value.second_point.x, value.second_point.y, value.second_point.z); + ( + value.first_point, + value.second_point, + value.definition_point, + (second - first).try_normalize().unwrap_or(DVec3::X), + ) + } + _ => return None, + }; + let first = DVec3::new(first.x, first.y, first.z); + let second = DVec3::new(second.x, second.y, second.z); + let definition = DVec3::new(definition.x, definition.y, definition.z); + let axis = axis.try_normalize().unwrap_or(DVec3::X); + let perpendicular = DVec3::new(-axis.y, axis.x, 0.0); + let offset = definition.dot(perpendicular); + let first_on_line = first + perpendicular * (offset - first.dot(perpendicular)); + let second_on_line = second + perpendicular * (offset - second.dot(perpendicular)); + let midpoint = (first_on_line + second_on_line) * 0.5; + + let text = dimension.base().text_middle_point; + let text = DVec3::new(text.x, text.y, text.z); + let line = second_on_line - first_on_line; + let length_squared = line.length_squared(); + if length_squared <= 1.0e-18 { + return Some(midpoint); + } + let parameter = (text - first_on_line).dot(line) / length_squared; + if text.is_finite() && (0.0..=1.0).contains(¶meter) { + Some((first_on_line + text) * 0.5) + } else { + Some(midpoint) } } } @@ -46,90 +107,129 @@ impl CadCommand for DimJogLineCommand { } fn prompt(&self) -> String { - match &self.step { - Step::PickDim => t!("DIMJOGLINE Select linear or aligned dimension:").into_owned(), - Step::PickJogPos { .. } => t!("DIMJOGLINE Specify jog location:").into_owned(), + match self.step { + Step::PickDim if self.remove => { + t!("DIMJOGLINE Select dimension to remove jog:").into_owned() + } + Step::PickDim => { + t!("DIMJOGLINE Select dimension to add jog or [Remove]:").into_owned() + } + Step::PickJogPos { .. } => { + t!("DIMJOGLINE Specify jog location (or press Enter):").into_owned() + } } } + fn options(&self) -> Vec { + if matches!(self.step, Step::PickDim) && !self.remove { + vec![CmdOption::new("Remove", "REMOVE")] + } else { + Vec::new() + } + } + + fn input_kind(&self) -> InputKind { + InputKind::Point + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::PickDim) && !self.remove + } + fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::PickDim) } - fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + fn inject_before_entity_pick(&self) -> bool { + matches!(self.step, Step::PickDim) + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult { + let Some(EntityType::Dimension(dimension @ (Dimension::Linear(_) | Dimension::Aligned(_)))) = + self.picked_entity.take() + else { + return CmdResult::ReportError( + t!("DIMJOGLINE: select a linear or aligned dimension.").into_owned(), + ); + }; if handle.is_null() { return CmdResult::NeedPoint; } + if self.remove { + return Self::result(handle, None); + } + self.dimension = Some(dimension); self.step = Step::PickJogPos { handle }; CmdResult::NeedPoint } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Step::PickJogPos { handle } = &self.step { - let h = *handle; - // Emit sentinel for commands.rs to store the jog position - use acadrust::entities::XLine; - let mut xl = XLine::default(); - xl.common.layer = format!("__DIMJOG__{},{:.6},{:.6}", h.value(), pt.x, pt.z); - return CmdResult::ReplaceEntity(h, vec![acadrust::EntityType::XLine(xl)]); + fn on_text_input(&mut self, text: &str) -> Option { + if matches!(self.step, Step::PickDim) + && matches!(text.trim().to_ascii_uppercase().as_str(), "R" | "REMOVE") + { + self.remove = true; + Some(CmdResult::NeedPoint) + } else { + None + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::PickJogPos { handle } => Self::result(handle, Some(point)), + Step::PickDim => CmdResult::NeedPoint, } - CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match (&self.step, &self.dimension) { + (Step::PickJogPos { handle }, Some(dimension)) => Self::default_position(dimension) + .map(|point| Self::result(*handle, Some(point))) + .unwrap_or(CmdResult::Cancel), + _ => CmdResult::Cancel, + } } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); + fn on_mouse_move(&mut self, point: DVec3) -> Option { if !matches!(self.step, Step::PickJogPos { .. }) { return None; } - let d = 0.3_f32; - Some(WireModel { - bg_adapt: None, - 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: "dimjog_preview".into(), - // Jog zigzag in the XY drawing plane (Z is elevation, ~0). The old - // marker varied Z, so in the top-down view it collapsed to a flat - // line instead of a zigzag. (#150) - points: vec![ - [pt.x - d, pt.y, pt.z], - [pt.x - d * 0.3, pt.y + d, pt.z], - [pt.x + d * 0.3, pt.y - d, pt.z], - [pt.x + d, pt.y, pt.z], - ], - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.2, - 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(), - }) + let axis = self + .dimension + .as_ref() + .and_then(|dimension| match dimension { + Dimension::Linear(value) => { + Some(Vec3::new(value.rotation.cos() as f32, value.rotation.sin() as f32, 0.0)) + } + Dimension::Aligned(value) => Some(Vec3::new( + (value.second_point.x - value.first_point.x) as f32, + (value.second_point.y - value.first_point.y) as f32, + 0.0, + )), + _ => None, + }) + .and_then(Vec3::try_normalize) + .unwrap_or(Vec3::X); + let perpendicular = Vec3::new(-axis.y, axis.x, 0.0); + let center = point.as_vec3(); + let size = 0.3; + let points = vec![ + center - axis * size, + center - axis * size * 0.25 + perpendicular * size, + center + axis * size * 0.25 - perpendicular * size, + center + axis * size, + ]; + let mut preview = WireModel::default(); + preview.name = "dimjog_preview".into(); + preview.points = points.into_iter().map(|point| point.to_array()).collect(); + preview.color = WireModel::CYAN; + preview.line_weight_px = 1.2; + Some(preview) } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMJOGLINE"] }); // DimJogLineCommand +inventory::submit!(crate::command::CommandRegistration { names: &["DIMJOGLINE"] }); diff --git a/src/modules/annotate/dimspace.rs b/src/modules/annotate/dimspace.rs index de6c19bf5..f52a748d9 100644 --- a/src/modules/annotate/dimspace.rs +++ b/src/modules/annotate/dimspace.rs @@ -3,12 +3,12 @@ // Workflow: // 1. Select the base dimension // 2. Select the other dimensions to space (click each, Enter to finish) -// 3. Enter spacing value (or 0 for automatic equal spacing) +// 3. Enter a non-negative spacing value, or choose Auto -use acadrust::Handle; +use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, SelectionEntity}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::t; @@ -31,12 +31,14 @@ enum Step { pub struct DimSpaceCommand { step: Step, + picked_entity: Option, } impl DimSpaceCommand { pub fn new() -> Self { Self { step: Step::PickBase, + picked_entity: None, } } } @@ -54,12 +56,66 @@ impl CadCommand for DimSpaceCommand { count = others.len() ) .into_owned(), - Step::EnterSpacing { .. } => t!("DIMSPACE Enter value (0 = auto):").into_owned(), + Step::EnterSpacing { .. } => { + t!("DIMSPACE Specify distance between dimensions or [Auto] :") + .into_owned() + } + } + } + + fn options(&self) -> Vec { + matches!(self.step, Step::EnterSpacing { .. }) + .then(|| vec![CmdOption::new("Auto", "AUTO")]) + .unwrap_or_default() + } + + fn input_kind(&self) -> InputKind { + if matches!(self.step, Step::EnterSpacing { .. }) { + InputKind::SingleToken + } else { + InputKind::Point } } fn needs_entity_pick(&self) -> bool { - matches!(self.step, Step::PickBase | Step::PickOthers { .. }) + matches!(self.step, Step::PickBase) + } + + fn inject_before_entity_pick(&self) -> bool { + matches!(self.step, Step::PickBase) + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.step, Step::PickOthers { .. }) + } + + fn selection_entities_exclude_locked(&self) -> bool { + true + } + + fn inject_selection_entities(&mut self, entities: Vec) { + let Step::PickOthers { base, others } = &mut self.step else { + return; + }; + others.clear(); + for selected in entities { + if selected.handle != *base + && matches!( + selected.entity, + EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + ) + ) + && !others.contains(&selected.handle) + { + others.push(selected.handle); + } + } } fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { @@ -68,29 +124,60 @@ impl CadCommand for DimSpaceCommand { } match &mut self.step { Step::PickBase => { + if !matches!( + self.picked_entity.take(), + Some(EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + )) + ) { + return CmdResult::ReportError( + t!("DIMSPACE: select a linear or aligned dimension.").into_owned(), + ); + } self.step = Step::PickOthers { base: handle, others: vec![], }; CmdResult::NeedPoint } - Step::PickOthers { others, .. } => { - if !others.contains(&handle) { - others.push(handle); - } - CmdResult::NeedPoint - } _ => CmdResult::NeedPoint, } } - fn wants_text_input(&self) -> bool { - matches!(self.step, Step::EnterSpacing { .. }) + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + let Step::PickOthers { base, others } = &self.step else { + return CmdResult::Cancel; + }; + if others.is_empty() { + return CmdResult::ReportError( + t!("DIMSPACE: select one or more linear or aligned dimensions.").into_owned(), + ); + } + let base = *base; + let others = others.clone(); + self.step = Step::EnterSpacing { base, others }; + CmdResult::NeedPoint } fn on_text_input(&mut self, text: &str) -> Option { if let Step::EnterSpacing { base, others } = &self.step { - let spacing: f64 = text.trim().parse().unwrap_or(0.0); + let token = text.trim(); + let spacing = if matches!(token.to_ascii_uppercase().as_str(), "A" | "AUTO") { + "AUTO".to_string() + } else { + let Ok(value) = token.parse::() else { + return Some(CmdResult::ReportError( + t!("DIMSPACE: enter a non-negative spacing value or Auto.").into_owned(), + )); + }; + if value < 0.0 { + return Some(CmdResult::ReportError( + t!("DIMSPACE: spacing value cannot be negative.").into_owned(), + )); + } + value.to_string() + }; let b = *base; let o = others.clone(); // Emit sentinel for commands.rs to handle @@ -116,15 +203,12 @@ impl CadCommand for DimSpaceCommand { } fn on_enter(&mut self) -> CmdResult { - match &self.step { - Step::PickOthers { base, others } if !others.is_empty() => { - let b = *base; - let o = others.clone(); - self.step = Step::EnterSpacing { base: b, others: o }; - CmdResult::NeedPoint - } - _ => CmdResult::Cancel, + if matches!(self.step, Step::EnterSpacing { .. }) { + return self + .on_text_input("AUTO") + .unwrap_or(CmdResult::Cancel); } + CmdResult::Cancel } } diff --git a/src/modules/annotate/dimtedit.rs b/src/modules/annotate/dimtedit.rs index 97c28168e..cb6119e5d 100644 --- a/src/modules/annotate/dimtedit.rs +++ b/src/modules/annotate/dimtedit.rs @@ -1,13 +1,10 @@ -// DIMTEDIT command — reposition the text of an existing dimension. -// -// Workflow: -// 1. Pick a dimension entity -// 2. Click the new text position (entity data is injected by update.rs after pick) +// DIMTEDIT — reposition, justify, home or rotate existing dimension text. +use acadrust::entities::{AttachmentPointType, Dimension}; use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -29,18 +26,47 @@ enum Step { handle: Handle, entity: Option, }, + EnterAngle { + handle: Handle, + entity: Option, + }, } pub struct DimTeditCommand { step: Step, + picked_entity: Option, +} + +#[derive(Clone, Copy)] +enum Placement { + Left, + Right, + Center, + Home, } impl DimTeditCommand { pub fn new() -> Self { Self { step: Step::PickDim, + picked_entity: None, } } + + fn finish_placement( + handle: Handle, + entity: &mut Option, + placement: Placement, + ) -> CmdResult { + let Some(mut entity) = entity.take() else { + return CmdResult::NeedPoint; + }; + let EntityType::Dimension(dimension) = &mut entity else { + return CmdResult::Cancel; + }; + apply_placement(dimension, placement); + CmdResult::UpdateEntityAndFinish { handle, entity } + } } impl CadCommand for DimTeditCommand { @@ -49,47 +75,123 @@ impl CadCommand for DimTeditCommand { } fn prompt(&self) -> String { - match &self.step { + match self.step { Step::PickDim => t!("DIMTEDIT Select dimension:").into_owned(), - Step::PickTextPos { .. } => { - t!("DIMTEDIT Specify new location for dimension text:").into_owned() + Step::PickTextPos { .. } => t!( + "DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]:" + ) + .into_owned(), + Step::EnterAngle { .. } => { + t!("DIMTEDIT Specify angle for dimension text:").into_owned() } } } + fn options(&self) -> Vec { + if !matches!(self.step, Step::PickTextPos { .. }) { + return Vec::new(); + } + vec![ + CmdOption::new("Left", "LEFT"), + CmdOption::new("Right", "RIGHT"), + CmdOption::new("Center", "CENTER"), + CmdOption::new("Home", "HOME"), + CmdOption::new("Angle", "ANGLE"), + ] + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::EnterAngle { .. } => InputKind::SingleToken, + Step::PickDim | Step::PickTextPos { .. } => InputKind::Point, + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::PickTextPos { .. }) + } + fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::PickDim) } - fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + fn inject_before_entity_pick(&self) -> bool { + matches!(self.step, Step::PickDim) + } + + fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } + if !matches!(self.picked_entity.as_ref(), Some(EntityType::Dimension(_))) { + self.picked_entity = None; + return CmdResult::ReportError( + t!("DIMTEDIT: select a dimension.").into_owned(), + ); + } self.step = Step::PickTextPos { handle, - entity: None, + entity: self.picked_entity.take(), }; CmdResult::NeedPoint } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Step::PickTextPos { handle, entity } = &mut self.step { - let h = *handle; - if let Some(mut ent) = entity.take() { - if let EntityType::Dimension(ref mut d) = ent { - let new_pt = acadrust::types::Vector3::new(pt.x, pt.y, pt.z); - d.base_mut().text_middle_point = new_pt; - d.base_mut().insertion_point = new_pt; - // Pin the text to this location, else the renderer recomputes - // the style-default placement and the move is discarded. - d.base_mut().text_user_positioned = true; + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().to_ascii_uppercase(); + match &mut self.step { + Step::PickTextPos { handle, entity } => match keyword.as_str() { + "L" | "LEFT" => Some(Self::finish_placement(*handle, entity, Placement::Left)), + "R" | "RIGHT" => { + Some(Self::finish_placement(*handle, entity, Placement::Right)) } - return CmdResult::ReplaceEntity(h, vec![ent]); + "C" | "CENTER" => { + Some(Self::finish_placement(*handle, entity, Placement::Center)) + } + "H" | "HOME" => Some(Self::finish_placement(*handle, entity, Placement::Home)), + "A" | "ANGLE" => { + let handle = *handle; + let entity = entity.take(); + self.step = Step::EnterAngle { handle, entity }; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + Step::EnterAngle { handle, entity } => { + let degrees = text.trim().parse::().ok()?; + let Some(mut entity) = entity.take() else { + return Some(CmdResult::NeedPoint); + }; + let EntityType::Dimension(dimension) = &mut entity else { + return Some(CmdResult::Cancel); + }; + dimension.base_mut().text_rotation = degrees.to_radians(); + Some(CmdResult::UpdateEntityAndFinish { + handle: *handle, + entity, + }) } - // No entity yet — wait for inject - CmdResult::NeedPoint - } else { - CmdResult::NeedPoint + Step::PickDim => None, + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let Step::PickTextPos { handle, entity } = &mut self.step else { + return CmdResult::NeedPoint; + }; + let Some(mut entity) = entity.take() else { + return CmdResult::NeedPoint; + }; + let EntityType::Dimension(dimension) = &mut entity else { + return CmdResult::Cancel; + }; + let point = acadrust::types::Vector3::new(point.x, point.y, point.z); + let base = dimension.base_mut(); + base.text_middle_point = point; + base.insertion_point = point; + base.text_user_positioned = true; + CmdResult::UpdateEntityAndFinish { + handle: *handle, + entity, } } @@ -97,63 +199,102 @@ impl CadCommand for DimTeditCommand { CmdResult::Cancel } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); + fn on_mouse_move(&mut self, point: DVec3) -> Option { if !matches!(self.step, Step::PickTextPos { .. }) { return None; } + let point = point.as_vec3(); let d = 0.2_f32; Some(WireModel { - bg_adapt: None, - 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: "dimtedit_preview".into(), - // Marker box in the XY drawing plane (Z is elevation, ~0). The old - // box varied Z, so in the top-down view it collapsed to a flat line - // instead of a square. (#150) points: vec![ - [pt.x - d, pt.y - d, pt.z], - [pt.x + d, pt.y - d, pt.z], - [pt.x + d, pt.y + d, pt.z], - [pt.x - d, pt.y + d, pt.z], - [pt.x - d, pt.y - d, pt.z], + [point.x - d, point.y - d, point.z], + [point.x + d, point.y - d, point.z], + [point.x + d, point.y + d, point.z], + [point.x - d, point.y + d, point.z], + [point.x - d, point.y - d, point.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(), + ..WireModel::default() }) } fn inject_picked_entity(&mut self, entity: EntityType) { - if let Step::PickTextPos { entity: slot, .. } = &mut self.step { - *slot = Some(entity); + match &mut self.step { + Step::PickTextPos { entity: slot, .. } | Step::EnterAngle { entity: slot, .. } => { + *slot = Some(entity); + } + Step::PickDim => self.picked_entity = Some(entity), } } } +fn dimension_line_endpoints(dimension: &Dimension) -> Option<(DVec3, DVec3)> { + let (first, second, definition, axis) = match dimension { + Dimension::Linear(value) => ( + value.first_point, + value.second_point, + value.definition_point, + DVec3::new(value.rotation.cos(), value.rotation.sin(), 0.0), + ), + Dimension::Aligned(value) => { + let delta = DVec3::new( + value.second_point.x - value.first_point.x, + value.second_point.y - value.first_point.y, + 0.0, + ); + if delta.length_squared() <= 1.0e-18 { + return None; + } + ( + value.first_point, + value.second_point, + value.definition_point, + delta.normalize(), + ) + } + _ => return None, + }; + let first = DVec3::new(first.x, first.y, first.z); + let second = DVec3::new(second.x, second.y, second.z); + let definition = DVec3::new(definition.x, definition.y, definition.z); + let perpendicular = DVec3::new(-axis.y, axis.x, 0.0); + let project = |point: DVec3| { + point + perpendicular * (definition - point).dot(perpendicular) + }; + let first = project(first); + let second = project(second); + if first.dot(axis) <= second.dot(axis) { + Some((first, second)) + } else { + Some((second, first)) + } +} -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMTED", "DIMTEDIT"] }); // DimTeditCommand +fn apply_placement(dimension: &mut Dimension, placement: Placement) { + if matches!(placement, Placement::Home) { + let base = dimension.base_mut(); + base.text_middle_point = acadrust::types::Vector3::ZERO; + base.insertion_point = acadrust::types::Vector3::ZERO; + base.text_user_positioned = false; + base.attachment_point = AttachmentPointType::MiddleCenter; + return; + } + let Some((left, right)) = dimension_line_endpoints(dimension) else { + return; + }; + let (point, attachment) = match placement { + Placement::Left => (left, AttachmentPointType::MiddleLeft), + Placement::Right => (right, AttachmentPointType::MiddleRight), + Placement::Center => ((left + right) * 0.5, AttachmentPointType::MiddleCenter), + Placement::Home => unreachable!(), + }; + let point = acadrust::types::Vector3::new(point.x, point.y, point.z); + let base = dimension.base_mut(); + base.text_middle_point = point; + base.insertion_point = point; + base.text_user_positioned = true; + base.attachment_point = attachment; +} + +inventory::submit!(crate::command::CommandRegistration { names: &["DIMTED", "DIMTEDIT"] }); diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index 711935b49..b88766a2a 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -230,8 +230,10 @@ impl CadCommand for JoggedRadiusDimensionCommand { fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText - } else { + } else if self.awaiting_angle { InputKind::SingleToken + } else { + InputKind::Point } } diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index 0c1552b8f..1456682f8 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -10,12 +10,12 @@ // (DXF group 340); editing/erasing them stays in sync via that link. use acadrust::entities::mtext::AttachmentPoint; -use acadrust::entities::{Leader, LeaderCreationType, MText}; +use acadrust::entities::{Leader, LeaderCreationType, LeaderPathType, MText}; use acadrust::types::Vector3; use acadrust::EntityType; use glam::{DVec3, Mat4, Vec3}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -31,8 +31,17 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { + Points, + Format, +} + pub struct LeaderCommand { verts: Vec, + step: Step, + path_type: LeaderPathType, + arrow_enabled: bool, plane: WorkingPlane, dimension_style: String, text_style: String, @@ -56,6 +65,9 @@ impl LeaderCommand { Self { verts: Vec::new(), + step: Step::Points, + path_type: LeaderPathType::StraightLine, + arrow_enabled: true, plane: WorkingPlane::default(), dimension_style: defaults.style_name, text_style: defaults.text_style_name, @@ -91,7 +103,7 @@ impl LeaderCommand { let mut leader_points = local.clone(); let first = local[0]; - let elbow = local[1]; + let elbow = *local.last().unwrap_or(&first); let sign = if elbow.x >= first.x { 1.0 } else { -1.0 }; let landing_end = DVec3::new( @@ -102,7 +114,7 @@ impl LeaderCommand { leader_points.push(landing_end); - let leader = build_leader( + let mut leader = build_leader( &leader_points, Mat4::IDENTITY, &self.dimension_style, @@ -110,6 +122,8 @@ impl LeaderCommand { self.gap, self.arrow_size, ); + leader.path_type = self.path_type; + leader.arrow_enabled = self.arrow_enabled; // The MTEXT starts at the real end of the landing. let (anchor, attach) = @@ -150,25 +164,95 @@ impl CadCommand for LeaderCommand { } fn prompt(&self) -> String { - if self.verts.is_empty() { - t!("LEADER Specify arrowhead point:").into_owned() + if self.step == Step::Format { + return t!("LEADER Enter leader formatting option [Spline/Straight/Arrow/None] :") + .into_owned(); + } + match self.verts.len() { + 0 => t!("LEADER Specify leader start point:").into_owned(), + 1 => t!("LEADER Specify next point:").into_owned(), + _ => t!("LEADER Specify next point or [Annotation/Format/Undo] :") + .into_owned(), + } + } + + fn options(&self) -> Vec { + if self.step == Step::Format { + return vec![ + CmdOption::new("Spline", "SPLINE"), + CmdOption::new("Straight", "STRAIGHT"), + CmdOption::new("Arrow", "ARROW"), + CmdOption::new("None", "NONE"), + ]; + } + if self.verts.len() >= 2 { + vec![ + CmdOption::new("Annotation", "ANNOTATION"), + CmdOption::new("Format", "FORMAT"), + CmdOption::new("Undo", "UNDO"), + ] } else { - t!("LEADER Specify landing point:").into_owned() + Vec::new() + } + } + + fn input_kind(&self) -> InputKind { + if self.step == Step::Format { + InputKind::SingleToken + } else { + InputKind::Point + } + } + + fn point_step_accepts_keywords(&self) -> bool { + self.step == Step::Points && self.verts.len() >= 2 + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().to_ascii_uppercase(); + if self.step == Step::Format { + match keyword.as_str() { + "S" | "SPLINE" => self.path_type = LeaderPathType::Spline, + "ST" | "STRAIGHT" => self.path_type = LeaderPathType::StraightLine, + "A" | "ARROW" => self.arrow_enabled = true, + "N" | "NONE" => self.arrow_enabled = false, + _ => return None, + } + self.step = Step::Points; + return Some(CmdResult::NeedPoint); + } + if self.verts.len() < 2 { + return None; + } + match keyword.as_str() { + "A" | "ANNOTATION" => Some(self.finish()), + "F" | "FORMAT" => { + self.step = Step::Format; + Some(CmdResult::NeedPoint) + } + "U" | "UNDO" => { + self.verts.pop(); + Some(CmdResult::NeedPoint) + } + _ => None, } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - self.verts.push(pt); - - if self.verts.len() >= 2 { - self.finish() - } else { - CmdResult::NeedPoint + if self.step != Step::Points { + return CmdResult::NeedPoint; } + self.verts.push(pt); + CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - self.finish() + if self.step == Step::Format { + self.step = Step::Points; + CmdResult::NeedPoint + } else { + self.finish() + } } fn on_escape(&mut self) -> CmdResult { @@ -176,7 +260,7 @@ impl CadCommand for LeaderCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if self.verts.is_empty() { + if self.step != Step::Points || self.verts.is_empty() { return None; } let mut pts: Vec = self diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 78a3e6772..263fc8cac 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -6,7 +6,7 @@ use cadkernel::geom2d::{ }; use crate::command::{ - CadCommand, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -224,11 +224,31 @@ impl CadCommand for LinearDimensionCommand { fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText - } else { + } else if self.awaiting_angle || self.awaiting_rotation { InputKind::SingleToken + } else { + InputKind::Point } } + fn options(&self) -> Vec { + if !matches!(self.step, Step::DimensionLine { .. }) + || self.awaiting_text + || self.awaiting_angle + || self.awaiting_rotation + { + return Vec::new(); + } + vec![ + CmdOption::new("MText", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + CmdOption::new("Horizontal", "HORIZONTAL"), + CmdOption::new("Vertical", "VERTICAL"), + CmdOption::new("Rotated", "ROTATED"), + ] + } + fn point_step_accepts_keywords(&self) -> bool { // While typing the override text or angle, route input as a value, not // a point pick / keyword. diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index d897d49d4..4ec1e8a4a 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -472,6 +472,8 @@ impl CadCommand for MLeaderCommand { fn input_kind(&self) -> InputKind { if matches!(self.step, Step::Layer | Step::PreEnterText) { InputKind::FreeText + } else if matches!(self.step, Step::PickPoints | Step::SelectMText) { + InputKind::Point } else { InputKind::SingleToken } diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index 8c48397fb..6ff2016ae 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -1,6 +1,7 @@ // Annotate module — dimension, text, leader, table, and markup tools. pub mod aligned_dim; +pub mod annotation_scale; pub mod arc_length_dim; pub mod angular_dim; pub mod data_extract; @@ -205,29 +206,33 @@ impl CadModule for AnnotateModule { RibbonGroup { title: "Annotation Scaling", tools: vec![ - RibbonItem::Tool(crate::modules::ToolDef { - id: "ANNOSCALE", - label: "Scale List", - icon: crate::modules::IconKind::Svg(include_bytes!( - "../../../assets/icons/scale_list.svg" - )), - event: crate::modules::ModuleEvent::Command("ANNOSCALE".to_string()), - }), - RibbonItem::Tool(crate::modules::ToolDef { - id: "OBJECTSCALE", - label: "Add Scale", + RibbonItem::LabeledDropdown { + id: "ANNOTATION_CURRENT_SCALE", + label: "Add Current Scale", icon: crate::modules::IconKind::Svg(include_bytes!( "../../../assets/icons/add_scale.svg" )), - // The quick "mark annotative at the current scale" - // action; bare OBJECTSCALE opens the manage dialog. - event: crate::modules::ModuleEvent::Command( - "OBJECTSCALE ADD".to_string(), - ), - }), + items: vec![ + ( + "OBJECTSCALE ADD", + "Add Current Scale", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/add_scale.svg" + )), + ), + ( + "OBJECTSCALE DELETE", + "Delete Current Scale", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/add_scale.svg" + )), + ), + ], + default: "OBJECTSCALE ADD", + }, RibbonItem::Tool(crate::modules::ToolDef { id: "SCALELISTEDIT", - label: "Scale Edit", + label: "Scale List", icon: crate::modules::IconKind::Svg(include_bytes!( "../../../assets/icons/scale_list.svg" )), @@ -236,13 +241,23 @@ impl CadModule for AnnotateModule { ), }), RibbonItem::Tool(crate::modules::ToolDef { - id: "SYNCPVIEWPORTS", - label: "Sync Scales", + id: "OBJECTSCALE", + label: "Add/Delete Scales", + icon: crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/add_scale.svg" + )), + event: crate::modules::ModuleEvent::Command( + "OBJECTSCALE".to_string(), + ), + }), + RibbonItem::Tool(crate::modules::ToolDef { + id: "ANNORESET", + label: "Sync Scale Positions", icon: crate::modules::IconKind::Svg(include_bytes!( "../../../assets/icons/sync.svg" )), event: crate::modules::ModuleEvent::Command( - "SYNCPVIEWPORTS".to_string(), + "ANNORESET".to_string(), ), }), ], diff --git a/src/modules/annotate/mtext.rs b/src/modules/annotate/mtext.rs index 24aaaf77e..013612d3a 100644 --- a/src/modules/annotate/mtext.rs +++ b/src/modules/annotate/mtext.rs @@ -246,6 +246,8 @@ impl CadCommand for MTextCommand { fn input_kind(&self) -> InputKind { if matches!(self.step, Step::Style) { InputKind::FreeText + } else if matches!(self.step, Step::FirstCorner | Step::OppositeCorner) { + InputKind::Point } else { InputKind::SingleToken } diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 8b917e33f..9611e8aa9 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -142,8 +142,10 @@ impl CadCommand for OrdinateDimCommand { fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText - } else { + } else if self.awaiting_angle { InputKind::SingleToken + } else { + InputKind::Point } } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index b61db83fb..ff69fcdd3 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -137,8 +137,10 @@ impl CadCommand for RadiusDimensionCommand { fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText - } else { + } else if self.awaiting_angle { InputKind::SingleToken + } else { + InputKind::Point } } diff --git a/src/modules/draw/draw/revcloud.rs b/src/modules/draw/draw/revcloud.rs index aa8e12008..9ee23b326 100644 --- a/src/modules/draw/draw/revcloud.rs +++ b/src/modules/draw/draw/revcloud.rs @@ -9,7 +9,7 @@ use cadkernel::space::{PlanarCurve, Plane, Vec3}; use glam::DVec3; use rustc_hash::FxHashMap; -use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane}; use crate::entities::curve::{curve_points, entity_curve, ocs_plane}; use crate::entities::lwpolyline::{is_revision_cloud, revision_cloud_from_curve}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -666,6 +666,17 @@ impl CadCommand for RevCloudCommand { true } + fn input_kind(&self) -> InputKind { + match self.stage { + Stage::ArcLength | Stage::Style | Stage::Reverse(_) => InputKind::SingleToken, + Stage::Create + | Stage::Object + | Stage::ModifySelect + | Stage::ModifyDraw(_) + | Stage::ModifyErase(_) => InputKind::Point, + } + } + fn point_step_accepts_keywords(&self) -> bool { matches!(self.stage, Stage::Create | Stage::ModifyDraw(_)) } diff --git a/src/modules/draw/draw/wipeout.rs b/src/modules/draw/draw/wipeout.rs index 56f60ca12..32385d301 100644 --- a/src/modules/draw/draw/wipeout.rs +++ b/src/modules/draw/draw/wipeout.rs @@ -8,7 +8,7 @@ use cadkernel::space::Plane; use glam::DVec3; use crate::t; -use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -270,6 +270,15 @@ impl CadCommand for WipeoutCommand { ) } + fn input_kind(&self) -> InputKind { + match self.mode { + WipeoutMode::Frames | WipeoutMode::ErasePolyline => InputKind::SingleToken, + WipeoutMode::Draw | WipeoutMode::Polyline | WipeoutMode::Rectangular => { + InputKind::Point + } + } + } + fn point_step_accepts_keywords(&self) -> bool { matches!( self.mode, diff --git a/src/scene/annotative.rs b/src/scene/annotative.rs index 557f178b7..ed5203e26 100644 --- a/src/scene/annotative.rs +++ b/src/scene/annotative.rs @@ -644,6 +644,44 @@ pub fn active_object_context_for_scale( default.or(first) } +/// Reset every alternate annotation-scale representation to the placement of +/// the representation displayed at `scale_handle`. The leaf identity, scale +/// handle and default marker stay intact; only the context payload is copied. +pub fn reset_annotation_context_positions( + doc: &mut CadDocument, + entity: Handle, + scale_handle: Option, +) -> bool { + let Some(source) = active_object_context_for_scale(doc, entity, scale_handle) else { + return false; + }; + let source_handle = source.handle; + let source_kind = source.kind.clone(); + let targets: Vec = annotation_scales_dict(doc, entity) + .and_then(|collection| as_dict(doc, collection)) + .map(|collection| { + collection + .entries + .iter() + .map(|(_, handle)| *handle) + .filter(|handle| *handle != source_handle) + .collect() + }) + .unwrap_or_default(); + + let mut changed = false; + for handle in targets { + let Some(ObjectType::ObjectContextData(context)) = doc.objects.get_mut(&handle) else { + continue; + }; + if context.kind != source_kind { + context.kind = source_kind.clone(); + changed = true; + } + } + changed +} + pub fn effective_annotation_scale_for( doc: &CadDocument, entity: &EntityType, From a35c996f9fa2cd10b6e78bfc3ea15dc1c8bff92d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:54:55 +0300 Subject: [PATCH 2/3] fix(annotate): complete table and data workflows --- Cargo.lock | 100 +- Cargo.toml | 1 + src/app/annotation_data.rs | 1398 ++++++++++++++++++++++++++++ src/app/command_driver.rs | 7 +- src/app/commands/dim.rs | 27 +- src/app/commands/display.rs | 59 +- src/app/commands/inquiry.rs | 79 +- src/app/commands/mod.rs | 2 +- src/app/mod.rs | 43 + src/app/update/mod.rs | 53 ++ src/app/view/modal.rs | 21 + src/command.rs | 1 + src/modules/annotate/data_link.rs | 46 +- src/modules/annotate/leader_cmd.rs | 63 +- src/modules/annotate/table_cmd.rs | 99 ++ src/ui/window/annotation_data.rs | 626 +++++++++++++ src/ui/window/mod.rs | 1 + 17 files changed, 2481 insertions(+), 145 deletions(-) create mode 100644 src/app/annotation_data.rs create mode 100644 src/ui/window/annotation_data.rs diff --git a/Cargo.lock b/Cargo.lock index adfd6cdc1..c6a78e90f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,7 @@ dependencies = [ "bytemuck", "cadkernel", "cadkernel-constraints", + "calamine", "clap", "console_error_panic_hook", "cosmic-text 0.15.0", @@ -587,6 +588,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "atoi_simd" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44" +dependencies = [ + "debug_unsafe", + "rustversion", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -912,6 +923,23 @@ dependencies = [ "nalgebra", ] +[[package]] +name = "calamine" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa68281b1a76b54a62156474adb06bb380a67e07dd60656e3217152b42183f3" +dependencies = [ + "atoi_simd", + "byteorder", + "codepage", + "encoding_rs", + "fast-float2", + "log", + "quick-xml 0.41.0", + "serde", + "zip", +] + [[package]] name = "calloop" version = "0.13.0" @@ -1058,6 +1086,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "codepage" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdff162541cd8b79de82e2edcc7eff3a8c2a6dc3d75152636028f96d93de3b26" +dependencies = [ + "encoding_rs", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -1342,6 +1379,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + [[package]] name = "defmt" version = "1.1.1" @@ -1671,6 +1714,12 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + [[package]] name = "fastrand" version = "2.4.1" @@ -1721,6 +1770,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -4504,6 +4554,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quote" version = "1.0.46" @@ -5934,6 +5994,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -6364,7 +6430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.39.4", "quote", ] @@ -7329,12 +7395,44 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index b01082514..1018fa1ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ winresource = "0.1" [dependencies] serde_json = "1" serde = { version = "1", features = ["derive"] } +calamine = "0.36.1" base64 = "0.22" semver = "1" url = "2" diff --git a/src/app/annotation_data.rs b/src/app/annotation_data.rs new file mode 100644 index 000000000..8c4126f6e --- /dev/null +++ b/src/app/annotation_data.rs @@ -0,0 +1,1398 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use acadrust::entities::{table::CellRange, TableBuilder}; +use acadrust::objects::{ClassObject, ClassObjectData, DataLink, DataLinkCustomData, ObjectType}; +use acadrust::types::{Handle, Vector3}; +use acadrust::{CadDocument, EntityType}; +use iced::Task; + +use super::{Message, ModalKind, OpenCADStudio}; +use crate::command::CadCommand; +use crate::ui::window::annotation_data::{ + DataExtractionField, DataExtractionState, DataLinkChoice, DataLinkField, + ExtractionBegin, ExtractionPage, ExtractionSource, LinkPathType, LinkRange, + TableInsertField, TableInsertion, TableSource, +}; + +#[derive(Clone)] +struct ExtractionRecord { + object_type: String, + handle: String, + layer: String, + color: String, + linetype: String, + details: String, +} + +impl ExtractionRecord { + fn value(&self, key: &str) -> String { + match key { + "type" => self.object_type.clone(), + "handle" => self.handle.clone(), + "layer" => self.layer.clone(), + "color" => self.color.clone(), + "linetype" => self.linetype.clone(), + "details" => self.details.clone(), + _ => String::new(), + } + } +} + +fn table_styles(doc: &CadDocument) -> Vec { + let mut styles: Vec = doc + .objects + .iter() + .filter_map(|(_, object)| match object { + ObjectType::TableStyle(style) => Some(style.name.clone()), + _ => None, + }) + .collect(); + styles.sort_by_key(|name| name.to_ascii_lowercase()); + styles.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + if styles.is_empty() { + styles.push("Standard".into()); + } + styles +} + +fn link_display_name(link: &DataLink) -> String { + if !link.description.trim().is_empty() && link.description != link.connection_string { + return link.description.clone(); + } + Path::new(&link.connection_string) + .file_stem() + .map(|value| value.to_string_lossy().into_owned()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "Data Link".into()) +} + +fn resolve_link_path(doc: &CadDocument, link: &DataLink) -> PathBuf { + let stored = PathBuf::from(&link.connection_string); + if stored.is_absolute() || link.path_option == 1 { + return stored; + } + doc.source_path + .as_deref() + .map(Path::new) + .and_then(Path::parent) + .map(|parent| parent.join(&stored)) + .unwrap_or(stored) +} + +fn data_link_choices(doc: &CadDocument) -> Vec { + let mut links = doc + .objects + .iter() + .filter_map(|(handle, object)| match object { + ObjectType::ClassObject(object) => match &object.data { + ClassObjectData::DataLink(link) => { + let path = resolve_link_path(doc, link); + Some(DataLinkChoice { + handle: *handle, + name: link_display_name(link), + path: path.to_string_lossy().into_owned(), + valid: path.is_file(), + }) + } + _ => None, + }, + _ => None, + }) + .collect::>(); + links.sort_by_key(|link| link.name.to_ascii_lowercase()); + links +} + +fn data_link<'a>(doc: &'a CadDocument, handle: Handle) -> Option<&'a DataLink> { + match doc.objects.get(&handle) { + Some(ObjectType::ClassObject(object)) => match &object.data { + ClassObjectData::DataLink(link) => Some(link), + _ => None, + }, + _ => None, + } +} + +fn data_link_mut<'a>(doc: &'a mut CadDocument, handle: Handle) -> Option<&'a mut DataLink> { + match doc.objects.get_mut(&handle) { + Some(ObjectType::ClassObject(object)) => match &mut object.data { + ClassObjectData::DataLink(link) => Some(link), + _ => None, + }, + _ => None, + } +} + +fn column_index(text: &str) -> Option { + let mut value = 0usize; + let mut any = false; + for byte in text.trim().bytes() { + if !byte.is_ascii_alphabetic() { + break; + } + any = true; + value = value.checked_mul(26)?.checked_add((byte.to_ascii_uppercase() - b'A' + 1) as usize)?; + } + any.then_some(value.saturating_sub(1)) +} + +fn cell_ref(text: &str) -> Option<(usize, usize)> { + let split = text.find(|c: char| c.is_ascii_digit())?; + let column = column_index(&text[..split])?; + let row = text[split..].trim().parse::().ok()?.checked_sub(1)?; + Some((row, column)) +} + +fn crop_range(rows: Vec>, range: &str) -> Vec> { + let (start, end) = range.split_once(':').unwrap_or((range, range)); + let (Some((r0, c0)), Some((r1, c1))) = (cell_ref(start), cell_ref(end)) else { + return rows; + }; + let (r0, r1) = (r0.min(r1), r0.max(r1)); + let (c0, c1) = (c0.min(c1), c0.max(c1)); + rows.into_iter() + .skip(r0) + .take(r1 - r0 + 1) + .map(|row| { + (c0..=c1) + .map(|column| row.get(column).cloned().unwrap_or_default()) + .collect() + }) + .collect() +} + +fn workbook_metadata(path: &Path) -> Result<(Vec, Vec), String> { + let ext = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if ext == "csv" || ext == "txt" { + return Ok((Vec::new(), Vec::new())); + } + use calamine::{open_workbook_auto, Reader}; + let workbook = open_workbook_auto(path).map_err(|error| error.to_string())?; + let sheets = workbook.sheet_names(); + let names = workbook + .defined_names() + .iter() + .map(|(name, _)| name.clone()) + .collect(); + Ok((sheets, names)) +} + +fn named_range_target(formula: &str) -> Option<(String, String)> { + let formula = formula.trim().trim_start_matches('='); + let (sheet, range) = formula.rsplit_once('!')?; + let sheet = sheet + .trim() + .trim_matches('\'') + .replace("''", "'"); + let range = range.replace('$', ""); + (!sheet.is_empty() && !range.is_empty()).then_some((sheet, range)) +} + +pub(crate) fn read_tabular_file( + path: &Path, + sheet: Option<&str>, + range_kind: LinkRange, + range_value: Option<&str>, +) -> Result>, String> { + let ext = path.extension().and_then(|value| value.to_str()).unwrap_or("").to_ascii_lowercase(); + let (rows, cell_range) = if ext == "csv" || ext == "txt" { + if range_kind == LinkRange::NamedRange { + return Err(crate::t!("Named ranges require a spreadsheet file.").into_owned()); + } + let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?; + ( + crate::app::commands::display::parse_csv_table(&text), + (range_kind == LinkRange::CellRange) + .then_some(range_value) + .flatten() + .map(str::to_string), + ) + } else { + use calamine::{open_workbook_auto, Reader}; + let mut workbook = open_workbook_auto(path).map_err(|error| error.to_string())?; + let (selected_sheet, cell_range) = if range_kind == LinkRange::NamedRange { + let name = range_value + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| crate::t!("Select a named range.").into_owned())?; + let formula = workbook + .defined_names() + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, formula)| formula.clone()) + .ok_or_else(|| crate::t!("The named range was not found.").into_owned())?; + named_range_target(&formula) + .ok_or_else(|| crate::t!("The named range does not refer to a worksheet cell range.").into_owned())? + } else { + let selected_sheet = sheet + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .or_else(|| workbook.sheet_names().first().cloned()) + .ok_or_else(|| crate::t!("The spreadsheet contains no worksheets.").into_owned())?; + let range = (range_kind == LinkRange::CellRange) + .then_some(range_value) + .flatten() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_default(); + (selected_sheet, range) + }; + let data = workbook + .worksheet_range(&selected_sheet) + .map_err(|error| error.to_string())?; + ( + data.rows() + .map(|row| row.iter().map(ToString::to_string).collect()) + .collect(), + (!cell_range.is_empty()).then_some(cell_range), + ) + }; + let rows = match cell_range.as_deref().filter(|value| !value.trim().is_empty()) { + Some(value) => crop_range(rows, value), + None => rows, + }; + if rows.is_empty() || rows.iter().all(Vec::is_empty) { + Err(crate::t!("The selected data source is empty.").into_owned()) + } else { + Ok(rows) + } +} + +fn table_style_handle(doc: &CadDocument, name: &str) -> Option { + doc.objects.iter().find_map(|(handle, object)| match object { + ObjectType::TableStyle(style) if style.name.eq_ignore_ascii_case(name) => Some(*handle), + _ => None, + }) +} + +fn build_table(rows: &[Vec], style: Option, title: Option<&str>) -> acadrust::entities::Table { + let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); + let title_rows = usize::from(title.is_some_and(|value| !value.trim().is_empty())); + let mut table = TableBuilder::new(rows.len().max(1) + title_rows, columns) + .at(Vector3::new(0.0, 0.0, 0.0)) + .row_height(0.5) + .column_width(2.0) + .build(); + table.table_style_handle = style; + if let Some(title) = title.filter(|value| !value.trim().is_empty()) { + table.set_cell_text(0, 0, title); + if columns > 1 { + table.merge_cells(CellRange::new(0, 0, 0, columns - 1)); + } + } + for (row_index, row) in rows.iter().enumerate() { + for (column_index, value) in row.iter().enumerate() { + table.set_cell_text(row_index + title_rows, column_index, value); + } + } + table +} + +fn link_selection(link: &DataLink) -> (Option, LinkRange, Option) { + let mut sheet = None; + let mut kind = LinkRange::EntireSheet; + let mut range = None; + for item in &link.custom_data { + if let Some(value) = item.value.strip_prefix("sheet:") { + sheet = (!value.is_empty()).then(|| value.to_string()); + } else if let Some(value) = item.value.strip_prefix("range-kind:") { + kind = if value.eq_ignore_ascii_case("named") { + LinkRange::NamedRange + } else if value.eq_ignore_ascii_case("cell") { + LinkRange::CellRange + } else { + LinkRange::EntireSheet + }; + } else if let Some(value) = item.value.strip_prefix("range:") { + range = (!value.is_empty()).then(|| value.to_string()); + if kind == LinkRange::EntireSheet { + kind = LinkRange::CellRange; + } + } + } + (sheet, kind, range) +} + +fn link_custom_data(sheet: &str, kind: LinkRange, range: Option<&str>) -> Vec { + let mut values = Vec::new(); + if !sheet.trim().is_empty() { + values.push(DataLinkCustomData { + target: Handle::NULL, + value: format!("sheet:{}", sheet.trim()), + }); + } + if kind != LinkRange::EntireSheet { + values.push(DataLinkCustomData { + target: Handle::NULL, + value: format!( + "range-kind:{}", + if kind == LinkRange::NamedRange { "named" } else { "cell" } + ), + }); + if let Some(range) = range.filter(|value| !value.trim().is_empty()) { + values.push(DataLinkCustomData { + target: Handle::NULL, + value: format!("range:{}", range.trim()), + }); + } + } + values +} + +fn read_link_rows(doc: &CadDocument, link: &DataLink) -> Result>, String> { + let (sheet, kind, range) = link_selection(link); + read_tabular_file( + &resolve_link_path(doc, link), + sheet.as_deref(), + kind, + range.as_deref(), + ) +} + +pub(crate) fn read_data_link( + doc: &CadDocument, + handle: Handle, +) -> Result>, String> { + let link = data_link(doc, handle) + .ok_or_else(|| crate::t!("The data link no longer exists.").into_owned())?; + read_link_rows(doc, link) +} + +pub(crate) fn data_link_write_path( + doc: &CadDocument, + handle: Handle, +) -> Result { + let link = data_link(doc, handle) + .ok_or_else(|| crate::t!("The data link no longer exists.").into_owned())?; + if link.option & 1 == 0 { + return Err(crate::t!("Writing to this data link is disabled.").into_owned()); + } + let path = resolve_link_path(doc, link); + let writable = path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("csv") || value.eq_ignore_ascii_case("txt")); + if !writable { + return Err(crate::t!("Writing linked data is supported for CSV and text sources.").into_owned()); + } + Ok(path) +} + +fn entity_details(entity: &EntityType) -> String { + match entity { + EntityType::Line(value) => format!("({:.3},{:.3},{:.3})-({:.3},{:.3},{:.3})", value.start.x, value.start.y, value.start.z, value.end.x, value.end.y, value.end.z), + EntityType::Circle(value) => format!("C({:.3},{:.3},{:.3}) R={:.3}", value.center.x, value.center.y, value.center.z, value.radius), + EntityType::Arc(value) => format!("C({:.3},{:.3},{:.3}) R={:.3} {:.1}°-{:.1}°", value.center.x, value.center.y, value.center.z, value.radius, value.start_angle.to_degrees(), value.end_angle.to_degrees()), + EntityType::Text(value) => value.value.clone(), + EntityType::MText(value) => value.value.clone(), + EntityType::Insert(value) => format!("{} @({:.3},{:.3},{:.3})", value.block_name, value.insert_point.x, value.insert_point.y, value.insert_point.z), + EntityType::LwPolyline(value) => format!("{} vertices", value.vertices.len()), + EntityType::Polyline(value) => format!("{} vertices", value.vertices.len()), + EntityType::Polyline2D(value) => format!("{} vertices", value.vertices.len()), + EntityType::Polyline3D(value) => format!("{} vertices", value.vertices.len()), + EntityType::Hatch(value) => value.pattern.name.clone(), + EntityType::Dimension(value) => format!("{:.3}", value.base().actual_measurement), + EntityType::Spline(value) => format!("{} control points", value.control_points.len()), + _ => String::new(), + } +} + +fn collect_records( + doc: &CadDocument, + allowed_handles: Option<&BTreeSet>, + allowed_types: &BTreeSet, + out: &mut Vec, +) { + for entity in doc.entities() { + if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) { + continue; + } + if allowed_handles.is_some_and(|handles| !handles.contains(&entity.common().handle)) { + continue; + } + let object_type = crate::entities::names::dxf_name(entity).to_string(); + if !allowed_types.is_empty() && !allowed_types.contains(&object_type) { + continue; + } + out.push(ExtractionRecord { + object_type, + handle: format!("{:X}", entity.common().handle.value()), + layer: entity.common().layer.clone(), + color: entity.common().color.to_string(), + linetype: entity.common().linetype.clone(), + details: entity_details(entity), + }); + } +} + +fn is_drawing_file(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("dwg") || value.eq_ignore_ascii_case("dxf")) +} + +fn collect_drawing_paths( + path: &Path, + recursive: bool, + out: &mut Vec, +) -> Result<(), String> { + if path.is_file() { + if is_drawing_file(path) { + out.push(path.to_path_buf()); + } + return Ok(()); + } + if !path.is_dir() { + return Err(crate::tf!("Data source not found: %{path}", path = path.display()).into_owned()); + } + for entry in std::fs::read_dir(path).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let child = entry.path(); + if child.is_file() && is_drawing_file(&child) { + out.push(child); + } else if recursive && child.is_dir() { + collect_drawing_paths(&child, true, out)?; + } + } + Ok(()) +} + +fn extraction_drawing_paths(state: &DataExtractionState) -> Result, String> { + let mut paths = Vec::new(); + for source in &state.source_files { + collect_drawing_paths(Path::new(source), state.include_subfolders, &mut paths)?; + } + paths.sort_by_key(|path| path.to_string_lossy().to_ascii_lowercase()); + paths.dedup_by(|left, right| left == right); + Ok(paths) +} + +fn collect_type_counts( + doc: &CadDocument, + allowed_handles: Option<&BTreeSet>, + counts: &mut BTreeMap, +) { + for entity in doc.entities() { + if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) { + continue; + } + if allowed_handles.is_some_and(|handles| !handles.contains(&entity.common().handle)) { + continue; + } + *counts + .entry(crate::entities::names::dxf_name(entity).to_string()) + .or_default() += 1; + } +} + +fn extraction_preview(state: &DataExtractionState, current: &CadDocument) -> Result>, String> { + let allowed_types = state.objects.iter().filter(|value| value.checked).map(|value| value.name.clone()).collect::>(); + let selected = state.selection_handles.iter().copied().collect::>(); + let mut records = Vec::new(); + match state.source { + ExtractionSource::CurrentDrawing => collect_records(current, None, &allowed_types, &mut records), + ExtractionSource::CurrentSelection => collect_records(current, Some(&selected), &allowed_types, &mut records), + ExtractionSource::DrawingsAndFolders => { + if state.include_current { + collect_records(current, None, &allowed_types, &mut records); + } + for source in extraction_drawing_paths(state)? { + let doc = crate::io::load_file(&source).map_err(|error| format!("{}: {error}", source.display()))?; + collect_records(&doc, None, &allowed_types, &mut records); + } + } + } + let props = state.properties.iter().filter(|value| value.checked).collect::>(); + if props.is_empty() { + return Err(crate::t!("Select at least one property.").into_owned()); + } + let mut header = Vec::new(); + if state.show_name { + header.push("Name".to_string()); + } + header.extend(props.iter().filter(|value| !(state.show_name && value.key == "type")).map(|value| value.name.clone())); + if state.show_count { + header.push("Count".to_string()); + } + let mut rows = Vec::new(); + if state.combine_identical { + let mut grouped: BTreeMap, usize> = BTreeMap::new(); + for record in records { + let mut values = Vec::new(); + if state.show_name { + values.push(record.object_type.clone()); + } + values.extend(props.iter().filter(|value| !(state.show_name && value.key == "type")).map(|value| record.value(&value.key))); + *grouped.entry(values).or_default() += 1; + } + for (mut values, count) in grouped { + if state.show_count { + values.push(count.to_string()); + } + rows.push(values); + } + } else { + for record in records { + let mut values = Vec::new(); + if state.show_name { + values.push(record.object_type.clone()); + } + values.extend(props.iter().filter(|value| !(state.show_name && value.key == "type")).map(|value| record.value(&value.key))); + if state.show_count { + values.push("1".into()); + } + rows.push(values); + } + } + let mut output = vec![header]; + output.extend(rows); + Ok(output) +} + +fn csv_escape(value: &str) -> String { + if value.contains([',', '"', '\r', '\n']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +fn rows_to_csv(rows: &[Vec]) -> String { + let mut csv = String::new(); + for row in rows { + csv.push_str(&row.iter().map(|value| csv_escape(value)).collect::>().join(",")); + csv.push('\n'); + } + csv +} + +fn save_extraction_settings(state: &DataExtractionState) -> Result<(), String> { + if state.settings_path.trim().is_empty() { + return Ok(()); + } + let object_types = state.objects.iter().filter(|value| value.checked).map(|value| value.name.clone()).collect::>(); + let properties = state.properties.iter().filter(|value| value.checked).map(|value| value.key.clone()).collect::>(); + let value = serde_json::json!({ + "version": 1, + "source": match state.source { + ExtractionSource::CurrentDrawing => "currentDrawing", + ExtractionSource::CurrentSelection => "currentSelection", + ExtractionSource::DrawingsAndFolders => "drawingsAndFolders", + }, + "includeCurrent": state.include_current, + "includeSubfolders": state.include_subfolders, + "sourceFiles": state.source_files, + "objectTypes": object_types, + "properties": properties, + "combineIdentical": state.combine_identical, + "showCount": state.show_count, + "showName": state.show_name, + "outputTable": state.output_table, + "outputFile": state.output_file, + "outputPath": state.output_path, + "tableStyle": state.table_style, + "tableTitle": state.table_title, + }); + let text = serde_json::to_string_pretty(&value).map_err(|error| error.to_string())?; + std::fs::write(&state.settings_path, text).map_err(|error| error.to_string()) +} + +fn load_extraction_settings(state: &mut DataExtractionState) -> Result<(), String> { + let text = std::fs::read_to_string(&state.settings_path).map_err(|error| error.to_string())?; + let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| error.to_string())?; + state.source = match value["source"].as_str() { + Some("currentSelection") => ExtractionSource::CurrentSelection, + Some("drawingsAndFolders") => ExtractionSource::DrawingsAndFolders, + _ => ExtractionSource::CurrentDrawing, + }; + state.include_current = value["includeCurrent"].as_bool().unwrap_or(state.include_current); + state.include_subfolders = value["includeSubfolders"].as_bool().unwrap_or(state.include_subfolders); + if let Some(files) = value["sourceFiles"].as_array() { + state.source_files = files + .iter() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect(); + } + let object_types = value["objectTypes"].as_array().map(|values| values.iter().filter_map(|value| value.as_str()).collect::>()).unwrap_or_default(); + for item in &mut state.objects { + item.checked = object_types.is_empty() || object_types.contains(item.name.as_str()); + } + let properties = value["properties"].as_array().map(|values| values.iter().filter_map(|value| value.as_str()).collect::>()).unwrap_or_default(); + for item in &mut state.properties { + item.checked = properties.is_empty() || properties.contains(item.key.as_str()); + } + state.combine_identical = value["combineIdentical"].as_bool().unwrap_or(state.combine_identical); + state.show_count = value["showCount"].as_bool().unwrap_or(state.show_count); + state.show_name = value["showName"].as_bool().unwrap_or(state.show_name); + state.output_table = value["outputTable"].as_bool().unwrap_or(state.output_table); + state.output_file = value["outputFile"].as_bool().unwrap_or(state.output_file); + state.output_path = value["outputPath"].as_str().unwrap_or(&state.output_path).to_string(); + state.table_style = value["tableStyle"].as_str().unwrap_or(&state.table_style).to_string(); + state.table_title = value["tableTitle"].as_str().unwrap_or(&state.table_title).to_string(); + Ok(()) +} + +impl OpenCADStudio { + fn reset_annotation_modal(&mut self, modal: ModalKind) { + self.active_modal = Some(modal); + self.reset_modal_geometry(); + } + + pub(super) fn open_table_insert(&mut self) { + let i = self.active_tab; + let doc = &self.tabs[i].scene.document; + let styles = table_styles(doc); + let current = doc.header.current_table_style_name.clone(); + let links = data_link_choices(doc); + self.table_insert.styles = styles; + self.table_insert.style = current; + self.table_insert.links = links; + self.table_insert.link = self.table_insert.links.first().cloned(); + self.table_insert.error.clear(); + self.reset_annotation_modal(ModalKind::InsertTable); + } + + pub(super) fn open_data_link_manager(&mut self, from_table: bool) { + self.data_link_parent_table = from_table; + self.refresh_data_link_manager(None); + self.data_link_manager.editing = false; + self.data_link_manager.status.clear(); + self.reset_annotation_modal(ModalKind::DataLinkManager); + } + + fn refresh_data_link_manager(&mut self, selected: Option) { + let doc = &self.tabs[self.active_tab].scene.document; + let links = data_link_choices(doc); + let selected = selected + .and_then(|handle| links.iter().find(|value| value.handle == handle).cloned()) + .or_else(|| self.data_link_manager.selected.as_ref().and_then(|old| links.iter().find(|value| value.handle == old.handle).cloned())) + .or_else(|| links.first().cloned()); + let preview = selected + .as_ref() + .and_then(|choice| data_link(doc, choice.handle)) + .and_then(|link| read_link_rows(doc, link).ok()) + .unwrap_or_default(); + self.data_link_manager.links = links; + self.data_link_manager.selected = selected; + self.data_link_manager.preview = preview; + } + + pub(super) fn open_data_extraction(&mut self) { + let i = self.active_tab; + let doc = &self.tabs[i].scene.document; + let selection_handles = self.tabs[i].scene.selected_entities().into_iter().map(|(handle, _)| handle).collect::>(); + let mut counts = BTreeMap::::new(); + for entity in doc.entities() { + if !matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) { + *counts.entry(crate::entities::names::dxf_name(entity).to_string()).or_default() += 1; + } + } + let mut state = DataExtractionState::default(); + state.selection_handles = selection_handles; + state.objects = counts.into_iter().map(|(name, count)| crate::ui::window::annotation_data::ExtractionObject { name, checked: true, count }).collect(); + state.table_styles = table_styles(doc); + state.table_style = doc.header.current_table_style_name.clone(); + self.data_extraction = state; + self.reset_annotation_modal(ModalKind::DataExtraction); + } + + pub(super) fn on_table_insert_style(&mut self, style: String) -> Task { + self.table_insert.style = style; + Task::none() + } + + pub(super) fn on_table_insert_field(&mut self, field: TableInsertField) -> Task { + match field { + TableInsertField::Style(value) => self.table_insert.style = value, + TableInsertField::Source(value) => self.table_insert.source = value, + TableInsertField::Link(value) => self.table_insert.link = Some(value), + TableInsertField::Insertion(value) => self.table_insert.insertion = value, + TableInsertField::Columns(value) => self.table_insert.columns = value, + TableInsertField::DataRows(value) => self.table_insert.data_rows = value, + TableInsertField::ColumnWidth(value) => self.table_insert.column_width = value, + TableInsertField::RowHeight(value) => self.table_insert.row_height = value, + TableInsertField::Preview(value) => self.table_insert.preview = value, + TableInsertField::FirstRowStyle(value) => self.table_insert.first_row_style = value, + TableInsertField::SecondRowStyle(value) => self.table_insert.second_row_style = value, + TableInsertField::OtherRowStyle(value) => self.table_insert.other_row_style = value, + } + self.table_insert.error.clear(); + Task::none() + } + + pub(super) fn on_table_insert_apply(&mut self) -> Task { + let i = self.active_tab; + if self.table_insert.source == TableSource::DataExtraction { + self.open_data_extraction(); + return Task::none(); + } + if self.table_insert.source == TableSource::DataLink { + let Some(choice) = self.table_insert.link.clone() else { + self.table_insert.error = crate::t!("Select a data link.").into_owned(); + return Task::none(); + }; + let doc = &self.tabs[i].scene.document; + let Some(link) = data_link(doc, choice.handle) else { + self.table_insert.error = crate::t!("The selected data link no longer exists.").into_owned(); + return Task::none(); + }; + let rows = match read_link_rows(doc, link) { + Ok(rows) => rows, + Err(error) => { + self.table_insert.error = error; + return Task::none(); + } + }; + let table = build_table(&rows, table_style_handle(doc, &self.table_insert.style), None); + let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing(table, choice.handle); + self.active_modal = None; + self.reset_modal_geometry(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Task::none(); + } + let parse_usize = |value: &str| value.trim().parse::().ok().filter(|value| *value > 0); + let parse_number = |value: &str| value.trim().parse::().ok().filter(|value| value.is_finite() && *value > 0.0); + let (Some(columns), Some(data_rows), Some(column_width), Some(row_height)) = ( + parse_usize(&self.table_insert.columns), + parse_usize(&self.table_insert.data_rows), + parse_number(&self.table_insert.column_width), + parse_number(&self.table_insert.row_height), + ) else { + self.table_insert.error = crate::t!("Columns, rows, width, and height must be positive values.").into_owned(); + return Task::none(); + }; + let doc = &self.tabs[i].scene.document; + let style = doc.objects.iter().find_map(|(handle, object)| match object { + ObjectType::TableStyle(style) if style.name.eq_ignore_ascii_case(&self.table_insert.style) => Some((*handle, style)), + _ => None, + }); + let multiplier = self.tabs[i].scene.creation_annotation_multiplier(); + let command = crate::modules::annotate::table_cmd::TableCommand::configured( + style, + multiplier, + columns, + data_rows, + column_width, + row_height, + self.table_insert.insertion == TableInsertion::Window, + self.table_insert.preview, + [ + &self.table_insert.first_row_style, + &self.table_insert.second_row_style, + &self.table_insert.other_row_style, + ], + ); + self.active_modal = None; + self.reset_modal_geometry(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Task::none() + } + + pub(super) fn on_data_link_new(&mut self) -> Task { + let mut n = 1usize; + loop { + let name = format!("Data Link {n}"); + if !self.data_link_manager.links.iter().any(|link| link.name.eq_ignore_ascii_case(&name)) { + self.data_link_manager.name = name; + break; + } + n += 1; + } + self.data_link_manager.path.clear(); + self.data_link_manager.path_type = LinkPathType::Full; + self.data_link_manager.editing_handle = None; + self.data_link_manager.editing = true; + self.data_link_manager.range_kind = LinkRange::EntireSheet; + self.data_link_manager.range.clear(); + self.data_link_manager.sheets.clear(); + self.data_link_manager.sheet.clear(); + self.data_link_manager.named_ranges.clear(); + self.data_link_manager.allow_write = false; + self.data_link_manager.use_source_formatting = true; + self.data_link_manager.update_source_formatting = true; + self.data_link_manager.insert_table = false; + self.data_link_manager.more_options = false; + self.data_link_manager.status.clear(); + Task::none() + } + + pub(super) fn on_data_link_select(&mut self, handle: Handle) -> Task { + self.refresh_data_link_manager(Some(handle)); + Task::none() + } + + pub(super) fn on_data_link_edit(&mut self) -> Task { + let Some(choice) = self.data_link_manager.selected.clone() else { + return Task::none(); + }; + let Some(link) = data_link(&self.tabs[self.active_tab].scene.document, choice.handle) else { + return Task::none(); + }; + self.data_link_manager.editing = true; + self.data_link_manager.editing_handle = Some(choice.handle); + self.data_link_manager.name = link_display_name(link); + let path = resolve_link_path(&self.tabs[self.active_tab].scene.document, link); + self.data_link_manager.path = path.to_string_lossy().into_owned(); + self.data_link_manager.path_type = match link.path_option { 2 => LinkPathType::Relative, 3 => LinkPathType::FileName, _ => LinkPathType::Full }; + let (sheet, range_kind, range) = link_selection(link); + let (sheets, named_ranges) = workbook_metadata(&path).unwrap_or_default(); + self.data_link_manager.sheets = sheets; + self.data_link_manager.named_ranges = named_ranges; + self.data_link_manager.sheet = sheet + .filter(|value| self.data_link_manager.sheets.iter().any(|candidate| candidate == value)) + .or_else(|| self.data_link_manager.sheets.first().cloned()) + .unwrap_or_default(); + self.data_link_manager.range = range.unwrap_or_default(); + self.data_link_manager.range_kind = range_kind; + self.data_link_manager.allow_write = link.option & 1 != 0; + self.data_link_manager.use_source_formatting = link.flags & 1 != 0; + self.data_link_manager.update_source_formatting = link.flags & 2 != 0; + self.data_link_manager.status.clear(); + Task::none() + } + + pub(super) fn on_data_link_field(&mut self, field: DataLinkField) -> Task { + match field { + DataLinkField::Name(value) => self.data_link_manager.name = value, + DataLinkField::Path(value) => self.data_link_manager.path = value, + DataLinkField::PathType(value) => self.data_link_manager.path_type = value, + DataLinkField::RangeKind(value) => self.data_link_manager.range_kind = value, + DataLinkField::Range(value) => self.data_link_manager.range = value, + DataLinkField::Sheet(value) => self.data_link_manager.sheet = value, + DataLinkField::AllowWrite(value) => self.data_link_manager.allow_write = value, + DataLinkField::UseSourceFormatting(value) => self.data_link_manager.use_source_formatting = value, + DataLinkField::UpdateSourceFormatting(value) => self.data_link_manager.update_source_formatting = value, + DataLinkField::InsertTable(value) => self.data_link_manager.insert_table = value, + DataLinkField::MoreOptions(value) => self.data_link_manager.more_options = value, + } + self.data_link_manager.status.clear(); + Task::none() + } + + pub(super) fn on_data_link_browse(&mut self) -> Task { + Task::perform( + async { + crate::sys::file_dialog() + .set_title(crate::t!("Choose a Spreadsheet File").as_ref()) + .add_filter(crate::t!("Spreadsheet Files").as_ref(), &["csv", "txt", "xls", "xlsx", "xlsb", "ods"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + .map(|handle| crate::sys::handle_path(&handle)) + }, + Message::DataLinkBrowseResult, + ) + } + + pub(super) fn on_data_link_browse_result(&mut self, path: Option) -> Task { + if let Some(path) = path { + self.data_link_manager.path = path.to_string_lossy().into_owned(); + let (sheets, named_ranges) = workbook_metadata(&path).unwrap_or_default(); + self.data_link_manager.sheets = sheets; + self.data_link_manager.named_ranges = named_ranges; + self.data_link_manager.sheet = self + .data_link_manager + .sheets + .first() + .cloned() + .unwrap_or_default(); + if self.data_link_manager.range_kind == LinkRange::NamedRange { + self.data_link_manager.range = self + .data_link_manager + .named_ranges + .first() + .cloned() + .unwrap_or_default(); + } + self.data_link_manager.preview = read_tabular_file( + &path, + (!self.data_link_manager.sheet.is_empty()) + .then_some(self.data_link_manager.sheet.as_str()), + self.data_link_manager.range_kind, + (!self.data_link_manager.range.is_empty()) + .then_some(self.data_link_manager.range.as_str()), + ) + .unwrap_or_default(); + } + Task::none() + } + + pub(super) fn on_data_link_save(&mut self) -> Task { + let i = self.active_tab; + let name = self.data_link_manager.name.trim().to_string(); + let input_path = PathBuf::from(self.data_link_manager.path.trim()); + if name.is_empty() { + self.data_link_manager.status = crate::t!("Enter a data link name.").into_owned(); + return Task::none(); + } + if self.data_link_manager.links.iter().any(|link| link.name.eq_ignore_ascii_case(&name) && Some(link.handle) != self.data_link_manager.editing_handle) { + self.data_link_manager.status = crate::t!("A data link with that name already exists.").into_owned(); + return Task::none(); + } + let range = (self.data_link_manager.range_kind != LinkRange::EntireSheet) + .then(|| self.data_link_manager.range.trim().to_string()) + .filter(|value| !value.is_empty()); + let preview = match read_tabular_file( + &input_path, + (!self.data_link_manager.sheet.is_empty()) + .then_some(self.data_link_manager.sheet.as_str()), + self.data_link_manager.range_kind, + range.as_deref(), + ) { + Ok(rows) => rows, + Err(error) => { + self.data_link_manager.status = error; + return Task::none(); + } + }; + let stored_path = match self.data_link_manager.path_type { + LinkPathType::Full => std::fs::canonicalize(&input_path).unwrap_or(input_path.clone()), + LinkPathType::Relative => self.tabs[i].scene.document.source_path.as_deref().map(Path::new).and_then(Path::parent).and_then(|parent| input_path.strip_prefix(parent).ok()).map(Path::to_path_buf).unwrap_or(input_path.clone()), + LinkPathType::FileName => input_path.file_name().map(PathBuf::from).unwrap_or(input_path.clone()), + }; + self.push_undo_snapshot(i, "DATALINK"); + let handle = if let Some(handle) = self.data_link_manager.editing_handle { + if let Some(link) = data_link_mut(&mut self.tabs[i].scene.document, handle) { + link.description = name; + link.tooltip = input_path.to_string_lossy().into_owned(); + link.connection_string = stored_path.to_string_lossy().into_owned(); + link.path_option = match self.data_link_manager.path_type { LinkPathType::Full => 1, LinkPathType::Relative => 2, LinkPathType::FileName => 3 }; + link.option = i32::from(self.data_link_manager.allow_write); + link.flags = i32::from(self.data_link_manager.use_source_formatting) | (i32::from(self.data_link_manager.update_source_formatting) << 1); + link.status_flags = 1; + link.update_status = "Linked".into(); + link.custom_data = link_custom_data( + &self.data_link_manager.sheet, + self.data_link_manager.range_kind, + range.as_deref(), + ); + } + handle + } else { + let handle = self.tabs[i].scene.document.allocate_handle(); + let mut object = ClassObject::new(ClassObjectData::DataLink(DataLink { + data_adapter: if input_path.extension().and_then(|value| value.to_str()).is_some_and(|value| value.eq_ignore_ascii_case("csv") || value.eq_ignore_ascii_case("txt")) { "CSV".into() } else { "Spreadsheet".into() }, + description: name, + tooltip: input_path.to_string_lossy().into_owned(), + connection_string: stored_path.to_string_lossy().into_owned(), + option: i32::from(self.data_link_manager.allow_write), + flags: i32::from(self.data_link_manager.use_source_formatting) | (i32::from(self.data_link_manager.update_source_formatting) << 1), + path_option: match self.data_link_manager.path_type { LinkPathType::Full => 1, LinkPathType::Relative => 2, LinkPathType::FileName => 3 }, + status_flags: 1, + update_status: "Linked".into(), + custom_data: link_custom_data( + &self.data_link_manager.sheet, + self.data_link_manager.range_kind, + range.as_deref(), + ), + ..DataLink::default() + })); + object.handle = handle; + self.tabs[i].scene.document.objects.insert(handle, ObjectType::ClassObject(object)); + handle + }; + self.tabs[i].dirty = true; + self.data_link_manager.editing = false; + self.data_link_manager.editing_handle = None; + self.data_link_manager.preview = preview.clone(); + self.refresh_data_link_manager(Some(handle)); + if self.data_link_manager.insert_table { + let style = table_style_handle(&self.tabs[i].scene.document, &self.tabs[i].scene.document.header.current_table_style_name); + let table = build_table(&preview, style, None); + let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing(table, handle); + self.active_modal = None; + self.reset_modal_geometry(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + Task::none() + } + + pub(super) fn on_data_link_delete(&mut self) -> Task { + let Some(handle) = self.data_link_manager.selected.as_ref().map(|value| value.handle) else { + return Task::none(); + }; + let i = self.active_tab; + self.push_undo_snapshot(i, "DATALINK"); + for entity in self.tabs[i].scene.document.entities_mut() { + if let EntityType::Table(table) = entity { + for row in &mut table.rows { + for cell in &mut row.cells { + if cell.data_link_handle == Some(handle) { + cell.data_link_handle = None; + cell.has_linked_data = false; + cell.data_link_rows = 0; + cell.data_link_columns = 0; + cell.state.remove(acadrust::entities::table::CellStateFlags::LINKED | acadrust::entities::table::CellStateFlags::CONTENT_LOCKED | acadrust::entities::table::CellStateFlags::FORMAT_LOCKED); + } + } + } + } + } + self.tabs[i].scene.document.objects.remove(&handle); + self.tabs[i].dirty = true; + self.data_link_manager.selected = None; + self.refresh_data_link_manager(None); + self.data_link_manager.status = crate::t!("Data link deleted; existing table values were retained.").into_owned(); + Task::none() + } + + pub(super) fn on_data_link_insert(&mut self) -> Task { + let Some(choice) = self.data_link_manager.selected.clone() else { + return Task::none(); + }; + let i = self.active_tab; + let doc = &self.tabs[i].scene.document; + let Some(link) = data_link(doc, choice.handle) else { return Task::none(); }; + let rows = match read_link_rows(doc, link) { + Ok(rows) => rows, + Err(error) => { + self.data_link_manager.status = error; + return Task::none(); + } + }; + let style = table_style_handle(doc, &doc.header.current_table_style_name); + let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing(build_table(&rows, style, None), choice.handle); + self.active_modal = None; + self.reset_modal_geometry(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Task::none() + } + + pub(super) fn on_data_link_close(&mut self) -> Task { + if self.data_link_parent_table { + self.data_link_parent_table = false; + let links = data_link_choices(&self.tabs[self.active_tab].scene.document); + self.table_insert.links = links; + self.table_insert.link = self.table_insert.links.first().cloned(); + self.reset_annotation_modal(ModalKind::InsertTable); + } else { + self.active_modal = None; + self.reset_modal_geometry(); + self.ribbon.deactivate_tool(); + } + Task::none() + } + + pub(super) fn on_data_extraction_field(&mut self, field: DataExtractionField) -> Task { + match field { + DataExtractionField::Begin(value) => self.data_extraction.begin = value, + DataExtractionField::SettingsPath(value) => self.data_extraction.settings_path = value, + DataExtractionField::Source(value) => self.data_extraction.source = value, + DataExtractionField::IncludeCurrent(value) => self.data_extraction.include_current = value, + DataExtractionField::IncludeSubfolders(value) => self.data_extraction.include_subfolders = value, + DataExtractionField::Object(index, value) => if let Some(item) = self.data_extraction.objects.get_mut(index) { item.checked = value; }, + DataExtractionField::Property(index, value) => if let Some(item) = self.data_extraction.properties.get_mut(index) { item.checked = value; }, + DataExtractionField::Category(category, value) => if value { self.data_extraction.categories.insert(category); } else { self.data_extraction.categories.remove(&category); }, + DataExtractionField::CombineIdentical(value) => self.data_extraction.combine_identical = value, + DataExtractionField::ShowCount(value) => self.data_extraction.show_count = value, + DataExtractionField::ShowName(value) => self.data_extraction.show_name = value, + DataExtractionField::OutputTable(value) => self.data_extraction.output_table = value, + DataExtractionField::OutputFile(value) => self.data_extraction.output_file = value, + DataExtractionField::OutputPath(value) => self.data_extraction.output_path = value, + DataExtractionField::TableStyle(value) => self.data_extraction.table_style = value, + DataExtractionField::TableTitle(value) => self.data_extraction.table_title = value, + } + self.data_extraction.error.clear(); + if self.data_extraction.page == ExtractionPage::Refine { + self.update_extraction_preview(); + } + Task::none() + } + + fn refresh_extraction_objects(&mut self) -> Result<(), String> { + let state = self.data_extraction.clone(); + let selected = state + .selection_handles + .iter() + .copied() + .collect::>(); + let mut counts = BTreeMap::new(); + let current = &self.tabs[self.active_tab].scene.document; + match state.source { + ExtractionSource::CurrentDrawing => collect_type_counts(current, None, &mut counts), + ExtractionSource::CurrentSelection => { + collect_type_counts(current, Some(&selected), &mut counts) + } + ExtractionSource::DrawingsAndFolders => { + if state.include_current { + collect_type_counts(current, None, &mut counts); + } + for path in extraction_drawing_paths(&state)? { + let doc = crate::io::load_file(&path) + .map_err(|error| format!("{}: {error}", path.display()))?; + collect_type_counts(&doc, None, &mut counts); + } + } + } + let previous = self + .data_extraction + .objects + .iter() + .map(|item| (item.name.to_ascii_lowercase(), item.checked)) + .collect::>(); + self.data_extraction.objects = counts + .into_iter() + .map(|(name, count)| crate::ui::window::annotation_data::ExtractionObject { + checked: previous + .get(&name.to_ascii_lowercase()) + .copied() + .unwrap_or(true), + name, + count, + }) + .collect(); + Ok(()) + } + + fn update_extraction_preview(&mut self) { + match extraction_preview(&self.data_extraction, &self.tabs[self.active_tab].scene.document) { + Ok(rows) => { + self.data_extraction.preview = rows; + self.data_extraction.error.clear(); + } + Err(error) => self.data_extraction.error = error, + } + } + + pub(super) fn on_data_extraction_back(&mut self) -> Task { + self.data_extraction.page = match self.data_extraction.page { + ExtractionPage::Begin => ExtractionPage::Begin, + ExtractionPage::Source => ExtractionPage::Begin, + ExtractionPage::Objects => ExtractionPage::Source, + ExtractionPage::Properties => ExtractionPage::Objects, + ExtractionPage::Refine => ExtractionPage::Properties, + ExtractionPage::Output => ExtractionPage::Refine, + ExtractionPage::TableStyle => ExtractionPage::Output, + ExtractionPage::Finish if self.data_extraction.output_table => ExtractionPage::TableStyle, + ExtractionPage::Finish => ExtractionPage::Output, + }; + self.data_extraction.error.clear(); + Task::none() + } + + pub(super) fn on_data_extraction_next(&mut self) -> Task { + self.data_extraction.error.clear(); + match self.data_extraction.page { + ExtractionPage::Begin => { + if self.data_extraction.settings_path.trim().is_empty() { + return self.on_data_extraction_browse_settings(); + } + if self.data_extraction.begin != ExtractionBegin::New { + if let Err(error) = load_extraction_settings(&mut self.data_extraction) { + self.data_extraction.error = error; + return Task::none(); + } + } + self.data_extraction.page = ExtractionPage::Source; + } + ExtractionPage::Source => { + if self.data_extraction.source == ExtractionSource::CurrentSelection && self.data_extraction.selection_handles.is_empty() { + self.data_extraction.error = crate::t!("No objects were selected when the wizard opened.").into_owned(); + return Task::none(); + } + if self.data_extraction.source == ExtractionSource::DrawingsAndFolders && !self.data_extraction.include_current && self.data_extraction.source_files.is_empty() { + self.data_extraction.error = crate::t!("Add a drawing or include the current drawing.").into_owned(); + return Task::none(); + } + if let Err(error) = self.refresh_extraction_objects() { + self.data_extraction.error = error; + return Task::none(); + } + if self.data_extraction.objects.is_empty() { + self.data_extraction.error = crate::t!("No extractable objects were found in the selected source.").into_owned(); + return Task::none(); + } + self.data_extraction.page = ExtractionPage::Objects; + } + ExtractionPage::Objects => { + if !self.data_extraction.objects.iter().any(|value| value.checked) { + self.data_extraction.error = crate::t!("Select at least one object type.").into_owned(); + return Task::none(); + } + self.data_extraction.page = ExtractionPage::Properties; + } + ExtractionPage::Properties => { + if !self.data_extraction.properties.iter().any(|value| value.checked) { + self.data_extraction.error = crate::t!("Select at least one property.").into_owned(); + return Task::none(); + } + self.data_extraction.page = ExtractionPage::Refine; + self.update_extraction_preview(); + } + ExtractionPage::Refine => { + self.update_extraction_preview(); + if self.data_extraction.error.is_empty() { + self.data_extraction.page = ExtractionPage::Output; + } + } + ExtractionPage::Output => { + if !self.data_extraction.output_table && !self.data_extraction.output_file { + self.data_extraction.error = crate::t!("Choose at least one output.").into_owned(); + return Task::none(); + } + if self.data_extraction.output_file && self.data_extraction.output_path.trim().is_empty() { + return self.on_data_extraction_browse_output(); + } + self.data_extraction.page = if self.data_extraction.output_table { ExtractionPage::TableStyle } else { ExtractionPage::Finish }; + } + ExtractionPage::TableStyle => self.data_extraction.page = ExtractionPage::Finish, + ExtractionPage::Finish => return self.on_data_extraction_finish(), + } + Task::none() + } + + pub(super) fn on_data_extraction_browse_settings(&mut self) -> Task { + let save = self.data_extraction.begin == ExtractionBegin::New; + Task::perform(async move { + let dialog = crate::sys::file_dialog().set_title(crate::t!("Data Extraction Settings").as_ref()).add_filter(crate::t!("Data Extraction Settings").as_ref(), &["dxex"]).add_filter(crate::t!("All Files").as_ref(), &["*"]); + if save { + dialog.set_file_name("extraction.dxex").save_file().await.map(|handle| crate::sys::handle_path(&handle)) + } else { + dialog.pick_file().await.map(|handle| crate::sys::handle_path(&handle)) + } + }, Message::DataExtractionBrowseSettingsResult) + } + + pub(super) fn on_data_extraction_browse_settings_result(&mut self, path: Option) -> Task { + if let Some(path) = path { + self.data_extraction.settings_path = path.to_string_lossy().into_owned(); + if self.data_extraction.begin != ExtractionBegin::New { + if let Err(error) = load_extraction_settings(&mut self.data_extraction) { + self.data_extraction.error = error; + return Task::none(); + } + } + self.data_extraction.page = ExtractionPage::Source; + } + Task::none() + } + + pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task { + Task::perform(async { + crate::sys::file_dialog() + .set_title(crate::t!("Add Drawings").as_ref()) + .add_filter(crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_files() + .await + .unwrap_or_default() + .into_iter() + .map(|handle| crate::sys::handle_path(&handle)) + .collect() + }, Message::DataExtractionAddDrawingsResult) + } + + pub(super) fn on_data_extraction_add_drawings_result(&mut self, paths: Vec) -> Task { + for path in paths { + let text = path.to_string_lossy().into_owned(); + if !self.data_extraction.source_files.iter().any(|value| value.eq_ignore_ascii_case(&text)) { + self.data_extraction.source_files.push(text); + } + } + Task::none() + } + + pub(super) fn on_data_extraction_add_folder(&mut self) -> Task { + Task::perform( + async { + crate::sys::file_dialog() + .set_title(crate::t!("Add Drawing Folder").as_ref()) + .pick_folder() + .await + .map(|handle| crate::sys::handle_path(&handle)) + }, + Message::DataExtractionAddFolderResult, + ) + } + + pub(super) fn on_data_extraction_add_folder_result( + &mut self, + path: Option, + ) -> Task { + if let Some(path) = path { + let text = path.to_string_lossy().into_owned(); + if !self + .data_extraction + .source_files + .iter() + .any(|value| value.eq_ignore_ascii_case(&text)) + { + self.data_extraction.source_files.push(text); + } + } + Task::none() + } + + pub(super) fn on_data_extraction_browse_output(&mut self) -> Task { + Task::perform(async { + crate::sys::file_dialog() + .set_title(crate::t!("Save Data Extraction").as_ref()) + .set_file_name("extraction.csv") + .add_filter(crate::t!("CSV").as_ref(), &["csv"]) + .add_filter(crate::t!("Tab-separated text").as_ref(), &["txt"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .save_file() + .await + .map(|handle| crate::sys::handle_path(&handle)) + }, Message::DataExtractionBrowseOutputResult) + } + + pub(super) fn on_data_extraction_browse_output_result(&mut self, path: Option) -> Task { + if let Some(path) = path { + self.data_extraction.output_path = path.to_string_lossy().into_owned(); + self.data_extraction.output_file = true; + if self.data_extraction.page == ExtractionPage::Output { + self.data_extraction.page = if self.data_extraction.output_table { ExtractionPage::TableStyle } else { ExtractionPage::Finish }; + } + } + Task::none() + } + + pub(super) fn on_data_extraction_finish(&mut self) -> Task { + self.update_extraction_preview(); + if !self.data_extraction.error.is_empty() { + return Task::none(); + } + if let Err(error) = save_extraction_settings(&self.data_extraction) { + self.data_extraction.error = error; + return Task::none(); + } + let rows = self.data_extraction.preview.clone(); + if self.data_extraction.output_file { + let path = PathBuf::from(self.data_extraction.output_path.trim()); + let text = if path.extension().and_then(|value| value.to_str()).is_some_and(|value| value.eq_ignore_ascii_case("txt")) { + rows.iter().map(|row| row.join("\t")).collect::>().join("\n") + "\n" + } else { + rows_to_csv(&rows) + }; + if let Err(error) = std::fs::write(&path, text) { + self.data_extraction.error = error.to_string(); + return Task::none(); + } + } + let i = self.active_tab; + if self.data_extraction.output_table { + let style = table_style_handle(&self.tabs[i].scene.document, &self.data_extraction.table_style); + let table = build_table(&rows, style, Some(&self.data_extraction.table_title)); + let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::unlinked(table); + self.active_modal = None; + self.reset_modal_geometry(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else { + self.active_modal = None; + self.reset_modal_geometry(); + self.ribbon.deactivate_tool(); + self.command_line.push_output(crate::t!("DATAEXTRACTION: external file created.").as_ref()); + } + Task::none() + } +} diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 016bdfe4b..31734995e 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2474,6 +2474,7 @@ impl OpenCADStudio { CmdResult::CommitManyAndEditText { entities, edit_index, + open_editor, } => { let label = self.history_label_from_active_cmd(i, "ENTITY"); let delta_safe = entities @@ -2527,8 +2528,10 @@ impl OpenCADStudio { if let Some(pd) = pending { self.commit_undo_delta(i, pd); } - if let Some(h) = edit_handle { - return self.begin_text_edit(h); + if open_editor { + if let Some(h) = edit_handle { + return self.begin_text_edit(h); + } } } CmdResult::CreateBlock { diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 8adf3b441..6108a16cf 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -668,32 +668,7 @@ impl OpenCADStudio { } "TABLE" => { - use crate::modules::annotate::table_cmd::TableCommand; - let name = self.tabs[i] - .scene - .document - .header - .current_table_style_name - .clone(); - let style = self.tabs[i] - .scene - .document - .objects - .iter() - .find_map(|(handle, object)| match object { - acadrust::objects::ObjectType::TableStyle(style) - if style.name.eq_ignore_ascii_case(&name) => - { - Some((*handle, style.clone())) - } - _ => None, - }); - let multiplier = self.tabs[i].scene.creation_annotation_multiplier(); - let cmd = style.as_ref().map_or_else(TableCommand::new, |(handle, style)| { - TableCommand::with_style(*handle, style, multiplier) - }); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.open_table_insert(); } "TABLEDIT" => { diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 7fed79462..a7604403e 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -1585,10 +1585,7 @@ impl OpenCADStudio { // DATALINK — create a persistent linked table. "DATALINK" => { - use crate::command::ValuePromptCommand; - let c = ValuePromptCommand::new("DATALINK", "DATALINK path to the .csv file:"); - self.command_line.push_info(&c.prompt()); - self.tabs[i].active_cmd = Some(Box::new(c)); + self.open_data_link_manager(false); } cmd if cmd.starts_with("DATALINK ") => { let path = cmd.trim_start_matches("DATALINK").trim(); @@ -1690,20 +1687,7 @@ impl OpenCADStudio { let Some(link_handle) = link_handle else { continue; }; - let path = match self.tabs[i].scene.document.objects.get(&link_handle) { - Some(acadrust::objects::ObjectType::ClassObject(object)) => { - match &object.data { - acadrust::objects::ClassObjectData::DataLink(link) => { - Some(link.connection_string.clone()) - } - _ => None, - } - } - _ => None, - }; - if let Some(path) = path { - jobs.push((*handle, link_handle, path)); - } + jobs.push((*handle, link_handle)); } if jobs.is_empty() { self.command_line @@ -1712,28 +1696,43 @@ impl OpenCADStudio { } if write_back { let mut written = 0usize; - for (table_handle, _, path) in &jobs { + let mut errors = Vec::new(); + for (table_handle, link_handle) in &jobs { let Some(acadrust::EntityType::Table(table)) = self.tabs[i].scene.document.get_entity(*table_handle) else { continue; }; let csv = table_to_csv(table); - if std::fs::write(path, csv).is_ok() { - written += 1; + match crate::app::annotation_data::data_link_write_path( + &self.tabs[i].scene.document, + *link_handle, + ) + .and_then(|path| std::fs::write(path, csv).map_err(|error| error.to_string())) + { + Ok(()) => written += 1, + Err(error) => errors.push(error), } } - self.command_line.push_output( - crate::tf!("DATALINKUPDATE: wrote {} linked source(s).", written).as_ref(), - ); + if written > 0 { + self.command_line.push_output( + crate::tf!("DATALINKUPDATE: wrote {} linked source(s).", written).as_ref(), + ); + } + for error in errors { + self.command_line.push_error(&error); + } return Some(Task::none()); } let updates: Vec<_> = jobs .into_iter() - .filter_map(|(table_handle, link_handle, path)| { - std::fs::read_to_string(&path) - .ok() - .map(|text| (table_handle, link_handle, parse_csv_table(&text))) + .filter_map(|(table_handle, link_handle)| { + crate::app::annotation_data::read_data_link( + &self.tabs[i].scene.document, + link_handle, + ) + .ok() + .map(|rows| (table_handle, link_handle, rows)) }) .filter(|(_, _, rows)| !rows.is_empty()) .collect(); @@ -1888,7 +1887,7 @@ fn parse_landxml_cgpoints(xml: &str) -> Vec<[f64; 3]> { out } -fn parse_csv_table(text: &str) -> Vec> { +pub(crate) fn parse_csv_table(text: &str) -> Vec> { let mut rows = Vec::new(); let mut row = Vec::new(); let mut field = String::new(); @@ -1928,7 +1927,7 @@ fn parse_csv_table(text: &str) -> Vec> { rows } -fn table_to_csv(table: &acadrust::entities::Table) -> String { +pub(crate) fn table_to_csv(table: &acadrust::entities::Table) -> String { fn escape(value: &str) -> String { if value.contains([',', '"', '\r', '\n']) { format!("\"{}\"", value.replace('"', "\"\"")) diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 6b94eeb9c..aa9c088a7 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -1198,8 +1198,7 @@ impl OpenCADStudio { } "DATAEXTRACTION" | "EATTEXT" | "ATTEXT" => { - let csv = build_data_extraction_csv(&self.tabs[i].scene.document); - return Some(Task::done(Message::DataExtractionSave(csv))); + self.open_data_extraction(); } // ── Find / Replace ──────────────────────────────────────────────── @@ -1935,82 +1934,6 @@ fn z_axis_normal(normal: acadrust::types::Vector3) -> bool { normal.x == 0.0 && normal.y == 0.0 && normal.z != 0.0 } -// ── DATAEXTRACTION ───────────────────────────────────────────────────────── - -/// Build a CSV string with one row per entity in model space. -/// Columns: Type, Handle, Layer, Color, Linetype, ExtraInfo -fn build_data_extraction_csv(doc: &acadrust::CadDocument) -> String { - use acadrust::EntityType; - - let mut out = String::from("Type,Handle,Layer,Color,Linetype,ExtraInfo\n"); - - let ms_handle = doc.header.model_space_block_handle; - for e in doc.entities() { - // Skip Block/EndBlock sentinels and paper-space entities. - if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) { - continue; - } - if !ms_handle.is_null() && e.common().owner_handle != ms_handle { - continue; - } - let type_name = crate::entities::names::dxf_name(e); - let handle = format!("{:X}", e.common().handle.value()); - let layer = csv_escape(&e.common().layer); - let color = format!("{}", e.common().color); - let lt = csv_escape(&e.common().linetype); - let extra = csv_escape(&entity_extra_info(e)); - out.push_str(&format!( - "{type_name},{handle},{layer},{color},{lt},{extra}\n" - )); - } - out -} - -/// Return a short geometry summary for CSV ExtraInfo column. -fn entity_extra_info(entity: &acadrust::EntityType) -> String { - use acadrust::EntityType; - match entity { - EntityType::Line(e) => format!( - "({:.3},{:.3})-({:.3},{:.3})", - e.start.x, e.start.y, e.end.x, e.end.y - ), - EntityType::Circle(e) => { - format!("C({:.3},{:.3}) R={:.3}", e.center.x, e.center.y, e.radius) - } - EntityType::Arc(e) => format!( - "C({:.3},{:.3}) R={:.3} {:.1}°-{:.1}°", - e.center.x, - e.center.y, - e.radius, - e.start_angle.to_degrees(), - e.end_angle.to_degrees() - ), - EntityType::Text(e) => e.value.clone(), - EntityType::MText(e) => e.value.chars().take(60).collect(), - EntityType::Insert(e) => format!( - "BLK={} @({:.3},{:.3})", - e.block_name, e.insert_point.x, e.insert_point.y - ), - EntityType::LwPolyline(e) => crate::tf!("{} vertices", e.vertices.len()).into_owned(), - EntityType::Polyline(e) => crate::tf!("{} vertices", e.vertices.len()).into_owned(), - EntityType::Polyline2D(e) => crate::tf!("{} vertices", e.vertices.len()).into_owned(), - EntityType::Polyline3D(e) => crate::tf!("{} vertices", e.vertices.len()).into_owned(), - EntityType::Hatch(e) => format!("PAT={}", e.pattern.name), - EntityType::Dimension(e) => format!("{:.3}", e.base().actual_measurement), - EntityType::Spline(e) => crate::tf!("{} ctrl pts", e.control_points.len()).into_owned(), - _ => String::new(), - } -} - -/// Escape a string for a CSV field (wrap in quotes if it contains comma/quote/newline). -fn csv_escape(s: &str) -> String { - if s.contains(',') || s.contains('"') || s.contains('\n') { - format!("\"{}\"", s.replace('"', "\"\"")) - } else { - s.to_string() - } -} - // ── CAL — arithmetic expression evaluator ────────────────────────────────── // A small recursive-descent parser for `+ - * /`, parentheses, unary signs and // decimal numbers. Self-contained (no external dependency). diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index ed8ffb5ef..3e749827f 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; mod blocks; mod dim; -mod display; +pub(crate) mod display; mod draw; mod fileops; mod inquiry; diff --git a/src/app/mod.rs b/src/app/mod.rs index c7df63c77..d35b0a0a6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -29,6 +29,7 @@ mod startup; mod style_ops; mod text_inline; mod tolerance_dialog; +mod annotation_data; mod update; mod view; mod visibility; @@ -1092,6 +1093,12 @@ pub(super) struct OpenCADStudio { /// without Apply, mirroring the style managers' staging. scale_stage: Option, + // ── Annotation table/data dialogs ──────────────────────────────────── + table_insert: crate::ui::window::annotation_data::TableInsertState, + data_link_manager: crate::ui::window::annotation_data::DataLinkManagerState, + data_link_parent_table: bool, + data_extraction: crate::ui::window::annotation_data::DataExtractionState, + // ── Plot Style Panel ────────────────────────────────────────────────── /// Selected ACI index in the panel (1-255). plotstyle_panel_aci: u8, @@ -1747,6 +1754,9 @@ pub enum ModalKind { /// Add / remove the annotation scales a single selected object has a /// per-object representation for. AnnoObjectScale, + InsertTable, + DataLinkManager, + DataExtraction, /// The scene is drawn by a software rasterizer, or not at all: what that /// means and what usually fixes it. Queued once per verdict; the status /// bar's ⚠ pill reopens it. @@ -3412,6 +3422,35 @@ pub enum Message { /// Background extraction/write completion. WblockWriteFinished(String, std::path::PathBuf, Result<(), String>), // ── DATAEXTRACTION ──────────────────────────────────────────────────── + TableInsertStyle(String), + TableInsertField(crate::ui::window::annotation_data::TableInsertField), + TableInsertApply, + DataLinkManagerOpen, + DataLinkNew, + DataLinkSelect(acadrust::types::Handle), + DataLinkEdit, + DataLinkEditCancel, + DataLinkField(crate::ui::window::annotation_data::DataLinkField), + DataLinkBrowse, + DataLinkBrowseResult(Option), + DataLinkSave, + DataLinkDelete, + DataLinkInsert, + DataLinkClose, + DataExtractionOpen, + DataExtractionField(crate::ui::window::annotation_data::DataExtractionField), + DataExtractionBack, + DataExtractionNext, + DataExtractionBrowseSettings, + DataExtractionBrowseSettingsResult(Option), + DataExtractionAddDrawings, + DataExtractionAddDrawingsResult(Vec), + DataExtractionAddFolder, + DataExtractionAddFolderResult(Option), + DataExtractionClearSources, + DataExtractionBrowseOutput, + DataExtractionBrowseOutputResult(Option), + DataExtractionFinish, /// Save the pre-built CSV string to a file chosen by the user. DataExtractionSave(String), /// Path chosen (or None = cancelled). @@ -3780,6 +3819,10 @@ impl OpenCADStudio { anno_object_scale_target: None, scale_rename_buf: String::new(), scale_stage: None, + table_insert: Default::default(), + data_link_manager: Default::default(), + data_link_parent_table: false, + data_extraction: Default::default(), layout_manager_rename_buf: String::new(), plotstyle_panel_aci: 1, ps_color_buf: String::new(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 79046b306..2b53857f5 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1070,6 +1070,59 @@ impl OpenCADStudio { Task::none() } + Message::TableInsertStyle(value) => self.on_table_insert_style(value), + Message::TableInsertField(field) => self.on_table_insert_field(field), + Message::TableInsertApply => self.on_table_insert_apply(), + Message::DataLinkManagerOpen => { + let from_table = self.active_modal == Some(super::ModalKind::InsertTable); + self.open_data_link_manager(from_table); + Task::none() + } + Message::DataLinkNew => self.on_data_link_new(), + Message::DataLinkSelect(handle) => self.on_data_link_select(handle), + Message::DataLinkEdit => self.on_data_link_edit(), + Message::DataLinkEditCancel => { + self.data_link_manager.editing = false; + self.data_link_manager.editing_handle = None; + self.data_link_manager.status.clear(); + Task::none() + } + Message::DataLinkField(field) => self.on_data_link_field(field), + Message::DataLinkBrowse => self.on_data_link_browse(), + Message::DataLinkBrowseResult(path) => self.on_data_link_browse_result(path), + Message::DataLinkSave => self.on_data_link_save(), + Message::DataLinkDelete => self.on_data_link_delete(), + Message::DataLinkInsert => self.on_data_link_insert(), + Message::DataLinkClose => self.on_data_link_close(), + Message::DataExtractionOpen => { + self.open_data_extraction(); + Task::none() + } + Message::DataExtractionField(field) => self.on_data_extraction_field(field), + Message::DataExtractionBack => self.on_data_extraction_back(), + Message::DataExtractionNext => self.on_data_extraction_next(), + Message::DataExtractionBrowseSettings => self.on_data_extraction_browse_settings(), + Message::DataExtractionBrowseSettingsResult(path) => { + self.on_data_extraction_browse_settings_result(path) + } + Message::DataExtractionAddDrawings => self.on_data_extraction_add_drawings(), + Message::DataExtractionAddDrawingsResult(paths) => { + self.on_data_extraction_add_drawings_result(paths) + } + Message::DataExtractionAddFolder => self.on_data_extraction_add_folder(), + Message::DataExtractionAddFolderResult(path) => { + self.on_data_extraction_add_folder_result(path) + } + Message::DataExtractionClearSources => { + self.data_extraction.source_files.clear(); + Task::none() + } + Message::DataExtractionBrowseOutput => self.on_data_extraction_browse_output(), + Message::DataExtractionBrowseOutputResult(path) => { + self.on_data_extraction_browse_output_result(path) + } + Message::DataExtractionFinish => self.on_data_extraction_finish(), + Message::DataExtractionSave(csv) => { let csv_clone = csv.clone(); Task::perform( diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index dee88a495..f336c2979 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -31,6 +31,9 @@ impl OpenCADStudio { Some(K::LayoutManager) => crate::tr!("modal", "layout-manager"), Some(K::ScaleManager) => crate::tr!("modal", "scale-manager"), Some(K::AnnoObjectScale) => crate::tr!("modal", "annotation-object-scale"), + Some(K::InsertTable) => crate::t!("Insert Table").into_owned(), + Some(K::DataLinkManager) => crate::t!("Data Link Manager").into_owned(), + Some(K::DataExtraction) => crate::t!("Data Extraction Wizard").into_owned(), Some(K::Plotstyle) => crate::tr!("modal", "plot-style-editor"), Some(K::TextStyle) => crate::tr!("modal", "text-style-manager"), Some(K::MlStyle) => crate::tr!("modal", "multiline-style-manager"), @@ -581,6 +584,24 @@ impl OpenCADStudio { }, ) } + super::super::ModalKind::InsertTable => sized_flow( + ex, + 620, + 650, + |flow| crate::ui::window::annotation_data::table_insert_view(&self.table_insert, flow), + ), + super::super::ModalKind::DataLinkManager => sized_flow( + ex, + 760, + 560, + |flow| crate::ui::window::annotation_data::data_link_view(&self.data_link_manager, flow), + ), + super::super::ModalKind::DataExtraction => sized_flow( + ex, + 780, + 570, + |flow| crate::ui::window::annotation_data::data_extraction_view(&self.data_extraction, flow), + ), super::super::ModalKind::Plotstyle => sized_flow( ex, 780, diff --git a/src/command.rs b/src/command.rs index b806cce34..52b3f4f84 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1384,6 +1384,7 @@ pub enum CmdResult { CommitManyAndEditText { entities: Vec, edit_index: usize, + open_editor: bool, }, /// Create a block definition from existing entities and insert one reference. CreateBlock { diff --git a/src/modules/annotate/data_link.rs b/src/modules/annotate/data_link.rs index 6669a04f9..a01a591c1 100644 --- a/src/modules/annotate/data_link.rs +++ b/src/modules/annotate/data_link.rs @@ -22,6 +22,7 @@ pub fn tool() -> ToolDef { pub struct DataLinkPlaceCommand { table: Table, plane: WorkingPlane, + command_name: &'static str, } impl DataLinkPlaceCommand { @@ -30,6 +31,43 @@ impl DataLinkPlaceCommand { Self { table, plane: WorkingPlane::default(), + command_name: "DATALINK", + } + } + + /// Place a table that references a data-link object already stored in the + /// drawing. Every populated cell is locked and points at the same link so + /// update and save/reopen keep one stable relationship. + pub fn existing(mut table: Table, link: acadrust::Handle) -> Self { + use acadrust::entities::table::CellStateFlags; + let rows = table.row_count() as i32; + let columns = table.column_count() as i32; + for row in &mut table.rows { + for cell in &mut row.cells { + cell.has_linked_data = true; + cell.data_link_handle = Some(link); + cell.data_link_rows = rows; + cell.data_link_columns = columns; + cell.state.insert( + CellStateFlags::LINKED + | CellStateFlags::CONTENT_LOCKED + | CellStateFlags::FORMAT_LOCKED, + ); + } + } + Self { + table, + plane: WorkingPlane::default(), + command_name: "DATALINK", + } + } + + /// Place a prepared, unlinked table (used by the extraction wizard). + pub fn unlinked(table: Table) -> Self { + Self { + table, + plane: WorkingPlane::default(), + command_name: "DATAEXTRACTION", } } @@ -105,11 +143,15 @@ impl CadCommand for DataLinkPlaceCommand { } fn name(&self) -> &'static str { - "DATALINK" + self.command_name } fn prompt(&self) -> String { - t!("DATALINK Specify insertion point:").into_owned() + if self.command_name == "DATALINK" { + t!("DATALINK Specify insertion point:").into_owned() + } else { + t!("DATAEXTRACTION Specify insertion point:").into_owned() + } } fn on_point(&mut self, point: DVec3) -> CmdResult { diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index 1456682f8..02584c94b 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -35,6 +35,8 @@ pub fn tool() -> ToolDef { enum Step { Points, Format, + Annotation, + AnnotationOptions, } pub struct LeaderCommand { @@ -50,6 +52,7 @@ pub struct LeaderCommand { gap: f64, arrow_size: f64, annotative: bool, + annotation_lines: Vec, } impl LeaderCommand { @@ -76,10 +79,11 @@ impl LeaderCommand { gap: defaults.gap, arrow_size: defaults.arrow_size, annotative: defaults.annotative, + annotation_lines: Vec::new(), } } - fn finish(&self) -> CmdResult { + fn finish(&self, annotation: Option<&str>, open_editor: bool) -> CmdResult { if self.verts.len() < 2 { return CmdResult::Cancel; } @@ -134,8 +138,13 @@ impl LeaderCommand { // when another representation becomes active. let mtext_height = displayed_height; + if annotation.is_none() { + leader.creation_type = LeaderCreationType::NoAnnotation; + return CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Leader(leader))); + } + let mtext = build_mtext( - "", + annotation.unwrap_or_default(), anchor, mtext_height, attach, @@ -150,6 +159,7 @@ impl LeaderCommand { self.plane.place_entity(EntityType::MText(mtext)), ], edit_index: 1, + open_editor, } } } @@ -168,6 +178,16 @@ impl CadCommand for LeaderCommand { return t!("LEADER Enter leader formatting option [Spline/Straight/Arrow/None] :") .into_owned(); } + if self.step == Step::AnnotationOptions { + return t!("LEADER Enter an annotation option [None/Mtext]:").into_owned(); + } + if self.step == Step::Annotation { + return if self.annotation_lines.is_empty() { + t!("LEADER Enter first line of annotation text or :").into_owned() + } else { + t!("LEADER Enter next line of annotation text:").into_owned() + }; + } match self.verts.len() { 0 => t!("LEADER Specify leader start point:").into_owned(), 1 => t!("LEADER Specify next point:").into_owned(), @@ -185,6 +205,12 @@ impl CadCommand for LeaderCommand { CmdOption::new("None", "NONE"), ]; } + if self.step == Step::AnnotationOptions { + return vec![ + CmdOption::new("None", "NONE"), + CmdOption::new("Mtext", "MTEXT"), + ]; + } if self.verts.len() >= 2 { vec![ CmdOption::new("Annotation", "ANNOTATION"), @@ -197,8 +223,10 @@ impl CadCommand for LeaderCommand { } fn input_kind(&self) -> InputKind { - if self.step == Step::Format { + if matches!(self.step, Step::Format | Step::AnnotationOptions) { InputKind::SingleToken + } else if self.step == Step::Annotation { + InputKind::FreeText } else { InputKind::Point } @@ -210,6 +238,17 @@ impl CadCommand for LeaderCommand { fn on_text_input(&mut self, text: &str) -> Option { let keyword = text.trim().to_ascii_uppercase(); + if self.step == Step::Annotation { + self.annotation_lines.push(text.to_string()); + return Some(CmdResult::NeedPoint); + } + if self.step == Step::AnnotationOptions { + return match keyword.as_str() { + "N" | "NONE" => Some(self.finish(None, false)), + "M" | "MTEXT" => Some(self.finish(Some(""), true)), + _ => None, + }; + } if self.step == Step::Format { match keyword.as_str() { "S" | "SPLINE" => self.path_type = LeaderPathType::Spline, @@ -225,7 +264,10 @@ impl CadCommand for LeaderCommand { return None; } match keyword.as_str() { - "A" | "ANNOTATION" => Some(self.finish()), + "A" | "ANNOTATION" => { + self.step = Step::Annotation; + Some(CmdResult::NeedPoint) + } "F" | "FORMAT" => { self.step = Step::Format; Some(CmdResult::NeedPoint) @@ -250,8 +292,19 @@ impl CadCommand for LeaderCommand { if self.step == Step::Format { self.step = Step::Points; CmdResult::NeedPoint + } else if self.step == Step::Annotation { + if self.annotation_lines.is_empty() { + self.step = Step::AnnotationOptions; + CmdResult::NeedPoint + } else { + let text = self.annotation_lines.join("\n"); + self.finish(Some(&text), false) + } + } else if self.step == Step::AnnotationOptions { + CmdResult::NeedPoint } else { - self.finish() + self.step = Step::Annotation; + CmdResult::NeedPoint } } diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index 664532093..0746c0a89 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -4,6 +4,7 @@ // data rows. The command remembers its previous settings and supports either a // fixed insertion point or a two-corner sizing window. +use acadrust::entities::table::{CellStyle, CellStylePropertyFlags}; use acadrust::entities::TableBuilder; use acadrust::types::Vector3; use acadrust::EntityType; @@ -101,9 +102,47 @@ pub struct TableCommand { preview_scale: f64, title_row: bool, header_row: bool, + row_style_overrides: Option<[CellStyle; 3]>, + preview_enabled: bool, plane: WorkingPlane, } +fn cell_style_from_row(style: &acadrust::objects::RowCellStyle) -> CellStyle { + let mut result = CellStyle::new(); + result.property_flags = CellStylePropertyFlags::DATA_TYPE + | CellStylePropertyFlags::DATA_FORMAT + | CellStylePropertyFlags::ALIGNMENT + | CellStylePropertyFlags::CONTENT_COLOR + | CellStylePropertyFlags::TEXT_STYLE + | CellStylePropertyFlags::TEXT_HEIGHT + | CellStylePropertyFlags::BACKGROUND_COLOR; + result.content_color = style.text_color; + result.text_style_handle = style.text_style_handle; + result.text_style_name = style.text_style_name.clone(); + result.text_height = style.text_height; + result.alignment = style.alignment as i32; + result.background_color = style.fill_color; + result.fill_enabled = style.fill_enabled; + result.value_data_type = style.data_type; + result.value_unit_type = style.unit_type; + result.value_format = style.format_string.clone(); + result +} + +fn selected_row_style( + table_style: &acadrust::objects::TableStyle, + name: &str, +) -> CellStyle { + let style = if name.eq_ignore_ascii_case("Title") { + &table_style.title_row_style + } else if name.eq_ignore_ascii_case("Header") { + &table_style.header_row_style + } else { + &table_style.data_row_style + }; + cell_style_from_row(style) +} + impl TableCommand { pub fn new() -> Self { Self { @@ -114,6 +153,8 @@ impl TableCommand { preview_scale: 1.0, title_row: true, header_row: true, + row_style_overrides: None, + preview_enabled: true, plane: WorkingPlane::default(), } } @@ -141,10 +182,60 @@ impl TableCommand { }, title_row: !style.title_suppressed, header_row: !style.header_suppressed, + row_style_overrides: None, + preview_enabled: true, plane: WorkingPlane::default(), } } + /// Build the insertion command from the values accepted by the Insert + /// Table dialog. The dialog owns validation, so the command starts at the + /// point/window placement step instead of asking for the same values again. + pub fn configured( + style: Option<(acadrust::Handle, &acadrust::objects::TableStyle)>, + annotation_multiplier: f64, + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + use_window: bool, + preview_enabled: bool, + row_style_names: [&str; 3], + ) -> Self { + let mut command = style.map_or_else(Self::new, |(handle, style)| { + Self::with_style(handle, style, annotation_multiplier) + }); + let insertion_mode = if use_window { + InsertionMode::Window + } else { + InsertionMode::Point + }; + if let Ok(mut saved) = saved_defaults().lock() { + *saved = TableDefaults { + columns, + data_rows, + column_width, + row_height, + insertion_mode, + }; + } + command.step = if use_window { + Step::WindowFirst { columns, data_rows } + } else { + Step::Insertion { + columns, + data_rows, + column_width, + row_height, + } + }; + command.preview_enabled = preview_enabled; + command.row_style_overrides = style.map(|(_, table_style)| { + row_style_names.map(|name| selected_row_style(table_style, name)) + }); + command + } + fn defaults(&self) -> TableDefaults { let mut defaults = saved_defaults().lock().map(|value| *value).unwrap_or_default(); if (defaults.column_width - DEFAULT_COL_WIDTH).abs() < 1.0e-9 { @@ -175,6 +266,11 @@ impl TableCommand { .column_width(column_width) .build(); table.table_style_handle = self.style_handle; + if let Some(styles) = &self.row_style_overrides { + for (row_index, row) in table.rows.iter_mut().enumerate() { + row.style = Some(styles[row_index.min(2)].clone()); + } + } self.plane.place_entity(EntityType::Table(table)) } @@ -447,6 +543,9 @@ impl CadCommand for TableCommand { } fn on_mouse_move(&mut self, point: DVec3) -> Option { + if !self.preview_enabled { + return None; + } match self.step { Step::Insertion { columns, diff --git a/src/ui/window/annotation_data.rs b/src/ui/window/annotation_data.rs new file mode 100644 index 000000000..80dd42563 --- /dev/null +++ b/src/ui/window/annotation_data.rs @@ -0,0 +1,626 @@ +//! Dialogs used by the Annotate tab's table and drawing-data tools. +//! +//! The working state lives outside the drawing until the user presses OK or +//! Finish. That keeps Cancel lossless and lets the wizard validate one page +//! before advancing to the next. + +use std::collections::BTreeSet; +use std::fmt; + +use acadrust::types::Handle; +use iced::widget::{button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space}; +use iced::{Border, Element, Fill, Length, Theme}; + +use crate::app::Message; +use crate::t; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TableSource { + #[default] + Empty, + DataLink, + DataExtraction, +} + +impl fmt::Display for TableSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Empty => t!("Empty table").into_owned(), + Self::DataLink => t!("Data link").into_owned(), + Self::DataExtraction => t!("Data extraction").into_owned(), + }; + f.write_str(&label) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TableInsertion { + #[default] + Point, + Window, +} + +impl fmt::Display for TableInsertion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Point => t!("Specify insertion point").into_owned(), + Self::Window => t!("Specify window").into_owned(), + }; + f.write_str(&label) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataLinkChoice { + pub handle: Handle, + pub name: String, + pub path: String, + pub valid: bool, +} + +impl fmt::Display for DataLinkChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.valid { + write!(f, "🔗 {}", self.name) + } else { + write!(f, "⚠ {}", self.name) + } + } +} + +#[derive(Debug, Clone)] +pub struct TableInsertState { + pub styles: Vec, + pub style: String, + pub source: TableSource, + pub links: Vec, + pub link: Option, + pub insertion: TableInsertion, + pub columns: String, + pub data_rows: String, + pub column_width: String, + pub row_height: String, + pub preview: bool, + pub first_row_style: String, + pub second_row_style: String, + pub other_row_style: String, + pub error: String, +} + +impl Default for TableInsertState { + fn default() -> Self { + Self { + styles: vec!["Standard".into()], + style: "Standard".into(), + source: TableSource::Empty, + links: Vec::new(), + link: None, + insertion: TableInsertion::Point, + columns: "3".into(), + data_rows: "4".into(), + column_width: "2.0".into(), + row_height: "0.5".into(), + preview: true, + first_row_style: "Title".into(), + second_row_style: "Header".into(), + other_row_style: "Data".into(), + error: String::new(), + } + } +} + +#[derive(Debug, Clone)] +pub enum TableInsertField { + Style(String), + Source(TableSource), + Link(DataLinkChoice), + Insertion(TableInsertion), + Columns(String), + DataRows(String), + ColumnWidth(String), + RowHeight(String), + Preview(bool), + FirstRowStyle(String), + SecondRowStyle(String), + OtherRowStyle(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LinkPathType { + #[default] + Full, + Relative, + FileName, +} + +impl fmt::Display for LinkPathType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Full => t!("Full path").into_owned(), + Self::Relative => t!("Relative path").into_owned(), + Self::FileName => t!("No path").into_owned(), + }; + f.write_str(&label) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LinkRange { + #[default] + EntireSheet, + NamedRange, + CellRange, +} + +impl fmt::Display for LinkRange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::EntireSheet => t!("Entire sheet").into_owned(), + Self::NamedRange => t!("Named range").into_owned(), + Self::CellRange => t!("Cell range").into_owned(), + }; + f.write_str(&label) + } +} + +#[derive(Debug, Clone)] +pub struct DataLinkManagerState { + pub links: Vec, + pub selected: Option, + pub editing: bool, + pub editing_handle: Option, + pub name: String, + pub path: String, + pub path_type: LinkPathType, + pub range_kind: LinkRange, + pub range: String, + pub sheets: Vec, + pub sheet: String, + pub named_ranges: Vec, + pub allow_write: bool, + pub use_source_formatting: bool, + pub update_source_formatting: bool, + pub insert_table: bool, + pub more_options: bool, + pub preview: Vec>, + pub status: String, +} + +impl Default for DataLinkManagerState { + fn default() -> Self { + Self { + links: Vec::new(), + selected: None, + editing: false, + editing_handle: None, + name: String::new(), + path: String::new(), + path_type: LinkPathType::Full, + range_kind: LinkRange::EntireSheet, + range: String::new(), + sheets: Vec::new(), + sheet: String::new(), + named_ranges: Vec::new(), + allow_write: false, + use_source_formatting: true, + update_source_formatting: true, + insert_table: false, + more_options: false, + preview: Vec::new(), + status: String::new(), + } + } +} + +#[derive(Debug, Clone)] +pub enum DataLinkField { + Name(String), + Path(String), + PathType(LinkPathType), + RangeKind(LinkRange), + Range(String), + Sheet(String), + AllowWrite(bool), + UseSourceFormatting(bool), + UpdateSourceFormatting(bool), + InsertTable(bool), + MoreOptions(bool), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ExtractionPage { + #[default] + Begin, + Source, + Objects, + Properties, + Refine, + Output, + TableStyle, + Finish, +} + +impl ExtractionPage { + pub const fn number(self) -> usize { + match self { + Self::Begin => 1, + Self::Source => 2, + Self::Objects => 3, + Self::Properties => 4, + Self::Refine => 5, + Self::Output => 6, + Self::TableStyle => 7, + Self::Finish => 8, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ExtractionBegin { + #[default] + New, + Template, + Edit, +} + +impl fmt::Display for ExtractionBegin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::New => t!("Create a new data extraction").into_owned(), + Self::Template => t!("Use a previous extraction as a template").into_owned(), + Self::Edit => t!("Edit an existing data extraction").into_owned(), + }; + f.write_str(&label) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ExtractionSource { + #[default] + CurrentDrawing, + CurrentSelection, + DrawingsAndFolders, +} + +impl fmt::Display for ExtractionSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::CurrentDrawing => t!("Current drawing").into_owned(), + Self::CurrentSelection => t!("Selected objects in current drawing").into_owned(), + Self::DrawingsAndFolders => t!("Drawings / folders").into_owned(), + }; + f.write_str(&label) + } +} + +#[derive(Debug, Clone)] +pub struct ExtractionObject { + pub name: String, + pub checked: bool, + pub count: usize, +} + +#[derive(Debug, Clone)] +pub struct ExtractionProperty { + pub key: String, + pub name: String, + pub category: String, + pub checked: bool, +} + +#[derive(Debug, Clone)] +pub struct DataExtractionState { + pub page: ExtractionPage, + pub begin: ExtractionBegin, + pub settings_path: String, + pub source: ExtractionSource, + pub include_current: bool, + pub include_subfolders: bool, + pub source_files: Vec, + pub selection_handles: Vec, + pub objects: Vec, + pub properties: Vec, + pub categories: BTreeSet, + pub combine_identical: bool, + pub show_count: bool, + pub show_name: bool, + pub preview: Vec>, + pub output_table: bool, + pub output_file: bool, + pub output_path: String, + pub table_styles: Vec, + pub table_style: String, + pub table_title: String, + pub error: String, +} + +impl Default for DataExtractionState { + fn default() -> Self { + Self { + page: ExtractionPage::Begin, + begin: ExtractionBegin::New, + settings_path: String::new(), + source: ExtractionSource::CurrentDrawing, + include_current: true, + include_subfolders: true, + source_files: Vec::new(), + selection_handles: Vec::new(), + objects: Vec::new(), + properties: vec![ + ExtractionProperty { key: "type".into(), name: "Type".into(), category: "General".into(), checked: true }, + ExtractionProperty { key: "handle".into(), name: "Handle".into(), category: "General".into(), checked: true }, + ExtractionProperty { key: "layer".into(), name: "Layer".into(), category: "General".into(), checked: true }, + ExtractionProperty { key: "color".into(), name: "Color".into(), category: "General".into(), checked: true }, + ExtractionProperty { key: "linetype".into(), name: "Linetype".into(), category: "General".into(), checked: true }, + ExtractionProperty { key: "details".into(), name: "Details".into(), category: "Geometry".into(), checked: true }, + ], + categories: ["General".to_string(), "Geometry".to_string()].into_iter().collect(), + combine_identical: false, + show_count: true, + show_name: true, + preview: Vec::new(), + output_table: true, + output_file: false, + output_path: String::new(), + table_styles: vec!["Standard".into()], + table_style: "Standard".into(), + table_title: "Data Extraction".into(), + error: String::new(), + } + } +} + +#[derive(Debug, Clone)] +pub enum DataExtractionField { + Begin(ExtractionBegin), + SettingsPath(String), + Source(ExtractionSource), + IncludeCurrent(bool), + IncludeSubfolders(bool), + Object(usize, bool), + Property(usize, bool), + Category(String, bool), + CombineIdentical(bool), + ShowCount(bool), + ShowName(bool), + OutputTable(bool), + OutputFile(bool), + OutputPath(String), + TableStyle(String), + TableTitle(String), +} + +fn muted(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.62)), + } +} + +fn group<'a>(title: impl Into, body: Element<'a, Message>) -> Element<'a, Message> { + container(column![text(title.into()).size(11).style(muted), body].spacing(7)) + .padding(9) + .width(Fill) + .style(|theme: &Theme| container::Style { + border: Border { width: 1.0, radius: 4.0.into(), color: theme.palette().background.strong.color }, + ..Default::default() + }) + .into() +} + +fn labeled_input<'a>(label: impl Into, value: &'a str, msg: fn(String) -> Message) -> Element<'a, Message> { + row![text(label.into()).size(11).style(muted).width(118), text_input("", value).on_input(msg).padding([4, 7]).size(12).width(Fill)] + .spacing(8).align_y(iced::Center).into() +} + +fn cell_style_picker<'a>( + label: impl Into, + selected: &'a str, + msg: fn(String) -> Message, +) -> Element<'a, Message> { + row![ + text(label.into()).size(11).style(muted).width(118), + pick_list( + Some(selected.to_string()), + vec!["Title".to_string(), "Header".to_string(), "Data".to_string()], + |value| value.clone(), + ) + .on_select(msg) + .padding([3, 6]) + .text_size(12) + .width(Fill), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +fn radio_button<'a>(selected: bool, label: impl Into, msg: Message) -> Element<'a, Message> { + let mark = if selected { "◉" } else { "○" }; + button(row![text(mark).size(13), text(label.into()).size(12)].spacing(6)) + .on_press(msg).padding([4, 7]).style(button::text).into() +} + +fn preview_grid<'a>(rows: &'a [Vec]) -> Element<'a, Message> { + if rows.is_empty() { + return text(t!("No preview available.")).size(11).style(muted).into(); + } + let lines = rows.iter().take(8).map(|row| row.join(" | ")).collect::>().join("\n"); + container(text(lines).size(11)).padding(6).width(Fill).into() +} + +pub fn table_insert_view(state: &TableInsertState, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + let style = pick_list(Some(state.style.clone()), state.styles.clone(), |value| value.clone()).on_select(Message::TableInsertStyle).padding([3, 6]).text_size(12).width(Fill); + let source = column![ + radio_button(state.source == TableSource::Empty, t!("Start from empty table"), Message::TableInsertField(TableInsertField::Source(TableSource::Empty))), + radio_button(state.source == TableSource::DataLink, t!("Start from data link"), Message::TableInsertField(TableInsertField::Source(TableSource::DataLink))), + radio_button(state.source == TableSource::DataExtraction, t!("Start from data extraction"), Message::TableInsertField(TableInsertField::Source(TableSource::DataExtraction))), + ].spacing(1); + let link_picker: Element<'_, Message> = if state.source == TableSource::DataLink { + row![ + text(t!("Data link")).size(11).style(muted).width(118), + pick_list(state.link.clone(), state.links.clone(), |value| value.to_string()).on_select(|value| Message::TableInsertField(TableInsertField::Link(value))).padding([3, 6]).text_size(12).width(Fill), + button(text(t!("Manage…")).size(11)).on_press(Message::DataLinkManagerOpen).padding([4, 8]), + ].spacing(8).align_y(iced::Center).into() + } else { Space::new().height(0).into() }; + let insertion = column![ + radio_button(state.insertion == TableInsertion::Point, t!("Specify insertion point"), Message::TableInsertField(TableInsertField::Insertion(TableInsertion::Point))), + radio_button(state.insertion == TableInsertion::Window, t!("Specify window"), Message::TableInsertField(TableInsertField::Insertion(TableInsertion::Window))), + ]; + let settings = column![ + labeled_input(t!("Columns"), &state.columns, |v| Message::TableInsertField(TableInsertField::Columns(v))), + labeled_input(t!("Column width"), &state.column_width, |v| Message::TableInsertField(TableInsertField::ColumnWidth(v))), + labeled_input(t!("Data rows"), &state.data_rows, |v| Message::TableInsertField(TableInsertField::DataRows(v))), + labeled_input(t!("Row height"), &state.row_height, |v| Message::TableInsertField(TableInsertField::RowHeight(v))), + ].spacing(5); + let cell_styles = column![ + cell_style_picker(t!("First row"), &state.first_row_style, |v| Message::TableInsertField(TableInsertField::FirstRowStyle(v))), + cell_style_picker(t!("Second row"), &state.second_row_style, |v| Message::TableInsertField(TableInsertField::SecondRowStyle(v))), + cell_style_picker(t!("All other rows"), &state.other_row_style, |v| Message::TableInsertField(TableInsertField::OtherRowStyle(v))), + ].spacing(5); + column![ + group(t!("Table style"), row![style].into()), + group(t!("Insert options"), column![source, link_picker].spacing(4).into()), + row![group(t!("Insertion behavior"), insertion.into()), group(t!("Column & row settings"), settings.into())].spacing(8), + group(t!("Set cell styles"), cell_styles.into()), + checkbox(state.preview).label(t!("Preview")).on_toggle(|v| Message::TableInsertField(TableInsertField::Preview(v))).size(14), + text(&state.error).size(11), + row![Space::new().width(Fill), button(text(t!("Cancel")).size(12)).on_press(Message::CloseModal).padding([5, 12]), button(text(t!("OK")).size(12)).on_press(Message::TableInsertApply).style(button::primary).padding([5, 12])].spacing(7), + ].spacing(8).padding(10).width(sizing.width).height(sizing.height).into() +} + +pub fn data_link_view(state: &DataLinkManagerState, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + let mut links = column![text(t!("Spreadsheet Links")).size(11).style(muted)].spacing(2); + links = links.push(button(text(t!("Create a New Data Link")).size(12)).on_press(Message::DataLinkNew).padding([5, 7]).style(button::text).width(Fill)); + for link in &state.links { + let selected = state.selected.as_ref().is_some_and(|v| v.handle == link.handle); + let label = if selected { format!("● {link}") } else { format!(" {link}") }; + links = links.push(button(text(label).size(12)).on_press(Message::DataLinkSelect(link.handle)).padding([5, 7]).style(button::text).width(Fill)); + } + let tree = container(scrollable(links).height(Fill)).padding(6).width(Length::Fixed(220.0)).height(Fill); + let right: Element<'_, Message> = if state.editing { + let mut editor = column![ + labeled_input(t!("Data link name"), &state.name, |v| Message::DataLinkField(DataLinkField::Name(v))), + row![text(t!("File")).size(11).style(muted).width(118), text_input("", &state.path).on_input(|v| Message::DataLinkField(DataLinkField::Path(v))).padding([4, 7]).size(12).width(Fill), button(text("…")).on_press(Message::DataLinkBrowse).padding([4, 9])].spacing(6).align_y(iced::Center), + row![text(t!("Path type")).size(11).style(muted).width(118), pick_list(Some(state.path_type), vec![LinkPathType::Full, LinkPathType::Relative, LinkPathType::FileName], |value| value.to_string()).on_select(|value| Message::DataLinkField(DataLinkField::PathType(value))).width(Fill).padding([3, 6])].spacing(8), + row![text(t!("Link option")).size(11).style(muted).width(118), pick_list(Some(state.range_kind), vec![LinkRange::EntireSheet, LinkRange::NamedRange, LinkRange::CellRange], |value| value.to_string()).on_select(|value| Message::DataLinkField(DataLinkField::RangeKind(value))).width(Fill).padding([3, 6])].spacing(8), + ].spacing(6); + if !state.sheets.is_empty() && state.range_kind != LinkRange::NamedRange { + editor = editor.push(row![ + text(t!("Sheet")).size(11).style(muted).width(118), + pick_list(Some(state.sheet.clone()), state.sheets.clone(), |value| value.clone()) + .on_select(|value| Message::DataLinkField(DataLinkField::Sheet(value))) + .width(Fill) + .padding([3, 6]), + ].spacing(8)); + } + if state.range_kind == LinkRange::NamedRange && !state.named_ranges.is_empty() { + editor = editor.push(row![ + text(t!("Named range")).size(11).style(muted).width(118), + pick_list(Some(state.range.clone()), state.named_ranges.clone(), |value| value.clone()) + .on_select(|value| Message::DataLinkField(DataLinkField::Range(value))) + .width(Fill) + .padding([3, 6]), + ].spacing(8)); + } else if state.range_kind != LinkRange::EntireSheet { + editor = editor.push(labeled_input(t!("Range"), &state.range, |v| Message::DataLinkField(DataLinkField::Range(v)))); + } + editor = editor.push(checkbox(state.more_options).label(t!("More options")).on_toggle(|v| Message::DataLinkField(DataLinkField::MoreOptions(v))).size(14)); + if state.more_options { + editor = editor + .push(checkbox(state.allow_write).label(t!("Allow writing to source file")).on_toggle(|v| Message::DataLinkField(DataLinkField::AllowWrite(v))).size(14)) + .push(checkbox(state.use_source_formatting).label(t!("Use source formatting")).on_toggle(|v| Message::DataLinkField(DataLinkField::UseSourceFormatting(v))).size(14)) + .push(checkbox(state.update_source_formatting).label(t!("Keep table updated to source formatting")).on_toggle(|v| Message::DataLinkField(DataLinkField::UpdateSourceFormatting(v))).size(14)); + } + editor = editor.push(checkbox(state.insert_table).label(t!("Insert table after saving link")).on_toggle(|v| Message::DataLinkField(DataLinkField::InsertTable(v))).size(14)); + group(t!("Link settings"), editor.into()) + } else if let Some(link) = &state.selected { + column![ + group(t!("Details"), column![text(format!("{}: {}", t!("Name"), link.name)).size(11), text(format!("{}: {}", t!("File"), link.path)).size(11), text(if link.valid { t!("Status: Linked") } else { t!("Status: Source not found") }).size(11)].spacing(5).into()), + group(t!("Preview"), preview_grid(&state.preview)), + ].spacing(8).into() + } else { + group(t!("Details"), text(t!("Select a data link or create a new one.")).size(11).style(muted).into()) + }; + let actions = if state.editing { + row![Space::new().width(Fill), button(text(t!("Cancel")).size(12)).on_press(Message::DataLinkEditCancel).padding([5, 11]), button(text(t!("OK")).size(12)).on_press(Message::DataLinkSave).style(button::primary).padding([5, 11])].spacing(7) + } else { + let enabled = state.selected.is_some(); + let edit = button(text(t!("Edit")).size(12)).padding([5, 11]); + let delete = button(text(t!("Delete")).size(12)).padding([5, 11]); + let insert = button(text(t!("Insert Table")).size(12)).padding([5, 11]); + row![if enabled { edit.on_press(Message::DataLinkEdit) } else { edit }, if enabled { delete.on_press(Message::DataLinkDelete) } else { delete }, Space::new().width(Fill), if enabled { insert.on_press(Message::DataLinkInsert) } else { insert }, button(text(t!("Close")).size(12)).on_press(Message::DataLinkClose).padding([5, 11])].spacing(7) + }; + column![row![tree, container(right).width(Fill).height(Fill)].spacing(8).height(Fill), text(&state.status).size(11), actions] + .spacing(7).padding(10).width(sizing.width).height(sizing.height).into() +} + +fn wizard_nav(state: &DataExtractionState) -> iced::widget::Row<'_, Message> { + let back = button(text(t!("Back")).size(12)).padding([5, 12]); + let next = button(text(if state.page == ExtractionPage::Finish { t!("Finish") } else { t!("Next") }).size(12)).padding([5, 12]).style(button::primary); + row![Space::new().width(Fill), if state.page == ExtractionPage::Begin { back } else { back.on_press(Message::DataExtractionBack) }, button(text(t!("Cancel")).size(12)).on_press(Message::CloseModal).padding([5, 12]), if state.error.is_empty() { next.on_press(if state.page == ExtractionPage::Finish { Message::DataExtractionFinish } else { Message::DataExtractionNext }) } else { next }].spacing(7) +} + +pub fn data_extraction_view(state: &DataExtractionState, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + let body: Element<'_, Message> = match state.page { + ExtractionPage::Begin => group(t!("Begin"), column![ + radio_button(state.begin == ExtractionBegin::New, t!("Create a new data extraction"), Message::DataExtractionField(DataExtractionField::Begin(ExtractionBegin::New))), + radio_button(state.begin == ExtractionBegin::Template, t!("Use a previous extraction as a template"), Message::DataExtractionField(DataExtractionField::Begin(ExtractionBegin::Template))), + radio_button(state.begin == ExtractionBegin::Edit, t!("Edit an existing data extraction"), Message::DataExtractionField(DataExtractionField::Begin(ExtractionBegin::Edit))), + row![text(t!("Extraction settings")).size(11).style(muted).width(118), text_input("", &state.settings_path).on_input(|v| Message::DataExtractionField(DataExtractionField::SettingsPath(v))).padding([4, 7]).size(12).width(Fill), button(text("…")).on_press(Message::DataExtractionBrowseSettings).padding([4, 9])].spacing(6).align_y(iced::Center), + ].spacing(5).into()), + ExtractionPage::Source => group(t!("Define data source"), column![ + radio_button(state.source == ExtractionSource::CurrentDrawing, t!("Current drawing"), Message::DataExtractionField(DataExtractionField::Source(ExtractionSource::CurrentDrawing))), + radio_button(state.source == ExtractionSource::CurrentSelection, t!("Select objects in the current drawing"), Message::DataExtractionField(DataExtractionField::Source(ExtractionSource::CurrentSelection))), + radio_button(state.source == ExtractionSource::DrawingsAndFolders, t!("Drawings / folders"), Message::DataExtractionField(DataExtractionField::Source(ExtractionSource::DrawingsAndFolders))), + checkbox(state.include_current).label(t!("Include current drawing")).on_toggle(|v| Message::DataExtractionField(DataExtractionField::IncludeCurrent(v))).size(14), + checkbox(state.include_subfolders).label(t!("Include subfolders")).on_toggle(|v| Message::DataExtractionField(DataExtractionField::IncludeSubfolders(v))).size(14), + row![ + button(text(t!("Add drawings…")).size(12)).on_press(Message::DataExtractionAddDrawings).padding([5, 10]), + button(text(t!("Add folder…")).size(12)).on_press(Message::DataExtractionAddFolder).padding([5, 10]), + button(text(t!("Clear")).size(12)).on_press(Message::DataExtractionClearSources).padding([5, 10]), + ].spacing(6), + text(state.source_files.join("\n")).size(10), + ].spacing(6).into()), + ExtractionPage::Objects => { + let mut list = column![].spacing(3); + for (i, item) in state.objects.iter().enumerate() { + list = list.push(checkbox(item.checked).label(format!("{} ({})", item.name, item.count)).on_toggle(move |v| Message::DataExtractionField(DataExtractionField::Object(i, v))).size(14)); + } + group(t!("Select objects"), scrollable(list).height(Fill).into()) + } + ExtractionPage::Properties => { + let mut categories = row![].spacing(10); + for category in ["General", "Geometry"] { + let checked = state.categories.contains(category); + let key = category.to_string(); + categories = categories.push(checkbox(checked).label(category).on_toggle(move |v| Message::DataExtractionField(DataExtractionField::Category(key.clone(), v))).size(14)); + } + let mut list = column![categories].spacing(4); + for (i, item) in state.properties.iter().enumerate().filter(|(_, p)| state.categories.contains(&p.category)) { + list = list.push(checkbox(item.checked).label(format!("{} · {}", item.name, item.category)).on_toggle(move |v| Message::DataExtractionField(DataExtractionField::Property(i, v))).size(14)); + } + group(t!("Select properties"), scrollable(list).height(Fill).into()) + } + ExtractionPage::Refine => group(t!("Refine data"), column![ + row![checkbox(state.combine_identical).label(t!("Combine identical rows")).on_toggle(|v| Message::DataExtractionField(DataExtractionField::CombineIdentical(v))).size(14), checkbox(state.show_count).label(t!("Show count column")).on_toggle(|v| Message::DataExtractionField(DataExtractionField::ShowCount(v))).size(14), checkbox(state.show_name).label(t!("Show name column")).on_toggle(|v| Message::DataExtractionField(DataExtractionField::ShowName(v))).size(14)].spacing(12), + scrollable(preview_grid(&state.preview)).height(Fill), + ].spacing(8).into()), + ExtractionPage::Output => group(t!("Choose output"), column![ + checkbox(state.output_table).label(t!("Insert data extraction table into drawing")).on_toggle(|v| Message::DataExtractionField(DataExtractionField::OutputTable(v))).size(14), + checkbox(state.output_file).label(t!("Output data to external file")).on_toggle(|v| Message::DataExtractionField(DataExtractionField::OutputFile(v))).size(14), + row![text(t!("Output file")).size(11).style(muted).width(118), text_input("", &state.output_path).on_input(|v| Message::DataExtractionField(DataExtractionField::OutputPath(v))).padding([4, 7]).size(12).width(Fill), button(text("…")).on_press(Message::DataExtractionBrowseOutput).padding([4, 9])].spacing(6).align_y(iced::Center), + ].spacing(7).into()), + ExtractionPage::TableStyle => group(t!("Table style"), column![ + row![text(t!("Style")).size(11).style(muted).width(118), pick_list(Some(state.table_style.clone()), state.table_styles.clone(), |value| value.clone()).on_select(|value| Message::DataExtractionField(DataExtractionField::TableStyle(value))).padding([3, 6]).width(Fill)].spacing(8), + labeled_input(t!("Title"), &state.table_title, |v| Message::DataExtractionField(DataExtractionField::TableTitle(v))), + preview_grid(&state.preview), + ].spacing(7).into()), + ExtractionPage::Finish => group(t!("Finish"), column![text(t!("The selected drawing data is ready to be extracted.")).size(12), text(format!("{}: {}", t!("Rows"), state.preview.len().saturating_sub(1))).size(11), text(format!("{}: {}{}", t!("Output"), if state.output_table { t!("Drawing table") } else { "".into() }, if state.output_file { format!(" {}", t!("External file")) } else { String::new() })).size(11)].spacing(7).into()), + }; + column![ + text(format!("{} {}/8", t!("Data Extraction"), state.page.number())).size(13), + container(body).height(Fill).width(Fill), + text(&state.error).size(11), + wizard_nav(state), + ].spacing(8).padding(10).width(sizing.width).height(sizing.height).into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index c43b80b7c..32b0f5d3a 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -16,6 +16,7 @@ pub mod open_progress; pub mod recovery; pub mod options; pub mod attribute_editor; +pub mod annotation_data; pub mod alias_editor; pub mod find_replace; pub mod named_parameters; From 666b28ffe3b1aecfbe3bbe969da51b9047cab8df Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:07:51 +0300 Subject: [PATCH 3/3] fix(annotate): reject invalid linked ranges --- src/app/annotation_data.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/annotation_data.rs b/src/app/annotation_data.rs index 8c4126f6e..0b415aa41 100644 --- a/src/app/annotation_data.rs +++ b/src/app/annotation_data.rs @@ -144,14 +144,14 @@ fn cell_ref(text: &str) -> Option<(usize, usize)> { Some((row, column)) } -fn crop_range(rows: Vec>, range: &str) -> Vec> { +fn crop_range(rows: Vec>, range: &str) -> Result>, String> { let (start, end) = range.split_once(':').unwrap_or((range, range)); let (Some((r0, c0)), Some((r1, c1))) = (cell_ref(start), cell_ref(end)) else { - return rows; + return Err(crate::t!("Enter a valid cell range such as A1:D10.").into_owned()); }; let (r0, r1) = (r0.min(r1), r0.max(r1)); let (c0, c1) = (c0.min(c1), c0.max(c1)); - rows.into_iter() + Ok(rows.into_iter() .skip(r0) .take(r1 - r0 + 1) .map(|row| { @@ -159,7 +159,7 @@ fn crop_range(rows: Vec>, range: &str) -> Vec> { .map(|column| row.get(column).cloned().unwrap_or_default()) .collect() }) - .collect() + .collect()) } fn workbook_metadata(path: &Path) -> Result<(Vec, Vec), String> { @@ -252,7 +252,7 @@ pub(crate) fn read_tabular_file( ) }; let rows = match cell_range.as_deref().filter(|value| !value.trim().is_empty()) { - Some(value) => crop_range(rows, value), + Some(value) => crop_range(rows, value)?, None => rows, }; if rows.is_empty() || rows.iter().all(Vec::is_empty) {