Ruled Surface: strips off an edge chain, exact where possible (#762) (#1151)

Ruled Surface: strips off an edge chain in normal, tangent, angle or direction mode, exact planes/cylinders/cones where possible, mitred or round corners, stitched into one surface body. SurfaceRuled is noted under format 21. Closes #762.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-14 21:44:56 -04:00
1 parent ed22d663e4
commit 2a4907884b
24 files changed
+1343 -36

No files matched your search

+105 -4
View File
@@ -2,7 +2,7 @@
//! solids), and the planned surface toolset (`planned!`: registered, not built yet).
use serde_json::Value;
use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind};
use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
use super::CommandSpec;
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
@@ -50,10 +50,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "CREATE")
.icon("surface_offset")
.params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"),
planned!("surface.ruled", "Ruled Surface", "762")
CommandSpec::new("surface.ruled", "Ruled Surface", ruled)
.at("SURFACE", "CREATE")
.icon("ruled")
.params("planned: body; edges: [[x,y,z]] (points on an edge chain); distance: expr; mode?: normal|tangent|angle|direction; angle?: expr; direction?: [x,y,z]; flip?: bool; corners?: mitre|round"),
.params("body; edges: [[x,y,z]] (points on a chain of its edges, open or closed); distance: expr (how far the strip reaches); mode?: normal|tangent|angle|direction (default normal: along the normal of the face beside each edge; tangent: continuing it); angle?: expr (angle mode: from `direction`, else from the face's normal, turned away from the face; 90 deg from the normal is tangent); direction?: [x,y,z] (direction mode: the rulings; angle mode: the reference, e.g. a pull direction for a draft); face?: [x,y,z] (a point on the face the directions come from; default: the face beside the most picked edges); flip?: bool (the other side); corners?: mitre|round (sharp corners of the chain; inside corners are always cut back). Lines and arcs give exact planes, cylinders and cones, other edges a fitted ruled B-spline. A new surface body"),
planned!("surface.boundary", "Boundary Surface", "763")
.at("SURFACE", "CREATE")
.icon("boundary")
@@ -236,6 +236,54 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, kind)
}
fn ruled(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.ruled";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let edges: Vec<solidcraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if edges.is_empty() || edges.len() > 10_000 {
return Err(bad(cmd, "`edges` must list points on a chain of the body's edges"));
}
let distance = expr(p, "distance").ok_or_else(|| bad(cmd, "`distance` is required"))?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
let mode = match str_(p, "mode").map(str::to_ascii_lowercase).as_deref() {
None | Some("normal") => RuledMode::Normal,
Some("tangent") => RuledMode::Tangent,
Some("angle") => RuledMode::Angle,
Some("direction") => RuledMode::Direction,
Some(o) => return Err(bad(cmd, format!("unknown mode `{o}` (normal, tangent, angle or direction)"))),
};
let angle = match mode {
RuledMode::Angle => {
let a = expr(p, "angle").ok_or_else(|| bad(cmd, "`angle` is required with mode angle"))?;
check_expr(s, &a, Kind::Angle, cmd, "angle")?;
Some(a)
}
_ => None,
};
let direction = match (mode, p.get("direction").filter(|v| !v.is_null())) {
(RuledMode::Angle | RuledMode::Direction, Some(v)) => {
let d = vec3(v).filter(|d| d.len() > 1e-12).ok_or_else(|| bad(cmd, "`direction` must be a non-zero [x, y, z]"))?;
Some(d)
}
(RuledMode::Direction, None) => return Err(bad(cmd, "`direction` is required with mode direction")),
_ => None,
};
let corners = match str_(p, "corners").map(str::to_ascii_lowercase).as_deref() {
None | Some("mitre") | Some("miter") => RuledCorners::Mitre,
Some("round") => RuledCorners::Round,
Some(o) => return Err(bad(cmd, format!("unknown corners `{o}` (mitre or round)"))),
};
let face = match p.get("face").filter(|v| !v.is_null()) {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`face` must be [x, y, z] on a face"))?),
None => None,
};
let kind = FeatureKind::SurfaceRuled { body, edges, distance, mode, angle, direction, flip: bool_(p, "flip").unwrap_or(false), corners, face };
add_feature(s, p, kind)
}
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
@@ -666,6 +714,59 @@ mod tests {
assert!(s.execute("surface.offset", &json!({"distance": 1})).is_err());
}
/// Ruled Surface round a box top (10 × 10), 2 wide: across the top, mitred, a 14 × 14 frame
/// less the top (96); up the top's normal, 80; at 45° with round corners, 80 + π·√2·2; along
/// a direction, 2 × 10 off one edge. The strips' faces are named after the edges they stand
/// on. Zero, a missing direction, an unknown mode and a non-finite distance are refused, the
/// design left as it was; undo, redo and reopening.
#[test]
fn ruled_surface_round_a_box_top() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"}));
let rim = json!([[5, 0, 10], [10, 5, 10], [5, 10, 10], [0, 5, 10]]);
let top = json!([5, 5, 10]);
run(&mut s, "surface.ruled", json!({"body": "Box", "edges": rim, "distance": "2 mm", "mode": "tangent", "face": top, "body_name": "Flange"}));
assert!((area(&s, "Flange") - 96.0).abs() < 1e-9, "{}", area(&s, "Flange"));
assert!(s.world_state().body("Flange").unwrap().body.is_surface() && s.world_state().body("Box").is_some());
run(&mut s, "surface.ruled", json!({"body": "Box", "edges": rim, "distance": 2, "face": top, "body_name": "Wall"}));
assert!((area(&s, "Wall") - 80.0).abs() < 1e-9);
run(
&mut s,
"surface.ruled",
json!({"body": "Box", "edges": rim, "distance": 2, "mode": "angle", "angle": "45 deg", "corners": "round", "face": top, "body_name": "Draft"}),
);
let want = 80.0 + std::f64::consts::PI * 2f64.sqrt() * 2.0;
assert!((area(&s, "Draft") - want).abs() < want * 2e-3, "{} vs {want}", area(&s, "Draft")); // (measured on meshes)
run(
&mut s,
"surface.ruled",
json!({"body": "Box", "edges": [[5, 0, 10]], "distance": 2, "mode": "direction", "direction": [0, -1, 0], "body_name": "Lip"}),
);
let bb = solidcraft_kernel::measure(&s.world_state().body("Lip").unwrap().body).unwrap().bbox;
assert!((area(&s, "Lip") - 20.0).abs() < 1e-9 && (bb.min.y + 2.0).abs() < 1e-9);
let st = s.world_state();
let names = solidcraft_doc::naming::face_names(st.body("Flange").unwrap());
assert!(names.len() == 4 && names.iter().all(|n| !n.is_empty()), "{names:?}");
let before = s.doc.features.len();
for bad in [
json!({"body": "Box", "edges": rim, "distance": 0}),
json!({"body": "Box", "edges": rim, "distance": "1e400 mm"}),
json!({"body": "Box", "edges": rim, "distance": 2, "mode": "direction"}),
json!({"body": "Box", "edges": rim, "distance": 2, "mode": "sideways"}),
json!({"body": "Box", "edges": rim, "distance": 2, "mode": "angle"}),
json!({"body": "Box", "distance": 2}),
json!({"body": "Nope", "edges": rim, "distance": 2}),
] {
assert!(s.execute("surface.ruled", &bad).is_err(), "{bad}");
}
assert_eq!(s.doc.features.len(), before);
run(&mut s, "edit.undo", json!({}));
assert!(s.world_state().body("Lip").is_none());
run(&mut s, "edit.redo", json!({}));
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((area(&cold, "Draft") - area(&s, "Draft")).abs() < 1e-9);
}
/// A 40 × 20 × 2 plate with three holes of radius 2 through it at x = 10, 20, 30 on y = 10.
fn plate_with_three_holes(s: &mut Session) {
run(s, "solid.box", json!({"length": 40, "width": 20, "height": 2, "body_name": "Plate"}));
@@ -775,7 +876,7 @@ mod tests {
/// as it was.
#[test]
fn planned_commands_refuse_and_leave_the_design_alone() {
let planned = ["surface.ruled", "surface.boundary", "surface.blend", "surface.fillet", "inspect.continuity"];
let planned = ["surface.boundary", "surface.blend", "surface.fillet", "inspect.continuity"];
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let before = s.doc.features.len();