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] 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() {