forked from 1b-agent/opencadstudio-upstream
1479 lines
58 KiB
Rust
1479 lines
58 KiB
Rust
// SPLINEDIT command — interactive spline editing.
|
|
//
|
|
// Phase 1: select a spline (entity pick)
|
|
// Phase 2: choose a sub-command:
|
|
// CLOSE — set the spline as closed (wrap last control point to first)
|
|
// OPEN — remove the closure
|
|
// REVERSE— reverse the control point order
|
|
// EXIT — done (Enter / Escape)
|
|
//
|
|
// Control-point dragging is already supported via the grip editing system.
|
|
|
|
use codec::types::Vector3;
|
|
use codec::EntityType;
|
|
use glam::DVec3;
|
|
|
|
use crate::command::{CadCommand, CmdResult};
|
|
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
|
use crate::scene::model::wire_model::WireModel;
|
|
|
|
#[allow(dead_code)]
|
|
pub fn tool() -> ToolDef {
|
|
ToolDef {
|
|
id: "SPLINEDIT",
|
|
label: "Spline Edit",
|
|
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/spline.svg")),
|
|
event: ModuleEvent::Command("SPLINEDIT".to_string()),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum Step {
|
|
SelectSpline,
|
|
Options,
|
|
Refine,
|
|
Join,
|
|
PolylinePrecision,
|
|
FitOptions,
|
|
FitTangentStart,
|
|
FitTangentEnd { start: DVec3 },
|
|
FitMove { index: usize },
|
|
FitSelectMove,
|
|
FitDelete,
|
|
FitAddPick,
|
|
FitAddNew { index: usize, first: bool },
|
|
Add,
|
|
Delete,
|
|
Elevate,
|
|
Move { index: usize, refine: bool },
|
|
Weight { index: usize },
|
|
SelectVertex { weight: bool, refine: bool },
|
|
}
|
|
|
|
pub struct SplineditCommand {
|
|
step: Step,
|
|
handle: codec::Handle,
|
|
spline: Option<codec::entities::Spline>,
|
|
pending: Option<codec::entities::Spline>,
|
|
history: Vec<(codec::Handle, codec::entities::Spline)>,
|
|
pick_context: Option<crate::command::PointPickContext>,
|
|
join_candidates: Vec<crate::command::SelectionEntity>,
|
|
delete_source: bool,
|
|
}
|
|
|
|
fn clear_fit_method(spline: &mut codec::entities::Spline) {
|
|
spline.fit_points.clear();
|
|
spline.begin_tangent = Vector3::ZERO;
|
|
spline.end_tangent = Vector3::ZERO;
|
|
spline.dwg_flags1 &= !1;
|
|
spline.dxf_flags &= !(32 | 1024);
|
|
}
|
|
|
|
fn weights_are_rational(weights: &[f64]) -> bool {
|
|
weights.first().is_some_and(|first| {
|
|
weights
|
|
.iter()
|
|
.any(|weight| (*weight - *first).abs() > 1e-12)
|
|
})
|
|
}
|
|
|
|
impl SplineditCommand {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
step: Step::SelectSpline,
|
|
handle: codec::Handle::NULL,
|
|
spline: None,
|
|
pending: None,
|
|
history: Vec::new(),
|
|
pick_context: None,
|
|
join_candidates: Vec::new(),
|
|
delete_source: true,
|
|
}
|
|
}
|
|
|
|
pub fn with_delete_source(mut self, delete: bool) -> Self {
|
|
self.delete_source = delete;
|
|
self
|
|
}
|
|
|
|
fn convert_polyline(&mut self, precision: u8) -> CmdResult {
|
|
let result = self.spline.as_ref().and_then(|source| {
|
|
let curve = spatial_spline(source)?;
|
|
let approximation = curve.to_polyline_precision(precision)?;
|
|
let mut points = approximation.points;
|
|
if self.closed() {
|
|
if points.first() != points.last() {
|
|
return None;
|
|
}
|
|
points.pop();
|
|
}
|
|
if points.len() < 2 {
|
|
return None;
|
|
}
|
|
let entity = if let Some(planar) =
|
|
crate::entities::curve::entity_curve(&EntityType::Spline(source.clone()))
|
|
{
|
|
let normal = planar.plane.normal()?;
|
|
let elevation =
|
|
kernel::space::Vec3::from(points[0]).dot(kernel::space::Vec3::from(normal));
|
|
let normal = Vector3::new(normal[0], normal[1], normal[2]);
|
|
let plane = crate::entities::curve::ocs_plane(normal.clone(), elevation);
|
|
let mut polyline = codec::LwPolyline::new();
|
|
polyline.common = source.common.clone();
|
|
polyline.elevation = elevation;
|
|
polyline.normal = normal;
|
|
polyline.is_closed = self.closed();
|
|
polyline.vertices = points
|
|
.iter()
|
|
.map(|point| {
|
|
let uv = plane.project(*point)?;
|
|
Some(codec::entities::LwVertex::new(codec::types::Vector2::new(
|
|
uv[0], uv[1],
|
|
)))
|
|
})
|
|
.collect::<Option<Vec<_>>>()?;
|
|
EntityType::LwPolyline(polyline)
|
|
} else {
|
|
let mut polyline = codec::entities::Polyline3D::from_points(
|
|
points
|
|
.iter()
|
|
.map(|p| Vector3::new(p[0], p[1], p[2]))
|
|
.collect(),
|
|
);
|
|
polyline.common = source.common.clone();
|
|
polyline.flags.closed = self.closed();
|
|
EntityType::Polyline3D(polyline)
|
|
};
|
|
Some(entity)
|
|
});
|
|
let Some(mut entity) = result else {
|
|
return CmdResult::ReportError(
|
|
crate::t!("Spline cannot be converted within the requested precision.")
|
|
.into_owned(),
|
|
);
|
|
};
|
|
if self.delete_source {
|
|
CmdResult::ReplaceMany(vec![(self.handle, vec![entity])], Vec::new())
|
|
} else {
|
|
entity.common_mut().handle = codec::Handle::NULL;
|
|
CmdResult::ReplaceMany(Vec::new(), vec![entity])
|
|
}
|
|
}
|
|
|
|
fn picked_vertex(&self, point: DVec3) -> Option<usize> {
|
|
self.picked_from_points(point, &self.spline.as_ref()?.control_points)
|
|
}
|
|
fn picked_fit_point(&self, point: DVec3) -> Option<usize> {
|
|
self.picked_from_points(point, &self.spline.as_ref()?.fit_points)
|
|
}
|
|
fn has_fit_data(&self) -> bool {
|
|
self.spline
|
|
.as_ref()
|
|
.is_some_and(|s| s.fit_points.len() >= 2)
|
|
}
|
|
|
|
fn replace_fit_points(&mut self, points: Vec<Vector3>) -> CmdResult {
|
|
let Some(source) = self.spline.as_ref() else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
if points == source.fit_points {
|
|
return CmdResult::NeedPoint;
|
|
}
|
|
let mut result = source.clone();
|
|
result.fit_points = points;
|
|
self.rebuild_fit(result)
|
|
}
|
|
|
|
fn rebuild_fit(&mut self, mut result: codec::entities::Spline) -> CmdResult {
|
|
let Some(source) = self.spline.as_ref() else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
if source.fit_tolerance != 0.0 || source.weights.windows(2).any(|w| w[0] != w[1]) {
|
|
return CmdResult::ReportError(
|
|
crate::t!(
|
|
"Editing weighted or tolerance-fitted interpolation data is not supported."
|
|
)
|
|
.into_owned(),
|
|
);
|
|
}
|
|
result.control_points.clear();
|
|
result.knots.clear();
|
|
result.weights.clear();
|
|
let Some(curve) = spatial_spline(&result)
|
|
.and_then(|curve| curve.compact_knots(source.control_tolerance.max(1e-9)))
|
|
else {
|
|
return CmdResult::ReportError(
|
|
crate::t!("Fit points do not define a valid spline.").into_owned(),
|
|
);
|
|
};
|
|
result.degree = curve.degree() as i32;
|
|
result.control_points = curve
|
|
.control_points()
|
|
.iter()
|
|
.map(|p| Vector3::new(p[0], p[1], p[2]))
|
|
.collect();
|
|
result.knots = curve.knots().to_vec();
|
|
result.weights = curve.weights().to_vec();
|
|
result.dwg_flags1 |= 1;
|
|
result.dxf_flags |= 32 | 1024;
|
|
result.flags.rational = false;
|
|
result.flags.planar = crate::entities::curve::spline_is_planar(&result);
|
|
self.replace(result)
|
|
}
|
|
|
|
fn purge_fit(&mut self) -> CmdResult {
|
|
let Some(source) = self.spline.as_ref() else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
let mut result = source.clone();
|
|
if crate::entities::spline::uses_fit_method(&result) {
|
|
if result.fit_tolerance != 0.0 {
|
|
return CmdResult::ReportError(
|
|
crate::t!("Fit data cannot be purged without a stored control curve.")
|
|
.into_owned(),
|
|
);
|
|
}
|
|
let Some(curve) = spatial_spline(source)
|
|
.and_then(|c| c.compact_knots(source.control_tolerance.max(1e-9)))
|
|
else {
|
|
return CmdResult::ReportError(
|
|
crate::t!("Fit data does not define a valid control curve.").into_owned(),
|
|
);
|
|
};
|
|
result.degree = curve.degree() as i32;
|
|
result.control_points = curve
|
|
.control_points()
|
|
.iter()
|
|
.map(|p| Vector3::new(p[0], p[1], p[2]))
|
|
.collect();
|
|
result.knots = curve.knots().to_vec();
|
|
result.weights = curve.weights().to_vec();
|
|
}
|
|
result.fit_points.clear();
|
|
result.begin_tangent = Vector3::ZERO;
|
|
result.end_tangent = Vector3::ZERO;
|
|
result.dwg_flags1 &= !1;
|
|
result.dxf_flags &= !(32 | 1024);
|
|
result.flags.rational = weights_are_rational(&result.weights);
|
|
self.step = Step::Options;
|
|
self.replace(result)
|
|
}
|
|
|
|
fn finish_fit_tangents(&mut self, start: DVec3, end: DVec3) -> CmdResult {
|
|
let Some(source) = self.spline.as_ref() else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
self.step = Step::FitOptions;
|
|
let begin = Vector3::new(start.x, start.y, start.z);
|
|
let end = Vector3::new(end.x, end.y, end.z);
|
|
if source.begin_tangent == begin && source.end_tangent == end {
|
|
return CmdResult::NeedPoint;
|
|
}
|
|
let mut result = source.clone();
|
|
result.begin_tangent = begin;
|
|
result.end_tangent = end;
|
|
self.rebuild_fit(result)
|
|
}
|
|
fn picked_from_points(&self, point: DVec3, points: &[Vector3]) -> Option<usize> {
|
|
let context = self.pick_context?;
|
|
let project = |point: DVec3| {
|
|
let clip = context.view * (point - context.eye).as_vec3().extend(1.0);
|
|
if !clip.is_finite() || clip.w <= 0.0 {
|
|
return None;
|
|
}
|
|
let screen = crate::scene::pick::hit_test::world_to_screen(
|
|
point,
|
|
context.view,
|
|
context.eye,
|
|
context.bounds,
|
|
);
|
|
(screen.x.is_finite()
|
|
&& screen.y.is_finite()
|
|
&& screen.x >= 0.0
|
|
&& screen.x <= context.bounds.width
|
|
&& screen.y >= 0.0
|
|
&& screen.y <= context.bounds.height)
|
|
.then_some(screen)
|
|
};
|
|
let cursor = project(point)?;
|
|
points
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, vertex)| {
|
|
let screen = project(DVec3::new(vertex.x, vertex.y, vertex.z))?;
|
|
let distance = (screen.x - cursor.x).hypot(screen.y - cursor.y);
|
|
(distance.is_finite() && distance <= context.aperture_px)
|
|
.then_some((index, distance))
|
|
})
|
|
.min_by(|a, b| a.1.total_cmp(&b.1))
|
|
.map(|(index, _)| index)
|
|
}
|
|
|
|
fn closed(&self) -> bool {
|
|
self.spline
|
|
.as_ref()
|
|
.is_some_and(|spline| spline.flags.closed || spline.flags.periodic)
|
|
}
|
|
|
|
fn replace(&mut self, spline: codec::entities::Spline) -> CmdResult {
|
|
self.pending = Some(spline.clone());
|
|
CmdResult::ReplaceManyContinue(vec![(self.handle, vec![EntityType::Spline(spline)])])
|
|
}
|
|
|
|
fn finish_join(&mut self) -> CmdResult {
|
|
self.step = Step::Options;
|
|
let candidates = std::mem::take(&mut self.join_candidates);
|
|
let Some(source) = self.spline.as_ref() else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
let selected: Vec<_> = candidates
|
|
.iter()
|
|
.filter(|item| item.handle != self.handle)
|
|
.map(|item| (item.handle, &item.entity))
|
|
.collect();
|
|
let Some((EntityType::Spline(mut spline), consumed)) =
|
|
super::join::join_to_source(&EntityType::Spline(source.clone()), &selected)
|
|
else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
spline.dwg_flags1 &= !1;
|
|
spline.dxf_flags &= !(32 | 1024);
|
|
self.pending = Some(spline.clone());
|
|
let mut replacements = vec![(self.handle, vec![EntityType::Spline(spline)])];
|
|
replacements.extend(consumed.into_iter().map(|handle| (handle, Vec::new())));
|
|
// The host stores one document snapshot, including every consumed curve.
|
|
// The existing command-local Undo restores that snapshot and the source cache.
|
|
CmdResult::ReplaceManyContinue(replacements)
|
|
}
|
|
|
|
fn refined(
|
|
&self,
|
|
point: Option<DVec3>,
|
|
degree: Option<usize>,
|
|
) -> Option<codec::entities::Spline> {
|
|
let source = self.spline.as_ref()?;
|
|
if let Some(degree) = degree {
|
|
let current = usize::try_from(source.degree).ok()?;
|
|
if degree <= current || degree > 25 {
|
|
return None;
|
|
}
|
|
let curve = spatial_spline(source)?;
|
|
let elevation = degree.checked_sub(curve.degree())?;
|
|
let curve = if elevation == 0 {
|
|
curve
|
|
} else {
|
|
curve.elevated(elevation)?
|
|
}
|
|
.compact_knots(source.control_tolerance.max(1e-9))?;
|
|
let mut result = source.clone();
|
|
result.degree = curve.degree() as i32;
|
|
result.control_points = curve
|
|
.control_points()
|
|
.iter()
|
|
.map(|point| Vector3::new(point[0], point[1], point[2]))
|
|
.collect();
|
|
result.knots = curve.knots().to_vec();
|
|
result.weights = curve.weights().to_vec();
|
|
clear_fit_method(&mut result);
|
|
result.flags.rational = curve.is_rational();
|
|
result.flags.planar = crate::entities::curve::spline_is_planar(&result);
|
|
return Some(result);
|
|
}
|
|
let planar = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone()))?;
|
|
let kernel::geom2d::Curve::Nurbs(mut curve) = planar.curve else {
|
|
return None;
|
|
};
|
|
if let Some(point) = point {
|
|
let projected = planar.plane.project([point.x, point.y, point.z])?;
|
|
let nearest = kernel::geom2d::closest_point(
|
|
&kernel::geom2d::Curve::Nurbs(curve.clone()),
|
|
projected,
|
|
);
|
|
let (start, end) = curve.domain();
|
|
let parameter = start + nearest.t * (end - start);
|
|
if parameter <= start || parameter >= end {
|
|
return None;
|
|
}
|
|
curve.insert_knot(parameter);
|
|
}
|
|
let mut result = crate::modules::draw::modify::spline_ops::nurbs_to_spline(&curve, source);
|
|
result.control_points = curve
|
|
.control_points()
|
|
.iter()
|
|
.map(|point| {
|
|
let world = planar.plane.point_at(*point);
|
|
Vector3::new(world[0], world[1], world[2])
|
|
})
|
|
.collect();
|
|
clear_fit_method(&mut result);
|
|
result.flags.rational = curve.is_rational();
|
|
result.flags.planar = crate::entities::curve::spline_is_planar(&result);
|
|
Some(result)
|
|
}
|
|
}
|
|
|
|
impl CadCommand for SplineditCommand {
|
|
fn name(&self) -> &'static str {
|
|
"SPLINEDIT"
|
|
}
|
|
fn prompt(&self) -> String {
|
|
match self.step {
|
|
Step::FitOptions if self.closed() => crate::t!("SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] <eXit>:").into_owned(),
|
|
Step::FitOptions => crate::t!("SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] <eXit>:").into_owned(),
|
|
Step::FitTangentStart => crate::t!("SPLINEDIT Specify start tangent or [System default]:").into_owned(),
|
|
Step::FitTangentEnd { .. } => crate::t!("SPLINEDIT Specify end tangent or [System default]:").into_owned(),
|
|
Step::FitMove { .. } => crate::t!("SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] <Next>:").into_owned(),
|
|
Step::FitSelectMove | Step::FitDelete | Step::FitAddPick => crate::t!("SPLINEDIT Specify existing fit point on spline <exit>:").into_owned(),
|
|
Step::FitAddNew { first: true, .. } => crate::t!("SPLINEDIT Specify new point or [After/Before] <exit>:").into_owned(),
|
|
Step::FitAddNew { .. } => crate::t!("SPLINEDIT Specify new fit point to add <exit>:").into_owned(),
|
|
Step::Options if self.has_fit_data() && self.closed() => crate::t!("SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] <eXit>:").into_owned(),
|
|
Step::Options if self.has_fit_data() => crate::t!("SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] <eXit>:").into_owned(),
|
|
Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(),
|
|
Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] <eXit>:").into_owned(),
|
|
Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] <eXit>:").into_owned(),
|
|
Step::PolylinePrecision => crate::t!("SPLINEDIT Specify precision 0-99 <10> (straight segments):").into_owned(),
|
|
Step::Join => crate::t!("SPLINEDIT Select any open curves to join to source:").into_owned(),
|
|
Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] <eXit>:").into_owned(),
|
|
Step::Add => crate::t!("SPLINEDIT Specify a point on the spline <exit>:").into_owned(),
|
|
Step::Delete => crate::t!("SPLINEDIT Specify control vertex to delete:").into_owned(),
|
|
Step::Elevate => format!("SPLINEDIT Enter new order <{}>:", self.spline.as_ref().map_or(4, |s| s.degree + 1)),
|
|
Step::Move { index, .. } => format!("SPLINEDIT Vertex {}: specify new location or [Next/Previous/Select point/eXit] <Next>:", index + 1),
|
|
Step::SelectVertex { .. } => crate::t!("SPLINEDIT Specify control vertex:").into_owned(),
|
|
Step::Weight { index } => format!("SPLINEDIT Vertex {}: enter new weight or [Next/Previous/Select point/eXit] <Next>:", index + 1),
|
|
}
|
|
}
|
|
fn options(&self) -> Vec<crate::command::CmdOption> {
|
|
use crate::command::CmdOption;
|
|
match self.step {
|
|
Step::FitOptions => {
|
|
let mut options = vec![
|
|
CmdOption::new("Add", "A"),
|
|
CmdOption::new("Delete", "D"),
|
|
CmdOption::new("Move", "M"),
|
|
CmdOption::new("Purge", "P"),
|
|
];
|
|
if !self.closed() {
|
|
options.push(CmdOption::new("Tangents", "T"));
|
|
}
|
|
options.push(CmdOption::new("Exit", "X"));
|
|
options
|
|
}
|
|
Step::FitTangentStart | Step::FitTangentEnd { .. } => {
|
|
vec![CmdOption::new("System default", "S")]
|
|
}
|
|
Step::FitMove { .. } => vec![
|
|
CmdOption::new("Next", "N"),
|
|
CmdOption::new("Previous", "P"),
|
|
CmdOption::new("Select point", "S"),
|
|
CmdOption::new("Exit", "X"),
|
|
],
|
|
Step::FitAddNew { first: true, .. } => {
|
|
vec![CmdOption::new("After", "A"), CmdOption::new("Before", "B")]
|
|
}
|
|
Step::Options => {
|
|
let mut options = vec![if self.closed() {
|
|
CmdOption::new("Open", "O")
|
|
} else {
|
|
CmdOption::new("Close", "C")
|
|
}];
|
|
if self.has_fit_data() {
|
|
options.insert(0, CmdOption::new("Fit data", "F"));
|
|
}
|
|
if !self.closed() {
|
|
options.push(CmdOption::new("Join", "J"));
|
|
}
|
|
options.extend([
|
|
CmdOption::new("Move vertex", "M"),
|
|
CmdOption::new("Refine", "R"),
|
|
CmdOption::new("Reverse", "E"),
|
|
CmdOption::new("Polyline (lines)", "P"),
|
|
CmdOption::new("Undo", "U"),
|
|
CmdOption::new("Exit", "X"),
|
|
]);
|
|
options
|
|
}
|
|
Step::Refine => vec![
|
|
CmdOption::new("Add", "A"),
|
|
CmdOption::new("Delete", "D"),
|
|
CmdOption::new("Elevate order", "E"),
|
|
CmdOption::new("Move", "M"),
|
|
CmdOption::new("Weight", "W"),
|
|
CmdOption::new("Exit", "X"),
|
|
],
|
|
Step::Move { .. } | Step::Weight { .. } => vec![
|
|
CmdOption::new("Next", "N"),
|
|
CmdOption::new("Previous", "P"),
|
|
CmdOption::new("Select point", "S"),
|
|
CmdOption::new("Exit", "X"),
|
|
],
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
fn is_selection_gathering(&self) -> bool {
|
|
matches!(self.step, Step::Join)
|
|
}
|
|
fn selection_entities_exclude_locked(&self) -> bool {
|
|
matches!(self.step, Step::Join)
|
|
}
|
|
fn inject_selection_entities(&mut self, entities: Vec<crate::command::SelectionEntity>) {
|
|
if matches!(self.step, Step::Join) {
|
|
self.join_candidates = entities;
|
|
}
|
|
}
|
|
fn on_selection_complete(&mut self, handles: Vec<codec::Handle>) -> CmdResult {
|
|
self.join_candidates
|
|
.retain(|item| handles.contains(&item.handle) && item.handle != self.handle);
|
|
CmdResult::NeedPoint
|
|
}
|
|
fn needs_entity_pick(&self) -> bool {
|
|
matches!(self.step, Step::SelectSpline)
|
|
}
|
|
fn inject_before_entity_pick(&self) -> bool {
|
|
true
|
|
}
|
|
fn inject_picked_entity(&mut self, entity: EntityType) {
|
|
self.spline = match entity {
|
|
EntityType::Spline(spline) => Some(spline),
|
|
_ => None,
|
|
};
|
|
}
|
|
fn on_entity_pick(&mut self, handle: codec::Handle, _pt: DVec3) -> CmdResult {
|
|
if handle.is_null() || self.spline.is_none() {
|
|
return CmdResult::NeedPoint;
|
|
}
|
|
self.handle = handle;
|
|
self.step = Step::Options;
|
|
CmdResult::NeedPoint
|
|
}
|
|
fn on_entity_replaced(&mut self, old: codec::Handle, new: &[codec::Handle]) {
|
|
if old == self.handle {
|
|
if let (Some(&handle), Some(replacement)) = (new.first(), self.pending.take()) {
|
|
if let Some(previous) = self.spline.replace(replacement) {
|
|
self.history.push((old, previous));
|
|
}
|
|
self.handle = handle;
|
|
}
|
|
}
|
|
}
|
|
fn wants_text_input(&self) -> bool {
|
|
!matches!(
|
|
self.step,
|
|
Step::SelectSpline
|
|
| Step::Join
|
|
| Step::Add
|
|
| Step::Delete
|
|
| Step::FitTangentStart
|
|
| Step::FitTangentEnd { .. }
|
|
| Step::Move { .. }
|
|
| Step::SelectVertex { .. }
|
|
| Step::FitMove { .. }
|
|
| Step::FitSelectMove
|
|
| Step::FitDelete
|
|
| Step::FitAddPick
|
|
| Step::FitAddNew { .. }
|
|
)
|
|
}
|
|
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
|
let upper = text.trim().to_uppercase();
|
|
if upper.is_empty() {
|
|
return Some(self.on_enter());
|
|
}
|
|
match self.step {
|
|
Step::Options => match upper.as_str() {
|
|
"F" | "FIT" if self.has_fit_data() => self.step = Step::FitOptions,
|
|
"P" | "POLYLINE" => self.step = Step::PolylinePrecision,
|
|
"J" | "JOIN" if !self.closed() => {
|
|
self.join_candidates.clear();
|
|
self.step = Step::Join;
|
|
}
|
|
"R" | "REFINE" => self.step = Step::Refine,
|
|
"M" | "MOVE" => {
|
|
self.step = Step::Move {
|
|
index: 0,
|
|
refine: false,
|
|
}
|
|
}
|
|
"X" | "EXIT" => return Some(CmdResult::Cancel),
|
|
"U" | "UNDO" => {
|
|
if let Some((handle, spline)) = self.history.pop() {
|
|
self.handle = handle;
|
|
self.spline = Some(spline);
|
|
return Some(CmdResult::UndoDocument);
|
|
}
|
|
}
|
|
"C" | "CLOSE" | "O" | "OPEN" | "E" | "REVERSE" | "REV" => {
|
|
if (matches!(upper.as_str(), "C" | "CLOSE") && self.closed())
|
|
|| (matches!(upper.as_str(), "O" | "OPEN") && !self.closed())
|
|
{
|
|
return Some(CmdResult::NeedPoint);
|
|
}
|
|
let mut spline = self.spline.clone()?;
|
|
let op = match upper.as_str() {
|
|
"C" | "CLOSE" => "__SPLINEDIT_CLOSE__",
|
|
"O" | "OPEN" => "__SPLINEDIT_OPEN__",
|
|
_ => "__SPLINEDIT_REVERSE__",
|
|
};
|
|
apply_to_spline(&mut spline, op);
|
|
if self.spline.as_ref() == Some(&spline) {
|
|
return Some(CmdResult::NeedPoint);
|
|
}
|
|
return Some(self.replace(spline));
|
|
}
|
|
_ => {}
|
|
},
|
|
Step::FitOptions => match upper.as_str() {
|
|
"A" | "ADD" => self.step = Step::FitAddPick,
|
|
"D" | "DELETE" => self.step = Step::FitDelete,
|
|
"M" | "MOVE" => self.step = Step::FitMove { index: 0 },
|
|
"P" | "PURGE" => return Some(self.purge_fit()),
|
|
"T" | "TANGENTS" if !self.closed() => self.step = Step::FitTangentStart,
|
|
"X" | "EXIT" => self.step = Step::Options,
|
|
_ => {}
|
|
},
|
|
Step::FitTangentStart
|
|
if matches!(upper.as_str(), "S" | "SYSTEM" | "SYSTEM DEFAULT") =>
|
|
{
|
|
self.step = Step::FitTangentEnd { start: DVec3::ZERO }
|
|
}
|
|
Step::FitTangentEnd { start }
|
|
if matches!(upper.as_str(), "S" | "SYSTEM" | "SYSTEM DEFAULT") =>
|
|
{
|
|
return Some(self.finish_fit_tangents(start, DVec3::ZERO))
|
|
}
|
|
Step::FitMove { index } => {
|
|
let count = self.spline.as_ref()?.fit_points.len();
|
|
if count == 0 {
|
|
return Some(CmdResult::NeedPoint);
|
|
}
|
|
self.step = match upper.as_str() {
|
|
"N" | "NEXT" => Step::FitMove {
|
|
index: (index + 1) % count,
|
|
},
|
|
"P" | "PREVIOUS" => Step::FitMove {
|
|
index: (index + count - 1) % count,
|
|
},
|
|
"S" | "SELECT" | "SELECT POINT" => Step::FitSelectMove,
|
|
"X" | "EXIT" => Step::FitOptions,
|
|
_ => self.step,
|
|
};
|
|
}
|
|
Step::FitAddNew { first: true, .. } => match upper.as_str() {
|
|
"A" | "AFTER" => {
|
|
self.step = Step::FitAddNew {
|
|
index: 1,
|
|
first: false,
|
|
}
|
|
}
|
|
"B" | "BEFORE" => {
|
|
self.step = Step::FitAddNew {
|
|
index: 0,
|
|
first: false,
|
|
}
|
|
}
|
|
_ => {}
|
|
},
|
|
Step::PolylinePrecision => {
|
|
let Ok(precision) = upper.parse::<u8>() else {
|
|
return Some(CmdResult::ReportError(
|
|
crate::t!("Requires an integer between 0 and 99.").into_owned(),
|
|
));
|
|
};
|
|
if precision > 99 {
|
|
return Some(CmdResult::ReportError(
|
|
crate::t!("Requires an integer between 0 and 99.").into_owned(),
|
|
));
|
|
}
|
|
return Some(self.convert_polyline(precision));
|
|
}
|
|
Step::Refine => match upper.as_str() {
|
|
"A" | "ADD" => self.step = Step::Add,
|
|
"D" | "DELETE" => self.step = Step::Delete,
|
|
"E" | "ELEVATE" => self.step = Step::Elevate,
|
|
"M" | "MOVE" => {
|
|
self.step = Step::Move {
|
|
index: 0,
|
|
refine: true,
|
|
}
|
|
}
|
|
"W" | "WEIGHT" => self.step = Step::Weight { index: 0 },
|
|
"X" | "EXIT" => self.step = Step::Options,
|
|
_ => {}
|
|
},
|
|
Step::Elevate => {
|
|
let current_order = self.spline.as_ref()?.degree.max(1) as usize + 1;
|
|
let Some(order) = upper
|
|
.parse::<usize>()
|
|
.ok()
|
|
.filter(|order| *order >= current_order && *order <= 26)
|
|
else {
|
|
return Some(CmdResult::NeedPoint);
|
|
};
|
|
if order == current_order {
|
|
self.step = Step::Refine;
|
|
return Some(CmdResult::NeedPoint);
|
|
}
|
|
if let Some(spline) = self.refined(None, Some(order - 1)) {
|
|
self.step = Step::Refine;
|
|
return Some(self.replace(spline));
|
|
}
|
|
}
|
|
Step::Move { index, .. } | Step::Weight { index } => {
|
|
let count = self.spline.as_ref().map_or(0, |s| s.control_points.len());
|
|
if count == 0 {
|
|
return Some(CmdResult::NeedPoint);
|
|
}
|
|
let next = match upper.as_str() {
|
|
"N" | "NEXT" => Some((index + 1) % count),
|
|
"P" | "PREVIOUS" => Some((index + count - 1) % count),
|
|
_ => None,
|
|
};
|
|
if let Some(next) = next {
|
|
self.step = match self.step {
|
|
Step::Move { refine, .. } => Step::Move {
|
|
index: next,
|
|
refine,
|
|
},
|
|
_ => Step::Weight { index: next },
|
|
};
|
|
} else if matches!(upper.as_str(), "S" | "SELECT") {
|
|
self.step = match self.step {
|
|
Step::Move { refine, .. } => Step::SelectVertex {
|
|
weight: false,
|
|
refine,
|
|
},
|
|
_ => Step::SelectVertex {
|
|
weight: true,
|
|
refine: true,
|
|
},
|
|
};
|
|
} else if matches!(upper.as_str(), "X" | "EXIT") {
|
|
self.step = match self.step {
|
|
Step::Move { refine: false, .. } => Step::Options,
|
|
_ => Step::Refine,
|
|
};
|
|
} else if matches!(self.step, Step::Weight { .. }) {
|
|
if let Some(weight) = upper
|
|
.replace(',', ".")
|
|
.parse::<f64>()
|
|
.ok()
|
|
.filter(|weight| weight.is_finite() && *weight > 0.0)
|
|
{
|
|
let mut spline = self.spline.clone()?;
|
|
spline.weights.resize(count, 1.0);
|
|
spline.weights[index] = weight;
|
|
spline.flags.rational = weights_are_rational(&spline.weights);
|
|
clear_fit_method(&mut spline);
|
|
return Some(self.replace(spline));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Some(CmdResult::NeedPoint)
|
|
}
|
|
fn wants_point_pick_context(&self) -> bool {
|
|
matches!(
|
|
self.step,
|
|
Step::Delete
|
|
| Step::SelectVertex { .. }
|
|
| Step::FitDelete
|
|
| Step::FitSelectMove
|
|
| Step::FitAddPick
|
|
)
|
|
}
|
|
fn set_point_pick_context(&mut self, context: Option<crate::command::PointPickContext>) {
|
|
self.pick_context = context;
|
|
}
|
|
fn on_point(&mut self, point: DVec3) -> CmdResult {
|
|
if !point.is_finite() {
|
|
return CmdResult::NeedPoint;
|
|
}
|
|
match self.step {
|
|
Step::FitSelectMove | Step::FitDelete | Step::FitAddPick => {
|
|
let Some(index) = self.picked_fit_point(point) else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
match self.step {
|
|
Step::FitSelectMove => {
|
|
self.step = Step::FitMove { index };
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::FitAddPick => {
|
|
self.step = Step::FitAddNew {
|
|
index: index + 1,
|
|
first: index == 0,
|
|
};
|
|
CmdResult::NeedPoint
|
|
}
|
|
_ => {
|
|
let mut points = self.spline.as_ref().unwrap().fit_points.clone();
|
|
if points.len() <= 2 {
|
|
self.step = Step::FitOptions;
|
|
return CmdResult::ReportError(
|
|
crate::t!("Cannot delete beyond this.").into_owned(),
|
|
);
|
|
}
|
|
points.remove(index);
|
|
self.replace_fit_points(points)
|
|
}
|
|
}
|
|
}
|
|
Step::FitTangentStart | Step::FitTangentEnd { .. } => {
|
|
let source = self.spline.as_ref().unwrap();
|
|
let first = matches!(self.step, Step::FitTangentStart);
|
|
let endpoint = if first {
|
|
source.fit_points.first()
|
|
} else {
|
|
source.fit_points.last()
|
|
}
|
|
.unwrap();
|
|
let endpoint = DVec3::new(endpoint.x, endpoint.y, endpoint.z);
|
|
let direction = point - endpoint;
|
|
let Some(direction) = direction.try_normalize() else {
|
|
return CmdResult::ReportError(
|
|
crate::t!("Tangent point must differ from the fit endpoint.").into_owned(),
|
|
);
|
|
};
|
|
if let Step::FitTangentEnd { start } = self.step {
|
|
self.finish_fit_tangents(start, direction)
|
|
} else {
|
|
self.step = Step::FitTangentEnd { start: direction };
|
|
CmdResult::NeedPoint
|
|
}
|
|
}
|
|
Step::FitMove { index } => {
|
|
let mut points = self.spline.as_ref().unwrap().fit_points.clone();
|
|
let Some(vertex) = points.get_mut(index) else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
*vertex = Vector3::new(point.x, point.y, point.z);
|
|
self.replace_fit_points(points)
|
|
}
|
|
Step::FitAddNew { index, .. } => {
|
|
let mut points = self.spline.as_ref().unwrap().fit_points.clone();
|
|
if index > points.len() {
|
|
return CmdResult::NeedPoint;
|
|
}
|
|
points.insert(index, Vector3::new(point.x, point.y, point.z));
|
|
let result = self.replace_fit_points(points);
|
|
if matches!(&result, CmdResult::ReplaceManyContinue(_)) {
|
|
self.step = Step::FitAddNew {
|
|
index: index + 1,
|
|
first: false,
|
|
};
|
|
}
|
|
result
|
|
}
|
|
Step::SelectVertex { weight, refine } => {
|
|
if let Some(index) = self.picked_vertex(point) {
|
|
self.step = if weight {
|
|
Step::Weight { index }
|
|
} else {
|
|
Step::Move { index, refine }
|
|
};
|
|
}
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::Delete => {
|
|
let Some(source) = self.spline.as_ref() else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
let Some(index) = self.picked_vertex(point) else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
let controls = source
|
|
.control_points
|
|
.iter()
|
|
.map(|point| [point.x, point.y, point.z])
|
|
.collect();
|
|
let weights = if source.weights.is_empty() {
|
|
vec![1.0; source.control_points.len()]
|
|
} else {
|
|
source.weights.clone()
|
|
};
|
|
let curve = kernel::space::NurbsCurve3::new_strict(
|
|
source.degree as usize,
|
|
controls,
|
|
source.knots.clone(),
|
|
weights,
|
|
)
|
|
.map(|curve| curve.with_periodicity(source.flags.periodic || source.flags.closed));
|
|
let Some(curve) = curve.and_then(|curve| curve.without_control_vertex(index))
|
|
else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
let mut spline = source.clone();
|
|
spline.degree = curve.degree() as i32;
|
|
spline.control_points = curve
|
|
.control_points()
|
|
.iter()
|
|
.map(|point| Vector3::new(point[0], point[1], point[2]))
|
|
.collect();
|
|
spline.weights = curve.weights().to_vec();
|
|
spline.knots = curve.knots().to_vec();
|
|
clear_fit_method(&mut spline);
|
|
spline.flags.rational = curve.is_rational();
|
|
spline.flags.planar = crate::entities::curve::spline_is_planar(&spline);
|
|
self.replace(spline)
|
|
}
|
|
Step::Add => self
|
|
.refined(Some(point), None)
|
|
.map_or(CmdResult::NeedPoint, |spline| self.replace(spline)),
|
|
Step::Move { index, .. } => {
|
|
let Some(mut spline) = self.spline.clone() else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
let Some(vertex) = spline.control_points.get_mut(index) else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
*vertex = Vector3::new(point.x, point.y, point.z);
|
|
clear_fit_method(&mut spline);
|
|
spline.flags.planar = crate::entities::curve::spline_is_planar(&spline);
|
|
self.replace(spline)
|
|
}
|
|
_ => CmdResult::NeedPoint,
|
|
}
|
|
}
|
|
fn on_enter(&mut self) -> CmdResult {
|
|
match self.step {
|
|
Step::SelectSpline | Step::Options => CmdResult::Cancel,
|
|
Step::FitOptions => {
|
|
self.step = Step::Options;
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::FitTangentStart => {
|
|
let Some(source) = self.spline.as_ref() else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
self.step = Step::FitTangentEnd {
|
|
start: DVec3::new(
|
|
source.begin_tangent.x,
|
|
source.begin_tangent.y,
|
|
source.begin_tangent.z,
|
|
),
|
|
};
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::FitTangentEnd { start } => {
|
|
let Some(source) = self.spline.as_ref() else {
|
|
return CmdResult::NeedPoint;
|
|
};
|
|
self.finish_fit_tangents(
|
|
start,
|
|
DVec3::new(
|
|
source.end_tangent.x,
|
|
source.end_tangent.y,
|
|
source.end_tangent.z,
|
|
),
|
|
)
|
|
}
|
|
Step::FitDelete | Step::FitAddPick | Step::FitSelectMove => {
|
|
self.step = Step::FitOptions;
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::FitAddNew { .. } => {
|
|
self.step = Step::FitAddPick;
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::FitMove { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint),
|
|
Step::Join => self.finish_join(),
|
|
Step::PolylinePrecision => self.convert_polyline(10),
|
|
Step::Refine => {
|
|
self.step = Step::Options;
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::Add | Step::Delete | Step::SelectVertex { .. } => {
|
|
self.step = Step::Refine;
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::Elevate => {
|
|
let order = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1);
|
|
self.on_text_input(&order.to_string())
|
|
.unwrap_or(CmdResult::NeedPoint)
|
|
}
|
|
Step::Move { .. } | Step::Weight { .. } => {
|
|
self.on_text_input("N").unwrap_or(CmdResult::NeedPoint)
|
|
}
|
|
}
|
|
}
|
|
fn on_escape(&mut self) -> CmdResult {
|
|
if matches!(self.step, Step::Join | Step::PolylinePrecision) {
|
|
self.join_candidates.clear();
|
|
self.step = Step::Options;
|
|
CmdResult::NeedPoint
|
|
} else {
|
|
CmdResult::Cancel
|
|
}
|
|
}
|
|
fn on_preview_wires(&mut self, _pt: DVec3) -> Vec<WireModel> {
|
|
vec![]
|
|
}
|
|
}
|
|
/// Apply a spline operation (CLOSE/OPEN/REVERSE) to a spline entity.
|
|
/// Called from `cmd_result.rs` when the ReplaceEntity sentinel is detected.
|
|
pub fn apply_spline_op(doc: &mut codec::CadDocument, handle: codec::Handle, op: &str) {
|
|
let Some(EntityType::Spline(spline)) = doc.get_entity_mut(handle) else {
|
|
return;
|
|
};
|
|
apply_to_spline(spline, op);
|
|
}
|
|
|
|
fn apply_to_spline(spline: &mut codec::entities::Spline, op: &str) {
|
|
let result = match op {
|
|
"__SPLINEDIT_CLOSE__" => change_closure(spline, true),
|
|
"__SPLINEDIT_OPEN__" => change_closure(spline, false),
|
|
"__SPLINEDIT_REVERSE__" => Some(super::reverse::reverse_spline(spline)),
|
|
_ => None,
|
|
};
|
|
if let Some(result) = result {
|
|
*spline = result;
|
|
}
|
|
}
|
|
|
|
fn change_closure(
|
|
source: &codec::entities::Spline,
|
|
closed: bool,
|
|
) -> Option<codec::entities::Spline> {
|
|
use kernel::space::{NurbsCurve3, Parameterization};
|
|
if (source.flags.closed || source.flags.periodic) == closed {
|
|
return None;
|
|
}
|
|
let fit_method =
|
|
!source.fit_points.is_empty() || source.dwg_flags1 & 1 != 0 || source.dxf_flags & 32 != 0;
|
|
let parameterization = match source.knot_parameterization {
|
|
1 => Parameterization::Centripetal,
|
|
2 => Parameterization::Uniform,
|
|
_ => Parameterization::Chord,
|
|
};
|
|
let xyz = |point: &Vector3| [point.x, point.y, point.z];
|
|
let controls: Vec<_> = source.control_points.iter().map(xyz).collect();
|
|
let degree = usize::try_from(source.degree).ok()?;
|
|
let weights = if source.weights.is_empty() {
|
|
vec![1.0; controls.len()]
|
|
} else {
|
|
source.weights.clone()
|
|
};
|
|
if fit_method && weights.windows(2).any(|pair| pair[0] != pair[1]) {
|
|
return None;
|
|
}
|
|
let stored_curve = || {
|
|
NurbsCurve3::new_strict(
|
|
degree,
|
|
controls.clone(),
|
|
source.knots.clone(),
|
|
weights.clone(),
|
|
)
|
|
};
|
|
let mut fit_points = Vec::new();
|
|
let curve = if fit_method {
|
|
fit_points = source.fit_points.iter().map(xyz).collect();
|
|
if fit_points.is_empty() {
|
|
let curve = stored_curve()?;
|
|
let (start, end) = curve.domain();
|
|
let mut parameters: Vec<_> = curve
|
|
.knots()
|
|
.iter()
|
|
.copied()
|
|
.filter(|parameter| {
|
|
*parameter >= start
|
|
&& if closed {
|
|
*parameter <= end
|
|
} else {
|
|
*parameter < end
|
|
}
|
|
})
|
|
.collect();
|
|
parameters.dedup();
|
|
fit_points = parameters
|
|
.into_iter()
|
|
.map(|parameter| curve.point_at_knot(parameter))
|
|
.collect();
|
|
}
|
|
let curve = if closed {
|
|
NurbsCurve3::interpolate_periodic(&fit_points, parameterization)?
|
|
} else {
|
|
NurbsCurve3::interpolate_fit(&fit_points, None, None, parameterization)?
|
|
};
|
|
curve.compact_knots(source.control_tolerance.max(1e-9))?
|
|
} else if closed {
|
|
NurbsCurve3::from_weighted_control_polygon(degree, &controls, &weights, true)?
|
|
} else {
|
|
let curve = stored_curve()?.with_periodicity(false);
|
|
curve.without_control_vertex(controls.len().checked_sub(1)?)?
|
|
};
|
|
let mut result = source.clone();
|
|
result.degree = curve.degree() as i32;
|
|
result.control_points = curve
|
|
.control_points()
|
|
.iter()
|
|
.map(|point| Vector3::new(point[0], point[1], point[2]))
|
|
.collect();
|
|
result.knots = curve.knots().to_vec();
|
|
result.weights = curve.weights().to_vec();
|
|
result.flags.closed = closed;
|
|
result.flags.periodic = closed;
|
|
if closed {
|
|
result.dxf_flags |= 2048;
|
|
} else {
|
|
result.dxf_flags &= !2048;
|
|
}
|
|
result.flags.rational = !fit_method && curve.is_rational();
|
|
result.fit_points = if !closed && fit_method {
|
|
fit_points
|
|
.iter()
|
|
.map(|point| Vector3::new(point[0], point[1], point[2]))
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
if fit_method {
|
|
result.dwg_flags1 |= 1;
|
|
result.dxf_flags |= 32 | 1024;
|
|
result.begin_tangent = Vector3::ZERO;
|
|
result.end_tangent = Vector3::ZERO;
|
|
}
|
|
result.flags.planar = crate::entities::curve::spline_is_planar(&result);
|
|
Some(result)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use codec::entities::Spline;
|
|
use codec::Handle;
|
|
use iced::Rectangle;
|
|
|
|
fn control_spline(points: &[[f64; 3]], degree: usize) -> Spline {
|
|
let mut spline = Spline::new();
|
|
spline.degree = degree as i32;
|
|
spline.control_points = points
|
|
.iter()
|
|
.map(|point| Vector3::new(point[0], point[1], point[2]))
|
|
.collect();
|
|
spline.knots = kernel::space::clamped_uniform_knots(degree, points.len());
|
|
spline
|
|
}
|
|
|
|
fn selected_command(spline: Spline) -> SplineditCommand {
|
|
let mut command = SplineditCommand::new();
|
|
command.inject_picked_entity(EntityType::Spline(spline));
|
|
command.on_entity_pick(Handle::new(7), DVec3::ZERO);
|
|
command
|
|
}
|
|
|
|
fn replacement(result: CmdResult) -> Spline {
|
|
let CmdResult::ReplaceManyContinue(mut replacements) = result else {
|
|
panic!("expected a continuing replacement");
|
|
};
|
|
let Some(EntityType::Spline(spline)) = replacements[0].1.pop() else {
|
|
panic!("expected a spline replacement");
|
|
};
|
|
spline
|
|
}
|
|
|
|
#[test]
|
|
fn moving_a_control_vertex_clears_fit_state_and_recomputes_planarity() {
|
|
let mut spline = control_spline(
|
|
&[
|
|
[0.0, 0.0, 0.0],
|
|
[1.0, 0.0, 0.0],
|
|
[2.0, 1.0, 0.0],
|
|
[3.0, 0.0, 0.0],
|
|
],
|
|
3,
|
|
);
|
|
spline.fit_points = vec![Vector3::new(0.0, 0.0, 0.0)];
|
|
spline.begin_tangent = Vector3::new(1.0, 0.0, 0.0);
|
|
spline.end_tangent = Vector3::new(1.0, 0.0, 0.0);
|
|
spline.dwg_flags1 = 1;
|
|
spline.dxf_flags = 32 | 1024;
|
|
spline.flags.planar = true;
|
|
let mut command = selected_command(spline);
|
|
command.on_text_input("MOVE");
|
|
let moved = replacement(command.on_point(DVec3::new(0.0, 0.0, 1.0)));
|
|
assert!(moved.fit_points.is_empty());
|
|
assert_eq!(moved.begin_tangent, Vector3::ZERO);
|
|
assert_eq!(moved.end_tangent, Vector3::ZERO);
|
|
assert_eq!(moved.dwg_flags1 & 1, 0);
|
|
assert_eq!(moved.dxf_flags & (32 | 1024), 0);
|
|
assert!(!moved.flags.planar);
|
|
}
|
|
|
|
#[test]
|
|
fn equal_control_weights_are_not_marked_rational() {
|
|
let spline = control_spline(
|
|
&[
|
|
[0.0, 0.0, 0.0],
|
|
[1.0, 1.0, 0.0],
|
|
[2.0, 1.0, 0.0],
|
|
[3.0, 0.0, 0.0],
|
|
],
|
|
3,
|
|
);
|
|
let mut command = selected_command(spline);
|
|
command.on_text_input("REFINE");
|
|
command.on_text_input("WEIGHT");
|
|
let weighted = replacement(command.on_text_input("1").unwrap());
|
|
assert!(!weighted.flags.rational);
|
|
assert_eq!(weighted.weights, vec![1.0; 4]);
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_the_only_weighted_vertex_clears_rational_and_fit_flags() {
|
|
let mut spline = control_spline(
|
|
&[
|
|
[-0.6, 0.0, 0.0],
|
|
[-0.2, 0.4, 0.0],
|
|
[0.2, 0.4, 0.0],
|
|
[0.6, 0.0, 0.0],
|
|
],
|
|
2,
|
|
);
|
|
spline.weights = vec![1.0, 2.0, 1.0, 1.0];
|
|
spline.flags.rational = true;
|
|
spline.dwg_flags1 = 1;
|
|
spline.dxf_flags = 32 | 1024;
|
|
let mut command = selected_command(spline);
|
|
command.on_text_input("REFINE");
|
|
command.on_text_input("DELETE");
|
|
command.pick_context = Some(crate::command::PointPickContext {
|
|
view: glam::Mat4::IDENTITY,
|
|
eye: DVec3::ZERO,
|
|
bounds: Rectangle::new(iced::Point::ORIGIN, iced::Size::new(100.0, 100.0)),
|
|
aperture_px: 2.0,
|
|
});
|
|
let deleted = replacement(command.on_point(DVec3::new(-0.2, 0.4, 0.0)));
|
|
assert_eq!(deleted.control_points.len(), 3);
|
|
assert!(!deleted.flags.rational);
|
|
assert_eq!(deleted.dwg_flags1 & 1, 0);
|
|
assert_eq!(deleted.dxf_flags & (32 | 1024), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn fit_only_degree_elevation_uses_the_interpolated_curve_degree() {
|
|
let mut spline = Spline::new();
|
|
spline.degree = 1;
|
|
spline.fit_points = vec![
|
|
Vector3::new(0.0, 0.0, 0.0),
|
|
Vector3::new(1.0, 2.0, 0.0),
|
|
Vector3::new(3.0, 0.0, 0.0),
|
|
];
|
|
let mut command = selected_command(spline);
|
|
command.on_text_input("REFINE");
|
|
command.on_text_input("ELEVATE");
|
|
assert!(matches!(
|
|
command.on_text_input("invalid"),
|
|
Some(CmdResult::NeedPoint)
|
|
));
|
|
assert!(matches!(command.step, Step::Elevate));
|
|
let elevated = replacement(command.on_text_input("5").unwrap());
|
|
assert_eq!(elevated.degree, 4);
|
|
assert!(elevated.fit_points.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_closed_weighted_control_curve_can_be_opened() {
|
|
let curve = kernel::space::NurbsCurve3::from_weighted_control_polygon(
|
|
2,
|
|
&[
|
|
[0.0, 0.0, 0.0],
|
|
[2.0, 0.0, 0.0],
|
|
[2.0, 2.0, 1.0],
|
|
[0.0, 2.0, 0.0],
|
|
],
|
|
&[1.0, 2.0, 0.75, 1.5],
|
|
true,
|
|
)
|
|
.unwrap();
|
|
let mut spline = control_spline(curve.control_points(), curve.degree());
|
|
spline.knots = curve.knots().to_vec();
|
|
spline.weights = curve.weights().to_vec();
|
|
spline.flags.closed = true;
|
|
spline.flags.periodic = true;
|
|
spline.flags.rational = true;
|
|
let opened = change_closure(&spline, false).expect("closed control curve opens");
|
|
assert!(!opened.flags.closed);
|
|
assert!(!opened.flags.periodic);
|
|
assert!(kernel::space::NurbsCurve3::new_strict(
|
|
opened.degree as usize,
|
|
opened
|
|
.control_points
|
|
.iter()
|
|
.map(|point| [point.x, point.y, point.z])
|
|
.collect(),
|
|
opened.knots.clone(),
|
|
opened.weights.clone(),
|
|
)
|
|
.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn control_vertex_pick_uses_the_screen_aperture() {
|
|
let spline = control_spline(&[[-0.5, 0.0, 0.0], [0.5, 0.0, 0.0]], 1);
|
|
let mut command = selected_command(spline);
|
|
command.pick_context = Some(crate::command::PointPickContext {
|
|
view: glam::Mat4::IDENTITY,
|
|
eye: DVec3::ZERO,
|
|
bounds: Rectangle::new(iced::Point::ORIGIN, iced::Size::new(100.0, 100.0)),
|
|
aperture_px: 2.0,
|
|
});
|
|
assert_eq!(command.picked_vertex(DVec3::new(-0.49, 0.0, 0.0)), Some(0));
|
|
assert_eq!(command.picked_vertex(DVec3::new(0.0, 0.5, 0.0)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn planar_polyline_conversion_honors_source_deletion_policy() {
|
|
let handle = Handle::new(41);
|
|
let mut spline = control_spline(&[[0.0, 0.0, 2.0], [1.0, 2.0, 2.0], [3.0, 0.0, 2.0]], 2);
|
|
spline.common.handle = handle;
|
|
spline.flags.planar = true;
|
|
let mut command = SplineditCommand::new().with_delete_source(false);
|
|
command.inject_picked_entity(EntityType::Spline(spline));
|
|
command.on_entity_pick(handle, DVec3::ZERO);
|
|
let CmdResult::ReplaceMany(replacements, additions) = command.convert_polyline(10) else {
|
|
panic!("expected a polyline conversion");
|
|
};
|
|
assert!(replacements.is_empty());
|
|
let [EntityType::LwPolyline(polyline)] = additions.as_slice() else {
|
|
panic!("expected a lightweight polyline");
|
|
};
|
|
assert!(polyline.common.handle.is_null());
|
|
assert_eq!(polyline.elevation, 2.0);
|
|
assert!(polyline.vertices.len() > 2);
|
|
}
|
|
|
|
#[test]
|
|
fn nonplanar_spline_converts_to_a_spatial_polyline() {
|
|
let spline = control_spline(
|
|
&[
|
|
[0.0, 0.0, 0.0],
|
|
[1.0, 0.0, 1.0],
|
|
[2.0, 1.0, 0.0],
|
|
[3.0, 0.0, 2.0],
|
|
],
|
|
3,
|
|
);
|
|
let mut command = selected_command(spline);
|
|
let CmdResult::ReplaceMany(replacements, additions) = command.convert_polyline(10) else {
|
|
panic!("expected a polyline conversion");
|
|
};
|
|
assert!(additions.is_empty());
|
|
let [(_, entities)] = replacements.as_slice() else {
|
|
panic!("expected one source replacement");
|
|
};
|
|
let [EntityType::Polyline3D(polyline)] = entities.as_slice() else {
|
|
panic!("expected a spatial polyline");
|
|
};
|
|
assert!(polyline.vertices.len() > 2);
|
|
}
|
|
|
|
#[test]
|
|
fn closed_conversion_closes_once_and_invalid_precision_retries() {
|
|
let curve = kernel::space::NurbsCurve3::from_control_polygon(
|
|
2,
|
|
&[
|
|
[0.0, 0.0, 0.0],
|
|
[2.0, 0.0, 0.0],
|
|
[2.0, 2.0, 0.0],
|
|
[0.0, 2.0, 0.0],
|
|
],
|
|
true,
|
|
)
|
|
.unwrap();
|
|
let mut spline = control_spline(curve.control_points(), curve.degree());
|
|
spline.knots = curve.knots().to_vec();
|
|
spline.weights = curve.weights().to_vec();
|
|
spline.flags.closed = true;
|
|
spline.flags.periodic = true;
|
|
let mut command = selected_command(spline);
|
|
command.on_text_input("POLYLINE");
|
|
assert!(matches!(
|
|
command.on_text_input("100"),
|
|
Some(CmdResult::ReportError(_))
|
|
));
|
|
assert!(matches!(command.step, Step::PolylinePrecision));
|
|
let CmdResult::ReplaceMany(replacements, _) = command.on_enter() else {
|
|
panic!("expected the default conversion");
|
|
};
|
|
let [(_, entities)] = replacements.as_slice() else {
|
|
panic!("expected one source replacement");
|
|
};
|
|
let [EntityType::LwPolyline(polyline)] = entities.as_slice() else {
|
|
panic!("expected a lightweight polyline");
|
|
};
|
|
assert!(polyline.is_closed);
|
|
assert!(polyline.vertices.len() >= 4);
|
|
assert_ne!(
|
|
polyline.vertices.first().unwrap().location,
|
|
polyline.vertices.last().unwrap().location
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn two_point_fit_data_accepts_forward_endpoint_tangents() {
|
|
let mut spline = Spline::new();
|
|
spline.degree = 3;
|
|
spline.fit_points = vec![Vector3::new(0.0, 0.0, 0.0), Vector3::new(4.0, 0.0, 0.0)];
|
|
let mut command = selected_command(spline);
|
|
assert!(command.has_fit_data());
|
|
command.on_text_input("FIT");
|
|
command.on_text_input("TANGENTS");
|
|
assert!(matches!(
|
|
command.on_point(DVec3::new(1.0, 0.0, 0.0)),
|
|
CmdResult::NeedPoint
|
|
));
|
|
let edited = replacement(command.on_point(DVec3::new(5.0, 0.0, 0.0)));
|
|
assert_eq!(edited.begin_tangent, Vector3::new(1.0, 0.0, 0.0));
|
|
assert_eq!(edited.end_tangent, Vector3::new(1.0, 0.0, 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn purge_rebuilds_an_insufficient_stored_control_curve() {
|
|
let mut spline = Spline::new();
|
|
spline.degree = 3;
|
|
spline.flags.rational = true;
|
|
spline.control_points = vec![Vector3::ZERO];
|
|
spline.fit_points = vec![
|
|
Vector3::new(0.0, 0.0, 0.0),
|
|
Vector3::new(1.0, 2.0, 0.0),
|
|
Vector3::new(3.0, 2.0, 0.0),
|
|
Vector3::new(4.0, 0.0, 0.0),
|
|
];
|
|
let mut command = selected_command(spline);
|
|
command.on_text_input("FIT");
|
|
let purged = replacement(command.on_text_input("PURGE").unwrap());
|
|
assert!(purged.fit_points.is_empty());
|
|
assert!(purged.control_points.len() > purged.degree as usize);
|
|
assert!(!purged.flags.rational);
|
|
}
|
|
}
|
|
|
|
// ── Autocomplete registry ─────────────────────────────────
|
|
inventory::submit!(crate::command::CommandRegistration {
|
|
names: &["SPLINEDIT"]
|
|
}); // SplineditCommand
|
|
|
|
fn spatial_spline(source: &codec::entities::Spline) -> Option<kernel::space::NurbsCurve3> {
|
|
let curve = if crate::entities::spline::uses_fit_method(source) {
|
|
crate::entities::spline::fit_nurbs3(source)?
|
|
} else {
|
|
let current = usize::try_from(source.degree).ok()?;
|
|
let weights = if source.weights.is_empty() {
|
|
vec![1.0; source.control_points.len()]
|
|
} else {
|
|
source.weights.clone()
|
|
};
|
|
kernel::space::NurbsCurve3::new_strict(
|
|
current,
|
|
source
|
|
.control_points
|
|
.iter()
|
|
.map(|point| [point.x, point.y, point.z])
|
|
.collect(),
|
|
source.knots.clone(),
|
|
weights,
|
|
)?
|
|
};
|
|
Some(curve.with_periodicity(source.flags.closed || source.flags.periodic))
|
|
}
|