Class-A surfacing 3/3: blend curves, curved-edge fillets, the G2 corner and continuity analysis (#1642)
Thanks @midasdf.
This commit is contained in:
1 parent
3126962cd4
commit
bbd4f29ebb
43 files changed
+4753
-132
No files matched your search
@@ -563,6 +563,10 @@ pub enum FeatureKind {
|
||||
/// What to do when the radius is too large for the geometry around the edges.
|
||||
#[serde(default, skip_serializing_if = "FilletFit::is_exact")]
|
||||
fit: FilletFit,
|
||||
/// What fills the corners where three blended edges meet: the rolling ball's sphere
|
||||
/// (default) or a G2 patch repeating each blend up to the second order.
|
||||
#[serde(default, skip_serializing_if = "FilletCorner::is_rolling")]
|
||||
corner: FilletCorner,
|
||||
},
|
||||
Chamfer {
|
||||
edges: Vec<Position>,
|
||||
@@ -1311,6 +1315,10 @@ pub enum FeatureKind {
|
||||
/// sketch curves: walls only, no caps.
|
||||
SurfaceLoft {
|
||||
sections: Vec<SheetSection>,
|
||||
#[serde(default = "loft_plain_continuity", skip_serializing_if = "loft_continuity_is_plain")]
|
||||
continuity: [Continuity; 2],
|
||||
#[serde(default, skip_serializing_if = "loft_crown_is_plain")]
|
||||
crown: [f64; 2],
|
||||
},
|
||||
/// The faces at `faces` copied out at a signed `distance` along their normals as a new
|
||||
/// surface body (0 copies them); `chain` takes each pick's whole smooth chain of faces;
|
||||
@@ -1491,6 +1499,23 @@ pub enum FeatureKind {
|
||||
},
|
||||
}
|
||||
|
||||
/// What fills the corner where three blended edges meet.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FilletCorner {
|
||||
/// A piece of the rolling ball's sphere: tangent (G1) to each blend.
|
||||
#[default]
|
||||
Rolling,
|
||||
/// A G2 patch repeating each blend's surface up to the second order (see `corner_g2`).
|
||||
G2,
|
||||
}
|
||||
|
||||
impl FilletCorner {
|
||||
pub fn is_rolling(&self) -> bool {
|
||||
matches!(self, FilletCorner::Rolling)
|
||||
}
|
||||
}
|
||||
|
||||
/// How Extend continues a surface past its edges.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -1734,6 +1759,28 @@ pub struct SheetSection {
|
||||
pub profiles: Option<ProfileSel>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub curves: Vec<String>,
|
||||
/// A section picked as an edge chain of a body instead of a sketch: `edges` are points on the
|
||||
/// chain, and the face the chain bounds is the surface the loft leaves there.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<BodyName>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub edges: Vec<Position>,
|
||||
}
|
||||
|
||||
/// A loft's continuity where a design does not say: both ends G0, the loft of old, which only
|
||||
/// passes through its sections.
|
||||
fn loft_plain_continuity() -> [Continuity; 2] {
|
||||
[Continuity::G0, Continuity::G0]
|
||||
}
|
||||
|
||||
/// A loft's continuity is plain when it only passes through its sections: both ends G0.
|
||||
fn loft_continuity_is_plain(c: &[Continuity; 2]) -> bool {
|
||||
*c == [Continuity::G0, Continuity::G0]
|
||||
}
|
||||
|
||||
/// A loft's crown is plain when neither end is filled out.
|
||||
fn loft_crown_is_plain(c: &[f64; 2]) -> bool {
|
||||
c.iter().all(|v| *v == 0.0)
|
||||
}
|
||||
|
||||
/// Coil cross-sections.
|
||||
|
||||
@@ -4551,8 +4551,16 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
};
|
||||
st.blend_edges.insert(f.id, edges.clone());
|
||||
let input = mb.traced();
|
||||
// A G2 corner is built by the corner construction alone, for a constant radius: any
|
||||
// other style (or a fitted radius) has no corner patch to repeat.
|
||||
if let FeatureKind::Fillet { corner: crate::FilletCorner::G2, style, fit, .. } = &f.kind
|
||||
&& (!matches!(style, crate::FilletStyle::Constant) || !matches!(fit, crate::FilletFit::Exact))
|
||||
{
|
||||
return Err(DocError::Invalid("a G2 corner is built for a constant-radius fillet (type: constant, fit: exact) only".into()));
|
||||
}
|
||||
// The blended body, and the lineage of what the last blend started from.
|
||||
let (nb, from) = match &f.kind {
|
||||
FeatureKind::Fillet { corner: crate::FilletCorner::G2, .. } => (kernel::fillet_g2_corners(&mb.body, &edges, r)?, input),
|
||||
FeatureKind::Fillet { style: crate::FilletStyle::Curvature, .. } => (kernel::fillet_g2(&mb.body, &edges, r)?, input),
|
||||
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => (kernel::fillet_chord(&mb.body, &edges, r)?, input),
|
||||
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => {
|
||||
@@ -5238,6 +5246,7 @@ mod chain_tests {
|
||||
body: None,
|
||||
style: crate::FilletStyle::Constant,
|
||||
fit: Default::default(),
|
||||
corner: Default::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `surface_offset`, `surface_ruled`, `surface_untrim`).
|
||||
|
||||
use super::*;
|
||||
use crate::{RuledCorners, RuledMode};
|
||||
use crate::{RuledCorners, RuledMode, SheetSection};
|
||||
use solidcraft_geom::Seg2;
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
@@ -313,7 +313,13 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
push_sheet(st, f, traced);
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections } => {
|
||||
FeatureKind::SurfaceLoft { sections, continuity, crown } => {
|
||||
// A loft with edge chains for sections, or one that has to leave its ends a particular
|
||||
// way, is built through `loft_blend` from the frames of every section; the plain one
|
||||
// (sketch chains only, both ends G0) is the loft of old.
|
||||
if *continuity != [kernel::Continuity::G0, kernel::Continuity::G0] || sections.iter().any(|s| s.body.is_some()) {
|
||||
return loft_blend(st, f, sections, *continuity, *crown, doc);
|
||||
}
|
||||
let mut secs = Vec::with_capacity(sections.len());
|
||||
let mut sketches = Vec::with_capacity(sections.len());
|
||||
for sec in sections {
|
||||
@@ -553,3 +559,118 @@ fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
}
|
||||
Ok(chains)
|
||||
}
|
||||
|
||||
/// A multi-section blend: every section as frames, the body ones read off the face their edge
|
||||
/// chain bounds, and the first and last leaving with `continuity` where the caller asks for it.
|
||||
fn loft_blend(
|
||||
st: &mut ModelState,
|
||||
f: &Feature,
|
||||
sections: &[SheetSection],
|
||||
continuity: [kernel::Continuity; 2],
|
||||
crown: [f64; 2],
|
||||
doc: &Document,
|
||||
) -> Result<()> {
|
||||
/// One section: a chain of sketch curves in its plane, or a body's edge chain.
|
||||
enum Piece {
|
||||
Sketch(solidcraft_geom::Plane, Vec<solidcraft_geom::Seg2>),
|
||||
Body(String, Vec<solidcraft_geom::Vec3>),
|
||||
}
|
||||
let mut pieces = Vec::with_capacity(sections.len());
|
||||
let mut centres = Vec::with_capacity(sections.len());
|
||||
for sec in sections {
|
||||
if let Some(name) = sec.body.as_deref() {
|
||||
if sec.edges.is_empty() {
|
||||
return Err(DocError::Invalid("a loft section picked from a body needs a chain of its edges".into()));
|
||||
}
|
||||
let n = sec.edges.len() as f64;
|
||||
centres.push(sec.edges.iter().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + **p) * (1.0 / n));
|
||||
pieces.push(Piece::Body(name.to_string(), crate::plain(&sec.edges)));
|
||||
continue;
|
||||
}
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(sec.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?,
|
||||
f.component,
|
||||
)?;
|
||||
let profiles = match (&sec.profiles, sec.curves.is_empty()) {
|
||||
(None, true) => Some(&ProfileSel::All),
|
||||
(p, _) => p.as_ref(),
|
||||
};
|
||||
let mut chains = sheet_chains(&ss, profiles, &sec.curves)?.into_iter();
|
||||
let (Some(chain), None) = (chains.next(), chains.next()) else {
|
||||
return Err(DocError::Invalid("each loft section must be one chain: one profile without holes, or curves meeting end to end".into()));
|
||||
};
|
||||
let pts: Vec<Vec2> = chain.iter().flat_map(|s| s.polyline(1e-2)).collect();
|
||||
let centre = pts.iter().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + ss.plane.to_world(*p)) * (1.0 / pts.len().max(1) as f64);
|
||||
centres.push(centre);
|
||||
pieces.push(Piece::Sketch(ss.plane, chain));
|
||||
}
|
||||
// Each section leaves towards the one after it; the last one leaves back into the blend.
|
||||
let stations = BLEND_STATIONS;
|
||||
let last = pieces.len().saturating_sub(1);
|
||||
let mut frames = Vec::with_capacity(pieces.len());
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
let (from, to) = if i < last { (i, i + 1) } else { (last, last.saturating_sub(1)) };
|
||||
let leave = match (centres.get(from), centres.get(to)) {
|
||||
(Some(a), Some(b)) => *b - *a,
|
||||
_ => solidcraft_geom::Vec3::ZERO,
|
||||
};
|
||||
match piece {
|
||||
Piece::Body(name, edges) => {
|
||||
let b = st.body(name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?.body.clone();
|
||||
frames.push(kernel::loft_blend::section_frames(&b, edges, leave, stations)?);
|
||||
}
|
||||
Piece::Sketch(plane, chain) => frames.push(chain_frames(plane, chain, leave, stations)),
|
||||
}
|
||||
}
|
||||
let body = kernel::loft_blend(&frames, continuity, crown, stations)?;
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The frames of one sketch chain, sampled by arclength at `stations` stations: a sketch has no
|
||||
/// face behind it, so the surface leaves along the run with no curvature to repeat there.
|
||||
fn chain_frames(
|
||||
plane: &solidcraft_geom::Plane,
|
||||
chain: &[solidcraft_geom::Seg2],
|
||||
leave: solidcraft_geom::Vec3,
|
||||
stations: usize,
|
||||
) -> Vec<kernel::Frame> {
|
||||
let mut pts: Vec<Vec2> = Vec::new();
|
||||
for seg in chain {
|
||||
for p in seg.polyline(1e-2) {
|
||||
if pts.last().is_none_or(|l: &Vec2| (*l - p).len() > 1e-12) {
|
||||
pts.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 || stations < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let total: f64 = pts.windows(2).map(|w| (w[1] - w[0]).len()).sum();
|
||||
if !(total > 1e-12) {
|
||||
return Vec::new();
|
||||
}
|
||||
let step = total / (stations - 1) as f64;
|
||||
let (mut walked, mut k) = (0.0f64, 0usize);
|
||||
let mut out = Vec::with_capacity(stations);
|
||||
for i in 0..stations {
|
||||
let want = step * i as f64;
|
||||
while k + 1 < pts.len() && walked + (pts[k + 1] - pts[k]).len() < want {
|
||||
walked += (pts[k + 1] - pts[k]).len();
|
||||
k += 1;
|
||||
}
|
||||
let (Some(a), Some(b)) = (pts.get(k).copied(), pts.get(k + 1).copied()) else {
|
||||
if let Some(p) = pts.get(k).copied() {
|
||||
out.push(kernel::Frame { p: plane.to_world(p), leave, bend: solidcraft_geom::Vec3::ZERO, bend_change: solidcraft_geom::Vec3::ZERO });
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let span = (b - a).len();
|
||||
let t = if span > 1e-12 { ((want - walked) / span).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let p = a * (1.0 - t) + b * t;
|
||||
out.push(kernel::Frame { p: plane.to_world(p), leave, bend: solidcraft_geom::Vec3::ZERO, bend_change: solidcraft_geom::Vec3::ZERO });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! The Class-A surface pass also lets a `Fillet` carry `corner` (`rolling`, the default and what
|
||||
//! every older design meant, or `g2`: a patch repeating each blend's surface up to the second
|
||||
//! order) and a `SurfaceLoft` carry `continuity` and `crown` and body edge-chain `sections` (a
|
||||
//! section picked as a `body` with `edges` instead of a `sketch`). A design without them saves as
|
||||
//! it always did — a loft without `continuity` reads and writes as `[g0, g0]`, the plain loft it
|
||||
//! always was — so older designs need no transformation; 0.13.x asks for an update instead of
|
||||
//! meeting a feature it can't read or dropping a corner style it does not round.
|
||||
@@ -113,6 +113,7 @@ fn box_fillet_cut_timeline() {
|
||||
body: None,
|
||||
style: Default::default(),
|
||||
fit: Default::default(),
|
||||
corner: Default::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -183,6 +184,7 @@ fn errors_are_reported_not_fatal() {
|
||||
body: None,
|
||||
style: Default::default(),
|
||||
fit: Default::default(),
|
||||
corner: Default::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -50,6 +50,7 @@ impl Walk for crate::document::Operation {}
|
||||
impl Walk for solidcraft_kernel::Continuity {}
|
||||
impl Walk for crate::document::Direction {}
|
||||
impl Walk for crate::document::FilletFit {}
|
||||
impl Walk for crate::document::FilletCorner {}
|
||||
impl Walk for crate::document::MeshData {}
|
||||
impl Walk for crate::document::RevolveConvention {}
|
||||
impl Walk for crate::document::CoilSection {}
|
||||
|
||||
Reference in new issue
Block a user