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

+4
View File
@@ -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);
+49
View File
@@ -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));
+52
View File
@@ -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, &regions, 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] = &sections[..] 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] = &regions[..] 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, &region, *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)
+9
View File
@@ -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 {