Surface Loft: sheets through open or closed sections (#847)
Surface Loft lofts open or closed curve chains, one section per sketch in pick order, into surface bodies. Adds the SurfaceLoft feature (format 18 note), the surface.loft command, a dialog with Edit Feature support, and kernel and engine tests. Part of #757.
This commit is contained in:
1 parent
7c85970bfc
commit
897b7d93b5
15 files changed
+363
-53
No files matched your search
@@ -325,6 +325,7 @@ impl FeatureKind {
|
||||
| FeatureKind::SurfaceExtrude { .. }
|
||||
| FeatureKind::SurfaceRevolve { .. }
|
||||
| FeatureKind::SurfaceSweep { .. }
|
||||
| FeatureKind::SurfaceLoft { .. }
|
||||
| FeatureKind::Combine { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
|
||||
@@ -62,6 +62,7 @@ impl FeatureKind {
|
||||
FeatureKind::Pipe { path_sketch, .. } => sk(path_sketch),
|
||||
FeatureKind::SheetFold { sketch: Some(s), .. } => sk(s),
|
||||
FeatureKind::Loft { sections, .. } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)),
|
||||
FeatureKind::SurfaceLoft { sections } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)),
|
||||
FeatureKind::Hole { points: Some(p), .. } => sk(&mut p.sketch),
|
||||
FeatureKind::Pattern { features, pattern, .. } => {
|
||||
feats(features);
|
||||
|
||||
@@ -982,6 +982,11 @@ pub enum FeatureKind {
|
||||
path_sketch: u64,
|
||||
path: Vec<String>,
|
||||
},
|
||||
/// A surface body lofted through sections in order, each one open or closed chain of
|
||||
/// sketch curves: walls only, no caps.
|
||||
SurfaceLoft {
|
||||
sections: Vec<SheetSection>,
|
||||
},
|
||||
/// A planar surface body extended outward by `distance` past the edges through `edges`.
|
||||
SurfaceExtend {
|
||||
body: String,
|
||||
@@ -1136,6 +1141,17 @@ pub struct LoftSection {
|
||||
pub point: Option<String>,
|
||||
}
|
||||
|
||||
/// A section of a surface loft: one chain from a sketch (one profile's boundary, or curves
|
||||
/// meeting end to end).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SheetSection {
|
||||
pub sketch: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profiles: Option<ProfileSel>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub curves: Vec<String>,
|
||||
}
|
||||
|
||||
/// Coil cross-sections.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -1292,6 +1308,7 @@ impl FeatureKind {
|
||||
K::Sweep { sketch, path_sketch, .. } | K::SurfaceSweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
|
||||
K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch],
|
||||
K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(),
|
||||
K::SurfaceLoft { sections } => sections.iter().map(|s| s.sketch).collect(),
|
||||
K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
|
||||
K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(),
|
||||
K::Sketch { .. }
|
||||
@@ -1420,6 +1437,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature",
|
||||
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolveFeature",
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
FeatureKind::Thicken { .. } => "ThickenFeature",
|
||||
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
|
||||
@@ -1491,6 +1509,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude",
|
||||
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolve",
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
FeatureKind::Thicken { .. } => "Thicken",
|
||||
FeatureKind::SurfaceTrim { .. } => "Trim",
|
||||
@@ -1520,6 +1539,7 @@ impl FeatureKind {
|
||||
vec![(*sk, profiles)]
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
|
||||
FeatureKind::SurfaceLoft { sections } => sections.iter_mut().filter_map(|s| Some((s.sketch, s.profiles.as_mut()?))).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1541,6 +1561,7 @@ impl FeatureKind {
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, §ion.profiles)).collect()
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections } => sections.iter().filter_map(|s| Some((s.sketch, s.profiles.as_ref()?))).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1648,7 +1669,7 @@ impl FeatureKind {
|
||||
},
|
||||
FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::Shell { thickness, .. } => v.push(thickness),
|
||||
FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::SurfaceSweep { .. } => {}
|
||||
FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::SurfaceSweep { .. } | FeatureKind::SurfaceLoft { .. } => {}
|
||||
FeatureKind::Draft { angle, neutral, .. } => {
|
||||
v.push(angle);
|
||||
plane_exprs(neutral, &mut v);
|
||||
|
||||
@@ -3637,7 +3637,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::Unstitch { .. }
|
||||
| FeatureKind::ReverseNormal { .. }
|
||||
| FeatureKind::SurfaceRevolve { .. }
|
||||
| FeatureKind::SurfaceSweep { .. } => surface::eval(doc, vals, f, st),
|
||||
| FeatureKind::SurfaceSweep { .. }
|
||||
| FeatureKind::SurfaceLoft { .. } => surface::eval(doc, vals, f, st),
|
||||
FeatureKind::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend, Surface Extrude,
|
||||
//! Revolve and Sweep, Unstitch and Reverse Normal (kernel `surfaces`).
|
||||
//! Revolve, Sweep and Loft, Unstitch and Reverse Normal (kernel `surfaces`).
|
||||
|
||||
use super::*;
|
||||
use solidcraft_geom::Seg2;
|
||||
@@ -181,6 +181,32 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections } => {
|
||||
let mut secs = Vec::with_capacity(sections.len());
|
||||
for sec in sections {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(sec.sketch)
|
||||
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?,
|
||||
f.component,
|
||||
)?;
|
||||
let profiles = match (&sec.profiles, sec.curves.is_empty()) {
|
||||
(None, true) => Some(&ProfileSel::All),
|
||||
(p, _) => p.as_ref(),
|
||||
};
|
||||
let mut chains = sheet_chains(&ss, profiles, &sec.curves)?.into_iter();
|
||||
let (Some(chain), None) = (chains.next(), chains.next()) else {
|
||||
return Err(DocError::Invalid(
|
||||
"each loft section must be one chain: one profile without holes, or curves meeting end to end".into(),
|
||||
));
|
||||
};
|
||||
secs.push((ss.plane, chain));
|
||||
}
|
||||
let body = kernel::loft_sheet(&secs)?;
|
||||
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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
//! `axis`, `angle`, optional `angle2` and `symmetric`) is also first saved in 18.
|
||||
//! A `SurfaceSweep` feature (Surface Sweep: `sketch`, optional `profiles` and `curves`,
|
||||
//! `path_sketch`, `path`) is also first saved in 18.
|
||||
//! A `SurfaceLoft` feature (Surface Loft: `sections`, each a `sketch` with optional `profiles`
|
||||
//! and `curves`) is also first saved in 18.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -290,6 +290,7 @@ impl FeatureKind {
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
| FeatureKind::SurfaceSweep { .. }
|
||||
| FeatureKind::SurfaceLoft { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
|
||||
@@ -42,10 +42,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_sweep")
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); name?, body_name? (a surface body, no caps)"),
|
||||
planned!("surface.loft", "Surface Loft", "757")
|
||||
CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_loft")
|
||||
.params("planned: the parameters of solid.loft, with open sections as well as closed ones (no caps); caps?: bool"),
|
||||
.params("sections: [{sketch, curves?: [curve ids], profiles?}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"),
|
||||
planned!("surface.offset", "Offset Surface", "758")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_offset")
|
||||
@@ -167,6 +167,25 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path })
|
||||
}
|
||||
|
||||
fn surface_loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.loft";
|
||||
let given = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must list the sections in order"))?;
|
||||
if !(2..=50).contains(&given.len()) {
|
||||
return Err(bad(cmd, "a loft needs 2…50 sections"));
|
||||
}
|
||||
let mut sections = Vec::with_capacity(given.len());
|
||||
for sec in given {
|
||||
let sketch = sketch_id(s, sec.get("sketch"), cmd, "sections.sketch")?;
|
||||
let curves = string_list(sec, "curves");
|
||||
if curves.len() > 1000 {
|
||||
return Err(bad(cmd, "at most 1000 curves a section"));
|
||||
}
|
||||
let profiles = if sec.get("profiles").is_some() { Some(profiles(sec, cmd)?) } else { None };
|
||||
sections.push(solidcraft_doc::SheetSection { sketch, profiles, curves });
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceLoft { sections })
|
||||
}
|
||||
|
||||
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() {
|
||||
@@ -489,21 +508,49 @@ mod tests {
|
||||
assert!(s.execute("surface.sweep", &json!({"sketch": "S", "curves": ["l"], "path_sketch": "P", "path": ["nope"]})).is_err());
|
||||
}
|
||||
|
||||
/// Surface Loft between sketches 5 apart: two lines give a 10 × 5 strip, two rectangles
|
||||
/// their four walls; undo, redo and reopening; a mix of open and closed sections, one
|
||||
/// section, and a section of two separate curves are refused.
|
||||
#[test]
|
||||
fn surface_loft_open_and_closed_sections() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Mid"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "ids": ["a"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 3], [10, 3]], "ids": ["apart"]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20, 0], "p1": [30, 6]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "B"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "ids": ["b"]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20, 0], "p1": [30, 6]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.loft", json!({"sections": [{"sketch": "A", "curves": ["a"]}, {"sketch": "B", "curves": ["b"]}], "body_name": "Strip"}));
|
||||
assert!(s.world_state().body("Strip").unwrap().body.is_surface());
|
||||
assert!((area(&s, "Strip") - 50.0).abs() < 1e-6, "{}", area(&s, "Strip"));
|
||||
run(&mut s, "surface.loft", json!({"sections": [{"sketch": "A", "profiles": [0]}, {"sketch": "B", "profiles": [0]}], "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") - 160.0).abs() < 1e-6, "{}", area(&s, "Walls"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.world_state().body("Walls").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, "Walls") - area(&s, "Walls")).abs() < 1e-9);
|
||||
assert!(s.execute("surface.loft", &json!({"sections": [{"sketch": "A", "curves": ["a"]}, {"sketch": "B", "profiles": [0]}]})).is_err());
|
||||
assert!(s.execute("surface.loft", &json!({"sections": [{"sketch": "A", "curves": ["a"]}]})).is_err());
|
||||
assert!(
|
||||
s.execute("surface.loft", &json!({"sections": [{"sketch": "A", "curves": ["a", "apart"]}, {"sketch": "B", "curves": ["b"]}]})).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.loft",
|
||||
"surface.offset",
|
||||
"surface.ruled",
|
||||
"surface.boundary",
|
||||
"surface.blend",
|
||||
"surface.untrim",
|
||||
"surface.fillet",
|
||||
"inspect.continuity",
|
||||
];
|
||||
let planned =
|
||||
["surface.offset", "surface.ruled", "surface.boundary", "surface.blend", "surface.untrim", "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();
|
||||
|
||||
+65
-30
@@ -1000,6 +1000,29 @@ pub(crate) fn cap_and_close(sides: Vec<Side>, w0: &[mt::Wire], w1: &[mt::Wire],
|
||||
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
|
||||
/// same sections in reverse order give the same body.
|
||||
pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
let (wires, edge_sources, reversed) = loft_wires(sections)?;
|
||||
// The wires run from the first section given, or (turned round) the last.
|
||||
// Three sections or more: one smooth surface through them all for each segment.
|
||||
if wires.len() >= 3
|
||||
&& let Ok(b) = guard("loft", || {
|
||||
let faces = smooth_loft_faces(&wires, &edge_sources, true)?;
|
||||
let (Some(w0), Some(w1)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("smooth loft: sections".into())) };
|
||||
cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1), reversed)
|
||||
})
|
||||
{
|
||||
return Ok(b);
|
||||
}
|
||||
guard("loft", || {
|
||||
let sides = ruled_loft_faces(&wires, &edge_sources)?;
|
||||
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft".into())) };
|
||||
cap_and_close(sides, std::slice::from_ref(first), std::slice::from_ref(last), reversed)
|
||||
})
|
||||
}
|
||||
|
||||
/// The wires of a loft's closed sections, matched: oriented alike, resampled to the same
|
||||
/// segment count and turned to start alike; with the profile segments under each wire edge, and
|
||||
/// whether the sections were turned round into the canonical order.
|
||||
pub(crate) fn loft_wires(sections: &[(Plane, Loop2)]) -> Result<(Vec<mt::Wire>, Vec<Vec<Vec<usize>>>, bool)> {
|
||||
if sections.len() < 2 || sections.len() > 50 {
|
||||
return Err(KernelError::Invalid("a loft needs 2…50 sections".into()));
|
||||
}
|
||||
@@ -1060,24 +1083,22 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
wires.push(uniform_wire(p, &r.lp)?);
|
||||
edge_sources.push(r.from);
|
||||
}
|
||||
// The wires run from the first section given, or (turned round) the last.
|
||||
// Three sections or more: one smooth surface through them all for each segment.
|
||||
if wires.len() >= 3
|
||||
&& let Ok(b) = guard("loft", || smooth_loft(&wires, &edge_sources, reversed))
|
||||
{
|
||||
return Ok(b);
|
||||
}
|
||||
guard("loft", || {
|
||||
// Each ruled face lies between two sections: it comes from the section edges it spans.
|
||||
Ok((wires, edge_sources, reversed))
|
||||
}
|
||||
|
||||
/// Ruled faces between each two neighbouring section wires; each comes from the section edges
|
||||
/// it spans.
|
||||
pub(crate) fn ruled_loft_faces(wires: &[mt::Wire], edge_sources: &[Vec<Vec<usize>>]) -> Result<Vec<Side>> {
|
||||
let mut sides: Vec<Side> = Vec::new();
|
||||
for (w, src) in wires.windows(2).zip(edge_sources.windows(2)) {
|
||||
let (Some(a), Some(b), Some(na), Some(nb)) = (w.first(), w.get(1), src.first(), src.get(1)) else { continue };
|
||||
for (k, w) in wires.windows(2).enumerate() {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
|
||||
let shell = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("loft: {e}")))?;
|
||||
sides.extend(record::ruled(&shell, a, b, na, nb));
|
||||
match (edge_sources.get(k), edge_sources.get(k + 1)) {
|
||||
(Some(na), Some(nb)) => sides.extend(record::ruled(&shell, a, b, na, nb)),
|
||||
_ => sides.extend(shell.face_iter().map(|f| (f.clone(), None))),
|
||||
}
|
||||
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft".into())) };
|
||||
cap_and_close(sides, std::slice::from_ref(first), std::slice::from_ref(last), reversed)
|
||||
})
|
||||
}
|
||||
Ok(sides)
|
||||
}
|
||||
|
||||
/// Points across the sections (one per section, in order) as a smooth B-spline: cubic through
|
||||
@@ -1097,7 +1118,10 @@ fn across(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Point3>> {
|
||||
/// k-th edges of all sections share one knot vector and their control points pair up. Face k
|
||||
/// comes from the profile segments under the k-th edge of every section (`numbers`: by
|
||||
/// section, by edge); the caps as [`cap_and_close`] takes them (`from_end`).
|
||||
fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec<Vec<usize>>], from_end: bool) -> Result<Body> {
|
||||
/// One smooth face through every section for each wire edge (cubic across four sections or
|
||||
/// more, quadratic across three), neighbours sharing their seams. `closed` wires join their
|
||||
/// last edge to their first; open ones have a seam at each end.
|
||||
pub(crate) fn smooth_loft_faces(wires: &[mt::Wire], numbers: &[Vec<Vec<usize>>], closed: bool) -> Result<Vec<Side>> {
|
||||
let fail = |m: &str| KernelError::Failed(format!("smooth loft: {m}"));
|
||||
let edges: Vec<Vec<mt::Edge>> = wires.iter().map(|w| w.edge_iter().cloned().collect()).collect();
|
||||
let n = edges.first().map(Vec::len).ok_or_else(|| fail("no sections"))?;
|
||||
@@ -1111,13 +1135,17 @@ fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec<Vec<usize>>], from_end: bool)
|
||||
}
|
||||
};
|
||||
let (Some(first), Some(last)) = (edges.first(), edges.last()) else { return Err(fail("sections")) };
|
||||
// Seams: through the k-th vertex of every section.
|
||||
let mut seams: Vec<mt::Edge> = Vec::with_capacity(n);
|
||||
for k in 0..n {
|
||||
let pts: Vec<Vec3> = edges.iter().filter_map(|es| es.get(k).map(|e| from_p3(e.front().point()))).collect();
|
||||
// Seams: through the k-th vertex of every section (an open wire's last one at the end of
|
||||
// its last edge).
|
||||
let vertex = |e: &mt::Edge, end: bool| if end { e.back().clone() } else { e.front().clone() };
|
||||
let seam_count = if closed { n } else { n + 1 };
|
||||
let mut seams: Vec<mt::Edge> = Vec::with_capacity(seam_count);
|
||||
for k in 0..seam_count {
|
||||
let (at, end) = if k < n { (k, false) } else { (n - 1, true) };
|
||||
let pts: Vec<Vec3> = edges.iter().filter_map(|es| es.get(at).map(|e| from_p3(vertex(e, end).point()))).collect();
|
||||
let c = across(&pts).ok_or_else(|| fail("seam"))?;
|
||||
let (Some(a), Some(b)) = (first.get(k), last.get(k)) else { return Err(fail("seam")) };
|
||||
seams.push(mt::Edge::new(a.front(), b.front(), mt::Curve::BSplineCurve(c)));
|
||||
let (Some(a), Some(b)) = (first.get(at), last.get(at)) else { return Err(fail("seam")) };
|
||||
seams.push(mt::Edge::new(&vertex(a, end), &vertex(b, end), mt::Curve::BSplineCurve(c)));
|
||||
}
|
||||
let mut faces = Vec::with_capacity(n);
|
||||
for k in 0..n {
|
||||
@@ -1139,17 +1167,16 @@ fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec<Vec<usize>>], from_end: bool)
|
||||
}
|
||||
let vk = vk.ok_or_else(|| fail("surface"))?;
|
||||
let surface = mt::BSplineSurface::try_new((uk, vk), ctrl).map_err(|e| fail(&e.to_string()))?;
|
||||
let (Some(e0), Some(e1), Some(s0), Some(s1)) = (first.get(k), last.get(k), seams.get(k), seams.get((k + 1) % n)) else {
|
||||
let (Some(e0), Some(e1), Some(s0), Some(s1)) = (first.get(k), last.get(k), seams.get(k), seams.get((k + 1) % seam_count)) else {
|
||||
return Err(fail("face"));
|
||||
};
|
||||
// u along the section edge, v across the sections: counter-clockwise in (u, v).
|
||||
let w: mt::Wire = vec![e0.clone(), s1.clone(), e1.inverse(), s0.inverse()].into();
|
||||
let face = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?;
|
||||
let under: Vec<&usize> = numbers.iter().filter_map(|s| s.get(k)).flatten().collect();
|
||||
faces.push((face, (under.len() >= numbers.len()).then(|| record::profile(under))));
|
||||
faces.push((face, (under.len() >= numbers.len().max(1)).then(|| record::profile(under))));
|
||||
}
|
||||
let (Some(w0), Some(w1)) = (wires.first(), wires.last()) else { return Err(fail("sections")) };
|
||||
cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1), from_end)
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
/// A 3D path segment for sweeps.
|
||||
@@ -1342,12 +1369,20 @@ pub(crate) fn interpolate_cubic(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Poi
|
||||
/// A closed wire whose edges are cubic B-splines parameterised (nearly) by arc length, so that
|
||||
/// ruled surfaces between sections pair points proportionally along each edge.
|
||||
pub(crate) fn uniform_wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
uniform_chain_wire(plane, &lp.segs, true)
|
||||
}
|
||||
|
||||
/// [`uniform_wire`] of a chain of segments, open or `closed`.
|
||||
pub(crate) fn uniform_chain_wire(plane: &Plane, segs: &[Seg2], closed: bool) -> Result<mt::Wire> {
|
||||
const SAMPLES: usize = 24;
|
||||
let n = lp.segs.len();
|
||||
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||||
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);
|
||||
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 pts: Vec<Vec3> = (0..SAMPLES).map(|k| plane.to_world(s.point_at(k as f64 / (SAMPLES - 1) as f64))).collect();
|
||||
let curve = interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("loft curve".into()))?;
|
||||
edges.push(mt::Edge::new(a, b, mt::Curve::BSplineCurve(curve)));
|
||||
|
||||
@@ -117,7 +117,7 @@ pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_s
|
||||
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, extrude_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet,
|
||||
copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet,
|
||||
sweep_sheet_points, thicken, trim, unstitch,
|
||||
};
|
||||
pub use sweep_path::sweep_path;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 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), copying faces out of a body, and
|
||||
//! sheets swept from sketch curves (surface extrude, revolve and sweep).
|
||||
//! sheets swept from sketch curves (surface extrude, revolve, sweep and loft).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -276,6 +276,75 @@ fn far_wire(swept: &mt::Shell, start: &mt::Wire, map: &dyn Fn(Vec3) -> Vec3) ->
|
||||
Ok(out.into())
|
||||
}
|
||||
|
||||
/// A surface lofted through two or more sections, each a chain of sketch segments on its plane,
|
||||
/// all open or all closed: ruled between two sections, one smooth face per segment through
|
||||
/// three or more. Closed sections are matched as the solid loft matches them; open ones are
|
||||
/// split to the same number of segments (longest first) and each turned to run the way the one
|
||||
/// before it does. Walls only, no caps; the history has no operands: every face is new.
|
||||
pub fn loft_sheet(sections: &[(Plane, Vec<Seg2>)]) -> Result<Body> {
|
||||
if sections.len() < 2 || sections.len() > 50 {
|
||||
return Err(KernelError::Invalid("a loft needs 2…50 sections".into()));
|
||||
}
|
||||
let closed: Vec<bool> = sections
|
||||
.iter()
|
||||
.map(|(_, c)| match (c.first(), c.last()) {
|
||||
(Some(f), Some(l)) if c.len() <= 4096 => Ok(l.end().dist(f.start()) <= 1e-6),
|
||||
_ => Err(KernelError::Invalid("a loft section has no curves, or too many".into())),
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let all_closed = closed.iter().all(|c| *c);
|
||||
if !all_closed && closed.iter().any(|c| *c) {
|
||||
return Err(KernelError::Invalid("the loft sections must be all open or all closed".into()));
|
||||
}
|
||||
for (plane, chain) in sections {
|
||||
// Checks the frame, the numbers and that each chain runs end to end.
|
||||
if !crate::build::frame_ok(plane) {
|
||||
return Err(KernelError::Invalid("a loft section's frame is not finite and orthonormal".into()));
|
||||
}
|
||||
crate::build::chain_wire(plane, chain)?;
|
||||
}
|
||||
let (wires, sources) = if all_closed {
|
||||
let loops: Vec<(Plane, solidcraft_geom::Loop2)> = sections.iter().map(|(p, c)| (*p, solidcraft_geom::Loop2 { segs: c.clone() })).collect();
|
||||
let (wires, sources, _) = crate::build::loft_wires(&loops)?;
|
||||
(wires, sources)
|
||||
} else {
|
||||
let n = sections.iter().map(|(_, c)| c.len()).max().unwrap_or(0);
|
||||
let mut wires = Vec::with_capacity(sections.len());
|
||||
let mut prev: Option<(Vec3, Vec3)> = None;
|
||||
for (plane, chain) in sections {
|
||||
let mut segs = chain.clone();
|
||||
// Split the longest segment in half until the counts match.
|
||||
while segs.len() < n {
|
||||
let Some(i) = segs.iter().enumerate().max_by(|a, b| a.1.length().total_cmp(&b.1.length())).map(|(i, _)| i) else { break };
|
||||
let (a, b) = segs[i].split_half();
|
||||
segs.splice(i..=i, [a, b]);
|
||||
}
|
||||
let ends = |c: &[Seg2]| c.first().zip(c.last()).map(|(f, l)| (plane.to_world(f.start()), plane.to_world(l.end())));
|
||||
if let (Some((ps, pe)), Some((s, e))) = (prev, ends(&segs))
|
||||
&& s.dist(ps) + e.dist(pe) > s.dist(pe) + e.dist(ps)
|
||||
{
|
||||
segs = segs.iter().rev().map(Seg2::reversed).collect();
|
||||
}
|
||||
prev = ends(&segs);
|
||||
wires.push(crate::build::uniform_chain_wire(plane, &segs, false)?);
|
||||
}
|
||||
(wires, Vec::new())
|
||||
};
|
||||
let faces = guard("surface loft", || {
|
||||
let smooth = if wires.len() >= 3 { crate::build::smooth_loft_faces(&wires, &sources, all_closed).ok() } else { None };
|
||||
match smooth {
|
||||
Some(f) => Ok(f),
|
||||
None => crate::build::ruled_loft_faces(&wires, &sources),
|
||||
}
|
||||
})?;
|
||||
let mut rec = Rebuild::default();
|
||||
let faces: Vec<mt::Face> = faces.into_iter().map(|(f, _)| f).collect();
|
||||
for f in &faces {
|
||||
rec.made(f, Vec::new());
|
||||
}
|
||||
body_of(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;
|
||||
@@ -1053,6 +1122,47 @@ mod tests {
|
||||
assert!(sweep_sheet_points(&Plane::XY, std::slice::from_ref(&line), &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).is_err());
|
||||
}
|
||||
|
||||
/// Sheets lofted between open lines and closed loops: a ruled strip (even when the second
|
||||
/// line runs the other way, or is in two pieces), a smooth strip through three, the walls
|
||||
/// between two squares, and a cylinder through three circles.
|
||||
#[test]
|
||||
fn loft_sheet_areas() {
|
||||
use std::f64::consts::PI;
|
||||
let at = |z: f64| Plane::XY.offset(z);
|
||||
let line = |a: f64, b: f64| Seg2::Line { a: Vec2::new(a, 0.0), b: Vec2::new(b, 0.0) };
|
||||
let area = |b: &Body| measure(b).unwrap().area;
|
||||
let strip = loft_sheet(&[(at(0.0), vec![line(0.0, 10.0)]), (at(5.0), vec![line(0.0, 10.0)])]).unwrap();
|
||||
assert!(strip.is_surface() && rel(area(&strip), 50.0) < 1e-6, "{}", area(&strip));
|
||||
let turned = loft_sheet(&[(at(0.0), vec![line(0.0, 10.0)]), (at(5.0), vec![line(10.0, 0.0)])]).unwrap();
|
||||
assert!(rel(area(&turned), 50.0) < 1e-6, "not twisted: {}", area(&turned));
|
||||
let pieces = loft_sheet(&[(at(0.0), vec![line(0.0, 10.0)]), (at(5.0), vec![line(0.0, 5.0), line(5.0, 10.0)])]).unwrap();
|
||||
assert!(rel(area(&pieces), 50.0) < 1e-6 && pieces.face_count() == 2, "{}", area(&pieces));
|
||||
let three = loft_sheet(&[(at(0.0), vec![line(0.0, 10.0)]), (at(5.0), vec![line(0.0, 10.0)]), (at(10.0), vec![line(0.0, 10.0)])]).unwrap();
|
||||
assert!(rel(area(&three), 100.0) < 1e-3, "{}", area(&three)); // (measured on meshes)
|
||||
let sq = 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)]);
|
||||
let walls = loft_sheet(&[(at(0.0), sq.segs.clone()), (at(4.0), sq.segs.clone())]).unwrap();
|
||||
assert!(walls.is_surface() && walls.face_count() == 4 && rel(area(&walls), 160.0) < 1e-6, "{}", area(&walls));
|
||||
let circle = Loop2::circle(Vec2::ZERO, 2.0).segs;
|
||||
let tube = loft_sheet(&[(at(0.0), circle.clone()), (at(5.0), circle.clone()), (at(10.0), circle)]).unwrap();
|
||||
assert!(tube.is_surface());
|
||||
let want = 2.0 * PI * 2.0 * 10.0;
|
||||
assert!(rel(area(&tube), want) < 2e-3, "{} vs {want}", area(&tube)); // (measured on meshes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_sheet_refuses_bad_input() {
|
||||
let at = |z: f64| Plane::XY.offset(z);
|
||||
let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }];
|
||||
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0)]).segs;
|
||||
assert!(loft_sheet(&[(at(0.0), line.clone())]).is_err(), "one section");
|
||||
assert!(loft_sheet(&[(at(0.0), line.clone()), (at(1.0), sq)]).is_err(), "open and closed");
|
||||
assert!(loft_sheet(&[(at(0.0), line.clone()), (at(1.0), vec![])]).is_err(), "an empty section");
|
||||
let gap = vec![line[0], Seg2::Line { a: Vec2::new(11.0, 0.0), b: Vec2::new(12.0, 0.0) }];
|
||||
assert!(loft_sheet(&[(at(0.0), line.clone()), (at(1.0), gap)]).is_err(), "a section with a gap");
|
||||
let nan = vec![Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::new(1.0, 0.0) }];
|
||||
assert!(loft_sheet(&[(at(0.0), line), (at(1.0), nan)]).is_err());
|
||||
}
|
||||
|
||||
#[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) }];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve and Sweep,
|
||||
//! 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, Revolve, Sweep and
|
||||
//! Loft, 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;
|
||||
@@ -26,6 +26,7 @@ pub enum Surf {
|
||||
axis: Option<Value>,
|
||||
},
|
||||
Sweep,
|
||||
Loft,
|
||||
Patch,
|
||||
Unstitch,
|
||||
ReverseNormal,
|
||||
@@ -53,6 +54,7 @@ impl Surf {
|
||||
Surf::Extrude { .. } => "SURFACE EXTRUDE",
|
||||
Surf::Revolve { .. } => "SURFACE REVOLVE",
|
||||
Surf::Sweep => "SURFACE SWEEP",
|
||||
Surf::Loft => "SURFACE LOFT",
|
||||
Surf::Patch => "PATCH",
|
||||
Surf::Unstitch => "UNSTITCH",
|
||||
Surf::ReverseNormal => "REVERSE NORMAL",
|
||||
@@ -84,6 +86,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)],
|
||||
Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)],
|
||||
Surf::Sweep => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Path", CURVES, true)],
|
||||
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
|
||||
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
|
||||
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
|
||||
Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)],
|
||||
@@ -106,6 +109,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
"surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 },
|
||||
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
|
||||
"surface.sweep" => Surf::Sweep,
|
||||
"surface.loft" => Surf::Loft,
|
||||
"surface.patch" => Surf::Patch,
|
||||
"surface.unstitch" => Surf::Unstitch,
|
||||
"surface.reverse_normal" => Surf::ReverseNormal,
|
||||
@@ -168,7 +172,7 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
combo(ui, "sx_dir", &EXTRUDE_SIDES.map(|(label, _)| label), direction);
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep => {}
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {}
|
||||
}
|
||||
enter
|
||||
}
|
||||
@@ -281,6 +285,34 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
p["path"] = json!(path);
|
||||
("surface.sweep", p)
|
||||
}
|
||||
Surf::Loft => {
|
||||
need(0, "the sections, one sketch each")?;
|
||||
// One section per sketch, in the order the sketches were first picked.
|
||||
let st = app.session.world_state();
|
||||
let sketch_of = |x: &Sel| match x {
|
||||
Sel::Profile { sketch, .. } => Some(*sketch),
|
||||
Sel::SketchCurve { id } => st.sketches.iter().rev().find(|ss| ss.sketch.curve_index(id).is_some()).map(|ss| ss.feature),
|
||||
_ => None,
|
||||
};
|
||||
let mut order: Vec<u64> = Vec::new();
|
||||
for x in items(inputs, 0) {
|
||||
let sk = sketch_of(x).ok_or("a picked curve is no longer in a sketch")?;
|
||||
if !order.contains(&sk) {
|
||||
order.push(sk);
|
||||
}
|
||||
}
|
||||
if order.len() < 2 {
|
||||
return Err("pick sections in two or more sketches".into());
|
||||
}
|
||||
let sections: Vec<Value> = order
|
||||
.iter()
|
||||
.map(|sk| {
|
||||
let picks: Vec<Sel> = items(inputs, 0).iter().filter(|x| sketch_of(x) == Some(*sk)).cloned().collect();
|
||||
sheet_picks(app, &picks)
|
||||
})
|
||||
.collect::<Result<_, String>>()?;
|
||||
("surface.loft", json!({ "sections": sections }))
|
||||
}
|
||||
Surf::Patch => {
|
||||
need(0, "a boundary (sketch profiles or a loop of edges)")?;
|
||||
let profiles: Vec<(u64, usize)> =
|
||||
@@ -405,6 +437,9 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
Surf::Sweep,
|
||||
vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()],
|
||||
),
|
||||
FeatureKind::SurfaceLoft { sections } => {
|
||||
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).collect()])
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let sels = sheet_sels(app, *sketch, profiles.as_ref(), curves);
|
||||
let direction = match extent.direction {
|
||||
|
||||
@@ -910,6 +910,35 @@ fn surface_sweep_dialog_takes_curves_and_a_path() {
|
||||
assert_eq!(back.inputs[1].items, vec![Sel::SketchCurve { id: "up".into() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_loft_dialog_makes_a_section_per_sketch() {
|
||||
// A line in each of two sketches 5 apart: two sections, a 10 × 5 strip; Edit Feature brings
|
||||
// the curves back.
|
||||
let mut app = app_with(json!([
|
||||
{"command": "construct.plane.offset", "params": {"base": "XY", "offset": 5, "name": "Mid"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [10, 0]], "ids": ["a"]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "sketch.create", "params": {"plane": "Mid"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [10, 0]], "ids": ["b"]}},
|
||||
{"command": "sketch.finish", "params": {}}
|
||||
]));
|
||||
let mut d = start(&mut app, vec![], "surface.loft");
|
||||
d.inputs[0].items = vec![Sel::SketchCurve { id: "a".into() }];
|
||||
assert!(apply_commands(&app, &d).is_err(), "one section");
|
||||
d.inputs[0].items.push(Sel::SketchCurve { id: "b".into() });
|
||||
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
|
||||
assert_eq!(id, "surface.loft");
|
||||
assert_eq!(p["sections"].as_array().map(Vec::len), Some(2), "{p}");
|
||||
assert_eq!((&p["sections"][0]["curves"], &p["sections"][1]["curves"]), (&json!(["a"]), &json!(["b"])), "{p}");
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
let area = app.session.model.state().bodies.last().unwrap().mesh().measure().area;
|
||||
assert!((area - 50.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_eq!(back.inputs[0].items, vec![Sel::SketchCurve { id: "a".into() }, Sel::SketchCurve { id: "b".into() }]);
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
@@ -16,6 +16,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
FeatureKind::ReverseNormal { .. } => "reverse_normal",
|
||||
FeatureKind::SurfaceRevolve { .. } => "surface_revolve",
|
||||
FeatureKind::SurfaceSweep { .. } => "surface_sweep",
|
||||
FeatureKind::SurfaceLoft { .. } => "surface_loft",
|
||||
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`). **340 commands.** Planned (registered, not built yet): 8. 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`). **341 commands.** Planned (registered, not built yet): 7. 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 (10, 7 planned)
|
||||
## Surfaces (11, 6 planned)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
@@ -157,7 +157,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Surface Extrude | `surface.extrude` | SURFACE › CREATE |
|
||||
| Surface Revolve | `surface.revolve` | SURFACE › CREATE |
|
||||
| Surface Sweep | `surface.sweep` | SURFACE › CREATE |
|
||||
| Surface Loft *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.loft` | SURFACE › CREATE |
|
||||
| Surface Loft | `surface.loft` | SURFACE › CREATE |
|
||||
| Offset Surface *(planned: [#758](https://github.com/bherbruck/solidcraft/issues/758))* | `surface.offset` | SURFACE › CREATE |
|
||||
| Ruled Surface *(planned: [#762](https://github.com/bherbruck/solidcraft/issues/762))* | `surface.ruled` | SURFACE › CREATE |
|
||||
| Boundary Surface *(planned: [#763](https://github.com/bherbruck/solidcraft/issues/763))* | `surface.boundary` | SURFACE › CREATE |
|
||||
|
||||
Reference in new issue
Block a user