use crate::{GizmoCamera, Overlay, Vec3};
pub const CURVE_COLOR: [f32; 4] = [0.45, 0.75, 1.0, 1.0];
pub const HANDLE_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
pub const HANDLE_SIZE_PX: f32 = 8.0;
pub fn polyline_display(points: &[Vec3], color: [f32; 4], closed: bool) -> Overlay {
let mut ov = Overlay::new();
if points.len() < 2 {
return ov;
}
for w in points.windows(2) {
ov.line(w[0], w[1], color);
}
if closed && points.len() >= 3 {
ov.line(points[points.len() - 1], points[0], color);
}
ov
}
#[derive(Debug, Clone, Copy)]
pub struct HelixParams {
pub axis_origin: Vec3,
pub axis_dir: Vec3,
pub radius: f32,
pub end_radius: f32,
pub pitch: f32,
pub turns: f32,
pub start_angle: f32,
pub right_handed: bool,
pub points_per_turn: usize,
}
impl HelixParams {
pub fn new(axis_origin: Vec3, radius: f32, pitch: f32, turns: f32) -> Self {
Self {
axis_origin,
axis_dir: Vec3::Z,
radius,
end_radius: radius,
pitch,
turns,
start_angle: 0.0,
right_handed: true,
points_per_turn: 24,
}
}
}
pub fn helix_points(p: &HelixParams) -> Vec<Vec3> {
let axis = if p.axis_dir.length() < 1e-9 {
Vec3::Z
} else {
p.axis_dir.normalized()
};
let u = axis.any_perp();
let v = axis.cross(u).normalized();
let ppt = p.points_per_turn.max(3);
let total = ((p.turns.abs() * ppt as f32).ceil() as usize).max(1);
let wind = if p.right_handed { 1.0 } else { -1.0 };
let mut out = Vec::with_capacity(total + 1);
for i in 0..=total {
let frac = (i as f32 / total as f32) * p.turns;
let ang = p.start_angle + wind * frac * std::f32::consts::TAU;
let (s, c) = ang.sin_cos();
let t = if p.turns.abs() > 1e-9 {
frac / p.turns
} else {
0.0
};
let r = p.radius + (p.end_radius - p.radius) * t;
let radial = u.scale(c * r).add(v.scale(s * r));
let axial = axis.scale(p.pitch * frac);
out.push(p.axis_origin.add(radial).add(axial));
}
out
}
pub fn helix_display(p: &HelixParams, color: [f32; 4]) -> Overlay {
polyline_display(&helix_points(p), color, false)
}
pub fn control_point_handles(
points: &[Vec3],
camera: &GizmoCamera,
color: [f32; 4],
) -> Overlay {
let mut ov = Overlay::new();
for &p in points {
let wpp = camera.world_per_pixel(p);
let half = HANDLE_SIZE_PX * 0.5 * wpp;
let right = camera.screen_right(p);
let mut up = right.cross(camera.forward);
up = if up.length() < 1e-9 {
right.any_perp()
} else {
up.normalized()
};
let c0 = p.add(right.scale(half)).add(up.scale(half));
let c1 = p.add(right.scale(-half)).add(up.scale(half));
let c2 = p.add(right.scale(-half)).add(up.scale(-half));
let c3 = p.add(right.scale(half)).add(up.scale(-half));
ov.tri(c0, c1, c2, color);
ov.tri(c0, c2, c3, color);
}
ov
}
pub fn hit_control_point(
points: &[Vec3],
camera: &GizmoCamera,
screen: [f32; 2],
) -> Option<usize> {
let threshold = HANDLE_SIZE_PX * 0.5 + 2.0;
let mut best: Option<usize> = None;
let mut best_d = threshold;
for (i, &p) in points.iter().enumerate() {
if let Some(s) = camera.world_to_screen(p) {
let d = (s[0] - screen[0]).abs().max((s[1] - screen[1]).abs());
if d <= best_d {
best_d = d;
best = Some(i);
}
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
fn cam(vp_px: f32) -> GizmoCamera {
let view_proj =
crate::raster::test_view_proj([0.0, 0.0, 20.0], [0.0, 0.0, 0.0], vp_px, vp_px);
GizmoCamera {
view_proj,
eye: Vec3::new(0.0, 0.0, 20.0),
forward: Vec3::new(0.0, 0.0, -1.0),
up: Vec3::Y,
viewport: [vp_px, vp_px],
orthographic: true,
}
}
fn sample_points(n: usize) -> Vec<Vec3> {
(0..n)
.map(|i| {
let x = i as f32;
Vec3::new(x, (x * 0.7).sin(), 0.0)
})
.collect()
}
#[test]
fn polyline_open_emits_n_minus_1_segments() {
let pts = sample_points(5);
let ov = polyline_display(&pts, CURVE_COLOR, false);
assert_eq!(ov.lines.len(), (5 - 1) * 2, "open: N-1 segments");
}
#[test]
fn polyline_closed_emits_n_segments() {
let pts = sample_points(5);
let ov = polyline_display(&pts, CURVE_COLOR, true);
assert_eq!(ov.lines.len(), 5 * 2, "closed: N segments");
}
#[test]
fn polyline_degenerate_inputs_are_empty() {
assert!(polyline_display(&[], CURVE_COLOR, false).lines.is_empty());
assert!(polyline_display(&[Vec3::ZERO], CURVE_COLOR, true)
.lines
.is_empty());
}
#[test]
fn helix_points_have_expected_count_and_radius() {
let p = HelixParams::new(Vec3::ZERO, 2.0, 1.0, 3.0); let pts = helix_points(&p);
assert_eq!(pts.len(), 3 * 24 + 1);
for q in &pts {
let r = (q.x * q.x + q.y * q.y).sqrt();
assert!((r - 2.0).abs() < 1e-3, "radius {r}");
}
assert!((pts[pts.len() - 1].z - 3.0).abs() < 1e-3);
}
#[test]
fn helix_taper_interpolates_radius() {
let mut p = HelixParams::new(Vec3::ZERO, 2.0, 1.0, 1.0);
p.end_radius = 4.0;
let pts = helix_points(&p);
let first_r = (pts[0].x * pts[0].x + pts[0].y * pts[0].y).sqrt();
let last = pts[pts.len() - 1];
let last_r = (last.x * last.x + last.y * last.y).sqrt();
assert!((first_r - 2.0).abs() < 1e-3, "start radius {first_r}");
assert!((last_r - 4.0).abs() < 1e-3, "end radius {last_r}");
}
#[test]
fn control_point_handles_and_hit_testing() {
let c = cam(400.0);
let pts = vec![
Vec3::new(-2.0, 0.0, 0.0),
Vec3::new(0.0, 1.5, 0.0),
Vec3::new(2.0, 0.0, 0.0),
];
let ov = control_point_handles(&pts, &c, HANDLE_COLOR);
assert_eq!(ov.tris.len(), pts.len() * 6);
let s = c.world_to_screen(pts[1]).unwrap();
assert_eq!(hit_control_point(&pts, &c, s), Some(1));
assert_eq!(hit_control_point(&pts, &c, [1.0, 1.0]), None);
}
}