From 8f3ba6dd40088f7f7b84b97ea94c93b1bdcfd321 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:20:34 +0300 Subject: [PATCH 1/3] Use rebuilt multiline geometry core --- Cargo.lock | 2 +- Cargo.toml | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f251820..fe095a7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=64e098c#64e098cc719e93af1052d5aaa6e33a7d7a2c44f9" +source = "git+https://github.com/ramox81/cadcodec.git?rev=7cb60c8#7cb60c87fab33471f7705dbe4c68e62ff25cee1d" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 761f2546..e7bdfe23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "64e098c", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "7cb60c8", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "73ac7e0", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -60,6 +60,9 @@ ashpd = { version = "0.13.13", default-features = false, features = ["async-io", iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } +[patch."https://github.com/HakanSeven12/cadcodec.git"] +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "7cb60c8" } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } meshopt = "0.6.2" From 2263168a2aafa039b005632d8605ce7208803878 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:20:34 +0300 Subject: [PATCH 2/3] Complete multiline command and properties behavior --- src/app/command_driver.rs | 23 ++ src/app/commands/draw.rs | 28 +- src/app/commands/mod.rs | 2 + src/app/commands/styleprops.rs | 32 +- src/app/update/command.rs | 50 +++ src/command.rs | 5 + src/entities/mline.rs | 385 ++++++++++++++++++---- src/modules/draw/draw/mline.rs | 545 ++++++++++++++++++++++---------- src/scene/convert/tessellate.rs | 50 +++ 9 files changed, 876 insertions(+), 244 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 7c69d400..5f0a954a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -662,6 +662,29 @@ 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 changed = (header.multiline_scale - scale).abs() > f64::EPSILON + || header.multiline_justification != justification + || !header.multiline_style.eq_ignore_ascii_case(&style_name) + || style_handle.is_some_and(|handle| { + header.current_multiline_style_handle != handle + }); + if changed { + header.multiline_scale = scale; + header.multiline_justification = justification; + header.multiline_style = style_name; + if let Some(handle) = style_handle { + header.current_multiline_style_handle = 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 6114e3b0..3ae1581e 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/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 de326abb..50c298f8 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..321869e8 100644 --- a/src/entities/mline.rs +++ b/src/entities/mline.rs @@ -1,7 +1,7 @@ use acadrust::entities::MLine; 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}; @@ -24,29 +24,51 @@ pub struct MLineLine { /// 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 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 @@ -100,6 +122,42 @@ pub fn mline_lines(m: &MLine, document: &acadrust::CadDocument) -> Vec [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; + }; + let angle = if vi == 0 { + style.start_angle + } else { + style.end_angle + }; + let tangent = glam::DVec3::new( + m.vertices[vi].direction.x, + m.vertices[vi].direction.y, + m.vertices[vi].direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(m.normal.x, m.normal.y, m.normal.z) + .normalize_or(glam::DVec3::Z); + let transverse = normal.cross(tangent).normalize_or(glam::DVec3::Y); + let base = glam::DVec3::new( + m.vertices[vi].position.x, + m.vertices[vi].position.y, + m.vertices[vi].position.z, + ); + let current = glam::DVec3::new(point[0], point[1], point[2]); + let tangent_shift = if angle.tan().abs() > 1.0e-9 { + (current - base).dot(transverse) / angle.tan() + } else { + 0.0 + }; + let adjusted = current + tangent * tangent_shift; + [adjusted.x, adjusted.y, adjusted.z] + }; let mut out: Vec = Vec::with_capacity(elems.len() + 2); for (ei, (_, color, linetype)) in elems.iter().enumerate() { @@ -113,8 +171,16 @@ pub fn mline_lines(m: &MLine, document: &acadrust::CadDocument) -> Vec 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 = glam::DVec3::new( + m.vertices[vi].direction.x, + m.vertices[vi].direction.y, + m.vertices[vi].direction.z, + ) + .normalize_or(glam::DVec3::X); + 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,6 +343,84 @@ pub fn mline_lines(m: &MLine, document: &acadrust::CadDocument) -> Vec Vec<[f64; 3]> { + let first = glam::DVec3::from_array(first); + let second = glam::DVec3::from_array(second); + let center = (first + second) * 0.5; + let transverse = (first - second) * 0.5; + let radius = transverse.length(); + let outward = if start { -direction } else { direction } * radius; + (0..=24) + .map(|step| { + let angle = std::f64::consts::PI * step as f64 / 24.0; + (center + transverse * angle.cos() + outward * angle.sin()).to_array() + }) + .collect() +} + +pub fn mline_fill_triangles_with_style( + m: &MLine, + style: &acadrust::objects::MLineStyle, +) -> Vec<[f64; 3]> { + use acadrust::entities::MLineFlags; + + 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 low = if element.offset < style.elements[low].offset { + index + } else { + low + }; + let high = if element.offset > style.elements[high].offset { + index + } else { + high + }; + (low, high) + }); + let offset_point = |vertex: usize, element: usize| -> [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 * m.scale_factor); + [ + item.position.x + item.miter.x * distance, + item.position.y + item.miter.y * distance, + item.position.z + item.miter.z * distance, + ] + }; + 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 a = offset_point(vertex, low_index); + let b = offset_point(vertex, high_index); + let c = offset_point(next, high_index); + let d = offset_point(next, low_index); + 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() { @@ -265,19 +482,22 @@ 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 offsets = mline_perpendicular_offsets(self); + if self.set_vertex_position(grip_id, position) { + restore_mline_offsets(self, &offsets); } } @@ -315,9 +535,16 @@ impl Grippable for MLine { 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 offsets = mline_perpendicular_offsets(self); + self.rebuild_geometry(); + restore_mline_offsets(self, &offsets); } A::RemoveVertex if grip_id < n && n > 2 => { + let mut offsets = mline_perpendicular_offsets(self); self.vertices.remove(grip_id); + offsets.remove(grip_id); + self.rebuild_geometry(); + restore_mline_offsets(self, &offsets); } _ => {} } @@ -334,11 +561,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", @@ -375,10 +598,6 @@ impl PropertyEditable for MLine { }; return; } - "ml_style" => { - self.style_name = value.to_string(); - return; - } _ => {} } let Ok(v) = value.trim().parse::() else { @@ -392,7 +611,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() || !old.is_finite() { + return; + } + if old == 0.0 { + mline.scale_factor = scale; return; } let ratio = scale / old; @@ -420,6 +643,50 @@ 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 = glam::DVec3::new(mline.normal.x, mline.normal.y, mline.normal.z) + .normalize_or(glam::DVec3::Z); + 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); + 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/modules/draw/draw/mline.rs b/src/modules/draw/draw/mline.rs index 120840e5..da492c09 100644 --- a/src/modules/draw/draw/mline.rs +++ b/src/modules/draw/draw/mline.rs @@ -1,58 +1,208 @@ -// 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, MLineJustification, MLineSegment}; +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 } 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) -> &'static str { + match self.justification { + MLineJustification::Top => "Top", + MLineJustification::Zero => "Zero", + MLineJustification::Bottom => "Bottom", } } + + 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 = 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([f32::NAN; 3]); + } + points.extend(line.points.into_iter().map(|point| { + if point[0].is_nan() { + [f32::NAN; 3] + } else { + let world = self + .plane + .to_world(DVec3::new(point[0], point[1], point[2])); + [world.x as f32, world.y as f32, world.z as f32] + } + })); + } + 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.x as f32, world.y as f32, world.z as f32] + }) + .collect(); + Some(WireModel { + 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: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris, + fill_tris_low: Vec::new(), + }) + } } impl CadCommand for MlineCommand { @@ -65,177 +215,242 @@ 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) - ) - .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() + let notice = self + .notice + .as_ref() + .map(|value| format!("{value}\n")) + .unwrap_or_default(); + let body = match self.step { + Step::Start => format!( + "MLINE Current settings: Justification = {}, Scale = {}, Style = {}\nSpecify start point or [Justification/Scale/STyle]:", + self.justification_name(), self.scale, self.style_name + ), + Step::Next if self.points.len() >= 3 => { + "MLINE Specify next point or [Close/Undo]:".to_string() + } + Step::Next => "MLINE Specify next point or [Undo]:".to_string(), + Step::Justification => format!( + "MLINE Enter justification type [Top/Zero/Bottom] <{}>:", + self.justification_name() + ), + Step::Scale => format!("MLINE Enter scale factor <{}>:", self.scale), + Step::Style => format!("MLINE Enter style name or [?] <{}>:", self.style_name), + }; + format!("{notice}{body}") + } + + fn options(&self) -> Vec { + match self.step { + Step::Start => vec![ + CmdOption::new("Justification", "J"), + CmdOption::new("Scale", "S"), + CmdOption::new("Style", "ST"), + ], + Step::Next if self.points.len() >= 3 => vec![ + CmdOption::new("Close", "C"), + CmdOption::new("Undo", "U"), + ], + Step::Next => vec![CmdOption::new("Undo", "U")], + Step::Justification => vec![ + CmdOption::new("Top", "T"), + CmdOption::new("Zero", "Z"), + CmdOption::new("Bottom", "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() { + return None; + } + self.scale = value; + self.step = Step::Start; + } + Step::Style => { + if token == "?" { + self.notice = Some(format!( + "Loaded multiline styles: {}", + self.styles + .iter() + .map(|(_, style)| style.name.as_str()) + .collect::>() + .join(", ") + )); + } else if self.select_style(token) { + self.step = Step::Start; + } else { + self.notice = Some(format!("Multiline style \"{token}\" was not found.")); } - 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 { - 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.push(value); + } + } + } + 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.style_element_count = style.elements.len().max(1); + for point in points { mline.add_vertex(Vector3::new(point.x, point.y, point.z)); } if closed { mline.close(); } + 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/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 1c4dfa83..29fc9a79 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -371,6 +371,56 @@ pub fn tessellate( 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 { + 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() { From fc047b3b38bc775df2fc0e7b0a2f0089def73f18 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:20:35 +0300 Subject: [PATCH 3/3] Add multiline editing workflows --- src/app/commands/inquiry.rs | 28 ++ src/modules/draw/modify/mledit.rs | 658 ++++++++++++++++++++++++++++++ src/modules/draw/modify/mod.rs | 1 + 3 files changed, 687 insertions(+) create mode 100644 src/modules/draw/modify/mledit.rs 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/modules/draw/modify/mledit.rs b/src/modules/draw/modify/mledit.rs new file mode 100644 index 00000000..65931728 --- /dev/null +++ b/src/modules/draw/modify/mledit.rs @@ -0,0 +1,658 @@ +use acadrust::entities::MLine; +use acadrust::objects::MLineStyle; +use acadrust::{EntityType, Handle}; +use glam::{DVec2, 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, _, projected, _) = closest_segment(&mline, point)?; + 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); + mline.rebuild_geometry(); + } + Tool::DeleteVertex => { + let vertex = closest_vertex(&mline, point)?; + if !mline.remove_vertex(vertex) { + return None; + } + } + _ => 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 direction = b - a; + let length = direction.length(); + if length <= 1.0e-10 { + continue; + } + let unit = direction / length; + let first = (start - a).dot(unit).clamp(0.0, length); + let second = (end - a).dot(unit).clamp(0.0, length); + let low = first.min(second); + let high = first.max(second); + let parameters = &mut mline.vertices[segment].segments[element].parameters; + if tool == Tool::WeldAll { + add_drawn_range(parameters, length, low, high); + } else { + remove_drawn_range(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 (intersection, first_fraction, second_fraction, sine) = segment_intersection( + center_segment(&first, first_segment)?, + center_segment(&second, second_segment)?, + )?; + let first_length = center_segment(&first, first_segment)?.1.distance( + center_segment(&first, first_segment)?.0, + ); + let second_length = center_segment(&second, second_segment)?.1.distance( + center_segment(&second, second_segment)?.0, + ); + let first_at = first_fraction * first_length; + let second_at = second_fraction * second_length; + let divisor = sine.abs().max(0.15); + 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, first_at, first_gap, None); + } + Tool::OpenCross => { + gap_elements(&mut first, first_segment, first_at, first_gap, None); + gap_elements( + &mut second, + second_segment, + second_at, + second_gap, + Some(outer_element_indices(&second_target.style)), + ); + } + Tool::MergedCross => { + gap_elements( + &mut first, + first_segment, + first_at, + first_gap, + Some(inner_element_indices(&first_target.style)), + ); + gap_elements( + &mut second, + second_segment, + second_at, + second_gap, + Some(inner_element_indices(&second_target.style)), + ); + } + Tool::ClosedTee | Tool::OpenTee | Tool::MergedTee => { + move_closest_end(&mut first, first_pick, intersection); + crate::modules::draw::draw::mline::sync_mline_element_parameters( + &mut first, + &first_target.style, + ); + 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, + second_at, + second_gap, + elements, + ); + } + Tool::CornerJoint => { + move_closest_end(&mut first, first_pick, intersection); + move_closest_end(&mut second, second_pick, intersection); + crate::modules::draw::draw::mline::sync_mline_element_parameters( + &mut first, + &first_target.style, + ); + crate::modules::draw::draw::mline::sync_mline_element_parameters( + &mut second, + &second_target.style, + ); + } + _ => 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 => "MLEDIT Choose an edit tool:".to_string(), + Mode::PickFirst(_) => "MLEDIT Select first multiline:".to_string(), + Mode::PickSecond { .. } => "MLEDIT Select second multiline:".to_string(), + Mode::PickRangeEnd { tool: Tool::WeldAll, .. } => { + "MLEDIT Specify the end of the weld range:".to_string() + } + Mode::PickRangeEnd { .. } => { + "MLEDIT Specify the second cut point:".to_string() + } + } + } + + fn options(&self) -> Vec { + if !matches!(self.mode, Mode::Choose) { + return Vec::new(); + } + vec![ + CmdOption::new("Closed Cross", "CC"), + CmdOption::new("Open Cross", "OC"), + CmdOption::new("Merged Cross", "MC"), + CmdOption::new("Closed Tee", "CT"), + CmdOption::new("Open Tee", "OT"), + CmdOption::new("Merged Tee", "MT"), + CmdOption::new("Corner Joint", "CJ"), + CmdOption::new("Add Vertex", "AV"), + CmdOption::new("Delete Vertex", "DV"), + CmdOption::new("Cut Single", "CS"), + CmdOption::new("Cut All", "CA"), + CmdOption::new("Weld All", "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 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) + }; + (0..count) + .filter_map(|index| { + let (a, b) = center_segment(mline, index)?; + let delta = b - a; + let length_squared = delta.length_squared(); + if length_squared <= 1.0e-20 { + return None; + } + let fraction = ((point - a).dot(delta) / length_squared).clamp(0.0, 1.0); + let projected = a + delta * fraction; + Some((index, fraction, projected, projected.distance_squared(point))) + }) + .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 left = DVec3::new(left.position.x, left.position.y, left.position.z) + .distance_squared(point); + let right = DVec3::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()?; + Some(DVec3::new( + item.position.x + item.miter.x * offset, + item.position.y + item.miter.y * offset, + item.position.z + item.miter.z * offset, + )) + }; + Some((point(index)?, point(next)?)) +} + +fn closest_element(mline: &MLine, segment: usize, point: DVec3) -> Option { + (0..mline.style_element_count) + .filter_map(|element| { + let (a, b) = element_segment(mline, segment, element)?; + let delta = b - a; + let fraction = ((point - a).dot(delta) / delta.length_squared().max(1.0e-20)) + .clamp(0.0, 1.0); + Some((element, (a + delta * fraction).distance_squared(point))) + }) + .min_by(|left, right| left.1.total_cmp(&right.1)) + .map(|(element, _)| element) +} + +fn drawn_ranges(parameters: &[f64], length: f64) -> Vec<(f64, f64)> { + if parameters.len() <= 1 { + return vec![(0.0, length)]; + } + let toggles = ¶meters[1..]; + let mut ranges = Vec::new(); + let mut index = 0; + while index < toggles.len() { + let start = toggles[index].clamp(0.0, length); + let end = toggles + .get(index + 1) + .copied() + .unwrap_or(length) + .clamp(0.0, length); + if end - start > 1.0e-9 { + ranges.push((start, end)); + } + index += 2; + } + ranges +} + +fn store_drawn_ranges(parameters: &mut Vec, length: f64, ranges: &[(f64, f64)]) { + let offset = parameters.first().copied().unwrap_or(0.0); + parameters.clear(); + parameters.push(offset); + if ranges.len() == 1 && ranges[0].0 <= 1.0e-9 && ranges[0].1 >= length - 1.0e-9 { + return; + } + for (start, end) in ranges { + parameters.push(*start); + if *end < length - 1.0e-9 { + parameters.push(*end); + } + } +} + +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 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, + at: f64, + 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 length = a.distance(b); + if let Some(parameters) = mline.vertices[segment].segments.get_mut(element) { + remove_drawn_range( + &mut parameters.parameters, + length, + (at - half_gap).max(0.0), + (at + half_gap).min(length), + ); + } + } +} + +fn move_closest_end(mline: &mut MLine, pick: DVec3, intersection: DVec3) { + if mline.vertices.is_empty() || mline.is_closed() { + return; + } + let first = DVec3::new( + mline.vertices[0].position.x, + mline.vertices[0].position.y, + mline.vertices[0].position.z, + ); + let last_index = mline.vertices.len() - 1; + let last = DVec3::new( + mline.vertices[last_index].position.x, + mline.vertices[last_index].position.y, + mline.vertices[last_index].position.z, + ); + let index = if first.distance_squared(pick) <= last.distance_squared(pick) { + 0 + } else { + last_index + }; + let _ = mline.set_vertex_position( + index, + acadrust::types::Vector3::new(intersection.x, intersection.y, intersection.z), + ); +} + +fn segment_intersection( + first: (DVec3, DVec3), + second: (DVec3, DVec3), +) -> Option<(DVec3, f64, f64, f64)> { + let p = first.0.truncate(); + let r = (first.1 - first.0).truncate(); + let q = second.0.truncate(); + let s = (second.1 - second.0).truncate(); + let cross = |left: DVec2, right: DVec2| left.x * right.y - left.y * right.x; + let denominator = cross(r, s); + if denominator.abs() <= 1.0e-10 { + return None; + } + let t = cross(q - p, s) / denominator; + let u = cross(q - p, r) / denominator; + if !(-1.0e-6..=1.0 + 1.0e-6).contains(&t) + || !(-1.0e-6..=1.0 + 1.0e-6).contains(&u) + { + return None; + } + let xy = p + r * t; + let z = first.0.z + (first.1.z - first.0.z) * t; + let sine = denominator / (r.length() * s.length()).max(1.0e-20); + Some((DVec3::new(xy.x, xy.y, z), 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;