diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 12e6b94b..671366f4 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1170,6 +1170,15 @@ draw = .tag-val = [__ocs_fmt_0__] = __ocs_fmt_1__ .no-closed-boundary-found = ⚠ No closed boundary found. .inverted = , inverted + .loaded-multiline-styles = Loaded multiline styles: __ocs_fmt_0__ + .mline-current-settings = MLINE Current settings: Justification = __ocs_fmt_0__, Scale = __ocs_fmt_1__, Style = __ocs_fmt_2__ + Specify start point or [Justification/Scale/STyle]: + .mline-enter-justification = MLINE Enter justification type [Top/Zero/Bottom] <__ocs_fmt_0__>: + .mline-enter-scale-factor-default = MLINE Enter scale factor <__ocs_fmt_0__>: + .mline-enter-style-name = MLINE Enter style name or [?] <__ocs_fmt_0__>: + .mline-specify-next-point-close-undo = MLINE Specify next point or [Close/Undo]: + .mline-specify-next-point-undo = MLINE Specify next point or [Undo]: + .multiline-style-was-not-found = Multiline style "__ocs_arg_style__" was not found. .value-2-point = 2-Point .value-2d-solid = 2D Solid .value-3-point = 3-Point @@ -1452,6 +1461,24 @@ draw = modify = .count-objects = (__ocs_arg_count__ objects) .edge-extend-indented-title-case = [Edge: Extend] + .add-vertex = Add Vertex + .closed-cross = Closed Cross + .closed-tee = Closed Tee + .corner-joint = Corner Joint + .cut-all = Cut All + .cut-single = Cut Single + .delete-vertex = Delete Vertex + .merged-cross = Merged Cross + .merged-tee = Merged Tee + .mledit-choose-an-edit-tool = MLEDIT Choose an edit tool: + .mledit-select-first-multiline = MLEDIT Select first multiline: + .mledit-select-second-multiline = MLEDIT Select second multiline: + .mledit-specify-end-of-weld-range = MLEDIT Specify the end of the weld range: + .mledit-specify-second-cut-point = MLEDIT Specify the second cut point: + .open-cross = Open Cross + .open-tee = Open Tee + .remove-vertex = Remove Vertex + .weld-all = Weld All .align-scale-objects-based-on-alignment-points = ALIGN Scale objects based on alignment points? [Y/N]: .align-select-objects-count-selected-enter-when = ALIGN Select objects (__ocs_arg_count__ selected, Enter when done): .align-specify-1st-destination-point = ALIGN Specify 1st destination point: diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 520a80b9..5c3f9ff4 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1152,6 +1152,15 @@ draw = .tag-val = [__ocs_fmt_0__] = __ocs_fmt_1__ .no-closed-boundary-found = ⚠ Kapalı sınır bulunamadı. .inverted = , ters çevrilmiş + .loaded-multiline-styles = Yüklü çoklu çizgi stilleri: __ocs_fmt_0__ + .mline-current-settings = MLINE Geçerli ayarlar: Hizalama = __ocs_fmt_0__, Ölçek = __ocs_fmt_1__, Stil = __ocs_fmt_2__ + Başlangıç noktasını belirtin veya [Hizalama/Ölçek/Stil]: + .mline-enter-justification = MLINE Hizalama türünü girin [Üst/Sıfır/Alt] <__ocs_fmt_0__>: + .mline-enter-scale-factor-default = MLINE Ölçek katsayısını girin <__ocs_fmt_0__>: + .mline-enter-style-name = MLINE Stil adını veya [?] girin <__ocs_fmt_0__>: + .mline-specify-next-point-close-undo = MLINE Sonraki noktayı belirtin veya [Kapat/Geri al]: + .mline-specify-next-point-undo = MLINE Sonraki noktayı belirtin veya [Geri al]: + .multiline-style-was-not-found = "__ocs_arg_style__" çoklu çizgi stili bulunamadı. .value-2-point = 2 Nokta .value-2d-solid = 2B Katı .value-3-point = 3 Nokta @@ -1434,6 +1443,24 @@ draw = modify = .count-objects = (__ocs_arg_count__ nesne) .edge-extend-indented-title-case = [Kenar: Uzat] + .add-vertex = Tepe Noktası Ekle + .closed-cross = Kapalı Kesişim + .closed-tee = Kapalı T Birleşimi + .corner-joint = Köşe Birleşimi + .cut-all = Tümünü Kes + .cut-single = Tekini Kes + .delete-vertex = Tepe Noktasını Sil + .merged-cross = Birleşik Kesişim + .merged-tee = Birleşik T Birleşimi + .mledit-choose-an-edit-tool = MLEDIT Bir düzenleme aracı seçin: + .mledit-select-first-multiline = MLEDIT İlk çoklu çizgiyi seçin: + .mledit-select-second-multiline = MLEDIT İkinci çoklu çizgiyi seçin: + .mledit-specify-end-of-weld-range = MLEDIT Kaynak aralığının sonunu belirtin: + .mledit-specify-second-cut-point = MLEDIT İkinci kesim noktasını belirtin: + .open-cross = Açık Kesişim + .open-tee = Açık T Birleşimi + .remove-vertex = Tepe Noktasını Kaldır + .weld-all = Tümünü Birleştir .align-scale-objects-based-on-alignment-points = ALIGN Nesneler hizalama noktalarına göre ölçeklensin mi? [E/H]: .align-select-objects-count-selected-enter-when = ALIGN Nesneleri seçin (__ocs_arg_count__ seçili, bitirmek için Enter): .align-specify-1st-destination-point = ALIGN 1. hedef noktasını belirtin: diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 7c69d400..f1924bc7 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -662,6 +662,26 @@ impl OpenCADStudio { tab.dirty = true; } } + let mline_settings = self.tabs[self.active_tab] + .active_cmd + .as_ref() + .and_then(|command| command.mline_settings()); + if let Some((scale, justification, style_name, style_handle)) = mline_settings { + let tab = &mut self.tabs[self.active_tab]; + let header = &mut tab.scene.document.header; + let style_handle = style_handle.unwrap_or(acadrust::Handle::NULL); + let changed = (header.multiline_scale - scale).abs() > f64::EPSILON + || header.multiline_justification != justification + || !header.multiline_style.eq_ignore_ascii_case(&style_name) + || header.current_multiline_style_handle != style_handle; + if changed { + header.multiline_scale = scale; + header.multiline_justification = justification; + header.multiline_style = style_name; + header.current_multiline_style_handle = style_handle; + tab.dirty = true; + } + } let was_active = self.tabs[self.active_tab].active_cmd.is_some(); let preserve_selection = matches!(result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..)); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 0009e0ba..085cce33 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -13,27 +13,27 @@ impl OpenCADStudio { "MLINE" => { use crate::modules::draw::draw::mline::MlineCommand; - let style_name = self.tabs[i].scene.document.header.multiline_style.clone(); - let style = self.tabs[i] + let header = &self.tabs[i].scene.document.header; + let style_name = header.multiline_style.clone(); + let scale = header.multiline_scale; + let justification = header.multiline_justification; + let styles = self.tabs[i] .scene .document .objects .iter() - .find_map(|(handle, object)| match object { - acadrust::objects::ObjectType::MLineStyle(style) - if style.name.eq_ignore_ascii_case(&style_name) => - { - Some((*handle, style.elements.len())) + .filter_map(|(handle, object)| match object { + acadrust::objects::ObjectType::MLineStyle(style) => { + Some((*handle, style.clone())) } _ => None, - }); - let (style_handle, element_count) = style - .map(|(handle, count)| (Some(handle), count)) - .unwrap_or((None, 2)); - let cmd_obj = MlineCommand::with_style( + }) + .collect(); + let cmd_obj = MlineCommand::with_styles( + styles, style_name, - style_handle, - element_count, + scale, + justification, ); self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index fdd1f459..b701f91d 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -286,6 +286,34 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } + "MLEDIT" => { + use crate::modules::draw::modify::mledit::{ + MlineEditCommand, MlineEditTarget, + }; + let document = &self.tabs[i].scene.document; + let targets = document + .entities() + .filter_map(|entity| { + let acadrust::EntityType::MLine(mline) = entity else { + return None; + }; + let style = crate::entities::mline::resolved_mline_style(mline, document) + .cloned() + .unwrap_or_else(acadrust::objects::MLineStyle::standard); + Some(( + entity.common().handle.value(), + MlineEditTarget { + entity: mline.clone(), + style, + }, + )) + }) + .collect(); + let command = MlineEditCommand::new(targets); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "SPLINEDIT" => { use crate::modules::draw::modify::splinedit::SplineditCommand; let cmd_obj = SplineditCommand::new(); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index ffa66bcb..ed249296 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -512,6 +512,8 @@ inventory::submit!(crate::command::CommandRegistration { "SHADEDGE", "MAXACTVP", "CMLJUST", + "CMLSCALE", + "CMLSTYLE", "TEXTQLTY", "SORTENTS", "FRAME", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 774f5cc8..cd1483c9 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -920,6 +920,8 @@ impl OpenCADStudio { | "SHADEDGE" | "MAXACTVP" | "CMLJUST" + | "CMLSCALE" + | "CMLSTYLE" | "TEXTQLTY" | "SORTENTS" | "FRAME" @@ -1697,17 +1699,35 @@ impl OpenCADStudio { } }, "CMLJUST" => match &value { - Some(v) => v - .parse::() - .map(|x| { + Some(v) => match v.parse::() { + Ok(x @ 0..=2) => { h.multiline_justification = x; - (format!("CMLJUST = {x}"), true) - }) - .map_err(|_| "SETVAR: integer value required.".into()), + Ok((format!("CMLJUST = {x}"), true)) + } + _ => Err("SETVAR: integer value from 0 to 2 required.".into()), + }, None => { Ok((format!("CMLJUST = {}", h.multiline_justification), false)) } }, + "CMLSCALE" => match &value { + Some(v) => match v.parse::() { + Ok(x) if x.is_finite() => { + let changed = h.multiline_scale != x; + h.multiline_scale = x; + Ok((format!("CMLSCALE = {x}"), changed)) + } + _ => Err("SETVAR: finite numeric value required.".into()), + }, + None => Ok((format!("CMLSCALE = {}", h.multiline_scale), false)), + }, + "CMLSTYLE" => match &value { + Some(_) => Err( + "SETVAR: CMLSTYLE is read-only here — use the MLSTYLE command." + .into(), + ), + None => Ok((format!("CMLSTYLE = {}", h.multiline_style), false)), + }, "TEXTQLTY" => match &value { Some(v) => v .parse::() diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 3ecaec32..805c8959 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2195,6 +2195,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::command::WorkingPlane::default() }; for &handle in &handles { + let mline_style = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + acadrust::EntityType::MLine(mline) => { + crate::entities::mline::resolved_mline_style( + mline, + &self.tabs[i].scene.document, + ) + .cloned() + } + _ => None, + }); if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_geom_prop_in_working_plane( @@ -2203,6 +2217,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &value, plane, ); + if matches!(field, "ml_justification" | "ml_scale") { + if let ( + acadrust::EntityType::MLine(mline), + Some(style), + ) = (entity, mline_style.as_ref()) + { + crate::modules::draw::draw::mline::sync_mline_element_parameters( + mline, style, + ); + } + } } } } @@ -2399,6 +2424,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { ) .is_none() { + let mline_style = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + acadrust::EntityType::MLine(mline) => { + crate::entities::mline::resolved_mline_style( + mline, + &self.tabs[i].scene.document, + ) + .cloned() + } + _ => None, + }); if let Some(entity) = self.tabs[i] .scene .document @@ -2410,6 +2449,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &val, plane, ); + if field == "ml_scale" { + if let ( + acadrust::EntityType::MLine(mline), + Some(style), + ) = (entity, mline_style.as_ref()) + { + crate::modules::draw::draw::mline::sync_mline_element_parameters( + mline, style, + ); + } + } } } } diff --git a/src/command.rs b/src/command.rs index e8a0abd5..39e086e4 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1855,6 +1855,11 @@ pub trait CadCommand: Send { None } + /// Current drawing-persisted multiline creation settings. + fn mline_settings(&self) -> Option<(f64, i16, String, Option)> { + None + } + /// Returns `true` when the active text prompt expects free-form prose /// that can legitimately contain whitespace (the body of a TEXT / /// MTEXT / DDEDIT entity, an attribute default value, etc.). For diff --git a/src/entities/mline.rs b/src/entities/mline.rs index 47b11c97..f9d10c7c 100644 --- a/src/entities/mline.rs +++ b/src/entities/mline.rs @@ -1,52 +1,469 @@ -use acadrust::entities::MLine; +use acadrust::entities::{MLine, MLineFlags, MLineSegment}; +use acadrust::types::Vector3; +use cadkernel::geom2d::{intersect::line_line, Vec2}; +use cadkernel::space::Plane; use crate::command::EntityTransform; -use crate::entities::common::{edit_prop as edit, square_grip}; +use crate::entities::common::{edit_prop as edit, ro_prop, square_grip}; use crate::entities::traits::{Grippable, PropertyEditable, RenderConvertible, Transformable}; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::model::wire_model::SnapHint; use crate::t; -/// One drawn line of a multiline: the polyline for a single style element (or -/// an end cap), tagged with the element's colour and linetype so the -/// tessellator can colour-bin and dash each one independently. +/// One styled line or cap of a multiline. pub struct MLineLine { pub points: Vec<[f64; 3]>, pub color: acadrust::types::Color, pub linetype: String, } -/// Resolve a multiline into its per-element parallel lines in WCS. -/// -/// Geometry comes from the referenced MLINESTYLE (element offsets, the -/// justification shift and the entity scale) rather than a fixed ±scale/2 guess, -/// so a custom style's offsets, colours and linetypes render the way the drawing -/// intends. Falls back to a ±0.5 two-line layout only when no MLINESTYLE can be -/// resolved (e.g. the style object is missing). -pub fn mline_lines(m: &MLine, document: &acadrust::CadDocument) -> Vec { - use acadrust::entities::{MLineFlags, MLineJustification}; +pub(crate) fn rebuild_mline_geometry(mline: &mut MLine) -> bool { + let count = mline.vertices.len(); + mline.flags.set(MLineFlags::HAS_VERTICES, count > 0); + let Some(first) = mline.vertices.first() else { + mline.start_point = Vector3::ZERO; + return true; + }; + mline.start_point = first.position; + if count == 1 { + return true; + } + + let points: Vec<[f64; 3]> = mline + .vertices + .iter() + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect(); + let axis = points + .windows(2) + .find_map(|pair| { + (cadkernel::space::Vec3::from(pair[1]) - cadkernel::space::Vec3::from(pair[0])) + .normalize() + .map(cadkernel::space::Vec3::to_array) + }) + .or_else(|| { + mline.is_closed() + .then(|| { + (cadkernel::space::Vec3::from(points[0]) + - cadkernel::space::Vec3::from(points[count - 1])) + .normalize() + }) + .flatten() + .map(cadkernel::space::Vec3::to_array) + }); + let Some(plane) = axis.and_then(|axis| { + Plane::orthonormal( + points[0], + axis, + [mline.normal.x, mline.normal.y, mline.normal.z], + ) + }) else { + return false; + }; + let Some(flat): Option> = points + .iter() + .map(|point| plane.project(*point).map(Vec2::from)) + .collect() + else { + return false; + }; + let closed = mline.is_closed() && count >= 3; + let direction_after = |index: usize| { + let attempts = if closed { + count - 1 + } else { + count.saturating_sub(index + 1) + }; + (1..=attempts).find_map(|step| { + let next = if closed { + (index + step) % count + } else { + index + step + }; + (flat[next] - flat[index]).normalize() + }) + }; + let direction_before = |index: usize| { + let attempts = if closed { count - 1 } else { index }; + (1..=attempts).find_map(|step| { + let previous = if closed { + (index + count - step % count) % count + } else { + index - step + }; + (flat[index] - flat[previous]).normalize() + }) + }; + + for index in 0..count { + let before = direction_before(index); + let after = direction_after(index); + let Some(direction) = after.or(before) else { + continue; + }; + let miter = match (before, after) { + (Some(before), Some(after)) => { + let first_offset = flat[index] + before.perpendicular(); + let second_offset = flat[index] + after.perpendicular(); + line_line( + first_offset.to_array(), + before.to_array(), + second_offset.to_array(), + after.to_array(), + ) + .and_then(|(at, _)| { + (first_offset + before * at - flat[index]).normalize() + }) + .unwrap_or_else(|| after.perpendicular()) + } + (Some(direction), None) | (None, Some(direction)) => direction.perpendicular(), + (None, None) => continue, + }; + let direction = plane.vector_at(direction.to_array()); + let miter = plane.vector_at(miter.to_array()); + mline.vertices[index].direction = Vector3::new(direction[0], direction[1], direction[2]); + mline.vertices[index].miter = Vector3::new(miter[0], miter[1], miter[2]); + } + true +} + +fn adjusted_mline_endpoint( + mline: &MLine, + style: &acadrust::objects::MLineStyle, + vertex: usize, + point: [f64; 3], +) -> [f64; 3] { + let angle = if vertex == 0 { + style.start_angle + } else { + style.end_angle + }; + let Some(tangent) = cadkernel::space::Vec3::new( + mline.vertices[vertex].direction.x, + mline.vertices[vertex].direction.y, + mline.vertices[vertex].direction.z, + ) + .normalize() + else { + return point; + }; + let Some(normal) = + cadkernel::space::Vec3::new(mline.normal.x, mline.normal.y, mline.normal.z).normalize() + else { + return point; + }; + let Some(transverse) = normal.cross(tangent).normalize() else { + return point; + }; + let base = cadkernel::space::Vec3::new( + mline.vertices[vertex].position.x, + mline.vertices[vertex].position.y, + mline.vertices[vertex].position.z, + ); + let current = cadkernel::space::Vec3::from(point); + let tangent = if angle.tan().abs() > 1.0e-9 { + tangent * ((current - base).dot(transverse) / angle.tan()) + } else { + cadkernel::space::Vec3::ZERO + }; + (current + tangent).to_array() +} + +pub(crate) fn mline_drawn_ranges(parameters: &[f64], length: f64) -> Vec<(f64, f64)> { + if parameters.len() <= 1 { + return vec![(0.0, length)]; + } + let mut cursor = parameters[1].clamp(0.0, length); + if parameters.len() == 2 { + return (cursor < length - 1.0e-9) + .then_some((cursor, length)) + .into_iter() + .collect(); + } + let mut drawn = true; + let mut ranges = Vec::new(); + for distance in ¶meters[2..] { + let next = (cursor + distance.max(0.0)).clamp(0.0, length); + if drawn && next - cursor > 1.0e-9 { + ranges.push((cursor, next)); + } + cursor = next; + drawn = !drawn; + } + if drawn && cursor < length - 1.0e-9 { + ranges.push((cursor, length)); + } + ranges +} + +fn mline_fill_ranges(parameters: &[f64], length: f64) -> Vec<(f64, f64)> { + let mut cuts: Vec<(f64, f64)> = parameters + .chunks(2) + .filter_map(|pair| { + let start = pair[0].clamp(0.0, length); + let end = pair.get(1).copied().unwrap_or(length).clamp(0.0, length); + (end - start > 1.0e-9).then_some((start, end)) + }) + .collect(); + cuts.sort_by(|left, right| left.0.total_cmp(&right.0)); + let mut drawn = Vec::new(); + let mut start = 0.0; + for cut in cuts { + if cut.0 > start + 1.0e-9 { + drawn.push((start, cut.0)); + } + start = start.max(cut.1); + } + if start < length - 1.0e-9 { + drawn.push((start, length)); + } + drawn +} + +fn mline_cut_ranges(parameters: &[f64], length: f64) -> Vec<(f64, f64)> { + parameters + .chunks(2) + .filter_map(|pair| { + let start = pair[0].clamp(0.0, length); + let end = pair.get(1).copied().unwrap_or(length).clamp(0.0, length); + (end - start > 1.0e-9).then_some((start, end)) + }) + .collect() +} + +pub(crate) fn store_mline_drawn_ranges( + parameters: &mut Vec, + length: f64, + ranges: &[(f64, f64)], +) { + let offset = parameters.first().copied().unwrap_or(0.0); + parameters.clear(); + parameters.push(offset); + let ranges = merge_mline_ranges( + ranges + .iter() + .filter_map(|range| { + let start = range.0.clamp(0.0, length); + let end = range.1.clamp(0.0, length); + (end - start > 1.0e-9).then_some((start, end)) + }) + .collect(), + ); + if ranges.is_empty() { + parameters.extend([0.0, 0.0]); + } else if ranges.len() == 1 + && ranges[0].0 <= 1.0e-9 + && ranges[0].1 >= length - 1.0e-9 + { + parameters.push(0.0); + } else { + parameters.push(ranges[0].0); + for (index, (start, end)) in ranges.iter().enumerate() { + if index > 0 { + parameters.push(start - ranges[index - 1].1); + } + parameters.push(end - start); + } + } +} + +fn store_mline_cut_ranges(parameters: &mut Vec, length: f64, ranges: &[(f64, f64)]) { + parameters.clear(); + for (start, end) in ranges { + parameters.push(*start); + if *end < length - 1.0e-9 { + parameters.push(*end); + } + } +} + +fn remap_mline_ranges( + ranges: &[(f64, f64)], + source_start: f64, + source_end: f64, + target_length: f64, +) -> Vec<(f64, f64)> { + let source_length = source_end - source_start; + if source_length <= 1.0e-12 || target_length <= 1.0e-12 { + return Vec::new(); + } + ranges + .iter() + .filter_map(|range| { + let start = range.0.max(source_start); + let end = range.1.min(source_end); + (end - start > 1.0e-9).then_some(( + (start - source_start) / source_length * target_length, + (end - source_start) / source_length * target_length, + )) + }) + .collect() +} + +fn shift_mline_ranges( + ranges: &[(f64, f64)], + source_length: f64, + shift: f64, + target_length: f64, +) -> Vec<(f64, f64)> { + ranges + .iter() + .filter_map(|range| { + let start = if range.0 <= 1.0e-9 { + 0.0 + } else { + (range.0 + shift).clamp(0.0, target_length) + }; + let end = if range.1 >= source_length - 1.0e-9 { + target_length + } else { + (range.1 + shift).clamp(0.0, target_length) + }; + (end - start > 1.0e-9).then_some((start, end)) + }) + .collect() +} + +fn merge_mline_ranges(mut ranges: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + ranges.sort_by(|left, right| left.0.total_cmp(&right.0)); + let mut merged: Vec<(f64, f64)> = Vec::new(); + for range in ranges { + if let Some(last) = merged.last_mut() { + if range.0 <= last.1 + 1.0e-9 { + last.1 = last.1.max(range.1); + continue; + } + } + merged.push(range); + } + merged +} + +fn mline_element_endpoints( + mline: &MLine, + vertex: usize, + element: usize, +) -> Option<(cadkernel::space::Vec3, cadkernel::space::Vec3)> { + let next = if vertex + 1 < mline.vertices.len() { + vertex + 1 + } else if mline.is_closed() { + 0 + } else { + return None; + }; + let point = |index: usize| { + let vertex = mline.vertices.get(index)?; + let offset = vertex.segments.get(element)?.parameters.first().copied()?; + let position = cadkernel::space::Vec3::new( + vertex.position.x, + vertex.position.y, + vertex.position.z, + ); + let miter = cadkernel::space::Vec3::new(vertex.miter.x, vertex.miter.y, vertex.miter.z); + Some(position + miter * offset) + }; + Some((point(vertex)?, point(next)?)) +} + +fn snapshot_mline_segment(mline: &MLine, vertex: usize) -> Option> { + (0..mline.style_element_count) + .map(|element| { + let endpoints = mline_element_endpoints(mline, vertex, element)?; + Some(( + mline.vertices.get(vertex)?.segments.get(element)?.clone(), + endpoints.0.distance(endpoints.1), + )) + }) + .collect() +} + +fn restore_shifted_mline_segment( + mline: &mut MLine, + vertex: usize, + source: Vec<(MLineSegment, f64)>, + moved_start: bool, +) { + for (element, (source, source_length)) in source.into_iter().enumerate() { + let Some(endpoints) = mline_element_endpoints(mline, vertex, element) else { + continue; + }; + let target_length = endpoints.0.distance(endpoints.1); + let shift = if moved_start { + target_length - source_length + } else { + 0.0 + }; + let drawn = shift_mline_ranges( + &mline_drawn_ranges(&source.parameters, source_length), + source_length, + shift, + target_length, + ); + let cuts = shift_mline_ranges( + &mline_cut_ranges(&source.area_fill_parameters, source_length), + source_length, + shift, + target_length, + ); + let Some(target) = mline + .vertices + .get_mut(vertex) + .and_then(|vertex| vertex.segments.get_mut(element)) + else { + continue; + }; + store_mline_drawn_ranges(&mut target.parameters, target_length, &drawn); + store_mline_cut_ranges(&mut target.area_fill_parameters, target_length, &cuts); + } +} + +/// Resolve a multiline into styled parallel lines in WCS. +pub fn resolved_mline_style<'a>( + m: &MLine, + document: &'a acadrust::CadDocument, +) -> Option<&'a acadrust::objects::MLineStyle> { use acadrust::objects::ObjectType; + + m.style_handle + .and_then(|handle| match document.objects.get(&handle) { + Some(ObjectType::MLineStyle(style)) => Some(style), + _ => None, + }) + .or_else(|| { + document.objects.values().find_map(|object| match object { + ObjectType::MLineStyle(style) + if style.name.eq_ignore_ascii_case(&m.style_name) => + { + Some(style) + } + _ => None, + }) + }) +} + +pub fn mline_lines(m: &MLine, document: &acadrust::CadDocument) -> Vec { + mline_lines_resolved(m, resolved_mline_style(m, document)) +} + +pub fn mline_lines_with_style( + m: &MLine, + style: &acadrust::objects::MLineStyle, +) -> Vec { + mline_lines_resolved(m, Some(style)) +} + +fn mline_lines_resolved( + m: &MLine, + style: Option<&acadrust::objects::MLineStyle>, +) -> Vec { + use acadrust::entities::{MLineFlags, MLineJustification}; use acadrust::types::Color; if m.vertices.is_empty() { return Vec::new(); } - // MLINESTYLE lookup: prefer the hard-pointer handle, fall back to the name. - let style = m - .style_handle - .and_then(|h| match document.objects.get(&h) { - Some(ObjectType::MLineStyle(s)) => Some(s), - _ => None, - }) - .or_else(|| { - document.objects.values().find_map(|o| match o { - ObjectType::MLineStyle(s) if s.name.eq_ignore_ascii_case(&m.style_name) => Some(s), - _ => None, - }) - }); - // (offset, colour, linetype) per element. let elems: Vec<(f64, Color, String)> = match style { Some(s) if !s.elements.is_empty() => s @@ -60,9 +477,7 @@ pub fn mline_lines(m: &MLine, document: &acadrust::CadDocument) -> Vec Vec f64 { m.vertices[vi] .segments @@ -94,61 +504,55 @@ pub fn mline_lines(m: &MLine, document: &acadrust::CadDocument) -> Vec [f64; 3] { let v = &m.vertices[vi]; - [ - v.position.x + v.miter.x * d, - v.position.y + v.miter.y * d, - v.position.z + v.miter.z * d, - ] + (cadkernel::space::Vec3::new(v.position.x, v.position.y, v.position.z) + + cadkernel::space::Vec3::new(v.miter.x, v.miter.y, v.miter.z) * d) + .to_array() + }; + let endpoint_pt = |vi: usize, ei: usize| -> [f64; 3] { + let point = off_pt(vi, elem_off(vi, ei)); + if closed || (vi != 0 && vi + 1 != n) { + return point; + } + let Some(style) = style else { + return point; + }; + adjusted_mline_endpoint(m, style, vi, point) }; let mut out: Vec = Vec::with_capacity(elems.len() + 2); for (ei, (_, color, linetype)) in elems.iter().enumerate() { let mut pts: Vec<[f64; 3]> = Vec::new(); - // Walk each segment (vi → vi+1, wrapping when closed); the vertex's - // parameters[1..] are draw-toggle distances along the element line — - // this is how crossing/merged multilines store their gaps. An odd - // toggle count leaves the final run open to the segment end. + // Parameters store the start offset, then alternating drawn and gap lengths. let seg_count = if closed { n } else { n.saturating_sub(1) }; let mut pen_at_end = false; for k in 0..seg_count { let vi = k; let wi = (k + 1) % n; - let a = off_pt(vi, elem_off(vi, ei)); - let b = off_pt(wi, elem_off(wi, ei)); - let seg = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; - let len = (seg[0] * seg[0] + seg[1] * seg[1] + seg[2] * seg[2]).sqrt(); - if len < 1e-12 { - continue; - } - let dirn = [seg[0] / len, seg[1] / len, seg[2] / len]; - let at = |t: f64| -> [f64; 3] { - [a[0] + dirn[0] * t, a[1] + dirn[1] * t, a[2] + dirn[2] * t] + let a = if !closed && vi == 0 { + endpoint_pt(vi, ei) + } else { + off_pt(vi, elem_off(vi, ei)) }; - let toggles: &[f64] = m + let b = if !closed && wi + 1 == n { + endpoint_pt(wi, ei) + } else { + off_pt(wi, elem_off(wi, ei)) + }; + let a = cadkernel::space::Vec3::from(a); + let b = cadkernel::space::Vec3::from(b); + let segment = b - a; + let len = segment.length(); + let Some(direction) = segment.normalize() else { + continue; + }; + let at = |t: f64| (a + direction * t).to_array(); + let parameters: &[f64] = m .vertices[vi] .segments .get(ei) - .map(|sg| sg.parameters.get(1..).unwrap_or(&[])) + .map(|segment| segment.parameters.as_slice()) .unwrap_or(&[]); - // Build (start, end) runs from the toggle list. - let mut runs: Vec<(f64, f64)> = Vec::new(); - if toggles.is_empty() { - runs.push((0.0, len)); - } else { - let mut i = 0; - while i < toggles.len() { - let start = toggles[i].clamp(0.0, len); - let end = toggles - .get(i + 1) - .copied() - .unwrap_or(len) - .clamp(0.0, len); - if end - start > 1e-9 { - runs.push((start, end)); - } - i += 2; - } - } + let runs = mline_drawn_ranges(parameters, len); for (ri, (t0, t1)) in runs.iter().enumerate() { let continuous = pen_at_end && ri == 0 && *t0 <= 1e-9 && !pts.is_empty(); if !continuous { @@ -171,32 +575,104 @@ pub fn mline_lines(m: &MLine, document: &acadrust::CadDocument) -> Vec= 2 { - let mut cap = |vi: usize| { - let mut dlo = f64::INFINITY; - let mut dhi = f64::NEG_INFINITY; - for ei in 0..elems.len() { - let d = elem_off(vi, ei); - dlo = dlo.min(d); - dhi = dhi.max(d); + let outer_points = |vi: usize, endpoint: bool| -> Option<([f64; 3], [f64; 3])> { + let mut order: Vec = (0..elems.len()).collect(); + order.sort_by(|a, b| elem_off(vi, *a).total_cmp(&elem_off(vi, *b))); + let first = *order.first()?; + let last = *order.last()?; + let point = |ei| { + if endpoint { + endpoint_pt(vi, ei) + } else { + off_pt(vi, elem_off(vi, ei)) } - if (dhi - dlo).abs() > 1e-9 { + }; + Some((point(first), point(last))) + }; + + if s.flags.display_joints { + let vertices: Box> = if closed { + Box::new(0..n) + } else { + Box::new(1..n.saturating_sub(1)) + }; + for vi in vertices { + if let Some((a, b)) = outer_points(vi, false) { out.push(MLineLine { - points: vec![off_pt(vi, dlo), off_pt(vi, dhi)], + points: vec![a, b], color: Color::ByLayer, linetype: "ByLayer".to_string(), }); } - }; - if s.flags.start_square_cap { - cap(0); } - if s.flags.end_square_cap { - cap(n - 1); + } + + if !closed && n >= 2 { + let start_suppressed = m.flags.contains(MLineFlags::NO_START_CAPS); + let end_suppressed = m.flags.contains(MLineFlags::NO_END_CAPS); + for (vi, start, suppressed, square, inner, round) in [ + ( + 0, + true, + start_suppressed, + s.flags.start_square_cap, + s.flags.start_inner_arcs_cap, + s.flags.start_round_cap, + ), + ( + n - 1, + false, + end_suppressed, + s.flags.end_square_cap, + s.flags.end_inner_arcs_cap, + s.flags.end_round_cap, + ), + ] { + if suppressed { + continue; + } + let Some((a, b)) = outer_points(vi, true) else { + continue; + }; + if square { + out.push(MLineLine { + points: vec![a, b], + color: Color::ByLayer, + linetype: "ByLayer".to_string(), + }); + } + let direction = [ + m.vertices[vi].direction.x, + m.vertices[vi].direction.y, + m.vertices[vi].direction.z, + ]; + if round { + out.push(MLineLine { + points: semicircle_cap(a, b, direction, start), + color: Color::ByLayer, + linetype: "ByLayer".to_string(), + }); + } + if inner && elems.len() > 2 { + let mut order: Vec = (0..elems.len()).collect(); + order.sort_by(|left, right| { + elem_off(vi, *left).total_cmp(&elem_off(vi, *right)) + }); + for pair in order.windows(2) { + out.push(MLineLine { + points: semicircle_cap( + endpoint_pt(vi, pair[0]), + endpoint_pt(vi, pair[1]), + direction, + start, + ), + color: Color::ByLayer, + linetype: "ByLayer".to_string(), + }); + } + } } } } @@ -204,15 +680,162 @@ pub fn mline_lines(m: &MLine, document: &acadrust::CadDocument) -> Vec Vec<[f64; 3]> { + let first = cadkernel::space::Vec3::from(first); + let second = cadkernel::space::Vec3::from(second); + let center = (first + second) * 0.5; + let transverse = (first - second) * 0.5; + let radius = transverse.length(); + let Some(x_axis) = transverse.normalize() else { + return Vec::new(); + }; + let Some(direction) = cadkernel::space::Vec3::from(direction).normalize() else { + return Vec::new(); + }; + let y_axis = if start { -direction } else { direction }; + cadkernel::space::PlanarCurve::new( + Plane::from_axes(center.to_array(), x_axis.to_array(), y_axis.to_array()), + cadkernel::geom2d::Curve::Arc(cadkernel::geom2d::Arc { + centre: [0.0, 0.0], + radius, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + }), + ) + .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) +} + +pub fn mline_fill_triangles_with_style( + m: &MLine, + style: &acadrust::objects::MLineStyle, +) -> Vec<[f64; 3]> { + if !style.flags.fill_on || m.vertices.len() < 2 || style.elements.len() < 2 { + return Vec::new(); + } + let (low_index, high_index) = style + .elements + .iter() + .enumerate() + .fold((0, 0), |(low, high), (index, element)| { + let offset = element.offset * m.scale_factor; + let low = if offset < style.elements[low].offset * m.scale_factor { + index + } else { + low + }; + let high = if offset > style.elements[high].offset * m.scale_factor { + index + } else { + high + }; + (low, high) + }); + let minimum = style + .elements + .iter() + .map(|element| element.offset) + .fold(f64::INFINITY, f64::min); + let maximum = style + .elements + .iter() + .map(|element| element.offset) + .fold(f64::NEG_INFINITY, f64::max); + let shift = match m.justification { + acadrust::entities::MLineJustification::Top => -maximum, + acadrust::entities::MLineJustification::Zero => 0.0, + acadrust::entities::MLineJustification::Bottom => -minimum, + }; + let offset_point = |vertex: usize, element: usize, endpoint: bool| -> [f64; 3] { + let item = &m.vertices[vertex]; + let distance = item + .segments + .get(element) + .and_then(|segment| segment.parameters.first()) + .copied() + .unwrap_or((style.elements[element].offset + shift) * m.scale_factor); + let point = (cadkernel::space::Vec3::new( + item.position.x, + item.position.y, + item.position.z, + ) + cadkernel::space::Vec3::new(item.miter.x, item.miter.y, item.miter.z) * distance) + .to_array(); + if endpoint { + adjusted_mline_endpoint(m, style, vertex, point) + } else { + point + } + }; + let closed = m.flags.contains(MLineFlags::CLOSED); + let segment_count = if closed { + m.vertices.len() + } else { + m.vertices.len() - 1 + }; + let mut triangles = Vec::with_capacity(segment_count * 6); + for vertex in 0..segment_count { + let next = (vertex + 1) % m.vertices.len(); + let start_endpoint = !closed && vertex == 0; + let end_endpoint = !closed && next + 1 == m.vertices.len(); + let low_start = cadkernel::space::Vec3::from(offset_point( + vertex, + low_index, + start_endpoint, + )); + let low_end = cadkernel::space::Vec3::from(offset_point(next, low_index, end_endpoint)); + let high_start = cadkernel::space::Vec3::from(offset_point( + vertex, + high_index, + start_endpoint, + )); + let high_end = cadkernel::space::Vec3::from(offset_point(next, high_index, end_endpoint)); + let low_length = low_start.distance(low_end); + let high_length = high_start.distance(high_end); + if low_length <= 1.0e-12 || high_length <= 1.0e-12 { + continue; + } + let fill_ranges = |element: usize, length: f64| { + let parameters = m.vertices[vertex] + .segments + .get(element) + .map(|segment| segment.area_fill_parameters.as_slice()) + .unwrap_or(&[]); + mline_fill_ranges(parameters, length) + .into_iter() + .map(|(start, end)| (start / length, end / length)) + .collect::>() + }; + let low_ranges = fill_ranges(low_index, low_length); + let high_ranges = fill_ranges(high_index, high_length); + for low in &low_ranges { + for high in &high_ranges { + let start = low.0.max(high.0); + let end = low.1.min(high.1); + if end - start <= 1.0e-9 { + continue; + } + let a = low_start.lerp(low_end, start).to_array(); + let b = high_start.lerp(high_end, start).to_array(); + let c = high_start.lerp(high_end, end).to_array(); + let d = low_start.lerp(low_end, end).to_array(); + triangles.extend([a, b, c, a, c, d]); + } + } + } + triangles +} + impl RenderConvertible for MLine { fn to_render(&self, document: &acadrust::CadDocument) -> Option { if self.vertices.is_empty() { return None; } - // NaN-separated flat list of every element line (single-colour path used - // by pick and the edit commands; the coloured render is built in - // `tessellate`, which special-cases MLINE). + // Picking uses one NaN-separated line list; rendering keeps style colours. let lines = mline_lines(self, document); let mut pts: Vec<[f64; 3]> = Vec::new(); for (i, l) in lines.iter().enumerate() { @@ -265,18 +888,67 @@ impl Grippable for MLine { } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { - if let Some(v) = self.vertices.get_mut(grip_id) { - match apply { - GripApply::Translate(d) => { - v.position.x += d.x as f64; - v.position.y += d.y as f64; - v.position.z += d.z as f64; - } - GripApply::Absolute(p) => { - v.position.x = p.x as f64; - v.position.y = p.y as f64; - v.position.z = p.z as f64; - } + let Some(vertex) = self.vertices.get(grip_id) else { + return; + }; + let position = match apply { + GripApply::Translate(delta) => acadrust::types::Vector3::new( + vertex.position.x + delta.x as f64, + vertex.position.y + delta.y as f64, + vertex.position.z + delta.z as f64, + ), + GripApply::Absolute(point) => { + acadrust::types::Vector3::new(point.x as f64, point.y as f64, point.z as f64) + } + }; + let count = self.vertices.len(); + let segment_count = if self.is_closed() { + count + } else { + count.saturating_sub(1) + }; + let mut affected = Vec::new(); + let candidates = if self.is_closed() { + vec![ + ((grip_id + count - 2) % count, false), + ((grip_id + count - 1) % count, false), + (grip_id, true), + ((grip_id + 1) % count, true), + ] + } else { + let mut candidates = Vec::new(); + if grip_id >= 2 { + candidates.push((grip_id - 2, false)); + } + if grip_id >= 1 { + candidates.push((grip_id - 1, false)); + } + if grip_id < segment_count { + candidates.push((grip_id, true)); + } + if grip_id + 1 < segment_count { + candidates.push((grip_id + 1, true)); + } + candidates + }; + for (segment, moved_start) in candidates { + if segment >= segment_count + || affected + .iter() + .any(|(existing, _, _)| *existing == segment) + { + continue; + } + if let Some(source) = snapshot_mline_segment(self, segment) { + affected.push((segment, moved_start, source)); + } + } + let offsets = mline_perpendicular_offsets(self); + self.vertices[grip_id].position = position; + if rebuild_mline_geometry(self) { + restore_mline_offsets(self, &offsets); + for (segment, moved_start, source) in affected { + restore_shifted_mline_segment(self, segment, source, moved_start); } } } @@ -304,20 +976,205 @@ impl Grippable for MLine { let n = self.vertices.len(); match action { A::AddVertex if grip_id < n => { - let i1 = (grip_id + 1).min(n - 1); - if i1 == grip_id { + let insert = if grip_id + 1 < n { + grip_id + 1 + } else if self.is_closed() { + n + } else { return; - } + }; + let next = (grip_id + 1) % n; let v0 = &self.vertices[grip_id]; - let v1 = &self.vertices[i1]; + let v1 = &self.vertices[next]; let mut new_v = v0.clone(); new_v.position.x = (v0.position.x + v1.position.x) * 0.5; new_v.position.y = (v0.position.y + v1.position.y) * 0.5; new_v.position.z = (v0.position.z + v1.position.z) * 0.5; - self.vertices.insert(i1, new_v); + let point = cadkernel::space::Vec3::new( + new_v.position.x, + new_v.position.y, + new_v.position.z, + ); + let source: Option> = (0..self.style_element_count) + .map(|element| { + let endpoints = mline_element_endpoints(self, grip_id, element)?; + let direction = endpoints.1 - endpoints.0; + let length = direction.length(); + let split = (point - endpoints.0) + .dot(direction.normalize()?) + .clamp(0.0, length); + Some(( + self.vertices[grip_id].segments.get(element)?.clone(), + length, + split, + )) + }) + .collect(); + let Some(source) = source else { + return; + }; + self.vertices.insert(insert, new_v); + let offsets = mline_perpendicular_offsets(self); + if !rebuild_mline_geometry(self) { + return; + } + restore_mline_offsets(self, &offsets); + for (element, (source, source_length, split)) in source.into_iter().enumerate() { + let Some(first) = mline_element_endpoints(self, grip_id, element) else { + continue; + }; + let Some(second) = mline_element_endpoints(self, insert, element) else { + continue; + }; + let first_length = first.0.distance(first.1); + let second_length = second.0.distance(second.1); + let drawn = mline_drawn_ranges(&source.parameters, source_length); + let cuts = mline_cut_ranges(&source.area_fill_parameters, source_length); + let first_drawn = remap_mline_ranges(&drawn, 0.0, split, first_length); + let second_drawn = + remap_mline_ranges(&drawn, split, source_length, second_length); + let first_cuts = remap_mline_ranges(&cuts, 0.0, split, first_length); + let second_cuts = + remap_mline_ranges(&cuts, split, source_length, second_length); + let first = &mut self.vertices[grip_id].segments[element]; + store_mline_drawn_ranges( + &mut first.parameters, + first_length, + &first_drawn, + ); + store_mline_cut_ranges( + &mut first.area_fill_parameters, + first_length, + &first_cuts, + ); + let second = &mut self.vertices[insert].segments[element]; + store_mline_drawn_ranges( + &mut second.parameters, + second_length, + &second_drawn, + ); + store_mline_cut_ranges( + &mut second.area_fill_parameters, + second_length, + &second_cuts, + ); + } } - A::RemoveVertex if grip_id < n && n > 2 => { + A::RemoveVertex + if grip_id < n && n > 2 && (!self.is_closed() || n > 3) => + { + let merge = self.is_closed() || (grip_id > 0 && grip_id + 1 < n); + let previous = if grip_id == 0 { n - 1 } else { grip_id - 1 }; + let source = merge.then(|| { + let first = snapshot_mline_segment(self, previous)?; + let second = snapshot_mline_segment(self, grip_id)?; + Some((first, second)) + }); + let source = match source { + Some(Some(source)) => Some(source), + Some(None) => return, + None => None, + }; + let mut peripheral = Vec::new(); + let candidates = if self.is_closed() { + vec![ + ((previous + n - 1) % n, false), + ((grip_id + 1) % n, true), + ] + } else { + let mut candidates = Vec::new(); + if grip_id >= 2 { + candidates.push((grip_id - 2, false)); + } + if grip_id + 2 < n { + candidates.push((grip_id + 1, true)); + } + candidates + }; + for (segment, moved_start) in candidates { + if (merge && (segment == previous || segment == grip_id)) + || peripheral + .iter() + .any(|(existing, _, _)| *existing == segment) + { + continue; + } + if let Some(snapshot) = snapshot_mline_segment(self, segment) { + let target = if segment > grip_id { + segment - 1 + } else { + segment + }; + peripheral.push((target, moved_start, snapshot)); + } + } + let mut offsets = mline_perpendicular_offsets(self); self.vertices.remove(grip_id); + offsets.remove(grip_id); + if !rebuild_mline_geometry(self) { + return; + } + restore_mline_offsets(self, &offsets); + for (segment, moved_start, snapshot) in peripheral { + restore_shifted_mline_segment(self, segment, snapshot, moved_start); + } + if let Some((first, second)) = source { + let previous = if grip_id == 0 { + self.vertices.len() - 1 + } else { + grip_id - 1 + }; + for (element, ((first, first_length), (second, second_length))) in + first.into_iter().zip(second).enumerate() + { + let Some(endpoints) = mline_element_endpoints(self, previous, element) + else { + continue; + }; + let target_length = endpoints.0.distance(endpoints.1); + let total = first_length + second_length; + let mut drawn = mline_drawn_ranges(&first.parameters, first_length); + drawn.extend( + mline_drawn_ranges(&second.parameters, second_length) + .into_iter() + .map(|range| { + (range.0 + first_length, range.1 + first_length) + }), + ); + let mut cuts = + mline_cut_ranges(&first.area_fill_parameters, first_length); + cuts.extend( + mline_cut_ranges(&second.area_fill_parameters, second_length) + .into_iter() + .map(|range| { + (range.0 + first_length, range.1 + first_length) + }), + ); + let drawn = merge_mline_ranges(remap_mline_ranges( + &drawn, + 0.0, + total, + target_length, + )); + let cuts = merge_mline_ranges(remap_mline_ranges( + &cuts, + 0.0, + total, + target_length, + )); + let target = &mut self.vertices[previous].segments[element]; + store_mline_drawn_ranges( + &mut target.parameters, + target_length, + &drawn, + ); + store_mline_cut_ranges( + &mut target.area_fill_parameters, + target_length, + &cuts, + ); + } + } } _ => {} } @@ -334,11 +1191,7 @@ impl PropertyEditable for MLine { vec![PropSection { title: t!("Misc").into_owned(), props: vec![ - Property { - label: t!("Style").into_owned(), - field: "ml_style", - value: PropValue::PlainText(self.style_name.clone()), - }, + ro_prop(t!("Style").as_ref(), "ml_style", self.style_name.clone()), Property { label: t!("Style justification").into_owned(), field: "ml_justification", @@ -363,8 +1216,13 @@ impl PropertyEditable for MLine { } else { value == "true" }; - self.flags - .set(acadrust::entities::MLineFlags::CLOSED, closed); + let offsets = mline_perpendicular_offsets(self); + self.flags.set( + acadrust::entities::MLineFlags::CLOSED, + closed && self.vertices.len() >= 3, + ); + rebuild_mline_geometry(self); + restore_mline_offsets(self, &offsets); return; } "ml_justification" => { @@ -375,10 +1233,6 @@ impl PropertyEditable for MLine { }; return; } - "ml_style" => { - self.style_name = value.to_string(); - return; - } _ => {} } let Ok(v) = value.trim().parse::() else { @@ -392,7 +1246,11 @@ impl PropertyEditable for MLine { fn set_mline_scale(mline: &mut MLine, scale: f64) { let old = mline.scale_factor; - if !scale.is_finite() || scale == 0.0 || !old.is_finite() || old == 0.0 { + if !scale.is_finite() || scale == 0.0 || !old.is_finite() { + return; + } + if old == 0.0 { + mline.scale_factor = scale; return; } let ratio = scale / old; @@ -420,6 +1278,54 @@ fn set_mline_scale(mline: &mut MLine, scale: f64) { mline.scale_factor = scale; } +fn mline_vertex_factor(mline: &MLine, index: usize) -> f64 { + let vertex = &mline.vertices[index]; + let normal = cadkernel::space::Vec3::new(mline.normal.x, mline.normal.y, mline.normal.z) + .normalize() + .unwrap_or(cadkernel::space::Vec3::Z); + let direction = + cadkernel::space::Vec3::new(vertex.direction.x, vertex.direction.y, vertex.direction.z) + .normalize() + .unwrap_or(cadkernel::space::Vec3::X); + let miter = cadkernel::space::Vec3::new(vertex.miter.x, vertex.miter.y, vertex.miter.z) + .normalize() + .unwrap_or(cadkernel::space::Vec3::Y); + miter.dot(normal.cross(direction)).abs().max(1.0e-9) +} + +fn mline_perpendicular_offsets(mline: &MLine) -> Vec>> { + mline + .vertices + .iter() + .enumerate() + .map(|(index, vertex)| { + let factor = mline_vertex_factor(mline, index); + vertex + .segments + .iter() + .map(|segment| segment.parameters.first().map(|value| value * factor)) + .collect() + }) + .collect() +} + +fn restore_mline_offsets(mline: &mut MLine, offsets: &[Vec>]) { + for index in 0..mline.vertices.len().min(offsets.len()) { + let factor = mline_vertex_factor(mline, index); + for (segment, offset) in mline.vertices[index] + .segments + .iter_mut() + .zip(&offsets[index]) + { + if let Some(offset) = offset { + if let Some(first) = segment.parameters.first_mut() { + *first = *offset / factor; + } + } + } + } +} + impl Transformable for MLine { fn apply_transform(&mut self, t: &EntityTransform) { crate::scene::view::transform::apply_standard_entity_transform(self, t, |entity, p1, p2| { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 0f86e646..c9d6a894 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1536,7 +1536,28 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "MLEADERSTYLE: '{}' not found." => Some(("layers", "mleaderstyle-not-found")), "MLEADERSTYLE: current style set to '{name}'." => Some(("layers", "mleaderstyle-current-style-set-to-name")), "MLEADERSTYLE: unknown property '{}'. Try: text_height arrowhead_size landing_distance landing_gap" => Some(("layers", "mleaderstyle-unknown-property-try-text-height")), + "Add Vertex" => Some(("modify", "add-vertex")), + "Closed Cross" => Some(("modify", "closed-cross")), + "Closed Tee" => Some(("modify", "closed-tee")), + "Corner Joint" => Some(("modify", "corner-joint")), + "Cut All" => Some(("modify", "cut-all")), + "Cut Single" => Some(("modify", "cut-single")), + "Delete Vertex" => Some(("modify", "delete-vertex")), + "Loaded multiline styles: {}" => Some(("draw", "loaded-multiline-styles")), + "Merged Cross" => Some(("modify", "merged-cross")), + "Merged Tee" => Some(("modify", "merged-tee")), + "MLEDIT Choose an edit tool:" => Some(("modify", "mledit-choose-an-edit-tool")), + "MLEDIT Select first multiline:" => Some(("modify", "mledit-select-first-multiline")), + "MLEDIT Select second multiline:" => Some(("modify", "mledit-select-second-multiline")), + "MLEDIT Specify the end of the weld range:" => Some(("modify", "mledit-specify-end-of-weld-range")), + "MLEDIT Specify the second cut point:" => Some(("modify", "mledit-specify-second-cut-point")), + "MLINE Current settings: Justification = {}, Scale = {}, Style = {}\nSpecify start point or [Justification/Scale/STyle]:" => Some(("draw", "mline-current-settings")), + "MLINE Enter justification type [Top/Zero/Bottom] <{}>:" => Some(("draw", "mline-enter-justification")), + "MLINE Enter scale factor <{}>:" => Some(("draw", "mline-enter-scale-factor-default")), "MLINE Enter scale factor:" => Some(("draw", "mline-enter-scale-factor")), + "MLINE Enter style name or [?] <{}>:" => Some(("draw", "mline-enter-style-name")), + "MLINE Specify next point or [Close/Undo]:" => Some(("draw", "mline-specify-next-point-close-undo")), + "MLINE Specify next point or [Undo]:" => Some(("draw", "mline-specify-next-point-undo")), "MLINE Specify next point (%{count} pts, Enter to finish, C to close, S to set scale):" => Some(("draw", "mline-specify-next-point-count-pts-enter-to-fi")), "MLINE Specify start point (scale=%{scale}):" => Some(("draw", "mline-specify-start-point-scale-scale")), "MLSTYLE: '{}' already exists." => Some(("layers", "mlstyle-already-exists")), @@ -1646,7 +1667,12 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Multileader Style Manager" => Some(("styles", "multileader-style-manager")), "Multileader draw order:" => Some(("annotate", "multileader-draw-order")), "Multileader style" => Some(("properties", "multileader-style")), + "Multiline style \"%{style}\" was not found." => Some(("draw", "multiline-style-was-not-found")), "Multiline\nText" => Some(("annotate", "multiline-text")), + "Open Cross" => Some(("modify", "open-cross")), + "Open Tee" => Some(("modify", "open-tee")), + "Remove Vertex" => Some(("modify", "remove-vertex")), + "Weld All" => Some(("modify", "weld-all")), "Multiline Style Manager" => Some(("styles", "multiline-style-manager")), "Multiple" => Some(("common", "multiple")), "Multiple lines" => Some(("properties", "multiple-lines")), diff --git a/src/modules/draw/draw/mline.rs b/src/modules/draw/draw/mline.rs index 1be63334..9e03c457 100644 --- a/src/modules/draw/draw/mline.rs +++ b/src/modules/draw/draw/mline.rs @@ -1,58 +1,217 @@ -// MLINE command — create a multiline (parallel lines). -// -// Workflow: pick vertices, Enter to finish. -// Text input (when >= 1 point picked): -// C / CLOSE → close and commit -// S → set scale factor then continue picking +// MLINE command — create a styled group of parallel lines. -use acadrust::entities::MLine; +use acadrust::entities::{MLine, MLineFlags, MLineJustification, MLineSegment, MLineVertex}; +use acadrust::objects::MLineStyle; use acadrust::types::Vector3; -use acadrust::EntityType; +use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::t; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; use crate::scene::model::wire_model::WireModel; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Start, + Next, + Justification, + Scale, + Style, +} + pub struct MlineCommand { points: Vec, scale: f64, - waiting_scale: bool, + justification: MLineJustification, + step: Step, style_name: String, - style_handle: Option, - style_element_count: usize, + style_handle: Option, + styles: Vec<(Handle, MLineStyle)>, + notice: Option, plane: WorkingPlane, } impl MlineCommand { - #[allow(dead_code)] - pub fn new() -> Self { + pub fn with_styles( + mut styles: Vec<(Handle, MLineStyle)>, + style_name: impl Into, + scale: f64, + justification: i16, + ) -> Self { + let requested = style_name.into(); + if styles.is_empty() { + styles.push((Handle::NULL, MLineStyle::standard())); + } + let selected = styles + .iter() + .find(|(_, style)| style.name.eq_ignore_ascii_case(&requested)) + .or_else(|| styles.first()); + let (style_handle, style_name) = selected + .map(|(handle, style)| { + ( + (!handle.is_null()).then_some(*handle), + style.name.clone(), + ) + }) + .unwrap_or((None, requested)); Self { - points: vec![], - scale: 1.0, - waiting_scale: false, - style_name: "Standard".into(), - style_handle: None, - style_element_count: 2, + points: Vec::new(), + scale: if scale.is_finite() && scale != 0.0 { + scale + } else { + 1.0 + }, + justification: MLineJustification::from(justification), + step: Step::Start, + style_name, + style_handle, + styles, + notice: None, plane: WorkingPlane::default(), } } - pub fn with_style( - style_name: impl Into, - style_handle: Option, - style_element_count: usize, - ) -> Self { - Self { - points: vec![], - scale: 1.0, - waiting_scale: false, - style_name: style_name.into(), - style_handle, - style_element_count: style_element_count.max(1), - plane: WorkingPlane::default(), + fn selected_style(&self) -> Option<&MLineStyle> { + self.styles + .iter() + .find(|(handle, style)| { + self.style_handle == Some(*handle) + || style.name.eq_ignore_ascii_case(&self.style_name) + }) + .map(|(_, style)| style) + } + + fn justification_name(&self) -> String { + match self.justification { + MLineJustification::Top => crate::t!("Top").into_owned(), + MLineJustification::Zero => crate::t!("Zero").into_owned(), + MLineJustification::Bottom => crate::t!("Bottom").into_owned(), } } + + fn select_style(&mut self, name: &str) -> bool { + let Some((handle, style)) = self + .styles + .iter() + .find(|(_, style)| style.name.eq_ignore_ascii_case(name.trim())) + else { + return false; + }; + self.style_handle = (!handle.is_null()).then_some(*handle); + self.style_name = style.name.clone(); + true + } + + fn commit(&self, closed: bool) -> Option { + let style = self.selected_style()?; + let local: Vec = self + .points + .iter() + .map(|point| self.plane.to_local(*point)) + .collect(); + let entity = build_mline( + &local, + self.scale, + self.justification, + closed, + style, + self.style_handle, + ); + Some(self.plane.place_entity(entity)) + } + + fn preview(&self, cursor: DVec3) -> Option { + let style = self.selected_style()?; + let mut local: Vec = self + .points + .iter() + .map(|point| self.plane.to_local(*point)) + .collect(); + let cursor_local = self.plane.to_local(cursor); + if local + .last() + .is_none_or(|last| last.distance_squared(cursor_local) > 1.0e-20) + { + local.push(cursor_local); + } + if local.len() < 2 { + return None; + } + let EntityType::MLine(mline) = build_mline( + &local, + self.scale, + self.justification, + false, + style, + self.style_handle, + ) else { + return None; + }; + let mut points = Vec::new(); + for (line_index, line) in + crate::entities::mline::mline_lines_with_style(&mline, style) + .into_iter() + .enumerate() + { + if line_index > 0 { + points.push([f64::NAN; 3]); + } + points.extend(line.points.into_iter().map(|point| { + if point[0].is_nan() { + [f64::NAN; 3] + } else { + let world = self + .plane + .to_world(DVec3::new(point[0], point[1], point[2])); + world.to_array() + } + })); + } + let (points, points_low) = + crate::scene::convert::tessellate::points_to_ds(points); + let fill_tris = crate::entities::mline::mline_fill_triangles_with_style(&mline, style) + .into_iter() + .map(|point| { + let world = self + .plane + .to_world(DVec3::new(point[0], point[1], point[2])); + world.to_array() + }) + .collect::>(); + let (fill_tris, fill_tris_low) = + crate::scene::convert::tessellate::points_to_ds(fill_tris); + Some(WireModel { + point_marker: None, + taper_widths: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: true, + 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: "mline_preview".into(), + points, + points_low, + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris, + fill_tris_low, + }) + } } impl CadCommand for MlineCommand { @@ -65,178 +224,256 @@ impl CadCommand for MlineCommand { } fn prompt(&self) -> String { - if self.waiting_scale { - t!("MLINE Enter scale factor:").into_owned() - } else if self.points.is_empty() { - t!( - "MLINE Specify start point (scale=%{scale}):", - scale = format!("{:.2}", self.scale) + let notice = self + .notice + .as_ref() + .map(|value| format!("{value}\n")) + .unwrap_or_default(); + let body = match self.step { + Step::Start => crate::tf!( + "MLINE Current settings: Justification = {}, Scale = {}, Style = {}\nSpecify start point or [Justification/Scale/STyle]:", + self.justification_name(), self.scale, self.style_name ) - .into_owned() - } else { - t!( - "MLINE Specify next point (%{count} pts, Enter to finish, C to close, S to set scale):", - count = self.points.len() + .into_owned(), + Step::Next if self.points.len() >= 3 => { + crate::t!("MLINE Specify next point or [Close/Undo]:").into_owned() + } + Step::Next => crate::t!("MLINE Specify next point or [Undo]:").into_owned(), + Step::Justification => crate::tf!( + "MLINE Enter justification type [Top/Zero/Bottom] <{}>:", + self.justification_name() ) - .into_owned() + .into_owned(), + Step::Scale => { + crate::tf!("MLINE Enter scale factor <{}>:", self.scale).into_owned() + } + Step::Style => { + crate::tf!("MLINE Enter style name or [?] <{}>:", self.style_name).into_owned() + } + }; + format!("{notice}{body}") + } + + fn options(&self) -> Vec { + match self.step { + Step::Start => vec![ + CmdOption::new(crate::t!("Justification").as_ref(), "J"), + CmdOption::new(crate::t!("Scale").as_ref(), "S"), + CmdOption::new(crate::t!("Style").as_ref(), "ST"), + ], + Step::Next if self.points.len() >= 3 => vec![ + CmdOption::new(crate::t!("Close").as_ref(), "C"), + CmdOption::new(crate::t!("Undo").as_ref(), "U"), + ], + Step::Next => vec![CmdOption::new(crate::t!("Undo").as_ref(), "U")], + Step::Justification => vec![ + CmdOption::new(crate::t!("Top").as_ref(), "T"), + CmdOption::new(crate::t!("Zero").as_ref(), "Z"), + CmdOption::new(crate::t!("Bottom").as_ref(), "B"), + ], + Step::Scale => Vec::new(), + Step::Style => self + .styles + .iter() + .map(|(_, style)| CmdOption::new(&style.name, &style.name)) + .collect(), } } fn wants_text_input(&self) -> bool { - self.waiting_scale || !self.points.is_empty() + true } fn point_step_accepts_keywords(&self) -> bool { - // The vertex steps accept J / S keywords but are point picks, so keep - // polar dynamic input. The scale prompt (`waiting_scale`) is genuine - // text entry and is excluded. - !self.waiting_scale && !self.points.is_empty() + matches!(self.step, Step::Start | Step::Next) + } + + fn mline_settings(&self) -> Option<(f64, i16, String, Option)> { + Some(( + self.scale, + self.justification as i16, + self.style_name.clone(), + self.style_handle, + )) } fn on_text_input(&mut self, text: &str) -> Option { - // Waiting for scale value - if self.waiting_scale { - let v: f64 = text - .trim() - .replace(',', ".") - .parse() - .ok() - .filter(|&v: &f64| v > 0.0)?; - self.scale = v; - self.waiting_scale = false; - return Some(CmdResult::NeedPoint); - } - - let up = text.trim().to_uppercase(); - - // Close command - if (up == "C" || up == "CLOSE") && self.points.len() >= 3 { - let entity = build_mline( - &self - .points - .iter() - .map(|point| self.plane.to_local(*point)) - .collect::>(), - self.scale, - true, - &self.style_name, - self.style_handle, - self.style_element_count, - ); - return Some(CmdResult::CommitAndExit(self.plane.place_entity(entity))); - } - - // Scale: "S" alone → prompt for value - if up == "S" { - self.waiting_scale = true; - return Some(CmdResult::NeedPoint); - } - - // Scale: "S " inline - if let Some(rest) = up.strip_prefix("S ") { - if let Ok(v) = rest.trim().replace(',', ".").parse::() { - if v > 0.0 { - self.scale = v; + let token = text.trim(); + let upper = token.to_uppercase(); + self.notice = None; + match self.step { + Step::Start => match upper.as_str() { + "J" | "JUSTIFICATION" => self.step = Step::Justification, + "S" | "SCALE" => self.step = Step::Scale, + "ST" | "STYLE" => self.step = Step::Style, + _ => return None, + }, + Step::Next => match upper.as_str() { + "U" | "UNDO" => { + self.points.pop(); + if self.points.is_empty() { + self.step = Step::Start; + } + } + "C" | "CLOSE" if self.points.len() >= 3 => { + return self.commit(true).map(CmdResult::CommitAndExit); + } + _ => return None, + }, + Step::Justification => { + self.justification = match upper.as_str() { + "T" | "TOP" => MLineJustification::Top, + "Z" | "ZERO" => MLineJustification::Zero, + "B" | "BOTTOM" => MLineJustification::Bottom, + _ => return None, + }; + self.step = Step::Start; + } + Step::Scale => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() || value == 0.0 { + return None; + } + self.scale = value; + self.step = Step::Start; + } + Step::Style => { + if token == "?" { + self.notice = Some(crate::tf!( + "Loaded multiline styles: {}", + self.styles + .iter() + .map(|(_, style)| style.name.as_str()) + .collect::>() + .join(", ") + ) + .into_owned()); + } else if self.select_style(token) { + self.step = Step::Start; + } else { + self.notice = Some( + crate::t!( + "Multiline style \"%{style}\" was not found.", + style = token + ) + .into_owned(), + ); } - return Some(CmdResult::NeedPoint); } } - - None + Some(CmdResult::NeedPoint) } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - self.points.push(pt); + fn on_point(&mut self, point: DVec3) -> CmdResult { + if !matches!(self.step, Step::Start | Step::Next) { + return CmdResult::NeedPoint; + } + if self + .points + .last() + .is_some_and(|last| last.distance_squared(point) <= 1.0e-20) + { + return CmdResult::NeedPoint; + } + self.points.push(point); + self.step = Step::Next; CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - if self.points.len() < 2 { - return CmdResult::Cancel; + match self.step { + Step::Start if self.points.is_empty() => CmdResult::Cancel, + Step::Next if self.points.len() >= 2 => self + .commit(false) + .map(CmdResult::CommitAndExit) + .unwrap_or(CmdResult::Cancel), + Step::Justification | Step::Scale | Step::Style => { + self.step = Step::Start; + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, } - let entity = build_mline( - &self - .points - .iter() - .map(|point| self.plane.to_local(*point)) - .collect::>(), - self.scale, - false, - &self.style_name, - self.style_handle, - self.style_element_count, - ); - CmdResult::CommitAndExit(self.plane.place_entity(entity)) } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); + fn on_undo_step(&mut self) -> Option { if self.points.is_empty() { return None; } - let mut pts: Vec<[f32; 3]> = self - .points - .iter() - .map(|p| [p.x as f32, p.y as f32, p.z as f32]) - .collect(); - pts.push([pt.x, pt.y, pt.z]); - Some(WireModel { - point_marker: None, - taper_widths: 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: "mline_preview".into(), - points: pts, - 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(), - }) + self.points.pop(); + if self.points.is_empty() { + self.step = Step::Start; + } + Some(CmdResult::NeedPoint) } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + (!self.points.is_empty()).then(|| self.preview(point)).flatten() + } +} + +pub(crate) fn sync_mline_element_parameters(mline: &mut MLine, style: &MLineStyle) { + let offsets: Vec = if style.elements.is_empty() { + vec![0.5, -0.5] + } else { + style.elements.iter().map(|element| element.offset).collect() + }; + let minimum = offsets.iter().copied().fold(f64::INFINITY, f64::min); + let maximum = offsets + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let shift = match mline.justification { + MLineJustification::Top => -maximum, + MLineJustification::Zero => 0.0, + MLineJustification::Bottom => -minimum, + }; + let normal = glam::DVec3::new(mline.normal.x, mline.normal.y, mline.normal.z) + .normalize_or(glam::DVec3::Z); + for vertex in &mut mline.vertices { + let direction = glam::DVec3::new(vertex.direction.x, vertex.direction.y, vertex.direction.z) + .normalize_or(glam::DVec3::X); + let miter = glam::DVec3::new(vertex.miter.x, vertex.miter.y, vertex.miter.z) + .normalize_or(glam::DVec3::Y); + let factor = miter.dot(normal.cross(direction)).abs().max(1.0e-9); + vertex + .segments + .resize_with(offsets.len(), MLineSegment::new); + vertex.segments.truncate(offsets.len()); + for (segment, offset) in vertex.segments.iter_mut().zip(&offsets) { + let value = (offset + shift) * mline.scale_factor / factor; + if let Some(first) = segment.parameters.first_mut() { + *first = value; + } else { + segment.parameters.extend([value, 0.0]); + } + } + } + mline.style_element_count = offsets.len(); } fn build_mline( - pts: &[DVec3], + points: &[DVec3], scale: f64, + justification: MLineJustification, closed: bool, - style_name: &str, - style_handle: Option, - style_element_count: usize, + style: &MLineStyle, + style_handle: Option, ) -> EntityType { let mut mline = MLine::new(); mline.scale_factor = scale; - mline.style_name = style_name.to_string(); + mline.justification = justification; + mline.style_name = style.name.clone(); mline.style_handle = style_handle; - mline.style_element_count = style_element_count; - for point in pts { - mline.add_vertex(Vector3::new(point.x, point.y, point.z)); - } - if closed { - mline.close(); + mline.style_element_count = style.elements.len().max(1); + for point in points { + let mut vertex = MLineVertex::new(Vector3::new(point.x, point.y, point.z)); + vertex.init_segments(mline.style_element_count); + mline.vertices.push(vertex); } + mline.flags.set(MLineFlags::CLOSED, closed); + crate::entities::mline::rebuild_mline_geometry(&mut mline); + sync_mline_element_parameters(&mut mline, style); EntityType::MLine(mline) } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["MLINE"] }); // MlineCommand +inventory::submit!(crate::command::CommandRegistration { names: &["MLINE"] }); diff --git a/src/modules/draw/modify/mledit.rs b/src/modules/draw/modify/mledit.rs new file mode 100644 index 00000000..4edf80db --- /dev/null +++ b/src/modules/draw/modify/mledit.rs @@ -0,0 +1,1133 @@ +use acadrust::entities::MLine; +use acadrust::objects::MLineStyle; +use acadrust::{EntityType, Handle}; +use cadkernel::geom2d::{Curve, Line}; +use cadkernel::space::{PlanarCurve, Plane, Vec3 as KernelVec3}; +use glam::DVec3; +use rustc_hash::FxHashMap as HashMap; + +use crate::command::{CadCommand, CmdOption, CmdResult}; + +#[derive(Clone)] +pub struct MlineEditTarget { + pub entity: MLine, + pub style: MLineStyle, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tool { + ClosedCross, + OpenCross, + MergedCross, + ClosedTee, + OpenTee, + MergedTee, + CornerJoint, + AddVertex, + DeleteVertex, + CutSingle, + CutAll, + WeldAll, +} + +enum Mode { + Choose, + PickFirst(Tool), + PickSecond { + tool: Tool, + first: Handle, + first_pick: DVec3, + }, + PickRangeEnd { + tool: Tool, + target: Handle, + start: DVec3, + }, +} + +pub struct MlineEditCommand { + targets: HashMap, + mode: Mode, +} + +impl MlineEditCommand { + pub fn new(targets: HashMap) -> Self { + Self { + targets, + mode: Mode::Choose, + } + } + + fn target(&self, handle: Handle) -> Option<&MlineEditTarget> { + self.targets.get(&handle.value()) + } + + fn replace(handle: Handle, mline: MLine) -> CmdResult { + CmdResult::ReplaceMany( + vec![(handle, vec![EntityType::MLine(mline)])], + Vec::new(), + ) + } + + fn edit_vertex(&self, tool: Tool, handle: Handle, point: DVec3) -> Option { + let target = self.target(handle)?; + let mut mline = target.entity.clone(); + match tool { + Tool::AddVertex => { + let (segment, fraction, projected, _) = closest_segment(&mline, point)?; + if !(1.0e-9..1.0 - 1.0e-9).contains(&fraction) { + return None; + } + let normal = DVec3::new(mline.normal.x, mline.normal.y, mline.normal.z); + let source: Vec<_> = (0..mline.style_element_count) + .filter_map(|element| { + let (start, end) = element_segment(&mline, segment, element)?; + let data = mline.vertices[segment].segments.get(element)?.clone(); + let curve = segment_curve(start, end, normal)?; + let length = curve.length(); + let split = curve.parameter_at(projected.to_array())?.clamp(0.0, 1.0) + * length; + Some((data, length, split)) + }) + .collect(); + if source.len() != mline.style_element_count { + return None; + } + let insert = segment + 1; + let mut vertex = mline.vertices[segment].clone(); + vertex.position = acadrust::types::Vector3::new( + projected.x, + projected.y, + projected.z, + ); + mline.vertices.insert(insert, vertex); + crate::entities::mline::rebuild_mline_geometry(&mut mline); + crate::modules::draw::draw::mline::sync_mline_element_parameters( + &mut mline, + &target.style, + ); + let lengths: Vec<_> = (0..mline.style_element_count) + .filter_map(|element| { + let first = element_segment(&mline, segment, element)?; + let second = element_segment(&mline, insert, element)?; + Some(( + segment_curve(first.0, first.1, normal)?.length(), + segment_curve(second.0, second.1, normal)?.length(), + )) + }) + .collect(); + if lengths.len() != mline.style_element_count { + return None; + } + for (element, ((source, source_length, split), (first_length, second_length))) in + source.into_iter().zip(lengths).enumerate() + { + let first_drawn = remap_ranges( + &drawn_ranges(&source.parameters, source_length), + 0.0, + split, + first_length, + ); + let second_drawn = remap_ranges( + &drawn_ranges(&source.parameters, source_length), + split, + source_length, + second_length, + ); + let first_cuts = remap_ranges( + &cut_ranges(&source.area_fill_parameters, source_length), + 0.0, + split, + first_length, + ); + let second_cuts = remap_ranges( + &cut_ranges(&source.area_fill_parameters, source_length), + split, + source_length, + second_length, + ); + let (before, after) = mline.vertices.split_at_mut(insert); + store_drawn_ranges( + &mut before[segment].segments[element].parameters, + first_length, + &first_drawn, + ); + store_cut_ranges( + &mut before[segment].segments[element].area_fill_parameters, + first_length, + &first_cuts, + ); + store_drawn_ranges( + &mut after[0].segments[element].parameters, + second_length, + &second_drawn, + ); + store_cut_ranges( + &mut after[0].segments[element].area_fill_parameters, + second_length, + &second_cuts, + ); + } + } + Tool::DeleteVertex => { + let vertex = closest_vertex(&mline, point)?; + if mline.vertices.len() <= 2 + || (mline.is_closed() && mline.vertices.len() <= 3) + { + return None; + } + let count = mline.vertices.len(); + let merge = mline.is_closed() || (vertex > 0 && vertex + 1 < count); + let previous = if vertex == 0 { count - 1 } else { vertex - 1 }; + let normal = DVec3::new(mline.normal.x, mline.normal.y, mline.normal.z); + let source = merge.then(|| { + (0..mline.style_element_count) + .filter_map(|element| { + let first = element_segment(&mline, previous, element)?; + let second = element_segment(&mline, vertex, element)?; + Some(( + mline.vertices[previous].segments.get(element)?.clone(), + segment_curve(first.0, first.1, normal)?.length(), + mline.vertices[vertex].segments.get(element)?.clone(), + segment_curve(second.0, second.1, normal)?.length(), + )) + }) + .collect::>() + }); + if source + .as_ref() + .is_some_and(|source| source.len() != mline.style_element_count) + { + return None; + } + let candidates = if mline.is_closed() { + vec![ + ((previous + count - 1) % count, false), + ((vertex + 1) % count, true), + ] + } else { + let mut candidates = Vec::new(); + if vertex >= 2 { + candidates.push((vertex - 2, false)); + } + if vertex + 2 < count { + candidates.push((vertex + 1, true)); + } + candidates + }; + let mut peripheral = Vec::new(); + for (segment, moved_start) in candidates { + if (merge && (segment == previous || segment == vertex)) + || peripheral + .iter() + .any(|(existing, _, _)| *existing == segment) + { + continue; + } + let snapshot = snapshot_segment_data(&mline, segment, normal)?; + let target = if segment > vertex { + segment - 1 + } else { + segment + }; + peripheral.push((target, moved_start, snapshot)); + } + mline.vertices.remove(vertex); + crate::entities::mline::rebuild_mline_geometry(&mut mline); + crate::modules::draw::draw::mline::sync_mline_element_parameters( + &mut mline, + &target.style, + ); + for (segment, moved_start, snapshot) in peripheral { + restore_shifted_segment_data( + &mut mline, + segment, + normal, + snapshot, + moved_start, + ); + } + if let Some(source) = source { + let previous = if vertex == 0 { + mline.vertices.len() - 1 + } else { + vertex - 1 + }; + for (element, (first, first_length, second, second_length)) in + source.into_iter().enumerate() + { + let segment = element_segment(&mline, previous, element)?; + let target_length = segment_curve(segment.0, segment.1, normal)?.length(); + let total = first_length + second_length; + let mut drawn = drawn_ranges(&first.parameters, first_length); + drawn.extend( + drawn_ranges(&second.parameters, second_length) + .into_iter() + .map(|range| { + (range.0 + first_length, range.1 + first_length) + }), + ); + let mut cuts = cut_ranges(&first.area_fill_parameters, first_length); + cuts.extend( + cut_ranges(&second.area_fill_parameters, second_length) + .into_iter() + .map(|range| { + (range.0 + first_length, range.1 + first_length) + }), + ); + let drawn = merge_ranges(remap_ranges( + &drawn, + 0.0, + total, + target_length, + )); + let cuts = merge_ranges(remap_ranges( + &cuts, + 0.0, + total, + target_length, + )); + let target = &mut mline.vertices[previous].segments[element]; + store_drawn_ranges(&mut target.parameters, target_length, &drawn); + store_cut_ranges( + &mut target.area_fill_parameters, + target_length, + &cuts, + ); + } + } + } + _ => return None, + } + crate::modules::draw::draw::mline::sync_mline_element_parameters( + &mut mline, + &target.style, + ); + Some(Self::replace(handle, mline)) + } + + fn edit_range( + &self, + tool: Tool, + handle: Handle, + start: DVec3, + end: DVec3, + ) -> Option { + let target = self.target(handle)?; + let mut mline = target.entity.clone(); + let (segment, _, _, _) = closest_segment(&mline, start)?; + let elements: Vec = match tool { + Tool::CutSingle => vec![closest_element(&mline, segment, start)?], + _ => (0..mline.style_element_count).collect(), + }; + for element in elements { + let (a, b) = element_segment(&mline, segment, element)?; + let curve = segment_curve( + a, + b, + DVec3::new(mline.normal.x, mline.normal.y, mline.normal.z), + )?; + let length = curve.length(); + if length <= 1.0e-10 { + continue; + } + let first = curve.parameter_at(start.to_array())?.clamp(0.0, 1.0) * length; + let second = curve.parameter_at(end.to_array())?.clamp(0.0, 1.0) * length; + let low = first.min(second); + let high = first.max(second); + let segment = &mut mline.vertices[segment].segments[element]; + if tool == Tool::WeldAll { + add_drawn_range(&mut segment.parameters, length, low, high); + remove_cut_range(&mut segment.area_fill_parameters, length, low, high); + } else { + remove_drawn_range(&mut segment.parameters, length, low, high); + add_cut_range(&mut segment.area_fill_parameters, length, low, high); + } + } + Some(Self::replace(handle, mline)) + } + + fn edit_pair( + &self, + tool: Tool, + first_handle: Handle, + first_pick: DVec3, + second_handle: Handle, + second_pick: DVec3, + ) -> Option { + if first_handle == second_handle { + return None; + } + let first_target = self.target(first_handle)?; + let second_target = self.target(second_handle)?; + let mut first = first_target.entity.clone(); + let mut second = second_target.entity.clone(); + let (first_segment, _, _, _) = closest_segment(&first, first_pick)?; + let (second_segment, _, _, _) = closest_segment(&second, second_pick)?; + let first_end = if matches!( + tool, + Tool::ClosedTee | Tool::OpenTee | Tool::MergedTee | Tool::CornerJoint + ) { + Some(selected_terminal_end(&first, first_segment, first_pick)?) + } else { + None + }; + let second_end = if tool == Tool::CornerJoint { + Some(selected_terminal_end(&second, second_segment, second_pick)?) + } else { + None + }; + let (intersection, first_fraction, second_fraction, sine) = segment_intersection( + center_segment(&first, first_segment)?, + center_segment(&second, second_segment)?, + DVec3::new(first.normal.x, first.normal.y, first.normal.z), + )?; + let on_segment = |value: f64| (-1.0e-6..=1.0 + 1.0e-6).contains(&value); + let valid = match tool { + Tool::ClosedCross | Tool::OpenCross | Tool::MergedCross => { + on_segment(first_fraction) && on_segment(second_fraction) + } + Tool::ClosedTee | Tool::OpenTee | Tool::MergedTee => { + on_segment(second_fraction) + } + Tool::CornerJoint => true, + _ => false, + }; + if !valid || sine.abs() <= 1.0e-6 { + return None; + } + let divisor = sine.abs(); + let first_gap = style_width(&second_target.style, second.scale_factor) * 0.5 / divisor; + let second_gap = style_width(&first_target.style, first.scale_factor) * 0.5 / divisor; + + match tool { + Tool::ClosedCross => { + gap_elements( + &mut first, + first_segment, + intersection, + first_gap, + None, + ); + } + Tool::OpenCross => { + gap_elements( + &mut first, + first_segment, + intersection, + first_gap, + None, + ); + gap_elements( + &mut second, + second_segment, + intersection, + second_gap, + Some(outer_element_indices(&second_target.style)), + ); + } + Tool::MergedCross => { + gap_elements( + &mut first, + first_segment, + intersection, + first_gap, + Some(inner_element_indices(&first_target.style)), + ); + gap_elements( + &mut second, + second_segment, + intersection, + second_gap, + Some(inner_element_indices(&second_target.style)), + ); + } + Tool::ClosedTee | Tool::OpenTee | Tool::MergedTee => { + move_closest_end( + &mut first, + &first_target.style, + first_end?, + intersection, + ); + let elements = match tool { + Tool::ClosedTee => None, + Tool::OpenTee => Some(outer_element_indices(&second_target.style)), + Tool::MergedTee => Some(inner_element_indices(&second_target.style)), + _ => unreachable!(), + }; + gap_elements( + &mut second, + second_segment, + intersection, + second_gap, + elements, + ); + } + Tool::CornerJoint => { + move_closest_end( + &mut first, + &first_target.style, + first_end?, + intersection, + ); + move_closest_end( + &mut second, + &second_target.style, + second_end?, + intersection, + ); + } + _ => return None, + } + + Some(CmdResult::ReplaceMany( + vec![ + (first_handle, vec![EntityType::MLine(first)]), + (second_handle, vec![EntityType::MLine(second)]), + ], + Vec::new(), + )) + } +} + +impl CadCommand for MlineEditCommand { + fn name(&self) -> &'static str { + "MLEDIT" + } + + fn prompt(&self) -> String { + match self.mode { + Mode::Choose => crate::t!("MLEDIT Choose an edit tool:").into_owned(), + Mode::PickFirst(_) => crate::t!("MLEDIT Select first multiline:").into_owned(), + Mode::PickSecond { .. } => { + crate::t!("MLEDIT Select second multiline:").into_owned() + } + Mode::PickRangeEnd { tool: Tool::WeldAll, .. } => { + crate::t!("MLEDIT Specify the end of the weld range:").into_owned() + } + Mode::PickRangeEnd { .. } => { + crate::t!("MLEDIT Specify the second cut point:").into_owned() + } + } + } + + fn options(&self) -> Vec { + if !matches!(self.mode, Mode::Choose) { + return Vec::new(); + } + vec![ + CmdOption::new(crate::t!("Closed Cross").as_ref(), "CC"), + CmdOption::new(crate::t!("Open Cross").as_ref(), "OC"), + CmdOption::new(crate::t!("Merged Cross").as_ref(), "MC"), + CmdOption::new(crate::t!("Closed Tee").as_ref(), "CT"), + CmdOption::new(crate::t!("Open Tee").as_ref(), "OT"), + CmdOption::new(crate::t!("Merged Tee").as_ref(), "MT"), + CmdOption::new(crate::t!("Corner Joint").as_ref(), "CJ"), + CmdOption::new(crate::t!("Add Vertex").as_ref(), "AV"), + CmdOption::new(crate::t!("Delete Vertex").as_ref(), "DV"), + CmdOption::new(crate::t!("Cut Single").as_ref(), "CS"), + CmdOption::new(crate::t!("Cut All").as_ref(), "CA"), + CmdOption::new(crate::t!("Weld All").as_ref(), "WA"), + ] + } + + fn wants_text_input(&self) -> bool { + matches!(self.mode, Mode::Choose) + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.mode, Mode::PickFirst(_) | Mode::PickSecond { .. }) + } + + fn entity_pick_highlights_hover(&self) -> bool { + self.needs_entity_pick() + } + + fn on_text_input(&mut self, text: &str) -> Option { + if !matches!(self.mode, Mode::Choose) { + return None; + } + let tool = match text.trim().to_uppercase().as_str() { + "CC" | "CLOSED CROSS" => Tool::ClosedCross, + "OC" | "OPEN CROSS" => Tool::OpenCross, + "MC" | "MERGED CROSS" => Tool::MergedCross, + "CT" | "CLOSED TEE" => Tool::ClosedTee, + "OT" | "OPEN TEE" => Tool::OpenTee, + "MT" | "MERGED TEE" => Tool::MergedTee, + "CJ" | "CORNER JOINT" => Tool::CornerJoint, + "AV" | "ADD VERTEX" => Tool::AddVertex, + "DV" | "DELETE VERTEX" => Tool::DeleteVertex, + "CS" | "CUT SINGLE" => Tool::CutSingle, + "CA" | "CUT ALL" => Tool::CutAll, + "WA" | "WELD ALL" => Tool::WeldAll, + _ => return None, + }; + self.mode = Mode::PickFirst(tool); + Some(CmdResult::NeedPoint) + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if self.target(handle).is_none() { + return CmdResult::NeedPoint; + } + match self.mode { + Mode::PickFirst(tool @ (Tool::AddVertex | Tool::DeleteVertex)) => self + .edit_vertex(tool, handle, point) + .unwrap_or(CmdResult::NeedPoint), + Mode::PickFirst(tool @ (Tool::CutSingle | Tool::CutAll | Tool::WeldAll)) => { + self.mode = Mode::PickRangeEnd { + tool, + target: handle, + start: point, + }; + CmdResult::NeedPoint + } + Mode::PickFirst(tool) => { + self.mode = Mode::PickSecond { + tool, + first: handle, + first_pick: point, + }; + CmdResult::NeedPoint + } + Mode::PickSecond { + tool, + first, + first_pick, + } => self + .edit_pair(tool, first, first_pick, handle, point) + .unwrap_or(CmdResult::NeedPoint), + _ => CmdResult::NeedPoint, + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.mode { + Mode::PickRangeEnd { + tool, + target, + start, + } => self + .edit_range(tool, target, start, point) + .unwrap_or(CmdResult::NeedPoint), + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +fn center_segment(mline: &MLine, index: usize) -> Option<(DVec3, DVec3)> { + let first = mline.vertices.get(index)?.position; + let next = if index + 1 < mline.vertices.len() { + index + 1 + } else if mline.is_closed() { + 0 + } else { + return None; + }; + let second = mline.vertices[next].position; + Some(( + DVec3::new(first.x, first.y, first.z), + DVec3::new(second.x, second.y, second.z), + )) +} + +fn segment_curve(first: DVec3, second: DVec3, normal: DVec3) -> Option { + let direction = KernelVec3::from(second.to_array()) - KernelVec3::from(first.to_array()); + let length = direction.length(); + let plane = Plane::orthonormal( + first.to_array(), + direction.normalize()?.to_array(), + normal.to_array(), + )?; + Some(PlanarCurve::new( + plane, + Curve::Line(Line { + start: [0.0, 0.0], + end: [length, 0.0], + }), + )) +} + +fn closest_segment(mline: &MLine, point: DVec3) -> Option<(usize, f64, DVec3, f64)> { + let count = if mline.is_closed() { + mline.vertices.len() + } else { + mline.vertices.len().saturating_sub(1) + }; + let normal = DVec3::new(mline.normal.x, mline.normal.y, mline.normal.z); + (0..count) + .filter_map(|index| { + let (a, b) = center_segment(mline, index)?; + let curve = segment_curve(a, b, normal)?; + let fraction = curve.parameter_at(point.to_array())?.clamp(0.0, 1.0); + let projected = DVec3::from_array(curve.point_at(fraction)); + let distance = KernelVec3::from(projected.to_array()) + .distance_squared(KernelVec3::from(point.to_array())); + Some((index, fraction, projected, distance)) + }) + .min_by(|left, right| left.3.total_cmp(&right.3)) +} + +fn closest_vertex(mline: &MLine, point: DVec3) -> Option { + mline + .vertices + .iter() + .enumerate() + .min_by(|(_, left), (_, right)| { + let point = KernelVec3::from(point.to_array()); + let left = KernelVec3::new(left.position.x, left.position.y, left.position.z) + .distance_squared(point); + let right = KernelVec3::new(right.position.x, right.position.y, right.position.z) + .distance_squared(point); + left.total_cmp(&right) + }) + .map(|(index, _)| index) +} + +fn element_segment(mline: &MLine, index: usize, element: usize) -> Option<(DVec3, DVec3)> { + let next = if index + 1 < mline.vertices.len() { + index + 1 + } else if mline.is_closed() { + 0 + } else { + return None; + }; + let point = |vertex: usize| -> Option { + let item = mline.vertices.get(vertex)?; + let offset = item.segments.get(element)?.parameters.first().copied()?; + let position = KernelVec3::new(item.position.x, item.position.y, item.position.z); + let miter = KernelVec3::new(item.miter.x, item.miter.y, item.miter.z); + Some(DVec3::from_array((position + miter * offset).to_array())) + }; + Some((point(index)?, point(next)?)) +} + +fn snapshot_segment_data( + mline: &MLine, + segment: usize, + normal: DVec3, +) -> Option> { + (0..mline.style_element_count) + .map(|element| { + let endpoints = element_segment(mline, segment, element)?; + Some(( + mline.vertices.get(segment)?.segments.get(element)?.clone(), + segment_curve(endpoints.0, endpoints.1, normal)?.length(), + )) + }) + .collect() +} + +fn restore_shifted_segment_data( + mline: &mut MLine, + segment: usize, + normal: DVec3, + source: Vec<(acadrust::entities::MLineSegment, f64)>, + moved_start: bool, +) { + for (element, (source, source_length)) in source.into_iter().enumerate() { + let Some(endpoints) = element_segment(mline, segment, element) else { + continue; + }; + let Some(target_length) = segment_curve(endpoints.0, endpoints.1, normal) + .map(|curve| curve.length()) + else { + continue; + }; + let shift = if moved_start { + target_length - source_length + } else { + 0.0 + }; + let drawn = shift_ranges( + &drawn_ranges(&source.parameters, source_length), + source_length, + shift, + target_length, + ); + let cuts = shift_ranges( + &cut_ranges(&source.area_fill_parameters, source_length), + source_length, + shift, + target_length, + ); + let Some(target) = mline + .vertices + .get_mut(segment) + .and_then(|vertex| vertex.segments.get_mut(element)) + else { + continue; + }; + store_drawn_ranges(&mut target.parameters, target_length, &drawn); + store_cut_ranges(&mut target.area_fill_parameters, target_length, &cuts); + } +} + +fn closest_element(mline: &MLine, segment: usize, point: DVec3) -> Option { + let normal = DVec3::new(mline.normal.x, mline.normal.y, mline.normal.z); + (0..mline.style_element_count) + .filter_map(|element| { + let (a, b) = element_segment(mline, segment, element)?; + let curve = segment_curve(a, b, normal)?; + let fraction = curve.parameter_at(point.to_array())?.clamp(0.0, 1.0); + let projected = KernelVec3::from(curve.point_at(fraction)); + Some(( + element, + projected.distance_squared(KernelVec3::from(point.to_array())), + )) + }) + .min_by(|left, right| left.1.total_cmp(&right.1)) + .map(|(element, _)| element) +} + +fn drawn_ranges(parameters: &[f64], length: f64) -> Vec<(f64, f64)> { + crate::entities::mline::mline_drawn_ranges(parameters, length) +} + +fn store_drawn_ranges(parameters: &mut Vec, length: f64, ranges: &[(f64, f64)]) { + crate::entities::mline::store_mline_drawn_ranges(parameters, length, ranges); +} + +fn remove_drawn_range(parameters: &mut Vec, length: f64, low: f64, high: f64) { + if high - low <= 1.0e-9 { + return; + } + let mut result = Vec::new(); + for (start, end) in drawn_ranges(parameters, length) { + if low > start + 1.0e-9 { + result.push((start, low.min(end))); + } + if high < end - 1.0e-9 { + result.push((high.max(start), end)); + } + } + store_drawn_ranges(parameters, length, &result); +} + +fn add_drawn_range(parameters: &mut Vec, length: f64, low: f64, high: f64) { + let mut ranges = drawn_ranges(parameters, length); + ranges.push((low, high)); + ranges.sort_by(|left, right| left.0.total_cmp(&right.0)); + let mut merged: Vec<(f64, f64)> = Vec::new(); + for range in ranges { + if let Some(last) = merged.last_mut() { + if range.0 <= last.1 + 1.0e-9 { + last.1 = last.1.max(range.1); + continue; + } + } + merged.push(range); + } + store_drawn_ranges(parameters, length, &merged); +} + +fn cut_ranges(parameters: &[f64], length: f64) -> Vec<(f64, f64)> { + parameters + .chunks(2) + .filter_map(|pair| { + let start = pair[0].clamp(0.0, length); + let end = pair.get(1).copied().unwrap_or(length).clamp(0.0, length); + (end - start > 1.0e-9).then_some((start, end)) + }) + .collect() +} + +fn store_cut_ranges(parameters: &mut Vec, length: f64, ranges: &[(f64, f64)]) { + parameters.clear(); + for (start, end) in ranges { + parameters.push(*start); + if *end < length - 1.0e-9 { + parameters.push(*end); + } + } +} + +fn add_cut_range(parameters: &mut Vec, length: f64, low: f64, high: f64) { + if high - low <= 1.0e-9 { + return; + } + let mut ranges = cut_ranges(parameters, length); + ranges.push((low, high)); + ranges.sort_by(|left, right| left.0.total_cmp(&right.0)); + let mut merged: Vec<(f64, f64)> = Vec::new(); + for range in ranges { + if let Some(last) = merged.last_mut() { + if range.0 <= last.1 + 1.0e-9 { + last.1 = last.1.max(range.1); + continue; + } + } + merged.push(range); + } + store_cut_ranges(parameters, length, &merged); +} + +fn remove_cut_range(parameters: &mut Vec, length: f64, low: f64, high: f64) { + if high - low <= 1.0e-9 { + return; + } + let mut result = Vec::new(); + for (start, end) in cut_ranges(parameters, length) { + if low > start + 1.0e-9 { + result.push((start, low.min(end))); + } + if high < end - 1.0e-9 { + result.push((high.max(start), end)); + } + } + store_cut_ranges(parameters, length, &result); +} + +fn remap_ranges( + ranges: &[(f64, f64)], + source_start: f64, + source_end: f64, + target_length: f64, +) -> Vec<(f64, f64)> { + let source_length = source_end - source_start; + if source_length <= 1.0e-12 || target_length <= 1.0e-12 { + return Vec::new(); + } + ranges + .iter() + .filter_map(|range| { + let start = range.0.max(source_start); + let end = range.1.min(source_end); + (end - start > 1.0e-9).then_some(( + (start - source_start) / source_length * target_length, + (end - source_start) / source_length * target_length, + )) + }) + .collect() +} + +fn merge_ranges(mut ranges: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + ranges.sort_by(|left, right| left.0.total_cmp(&right.0)); + let mut merged: Vec<(f64, f64)> = Vec::new(); + for range in ranges { + if let Some(last) = merged.last_mut() { + if range.0 <= last.1 + 1.0e-9 { + last.1 = last.1.max(range.1); + continue; + } + } + merged.push(range); + } + merged +} + +fn shift_ranges( + ranges: &[(f64, f64)], + source_length: f64, + shift: f64, + target_length: f64, +) -> Vec<(f64, f64)> { + ranges + .iter() + .filter_map(|range| { + let start = if range.0 <= 1.0e-9 { + 0.0 + } else { + (range.0 + shift).clamp(0.0, target_length) + }; + let end = if range.1 >= source_length - 1.0e-9 { + target_length + } else { + (range.1 + shift).clamp(0.0, target_length) + }; + (end - start > 1.0e-9).then_some((start, end)) + }) + .collect() +} + +fn style_width(style: &MLineStyle, scale: f64) -> f64 { + let low = style + .elements + .iter() + .map(|element| element.offset) + .fold(f64::INFINITY, f64::min); + let high = style + .elements + .iter() + .map(|element| element.offset) + .fold(f64::NEG_INFINITY, f64::max); + if low.is_finite() && high.is_finite() { + (high - low).abs() * scale.abs() + } else { + scale.abs() + } +} + +fn outer_element_indices(style: &MLineStyle) -> Vec { + if style.elements.is_empty() { + return Vec::new(); + } + let low = style + .elements + .iter() + .enumerate() + .min_by(|(_, left), (_, right)| left.offset.total_cmp(&right.offset)) + .map(|(index, _)| index) + .unwrap_or(0); + let high = style + .elements + .iter() + .enumerate() + .max_by(|(_, left), (_, right)| left.offset.total_cmp(&right.offset)) + .map(|(index, _)| index) + .unwrap_or(low); + if low == high { + vec![low] + } else { + vec![low, high] + } +} + +fn inner_element_indices(style: &MLineStyle) -> Vec { + let outer = outer_element_indices(style); + let inner: Vec = (0..style.elements.len()) + .filter(|index| !outer.contains(index)) + .collect(); + if inner.is_empty() { + outer.into_iter().take(1).collect() + } else { + inner + } +} + +fn gap_elements( + mline: &mut MLine, + segment: usize, + intersection: DVec3, + half_gap: f64, + elements: Option>, +) { + let elements = elements.unwrap_or_else(|| (0..mline.style_element_count).collect()); + for element in elements { + let Some((a, b)) = element_segment(mline, segment, element) else { + continue; + }; + let Some(curve) = segment_curve( + a, + b, + DVec3::new(mline.normal.x, mline.normal.y, mline.normal.z), + ) else { + continue; + }; + let length = curve.length(); + if let Some(element) = mline.vertices[segment].segments.get_mut(element) { + let Some(fraction) = curve.parameter_at(intersection.to_array()) else { + continue; + }; + let at = fraction.clamp(0.0, 1.0) * length; + let low = (at - half_gap).max(0.0); + let high = (at + half_gap).min(length); + remove_drawn_range(&mut element.parameters, length, low, high); + add_cut_range(&mut element.area_fill_parameters, length, low, high); + } + } +} + +fn move_closest_end( + mline: &mut MLine, + style: &MLineStyle, + index: usize, + intersection: DVec3, +) { + if mline.vertices.is_empty() || mline.is_closed() || index >= mline.vertices.len() { + return; + } + let last_index = mline.vertices.len() - 1; + if index != 0 && index != last_index { + return; + } + let segment = if index == 0 { 0 } else { last_index - 1 }; + let normal = DVec3::new(mline.normal.x, mline.normal.y, mline.normal.z); + let Some(source) = snapshot_segment_data(mline, segment, normal) else { + return; + }; + let peripheral_segment = if index == 0 { + (mline.vertices.len() > 2).then_some(1) + } else { + (mline.vertices.len() > 2).then_some(last_index - 2) + }; + let peripheral = peripheral_segment + .and_then(|segment| snapshot_segment_data(mline, segment, normal).map(|data| (segment, data))); + mline.vertices[index].position = acadrust::types::Vector3::new( + intersection.x, + intersection.y, + intersection.z, + ); + crate::entities::mline::rebuild_mline_geometry(mline); + crate::modules::draw::draw::mline::sync_mline_element_parameters(mline, style); + restore_shifted_segment_data(mline, segment, normal, source, index == 0); + if let Some((segment, source)) = peripheral { + restore_shifted_segment_data(mline, segment, normal, source, index == 0); + } +} + +fn selected_terminal_end(mline: &MLine, segment: usize, pick: DVec3) -> Option { + if mline.is_closed() || mline.vertices.len() < 2 { + return None; + } + let last_vertex = mline.vertices.len() - 1; + let last_segment = last_vertex - 1; + match (segment == 0, segment == last_segment) { + (true, true) => { + let pick = KernelVec3::from(pick.to_array()); + let first = &mline.vertices[0].position; + let last = &mline.vertices[last_vertex].position; + let first_distance = KernelVec3::new(first.x, first.y, first.z).distance_squared(pick); + let last_distance = KernelVec3::new(last.x, last.y, last.z).distance_squared(pick); + Some(if first_distance <= last_distance { 0 } else { last_vertex }) + } + (true, false) => Some(0), + (false, true) => Some(last_vertex), + (false, false) => None, + } +} + +fn segment_intersection( + first: (DVec3, DVec3), + second: (DVec3, DVec3), + normal: DVec3, +) -> Option<(DVec3, f64, f64, f64)> { + let axis = (cadkernel::space::Vec3::from(first.1.to_array()) + - cadkernel::space::Vec3::from(first.0.to_array())) + .normalize()? + .to_array(); + let plane = cadkernel::space::Plane::orthonormal( + first.0.to_array(), + axis, + normal.to_array(), + )?; + let points = [ + first.0.to_array(), + first.1.to_array(), + second.0.to_array(), + second.1.to_array(), + ]; + let tolerance = cadkernel::space::coplanarity_tolerance(&points); + if !plane.contains(points[2], tolerance) || !plane.contains(points[3], tolerance) { + return None; + } + let p = cadkernel::geom2d::Vec2::from(plane.project(points[0])?); + let q = cadkernel::geom2d::Vec2::from(plane.project(points[2])?); + let r = cadkernel::geom2d::Vec2::from(plane.project(points[1])?) - p; + let s = cadkernel::geom2d::Vec2::from(plane.project(points[3])?) - q; + let (t, u) = cadkernel::geom2d::intersect::line_line( + p.to_array(), + r.to_array(), + q.to_array(), + s.to_array(), + )?; + let intersection = plane.point_at((p + r * t).to_array()); + let sine = r.cross(s) / (r.length() * s.length()).max(f64::MIN_POSITIVE); + Some((DVec3::from_array(intersection), t, u, sine)) +} + +inventory::submit!(crate::command::CommandRegistration { names: &["MLEDIT"] }); diff --git a/src/modules/draw/modify/mod.rs b/src/modules/draw/modify/mod.rs index d4c146e4..a497c4c4 100644 --- a/src/modules/draw/modify/mod.rs +++ b/src/modules/draw/modify/mod.rs @@ -12,6 +12,7 @@ pub mod geom; pub mod join; pub mod lengthen; pub mod mirror; +pub mod mledit; pub mod offset; pub mod pedit; pub mod block_edit; diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 3980d710..be7cfa20 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -369,9 +369,68 @@ pub fn tessellate( let dz = w[1].position.z - w[0].position.z; acc += (dx * dx + dy * dy + dz * dz).sqrt(); } + if m.is_closed() && m.vertices.len() > 1 { + let first = &m.vertices[0].position; + let last = &m.vertices[m.vertices.len() - 1].position; + let dx = first.x - last.x; + let dy = first.y - last.y; + let dz = first.z - last.z; + acc += (dx * dx + dy * dy + dz * dz).sqrt(); + } acc as f32 }; let mut out: Vec = Vec::with_capacity(lines.len()); + if let Some(style) = crate::entities::mline::resolved_mline_style(m, document) { + let triangles = + crate::entities::mline::mline_fill_triangles_with_style(m, style); + if !triangles.is_empty() { + let (fill_tris, fill_tris_low) = points_to_ds(triangles); + let fill_color = if selected { + WireModel::SELECTED + } else { + match style.fill_color { + AcadColor::ByLayer | AcadColor::ByBlock => entity_color, + other => { + let [r, g, b, _] = + crate::scene::convert::tess_util::aci_to_rgba(&other); + [r, g, b, entity_color[3]] + } + } + }; + out.push(WireModel { + point_marker: None, + taper_widths: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: true, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: name.clone(), + points: Vec::new(), + points_low: Vec::new(), + color: fill_color, + selected, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris, + fill_tris_low, + }); + } + } let mut snap_attached = false; for l in lines { if l.points.is_empty() {