Surface Extrude: sheets from open curves and profile walls (#815)
surface.extrude sweeps open curve chains and profile boundaries along the sketch normal into a surface body (walls only, no caps). Saved under format 17. Refs #757 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8dd583930e
commit
e540832626
17 files changed
+493
-79
No files matched your search
@@ -321,6 +321,7 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Revolve { .. }
|
||||
| FeatureKind::Extrude { .. }
|
||||
| FeatureKind::SurfaceExtrude { .. }
|
||||
| FeatureKind::Combine { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
|
||||
@@ -47,6 +47,7 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
FeatureKind::Extrude { sketch, .. }
|
||||
| FeatureKind::SurfaceExtrude { sketch, .. }
|
||||
| FeatureKind::Revolve { sketch, .. }
|
||||
| FeatureKind::Emboss { sketch, .. }
|
||||
| FeatureKind::SheetBase { sketch, .. }
|
||||
|
||||
@@ -925,6 +925,18 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
/// A surface body swept straight along the sketch normal from open curve chains and the
|
||||
/// boundaries of profiles: walls only, no caps.
|
||||
SurfaceExtrude {
|
||||
sketch: u64,
|
||||
/// Profiles whose boundary loops are swept (none: only `curves`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
profiles: Option<ProfileSel>,
|
||||
/// Sketch curve ids, grouped into chains of curves that meet end to end.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
curves: Vec<String>,
|
||||
extent: Extent,
|
||||
},
|
||||
/// A planar surface body extended outward by `distance` past the edges through `edges`.
|
||||
SurfaceExtend {
|
||||
body: String,
|
||||
@@ -1225,6 +1237,7 @@ impl FeatureKind {
|
||||
use FeatureKind as K;
|
||||
match self {
|
||||
K::Extrude { sketch, .. }
|
||||
| K::SurfaceExtrude { sketch, .. }
|
||||
| K::Revolve { sketch, .. }
|
||||
| K::Emboss { sketch, .. }
|
||||
| K::Rib { sketch, .. }
|
||||
@@ -1356,6 +1369,7 @@ impl FeatureKind {
|
||||
FeatureKind::Align { .. } => "AlignFeature",
|
||||
FeatureKind::Remove { .. } => "RemoveFeature",
|
||||
FeatureKind::Patch { .. } => "PatchFeature",
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
FeatureKind::Thicken { .. } => "ThickenFeature",
|
||||
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
|
||||
@@ -1422,6 +1436,7 @@ impl FeatureKind {
|
||||
FeatureKind::Align { .. } => "Align",
|
||||
FeatureKind::Remove { .. } => "Remove",
|
||||
FeatureKind::Patch { .. } => "Patch",
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
FeatureKind::Thicken { .. } => "Thicken",
|
||||
FeatureKind::SurfaceTrim { .. } => "Trim",
|
||||
@@ -1442,7 +1457,9 @@ impl FeatureKind {
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. } => vec![(*sk, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. } | FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. } => {
|
||||
vec![(*sk, profiles)]
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
@@ -1456,7 +1473,9 @@ impl FeatureKind {
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } | FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. } => {
|
||||
vec![(*sketch, profiles)]
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, §ion.profiles)).collect()
|
||||
}
|
||||
@@ -1504,7 +1523,7 @@ impl FeatureKind {
|
||||
| FeatureKind::ConstructionPlane { plane }
|
||||
| FeatureKind::Split { plane, .. }
|
||||
| FeatureKind::SplitFace { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
|
||||
v.push(&extent.distance);
|
||||
if let Some(d) = &extent.distance2 {
|
||||
v.push(d);
|
||||
|
||||
+14
-7
@@ -2339,12 +2339,7 @@ fn feature_tools_with_constructor(
|
||||
};
|
||||
// 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),
|
||||
(None, crate::Direction::Symmetric) => (-d, d),
|
||||
};
|
||||
let (a, b) = extent_sides(vals, extent, d, direction)?;
|
||||
// A negative distance flips the side.
|
||||
let (mut lo, mut hi) = if a <= b { (a, b) } else { (b, a) };
|
||||
// To a curved (or slanted) face: run right through the target body, then keep what
|
||||
@@ -2892,6 +2887,17 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Where an extrude of distance `d` starts and ends along the sketch normal, before any start
|
||||
/// offset: one side, the other, both (symmetric), or a second distance on the other side.
|
||||
pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Extent, d: f64, direction: crate::Direction) -> Result<(f64, f64)> {
|
||||
Ok(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),
|
||||
(None, crate::Direction::Symmetric) => (-d, d),
|
||||
})
|
||||
}
|
||||
|
||||
/// Operation and targets of a feature that makes tools.
|
||||
fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
|
||||
match &f.kind {
|
||||
@@ -3509,7 +3515,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::Stitch { .. }
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::SurfaceTrim { .. }
|
||||
| FeatureKind::SurfaceExtend { .. } => surface::eval(doc, vals, f, st),
|
||||
| FeatureKind::SurfaceExtend { .. }
|
||||
| FeatureKind::SurfaceExtrude { .. } => surface::eval(doc, vals, f, st),
|
||||
FeatureKind::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Evaluation of the surface features: Patch, Stitch, Thicken and Trim (kernel `surfaces`).
|
||||
//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend and Surface Extrude
|
||||
//! (kernel `surfaces`).
|
||||
|
||||
use super::*;
|
||||
use solidcraft_geom::Seg2;
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
match &f.kind {
|
||||
@@ -84,6 +86,83 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() {
|
||||
return Err(DocError::Invalid("not supported yet: a surface extrude through all, to or from an object, or with a taper".into()));
|
||||
}
|
||||
let d = val(vals, &extent.distance, Kind::Length)?;
|
||||
let off = extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0);
|
||||
let (a, b) = extent_sides(vals, extent, d, extent.direction)?;
|
||||
let mut chains: Vec<Vec<Seg2>> = Vec::new();
|
||||
if let Some(sel) = profiles {
|
||||
for r in solidcraft_sketch::merge_regions(&select_profiles(&ss, sel)?) {
|
||||
chains.extend(std::iter::once(r.outer).chain(r.holes).map(|lp| lp.segs));
|
||||
}
|
||||
}
|
||||
chains.extend(curve_chains(&ss, curves)?);
|
||||
if chains.is_empty() {
|
||||
return Err(DocError::Invalid("pick the profiles or curves to extrude".into()));
|
||||
}
|
||||
let body = kernel::extrude_sheet(&ss.plane, &chains, a + off, b + off)?;
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DocError::Invalid("not a surface feature".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most sketch curves one feature chains.
|
||||
const MAX_CHAIN_CURVES: usize = 1000;
|
||||
|
||||
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
|
||||
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
|
||||
/// starts a chain of its own.
|
||||
fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
if ids.len() > MAX_CHAIN_CURVES {
|
||||
return Err(DocError::Invalid(format!("at most {MAX_CHAIN_CURVES} curves")));
|
||||
}
|
||||
let mut pieces: std::collections::VecDeque<Vec<Seg2>> = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
|
||||
Ok(ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
let reversed = |p: Vec<Seg2>| -> Vec<Seg2> { p.iter().rev().map(Seg2::reversed).collect() };
|
||||
let near = |p: Vec2, q: Vec2| p.dist(q) <= 1e-6;
|
||||
let mut chains = Vec::new();
|
||||
while let Some(mut chain) = pieces.pop_front() {
|
||||
while let (Some(head), Some(tail)) = (chain.first().map(Seg2::start), chain.last().map(Seg2::end)) {
|
||||
if near(head, tail) {
|
||||
break;
|
||||
}
|
||||
let ends = |p: &Vec<Seg2>| p.first().map(Seg2::start).zip(p.last().map(Seg2::end));
|
||||
let Some(i) = pieces.iter().position(|p| ends(p).is_some_and(|(s, e)| near(s, tail) || near(e, tail) || near(s, head) || near(e, head)))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let Some(p) = pieces.remove(i) else { break };
|
||||
let Some((s, e)) = ends(&p) else { continue };
|
||||
if near(s, tail) {
|
||||
chain.extend(p);
|
||||
} else if near(e, tail) {
|
||||
chain.extend(reversed(p));
|
||||
} else if near(e, head) {
|
||||
chain = p.into_iter().chain(chain).collect();
|
||||
} else {
|
||||
chain = reversed(p).into_iter().chain(chain).collect();
|
||||
}
|
||||
}
|
||||
chains.push(chain);
|
||||
}
|
||||
Ok(chains)
|
||||
}
|
||||
@@ -100,6 +100,8 @@
|
||||
//! Under the same version, a sheet metal rule's corner relief shape can also be `tear` (no
|
||||
//! relief: each bend is cut straight from the bends' corner to where its flange's end
|
||||
//! starts); a rule that says `notch`, `round` or `rectangular` builds what it did.
|
||||
//! A `SurfaceExtrude` feature (Surface Extrude: `sketch`, optional `profiles`, `curves` and
|
||||
//! `extent`, walls only) is first saved in 17; 0.8.0 would refuse the unknown feature.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
},
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
|
||||
v.push(("Distance", &mut extent.distance, L));
|
||||
if let Some(d) = &mut extent.distance2 {
|
||||
v.push(("Distance 2", d, L));
|
||||
|
||||
@@ -504,6 +504,26 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None),
|
||||
};
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: match face_profile {
|
||||
Some(f) => f,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent,
|
||||
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)?,
|
||||
},
|
||||
targets: string_list(p, "targets"),
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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 to_object = p.get("to").is_some();
|
||||
if to_object && p.get("to").and_then(vec3).is_none() {
|
||||
@@ -532,30 +552,17 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(d) = &start_offset {
|
||||
check_expr(s, d, Kind::Length, cmd, "start_offset")?;
|
||||
}
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: match face_profile {
|
||||
Some(f) => f,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
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)?,
|
||||
},
|
||||
targets: string_list(p, "targets"),
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
Ok(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),
|
||||
})
|
||||
}
|
||||
|
||||
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::Value;
|
||||
use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, check_expr, feature_sketch, operation, plane_param, profiles};
|
||||
use super::features::{add_feature, check_expr, extent_param, feature_sketch, operation, plane_param, profiles};
|
||||
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -30,10 +30,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("trim")
|
||||
.params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"),
|
||||
planned!("surface.extrude", "Surface Extrude", "757")
|
||||
CommandSpec::new("surface.extrude", "Surface Extrude", surface_extrude)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_extrude")
|
||||
.params("planned: the parameters of solid.extrude, with open curve chains as well as profiles (walls only, no caps); caps?: bool (close a closed profile)"),
|
||||
.params("sketch?: id|name; curves?: [curve ids] (open or closed chains of curves meeting end to end) and/or profiles? (their boundaries; the default when no curves are given); distance: expr; direction?: positive|negative|symmetric; distance2?: expr; start_offset?: expr; name?, body_name? (a surface body: walls only, no caps)"),
|
||||
planned!("surface.revolve", "Surface Revolve", "757")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_revolve")
|
||||
@@ -95,6 +95,21 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new() })
|
||||
}
|
||||
|
||||
fn surface_extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.extrude";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let curves = string_list(p, "curves");
|
||||
if curves.len() > 1000 {
|
||||
return Err(bad(cmd, "at most 1000 curves"));
|
||||
}
|
||||
let profiles = if curves.is_empty() || p.get("profiles").is_some() { Some(profiles(p, cmd)?) } else { None };
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() {
|
||||
return Err(bad(cmd, "not supported yet: through all, to or from an object, or a taper"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent })
|
||||
}
|
||||
|
||||
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() {
|
||||
@@ -177,13 +192,75 @@ mod tests {
|
||||
solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
|
||||
}
|
||||
|
||||
fn area(s: &Session, name: &str) -> f64 {
|
||||
solidcraft_kernel::measure(&s.world_state().body(name).unwrap().body).unwrap().area
|
||||
}
|
||||
|
||||
/// Surface Extrude of open curves: a line and an arc joined end to end (listed in either
|
||||
/// order) make one sheet of two walls; both sides of the sketch; undo, redo and reopening
|
||||
/// give the same sheet.
|
||||
#[test]
|
||||
fn surface_extrude_open_curves() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "ids": ["l1"]}));
|
||||
let arc = run(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [20, 0], "through": [15, 5]}))["created"]["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.extrude", json!({"sketch": "S", "curves": [arc, "l1"], "distance": 4, "body_name": "Wall"}));
|
||||
let st = s.world_state();
|
||||
let wall = &st.body("Wall").unwrap().body;
|
||||
assert!(wall.is_surface() && wall.face_count() == 2);
|
||||
// The line's wall and the half circle's (radius 5): length × height.
|
||||
let want = (10.0 + std::f64::consts::PI * 5.0) * 4.0;
|
||||
assert!((area(&s, "Wall") - want).abs() < want * 1e-3, "{} vs {want}", area(&s, "Wall"));
|
||||
run(&mut s, "surface.extrude", json!({"sketch": "S", "curves": ["l1"], "distance": 2, "direction": "symmetric", "body_name": "Both"}));
|
||||
let bb = solidcraft_kernel::measure(&s.world_state().body("Both").unwrap().body).unwrap().bbox;
|
||||
assert!((bb.min.z + 2.0).abs() < 1e-9 && (bb.max.z - 2.0).abs() < 1e-9, "{bb:?}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.world_state().body("Both").is_none());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.world_state().body("Both").is_some());
|
||||
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((area(&cold, "Wall") - area(&s, "Wall")).abs() < 1e-9);
|
||||
// Refused: no such curve, a zero distance, and an extent a sheet does not take yet.
|
||||
assert!(s.execute("surface.extrude", &json!({"sketch": "S", "curves": ["nope"], "distance": 4})).is_err());
|
||||
assert!(s.execute("surface.extrude", &json!({"sketch": "S", "curves": ["l1"], "distance": 0})).is_err());
|
||||
assert!(s.execute("surface.extrude", &json!({"sketch": "S", "curves": ["l1"], "distance": 4, "through_all": true})).is_err());
|
||||
}
|
||||
|
||||
/// Surface Extrude of a profile gives its walls alone; patching both ends and stitching
|
||||
/// makes the solid that solid.extrude makes of the same profile.
|
||||
#[test]
|
||||
fn surface_extrude_profile_walls_stitch_into_the_solid() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 6]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.extrude", json!({"sketch": "R", "distance": 3, "body_name": "Walls"}));
|
||||
let st = s.world_state();
|
||||
let walls = &st.body("Walls").unwrap().body;
|
||||
assert!(walls.is_surface() && walls.face_count() == 4);
|
||||
assert!((area(&s, "Walls") - 96.0).abs() < 1e-9);
|
||||
let edges = |z: f64| json!([[5, 0, z], [10, 3, z], [5, 6, z], [0, 3, z]]);
|
||||
run(&mut s, "surface.patch", json!({"edges": edges(0.0), "body": "Walls", "body_name": "Bottom"}));
|
||||
run(&mut s, "surface.patch", json!({"edges": edges(3.0), "body": "Walls", "body_name": "Top"}));
|
||||
run(&mut s, "surface.stitch", json!({"bodies": ["Walls", "Bottom", "Top"]}));
|
||||
let st = s.world_state();
|
||||
assert!(!st.body("Walls").unwrap().body.is_surface());
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "R", "distance": 3, "body_name": "Solid"}));
|
||||
assert!((volume(&s, "Walls") - volume(&s, "Solid")).abs() < 1e-9);
|
||||
assert!((volume(&s, "Walls") - 180.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// The planned surface commands are registered but refuse to run: disabled with the
|
||||
/// issue that tracks them, an error (never a panic) from every caller, and the design left
|
||||
/// as it was.
|
||||
#[test]
|
||||
fn planned_commands_refuse_and_leave_the_design_alone() {
|
||||
let planned = [
|
||||
"surface.extrude",
|
||||
"surface.revolve",
|
||||
"surface.sweep",
|
||||
"surface.loft",
|
||||
|
||||
+59
-23
@@ -31,11 +31,41 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
|
||||
if !lp.is_closed(1e-6) {
|
||||
return Err(KernelError::Invalid("profile loop is not closed".into()));
|
||||
}
|
||||
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||||
chain_sources(plane, &lp.segs, true)
|
||||
}
|
||||
|
||||
/// An open wire in 3D through a chain of sketch segments, each starting where the one before
|
||||
/// ends (a closed chain makes a closed wire).
|
||||
pub(crate) fn chain_wire(plane: &Plane, segs: &[Seg2]) -> Result<mt::Wire> {
|
||||
let n = segs.len();
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid(format!("curve chain with {n} segments")));
|
||||
}
|
||||
if !segs.iter().all(seg_ok) {
|
||||
return Err(KernelError::Invalid("non-finite curve".into()));
|
||||
}
|
||||
for (s, t) in segs.iter().zip(segs.iter().skip(1)) {
|
||||
if s.end().dist(t.start()) > 1e-6 {
|
||||
return Err(KernelError::Invalid("the curves are not connected end to end".into()));
|
||||
}
|
||||
}
|
||||
let closed = segs.first().zip(segs.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= 1e-6);
|
||||
chain_sources(plane, segs, closed).map(|(wire, _)| wire)
|
||||
}
|
||||
|
||||
/// The edges of a chain of segments (closed: the last one ends on the first's start vertex),
|
||||
/// with each edge's segment and that segment's position in the chain (a big arc makes two
|
||||
/// edges).
|
||||
fn chain_sources(plane: &Plane, segs: &[Seg2], closed: bool) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2, usize)>)> {
|
||||
let n = segs.len();
|
||||
let mut verts: Vec<mt::Vertex> = segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||||
if !closed && let Some(last) = segs.last() {
|
||||
verts.push(builder::vertex(p3(plane.to_world(last.end()))));
|
||||
}
|
||||
let mut edges = Vec::with_capacity(n);
|
||||
let mut sources = Vec::with_capacity(n);
|
||||
for (i, s) in lp.segs.iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue };
|
||||
for (i, s) in segs.iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get(if closed { (i + 1) % n } else { i + 1 })) else { continue };
|
||||
let e: mt::Edge = match *s {
|
||||
Seg2::Line { .. } => builder::line(a, b),
|
||||
Seg2::Arc { sweep, .. } => {
|
||||
@@ -128,32 +158,38 @@ fn region_ok(r: &Region2) -> Result<()> {
|
||||
if r.area().abs() < 1e-9 {
|
||||
return Err(KernelError::Invalid("profile has no area".into()));
|
||||
}
|
||||
for s in std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs) {
|
||||
let ok = match *s {
|
||||
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => p0.is_finite() && p1.is_finite() && p2.is_finite() && p3.is_finite(),
|
||||
Seg2::Conic { a, apex, b, w } => a.is_finite() && apex.is_finite() && b.is_finite() && w.is_finite() && w > 1e-9,
|
||||
Seg2::Bezier { n, p } => (4..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) && p.iter().all(|q| q.is_finite()),
|
||||
};
|
||||
if !ok {
|
||||
return Err(KernelError::Invalid("non-finite profile".into()));
|
||||
}
|
||||
if !std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).all(seg_ok) {
|
||||
return Err(KernelError::Invalid("non-finite profile".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A segment with finite, well-formed numbers.
|
||||
fn seg_ok(s: &Seg2) -> bool {
|
||||
match *s {
|
||||
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => p0.is_finite() && p1.is_finite() && p2.is_finite() && p3.is_finite(),
|
||||
Seg2::Conic { a, apex, b, w } => a.is_finite() && apex.is_finite() && b.is_finite() && w.is_finite() && w > 1e-9,
|
||||
Seg2::Bezier { n, p } => (4..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) && p.iter().all(|q| q.is_finite()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A finite, orthonormal sketch frame.
|
||||
pub(crate) fn frame_ok(plane: &Plane) -> bool {
|
||||
plane.origin.is_finite()
|
||||
&& plane.x.is_finite()
|
||||
&& plane.y.is_finite()
|
||||
&& (plane.x.len2() - 1.).abs() <= 1e-8
|
||||
&& (plane.y.len2() - 1.).abs() <= 1e-8
|
||||
&& plane.x.dot(plane.y).abs() <= 1e-8
|
||||
}
|
||||
|
||||
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
|
||||
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
||||
if !plane.origin.is_finite()
|
||||
|| !plane.x.is_finite()
|
||||
|| !plane.y.is_finite()
|
||||
|| (plane.x.len2() - 1.).abs() > 1e-8
|
||||
|| (plane.y.len2() - 1.).abs() > 1e-8
|
||||
|| plane.x.dot(plane.y).abs() > 1e-8
|
||||
{
|
||||
if !frame_ok(plane) {
|
||||
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
|
||||
}
|
||||
let (start, end) = (finite(start, "start")?, finite(end, "end")?);
|
||||
|
||||
@@ -109,7 +109,7 @@ pub use step::step_export;
|
||||
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate};
|
||||
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
|
||||
pub use surface_kind::{FaceSurface, face_surface};
|
||||
pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicken, trim};
|
||||
pub use surfaces::{copy_faces, extend, extrude_sheet, patch_edges, patch_region, stitch, thicken, trim};
|
||||
pub use sweep_path::sweep_path;
|
||||
pub use topo::{CylinderFace, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
//! filling a closed loop of edges — planar, or B-spline patches for a loop that is not — or a
|
||||
//! planar sketch region), Stitch (surfaces sharing edges sewn
|
||||
//! into one body, a solid when they close), Trim (a surface cut by a tool, one side kept),
|
||||
//! Thicken (a surface offset along its normals into a solid) and copying faces out of a body.
|
||||
//! Thicken (a surface offset along its normals into a solid), copying faces out of a body, and
|
||||
//! sheets swept from sketch curves (surface extrude).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface3D, SearchNearestParameter};
|
||||
use solidcraft_geom::{Plane, Region2, Vec3};
|
||||
use solidcraft_geom::{Plane, Region2, Seg2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
@@ -63,6 +64,42 @@ pub fn patch_region(plane: &Plane, region: &Region2) -> Result<Body> {
|
||||
Ok(rebuilt_body(surface_body(vec![f]), &[], &rec))
|
||||
}
|
||||
|
||||
/// Most curve chains one surface extrude takes.
|
||||
const MAX_CHAINS: usize = 10_000;
|
||||
|
||||
/// A surface extruded from chains of sketch segments on `plane`: each chain (open, or closed
|
||||
/// such as a profile's boundary) moved straight from `start` to `end` along the plane normal
|
||||
/// (offsets in mm). The walls it sweeps form one surface body with no caps. Its history has no
|
||||
/// operands: every face is new.
|
||||
pub fn extrude_sheet(plane: &Plane, chains: &[Vec<Seg2>], start: f64, end: f64) -> Result<Body> {
|
||||
if !crate::build::frame_ok(plane) {
|
||||
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
|
||||
}
|
||||
if !(start.is_finite() && end.is_finite() && start.abs() < 1e7 && end.abs() < 1e7) {
|
||||
return Err(KernelError::Invalid("extrude distance is not finite".into()));
|
||||
}
|
||||
let (lo, hi) = if end >= start { (start, end) } else { (end, start) };
|
||||
if hi - lo < 1e-6 {
|
||||
return Err(KernelError::Invalid("extrude distance is zero".into()));
|
||||
}
|
||||
if chains.is_empty() || chains.len() > MAX_CHAINS {
|
||||
return Err(KernelError::Invalid("pick the curves or profiles to extrude".into()));
|
||||
}
|
||||
let base = plane.offset(lo);
|
||||
let along = crate::body::v3(plane.normal() * (hi - lo));
|
||||
let mut rec = Rebuild::default();
|
||||
let mut faces = Vec::new();
|
||||
for chain in chains {
|
||||
let wire = crate::build::chain_wire(&base, chain)?;
|
||||
let walls: mt::Shell = guard("surface extrude", || Ok(mt::builder::tsweep(&wire, along)))?;
|
||||
for f in walls.face_iter() {
|
||||
rec.made(f, Vec::new());
|
||||
faces.push(f.clone());
|
||||
}
|
||||
}
|
||||
Ok(rebuilt_body(surface_body(faces), &[], &rec))
|
||||
}
|
||||
|
||||
/// The edge of a body nearest a point (by samples along each edge).
|
||||
pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option<mt::Edge> {
|
||||
let mut best: Option<(f64, mt::Edge)> = None;
|
||||
@@ -630,6 +667,49 @@ mod tests {
|
||||
(a - b).abs() / b.abs().max(1e-9)
|
||||
}
|
||||
|
||||
/// Walls swept from an open line, an open arc and a closed square: areas are length × height,
|
||||
/// no caps, and stitching two patches onto the square's walls closes the box.
|
||||
#[test]
|
||||
fn extrude_sheet_of_open_and_closed_chains() {
|
||||
let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }];
|
||||
let s = extrude_sheet(&Plane::XY, std::slice::from_ref(&line), 0.0, 5.0).unwrap();
|
||||
assert!(s.is_surface() && s.face_count() == 1);
|
||||
assert!(rel(measure(&s).unwrap().area, 50.0) < 1e-9);
|
||||
// A quarter arc of radius 4, swept from -1 to 2: area r θ h.
|
||||
let arc = vec![Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 4.0, start: 0.0, sweep: std::f64::consts::FRAC_PI_2 }];
|
||||
let a = measure(&extrude_sheet(&Plane::XY, &[arc], 2.0, -1.0).unwrap()).unwrap().area;
|
||||
let want = 4.0 * std::f64::consts::FRAC_PI_2 * 3.0;
|
||||
assert!(rel(a, want) < 1e-3, "{a} vs {want}"); // (measured on meshes)
|
||||
// A line then a line back up: one chain, two faces.
|
||||
let bent = vec![line[0], Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 6.0) }];
|
||||
assert_eq!(extrude_sheet(&Plane::XY, &[bent], 0.0, 1.0).unwrap().face_count(), 2);
|
||||
// A closed square: four walls, open at both ends; with a patch on each end, a solid box.
|
||||
let sq = Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let walls = extrude_sheet(&Plane::XY, std::slice::from_ref(&sq.outer.segs), 0.0, 4.0).unwrap();
|
||||
assert!(walls.is_surface() && walls.face_count() == 4);
|
||||
assert!(rel(measure(&walls).unwrap().area, 160.0) < 1e-9);
|
||||
let (bottom, top) = (patch_region(&Plane::XY, &sq).unwrap(), patch_region(&Plane::XY.offset(4.0), &sq).unwrap());
|
||||
let closed = stitch(&[&walls, &bottom, &top], 1e-6).unwrap();
|
||||
assert!(!closed.is_surface());
|
||||
assert!(rel(measure(&closed).unwrap().volume, 400.0) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrude_sheet_refuses_bad_input() {
|
||||
let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }];
|
||||
assert!(extrude_sheet(&Plane::XY, std::slice::from_ref(&line), 0.0, 0.0).is_err());
|
||||
assert!(extrude_sheet(&Plane::XY, std::slice::from_ref(&line), 0.0, f64::NAN).is_err());
|
||||
assert!(extrude_sheet(&Plane::XY, &[], 0.0, 1.0).is_err());
|
||||
assert!(extrude_sheet(&Plane::XY, &[vec![]], 0.0, 1.0).is_err());
|
||||
let gap = vec![line[0], Seg2::Line { a: Vec2::new(11.0, 0.0), b: Vec2::new(12.0, 0.0) }];
|
||||
assert!(extrude_sheet(&Plane::XY, &[gap], 0.0, 1.0).is_err());
|
||||
let nan = vec![Seg2::Line { a: Vec2::new(0.0, f64::NAN), b: Vec2::new(1.0, 0.0) }];
|
||||
assert!(extrude_sheet(&Plane::XY, &[nan], 0.0, 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_and_thicken_a_square() {
|
||||
let r = Region2 {
|
||||
|
||||
@@ -3610,7 +3610,7 @@ fn source_features(s: &Session, bodies: &[String]) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// The sketch holding all these curve ids (the active sketch first, never `not`).
|
||||
fn curves_sketch(app: &SolveApp, ids: &[String], not: Option<u64>) -> Option<u64> {
|
||||
pub(crate) fn curves_sketch(app: &SolveApp, ids: &[String], not: Option<u64>) -> Option<u64> {
|
||||
let st = app.session.world_state();
|
||||
let has =
|
||||
|sid: u64| st.sketch(sid).is_some_and(|ss| ids.iter().all(|id| ss.sketch.curve_index(id).is_some() || ss.sketch.wire_index(id).is_some()));
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
//! Dialogs for the surface tools and face splitting (#19): Patch, Stitch, Thicken, Extend,
|
||||
//! Trim and Split Face. Each has a live preview and Edit Feature.
|
||||
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Patch, Stitch,
|
||||
//! Thicken, Extend, Trim and Split Face. Each has a live preview and Edit Feature.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solidcraft_engine::doc::{FaceAt, FeatureKind, PlaneRef};
|
||||
use solidcraft_engine::doc::{Direction, FaceAt, FeatureKind, PlaneRef, ProfileSel};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{Kind, OP_LABELS, OPS, combo, field, op_index, row_label};
|
||||
use crate::selection::{BODIES, EDGES, FACES, PLANES, PROFILES, SelInput};
|
||||
use crate::selection::{BODIES, CURVES, EDGES, FACES, PLANES, PROFILES, SelInput};
|
||||
|
||||
/// A surface tool dialog's values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Surf {
|
||||
/// `direction`: an index into [`EXTRUDE_SIDES`].
|
||||
Extrude {
|
||||
distance: String,
|
||||
direction: usize,
|
||||
},
|
||||
Patch,
|
||||
Stitch { tolerance: String },
|
||||
ConvertMesh { tolerance: String },
|
||||
Thicken { thickness: String, symmetric: bool, operation: usize },
|
||||
Extend { distance: String },
|
||||
Stitch {
|
||||
tolerance: String,
|
||||
},
|
||||
ConvertMesh {
|
||||
tolerance: String,
|
||||
},
|
||||
Thicken {
|
||||
thickness: String,
|
||||
symmetric: bool,
|
||||
operation: usize,
|
||||
},
|
||||
Extend {
|
||||
distance: String,
|
||||
},
|
||||
Trim,
|
||||
SplitFace,
|
||||
}
|
||||
@@ -26,6 +41,7 @@ pub enum Surf {
|
||||
impl Surf {
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Surf::Extrude { .. } => "SURFACE EXTRUDE",
|
||||
Surf::Patch => "PATCH",
|
||||
Surf::Stitch { .. } => "STITCH",
|
||||
Surf::ConvertMesh { .. } => "CONVERT MESH",
|
||||
@@ -40,14 +56,18 @@ impl Surf {
|
||||
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
|
||||
Some(match self {
|
||||
Surf::Thicken { thickness, .. } => ("Thickness", ValueKind::Length, thickness),
|
||||
Surf::Extend { distance } => ("Distance", ValueKind::Length, distance),
|
||||
Surf::Extend { distance } | Surf::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The sides a surface extrude goes to (labels, and the `direction` parameter).
|
||||
const EXTRUDE_SIDES: [(&str, &str); 3] = [("One Side", "positive"), ("Symmetric", "symmetric"), ("Flipped", "negative")];
|
||||
|
||||
fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
match k {
|
||||
Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)],
|
||||
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
|
||||
Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
|
||||
Surf::ConvertMesh { .. } => vec![SelInput::new("Mesh body", BODIES, false)],
|
||||
@@ -65,6 +85,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
/// The dialog for a surface command.
|
||||
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let k = match id {
|
||||
"surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 },
|
||||
"surface.patch" => Surf::Patch,
|
||||
"surface.stitch" => Surf::Stitch { tolerance: String::new() },
|
||||
"mesh.convert" => Surf::ConvertMesh { tolerance: "0.05 mm".into() },
|
||||
@@ -107,6 +128,14 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
enter |= field(ui, distance);
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Extrude { distance, direction } => {
|
||||
row_label(ui, "Distance");
|
||||
enter |= field(ui, distance);
|
||||
ui.end_row();
|
||||
row_label(ui, "Direction");
|
||||
combo(ui, "sx_dir", &EXTRUDE_SIDES.map(|(label, _)| label), direction);
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace => {}
|
||||
}
|
||||
enter
|
||||
@@ -141,6 +170,32 @@ fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), Stri
|
||||
pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
|
||||
let need = |i: usize, what: &str| if items(inputs, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) };
|
||||
let (cmd, mut p) = match k {
|
||||
Surf::Extrude { distance, direction } => {
|
||||
need(0, "profiles or curves to extrude")?;
|
||||
let picks = items(inputs, 0);
|
||||
let profiles: Vec<(u64, usize)> =
|
||||
picks.iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect();
|
||||
let curves: Vec<String> = picks.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
|
||||
let sketch = match profiles.first() {
|
||||
Some((sk, _)) => *sk,
|
||||
None => crate::dialogs::curves_sketch(app, &curves, None).ok_or("the curves must be in one sketch")?,
|
||||
};
|
||||
if profiles.iter().any(|(sk, _)| *sk != sketch) {
|
||||
return Err("the profiles and curves must be in one sketch".into());
|
||||
}
|
||||
let mut p = json!({
|
||||
"sketch": sketch,
|
||||
"distance": distance,
|
||||
"direction": EXTRUDE_SIDES.get(*direction).map_or("positive", |(_, d)| *d),
|
||||
});
|
||||
if !curves.is_empty() {
|
||||
p["curves"] = json!(curves);
|
||||
}
|
||||
if !profiles.is_empty() {
|
||||
p["profiles"] = json!(profiles.iter().map(|(_, i)| *i).collect::<Vec<_>>());
|
||||
}
|
||||
("surface.extrude", p)
|
||||
}
|
||||
Surf::Patch => {
|
||||
need(0, "a boundary (sketch profiles or a loop of edges)")?;
|
||||
let profiles: Vec<(u64, usize)> =
|
||||
@@ -230,6 +285,26 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelI
|
||||
let s = &app.session;
|
||||
let edge = |p: &Vec3| crate::dialogs::edge_sel(s, *p);
|
||||
let (k, fill): (Surf, Vec<Vec<Sel>>) = match kind {
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let mut sels: Vec<Sel> = Vec::new();
|
||||
let st = s.world_state();
|
||||
if let (Some(sel), Some(ss)) = (profiles, st.sketch(*sketch)) {
|
||||
let all = 0..ss.profiles.len();
|
||||
match sel {
|
||||
ProfileSel::Indices { indices } => {
|
||||
sels.extend(indices.iter().filter(|i| all.contains(i)).map(|index| Sel::Profile { sketch: *sketch, index: *index }))
|
||||
}
|
||||
_ => sels.extend(all.map(|index| Sel::Profile { sketch: *sketch, index })),
|
||||
}
|
||||
}
|
||||
sels.extend(curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }));
|
||||
let direction = match extent.direction {
|
||||
Direction::Positive => 0,
|
||||
Direction::Symmetric => 1,
|
||||
Direction::Negative => 2,
|
||||
};
|
||||
(Surf::Extrude { distance: extent.distance.clone(), direction }, vec![sels])
|
||||
}
|
||||
FeatureKind::Patch { sketch, edges, .. } => {
|
||||
let mut sels: Vec<Sel> = edges.iter().filter_map(edge).collect();
|
||||
if let Some(sk) = sketch {
|
||||
|
||||
@@ -800,6 +800,35 @@ fn surface_dialogs_make_their_commands() {
|
||||
assert!((v - 30.0 * 20.0 * 2.0).abs() < 1.0, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_extrude_dialog_takes_profiles_and_curves() {
|
||||
// A rectangle and a free line in one sketch: the dialog sends both (the profile's walls
|
||||
// and the line's wall) as one surface extrude, and Edit Feature brings the picks back.
|
||||
let mut app = app_with(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [30, 20]}},
|
||||
{"command": "sketch.line", "params": {"points": [[40, 0], [50, 0]], "ids": ["free"]}},
|
||||
{"command": "sketch.finish", "params": {}}
|
||||
]));
|
||||
let sk = app.session.doc.features.last().unwrap().id;
|
||||
let mut d = start(&mut app, vec![], "surface.extrude");
|
||||
d.inputs[0].items = vec![Sel::Profile { sketch: sk, index: 0 }, Sel::SketchCurve { id: "free".into() }];
|
||||
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
|
||||
assert_eq!(id, "surface.extrude");
|
||||
assert_eq!(p["curves"], json!(["free"]), "{p}");
|
||||
assert_eq!(p["profiles"], json!([0]), "{p}");
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
let st = app.session.model.state();
|
||||
let sheet = st.bodies.last().unwrap();
|
||||
assert!(sheet.body.is_surface() && sheet.body.face_count() == 5);
|
||||
let area = sheet.mesh().measure().area;
|
||||
assert!((area - (100.0 + 10.0) * 10.0).abs() < 1e-6, "{area}");
|
||||
let fid = app.session.doc.features.last().unwrap().id;
|
||||
let back = crate::dialogs::for_feature(&app, fid, None).unwrap();
|
||||
assert!(matches!(back.kind, Kind::Surface(_)));
|
||||
assert_eq!(back.inputs[0].items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_dialog_fills_a_loop_of_edges_that_is_not_planar() {
|
||||
// A box with its top cut away by a sideways cylinder: its rim is a closed loop of four
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
match k {
|
||||
FeatureKind::Sketch { .. } => "sketch",
|
||||
FeatureKind::Extrude { .. } => "extrude",
|
||||
FeatureKind::SurfaceExtrude { .. } => "surface_extrude",
|
||||
FeatureKind::Revolve { .. } => "revolve",
|
||||
FeatureKind::Fillet { .. } => "fillet",
|
||||
FeatureKind::Chamfer { .. } => "chamfer",
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Features
|
||||
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **335 commands.** Planned (registered, not built yet): 13. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **336 commands.** Planned (registered, not built yet): 12. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
|
||||
## Sketch (98)
|
||||
|
||||
@@ -145,7 +145,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Align | `solid.align` | SOLID › MODIFY |
|
||||
| Remove | `solid.remove` | SOLID › MODIFY |
|
||||
|
||||
## Surfaces (5, 12 planned)
|
||||
## Surfaces (6, 11 planned)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
@@ -154,7 +154,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Thicken | `surface.thicken` | SURFACE › CREATE |
|
||||
| Extend | `surface.extend` | SURFACE › MODIFY |
|
||||
| Trim | `surface.trim` | SURFACE › MODIFY |
|
||||
| Surface Extrude *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.extrude` | SURFACE › CREATE |
|
||||
| Surface Extrude | `surface.extrude` | SURFACE › CREATE |
|
||||
| Surface Revolve *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.revolve` | SURFACE › CREATE |
|
||||
| Surface Sweep *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.sweep` | SURFACE › CREATE |
|
||||
| Surface Loft *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.loft` | SURFACE › CREATE |
|
||||
|
||||
Reference in new issue
Block a user