Coil (PrimitiveCoil: diameter, two of revolutions/height/pitch, circular or square section inside/on/outside, start angle, handedness) on a new kernel helical sweep; loft to a point (sketch point sections, pyramids); recipes: a sweep along a fitted 3D helix replays as a coil (oracle 49, compared within 0.5% since Fusion's path is a spline fit) and loft point sections (oracle 37)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
4455402eab
commit
9df954cd94
12 files changed
+490
-12
No files matched your search
@@ -8,6 +8,13 @@ use crate::{read_json, recipe};
|
||||
/// Relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
|
||||
pub const REL_TOL: f64 = 1e-3;
|
||||
|
||||
/// Cases compared loosely (relative tolerance on volume and area, topology not compared), and why.
|
||||
const APPROXIMATE: [(&str, f64, &str); 1] = [(
|
||||
"49-helical-sweep",
|
||||
5e-3,
|
||||
"Fusion sweeps along a spline fitted through helix points; SolveCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all",
|
||||
)];
|
||||
|
||||
fn check_case(dir: &str) -> Value {
|
||||
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let rp = format!("{dir}/recipe.json");
|
||||
@@ -38,18 +45,27 @@ fn check_case(dir: &str) -> Value {
|
||||
pass &= ok;
|
||||
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
|
||||
};
|
||||
// Cases where Fusion's part is itself an approximation: compare size loosely, not topology.
|
||||
let approx = APPROXIMATE.iter().find(|(c, _, _)| *c == name);
|
||||
let tol = approx.map(|(_, t, _)| *t).unwrap_or(REL_TOL);
|
||||
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
|
||||
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), REL_TOL);
|
||||
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), REL_TOL);
|
||||
num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0);
|
||||
num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0);
|
||||
num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0);
|
||||
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), tol);
|
||||
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), tol);
|
||||
if approx.is_none() {
|
||||
num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0);
|
||||
num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0);
|
||||
num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0);
|
||||
}
|
||||
// Our STEP export of the rebuilt part: well formed, and read back with no warnings and the
|
||||
// same volume.
|
||||
let (ok, note) = step_export_check(&s, got["total"]["volume_mm3"].as_f64().unwrap_or(0.0));
|
||||
pass &= ok;
|
||||
checks.push(json!({"check": "step_export", "got": note, "want": "valid, re-imports cleanly", "ok": ok}));
|
||||
json!({"case": name, "pass": pass, "checks": checks})
|
||||
let mut out = json!({"case": name, "pass": pass, "checks": checks});
|
||||
if let Some((_, _, why)) = approx {
|
||||
out["note"] = json!(why);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Export the session's bodies as STEP, validate the file and import it again.
|
||||
|
||||
@@ -61,6 +61,8 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
Some("point") => out.push(json!({"command": "DrawPoint", "params": {"point": parg(e.get("at").map(|a| json!({"at": a})).as_ref().or(Some(e))), "id": id}})),
|
||||
// A 3D helix fit: the sweep along it becomes a coil (see `helix_path`).
|
||||
Some("fitted_spline") if e.get("fit_points").and_then(Value::as_array).is_some_and(|a| a.iter().any(|p| p.get(2).is_some())) => {}
|
||||
Some(other) => return Err(format!("recipe: sketch entity type `{other}` is not supported yet")),
|
||||
None => return Err("recipe: sketch entity without type".into()),
|
||||
}
|
||||
@@ -180,6 +182,106 @@ fn profile_sketch(f: &Value) -> Value {
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn sketch_entity<'a>(features: &'a [Value], sketch: &Value, id: &Value) -> Option<&'a Value> {
|
||||
features
|
||||
.iter()
|
||||
.find(|x| x.get("op").and_then(Value::as_str) == Some("sketch") && x.get("name") == Some(sketch))?
|
||||
.get("entities")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|e| e.get("id") == Some(id))
|
||||
}
|
||||
|
||||
/// A sweep of a circle along a fitted 3D spline that is a helix about a Z-parallel axis:
|
||||
/// (base, diameter, pitch, turns, start angle, clockwise, section size).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn helix_path(features: &[Value], f: &Value) -> Option<([f64; 3], f64, f64, f64, f64, bool, f64)> {
|
||||
let path = f.get("path")?;
|
||||
let curve = sketch_entity(features, path.get("sketch")?, path.get("curves")?.as_array()?.first()?)?;
|
||||
if curve.get("type")?.as_str()? != "fitted_spline" {
|
||||
return None;
|
||||
}
|
||||
let pts: Vec<[f64; 3]> = curve
|
||||
.get("fit_points")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let a = p.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
|
||||
})
|
||||
.collect();
|
||||
if pts.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let n = pts.len() as f64;
|
||||
let (cx, cy) = circle_fit(&pts)?;
|
||||
let r: Vec<f64> = pts.iter().map(|p| (p[0] - cx).hypot(p[1] - cy)).collect();
|
||||
let rm = r.iter().sum::<f64>() / n;
|
||||
if rm <= 0.0 || r.iter().any(|x| (x - rm).abs() > 1e-3 * rm) {
|
||||
return None;
|
||||
}
|
||||
// Unwrapped angle and height: both linear along a helix.
|
||||
let mut total = 0.0;
|
||||
let mut prev = (pts[0][1] - cy).atan2(pts[0][0] - cx);
|
||||
let start = prev;
|
||||
for p in pts.iter().skip(1) {
|
||||
let a = (p[1] - cy).atan2(p[0] - cx);
|
||||
let mut d = a - prev;
|
||||
while d > std::f64::consts::PI {
|
||||
d -= std::f64::consts::TAU;
|
||||
}
|
||||
while d < -std::f64::consts::PI {
|
||||
d += std::f64::consts::TAU;
|
||||
}
|
||||
total += d;
|
||||
prev = a;
|
||||
}
|
||||
let turns = total.abs() / std::f64::consts::TAU;
|
||||
let rise = pts.last()?[2] - pts[0][2];
|
||||
if turns < 1e-6 || rise.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
// The section: a circle in the profile sketch.
|
||||
let prof = f.get("profile").or_else(|| f.get("profiles").and_then(|x| x.get(0)))?;
|
||||
let loop0 = prof.get("loops")?.get(0)?.get("curves")?.get(0)?;
|
||||
let circle = sketch_entity(features, prof.get("sketch")?, loop0)?;
|
||||
let size = 2.0 * circle.get("radius")?.as_f64()?;
|
||||
Some(([cx, cy, pts[0][2]], 2.0 * rm, rise.abs() / turns, turns, start, (total < 0.0) != (rise < 0.0), size))
|
||||
}
|
||||
|
||||
/// Least-squares circle through the points' (x, y): x² + y² + D x + E y + F = 0 (Kåsa).
|
||||
fn circle_fit(pts: &[[f64; 3]]) -> Option<(f64, f64)> {
|
||||
let mut m = [[0.0f64; 4]; 3];
|
||||
for p in pts {
|
||||
let row = [p[0], p[1], 1.0];
|
||||
let rhs = -(p[0] * p[0] + p[1] * p[1]);
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
m[i][j] += row[i] * row[j];
|
||||
}
|
||||
m[i][3] += row[i] * rhs;
|
||||
}
|
||||
}
|
||||
// Gauss-Jordan on the 3x4 normal equations.
|
||||
for c in 0..3 {
|
||||
let piv = (c..3).max_by(|a, b| m[*a][c].abs().total_cmp(&m[*b][c].abs()))?;
|
||||
m.swap(c, piv);
|
||||
if m[c][c].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
for r in 0..3 {
|
||||
if r != c {
|
||||
let f = m[r][c] / m[c][c];
|
||||
for k in c..4 {
|
||||
m[r][k] -= f * m[c][k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (d, e) = (m[0][3] / m[0][0], m[1][3] / m[1][1]);
|
||||
Some((-d / 2.0, -e / 2.0))
|
||||
}
|
||||
|
||||
pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
for p in recipe.get("parameters").and_then(Value::as_array).into_iter().flatten() {
|
||||
@@ -244,10 +346,20 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|sec| json!({"sketch": sec.get("sketch"), "profiles": [sec]}))
|
||||
.map(|sec| match sec.get("point") {
|
||||
Some(pt) => json!({"sketch": sec.get("sketch"), "point": pt}),
|
||||
None => json!({"sketch": sec.get("sketch"), "profiles": [sec]}),
|
||||
})
|
||||
.collect();
|
||||
out.push(json!({"command": "SolidLoft", "params": {"sections": sections, "operation": op(f), "name": name, "body_names": bodies}}));
|
||||
}
|
||||
Some("sweep") if helix_path(features, f).is_some() => {
|
||||
// A swept circle along a fitted 3D helix: a coil (the true helix).
|
||||
let Some((base, diameter, pitch, turns, start, cw, size)) = helix_path(features, f) else { continue };
|
||||
out.push(json!({"command": "PrimitiveCoil", "params": {
|
||||
"base": base, "diameter": diameter, "pitch": pitch, "revolutions": turns, "section_size": size,
|
||||
"start_angle": format!("{start} rad"), "clockwise": cw, "operation": op(f), "name": name, "body_names": bodies}}));
|
||||
}
|
||||
Some("sweep") => {
|
||||
let path = f.get("path").cloned().unwrap_or(Value::Null);
|
||||
out.push(json!({"command": "Sweep", "params": {
|
||||
|
||||
Reference in new issue
Block a user