Class-A surfacing 1/3: the continuity core, a G3 free-form blend and patch crown (#1249)

The continuity core (kernel bridge: Hermite control rows reproducing a support surface's position, tangent, curvature and change of curvature, G0 to G3), surface.blend between two edge chains (#764), and a crown for fitted patches. Existing patches and fillets are unchanged by default (crown 0 is the plain patch bit for bit). SurfaceBlend and Patch.crown are noted under format 22.
This commit is contained in:
midasdf authored and GitHub committed 2026-10-15 13:32:16 -04:00
1 parent 4e10c31fdd
commit e8e2ccc8a9
17 files changed
+1687 -56

No files matched your search

+47
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use solidcraft_geom::{Plane, Vec2, Vec3};
use solidcraft_kernel::Continuity;
use solidcraft_sketch::Sketch;
use crate::construct::{AxisDef, GeoRef, PointDef};
@@ -1254,6 +1255,10 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[walk(pos)]
edges: Vec<Vec3>,
/// How full a fitted patch's middle is (`crown`, -0.9…3, default 0; a planar loop is an
/// exact plane whatever this says).
#[serde(default, skip_serializing_if = "is_zero")]
crown: f64,
},
/// Surface bodies sewn along shared edges into the first one (a solid when they close).
Stitch {
@@ -1437,6 +1442,25 @@ pub enum FeatureKind {
#[serde(default = "yes", skip_serializing_if = "is_yes")]
neighbours: bool,
},
/// A blend surface bridging two picked edge chains of two bodies (`surface.blend`): each end
/// repeats the surface it leaves up to its requested continuity, with the fullness `bulge`.
SurfaceBlend {
from: BlendSide,
to: BlendSide,
/// How much of each support surface the blend repeats: [from, to].
#[serde(default = "blend_orders")]
continuity: [Continuity; 2],
/// How full each end leaves: [from, to]; 0 is the plain bridge.
#[serde(default)]
bulge: [f64; 2],
/// Points the blend leaves towards, when the faces at the picks do not say: [from, to].
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(pos)]
align: Option<[Vec3; 2]>,
/// Sew the blend onto its two bodies where the boundaries meet.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
stitch: bool,
},
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
/// `keep`.
SurfaceTrim {
@@ -1614,6 +1638,22 @@ pub struct FaceAt {
pub point: Vec3,
}
/// One end of a blend: a body and the points that pick the edges of its boundary chain. With two
/// picks on the same edge the chain is that one edge; more pick a chain that runs on from pick to
/// pick (the order of the picks does not matter).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct BlendSide {
#[walk(body)]
pub body: String,
#[walk(pos)]
pub edges: Vec<Vec3>,
}
/// The continuity a blend repeats by default: the tangent plane on both sides.
fn blend_orders() -> [Continuity; 2] {
[Continuity::G1, Continuity::G1]
}
/// Triangles of one imported mesh body (millimetres).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshData {
@@ -1686,6 +1726,11 @@ fn is_zero_i8(v: &i8) -> bool {
*v == 0
}
/// A parameter that is left out when it is 0 (an optional shape knob).
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
/// Hole shapes.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -1892,6 +1937,7 @@ impl FeatureKind {
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolveFeature",
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
FeatureKind::Thicken { .. } => "ThickenFeature",
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
@@ -1968,6 +2014,7 @@ impl FeatureKind {
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolve",
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
FeatureKind::SurfaceBlend { .. } => "Blend",
FeatureKind::Stitch { .. } => "Stitch",
FeatureKind::Thicken { .. } => "Thicken",
FeatureKind::SurfaceTrim { .. } => "Trim",
+2 -1
View File
@@ -4057,9 +4057,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SurfaceRevolve { .. }
| FeatureKind::SurfaceSweep { .. }
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::SurfaceBlend { .. }
| FeatureKind::SurfaceOffset { .. }
| FeatureKind::SurfaceRuled { .. }
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st),
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st, warning),
FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::ReplaceFace { .. }
+26 -3
View File
@@ -6,9 +6,9 @@ use super::*;
use crate::{RuledCorners, RuledMode};
use solidcraft_geom::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, body, edges } => {
FeatureKind::Patch { sketch, profiles, body, edges, crown } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
@@ -20,7 +20,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
if !edges.is_empty() {
let i = body_at(st, body, edges)?;
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
made.push(kernel::patch_edges(&b.body, edges)?);
made.push(kernel::patch_edges_crowned(&b.body, edges, *crown)?);
}
if made.is_empty() {
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
@@ -107,6 +107,26 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, stitch } => {
let a = st.body(&from.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", from.body)))?.body.clone();
let b = st.body(&to.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", to.body)))?.body.clone();
if from.edges.is_empty() || to.edges.is_empty() {
return Err(DocError::Invalid("a blend needs a chain of edges at each end".into()));
}
let (made, gap) = kernel::blend_between(&a, &from.edges, &b, &to.edges, *continuity, *bulge, *align, BLEND_STATIONS, *stitch)?;
if gap > 1e-9 {
*warning = Some(format!("the blend's boundary lies up to {gap:.4} mm from the edges it was built on"));
}
let name = new_name(st, f, 0);
// A stitched blend takes the place of both bodies it joins; otherwise it is a new
// surface body of its own.
if *stitch {
let gone = [from.body.clone(), to.body.clone()];
st.bodies.retain(|x| !gone.contains(&x.name));
}
st.bodies.push(ModelBody::new(name, made, f.id));
Ok(())
}
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
let ss = sketch_in_feature_frame(
doc,
@@ -329,6 +349,9 @@ fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[Stri
/// Most sketch curves one feature chains.
const MAX_CHAIN_CURVES: usize = 1000;
/// Stations a blend samples each of its two boundaries at: enough that the boundary it fits is
/// within a small part of a thousandth of the chain's length of the edges it was built on.
const BLEND_STATIONS: usize = 41;
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
@@ -158,6 +158,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
),
("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"})),
("unstitch", json!({"type": "unstitch", "body": "B"})),
("reverse_normal", json!({"type": "reverse_normal", "body": "B"})),
@@ -328,6 +328,7 @@ surface_sweep_options
Scale = surface_sweep.scale (Unitless)
expressions: surface_sweep.twist, surface_sweep.scale
surface_loft
surface_blend
surface_offset
inputs:
Distance = soff.distance (Length)
@@ -147,6 +147,8 @@ surface_revolve
surface_sweep
surface_sweep_options
surface_loft
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_offset
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
@@ -169,6 +169,8 @@ surface_sweep_options
surface_loft
sketches: [4, 5]
remap_refs: sections[0].sketch, sections[1].sketch
surface_blend
remap_bodies: from.body
surface_offset
remap_bodies: body
unstitch
+5
View File
@@ -212,6 +212,11 @@
//! parentheses (`2,5 mm` is `2.5 mm`, #1347; inside a function's parentheses the comma still
//! separates its arguments). Nothing new is saved, but an expression typed that way is saved as
//! typed; 0.12.0 asks for an update instead of failing to evaluate it.
//! Also under 22, a design can hold a `SurfaceBlend` (Blend Surface: `from`/`to` edge chains of
//! two bodies, optional `continuity`, `bulge`, `align`, `stitch`), and a `Patch` can carry a
//! `crown` (0 by default, and then not saved: a bump that vanishes on the loop, so the face
//! still meets the picked edges). Version 21 designs need no transformation; 0.12.0 asks for an
//! update instead of meeting a feature it can't read or dropping a patch's crown.
use serde_json::Value;
+31
View File
@@ -44,6 +44,7 @@ impl Walk for ProfileSel {}
impl Walk for solidcraft_sketch::Sketch {}
impl Walk for solidcraft_kernel::StepNode {}
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::MeshData {}
@@ -136,6 +137,36 @@ impl<T> Each<T> for Vec<T> {
}
}
impl<T, const N: usize> Each<T> for [T; N] {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
for t in self {
f(t)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
for t in self {
f(t)
}
}
}
impl<T, const N: usize> Each<T> for Option<[T; N]> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
if let Some(a) = self {
for t in a {
f(t)
}
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
if let Some(a) = self {
for t in a {
f(t)
}
}
}
}
impl<T> Each<T> for Option<Vec<T>> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
for t in self.iter().flatten() {