Patch: G1/G2 to the faces beside the loop, general and N-sided loops, points (#759) (#1521)

Thanks @jyviko.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-15 23:07:24 -04:00
1 parent 77bdcc47e9
commit fac3f96bdd
24 files changed
+2688 -77

No files matched your search

+138 -4
View File
@@ -2,7 +2,9 @@
//! solids), and the planned surface toolset (`planned!`: registered, not built yet).
use serde_json::Value;
use solidcraft_doc::{BlendSide, ExtendMode, ExtendTo, FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
use solidcraft_doc::{
BlendSide, EdgeContinuity, ExtendMode, ExtendTo, FaceAt, FeatureKind, PatchContinuity, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind,
};
use solidcraft_kernel::Continuity;
use super::CommandSpec;
@@ -14,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges; a loop that is planar is the exact plane whatever the crown); name?, body_name?"),
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges) or [{edge: [x,y,z], g: g0|g1|g2}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or g2 edge); name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
@@ -84,24 +86,91 @@ pub static COMMANDS: &[CommandSpec] = &[
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.patch";
let crown = crown_param(p, cmd)?;
let continuity = patch_continuity(p, cmd)?;
let points = patch_points(p, cmd)?;
let edges: Vec<solidcraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
let mut v = if !edges.is_empty() {
if edges.len() > 10_000 {
return Err(bad(cmd, "too many edges"));
}
let body = str_(p, "body").map(str::to_string);
add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges, crown })?
add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges, crown, continuity: continuity.clone(), points })?
} else {
if !continuity.is_g0() || !points.is_empty() {
return Err(bad(cmd, "`continuity` and `points` are for a patch on a loop of edges"));
}
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new(), crown })?
let kind = FeatureKind::Patch {
sketch: Some(sketch),
profiles,
body: None,
edges: Vec::new(),
crown,
continuity: PatchContinuity::default(),
points,
};
add_feature(s, p, kind)?
};
if let Some(o) = v.as_object_mut() {
o.insert("crown".into(), serde_json::json!(crown));
o.insert("continuity".into(), serde_json::to_value(&continuity).unwrap_or(Value::Null));
}
Ok(v)
}
/// A continuity word for a patch: g0, g1 or g2 (a patch does not hold g3).
fn patch_order(v: Option<&Value>, cmd: &str) -> Result<Continuity> {
match v.and_then(Value::as_str).and_then(Continuity::parse) {
Some(g) if g != Continuity::G3 => Ok(g),
_ => Err(bad(cmd, "a patch's continuity is g0, g1 or g2")),
}
}
/// The patch's `continuity`: one word for every edge, a list of `{edge, g}` for some edges (the
/// rest g0), or `{all, edges}`.
fn patch_continuity(p: &Value, cmd: &str) -> Result<PatchContinuity> {
let edges = |list: &Value| -> Result<Vec<EdgeContinuity>> {
let list = list.as_array().ok_or_else(|| bad(cmd, "`continuity` edges must be a list of {edge: [x,y,z], g}"))?;
if list.len() > 10_000 {
return Err(bad(cmd, "`continuity` lists at most 10000 edges"));
}
list.iter()
.map(|e| {
let edge = e.get("edge").and_then(vec3).ok_or_else(|| bad(cmd, "a `continuity` entry's `edge` must be a point [x,y,z]"))?;
Ok(EdgeContinuity { edge, g: patch_order(e.get("g"), cmd)? })
})
.collect()
};
match p.get("continuity") {
None => Ok(PatchContinuity::default()),
Some(v) if v.is_string() => Ok(PatchContinuity { all: patch_order(Some(v), cmd)?, edges: Vec::new() }),
Some(v) if v.is_array() => Ok(PatchContinuity { all: Continuity::G0, edges: edges(v)? }),
Some(v) if v.is_object() => {
let all = match v.get("all") {
None => Continuity::G0,
some => patch_order(some, cmd)?,
};
let edges = match v.get("edges") {
None => Vec::new(),
Some(list) => edges(list)?,
};
Ok(PatchContinuity { all, edges })
}
Some(_) => Err(bad(cmd, "`continuity` must be g0, g1 or g2, or a list of {edge, g}")),
}
}
/// The points a patch passes through (at most 64, each a finite point).
fn patch_points(p: &Value, cmd: &str) -> Result<Vec<solidcraft_geom::Vec3>> {
let Some(v) = p.get("points") else { return Ok(Vec::new()) };
let list = v
.as_array()
.filter(|a| a.len() <= solidcraft_geom::fill_general::MAX_FILL_POINTS)
.ok_or_else(|| bad(cmd, "`points` must list at most 64 points [x,y,z]"))?;
list.iter().map(|q| vec3(q).filter(|q| q.is_finite()).ok_or_else(|| bad(cmd, "a patch point must be a finite [x,y,z]"))).collect()
}
/// The patch's `crown`: how full a fitted patch's middle is made, -0.9…3 (default 0). A hostile
/// value (NaN, infinity, out of range, a string) is refused.
fn crown_param(p: &Value, cmd: &str) -> Result<f64> {
@@ -1329,4 +1398,69 @@ mod tests {
.is_err()
);
}
/// A tube whose top face is taken off (`surface.unstitch`), and points round its rim.
fn open_tube() -> (Session, Value) {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Tube"}));
run(&mut s, "surface.unstitch", json!({"body": "Tube", "faces": [[0, 0, 30]]}));
let rim: Vec<Value> = (0..8).map(|k| f64::from(k) * 0.785).map(|a| json!([10.0 * a.cos(), 10.0 * a.sin(), 30.0])).collect();
(s, json!(rim))
}
/// `surface.patch continuity` and `points`: the tube's open end capped G1, per-edge
/// continuity, a point the cap passes through, all saved with the design and read back;
/// hostile values refused.
#[test]
fn patch_continuity_and_points_cap_a_tube() {
// A coarse mesh is enough to tell the patches apart (measuring a fitted face's area
// exactly tessellates it finely).
let mesh = |s: &Session, name: &str| s.world_state().body(name).unwrap().body.tessellate(0.05).unwrap();
let (mut s, rim) = open_tube();
let reply = run(&mut s, "surface.patch", json!({"edges": rim, "body": "Tube", "continuity": "g1", "body_name": "Cap"}));
assert_eq!(reply["continuity"], json!({"all": "g1"}), "{reply}");
// A dome over the rim, not the flat disc a G0 patch of a planar rim is.
let disc = std::f64::consts::PI * 100.0;
let dome = mesh(&s, "Cap").measure().area;
assert!(dome > disc * 1.2, "a dome, not the disc: {dome}");
// Through a point 8 above the rim, G1 to the wall: the cap reaches it.
run(&mut s, "surface.patch", json!({"edges": rim, "body": "Tube", "continuity": {"all": "g1"}, "points": [[0, 0, 38]], "body_name": "Tall"}));
let top = mesh(&s, "Tall").bounds().max.z;
assert!(top >= 38.0 - 1e-2, "the cap passes through the point: top {top}");
// One edge G2, the rest G0 (the list form).
let first = rim[0].clone();
let reply =
run(&mut s, "surface.patch", json!({"edges": rim, "body": "Tube", "continuity": [{"edge": first, "g": "g2"}], "body_name": "Mixed"}));
assert_eq!(reply["continuity"]["edges"][0]["g"], json!("g2"), "{reply}");
// Saved and read back: the same patches.
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let reopened = Session::new(back);
for name in ["Cap", "Tall", "Mixed"] {
let (a, b) = (mesh(&s, name).positions, mesh(&reopened, name).positions);
assert_eq!(a, b, "{name}: the same surface read back");
}
assert!(s.doc.to_json().contains("\"continuity\""), "the continuity is saved");
// Hostile and misplaced values.
for (key, v) in [
("continuity", json!("g3")),
("continuity", json!("smooth")),
("continuity", json!(5)),
("continuity", json!([{"edge": "here", "g": "g1"}])),
("continuity", json!([{"edge": [f64::INFINITY, 0, 0], "g": "g1"}])),
("continuity", json!({"all": "g9"})),
("continuity", json!([{"edge": [0, 0, 0], "g": "g1"}])),
("points", json!([[0, 0, f64::NAN]])),
("points", json!("up")),
("points", json!(vec![[0, 0, 35]; 65])),
("points", json!([[100, 0, 30]])),
] {
let mut p = json!({"edges": rim, "body": "Tube"});
p[key] = v.clone();
assert!(s.execute("surface.patch", &p).is_err(), "accepted {key}: {v}");
}
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 0], "p1": [40, 10]}));
let sketch_patch = json!({"sketch": 1, "continuity": "g1"});
assert!(s.execute("surface.patch", &sketch_patch).is_err(), "continuity on a sketch patch");
}
}