mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-25 19:36:49 -06:00
Squash of PR #521 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
5.7 KiB
Rust
114 lines
5.7 KiB
Rust
//! Helices (#510): HELIX makes a helix object drawn as a spline, which edits, grips, snaps and
|
||
//! explodes like other curves.
|
||
|
||
use cadcraft_doc::{EntityKind, Handle, Helix, Space};
|
||
use cadcraft_engine::Session;
|
||
use cadcraft_engine::snap::{self, mode};
|
||
use cadcraft_geom::{Spline, Vec2};
|
||
use serde_json::{Value, json};
|
||
|
||
fn h(v: &Value) -> Handle {
|
||
Handle::parse_hex(v["handle"].as_str().unwrap()).unwrap()
|
||
}
|
||
|
||
fn helix(s: &Session, hd: Handle) -> Helix {
|
||
match &s.doc().unwrap().entity(hd).unwrap().kind {
|
||
EntityKind::Helix(x) => x.clone(),
|
||
k => panic!("not a helix: {k:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn helix_is_an_object_on_its_points() {
|
||
let mut s = Session::new();
|
||
let a = h(&s.execute("helix", &json!({"center": [1, 2], "baseRadius": 3, "topRadius": 1, "height": 8, "turns": 4})).unwrap());
|
||
let x = helix(&s, a);
|
||
assert_eq!((x.base_radius, x.top_radius, x.height, x.turns, x.ccw), (3.0, 1.0, 8.0, 4.0, true));
|
||
assert_eq!(s.doc().unwrap().entity(a).unwrap().kind.type_name(), "Helix");
|
||
assert!((x.turn_height() - 2.0).abs() < 1e-12);
|
||
// The spline seen from above runs through the helix's points, and the heights of its
|
||
// control points make the 3D spline run through them too.
|
||
let (sp, z) = x.spline3();
|
||
let height = Spline { control: z.iter().map(|z| Vec2::new(*z, 0.0)).collect(), ..sp.clone() };
|
||
let (lo, hi) = sp.domain();
|
||
let fit = x.fit_points();
|
||
for (i, p) in fit.iter().enumerate() {
|
||
let t = lo + (hi - lo) * i as f64 / (fit.len() - 1) as f64;
|
||
assert!(sp.eval(t).near(p.xy(), 1e-6), "{i}: {:?} vs {p:?}", sp.eval(t));
|
||
assert!((height.eval(t).x - p.z).abs() < 1e-6, "{i}: z {} vs {}", height.eval(t).x, p.z);
|
||
}
|
||
// Between the points the spline stays on the helix's radius closely.
|
||
for i in 0..200 {
|
||
let t = i as f64 / 199.0;
|
||
let q = sp.eval(lo + (hi - lo) * t);
|
||
let r = x.base_radius + (x.top_radius - x.base_radius) * t;
|
||
assert!((q.dist(Vec2::new(1.0, 2.0)) - r).abs() < 0.02 * r, "{t}: {q:?}");
|
||
}
|
||
assert!(s.execute("helix", &json!({"center": [0, 0], "baseRadius": -1})).is_err());
|
||
assert!(s.execute("helix", &json!({"center": [0, 0], "baseRadius": 1, "height": 1e300})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn helix_edits_grips_snaps_and_explode() {
|
||
let mut s = Session::new();
|
||
let a = h(&s.execute("helix", &json!({"center": [0, 0], "baseRadius": 2, "topRadius": 2, "height": 5, "turns": 3})).unwrap());
|
||
s.execute("move", &json!({"handles": [a.hex()], "delta": [10, 0]})).unwrap();
|
||
s.execute("scale", &json!({"handles": [a.hex()], "base": [10, 0], "factor": 2})).unwrap();
|
||
let x = helix(&s, a);
|
||
assert_eq!((x.center.xy(), x.base_radius, x.height), (Vec2::new(10.0, 0.0), 4.0, 10.0));
|
||
s.execute("rotate", &json!({"handles": [a.hex()], "base": [10, 0], "angle": 90})).unwrap();
|
||
assert!(helix(&s, a).start_point().xy().near(Vec2::new(10.0, 4.0), 1e-9));
|
||
s.execute("mirror", &json!({"handles": [a.hex()], "p1": [0, 0], "p2": [0, 1], "erase": true})).unwrap();
|
||
let a = s.doc().unwrap().model.iter().last().unwrap().handle;
|
||
let x = helix(&s, a);
|
||
assert!(!x.ccw, "mirrored twist");
|
||
assert!(x.center.xy().near(Vec2::new(-10.0, 0.0), 1e-9) && x.start_point().xy().near(Vec2::new(-10.0, 4.0), 1e-9));
|
||
|
||
// Grips: centre, base radius, top radius.
|
||
let k = s.doc().unwrap().entity(a).unwrap().kind.clone();
|
||
assert_eq!(k.grips().len(), 3);
|
||
let EntityKind::Helix(y) = cadcraft_engine::grips::stretch_grip(&k, 2, Vec2::new(-10.0, 1.0)).unwrap() else { panic!() };
|
||
assert!((y.top_radius - 1.0).abs() < 1e-9 && (y.base_radius - 4.0).abs() < 1e-9);
|
||
let EntityKind::Helix(y) = cadcraft_engine::grips::stretch_grip(&k, 0, Vec2::new(0.0, 0.0)).unwrap() else { panic!() };
|
||
assert_eq!(y.center.xy(), Vec2::ZERO);
|
||
|
||
// The start point snaps as an endpoint; picking the curve picks the helix.
|
||
let d = s.doc().unwrap();
|
||
let start = x.start_point().xy();
|
||
let hit = snap::osnap(d, &Space::Model, start + Vec2::new(0.05, 0.02), 0.3, mode::END, None, false).unwrap();
|
||
assert!(hit.point.near(start, 1e-9));
|
||
assert_eq!(cadcraft_engine::select::pick(d, &Space::Model, start, 0.1), Some(a));
|
||
let ext = d.extents(&Space::Model);
|
||
assert!(ext.min.x > -14.2 && ext.max.x < -5.8 && ext.min.x < -13.9, "{ext:?}");
|
||
|
||
// EXPLODE gives the spline; FLATTEN too.
|
||
let b = h(&s.execute("helix", &json!({"center": [0, 0], "baseRadius": 1})).unwrap());
|
||
s.execute("explode", &json!({"handles": [a.hex()]})).unwrap();
|
||
s.execute("flatten", &json!({"handles": [b.hex()]})).unwrap();
|
||
let d = s.doc().unwrap();
|
||
assert_eq!(d.model.iter().filter(|e| matches!(e.kind, EntityKind::Spline(_))).count(), 2);
|
||
assert!(!d.model.iter().any(|e| matches!(e.kind, EntityKind::Helix(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn helix_command_line_preview_and_hostile_values() {
|
||
let mut s = Session::new();
|
||
s.cmdline("helix 0,0 d 4 1 t 2.5 h 2").unwrap();
|
||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||
let x = match &s.doc().unwrap().model.iter().last().unwrap().kind {
|
||
EntityKind::Helix(x) => x.clone(),
|
||
k => panic!("{k:?}"),
|
||
};
|
||
assert_eq!((x.base_radius, x.top_radius, x.turns), (2.0, 1.0, 2.5));
|
||
assert!((x.height - 5.0).abs() < 1e-9, "turn height 2 × 2.5 turns: {}", x.height);
|
||
for p in [
|
||
json!({"center": [0, 0], "baseRadius": 1e308, "topRadius": 1e308, "turns": 500, "height": -1e308}),
|
||
json!({"center": [1e308, -1e308], "baseRadius": 1e-300, "turns": 1e-300}),
|
||
json!({"center": [0, 0], "baseRadius": 1, "turns": 500, "height": 1e11}),
|
||
] {
|
||
let _ = s.execute("helix", &p);
|
||
}
|
||
let d = s.doc().unwrap();
|
||
let _ = cadcraft_render::build(d, &Space::Model, &cadcraft_render::Options::default());
|
||
}
|