Thin Extrude builds walls of a set thickness along sketch curves, open chains and closed loops, with the wall centred, inside, outside or on both sides at two thicknesses. Saved under format 20 (thin on an Extrude); an extrude without thin saves as before. Closes #721.
This commit is contained in:
1 parent
86ea018d05
commit
6df69e9def
38 files changed
+1440
-34
No files matched your search
@@ -1,7 +1,7 @@
|
||||
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr};
|
||||
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, ThinSide, ThinWall, expr::Kind, point_expr};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -119,6 +119,11 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
let sid = feature_sketch(s, p, "Extrude").ok()?;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(sid)?;
|
||||
if bool_(p, "thin").unwrap_or(false) {
|
||||
let thin = thin_param(s, p, sid, "solid.extrude").ok()?;
|
||||
let q = thin_regions(s, ss, &thin).ok()?.first()?.interior_point();
|
||||
return Some((ss.plane.to_world(q), ss.plane.normal()));
|
||||
}
|
||||
let ps = &ss.profiles;
|
||||
let q = match profiles(p, "Extrude").ok()? {
|
||||
ProfileSel::All => ps.first()?.region.interior_point(),
|
||||
@@ -505,10 +510,21 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => (feature_sketch(s, p, cmd)?, None),
|
||||
};
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
let thin = match bool_(p, "thin") {
|
||||
Some(true) => {
|
||||
if face_profile.is_some() || p.get("profiles").is_some() {
|
||||
return Err(bad(cmd, "a thin extrude follows `curves`; it takes no `profiles` or `face`"));
|
||||
}
|
||||
Some(thin_param(s, p, sketch, cmd)?)
|
||||
}
|
||||
_ if p.get("thin").is_some_and(|v| !v.is_boolean()) => return Err(bad(cmd, "`thin` must be true or false")),
|
||||
_ => None,
|
||||
};
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: match face_profile {
|
||||
Some(f) => f,
|
||||
None if thin.is_some() => ProfileSel::All,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent,
|
||||
@@ -517,10 +533,64 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => operation(p, cmd)?,
|
||||
},
|
||||
targets: string_list(p, "targets"),
|
||||
thin,
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn
|
||||
/// curve when none are given), its thickness and which side of them it lies on.
|
||||
fn thin_param(s: &Session, p: &Value, sketch: u64, cmd: &str) -> Result<ThinWall> {
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
|
||||
let mut curves = string_list(p, "curves");
|
||||
if curves.is_empty() {
|
||||
curves = ss.sketch.curves.iter().filter(|c| !c.construction && !c.centerline && c.link.is_none()).map(|c| c.id.clone()).collect();
|
||||
}
|
||||
if curves.is_empty() || curves.len() > 1000 {
|
||||
return Err(bad(cmd, "a thin extrude needs 1…1000 sketch curves (`curves`)"));
|
||||
}
|
||||
if let Some(c) = curves.iter().find(|c| ss.sketch.curve_index(c).is_none()) {
|
||||
return Err(bad(cmd, format!("no curve `{c}` in the sketch")));
|
||||
}
|
||||
let thickness = req_expr(cmd, p, "thickness")?;
|
||||
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
|
||||
let side = match str_(p, "side") {
|
||||
None => ThinSide::Center,
|
||||
Some(o) => ThinSide::parse(o).ok_or_else(|| bad(cmd, format!("unknown side `{o}` (center, inside, outside or two_sides)")))?,
|
||||
};
|
||||
let thickness2 = expr(p, "thickness2");
|
||||
match (&thickness2, side) {
|
||||
(Some(t), ThinSide::TwoSides) => check_expr(s, t, Kind::Length, cmd, "thickness2")?,
|
||||
(None, ThinSide::TwoSides) => return Err(bad(cmd, "`side: two_sides` needs `thickness2` (the thickness outside)")),
|
||||
(Some(_), _) => return Err(bad(cmd, "`thickness2` goes with `side: two_sides`")),
|
||||
(None, _) => {}
|
||||
}
|
||||
let thin = ThinWall { curves, thickness, side, thickness2 };
|
||||
// Refuse a wall that can't be built now, with the reason, rather than a failed feature.
|
||||
thin_regions(s, ss, &thin).map_err(|e| bad(cmd, e))?;
|
||||
Ok(thin)
|
||||
}
|
||||
|
||||
/// The regions of a thin wall in its (solved) sketch, with the current parameter values.
|
||||
fn thin_regions(s: &Session, ss: &solidcraft_doc::SolvedSketch, thin: &ThinWall) -> std::result::Result<Vec<solidcraft_geom::Region2>, String> {
|
||||
let length = |e: &str| s.doc.eval(&s.doc.with_design_unit(e, Kind::Length), Kind::Length).map_err(|e| e.to_string());
|
||||
let t = length(&thin.thickness)?;
|
||||
let (inside, outside) = match thin.side {
|
||||
ThinSide::Center => (t / 2.0, t / 2.0),
|
||||
ThinSide::Inside => (t, 0.0),
|
||||
ThinSide::Outside => (0.0, t),
|
||||
ThinSide::TwoSides => (t, length(thin.thickness2.as_deref().unwrap_or("0"))?),
|
||||
};
|
||||
let curves: Vec<Vec<solidcraft_geom::Seg2>> = thin
|
||||
.curves
|
||||
.iter()
|
||||
.filter_map(|id| ss.sketch.curve_index(id))
|
||||
.map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
.collect();
|
||||
solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| format!("thin wall: {e}"))
|
||||
}
|
||||
|
||||
/// An extrude's extent from its parameters: distance (or through all, or to an object), the
|
||||
/// side(s), a second distance, a start offset, a taper, and from an object.
|
||||
pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent> {
|
||||
@@ -1696,3 +1766,7 @@ mod solid_move_copy_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "move_in_sketch_tests.rs"]
|
||||
mod move_in_sketch_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thin_extrude_tests.rs"]
|
||||
mod thin_extrude_tests;
|
||||
@@ -1475,7 +1475,7 @@ fn offset_driver_format18_offsets_keep_fixed_distances_on_upgrade() {
|
||||
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
json["format"] = json!("solidcraft/18");
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&json.to_string()).unwrap());
|
||||
assert_eq!(reopened.doc.format, "solidcraft/19");
|
||||
assert_eq!(reopened.doc.format, "solidcraft/20");
|
||||
run(&mut reopened, "sketch.edit", json!({"sketch":1}));
|
||||
let sk = sketch(&reopened);
|
||||
assert!((sk.offsets[0].distance - 2.0).abs() < 1e-8);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Thin Extrude (#721): walls along sketch curves, their volumes worked out by hand (a wall's
|
||||
//! area times its height).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn body_count(s: &mut Session) -> u64 {
|
||||
run(s, "inspect.measure", json!({}))["body_count"].as_u64().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) {
|
||||
assert!((a - b).abs() <= 1e-6 * b.abs().max(1.0), "{a} != {b}");
|
||||
}
|
||||
|
||||
/// A sketch on XY holding a 40 × 20 rectangle from (5, 5); returns its side ids.
|
||||
fn rectangle(s: &mut Session) -> Vec<String> {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
|
||||
let r = run(s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [45, 25]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
["bottom", "right", "top", "left"].iter().map(|k| r["sides"][k].as_str().unwrap_or_default().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_makes_a_ring_wall_on_either_side() {
|
||||
for (side, extra, area) in [
|
||||
("center", json!({}), 42.0 * 22.0 - 38.0 * 18.0),
|
||||
("inside", json!({}), 40.0 * 20.0 - 36.0 * 16.0),
|
||||
("outside", json!({}), 44.0 * 24.0 - 40.0 * 20.0),
|
||||
("two_sides", json!({"thickness2": 1.5}), 43.0 * 23.0 - 39.0 * 19.0),
|
||||
] {
|
||||
let mut s = Session::default();
|
||||
rectangle(&mut s);
|
||||
let mut p = json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": side, "distance": 10});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
if side == "two_sides" {
|
||||
p["thickness"] = json!(0.5);
|
||||
}
|
||||
run(&mut s, "solid.extrude", p);
|
||||
close(volume(&mut s), area * 10.0);
|
||||
assert_eq!(body_count(&mut s), 1, "{side}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thickness_is_a_parameter_and_the_wall_survives_reopening() {
|
||||
let mut s = Session::default();
|
||||
rectangle(&mut s);
|
||||
let r = run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 10, "name": "Wall"}));
|
||||
assert!(r["created_bodies"].as_array().is_some_and(|b| b.len() == 1));
|
||||
close(volume(&mut s), 240.0 * 10.0);
|
||||
let f = s.doc.features.iter().find(|f| f.name == "Wall").cloned().unwrap();
|
||||
let t = f.param_names.iter().zip(f.kind.inputs()).find(|(_, (label, _, _))| *label == "Thickness").map(|(n, _)| n.clone()).unwrap();
|
||||
run(&mut s, "parameters.change", json!({"name": t, "expression": "4"}));
|
||||
close(volume(&mut s), (44.0 * 24.0 - 36.0 * 16.0) * 10.0);
|
||||
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
close(volume(&mut reopened), (44.0 * 24.0 - 36.0 * 16.0) * 10.0);
|
||||
// A plain extrude of the same sketch fills the rectangle.
|
||||
let mut s = Session::default();
|
||||
rectangle(&mut s);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10}));
|
||||
close(volume(&mut s), 800.0 * 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_curves_make_an_open_wall_that_joins_and_cuts() {
|
||||
// Three sides of the rectangle: a U, inside toward the open side's middle.
|
||||
let mut s = Session::default();
|
||||
let sides = rectangle(&mut s);
|
||||
let u = [&sides[0], &sides[1], &sides[2]];
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "curves": u, "thickness": 2, "side": "inside", "distance": 5}));
|
||||
// 40 × 2 twice and 20 × 2, less the two 2 × 2 corners they share.
|
||||
close(volume(&mut s), (80.0 + 40.0 + 80.0 - 8.0) * 5.0);
|
||||
// Cut a groove 3 deep along the whole outline into a block under it.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "corner": [0, 0, -10], "body_name": "Block"}));
|
||||
rectangle(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 3, "direction": "negative", "operation": "cut"}),
|
||||
);
|
||||
close(volume(&mut s), 15000.0 - 240.0 * 3.0);
|
||||
assert_eq!(body_count(&mut s), 1);
|
||||
// And join a rim on top of it.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "corner": [0, 0, -10], "body_name": "Block"}));
|
||||
rectangle(&mut s);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 4, "operation": "auto"}));
|
||||
close(volume(&mut s), 15000.0 + 240.0 * 4.0);
|
||||
assert_eq!(body_count(&mut s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ring_and_a_cross_of_walls_are_one_body() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Target"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 20}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, -20], [0, 20]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Target", "thin": true, "thickness": 1, "distance": 5}));
|
||||
assert_eq!(body_count(&mut s), 1);
|
||||
// The ring, plus the bars inside it less where they cross and where they run into it.
|
||||
let ring = std::f64::consts::PI * (20.5f64.powi(2) - 19.5f64.powi(2));
|
||||
let v = volume(&mut s);
|
||||
assert!(v > (ring + 2.0 * 38.0 - 1.0) * 5.0 && v < (ring + 2.0 * 40.0) * 5.0, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_thin_walls_are_refused_and_change_nothing() {
|
||||
let mut s = Session::default();
|
||||
rectangle(&mut s);
|
||||
let before = s.doc.to_json();
|
||||
for (p, says) in [
|
||||
(json!({"thin": true, "thickness": 2, "distance": 5, "curves": ["nope"]}), "no curve"),
|
||||
(json!({"thin": true, "distance": 5}), "thickness"),
|
||||
(json!({"thin": true, "thickness": 2, "distance": 5, "side": "two_sides"}), "thickness2"),
|
||||
(json!({"thin": true, "thickness": 2, "thickness2": 1, "distance": 5}), "two_sides"),
|
||||
(json!({"thin": true, "thickness": 2, "distance": 5, "side": "sideways"}), "unknown side"),
|
||||
(json!({"thin": true, "thickness": 2, "distance": 5, "profiles": [0]}), "profiles"),
|
||||
(json!({"thin": true, "thickness": 0, "distance": 5}), "thickness"),
|
||||
(json!({"thin": true, "thickness": -1, "distance": 5}), "thickness"),
|
||||
(json!({"thin": "yes", "thickness": 1, "distance": 5}), "`thin`"),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["sketch"] = json!("Outline");
|
||||
let e = s.execute("solid.extrude", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains(says), "{p}: {e}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
// A wall thicker than an arc it follows bends.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let e = s.execute("solid.extrude", &json!({"sketch": "Small", "thin": true, "thickness": 3, "side": "inside", "distance": 5}));
|
||||
assert!(e.is_err_and(|e| e.to_string().contains("thicker")));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Small", "thin": true, "thickness": 3, "side": "outside", "distance": 5}));
|
||||
// Measured from the tessellation, within its 1e-4.
|
||||
let (v, exact) = (volume(&mut s), std::f64::consts::PI * (16.0 - 1.0) * 5.0);
|
||||
assert!((v - exact).abs() < 1e-4 * exact, "{v} vs {exact}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_thin_params_never_panic() {
|
||||
let keys = ["curves", "thickness", "thickness2", "side", "distance", "taper", "direction", "to", "operation"];
|
||||
for v in crate::tests::hostile_values().into_iter().chain([json!(1e9), json!(1e-9), json!("1e6 m"), json!(["l1", "l1", "l1"])]) {
|
||||
for k in keys {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
rectangle(&mut s);
|
||||
let mut p = json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": "two_sides", "thickness2": 1, "distance": 5});
|
||||
p[k] = v.clone();
|
||||
if let Err(crate::EngineError::Internal(id, msg)) = s.execute("solid.extrude", &p) {
|
||||
panic!("{id} {p}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,7 +602,7 @@ fn script_runs_commands() {
|
||||
assert!(s.run_script(&json!([{"command": "Nope"}])).is_err());
|
||||
}
|
||||
|
||||
fn hostile_values() -> Vec<Value> {
|
||||
pub(crate) fn hostile_values() -> Vec<Value> {
|
||||
vec![
|
||||
Value::Null,
|
||||
json!(true),
|
||||
|
||||
Reference in new issue
Block a user