surface.boundary: surfaces through boundary curves and curve networks (#763) (#2129)

* feat(geom): surfaces through curve networks, and spline spaces to build them

Adds spline_space (clamped B-spline spaces, Hermite interpolation, exact
embedding by Bézier degree elevation and knot insertion, a monotone cubic
for reparameterising) and network (loft, Coons and Gordon surfaces through
curves in one or two directions, with G1/G2 cross-boundary conditions
against a neighbouring face). Part of #763.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(kernel): boundary surfaces from sketch curves, 3D curves and edges

kernel::boundary_network turns sketch chains, 3D sketch polylines and edge chains
into network curves, gives G1 frames of the faces along open edges
(EdgeFaces), builds the B-spline face (sides split at chain vertices so a
join shares the body's edges) and joins it into a surface body by stitching.
G2 is refused for now (not supported yet). Part of #763.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(kernel): second derivatives of B-spline surfaces in the vendored truck-geometry

BSplineSurface::uuder and vvder multiplied the second differences by p
instead of p·(p − 1), as BSplineCurve::der2 does, so every surface of
degree p ≥ 3 (NURBS too, through its homogeneous surface) reported
1/(p − 1) of its second derivative: a cubic surface's curvature read as
half. This is why a G2 boundary surface on a cylinder measured half the
curvature it carries (#763). New tests hold both to finite differences
for degrees 2 to 5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(kernel): G2 boundary surfaces against a body's face

The network always carried the face's curvature across a G2 edge; the
refusal came from measuring it with truck's halved surface second
derivatives (fixed in the previous commit). EdgeFaces now reads the
face's second fundamental form from the exact second derivatives
instead of differencing the normal. A new test holds the curvature
across a cylinder's edge to the cylinder's within 0.1%, also with a
sheared far curve; G3 is refused (a network holds up to G2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* test(kernel): a boundary patch with straight corners measures its own area

A stadium's arcs meet its lines at 180°, so the Coons patch is singular
at its corners. From such a corner the reference mesher's boundary walk
ran onto the B-spline's extrapolation past its domain (a planar spline
passes through every point of its plane there), and the face measured
289 mm² for its 158.5 (#763). The vendored mesher's bounded projection
(nearest_projection, #2043) keeps the walk on the domain; this holds it
there, by the faces, the measuring mesh and the display mesh. The
display path (#1653) was never affected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(geom,kernel): three-sided boundary surfaces (one side collapsed to a point)

A network curve may be a PointCurve: the apex where the two sides beside
it meet. The curves through it cross it at the point itself, its row of
the net collapses there, and the kernel's face leaves that side out of
its loop (three edges, one vertex at the apex). One point per patch, as
a side; refused elsewhere, when it moves, or with a continuity
condition. Tests: a triangle and a quarter disc filled flat with their
analytic areas (40 mm², 25π), by geom and by the kernel's faces and
meshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(doc): boundary surfaces through curve networks and loops of three or four

FeatureKind::SurfaceNetwork saves a network as `u` and `v` curves, each
a sketch chain (planar or 3D), a chain of a body's edges with an
optional G1/G2 continuity, or a point (the apex of a three-sided
patch), and an `operation` that can join the surface into the body of
its edges. A SurfaceBoundary of three or four sketch curves, refused
until now, builds as the flat Coons patch of its loop (three collapse
the side opposite the first curve). The format change is one pending
file; feature-walk goldens record the new variant's sketches, bodies
and points.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(engine): surface.boundary through loops of three or four and curve networks

The command keeps its loop form (two to four curves of one sketch; three
and four now build the flat patch of the loop, crown refused there) and
gains a network form: `u` and `v`, each curve a sketch curve id, a
{sketch?, curves} chain (planar or 3D), a {body, edges, continuity?}
edge chain (g1/g2 with the face beside it) or a {point} for a
three-sided patch, with `operation` new or join. Edits may move between
the two forms (timeline.redefine accepts the command now that it has a
dialog).

Tests: analytic areas of loop patches and of a hyperbolic paraboloid
bounded by four 3D lines (every mesh vertex on z = xy/10), a loft
across two sketches, G1 and G2 measured across a cylinder's edge with
inspect.continuity (angle < 0.1°; curvature jump 1e-16 with G2 against
0.027 with G1), 26 hostile networks refused without panicking, and u/v
added to hostile_params_never_panic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(ui): Boundary Surface dialog

Curves and Cross curves take sketch curves and edges (picks that meet
end to end in one sketch or body are one chain); Continuity and
Operation (New Body or Join). Two to four sketch curves of one sketch
with nothing across are the loop patch; anything else is a network.
Edit Feature brings either back (per-side orders, crown and stations
the dialog does not show are kept). Strings in en, ja and zh.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* docs: ROADMAP M8 and the bridge patch's notes for boundary networks (#763)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* test(oracle): a boundary surface through a loop, thickened (#763)

Case 83: four sketch curves (a square's three sides and a half circle
for its top) make one boundary surface, thickened 2 mm; analytic values
from tools/oracle-analytic/cases_surfaces.py (V = 2A, area 2A + 2P,
centroid by moments). Recipes gain boundary_surface, thicken and remove.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* refactor: boundary surfaces, review cleanups

Project onto the face once per derivative evaluation (the frame is
passed to natural() and reused by scale()), keep each track's
finite-difference step instead of rescanning its table, export the
kernel's edge-pick and chain caps and the default station count for
the engine, doc and dialog to share, map TooLarge spline errors to
KernelError::TooLarge, reuse the engine's operation parser, and skip
the dialog's sketch lookup when the picks are no loop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-17 21:47:38 -04:00
1 parent ae6a33bf96
commit 83c31d77c4
37 files changed
+4358 -44

No files matched your search

+40 -3
View File
@@ -1465,6 +1465,19 @@ pub enum FeatureKind {
#[serde(default = "boundary_samples")]
samples: usize,
},
/// One surface through a curve network (`surface.boundary` with `u` and `v`, issue #763): the
/// curves `u` run one way (two or more: the surface lofts through them) and `v` cross them the
/// other way (two each way: a Coons patch; more: a Gordon surface through every curve). A side
/// may be a point (a three-sided patch). A curve that is a chain of a surface's open edges may
/// carry G1 or G2 with the face beside it. `operation` join sews the surface into the body of
/// its first edge curve (a solid when it closes the gap there).
SurfaceNetwork {
u: Vec<NetworkCurve>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
v: Vec<NetworkCurve>,
#[serde(default)]
operation: Operation,
},
/// 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
@@ -1692,6 +1705,27 @@ pub struct BlendSide {
pub edges: Vec<Position>,
}
/// One curve of a boundary surface's network ([`FeatureKind::SurfaceNetwork`]): a chain of a
/// sketch's `curves` (planar or 3D, meeting end to end), a chain of `body`'s edges picked by points
/// on them (`edges`), or a side collapsed to the point `point` (the apex of a three-sided patch).
/// A chain of a surface's open edges may carry `continuity` with the face beside it (G0, the
/// default, G1 or G2).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct NetworkCurve {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sketch: Option<SketchId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub curves: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edges: Vec<Position>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point: Option<Position>,
#[serde(default = "patch_g0", skip_serializing_if = "is_g0")]
pub continuity: Continuity,
}
/// How a patch on a loop of edges meets the faces beside the loop: every edge with `all`, but the
/// edges picked in `edges` with their own.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
@@ -1733,9 +1767,12 @@ 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
BOUNDARY_SAMPLES
}
/// The stations a boundary patch carries when a design does not say.
pub const BOUNDARY_SAMPLES: usize = 25;
fn blend_orders() -> [Continuity; 2] {
[Continuity::G1, Continuity::G1]
}
@@ -2088,7 +2125,7 @@ impl FeatureKind {
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
FeatureKind::SurfaceBoundary { .. } => "BoundaryFeature",
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "BoundaryFeature",
FeatureKind::SurfaceMatch { .. } => "MatchFeature",
FeatureKind::SurfaceFair { .. } => "FairFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
@@ -2168,7 +2205,7 @@ impl FeatureKind {
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
FeatureKind::SurfaceBlend { .. } => "Blend",
FeatureKind::SurfaceBoundary { .. } => "Boundary",
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "Boundary",
FeatureKind::SurfaceMatch { .. } => "Match",
FeatureKind::SurfaceFair { .. } => "Fair",
FeatureKind::Stitch { .. } => "Stitch",
+15
View File
@@ -1035,6 +1035,20 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
}
return hash.finish();
}
// A network through 3D sketch curves reads their points through the components the
// feature and those sketches are placed in, as a sweep's wired path does.
if let FeatureKind::SurfaceNetwork { u, v, .. } = &f.kind {
let sketches: Vec<crate::SketchId> = u.iter().chain(v).filter_map(|c| c.sketch).collect();
if sketches.iter().any(wired) {
let mut hash = std::collections::hash_map::DefaultHasher::new();
doc.component_transform(f.component).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
for sketch in &sketches {
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
return hash.finish();
}
}
// To / From Object finds its target where that target's component is placed (#928).
let to_object = match &f.kind {
FeatureKind::Extrude { extent, .. } => extent.to.is_some() || extent.from.is_some(),
@@ -4691,6 +4705,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::SurfaceBlend { .. }
| FeatureKind::SurfaceBoundary { .. }
| FeatureKind::SurfaceNetwork { .. }
| FeatureKind::SurfaceMatch { .. }
| FeatureKind::SurfaceFair { .. }
| FeatureKind::SurfaceOffset { .. }
+168 -2
View File
@@ -151,12 +151,49 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
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)?;
// Two curves are the two sides of a bridge. Three or four run round a loop: the Coons
// patch of the loop, which lies in the sketch's plane with them, so every order holds.
let body = if curves.len() >= 3 {
if *crown != 0.0 {
return Err(DocError::Invalid(
"not supported yet: crown on a patch through three or four curves (it is the flat patch of its sides)".into(),
));
}
loop_patch(&ss, curves)?
} else {
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
kernel::boundary_patch(&boundaries, *crown, *samples)?
};
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::new(name, body, f.id));
Ok(())
}
FeatureKind::SurfaceNetwork { u, v, operation } => {
if !matches!(operation, crate::Operation::NewBody | crate::Operation::Join) {
return Err(DocError::Invalid("a boundary surface is a new body or joins the body of its edges".into()));
}
let us = network_sources(doc, st, f, u, "U")?;
let vs = network_sources(doc, st, f, v, "V")?;
let made = kernel::boundary_surface(&network_curves(&us), &network_curves(&vs))?;
if *operation == crate::Operation::Join {
// Into the body of the first curve picked on a body's edges.
let target = u
.iter()
.chain(v)
.find_map(|c| c.body.clone())
.ok_or_else(|| DocError::Invalid("join needs a curve on a body's edges: the surface is sewn into that body".into()))?;
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Unknown(format!("body `{target}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let joined = kernel::boundary_join(&mb.body, &made)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, joined, mb.feature);
}
return Ok(());
}
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::new(name, made, 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())) };
@@ -439,6 +476,135 @@ fn push_sheet(st: &mut ModelState, f: &Feature, traced: crate::lineage::Traced)
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
}
/// The most curves one direction of a network may list (the kernel's own cap).
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
/// A loop of three or four sketch curves as one patch: four give the Coons patch of opposite pairs
/// (the first and third one way, the second and fourth the other); three collapse the side
/// opposite the first curve to the corner where the other two meet.
fn loop_patch(ss: &SolvedSketch, curves: &[String]) -> Result<kernel::Body> {
let mut chains: Vec<Vec<Seg2>> = 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 segs = ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci));
if segs.is_empty() {
return Err(DocError::Invalid(format!("curve `{id}` has no geometry to build on")));
}
chains.push(segs);
}
let side = |k: usize| -> Result<kernel::BoundaryCurve<'static>> {
let (Some(id), Some(segs)) = (curves.get(k), chains.get(k)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
Ok(kernel::BoundaryCurve {
label: format!("curve `{id}`"),
source: kernel::CurveSource::Sketch { plane: ss.plane, segs: segs.clone() },
continuity: kernel::Continuity::G0,
})
};
match curves.len() {
4 => Ok(kernel::boundary_surface(&[side(0)?, side(2)?], &[side(1)?, side(3)?])?),
3 => {
// The corner the second and third curves share, whichever way each was drawn.
let ends = |k: usize| -> Option<[Vec2; 2]> {
let c = chains.get(k)?;
Some([c.first()?.start(), c.last()?.end()])
};
let (Some(b), Some(c)) = (ends(1), ends(2)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
let mut best = (f64::INFINITY, Vec2::new(0.0, 0.0));
for p in b {
for q in c {
if p.dist(q) < best.0 {
best = (p.dist(q), (p + q) * 0.5);
}
}
}
let apex = kernel::BoundaryCurve {
label: "the corner opposite the first curve".into(),
source: kernel::CurveSource::Point(ss.plane.to_world(best.1)),
continuity: kernel::Continuity::G0,
};
Ok(kernel::boundary_surface(&[side(0)?, apex], &[side(1)?, side(2)?])?)
}
_ => Err(DocError::Invalid("a loop patch takes three or four curves".into())),
}
}
/// A network curve resolved in the feature's frame, owning what it is built from.
enum NetSource {
Sketch(solidcraft_geom::Plane, Vec<Seg2>),
Points(Vec<Vec3>),
Edges(kernel::Body, Vec<Vec3>),
Point(Vec3),
}
/// The curves of one direction of a network, resolved: each one sketch chain, 3D sketch chain,
/// edge chain or point, labelled `U1`, `U2`, … (with `dir` "U") for messages.
fn network_sources(
doc: &Document,
st: &ModelState,
f: &Feature,
list: &[crate::NetworkCurve],
dir: &str,
) -> Result<Vec<(String, NetSource, kernel::Continuity)>> {
if list.len() > MAX_NETWORK_CURVES {
return Err(DocError::Invalid(format!("at most {MAX_NETWORK_CURVES} curves each way")));
}
let mut out = Vec::with_capacity(list.len());
for (k, c) in list.iter().enumerate() {
let label = format!("{dir}{}", k + 1);
let given = [c.sketch.is_some() || !c.curves.is_empty(), c.body.is_some() || !c.edges.is_empty(), c.point.is_some()];
if given.iter().filter(|g| **g).count() != 1 {
return Err(DocError::Invalid(format!("{label} must be sketch curves, a body's edges or a point")));
}
let source = if let Some(p) = c.point {
NetSource::Point(p.0)
} else if let Some(name) = &c.body {
if c.edges.is_empty() {
return Err(DocError::Invalid(format!("{label} needs points on the edges of {name}")));
}
let b = st.body(name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?;
NetSource::Edges(b.body.clone(), crate::plain(&c.edges))
} else {
let sk = c.sketch.ok_or_else(|| DocError::Invalid(format!("{label} needs the sketch its curves are in")))?;
if c.curves.is_empty() || c.curves.len() > MAX_CHAIN_CURVES {
return Err(DocError::Invalid(format!("{label} needs 1 to {MAX_CHAIN_CURVES} curves of its sketch")));
}
let raw = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
// 3D sketch curves are followed through their points; planar ones exactly.
let mapped = path_in_frame(doc, raw, f.component)?;
match path_points(&mapped, &c.curves, Vec3::new(f64::NAN, f64::NAN, f64::NAN))? {
Some(pts) => NetSource::Points(pts),
None => {
let ss = sketch_in_feature_frame(doc, raw, f.component)?;
let mut chains = curve_chains(&ss, &c.curves)?.into_iter();
let (Some(chain), None) = (chains.next(), chains.next()) else {
return Err(DocError::Invalid(format!("{label}: its curves must meet end to end in one chain")));
};
NetSource::Sketch(ss.plane, chain)
}
}
};
out.push((label, source, c.continuity));
}
Ok(out)
}
/// The kernel's curves over resolved sources (borrowing their bodies).
fn network_curves(sources: &[(String, NetSource, kernel::Continuity)]) -> Vec<kernel::BoundaryCurve<'_>> {
sources
.iter()
.map(|(label, src, g)| kernel::BoundaryCurve {
label: label.clone(),
source: match src {
NetSource::Sketch(plane, segs) => kernel::CurveSource::Sketch { plane: *plane, segs: segs.clone() },
NetSource::Points(pts) => kernel::CurveSource::Points(pts.clone()),
NetSource::Edges(body, at) => kernel::CurveSource::Edges { body, at: at.clone() },
NetSource::Point(p) => kernel::CurveSource::Point(*p),
},
continuity: *g,
})
.collect()
}
/// 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
@@ -164,6 +164,10 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
// 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_network",
json!({"type": "surface_network", "u": [{"sketch": 1, "curves": ["l1"]}, {"body": "B", "edges": [o], "continuity": "g1"}], "v": [{"sketch": 2, "curves": ["l1"]}, {"point": o}], "operation": "join"}),
),
("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"})),
@@ -342,6 +342,7 @@ surface_offset
expressions: soff.distance, soff.tolerance
surface_blend
surface_boundary
surface_network
surface_match
surface_fair
inputs:
@@ -157,6 +157,8 @@ surface_offset
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_network
moves: u[1].edges[0][0], u[1].edges[0][1], u[1].edges[0][2], v[1].point[0], v[1].point[1], v[1].point[2]
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]
@@ -182,6 +182,10 @@ surface_blend
surface_boundary
sketches: [1]
remap_refs: sketch
surface_network
sketches: [1, 2]
remap_refs: u[0].sketch, v[0].sketch
remap_bodies: u[1].body
surface_match
remap_bodies: body
surface_fair
@@ -0,0 +1,7 @@
//! A `SurfaceNetwork` (Boundary Surface through a curve network, #763) saves `u` and `v`, each a
//! list of curves: a `sketch` and its `curves`, a `body` and points on its `edges`, or a `point`
//! (the apex of a three-sided patch), with an optional `continuity` (`g1` or `g2` with the face
//! beside an edge chain), and an `operation` (`new_body`, or `join` into the body of its edges).
//! A `SurfaceBoundary` of three or four curves, which older builds refused, now builds and saves
//! as it always did. Older designs need no transformation; the last release asks for an update
//! instead of meeting a feature it can't read.