Surface Sweep: sheets carried from open curves and profiles along a path (#843)
Surface Sweep carries open curves and closed profile boundaries along a sketch path into surface bodies, with smooth walls along curved point paths. Adds the SurfaceSweep feature (format 18 note), the surface.sweep command, a dialog with Edit Feature support, and kernel and engine tests. Part of #757.
This commit is contained in:
1 parent
57d738eac4
commit
aba57f1ca2
16 files changed
+480
-27
No files matched your search
@@ -324,6 +324,7 @@ impl FeatureKind {
|
||||
| FeatureKind::Extrude { .. }
|
||||
| FeatureKind::SurfaceExtrude { .. }
|
||||
| FeatureKind::SurfaceRevolve { .. }
|
||||
| FeatureKind::SurfaceSweep { .. }
|
||||
| FeatureKind::Combine { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
|
||||
@@ -55,7 +55,7 @@ impl FeatureKind {
|
||||
| FeatureKind::SheetContour { sketch, .. }
|
||||
| FeatureKind::Rib { sketch, .. } => sk(sketch),
|
||||
FeatureKind::Patch { sketch: Some(s), .. } => sk(s),
|
||||
FeatureKind::Sweep { sketch, path_sketch, .. } => {
|
||||
FeatureKind::Sweep { sketch, path_sketch, .. } | FeatureKind::SurfaceSweep { sketch, path_sketch, .. } => {
|
||||
sk(sketch);
|
||||
sk(path_sketch);
|
||||
}
|
||||
|
||||
@@ -971,6 +971,17 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
symmetric: bool,
|
||||
},
|
||||
/// A surface body swept along a chain of sketch curves from open curve chains and the
|
||||
/// boundaries of profiles: walls only, no caps.
|
||||
SurfaceSweep {
|
||||
sketch: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
profiles: Option<ProfileSel>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
curves: Vec<String>,
|
||||
path_sketch: u64,
|
||||
path: Vec<String>,
|
||||
},
|
||||
/// A planar surface body extended outward by `distance` past the edges through `edges`.
|
||||
SurfaceExtend {
|
||||
body: String,
|
||||
@@ -1278,7 +1289,7 @@ impl FeatureKind {
|
||||
| K::Rib { sketch, .. }
|
||||
| K::SheetBase { sketch, .. }
|
||||
| K::SheetContour { sketch, .. } => vec![*sketch],
|
||||
K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
|
||||
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::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
|
||||
@@ -1408,6 +1419,7 @@ impl FeatureKind {
|
||||
FeatureKind::Patch { .. } => "PatchFeature",
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature",
|
||||
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolveFeature",
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
FeatureKind::Thicken { .. } => "ThickenFeature",
|
||||
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
|
||||
@@ -1478,6 +1490,7 @@ impl FeatureKind {
|
||||
FeatureKind::Patch { .. } => "Patch",
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude",
|
||||
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolve",
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
FeatureKind::Thicken { .. } => "Thicken",
|
||||
FeatureKind::SurfaceTrim { .. } => "Trim",
|
||||
@@ -1502,7 +1515,8 @@ impl FeatureKind {
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. }
|
||||
| FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. }
|
||||
| FeatureKind::SurfaceRevolve { sketch: sk, profiles: Some(profiles), .. } => {
|
||||
| FeatureKind::SurfaceRevolve { sketch: sk, profiles: Some(profiles), .. }
|
||||
| FeatureKind::SurfaceSweep { 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(),
|
||||
@@ -1520,7 +1534,8 @@ impl FeatureKind {
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. }
|
||||
| FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. }
|
||||
| FeatureKind::SurfaceRevolve { sketch, profiles: Some(profiles), .. } => {
|
||||
| FeatureKind::SurfaceRevolve { sketch, profiles: Some(profiles), .. }
|
||||
| FeatureKind::SurfaceSweep { sketch, profiles: Some(profiles), .. } => {
|
||||
vec![(*sketch, profiles)]
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
@@ -1633,7 +1648,7 @@ impl FeatureKind {
|
||||
},
|
||||
FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::Shell { thickness, .. } => v.push(thickness),
|
||||
FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } => {}
|
||||
FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::SurfaceSweep { .. } => {}
|
||||
FeatureKind::Draft { angle, neutral, .. } => {
|
||||
v.push(angle);
|
||||
plane_exprs(neutral, &mut v);
|
||||
|
||||
@@ -880,6 +880,7 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
|
||||
fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
let path = match &f.kind {
|
||||
FeatureKind::Sweep { path_sketch, .. }
|
||||
| FeatureKind::SurfaceSweep { path_sketch, .. }
|
||||
| FeatureKind::Pipe { path_sketch, .. }
|
||||
| FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch),
|
||||
_ => None,
|
||||
@@ -892,7 +893,7 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
for frame in [doc.component_transform(f.component), doc.component_transform(owner)] {
|
||||
frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
}
|
||||
if let FeatureKind::Sweep { sketch, .. } = &f.kind {
|
||||
if let FeatureKind::Sweep { sketch, .. } | FeatureKind::SurfaceSweep { sketch, .. } = &f.kind {
|
||||
let owner = doc.feature(*sketch).map(|feature| feature.component).unwrap_or(0);
|
||||
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
}
|
||||
@@ -3635,7 +3636,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::SurfaceExtrude { .. }
|
||||
| FeatureKind::Unstitch { .. }
|
||||
| FeatureKind::ReverseNormal { .. }
|
||||
| FeatureKind::SurfaceRevolve { .. } => surface::eval(doc, vals, f, st),
|
||||
| FeatureKind::SurfaceRevolve { .. }
|
||||
| FeatureKind::SurfaceSweep { .. } => 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,
|
||||
//! Unstitch and Reverse Normal (kernel `surfaces`).
|
||||
//! Revolve and Sweep, Unstitch and Reverse Normal (kernel `surfaces`).
|
||||
|
||||
use super::*;
|
||||
use solidcraft_geom::Seg2;
|
||||
@@ -155,6 +155,32 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
let ps =
|
||||
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||||
// As Sweep: a 3D path sketch is followed in the feature's frame, the curves with it.
|
||||
let profile;
|
||||
let ss = if !ps.sketch.wires.is_empty() {
|
||||
profile = profile_in_frame(doc, ss, f.component)?;
|
||||
&profile
|
||||
} else {
|
||||
ss
|
||||
};
|
||||
let mapped = path_in_frame(doc, ps, f.component)?;
|
||||
let ps = mapped.as_ref();
|
||||
let chains = sheet_chains(ss, profiles.as_ref(), curves)?;
|
||||
// The path runs on from the end nearest the middle of the curves.
|
||||
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
|
||||
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
|
||||
let body = match path_points(ps, path, start)? {
|
||||
Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?,
|
||||
None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?,
|
||||
};
|
||||
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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
//! saves as before. 0.9.0 would heal a gap the design leaves open.
|
||||
//! A `SurfaceRevolve` feature (Surface Revolve: `sketch`, optional `profiles`, `curves`,
|
||||
//! `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.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ impl FeatureKind {
|
||||
| FeatureKind::BoundaryFill { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
| FeatureKind::SurfaceSweep { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
|
||||
@@ -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, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles};
|
||||
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
|
||||
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -38,10 +38,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_revolve")
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|{origin, dir}; angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"),
|
||||
planned!("surface.sweep", "Surface Sweep", "757")
|
||||
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_sweep")
|
||||
.params("planned: the parameters of solid.sweep, with an open profile chain as well as a closed one (no caps); caps?: bool"),
|
||||
.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")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_loft")
|
||||
@@ -155,6 +155,18 @@ fn surface_revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::SurfaceRevolve { sketch, profiles, curves, axis, angle, angle2, symmetric })
|
||||
}
|
||||
|
||||
fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.sweep";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let (profiles, curves) = sheet_inputs(p, cmd)?;
|
||||
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
|
||||
let path = string_list(p, "path");
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(bad(cmd, "`path` must list 1…1000 curve ids"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path })
|
||||
}
|
||||
|
||||
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() {
|
||||
@@ -432,13 +444,57 @@ mod tests {
|
||||
assert!(s.execute("surface.revolve", &json!({"sketch": "S", "curves": ["side"], "axis": "y", "angle": "400 deg"})).is_err());
|
||||
}
|
||||
|
||||
/// Surface Sweep: a line along y carried up a path in the XZ plane, straight then round a
|
||||
/// quarter turn (every point of it 5 from the arc's axis: 10 × (5 + 5π/2)), or round a
|
||||
/// corner (10 × both legs); a rectangle's walls up the straight leg; undo, redo, reopening.
|
||||
#[test]
|
||||
fn surface_sweep_curves_and_profiles() {
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 10]], "ids": ["l"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "P"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 5]], "ids": ["up"]}));
|
||||
let arc = run(
|
||||
&mut s,
|
||||
"sketch.arc.three_point",
|
||||
json!({"start": [0, 5], "end": [5, 10], "through": [5.0 - 5.0 / 2f64.sqrt(), 5.0 + 5.0 / 2f64.sqrt()]}),
|
||||
)["created"]["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 5], [5, 5]], "ids": ["over"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let close = |a: f64, want: f64| assert!((a - want).abs() < want * 2e-3, "{a} vs {want}"); // (measured on meshes)
|
||||
run(&mut s, "surface.sweep", json!({"sketch": "S", "curves": ["l"], "path_sketch": "P", "path": ["up", arc], "body_name": "Bend"}));
|
||||
assert!(s.world_state().body("Bend").unwrap().body.is_surface());
|
||||
close(area(&s, "Bend"), 10.0 * (5.0 + 5.0 * FRAC_PI_2));
|
||||
run(&mut s, "surface.sweep", json!({"sketch": "S", "curves": ["l"], "path_sketch": "P", "path": ["up", "over"], "body_name": "Corner"}));
|
||||
close(area(&s, "Corner"), 100.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-1, -1], "p1": [1, 2]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.sweep", json!({"sketch": "R", "path_sketch": "P", "path": ["up"], "body_name": "Walls"}));
|
||||
let st = s.world_state();
|
||||
assert!(st.body("Walls").unwrap().body.is_surface() && st.body("Walls").unwrap().body.face_count() == 4);
|
||||
close(area(&s, "Walls"), 10.0 * 5.0);
|
||||
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, "Bend") - area(&s, "Bend")).abs() < 1e-9);
|
||||
// Refused: no path, and a path curve that is not there.
|
||||
assert!(s.execute("surface.sweep", &json!({"sketch": "S", "curves": ["l"], "path_sketch": "P"})).is_err());
|
||||
assert!(s.execute("surface.sweep", &json!({"sketch": "S", "curves": ["l"], "path_sketch": "P", "path": ["nope"]})).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.sweep",
|
||||
"surface.loft",
|
||||
"surface.offset",
|
||||
"surface.ruled",
|
||||
|
||||
@@ -1170,7 +1170,7 @@ pub enum PathSeg {
|
||||
|
||||
impl PathSeg {
|
||||
/// Unit directions of the path where the segment starts and ends.
|
||||
fn tangents(&self) -> Option<(Vec3, Vec3)> {
|
||||
pub(crate) fn tangents(&self) -> Option<(Vec3, Vec3)> {
|
||||
match *self {
|
||||
PathSeg::Line { a, b } => {
|
||||
let t = (b - a).normalized()?;
|
||||
@@ -1185,7 +1185,7 @@ impl PathSeg {
|
||||
}
|
||||
|
||||
/// Points along the segment (an arc within `tol` of its chords), without its start.
|
||||
fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
|
||||
pub(crate) fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
|
||||
match *self {
|
||||
PathSeg::Line { b, .. } => vec![b],
|
||||
PathSeg::Arc { a, center, axis, angle } => {
|
||||
@@ -1200,7 +1200,7 @@ impl PathSeg {
|
||||
}
|
||||
|
||||
/// `p` turned by `angle` about the unit axis `k` through `center` (right hand).
|
||||
fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
|
||||
pub(crate) fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
|
||||
let v = p - center;
|
||||
let (s, c) = angle.sin_cos();
|
||||
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
|
||||
|
||||
@@ -117,7 +117,8 @@ 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, thicken, trim, unstitch,
|
||||
copy_faces, extend, extrude_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;
|
||||
pub use topo::{CylinderFace, cylinder_face_at};
|
||||
|
||||
@@ -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 and revolve).
|
||||
//! sheets swept from sketch curves (surface extrude, revolve and sweep).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -162,6 +162,120 @@ pub fn revolve_sheet(plane: &Plane, chains: &[Vec<Seg2>], axis_origin: Vec2, axi
|
||||
body_of(faces, &[], &rec)
|
||||
}
|
||||
|
||||
/// A surface swept from chains of sketch segments on `plane` along a path that starts near
|
||||
/// them. Along a path whose direction runs on across every joint, lines slide the chains and
|
||||
/// arcs turn them, exactly; any other path (curves followed as polylines) goes through
|
||||
/// [`sweep_sheet_points`]. The walls form one surface body with no caps (a solid where they
|
||||
/// close); its history has no operands: every face is new.
|
||||
pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg]) -> Result<Body> {
|
||||
use crate::PathSeg;
|
||||
if !crate::build::frame_ok(plane) {
|
||||
return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into()));
|
||||
}
|
||||
if chains.is_empty() || chains.len() > MAX_CHAINS {
|
||||
return Err(KernelError::Invalid("pick the curves or profiles to sweep".into()));
|
||||
}
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
let ends: Vec<(Vec3, Vec3)> =
|
||||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) {
|
||||
let mut pts = vec![first];
|
||||
for seg in path {
|
||||
pts.extend(seg.points_after_start(1e-2));
|
||||
}
|
||||
return sweep_sheet_points(plane, chains, &pts);
|
||||
}
|
||||
let mut rec = Rebuild::default();
|
||||
let mut faces = Vec::new();
|
||||
for chain in chains {
|
||||
let mut cur = crate::build::chain_wire(plane, chain)?;
|
||||
for seg in path {
|
||||
let (part, map): (mt::Shell, Box<dyn Fn(Vec3) -> Vec3>) = match *seg {
|
||||
PathSeg::Line { a, b } => {
|
||||
(guard("surface sweep", || Ok(mt::builder::tsweep(&cur, crate::body::v3(b - a))))?, Box::new(move |p| p + (b - a)))
|
||||
}
|
||||
PathSeg::Arc { center, axis, angle, .. } => {
|
||||
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("a path arc has no axis".into()))?;
|
||||
let part = guard("surface sweep", || Ok(mt::builder::rsweep(&cur, p3(center), crate::body::v3(k), mt::Rad(angle))))?;
|
||||
(part, Box::new(move |p| crate::build::rotate_about(p, center, k, angle)))
|
||||
}
|
||||
};
|
||||
let next = far_wire(&part, &cur, map.as_ref())?;
|
||||
for f in part.face_iter() {
|
||||
rec.made(f, Vec::new());
|
||||
faces.push(f.clone());
|
||||
}
|
||||
cur = next;
|
||||
}
|
||||
}
|
||||
body_of(faces, &[], &rec)
|
||||
}
|
||||
|
||||
/// A surface swept from chains of sketch segments on `plane` along a 3D polyline path: one smooth
|
||||
/// B-spline wall per segment where the path has no corners, else ruled walls between the path's
|
||||
/// points, mitred at the corners (see [`sweep_sheet`]).
|
||||
pub fn sweep_sheet_points(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Body> {
|
||||
if !crate::build::frame_ok(plane) {
|
||||
return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into()));
|
||||
}
|
||||
if chains.is_empty() || chains.len() > MAX_CHAINS {
|
||||
return Err(KernelError::Invalid("pick the curves or profiles to sweep".into()));
|
||||
}
|
||||
let sheet = |faces: Vec<mt::Face>| {
|
||||
let mut rec = Rebuild::default();
|
||||
for f in &faces {
|
||||
rec.made(f, Vec::new());
|
||||
}
|
||||
body_of(faces, &[], &rec)
|
||||
};
|
||||
let ruled = sheet(crate::sweep_path::ruled_sheet(plane, chains, path)?)?;
|
||||
// Smooth walls where the path has no corners, kept when their area agrees with the ruled
|
||||
// walls' (a fit that went wrong falls back to those).
|
||||
if let Ok(Some(faces)) = crate::sweep_path::smooth_sheet(plane, chains, path)
|
||||
&& let Ok(smooth) = sheet(faces)
|
||||
&& let (Ok(a), Ok(b)) = (crate::measure(&smooth), crate::measure(&ruled))
|
||||
&& (a.area - b.area).abs() <= 0.02 * b.area.abs()
|
||||
{
|
||||
return Ok(smooth);
|
||||
}
|
||||
Ok(ruled)
|
||||
}
|
||||
|
||||
/// The far side of a sweep of the wire `start`: for each of its edges, in order and the same
|
||||
/// way round, the edge of `swept` that `map` carries it onto (matched by its ends and middle).
|
||||
fn far_wire(swept: &mt::Shell, start: &mt::Wire, map: &dyn Fn(Vec3) -> Vec3) -> Result<mt::Wire> {
|
||||
let near = |p: Vec3, q: Vec3| p.dist(q) <= 1e-6 * (1.0 + p.len().max(q.len()));
|
||||
let mid = |e: &mt::Edge| {
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
from_p3(c.subs((t0 + t1) / 2.0))
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for e in start.edge_iter() {
|
||||
let (a, b, m) = (map(from_p3(e.front().point())), map(from_p3(e.back().point())), map(mid(e)));
|
||||
let found = swept.edge_iter().find_map(|f| {
|
||||
let (fa, fb) = (from_p3(f.front().point()), from_p3(f.back().point()));
|
||||
if !near(mid(&f), m) {
|
||||
None
|
||||
} else if near(fa, a) && near(fb, b) {
|
||||
Some(f)
|
||||
} else if near(fa, b) && near(fb, a) {
|
||||
Some(f.inverse())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
out.push(found.ok_or_else(|| KernelError::Failed("sweep: lost the curves along the path".into()))?);
|
||||
}
|
||||
Ok(out.into())
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -879,6 +993,66 @@ mod tests {
|
||||
assert!(revolve_sheet(&Plane::XY, &[vec![line((1.0, 0.0), (1.0, 1.0))]], Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0), 0.0, 1.0).is_err());
|
||||
}
|
||||
|
||||
/// A line along x swept up z: straight (length × height), then on round a quarter turn
|
||||
/// (a quarter cylinder of radius 5 about an axis along x: 10 × 5π/2, the arc taking up the
|
||||
/// line's far edge), and along a path with a corner (mitred ruled walls, 10 × both legs); a
|
||||
/// closed square's four walls; a 3D point path.
|
||||
#[test]
|
||||
fn sweep_sheet_areas() {
|
||||
use crate::PathSeg;
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }];
|
||||
let up = PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 5.0) };
|
||||
let area = |b: &Body| measure(b).unwrap().area;
|
||||
let straight = sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[up]).unwrap();
|
||||
assert!(straight.is_surface() && rel(area(&straight), 50.0) < 1e-9);
|
||||
let bend = PathSeg::Arc { a: Vec3::new(0.0, 0.0, 5.0), center: Vec3::new(0.0, 5.0, 5.0), axis: -Vec3::X, angle: FRAC_PI_2 };
|
||||
let smooth = sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[up, bend]).unwrap();
|
||||
assert!(smooth.is_surface());
|
||||
let want = 50.0 + 10.0 * 5.0 * FRAC_PI_2;
|
||||
assert!(rel(area(&smooth), want) < 2e-3, "{} vs {want}", area(&smooth)); // (measured on meshes)
|
||||
let over = PathSeg::Line { a: Vec3::new(0.0, 0.0, 5.0), b: Vec3::new(0.0, 5.0, 5.0) };
|
||||
let cornered = sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[up, over]).unwrap();
|
||||
assert!(cornered.is_surface() && rel(area(&cornered), 100.0) < 1e-9, "{}", area(&cornered));
|
||||
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 4.0), Vec2::new(0.0, 4.0)]);
|
||||
let walls = sweep_sheet(&Plane::XY, std::slice::from_ref(&sq.segs), &[up]).unwrap();
|
||||
assert!(walls.is_surface() && walls.face_count() == 4 && rel(area(&walls), 80.0) < 1e-9);
|
||||
let pts: Vec<Vec3> = (0..=10).map(|i| Vec3::new(0.0, 0.0, i as f64)).collect();
|
||||
assert!(rel(area(&sweep_sheet_points(&Plane::XY, std::slice::from_ref(&line), &pts).unwrap()), 100.0) < 1e-6);
|
||||
// A smooth point path: a quarter circle of radius 30 in the YZ plane. One smooth wall (not
|
||||
// forty ruled strips), a piece of cylinder: 10 × 15π.
|
||||
let arc: Vec<Vec3> = (0..=40)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 40.0 * FRAC_PI_2;
|
||||
Vec3::new(0.0, 30.0 * (1.0 - t.cos()), 30.0 * t.sin())
|
||||
})
|
||||
.collect();
|
||||
let curved = sweep_sheet_points(&Plane::XY, std::slice::from_ref(&line), &arc).unwrap();
|
||||
assert_eq!(curved.face_count(), 1, "smooth wall");
|
||||
let want = 10.0 * 30.0 * FRAC_PI_2;
|
||||
assert!(rel(area(&curved), want) < 2e-3, "{} vs {want}", area(&curved)); // (measured on meshes)
|
||||
// A bent chain along the same path: two smooth walls joined by one shared seam edge.
|
||||
let bent = vec![line[0], Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 5.0) }];
|
||||
let two = sweep_sheet_points(&Plane::XY, &[bent], &arc).unwrap();
|
||||
assert_eq!(two.face_count(), 2);
|
||||
let edges: std::collections::HashSet<_> = two.solid.edge_iter().map(|e| e.id()).collect();
|
||||
assert_eq!(edges.len(), 7, "the walls share their seam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_sheet_refuses_bad_input() {
|
||||
use crate::PathSeg;
|
||||
let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }];
|
||||
let up = PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 5.0) };
|
||||
assert!(sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[]).is_err());
|
||||
assert!(sweep_sheet(&Plane::XY, &[], &[up]).is_err());
|
||||
assert!(sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[PathSeg::Line { a: Vec3::ZERO, b: Vec3::ZERO }]).is_err());
|
||||
let no_axis = PathSeg::Arc { a: Vec3::ZERO, center: Vec3::new(0.0, 5.0, 0.0), axis: Vec3::ZERO, angle: 1.0 };
|
||||
assert!(sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[no_axis]).is_err());
|
||||
assert!(sweep_sheet_points(&Plane::XY, std::slice::from_ref(&line), &[Vec3::ZERO]).is_err());
|
||||
assert!(sweep_sheet_points(&Plane::XY, std::slice::from_ref(&line), &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).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) }];
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! between stations, mitred at the corners.
|
||||
|
||||
use mt::builder;
|
||||
use solidcraft_geom::{Plane, Region2, Vec2, Vec3};
|
||||
use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, p3};
|
||||
@@ -175,9 +175,9 @@ fn start_cos(plane: &Plane, tangents: &[Vec3]) -> f64 {
|
||||
tangents.first().map(|t| t.dot(plane.normal()).abs()).unwrap_or(1.0).max(1e-3)
|
||||
}
|
||||
|
||||
/// Ruled walls between stations at the path's points (mitred at corners), every loop of the
|
||||
/// (oriented) profile.
|
||||
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result<Body> {
|
||||
/// Stations at the path's points (at most [`MAX_RULED_STATIONS`]), mitred at corners, with the
|
||||
/// points they stand at.
|
||||
fn ruled_stations(pts: &[Vec3]) -> Result<(Vec<Vec3>, Vec<Station>)> {
|
||||
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(pts, MAX_RULED_STATIONS) } else { pts.to_vec() };
|
||||
let n = pts.len();
|
||||
let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
|
||||
@@ -202,6 +202,133 @@ fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result<Body>
|
||||
mitre.push(m);
|
||||
}
|
||||
let st = stations(&pts, &tangents, &mitre)?;
|
||||
Ok((pts, st))
|
||||
}
|
||||
|
||||
/// Walls of chains of sketch segments on `plane` (open, or closed) carried along a 3D polyline
|
||||
/// path that starts near them: ruled between stations at the path's points, mitred at corners,
|
||||
/// with no caps. Neighbouring walls share their edges.
|
||||
pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Vec<mt::Face>> {
|
||||
let (pts, st) = ruled_stations(&clean(path)?)?;
|
||||
let edges: usize = chains.iter().map(Vec::len).sum();
|
||||
if st.len().saturating_mul(edges) > 400_000 {
|
||||
return Err(KernelError::Invalid("the sweep is too detailed (curves × path points)".into()));
|
||||
}
|
||||
let s0 = pts.first().copied().unwrap_or_default();
|
||||
let wires: Vec<Vec<mt::Wire>> = st
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let pl = station_plane(plane, s0, s);
|
||||
chains.iter().map(|c| crate::build::chain_wire(&pl, c)).collect::<Result<Vec<_>>>()
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
guard("path sweep", || {
|
||||
let mut faces = Vec::new();
|
||||
for w in wires.windows(2) {
|
||||
let (Some(w0), Some(w1)) = (w.first(), w.get(1)) else { continue };
|
||||
for (a, b) in w0.iter().zip(w1) {
|
||||
let sides: mt::Shell = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("path sweep: {e}")))?;
|
||||
faces.extend(sides.face_iter().cloned());
|
||||
}
|
||||
}
|
||||
Ok(faces)
|
||||
})
|
||||
}
|
||||
|
||||
/// Walls of chains of sketch segments on `plane` carried along a smooth 3D polyline path (no
|
||||
/// turn sharper than [`CORNER`]): one B-spline wall per segment through evenly spaced,
|
||||
/// rotation-minimising stations, neighbouring walls sharing their seam edges; no caps. `None`
|
||||
/// for a path with corners (see [`ruled_sheet`]).
|
||||
pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Option<Vec<mt::Face>>> {
|
||||
let pts = clean(path)?;
|
||||
if pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER) {
|
||||
return Ok(None);
|
||||
}
|
||||
let fail = |w: &str| KernelError::Failed(format!("path sweep: {w}"));
|
||||
if chains.iter().map(Vec::len).sum::<usize>() > 4096 {
|
||||
return Err(fail("too many curves for a smooth sweep"));
|
||||
}
|
||||
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS);
|
||||
let sp = resample(&pts, n);
|
||||
let tangents = central_tangents(&sp)?;
|
||||
let st = stations(&sp, &tangents, &[])?;
|
||||
let s0 = st.first().map(|s| s.at).unwrap_or_default();
|
||||
let at = |i: usize, q: Vec2| st.get(i).map(|s| s.at + s.apply(plane.to_world(q) - s0)).unwrap_or_default();
|
||||
let n = st.len() - 1;
|
||||
let mut faces = Vec::new();
|
||||
for chain in chains {
|
||||
let closed = chain.first().zip(chain.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= 1e-6);
|
||||
// Each segment: its curve at the first and last station, the columns through its start
|
||||
// and end, and the wall surface.
|
||||
let mut data = Vec::with_capacity(chain.len());
|
||||
for s in chain {
|
||||
let mut station_curves = Vec::with_capacity(n + 1);
|
||||
for i in 0..=n {
|
||||
let q: Vec<Vec3> = (0..SEG_SAMPLES).map(|j| at(i, s.point_at(j as f64 / (SEG_SAMPLES - 1) as f64))).collect();
|
||||
station_curves.push(interpolate_cubic(&q).ok_or_else(|| fail("profile curve"))?);
|
||||
}
|
||||
let kv = station_curves.first().map(|c| c.knot_vec().clone()).ok_or_else(|| fail("no stations"))?;
|
||||
let mut columns = Vec::with_capacity(SEG_SAMPLES);
|
||||
for j in 0..SEG_SAMPLES {
|
||||
let q: Vec<Vec3> = station_curves
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let p = c.control_point(j);
|
||||
Vec3::new(p.x, p.y, p.z)
|
||||
})
|
||||
.collect();
|
||||
columns.push(interpolate_cubic(&q).ok_or_else(|| fail("path curve"))?);
|
||||
}
|
||||
let ku = columns.first().map(|c| c.knot_vec().clone()).ok_or_else(|| fail("no columns"))?;
|
||||
let net: Vec<Vec<mt::Point3>> = (0..=n).map(|m| columns.iter().map(|c| *c.control_point(m)).collect()).collect();
|
||||
let surf = mt::BSplineSurface::try_new((ku, kv), net).map_err(|e| fail(&e.to_string()))?;
|
||||
let (Some(c0), Some(c1), Some(first), Some(last)) =
|
||||
(station_curves.first().cloned(), station_curves.last().cloned(), columns.first().cloned(), columns.last().cloned())
|
||||
else {
|
||||
return Err(fail("stations"));
|
||||
};
|
||||
data.push((c0, c1, first, last, surf));
|
||||
}
|
||||
let walls: Vec<mt::Face> = guard("path sweep", || {
|
||||
let m = chain.len();
|
||||
// Vertices along the chain (an open chain has one more than it has segments).
|
||||
let corners: Vec<Vec2> = chain.iter().map(Seg2::start).chain(chain.last().map(Seg2::end).filter(|_| !closed)).collect();
|
||||
let start: Vec<mt::Vertex> = corners.iter().map(|q| builder::vertex(p3(at(0, *q)))).collect();
|
||||
let end: Vec<mt::Vertex> = corners.iter().map(|q| builder::vertex(p3(at(n, *q)))).collect();
|
||||
let next = |j: usize| if closed { (j + 1) % m } else { j + 1 };
|
||||
let mut seams = Vec::with_capacity(corners.len());
|
||||
for (k, (a, b)) in start.iter().zip(&end).enumerate() {
|
||||
// The column through each corner: the start column of the segment leaving it, or
|
||||
// the end column of the last segment.
|
||||
let col = match data.get(k) {
|
||||
Some((_, _, first, _, _)) => first,
|
||||
None => data.last().map(|d| &d.3).ok_or_else(|| fail("seam"))?,
|
||||
};
|
||||
seams.push(mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(col.clone())).map_err(|e| fail(&e.to_string()))?);
|
||||
}
|
||||
let mut walls = Vec::with_capacity(m);
|
||||
for (j, (c0, c1, _, _, surf)) in data.iter().enumerate() {
|
||||
let (Some(a0), Some(b0), Some(a1), Some(b1), Some(sa), Some(sb)) =
|
||||
(start.get(j), start.get(next(j)), end.get(j), end.get(next(j)), seams.get(j), seams.get(next(j)))
|
||||
else {
|
||||
return Err(fail("edges"));
|
||||
};
|
||||
let e0 = mt::Edge::try_new(a0, b0, mt::Curve::BSplineCurve(c0.clone())).map_err(|e| fail(&e.to_string()))?;
|
||||
let e1 = mt::Edge::try_new(a1, b1, mt::Curve::BSplineCurve(c1.clone())).map_err(|e| fail(&e.to_string()))?;
|
||||
let w: mt::Wire = vec![sa.clone(), e1, sb.inverse(), e0.inverse()].into();
|
||||
walls.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?);
|
||||
}
|
||||
Ok(walls)
|
||||
})?;
|
||||
faces.extend(walls);
|
||||
}
|
||||
Ok(Some(faces))
|
||||
}
|
||||
|
||||
/// Ruled walls between stations at the path's points (mitred at corners), every loop of the
|
||||
/// (oriented) profile.
|
||||
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result<Body> {
|
||||
let (pts, st) = ruled_stations(pts)?;
|
||||
let s0 = pts.first().copied().unwrap_or_default();
|
||||
let loops: Vec<&NumberedLoop> = profile.loops().collect();
|
||||
let mut wires: Vec<Vec<mt::Wire>> = Vec::with_capacity(st.len());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude and Revolve, 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 and Sweep,
|
||||
//! 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;
|
||||
@@ -25,6 +25,7 @@ pub enum Surf {
|
||||
symmetric: bool,
|
||||
axis: Option<Value>,
|
||||
},
|
||||
Sweep,
|
||||
Patch,
|
||||
Unstitch,
|
||||
ReverseNormal,
|
||||
@@ -51,6 +52,7 @@ impl Surf {
|
||||
match self {
|
||||
Surf::Extrude { .. } => "SURFACE EXTRUDE",
|
||||
Surf::Revolve { .. } => "SURFACE REVOLVE",
|
||||
Surf::Sweep => "SURFACE SWEEP",
|
||||
Surf::Patch => "PATCH",
|
||||
Surf::Unstitch => "UNSTITCH",
|
||||
Surf::ReverseNormal => "REVERSE NORMAL",
|
||||
@@ -81,6 +83,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
match k {
|
||||
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::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)],
|
||||
@@ -102,6 +105,7 @@ 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.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
|
||||
"surface.sweep" => Surf::Sweep,
|
||||
"surface.patch" => Surf::Patch,
|
||||
"surface.unstitch" => Surf::Unstitch,
|
||||
"surface.reverse_normal" => Surf::ReverseNormal,
|
||||
@@ -164,7 +168,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::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep => {}
|
||||
}
|
||||
enter
|
||||
}
|
||||
@@ -266,6 +270,17 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
}
|
||||
("surface.revolve", p)
|
||||
}
|
||||
Surf::Sweep => {
|
||||
need(0, "profiles or curves to sweep")?;
|
||||
need(1, "the path")?;
|
||||
let mut p = sheet_picks(app, items(inputs, 0))?;
|
||||
let path: Vec<String> =
|
||||
items(inputs, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
|
||||
p["path_sketch"] =
|
||||
json!(crate::dialogs::curves_sketch(app, &path, p["sketch"].as_u64()).ok_or("the path curves must be in one other sketch")?);
|
||||
p["path"] = json!(path);
|
||||
("surface.sweep", p)
|
||||
}
|
||||
Surf::Patch => {
|
||||
need(0, "a boundary (sketch profiles or a loop of edges)")?;
|
||||
let profiles: Vec<(u64, usize)> =
|
||||
@@ -386,6 +401,10 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
}
|
||||
return Some((Kind::Surface(k), ins, extra));
|
||||
}
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, .. } => (
|
||||
Surf::Sweep,
|
||||
vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()],
|
||||
),
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let sels = sheet_sels(app, *sketch, profiles.as_ref(), curves);
|
||||
let direction = match extent.direction {
|
||||
|
||||
@@ -882,6 +882,34 @@ fn surface_revolve_dialog_takes_curves_and_an_axis() {
|
||||
assert_eq!(back.inputs[1].items, vec![Sel::Axis { name: "y".into() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_sweep_dialog_takes_curves_and_a_path() {
|
||||
// A line along y carried 5 up a line in the XZ plane: a 10 × 5 sheet. Edit Feature brings
|
||||
// back the curve and the path.
|
||||
let mut app = app_with(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [0, 10]], "ids": ["l"]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "sketch.create", "params": {"plane": "XZ"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [0, 5]], "ids": ["up"]}},
|
||||
{"command": "sketch.finish", "params": {}}
|
||||
]));
|
||||
let mut d = start(&mut app, vec![], "surface.sweep");
|
||||
d.inputs[0].items = vec![Sel::SketchCurve { id: "l".into() }];
|
||||
assert!(apply_commands(&app, &d).is_err(), "no path yet");
|
||||
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
|
||||
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
|
||||
assert_eq!((id.as_str(), &p["path"], &p["curves"]), ("surface.sweep", &json!(["up"]), &json!(["l"])), "{p}");
|
||||
assert_ne!(p["path_sketch"], p["sketch"], "{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: "l".into() }]);
|
||||
assert_eq!(back.inputs[1].items, vec![Sel::SketchCurve { id: "up".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
|
||||
|
||||
@@ -15,6 +15,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
FeatureKind::Unstitch { .. } => "unstitch",
|
||||
FeatureKind::ReverseNormal { .. } => "reverse_normal",
|
||||
FeatureKind::SurfaceRevolve { .. } => "surface_revolve",
|
||||
FeatureKind::SurfaceSweep { .. } => "surface_sweep",
|
||||
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`). **339 commands.** Planned (registered, not built yet): 9. 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`). **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).
|
||||
|
||||
## 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 (9, 8 planned)
|
||||
## Surfaces (10, 7 planned)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
@@ -156,7 +156,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Trim | `surface.trim` | SURFACE › MODIFY |
|
||||
| Surface Extrude | `surface.extrude` | SURFACE › CREATE |
|
||||
| Surface Revolve | `surface.revolve` | SURFACE › CREATE |
|
||||
| Surface Sweep *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.sweep` | SURFACE › CREATE |
|
||||
| Surface Sweep | `surface.sweep` | SURFACE › CREATE |
|
||||
| Surface Loft *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `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 |
|
||||
|
||||
Reference in new issue
Block a user