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

+2
View File
@@ -74,6 +74,7 @@ mod sketch_modify;
mod sketch_project;
mod surface;
mod surface_analysis;
mod surface_match;
mod view_dxf;
mod view_navigation;
@@ -236,6 +237,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
v.extend(surface::COMMANDS.iter());
v.extend(surface_match::COMMANDS.iter());
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
+95 -7
View File
@@ -3,7 +3,8 @@
use serde_json::Value;
use solidcraft_doc::{
BlendSide, EdgeContinuity, ExtendMode, ExtendTo, FaceAt, FeatureKind, PatchContinuity, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind,
BlendSide, EdgeContinuity, Expr, ExtendMode, ExtendTo, FaceAt, FeatureKind, PatchContinuity, PlaneRef, ProfileSel, RuledCorners, RuledMode,
expr::Kind,
};
use solidcraft_doc::{BodyName, SketchId};
use solidcraft_doc::{Dir, FacePick, Position};
@@ -11,7 +12,7 @@ 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};
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
use crate::params::{bad, bool_, expr, num, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
@@ -59,10 +60,6 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "CREATE")
.icon("ruled")
.params("body; edges: [[x,y,z]] (points on a chain of its edges, open or closed); distance: expr (how far the strip reaches); mode?: normal|tangent|angle|direction (default normal: along the normal of the face beside each edge; tangent: continuing it); angle?: expr (angle mode: from `direction`, else from the face's normal, turned away from the face; 90 deg from the normal is tangent); direction?: [x,y,z] (direction mode: the rulings; angle mode: the reference, e.g. a pull direction for a draft); face?: [x,y,z] (a point on the face the directions come from; default: the face beside the most picked edges); flip?: bool (the other side); corners?: mitre|round (sharp corners of the chain; inside corners are always cut back). Lines and arcs give exact planes, cylinders and cones, other edges a fitted ruled B-spline. A new surface body"),
planned!("surface.boundary", "Boundary Surface", "763")
.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"),
CommandSpec::new("surface.blend", "Blend Surface", blend)
.at("SURFACE", "CREATE")
.icon("surface_blend")
@@ -79,6 +76,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "MODIFY")
.icon("reverse_normal")
.params("body (a surface); faces?: [[x,y,z]] (points on the faces to turn over; default: all of them)"),
CommandSpec::new("surface.fair", "Fair Surface", fair)
.at("SURFACE", "MODIFY")
.icon("spline_cv")
.params("body: surface name; faces?: [[x,y,z]] points on the faces to fair (default: every face); strength?: number (0..1 of the full fairing step, default 1); iterations?: number (1..1000 steps, default 1); keep_edges?: bool (default true: hold the boundary row and the row next to it, so a face keeps meeting its neighbours in position and in tangent); tolerance?: expr mm (how far a control vertex may move, default 0.001 of the body's size). Moves the interior control vertices of each B-spline face to flatten its curvature variation while the boundary stays put: the face keeps its edges and the body its shape within the tolerance, and the warning in the reply carries the energy before and after; name?"),
planned!("surface.fillet", "Surface Fillet", "769")
.at("SURFACE", "MODIFY")
.icon("surface_fillet")
@@ -558,6 +559,39 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::SurfaceTrim { body: BodyName(body), plane, tool, keep: Position(keep) })
}
/// `surface.fair`: the B-spline faces of a body faired in place. The tolerance a caller leaves
/// out is a thousandth of the body's own size, so a fairing run can never make a design jump by
/// more than a hair of itself.
fn fair(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.fair";
let (body, faces) = body_faces(s, p, cmd)?;
let size = {
let state = s.model.state();
let Some(mb) = state.body(&body) else { return Err(bad(cmd, format!("no body `{body}`"))) };
mb.body.size()
};
let strength = num(p, "strength").unwrap_or(1.0);
if !(strength.is_finite() && strength > 0.0 && strength <= 1.0) {
return Err(bad(cmd, "`strength` must be greater than 0 and at most 1 (the whole fairing step)"));
}
let steps = num(p, "iterations").unwrap_or(1.0);
if !(steps.is_finite() && (1.0..=1000.0).contains(&steps)) {
return Err(bad(cmd, "`iterations` must be 1 to 1000"));
}
let keep_edges = bool_(p, "keep_edges").unwrap_or(true);
let tolerance = expr(p, "tolerance").unwrap_or_else(|| Expr::new(format!("{:.6}", (size * 1e-3).max(1e-6))));
check_expr(s, tolerance.as_str(), Kind::Length, cmd, "tolerance")?;
let kind = FeatureKind::SurfaceFair {
body: BodyName::from(body),
faces: faces.into_iter().map(FacePick).collect(),
strength,
iterations: steps as u32,
keep_edges,
tolerance,
};
add_feature(s, p, kind)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1178,7 +1212,7 @@ mod tests {
/// as it was.
#[test]
fn planned_commands_refuse_and_leave_the_design_alone() {
let planned = ["surface.boundary", "surface.fillet", "inspect.continuity"];
let planned = ["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();
@@ -1500,4 +1534,58 @@ mod tests {
let sketch_patch = json!({"sketch": 1, "continuity": "g1"});
assert!(s.execute("surface.patch", &sketch_patch).is_err(), "continuity on a sketch patch");
}
/// The energies the reply's warning carries: the fairness energy before and after.
fn energies(reply: &Value) -> (f64, f64) {
let warning = reply["warning"].as_str().unwrap_or_else(|| panic!("no warning in {reply}"));
let after = warning.split("energy ").nth(1).unwrap_or_else(|| panic!("no energies in `{warning}`"));
let numbers: Vec<f64> = after
.split(|c: char| !(c.is_ascii_digit() || c == '.' || c == 'e' || c == '-' || c == '+'))
.filter_map(|w| w.parse::<f64>().ok())
.collect();
assert!(numbers.len() >= 2, "no energies in `{warning}`");
(numbers[0], numbers[1])
}
/// Surface Fair on the fitted B-spline face of an offset sphere: the face is smoothed in
/// place, its edges do not move at all, the reply carries the energy before and after, undo,
/// redo and a cold reopen keep it, and hostile inputs are refused.
#[test]
fn fairing_a_b_spline_face_keeps_its_edges_and_reports_its_energy() {
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"radius": 20, "body_name": "Ball"}));
// A sphere is neither a plane nor a cylinder, so its offset is a fitted B-spline face.
run(&mut s, "surface.offset", json!({"faces": [[0, 0, 20]], "body": "Ball", "distance": 2, "body_name": "Shell"}));
let st = s.world_state();
assert!(st.body("Shell").unwrap().body.is_surface(), "an offset sphere face is a sheet");
let before: Vec<solidcraft_geom::Vec3> = st.body("Shell").unwrap().body.edges(0.1).unwrap().iter().flat_map(|e| e.points.clone()).collect();
assert!(!before.is_empty());
let area_before = area(&s, "Shell");
// The fairing reports both energies, the second no larger than the first.
let reply = run(&mut s, "surface.fair", json!({"body": "Shell", "tolerance": 1.0, "iterations": 3}));
let (energy_before, energy_after) = energies(&reply);
let area_after = area(&s, "Shell");
eprintln!("surface.fair: {reply}\nenergies {energy_before:e} → {energy_after:e}, area {area_before} → {area_after}");
assert!(energy_before > 0.0 && energy_after <= energy_before, "{energy_before:e} → {energy_after:e}");
// The face moved (the fit is not the flattest surface on its own rim), but not its edges:
// every boundary point of the body is where it was, bit for bit.
assert!((area_after - area_before).abs() > 0.0 || energy_after < energy_before);
let after: Vec<solidcraft_geom::Vec3> = st.body("Shell").unwrap().body.edges(0.1).unwrap().iter().flat_map(|e| e.points.clone()).collect();
assert_eq!(after, before, "fairing must not move the face's edges");
assert_eq!(st.body("Shell").unwrap().body.face_count(), 1);
// Undo takes the fairing back, redo puts it back, and a cold reopen rebuilds the same.
run(&mut s, "edit.undo", json!({}));
assert!((area(&s, "Shell") - area_before).abs() < 1e-9, "undo did not take the fairing back");
run(&mut s, "edit.redo", json!({}));
assert!((area(&s, "Shell") - area_after).abs() < 1e-9, "redo did not put the fairing back");
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((area(&cold, "Shell") - area_after).abs() < 1e-9, "a cold reopen did not rebuild the same sheet");
// Refusals: an unknown body, a body whose faces are all planes, an out-of-range strength
// or step count, and a missing tolerance expression.
assert!(s.execute("surface.fair", &json!({"body": "Nope", "tolerance": 1.0})).is_err());
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"}));
assert!(s.execute("surface.fair", &json!({"body": "Box", "tolerance": 1.0})).is_err());
assert!(s.execute("surface.fair", &json!({"body": "Shell", "strength": 2, "tolerance": 1.0})).is_err());
assert!(s.execute("surface.fair", &json!({"body": "Shell", "iterations": 0, "tolerance": 1.0})).is_err());
assert!(s.execute("surface.fair", &json!({"body": "Shell", "tolerance": 0})).is_err());
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Boundary surfaces: one patch through a network of two to four sketch curves (`surface.boundary`,
//! issue #763). The kernel builds the face from the curves' own frames; this is the command's
//! parameters and its checks, so that a loop it cannot hold is refused before anything is built.
use serde_json::Value;
use solidcraft_doc::{FeatureKind, SketchId};
use solidcraft_kernel::Continuity;
use super::CommandSpec;
use super::features::{add_feature, feature_sketch};
use crate::params::{bad, string_list};
use crate::{Result, Session};
/// The stations a boundary carries by default (the patch's resolution along the loop).
const DEFAULT_SAMPLES: usize = 25;
/// The fewest stations a patch can hold the widest continuity pair with: a G3 side needs four rows
/// at each of two opposite sides, so eight.
const MIN_SAMPLES: usize = 8;
/// The most stations a boundary may carry (the kernel's own cap).
const MAX_SAMPLES: usize = 512;
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("surface.boundary", "Boundary Surface", boundary)
.at("SURFACE", "CREATE")
.icon("boundary")
.params(
"sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; the patch runs through them and leaves each boundary along the tangent across it); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary); samples?: 8…512 stations per boundary (default 25); name?, body_name? (a surface body)",
)];
/// `surface.boundary`: one patch through a network of two to four sketch curves.
fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.boundary";
let sketch = feature_sketch(s, p, cmd)?;
let curves = string_list(p, "curves");
if !(2..=4).contains(&curves.len()) {
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order"));
}
// The curves must be the sketch's own, and they must run on into one another: the last station
// of each is the first of the next, and the last boundary's end is the first one's start.
let state = s.model.state();
let sk = state.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch has not been solved"))?;
let mut ends: Vec<(solidcraft_geom::Vec2, solidcraft_geom::Vec2)> = Vec::with_capacity(curves.len());
for id in &curves {
let ci = sk.sketch.curve_index(id).ok_or_else(|| bad(cmd, format!("no sketch curve `{id}`")))?;
let pieces = sk.sketch.exact_pieces(ci);
let (Some(first), Some(last)) = (pieces.first(), pieces.last()) else {
return Err(bad(cmd, format!("curve `{id}` has no exact geometry to build on")));
};
if last.end().dist(first.start()) < 1e-9 {
return Err(bad(cmd, format!("curve `{id}` is closed: a boundary must be one side of the loop")));
}
ends.push((first.start(), last.end()));
}
// Two boundaries are the two sides of a bridge, which do not meet; three or four run round a
// loop, so each has to end where the next begins.
let scale = ends.iter().map(|(a, b)| a.dist(*b)).fold(1.0_f64, f64::max);
let tol = scale * 1e-4 + 1e-9;
for i in 0..if curves.len() >= 3 { ends.len() } else { 0 } {
let next = (i + 1) % ends.len();
let (Some(end), Some(start)) = (ends.get(i).map(|e| e.1), ends.get(next).map(|e| e.0)) else {
return Err(bad(cmd, "a curve is missing its ends"));
};
let gap = end.dist(start);
if gap > tol {
return Err(bad(
cmd,
format!("curve `{}` ends {gap:.4} mm from where `{}` starts: list the curves in loop order", curves[i], curves[next]),
));
}
}
drop(state);
let orders = match p.get("continuity") {
None | Some(Value::Null) => vec![Continuity::G1; curves.len()],
Some(Value::String(w)) => {
let one = Continuity::parse(w).ok_or_else(|| bad(cmd, "`continuity` must be g0, g1, g2 or g3"))?;
vec![one; curves.len()]
}
Some(v) => {
let list = v.as_array().filter(|a| a.len() == curves.len()).ok_or_else(|| bad(cmd, "`continuity` must be one word, or one per curve"))?;
let mut out = Vec::with_capacity(list.len());
for x in list {
out.push(x.as_str().and_then(Continuity::parse).ok_or_else(|| bad(cmd, "`continuity` entries must be g0, g1, g2 or g3"))?);
}
out
}
};
let crown = match p.get("crown") {
None | Some(Value::Null) => 0.0,
Some(v) => v
.as_f64()
.filter(|x| x.is_finite() && (solidcraft_geom::fill::MIN_CROWN..=solidcraft_geom::fill::MAX_CROWN).contains(x))
.ok_or_else(|| bad(cmd, "`crown` must be a number between -0.9 and 3"))?,
};
let samples = match p.get("samples") {
None | Some(Value::Null) => DEFAULT_SAMPLES,
Some(v) => {
let n = v.as_f64().filter(|x| x.is_finite() && x.fract() == 0.0).map(|x| x as usize);
n.filter(|x| (MIN_SAMPLES..=MAX_SAMPLES).contains(x))
.ok_or_else(|| bad(cmd, format!("`samples` must be a whole number of stations between {MIN_SAMPLES} and {MAX_SAMPLES}")))?
}
};
add_feature(s, p, FeatureKind::SurfaceBoundary { sketch: SketchId(sketch), curves, orders, crown, samples })
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn ids(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
/// Two parallel 20 mm lines 10 mm apart, and the patch between them.
fn two_walls(s: &mut Session) -> Vec<String> {
let a = ids(&run(s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["created"]["curves"])[0].clone();
let b = ids(&run(s, "sketch.line", json!({"points": [[0, 10], [20, 10]]}))["created"]["curves"])[0].clone();
vec![a, b]
}
#[test]
fn a_boundary_patch_between_two_sketch_curves_is_the_sheet_they_span() {
for continuity in [json!("g0"), json!("g1"), json!("g2"), json!(["g1", "g2"])] {
let mut s = new_sketch();
let curves = two_walls(&mut s);
let r = run(&mut s, "surface.boundary", json!({"curves": curves, "continuity": continuity}));
let body = ids(&r["created_bodies"])[0].clone();
// It is one face, its boundary is the two lines themselves, and it covers exactly the
// 20 × 10 sheet between them — all four frames lie in the sketch's plane.
let f = run(&mut s, "model.faces", json!({"body": body}));
let faces = f["faces"].as_array().cloned().unwrap_or_default();
assert_eq!(faces.len(), 1, "{continuity}: {f}");
assert!((faces[0]["area"].as_f64().unwrap_or(f64::NAN) - 200.0).abs() < 1e-4, "{continuity}: {f}");
let normal = faces[0]["plane_normal"].as_array().cloned().unwrap_or_default();
assert_eq!(normal.len(), 3, "{continuity}: the patch is planar: {f}");
assert!(normal[2].as_f64().unwrap_or(0.0).abs() > 1.0 - 1e-9, "{continuity}: it lies in the sketch's plane: {f}");
let m = run(&mut s, "inspect.measure", json!({}));
let area = m["total"]["area_mm2"].as_f64().unwrap_or(f64::NAN);
assert!((area - 200.0).abs() < 1e-4, "{continuity}: {area}");
}
}
#[test]
fn a_boundary_patch_refuses_a_loop_and_values_it_cannot_hold() {
let mut s = new_sketch();
let curves = two_walls(&mut s);
let extra = ids(&run(&mut s, "sketch.line", json!({"points": [[30, 30], [40, 40]]}))["created"]["curves"])[0].clone();
// A loop of three or four: the kernel refuses it with its own reason (a tensor-product patch
// cannot hold two sides' data at a corner, issue #763) rather than building it approximately.
let triangle = ["l1", "l2", "l3"].map(|id| {
let _ = id;
String::new()
});
let _ = triangle;
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["created"]["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 0], [10, 15]]}))["created"]["curves"])[0].clone();
let c = ids(&run(&mut s, "sketch.line", json!({"points": [[10, 15], [0, 0]]}))["created"]["curves"])[0].clone();
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "continuity": "g2"})).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("#763"), "three boundaries: {e}");
// A closed square of four lines, in loop order.
let sq = [[[0, 0], [10, 0]], [[10, 0], [10, 10]], [[10, 10], [0, 10]], [[0, 10], [0, 0]]];
let square: Vec<String> = sq.iter().map(|p| ids(&run(&mut s, "sketch.line", json!({"points": p}))["created"]["curves"])[0].clone()).collect();
let e = s.execute("surface.boundary", &json!({"curves": square})).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("#763"), "four boundaries: {e}");
// Parameters and curves that make no sense.
let cases: Vec<(Value, &str)> = vec![
(json!({"curves": [curves[0]]}), "2 to 4"),
(json!({"curves": [curves[0], curves[1], extra.clone(), extra.clone(), extra.clone()]}), "2 to 4"),
(json!({"curves": [curves[0], "nope"]}), "no sketch curve"),
(json!({"curves": curves, "continuity": "g9"}), "continuity"),
(json!({"curves": curves, "continuity": ["g1", "g2", "g3"]}), "continuity"),
(json!({"curves": curves, "crown": 5}), "crown"),
(json!({"curves": curves, "crown": "full"}), "crown"),
(json!({"curves": curves, "samples": 3}), "samples"),
(json!({"curves": curves, "samples": 2.5}), "samples"),
];
for (params, want) in cases {
let e = s.execute("surface.boundary", &params).unwrap_err().to_string();
assert!(e.contains(want), "{params}: {e}");
}
// Nothing was built by any of them.
assert!(s.model.state().bodies.is_empty(), "a refused patch left a body behind");
}
}
+160
View File
@@ -0,0 +1,160 @@
//! `surface.match`: rebuild a surface body's face so that one of its boundaries takes the place of
//! a picked edge chain of another body, repeating that body's shape along the seam up to a
//! requested continuity, while the boundary opposite it keeps its place. The body keeps its name:
//! a match is a modify, like Trim, Extend and Fair.
use serde_json::Value;
use solidcraft_doc::{BodyName, FacePick, FeatureKind, Position};
use solidcraft_kernel::Continuity;
use super::CommandSpec;
use super::features::add_feature;
use crate::params::{bad, str_, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.match", "Match Surface", match_surface)
.at("SURFACE", "MODIFY")
.icon("continuity")
.params(
"body: surface name (the body whose face is rebuilt); faces?: [[x,y,z]] (points on that face, default: the body's first); target: body name (the body the seam must meet); edges: [[x,y,z]] (points on one edge chain of the target: the rebuilt boundary takes its place); order?: g1|g2|g3 (default g2: position and tangent plane, the curvature too, or how that curvature changes along the seam). The far boundary stays where it was and the body keeps its name",
),
];
/// `surface.match`: one face of `body` is rebuilt so that its boundary nearest the picked chain of
/// `target` lies on that chain and repeats the target's own shape there up to `order`.
fn match_surface(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.match";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let target = str_(p, "target").ok_or_else(|| bad(cmd, "`target` is required"))?.to_string();
if s.model.state().body(&target).is_none() {
return Err(bad(cmd, format!("no body `{target}`")));
}
let faces: Vec<solidcraft_geom::Vec3> = p.get("faces").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if faces.len() > 64 {
return Err(bad(cmd, "too many points on the face to rebuild"));
}
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() || edges.len() > 10_000 {
return Err(bad(cmd, "`edges` must list 1 to 10000 points on the target's edge chain"));
}
let order = match p.get("order") {
None => Continuity::G2,
Some(v) => v.as_str().and_then(Continuity::parse).filter(|o| *o != Continuity::G0).ok_or_else(|| bad(cmd, "`order` must be g1, g2 or g3"))?,
};
add_feature(
s,
p,
FeatureKind::SurfaceMatch {
body: BodyName::from(body),
faces: faces.into_iter().map(FacePick).collect(),
target: BodyName::from(target),
edges: edges.into_iter().map(Position).collect(),
order,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
use serde_json::json;
use solidcraft_geom::Vec3;
fn run(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
}
/// The picked chain: two points of the cylinder's own vertical wall seam, clear of the corners
/// at either end. The seam is where `solid.extrude` starts the circle, so it is read off the
/// body rather than assumed.
fn picks(s: &Session) -> Vec<Vec3> {
let boss = s.world_state().body("Boss").unwrap().body.clone();
let e = boss
.edges(boss.size() * 1e-2)
.unwrap()
.into_iter()
.find(|e| e.length > 25.0 && (e.mid.x.hypot(e.mid.y) - 10.0).abs() < 0.05 && e.mid.z > 5.0 && e.mid.z < 25.0)
.expect("the cylinder has a straight vertical wall seam");
let (a, b) = (e.points.first().copied(), e.points.last().copied());
let (a, b) = (a.unwrap_or(e.mid), b.unwrap_or(e.mid));
vec![a + (b - a) * 0.25, a + (b - a) * 0.75]
}
/// A cylinder of radius 10, 30 high, and a cylindrical skirt of radius 11 from the cylinder's
/// own wall seam round 60 degrees: the skirt's near boundary is 1 mm off the seam line.
fn boss_and_skirt(s: &mut Session) {
run(s, "sketch.create", json!({"plane": "XY", "name": "Round"}));
run(s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": "Round", "distance": 30, "body_name": "Boss"}));
let seam = picks(s)[0];
let a = seam.y.atan2(seam.x);
let at = |t: f64| json!([11.0 * t.cos(), 11.0 * t.sin()]);
run(s, "sketch.create", json!({"plane": "XY", "name": "Skirt sketch"}));
let arc = run(s, "sketch.arc.three_point", json!({"start": at(a), "end": at(a + 60f64.to_radians()), "through": at(a + 30f64.to_radians())}))
["created"]["curves"][0]
.as_str()
.unwrap()
.to_string();
run(s, "sketch.finish", json!({}));
run(s, "surface.extrude", json!({"sketch": "Skirt sketch", "curves": [arc], "distance": 30, "body_name": "Skirt"}));
}
/// The seam of a matched skirt, measured back off the two bodies: how far the rebuilt boundary
/// is from the picked seam line, the angle between the two surfaces' normals across it, and
/// the continuity it holds.
fn seam(s: &Session) -> (f64, f64, Continuity) {
let st = s.world_state();
let made = st.body("Skirt").unwrap().body.clone();
let target = st.body("Boss").unwrap().body.clone();
solidcraft_kernel::seam_continuity(&made, &target, &picks(s)).unwrap()
}
/// `surface.match`: a skirt meets a cylinder's wall seam at g2; the body keeps its name, undo
/// and redo move it back and forth, a cold reopen gives the same seam, and what cannot be
/// matched is refused.
#[test]
fn surface_match_rebuilds_a_skirt_onto_a_cylinder() {
let mut s = Session::default();
boss_and_skirt(&mut s);
// Before the match the skirt's boundary is the 1 mm-wider line it was extruded at.
let (gap, _, _) = seam(&s);
assert!((gap - 1.0).abs() < 0.05, "the skirt starts a millimetre off the seam: {gap}");
let chain = picks(&s);
run(&mut s, "surface.match", json!({"body": "Skirt", "target": "Boss", "edges": chain, "order": "g2"}));
let st = s.world_state();
assert!(st.body("Skirt").is_some(), "the matched body keeps its name");
assert!(st.body("Boss").is_some(), "the body it was matched to stays too");
assert!(st.body("Skirt").unwrap().body.is_surface(), "a match stays a surface");
let (gap, deg, order) = seam(&s);
println!("engine: gap {gap:e} mm, normal {deg:e} deg, order {order:?}");
assert!(gap < 1e-6, "the seam is on the picked line: {gap:e} mm");
assert!(deg < 0.05, "the seam holds the wall's tangent plane: {deg} deg");
assert!(order.conditions() >= Continuity::G1.conditions(), "{order:?}");
// Undo puts the boundary back, redo matches it again, and a cold reopen agrees.
run(&mut s, "edit.undo", json!({}));
assert!(seam(&s).0 > 0.5, "undo restores the original skirt");
run(&mut s, "edit.redo", json!({}));
assert!(seam(&s).1 < 0.05, "redo matches it again");
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!(seam(&cold).1 < 0.05, "a reopened design matches the same");
// Refusals: an unknown body, no target, no picks on the target, a word that is no order,
// and a body that is not a surface.
assert!(s.execute("surface.match", &json!({"body": "Nope", "target": "Boss", "edges": picks(&s)})).is_err());
assert!(s.execute("surface.match", &json!({"body": "Skirt", "edges": picks(&s)})).is_err());
assert!(s.execute("surface.match", &json!({"body": "Skirt", "target": "Boss", "edges": []})).is_err());
assert!(s.execute("surface.match", &json!({"body": "Skirt", "target": "Nope", "edges": picks(&s)})).is_err());
assert!(s.execute("surface.match", &json!({"body": "Skirt", "target": "Boss", "edges": picks(&s), "order": "g5"})).is_err());
assert!(s.execute("surface.match", &json!({"body": "Skirt", "target": "Boss", "edges": [[100, 0, 0]]})).is_err());
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [4, 4]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 4, "body_name": "Block"}));
assert!(s.execute("surface.match", &json!({"body": "Block", "target": "Boss", "edges": picks(&s)})).is_err(), "a solid is not a surface");
}
}