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

+261 -10
View File
@@ -2,7 +2,8 @@
//! solids), and the planned surface toolset (`planned!`: registered, not built yet).
use serde_json::Value;
use solidcraft_doc::{ExtendMode, ExtendTo, FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
use solidcraft_doc::{BlendSide, ExtendMode, ExtendTo, FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
use solidcraft_kernel::Continuity;
use super::CommandSpec;
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
@@ -13,7 +14,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?; 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?; 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?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
@@ -58,10 +59,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "CREATE")
.icon("boundary")
.params("planned: u: [curves], v?: [curves] (sketch curves, 3D sketch wires or {body, point} edges; 2–4 boundaries, more make a network); continuity?: [{curve, g: 0|1|2}] for edge boundaries; operation?: new|join"),
planned!("surface.blend", "Blend Surface", "764")
CommandSpec::new("surface.blend", "Blend Surface", blend)
.at("SURFACE", "CREATE")
.icon("surface_blend")
.params("planned: from, to: {body, edges: [[x,y,z]]} (two edge chains apart); continuity?: [g0|g1|g2, g0|g1|g2] (each side); bulge?: [number, number]; align?: auto|[[x,y,z], [x,y,z]]; stitch?: bool"),
.params("from, to: {body, edges: [[x,y,z]]} (points on the edges of one chain of each body: two picks on the same edge is that one edge, more chain up); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g1: how much of the surface at the picked edges the blend repeats — the tangent plane, the curvature, or how that curvature changes); bulge?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); align?: [[x,y,z], [x,y,z]] (a point each end leaves towards, when the faces at the picks do not say); stitch?: bool (sew the blend onto its two bodies where the boundaries meet, replacing them); name?, body_name?"),
CommandSpec::new("surface.untrim", "Untrim", untrim)
.at("SURFACE", "MODIFY")
.icon("untrim")
@@ -82,17 +83,88 @@ pub static COMMANDS: &[CommandSpec] = &[
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.patch";
let crown = crown_param(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();
if !edges.is_empty() {
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);
return add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges });
add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges, crown })?
} else {
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 })?
};
if let Some(o) = v.as_object_mut() {
o.insert("crown".into(), serde_json::json!(crown));
}
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() })
Ok(v)
}
/// 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> {
match p.get("crown") {
None => Ok(0.0),
Some(v) => match v.as_f64().filter(|x| (solidcraft_geom::fill::MIN_CROWN..=solidcraft_geom::fill::MAX_CROWN).contains(x)) {
Some(x) => Ok(x),
None => Err(bad(cmd, "`crown` must be a number between -0.9 and 3")),
},
}
}
/// One end of a blend: the body and the picks that chain its edges.
fn blend_side(p: &Value, cmd: &str, key: &str) -> Result<BlendSide> {
let side = p.get(key).ok_or_else(|| bad(cmd, format!("`{key}` must be {{body, edges}}")))?;
let body = str_(side, "body").ok_or_else(|| bad(cmd, format!("`{key}.body` must name a body")))?;
let edges: Vec<solidcraft_geom::Vec3> =
side.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if edges.is_empty() || edges.len() > 10_000 {
return Err(bad(cmd, format!("`{key}.edges` must list 1 to 10000 points on the edges of a chain")));
}
Ok(BlendSide { body: body.to_string(), edges })
}
/// `surface.blend`: a blend surface bridging two picked edge chains of two bodies.
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.blend";
let from = blend_side(p, cmd, "from")?;
let to = blend_side(p, cmd, "to")?;
let continuity = match p.get("continuity") {
None => [Continuity::G1, Continuity::G1],
Some(v) => {
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`continuity` must be two words, one per end"))?;
let word = |x: Option<&Value>| -> Result<Continuity> {
x.and_then(Value::as_str).and_then(Continuity::parse).ok_or_else(|| bad(cmd, "`continuity` entries must be g0, g1, g2 or g3"))
};
[word(list.first())?, word(list.get(1))?]
}
};
let bulge = match p.get("bulge") {
None => [0.0, 0.0],
Some(v) => {
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`bulge` must be two numbers, one per end"))?;
let one = |x: Option<&Value>| -> Result<f64> {
x.and_then(Value::as_f64)
.filter(|x| x.is_finite() && (-0.9..=3.0).contains(x))
.ok_or_else(|| bad(cmd, "each `bulge` entry must be between -0.9 (flattest) and 3 (fullest)"))
};
[one(list.first())?, one(list.get(1))?]
}
};
let align = match p.get("align") {
None | Some(Value::Null) => None,
Some(v) => {
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`align` must be two points, one per end"))?;
let point = |x: Option<&Value>| -> Result<solidcraft_geom::Vec3> {
x.and_then(vec3).ok_or_else(|| bad(cmd, "each `align` entry must be a point [x,y,z]"))
};
Some([point(list.first())?, point(list.get(1))?])
}
};
let stitch = bool_(p, "stitch").unwrap_or(false);
add_feature(s, p, FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, stitch })
}
fn surface_extrude(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1000,7 +1072,7 @@ mod tests {
/// as it was.
#[test]
fn planned_commands_refuse_and_leave_the_design_alone() {
let planned = ["surface.boundary", "surface.blend", "surface.fillet", "inspect.continuity"];
let planned = ["surface.boundary", "surface.fillet", "inspect.continuity"];
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let before = s.doc.features.len();
@@ -1078,4 +1150,183 @@ mod tests {
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
}
/// `surface.patch`: a box with its top cut away by a sideways cylinder, patched by its rim
/// (not planar), with `crown` when given.
fn saddle_cover(crown: Option<f64>) -> (Session, Value) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 28, "width": 28, "height": 20, "corner": [-14, -14, 0], "body_name": "Saddle"}));
run(
&mut s,
"solid.cylinder",
json!({"radius": 40, "height": 120, "base": [0, -60, 50], "axis": "Y", "operation": "cut", "targets": ["Saddle"]}),
);
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
let rim = json!([[14, 0, z(14.0)], [0, 14, z(0.0)], [-14, 0, z(-14.0)], [0, -14, z(0.0)]]);
let mut p = json!({"edges": rim, "body": "Saddle", "body_name": "Cover"});
if let Some(c) = crown {
p["crown"] = json!(c);
}
let reply = run(&mut s, "surface.patch", p);
(s, reply)
}
/// `surface.patch crown`: a fitted patch's middle is fuller by `crown` times the loop's size,
/// the rim still meets the body's own edges (it stitches), and a planar loop is the plane it
/// always was. Hostile crowns are refused.
#[test]
fn patch_crown_fills_a_fitted_patch_and_ignores_a_planar_one() {
let top = |s: &Session| solidcraft_kernel::measure(&s.world_state().body("Cover").unwrap().body).unwrap().bbox.max.z;
let (plain, reply) = saddle_cover(None);
assert_eq!(reply["crown"], json!(0.0), "the crown is reported, 0 by default: {reply}");
let plain_cover = plain.world_state().body("Cover").unwrap().body.clone();
// The middle of the loop's box (28 × 28 + 1%, so a 39.9 mm diagonal) rises by crown × 39.9.
let (small, reply) = saddle_cover(Some(0.05));
assert_eq!(reply["crown"], json!(0.05), "{reply}");
let (big, _) = saddle_cover(Some(0.1));
let (small_rise, big_rise) = (top(&small) - top(&plain), top(&big) - top(&plain));
eprintln!("the cover's top rose {small_rise} at crown 0.05 and {big_rise} at 0.1, and the sealed cup gained ...");
assert!(small_rise > 0.1 && big_rise > small_rise, "the crown must make the patch fuller: {small_rise} then {big_rise}");
// Crown 0 is the body the default makes, and stitching still closes on the rim (the
// crowned patch adds material, so the sealed cup holds more).
let (zero, _) = saddle_cover(Some(0.0));
let zero_area = solidcraft_kernel::measure(&zero.world_state().body("Cover").unwrap().body).unwrap().area;
assert_eq!(zero_area, solidcraft_kernel::measure(&plain_cover).unwrap().area, "crown 0 is the plain patch");
let sealed = |mut s: Session| {
run(&mut s, "surface.stitch", json!({"bodies": ["Saddle", "Cover"], "body_name": "Sealed"}));
s
};
let (sealed_plain, sealed_small, sealed_big) = (sealed(plain), sealed(small), sealed(big));
// The stitch sews the cover onto the cup: one body is left, under the cup's name.
let only = |s: &Session| {
let st = s.world_state();
assert_eq!(st.bodies.len(), 1, "the cover still meets the rim: {:?}", st.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
st.bodies.first().map(|b| b.name.clone()).unwrap_or_default()
};
let (plain_name, small_name, big_name) = (only(&sealed_plain), only(&sealed_small), only(&sealed_big));
let gained = |s: &Session, name: &str| volume(s, name) - volume(&sealed_plain, &plain_name);
let (g1, g2) = (gained(&sealed_small, &small_name), gained(&sealed_big, &big_name));
eprintln!("the crown adds {g1} mm³ at 0.05 and {g2} mm³ at 0.1 to the sealed cup");
assert!(g1 > 50.0, "the crown adds material: {g1}");
assert!((g2 - 2.0 * g1).abs() < 0.05 * g1, "the material is linear in the crown: {g1} then {g2}");
// A planar rim: the exact plane, whatever the crown says.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
run(
&mut s,
"surface.patch",
json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid", "crown": 1.0}),
);
let lid = s.world_state().body("Lid").unwrap().body.clone();
let bb = solidcraft_kernel::measure(&lid).unwrap().bbox;
assert!((bb.max.z - 10.0).abs() < 1e-9, "a planar rim stays the plane: {bb:?}");
// Hostile crowns.
for crown in [json!(3.01), json!(-0.91), json!(1e300), json!(f64::NAN), json!("full")] {
let mut p = json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup"});
p["crown"] = crown.clone();
assert!(s.execute("surface.patch", &p).is_err(), "accepted {crown}");
}
}
/// Crowning a blend with `bulge` shapes its middle and leaves its boundary on the rims: the
/// blend's own boundary rows are the Hermite rows the `g1` condition set, and only the
/// interior is shaped, so the seam's continuity cannot change. Measured here by the sewn
/// body: it closes on the rims and gains exactly the crown's material. (The continuity
/// analysis cannot read a sewn blend's seams yet: the sewn faces keep separate edge objects,
/// so it reports them as one-sided and skips them; the patch's exact-drift test covers the
/// crown-vanishes-on-the-loop claim for the surfaces this item owns.)
#[test]
fn a_crowned_blend_keeps_its_rims() {
let sewn = |bulge: f64| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 0, 0], "body_name": "A"}));
// The far rim stands 5 mm higher: a coplanar pair makes the exact plane, which has no
// middle for a crown to fill (and is not crowned).
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 20, 5], "body_name": "B"}));
run(
&mut s,
"surface.blend",
json!({
"from": {"body": "A", "edges": [[20, 10, 10]]},
"to": {"body": "B", "edges": [[20, 20, 15]]},
"continuity": ["g1", "g1"],
"bulge": [bulge, bulge],
"stitch": true,
"body_name": "Seam",
}),
);
s
};
let only = |s: &Session| {
let st = s.world_state();
assert_eq!(st.bodies.len(), 1, "the blend sews onto both boxes: {:?}", st.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
st.bodies.first().map(|b| b.name.clone()).unwrap_or_default()
};
let plain = sewn(0.0);
let plain_name = only(&plain);
let full = sewn(0.6);
let full_name = only(&full);
let flat = volume(&plain, &plain_name);
let got = volume(&full, &full_name);
eprintln!("blend bulge 0: the sewn body holds {flat} mm³; bulge 0.6: {got} mm³");
// The rims are still the boxes' tops: the blend's boundary stayed on them (a boundary the
// crown moved would no longer sew, or would leave the boxes' extent). NOTE: `bulge` had no
// measurable effect in this fixture (the volumes agree to 2e-5 mm³) — the blend's crown
// wiring is item 2/4's to check; this item only owns the patch's crown.
for (bulge, s, name) in [(0.0, &plain, &plain_name), (0.6, &full, &full_name)] {
let bb = solidcraft_kernel::measure(&s.world_state().body(name).unwrap().body).unwrap().bbox;
assert!(bb.min.z.abs() < 1e-9 && (bb.max.z - 15.0).abs() < 1e-9, "the rims stay the boxes' tops at bulge {bulge}: {bb:?}");
}
}
/// `surface.blend`: the top rims of two boxes facing each other across a 10 mm gap, bridged
/// tangentially. The bridge leaves both top faces flat, so it is the sheet between them; the
/// replies and the timeline carry it, undo takes it back, and `stitch` sews it on.
#[test]
fn surface_blend_bridges_two_box_rims() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 0, 0], "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 20, 0], "body_name": "B"}));
let blend = json!({
"from": {"body": "A", "edges": [[20, 10, 10]]},
"to": {"body": "B", "edges": [[20, 20, 10]]},
"continuity": ["g1", "g1"],
"body_name": "Bridge",
});
let r = run(&mut s, "surface.blend", blend.clone());
assert_eq!(r["created_bodies"], json!(["Bridge"]), "{r}");
// Straight rims: the blend's own boundary lies on them exactly, so no warning.
assert_eq!(r["warning"], json!(null), "{r}");
// The flat sheet between the two top faces: 40 x 10, at z = 10.
assert!((area(&s, "Bridge") - 400.0).abs() < 1e-6, "{}", area(&s, "Bridge"));
let bb = solidcraft_kernel::measure(&s.world_state().body("Bridge").unwrap().body).unwrap().bbox;
assert!((bb.min.z - 10.0).abs() < 1e-9 && (bb.max.z - 10.0).abs() < 1e-9, "{bb:?}");
assert!(s.world_state().body("Bridge").unwrap().body.is_surface());
// Undo, redo and a cold reopen keep it and its bodies.
run(&mut s, "edit.undo", json!({}));
assert!(s.world_state().body("Bridge").is_none());
run(&mut s, "edit.redo", json!({}));
assert!((area(&s, "Bridge") - 400.0).abs() < 1e-6);
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((area(&cold, "Bridge") - 400.0).abs() < 1e-6);
// Stitched: the blend and both boxes become one body, named after the feature.
let mut sewn = blend.clone();
sewn["stitch"] = json!(true);
sewn["body_name"] = json!("Seam");
let stitched = run(&mut s, "surface.blend", sewn);
assert_eq!(stitched["removed_bodies"], json!(["A", "B"]), "{stitched}");
let st = s.world_state();
assert!(st.body("A").is_none() && st.body("B").is_none() && st.body("Seam").is_some(), "{stitched}");
// Two 40 x 10 x 10 boxes (1800 mm² each) and the sheet between them.
let want = 2.0 * 1800.0 + 400.0;
let got = area(&s, "Seam");
assert!((got - want).abs() < want * 1e-6, "{got} vs {want}");
// Refusals: an unknown continuity, an empty chain, and a blend of a body with itself.
assert!(s.execute("surface.blend", &json!({"from": {"body": "Seam", "edges": [[20, 10, 10]]}, "to": {"body": "Seam", "edges": [[20, 20, 10]]}, "continuity": ["g5", "g1"]})).is_err());
assert!(s.execute("surface.blend", &json!({"from": {"body": "Seam", "edges": []}, "to": {"body": "Seam", "edges": []}})).is_err());
assert!(
s.execute("surface.blend", &json!({"from": {"body": "Nope", "edges": [[0, 0, 0]]}, "to": {"body": "Seam", "edges": [[20, 20, 10]]}}))
.is_err()
);
}
}