diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 03332480c..f4c81c0a9 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2447,6 +2447,8 @@ properties = .associative = Associative .at-least = At least .attachment-type = Attachment type + .attachment = Attachment + .arrowhead-size = Arrowhead Size .attenuation = Attenuation .attribute-definition = Attribute Definition .attributedefinition = AttributeDefinition diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 32ac287e0..51d0db147 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2387,6 +2387,8 @@ properties = .associative = İlişkisel .at-least = En az .attachment-type = Bağlantı türü + .attachment = Bağlantı + .arrowhead-size = Ok Ucu Boyutu .attenuation = Zayıflama .attribute-definition = Öznitelik Tanımı .attributedefinition = Öznitelik Tanımı diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 31e83bc45..47769b7e3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1561,6 +1561,48 @@ impl OpenCADStudio { self.command_line.push_info(&prompt); } } + CmdResult::AlignMLeaders { + mut handles, + from, + to, + } => { + handles.retain(|handle| { + !self.tabs[i].scene.is_layer_locked(*handle) + && matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(acadrust::EntityType::MultiLeader(_)) + ) + }); + if !handles.is_empty() { + self.push_undo_snapshot(i, "MLEADERALIGN"); + if apply_mleader_align(&mut self.tabs[i].scene, &handles, from, to) { + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::t!("MLEADERALIGN Leaders aligned.").as_ref(), + ); + } + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + } + CmdResult::CollectMLeaders { handles, point } => { + let compatible = compatible_mleader_collect_handles(&self.tabs[i].scene, &handles); + if compatible.len() >= 2 { + self.push_undo_snapshot(i, "MLEADERCOLLECT"); + if apply_mleader_collect(&mut self.tabs[i].scene, &compatible, point) { + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::t!("MLEADERCOLLECT Leaders collected.").as_ref(), + ); + } + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + } CmdResult::CommitSolid { entity, solid, @@ -2020,30 +2062,6 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; return Task::none(); } - if layer.starts_with("__MLEADERALIGN__") { - if let Some(encoded) = layer.strip_prefix("__MLEADERALIGN__") { - apply_mleader_align(&mut self.tabs[i].scene, encoded); - } - self.push_undo_snapshot(i, "MLEADERALIGN"); - self.command_line - .push_output(crate::t!("MLEADERALIGN Leaders aligned.").as_ref()); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - return Task::none(); - } - if layer.starts_with("__MLEADERCOLLECT__") { - if let Some(encoded) = layer.strip_prefix("__MLEADERCOLLECT__") { - apply_mleader_collect(&mut self.tabs[i].scene, encoded); - } - self.push_undo_snapshot(i, "MLEADERCOLLECT"); - self.command_line - .push_output(crate::t!("MLEADERCOLLECT Leaders collected.").as_ref()); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - return Task::none(); - } } } @@ -4598,92 +4616,154 @@ fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) { } } -// ── MLEADERALIGN helper ─────────────────────────────────────────────────────── +fn apply_mleader_align( + scene: &mut crate::scene::Scene, + handles: &[acadrust::Handle], + from: glam::DVec3, + to: glam::DVec3, +) -> bool { + use cadkernel::geom2d::{closest_point, Curve, Vec2, XLine}; -/// Parse `h1,h2,...;fx,fz;tx,tz` and align multileader content points along the direction. -fn apply_mleader_align(scene: &mut crate::scene::Scene, encoded: &str) { - // Format: "

,

,...;,;," - let parts: Vec<&str> = encoded.splitn(3, ';').collect(); - if parts.len() < 3 { - return; - } - let handles: Vec = parts[0] - .split(',') - .filter_map(|s| s.parse::().ok().map(acadrust::Handle::from)) - .collect(); - let from_parts: Vec = parts[1].split(',').filter_map(|s| s.parse().ok()).collect(); - let to_parts: Vec = parts[2].split(',').filter_map(|s| s.parse().ok()).collect(); - if from_parts.len() < 2 || to_parts.len() < 2 || handles.is_empty() { - return; - } - - let fx = from_parts[0]; - let fz = from_parts[1]; - let tx = to_parts[0]; - let tz = to_parts[1]; - let dx = tx - fx; - let dz = tz - fz; - let len = (dx * dx + dz * dz).sqrt(); - if len < 1e-9 { - return; - } - - // Project each multileader's content point onto the alignment line, then - // snap it to the line (preserve perpendicular offset from line is discarded; - // align along direction through `from`). - for h in handles { - if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(h) { - let cp = &mut ml.context.content_base_point; - // Project onto line from_pt + t * dir: keep t component, set perpendicular = 0 - let rel_x = cp.x - fx; - let rel_z = cp.z - fz; - let t = (rel_x * (dx / len) + rel_z * (dz / len)) / len; - let t = t.clamp(0.0, 1.0); - cp.x = fx + t * dx; - cp.z = fz + t * dz; + let Some(direction) = Vec2::new(to.x - from.x, to.y - from.y).normalize() else { + return false; + }; + let line = Curve::XLine(XLine { + base: [from.x, from.y], + direction: direction.into(), + }); + let mut changed = Vec::new(); + for &handle in handles { + if let Some(acadrust::EntityType::MultiLeader(ml)) = + scene.document.get_entity_mut(handle) + { + let old = ml.context.content_base_point; + let projected = closest_point(&line, [old.x, old.y]).point; + let new_x = projected[0]; + let new_y = projected[1]; + let shift_x = new_x - old.x; + let shift_y = new_y - old.y; + if shift_x.abs() <= 1.0e-12 && shift_y.abs() <= 1.0e-12 { + continue; + } + ml.context.content_base_point.x = new_x; + ml.context.content_base_point.y = new_y; + ml.context.text_location.x += shift_x; + ml.context.text_location.y += shift_y; + ml.context.block_content_location.x += shift_x; + ml.context.block_content_location.y += shift_y; + for root in &mut ml.context.leader_roots { + root.connection_point.x += shift_x; + root.connection_point.y += shift_y; + } + changed.push((handle, crate::scene::ChangeKind::Modified)); } } + if !changed.is_empty() { + scene.bump_entities(&changed); + } + !changed.is_empty() } -// ── MLEADERCOLLECT helper ───────────────────────────────────────────────────── - -/// Parse `h1,h2,...;px,pz` — merge all selected multileaders into the first one at position. -fn apply_mleader_collect(scene: &mut crate::scene::Scene, encoded: &str) { - let parts: Vec<&str> = encoded.splitn(2, ';').collect(); - if parts.len() < 2 { - return; - } - let handles: Vec = parts[0] - .split(',') - .filter_map(|s| s.parse::().ok().map(acadrust::Handle::from)) - .collect(); - let pos_parts: Vec = parts[1].split(',').filter_map(|s| s.parse().ok()).collect(); - if handles.len() < 2 || pos_parts.len() < 2 { - return; - } - - let px = pos_parts[0]; - let pz = pos_parts[1]; - - // Collect all leader roots from the secondary multileaders. - let mut extra_roots: Vec = Vec::new(); - for &h in &handles[1..] { - if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) { - extra_roots.extend(ml.context.leader_roots.iter().cloned()); +fn compatible_mleader_collect_handles( + scene: &crate::scene::Scene, + handles: &[acadrust::Handle], +) -> Vec { + let Some((base_block, base_style)) = handles.iter().find_map(|handle| { + if scene.is_layer_locked(*handle) { + return None; + } + let acadrust::EntityType::MultiLeader(leader) = + scene.document.get_entity(*handle)? + else { + return None; + }; + (leader.content_type == acadrust::entities::LeaderContentType::Block) + .then_some((leader.block_content_handle, leader.style_handle)) + }) else { + return Vec::new(); + }; + let mut compatible = Vec::new(); + for handle in handles.iter().copied() { + if compatible.contains(&handle) { + continue; + } + if !scene.is_layer_locked(handle) + && matches!( + scene.document.get_entity(handle), + Some(acadrust::EntityType::MultiLeader(leader)) + if leader.content_type == acadrust::entities::LeaderContentType::Block + && leader.block_content_handle == base_block + && leader.style_handle == base_style + ) + { + compatible.push(handle); } } + compatible +} + +fn apply_mleader_collect( + scene: &mut crate::scene::Scene, + handles: &[acadrust::Handle], + point: glam::DVec3, +) -> bool { + if handles.len() < 2 { + return false; + } + let px = point.x; + let py = point.y; + let Some(acadrust::EntityType::MultiLeader(base)) = + scene.document.get_entity(handles[0]) + else { + return false; + }; + let base_block = base.block_content_handle; + let base_style = base.style_handle; + + let mut extra_roots: Vec = Vec::new(); + let mut merged_handles = Vec::new(); + for &h in &handles[1..] { + if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) { + if ml.content_type == acadrust::entities::LeaderContentType::Block + && ml.block_content_handle == base_block + && ml.style_handle == base_style + { + let shift_x = px - ml.context.content_base_point.x; + let shift_y = py - ml.context.content_base_point.y; + extra_roots.extend(ml.context.leader_roots.iter().cloned().map(|mut root| { + root.connection_point.x += shift_x; + root.connection_point.y += shift_y; + root + })); + merged_handles.push(h); + } + } + } + if merged_handles.is_empty() { + return false; + } - // Add collected roots to the first multileader and move its content point. if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handles[0]) { + let shift_x = px - ml.context.content_base_point.x; + let shift_y = py - ml.context.content_base_point.y; + for root in &mut ml.context.leader_roots { + root.connection_point.x += shift_x; + root.connection_point.y += shift_y; + } ml.context.content_base_point.x = px; - ml.context.content_base_point.z = pz; + ml.context.content_base_point.y = py; + ml.context.block_content_location.x = px; + ml.context.block_content_location.y = py; + ml.context.text_location.x = px; + ml.context.text_location.y = py; for root in extra_roots { ml.context.leader_roots.push(root); } } - // Erase the secondary multileaders. - scene.erase_entities(&handles[1..]); + scene.erase_entities(&merged_handles); + scene.bump_entities(&[(handles[0], crate::scene::ChangeKind::Modified)]); + true } /// MATCHPROP special properties (#281): copy every STYLE-affecting field from diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index a786ba99c..0561d3e96 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -604,9 +604,31 @@ impl OpenCADStudio { _ => None, }); let multiplier = self.tabs[i].scene.creation_annotation_multiplier(); + let block_sources = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|block| !block.name.is_empty() && !block.name.starts_with('*')) + .map(|block| (block.name.clone(), block.handle)) + .collect(); + let layers = self.tabs[i] + .scene + .document + .layers + .iter() + .map(|layer| layer.name.clone()) + .collect(); + let text_styles = self.tabs[i] + .scene + .document + .text_styles + .iter() + .map(|style| (style.name.clone(), style.handle)) + .collect(); let new_cmd = style.map_or_else(MLeaderCommand::new, |style| { MLeaderCommand::with_style(style, multiplier) - }); + }).with_drawing_resources(block_sources, layers, text_styles); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 5e3b7bc4c..a2cd8ba9a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2402,6 +2402,11 @@ pub enum Message { PropColorChanged(AcadColor), /// User selected a lineweight from the Properties pick_list. PropLwChanged(LineWeight), + /// User selected an object-specific lineweight override. + PropFieldLwChanged { + field: &'static str, + value: LineWeight, + }, /// User selected a linetype from the linetype pick_list. PropLinetypeChanged(String), /// User toggled a boolean property (e.g. Invisible). diff --git a/src/app/properties.rs b/src/app/properties.rs index ccc980942..a98e40813 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -845,6 +845,12 @@ impl OpenCADStudio { .map(|b| b.name.clone()), ) .collect(); + let block_names: Vec = doc + .block_records + .iter() + .map(|block| block.name.clone()) + .filter(|name| !name.is_empty() && !name.starts_with('*')) + .collect(); let tstyle_names = text_style_names.clone(); // Currently selected names. let cur_style = ml @@ -878,6 +884,15 @@ impl OpenCADStudio { doc.line_types.iter().find(|l| l.handle == h).map(|l| l.name.clone()) }) .unwrap_or_else(|| "ByBlock".to_string()); + let cur_block = ml + .block_content_handle + .and_then(|handle| { + doc.block_records + .iter() + .find(|block| block.handle == handle) + .map(|block| block.name.clone()) + }) + .unwrap_or_else(|| "(none)".to_string()); let mut set_choice = |field: &str, selected: String, options: Vec| { for section in sections.iter_mut() { @@ -896,6 +911,9 @@ impl OpenCADStudio { set_choice("text_style_handle", cur_tstyle, tstyle_names); set_choice("arrowhead_handle", cur_arrow, arrow_names); set_choice("line_type_handle", cur_ltype, ltype_names); + if !block_names.is_empty() { + set_choice("block_content_handle", cur_block, block_names); + } } // Inject viewport-only properties that require doc access. @@ -1394,6 +1412,21 @@ impl OpenCADStudio { // MultiLeader: max points + segment-angle constraints // are MLeaderStyle settings, not stored on the entity. acadrust::EntityType::MultiLeader(ml) => { + if ml + .text_style_handle + .and_then(|handle| { + doc.text_styles.iter().find(|style| style.handle == handle) + }) + .is_some_and(|style| style.has_fixed_height()) + { + set_row_value( + &mut sections, + "text_height", + PropValue::ReadOnly( + crate::entities::common::format_length(ml.text_height), + ), + ); + } if let Some(sh) = ml.style_handle { if let Some((mx, a1, a2)) = doc.objects.iter().find_map(|(h, o)| match o { @@ -2562,9 +2595,21 @@ impl OpenCADStudio { | acadrust::EntityType::Polyline2D(_) | acadrust::EntityType::Polyline3D(_) ); - let layer = &self.tabs[i].active_layer; - if layer != "0" || entity.as_entity().layer().is_empty() { - entity.as_entity_mut().set_layer(layer.clone()); + let explicit_mleader_layer = match &entity { + acadrust::EntityType::MultiLeader(leader) => leader + .common + .layer + .strip_prefix("__MLEADER_LAYER__") + .map(str::to_owned), + _ => None, + }; + if let Some(layer) = explicit_mleader_layer { + entity.as_entity_mut().set_layer(layer); + } else { + let layer = &self.tabs[i].active_layer; + if layer != "0" || entity.as_entity().layer().is_empty() { + entity.as_entity_mut().set_layer(layer.clone()); + } } // INSUNITS: when inserting a block whose BlockRecord.units differ @@ -2806,8 +2851,13 @@ fn make_sections_read_only( acadrust::types::Color::Index(index) => index.to_string(), acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), }, - PropValue::ColorVaries | PropValue::LwVaries => VARIES_LABEL.to_string(), - PropValue::LwChoice(lineweight) => { + PropValue::ColorVaries + | PropValue::LwVaries + | PropValue::FieldLwVaries { .. } => VARIES_LABEL.to_string(), + PropValue::LwChoice(lineweight) + | PropValue::FieldLwChoice { + value: lineweight, .. + } => { ui::properties::LwItem(*lineweight).to_string() } PropValue::BoolToggle { value, .. } => { @@ -2965,6 +3015,17 @@ fn merge_prop_value( (PropValue::LwChoice(_), PropValue::LwChoice(_)) | (PropValue::LwVaries, _) | (_, PropValue::LwVaries) => PropValue::LwVaries, + ( + PropValue::FieldLwChoice { field, .. }, + PropValue::FieldLwChoice { + field: other_field, + .. + }, + ) if field == other_field => PropValue::FieldLwVaries { field }, + (PropValue::FieldLwVaries { field }, _) + | (_, PropValue::FieldLwVaries { field }) => { + PropValue::FieldLwVaries { field } + } (PropValue::LinetypeChoice(_), PropValue::LinetypeChoice(_)) => { PropValue::LinetypeChoice(VARIES_LABEL.into()) } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 81b3007b0..49f50fd08 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2053,6 +2053,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { | "text_style_handle" | "arrowhead_handle" | "line_type_handle" + | "block_content_handle" ) { // Resolve a picked name back to the handle the MLEADER // stores. The style/text-style rows keep their existing @@ -2098,6 +2099,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .map(|l| l.handle) } } + "block_content_handle" => doc + .block_records + .iter() + .find(|block| block.name == value) + .map(|block| block.handle), _ => None, }; for &handle in &handles { @@ -2121,10 +2127,52 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { "text_style_handle" => { if let Some(h) = resolved { ml.text_style_handle = Some(h); + ml.context.text_style_handle = Some(h); + ml.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::TEXT_STYLE, + ); + } + } + "arrowhead_handle" => { + ml.arrowhead_handle = resolved; + for root in &mut ml.context.leader_roots { + for line in &mut root.lines { + line.arrowhead_handle = resolved; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::ARROWHEAD, + ); + } + } + ml.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::ARROWHEAD, + ); + } + "line_type_handle" => { + ml.line_type_handle = resolved; + for root in &mut ml.context.leader_roots { + for line in &mut root.lines { + line.line_type_handle = resolved; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::LINE_TYPE, + ); + } + } + ml.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::LEADER_LINE_TYPE, + ); + } + "block_content_handle" => { + if let Some(block_handle) = resolved { + ml.block_content_handle = Some(block_handle); + ml.context.block_content_handle = Some(block_handle); + ml.context.has_block_contents = true; + ml.context.block_content_location = + ml.context.content_base_point; + ml.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT, + ); } } - "arrowhead_handle" => ml.arrowhead_handle = resolved, - "line_type_handle" => ml.line_type_handle = resolved, _ => {} } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index cb501dc7d..469b81411 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4657,6 +4657,32 @@ impl OpenCADStudio { Task::none() } + Message::PropFieldLwChanged { field, value } => { + let i = self.active_tab; + let handles = self.property_target_handles(i); + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(acadrust::EntityType::MultiLeader(leader)) = + app.tabs[i].scene.document.get_entity_mut(handle) + { + if field == "line_weight" { + leader.line_weight = value; + leader.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::LEADER_LINE_WEIGHT, + ); + for root in &mut leader.context.leader_roots { + for line in &mut root.lines { + line.line_weight = value; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::LINE_WEIGHT, + ); + } + } + } + } + }); + Task::none() + } + Message::PropLinetypeChanged(lt) => { let i = self.active_tab; let handles = self.property_target_handles(i); @@ -5114,6 +5140,54 @@ impl OpenCADStudio { } return Task::none(); } + if matches!( + field.as_str(), + "line_color" | "text_color" | "block_content_color" + | "background_fill_color" + ) { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(acadrust::EntityType::MultiLeader(leader)) = + app.tabs[i].scene.document.get_entity_mut(handle) + { + match field.as_str() { + "line_color" => { + leader.line_color = color; + leader.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::LINE_COLOR, + ); + for root in &mut leader.context.leader_roots { + for line in &mut root.lines { + line.line_color = color; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::LINE_COLOR, + ); + } + } + } + "text_color" => { + leader.text_color = color; + leader.context.text_color = color; + leader.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::TEXT_COLOR, + ); + } + "block_content_color" => { + leader.block_content_color = color; + leader.context.block_content_color = color; + leader.property_override_flags.insert( + acadrust::entities::MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_COLOR, + ); + } + "background_fill_color" => { + leader.context.background_fill_color = color; + } + _ => {} + } + } + }); + self.tabs[i].properties.open_color_field = None; + return Task::none(); + } if matches!( field.as_str(), "tbl_cell_content_color" | "tbl_cell_background_color" diff --git a/src/command.rs b/src/command.rs index d58bc4822..491672afc 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1260,6 +1260,17 @@ pub enum CmdResult { /// Persist the quick-dimension extension-origin priority while keeping the /// active command at its current prompt (0 = endpoints, 1 = intersections). SetQuickDimensionSnapPriority(u8), + /// Align multileader content points along a picked infinite line. + AlignMLeaders { + handles: Vec, + from: DVec3, + to: DVec3, + }, + /// Merge compatible block multileaders at a picked content point. + CollectMLeaders { + handles: Vec, + point: DVec3, + }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded /// rendering). Ends the command. diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 4084a7805..49b4a8127 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -1,7 +1,9 @@ use acadrust::entities::{ - FlowDirectionType, LeaderContentType, LineSpacingStyle, MultiLeader, MultiLeaderPathType, - TextAlignmentType, TextAttachmentDirectionType, TextAttachmentType, + BlockContentConnectionType, FlowDirectionType, LeaderContentType, LineSpacingStyle, + MultiLeader, MultiLeaderPathType, MultiLeaderPropertyOverrideFlags, TextAlignmentType, + TextAttachmentDirectionType, TextAttachmentType, }; +use cadkernel::geom2d::Transform; use crate::entities::text_support::{ layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle, @@ -904,9 +906,7 @@ fn properties(ml: &MultiLeader) -> Vec { // ── Leaders ────────────────────────────────────────────────────────── // Landing rows are folded in here: the standalone "Leader Structure" group // is a style-dialog tab, not a palette group. - let leaders = PropSection { - title: t!("Leaders").into_owned(), - props: vec![ + let mut leader_props = vec![ choice( t!("Leader type").as_ref(), "path_type", @@ -923,20 +923,34 @@ fn properties(ml: &MultiLeader) -> Vec { Property { label: t!("Leader lineweight").into_owned(), field: "line_weight", - value: PropValue::LwChoice(ml.line_weight), + value: PropValue::FieldLwChoice { + field: "line_weight", + value: ml.line_weight, + }, }, // Arrowhead block name resolved by the panel builder (default "Closed filled"). ro(t!("Arrowhead").as_ref(), "arrowhead_handle", "Closed filled"), - edit(t!("Arrowhead size").as_ref(), "arrowhead_size", ml.arrowhead_size), + edit(t!("Arrowhead Size").as_ref(), "arrowhead_size", ml.arrowhead_size), bool_toggle(t!("Horizontal Landing").as_ref(), "enable_dogleg", ml.enable_dogleg), num_row( t!("Landing distance").as_ref(), "landing_distance", - ml.dogleg_length, + if ml.enable_dogleg { ml.dogleg_length } else { 0.0 }, ml.enable_dogleg, ), - bool_toggle(t!("Leader extension").as_ref(), "enable_landing", ml.enable_landing), - ], + ]; + // The extension is an MText-only landing option. It is absent for block + // content and whenever the horizontal landing itself is disabled. + if ml.content_type == LeaderContentType::MText && ml.enable_dogleg { + leader_props.push(bool_toggle( + t!("Leader extension").as_ref(), + "extend_leader_to_text", + ml.extend_leader_to_text, + )); + } + let leaders = PropSection { + title: t!("Leaders").into_owned(), + props: leader_props, }; // ── Text (shown only for MText content) ────────────────────────────── @@ -988,6 +1002,18 @@ fn properties(ml: &MultiLeader) -> Vec { attach_dir_str(&ml.text_attachment_direction), &["Horizontal", "Vertical"], ), + edit( + t!("Landing gap").as_ref(), + "landing_gap", + ctx.landing_gap / ctx.scale_factor.max(1.0e-12), + ), + bool_toggle(t!("Text frame").as_ref(), "text_frame", ml.text_frame), + ], + }; + let mut text = text; + let attachment_insert = text.props.len().saturating_sub(2); + let attachment_rows = match ml.text_attachment_direction { + TextAttachmentDirectionType::Horizontal => vec![ choice( t!("Left Attachment").as_ref(), "text_left_attachment", @@ -1000,10 +1026,23 @@ fn properties(ml: &MultiLeader) -> Vec { attachment_str(&ml.text_right_attachment), &ATTACH_LABELS, ), - edit(t!("Landing gap").as_ref(), "landing_gap", ctx.landing_gap), - bool_toggle(t!("Text frame").as_ref(), "text_frame", ml.text_frame), + ], + TextAttachmentDirectionType::Vertical => vec![ + choice( + t!("Top Attachment").as_ref(), + "text_top_attachment", + attachment_str(&ml.text_top_attachment), + &ATTACH_LABELS, + ), + choice( + t!("Bottom Attachment").as_ref(), + "text_bottom_attachment", + attachment_str(&ml.text_bottom_attachment), + &ATTACH_LABELS, + ), ], }; + text.props.splice(attachment_insert..attachment_insert, attachment_rows); // ── Block ──────────────────────────────────────────────────────────── let block = PropSection { @@ -1014,29 +1053,16 @@ fn properties(ml: &MultiLeader) -> Vec { "block_content_handle", hexh(ml.block_content_handle), ), - ro( - t!("Block connection").as_ref(), + choice( + t!("Attachment").as_ref(), "block_connection_type", - format!("{:?}", ml.block_connection_type), - ), - ro( - t!("Block color").as_ref(), - "block_content_color", - format!("{:?}", ml.block_content_color), - ), - ro( - t!("Block scale").as_ref(), - "block_scale", - format!( - "{:.3} × {:.3} × {:.3}", - ml.block_scale.x, ml.block_scale.y, ml.block_scale.z - ), - ), - edit( - t!("Block rotation").as_ref(), - "block_rotation", - ml.block_rotation.to_degrees(), + match ml.block_connection_type { + BlockContentConnectionType::BasePoint => "Insertion point", + BlockContentConnectionType::BlockExtents => "Extents", + }, + &["Extents", "Insertion point"], ), + edit(t!("Scale").as_ref(), "block_scale", ml.block_scale.x), ], }; @@ -1067,7 +1093,9 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "text_height" => { if let Some(v) = f64(value) { ml.text_height = v; - ml.context.text_height = v; + ml.context.text_height = v * ml.context.scale_factor.max(1.0e-12); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_HEIGHT); } } "text_x" => { @@ -1093,7 +1121,9 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { !ml.text_frame } else { value == "true" - } + }; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_FRAME); } "path_type" => { ml.path_type = match value { @@ -1101,19 +1131,40 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "None" => MultiLeaderPathType::Invisible, _ => MultiLeaderPathType::StraightLineSegments, }; + for root in &mut ml.context.leader_roots { + for line in &mut root.lines { + line.path_type = ml.path_type; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::PATH_TYPE, + ); + } + } + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::PATH_TYPE); } "enable_landing" => { ml.enable_landing = if value == "toggle" { !ml.enable_landing } else { value == "true" - } + }; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::ENABLE_LANDING); } "enable_dogleg" => { ml.enable_dogleg = if value == "toggle" { !ml.enable_dogleg } else { value == "true" + }; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::ENABLE_DOGLEG); + } + "extend_leader_to_text" => { + ml.extend_leader_to_text = if value == "toggle" { + !ml.extend_leader_to_text + } else { + value == "true" } } "enable_annotation_scale" => { @@ -1130,39 +1181,130 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { value == "true" } } + "background_mask_fill_on" => { + ml.context.background_mask_fill_on = if value == "toggle" { + !ml.context.background_mask_fill_on + } else { + value == "true" + } + } + "background_scale_factor" => { + if let Some(v) = f64(value) { + if v > 0.0 { + ml.context.background_scale_factor = v; + } + } + } + "background_transparency" => { + if let Ok(v) = value.trim().parse::() { + ml.context.background_transparency = v.clamp(0, 90); + } + } "dogleg_length" => { if let Some(v) = f64(value) { ml.dogleg_length = v; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::LANDING_DISTANCE); } } "arrowhead_size" => { if let Some(v) = f64(value) { ml.arrowhead_size = v; + ml.context.arrowhead_size = v * ml.context.scale_factor.max(1.0e-12); + for root in &mut ml.context.leader_roots { + for line in &mut root.lines { + line.arrowhead_size = v; + line.override_flags.insert( + acadrust::entities::LeaderLinePropertyOverrideFlags::ARROWHEAD_SIZE, + ); + } + } + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::ARROWHEAD_SIZE); } } "scale_factor" => { if let Some(v) = f64(value) { - ml.scale_factor = v; + if v > 0.0 { + let old = ml.context.scale_factor.max(1.0e-12); + let ratio = v / old; + ml.scale_factor = v; + ml.context.scale_factor = v; + ml.context.arrowhead_size *= ratio; + ml.context.text_height *= ratio; + ml.context.landing_gap *= ratio; + ml.block_scale.x *= ratio; + ml.block_scale.y *= ratio; + ml.block_scale.z *= ratio; + ml.context.block_content_scale = ml.block_scale; + if let Some(root) = ml.context.leader_roots.first() { + let anchor = root.connection_point; + for point in [ + &mut ml.context.content_base_point, + &mut ml.context.text_location, + &mut ml.context.block_content_location, + ] { + point.x = anchor.x + (point.x - anchor.x) * ratio; + point.y = anchor.y + (point.y - anchor.y) * ratio; + point.z = anchor.z + (point.z - anchor.z) * ratio; + } + } + for root in &mut ml.context.leader_roots { + root.landing_distance *= ratio; + } + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::SCALE_FACTOR); + } } } "landing_distance" => { if let Some(v) = f64(value) { ml.dogleg_length = v; - if let Some(root) = ml.context.leader_roots.first_mut() { - root.landing_distance = v; + let display = v * ml.context.scale_factor.max(1.0e-12); + for root in &mut ml.context.leader_roots { + root.landing_distance = display; } + place_content_after_landing(ml); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::LANDING_DISTANCE); } } "landing_gap" => { if let Some(v) = f64(value) { - ml.context.landing_gap = v; + ml.context.landing_gap = v * ml.context.scale_factor.max(1.0e-12); + place_content_after_landing(ml); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::LANDING_GAP); } } "block_rotation" => { if let Some(v) = f64(value) { ml.block_rotation = v.to_radians(); + ml.context.block_rotation = ml.block_rotation; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_ROTATION); } } + "block_scale" => { + if let Some(v) = f64(value) { + if v > 0.0 { + ml.block_scale = acadrust::types::Vector3::new(v, v, v); + ml.context.block_content_scale = ml.block_scale; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_SCALE); + } + } + } + "block_connection_type" => { + ml.block_connection_type = if value == "Insertion point" { + BlockContentConnectionType::BasePoint + } else { + BlockContentConnectionType::BlockExtents + }; + ml.context.block_connection_type = ml.block_connection_type; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_CONNECTION); + } "conn_x" => { if let (Some(v), Some(root)) = (f64(value), ml.context.leader_roots.first_mut()) { root.connection_point.x = v; @@ -1181,18 +1323,26 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "text_left_attachment" => { ml.text_left_attachment = parse_attachment(value); ml.context.text_left_attachment = parse_attachment(value); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_LEFT_ATTACHMENT); } "text_right_attachment" => { ml.text_right_attachment = parse_attachment(value); ml.context.text_right_attachment = parse_attachment(value); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_RIGHT_ATTACHMENT); } "text_top_attachment" => { ml.text_top_attachment = parse_attachment(value); ml.context.text_top_attachment = parse_attachment(value); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_TOP_ATTACHMENT); } "text_bottom_attachment" => { ml.text_bottom_attachment = parse_attachment(value); ml.context.text_bottom_attachment = parse_attachment(value); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_BOTTOM_ATTACHMENT); } "text_alignment" => { ml.text_alignment = match value { @@ -1205,6 +1355,8 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "Right" => TextAlignmentType::Right, _ => TextAlignmentType::Left, }; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_ALIGNMENT); } "text_flow_direction" => { ml.context.text_flow_direction = match value { @@ -1218,6 +1370,16 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "Vertical" => TextAttachmentDirectionType::Vertical, _ => TextAttachmentDirectionType::Horizontal, }; + if ml.text_attachment_direction == TextAttachmentDirectionType::Vertical { + ml.enable_dogleg = false; + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::ENABLE_DOGLEG); + } + for root in &mut ml.context.leader_roots { + root.text_attachment_direction = ml.text_attachment_direction; + } + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_ATTACHMENT_DIRECTION); } "line_space_style" => { ml.context.line_spacing_style = match value { @@ -1233,6 +1395,13 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "text_rotation" => { if let Some(v) = f64(value) { ml.context.text_rotation = v.to_radians(); + ml.context.text_direction = acadrust::types::Vector3::new( + ml.context.text_rotation.cos(), + ml.context.text_rotation.sin(), + 0.0, + ); + ml.property_override_flags + .insert(MultiLeaderPropertyOverrideFlags::TEXT_ANGLE); } } "line_spacing" => { @@ -1254,6 +1423,27 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { } } +fn place_content_after_landing(ml: &mut MultiLeader) { + let Some(root) = ml.context.leader_roots.first() else { + return; + }; + let length = (root.direction.x * root.direction.x + root.direction.y * root.direction.y).sqrt(); + let (ux, uy) = if length > 1.0e-12 { + (root.direction.x / length, root.direction.y / length) + } else { + (1.0, 0.0) + }; + let distance = root.landing_distance.max(0.0) + ml.context.landing_gap.max(0.0); + let location = acadrust::types::Vector3::new( + root.connection_point.x + ux * distance, + root.connection_point.y + uy * distance, + root.connection_point.z, + ); + ml.context.content_base_point = location; + ml.context.text_location = location; + ml.context.block_content_location = location; +} + fn parse_attachment(s: &str) -> TextAttachmentType { match s { "Top of top line" => TextAttachmentType::TopOfTopLine, @@ -1758,9 +1948,7 @@ impl MultiLeaderTess for MultiLeader { } } - // Vertically-attached leaders (text above/below) have no dogleg: - // the leader meets the text edge directly, so the short horizontal - // dash would be a stray mark AutoCAD never draws. + // Vertically attached leaders meet the text edge directly. let vertical_attach = matches!( ml.text_attachment_direction, acadrust::entities::multileader::TextAttachmentDirectionType::Vertical @@ -1785,11 +1973,7 @@ impl MultiLeaderTess for MultiLeader { // for a rotated leader that is the angled baseline, not world // X. Roots without a usable direction keep the legacy // horizontal toward the text side. - let (ux, uy) = ml - .context - .leader_roots - .first() - .map(|r| (r.direction.x, r.direction.y)) + let (ux, uy) = Some((root.direction.x, root.direction.y)) .filter(|(x, y)| (x * x + y * y).sqrt() > 1e-9) .map(|(x, y)| { let l = (x * x + y * y).sqrt(); @@ -1804,6 +1988,15 @@ impl MultiLeaderTess for MultiLeader { points.push(nan); points.push(cp_f); points.push(landing_end); + if ml.extend_leader_to_text { + let gap = ml.context.landing_gap.max(0.0) + * context_scale_correction as f64; + points.push([ + (text_loc_w.x - ux * gap) as f32, + (text_loc_w.y - uy * gap) as f32, + text_loc_w.z as f32, + ]); + } } } @@ -1856,31 +2049,66 @@ impl MultiLeaderTess for MultiLeader { // synthetic Insert supplies that context to the shared scene graph; // every child then uses its normal tessellator. if ml.content_type == LeaderContentType::Block && ml.context.has_block_contents { - let block_name = match ml.block_content_handle { + let block_record = match ml.block_content_handle { Some(h) if !h.is_null() => document .block_records .iter() .find(|br| br.handle == h) - .map(|br| br.name.clone()), + .cloned(), _ => None, }; - if let Some(block_name) = block_name { + if let Some(block_record) = block_record { + let block_name = block_record.name.clone(); let block_color = if selected { line_color } else { color_or_inherit(&ml.block_content_color, entity_color) }; - let mut synth_ins = - acadrust::entities::Insert::new(block_name, ml.context.block_content_location); + let mut insertion = ml.context.block_content_location; + if ml.block_connection_type == BlockContentConnectionType::BlockExtents { + let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + for entity_handle in &block_record.entity_handles { + if let Some(entity) = document.get_entity(*entity_handle) { + let aabb = crate::scene::convert::tess::entity_aabb(entity); + if aabb != WireModel::UNBOUNDED_AABB { + bounds[0] = bounds[0].min(aabb[0]); + bounds[1] = bounds[1].min(aabb[1]); + bounds[2] = bounds[2].max(aabb[2]); + bounds[3] = bounds[3].max(aabb[3]); + } + } + } + if bounds[0] <= bounds[2] && bounds[1] <= bounds[3] { + let to_block = Transform::rotation(-ml.block_rotation); + let from_left = ml + .context + .leader_roots + .first() + .map_or(true, |root| { + to_block.apply_vector([ + root.direction.x, + root.direction.y, + ])[0] + >= 0.0 + }); + let anchor_x = if from_left { bounds[0] } else { bounds[2] } as f64; + let anchor_y = ((bounds[1] + bounds[3]) * 0.5) as f64; + let local_x = + (anchor_x - block_record.base_point.x) * ml.block_scale.x; + let local_y = + (anchor_y - block_record.base_point.y) * ml.block_scale.y; + let rotated = Transform::rotation(ml.block_rotation) + .apply_vector([local_x, local_y]); + insertion.x -= rotated[0]; + insertion.y -= rotated[1]; + } + } + let mut synth_ins = acadrust::entities::Insert::new(block_name, insertion); synth_ins.set_x_scale(ml.block_scale.x); synth_ins.set_y_scale(ml.block_scale.y); synth_ins.set_z_scale(ml.block_scale.z); synth_ins.rotation = ml.block_rotation; synth_ins.common.layer = ml.common.layer.clone(); - // block_connection_type (BlockExtents vs BasePoint) chooses the - // anchor when *creating* the multileader; at render time the - // file's stored leader endpoints already encode that choice. - let _ = ml.block_connection_type; let depths = rustc_hash::FxHashMap::default(); let graph = crate::scene::render_graph::RenderSceneGraph::new( document, @@ -1956,12 +2184,7 @@ impl MultiLeaderTess for MultiLeader { let local_ins_x = (ins.x) as f32; let z = (ins.z) as f32; - // Rotation: the annotation context is AutoCAD's BAKED display - // state — text_direction / text_rotation already reflect whatever - // the style's angle setting produced at edit time. Re-deriving - // from ml.text_angle_type here (e.g. forcing Horizontal to 0) - // un-rotated leaders whose stored context is angled; the angle - // type only guides recomputation during edits, not display. + // The context stores the resolved display rotation. let td = ctx.text_direction; let mut rot = if td.x.abs() > 1e-9 || td.y.abs() > 1e-9 { (td.y as f32).atan2(td.x as f32) @@ -2013,14 +2236,14 @@ impl MultiLeaderTess for MultiLeader { // anchor within the text block (Left/Center/Right) — honour it // instead of guessing from the leader side. use acadrust::entities::multileader::{ - TextAttachmentDirectionType, TextAttachmentPointType, + TextAttachmentDirectionType, }; // The context's attachment point exists in every DWG version; // the entity-level copy only exists from R2010 on. - let h_anchor: f32 = match ctx.text_attachment_point { - TextAttachmentPointType::Left => 0.0, - TextAttachmentPointType::Center => 0.5, - TextAttachmentPointType::Right => 1.0, + let h_anchor: f32 = match ctx.text_alignment { + TextAlignmentType::Left => 0.0, + TextAlignmentType::Center => 0.5, + TextAlignmentType::Right => 1.0, }; // Pick the vertical-anchor attachment based on text_attachment_direction: // Horizontal — leader attaches left/right; use ml.text_left_attachment @@ -2273,14 +2496,9 @@ impl MultiLeaderTess for MultiLeader { // Text frame / background-fill rectangle in local frame, then rotated to WCS. if ml.text_frame || ctx.background_fill_enabled { - // Frame/fill offset from the glyphs: the context's landing gap - // (AutoCAD spaces the box by exactly that), with the old - // quarter-height heuristic as the no-gap fallback. - let pad = if ctx.landing_gap > 0.0 { - ctx.landing_gap as f32 - } else { - height * 0.25 - }; + // Border offset is a scale factor around the laid-out text box. + let pad = height + * ((ctx.background_scale_factor.max(1.0) as f32 - 1.0) * 0.5); // Box the glyphs that were actually laid out (valid for // vertical flow too); the metric-derived box is only the // no-glyph fallback. @@ -2311,11 +2529,16 @@ impl MultiLeaderTess for MultiLeader { // Background fill — emit two triangles; renders under the text strokes. if ctx.background_fill_enabled { - let fill_color = if selected { + let mut fill_color = if selected { line_color + } else if ctx.background_mask_fill_on { + bg_color } else { color_or_inherit(&ctx.background_fill_color, entity_color) }; + fill_color[3] *= + (1.0 - ctx.background_transparency.clamp(0, 90) as f32 / 100.0) + .clamp(0.1, 1.0); let fill_tris: Vec<[f32; 3]> = vec![ wcs_corners[0], wcs_corners[1], diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 92147a70e..f86fcc48e 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -325,6 +325,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Arrow size (DIMASZ)" => Some(("styles", "arrow-size-dimasz")), "Arrowhead" => Some(("common", "arrowhead")), "Arrowhead (DIMBLK)" => Some(("styles", "arrowhead-dimblk")), + "Arrowhead Size" => Some(("properties", "arrowhead-size")), "Arrowhead block:" => Some(("styles", "arrowhead-block")), "Arrowhead size" => Some(("common", "arrowhead-size")), "Arrowhead size:" => Some(("styles", "arrowhead-size")), @@ -332,6 +333,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Arrows out first" => Some(("styles", "arrows-out-first")), "As displayed" => Some(("common", "as-displayed")), "Associative" => Some(("properties", "associative")), + "Attachment" => Some(("properties", "attachment")), "At first ext" => Some(("styles", "at-first-ext")), "At least" => Some(("properties", "at-least")), "At second ext" => Some(("styles", "at-second-ext")), diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 5526c6d31..1214d45d7 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -1,11 +1,11 @@ // MLEADER: pick the arrow and elbow, then edit the text. -use acadrust::entities::MultiLeader; +use acadrust::entities::{LeaderContentType, MultiLeader, MultiLeaderPathType}; use acadrust::types::Vector3; use acadrust::EntityType; use glam::{DVec3, Mat4, Vec3}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -26,6 +26,47 @@ pub struct MLeaderCommand { plane: WorkingPlane, style: Option, display_scale: f64, + order: CreationOrder, + step: Step, + content_type: LeaderContentType, + path_type: MultiLeaderPathType, + enable_landing: bool, + landing_length: f64, + max_points: usize, + first_angle: Option, + second_angle: Option, + layer: String, + text: String, + picked_entity: Option, + selected_mtext: Option, + block_sources: Vec<(String, acadrust::Handle)>, + block_handle: Option, + layers: Vec, + text_styles: Vec<(String, acadrust::Handle)>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CreationOrder { + ArrowFirst, + LandingFirst, + ContentFirst, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { + PickPoints, + Options, + LeaderType, + Landing, + LandingLength, + ContentType, + MaxPoints, + FirstAngle, + SecondAngle, + Layer, + PreEnterText, + SelectMText, + BlockSource, } impl MLeaderCommand { @@ -35,6 +76,23 @@ impl MLeaderCommand { plane: WorkingPlane::default(), style: None, display_scale: 1.0, + order: CreationOrder::ArrowFirst, + step: Step::PickPoints, + content_type: LeaderContentType::MText, + path_type: MultiLeaderPathType::StraightLineSegments, + enable_landing: true, + landing_length: 2.5, + max_points: 2, + first_angle: None, + second_angle: None, + layer: String::new(), + text: String::new(), + picked_entity: None, + selected_mtext: None, + block_sources: Vec::new(), + block_handle: None, + layers: Vec::new(), + text_styles: Vec::new(), } } @@ -50,10 +108,214 @@ impl MLeaderCommand { Self { verts: Vec::new(), plane: WorkingPlane::default(), - style: Some(style), + style: Some(style.clone()), display_scale, + order: CreationOrder::ArrowFirst, + step: Step::PickPoints, + content_type: (style.content_type as i16).into(), + path_type: (style.path_type as i16).into(), + enable_landing: style.enable_landing, + landing_length: style.landing_distance, + max_points: style.max_leader_points.max(2) as usize, + first_angle: (style.first_segment_angle.abs() > 1.0e-12) + .then_some(style.first_segment_angle), + second_angle: (style.second_segment_angle.abs() > 1.0e-12) + .then_some(style.second_segment_angle), + layer: String::new(), + text: style.default_text.clone(), + picked_entity: None, + selected_mtext: None, + block_sources: Vec::new(), + block_handle: style.block_content_handle, + layers: Vec::new(), + text_styles: Vec::new(), } } + + pub fn with_drawing_resources( + mut self, + block_sources: Vec<(String, acadrust::Handle)>, + layers: Vec, + text_styles: Vec<(String, acadrust::Handle)>, + ) -> Self { + self.block_sources = block_sources; + self.layers = layers; + self.text_styles = text_styles; + self + } + + fn point_limit(&self) -> usize { + if self.order == CreationOrder::ContentFirst { + 2 + } else { + self.max_points.max(2) + } + } + + fn finish(&self) -> CmdResult { + if self.verts.len() < 2 { + return CmdResult::Cancel; + } + let local: Vec = self + .verts + .iter() + .map(|point| self.plane.to_local(*point)) + .collect(); + let (leader_points, content_point) = match self.order { + CreationOrder::ArrowFirst => (local, None), + CreationOrder::LandingFirst => { + let mut points = local; + points.reverse(); + (points, None) + } + CreationOrder::ContentFirst => { + let content = local[0]; + let arrow = local[1]; + let sign = if content.x >= arrow.x { 1.0 } else { -1.0 }; + let elbow = DVec3::new( + content.x - sign * self.landing_length * self.display_scale, + content.y, + content.z, + ); + (vec![arrow, elbow], Some(content)) + } + }; + let mut ml = build_mleader( + &self.text, + &leader_points, + content_point, + Mat4::IDENTITY, + self.style.as_ref(), + self.display_scale, + ); + ml.content_type = self.content_type; + ml.path_type = self.path_type; + ml.enable_landing = self.enable_landing; + ml.enable_dogleg = self.enable_landing; + ml.dogleg_length = self.landing_length; + if self.content_type == LeaderContentType::Block { + ml.block_content_handle = self.block_handle; + } + if !self.layer.trim().is_empty() { + ml.common.layer = format!("__MLEADER_LAYER__{}", self.layer.trim()); + } + for root in &mut ml.context.leader_roots { + root.landing_distance = self.landing_length * self.display_scale; + for line in &mut root.lines { + line.path_type = self.path_type; + } + } + normalize_content_context(&mut ml); + if let Some(source) = &self.selected_mtext { + let scale = self.display_scale.max(1.0e-12); + ml.text_height = source.height / scale; + ml.context.text_height = source.height; + ml.context.text_width = source.rectangle_width; + ml.context.text_rotation = source.rotation; + ml.context.text_direction = source.dwg_x_direction.unwrap_or_else(|| { + Vector3::new(source.rotation.cos(), source.rotation.sin(), 0.0) + }); + ml.context.line_spacing_factor = source.line_spacing_factor; + ml.context.line_spacing_style = source.line_spacing_style; + ml.text_color = source.common.color; + ml.context.text_color = source.common.color; + ml.text_frame = source.background_fill_flags & 0x10 != 0; + ml.context.background_fill_enabled = source.background_fill_flags & 0x01 != 0; + ml.context.background_mask_fill_on = source.background_fill_flags & 0x02 != 0; + ml.context.background_scale_factor = source.background_scale; + ml.context.background_fill_color = source.background_color; + ml.context.background_transparency = source.background_transparency; + ml.context.text_flow_direction = match source.drawing_direction { + acadrust::entities::mtext::DrawingDirection::LeftToRight => { + acadrust::entities::multileader::FlowDirectionType::Horizontal + } + acadrust::entities::mtext::DrawingDirection::TopToBottom => { + acadrust::entities::multileader::FlowDirectionType::Vertical + } + acadrust::entities::mtext::DrawingDirection::ByStyle => { + acadrust::entities::multileader::FlowDirectionType::ByStyle + } + }; + let (attachment, alignment) = match source.attachment_point { + acadrust::entities::mtext::AttachmentPoint::TopCenter + | acadrust::entities::mtext::AttachmentPoint::MiddleCenter + | acadrust::entities::mtext::AttachmentPoint::BottomCenter => { + ( + acadrust::entities::multileader::TextAttachmentPointType::Center, + acadrust::entities::TextAlignmentType::Center, + ) + } + acadrust::entities::mtext::AttachmentPoint::TopRight + | acadrust::entities::mtext::AttachmentPoint::MiddleRight + | acadrust::entities::mtext::AttachmentPoint::BottomRight => { + ( + acadrust::entities::multileader::TextAttachmentPointType::Right, + acadrust::entities::TextAlignmentType::Right, + ) + } + _ => ( + acadrust::entities::multileader::TextAttachmentPointType::Left, + acadrust::entities::TextAlignmentType::Left, + ), + }; + ml.context.text_attachment_point = attachment; + ml.text_alignment = alignment; + ml.context.text_alignment = alignment; + let style_handle = self + .text_styles + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(&source.style)) + .map(|(_, handle)| *handle); + ml.text_style_handle = style_handle; + ml.context.text_style_handle = style_handle; + } + let entity = self.plane.place_entity(EntityType::MultiLeader(ml)); + if self.content_type == LeaderContentType::MText && self.text.is_empty() { + CmdResult::CommitAndEditText(entity) + } else { + CmdResult::CommitAndExit(entity) + } + } + + fn constrained_point(&self, point: DVec3) -> DVec3 { + let Some(previous) = self.verts.last().copied() else { + return point; + }; + let segment_index = self.verts.len() - 1; + let constraint = match (self.order, segment_index) { + (CreationOrder::LandingFirst, 0) => self.second_angle, + (CreationOrder::LandingFirst, 1) => self.first_angle, + (_, 0) => self.first_angle, + (_, 1) => self.second_angle, + _ => None, + }; + let Some(angle) = constraint else { + return point; + }; + let previous_local = self.plane.to_local(previous); + let point_local = self.plane.to_local(point); + let delta = point_local - previous_local; + let length = delta.truncate().length(); + if length <= 1.0e-12 { + return point; + } + let candidates = [angle, -angle, std::f64::consts::PI - angle, angle - std::f64::consts::PI]; + let current = delta.y.atan2(delta.x); + let chosen = candidates + .into_iter() + .min_by(|a, b| { + angle_delta(current, *a) + .partial_cmp(&angle_delta(current, *b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or(angle); + let local = DVec3::new( + previous_local.x + length * chosen.cos(), + previous_local.y + length * chosen.sin(), + point_local.z, + ); + self.plane.to_world(local) + } } impl CadCommand for MLeaderCommand { @@ -66,60 +328,86 @@ impl CadCommand for MLeaderCommand { } fn prompt(&self) -> String { - if self.verts.is_empty() { - t!("MLEADER Specify arrowhead point:").into_owned() - } else { - t!("MLEADER Specify landing point:").into_owned() + match self.step { + Step::Options => t!("MLEADER Options [Leader type/Landing/Content type/Max points/First angle/Second angle/Layer/Exit]:").into_owned(), + Step::LeaderType => t!("MLEADER Leader type [Straight/Spline/None]:").into_owned(), + Step::Landing => t!("MLEADER Leader landing [Yes/No]:").into_owned(), + Step::LandingLength => t!("MLEADER Specify landing distance:").into_owned(), + Step::ContentType => t!("MLEADER Content type [MText/Block/None]:").into_owned(), + Step::MaxPoints => t!("MLEADER Enter maximum leader points:").into_owned(), + Step::FirstAngle => t!("MLEADER First segment angle [Any/15/30/45/60/90]:").into_owned(), + Step::SecondAngle => t!("MLEADER Second segment angle [Any/15/30/45/60/90]:").into_owned(), + Step::Layer => t!("MLEADER Enter layer name:").into_owned(), + Step::PreEnterText => t!("MLEADER Enter text:").into_owned(), + Step::SelectMText => t!("MLEADER Select an MText object:").into_owned(), + Step::BlockSource => t!("MLEADER Enter source block name:").into_owned(), + Step::PickPoints if self.verts.is_empty() => match self.order { + CreationOrder::ArrowFirst => t!("MLEADER Specify arrowhead point or [Landing first/Content first/Text/Select MText/Options]:").into_owned(), + CreationOrder::LandingFirst => t!("MLEADER Specify landing point or [Arrowhead first/Content first/Text/Select MText/Options]:").into_owned(), + CreationOrder::ContentFirst => t!("MLEADER Specify content location or [Arrowhead first/Landing first/Text/Select MText/Options]:").into_owned(), + }, + Step::PickPoints if self.order == CreationOrder::ContentFirst => { + t!("MLEADER Specify arrowhead point:").into_owned() + } + Step::PickPoints => t!("MLEADER Specify next leader point or press Enter to finish:").into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::PickPoints if self.verts.is_empty() => vec![ + CmdOption::new("Arrowhead first", "A"), + CmdOption::new("Landing first", "L"), + CmdOption::new("Content first", "C"), + CmdOption::new("Text", "T"), + CmdOption::new("Select MText", "S"), + CmdOption::new("Options", "O"), + ], + Step::Options => vec![ + CmdOption::new("Leader type", "LT"), + CmdOption::new("Landing", "LD"), + CmdOption::new("Content type", "CT"), + CmdOption::new("Max points", "M"), + CmdOption::new("First angle", "F"), + CmdOption::new("Second angle", "S"), + CmdOption::new("Layer", "LA"), + CmdOption::new("Exit", "X"), + ], + Step::LeaderType => ["Straight", "Spline", "None"].into_iter().map(|v| CmdOption::new(v, v)).collect(), + Step::Landing => ["Yes", "No"].into_iter().map(|v| CmdOption::new(v, v)).collect(), + Step::ContentType => ["MText", "Block", "None"].into_iter().map(|v| CmdOption::new(v, v)).collect(), + Step::BlockSource => self + .block_sources + .iter() + .map(|(name, _)| CmdOption::new(name, name)) + .collect(), + Step::Layer => self + .layers + .iter() + .map(|name| CmdOption::new(name, name)) + .collect(), + Step::FirstAngle | Step::SecondAngle => ["Any", "15", "30", "45", "60", "90"].into_iter().map(|v| CmdOption::new(v, v)).collect(), + _ => Vec::new(), } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - self.verts.push(pt); - - if self.verts.len() < 2 { + if self.step != Step::PickPoints { return CmdResult::NeedPoint; } + self.verts.push(self.constrained_point(pt)); - let local: Vec = self - .verts - .iter() - .map(|point| self.plane.to_local(*point)) - .collect(); - - let ml = build_mleader( - "", - &local, - Mat4::IDENTITY, - self.style.as_ref(), - self.display_scale, - ); - - CmdResult::CommitAndEditText( - self.plane.place_entity(EntityType::MultiLeader(ml)), - ) + if self.verts.len() < self.point_limit() { + return CmdResult::NeedPoint; + } + self.finish() } fn on_enter(&mut self) -> CmdResult { - if self.verts.len() < 2 { + if self.step != Step::PickPoints || self.verts.len() < 2 { return CmdResult::Cancel; } - // Place the leader with empty text, then open the in-place MText editor - // so the user types the annotation into the rich editor. - let local: Vec = self - .verts - .iter() - .map(|point| self.plane.to_local(*point)) - .collect(); - let ml = build_mleader( - "", - &local, - Mat4::IDENTITY, - self.style.as_ref(), - self.display_scale, - ); - CmdResult::CommitAndEditText( - self.plane.place_entity(EntityType::MultiLeader(ml)), - ) + self.finish() } fn on_escape(&mut self) -> CmdResult { @@ -127,16 +415,37 @@ impl CadCommand for MLeaderCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if self.verts.is_empty() { + if self.step != Step::PickPoints || self.verts.is_empty() { return None; } // Preview / rubber-band is GPU screen-space: downcast to f32 here. - let mut pts: Vec = self + let mut local_points: Vec = self .verts .iter() - .map(|point| self.plane.to_local(*point).as_vec3()) + .map(|point| self.plane.to_local(*point)) .collect(); - pts.push(self.plane.to_local(pt).as_vec3()); + local_points.push(self.plane.to_local(self.constrained_point(pt))); + let pts: Vec = match self.order { + CreationOrder::ArrowFirst => local_points, + CreationOrder::LandingFirst => { + local_points.reverse(); + local_points + } + CreationOrder::ContentFirst => { + let content = local_points[0]; + let arrow = *local_points.last().unwrap_or(&content); + let sign = if content.x >= arrow.x { 1.0 } else { -1.0 }; + let elbow = DVec3::new( + content.x - sign * self.landing_length * self.display_scale, + content.y, + content.z, + ); + vec![arrow, elbow] + } + } + .into_iter() + .map(DVec3::as_vec3) + .collect(); let arrow_size = self .style .as_ref() @@ -159,6 +468,178 @@ impl CadCommand for MLeaderCommand { .collect(); Some(preview) } + + fn wants_text_input(&self) -> bool { + true + } + + fn wants_text_with_spaces(&self) -> bool { + matches!(self.step, Step::Layer | Step::PreEnterText) + } + + fn point_step_accepts_keywords(&self) -> bool { + self.step == Step::PickPoints + } + + fn on_text_input(&mut self, input: &str) -> Option { + let text = input.trim(); + let upper = text.to_ascii_uppercase(); + match self.step { + Step::PickPoints if self.verts.is_empty() => match upper.as_str() { + "A" | "ARROWHEAD" | "ARROWHEAD FIRST" => self.order = CreationOrder::ArrowFirst, + "L" | "LANDING" | "LANDING FIRST" => self.order = CreationOrder::LandingFirst, + "C" | "CONTENT" | "CONTENT FIRST" => self.order = CreationOrder::ContentFirst, + "T" | "TEXT" => self.step = Step::PreEnterText, + "S" | "SELECT" | "SELECT MTEXT" => self.step = Step::SelectMText, + "O" | "OPTIONS" => self.step = Step::Options, + _ => return None, + }, + Step::Options => match upper.as_str() { + "LT" | "LEADER TYPE" => self.step = Step::LeaderType, + "LD" | "LANDING" => self.step = Step::Landing, + "CT" | "CONTENT TYPE" => self.step = Step::ContentType, + "M" | "MAX" | "MAX POINTS" => self.step = Step::MaxPoints, + "F" | "FIRST" | "FIRST ANGLE" => self.step = Step::FirstAngle, + "S" | "SECOND" | "SECOND ANGLE" => self.step = Step::SecondAngle, + "LA" | "LAYER" => self.step = Step::Layer, + "X" | "EXIT" => self.step = Step::PickPoints, + _ => return None, + }, + Step::LeaderType => { + self.path_type = match upper.as_str() { + "SPLINE" => MultiLeaderPathType::Spline, + "NONE" => MultiLeaderPathType::Invisible, + "STRAIGHT" => MultiLeaderPathType::StraightLineSegments, + _ => return None, + }; + self.step = Step::Options; + } + Step::Landing => match upper.as_str() { + "Y" | "YES" => { + self.enable_landing = true; + self.step = Step::LandingLength; + } + "N" | "NO" => { + self.enable_landing = false; + self.step = Step::Options; + } + _ => return None, + }, + Step::LandingLength => { + let value = crate::entities::common::parse_typed_length(text)?; + if value <= 0.0 { + return None; + } + self.landing_length = value; + self.step = Step::Options; + } + Step::ContentType => { + self.content_type = match upper.as_str() { + "MTEXT" => LeaderContentType::MText, + "BLOCK" => LeaderContentType::Block, + "NONE" => LeaderContentType::None, + _ => return None, + }; + self.step = if self.content_type == LeaderContentType::Block + && self.block_handle.is_none() + { + Step::BlockSource + } else { + Step::Options + }; + } + Step::MaxPoints => { + let value = text.parse::().ok()?; + if value < 2 { + return None; + } + self.max_points = value; + self.step = Step::Options; + } + Step::FirstAngle | Step::SecondAngle => { + let angle = if upper == "ANY" { + None + } else { + let degrees = text.parse::().ok()?; + if !matches!(degrees as i32, 15 | 30 | 45 | 60 | 90) { + return None; + } + Some(degrees.to_radians()) + }; + if self.step == Step::FirstAngle { + self.first_angle = angle; + } else { + self.second_angle = angle; + } + self.step = Step::Options; + } + Step::Layer => { + if text.is_empty() { + return None; + } + let Some(layer) = self + .layers + .iter() + .find(|layer| layer.eq_ignore_ascii_case(text)) + .cloned() + else { + return None; + }; + self.layer = layer; + self.step = Step::Options; + } + Step::PreEnterText => { + self.text = input.to_string(); + self.content_type = LeaderContentType::MText; + self.step = Step::PickPoints; + } + Step::SelectMText => return None, + Step::BlockSource => { + let Some((_, handle)) = self + .block_sources + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(text)) + else { + return None; + }; + self.block_handle = Some(*handle); + self.content_type = LeaderContentType::Block; + self.step = Step::Options; + } + Step::PickPoints => return None, + } + Some(CmdResult::NeedPoint) + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::SelectMText + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, _handle: acadrust::Handle, _point: DVec3) -> CmdResult { + let Some(EntityType::MText(text)) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + self.verts.clear(); + self.verts.push(DVec3::new( + text.insertion_point.x, + text.insertion_point.y, + text.insertion_point.z, + )); + self.text = text.value.clone(); + self.selected_mtext = Some(text); + self.content_type = LeaderContentType::MText; + self.order = CreationOrder::ContentFirst; + self.step = Step::PickPoints; + CmdResult::NeedPoint + } } // ── Helpers ───────────────────────────────────────────────────────────────── @@ -170,18 +651,24 @@ fn v3(p: DVec3) -> Vector3 { fn build_mleader( text: &str, verts: &[DVec3], + content_point: Option, ucs: Mat4, style: Option<&acadrust::objects::MultiLeaderStyle>, display_scale: f64, ) -> MultiLeader { - // First click is the arrow; second click is the elbow. + // First point is the arrow, last point is the elbow. Intermediate points + // are leader vertices governed by the configured maximum-point count. let arrow_pt = verts[0]; - let elbow_pt = verts[1]; + let elbow_pt = *verts.last().unwrap_or(&verts[0]); - let arrow_v3 = v3(arrow_pt); let elbow_v3 = v3(elbow_pt); - let mut ml = MultiLeader::with_text(text, elbow_v3, vec![arrow_v3]); + let leader_vertices = verts[..verts.len().saturating_sub(1)] + .iter() + .copied() + .map(v3) + .collect(); + let mut ml = MultiLeader::with_text(text, elbow_v3, leader_vertices); if let Some(style) = style { crate::scene::annotative::apply_mleader_style(&mut ml, style); } else { @@ -225,11 +712,13 @@ fn build_mleader( // Place text beyond the landing and gap. let off = landing * (landing_distance + landing_gap) as f32; - let text_location = Vector3::new( - elbow_v3.x + off.x as f64, - elbow_v3.y + off.y as f64, - elbow_v3.z, - ); + let text_location = content_point.map(v3).unwrap_or_else(|| { + Vector3::new( + elbow_v3.x + off.x as f64, + elbow_v3.y + off.y as f64, + elbow_v3.z, + ) + }); ml.context.text_location = text_location; ml.context.content_base_point = text_location; @@ -237,6 +726,34 @@ fn build_mleader( ml } +fn normalize_content_context(ml: &mut MultiLeader) { + match ml.content_type { + LeaderContentType::MText => { + ml.context.has_text_contents = true; + ml.context.has_block_contents = false; + } + LeaderContentType::Block => { + ml.context.has_text_contents = false; + ml.context.has_block_contents = ml.block_content_handle.is_some(); + ml.context.block_content_handle = ml.block_content_handle; + ml.context.block_content_location = ml.context.content_base_point; + ml.context.block_content_scale = ml.block_scale; + ml.context.block_rotation = ml.block_rotation; + ml.context.block_content_color = ml.block_content_color; + ml.context.block_connection_type = ml.block_connection_type; + } + _ => { + ml.context.has_text_contents = false; + ml.context.has_block_contents = false; + } + } +} + +fn angle_delta(a: f64, b: f64) -> f64 { + let two_pi = std::f64::consts::TAU; + ((a - b + std::f64::consts::PI).rem_euclid(two_pi) - std::f64::consts::PI).abs() +} + fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { let mut points: Vec<[f32; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect(); if pts.len() >= 2 { diff --git a/src/modules/annotate/mleader_edit.rs b/src/modules/annotate/mleader_edit.rs index fe4727970..07a7feab9 100644 --- a/src/modules/annotate/mleader_edit.rs +++ b/src/modules/annotate/mleader_edit.rs @@ -8,6 +8,7 @@ use acadrust::entities::{LeaderLine, MultiLeader}; use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; +use cadkernel::geom2d::{closest_point, Curve, Line}; use glam::{DVec3, Vec3}; use crate::command::{CadCommand, CmdResult}; @@ -92,6 +93,9 @@ impl CadCommand for MLeaderAddCommand { match &mut self.step { AddStep::PickArrowhead { handle, entity } => { if let Some(ent) = entity.take() { + if !matches!(ent, EntityType::MultiLeader(_)) { + return CmdResult::Cancel; + } let h = *handle; self.step = AddStep::CollectPoints { handle: h, @@ -121,14 +125,46 @@ impl CadCommand for MLeaderAddCommand { return CmdResult::Cancel; } let h = *handle; - if let EntityType::MultiLeader(ref mut ml) = entity { - // Add a new leader root with the collected points + if let EntityType::MultiLeader(ref mut ml) = entity { let points: Vec = pts .iter() .map(|p| Vector3::new(p.x, p.y, p.z)) .collect(); + let template_root = ml.context.leader_roots.first().cloned(); + let template_line = template_root + .as_ref() + .and_then(|root| root.lines.first()) + .cloned(); + let path_type = ml.path_type; + let line_color = ml.line_color; + let line_type_handle = ml.line_type_handle; + let line_weight = ml.line_weight; + let arrowhead_handle = ml.arrowhead_handle; + let arrowhead_size = ml.arrowhead_size; let root = ml.context.add_leader_root(); - root.create_line(points); + if let Some(template) = template_root { + root.connection_point = template.connection_point; + root.direction = template.direction; + root.landing_distance = template.landing_distance; + root.text_attachment_direction = template.text_attachment_direction; + } + let line = root.create_line(points); + if let Some(template) = template_line { + line.path_type = template.path_type; + line.line_color = template.line_color; + line.line_type_handle = template.line_type_handle; + line.line_weight = template.line_weight; + line.arrowhead_handle = template.arrowhead_handle; + line.arrowhead_size = template.arrowhead_size; + line.override_flags = template.override_flags; + } else { + line.path_type = path_type; + line.line_color = line_color; + line.line_type_handle = line_type_handle; + line.line_weight = line_weight; + line.arrowhead_handle = arrowhead_handle; + line.arrowhead_size = arrowhead_size; + } } let updated = std::mem::replace(entity, EntityType::XLine(Default::default())); return CmdResult::ReplaceEntity(h, vec![updated]); @@ -223,26 +259,43 @@ impl CadCommand for MLeaderRemoveCommand { if let Some(mut ent) = entity.take() { let h = *handle; if let EntityType::MultiLeader(ref mut ml) = ent { - // Remove the leader root whose first point is closest to `pt` let pick = Vector3::new(pt.x as f64, pt.y as f64, pt.z as f64); let best = ml .context .leader_roots .iter() .enumerate() - .filter_map(|(i, root)| { - root.lines.first().and_then(|l| l.points.first()).map(|p| { - let dx = p.x - pick.x; - let dz = p.z - pick.z; - (i, dx * dx + dz * dz) + .flat_map(|(root_index, root)| { + root.lines.iter().enumerate().map(move |(line_index, line)| { + let mut distance = line + .points + .windows(2) + .map(|segment| point_segment_distance_xy(pick, segment[0], segment[1])) + .fold(f64::INFINITY, f64::min); + if let Some(last) = line.points.last().copied() { + distance = distance.min(point_segment_distance_xy( + pick, + last, + root.connection_point, + )); + } + (root_index, line_index, distance) }) }) - .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); - if let Some((idx, _)) = best { - if ml.context.leader_roots.len() > 1 { - ml.context.leader_roots.remove(idx); - } + .min_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); + let Some((root_index, line_index, _)) = best else { + return CmdResult::Cancel; + }; + if ml.total_leader_line_count() <= 1 { + return CmdResult::Cancel; } + let root = &mut ml.context.leader_roots[root_index]; + root.lines.remove(line_index); + if root.lines.is_empty() { + ml.context.leader_roots.remove(root_index); + } + } else { + return CmdResult::Cancel; } return CmdResult::ReplaceEntity(h, vec![ent]); } @@ -338,25 +391,11 @@ impl CadCommand for MLeaderAlignCommand { CmdResult::NeedPoint } AlignStep::PickEndDir { handles, from } => { - let h = handles.clone(); - let f = *from; - // Emit sentinel for commands.rs - use acadrust::entities::XLine; - let mut xl = XLine::default(); - let hstr: Vec = h.iter().map(|hh| hh.value().to_string()).collect(); - xl.common.layer = format!( - "__MLEADERALIGN__{};{:.4},{:.4};{:.4},{:.4}", - hstr.join(","), - f.x, - f.z, - pt.x, - pt.z - ); - // Use first handle as the "replaced" entity (sentinel) - if let Some(&first) = h.first() { - return CmdResult::ReplaceEntity(first, vec![EntityType::XLine(xl)]); + CmdResult::AlignMLeaders { + handles: handles.clone(), + from: *from, + to: pt, } - CmdResult::Cancel } _ => CmdResult::NeedPoint, } @@ -431,21 +470,10 @@ impl CadCommand for MLeaderCollectCommand { fn on_point(&mut self, pt: DVec3) -> CmdResult { if let CollectStep::PickLocation { handles } = &self.step { - let h = handles.clone(); - // Build a new combined multileader at the collection point, delete originals. - // The first handle is the "base" — the rest are erased and their leaders merged. - use acadrust::entities::XLine; - let mut xl = XLine::default(); - let hstr: Vec = h.iter().map(|hh| hh.value().to_string()).collect(); - xl.common.layer = format!( - "__MLEADERCOLLECT__{};{:.4},{:.4}", - hstr.join(","), - pt.x, - pt.z - ); - if let Some(&first) = h.first() { - return CmdResult::ReplaceEntity(first, vec![EntityType::XLine(xl)]); - } + return CmdResult::CollectMLeaders { + handles: handles.clone(), + point: pt, + }; } CmdResult::NeedPoint } @@ -496,6 +524,17 @@ fn preview_wire(pts: &[Vec3]) -> WireModel { } } +fn point_segment_distance_xy(point: Vector3, start: Vector3, end: Vector3) -> f64 { + closest_point( + &Curve::Line(Line { + start: [start.x, start.y], + end: [end.x, end.y], + }), + [point.x, point.y], + ) + .distance +} + // Silence unused-import warning for MultiLeader and LeaderLine if not used in all paths fn _uses_ml_types(_ml: &MultiLeader, _ll: &LeaderLine) {} diff --git a/src/scene/annotative.rs b/src/scene/annotative.rs index 8e3af638c..1e0a3b4cd 100644 --- a/src/scene/annotative.rs +++ b/src/scene/annotative.rs @@ -1502,66 +1502,153 @@ pub fn apply_mleader_style( entity: &mut acadrust::entities::MultiLeader, style: &acadrust::objects::MultiLeaderStyle, ) { + use acadrust::entities::MultiLeaderPropertyOverrideFlags as F; + + let flags = entity.property_override_flags; entity.style_handle = Some(style.handle); - entity.content_type = (style.content_type as i16).into(); - entity.path_type = (style.path_type as i16).into(); - entity.line_color = style.line_color; - entity.line_type_handle = style.line_type_handle; - entity.line_weight = style.line_weight; - entity.enable_landing = style.enable_landing; - entity.enable_dogleg = style.enable_dogleg; - entity.dogleg_length = style.landing_distance; - entity.arrowhead_handle = style.arrowhead_handle; - entity.arrowhead_size = style.arrowhead_size; - entity.context.arrowhead_size = style.arrowhead_size; - entity.context.landing_gap = style.landing_gap; - entity.text_style_handle = style.text_style_handle; - entity.text_color = style.text_color; - entity.text_frame = style.text_frame; - entity.text_height = style.text_height; - entity.context.text_height = style.text_height; - entity.context.text_style_handle = style.text_style_handle; - entity.context.text_color = style.text_color; - entity.text_left_attachment = (style.text_left_attachment as i16).into(); - entity.text_right_attachment = (style.text_right_attachment as i16).into(); - entity.text_top_attachment = (style.text_top_attachment as i16).into(); - entity.text_bottom_attachment = (style.text_bottom_attachment as i16).into(); - entity.text_attachment_direction = (style.text_attachment_direction as i16).into(); - entity.text_alignment = (style.text_alignment as i16).into(); - entity.text_angle_type = (style.text_angle_type as i16).into(); + if !flags.contains(F::CONTENT_TYPE) { + entity.content_type = (style.content_type as i16).into(); + } + if !flags.contains(F::PATH_TYPE) { + entity.path_type = (style.path_type as i16).into(); + } + if !flags.contains(F::LINE_COLOR) { + entity.line_color = style.line_color; + } + if !flags.contains(F::LEADER_LINE_TYPE) { + entity.line_type_handle = style.line_type_handle; + } + if !flags.contains(F::LEADER_LINE_WEIGHT) { + entity.line_weight = style.line_weight; + } + if !flags.contains(F::ENABLE_LANDING) { + entity.enable_landing = style.enable_landing; + } + if !flags.contains(F::ENABLE_DOGLEG) { + entity.enable_dogleg = style.enable_dogleg; + } + if !flags.contains(F::LANDING_DISTANCE) { + entity.dogleg_length = style.landing_distance; + } + if !flags.contains(F::LANDING_GAP) { + entity.context.landing_gap = style.landing_gap; + } + if !flags.contains(F::ARROWHEAD) { + entity.arrowhead_handle = style.arrowhead_handle; + } + if !flags.contains(F::ARROWHEAD_SIZE) { + entity.arrowhead_size = style.arrowhead_size; + entity.context.arrowhead_size = style.arrowhead_size; + } + if !flags.contains(F::TEXT_STYLE) { + entity.text_style_handle = style.text_style_handle; + } + if !flags.contains(F::TEXT_COLOR) { + entity.text_color = style.text_color; + } + if !flags.contains(F::TEXT_FRAME) { + entity.text_frame = style.text_frame; + } + if !flags.contains(F::TEXT_HEIGHT) { + entity.text_height = style.text_height; + entity.context.text_height = style.text_height; + } + if !flags.contains(F::TEXT_LEFT_ATTACHMENT) { + entity.text_left_attachment = (style.text_left_attachment as i16).into(); + } + if !flags.contains(F::TEXT_RIGHT_ATTACHMENT) { + entity.text_right_attachment = (style.text_right_attachment as i16).into(); + } + if !flags.contains(F::TEXT_TOP_ATTACHMENT) { + entity.text_top_attachment = (style.text_top_attachment as i16).into(); + } + if !flags.contains(F::TEXT_BOTTOM_ATTACHMENT) { + entity.text_bottom_attachment = (style.text_bottom_attachment as i16).into(); + } + if !flags.contains(F::TEXT_ATTACHMENT_DIRECTION) { + entity.text_attachment_direction = (style.text_attachment_direction as i16).into(); + } + if !flags.contains(F::TEXT_ALIGNMENT) { + entity.text_alignment = (style.text_alignment as i16).into(); + } + if !flags.contains(F::TEXT_ANGLE) { + entity.text_angle_type = (style.text_angle_type as i16).into(); + } entity.context.text_left_attachment = entity.text_left_attachment; entity.context.text_right_attachment = entity.text_right_attachment; entity.context.text_top_attachment = entity.text_top_attachment; entity.context.text_bottom_attachment = entity.text_bottom_attachment; entity.context.text_alignment = entity.text_alignment; - entity.block_content_handle = style.block_content_handle; - entity.block_content_color = style.block_content_color; - entity.block_connection_type = (style.block_content_connection as i16).into(); - entity.block_rotation = style.block_content_rotation; - entity.block_scale = Vector3::new( - style.block_content_scale_x, - style.block_content_scale_y, - style.block_content_scale_z, - ); - entity.scale_factor = style.scale_factor; - entity.context.block_content_handle = style.block_content_handle; - entity.context.block_content_color = style.block_content_color; + entity.context.text_style_handle = entity.text_style_handle; + entity.context.text_color = entity.text_color; + if !flags.contains(F::BLOCK_CONTENT) { + entity.block_content_handle = style.block_content_handle; + } + if !flags.contains(F::BLOCK_CONTENT_COLOR) { + entity.block_content_color = style.block_content_color; + } + if !flags.contains(F::BLOCK_CONTENT_CONNECTION) { + entity.block_connection_type = (style.block_content_connection as i16).into(); + } + if !flags.contains(F::BLOCK_CONTENT_ROTATION) { + entity.block_rotation = style.block_content_rotation; + } + if !flags.contains(F::BLOCK_CONTENT_SCALE) { + entity.block_scale = Vector3::new( + style.block_content_scale_x, + style.block_content_scale_y, + style.block_content_scale_z, + ); + } + if !flags.contains(F::SCALE_FACTOR) { + entity.scale_factor = style.scale_factor; + entity.context.scale_factor = style.scale_factor; + } + entity.context.block_content_handle = entity.block_content_handle; + entity.context.block_content_color = entity.block_content_color; entity.context.block_connection_type = entity.block_connection_type; - entity.context.block_rotation = style.block_content_rotation; + entity.context.block_rotation = entity.block_rotation; entity.context.block_content_scale = entity.block_scale; - entity.context.scale_factor = style.scale_factor; entity.enable_annotation_scale = style.is_annotative; + match entity.content_type { + acadrust::entities::LeaderContentType::MText => { + entity.context.has_text_contents = true; + entity.context.has_block_contents = false; + } + acadrust::entities::LeaderContentType::Block => { + entity.context.has_text_contents = false; + entity.context.has_block_contents = entity.block_content_handle.is_some(); + entity.context.block_content_location = entity.context.content_base_point; + } + _ => { + entity.context.has_text_contents = false; + entity.context.has_block_contents = false; + } + } for root in &mut entity.context.leader_roots { - root.landing_distance = style.landing_distance; + if !flags.contains(F::LANDING_DISTANCE) { + root.landing_distance = entity.dogleg_length; + } root.text_attachment_direction = entity.text_attachment_direction; for line in &mut root.lines { - if line.override_flags.is_empty() { + use acadrust::entities::LeaderLinePropertyOverrideFlags as F; + if !line.override_flags.contains(F::PATH_TYPE) { line.path_type = entity.path_type; - line.line_color = style.line_color; - line.line_type_handle = style.line_type_handle; - line.line_weight = style.line_weight; - line.arrowhead_handle = style.arrowhead_handle; - line.arrowhead_size = style.arrowhead_size; + } + if !line.override_flags.contains(F::LINE_COLOR) { + line.line_color = entity.line_color; + } + if !line.override_flags.contains(F::LINE_TYPE) { + line.line_type_handle = entity.line_type_handle; + } + if !line.override_flags.contains(F::LINE_WEIGHT) { + line.line_weight = entity.line_weight; + } + if !line.override_flags.contains(F::ARROWHEAD) { + line.arrowhead_handle = entity.arrowhead_handle; + } + if !line.override_flags.contains(F::ARROWHEAD_SIZE) { + line.arrowhead_size = entity.arrowhead_size; } } } @@ -1571,6 +1658,16 @@ fn apply_mleader_style_at_display_scale( style: &acadrust::objects::MultiLeaderStyle, display_scale: f64, ) { + use acadrust::entities::MultiLeaderPropertyOverrideFlags as F; + + let old_scale = entity.context.scale_factor.max(1.0e-12); + let landing_gap = entity.context.landing_gap / old_scale; + let landing_distances = entity + .context + .leader_roots + .iter() + .map(|root| root.landing_distance / old_scale) + .collect::>(); apply_mleader_style(entity, style); let scale = if display_scale.is_finite() @@ -1584,18 +1681,27 @@ fn apply_mleader_style_at_display_scale( // Context sizes are stored at their display scale. entity.context.scale_factor = scale; - entity.context.text_height = - style.text_height * scale; + entity.context.text_height = entity.text_height * scale; + entity.context.arrowhead_size = entity.arrowhead_size * scale; + entity.context.landing_gap = if entity.property_override_flags.contains(F::LANDING_GAP) { + landing_gap * scale + } else { + style.landing_gap * scale + }; - entity.context.arrowhead_size = - style.arrowhead_size * scale; - - entity.context.landing_gap = - style.landing_gap * scale; - - for root in &mut entity.context.leader_roots { - root.landing_distance = - style.landing_distance * scale; + for (index, root) in entity.context.leader_roots.iter_mut().enumerate() { + root.landing_distance = if entity + .property_override_flags + .contains(F::LANDING_DISTANCE) + { + landing_distances + .get(index) + .copied() + .unwrap_or(entity.dogleg_length) + * scale + } else { + entity.dogleg_length * scale + }; root.text_attachment_direction = entity.text_attachment_direction; @@ -1854,4 +1960,3 @@ pub fn is_annotative(doc: &CadDocument, entity: &EntityType) -> bool { _ => false, } } - diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 613a82f35..e0f30da28 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -34,6 +34,13 @@ pub enum PropValue { ColorVaries, /// Line weight — rendered as a combo_box. LwChoice(LineWeight), + /// Object-specific line weight routed by field name. + FieldLwChoice { + field: &'static str, + value: LineWeight, + }, + /// Object-specific lineweight varies across the current selection. + FieldLwVaries { field: &'static str }, /// Lineweight varies across the current multi-selection. LwVaries, /// Linetype name — rendered as a combo_box. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index aa951bdd8..db367e61c 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -539,7 +539,8 @@ impl Scene { "ByLayer".into(), "ByBlock".into(), ]), - PropValue::LwChoice(_) => QSelectValueEditor::Choice(vec![ + PropValue::LwChoice(_) + | PropValue::FieldLwChoice { .. } => QSelectValueEditor::Choice(vec![ "ByLayer".into(), "ByBlock".into(), "Default".into(), @@ -567,7 +568,8 @@ impl Scene { PropValue::AttrText { .. } => QSelectValueEditor::Text, PropValue::Stepper { .. } | PropValue::ColorVaries - | PropValue::LwVaries => continue, + | PropValue::LwVaries + | PropValue::FieldLwVaries { .. } => continue, } }; out.push(choice(prop.field, prop.label, editor)); @@ -696,13 +698,18 @@ impl Scene { PropValue::Choice { selected, .. } => selected, PropValue::EditChoice { value, .. } => value, PropValue::ColorChoice(c) => Self::format_color(c), - PropValue::LwChoice(lw) => Self::format_lineweight(lw), + PropValue::LwChoice(lw) + | PropValue::FieldLwChoice { value: lw, .. } => { + Self::format_lineweight(lw) + } PropValue::LinetypeChoice(s) => s, PropValue::HatchPatternChoice(s) => s, PropValue::BoolToggle { value, .. } => value.to_string(), PropValue::AttrText { value, .. } => value, PropValue::Stepper { display, .. } => display, - PropValue::ColorVaries | PropValue::LwVaries => return None, + PropValue::ColorVaries + | PropValue::LwVaries + | PropValue::FieldLwVaries { .. } => return None, }) } } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index b6d129710..7023f0774 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -763,6 +763,12 @@ impl PropertiesPanel { PropValue::ColorVaries => self.render_color_varies_row(label), PropValue::LayerChoice(layer) => self.render_layer_row(label, layer), PropValue::LwChoice(lw) => self.render_lw_row(label, *lw), + PropValue::FieldLwChoice { field, value } => { + self.render_field_lw_row(label, field, *value) + } + PropValue::FieldLwVaries { field } => { + self.render_field_lw_varies_row(label, field) + } PropValue::LwVaries => self.render_lw_varies_row(label), PropValue::LinetypeChoice(lt) => self.render_linetype_row(label, lt), PropValue::Choice { selected, options } => { @@ -861,6 +867,10 @@ impl PropertiesPanel { | "dim_ext_line_color" | "dim_text_color" | "dim_text_fill_color" + | "line_color" + | "text_color" + | "block_content_color" + | "background_fill_color" ) { let open = self.open_color_field.as_deref() == Some(field); let fsel = field.to_string(); @@ -871,7 +881,9 @@ impl PropertiesPanel { background: true, ..Default::default() } - } else if field.starts_with("dim_") { + } else if field.starts_with("dim_") + || matches!(field, "line_color" | "text_color" | "block_content_color") + { crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, @@ -998,6 +1010,36 @@ impl PropertiesPanel { prop_row_widget(label, combo.into()) } + fn render_field_lw_row<'a>( + &'a self, + label: &'a str, + field: &'static str, + lw: LineWeight, + ) -> Element<'a, Message> { + let selected = LwItem(lw); + let combo = combo_box( + &self.lineweight_combo, + "", + Some(&selected), + move |item: LwItem| Message::PropFieldLwChanged { + field, + value: item.0, + }, + ) + .size(FONT_SZ) + .padding(Padding { + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, + left: 6.0, + right: 6.0, + }) + .input_style(combo_input_style) + .on_open(Message::PropColorPickerClose) + .width(Length::Fill); + + prop_row_widget(label, combo.into()) + } + fn render_lw_varies_row<'a>(&'a self, label: &'a str) -> Element<'a, Message> { let combo = combo_box( &self.lineweight_combo, @@ -1019,6 +1061,34 @@ impl PropertiesPanel { prop_row_widget(label, combo.into()) } + fn render_field_lw_varies_row<'a>( + &'a self, + label: &'a str, + field: &'static str, + ) -> Element<'a, Message> { + let combo = combo_box( + &self.lineweight_combo, + VARIES_LABEL, + None, + move |item: LwItem| Message::PropFieldLwChanged { + field, + value: item.0, + }, + ) + .size(FONT_SZ) + .padding(Padding { + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, + left: 6.0, + right: 6.0, + }) + .input_style(combo_input_style) + .on_open(Message::PropColorPickerClose) + .width(Length::Fill); + + prop_row_widget(label, combo.into()) + } + // ── Linetype row (combo_box) ────────────────────────────────────────── fn render_linetype_row<'a>(&'a self, label: &'a str, current: &'a str) -> Element<'a, Message> {