diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index f4c81c0a9..87bed4168 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2831,7 +2831,7 @@ properties = .polyfacemesh = PolyfaceMesh .polygonmesh = PolygonMesh .polyline2d = Polyline2D - .polyline3d = Polyline3D + .polyline3d = 3D Polyline .predefined = Predefined .property-bag = Property Bag .proxy-entity = Proxy Entity diff --git a/src/app/properties.rs b/src/app/properties.rs index 5a854f125..e5287698a 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -121,6 +121,9 @@ impl OpenCADStudio { .and_then(|entity| match entity { acadrust::EntityType::LwPolyline(polyline) => Some(polyline.vertices.len()), acadrust::EntityType::Polyline2D(polyline) => Some(polyline.vertices.len()), + acadrust::EntityType::Polyline3D(polyline) => Some( + crate::entities::polyline::polyline3d_control_vertex_count(polyline), + ), acadrust::EntityType::Leader(leader) => Some(leader.vertices.len()), acadrust::EntityType::Spline(spline) => { Some(if crate::entities::spline::shows_fit_points(spline) { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 469b81411..56772b291 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4927,6 +4927,9 @@ impl OpenCADStudio { match self.tabs[i].scene.document.get_entity(handles[0]) { Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(), Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), + Some(acadrust::EntityType::Polyline3D(p)) => { + crate::entities::polyline::polyline3d_control_vertex_count(p) + } Some(acadrust::EntityType::Table(table)) => { table.row_count().saturating_mul(table.column_count()) } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index a9c104fa1..36d15d5f1 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -432,6 +432,7 @@ impl OpenCADStudio { let indexed = match tab.scene.document.get_entity(h) { Some(acadrust::EntityType::LwPolyline(_)) | Some(acadrust::EntityType::Polyline2D(_)) + | Some(acadrust::EntityType::Polyline3D(_)) | Some(acadrust::EntityType::Spline(_)) => true, _ => false, }; diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index 54d98ab77..d9cd71404 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -3,12 +3,14 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ - edit_prop as edit, parse_f64, ro_prop as ro, square_grip, stepper_prop as stepper, + edit_prop as edit, format_length, parse_f64, ro_prop as ro, square_grip, + stepper_prop as stepper, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::model::wire_model::TangentGeom; +use cadkernel::space::{NurbsCurve3, Vec3}; // ── Polyline (old-style 3D heavy polyline) ──────────────────────────────────── @@ -783,20 +785,191 @@ impl Transformable for Polyline2D { // ── Polyline3D ──────────────────────────────────────────────────────────────── +const POLY3D_SPLINE_POINT: i32 = 8; +const POLY3D_SPLINE_CONTROL: i32 = 16; +const POLY3D_VERTEX: i32 = 32; + +fn polyline3d_control_indices(pl: &Polyline3D) -> Vec { + if pl + .vertices + .iter() + .any(|vertex| vertex.flags & POLY3D_SPLINE_CONTROL != 0) + { + return pl + .vertices + .iter() + .enumerate() + .filter_map(|(index, vertex)| { + (vertex.flags & POLY3D_SPLINE_CONTROL != 0).then_some(index) + }) + .collect(); + } + + let controls: Vec = pl + .vertices + .iter() + .enumerate() + .filter_map(|(index, vertex)| { + (vertex.flags & POLY3D_SPLINE_POINT == 0).then_some(index) + }) + .collect(); + if controls.is_empty() { + (0..pl.vertices.len()).collect() + } else { + controls + } +} + +pub(crate) fn polyline3d_control_vertex_count(pl: &Polyline3D) -> usize { + polyline3d_control_indices(pl).len() +} + +fn polyline3d_controls(pl: &Polyline3D) -> Vec { + polyline3d_control_indices(pl) + .into_iter() + .filter_map(|index| pl.vertices.get(index).cloned()) + .collect() +} + +fn polyline3d_wire_points(pl: &Polyline3D) -> Vec<[f64; 3]> { + let curve: Vec<[f64; 3]> = pl + .vertices + .iter() + .filter(|vertex| vertex.flags & POLY3D_SPLINE_POINT != 0) + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect(); + if !curve.is_empty() { + curve + } else { + polyline3d_controls(pl) + .iter() + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect() + } +} + +fn polyline3d_length(pl: &Polyline3D) -> f64 { + let points = polyline3d_wire_points(pl); + let mut length = points + .windows(2) + .map(|pair| Vec3::from(pair[0]).distance(Vec3::from(pair[1]))) + .sum::(); + if pl.is_closed() && points.len() >= 2 { + length += Vec3::from(points[0]) + .distance(Vec3::from(points[points.len() - 1])); + } + length +} + +fn polyline3d_spline_samples( + controls: &[[f64; 3]], + degree: usize, + closed: bool, +) -> Option> { + if !controls.iter().flatten().all(|value| value.is_finite()) { + return None; + } + let curve = if closed { + let mut periodic_controls = controls.to_vec(); + periodic_controls.extend(controls.iter().take(degree).copied()); + let knots = (0..periodic_controls.len() + degree + 1) + .map(|index| index as f64) + .collect(); + NurbsCurve3::new(degree, periodic_controls, knots, None)?.with_periodicity(true) + } else { + NurbsCurve3::new(degree, controls.to_vec(), Vec::new(), None)? + }; + let mut samples = curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE); + if closed + && samples.len() >= 2 + && Vec3::from(samples[0]).distance(Vec3::from(samples[samples.len() - 1])) <= 1.0e-9 + { + samples.pop(); + } + Some(samples) +} + +fn rebuild_polyline3d_fit(pl: &mut Polyline3D) -> bool { + use acadrust::entities::polyline3d::SmoothSurfaceType as SST; + + let mut controls = polyline3d_controls(pl); + if pl.smooth_type == SST::None { + for vertex in &mut controls { + vertex.flags = + (vertex.flags & !(POLY3D_SPLINE_POINT | POLY3D_SPLINE_CONTROL)) | POLY3D_VERTEX; + } + pl.vertices = controls; + pl.flags.spline_fit = false; + return true; + } + + if pl.smooth_type == SST::Bezier { + pl.smooth_type = SST::CubicBSpline; + } + let degree = match pl.smooth_type { + SST::QuadraticBSpline => 2, + SST::CubicBSpline => 3, + SST::Bezier => unreachable!(), + SST::None => unreachable!(), + }; + if controls.len() <= degree { + return false; + } + let points: Vec<[f64; 3]> = controls + .iter() + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect(); + let Some(samples) = polyline3d_spline_samples(&points, degree, pl.is_closed()) else { + return false; + }; + + for vertex in &mut controls { + vertex.flags = (vertex.flags & !(POLY3D_SPLINE_POINT | POLY3D_SPLINE_CONTROL)) + | POLY3D_VERTEX + | POLY3D_SPLINE_CONTROL; + } + let vertex_layer = controls + .first() + .map(|vertex| vertex.layer.clone()) + .unwrap_or_else(|| "0".to_string()); + let curve_vertices = samples.into_iter().map(|point| { + let mut vertex = acadrust::entities::Vertex3DPolyline::from_xyz( + point[0], point[1], point[2], + ); + vertex.layer = vertex_layer.clone(); + vertex.flags = POLY3D_VERTEX | POLY3D_SPLINE_POINT; + vertex + }); + controls.extend(curve_vertices); + pl.vertices = controls; + pl.flags.spline_fit = true; + true +} + fn tessellate_polyline3d(pl: &Polyline3D) -> RenderEntity { let to_pt = |v: &acadrust::entities::Vertex3DPolyline| -> [f64; 3] { [v.position.x, v.position.y, v.position.z] }; - // DXF vertex flags: 8 = spline-fit curve point, 16 = spline frame control point. - // When spline-fit vertices are present use them for the wire and control points for snap; - // otherwise treat all vertices uniformly. - let spline_curve: Vec<_> = pl.vertices.iter().filter(|v| v.flags & 8 != 0).collect(); - let ctrl_pts: Vec<_> = pl.vertices.iter().filter(|v| v.flags & 16 != 0).collect(); + // Spline samples draw the wire; frame vertices remain snap points. + let spline_curve: Vec<_> = pl + .vertices + .iter() + .filter(|v| v.flags & POLY3D_SPLINE_POINT != 0) + .collect(); + let ctrl_pts: Vec<_> = pl + .vertices + .iter() + .filter(|v| v.flags & POLY3D_SPLINE_CONTROL != 0) + .collect(); let (wire_pts, key_verts) = if !spline_curve.is_empty() { let wire: Vec<[f64; 3]> = spline_curve.iter().map(|v| to_pt(v)).collect(); - let ctrl: Vec<[f64; 3]> = ctrl_pts.iter().map(|v| to_pt(v)).collect(); + let ctrl: Vec<[f64; 3]> = if ctrl_pts.is_empty() { + polyline3d_controls(pl).iter().map(to_pt).collect() + } else { + ctrl_pts.iter().map(|v| to_pt(v)).collect() + }; (wire, ctrl) } else { let pts: Vec<[f64; 3]> = pl.vertices.iter().map(to_pt).collect(); @@ -826,8 +999,9 @@ impl RenderConvertible for Polyline3D { impl Grippable for Polyline3D { fn grips(&self) -> Vec { - self.vertices - .iter() + polyline3d_control_indices(self) + .into_iter() + .filter_map(|index| self.vertices.get(index)) .enumerate() .map(|(i, v)| { square_grip( @@ -839,7 +1013,8 @@ impl Grippable for Polyline3D { } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { - if let Some(v) = self.vertices.get_mut(grip_id) { + let raw_index = polyline3d_control_indices(self).get(grip_id).copied(); + if let Some(v) = raw_index.and_then(|index| self.vertices.get_mut(index)) { match apply { GripApply::Translate(d) => { v.position.x += d.x as f64; @@ -852,12 +1027,19 @@ impl Grippable for Polyline3D { v.position.z = p.z as f64; } } + if self.smooth_type + != acadrust::entities::polyline3d::SmoothSurfaceType::None + { + let _ = rebuild_polyline3d_fit(self); + } } } fn grip_menu(&self, _grip_id: usize) -> Vec { use crate::scene::model::object::{GripMenuAction, GripMenuItem}; - vec![ + use acadrust::entities::polyline3d::SmoothSurfaceType as SST; + + let mut items = vec![ GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch, @@ -866,40 +1048,75 @@ impl Grippable for Polyline3D { label: "Add Vertex", action: GripMenuAction::AddVertex, }, - GripMenuItem { + ]; + let minimum = match self.smooth_type { + SST::None if self.is_closed() => 3, + SST::None => 2, + SST::QuadraticBSpline => 3, + SST::CubicBSpline | SST::Bezier => 4, + }; + if polyline3d_control_vertex_count(self) > minimum { + items.push(GripMenuItem { label: "Remove Vertex", action: GripMenuAction::RemoveVertex, - }, - ] + }); + } + items } fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) { use crate::scene::model::object::GripMenuAction as A; - let n = self.vertices.len(); + use acadrust::entities::polyline3d::SmoothSurfaceType as SST; + + let mut controls = polyline3d_controls(self); + let n = controls.len(); match action { A::AddVertex if grip_id < n => { if grip_id == n - 1 && !self.is_closed() { - let mut new_v = self.vertices[grip_id].clone(); + let mut new_v = controls[grip_id].clone(); + if n >= 2 { + let previous = &controls[n - 2].position; + let last = &controls[n - 1].position; + let next = Vec3::new(previous.x, previous.y, previous.z).lerp( + Vec3::new(last.x, last.y, last.z), + 2.0, + ); + new_v.position.x = next.x; + new_v.position.y = next.y; + new_v.position.z = next.z; + } new_v.handle = acadrust::Handle::NULL; - self.vertices.push(new_v); - return; + controls.push(new_v); + } else { + let i1 = (grip_id + 1) % n; + let v0 = &controls[grip_id]; + let v1 = &controls[i1]; + let midpoint = Vec3::new(v0.position.x, v0.position.y, v0.position.z).lerp( + Vec3::new(v1.position.x, v1.position.y, v1.position.z), + 0.5, + ); + let mut new_v = v0.clone(); + new_v.position.x = midpoint.x; + new_v.position.y = midpoint.y; + new_v.position.z = midpoint.z; + new_v.handle = acadrust::Handle::NULL; + controls.insert(grip_id + 1, new_v); } - let i1 = (grip_id + 1) % n; - let v0 = &self.vertices[grip_id]; - let v1 = &self.vertices[i1]; - let mx = (v0.position.x + v1.position.x) * 0.5; - let my = (v0.position.y + v1.position.y) * 0.5; - let mz = (v0.position.z + v1.position.z) * 0.5; - let mut new_v = v0.clone(); - new_v.position.x = mx; - new_v.position.y = my; - new_v.position.z = mz; - new_v.handle = acadrust::Handle::NULL; - let insert_at = (grip_id + 1).min(self.vertices.len()); - self.vertices.insert(insert_at, new_v); + self.vertices = controls; + let _ = rebuild_polyline3d_fit(self); } - A::RemoveVertex if grip_id < n && n > 2 => { - self.vertices.remove(grip_id); + A::RemoveVertex if grip_id < n => { + let minimum = match self.smooth_type { + SST::None if self.is_closed() => 3, + SST::None => 2, + SST::QuadraticBSpline => 3, + SST::CubicBSpline | SST::Bezier => 4, + }; + if n > minimum { + controls.remove(grip_id); + self.vertices = controls; + let _ = rebuild_polyline3d_fit(self); + } } _ => {} } @@ -909,40 +1126,79 @@ impl Grippable for Polyline3D { impl PropertyEditable for Polyline3D { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { use acadrust::entities::polyline3d::SmoothSurfaceType as SST; - let n = self.vertices.len(); - let v0 = self.vertices.first(); - let vertex_x = v0.map(|v| v.position.x).unwrap_or_default(); - let vertex_y = v0.map(|v| v.position.y).unwrap_or_default(); - let vertex_z = v0.map(|v| v.position.z).unwrap_or_default(); + let control_indices = polyline3d_control_indices(self); + let n = control_indices.len(); + let vi = if n == 0 { + 0 + } else { + crate::scene::view::dispatch::prop_current_vertex().min(n - 1) + }; + let vertex = control_indices + .get(vi) + .and_then(|index| self.vertices.get(*index)); + let vertex_x = vertex.map(|v| v.position.x).unwrap_or_default(); + let vertex_y = vertex.map(|v| v.position.y).unwrap_or_default(); + let vertex_z = vertex.map(|v| v.position.z).unwrap_or_default(); + let vertex_label = if n == 0 { + "—".to_string() + } else { + format!("{} / {}", vi + 1, n) + }; let fit_smooth = match self.smooth_type { SST::None => "None", SST::QuadraticBSpline => "Quadratic", - SST::CubicBSpline => "Cubic", - SST::Bezier => "Bezier", + SST::CubicBSpline | SST::Bezier => "Cubic", }; + let mut smooth_options = vec!["None".to_string()]; + if n >= 3 { + smooth_options.push("Quadratic".to_string()); + } + if n >= 4 { + smooth_options.push("Cubic".to_string()); + } vec![ PropSection { title: t!("Geometry").into_owned(), props: vec![ - ro(t!("Vertex").as_ref(), "pl3_vertex", if n > 0 { "1" } else { "" }), + stepper( + t!("Current Vertex").as_ref(), + "pl3_current_vertex", + vertex_label, + ), edit(t!("Vertex X").as_ref(), "pl3_vertex_x", vertex_x), edit(t!("Vertex Y").as_ref(), "pl3_vertex_y", vertex_y), edit(t!("Vertex Z").as_ref(), "pl3_vertex_z", vertex_z), + ro( + t!("Length").as_ref(), + "pl3_length", + format_length(polyline3d_length(self)), + ), ], }, PropSection { title: t!("Misc").into_owned(), props: vec![ Property { + label: t!("Fit/Smooth").into_owned(), + field: "pl3_smooth", + value: PropValue::Choice { + selected: fit_smooth.to_string(), + options: smooth_options, + }, + }, + Property { label: t!("Closed").into_owned(), field: "pl3_closed", - value: PropValue::BoolToggle { - field: "pl3_closed", - value: self.is_closed(), + value: if n >= 3 { + PropValue::BoolToggle { + field: "pl3_closed", + value: self.is_closed(), + } + } else { + PropValue::ReadOnly("No".to_string()) }, }, - ro(t!("Fit/Smooth").as_ref(), "pl3_smooth", fit_smooth), ], }, ] @@ -956,25 +1212,65 @@ impl PropertyEditable for Polyline3D { } else { value == "true" }; + if closed && polyline3d_control_vertex_count(self) < 3 { + return; + } if closed { self.close(); } else { self.open(); } + if self.smooth_type + != acadrust::entities::polyline3d::SmoothSurfaceType::None + { + let _ = rebuild_polyline3d_fit(self); + } + } + "pl3_smooth" => { + use acadrust::entities::polyline3d::SmoothSurfaceType as SST; + let next = match value { + "None" => SST::None, + "Quadratic" => SST::QuadraticBSpline, + "Cubic" => SST::CubicBSpline, + _ => return, + }; + let previous = self.smooth_type; + self.smooth_type = next; + if !rebuild_polyline3d_fit(self) { + self.smooth_type = previous; + } } "pl3_vertex_x" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) { + let vi = crate::scene::view::dispatch::prop_current_vertex(); + let raw_index = polyline3d_control_indices(self).get(vi).copied(); + if let (Some(v), Some(vert)) = ( + parse_f64(value).filter(|value| value.is_finite()), + raw_index.and_then(|index| self.vertices.get_mut(index)), + ) { vert.position.x = v; + let _ = rebuild_polyline3d_fit(self); } } "pl3_vertex_y" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) { + let vi = crate::scene::view::dispatch::prop_current_vertex(); + let raw_index = polyline3d_control_indices(self).get(vi).copied(); + if let (Some(v), Some(vert)) = ( + parse_f64(value).filter(|value| value.is_finite()), + raw_index.and_then(|index| self.vertices.get_mut(index)), + ) { vert.position.y = v; + let _ = rebuild_polyline3d_fit(self); } } "pl3_vertex_z" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) { + let vi = crate::scene::view::dispatch::prop_current_vertex(); + let raw_index = polyline3d_control_indices(self).get(vi).copied(); + if let (Some(v), Some(vert)) = ( + parse_f64(value).filter(|value| value.is_finite()), + raw_index.and_then(|index| self.vertices.get_mut(index)), + ) { vert.position.z = v; + let _ = rebuild_polyline3d_fit(self); } } _ => {} diff --git a/src/modules/draw/draw/poly3d.rs b/src/modules/draw/draw/poly3d.rs index 4891d31f5..773a53ce8 100644 --- a/src/modules/draw/draw/poly3d.rs +++ b/src/modules/draw/draw/poly3d.rs @@ -1,11 +1,4 @@ -// 3D polyline tool — interactive command. -// -// Command: 3DPOLY — pick a sequence of points whose Z ordinates may differ and -// commit them as a single 3D polyline of straight segments. Unlike PLINE (a -// planar lightweight polyline with bulge arcs), every vertex keeps its own Z, -// so the result is a true non-planar path. Close (C) adds a closing segment -// back to the first point; Undo (U) drops the last picked vertex. Enter / Esc -// finishes the open polyline. +// 3DPOLY creates open or closed non-planar paths. use acadrust::entities::Polyline3D; use acadrust::types::Vector3; @@ -67,7 +60,7 @@ impl CadCommand for Poly3dCommand { fn prompt(&self) -> String { match self.points.len() { 0 => t!("3DPOLY Specify start point of polyline:").into_owned(), - 1 => t!("3DPOLY Specify next point [Undo]:").into_owned(), + 1 | 2 => t!("3DPOLY Specify next point [Undo]:").into_owned(), _ => t!("3DPOLY Specify next point [Close/Undo]:").into_owned(), } } @@ -98,10 +91,10 @@ impl CadCommand for Poly3dCommand { fn on_text_input(&mut self, text: &str) -> Option { match text.trim().to_uppercase().as_str() { - "C" | "CLOSE" => match self.build(true) { - Some(e) => Some(CmdResult::CommitAndExit(e)), - None => Some(CmdResult::NeedPoint), - }, + "C" | "CLOSE" if self.points.len() >= 3 => self + .build(true) + .map(CmdResult::CommitAndExit), + "C" | "CLOSE" => Some(CmdResult::NeedPoint), "U" | "UNDO" => { self.points.pop(); Some(CmdResult::NeedPoint) diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 7023f0774..4d157b241 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -1771,6 +1771,9 @@ fn coord_suffix(label: &str) -> Option<(&str, usize)> { /// Length of the coordinate group starting at `idx`: consecutive text rows /// labelled " X", " Y" and optionally " Z". 0/1 = no group. fn coord_group_len(props: &[crate::scene::model::object::Property], idx: usize) -> usize { + if props[idx].field == "pl3_vertex_x" { + return 0; + } let groupable = |p: &crate::scene::model::object::Property| { matches!( p.value,