Lips on rounded, shelled rims: the band along the rim's edge is raised from the body's own faces (kernel::raise_band: the rim face split along the band's exact offset edge, the band's walls swept up from the walls below, a top), since a join can't merge walls that continue each other; grooves cut with a band reaching into the free side so no wall coincides; tests against the band's area times its height
Fixes #29 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
This commit is contained in:
1 parent
80f5a58806
commit
67c79ec91c
6 files changed
+297
-4
No files matched your search
@@ -783,11 +783,28 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
return Err(DocError::Invalid("pick the rim face of a shelled body (a face with an inner and an outer edge)".into()));
|
||||
};
|
||||
let bw = if *groove { w + g } else { w };
|
||||
// The band along the chosen edge, inside the rim face.
|
||||
// A lip: the band raised from the rim's own faces (its walls continue the walls
|
||||
// below, which a join can't do: they would coincide).
|
||||
if !*groove
|
||||
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
|
||||
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
|
||||
{
|
||||
*slot = ModelBody::new(target.clone(), b, slot.feature);
|
||||
return Ok(());
|
||||
}
|
||||
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
|
||||
// the edge into the free side (the cavity, or outside), so no wall coincides.
|
||||
let free = if *groove { bw } else { 0.0 };
|
||||
let band = if *outside {
|
||||
Region2 { outer: Loop2::polygon(&outer).ccw(), holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()] }
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()],
|
||||
}
|
||||
} else {
|
||||
Region2 { outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(), holes: vec![Loop2::polygon(&inner).ccw().reversed()] }
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()],
|
||||
}
|
||||
};
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
if *groove {
|
||||
|
||||
@@ -280,3 +280,33 @@ fn lip_after_boss() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A lip, a groove and an outside lip on the rim of a rounded, shelled enclosure (#29: the
|
||||
/// lip's walls continue the shell's walls, which a boolean join couldn't do).
|
||||
#[test]
|
||||
fn lip_on_a_rounded_shelled_rim() {
|
||||
use std::f64::consts::PI;
|
||||
let base = json!([
|
||||
{"command": "solid.box", "params": {"corner": [-40, -30, 0], "length": 80, "width": 60, "height": 30}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}},
|
||||
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": 2}}
|
||||
]);
|
||||
let v0 = total_volume(&script(base.clone()));
|
||||
let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
|
||||
let inside = ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0);
|
||||
for (extra, want) in [
|
||||
(json!({}), v0 + inside * 1.5),
|
||||
(json!({"side": "outside"}), v0 + (ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0)) * 1.5),
|
||||
// (The groove's band comes from the rim's meshed outline: within its sag.)
|
||||
(json!({"type": "groove"}), v0 - inside * 1.5),
|
||||
] {
|
||||
let mut p = json!({"face": [0, 29, 30], "width": 1, "height": 1.5});
|
||||
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
let mut steps = base.as_array().cloned().unwrap_or_default();
|
||||
steps.push(json!({"command": "plastic.lip", "params": p}));
|
||||
let v = total_volume(&script(Value::Array(steps)));
|
||||
assert!((v - want).abs() < 1e-3 * want, "{extra}: {v} vs {want}");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ mod heal;
|
||||
mod helix;
|
||||
mod iges_in;
|
||||
mod iges_out;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod measure;
|
||||
mod meshbody;
|
||||
@@ -56,6 +57,7 @@ pub use delete_face::delete_faces;
|
||||
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
|
||||
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
|
||||
pub use iges_out::iges_export_bodies;
|
||||
pub use lip::raise_band;
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use offset::with_same_surface;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Raising a band of a planar face (a plastic part's lip): the band along one of the face's
|
||||
//! boundary loops, a set width into the face, is pushed out along the face's normal. Built
|
||||
//! directly from the body's faces (the band's walls continue the walls below, which a boolean
|
||||
//! union can't join: their faces would coincide).
|
||||
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
fn fail(m: &str) -> KernelError {
|
||||
KernelError::Failed(format!("raising a band: {m}"))
|
||||
}
|
||||
|
||||
/// A boundary edge of the face, as its ends and (for an arc) a point halfway.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Piece {
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
mid: Option<Vec3>,
|
||||
}
|
||||
|
||||
/// The planar face of `body` at `at`: its index and outward normal.
|
||||
fn face_at(body: &Body, at: Vec3) -> Result<(usize, Vec3)> {
|
||||
let size = body.size();
|
||||
let mesh = body.tessellate((size * 1e-3).max(1e-3))?;
|
||||
let near = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter_map(|(t, f)| {
|
||||
let [a, b, c] = mesh.tri(t)?;
|
||||
let n = (b - a).cross(c - a).normalized()?;
|
||||
let q = at - n * (at - a).dot(n);
|
||||
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-9);
|
||||
inside.then_some(((at - a).dot(n).abs(), *f as usize))
|
||||
})
|
||||
.min_by(|x, y| x.0.total_cmp(&y.0));
|
||||
let (d, f) = near.ok_or_else(|| fail("no face there"))?;
|
||||
if d > size * 1e-3 {
|
||||
return Err(fail("no face there"));
|
||||
}
|
||||
let face = body.solid.face_iter().nth(f).ok_or_else(|| fail("face"))?;
|
||||
let mt::Surface::Plane(pl) = face.oriented_surface() else { return Err(fail("the face must be planar")) };
|
||||
let n = pl.normal();
|
||||
Ok((f, Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| fail("normal"))?))
|
||||
}
|
||||
|
||||
/// The face's loop moved `w` into the face (to the left of its edges, as a face's boundary has
|
||||
/// its inside on the left), corners mitred where the offsets don't already meet.
|
||||
fn offset_loop(pieces: &[Piece], n: Vec3, w: f64, tol: f64) -> Result<Vec<Piece>> {
|
||||
let left = |a: Vec3, b: Vec3| n.cross(b - a).normalized();
|
||||
let mut out: Vec<Piece> = Vec::new();
|
||||
for p in pieces {
|
||||
out.push(match p.mid {
|
||||
None => {
|
||||
let l = left(p.a, p.b).ok_or_else(|| fail("zero edge"))?;
|
||||
Piece { a: p.a + l * w, b: p.b + l * w, mid: None }
|
||||
}
|
||||
Some(m) => {
|
||||
// Each point moves square to the arc (along its radius), to the left.
|
||||
let (la, lm, lb) =
|
||||
(left(p.a, m).ok_or_else(|| fail("arc"))?, left(p.a, p.b).ok_or_else(|| fail("arc"))?, left(m, p.b).ok_or_else(|| fail("arc"))?);
|
||||
let c = circle_centre(p.a, m, p.b).ok_or_else(|| fail("arc"))?;
|
||||
let radial = |q: Vec3, hint: Vec3| {
|
||||
let r = (q - c).normalized().unwrap_or(hint);
|
||||
if r.dot(hint) >= 0.0 { r } else { -r }
|
||||
};
|
||||
let _ = (la, lb);
|
||||
Piece { a: p.a + radial(p.a, lm) * w, b: p.b + radial(p.b, lm) * w, mid: Some(m + radial(m, lm) * w) }
|
||||
}
|
||||
});
|
||||
}
|
||||
// Joints: where the offsets don't meet (sharp corners of lines), the lines are extended or
|
||||
// cut back to their crossing.
|
||||
let k = out.len();
|
||||
for i in 0..k {
|
||||
let j = (i + 1) % k;
|
||||
let (Some(pi), Some(pj)) = (out.get(i).copied(), out.get(j).copied()) else { continue };
|
||||
if pi.b.dist(pj.a) < tol {
|
||||
if let Some(x) = out.get_mut(j) {
|
||||
x.a = pi.b;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match (pi.mid, pj.mid) {
|
||||
(None, None) => {
|
||||
let (d1, d2) = (pi.b - pi.a, pj.b - pj.a);
|
||||
let w0 = pj.a - pi.a;
|
||||
let cr = d1.cross(d2);
|
||||
let den = cr.len2();
|
||||
if den < 1e-24 {
|
||||
return Err(fail("parallel edges at a corner"));
|
||||
}
|
||||
let t = w0.cross(d2).dot(cr) / den;
|
||||
let x = pi.a + d1 * t;
|
||||
if let Some(e) = out.get_mut(i) {
|
||||
e.b = x;
|
||||
}
|
||||
if let Some(e) = out.get_mut(j) {
|
||||
e.a = x;
|
||||
}
|
||||
}
|
||||
_ => return Err(fail("an arc meeting another edge at a corner")),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn circle_centre(a: Vec3, m: Vec3, b: Vec3) -> Option<Vec3> {
|
||||
let (u, v) = (b - a, m - a);
|
||||
let w = u.cross(v);
|
||||
let d = 2.0 * w.len2();
|
||||
if d < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
Some(a + (v.cross(w) * u.len2() + w.cross(u) * v.len2()) * (1.0 / d))
|
||||
}
|
||||
|
||||
/// Raise the band of the planar face at `at` that runs `width` wide along its outer loop
|
||||
/// (`outside`) or its inner loop, by `height` along the face's normal.
|
||||
pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool) -> Result<Body> {
|
||||
body.require_brep("a lip")?;
|
||||
if !(width > 1e-6 && height > 1e-6 && width.is_finite() && height.is_finite()) {
|
||||
return Err(KernelError::Invalid("the band's width and height must be positive".into()));
|
||||
}
|
||||
if body.solid.boundaries().len() != 1 {
|
||||
return Err(fail("a body in one piece"));
|
||||
}
|
||||
let size = body.size();
|
||||
let tol = (size * 1e-7).max(1e-9) * 100.0;
|
||||
let (fi, n) = face_at(body, at)?;
|
||||
let faces: Vec<mt::Face> = body.solid.face_iter().cloned().collect();
|
||||
let f = faces.get(fi).ok_or_else(|| fail("face"))?;
|
||||
let wires = f.boundaries();
|
||||
if wires.len() != 2 {
|
||||
return Err(fail("the face must have an outer and one inner loop (a rim)"));
|
||||
}
|
||||
// Outer loop: turning positively about n.
|
||||
let turn = |w: &mt::Wire| -> f64 {
|
||||
let pts: Vec<Vec3> = w.vertex_iter().map(|v| from_p3(v.point())).collect();
|
||||
let mut s = Vec3::ZERO;
|
||||
for (k, p) in pts.iter().enumerate() {
|
||||
if let Some(q) = pts.get((k + 1) % pts.len()) {
|
||||
s += p.cross(*q);
|
||||
}
|
||||
}
|
||||
s.dot(n)
|
||||
};
|
||||
let (w0, w1) = (wires.first().ok_or_else(|| fail("loop"))?, wires.get(1).ok_or_else(|| fail("loop"))?);
|
||||
let (outer, inner) = if turn(w0) >= turn(w1) { (w0, w1) } else { (w1, w0) };
|
||||
let (along, other) = if outside { (outer, inner) } else { (inner, outer) };
|
||||
let pieces: Vec<Piece> = along
|
||||
.edge_iter()
|
||||
.map(|e| {
|
||||
let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point()));
|
||||
match crate::offset::arc_mid(e, size * 1e-6) {
|
||||
Some(None) => Ok(Piece { a, b, mid: None }),
|
||||
Some(Some(m)) => Ok(Piece { a, b, mid: Some(m) }),
|
||||
None => Err(fail("edges must be lines or arcs")),
|
||||
}
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let moved = offset_loop(&pieces, n, width, tol)?;
|
||||
guard("lip", || {
|
||||
// The band's other edge, on the face.
|
||||
let verts: Vec<mt::Vertex> = moved.iter().map(|p| builder::vertex(p3(p.a))).collect();
|
||||
let k = verts.len();
|
||||
let mut cedges: Vec<mt::Edge> = Vec::with_capacity(k);
|
||||
for (i, p) in moved.iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % k)) else { return Err(fail("vertex")) };
|
||||
cedges.push(match p.mid {
|
||||
None => builder::line(a, b),
|
||||
Some(m) => builder::circle_arc(a, b, p3(m)),
|
||||
});
|
||||
}
|
||||
let c: mt::Wire = cedges.into();
|
||||
// The rest of the face: its other loop, and the band's edge as a loop of its own.
|
||||
// (The band's edge runs the face's way round: a hole when the band follows the inner
|
||||
// loop, the outer loop when it follows the outer.)
|
||||
let rest = mt::Face::try_new(vec![other.clone(), c.clone()], f.oriented_surface()).map_err(|e| fail(&e.to_string()))?;
|
||||
// Walls: the face's loop and the band's edge swept out; the band's top.
|
||||
let up = v3(n * height);
|
||||
let wall_along: mt::Shell = builder::tsweep(along, up);
|
||||
let wall_c: mt::Shell = builder::tsweep(&c, up);
|
||||
let top_of = |sh: &mt::Shell| -> Vec<mt::Wire> {
|
||||
sh.extract_boundaries().into_iter().filter(|w| w.vertex_iter().all(|v| (from_p3(v.point()) - at).dot(n) > height * 0.5)).collect()
|
||||
};
|
||||
let flipped = |sh: &mt::Shell, yes: bool| -> mt::Shell {
|
||||
if yes { sh.face_iter().map(|x| x.inverse()).collect::<Vec<_>>().into() } else { sh.clone() }
|
||||
};
|
||||
let mut last = String::new();
|
||||
for flip in 0..8u32 {
|
||||
let (wa, wc) = (flipped(&wall_along, flip & 1 == 1), flipped(&wall_c, flip & 2 == 2));
|
||||
let mut tops = top_of(&wa);
|
||||
tops.extend(top_of(&wc));
|
||||
if tops.len() != 2 {
|
||||
return Err(fail("the band's top"));
|
||||
}
|
||||
// The top closes both walls: its loops run against theirs.
|
||||
let tops: Vec<mt::Wire> = tops.iter().map(|w| w.inverse()).collect();
|
||||
let Ok(top) = builder::try_attach_plane(&tops) else { continue };
|
||||
let top = if flip & 4 == 4 { top.inverse() } else { top };
|
||||
let mut all: Vec<mt::Face> = faces.iter().enumerate().filter(|(i, _)| *i != fi).map(|(_, x)| x.clone()).collect();
|
||||
all.push(rest.clone());
|
||||
all.extend(wa.face_iter().cloned());
|
||||
all.extend(wc.face_iter().cloned());
|
||||
all.push(top);
|
||||
match Solid::try_new(vec![all.into()]) {
|
||||
Ok(s) => {
|
||||
let b = Body::new(s)?;
|
||||
if b.validity().is_empty() {
|
||||
return Ok(b);
|
||||
}
|
||||
last = b.validity().join("; ");
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
Err(fail(&last))
|
||||
})
|
||||
}
|
||||
@@ -423,7 +423,7 @@ fn span_step(ns: &[Vec3], s: &[f64]) -> Option<Vec3> {
|
||||
}
|
||||
|
||||
/// A line or circular arc through an edge (by samples): `None` for other curves.
|
||||
fn arc_mid(e: &mt::Edge, tol: f64) -> Option<Option<Vec3>> {
|
||||
pub(crate) fn arc_mid(e: &mt::Edge, tol: f64) -> Option<Option<Vec3>> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
|
||||
@@ -1393,3 +1393,24 @@ fn perf_measure_5000_faces() {
|
||||
measure(&b).unwrap();
|
||||
assert!(t.elapsed().as_secs_f64() < 1.5, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// A lip on a rounded, shelled rim (#29): the band along the rim's inner (or outer) edge raised,
|
||||
/// its walls continuing the walls below. Exactly the band's area times its height is added.
|
||||
#[test]
|
||||
fn raise_band_on_a_rounded_rim() {
|
||||
let b = box_solid(Vec3::new(-40.0, -30.0, 0.0), Vec3::new(40.0, 30.0, 30.0)).unwrap();
|
||||
let f =
|
||||
fillet(&b, &[Vec3::new(40.0, 30.0, 15.0), Vec3::new(-40.0, 30.0, 15.0), Vec3::new(-40.0, -30.0, 15.0), Vec3::new(40.0, -30.0, 15.0)], 6.0)
|
||||
.unwrap();
|
||||
let sh = shell(&f, &[Vec3::new(0.0, 0.0, 30.0)], 2.0).unwrap();
|
||||
let v0 = measure(&sh).unwrap().volume;
|
||||
// A rounded rectangle's area.
|
||||
let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
|
||||
for outside in [false, true] {
|
||||
let lip = crate::lip::raise_band(&sh, Vec3::new(0.0, 29.0, 30.0), 1.0, 1.5, outside).unwrap();
|
||||
assert!(lip.validity().is_empty(), "{:?}", lip.validity());
|
||||
let band = if outside { ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0) } else { ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0) };
|
||||
let v = measure(&lip).unwrap().volume;
|
||||
assert!((v - (v0 + band * 1.5)).abs() < 1e-5 * v, "outside {outside}: {v} vs {}", v0 + band * 1.5);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user