Surface Revolve: sheets turned from open curves and profiles (#824)
Surface Revolve turns open curves and closed profile boundaries about a sketch axis into surface bodies, with cone apexes and sphere poles where a chain ends on the axis, and a closed shell made solid. Adds the SurfaceRevolve feature (format 18 note), the surface.revolve command, a dialog with Edit Feature support and analytic kernel and engine tests. Part of #757.
This commit is contained in:
1 parent
b41ad23e3f
commit
2eb292dd4a
16 files changed
+411
-83
No files matched your search
@@ -221,7 +221,8 @@ impl FeatureKind {
|
||||
pt(&mut t.point);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. } => {
|
||||
FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. }
|
||||
| FeatureKind::SurfaceRevolve { axis: AxisRef::Line { origin, dir: d }, .. } => {
|
||||
pt(origin);
|
||||
dir(d);
|
||||
}
|
||||
@@ -322,6 +323,7 @@ impl FeatureKind {
|
||||
FeatureKind::Revolve { .. }
|
||||
| FeatureKind::Extrude { .. }
|
||||
| FeatureKind::SurfaceExtrude { .. }
|
||||
| FeatureKind::SurfaceRevolve { .. }
|
||||
| FeatureKind::Combine { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
|
||||
@@ -48,6 +48,7 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Extrude { sketch, .. }
|
||||
| FeatureKind::SurfaceExtrude { sketch, .. }
|
||||
| FeatureKind::SurfaceRevolve { sketch, .. }
|
||||
| FeatureKind::Revolve { sketch, .. }
|
||||
| FeatureKind::Emboss { sketch, .. }
|
||||
| FeatureKind::SheetBase { sketch, .. }
|
||||
|
||||
@@ -954,6 +954,23 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
faces: Vec<Vec3>,
|
||||
},
|
||||
/// A surface body turned about an axis from open curve chains and the boundaries of
|
||||
/// profiles: walls only, no caps (a solid where a full turn closes them).
|
||||
SurfaceRevolve {
|
||||
sketch: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
profiles: Option<ProfileSel>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
curves: Vec<String>,
|
||||
axis: AxisRef,
|
||||
angle: String,
|
||||
/// Two Sides: the angle on the other side.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
angle2: Option<String>,
|
||||
/// Symmetric: `angle` split evenly either side of the sketch.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
symmetric: bool,
|
||||
},
|
||||
/// A planar surface body extended outward by `distance` past the edges through `edges`.
|
||||
SurfaceExtend {
|
||||
body: String,
|
||||
@@ -1255,6 +1272,7 @@ impl FeatureKind {
|
||||
match self {
|
||||
K::Extrude { sketch, .. }
|
||||
| K::SurfaceExtrude { sketch, .. }
|
||||
| K::SurfaceRevolve { sketch, .. }
|
||||
| K::Revolve { sketch, .. }
|
||||
| K::Emboss { sketch, .. }
|
||||
| K::Rib { sketch, .. }
|
||||
@@ -1389,6 +1407,7 @@ impl FeatureKind {
|
||||
FeatureKind::Remove { .. } => "RemoveFeature",
|
||||
FeatureKind::Patch { .. } => "PatchFeature",
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature",
|
||||
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolveFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
FeatureKind::Thicken { .. } => "ThickenFeature",
|
||||
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
|
||||
@@ -1458,6 +1477,7 @@ impl FeatureKind {
|
||||
FeatureKind::Remove { .. } => "Remove",
|
||||
FeatureKind::Patch { .. } => "Patch",
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude",
|
||||
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolve",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
FeatureKind::Thicken { .. } => "Thicken",
|
||||
FeatureKind::SurfaceTrim { .. } => "Trim",
|
||||
@@ -1480,7 +1500,9 @@ impl FeatureKind {
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. } | FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. } => {
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. }
|
||||
| FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. }
|
||||
| FeatureKind::SurfaceRevolve { 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(),
|
||||
@@ -1496,7 +1518,9 @@ impl FeatureKind {
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } | FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. } => {
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. }
|
||||
| FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. }
|
||||
| FeatureKind::SurfaceRevolve { sketch, profiles: Some(profiles), .. } => {
|
||||
vec![(*sketch, profiles)]
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
@@ -1561,7 +1585,7 @@ impl FeatureKind {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, angle2, .. } => {
|
||||
FeatureKind::Revolve { angle, angle2, .. } | FeatureKind::SurfaceRevolve { angle, angle2, .. } => {
|
||||
v.push(angle);
|
||||
v.extend(angle2.iter().map(String::as_str));
|
||||
}
|
||||
|
||||
@@ -3634,7 +3634,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::SurfaceExtend { .. }
|
||||
| FeatureKind::SurfaceExtrude { .. }
|
||||
| FeatureKind::Unstitch { .. }
|
||||
| FeatureKind::ReverseNormal { .. } => surface::eval(doc, vals, f, st),
|
||||
| FeatureKind::ReverseNormal { .. }
|
||||
| FeatureKind::SurfaceRevolve { .. } => surface::eval(doc, vals, f, st),
|
||||
FeatureKind::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
|
||||
@@ -98,17 +98,28 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
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));
|
||||
let body = kernel::extrude_sheet(&ss.plane, &sheet_chains(&ss, profiles.as_ref(), curves)?, a + off, b + off)?;
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceRevolve { sketch, profiles, curves, axis, angle, angle2, symmetric } => {
|
||||
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,
|
||||
)?;
|
||||
let (o, d) = revolve_axis(&ss, axis)?;
|
||||
let a = val(vals, angle, Kind::Angle)?;
|
||||
let (start, sweep) = match (angle2, symmetric) {
|
||||
(Some(a2), _) => {
|
||||
let a2 = val(vals, a2, Kind::Angle)?;
|
||||
(-a2, a + a2)
|
||||
}
|
||||
}
|
||||
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)?;
|
||||
(None, true) => (-a / 2.0, a),
|
||||
(None, false) => (0.0, a),
|
||||
};
|
||||
let body = kernel::revolve_sheet(&ss.plane, &sheet_chains(&ss, profiles.as_ref(), curves)?, o, d, start, sweep)?;
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
@@ -148,6 +159,22 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a surface builder sweeps: the boundary loops of the picked profiles, then the picked
|
||||
/// curves chained end to end.
|
||||
fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
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".into()));
|
||||
}
|
||||
Ok(chains)
|
||||
}
|
||||
|
||||
/// Most sketch curves one feature chains.
|
||||
const MAX_CHAIN_CURVES: usize = 1000;
|
||||
|
||||
|
||||
@@ -116,6 +116,8 @@
|
||||
//! Under the same version, Delete Face can leave the gap open (`heal: false` on the feature:
|
||||
//! the faces left are a surface body); a Delete Face without it heals, as it always did, and
|
||||
//! 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.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ impl FeatureKind {
|
||||
v.push(("To offset", d, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, angle2, .. } => {
|
||||
FeatureKind::Revolve { angle, angle2, .. } | FeatureKind::SurfaceRevolve { angle, angle2, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
if let Some(a) = angle2 {
|
||||
v.push(("Angle 2", a, A));
|
||||
|
||||
@@ -576,18 +576,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None),
|
||||
};
|
||||
let axis = if let Some(o) = p.get("axis").filter(|v| v.is_object()) {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?;
|
||||
AxisRef::Line { origin, dir }
|
||||
} else {
|
||||
match str_(p, "axis") {
|
||||
Some(a @ ("X" | "Y" | "Z")) => AxisRef::World { axis: a.into() },
|
||||
Some(a @ ("x" | "y")) => AxisRef::SketchAxis { axis: a.into() },
|
||||
Some(id) => AxisRef::SketchLine { curve: id.into() },
|
||||
None => return Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z or {origin, dir}")),
|
||||
}
|
||||
};
|
||||
let axis = axis_param(p, cmd)?;
|
||||
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
|
||||
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
|
||||
let profiles = match face_profile {
|
||||
@@ -623,6 +612,22 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
/// A revolve axis from its parameter: a sketch line id, the sketch's x or y axis, a world axis,
|
||||
/// or {origin, dir}.
|
||||
pub(super) fn axis_param(p: &Value, cmd: &str) -> Result<AxisRef> {
|
||||
if let Some(o) = p.get("axis").filter(|v| v.is_object()) {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?;
|
||||
return Ok(AxisRef::Line { origin, dir });
|
||||
}
|
||||
match str_(p, "axis") {
|
||||
Some(a @ ("X" | "Y" | "Z")) => Ok(AxisRef::World { axis: a.into() }),
|
||||
Some(a @ ("x" | "y")) => Ok(AxisRef::SketchAxis { axis: a.into() }),
|
||||
Some(id) => Ok(AxisRef::SketchLine { curve: id.into() }),
|
||||
None => Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z or {origin, dir}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn radius_expr(s: &Session, p: &Value, cmd: &str) -> Result<String> {
|
||||
match (expr(p, "radius"), expr(p, "diameter")) {
|
||||
(Some(r), _) => Ok(r),
|
||||
|
||||
@@ -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, 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};
|
||||
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -34,10 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_extrude")
|
||||
.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")
|
||||
CommandSpec::new("surface.revolve", "Surface Revolve", surface_revolve)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_revolve")
|
||||
.params("planned: the parameters of solid.revolve, with open curve chains as well as profiles (no caps); caps?: bool"),
|
||||
.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")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_sweep")
|
||||
@@ -98,11 +98,7 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 (profiles, curves) = sheet_inputs(p, cmd)?;
|
||||
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"));
|
||||
@@ -133,6 +129,32 @@ fn reverse_normal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::ReverseNormal { body, faces })
|
||||
}
|
||||
|
||||
/// The profiles and curves a surface builder takes: `curves`, and `profiles` (all of them when
|
||||
/// neither is given).
|
||||
fn sheet_inputs(p: &Value, cmd: &str) -> Result<(Option<solidcraft_doc::ProfileSel>, Vec<String>)> {
|
||||
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 };
|
||||
Ok((profiles, curves))
|
||||
}
|
||||
|
||||
fn surface_revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.revolve";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let (profiles, curves) = sheet_inputs(p, cmd)?;
|
||||
let axis = axis_param(p, cmd)?;
|
||||
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
|
||||
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
|
||||
let angle2 = expr(p, "angle2");
|
||||
if let Some(a) = &angle2 {
|
||||
check_expr(s, a, Kind::Angle, cmd, "angle2")?;
|
||||
}
|
||||
let symmetric = bool_(p, "symmetric").unwrap_or(false) || str_(p, "direction").is_some_and(|d| d.eq_ignore_ascii_case("symmetric"));
|
||||
add_feature(s, p, FeatureKind::SurfaceRevolve { sketch, profiles, curves, axis, angle, angle2, symmetric })
|
||||
}
|
||||
|
||||
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() {
|
||||
@@ -364,13 +386,58 @@ mod tests {
|
||||
assert!(s.execute("face.delete", &json!({"faces": all, "body": "Healed", "heal": false})).is_err());
|
||||
}
|
||||
|
||||
/// Surface Revolve of open curves about the sketch's y axis: a line beside the axis makes a
|
||||
/// cylinder (all of it, a quarter, a symmetric quarter), a line from the axis a cone, a half
|
||||
/// circle a solid ball; a profile turned all the way round is the solid solid.revolve makes.
|
||||
#[test]
|
||||
fn surface_revolve_curves_and_profiles() {
|
||||
use std::f64::consts::PI;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[3, 0], [3, 4]], "ids": ["side"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 10], [3, 14]], "ids": ["slant"]}));
|
||||
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.revolve", json!({"sketch": "S", "curves": ["side"], "axis": "y", "body_name": "Tube"}));
|
||||
close(area(&s, "Tube"), 2.0 * PI * 3.0 * 4.0);
|
||||
run(&mut s, "surface.revolve", json!({"sketch": "S", "curves": ["side"], "axis": "Y", "angle": "90 deg", "body_name": "Quarter"}));
|
||||
close(area(&s, "Quarter"), PI / 2.0 * 3.0 * 4.0);
|
||||
run(
|
||||
&mut s,
|
||||
"surface.revolve",
|
||||
json!({"sketch": "S", "curves": ["side"], "axis": "y", "angle": "90 deg", "symmetric": true, "body_name": "Both"}),
|
||||
);
|
||||
let bb = solidcraft_kernel::measure(&s.world_state().body("Both").unwrap().body).unwrap().bbox;
|
||||
let z = 3.0 * (PI / 4.0).sin();
|
||||
assert!((bb.min.z + z).abs() < 1e-3 && (bb.max.z - z).abs() < 1e-3, "{bb:?}");
|
||||
run(&mut s, "surface.revolve", json!({"sketch": "S", "curves": ["slant"], "axis": "y", "body_name": "Cone"}));
|
||||
close(area(&s, "Cone"), PI * 3.0 * 5.0);
|
||||
assert!(s.world_state().body("Cone").unwrap().body.is_surface());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.world_state().body("Cone").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, "Cone") - area(&s, "Cone")).abs() < 1e-9);
|
||||
// A rectangle off the axis turned all the way round closes into the ring solid.revolve makes.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 0], "p1": [7, 3]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.revolve", json!({"sketch": "R", "axis": "y", "body_name": "Ring"}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "R", "axis": "y", "body_name": "Solid"}));
|
||||
assert!(!s.world_state().body("Ring").unwrap().body.is_surface());
|
||||
close(volume(&s, "Ring"), volume(&s, "Solid"));
|
||||
// Refused: a curve across the axis, no axis, and a turn of more than 360°.
|
||||
assert!(s.execute("surface.revolve", &json!({"sketch": "S", "curves": ["side"], "axis": {"origin": [3, 2, 0], "dir": [1, 0, 0]}})).is_err());
|
||||
assert!(s.execute("surface.revolve", &json!({"sketch": "S", "curves": ["side"]})).is_err());
|
||||
assert!(s.execute("surface.revolve", &json!({"sketch": "S", "curves": ["side"], "axis": "y", "angle": "400 deg"})).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.revolve",
|
||||
"surface.sweep",
|
||||
"surface.loft",
|
||||
"surface.offset",
|
||||
|
||||
@@ -113,7 +113,9 @@ 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, extrude_sheet, patch_edges, patch_region, remove_faces, reverse_faces, stitch, thicken, trim, unstitch};
|
||||
pub use surfaces::{
|
||||
copy_faces, extend, extrude_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, thicken, trim, unstitch,
|
||||
};
|
||||
pub use sweep_path::sweep_path;
|
||||
pub use topo::{CylinderFace, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
//! 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).
|
||||
//! sheets swept from sketch curves (surface extrude and revolve).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface3D, SearchNearestParameter};
|
||||
use solidcraft_geom::{Plane, Region2, Seg2, Vec3};
|
||||
use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
@@ -100,6 +100,68 @@ pub fn extrude_sheet(plane: &Plane, chains: &[Vec<Seg2>], start: f64, end: f64)
|
||||
Ok(rebuilt_body(surface_body(faces), &[], &rec))
|
||||
}
|
||||
|
||||
/// A surface revolved from chains of sketch segments on `plane` about the sketch line through
|
||||
/// `axis_origin` along `axis_dir`: each chain turned from `start` through `sweep` radians (a
|
||||
/// full turn at most), positive angles carrying it toward the sketch normal. A chain end on the
|
||||
/// axis stays put there (a cone's apex, a sphere's pole). The walls form one surface body with
|
||||
/// no caps, or a solid where they close (a full turn of a closed curve, or of a curve from the
|
||||
/// axis back to it); its history has no operands: every face is new.
|
||||
pub fn revolve_sheet(plane: &Plane, chains: &[Vec<Seg2>], axis_origin: Vec2, axis_dir: Vec2, start: f64, sweep: f64) -> Result<Body> {
|
||||
use std::f64::consts::TAU;
|
||||
if !crate::build::frame_ok(plane) || !axis_origin.is_finite() || !axis_dir.is_finite() {
|
||||
return Err(KernelError::Invalid("revolve frame is not finite and orthonormal".into()));
|
||||
}
|
||||
if !(start.is_finite() && sweep.is_finite() && start.abs() <= TAU && sweep.abs() <= TAU + 1e-9) {
|
||||
return Err(KernelError::Invalid("revolve angles must be finite, a full turn at most".into()));
|
||||
}
|
||||
if sweep.abs() < 1e-6 {
|
||||
return Err(KernelError::Invalid("revolve angle is zero".into()));
|
||||
}
|
||||
if chains.is_empty() || chains.len() > MAX_CHAINS {
|
||||
return Err(KernelError::Invalid("pick the curves or profiles to revolve".into()));
|
||||
}
|
||||
let d = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?;
|
||||
let side = |p: Vec2| d.cross(p - axis_origin);
|
||||
let on_axis = |p: Vec2| side(p).abs() < 1e-7;
|
||||
let (origin, n) = (plane.to_world(axis_origin), plane.normal());
|
||||
let axis = plane.dir_to_world(d).normalized().ok_or_else(|| KernelError::Invalid("revolve frame has no axis".into()))?;
|
||||
let mut rec = Rebuild::default();
|
||||
let mut faces = Vec::new();
|
||||
for chain in chains {
|
||||
// On one side of the axis; the point farthest from it fixes the turning sense.
|
||||
let pts: Vec<Vec2> = chain.iter().flat_map(|s| s.polyline(1e-3)).collect();
|
||||
if pts.iter().any(|p| side(*p) < -1e-7) && pts.iter().any(|p| side(*p) > 1e-7) {
|
||||
return Err(KernelError::Invalid("the curves cross the revolve axis".into()));
|
||||
}
|
||||
let far = pts.iter().copied().max_by(|a, b| side(*a).abs().total_cmp(&side(*b).abs())).filter(|p| !on_axis(*p));
|
||||
let Some(far) = far else { return Err(KernelError::Invalid("the curves lie on the revolve axis".into())) };
|
||||
let radial = plane.to_world(far) - origin;
|
||||
let a = if axis.cross(radial).dot(n) < 0.0 { -axis } else { axis };
|
||||
let (first, last) = match (chain.first(), chain.last()) {
|
||||
(Some(f), Some(l)) => (f.start(), l.end()),
|
||||
_ => return Err(KernelError::Invalid("an empty curve chain".into())),
|
||||
};
|
||||
let closed = first.dist(last) <= 1e-6;
|
||||
if closed && pts.iter().any(|p| on_axis(*p)) {
|
||||
return Err(KernelError::Invalid("not supported yet: a closed curve touching the revolve axis".into()));
|
||||
}
|
||||
let wire = crate::build::chain_wire(plane, chain)?;
|
||||
let walls: mt::Shell = guard("surface revolve", || {
|
||||
let wire = mt::builder::rotated(&wire, p3(origin), crate::body::v3(a), mt::Rad(start));
|
||||
Ok(match (on_axis(first), on_axis(last)) {
|
||||
(true, _) => mt::builder::cone(&wire, crate::body::v3(a), mt::Rad(sweep)),
|
||||
(false, true) => mt::builder::cone(&wire.inverse(), crate::body::v3(a), mt::Rad(sweep)),
|
||||
(false, false) => mt::builder::rsweep(&wire, p3(origin), crate::body::v3(a), mt::Rad(sweep)),
|
||||
})
|
||||
})?;
|
||||
for f in walls.face_iter() {
|
||||
rec.made(f, Vec::new());
|
||||
faces.push(f.clone());
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -773,6 +835,50 @@ mod tests {
|
||||
assert!(rel(measure(&closed).unwrap().volume, 400.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// Sheets revolved about the sketch's y axis, against their analytic areas: a cylinder (and a
|
||||
/// quarter of one, started a quarter turn round), cones with the apex at either end, a sphere
|
||||
/// from a half circle, and a square's ring (Pappus: 2π × centroid distance × length).
|
||||
#[test]
|
||||
fn revolve_sheet_areas() {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
let sheet = |chain: Vec<Seg2>, start: f64, sweep: f64| {
|
||||
revolve_sheet(&Plane::XY, &[chain], Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0), start, sweep).unwrap()
|
||||
};
|
||||
let rev = |chain: Vec<Seg2>, start: f64, sweep: f64| measure(&sheet(chain, start, sweep)).unwrap().area;
|
||||
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
|
||||
let close = |a: f64, want: f64| assert!(rel(a, want) < 2e-3, "{a} vs {want}"); // (measured on meshes)
|
||||
close(rev(vec![line((3.0, 0.0), (3.0, 4.0))], 0.0, 2.0 * PI), 2.0 * PI * 3.0 * 4.0);
|
||||
close(rev(vec![line((3.0, 0.0), (3.0, 4.0))], FRAC_PI_2, FRAC_PI_2), FRAC_PI_2 * 3.0 * 4.0);
|
||||
close(rev(vec![line((0.0, 0.0), (3.0, 4.0))], 0.0, 2.0 * PI), PI * 3.0 * 5.0);
|
||||
close(rev(vec![line((3.0, 4.0), (0.0, 0.0))], 0.0, 2.0 * PI), PI * 3.0 * 5.0);
|
||||
assert!(sheet(vec![line((3.0, 0.0), (3.0, 4.0))], 0.0, 1.0).is_surface());
|
||||
// Closing shells are solids, outward: a sphere from a half circle, a square's ring.
|
||||
let half = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 2.0, start: -FRAC_PI_2, sweep: PI };
|
||||
let ball = measure(&sheet(vec![half], 0.0, 2.0 * PI)).unwrap();
|
||||
close(ball.area, 4.0 * PI * 4.0);
|
||||
close(ball.volume, 4.0 / 3.0 * PI * 8.0);
|
||||
assert!(sheet(vec![half], 0.0, PI).is_surface());
|
||||
let sq = Loop2::polygon(&[Vec2::new(2.0, 0.0), Vec2::new(3.0, 0.0), Vec2::new(3.0, 1.0), Vec2::new(2.0, 1.0)]);
|
||||
let ring = measure(&sheet(sq.segs.clone(), 0.0, 2.0 * PI)).unwrap();
|
||||
close(ring.area, 2.0 * PI * (2.0 + 3.0 + 2.5 + 2.5));
|
||||
close(ring.volume, 2.0 * PI * 2.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_sheet_refuses_bad_input() {
|
||||
let rev = |chain: Vec<Seg2>, sweep: f64| revolve_sheet(&Plane::XY, &[chain], Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0), 0.0, sweep);
|
||||
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
|
||||
assert!(rev(vec![line((-1.0, 0.0), (1.0, 1.0))], 1.0).is_err(), "crosses the axis");
|
||||
assert!(rev(vec![line((0.0, 0.0), (0.0, 1.0))], 1.0).is_err(), "on the axis");
|
||||
assert!(rev(vec![line((1.0, 0.0), (1.0, 1.0))], 0.0).is_err(), "zero angle");
|
||||
assert!(rev(vec![line((1.0, 0.0), (1.0, 1.0))], 7.0).is_err(), "more than a turn");
|
||||
assert!(rev(vec![line((1.0, 0.0), (1.0, 1.0))], f64::NAN).is_err());
|
||||
let touching = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(0.0, 1.0)]);
|
||||
assert!(rev(touching.segs, 1.0).is_err(), "a closed curve touching the axis");
|
||||
assert!(revolve_sheet(&Plane::XY, &[], Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0), 0.0, 1.0).is_err());
|
||||
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());
|
||||
}
|
||||
|
||||
#[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) }];
|
||||
|
||||
@@ -4417,7 +4417,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
other => {
|
||||
let (kind, inputs, extra) = crate::dialogs_sheet::for_feature(app, other)
|
||||
.or_else(|| crate::dialogs_plastic::for_feature(app, other))
|
||||
.or_else(|| crate::dialogs_surface::for_feature(app, other))?;
|
||||
.or_else(|| crate::dialogs_surface::for_feature(app, other, &feature_frame(s, f.component)))?;
|
||||
let mut d = Dialog::new(kind, inputs);
|
||||
d.extra = extra;
|
||||
d
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solidcraft_engine::doc::{Direction, FaceAt, FeatureKind, PlaneRef, ProfileSel};
|
||||
use solidcraft_engine::doc::{AxisRef, Direction, FaceAt, FeatureKind, Mat, 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, CURVES, EDGES, FACES, PLANES, PROFILES, SelInput};
|
||||
use crate::selection::{AXES, BODIES, CURVES, EDGES, FACES, PLANES, PROFILES, SelInput};
|
||||
|
||||
/// A surface tool dialog's values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -19,6 +19,12 @@ pub enum Surf {
|
||||
distance: String,
|
||||
direction: usize,
|
||||
},
|
||||
/// `axis`: a stored {origin, dir} axis, used while no axis is picked.
|
||||
Revolve {
|
||||
angle: String,
|
||||
symmetric: bool,
|
||||
axis: Option<Value>,
|
||||
},
|
||||
Patch,
|
||||
Unstitch,
|
||||
ReverseNormal,
|
||||
@@ -44,6 +50,7 @@ impl Surf {
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Surf::Extrude { .. } => "SURFACE EXTRUDE",
|
||||
Surf::Revolve { .. } => "SURFACE REVOLVE",
|
||||
Surf::Patch => "PATCH",
|
||||
Surf::Unstitch => "UNSTITCH",
|
||||
Surf::ReverseNormal => "REVERSE NORMAL",
|
||||
@@ -61,6 +68,7 @@ impl Surf {
|
||||
Some(match self {
|
||||
Surf::Thicken { thickness, .. } => ("Thickness", ValueKind::Length, thickness),
|
||||
Surf::Extend { distance } | Surf::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance),
|
||||
Surf::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -72,6 +80,7 @@ const EXTRUDE_SIDES: [(&str, &str); 3] = [("One Side", "positive"), ("Symmetric"
|
||||
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::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)],
|
||||
@@ -92,6 +101,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
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.patch" => Surf::Patch,
|
||||
"surface.unstitch" => Surf::Unstitch,
|
||||
"surface.reverse_normal" => Surf::ReverseNormal,
|
||||
@@ -136,6 +146,16 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
enter |= field(ui, distance);
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Revolve { angle, symmetric, .. } => {
|
||||
row_label(ui, "Angle");
|
||||
enter |= field(ui, angle);
|
||||
ui.end_row();
|
||||
row_label(ui, "Direction");
|
||||
let mut dir = usize::from(*symmetric);
|
||||
combo(ui, "sr_dir", &["One Side", "Symmetric"], &mut dir);
|
||||
*symmetric = dir == 1;
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Extrude { distance, direction } => {
|
||||
row_label(ui, "Distance");
|
||||
enter |= field(ui, distance);
|
||||
@@ -175,6 +195,39 @@ fn body_and_faces(picks: &[Sel]) -> Result<(String, Vec<Value>), String> {
|
||||
Ok((body, faces))
|
||||
}
|
||||
|
||||
/// The sketch, profiles and curves of a surface builder's picks (all in one sketch).
|
||||
fn sheet_picks(app: &SolveApp, picks: &[Sel]) -> Result<Value, String> {
|
||||
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 });
|
||||
if !curves.is_empty() {
|
||||
p["curves"] = json!(curves);
|
||||
}
|
||||
if !profiles.is_empty() {
|
||||
p["profiles"] = json!(profiles.iter().map(|(_, i)| *i).collect::<Vec<_>>());
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// A surface builder's stored picks as selections again: its profiles, then its curves.
|
||||
fn sheet_sels(app: &SolveApp, sketch: u64, profiles: Option<&ProfileSel>, curves: &[String]) -> Vec<Sel> {
|
||||
let st = app.session.world_state();
|
||||
let mut sels: Vec<Sel> = match (profiles, st.sketch(sketch)) {
|
||||
(Some(sel), Some(ss)) => crate::dialogs::profile_indices(ss, sel).into_iter().map(|index| Sel::Profile { sketch, index }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
sels.extend(curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }));
|
||||
sels
|
||||
}
|
||||
|
||||
/// A splitting or trimming tool: a plane by name, a planar face as its plane, or any other
|
||||
/// face as {body, point} (its surface extended).
|
||||
fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), String> {
|
||||
@@ -194,30 +247,25 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
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<_>>());
|
||||
}
|
||||
let mut p = sheet_picks(app, items(inputs, 0))?;
|
||||
p["distance"] = json!(distance);
|
||||
p["direction"] = json!(EXTRUDE_SIDES.get(*direction).map_or("positive", |(_, d)| *d));
|
||||
("surface.extrude", p)
|
||||
}
|
||||
Surf::Revolve { angle, symmetric, axis } => {
|
||||
need(0, "profiles or curves to revolve")?;
|
||||
let mut p = sheet_picks(app, items(inputs, 0))?;
|
||||
p["axis"] = match items(inputs, 1).first() {
|
||||
Some(Sel::Axis { name }) => json!(name),
|
||||
Some(Sel::SketchCurve { id }) => json!(id),
|
||||
_ => axis.clone().ok_or("select an axis")?,
|
||||
};
|
||||
p["angle"] = json!(angle);
|
||||
if *symmetric {
|
||||
p["symmetric"] = json!(true);
|
||||
}
|
||||
("surface.revolve", p)
|
||||
}
|
||||
Surf::Patch => {
|
||||
need(0, "a boundary (sketch profiles or a loop of edges)")?;
|
||||
let profiles: Vec<(u64, usize)> =
|
||||
@@ -309,23 +357,37 @@ fn tool_sel(app: &SolveApp, plane: &PlaneRef, tool: Option<&FaceAt>) -> Option<S
|
||||
}
|
||||
|
||||
/// The dialog for editing a surface feature.
|
||||
pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelInput>, Map<String, Value>)> {
|
||||
/// `frame` maps the feature's component frame to the world, for an axis stored as a line.
|
||||
pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(Kind, Vec<SelInput>, Map<String, Value>)> {
|
||||
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 })),
|
||||
}
|
||||
FeatureKind::SurfaceRevolve { sketch, profiles, curves, axis, angle, angle2, symmetric } => {
|
||||
let (pick, stored) = match axis {
|
||||
AxisRef::World { axis } => (Some(Sel::Axis { name: axis.to_ascii_uppercase() }), None),
|
||||
AxisRef::SketchAxis { axis } => (Some(Sel::Axis { name: axis.to_ascii_lowercase() }), None),
|
||||
AxisRef::SketchLine { curve } => (Some(Sel::SketchCurve { id: curve.clone() }), None),
|
||||
AxisRef::Line { origin, dir } => (
|
||||
None,
|
||||
Some(json!({
|
||||
"origin": pt(solidcraft_engine::doc::apply_point(frame, *origin)),
|
||||
"dir": pt(solidcraft_engine::doc::apply_vector(frame, *dir))
|
||||
})),
|
||||
),
|
||||
};
|
||||
let mut extra = Map::new();
|
||||
if let Some(a2) = angle2 {
|
||||
extra.insert("angle2".into(), json!(a2));
|
||||
}
|
||||
sels.extend(curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }));
|
||||
let k = Surf::Revolve { angle: angle.clone(), symmetric: *symmetric, axis: stored };
|
||||
let mut ins = inputs(&k);
|
||||
for (inp, items) in ins.iter_mut().zip([sheet_sels(app, *sketch, profiles.as_ref(), curves), pick.into_iter().collect()]) {
|
||||
inp.items = items;
|
||||
}
|
||||
return Some((Kind::Surface(k), ins, extra));
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let sels = sheet_sels(app, *sketch, profiles.as_ref(), curves);
|
||||
let direction = match extent.direction {
|
||||
Direction::Positive => 0,
|
||||
Direction::Symmetric => 1,
|
||||
|
||||
@@ -854,6 +854,34 @@ fn unstitch_and_reverse_normal_dialogs_take_a_body_or_faces() {
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_revolve_dialog_takes_curves_and_an_axis() {
|
||||
// A line beside the sketch's y axis, turned a quarter round: a quarter cylinder. Edit
|
||||
// Feature brings back the curve and the axis.
|
||||
let mut app = app_with(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.line", "params": {"points": [[3, 0], [3, 4]], "ids": ["side"]}},
|
||||
{"command": "sketch.finish", "params": {}}
|
||||
]));
|
||||
let mut d = start(&mut app, vec![], "surface.revolve");
|
||||
d.inputs[0].items = vec![Sel::SketchCurve { id: "side".into() }];
|
||||
assert!(apply_commands(&app, &d).is_err(), "no axis yet");
|
||||
d.inputs[1].items = vec![Sel::Axis { name: "y".into() }];
|
||||
if let Kind::Surface(crate::dialogs_surface::Surf::Revolve { angle, .. }) = &mut d.kind {
|
||||
*angle = "90 deg".into();
|
||||
}
|
||||
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
|
||||
assert_eq!((id.as_str(), &p["axis"], &p["curves"]), ("surface.revolve", &json!("y"), &json!(["side"])), "{p}");
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
let area = app.session.model.state().bodies.last().unwrap().mesh().measure().area;
|
||||
let want = std::f64::consts::FRAC_PI_2 * 3.0 * 4.0;
|
||||
assert!((area - want).abs() < want * 2e-3, "{area} vs {want}");
|
||||
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: "side".into() }]);
|
||||
assert_eq!(back.inputs[1].items, vec![Sel::Axis { name: "y".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
|
||||
|
||||
@@ -14,6 +14,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
FeatureKind::SurfaceExtrude { .. } => "surface_extrude",
|
||||
FeatureKind::Unstitch { .. } => "unstitch",
|
||||
FeatureKind::ReverseNormal { .. } => "reverse_normal",
|
||||
FeatureKind::SurfaceRevolve { .. } => "surface_revolve",
|
||||
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`). **338 commands.** Planned (registered, not built yet): 10. 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`). **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).
|
||||
|
||||
## 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 (8, 9 planned)
|
||||
## Surfaces (9, 8 planned)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
@@ -155,7 +155,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Extend | `surface.extend` | SURFACE › MODIFY |
|
||||
| Trim | `surface.trim` | SURFACE › MODIFY |
|
||||
| Surface Extrude | `surface.extrude` | SURFACE › CREATE |
|
||||
| Surface Revolve *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.revolve` | SURFACE › CREATE |
|
||||
| Surface Revolve | `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 |
|
||||
| Offset Surface *(planned: [#758](https://github.com/bherbruck/solidcraft/issues/758))* | `surface.offset` | SURFACE › CREATE |
|
||||
|
||||
Reference in new issue
Block a user