Class-A surfacing 2/3: surface match, boundary patch and CV fairing (#1487)
Thanks @midasdf.
This commit is contained in:
1 parent
7f79443b46
commit
07da7f8065
27 files changed
+3513
-11
No files matched your search
@@ -1384,6 +1384,59 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
stitch: bool,
|
||||
},
|
||||
/// One surface patch through a network of two to four boundary curves of a sketch
|
||||
/// (`surface.boundary`, issue #763): the patch runs through the curves in loop order and leaves
|
||||
/// each boundary at its own `order` — a sketch curve's boundary is a plane, so G1 and up hold
|
||||
/// exactly — with `crown` shaping the middle without touching a boundary.
|
||||
SurfaceBoundary {
|
||||
sketch: SketchId,
|
||||
/// The curves in loop order: each one ends where the next begins, and the last where the
|
||||
/// first does.
|
||||
curves: Vec<String>,
|
||||
/// How much of the surface across each boundary the patch repeats, one per curve (none: a
|
||||
/// tangent plane, `g1`, on every side).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
orders: Vec<Continuity>,
|
||||
/// How full the middle is: 0 the plain construction, up to 3 fuller, down to -0.9 flatter.
|
||||
#[serde(default)]
|
||||
crown: f64,
|
||||
/// The stations every boundary carries.
|
||||
#[serde(default = "boundary_samples")]
|
||||
samples: usize,
|
||||
},
|
||||
/// One face of a surface body rebuilt against another body (`surface.match`): the rebuilt
|
||||
/// boundary nearest the chain of `edges` on `target` takes that chain's place and repeats the
|
||||
/// target's own shape along the seam up to `order`, while the boundary opposite it, and the
|
||||
/// body's name, stay where they were. `faces` picks the face to rebuild (its first, when none).
|
||||
SurfaceMatch {
|
||||
body: BodyName,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
faces: Vec<FacePick>,
|
||||
target: BodyName,
|
||||
edges: Vec<Position>,
|
||||
#[serde(default = "match_order")]
|
||||
order: Continuity,
|
||||
},
|
||||
/// The B-spline faces at `faces` of `body` faired (`surface.fair`): each face's interior
|
||||
/// control vertices move to flatten the thin-plate (curvature-variation) energy of the face,
|
||||
/// `strength` of the full least-squares step at a time, for at most `iterations` steps.
|
||||
/// `keep_edges` holds the row of control points next to each edge as well as the boundary row
|
||||
/// itself, so the face keeps meeting its neighbours in position and in tangent. `tolerance`
|
||||
/// is the farthest a control point may move, so the faired face stays within it of the one the
|
||||
/// design asked for; an empty `faces` fairs every face of the body.
|
||||
SurfaceFair {
|
||||
body: BodyName,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
faces: Vec<FacePick>,
|
||||
#[serde(default = "fair_strength")]
|
||||
strength: f64,
|
||||
#[serde(default = "fair_iterations")]
|
||||
iterations: u32,
|
||||
#[serde(default = "fair_keep_edges")]
|
||||
keep_edges: bool,
|
||||
#[walk(length = "Tolerance")]
|
||||
tolerance: Expr,
|
||||
},
|
||||
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
|
||||
/// `keep`.
|
||||
SurfaceTrim {
|
||||
@@ -1600,10 +1653,33 @@ fn is_g0(g: &Continuity) -> bool {
|
||||
}
|
||||
|
||||
/// The continuity a blend repeats by default: the tangent plane on both sides.
|
||||
/// The stations a boundary patch carries by default.
|
||||
fn boundary_samples() -> usize {
|
||||
25
|
||||
}
|
||||
|
||||
fn blend_orders() -> [Continuity; 2] {
|
||||
[Continuity::G1, Continuity::G1]
|
||||
}
|
||||
|
||||
/// The continuity a match holds by default: position, tangent plane and curvature.
|
||||
fn match_order() -> Continuity {
|
||||
Continuity::G2
|
||||
}
|
||||
|
||||
/// A fairing's defaults for a design that leaves them out: the whole least-squares step at a
|
||||
/// time, and the boundary held (the row next to each edge too, so the face still meets its
|
||||
/// neighbours in tangent).
|
||||
fn fair_strength() -> f64 {
|
||||
1.0
|
||||
}
|
||||
fn fair_iterations() -> u32 {
|
||||
1
|
||||
}
|
||||
fn fair_keep_edges() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Triangles of one imported mesh body (millimetres).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MeshData {
|
||||
@@ -1896,6 +1972,9 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
|
||||
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
|
||||
FeatureKind::SurfaceBoundary { .. } => "BoundaryFeature",
|
||||
FeatureKind::SurfaceMatch { .. } => "MatchFeature",
|
||||
FeatureKind::SurfaceFair { .. } => "FairFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
FeatureKind::Thicken { .. } => "ThickenFeature",
|
||||
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
|
||||
@@ -1973,6 +2052,9 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
|
||||
FeatureKind::SurfaceBlend { .. } => "Blend",
|
||||
FeatureKind::SurfaceBoundary { .. } => "Boundary",
|
||||
FeatureKind::SurfaceMatch { .. } => "Match",
|
||||
FeatureKind::SurfaceFair { .. } => "Fair",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
FeatureKind::Thicken { .. } => "Thicken",
|
||||
FeatureKind::SurfaceTrim { .. } => "Trim",
|
||||
|
||||
@@ -4133,6 +4133,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::SurfaceSweep { .. }
|
||||
| FeatureKind::SurfaceLoft { .. }
|
||||
| FeatureKind::SurfaceBlend { .. }
|
||||
| FeatureKind::SurfaceBoundary { .. }
|
||||
| FeatureKind::SurfaceMatch { .. }
|
||||
| FeatureKind::SurfaceFair { .. }
|
||||
| FeatureKind::SurfaceOffset { .. }
|
||||
| FeatureKind::SurfaceRuled { .. }
|
||||
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st, warning),
|
||||
|
||||
@@ -145,6 +145,78 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
st.bodies.push(ModelBody::new(name, made, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceBoundary { sketch, curves, orders, crown, samples } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
|
||||
let body = kernel::boundary_patch(&boundaries, *crown, *samples)?;
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceMatch { body, faces, target, edges, order } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let tb = st.body(target).ok_or_else(|| DocError::Unknown(format!("body `{target}`")))?.body.clone();
|
||||
if edges.is_empty() {
|
||||
return Err(DocError::Invalid("a match needs a chain of the target's edges".into()));
|
||||
}
|
||||
// Picked by a point on the face; without picks, the body's first face.
|
||||
let picked: Vec<solidcraft_geom::Vec3> = if faces.is_empty() {
|
||||
let faces = mb.body.faces(mb.body.size() * 1e-2)?;
|
||||
vec![faces.first().ok_or_else(|| DocError::Invalid(format!("`{body}` has no face to match")))?.centroid]
|
||||
} else {
|
||||
crate::plain(faces)
|
||||
};
|
||||
let made = kernel::match_surface(&mb.body, &picked, &tb, &crate::plain(edges), *order)?;
|
||||
// A match rebuilds the face in place: the body keeps its name, and whatever meets it
|
||||
// downstream still finds it.
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, made, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceFair { body, faces, strength, iterations, keep_edges, tolerance } => {
|
||||
let tol = val(vals, tolerance, Kind::Length)?;
|
||||
let opts = kernel::FairOptions::new(*strength, *iterations as usize, *keep_edges, tol)?;
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
// Picked by a point on the face; without picks, every face of the body.
|
||||
let mut picked: Vec<usize> = Vec::new();
|
||||
if faces.is_empty() {
|
||||
picked.extend(0..mb.body.face_count());
|
||||
} else {
|
||||
for p in faces {
|
||||
let fi =
|
||||
crate::appearance::face_index_at(&mb, p.0).ok_or_else(|| DocError::Invalid(format!("Fair: no face of {body} at {p:?}")))?;
|
||||
if !picked.contains(&fi) {
|
||||
picked.push(fi);
|
||||
}
|
||||
}
|
||||
}
|
||||
if picked.is_empty() {
|
||||
return Err(DocError::Invalid("the body has no faces to fair".into()));
|
||||
}
|
||||
// The face keeps every edge it had: only the surface between them is faired. The
|
||||
// energy before and after goes into the feature's warning, so the reply can say how
|
||||
// much the face moved.
|
||||
let (faired, report) = kernel::fair_faces(&mb.body, &picked, &opts)?;
|
||||
*warning = Some(format!(
|
||||
"faired {} face(s): energy {:.6} → {:.6} in {} step(s), control points moved up to {:.4} mm",
|
||||
picked.len(),
|
||||
report.energy_before,
|
||||
report.energy_after,
|
||||
report.steps,
|
||||
report.movement
|
||||
));
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, faired, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
@@ -349,6 +421,70 @@ fn push_sheet(st: &mut ModelState, f: &Feature, traced: crate::lineage::Traced)
|
||||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||||
}
|
||||
|
||||
/// The boundaries `surface.boundary` builds its patch from: each picked curve's own stations in
|
||||
/// the sketch's frame, every one of them leaving the boundary square into the loop — the direction
|
||||
/// from the curve towards the middle of the loop, taken across its tangent — over the plane the
|
||||
/// curve lies in (so a chain of planar curves makes a flat patch, and a network that is not
|
||||
/// coplanar is followed as the plane through the curve's own tangent). `orders` sets how much of
|
||||
/// that plane each side repeats.
|
||||
fn boundary_frames(
|
||||
ss: &SolvedSketch,
|
||||
curves: &[String],
|
||||
orders: &[solidcraft_kernel::Continuity],
|
||||
samples: usize,
|
||||
) -> Result<Vec<solidcraft_kernel::Boundary>> {
|
||||
use solidcraft_kernel::{Boundary, Frame};
|
||||
// The stations along each curve, evenly in its own arclength, so every side carries the same
|
||||
// number of frames and the patch's net is square.
|
||||
let mut sides: Vec<Vec<solidcraft_geom::Vec3>> = Vec::with_capacity(curves.len());
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("sketch curve `{id}`")))?;
|
||||
let pieces = ss.sketch.exact_pieces(ci);
|
||||
if pieces.is_empty() {
|
||||
return Err(DocError::Invalid(format!("curve `{id}` has no exact geometry to build on")));
|
||||
}
|
||||
let lengths: Vec<f64> = pieces.iter().map(Seg2::length).collect();
|
||||
let total: f64 = lengths.iter().sum();
|
||||
if !total.is_finite() || total <= 1e-9 {
|
||||
return Err(DocError::Invalid(format!("curve `{id}` has no length")));
|
||||
}
|
||||
let mut pts = Vec::with_capacity(samples);
|
||||
let (mut used, mut at) = (0.0, 0usize);
|
||||
for k in 0..samples {
|
||||
let want = total * k as f64 / (samples.saturating_sub(1)).max(1) as f64;
|
||||
while at + 1 < pieces.len() && used + lengths[at] < want {
|
||||
used += lengths[at];
|
||||
at += 1;
|
||||
}
|
||||
let (Some(piece), Some(len)) = (pieces.get(at), lengths.get(at)) else { return Err(DocError::Invalid("boundary piece".into())) };
|
||||
let t = ((want - used) / len.max(1e-12)).clamp(0.0, 1.0);
|
||||
pts.push(ss.plane.to_world(piece.point_at(t)));
|
||||
}
|
||||
sides.push(pts);
|
||||
}
|
||||
// The middle of the loop, so every side leaves towards the inside of the patch.
|
||||
let count = sides.iter().map(Vec::len).sum::<usize>().max(1) as f64;
|
||||
let centre = sides.iter().flatten().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + *p) * (1.0 / count);
|
||||
let mut out = Vec::with_capacity(sides.len());
|
||||
for (side, pts) in sides.iter().enumerate() {
|
||||
let mut frames = Vec::with_capacity(pts.len());
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
// The tangent from the stations on either side, one-sided at the ends (where the side
|
||||
// only has one neighbour).
|
||||
let before = if i == 0 { *p } else { pts[i - 1] };
|
||||
let after = if i + 1 == pts.len() { *p } else { pts[i + 1] };
|
||||
let tangent = (after - before).normalized().ok_or_else(|| DocError::Invalid("a boundary curve has a stationary station".into()))?;
|
||||
let across = centre - *p;
|
||||
let leave = (across - tangent * across.dot(tangent))
|
||||
.normalized()
|
||||
.ok_or_else(|| DocError::Invalid("a boundary runs through the middle of the loop: no patch can leave it".into()))?;
|
||||
frames.push(Frame { p: *p, leave, bend: Vec3::ZERO, bend_change: Vec3::ZERO });
|
||||
}
|
||||
out.push(Boundary { frames, order: orders.get(side).copied().unwrap_or(solidcraft_kernel::Continuity::G1) });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 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>>> {
|
||||
|
||||
@@ -161,6 +161,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
("surface_loft", json!({"type": "surface_loft", "sections": []})),
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
|
||||
// The Class-A surface family (surface.blend/boundary/match/fair).
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
|
||||
("surface_match", json!({"type": "surface_match", "body": "B", "faces": [o], "target": "C", "edges": [o]})),
|
||||
("surface_fair", json!({"type": "surface_fair", "body": "B", "faces": [o], "tolerance": "sfair.tolerance"})),
|
||||
("unstitch", json!({"type": "unstitch", "body": "B"})),
|
||||
("reverse_normal", json!({"type": "reverse_normal", "body": "B"})),
|
||||
("surface_untrim", json!({"type": "surface_untrim", "face": {"body": "B", "point": o}, "holes": [o], "outer": true})),
|
||||
|
||||
@@ -340,6 +340,13 @@ surface_offset
|
||||
Distance = soff.distance (Length)
|
||||
Tolerance = soff.tolerance (Length)
|
||||
expressions: soff.distance, soff.tolerance
|
||||
surface_blend
|
||||
surface_boundary
|
||||
surface_match
|
||||
surface_fair
|
||||
inputs:
|
||||
Tolerance = sfair.tolerance (Length)
|
||||
expressions: sfair.tolerance
|
||||
unstitch
|
||||
reverse_normal
|
||||
surface_untrim
|
||||
|
||||
@@ -154,6 +154,15 @@ surface_blend
|
||||
surface_offset
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
surface_blend
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
surface_boundary
|
||||
surface_match
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
surface_fair
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
unstitch
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
reverse_normal
|
||||
|
||||
@@ -177,6 +177,15 @@ surface_blend
|
||||
remap_bodies: from.body
|
||||
surface_offset
|
||||
remap_bodies: body
|
||||
surface_blend
|
||||
remap_bodies: from.body
|
||||
surface_boundary
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
surface_match
|
||||
remap_bodies: body
|
||||
surface_fair
|
||||
remap_bodies: body
|
||||
unstitch
|
||||
remap_bodies: body
|
||||
reverse_normal
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The Class-A surface pass adds `SurfaceBoundary` (Boundary Surface: `sketch`, `curves`,
|
||||
//! optional `orders`, `crown`, `samples`), `SurfaceMatch` (Match Surface: `body`, optional
|
||||
//! `faces`, `target`, `edges`, optional `order`) and `SurfaceFair` (Fair Surface: `body`, optional
|
||||
//! `faces`, `strength`, `iterations`, `keep_edges`, `tolerance`). Older designs need no
|
||||
//! transformation; 0.13.x asks for an update instead of meeting a feature it can't read.
|
||||
@@ -40,6 +40,8 @@ impl Walk for bool {}
|
||||
impl Walk for u64 {}
|
||||
impl Walk for i8 {}
|
||||
impl Walk for f64 {}
|
||||
impl Walk for usize {}
|
||||
impl Walk for u32 {}
|
||||
impl Walk for ProfileSel {}
|
||||
impl Walk for solidcraft_sketch::Sketch {}
|
||||
impl Walk for solidcraft_sketch::PatternAlignment {}
|
||||
|
||||
Reference in new issue
Block a user