diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 3e7faa41..7c4c8641 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, + /// 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, + /// Offset past the To Object face (expression; negative stops short). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to_offset: Option, + /// 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, } /// 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(), } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index ffd93785..066d1633 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1249,7 +1249,37 @@ fn feature_tools(vals: &BTreeMap, 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 = 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, f: &Feature, st: &ModelState) - .map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper)) .collect::, _>>()?); } - 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 = 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) { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 2238b2c9..6c8f2000 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -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![], diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index eabb0eb1..58fb20bb 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 { 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 { 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)?, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0420342e..34c44062 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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()); +}