From a46d00f36135d6f914ac09517f0a714c84f430ee Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 15:16:29 +0000 Subject: [PATCH] Revolve Two Sides (angle2), Symmetric (angle split either side) and To Object (turn until a face, vertex or point, kept by persistent face name) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/document.rs | 15 +++++++++++- crates/doc/src/eval.rs | 40 ++++++++++++++++++++++++++++--- crates/doc/src/params.rs | 7 +++++- crates/doc/src/tests.rs | 3 +++ crates/engine/src/cmd/features.rs | 24 +++++++++++++++++-- crates/engine/src/tests.rs | 32 +++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 2 +- 7 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 7c4c8641..258f3703 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -175,6 +175,15 @@ pub enum FeatureKind { operation: Operation, #[serde(default, skip_serializing_if = "Vec::is_empty")] targets: Vec, + /// Two Sides: the angle on the other side. + #[serde(default, skip_serializing_if = "Option::is_none")] + angle2: Option, + /// Symmetric: `angle` split evenly either side of the profile. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + symmetric: bool, + /// To Object: turn until the face, vertex or point here. + #[serde(default, skip_serializing_if = "Option::is_none")] + to: Option, }, Fillet { /// Reference points on the edges to round (each re-finds the nearest edge). @@ -1000,6 +1009,7 @@ impl FeatureKind { | FeatureKind::ReplaceFace { faces, .. } => faces.iter_mut().collect(), FeatureKind::Hole { position, points: None, .. } => vec![position], FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(), + FeatureKind::Revolve { to: Some(p), .. } => vec![p], _ => Vec::new(), } } @@ -1027,7 +1037,10 @@ impl FeatureKind { v.push(d); } } - FeatureKind::Revolve { angle, .. } => v.push(angle), + FeatureKind::Revolve { angle, angle2, .. } => { + v.push(angle); + v.extend(angle2.iter().map(String::as_str)); + } FeatureKind::Fillet { radius, style, .. } => { v.push(radius); if let FilletStyle::Variable { radius2, .. } = style { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 066d1633..6f809d42 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1317,11 +1317,39 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - } Ok(tools) } - FeatureKind::Revolve { sketch, profiles, axis, angle, .. } => { + FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let (o, d) = revolve_axis(&ss, axis)?; - let ang = val(vals, angle, Kind::Angle)?; + // Where the turn starts (from the profile) and how far it goes. + let (start, ang) = match (to, angle2, symmetric) { + (Some(p), _, _) => { + let (ow, aw) = (ss.plane.to_world(o), ss.plane.dir_to_world(d)); + let pts: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); + let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64)); + let flat = |q: Vec3| { + let v = q - ow; + v - aw * v.dot(aw) + }; + let (u, v) = (flat(c), flat(*p)); + // The revolve turns clockwise about its axis direction (the kernel's sense). + let a = (aw * -1.0).dot(u.cross(v)).atan2(u.dot(v)); + let a = if a <= 1e-9 { a + std::f64::consts::TAU } else { a }; + (0.0, a) + } + (None, Some(a2), _) => { + let (a, b) = (val(vals, angle, Kind::Angle)?, val(vals, a2, Kind::Angle)?); + (-b, a + b) + } + (None, None, true) => { + let a = val(vals, angle, Kind::Angle)?; + (-a / 2.0, a) + } + (None, None, false) => (0.0, val(vals, angle, Kind::Angle)?), + }; + if !(ang.abs() > 1e-9 && ang.abs() <= std::f64::consts::TAU + 1e-9) { + return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into())); + } let (operation, targets) = feature_op(f); let axis_w = ss.plane.dir_to_world(d); let origin_w = ss.plane.to_world(o); @@ -1336,7 +1364,13 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - .collect::>() }; let regions: Vec = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect(); - Ok(kernel::revolve(&ss.plane, ®ions, o, d, ang)?) + let bodies = kernel::revolve(&ss.plane, ®ions, o, d, ang)?; + if start.abs() < 1e-12 { + return Ok(bodies); + } + // Turned back to start before the profile. + let m = crate::rigid(Vec3::ZERO, origin_w, axis_w, -start); + Ok(bodies.iter().map(|b| kernel::transform_matrix(b, m)).collect::, _>>()?) } FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.point.is_some()) => { // A profile lofted to a sketch point (an apex). diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index e8c68681..6c95e57a 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -47,7 +47,12 @@ impl FeatureKind { v.push(("Taper angle", d, A)); } } - FeatureKind::Revolve { angle, .. } => v.push(("Angle", angle, A)), + FeatureKind::Revolve { angle, angle2, .. } => { + v.push(("Angle", angle, A)); + if let Some(a) = angle2 { + v.push(("Angle 2", a, A)); + } + } FeatureKind::Fillet { radius, style, .. } => match style { crate::FilletStyle::Constant => v.push(("Radius", radius, L)), crate::FilletStyle::Chord => v.push(("Chord length", radius, L)), diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 6c8f2000..a11dd964 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -135,6 +135,9 @@ fn errors_are_reported_not_fatal() { angle: "90 deg".into(), operation: Operation::NewBody, targets: vec![], + angle2: None, + symmetric: false, + to: None, }, None, ) diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 58fb20bb..4ac8a6d5 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.revolve", "Revolve", revolve) .at("SOLID", "CREATE") .icon("revolve") - .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"), + .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"), CommandSpec::new("solid.sweep", "Sweep", sweep) .at("SOLID", "CREATE") .icon("sweep") @@ -408,7 +408,27 @@ fn revolve(s: &mut Session, p: &Value) -> Result { Some(f) => f, None => profiles(p, cmd)?, }; - let kind = FeatureKind::Revolve { sketch, profiles, axis, angle, operation: operation(p, cmd)?, targets: string_list(p, "targets") }; + let angle2 = expr(p, "angle2"); + if let Some(a) = &angle2 { + check_expr(s, a, Kind::Angle, cmd, "angle2")?; + } + let to = match p.get("to") { + Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] to turn to"))?), + None => None, + }; + let symmetric = + matches!(str_(p, "direction").map(str::to_ascii_lowercase).as_deref(), Some("symmetric")) || bool_(p, "symmetric").unwrap_or(false); + let kind = FeatureKind::Revolve { + sketch, + profiles, + axis, + angle, + operation: operation(p, cmd)?, + targets: string_list(p, "targets"), + angle2, + symmetric, + to, + }; add_feature(s, p, kind) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 34c44062..a001fcca 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1683,3 +1683,35 @@ fn extrude_to_and_from_objects() { assert_eq!(name, vec!["F1:-z".to_string()], "the To face is stored by name"); assert!(s.execute("solid.extrude", &json!({"sketch": "P", "to": "nope"})).is_err()); } + +/// Revolve Two Sides, Symmetric and To Object. +#[test] +fn revolve_two_sides_symmetric_and_to_object() { + let ring = |extra: Value| { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"})); + run(&mut s, "sketch.line", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true})); + run(&mut s, "sketch.finish", json!({})); + let mut p = json!({"sketch": "S", "axis": "y", "angle": "90 deg"}); + for (k, v) in extra.as_object().into_iter().flatten() { + p[k] = v.clone(); + } + run(&mut s, "solid.revolve", p); + let m = run(&mut s, "inspect.measure", json!({})); + (m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), m["bodies"][0]["bbox"].clone()) + }; + let full = PI * (225.0 - 100.0) * 5.0; + let (v, _) = ring(json!({})); + assert!(rel(v, full / 4.0) < 1e-3); + let (v, _) = ring(json!({"angle2": "45 deg"})); + assert!(rel(v, full * 135.0 / 360.0) < 1e-3, "{v}"); + let (v, b) = ring(json!({"direction": "symmetric"})); + assert!(rel(v, full / 4.0) < 1e-3); + // Split evenly: the extent either side of the sketch plane (y = 0) is the same. + assert!((b["max"][1].as_f64().unwrap_or(0.0) + b["min"][1].as_f64().unwrap_or(0.0)).abs() < 1e-3, "{b}"); + // To a point a quarter turn round: the same as 90°. + let (v90, b90) = ring(json!({})); + let corner = if b90["max"][1].as_f64().unwrap_or(0.0) > 1.0 { json!([0, 12, 0]) } else { json!([0, -12, 0]) }; + let (v, _) = ring(json!({"to": corner})); + assert!(rel(v, v90) < 1e-3, "{v} {v90}"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 347667b8..ac37e760 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2407,7 +2407,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, .. } => { let mut d = start("solid.revolve")?; d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) }; let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();