Class-A surfacing 2/3: surface match, boundary patch and CV fairing (#1487)

Thanks @midasdf.
This commit is contained in:
midasdf authored and GitHub committed 2026-10-16 03:26:20 -04:00
1 parent 7f79443b46
commit 07da7f8065
27 files changed
+3513 -11

No files matched your search

+82
View File
@@ -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",
+3
View File
@@ -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),
+136
View File
@@ -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.
+2
View File
@@ -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 {}