diff --git a/Cargo.lock b/Cargo.lock index 0583d5bfe..477f5cbdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed#788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9a332bb3c48eca90c3eefaa17aed514da0b8694a#9a332bb3c48eca90c3eefaa17aed514da0b8694a" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b2b1d4b#b2b1d4b0cd7def2d26a0d519991fc89a89f916d3" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=1283cce45439abecdfd49828c7d6b84cb8ba0181#1283cce45439abecdfd49828c7d6b84cb8ba0181" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 64b2533c9..999471a6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9a332bb3c48eca90c3eefaa17aed514da0b8694a", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "1283cce45439abecdfd49828c7d6b84cb8ba0181", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9a332bb3c48eca90c3eefaa17aed514da0b8694a" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/src/entities/helix.rs b/src/entities/helix.rs index 2e5bf7d1c..aa753c6e3 100644 --- a/src/entities/helix.rs +++ b/src/entities/helix.rs @@ -1,106 +1,427 @@ -// Helix entity (AcDbHelix): a spline curve plus its generating parameters. -// -// A loaded helix carries a fully evaluated `spline` (the NURBS the file -// stored) alongside the parameters that generated it (base point, axis, -// radius, turns, turn height, twist, constraint). We render and grip the -// entity through its embedded spline, and expose the generating parameters -// as the Geometry property group. - -use acadrust::entities::{Helix, HelixConstraint}; -use crate::t; +use acadrust::entities::{Helix, HelixConstraint, Spline}; +use acadrust::types::Vector3; +use cadkernel::space::{HelixCurve, HelixDirection, NurbsCurve3, Vec3}; +use glam::DVec3; use crate::command::EntityTransform; -use crate::entities::common::ro_prop as ro; -use crate::entities::traits::{Grippable, Transformable, RenderConvertible}; +use crate::entities::common::{ + center_grip, edit_prop as edit, format_angle, format_length, parse_f64, ro_prop as ro, + square_grip, +}; +use crate::entities::traits::{Grippable, RenderConvertible, Transformable}; use crate::scene::convert::acad_to_render::RenderEntity; -use crate::scene::model::object::{GripApply, GripDef, PropSection}; +use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; +use crate::t; -/// Perpendicular distance from `p` to the helix axis (line through -/// `axis_base_point` along `axis_vector`). Used to recover the top radius -/// from the last defining point of the spline. -fn radius_from_axis(helix: &Helix, p: &acadrust::types::Vector3) -> Option { - let base = &helix.axis_base_point; - let axis = helix.axis_vector.normalize(); - // A zero axis vector cannot define a line. - if axis.length() < 1e-9 { - return None; +const EPSILON: f64 = 1.0e-9; + +fn point(vector: Vector3) -> Vec3 { + Vec3::new(vector.x, vector.y, vector.z) +} + +fn vector(value: Vec3) -> Vector3 { + Vector3::new(value.x, value.y, value.z) +} + +fn display(value: Vec3) -> DVec3 { + DVec3::new(value.x, value.y, value.z) +} + +fn kernel(value: DVec3) -> Vec3 { + Vec3::new(value.x, value.y, value.z) +} + +fn finite(value: Vec3) -> bool { + value.x.is_finite() && value.y.is_finite() && value.z.is_finite() +} + +fn spline_curve(spline: &Spline) -> Option { + let controls: Vec<[f64; 3]> = spline + .control_points + .iter() + .map(|value| [value.x, value.y, value.z]) + .collect(); + let degree = usize::try_from(spline.degree).ok()?; + let weights = if spline.weights.is_empty() { + vec![1.0; controls.len()] + } else { + spline.weights.clone() + }; + NurbsCurve3::new_strict(degree, controls, spline.knots.clone(), weights) +} + +fn axis_frame(helix: &Helix) -> Option<(Vec3, Vec3, Vec3)> { + let base = point(helix.axis_base_point); + let axis = point(helix.axis_vector).normalize()?; + let radial = point(helix.start_point) - base; + let start_direction = (radial - axis * radial.dot(axis)).normalize()?; + Some((base, axis, start_direction)) +} + +fn radius_from_axis(helix: &Helix, value: Vec3) -> Option { + let (base, axis, _) = axis_frame(helix)?; + let delta = value - base; + Some((delta - axis * delta.dot(axis)).length()) +} + +fn top_radius(helix: &Helix) -> f64 { + spline_curve(&helix.spline) + .map(|curve| Vec3::from(curve.point_at(1.0))) + .and_then(|endpoint| radius_from_axis(helix, endpoint)) + .unwrap_or(helix.radius) +} + +fn kernel_curve(helix: &Helix, top_radius: f64) -> Option { + let (base, axis, start_direction) = axis_frame(helix)?; + Some(HelixCurve { + base_center: base.to_array(), + axis_direction: axis.to_array(), + start_direction: start_direction.to_array(), + base_radius: helix.radius, + top_radius, + height: helix.turns * helix.turn_height, + turns: helix.turns, + direction: if helix.handedness { + HelixDirection::CounterClockwise + } else { + HelixDirection::Clockwise + }, + }) +} + +fn projected_direction(direction: Vec3, axis: Vec3) -> Option { + (direction - axis * direction.dot(axis)).normalize() +} + +fn perpendicular(axis: Vec3) -> Option { + let basis = if axis.x.abs() <= axis.y.abs() && axis.x.abs() <= axis.z.abs() { + Vec3::X + } else if axis.y.abs() <= axis.z.abs() { + Vec3::Y + } else { + Vec3::Z + }; + projected_direction(basis, axis) +} + +fn rebuild(helix: &mut Helix, top_radius: f64) -> bool { + let Some(curve) = kernel_curve(helix, top_radius) else { + return false; + }; + let Some(nurbs) = curve.nurbs() else { + return false; + }; + helix.spline.degree = nurbs.degree() as i32; + helix.spline.knots = nurbs.knots().to_vec(); + helix.spline.control_points = nurbs + .control_points() + .iter() + .map(|value| Vector3::new(value[0], value[1], value[2])) + .collect(); + helix.spline.weights = if nurbs.is_rational() { + nurbs.weights().to_vec() + } else { + Vec::new() + }; + helix.spline.fit_points.clear(); + helix.spline.flags.closed = false; + helix.spline.flags.periodic = false; + helix.spline.flags.rational = nurbs.is_rational(); + helix.spline.flags.planar = false; + helix.spline.flags.linear = false; + true +} + +fn choice(label: &str, field: &'static str, selected: &str, options: &[String]) -> Property { + Property { + label: label.to_string(), + field, + value: PropValue::Choice { + selected: selected.to_string(), + options: options.to_vec(), + }, } - let vx = p.x - base.x; - let vy = p.y - base.y; - let vz = p.z - base.z; - let proj = vx * axis.x + vy * axis.y + vz * axis.z; - let px = vx - proj * axis.x; - let py = vy - proj * axis.y; - let pz = vz - proj * axis.z; - Some((px * px + py * py + pz * pz).sqrt()) } fn grips(helix: &Helix) -> Vec { - Grippable::grips(&helix.spline) + let Some((base, axis, _)) = axis_frame(helix) else { + return Vec::new(); + }; + let height = helix.turns * helix.turn_height; + let top_center = base + axis * height; + let top_endpoint = spline_curve(&helix.spline) + .map(|curve| Vec3::from(curve.point_at(1.0))) + .unwrap_or(top_center); + vec![ + center_grip(0, display(base)), + square_grip(1, display(point(helix.start_point))), + square_grip(2, display(top_center)), + square_grip(3, display(top_endpoint)), + ] } fn properties(helix: &Helix) -> Vec { - let bp = &helix.axis_base_point; - let turns = helix.turns; - let turn_height = helix.turn_height; - // Overall height is turns × per-turn rise. - let height = turns * turn_height; - let base_radius = helix.radius; - // Top radius: the axial distance of the last defining point. For a - // cylindrical helix this equals the base radius. - let top_radius = helix - .spline - .control_points - .last() - .or_else(|| helix.spline.fit_points.last()) - .and_then(|p| radius_from_axis(helix, p)) - .unwrap_or(base_radius); - // Turn slope: rise angle of a single turn against the base circumference. - let turn_slope = if base_radius.abs() > 1e-9 { - (turn_height / (std::f64::consts::TAU * base_radius)) - .atan() - .to_degrees() - } else { - 0.0 - }; - let twist = if helix.handedness { "CW" } else { "CCW" }; + let base = point(helix.axis_base_point); + let height = helix.turns * helix.turn_height; + let top_radius = top_radius(helix); + let curve = kernel_curve(helix, top_radius); + let turn_slope = curve.as_ref().and_then(HelixCurve::turn_slope).unwrap_or(0.0); + let total_length = curve.as_ref().and_then(HelixCurve::length).unwrap_or(0.0); let constrain = match helix.constraint { - HelixConstraint::TurnHeight => "Turn Height", - HelixConstraint::Turns => "Turns", - HelixConstraint::Height => "Height", + HelixConstraint::TurnHeight => t!("Turn Height").into_owned(), + HelixConstraint::Turns => t!("Turns").into_owned(), + HelixConstraint::Height => t!("Height").into_owned(), }; + let twist = if helix.handedness { + t!("CCW").into_owned() + } else { + t!("CW").into_owned() + }; + let constraint_options = vec![ + t!("Turn Height").into_owned(), + t!("Turns").into_owned(), + t!("Height").into_owned(), + ]; + let twist_options = vec![t!("CW").into_owned(), t!("CCW").into_owned()]; vec![PropSection { title: t!("Geometry").into_owned(), props: vec![ - ro(t!("Base point X").as_ref(), "base_x", format!("{:.4}", bp.x)), - ro(t!("Base point Y").as_ref(), "base_y", format!("{:.4}", bp.y)), - ro(t!("Base point Z").as_ref(), "base_z", format!("{:.4}", bp.z)), - ro(t!("Number of turns").as_ref(), "num_turns", format!("{turns:.4}")), - ro(t!("Turn height").as_ref(), "turn_height", format!("{turn_height:.4}")), - ro(t!("Turns").as_ref(), "turns", format!("{turns:.4}")), - ro(t!("Height").as_ref(), "height", format!("{height:.4}")), - ro(t!("Base radius").as_ref(), "base_radius", format!("{base_radius:.4}")), - ro(t!("Top radius").as_ref(), "top_radius", format!("{top_radius:.4}")), - ro(t!("Turn slope").as_ref(), "turn_slope", format!("{turn_slope:.4}")), - ro(t!("Twist").as_ref(), "twist", twist), - ro(t!("Constrain").as_ref(), "constrain", constrain), + edit(t!("Position X").as_ref(), "position_x", base.x), + edit(t!("Position Y").as_ref(), "position_y", base.y), + edit(t!("Position Z").as_ref(), "position_z", base.z), + choice(t!("Constrain").as_ref(), "constrain", &constrain, &constraint_options), + edit(t!("Height").as_ref(), "height", height), + edit(t!("Turns").as_ref(), "turns", helix.turns), + edit(t!("Turn height").as_ref(), "turn_height", helix.turn_height), + edit(t!("Base radius").as_ref(), "base_radius", helix.radius), + edit(t!("Top radius").as_ref(), "top_radius", top_radius), + choice(t!("Twist").as_ref(), "twist", &twist, &twist_options), + ro(t!("Turn slope").as_ref(), "turn_slope", format_angle(turn_slope)), + ro(t!("Total length").as_ref(), "total_length", format_length(total_length)), ], }] } -/// Helix geometry is derived from the stored spline; the generating -/// parameters are read-only (editing one would require regenerating the -/// spline, which the loaded entity does not carry a recipe for). -fn apply_geom_prop(_helix: &mut Helix, _field: &str, _value: &str) {} - -fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) { - Grippable::apply_grip(&mut helix.spline, grip_id, apply); +fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { + if field == "constrain" { + let constraint = if value == t!("Height").as_ref() { + HelixConstraint::Height + } else if value == t!("Turns").as_ref() { + HelixConstraint::Turns + } else if value == t!("Turn Height").as_ref() { + HelixConstraint::TurnHeight + } else { + return; + }; + helix.constraint = constraint; + return; + } + let original = helix.clone(); + let top = top_radius(helix); + if field == "twist" { + if value == t!("CCW").as_ref() { + helix.handedness = true; + } else if value == t!("CW").as_ref() { + helix.handedness = false; + } else { + return; + } + if !rebuild(helix, top) { + *helix = original; + } + return; + } + let Some(number) = parse_f64(value).filter(|number| number.is_finite()) else { + return; + }; + let old_base = point(helix.axis_base_point); + let old_height = helix.turns * helix.turn_height; + match field { + "position_x" | "position_y" | "position_z" => { + let mut base = old_base; + match field { + "position_x" => base.x = number, + "position_y" => base.y = number, + _ => base.z = number, + } + let delta = base - old_base; + helix.axis_base_point = vector(base); + helix.start_point = vector(point(helix.start_point) + delta); + } + "height" if number.abs() > EPSILON => { + if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON { + let turns = number / helix.turn_height; + if turns <= EPSILON { + return; + } + helix.turns = turns; + } else { + helix.turn_height = number / helix.turns.max(EPSILON); + } + } + "turns" if number > EPSILON => { + if helix.constraint == HelixConstraint::TurnHeight { + helix.turns = number; + } else { + helix.turns = number; + helix.turn_height = old_height / number; + } + } + "turn_height" if number.abs() > EPSILON => { + if helix.constraint == HelixConstraint::Turns { + helix.turn_height = number; + } else { + let turns = old_height / number; + if turns <= EPSILON { + return; + } + helix.turn_height = number; + helix.turns = turns; + } + } + "base_radius" if number > EPSILON => { + let Some((base, _, start_direction)) = axis_frame(helix) else { + return; + }; + helix.radius = number; + helix.start_point = vector(base + start_direction * number); + } + "top_radius" if number >= 0.0 => { + if !rebuild(helix, number) { + *helix = original; + } + return; + } + _ => return, + } + if !rebuild(helix, top) { + *helix = original; + } } -fn apply_transform(helix: &mut Helix, t: &EntityTransform) { - // Move the visible curve, then keep the generating points in step. - Transformable::apply_transform(&mut helix.spline, t); +fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) { + let original = helix.clone(); + let top = top_radius(helix); + let Some((base, axis, start_direction)) = axis_frame(helix) else { + return; + }; + match (grip_id, apply) { + (0, GripApply::Translate(delta)) => { + let delta = kernel(delta); + if !finite(delta) { + return; + } + helix.axis_base_point = vector(base + delta); + helix.start_point = vector(point(helix.start_point) + delta); + } + (0, GripApply::Absolute(position)) => { + let position = kernel(position); + if !finite(position) { + return; + } + let delta = position - base; + helix.axis_base_point = vector(position); + helix.start_point = vector(point(helix.start_point) + delta); + } + (1, GripApply::Absolute(position)) => { + let position = kernel(position); + if !finite(position) { + return; + } + let radial = position - base - axis * (position - base).dot(axis); + let radius = radial.length(); + if radius <= EPSILON { + return; + } + helix.radius = radius; + helix.start_point = vector(base + radial); + } + (2, GripApply::Absolute(position)) => { + let position = kernel(position); + if !finite(position) { + return; + } + let axis_vector = position - base; + let height = axis_vector.length(); + if height <= EPSILON { + return; + } + let target_axis = axis_vector / height; + if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON { + helix.axis_vector = vector(target_axis * helix.turn_height.signum()); + helix.turns = height / helix.turn_height.abs(); + } else { + helix.axis_vector = vector(target_axis); + helix.turn_height = height / helix.turns.max(EPSILON); + } + let new_axis = point(helix.axis_vector); + let start_direction = projected_direction(start_direction, new_axis) + .or_else(|| perpendicular(new_axis)); + let Some(start_direction) = start_direction else { + *helix = original; + return; + }; + helix.start_point = vector(base + start_direction * helix.radius); + } + (3, GripApply::Absolute(position)) => { + let position = kernel(position); + if !finite(position) { + return; + } + let delta = position - base; + let height = delta.dot(axis); + let radius = (delta - axis * height).length(); + if height.abs() <= EPSILON { + return; + } + if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON { + let turns = height / helix.turn_height; + if turns <= EPSILON { + return; + } + helix.turns = turns; + } else { + helix.turn_height = height / helix.turns.max(EPSILON); + } + if !rebuild(helix, radius) { + *helix = original; + } + return; + } + _ => return, + } + if !rebuild(helix, top) { + *helix = original; + } +} + +fn apply_transform(helix: &mut Helix, transform: &EntityTransform) { + match transform { + EntityTransform::Translate(delta) => { + acadrust::Entity::translate(helix, Vector3::new(delta.x, delta.y, delta.z)); + } + EntityTransform::Rotate { center, axis, angle_rad } => { + crate::scene::view::transform::apply_standard_transform( + helix, + *center, + *axis, + *angle_rad, + ); + } + EntityTransform::Scale { center, factor } => { + crate::scene::view::transform::apply_standard_scale(helix, *center, *factor); + } + EntityTransform::Mirror { p1, p2, working_normal } => { + let reflection = crate::scene::view::transform::reflection_about_working_line( + *p1, + *p2, + *working_normal, + ); + acadrust::Entity::apply_transform(helix, &reflection); + } + EntityTransform::Affine(value) => acadrust::Entity::apply_transform(helix, value), + } } impl RenderConvertible for Helix { @@ -113,6 +434,7 @@ impl Grippable for Helix { fn grips(&self) -> Vec { grips(self) } + fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { apply_grip(self, grip_id, apply); } @@ -122,13 +444,14 @@ impl crate::entities::traits::PropertyEditable for Helix { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { properties(self) } + fn apply_geom_prop(&mut self, field: &str, value: &str) { apply_geom_prop(self, field, value); } } impl Transformable for Helix { - fn apply_transform(&mut self, t: &EntityTransform) { - apply_transform(self, t); + fn apply_transform(&mut self, transform: &EntityTransform) { + apply_transform(self, transform); } } diff --git a/src/modules/draw/defaults.rs b/src/modules/draw/defaults.rs index f21a40f89..5331ac714 100644 --- a/src/modules/draw/defaults.rs +++ b/src/modules/draw/defaults.rs @@ -1,5 +1,4 @@ -// Persistent command defaults — last-used values survive across command runs -// within the same session. Uses thread_local! so no synchronisation needed. +// Last-used command values for this session. use std::cell::Cell; @@ -28,6 +27,10 @@ thread_local! { static RECT_FILLET: Cell = Cell::new(0.0); static DONUT_INNER_DIAMETER: Cell = Cell::new(0.5); static DONUT_OUTER_DIAMETER: Cell = Cell::new(1.0); + static HELIX_BASE_RADIUS: Cell = Cell::new(1.0); + static HELIX_HEIGHT: Cell = Cell::new(1.0); + static HELIX_TURNS: Cell = Cell::new(3.0); + static HELIX_COUNTER_CLOCKWISE: Cell = Cell::new(true); } macro_rules! accessors { @@ -70,6 +73,16 @@ accessors!(get_rect_rotation, set_rect_rotation, RECT_ROTATION); accessors!(get_rect_chamfer1, set_rect_chamfer1, RECT_CHAMFER1); accessors!(get_rect_chamfer2, set_rect_chamfer2, RECT_CHAMFER2); accessors!(get_rect_fillet, set_rect_fillet, RECT_FILLET); +accessors!(get_helix_base_radius, set_helix_base_radius, HELIX_BASE_RADIUS); +accessors!(get_helix_height, set_helix_height, HELIX_HEIGHT); +accessors!(get_helix_turns, set_helix_turns, HELIX_TURNS); +pub fn get_helix_counter_clockwise() -> bool { + HELIX_COUNTER_CLOCKWISE.with(|value| value.get()) +} + +pub fn set_helix_counter_clockwise(value: bool) { + HELIX_COUNTER_CLOCKWISE.with(|current| current.set(value)); +} accessors!( get_donut_inner_diameter, set_donut_inner_diameter, diff --git a/src/modules/draw/draw/helix.rs b/src/modules/draw/draw/helix.rs index 486124d24..fbeb2ca63 100644 --- a/src/modules/draw/draw/helix.rs +++ b/src/modules/draw/draw/helix.rs @@ -1,35 +1,18 @@ -// Parametric helix tool — interactive command. -// -// Command: HELIX — build a parametric 3D helix and commit it as a single 3D -// polyline whose straight segments approximate the curve. The user picks a -// base centre point, then types the base radius, the top radius (Enter keeps -// it equal to the base radius for a cylindrical helix), the height, and the -// number of turns (Enter accepts a default of three turns). -// -// The curve is sampled at a fixed angular resolution: with `SEG_PER_TURN` -// samples per turn the helix has `turns * SEG_PER_TURN` segments. For each -// sample the radius interpolates linearly from base to top, the elevation -// interpolates linearly from zero to the height, and the planar position is -// `(radius*cos(angle), radius*sin(angle))` offset from the centre. - -use acadrust::entities::Polyline3D; +use acadrust::entities::{Helix, HelixConstraint, Spline}; use acadrust::types::Vector3; use acadrust::EntityType; -use crate::t; - -use crate::command::{CadCommand, CmdResult, DynField, WorkingPlane}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; +use cadkernel::space::{HelixCurve, HelixDirection, Vec3}; use glam::DVec3; -/// Angular samples per full turn of the helix. -const SEG_PER_TURN: usize = 36; -/// Default number of turns when the user accepts the prompt with Enter. -const DEFAULT_TURNS: f64 = 3.0; +use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane}; +use crate::modules::draw::defaults; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use crate::t; -// ── Ribbon definition ───────────────────────────────────────────────────── +const EPSILON: f64 = 1.0e-9; -#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +#[allow(dead_code)] pub fn tool() -> ToolDef { ToolDef { id: "HELIX", @@ -39,16 +22,18 @@ pub fn tool() -> ToolDef { } } -// ── Command implementation ──────────────────────────────────────────────── - -/// Which parameter the command is currently collecting. #[derive(Clone, Copy, PartialEq, Eq)] enum Step { Center, BaseRadius, + BaseDiameter, TopRadius, - Height, + TopDiameter, + Final, + AxisEndpoint, Turns, + TurnHeight, + Twist, } pub struct HelixCommand { @@ -58,58 +43,130 @@ pub struct HelixCommand { top_radius: f64, height: f64, turns: f64, + requested_turn_height: Option, + counter_clockwise: bool, + constraint: HelixConstraint, plane: WorkingPlane, } impl HelixCommand { pub fn new() -> Self { + let base_radius = defaults::get_helix_base_radius(); Self { step: Step::Center, center: DVec3::ZERO, - base_radius: 0.0, - top_radius: 0.0, - height: 0.0, - turns: DEFAULT_TURNS, + base_radius, + top_radius: base_radius, + height: defaults::get_helix_height(), + turns: defaults::get_helix_turns(), + requested_turn_height: None, + counter_clockwise: defaults::get_helix_counter_clockwise(), + constraint: HelixConstraint::Turns, plane: WorkingPlane::default(), } } - /// Sample the helix into a list of world points. - fn sample_points(&self, base_radius: f64, top_radius: f64, height: f64, turns: f64) -> Vec { - let turns = if turns <= 0.0 { DEFAULT_TURNS } else { turns }; - let segments = ((turns * SEG_PER_TURN as f64).round() as usize).max(1); - let mut pts = Vec::with_capacity(segments + 1); - for t in 0..=segments { - let frac = t as f64 / segments as f64; - let angle = turns * std::f64::consts::TAU * frac; - let radius = base_radius + (top_radius - base_radius) * frac; - let z = height * frac; - pts.push( - self.center - + self.plane.x * (radius * angle.cos()) - + self.plane.y * (radius * angle.sin()) - + self.plane.z * z, - ); - } - pts - } - - /// Build the committed helix entity from the collected parameters. - fn build(&self) -> Option { - let pts = self.sample_points(self.base_radius, self.top_radius, self.height, self.turns); - if pts.len() < 2 { - return None; - } - let verts: Vec = pts.iter().map(|p| Vector3::new(p.x, p.y, p.z)).collect(); - let mut pl = Polyline3D::from_points(verts); - pl.flags.closed = false; - Some(EntityType::Polyline3D(pl)) - } - - /// Parse a positive distance from typed text; returns `None` on failure. fn parse_value(text: &str) -> Option { crate::entities::common::parse_typed_length(text) } + + fn parse_turns(text: &str) -> Option { + text.trim().replace(',', ".").parse().ok().filter(|value: &f64| value.is_finite()) + } + + fn axis_start_direction(&self, axis: Vec3) -> Option { + let project = |direction: DVec3| { + let direction = Vec3::from(direction.to_array()); + (direction - axis * direction.dot(axis)).normalize() + }; + project(self.plane.x).or_else(|| project(self.plane.y)) + } + + fn resolved_turns(&self, height: f64) -> f64 { + match self.requested_turn_height { + Some(turn_height) if turn_height > EPSILON => (height.abs() / turn_height).max(EPSILON), + _ => self.turns, + } + } + + fn kernel_curve(&self, height: f64, axis: DVec3) -> Option { + let axis = Vec3::from(axis.to_array()) + .normalize() + .or_else(|| Vec3::from(self.plane.z.to_array()).normalize())?; + Some(HelixCurve { + base_center: self.center.to_array(), + axis_direction: axis.to_array(), + start_direction: self.axis_start_direction(axis)?.to_array(), + base_radius: self.base_radius, + top_radius: self.top_radius, + height, + turns: self.resolved_turns(height), + direction: if self.counter_clockwise { + HelixDirection::CounterClockwise + } else { + HelixDirection::Clockwise + }, + }) + } + + fn build(&self, height: f64, axis: DVec3) -> Option { + let curve = self.kernel_curve(height, axis)?; + let nurbs = curve.nurbs()?; + let axis = DVec3::from_array(curve.axis_direction).normalize_or(self.plane.z); + let start_direction = DVec3::from_array(curve.start_direction).normalize_or(self.plane.x); + let mut spline = Spline::new(); + spline.degree = nurbs.degree() as i32; + spline.knots = nurbs.knots().to_vec(); + spline.control_points = nurbs + .control_points() + .iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect(); + spline.flags.planar = false; + spline.flags.rational = nurbs.is_rational(); + if spline.flags.rational { + spline.weights = nurbs.weights().to_vec(); + } + + let mut helix = Helix::new(); + helix.spline = spline; + helix.axis_base_point = Vector3::new(self.center.x, self.center.y, self.center.z); + let start = self.center + start_direction * curve.base_radius; + helix.start_point = Vector3::new(start.x, start.y, start.z); + helix.axis_vector = Vector3::new(axis.x, axis.y, axis.z); + helix.radius = curve.base_radius; + helix.turns = curve.turns; + helix.turn_height = height / curve.turns; + helix.handedness = self.counter_clockwise; + helix.constraint = self.constraint; + Some(EntityType::Helix(helix)) + } + + fn commit(&mut self, height: f64, axis: DVec3) -> CmdResult { + let Some(entity) = self.build(height, axis) else { + return CmdResult::NeedPoint; + }; + self.height = height; + self.turns = self.resolved_turns(height); + defaults::set_helix_base_radius(self.base_radius); + defaults::set_helix_height(height); + defaults::set_helix_turns(self.turns); + defaults::set_helix_counter_clockwise(self.counter_clockwise); + CmdResult::CommitAndExit(entity) + } + + fn preview(&self, height: f64, axis: DVec3) -> Option { + let points = self + .kernel_curve(height, axis)? + .nurbs()? + .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE); + Some(WireModel::solid_f64( + "helix_preview".to_string(), + points, + WireModel::CYAN, + false, + )) + } } impl CadCommand for HelixCommand { @@ -123,65 +180,158 @@ impl CadCommand for HelixCommand { fn prompt(&self) -> String { match self.step { - Step::Center => t!("HELIX Specify centre point of base:").into_owned(), - Step::BaseRadius => t!("HELIX Specify base radius:").into_owned(), - Step::TopRadius => t!("HELIX Specify top radius :").into_owned(), - Step::Height => t!("HELIX Specify helix height:").into_owned(), - Step::Turns => t!("HELIX Enter number of turns <3>:").into_owned(), + Step::Center => crate::tf!( + "HELIX Number of turns = {:.4} Twist = {}\nSpecify center point of base:", + self.turns, + if self.counter_clockwise { "CCW" } else { "CW" } + ) + .into_owned(), + Step::BaseRadius => crate::tf!( + "HELIX Specify base radius or [Diameter] <{:.4}>:", + self.base_radius + ) + .into_owned(), + Step::BaseDiameter => crate::tf!( + "HELIX Specify base diameter <{:.4}>:", + self.base_radius * 2.0 + ) + .into_owned(), + Step::TopRadius => crate::tf!( + "HELIX Specify top radius or [Diameter] <{:.4}>:", + self.base_radius + ) + .into_owned(), + Step::TopDiameter => crate::tf!( + "HELIX Specify top diameter <{:.4}>:", + self.base_radius * 2.0 + ) + .into_owned(), + Step::Final => crate::tf!( + "HELIX Specify helix height or [Axis endpoint/Turns/turn Height/tWist] <{:.4}>:", + self.height + ) + .into_owned(), + Step::AxisEndpoint => t!("HELIX Specify axis endpoint:").into_owned(), + Step::Turns => crate::tf!("HELIX Enter number of turns <{:.4}>:", self.turns).into_owned(), + Step::TurnHeight => crate::tf!( + "HELIX Specify distance between turns <{:.4}>:", + self.height.abs() / self.turns.max(EPSILON) + ) + .into_owned(), + Step::Twist => crate::tf!( + "HELIX Enter twist direction [Clockwise/Counterclockwise] <{}>:", + if self.counter_clockwise { "CCW" } else { "CW" } + ) + .into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { + fn options(&self) -> Vec { + match self.step { + Step::BaseRadius | Step::TopRadius => vec![CmdOption::new(t!("Diameter").as_ref(), "D")], + Step::Final => vec![ + CmdOption::new(t!("Axis endpoint").as_ref(), "A"), + CmdOption::new(t!("Turns").as_ref(), "T"), + CmdOption::new(t!("Turn Height").as_ref(), "H"), + CmdOption::new(t!("Twist").as_ref(), "W"), + ], + Step::Twist => vec![ + CmdOption::new(t!("Clockwise").as_ref(), "CW"), + CmdOption::new(t!("Counterclockwise").as_ref(), "CCW"), + ], + _ => Vec::new(), + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::BaseRadius | Step::TopRadius | Step::Final | Step::Twist) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { match self.step { Step::Center => { - self.center = pt; + self.center = point; self.step = Step::BaseRadius; CmdResult::NeedPoint } - Step::BaseRadius => { - // A point click resolves the base radius as the planar distance - // from the centre to the picked point. - let delta = self.plane.vector_to_local(pt - self.center); - let r = delta.x.hypot(delta.y); - if r > 0.0 { - self.base_radius = r; + Step::BaseRadius | Step::BaseDiameter => { + let local = self.plane.vector_to_local(point - self.center); + let distance = local.x.hypot(local.y); + let radius = if self.step == Step::BaseDiameter { + distance * 0.5 + } else { + distance + }; + if radius > EPSILON { + self.base_radius = radius; + self.top_radius = radius; self.step = Step::TopRadius; } CmdResult::NeedPoint } - Step::TopRadius => { - let delta = self.plane.vector_to_local(pt - self.center); - let r = delta.x.hypot(delta.y); - self.top_radius = if r > 0.0 { r } else { self.base_radius }; - self.step = Step::Height; + Step::TopRadius | Step::TopDiameter => { + let local = self.plane.vector_to_local(point - self.center); + let distance = local.x.hypot(local.y); + self.top_radius = if self.step == Step::TopDiameter { + distance * 0.5 + } else { + distance + }; + self.step = Step::Final; CmdResult::NeedPoint } - Step::Height => { - // Use the elevation of the picked point relative to the centre. - let h = (pt - self.center).dot(self.plane.z); - if h.abs() > 0.0 { - self.height = h; - self.step = Step::Turns; + Step::Final => { + let height = (point - self.center).dot(self.plane.z); + if height.abs() <= EPSILON { + CmdResult::NeedPoint + } else { + self.commit(height, self.plane.z) } - CmdResult::NeedPoint } - Step::Turns => CmdResult::NeedPoint, + Step::AxisEndpoint => { + let vector = point - self.center; + let height = vector.length(); + if height <= EPSILON { + CmdResult::NeedPoint + } else { + self.commit(height, vector / height) + } + } + Step::Turns | Step::TurnHeight | Step::Twist => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { match self.step { - Step::TopRadius => { - // Enter keeps the top radius equal to the base radius. + Step::Center => CmdResult::Cancel, + Step::BaseRadius | Step::BaseDiameter => { self.top_radius = self.base_radius; - self.step = Step::Height; + self.step = Step::TopRadius; + CmdResult::NeedPoint + } + Step::TopRadius | Step::TopDiameter => { + self.top_radius = self.base_radius; + self.step = Step::Final; + CmdResult::NeedPoint + } + Step::Final => self.commit(self.height, self.plane.z), + Step::AxisEndpoint => CmdResult::NeedPoint, + Step::Turns => { + self.constraint = HelixConstraint::Turns; + self.requested_turn_height = None; + self.step = Step::Final; + CmdResult::NeedPoint + } + Step::TurnHeight => { + self.constraint = HelixConstraint::TurnHeight; + self.requested_turn_height = Some(self.height.abs() / self.turns.max(EPSILON)); + self.step = Step::Final; + CmdResult::NeedPoint + } + Step::Twist => { + self.step = Step::Final; CmdResult::NeedPoint } - Step::Turns => match self.build() { - Some(e) => CmdResult::CommitAndExit(e), - None => CmdResult::Cancel, - }, - _ => CmdResult::NeedPoint, } } @@ -190,123 +340,156 @@ impl CadCommand for HelixCommand { } fn wants_text_input(&self) -> bool { - // Every step past the centre pick reads a typed numeric value. self.step != Step::Center } fn dyn_field(&self) -> DynField { match self.step { - Step::Center => DynField::Point, - Step::BaseRadius | Step::TopRadius => DynField::Distance, - Step::Height => DynField::Distance, - Step::Turns => DynField::Scalar, + Step::Center | Step::AxisEndpoint => DynField::Point, + Step::BaseRadius | Step::BaseDiameter | Step::TopRadius | Step::TopDiameter | Step::Final | Step::TurnHeight => DynField::Distance, + Step::Turns | Step::Twist => DynField::Scalar, } } fn on_text_input(&mut self, text: &str) -> Option { - let t = text.trim(); + let value = text.trim(); + let keyword = value.to_ascii_uppercase(); match self.step { Step::Center => None, + Step::BaseRadius if matches!(keyword.as_str(), "D" | "DIAMETER") => { + self.step = Step::BaseDiameter; + Some(CmdResult::NeedPoint) + } + Step::TopRadius if matches!(keyword.as_str(), "D" | "DIAMETER") => { + self.step = Step::TopDiameter; + Some(CmdResult::NeedPoint) + } + Step::Final if matches!(keyword.as_str(), "A" | "AXIS" | "AXIS ENDPOINT") => { + self.step = Step::AxisEndpoint; + Some(CmdResult::NeedPoint) + } + Step::Final if matches!(keyword.as_str(), "T" | "TURNS") => { + self.step = Step::Turns; + Some(CmdResult::NeedPoint) + } + Step::Final if matches!(keyword.as_str(), "H" | "HEIGHT" | "TURN HEIGHT") => { + self.step = Step::TurnHeight; + Some(CmdResult::NeedPoint) + } + Step::Final if matches!(keyword.as_str(), "W" | "TWIST") => { + self.step = Step::Twist; + Some(CmdResult::NeedPoint) + } Step::BaseRadius => { - if let Some(v) = Self::parse_value(t) { - if v > 0.0 { - self.base_radius = v; - self.step = Step::TopRadius; - } + let radius = Self::parse_value(value)?; + if radius > EPSILON { + self.base_radius = radius; + self.top_radius = radius; + self.step = Step::TopRadius; + } + Some(CmdResult::NeedPoint) + } + Step::BaseDiameter => { + let diameter = Self::parse_value(value)?; + if diameter > EPSILON { + self.base_radius = diameter * 0.5; + self.top_radius = self.base_radius; + self.step = Step::TopRadius; } Some(CmdResult::NeedPoint) } Step::TopRadius => { - if t.is_empty() { - // Empty entry keeps the top radius equal to the base radius. - self.top_radius = self.base_radius; - self.step = Step::Height; - } else if let Some(v) = Self::parse_value(t) { - self.top_radius = if v > 0.0 { v } else { self.base_radius }; - self.step = Step::Height; + let radius = Self::parse_value(value)?; + if radius >= 0.0 { + self.top_radius = radius; + self.step = Step::Final; } Some(CmdResult::NeedPoint) } - Step::Height => { - if let Some(v) = Self::parse_value(t) { - if v != 0.0 { - self.height = v; - self.step = Step::Turns; - } + Step::TopDiameter => { + let diameter = Self::parse_value(value)?; + if diameter >= 0.0 { + self.top_radius = diameter * 0.5; + self.step = Step::Final; } Some(CmdResult::NeedPoint) } + Step::Final => { + let height = Self::parse_value(value)?; + (height.abs() > EPSILON).then(|| self.commit(height, self.plane.z)) + } + Step::AxisEndpoint => None, Step::Turns => { - self.turns = if t.is_empty() { - DEFAULT_TURNS - } else { - match Self::parse_value(t) { - Some(v) if v > 0.0 => v, - _ => return Some(CmdResult::NeedPoint), - } - }; - match self.build() { - Some(e) => Some(CmdResult::CommitAndExit(e)), - None => Some(CmdResult::Cancel), + let turns = Self::parse_turns(value)?; + if turns > EPSILON { + self.turns = turns; + self.requested_turn_height = None; + self.constraint = HelixConstraint::Turns; + self.step = Step::Final; } + Some(CmdResult::NeedPoint) + } + Step::TurnHeight => { + let turn_height = Self::parse_value(value)?; + if turn_height > EPSILON { + self.requested_turn_height = Some(turn_height); + self.constraint = HelixConstraint::TurnHeight; + self.step = Step::Final; + } + Some(CmdResult::NeedPoint) + } + Step::Twist => { + match keyword.as_str() { + "C" | "CW" | "CLOCKWISE" => self.counter_clockwise = false, + "CC" | "CCW" | "COUNTERCLOCKWISE" => self.counter_clockwise = true, + _ => return Some(CmdResult::NeedPoint), + } + self.step = Step::Final; + Some(CmdResult::NeedPoint) } } } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - // Preview the helix once enough parameters are known. Before a base - // radius is fixed, track the cursor distance to size a live ring. + fn on_mouse_move(&mut self, point: DVec3) -> Option { match self.step { Step::Center => None, - Step::BaseRadius => { - let delta = self.plane.vector_to_local(pt - self.center); - let r = delta.x.hypot(delta.y); - if r <= 0.0 { - return None; - } - let pts = self.sample_points(r, r, 0.0, 1.0); - let raw: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect(); - Some(WireModel::solid_f64( - "helix_preview".to_string(), - raw, - WireModel::CYAN, - false, - )) - } - _ => { - // Use the parameters fixed so far; for the height step let the - // cursor elevation drive the live height. - let height = if self.step == Step::Height { - (pt - self.center).dot(self.plane.z) + Step::BaseRadius | Step::BaseDiameter => { + let local = self.plane.vector_to_local(point - self.center); + let old = (self.base_radius, self.top_radius); + let distance = local.x.hypot(local.y); + self.base_radius = if self.step == Step::BaseDiameter { + distance * 0.5 } else { - self.height - }; - let top = if self.step == Step::TopRadius { - let delta = self.plane.vector_to_local(pt - self.center); - let r = delta.x.hypot(delta.y); - if r > 0.0 { - r - } else { - self.base_radius - } - } else { - self.top_radius - }; - let pts = self.sample_points(self.base_radius, top, height, self.turns); - if pts.len() < 2 { - return None; + distance } - let raw: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect(); - Some(WireModel::solid_f64( - "helix_preview".to_string(), - raw, - WireModel::CYAN, - false, - )) + .max(EPSILON); + self.top_radius = self.base_radius; + let preview = self.preview(0.0, self.plane.z); + (self.base_radius, self.top_radius) = old; + preview } + Step::TopRadius | Step::TopDiameter => { + let local = self.plane.vector_to_local(point - self.center); + let old = self.top_radius; + let distance = local.x.hypot(local.y); + self.top_radius = if self.step == Step::TopDiameter { + distance * 0.5 + } else { + distance + }; + let preview = self.preview(0.0, self.plane.z); + self.top_radius = old; + preview + } + Step::Final => self.preview((point - self.center).dot(self.plane.z), self.plane.z), + Step::AxisEndpoint => { + let vector = point - self.center; + let height = vector.length(); + (height > EPSILON).then(|| self.preview(height, vector / height)).flatten() + } + Step::Turns | Step::TurnHeight | Step::Twist => self.preview(self.height, self.plane.z), } } } -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["HELIX"] }); // HelixCommand +inventory::submit!(crate::command::CommandRegistration { names: &["HELIX"] });