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:
bherbruckandClaude Opus 5.5 committed 2026-10-08 01:19:55 +00:00
1 parent 4455402eab
commit 9df954cd94
12 files changed
+490 -12

No files matched your search

+22 -6
View File
@@ -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.
+113 -1
View File
@@ -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": {