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": {
|
||||
|
||||
@@ -187,6 +187,10 @@ impl FeatureKind {
|
||||
dir(direction);
|
||||
}
|
||||
FeatureKind::Thread { face, .. } => pt(face),
|
||||
FeatureKind::Coil { base, axis, .. } => {
|
||||
pt(base);
|
||||
dir(axis);
|
||||
}
|
||||
FeatureKind::ReplaceFace { faces, target, .. } => {
|
||||
faces.iter_mut().for_each(pt);
|
||||
map_plane_ref(m, target);
|
||||
|
||||
@@ -379,6 +379,33 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
flip: bool,
|
||||
},
|
||||
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
|
||||
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
|
||||
Coil {
|
||||
base: Vec3,
|
||||
#[serde(default = "z_axis")]
|
||||
axis: Vec3,
|
||||
diameter: String,
|
||||
pitch: String,
|
||||
turns: String,
|
||||
section_size: String,
|
||||
#[serde(default)]
|
||||
section: CoilSection,
|
||||
/// -1 inside, 0 on the centre, 1 outside of the diameter.
|
||||
#[serde(default, skip_serializing_if = "is_zero_i8")]
|
||||
position: i8,
|
||||
/// Start angle about the axis (expression; 0 = from the axis toward +x, or a
|
||||
/// perpendicular direction when the axis is x).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
start_angle: Option<String>,
|
||||
/// Turn the other way (left-handed).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
clockwise: bool,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
/// Remove bodies from the model (from here on in the timeline).
|
||||
Remove {
|
||||
bodies: Vec<String>,
|
||||
@@ -446,6 +473,22 @@ pub struct Component {
|
||||
pub struct LoftSection {
|
||||
pub sketch: u64,
|
||||
pub profiles: ProfileSel,
|
||||
/// A sketch point instead of a profile (the loft closes to it, as a pyramid's apex).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub point: Option<String>,
|
||||
}
|
||||
|
||||
/// Coil cross-sections.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CoilSection {
|
||||
#[default]
|
||||
Circular,
|
||||
Square,
|
||||
}
|
||||
|
||||
fn is_zero_i8(v: &i8) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
/// Hole shapes.
|
||||
@@ -563,6 +606,7 @@ impl FeatureKind {
|
||||
FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature",
|
||||
FeatureKind::Pipe { .. } => "PipeFeature",
|
||||
FeatureKind::Emboss { .. } => "EmbossFeature",
|
||||
FeatureKind::Coil { .. } => "CoilFeature",
|
||||
FeatureKind::Rib { web: false, .. } => "RibFeature",
|
||||
FeatureKind::Rib { .. } => "WebFeature",
|
||||
FeatureKind::ReplaceFace { .. } => "ReplaceFaceFeature",
|
||||
@@ -603,6 +647,7 @@ impl FeatureKind {
|
||||
FeatureKind::BoundingSolid { .. } => "BoundingSolid",
|
||||
FeatureKind::Pipe { .. } => "Pipe",
|
||||
FeatureKind::Emboss { .. } => "Emboss",
|
||||
FeatureKind::Coil { .. } => "Coil",
|
||||
FeatureKind::Rib { web: false, .. } => "Rib",
|
||||
FeatureKind::Rib { .. } => "Web",
|
||||
FeatureKind::ReplaceFace { .. } => "ReplaceFace",
|
||||
@@ -683,6 +728,10 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { depth, .. } => v.push(depth),
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => {
|
||||
v.extend([diameter.as_str(), pitch, turns, section_size]);
|
||||
v.extend(start_angle.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Rib { thickness, depth, .. } => {
|
||||
v.push(thickness);
|
||||
v.extend(depth.iter().map(String::as_str));
|
||||
|
||||
@@ -943,6 +943,56 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
let regions: Vec<Region2> = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect();
|
||||
Ok(kernel::revolve(&ss.plane, ®ions, o, d, ang)?)
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.point.is_some()) => {
|
||||
// A profile lofted to a sketch point (an apex).
|
||||
let [a, b] = §ions[..] else { return Err(DocError::Invalid("not supported yet: a point section in a loft of more than two".into())) };
|
||||
let (prof, pt) = if a.point.is_some() { (b, a) } else { (a, b) };
|
||||
let ss = st.sketch(prof.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", prof.sketch)))?;
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, &prof.profiles)?);
|
||||
let [r] = ®ions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
|
||||
let ps = st.sketch(pt.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", pt.sketch)))?;
|
||||
let id = pt.point.as_deref().unwrap_or_default();
|
||||
let q = ps.sketch.points.iter().find(|p| p.id == id).ok_or_else(|| DocError::Unknown(format!("point `{id}` in {}", ps.name)))?;
|
||||
Ok(vec![kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?])
|
||||
}
|
||||
FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => {
|
||||
let k = axis.normalized().ok_or_else(|| DocError::Invalid("coil axis".into()))?;
|
||||
let (d, p, n, s) = (
|
||||
val(vals, diameter, Kind::Length)?,
|
||||
val(vals, pitch, Kind::Length)?,
|
||||
val(vals, turns, Kind::Unitless)?,
|
||||
val(vals, section_size, Kind::Length)?,
|
||||
);
|
||||
if !(d > 0.0 && s > 0.0 && n > 0.0) {
|
||||
return Err(DocError::Invalid("the coil diameter, section size and revolutions must be positive".into()));
|
||||
}
|
||||
let r = d / 2.0 + f64::from(*position) * s / 2.0;
|
||||
if r <= s / 2.0 {
|
||||
return Err(DocError::Invalid("the section reaches the coil's axis".into()));
|
||||
}
|
||||
let a0 = match start_angle {
|
||||
Some(e) => val(vals, e, Kind::Angle)?,
|
||||
None => 0.0,
|
||||
};
|
||||
// Radial direction at the start; the section lies in the plane of the axis.
|
||||
let x0 = if k.cross(Vec3::X).len() > 1e-6 { (Vec3::X - k * k.dot(Vec3::X)).normalized() } else { Some(k.any_perp()) };
|
||||
let x0 = x0.ok_or_else(|| DocError::Invalid("coil axis".into()))?;
|
||||
let x = x0 * a0.cos() + k.cross(x0) * a0.sin();
|
||||
let plane = Plane { origin: *base, x, y: k };
|
||||
let c = Vec2::new(r, 0.0);
|
||||
let outer = match section {
|
||||
crate::CoilSection::Circular => solvecraft_geom::Loop2::circle(c, s / 2.0),
|
||||
crate::CoilSection::Square => {
|
||||
let h = s / 2.0;
|
||||
solvecraft_geom::Loop2::polygon(&[c + Vec2::new(-h, -h), c + Vec2::new(h, -h), c + Vec2::new(h, h), c + Vec2::new(-h, h)])
|
||||
}
|
||||
};
|
||||
let region = Region2 { outer, holes: Vec::new() };
|
||||
let axis = if *clockwise { k * -1.0 } else { k };
|
||||
// A left-handed coil turns about −axis and climbs along +axis: mirror the pitch.
|
||||
let pitch = if *clockwise { -p } else { p };
|
||||
Ok(vec![kernel::sweep_helix(&plane, ®ion, *base, axis, pitch, n)?])
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
let mut secs = Vec::new();
|
||||
for sec in sections {
|
||||
@@ -1076,6 +1126,7 @@ fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
|
||||
FeatureKind::Hole { .. } => (Operation::Cut, Vec::new()),
|
||||
FeatureKind::Pipe { operation, targets, .. } => (*operation, targets.clone()),
|
||||
FeatureKind::Loft { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => (*operation, targets.clone()),
|
||||
FeatureKind::Coil { operation, targets, .. } => (*operation, targets.clone()),
|
||||
_ => (Operation::NewBody, Vec::new()),
|
||||
}
|
||||
}
|
||||
@@ -1342,6 +1393,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::Pipe { operation, targets, .. }
|
||||
| FeatureKind::Revolve { operation, targets, .. }
|
||||
| FeatureKind::Loft { operation, targets, .. }
|
||||
| FeatureKind::Coil { operation, targets, .. }
|
||||
| FeatureKind::Sweep { operation, targets, .. } => {
|
||||
let tools = feature_tools(vals, f, st)?;
|
||||
apply_op(st, f, tools, *operation, targets)
|
||||
|
||||
@@ -138,6 +138,15 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { depth, .. } => v.push(("Depth", depth, L)),
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => {
|
||||
v.push(("Diameter", diameter, L));
|
||||
v.push(("Pitch", pitch, L));
|
||||
v.push(("Revolutions", turns, U));
|
||||
v.push(("Section size", section_size, L));
|
||||
if let Some(a) = start_angle {
|
||||
v.push(("Start angle", a, A));
|
||||
}
|
||||
}
|
||||
FeatureKind::Rib { thickness, depth, .. } => {
|
||||
v.push(("Thickness", thickness, L));
|
||||
if let Some(d) = depth {
|
||||
|
||||
@@ -22,7 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"),
|
||||
CommandSpec::new("SolidLoft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?}] (2 or more, in order); operation?"),
|
||||
CommandSpec::new("SolidLoft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
|
||||
CommandSpec::new("PrimitiveBox", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
|
||||
CommandSpec::new("PrimitiveCylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
|
||||
CommandSpec::new("PrimitiveSphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
|
||||
@@ -845,7 +845,16 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut sections = Vec::new();
|
||||
for sec in list {
|
||||
let sketch = sketch_id(s, sec.get("sketch"), cmd, "sections[].sketch")?;
|
||||
sections.push(solvecraft_doc::LoftSection { sketch, profiles: profiles(sec, cmd)? });
|
||||
// A sketch point closes the loft to an apex.
|
||||
if let Some(pt) = str_(sec, "point") {
|
||||
let st = s.model.state();
|
||||
if !st.sketch(sketch).is_some_and(|ss| ss.sketch.points.iter().any(|q| q.id == pt)) {
|
||||
return Err(bad(cmd, format!("no point `{pt}` in the section's sketch")));
|
||||
}
|
||||
sections.push(solvecraft_doc::LoftSection { sketch, profiles: solvecraft_doc::ProfileSel::All, point: Some(pt.to_string()) });
|
||||
continue;
|
||||
}
|
||||
sections.push(solvecraft_doc::LoftSection { sketch, profiles: profiles(sec, cmd)?, point: None });
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Loft { sections, operation: operation(p, cmd)?, targets: string_list(p, "targets") })
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("PrimitiveCoil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
|
||||
"diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \
|
||||
base?: [x,y,z]; axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?",
|
||||
),
|
||||
CommandSpec::new("FusionRibCommand", "Rib", rib)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("rib")
|
||||
@@ -34,6 +38,70 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("SoftDeleteCommand", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
|
||||
];
|
||||
|
||||
fn coil(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "PrimitiveCoil";
|
||||
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let axis = match p.get("axis") {
|
||||
None => Vec3::Z,
|
||||
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
|
||||
"X" => Vec3::X,
|
||||
"Y" => Vec3::Y,
|
||||
"Z" => Vec3::Z,
|
||||
_ => return Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")),
|
||||
},
|
||||
Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]"))?,
|
||||
};
|
||||
let diameter = req_expr(cmd, p, "diameter")?;
|
||||
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
|
||||
let turns = expr(p, "revolutions").or_else(|| expr(p, "turns"));
|
||||
let (height, pitch) = (expr(p, "height"), expr(p, "pitch"));
|
||||
let (turns, pitch) = match (turns, height, pitch) {
|
||||
(Some(n), _, Some(pt)) => (n, pt),
|
||||
(Some(n), Some(h), None) => (n.clone(), format!("({h}) / ({n})")),
|
||||
(None, Some(h), Some(pt)) => (format!("({h}) / ({pt})"), pt),
|
||||
_ => return Err(bad(cmd, "give two of `revolutions`, `height` and `pitch`")),
|
||||
};
|
||||
check_expr(s, &turns, Kind::Unitless, cmd, "revolutions")?;
|
||||
check_expr(s, &pitch, Kind::Length, cmd, "pitch")?;
|
||||
let section_size = req_expr(cmd, p, "section_size")?;
|
||||
check_expr(s, §ion_size, Kind::Length, cmd, "section_size")?;
|
||||
let section = match str_(p, "section").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("circular" | "circle") => solvecraft_doc::CoilSection::Circular,
|
||||
Some("square") => solvecraft_doc::CoilSection::Square,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown section `{o}` (circular or square)"))),
|
||||
};
|
||||
let position = match str_(p, "section_position").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("center" | "on" | "on_center") => 0,
|
||||
Some("inside") => -1,
|
||||
Some("outside") => 1,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown section position `{o}` (inside, center or outside)"))),
|
||||
};
|
||||
let start_angle = expr(p, "start_angle");
|
||||
if let Some(a) = &start_angle {
|
||||
check_expr(s, a, Kind::Angle, cmd, "start_angle")?;
|
||||
}
|
||||
let clockwise = bool_(p, "clockwise").unwrap_or(false);
|
||||
let operation = super::features::operation(p, cmd)?;
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Coil {
|
||||
base,
|
||||
axis,
|
||||
diameter,
|
||||
pitch,
|
||||
turns,
|
||||
section_size,
|
||||
section,
|
||||
position,
|
||||
start_angle,
|
||||
clockwise,
|
||||
operation,
|
||||
targets: string_list(p, "targets"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn curves_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
let c = string_list(p, "curves");
|
||||
if c.is_empty() || c.len() > 1000 {
|
||||
|
||||
@@ -108,3 +108,32 @@ fn replace_face_align_remove() {
|
||||
run(&mut s, "SoftDeleteCommand", json!({"bodies": ["B"]}));
|
||||
assert_eq!(measure(&mut s)["body_count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coil_and_loft_to_a_point() {
|
||||
use std::f64::consts::{PI, TAU};
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "PrimitiveCoil", json!({"diameter": 40, "revolutions": 2, "pitch": 8, "section_size": 4}));
|
||||
assert!(rel(volume(&mut s), PI * 4.0 * TAU * 20.0 * 2.0) < 1e-3, "{}", volume(&mut s));
|
||||
// Height and revolutions give the pitch; a square section outside the diameter.
|
||||
let mut s = Session::default();
|
||||
run(
|
||||
&mut s,
|
||||
"PrimitiveCoil",
|
||||
json!({"diameter": 40, "revolutions": 1.5, "height": 15, "section": "square", "section_size": 2, "section_position": "outside", "axis": "X"}),
|
||||
);
|
||||
assert!(rel(volume(&mut s), 4.0 * TAU * 21.0 * 1.5) < 1e-3, "{}", volume(&mut s));
|
||||
assert!(s.execute("PrimitiveCoil", &json!({"diameter": 40, "pitch": 1, "revolutions": 3, "section_size": 4})).is_err(), "runs into itself");
|
||||
assert!(s.execute("PrimitiveCoil", &json!({"diameter": 40, "section_size": 4, "pitch": 8})).is_err(), "two of three");
|
||||
// Pyramid: a square lofted to a point.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [-10, -10], "p1": [10, 10]}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
|
||||
run(&mut s, "DrawPoint", json!({"point": [0, 0], "id": "apex"}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "SolidLoft", json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "apex"}]}));
|
||||
assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6);
|
||||
assert!(s.execute("SolidLoft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
|
||||
}
|
||||
@@ -14,7 +14,7 @@ fn finite(x: f64, what: &str) -> Result<f64> {
|
||||
}
|
||||
|
||||
/// A closed wire in 3D from a sketch loop.
|
||||
fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
let n = lp.segs.len();
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid(format!("loop with {n} segments")));
|
||||
@@ -410,7 +410,7 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
|
||||
}
|
||||
|
||||
/// Close a ruled shell (side faces between matching wires) with planar caps into a body.
|
||||
fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt::Wire]) -> Result<Body> {
|
||||
pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt::Wire]) -> Result<Body> {
|
||||
let bottom =
|
||||
builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>()).map_err(|e| KernelError::Failed(format!("cap: {e}")))?;
|
||||
let top = builder::try_attach_plane(w1).map_err(|e| KernelError::Failed(format!("cap: {e}")))?;
|
||||
@@ -662,7 +662,7 @@ pub(crate) fn interpolate_cubic(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Poi
|
||||
|
||||
/// A closed wire whose edges are cubic B-splines parameterised (nearly) by arc length, so that
|
||||
/// ruled surfaces between sections pair points proportionally along each edge.
|
||||
fn uniform_wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
pub(crate) fn uniform_wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
const SAMPLES: usize = 24;
|
||||
let n = lp.segs.len();
|
||||
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Helical sweeps (coils, springs) and lofts to a point (pyramids, cones).
|
||||
|
||||
use mt::builder;
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, Solid, p3};
|
||||
use crate::build::{cap_and_close, uniform_wire};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// Rotate `v` about the unit axis `k` by `ang` (Rodrigues).
|
||||
fn rot(v: Vec3, k: Vec3, ang: f64) -> Vec3 {
|
||||
v * ang.cos() + k.cross(v) * ang.sin() + k * (k.dot(v) * (1.0 - ang.cos()))
|
||||
}
|
||||
|
||||
/// Sweep a planar region's outer loop along a helix about `axis` through `axis_origin`:
|
||||
/// `turns` revolutions (right-handed about `axis`), rising `pitch` per turn. The profile keeps
|
||||
/// its attitude relative to the axis, as a screw motion does; between stations (3° apart) the
|
||||
/// walls are ruled.
|
||||
pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec3, pitch: f64, turns: f64) -> Result<Body> {
|
||||
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("helix axis".into()))?;
|
||||
if !(pitch.is_finite() && turns.is_finite() && turns > 0.0 && turns <= 200.0 && pitch.abs() < 1e7 && axis_origin.is_finite()) {
|
||||
return Err(KernelError::Invalid("helix: turns must be in (0, 200] and the pitch finite".into()));
|
||||
}
|
||||
if region.area().abs() < 1e-9 {
|
||||
return Err(KernelError::Invalid("profile has no area".into()));
|
||||
}
|
||||
// The profile must clear itself after one turn.
|
||||
let lp = region.outer.ccw();
|
||||
let along: Vec<f64> =
|
||||
lp.segs.iter().flat_map(|s| (0..8).map(move |i| s.point_at(i as f64 / 8.0))).map(|q| (plane.to_world(q) - axis_origin).dot(k)).collect();
|
||||
let extent = along.iter().cloned().fold(f64::NEG_INFINITY, f64::max) - along.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
if turns > 1.0 && pitch.abs() <= extent {
|
||||
return Err(KernelError::Invalid(format!("the coil runs into itself: the pitch must exceed the section ({extent:.3} mm)")));
|
||||
}
|
||||
let n = ((turns * 120.0).ceil() as usize).clamp(4, 24_000);
|
||||
let total = std::f64::consts::TAU * turns;
|
||||
let mut wires = Vec::with_capacity(n + 1);
|
||||
for i in 0..=n {
|
||||
let a = total * i as f64 / n as f64;
|
||||
let lift = k * (pitch * a / std::f64::consts::TAU);
|
||||
let o = axis_origin + rot(plane.origin - axis_origin, k, a) + lift;
|
||||
let pl = Plane { origin: o, x: rot(plane.x, k, a), y: rot(plane.y, k, a) };
|
||||
wires.push(uniform_wire(&pl, &lp)?);
|
||||
}
|
||||
guard("helix sweep", || {
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
for w in wires.windows(2) {
|
||||
let sides = builder::try_wire_homotopy(&w[0], &w[1]).map_err(|e| KernelError::Failed(format!("helix: {e}")))?;
|
||||
faces.extend(sides.face_iter().cloned());
|
||||
}
|
||||
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("helix".into())) };
|
||||
cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last))
|
||||
})
|
||||
}
|
||||
|
||||
/// A solid from a planar loop of straight segments to an apex point off its plane (a pyramid).
|
||||
pub fn loft_to_point(plane: &Plane, lp: &Loop2, apex: Vec3) -> Result<Body> {
|
||||
if !apex.is_finite() || lp.segs.is_empty() || lp.segs.len() > 10_000 {
|
||||
return Err(KernelError::Invalid("loft to a point: bad section or apex".into()));
|
||||
}
|
||||
if ((apex - plane.origin).dot(plane.normal())).abs() < 1e-9 {
|
||||
return Err(KernelError::Invalid("the apex lies in the section's plane".into()));
|
||||
}
|
||||
if lp.segs.iter().any(|s| !matches!(s, Seg2::Line { .. })) {
|
||||
return Err(KernelError::Invalid("not supported yet: lofting curved sections to a point".into()));
|
||||
}
|
||||
// Wind the base so its normal points away from the apex.
|
||||
let mut lp = lp.ccw();
|
||||
if (apex - plane.origin).dot(plane.normal()) > 0.0 {
|
||||
lp = lp.reversed();
|
||||
}
|
||||
guard("loft to point", || {
|
||||
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||||
let top = builder::vertex(p3(apex));
|
||||
let m = verts.len();
|
||||
let base: Vec<mt::Edge> = (0..m).filter_map(|i| Some(builder::line(verts.get(i)?, verts.get((i + 1) % m)?))).collect();
|
||||
let sides: Vec<mt::Edge> = verts.iter().map(|v| builder::line(v, &top)).collect();
|
||||
let mut faces = Vec::with_capacity(m + 1);
|
||||
let bw: mt::Wire = base.iter().cloned().collect();
|
||||
faces.push(builder::try_attach_plane(&[bw]).map_err(|e| KernelError::Failed(format!("loft base: {e}")))?);
|
||||
for i in 0..m {
|
||||
let (Some(e), Some(up), Some(down)) = (base.get(i), sides.get((i + 1) % m), sides.get(i)) else { continue };
|
||||
// Triangle i: along the base edge, up to the apex, back down — opposite to the base's winding.
|
||||
let w: mt::Wire = vec![e.inverse(), down.clone(), up.inverse()].into();
|
||||
faces.push(builder::try_attach_plane(&[w]).map_err(|e| KernelError::Failed(format!("loft side: {e}")))?);
|
||||
}
|
||||
let shell: mt::Shell = faces.into();
|
||||
match Solid::try_new(vec![shell.clone()]) {
|
||||
Ok(s) => Body::new(s),
|
||||
Err(_) => {
|
||||
let inv: mt::Shell = shell.face_iter().map(|f| f.inverse()).collect();
|
||||
Body::new(Solid::try_new(vec![inv]).map_err(|e| KernelError::Failed(format!("loft to point: {e}")))?)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solvecraft_geom::Vec2;
|
||||
|
||||
#[test]
|
||||
fn coil_volume_is_section_times_helix_length() {
|
||||
// A 2 mm circle in a plane through the Z axis, 20 mm out, 3 turns, pitch 8: a screw
|
||||
// motion of an axial section sweeps π r² · 2πR · turns (Fusion's spline helix: 4738.1).
|
||||
let pl = Plane::named("XZ").unwrap_or_else(|| Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z });
|
||||
let region = Region2 { outer: Loop2::circle(Vec2::new(20.0, 0.0), 2.0), holes: vec![] };
|
||||
let b = sweep_helix(&pl, ®ion, Vec3::ZERO, Vec3::Z, 8.0, 3.0).unwrap();
|
||||
let v = crate::measure(&b).unwrap().volume;
|
||||
let want = std::f64::consts::PI * 4.0 * std::f64::consts::TAU * 20.0 * 3.0;
|
||||
assert!((v - want).abs() / want < 1e-3, "{v} vs {want}");
|
||||
assert!(sweep_helix(&pl, ®ion, Vec3::ZERO, Vec3::Z, 3.0, 3.0).is_err(), "pitch below the section");
|
||||
assert!(sweep_helix(&pl, ®ion, Vec3::ZERO, Vec3::ZERO, 8.0, 3.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pyramid() {
|
||||
let pl = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
|
||||
let lp = Loop2::polygon(&[Vec2::new(-20.0, -20.0), Vec2::new(20.0, -20.0), Vec2::new(20.0, 20.0), Vec2::new(-20.0, 20.0)]);
|
||||
let b = loft_to_point(&pl, &lp, Vec3::new(0.0, 0.0, 30.0)).unwrap();
|
||||
let v = crate::measure(&b).unwrap().volume;
|
||||
assert!((v - 1600.0 * 30.0 / 3.0).abs() < 1e-6, "{v}");
|
||||
assert_eq!(b.face_count(), 5);
|
||||
assert!(loft_to_point(&pl, &lp, Vec3::new(1.0, 1.0, 0.0)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ mod body;
|
||||
mod build;
|
||||
mod coplanar;
|
||||
mod heal;
|
||||
mod helix;
|
||||
mod loopblend;
|
||||
mod measure;
|
||||
mod meshbody;
|
||||
@@ -31,6 +32,7 @@ mod topo;
|
||||
pub use blend::{chamfer, fillet};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo};
|
||||
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||
pub use helix::{loft_to_point, sweep_helix};
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
|
||||
|
||||
Reference in new issue
Block a user