Extrude To Object (a face, vertex or point; parallel faces by their plane, others where the profile's middle meets them; to_offset past it) and From Object (start at a face or point); both kept by persistent face name
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
9ee8ba5dd7
commit
ab328ae018
5 files changed
+134
-9
No files matched your search
@@ -121,6 +121,16 @@ pub struct Extent {
|
||||
/// Taper angle (expression; positive grows the profile).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub taper: Option<String>,
|
||||
/// To Object: end at the face (or vertex/point) at this point, instead of `distance`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub to: Option<Vec3>,
|
||||
/// Offset past the To Object face (expression; negative stops short).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub to_offset: Option<String>,
|
||||
/// From Object: start at the face (or point) here instead of the sketch plane (plus
|
||||
/// `start_offset`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub from: Option<Vec3>,
|
||||
}
|
||||
|
||||
/// Revolve axis.
|
||||
@@ -989,6 +999,7 @@ impl FeatureKind {
|
||||
| FeatureKind::OffsetFace { faces, .. }
|
||||
| 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(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
+69
-6
@@ -1249,7 +1249,37 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
||||
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 d = if extent.through_all { through_all_distance(st, &ss.plane, targets)? } else { val(vals, &extent.distance, Kind::Length)? };
|
||||
// From / To Object: where the face or point lies along the normal, measured over the
|
||||
// middle of the profiles.
|
||||
let n = ss.plane.normal();
|
||||
let centre = {
|
||||
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||||
let c = pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64);
|
||||
ss.plane.to_world(c)
|
||||
};
|
||||
let from = match extent.from {
|
||||
Some(p) => object_height(st, &ss.plane, centre, p),
|
||||
None => 0.0,
|
||||
};
|
||||
let d = match extent.to {
|
||||
Some(p) => {
|
||||
let off = match &extent.to_offset {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let h = object_height(st, &ss.plane, centre, p);
|
||||
let start = from + extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0);
|
||||
// Past the face by the offset, in the direction travelled.
|
||||
let to = h + if h >= start { off } else { -off };
|
||||
if (to - start).abs() < 1e-9 {
|
||||
return Err(DocError::Invalid("the To object is where the extrude starts".into()));
|
||||
}
|
||||
to - start
|
||||
}
|
||||
None if extent.through_all => through_all_distance(st, &ss.plane, targets)?,
|
||||
None => val(vals, &extent.distance, Kind::Length)?,
|
||||
};
|
||||
let _ = n;
|
||||
if let Some(tp) = &extent.taper {
|
||||
let taper = val(vals, tp, Kind::Angle)?;
|
||||
if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric {
|
||||
@@ -1262,11 +1292,14 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
.map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?);
|
||||
}
|
||||
let off = match &extent.start_offset {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let (a, b) = match (&extent.distance2, extent.direction) {
|
||||
let off = from
|
||||
+ match &extent.start_offset {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => 0.0,
|
||||
};
|
||||
// To Object gives a signed distance from the start: one side only.
|
||||
let direction = if extent.to.is_some() { crate::Direction::Positive } else { extent.direction };
|
||||
let (a, b) = match (&extent.distance2, direction) {
|
||||
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
|
||||
(None, crate::Direction::Positive) => (0.0, d),
|
||||
(None, crate::Direction::Negative) => (-d, 0.0),
|
||||
@@ -1827,6 +1860,36 @@ mod more;
|
||||
#[path = "eval_surface.rs"]
|
||||
mod surface;
|
||||
|
||||
/// Height above `plane` (along its normal) of the object at `p` as seen from `centre`: the face
|
||||
/// there if it is planar and parallel (its plane), else where a ray from `centre` along the
|
||||
/// normal meets the body there, else the point itself.
|
||||
fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 {
|
||||
let n = plane.normal();
|
||||
let h = |q: Vec3| (q - plane.origin).dot(n);
|
||||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
let Ok(faces) = b.body.faces(tol) else { continue };
|
||||
let m = b.mesh();
|
||||
let on: Vec<usize> = faces.iter().filter(|f| crate::appearance::face_index_at(b, p) == Some(f.index)).map(|f| f.index).collect();
|
||||
let Some(&fi) = on.first() else { continue };
|
||||
if let Some(f) = faces.iter().find(|f| f.index == fi)
|
||||
&& f.plane_normal.is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6)
|
||||
{
|
||||
return h(f.centroid);
|
||||
}
|
||||
// A curved or slanted face: where the profile's middle meets it.
|
||||
for dir in [n, n * -1.0] {
|
||||
if let Some((t, tri)) = m.raycast(centre, dir)
|
||||
&& m.tri_face.get(tri).is_some_and(|x| *x as usize == fi)
|
||||
{
|
||||
return h(centre + dir * t);
|
||||
}
|
||||
}
|
||||
return h(p);
|
||||
}
|
||||
h(p)
|
||||
}
|
||||
|
||||
/// The plane of the body face a sketch sits on, found again: among planar faces with the
|
||||
/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it.
|
||||
fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) -> (Plane, Option<String>) {
|
||||
|
||||
@@ -38,6 +38,9 @@ fn plate_doc() -> (Document, u64) {
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
},
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
@@ -97,6 +100,9 @@ fn box_fillet_cut_timeline() {
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
},
|
||||
operation: Operation::Cut,
|
||||
targets: vec![],
|
||||
|
||||
@@ -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] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); 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] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; 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, join out of one, else new); targets?: [body]; name?; body_name?"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -326,7 +326,14 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => (feature_sketch(s, p, cmd)?, None),
|
||||
};
|
||||
let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all"));
|
||||
let distance = if through_all { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
|
||||
let to_object = p.get("to").is_some();
|
||||
if to_object && p.get("to").and_then(vec3).is_none() {
|
||||
return Err(bad(cmd, "`to` must be a point [x, y, z] on the face, vertex or point to extrude to"));
|
||||
}
|
||||
if p.get("from").is_some() && p.get("from").and_then(vec3).is_none() {
|
||||
return Err(bad(cmd, "`from` must be a point [x, y, z] on the face or point to start from"));
|
||||
}
|
||||
let distance = if through_all || to_object { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
let taper = expr(p, "taper");
|
||||
if let Some(t) = &taper {
|
||||
@@ -352,7 +359,17 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(f) => f,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent: Extent { distance, direction, distance2, start_offset, through_all, taper },
|
||||
extent: Extent {
|
||||
distance,
|
||||
direction,
|
||||
distance2,
|
||||
start_offset,
|
||||
through_all,
|
||||
taper,
|
||||
to: p.get("to").and_then(vec3),
|
||||
to_offset: expr(p, "to_offset"),
|
||||
from: p.get("from").and_then(vec3),
|
||||
},
|
||||
operation: match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
|
||||
@@ -1655,3 +1655,31 @@ fn appearances_survive_later_features() {
|
||||
assert!(red(&mut t, &body), "the imported red face carries on");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Extrude To Object (a parallel face, with an offset; a slanted face) and From Object.
|
||||
#[test]
|
||||
fn extrude_to_and_from_objects() {
|
||||
let mut s = Session::default();
|
||||
// A 10 mm plate at z 30…40 to extrude up to, and a profile on XY.
|
||||
run(&mut s, "solid.box", json!({"corner": [0, 0, 30], "length": 40, "width": 40, "height": 10, "body_name": "Roof"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "P"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let top = |s: &mut Session, b: &str| run(s, "inspect.measure", json!({"bodies": [b]}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "P", "to": [20, 20, 30], "body_name": "Post"}));
|
||||
assert!((top(&mut s, "Post") - 30.0).abs() < 1e-6, "{}", top(&mut s, "Post"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "P", "to": [20, 20, 30], "to_offset": -5, "body_name": "Post"}));
|
||||
assert!((top(&mut s, "Post") - 25.0).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// From the roof's top face (z 40) to 10 mm above it.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "P", "from": [20, 20, 40], "distance": 10, "body_name": "Post"}));
|
||||
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Post"]}));
|
||||
assert!((m["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 40.0).abs() < 1e-6 && (top(&mut s, "Post") - 50.0).abs() < 1e-6, "{m}");
|
||||
// The To face is kept by its persistent name, so it is followed when it moves.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "P", "to": [20, 20, 30], "body_name": "Post"}));
|
||||
let name = s.doc.features.last().map(|f| f.face_names.clone()).unwrap_or_default();
|
||||
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());
|
||||
}
|
||||
Reference in new issue
Block a user