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

+11 -2
View File
@@ -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") })
}
+68
View File
@@ -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, &section_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());
}