From ff19353a212e7ea95db781d803960987444c9590 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 29 Jul 2026 21:21:26 +0300 Subject: [PATCH] feat(draw): add BLEND command Closes #560 --- src/app/commands/dim.rs | 16 + src/modules/draw/modify/blend.rs | 685 +++++++++++++++++++++++++++++++ src/modules/draw/modify/mod.rs | 1 + 3 files changed, 702 insertions(+) create mode 100644 src/modules/draw/modify/blend.rs diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 2b4c1e577..54062caf7 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -819,6 +819,22 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "BLEND" | "BLE" => { + use crate::modules::draw::modify::blend::BlendCommand; + let all_entities: Vec<_> = self.tabs[i] + .scene + .entity_wires() + .iter() + .filter_map(|wire| { + let handle = Scene::handle_from_wire_name(&wire.name)?; + self.tabs[i].scene.document.get_entity(handle).cloned() + }) + .collect(); + let new_cmd = BlendCommand::new(all_entities); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + "ARRAY" | "ARRAYRECT" => { let handles: Vec<_> = self.tabs[i] .scene diff --git a/src/modules/draw/modify/blend.rs b/src/modules/draw/modify/blend.rs new file mode 100644 index 000000000..a7e4b0f15 --- /dev/null +++ b/src/modules/draw/modify/blend.rs @@ -0,0 +1,685 @@ +//! BLEND (BLE) — create a smooth spline between two open curve endpoints. +//! +//! The endpoint nearest each pick is used. Tangent continuity creates a cubic +//! Bezier (G1); curvature continuity creates a quintic Bezier whose endpoint +//! first and second derivatives reproduce the source curves' tangent and +//! geometric curvature (G2). + +use acadrust::entities::{EntityCommon, Spline}; +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle}; +use glam::DVec3; +use truck_modeling::base::{BoundedCurve, ParametricCurve, Vector4}; +use truck_modeling::{BSplineCurve, KnotVec, NurbsCurve, Point3}; + +use crate::command::{CadCommand, CmdOption, CmdResult}; +use crate::entities::common::BulgeArc; +use crate::scene::model::wire_model::WireModel; +use crate::scene::view::transform::{ocs_axes, ocs_point_to_wcs}; + +use super::entity_index::ModifyEntityIndex; + +const EPS: f64 = 1.0e-10; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Continuity { + Tangent, + Curvature, +} + +impl Continuity { + fn label(self) -> &'static str { + match self { + Self::Tangent => "Tangent", + Self::Curvature => "Curvature", + } + } +} + +#[derive(Clone, Copy)] +struct EndpointFrame { + point: DVec3, + /// Direction from the source curve interior towards its selected endpoint. + outward: DVec3, + /// Geometric curvature vector at the endpoint. + curvature: DVec3, +} + +#[derive(Clone, Copy)] +enum BlendStep { + First, + Continuity { + resume: Option<(Handle, EndpointFrame)>, + }, + Second { + handle: Handle, + endpoint: EndpointFrame, + }, +} + +pub struct BlendCommand { + continuity: Continuity, + step: BlendStep, + all_entities: Vec, + entity_index: ModifyEntityIndex, +} + +impl BlendCommand { + pub fn new(all_entities: Vec) -> Self { + let entity_index = ModifyEntityIndex::build(&all_entities); + Self { + continuity: Continuity::Curvature, + step: BlendStep::First, + all_entities, + entity_index, + } + } + + fn entity(&self, handle: Handle) -> Option<&EntityType> { + self.entity_index.get(&self.all_entities, handle) + } + + fn blend_for( + &self, + first_handle: Handle, + first: EndpointFrame, + second_handle: Handle, + click: DVec3, + ) -> Option { + if first_handle == second_handle { + return None; + } + let second_entity = self.entity(second_handle)?; + let second = endpoint_frame(second_entity, click)?; + let common = blend_common(self.entity(first_handle)?.common()); + build_blend(first, second, self.continuity, common) + } +} + +impl CadCommand for BlendCommand { + fn name(&self) -> &'static str { + "BLEND" + } + + fn prompt(&self) -> String { + match self.step { + BlendStep::First => format!( + "BLEND Select first open curve [Continuity={}]:", + self.continuity.label() + ), + BlendStep::Continuity { .. } => format!( + "BLEND Enter continuity [Tangent/Curvature] <{}>:", + self.continuity.label() + ), + BlendStep::Second { .. } => format!( + "BLEND Select second open curve [Continuity={}]:", + self.continuity.label() + ), + } + } + + fn options(&self) -> Vec { + match self.step { + BlendStep::First | BlendStep::Second { .. } => { + vec![CmdOption::new("Continuity", "C")] + } + BlendStep::Continuity { .. } => vec![ + CmdOption::new("Tangent", "T"), + CmdOption::new("Curvature", "C"), + ], + } + } + + fn wants_text_input(&self) -> bool { + matches!(self.step, BlendStep::Continuity { .. }) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let upper = text.trim().to_ascii_uppercase(); + match self.step { + BlendStep::First => { + if upper == "C" || upper == "CONTINUITY" { + self.step = BlendStep::Continuity { resume: None }; + Some(CmdResult::NeedPoint) + } else { + None + } + } + BlendStep::Second { handle, endpoint } => { + if upper == "C" || upper == "CONTINUITY" { + self.step = BlendStep::Continuity { + resume: Some((handle, endpoint)), + }; + Some(CmdResult::NeedPoint) + } else { + None + } + } + BlendStep::Continuity { resume } => { + if upper.is_empty() { + self.step = resume + .map(|(handle, endpoint)| BlendStep::Second { handle, endpoint }) + .unwrap_or(BlendStep::First); + return Some(CmdResult::NeedPoint); + } + match upper.as_str() { + "T" | "TANGENT" => self.continuity = Continuity::Tangent, + "C" | "CURVATURE" => self.continuity = Continuity::Curvature, + _ => return Some(CmdResult::NeedPoint), + } + self.step = resume + .map(|(handle, endpoint)| BlendStep::Second { handle, endpoint }) + .unwrap_or(BlendStep::First); + Some(CmdResult::NeedPoint) + } + } + } + + fn needs_entity_pick(&self) -> bool { + !matches!(self.step, BlendStep::Continuity { .. }) + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, click: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + match self.step { + BlendStep::First => { + let Some(endpoint) = self.entity(handle).and_then(|e| endpoint_frame(e, click)) + else { + return CmdResult::NeedPoint; + }; + self.step = BlendStep::Second { handle, endpoint }; + CmdResult::NeedPoint + } + BlendStep::Second { + handle: first_handle, + endpoint: first, + } => self + .blend_for(first_handle, first, handle, click) + .map(EntityType::Spline) + .map(CmdResult::CommitAndExit) + .unwrap_or(CmdResult::NeedPoint), + BlendStep::Continuity { .. } => CmdResult::NeedPoint, + } + } + + fn on_hover_entity(&mut self, handle: Handle, click: DVec3) -> Vec { + let BlendStep::Second { + handle: first_handle, + endpoint: first, + } = self.step + else { + return Vec::new(); + }; + let Some(spline) = self.blend_for(first_handle, first, handle, click) else { + return Vec::new(); + }; + vec![WireModel::solid_f64( + "blend_preview".into(), + sample_bezier(&spline.control_points, 64), + WireModel::CYAN, + false, + )] + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if let BlendStep::Continuity { resume } = self.step { + self.step = resume + .map(|(handle, endpoint)| BlendStep::Second { handle, endpoint }) + .unwrap_or(BlendStep::First); + CmdResult::NeedPoint + } else { + CmdResult::Cancel + } + } +} + +fn blend_common(source: &EntityCommon) -> EntityCommon { + let mut common = EntityCommon::new(); + common.layer = source.layer.clone(); + common.color = source.color; + common.line_weight = source.line_weight; + common.linetype = source.linetype.clone(); + common.linetype_handle = source.linetype_handle; + common.linetype_scale = source.linetype_scale; + common.transparency = source.transparency; + common +} + +fn build_blend( + first: EndpointFrame, + second: EndpointFrame, + continuity: Continuity, + common: EntityCommon, +) -> Option { + let chord = second.point - first.point; + let length = chord.length(); + if length <= EPS || !length.is_finite() { + return None; + } + let t0 = first.outward.try_normalize()?; + // The source's outward direction at the second end points away from the + // blend. Reverse it to get the blend's arrival direction. + let t1 = -second.outward.try_normalize()?; + + let (degree, points) = match continuity { + Continuity::Tangent => { + let handle = length / 3.0; + ( + 3, + vec![ + first.point, + first.point + handle * t0, + second.point - handle * t1, + second.point, + ], + ) + } + Continuity::Curvature => { + // Smaller endpoint speeds prevent a tight source curve joined over + // a long gap from creating enormous inner control points. Changing + // speed does not change the matched geometric curvature. + let speed0 = endpoint_speed(length, first.curvature.length()); + let speed1 = endpoint_speed(length, second.curvature.length()); + let p0 = first.point; + let p1 = p0 + (speed0 / 5.0) * t0; + let p2 = 2.0 * p1 - p0 + (speed0 * speed0 / 20.0) * first.curvature; + let p5 = second.point; + let p4 = p5 - (speed1 / 5.0) * t1; + let p3 = 2.0 * p4 - p5 + (speed1 * speed1 / 20.0) * second.curvature; + (5, vec![p0, p1, p2, p3, p4, p5]) + } + }; + + let mut spline = Spline::new(); + spline.common = common; + spline.degree = degree; + spline.knots = Spline::generate_clamped_knots(degree as usize, points.len()); + spline.control_points = points + .into_iter() + .map(|p| Vector3::new(p.x, p.y, p.z)) + .collect(); + Some(spline) +} + +fn endpoint_speed(chord: f64, curvature: f64) -> f64 { + if curvature <= EPS { + chord + } else { + chord.min((2.0 * chord / curvature).sqrt()) + } +} + +fn endpoint_frame(entity: &EntityType, click: DVec3) -> Option { + match entity { + EntityType::Line(line) => { + let start = to_dvec(line.start); + let end = to_dvec(line.end); + segment_endpoint(start, end, click) + } + EntityType::Arc(arc) => arc_endpoint(arc, click), + EntityType::LwPolyline(poly) if !poly.is_closed => { + let n = poly.vertices.len(); + if n < 2 { + return None; + } + let normal = to_dvec(poly.normal).normalize_or_zero(); + let selected_start = click.distance_squared(lw_point(poly, 0)) + <= click.distance_squared(lw_point(poly, n - 1)); + let segment = if selected_start { 0 } else { n - 2 }; + planar_segment_endpoint( + [ + poly.vertices[segment].location.x, + poly.vertices[segment].location.y, + ], + [ + poly.vertices[segment + 1].location.x, + poly.vertices[segment + 1].location.y, + ], + poly.vertices[segment].bulge, + poly.elevation, + normal, + selected_start, + ) + } + EntityType::Polyline2D(poly) if !poly.is_closed() => { + let filtered = crate::entities::polyline::drawn_vertices2d(poly); + let vertices = filtered.as_deref().unwrap_or(&poly.vertices); + let n = vertices.len(); + if n < 2 { + return None; + } + let normal = to_dvec(poly.normal).normalize_or_zero(); + let first = ocs_to_dvec( + vertices[0].location.x, + vertices[0].location.y, + poly.elevation, + normal, + ); + let last = ocs_to_dvec( + vertices[n - 1].location.x, + vertices[n - 1].location.y, + poly.elevation, + normal, + ); + let selected_start = click.distance_squared(first) <= click.distance_squared(last); + let segment = if selected_start { 0 } else { n - 2 }; + planar_segment_endpoint( + [vertices[segment].location.x, vertices[segment].location.y], + [ + vertices[segment + 1].location.x, + vertices[segment + 1].location.y, + ], + vertices[segment].bulge, + poly.elevation, + normal, + selected_start, + ) + } + EntityType::Polyline(poly) if !poly.is_closed() => { + let points: Vec<_> = poly.vertices.iter().map(|v| to_dvec(v.location)).collect(); + polyline_endpoint(&points, click) + } + EntityType::Polyline3D(poly) if !poly.is_closed() => { + let points: Vec<_> = poly.vertices.iter().map(|v| to_dvec(v.position)).collect(); + polyline_endpoint(&points, click) + } + EntityType::Spline(spline) if !spline.flags.closed && !spline.flags.periodic => { + spline_endpoint(spline, click) + } + _ => None, + } +} + +fn segment_endpoint(start: DVec3, end: DVec3, click: DVec3) -> Option { + let direction = (end - start).try_normalize()?; + if click.distance_squared(start) <= click.distance_squared(end) { + Some(EndpointFrame { + point: start, + outward: -direction, + curvature: DVec3::ZERO, + }) + } else { + Some(EndpointFrame { + point: end, + outward: direction, + curvature: DVec3::ZERO, + }) + } +} + +fn polyline_endpoint(points: &[DVec3], click: DVec3) -> Option { + if points.len() < 2 { + return None; + } + if click.distance_squared(points[0]) <= click.distance_squared(points[points.len() - 1]) { + segment_endpoint(points[0], points[1], points[0]) + } else { + segment_endpoint( + points[points.len() - 2], + points[points.len() - 1], + points[points.len() - 1], + ) + } +} + +fn arc_endpoint(arc: &acadrust::entities::Arc, click: DVec3) -> Option { + if arc.radius <= EPS { + return None; + } + let normal_tuple = (arc.normal.x, arc.normal.y, arc.normal.z); + let normal = to_dvec(arc.normal).try_normalize()?; + let (ax, ay) = ocs_axes(normal_tuple); + let axis_x = DVec3::new(ax.0, ax.1, ax.2); + let axis_y = DVec3::new(ay.0, ay.1, ay.2); + let center = tuple_to_dvec(ocs_point_to_wcs( + (arc.center.x, arc.center.y, arc.center.z), + normal_tuple, + )); + let at = |angle: f64| center + arc.radius * (angle.cos() * axis_x + angle.sin() * axis_y); + let start = at(arc.start_angle); + let end = at(arc.end_angle); + let (point, angle, at_start) = if click.distance_squared(start) <= click.distance_squared(end) { + (start, arc.start_angle, true) + } else { + (end, arc.end_angle, false) + }; + let tangent = normal + .cross(point - center) + .try_normalize() + .or_else(|| (-angle.sin() * axis_x + angle.cos() * axis_y).try_normalize())?; + Some(EndpointFrame { + point, + outward: if at_start { -tangent } else { tangent }, + curvature: (center - point) / (arc.radius * arc.radius), + }) +} + +fn lw_point(poly: &acadrust::entities::LwPolyline, index: usize) -> DVec3 { + let normal = to_dvec(poly.normal).normalize_or_zero(); + let p = poly.vertices[index].location; + ocs_to_dvec(p.x, p.y, poly.elevation, normal) +} + +fn planar_segment_endpoint( + p0: [f64; 2], + p1: [f64; 2], + bulge: f64, + elevation: f64, + normal: DVec3, + selected_start: bool, +) -> Option { + if normal.length_squared() <= EPS { + return None; + } + let start = ocs_to_dvec(p0[0], p0[1], elevation, normal); + let end = ocs_to_dvec(p1[0], p1[1], elevation, normal); + let Some(arc) = BulgeArc::from_bulge(p0, p1, bulge) else { + return segment_endpoint(start, end, if selected_start { start } else { end }); + }; + let center = ocs_to_dvec(arc.center[0], arc.center[1], elevation, normal); + let point = if selected_start { start } else { end }; + let traversal_tangent = (normal.cross(point - center) * bulge.signum()).try_normalize()?; + Some(EndpointFrame { + point, + outward: if selected_start { + -traversal_tangent + } else { + traversal_tangent + }, + curvature: (center - point) / (arc.radius * arc.radius), + }) +} + +fn spline_endpoint(spline: &Spline, click: DVec3) -> Option { + if spline.control_points.len() >= 2 { + return spline_control_endpoint(spline, click); + } + let points: Vec<_> = spline.fit_points.iter().copied().map(to_dvec).collect(); + if points.len() < 2 { + return None; + } + let at_start = + click.distance_squared(points[0]) <= click.distance_squared(points[points.len() - 1]); + let index = if at_start { 0 } else { points.len() - 1 }; + let tangent = if at_start { + let stored = to_dvec(spline.begin_tangent); + if stored.length_squared() > EPS { + stored + } else { + points[1] - points[0] + } + } else { + let stored = to_dvec(spline.end_tangent); + if stored.length_squared() > EPS { + stored + } else { + points[index] - points[index - 1] + } + } + .try_normalize()?; + let curvature = if points.len() >= 3 { + if at_start { + circumcircle_curvature(points[0], points[1], points[2]) + } else { + circumcircle_curvature(points[index], points[index - 1], points[index - 2]) + } + } else { + DVec3::ZERO + }; + Some(EndpointFrame { + point: points[index], + outward: if at_start { -tangent } else { tangent }, + curvature, + }) +} + +fn spline_control_endpoint(spline: &Spline, click: DVec3) -> Option { + let count = spline.control_points.len(); + let degree = usize::try_from(spline.degree).ok()?; + if degree == 0 || count <= degree { + return None; + } + let knots = if spline.knots.len() == count + degree + 1 { + KnotVec::from(spline.knots.clone()) + } else { + KnotVec::uniform_knot(degree, count - 1) + }; + + if spline.weights.len() == count { + let homogeneous: Vec = spline + .control_points + .iter() + .zip(&spline.weights) + .map(|(point, &weight)| { + let weight = if weight.abs() <= EPS { 1.0 } else { weight }; + Vector4::new(point.x * weight, point.y * weight, point.z * weight, weight) + }) + .collect(); + let curve = NurbsCurve::new(BSplineCurve::new(knots, homogeneous)); + let (start, end) = curve.range_tuple(); + let at_start = click.distance_squared(point_to_dvec(curve.subs(start))) + <= click.distance_squared(point_to_dvec(curve.subs(end))); + let parameter = if at_start { start } else { end }; + curve_frame( + point_to_dvec(curve.subs(parameter)), + vector_to_dvec(curve.der(parameter)), + vector_to_dvec(curve.der2(parameter)), + at_start, + ) + } else { + let controls: Vec = spline + .control_points + .iter() + .map(|point| Point3::new(point.x, point.y, point.z)) + .collect(); + let curve = BSplineCurve::new(knots, controls); + let (start, end) = curve.range_tuple(); + let at_start = click.distance_squared(point_to_dvec(curve.subs(start))) + <= click.distance_squared(point_to_dvec(curve.subs(end))); + let parameter = if at_start { start } else { end }; + curve_frame( + point_to_dvec(curve.subs(parameter)), + vector_to_dvec(curve.der(parameter)), + vector_to_dvec(curve.der2(parameter)), + at_start, + ) + } +} + +fn curve_frame( + point: DVec3, + first_derivative: DVec3, + second_derivative: DVec3, + at_start: bool, +) -> Option { + let speed_squared = first_derivative.length_squared(); + if speed_squared <= EPS { + return None; + } + let tangent = first_derivative / speed_squared.sqrt(); + let normal_second = second_derivative - tangent * second_derivative.dot(tangent); + Some(EndpointFrame { + point, + outward: if at_start { -tangent } else { tangent }, + curvature: normal_second / speed_squared, + }) +} + +fn circumcircle_curvature(point: DVec3, next: DVec3, third: DVec3) -> DVec3 { + let u = next - point; + let v = third - point; + let cross = u.cross(v); + let denominator = 2.0 * cross.length_squared(); + if denominator <= EPS { + return DVec3::ZERO; + } + let center_offset = + (u.length_squared() * v.cross(cross) + v.length_squared() * cross.cross(u)) / denominator; + let radius_squared = center_offset.length_squared(); + if radius_squared <= EPS { + DVec3::ZERO + } else { + center_offset / radius_squared + } +} + +fn sample_bezier(control_points: &[Vector3], segments: usize) -> Vec<[f64; 3]> { + let degree = control_points.len().saturating_sub(1); + (0..=segments) + .map(|step| { + let t = step as f64 / segments as f64; + let mut point = DVec3::ZERO; + for (index, control) in control_points.iter().enumerate() { + let coefficient = binomial(degree, index) as f64 + * t.powi(index as i32) + * (1.0 - t).powi((degree - index) as i32); + point += coefficient * to_dvec(*control); + } + [point.x, point.y, point.z] + }) + .collect() +} + +fn binomial(n: usize, k: usize) -> usize { + match (n, k) { + (3, 0 | 3) | (5, 0 | 5) => 1, + (3, 1 | 2) => 3, + (5, 1 | 4) => 5, + (5, 2 | 3) => 10, + _ => 1, + } +} + +fn ocs_to_dvec(x: f64, y: f64, z: f64, normal: DVec3) -> DVec3 { + tuple_to_dvec(ocs_point_to_wcs((x, y, z), (normal.x, normal.y, normal.z))) +} + +fn tuple_to_dvec(point: (f64, f64, f64)) -> DVec3 { + DVec3::new(point.0, point.1, point.2) +} + +fn to_dvec(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + +fn point_to_dvec(point: Point3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + +fn vector_to_dvec(vector: truck_modeling::Vector3) -> DVec3 { + DVec3::new(vector.x, vector.y, vector.z) +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["BLEND", "BLE"] +}); diff --git a/src/modules/draw/modify/mod.rs b/src/modules/draw/modify/mod.rs index 2a5e7a1cf..04302a18b 100644 --- a/src/modules/draw/modify/mod.rs +++ b/src/modules/draw/modify/mod.rs @@ -1,6 +1,7 @@ pub mod align; pub mod array; pub mod attedit; +pub mod blend; pub mod break_cmd; pub mod copy; pub mod delete;