A silhouette takes its facing from the surface's normals

A flat triangle beside the outline can tip the other way from the surface it approximates, and its
edges joined the outline. On SMLib's sphere that made 192 segments instead of the equator's 64, 100
curves and 95 profiles from the tight fit of #7989. Each corner's normal is used where it is close
to the triangle's own; a mesh without such normals (none, zero, not numbers, or averaged across
creases) keeps the triangle's normal, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
This commit is contained in:
brett19
2026-10-31 20:01:29 -07:00
parent 48fcb7aac1
commit abd7398551
3 changed files with 58 additions and 2 deletions
+20 -2
View File
@@ -1334,6 +1334,24 @@ fn section_of_polyline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
out
}
/// Which way the surface faces at triangle `t`: its normals at the corners, added. A flat triangle
/// close to the outline can tip the other way from the surface it approximates (one whose far
/// corner lies inside the chord of its near edge), which would add spurs to the outline. The
/// triangle's own normal `flat` when a corner has none, or when they aren't that surface's: unit,
/// and close to `flat` (normals averaged across a crease, as an imported mesh may have, are not).
fn surface_facing(m: &Mesh, t: &[u32; 3], flat: Vec3) -> Vec3 {
/// Least cosine between a corner's normal and the triangle's: about 26 degrees.
const AGREES: f64 = 0.9;
let mut sum = Vec3::ZERO;
for i in t {
match m.normals.get(*i as usize).and_then(|v| v.normalized()) {
Some(v) if v.dot(flat) >= AGREES => sum = sum + v,
_ => return flat,
}
}
sum.normalized().unwrap_or(flat)
}
/// The outline of a body seen along the sketch normal: mesh edges between triangles facing the
/// sketch and triangles facing away (or edge-on), projected and fitted.
fn silhouette(plane: &Plane, m: &Mesh) -> Vec<LinkGeom> {
@@ -1346,8 +1364,8 @@ fn silhouette(plane: &Plane, m: &Mesh) -> Vec<LinkGeom> {
for t in &m.triangles {
let Some([a, b, c]) = m.tri(t) else { continue };
let fnrm = (b - a).cross(c - a);
let Some(un) = fnrm.normalized() else { continue };
let front = un.dot(n) > 1e-6;
let Some(flat) = fnrm.normalized() else { continue };
let front = surface_facing(m, t, flat).dot(n) > 1e-6;
for (p, r) in [(a, b), (b, c), (c, a)] {
let (kp, kr) = (key(p), key(r));
let k = if kp <= kr { (kp, kr) } else { (kr, kp) };
+20
View File
@@ -177,6 +177,26 @@ fn boundary_edges(mesh: &Mesh, mut edges: Vec<usize>, faces: &BTreeSet<usize>, p
mod tests {
use super::*;
/// A cube's outline from above is its four sides whatever normals its mesh carries: none, too
/// few, zero or not numbers fall back to each triangle's own normal, and so do normals
/// averaged across its creases, as an imported mesh may have them.
#[test]
fn a_silhouette_takes_the_triangles_own_normal_where_the_mesh_has_no_surface_normal() {
let corner = |i: u32| Vec3::new(f64::from(i & 1), f64::from((i >> 1) & 1), f64::from((i >> 2) & 1)) * 10.0;
let positions: Vec<Vec3> = (0..8).map(corner).collect();
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let triangles: Vec<[u32; 3]> = quads.iter().flat_map(|[a, b, c, d]| [[*a, *b, *c], [*a, *c, *d]]).collect();
let averaged: Vec<Vec3> = positions.iter().map(|p| (*p - Vec3::new(5.0, 5.0, 5.0)).normalized().unwrap_or(Vec3::Z)).collect();
let nan = Vec3::new(f64::NAN, 0.0, 0.0);
for normals in [Vec::new(), vec![Vec3::Z; 3], vec![Vec3::ZERO; 8], vec![nan; 8], vec![Vec3::new(f64::INFINITY, 0.0, 0.0); 8], averaged] {
let tri_face = vec![0; triangles.len()];
let mesh = Mesh { positions: positions.clone(), normals: normals.clone(), triangles: triangles.clone(), tri_face, ..Mesh::default() };
let outline = super::super::silhouette(&Plane::XY, &mesh);
let sides = outline.iter().filter(|g| matches!(g, solidcraft_sketch::LinkGeom::Line(a, b) if (a.dist(*b) - 10.0).abs() < 1e-9)).count();
assert_eq!((outline.len(), sides), (4, 4), "{normals:?}: {outline:?}");
}
}
#[test]
fn face_family_omits_only_exact_shared_topological_edges() {
let mut mesh = Mesh::default();
@@ -1296,3 +1296,21 @@ fn a_thicker_angle_moves_the_projected_face_of_the_sketch_on_it() {
check(&s);
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
}
/// A sphere's outline from above is its equator: one circle and one profile. Flat triangles beside
/// the equator can tip the other way from the sphere, and their edges must not join the outline.
#[test]
fn a_spheres_silhouette_is_one_circle() {
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"radius": 8, "body_name": "Ball"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": -10, "name": "Below"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"body": "Ball"}]}));
let curves = r["links"][0]["curves"].as_array().unwrap();
assert_eq!(curves.len(), 1, "{r}");
let inspected = run(&mut s, "sketch.inspect", json!({}));
let circle = inspected["curves"].as_array().unwrap().iter().find(|c| c["id"] == curves[0]).unwrap();
assert_eq!(circle["type"], "circle", "{circle}");
assert!((circle["radius"].as_f64().unwrap() - 8.0).abs() < 1e-6, "{circle}");
let done = run(&mut s, "sketch.finish", json!({}));
assert_eq!(done["profiles"], 1, "{done}");
}