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:
1 parent
ed22d663e4
commit
2a4907884b
24 files changed
+1343
-36
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user