Display tessellation: trimmed faces as four-sided patches (#303) (#1163)

Trimmed faces are cut into four-sided patches (squares around holes, butterflies for round faces, motorcycle traces from concave corners) and each is meshed as a Coons grid, with a cut grid as the fallback; cells are at most 2:1 along straight directions. Folded blends are refused rather than overlapped. The README lists display meshes and the gpu crate.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-14 15:01:05 -04:00
1 parent e519fbd14d
commit e3459edea8
11 files changed
+2068 -38

No files matched your search

+11 -1
View File
@@ -134,6 +134,16 @@ rebuild with no errors, replayed here at 2× speed.
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
- **Display meshes** from our own tessellation planner: each face becomes a plan of (u, v)
points and triangles that shares its edge nodes with its neighbours, so the mesh is watertight.
Facets follow the chord tolerance, the facet angle and a longest side, with cells at most 2:1
along straight directions. Box-bounded and four-cornered faces mesh as structured grids.
Trimmed faces are cut into four-sided patches: squares around holes, butterflies in round
faces, motorcycle traces from concave corners (after Lyon et al. 2019). Each patch then meshes
as a Coons grid. Intersection edges are evaluated by Newton on both surfaces. A compute shader
evaluates the plans for the whole scene in one dispatch, with a CPU fallback when the GPU
can't build it. Planning and evaluating the 78 reference bodies takes about 65 ms, 50–65×
faster than the previous mesher.
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
control channel so agents and tests can drive it.
@@ -215,7 +225,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
<!-- ANCHOR_END: architecture -->
+3 -1
View File
@@ -337,7 +337,9 @@ fn round_faces_of_every_kind_are_divided_by_the_angle() {
let check = |name: &str, b: &Body| {
let coarse = b.display_mesh(3.0).unwrap();
let round = b.display_mesh_angular(3.0, DISPLAY_ANGLE).unwrap();
assert!(turn(b, &coarse) > 2.0 * DISPLAY_ANGLE, "{name}: the chord tolerance leaves facets of {} degrees", turn(b, &coarse).to_degrees());
// The chord tolerance alone leaves facets coarser than the angle (the size rules' longest
// side and aspect limit already keep them near 40°), so the angle has work to do.
assert!(turn(b, &coarse) > 1.5 * DISPLAY_ANGLE, "{name}: the chord tolerance leaves facets of {} degrees", turn(b, &coarse).to_degrees());
// A side crosses at most one facet in each direction.
assert!(turn(b, &round) <= 1.5 * DISPLAY_ANGLE, "{name}: a side turns {} degrees", turn(b, &round).to_degrees());
assert_eq!(open_sides(&round, 1e-6), 0, "{name}");
+63
View File
@@ -135,6 +135,69 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>
Some((extra, tris))
}
/// The band between a face's boundary `loops` (ranges of the first `n` points of `points`) and
/// the staircase around a block of grid cells inside it (`stairs`, sides between grid points
/// of `points`): a constrained Delaunay triangulation of both with no point added, keeping the
/// faces inside the boundary and outside the block (an odd number of constraints from the
/// outside). Distances in the metric `scale`. Triangles counter-clockwise in (u, v), indices
/// into `points`; `None` when a constraint cannot be kept (the staircase meets the boundary).
pub(crate) fn band(points: &[[f64; 2]], n: usize, loops: &[std::ops::Range<usize>], stairs: &[(u32, u32)], scale: [f64; 2]) -> Option<Vec<[u32; 3]>> {
let to = |p: &[f64; 2]| spade::mitigate_underflow(Point2::new(p[0] * scale[0], p[1] * scale[1]));
let mut cdt = Cdt::new();
let mut index: HashMap<FixedVertexHandle, u32> = HashMap::new();
let mut handle: HashMap<u32, FixedVertexHandle> = HashMap::new();
let mut add = |cdt: &mut Cdt, i: u32| -> Option<FixedVertexHandle> {
if let Some(h) = handle.get(&i) {
return Some(*h);
}
let p = points.get(i as usize)?;
if !p.iter().all(|x| x.is_finite()) {
return None;
}
let h = cdt.insert(to(p)).ok()?;
// Two points at one place (pole copies) share a vertex: the first index names it.
index.entry(h).or_insert(i);
handle.insert(i, h);
Some(h)
};
let mut sides: Vec<(FixedVertexHandle, FixedVertexHandle)> = Vec::new();
for r in loops {
let len = r.len();
for k in 0..len {
let (a, b) = (u32::try_from(r.start + k).ok()?, u32::try_from(r.start + (k + 1) % len).ok()?);
if (a as usize) >= n || (b as usize) >= n {
return None;
}
sides.push((add(&mut cdt, a)?, add(&mut cdt, b)?));
}
}
for &(a, b) in stairs {
sides.push((add(&mut cdt, a)?, add(&mut cdt, b)?));
}
for (a, b) in sides {
if a == b || cdt.exists_constraint(a, b) {
continue;
}
if !cdt.can_add_constraint(a, b) {
return None;
}
cdt.add_constraint(a, b);
}
let region = region(&cdt);
let mut tris = Vec::new();
for face in cdt.inner_faces() {
if !region.get(face.index()).copied().unwrap_or(false) {
continue;
}
let mut t = [0u32; 3];
for (slot, v) in t.iter_mut().zip(face.vertices()) {
*slot = *index.get(&v.fix())?;
}
tris.push(t);
}
Some(tris)
}
/// Insert the parameters of the surface point under every triangle centre that lies more than
/// the tolerance from the triangle or whose corner normals turn more than the facet angle, and
/// under the middle of every non-constraint side that sags more than the tolerance or is
+224
View File
@@ -0,0 +1,224 @@
//! The cuts that make every patch of a trimmed face four-sided. Each hole gets a square around
//! it in (u, v) (sides on iso-lines, halfway to the nearest other feature), joined to the hole at
//! the hole's four 45° points: the ring between hole and square is four collar patches (a quarter
//! of the hole's edge, a straight cut, a side of the square, a straight cut), the O-grid round a
//! hole. The motorcycle graph then runs with the squares as the obstacles (Lyon et al. 2019 §4):
//! a square's corners are its concave peaks, so the traces extend the squares' sides, and every
//! patch outside the collars is a rectangle bounded by iso-lines.
use super::motorcycle::{self, End as Stop};
use super::pieces::{End, Segment};
/// The face's cuts, given its boundary loops (ranges of `uvs`), the metric `scale` and the face's
/// seed (its node spacing, mm; traces less than a quarter of it apart meet head-on). `flat`: the
/// face is a plane, whose shading does not depend on its interior: a butterfly's square sides
/// take half the facing arc's count there (each wing steps 2:1), instead of the whole count. `None` when the face has no hole, or a hole has no room
/// for a collar (it touches another feature).
pub(crate) fn segments(uvs: &[[f64; 2]], loops: &[std::ops::Range<usize>], scale: [f64; 2], seed: f64, flat: bool) -> Option<Vec<Segment>> {
let snap = 0.25 * seed;
let metric = |q: [f64; 2]| [q[0] * scale[0], q[1] * scale[1]];
let unmetric = |q: [f64; 2]| [q[0] / scale[0], q[1] / scale[1]];
let area = |r: &std::ops::Range<usize>| -> f64 {
let m = r.len();
(0..m)
.filter_map(|k| {
let (a, b) = (metric(*uvs.get(r.start + k)?), metric(*uvs.get(r.start + (k + 1) % m)?));
Some(0.5 * (a[0] * b[1] - b[0] * a[1]))
})
.sum()
};
// The outer loop encloses the most area; the others are holes.
let outer = loops.iter().enumerate().max_by(|a, b| area(a.1).abs().total_cmp(&area(b.1).abs()))?.0;
let holes: Vec<&std::ops::Range<usize>> = loops.iter().enumerate().filter(|(i, _)| *i != outer).map(|(_, r)| r).collect();
let outer = loops.get(outer)?;
let bbox = |r: &std::ops::Range<usize>| -> Option<([f64; 2], [f64; 2])> {
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
for k in r.clone() {
let q = metric(*uvs.get(k)?);
for d in 0..2 {
lo[d] = lo[d].min(q[d]);
hi[d] = hi[d].max(q[d]);
}
}
Some((lo, hi))
};
// The node of loop `r` furthest out towards each corner of its box, in the box's own
// proportions (its 45° points on a round loop), corner by corner from (min, min).
let supports = |r: &std::ops::Range<usize>| -> Option<[usize; 4]> {
let (lo, hi) = bbox(r)?;
let (w, h) = (hi[0] - lo[0], hi[1] - lo[1]);
if !(w > 0.0 && h > 0.0) {
return None;
}
let mut out = [0usize; 4];
for (slot, dir) in out.iter_mut().zip([[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]) {
let reach = |i: usize| {
uvs.get(i).map_or(f64::MIN, |q| {
let q = metric(*q);
dir[0] * (q[0] - lo[0]) / w + dir[1] * (q[1] - lo[1]) / h
})
};
*slot = (r.start..r.end).max_by(|&x, &y| reach(x).total_cmp(&reach(y)))?;
}
let mut distinct = out.to_vec();
distinct.sort_unstable();
distinct.dedup();
(distinct.len() == 4).then_some(out)
};
// The arc of loop `r` (in nodes) between its support points `a` and `b`, the way that passes
// neither of the other two.
let side_count = |n: usize| if flat { n.div_ceil(2).max(1) } else { n.max(1) };
let arc = |r: &std::ops::Range<usize>, p: &[usize; 4], k: usize| -> Option<usize> {
let m = r.len();
let (a, b) = (*p.get(k)? - r.start, *p.get((k + 1) % 4)? - r.start);
let fwd = (b + m - a) % m;
let others: Vec<usize> = (0..4).filter(|&j| j != k && j != (k + 1) % 4).filter_map(|j| p.get(j).map(|x| (x - r.start + m - a) % m)).collect();
Some(if others.iter().all(|&o| o > fwd) { fwd } else { m - fwd })
};
// A face of one round loop (no hole, no concave peak; a disc, an ellipse): the butterfly.
// A square inside it, its corners halfway from the loop's centre to the loop's four 45°
// points, each joined to its point; each square side in as many segments as the arc facing
// it. Five four-sided patches: the square and four wings.
if holes.is_empty() {
let alone: Vec<[f64; 2]> = outer.clone().filter_map(|k| uvs.get(k).copied()).collect();
if !motorcycle::traces(&alone, &[std::ops::Range { start: 0, end: alone.len() }], scale, snap).is_empty() {
return None;
}
let p = supports(outer)?;
let (lo, hi) = bbox(outer)?;
let centre = [0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1])];
let mut corners = [[0.0; 2]; 4];
for (c, &i) in corners.iter_mut().zip(&p) {
let q = metric(*uvs.get(i)?);
*c = unmetric([centre[0] + 0.5 * (q[0] - centre[0]), centre[1] + 0.5 * (q[1] - centre[1])]);
}
let mut segs = Vec::with_capacity(8);
for k in 0..4 {
segs.push(Segment {
a: End::Point(*corners.get(k)?),
b: End::Point(*corners.get((k + 1) % 4)?),
count: Some(side_count(arc(outer, &p, k)?)),
});
}
for (k, &i) in p.iter().enumerate() {
segs.push(Segment { a: End::Point(*corners.get(k)?), b: End::Node(u32::try_from(i).ok()?), count: None });
}
return Some(segs);
}
// A square needs a strip at least a seed wide between it and the outer loop on every side
// (and a seed of collar inside it): a hole with less than two seeds to the outer loop on any
// side has no room for one, so the outer loop is its square, and the collar cuts join the
// hole's 45° points to the outer loop's.
let (olo, ohi) = bbox(outer)?;
for hole in &holes {
let (lo, hi) = bbox(hole)?;
let gaps = [lo[0] - olo[0], lo[1] - olo[1], ohi[0] - hi[0], ohi[1] - hi[1]];
if holes.len() == 1 && gaps.iter().any(|g| *g < 2.0 * seed) {
let (inner, out) = (supports(hole)?, supports(outer)?);
return inner
.iter()
.zip(out)
.map(|(&a, b)| Some(Segment { a: End::Node(u32::try_from(a).ok()?), b: End::Node(u32::try_from(b).ok()?), count: None }))
.collect();
}
}
let boxes: Vec<([f64; 2], [f64; 2])> = holes.iter().map(|r| bbox(r)).collect::<Option<_>>()?;
// Distances in the metric: point to box, point to segment, box to box.
let to_box = |p: [f64; 2], (lo, hi): ([f64; 2], [f64; 2])| {
let dx = (lo[0] - p[0]).max(0.0).max(p[0] - hi[0]);
let dy = (lo[1] - p[1]).max(0.0).max(p[1] - hi[1]);
dx.hypot(dy)
};
let to_segment = |p: [f64; 2], a: [f64; 2], b: [f64; 2]| {
let (ab, ap) = ([b[0] - a[0], b[1] - a[1]], [p[0] - a[0], p[1] - a[1]]);
let len2 = ab[0] * ab[0] + ab[1] * ab[1];
let t = if len2 > 0.0 { ((ap[0] * ab[0] + ap[1] * ab[1]) / len2).clamp(0.0, 1.0) } else { 0.0 };
(p[0] - a[0] - t * ab[0]).hypot(p[1] - a[1] - t * ab[1])
};
let corners_of = |(lo, hi): ([f64; 2], [f64; 2])| [[lo[0], lo[1]], [hi[0], lo[1]], [hi[0], hi[1]], [lo[0], hi[1]]];
// Each square: the hole's box grown by half its clearance to the outer loop and a quarter of
// its gap to another hole's box (both squares grow into that gap), so squares never touch.
let m = outer.len();
let mut squares: Vec<([f64; 2], [f64; 2])> = Vec::with_capacity(boxes.len());
for (i, &bx) in boxes.iter().enumerate() {
let mut clear = f64::INFINITY;
for k in 0..m {
let (a, b) = (metric(*uvs.get(outer.start + k)?), metric(*uvs.get(outer.start + (k + 1) % m)?));
clear = clear.min(to_box(a, bx));
for c in corners_of(bx) {
clear = clear.min(to_segment(c, a, b));
}
}
for (j, &other) in boxes.iter().enumerate() {
if j != i {
// Box to box: the gap along each axis.
let gx = (other.0[0] - bx.1[0]).max(bx.0[0] - other.1[0]).max(0.0);
let gy = (other.0[1] - bx.1[1]).max(bx.0[1] - other.1[1]).max(0.0);
// Both holes' squares grow into this gap: each takes half of it.
clear = clear.min(0.5 * gx.hypot(gy));
}
}
let margin = 0.5 * clear;
if !(margin.is_finite() && margin > 0.0) {
return None;
}
squares.push(([bx.0[0] - margin, bx.0[1] - margin], [bx.1[0] + margin, bx.1[1] + margin]));
}
let mut segs: Vec<Segment> = Vec::new();
// Each hole's four 45° points (towards its square's corners, in order).
let picks: Vec<[usize; 4]> = holes.iter().map(|r| supports(r)).collect::<Option<_>>()?;
// Square sides (four per hole, corner k to corner k + 1), each in as many segments as the
// hole's arc facing it (between its 45° points k and k + 1, the way that passes neither
// other point): the wings of the butterfly then have matching opposite sides.
for ((&sq, hole), p) in squares.iter().zip(&holes).zip(&picks) {
let c = corners_of(sq).map(unmetric);
for k in 0..4 {
segs.push(Segment { a: End::Point(*c.get(k)?), b: End::Point(*c.get((k + 1) % 4)?), count: Some(side_count(arc(hole, p, k)?)) });
}
}
for (p, &sq) in picks.iter().zip(&squares) {
let c = corners_of(sq).map(unmetric);
for (k, &i) in p.iter().enumerate() {
segs.push(Segment { a: End::Point(*c.get(k)?), b: End::Node(u32::try_from(i).ok()?), count: None });
}
}
// The motorcycles, with the squares as the obstacles: the outer loop, then each square as a
// loop of its four corners.
let mut domain: Vec<[f64; 2]> = outer.clone().filter_map(|k| uvs.get(k).copied()).collect();
let mut dloops = vec![std::ops::Range { start: 0, end: domain.len() }];
for &sq in &squares {
let start = domain.len();
domain.extend(corners_of(sq).map(unmetric));
dloops.push(start..start + 4);
}
let traces = motorcycle::traces(&domain, &dloops, scale, snap);
let base = segs.len();
let on_outer = |i: usize| i < outer.len();
for (ti, t) in traces.iter().enumerate() {
let start = if on_outer(t.start) { End::Node(u32::try_from(outer.start + t.start).ok()?) } else { End::Point(*domain.get(t.start)?) };
let end = match t.end {
Stop::Boundary(a, b) if on_outer(a) && on_outer(b) => {
// The nearer end of the boundary segment it reached (a cut never adds a node to
// the boundary).
let (pa, pb) = (metric(*domain.get(a)?), metric(*domain.get(b)?));
let q = metric(t.b);
let k = if (pa[0] - q[0]).hypot(pa[1] - q[1]) <= (pb[0] - q[0]).hypot(pb[1] - q[1]) { a } else { b };
End::Node(u32::try_from(outer.start + k).ok()?)
}
Stop::Boundary(a, b) => {
// A side of a square: corners `a` and `b` of square `s`.
let (s, la, lb) = ((a - outer.len()) / 4, (a - outer.len()) % 4, (b - outer.len()) % 4);
let k = if (la + 1) % 4 == lb { la } else { lb };
End::On(4 * s + k, t.b)
}
Stop::Trace(c) => End::On(base + c, t.b),
Stop::HeadOn(j) => {
let o = traces.get(j)?;
End::Point([0.5 * (t.b[0] + o.b[0]), 0.5 * (t.b[1] + o.b[1])])
}
};
let _ = ti;
segs.push(Segment { a: start, b: end, count: None });
}
Some(segs)
}
+285 -21
View File
@@ -14,11 +14,20 @@ const CORNER_TURN: f64 = 0.6;
struct Side {
nodes: Vec<u32>,
at: Vec<f64>,
/// Its length in 3D (mm).
len: f64,
}
/// The four corners of a single boundary loop, by how sharply the loop turns there in (u, v)
/// scaled by `scale` (one unit per tolerance cell); `None` unless there are exactly four.
fn corners(uv: &[[f64; 2]], scale: [f64; 2]) -> Option<[usize; 4]> {
let [a, b, c, d] = candidates(uv, scale)?[..] else { return None };
Some([a, b, c, d])
}
/// Every node where the loop turns more than [`CORNER_TURN`] (or meets a pole copy), in loop
/// order.
fn candidates(uv: &[[f64; 2]], scale: [f64; 2]) -> Option<Vec<usize>> {
let n = uv.len();
if n < 4 {
return None;
@@ -39,8 +48,60 @@ fn corners(uv: &[[f64; 2]], scale: [f64; 2]) -> Option<[usize; 4]> {
found.push(i);
}
}
let [a, b, c, d] = found[..] else { return None };
Some([a, b, c, d])
Some(found)
}
/// Most segments a patch's own level gives one direction.
const MAX_LEVEL: usize = 4096;
/// Most corner candidates [`coons_best`] chooses four from (8 choose 4 = 70 blends at most).
const MAX_CANDIDATES: usize = 8;
/// Like [`coons`] for a loop with more than four corners (a patch of a trimmed face with a
/// corner cut off, the wing of a butterfly around a hole): every choice of four of its corners
/// is blended, and the one whose smallest triangle angle (in the metric `scale`) is largest
/// wins. Its other corners lie on sides. `None` if no choice gives a blend that does not fold.
pub(crate) fn coons_best(uv: &[[f64; 2]], fixed: &[Vec3], scale: [f64; 2], max_points: usize, h: f64) -> Option<(Vec<[f64; 2]>, Vec<[u32; 3]>)> {
let c = candidates(uv, scale)?;
if c.len() < 4 || c.len() > MAX_CANDIDATES {
return None;
}
let mut best: Option<(f64, (Vec<[f64; 2]>, Vec<[u32; 3]>))> = None;
let k = c.len();
for a in 0..k {
for b in a + 1..k {
for d in b + 1..k {
for e in d + 1..k {
let pick = [*c.get(a)?, *c.get(b)?, *c.get(d)?, *c.get(e)?];
let Some(r) = coons_at(uv, fixed, pick, max_points, h, None) else { continue };
let q = min_angle(uv, &r, scale);
if best.as_ref().is_none_or(|(bq, _)| q > *bq) {
best = Some((q, r));
}
}
}
}
}
best.map(|(_, r)| r)
}
/// Smallest corner angle (radians) of the triangles, in the metric.
fn min_angle(uv: &[[f64; 2]], r: &(Vec<[f64; 2]>, Vec<[u32; 3]>), scale: [f64; 2]) -> f64 {
let at = |i: u32| {
let i = i as usize;
let q = if i < uv.len() { uv.get(i) } else { r.0.get(i - uv.len()) };
q.map(|q| [q[0] * scale[0], q[1] * scale[1]])
};
let mut worst = std::f64::consts::PI;
for t in &r.1 {
let (Some(a), Some(b), Some(c)) = (at(t[0]), at(t[1]), at(t[2])) else { continue };
for (p, q, s) in [(a, b, c), (b, c, a), (c, a, b)] {
let (u, v) = ([q[0] - p[0], q[1] - p[1]], [s[0] - p[0], s[1] - p[1]]);
let ang = (u[0] * v[1] - u[1] * v[0]).abs().atan2(u[0] * v[0] + u[1] * v[1]);
worst = worst.min(ang);
}
}
worst
}
fn side(fixed: &[Vec3], n: usize, from: usize, to: usize) -> Option<Side> {
@@ -58,9 +119,9 @@ fn side(fixed: &[Vec3], n: usize, from: usize, to: usize) -> Option<Side> {
if !(total > 0.0) {
// A pole side: evenly along it.
let m = (nodes.len() - 1).max(1) as f64;
return Some(Side { at: (0..nodes.len()).map(|k| k as f64 / m).collect(), nodes });
return Some(Side { at: (0..nodes.len()).map(|k| k as f64 / m).collect(), nodes, len: 0.0 });
}
Some(Side { nodes, at: at.into_iter().map(|x| x / total).collect() })
Some(Side { nodes, at: at.into_iter().map(|x| x / total).collect(), len: total })
}
/// The point of a side at position `s` (0 to 1), linear between its nodes.
@@ -72,9 +133,12 @@ fn along(side: &Side, uv: &[[f64; 2]], s: f64) -> Option<[f64; 2]> {
Some([p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f])
}
/// Interior line positions for a pair of opposite sides: the sides' own when they have the
/// same nodes (so the grid meets both node for node), else evenly as many as the denser side.
fn lines(a: &Side, b: &Side) -> Vec<f64> {
/// Interior line positions for a pair of opposite sides: the sides' own when they have as many
/// nodes as each other (so the grid meets both node for node); else evenly at the inner level a
/// GPU tessellator takes, the larger of the two sides' (outer) levels, and never coarser than the
/// size rules ask (the sides' mean length over the target side `h`). The denser side then meets
/// the grid node for node and the sparser one steps down to it through its ring.
fn lines(a: &Side, b: &Side, h: f64) -> Vec<f64> {
let inner = |s: &Side| s.at.get(1..s.at.len().saturating_sub(1)).map(<[f64]>::to_vec).unwrap_or_default();
if a.at.len() == b.at.len() {
let l: Vec<f64> = inner(a).iter().zip(inner(b)).map(|(x, y)| 0.5 * (x + y)).collect();
@@ -82,23 +146,43 @@ fn lines(a: &Side, b: &Side) -> Vec<f64> {
return l;
}
}
let n = a.at.len().max(b.at.len()).saturating_sub(1).max(2);
let size = if h > 0.0 { (0.5 * (a.len + b.len) / h).ceil() } else { 1.0 };
let size = if size.is_finite() { size as usize } else { 1 };
let outer = a.at.len().max(b.at.len()).saturating_sub(1);
let n = outer.max(size).clamp(2, MAX_LEVEL);
(1..n).map(|i| i as f64 / n as f64).collect()
}
/// Triangulate the loop `uv` (with its fixed 3D nodes) as a Coons patch: boundary vertices
/// keep their indices, the interior grid follows. Triangles counter-clockwise in (u, v).
/// `fine(a, b)`: whether the surface between two parameter points needs no point between them
/// (the grid lines, taken from the sides, are split inside the face where it does not).
///
/// `fine(a, b)`: whether the surface between two parameter points needs no point between them.
/// Given for a whole face, whose sides are its edges' nodes: where its grid converges (towards a
/// pole) the interior can need more lines than the sides carry, and the lines are split there.
/// A motorcycle patch passes `None`: its sides are cuts seeded at the face's global seed.
pub(crate) fn coons(
uv: &[[f64; 2]],
fixed: &[Vec3],
scale: [f64; 2],
max_points: usize,
fine: &dyn Fn([f64; 2], [f64; 2]) -> bool,
h: f64,
fine: Option<&dyn Fn([f64; 2], [f64; 2]) -> bool>,
) -> Option<(Vec<[f64; 2]>, Vec<[u32; 3]>)> {
let pick = corners(uv, scale)?;
coons_at(uv, fixed, pick, max_points, h, fine)
}
/// [`coons`] with the four corners given (loop indices, in loop order).
pub(crate) fn coons_at(
uv: &[[f64; 2]],
fixed: &[Vec3],
pick: [usize; 4],
max_points: usize,
h: f64,
fine: Option<&dyn Fn([f64; 2], [f64; 2]) -> bool>,
) -> Option<(Vec<[f64; 2]>, Vec<[u32; 3]>)> {
let n = uv.len();
let [c0, c1, c2, c3] = corners(uv, scale)?;
let [c0, c1, c2, c3] = pick;
// Sides in loop order; the bottom (0) and right (1) run with the loop, the top (2) and
// left (3) against it, so s and t both run from corner c0.
let bottom = side(fixed, n, c0, c1)?;
@@ -116,8 +200,16 @@ pub(crate) fn coons(
};
Some([blend(0), blend(1)])
};
let ss = refine(lines(&bottom, &top), max_points, &|a, b, x| Some(fine(at(a, x)?, at(b, x)?)));
let ts = refine(lines(&left, &right), max_points, &|a, b, x| Some(fine(at(x, a)?, at(x, b)?)));
// The lines come from the sides' seeds (see [`lines`]): every side is seeded at the face's
// global seed, which the size rules set for the whole surface, so the interior needs no finer
// spacing than its sides.
let (ss, ts) = (lines(&bottom, &top, h), lines(&left, &right, h));
let (ss, ts) = match fine {
Some(fine) => {
(refine(ss, max_points, &|a, b, x| Some(fine(at(a, x)?, at(b, x)?))), refine(ts, max_points, &|a, b, x| Some(fine(at(x, a)?, at(x, b)?))))
}
None => (ss, ts),
};
if ss.len().checked_mul(ts.len())? > max_points {
return None;
}
@@ -141,10 +233,46 @@ pub(crate) fn coons(
// the bottom and right strips come out counter-clockwise, the top and left clockwise.
let row = |j: usize| (0..ns).map(|i| g(i, j)).collect::<Vec<u32>>();
let col = |i: usize| (0..nt).map(|j| g(i, j)).collect::<Vec<u32>>();
zip(&bottom, &row(0), &ss, false, &mut tris)?;
zip(&right, &col(ns - 1), &ts, false, &mut tris)?;
zip(&top, &row(nt - 1), &ss, true, &mut tris)?;
zip(&left, &col(0), &ts, true, &mut tris)?;
// Where a side's segments and its grid row's differ, a transition row steps between them
// halfway to the first grid line (its segments the two counts' geometric mean), each zipped
// to it, with one triangle at each end closing it against the corner.
let (s_first, s_last) = (*ss.first()?, *ss.last()?);
let (t_first, t_last) = (*ts.first()?, *ts.last()?);
// Each side: its grid row, the row's positions, the orientation, where the transition row
// runs, and the point at a position along it. The row lies halfway between the side and the
// grid row; its ends lie halfway between the diagonal from the corner to the grid row's end
// and that end, so they are strictly inside the side's own ring.
#[allow(clippy::type_complexity)]
let sides: [(&Side, Vec<u32>, &[f64], bool, (f64, f64), &dyn Fn(f64) -> [f64; 2]); 4] = [
(&bottom, row(0), &ss, false, (0.75 * s_first, 1.0 - 0.75 * (1.0 - s_last)), &|x| [x, 0.5 * t_first]),
(&right, col(ns - 1), &ts, false, (0.75 * t_first, 1.0 - 0.75 * (1.0 - t_last)), &|x| [0.5 * (1.0 + s_last), x]),
(&top, row(nt - 1), &ss, true, (0.75 * s_first, 1.0 - 0.75 * (1.0 - s_last)), &|x| [x, 0.5 * (1.0 + t_last)]),
(&left, col(0), &ts, true, (0.75 * t_first, 1.0 - 0.75 * (1.0 - t_last)), &|x| [0.5 * s_first, x]),
];
for (side, inner, inner_at, flip, (x0, x1), place) in sides {
let (outer_segs, inner_segs) = (side.nodes.len().saturating_sub(1), inner.len() + 1);
let mean = ((outer_segs * inner_segs) as f64).sqrt().round() as usize;
if mean == outer_segs || mean == inner_segs || mean < 2 {
zip(side, &inner, inner_at, flip, &mut tris)?;
continue;
}
let k = mean - 1;
let at_t: Vec<f64> = (0..k).map(|j| if k > 1 { x0 + (x1 - x0) * j as f64 / (k - 1) as f64 } else { 0.5 * (x0 + x1) }).collect();
let mut mid: Vec<u32> = Vec::with_capacity(k);
for &x in &at_t {
let [sx, tx] = place(x);
mid.push(u32::try_from(out.len()).ok()?);
out.push(at(sx, tx)?);
}
zip(side, &mid, &at_t, flip, &mut tris)?;
zip(&Side { nodes: mid.clone(), at: at_t.clone(), len: 0.0 }, &inner, inner_at, flip, &mut tris)?;
// The two end triangles: corner, transition end, grid end, turned like the zips' (in
// (s, t) the first runs with them and the last against). Never turned to fit the blend:
// a blend that folds there leaves one inverted, and the patch is refused below.
for t in [[*side.nodes.first()?, *mid.first()?, *inner.first()?], [*side.nodes.last()?, *inner.last()?, *mid.last()?]] {
tris.push(if flip { [t[0], t[2], t[1]] } else { t });
}
}
// The (s, t) square maps to (u, v) keeping or reversing orientation, with the loop: all
// triangles must share one sign, which is then made counter-clockwise.
let area = |t: &[u32; 3]| -> Option<f64> {
@@ -188,7 +316,7 @@ fn refine(lines: Vec<f64>, max: usize, fine: &dyn Fn(f64, f64, f64) -> Option<bo
}
fn reversed(s: Side) -> Side {
Side { nodes: s.nodes.into_iter().rev().collect(), at: s.at.into_iter().rev().map(|x| 1.0 - x).collect() }
Side { nodes: s.nodes.into_iter().rev().collect(), at: s.at.into_iter().rev().map(|x| 1.0 - x).collect(), len: s.len }
}
/// Join a side (its nodes at positions `side.at`) to a grid row at positions `inner_at`.
@@ -251,7 +379,7 @@ mod tests {
b.reverse();
}
let fixed: Vec<Vec3> = b.iter().map(|p| Vec3::new(p[0], p[1], 0.0)).collect();
let Some((extra, tris)) = coons(&b, &fixed, [1.0, 1.0], 10_000, &|_, _| true) else { panic!("meshes (k {k})") };
let Some((extra, tris)) = coons(&b, &fixed, [1.0, 1.0], 10_000, 1.0, None) else { panic!("meshes (k {k})") };
let mut uv = b.clone();
uv.extend(extra);
assert!((area(&uv, &tris) - 16.0).abs() < 1e-9, "k {k} reverse {reverse}: {}", area(&uv, &tris));
@@ -259,10 +387,146 @@ mod tests {
}
}
/// Opposite sides with different node counts (20 against 4): the interior grid takes the
/// larger side's level (a tessellator's inner level), so the dense side meets it node for node
/// and the sparse side steps down to it through a transition row. Covered exactly, nothing
/// missing or doubled.
#[test]
fn the_interior_level_is_the_larger_sides_and_the_sparser_side_steps_down() {
// A 4 × 4 square: bottom in 20 segments, the other sides in 4.
let mut b: Vec<[f64; 2]> = (0..20).map(|k| [k as f64 * 0.2, 0.0]).collect();
b.extend((0..4).map(|k| [4.0, k as f64]));
b.extend((0..4).map(|k| [4.0 - k as f64, 4.0]));
b.extend((0..4).map(|k| [0.0, 4.0 - k as f64]));
let fixed: Vec<Vec3> = b.iter().map(|p| Vec3::new(p[0], p[1], 0.0)).collect();
let Some((extra, tris)) = coons(&b, &fixed, [1.0, 1.0], 10_000, 1.0, None) else { panic!("meshes") };
let mut uv = b.clone();
uv.extend(extra.iter().copied());
assert!((area(&uv, &tris) - 16.0).abs() < 1e-9);
// Level 20 along s (the bottom's), 4 along t: 19 × 3 interior points; plus the transition
// row by the 4-segment top (√(4 · 20) ≈ 9 segments: 8 points).
assert_eq!(extra.len(), 19 * 3 + 8, "{}", extra.len());
// Every inner side shared by two triangles, every boundary side used once: nothing
// missing or doubled at the transition's ends.
let mut sides: std::collections::HashMap<(u32, u32), i32> = std::collections::HashMap::new();
for t in &tris {
for e in 0..3 {
*sides.entry((t[e], t[(e + 1) % 3])).or_default() += 1;
}
}
let n = b.len() as u32;
for (&(a, c), &k) in &sides {
assert_eq!(k, 1, "side {a}-{c} once each way");
let boundary = a < n && c < n && (c == (a + 1) % n);
assert!(boundary || sides.contains_key(&(c, a)), "inner side {a}-{c} shared");
}
}
#[test]
fn five_corners_is_not_a_coons_patch() {
let b = [[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.5, 1.0], [0.0, 1.0]];
let fixed: Vec<Vec3> = b.iter().map(|p| Vec3::new(p[0], p[1], 0.0)).collect();
assert!(coons(&b, &fixed, [1.0, 1.0], 100, &|_, _| true).is_none());
assert!(coons(&b, &fixed, [1.0, 1.0], 100, 1.0, None).is_none());
}
/// A planar strip with a fillet at one end and a chamfer at the other, in mm (a face of
/// robustness seed 33): its corners make the one-segment chamfer a side facing the long one,
/// and at some target sides the blend folds beside the chamfer. The transition row's end
/// triangles are turned like its zips, never to fit the blend, so a fold leaves a triangle
/// inverted and the blend is refused; any blend given covers the loop exactly once.
#[test]
#[allow(clippy::approx_constant)] // measured coordinates, one of them near ln 10
fn a_blend_folding_beside_a_short_side_is_refused_not_overlapped() {
let b: Vec<[f64; 2]> = vec![
[24.0000, 3.0150],
[23.0000, 4.4292],
[16.6667, 4.4292],
[10.3333, 4.4292],
[4.0000, 4.4292],
[4.0000, 3.0150],
[4.0002, 2.9836],
[4.0010, 2.9520],
[4.0023, 2.9201],
[4.0040, 2.8880],
[4.0064, 2.8556],
[4.0092, 2.8231],
[4.0127, 2.7905],
[4.0167, 2.7577],
[4.0213, 2.7248],
[4.0265, 2.6919],
[4.0322, 2.6589],
[4.0386, 2.6259],
[4.0456, 2.5929],
[4.0532, 2.5600],
[4.0614, 2.5272],
[4.0702, 2.4945],
[4.0797, 2.4619],
[4.0897, 2.4295],
[4.1004, 2.3974],
[4.1117, 2.3655],
[4.1236, 2.3339],
[4.1362, 2.3026],
[4.1493, 2.2717],
[4.1630, 2.2412],
[4.1773, 2.2111],
[4.1922, 2.1814],
[4.2076, 2.1523],
[4.2236, 2.1237],
[4.2402, 2.0956],
[4.2573, 2.0681],
[4.2748, 2.0412],
[4.2929, 2.0150],
[4.3114, 1.9895],
[4.3304, 1.9646],
[4.3499, 1.9405],
[4.3697, 1.9171],
[4.3899, 1.8945],
[4.4105, 1.8726],
[4.4315, 1.8516],
[4.4528, 1.8313],
[4.4744, 1.8119],
[4.4962, 1.7934],
[4.5184, 1.7757],
[4.5407, 1.7588],
[4.5633, 1.7428],
[4.5860, 1.7277],
[4.6089, 1.7135],
[4.6319, 1.7001],
[4.6550, 1.6876],
[4.6783, 1.6760],
[4.7015, 1.6653],
[4.7248, 1.6554],
[4.7482, 1.6464],
[4.7715, 1.6382],
[4.7948, 1.6309],
[4.8180, 1.6244],
[4.8412, 1.6188],
[4.8643, 1.6139],
[4.8873, 1.6098],
[4.9101, 1.6065],
[4.9329, 1.6040],
[4.9554, 1.6022],
[4.9778, 1.6012],
[5.0000, 1.6008],
[11.3333, 1.6008],
[17.6667, 1.6008],
[24.0000, 1.6008],
];
let fixed: Vec<Vec3> = b.iter().map(|p| Vec3::new(p[0], p[1], 0.0)).collect();
let n = b.len();
let loop_area: f64 = (0..n).map(|k| 0.5 * (b[k][0] * b[(k + 1) % n][1] - b[(k + 1) % n][0] * b[k][1])).sum();
for h in [0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0] {
let Some((extra, tris)) = coons(&b, &fixed, [1.0, 1.0], 100_000, h, None) else { continue };
let mut uv = b.clone();
uv.extend(extra);
assert!((area(&uv, &tris) - loop_area).abs() < 1e-9 * loop_area, "h {h}: {} for {loop_area}", area(&uv, &tris));
let mut sides: std::collections::HashMap<(u32, u32), usize> = std::collections::HashMap::new();
for t in &tris {
for k in 0..3 {
*sides.entry((t[k], t[(k + 1) % 3])).or_default() += 1;
}
}
assert!(sides.values().all(|&k| k == 1), "h {h}: a side used twice the same way");
}
}
}
+518
View File
@@ -0,0 +1,518 @@
//! A trimmed face as an iso-line T-mesh: full-length grid lines from the size rules plus the
//! face's motorcycle traces (which run only as far as they reach), whose rectangles that lie
//! well inside the face are meshed directly. The band between those rectangles (a staircase)
//! and the face's boundary nodes is triangulated with both kept as constraints and no point
//! added. The boundary stays exactly the shared edge nodes, so the face meets its neighbours
//! exactly, and only the band next to the boundary is irregular (cut-cell meshing).
use super::cdt;
use super::motorcycle::Trace;
/// A cell is kept when the boundary stays at least this fraction of its smaller side away from
/// it (in the metric): closer, the band triangle between them would be a sliver.
const CLEARANCE: f64 = 0.35;
/// Two lines closer than this fraction of a grid cell would leave a strip of slivers between
/// them: a grid line that close to a trace moves onto the trace (the trace runs through its
/// peak, which is the point of it), and a trace that close to an earlier one joins it.
const SNAP: f64 = 0.25;
/// One direction's lines: positions (increasing) and whether each runs the whole face.
struct Lines {
at: Vec<f64>,
full: Vec<bool>,
}
/// The full lines `grid` plus the traces' lines in this direction (see [`SNAP`]; the first and
/// last grid lines, the face's parameter box, never move). Returns the lines and, per trace
/// (in order), its line's index.
fn lines(grid: &[f64], traces: &[f64]) -> Option<(Lines, Vec<usize>)> {
let mut at: Vec<f64> = grid.to_vec();
let mut full = vec![true; at.len()];
let mut place = Vec::with_capacity(traces.len());
for &c in traces {
// The full-grid cell around c, and every line inside it.
let k = grid.partition_point(|g| *g < c);
let (lo, hi) = (*grid.get(k.saturating_sub(1))?, *grid.get(k.min(grid.len().saturating_sub(1)))?);
let w = (hi - lo).max(f64::MIN_POSITIVE);
let last = grid.len().saturating_sub(1);
let near = at.iter().enumerate().filter(|(_, x)| (**x - c).abs() < SNAP * w).min_by(|a, b| (a.1 - c).abs().total_cmp(&(b.1 - c).abs()));
match near {
// An earlier trace, or a box edge: join it.
Some((i, _)) if !full.get(i).copied().unwrap_or(false) || i == 0 || i == last => place.push(i),
// An inner grid line: it moves onto the trace.
Some((i, _)) => {
*at.get_mut(i)? = c;
place.push(i);
}
None => {
at.push(c);
full.push(false);
place.push(at.len() - 1);
}
}
}
// Sort, carrying the trace indices along.
let mut order: Vec<usize> = (0..at.len()).collect();
order.sort_by(|a, b| at.get(*a).copied().unwrap_or(0.0).total_cmp(&at.get(*b).copied().unwrap_or(0.0)));
let mut rank = vec![0; at.len()];
for (r, &i) in order.iter().enumerate() {
*rank.get_mut(i)? = r;
}
let lines =
Lines { at: order.iter().filter_map(|&i| at.get(i).copied()).collect(), full: order.iter().filter_map(|&i| full.get(i).copied()).collect() };
let place = place.iter().filter_map(|&i| rank.get(i).copied()).collect::<Vec<_>>();
(place.len() == traces.len()).then_some((lines, place))
}
/// Mesh the region bounded by `loops` (ranges of `boundary`, each closed; even–odd, so inner
/// loops are holes) with the full grid lines `us` × `vs` (increasing, spanning the region's
/// parameter box) and the motorcycle `traces`, measured in the metric `scale`. Boundary
/// vertices keep their indices; grid points follow. Triangles counter-clockwise in (u, v).
/// `None` when no cell is kept or the band cannot be triangulated (the face then goes to
/// Delaunay).
pub(crate) fn cut_grid(
boundary: &[[f64; 2]],
loops: &[std::ops::Range<usize>],
us: &[f64],
vs: &[f64],
traces: &[Trace],
scale: [f64; 2],
) -> Option<(Vec<[f64; 2]>, Vec<[u32; 3]>)> {
if us.len() < 2 || vs.len() < 2 || !scale.iter().all(|s| s.is_finite() && *s > 0.0) {
return None;
}
// Traces on u = c (axis 0) are lines in u; on v = c, lines in v.
let tu: Vec<&Trace> = traces.iter().filter(|t| t.axis == 0).collect();
let tv: Vec<&Trace> = traces.iter().filter(|t| t.axis == 1).collect();
let (lu, pu) = lines(us, &tu.iter().map(|t| t.at).collect::<Vec<_>>())?;
let (lv, pv) = lines(vs, &tv.iter().map(|t| t.at).collect::<Vec<_>>())?;
let (nu, nv) = (lu.at.len(), lv.at.len());
let (cu, cv) = (nu - 1, nv - 1);
let cells = cu.checked_mul(cv)?;
if cells > super::grid::MAX_FACE_GRID {
return None;
}
// Which sides of the fine cells are lines: the vertical side at column i in row j, the
// horizontal side at row j in column i. A trace covers a side it runs at least half along.
let covers = |t: &Trace, a: f64, b: f64| (t.to.min(b) - t.from.max(a)) >= 0.5 * (b - a);
let mut vert = vec![false; nu * cv];
for i in 0..nu {
let full = lu.full.get(i).copied().unwrap_or(false);
for j in 0..cv {
let (a, b) = (*lv.at.get(j)?, *lv.at.get(j + 1)?);
*vert.get_mut(j * nu + i)? = full || tu.iter().zip(&pu).any(|(t, &k)| k == i && covers(t, a, b));
}
}
let mut horiz = vec![false; nv * cu];
for j in 0..nv {
let full = lv.full.get(j).copied().unwrap_or(false);
for i in 0..cu {
let (a, b) = (*lu.at.get(i)?, *lu.at.get(i + 1)?);
*horiz.get_mut(j * cu + i)? = full || tv.iter().zip(&pv).any(|(t, &k)| k == j && covers(t, a, b));
}
}
// Rectangles: fine cells joined across sides that are no line. A T-mesh's faces are
// rectangles; anything else (a trace ending in the open after snapping) falls back to
// every line running the whole face.
let rects = rectangles(cu, cv, &vert, &horiz).or_else(|| {
vert.iter_mut().for_each(|s| *s = true);
horiz.iter_mut().for_each(|s| *s = true);
rectangles(cu, cv, &vert, &horiz)
})?;
// In the metric, so clearances are lengths on the surface.
let gx: Vec<f64> = lu.at.iter().map(|u| u * scale[0]).collect();
let gy: Vec<f64> = lv.at.iter().map(|v| v * scale[1]).collect();
let segs: Vec<([f64; 2], [f64; 2])> = loops
.iter()
.flat_map(|r| {
let n = r.len();
(0..n).filter_map(move |k| Some((*boundary.get(r.start + k)?, *boundary.get(r.start + (k + 1) % n)?)))
})
.map(|(a, b)| ([a[0] * scale[0], a[1] * scale[1]], [b[0] * scale[0], b[1] * scale[1]]))
.collect();
let keep = clear_cells(&gx, &gy, &segs, &rects)?;
if !keep.iter().any(|k| *k) {
return None;
}
let kept = |i: isize, j: isize| -> bool {
i >= 0
&& j >= 0
&& (i as usize) < cu
&& (j as usize) < cv
&& rects.of.get(j as usize * cu + i as usize).and_then(|r| keep.get(*r)).copied().unwrap_or(false)
};
// Grid points on the kept rectangles' corners, numbered after the boundary.
let mut used = vec![false; nu * nv];
for (r, b) in rects.boxes.iter().enumerate() {
if keep.get(r).copied().unwrap_or(false) {
for (i, j) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])] {
*used.get_mut(j * nu + i)? = true;
}
}
}
let n = boundary.len();
let mut id: Vec<Option<u32>> = vec![None; nu * nv];
let mut extra: Vec<[f64; 2]> = Vec::new();
let mut node = |i: usize, j: usize, extra: &mut Vec<[f64; 2]>| -> Option<u32> {
let slot = id.get_mut(j * nu + i)?;
if let Some(k) = *slot {
return Some(k);
}
let k = u32::try_from(n + extra.len()).ok()?;
extra.push([*lu.at.get(i)?, *lv.at.get(j)?]);
*slot = Some(k);
Some(k)
};
let mut tris: Vec<[u32; 3]> = Vec::new();
// The staircase: pieces of kept rectangles' sides with no kept rectangle across,
// counter-clockwise around the kept region (so the band lies to their right).
let mut stairs: Vec<(u32, u32)> = Vec::new();
for (r, b) in rects.boxes.iter().enumerate() {
if !keep.get(r).copied().unwrap_or(false) {
continue;
}
let [i0, j0, i1, j1] = *b;
// The perimeter counter-clockwise as grid points, each with the fine cell across the
// side that leaves it.
let mut ring: Vec<((usize, usize), (isize, isize))> = Vec::new();
for i in i0..i1 {
ring.push(((i, j0), (i as isize, j0 as isize - 1)));
}
for j in j0..j1 {
ring.push(((i1, j), (i1 as isize, j as isize)));
}
for i in (i0 + 1..=i1).rev() {
ring.push(((i, j1), (i as isize - 1, j1 as isize)));
}
for j in (j0 + 1..=j1).rev() {
ring.push(((i0, j), (i0 as isize - 1, j as isize - 1)));
}
let corners = ring.iter().filter(|((i, j), _)| used.get(j * nu + i).copied().unwrap_or(false)).count();
let mut poly: Vec<u32> = Vec::with_capacity(corners);
let mut across: Vec<(isize, isize)> = Vec::with_capacity(corners);
for &((i, j), c) in &ring {
if used.get(j * nu + i).copied().unwrap_or(false) {
poly.push(node(i, j, &mut extra)?);
across.push(c);
}
}
if poly.len() == 4 {
let [a, b, c, d] = [*poly.first()?, *poly.get(1)?, *poly.get(2)?, *poly.get(3)?];
tris.push([a, b, c]);
tris.push([a, c, d]);
} else {
// Hanging nodes from neighbours: a fan from the rectangle's centre.
let centre = [0.5 * (lu.at.get(i0)? + lu.at.get(i1)?), 0.5 * (lv.at.get(j0)? + lv.at.get(j1)?)];
let m = u32::try_from(n + extra.len()).ok()?;
extra.push(centre);
for k in 0..poly.len() {
tris.push([m, *poly.get(k)?, *poly.get((k + 1) % poly.len())?]);
}
}
for k in 0..poly.len() {
let (ci, cj) = *across.get(k)?;
if !kept(ci, cj) {
stairs.push((*poly.get(k)?, *poly.get((k + 1) % poly.len())?));
}
}
}
let mut all = boundary.to_vec();
all.extend_from_slice(&extra);
let band = cdt::band(&all, n, loops, &stairs, scale)?;
tris.extend(band);
// Every triangle counter-clockwise with some area: one that is not has folded.
for t in &tris {
let (a, b, c) = (all.get(t[0] as usize)?, all.get(t[1] as usize)?, all.get(t[2] as usize)?);
let area = (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]);
if !(area > 0.0) {
return None;
}
}
Some((extra, tris))
}
/// The rectangles of a T-mesh over `cu` × `cv` fine cells: per cell its rectangle, and per
/// rectangle its corners as grid indices `[i0, j0, i1, j1]`. `None` if a face is not a
/// rectangle.
struct Rects {
of: Vec<usize>,
boxes: Vec<[usize; 4]>,
}
fn rectangles(cu: usize, cv: usize, vert: &[bool], horiz: &[bool]) -> Option<Rects> {
let nu = cu + 1;
let mut of = vec![usize::MAX; cu * cv];
let mut boxes = Vec::new();
for j0 in 0..cv {
for i0 in 0..cu {
if *of.get(j0 * cu + i0)? != usize::MAX {
continue;
}
// Grow right while no line separates, then up while the whole row is open.
let mut i1 = i0 + 1;
while i1 < cu && !*vert.get(j0 * nu + i1)? {
i1 += 1;
}
let mut j1 = j0 + 1;
while j1 < cv && (i0..i1).all(|i| horiz.get(j1 * cu + i).is_some_and(|h| !*h)) {
j1 += 1;
}
let r = boxes.len();
for j in j0..j1 {
for i in i0..i1 {
let slot = of.get_mut(j * cu + i)?;
if *slot != usize::MAX {
return None;
}
*slot = r;
}
}
boxes.push([i0, j0, i1, j1]);
}
}
// A rectangle: no line inside it, and lines all round it.
for b in &boxes {
let [i0, j0, i1, j1] = *b;
for j in j0..j1 {
for i in i0 + 1..i1 {
if *vert.get(j * nu + i)? {
return None;
}
}
}
for i in i0..i1 {
for j in j0 + 1..j1 {
if *horiz.get(j * cu + i)? {
return None;
}
}
}
for j in j0..j1 {
if (i0 > 0 && !*vert.get(j * nu + i0)?) || (i1 < cu && !*vert.get(j * nu + i1)?) {
return None;
}
}
for i in i0..i1 {
if (j0 > 0 && !*horiz.get(j0 * cu + i)?) || (j1 < cv && !*horiz.get(j1 * cu + i)?) {
return None;
}
}
}
Some(Rects { of, boxes })
}
/// Per rectangle: kept when every fine cell in it is inside the region and clear of the
/// boundary (see [`CLEARANCE`]). `gx`, `gy`: the lines in the metric; `segs`: the boundary.
fn clear_cells(gx: &[f64], gy: &[f64], segs: &[([f64; 2], [f64; 2])], rects: &Rects) -> Option<Vec<bool>> {
let (cu, cv) = (gx.len().saturating_sub(1), gy.len().saturating_sub(1));
let cells = cu * cv;
let cell = |i: usize, j: usize| -> Option<([f64; 2], [f64; 2])> {
let (x0, x1, y0, y1) = (*gx.get(i)?, *gx.get(i + 1)?, *gy.get(j)?, *gy.get(j + 1)?);
// The clearance of the rectangle the cell belongs to, not of the (possibly thin) cell.
let b = rects.boxes.get(*rects.of.get(j * cu + i)?)?;
let (w, h) = (gx.get(b[2])? - gx.get(b[0])?, gy.get(b[3])? - gy.get(b[1])?);
let pad = CLEARANCE * w.min(h);
Some(([x0 - pad, y0 - pad], [x1 + pad, y1 + pad]))
};
// Cells the boundary comes near: each segment marks the cells whose padded box it touches.
let mut near = vec![false; cells];
let maxpad = rects
.boxes
.iter()
.filter_map(|b| Some(CLEARANCE * (gx.get(b[2])? - gx.get(b[0])?).min(gy.get(b[3])? - gy.get(b[1])?)))
.fold(0.0f64, f64::max);
let span = |g: &[f64], lo: f64, hi: f64| {
let a = g.partition_point(|t| *t < lo - maxpad).saturating_sub(1);
let b = (g.partition_point(|t| *t <= hi + maxpad) + 1).min(g.len().saturating_sub(1));
(a, b)
};
for (a, b) in segs {
let (i0, i1) = span(gx, a[0].min(b[0]), a[0].max(b[0]));
let (j0, j1) = span(gy, a[1].min(b[1]), a[1].max(b[1]));
for j in j0..j1 {
for i in i0..i1 {
let Some((lo, hi)) = cell(i, j) else { continue };
if segment_meets_box(*a, *b, lo, hi)
&& let Some(c) = near.get_mut(j * cu + i)
{
*c = true;
}
}
}
}
// The other cells are wholly inside or wholly outside: even–odd along each row's middle.
let mut keep = vec![true; rects.boxes.len()];
for j in 0..cv {
let (Some(y0), Some(y1)) = (gy.get(j), gy.get(j + 1)) else { return None };
let ym = 0.5 * (y0 + y1);
let mut cross: Vec<f64> =
segs.iter().filter(|(a, b)| (a[1] > ym) != (b[1] > ym)).map(|(a, b)| a[0] + (ym - a[1]) / (b[1] - a[1]) * (b[0] - a[0])).collect();
cross.sort_by(f64::total_cmp);
for i in 0..cu {
let k = j * cu + i;
let (Some(x0), Some(x1)) = (gx.get(i), gx.get(i + 1)) else { return None };
let xm = 0.5 * (x0 + x1);
let inside = cross.partition_point(|c| *c < xm) % 2 == 1;
if near.get(k).copied().unwrap_or(true) || !inside {
*keep.get_mut(*rects.of.get(k)?)? = false;
}
}
}
Some(keep)
}
/// Whether the segment `a`–`b` meets the closed box `lo`–`hi` (Liang–Barsky clipping).
fn segment_meets_box(a: [f64; 2], b: [f64; 2], lo: [f64; 2], hi: [f64; 2]) -> bool {
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for d in 0..2 {
let delta = b[d] - a[d];
for (p, q) in [(-delta, a[d] - lo[d]), (delta, hi[d] - a[d])] {
if p == 0.0 {
if q < 0.0 {
return false;
}
} else {
let r = q / p;
if p < 0.0 {
t0 = t0.max(r);
} else {
t1 = t1.min(r);
}
if t0 > t1 {
return false;
}
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
fn circle(c: [f64; 2], r: f64, n: usize, ccw: bool) -> Vec<[f64; 2]> {
(0..n)
.map(|k| {
let a = std::f64::consts::TAU * k as f64 / n as f64 * if ccw { 1.0 } else { -1.0 };
[c[0] + r * a.cos(), c[1] + r * a.sin()]
})
.collect()
}
/// A single loop over all `n` boundary points.
fn one(n: usize) -> [std::ops::Range<usize>; 1] {
[std::ops::Range { start: 0, end: n }]
}
fn lines(a: f64, b: f64, n: usize) -> Vec<f64> {
(0..=n).map(|k| a + (b - a) * k as f64 / n as f64).collect()
}
/// Every boundary segment is the side of exactly one triangle, every inner side of two,
/// and the triangles cover the region's area.
fn check(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>], area: f64, out: &(Vec<[f64; 2]>, Vec<[u32; 3]>)) {
let (extra, tris) = out;
let mut all = boundary.to_vec();
all.extend_from_slice(extra);
let mut sides: std::collections::HashMap<(u32, u32), i32> = std::collections::HashMap::new();
let mut sum = 0.0;
for t in tris {
let [a, b, c] = t.map(|i| all[i as usize]);
let ar = 0.5 * ((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]));
assert!(ar > 0.0, "counter-clockwise");
sum += ar;
for e in 0..3 {
*sides.entry((t[e], t[(e + 1) % 3])).or_default() += 1;
}
}
for r in loops {
let n = r.len();
for k in 0..n {
let (a, b) = ((r.start + k) as u32, (r.start + (k + 1) % n) as u32);
assert_eq!(sides.get(&(a, b)).copied().unwrap_or(0), 1, "boundary side {a}-{b} used once, forwards");
sides.remove(&(a, b));
}
}
for (&(a, b), &k) in &sides {
assert_eq!(k, 1);
assert_eq!(sides.get(&(b, a)).copied(), Some(1), "inner side {a}-{b} shared");
}
assert!((sum - area).abs() < 1e-9 * area.max(1.0), "area {sum} vs {area}");
}
#[test]
fn a_disc_is_cells_and_a_band() {
let b = circle([0.0, 0.0], 1.0, 64, true);
let loops = one(64);
let poly_area = 0.5 * 64.0 * (std::f64::consts::TAU / 64.0).sin();
let Some(out) = cut_grid(&b, &loops, &lines(-1.0, 1.0, 10), &lines(-1.0, 1.0, 10), &[], [1.0, 1.0]) else { panic!("meshes") };
check(&b, &loops, poly_area, &out);
// Most of the disc is grid cells (two triangles each).
assert!(out.1.len() > 100, "{}", out.1.len());
}
#[test]
fn a_square_with_a_hole() {
let mut b = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
b.extend(circle([2.0, 2.0], 1.0, 32, false));
let loops = [0..4, 4..36];
let hole = 0.5 * 32.0 * (std::f64::consts::TAU / 32.0).sin();
let Some(out) = cut_grid(&b, &loops, &lines(0.0, 4.0, 16), &lines(0.0, 4.0, 16), &[], [1.0, 1.0]) else { panic!("meshes") };
check(&b, &loops, 16.0 - hole, &out);
}
#[test]
fn a_curved_triangle_in_a_stretched_metric() {
// A three-cornered patch (as a vertex blend in its parameters), u twice as long as v.
let mut b: Vec<[f64; 2]> = (0..=8).map(|k| [k as f64 / 8.0, 0.0]).collect();
b.extend((1..8).map(|k| {
let a = std::f64::consts::FRAC_PI_2 * k as f64 / 8.0;
[a.cos(), a.sin()]
}));
b.extend((1..8).rev().map(|k| [0.0, k as f64 / 8.0]));
let n = b.len();
let loops = one(n);
let area: f64 = (0..n)
.map(|k| {
let (p, q) = (b[k], b[(k + 1) % n]);
0.5 * (p[0] * q[1] - q[0] * p[1])
})
.sum();
let Some(out) = cut_grid(&b, &loops, &lines(0.0, 1.0, 12), &lines(0.0, 1.0, 6), &[], [2.0, 1.0]) else { panic!("meshes") };
check(&b, &loops, area, &out);
}
/// The hole's motorcycle traces (lines through its four extremes) become T-mesh lines that
/// stop where the traces stop; the mesh stays exact on the boundary and covers the region.
#[test]
fn a_square_with_a_hole_and_its_traces() {
let mut b = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
b.extend(circle([1.7, 2.3], 1.0, 32, false));
let loops = [0..4, 4..36];
let hole = 0.5 * 32.0 * (std::f64::consts::TAU / 32.0).sin();
let traces = super::super::motorcycle::traces(&b, &loops, [1.0, 1.0], 0.0);
assert_eq!(traces.len(), 4, "{traces:?}");
let Some(out) = cut_grid(&b, &loops, &lines(0.0, 4.0, 16), &lines(0.0, 4.0, 16), &traces, [1.0, 1.0]) else { panic!("meshes") };
check(&b, &loops, 16.0 - hole, &out);
// The traces run through the hole's extremes: along v = 2.3 from its u-extremes and
// along u = 1.7 from its v-extremes; their lines are in the grid.
for (d, x) in [(0, 1.7), (1, 2.3)] {
assert!(out.0.iter().any(|p| (p[d] - x).abs() < 1e-9), "a grid point on line {d} = {x}");
}
}
#[test]
fn nothing_kept_is_refused() {
let b = circle([0.0, 0.0], 0.1, 16, true);
assert!(cut_grid(&b, &one(16), &lines(-1.0, 1.0, 1), &lines(-1.0, 1.0, 1), &[], [1.0, 1.0]).is_none());
}
#[test]
fn segments_meet_boxes() {
assert!(segment_meets_box([-1.0, 0.5], [2.0, 0.5], [0.0, 0.0], [1.0, 1.0]));
assert!(!segment_meets_box([-1.0, 2.0], [2.0, 2.0], [0.0, 0.0], [1.0, 1.0]));
assert!(segment_meets_box([0.5, 0.5], [0.6, 0.6], [0.0, 0.0], [1.0, 1.0]));
assert!(!segment_meets_box([2.0, 0.0], [3.0, 1.0], [0.0, 0.0], [1.0, 1.0]));
}
}
+235 -5
View File
@@ -17,9 +17,13 @@
//! old way.
pub(crate) mod cdt;
pub(crate) mod collar;
pub(crate) mod coons;
pub(crate) mod cutgrid;
pub(crate) mod edges;
pub(crate) mod grid;
pub(crate) mod motorcycle;
pub(crate) mod pieces;
pub(crate) mod size;
pub(crate) mod ssi;
pub(crate) mod surface;
@@ -348,7 +352,7 @@ fn plan_face(
let structured = match structured {
None if ranges.len() == 1 => {
let fine = |a: [f64; 2], b: [f64; 2]| uv::fine_between(s, rules, h, a, b);
coons::coons(&uvs, &fixed, speed, grid::MAX_FACE_GRID, &fine)
coons::coons(&uvs, &fixed, speed, grid::MAX_FACE_GRID, h, Some(&fine))
}
other => other,
};
@@ -362,12 +366,33 @@ fn plan_face(
r
}
None => {
if planar {
stats.planar += 1;
// A curved face neither box nor four-cornered: cut into four-sided patches, else
// the iso-line grid cut along its motorcycle traces and trimmed by its boundary,
// else Delaunay. A plane: the butterfly of a round face, else Delaunay (a plane
// is shaded the same by any triangles, and its Delaunay needs no refinement).
let face = Region { s, uvs: &uvs, fixed: &fixed, ranges: &ranges, rules, h, speed, us: &us, vs: &vs };
let trimmed = if planar {
if ranges.len() == 1 { plan_pieces(&face) } else { None }
} else {
stats.delaunay += 1;
plan_pieces(&face).or_else(|| {
let traces = motorcycle::traces(&uvs, &ranges, speed, 0.25 * h);
cutgrid::cut_grid(&uvs, &ranges, &us, &vs, &traces, speed)
})
};
match trimmed {
Some(r) => {
stats.structured += 1;
r
}
None => {
if planar {
stats.planar += 1;
} else {
stats.delaunay += 1;
}
delaunay()?
}
}
delaunay()?
}
}
};
@@ -384,6 +409,211 @@ fn plan_face(
Ok(FacePlan { face: u32::try_from(fi).unwrap_or(u32::MAX), surface: None, uv: all, fixed, normals: Vec::new(), flip: false, triangles })
}
/// A face's (u, v) region and what meshing it needs.
struct Region<'a> {
s: &'a EvalSurface,
uvs: &'a [[f64; 2]],
fixed: &'a [Vec3],
ranges: &'a [std::ops::Range<usize>],
rules: &'a size::Rules,
h: f64,
speed: [f64; 2],
us: &'a [f64],
vs: &'a [f64],
}
/// A trimmed face cut into patches (squares round its holes or a butterfly in a round face,
/// joined to the outer loop by motorcycle traces), each meshed on its own (a Coons blend on its
/// best four corners; else the trimmed grid; else Delaunay), all sharing the cuts' nodes. The
/// cut nodes and patch interiors follow the boundary nodes. `None` when the cuts do not make
/// patches.
fn plan_pieces(f: &Region) -> Option<(Vec<[f64; 2]>, Vec<[u32; 3]>)> {
// The face's global seed: one spacing in every direction, the finest its size rules ask for
// along either parameter (the median gap between the grid lines they give, in mm). On a
// cylinder that is the spacing round it, which the cuts along its axis then get too.
let median_gap = |lines: &[f64], speed: f64| -> f64 {
let mut gaps: Vec<f64> = lines.windows(2).filter_map(|w| Some((w.get(1)? - w.first()?) * speed)).filter(|g| *g > 0.0).collect();
gaps.sort_by(f64::total_cmp);
gaps.get(gaps.len() / 2).copied().unwrap_or(f64::INFINITY)
};
let seed = median_gap(f.us, f.speed[0]).min(median_gap(f.vs, f.speed[1])).min(f.h);
let segments = collar::segments(f.uvs, f.ranges, f.speed, seed, matches!(f.s, EvalSurface::Plane { .. }))?;
let s = f.s;
let fine = |a: [f64; 2], b: [f64; 2]| uv::fine_between(s, f.rules, f.h, a, b);
// A cut's seeds: spaced from the boundary's own spacing at each end, growing geometrically
// between them, never wider than the global seed, and split further wherever the surface
// between two seeds fails the size rules.
let seeds = |a: [f64; 2], b: [f64; 2], sa: f64, sb: f64| -> Vec<f64> { graded(a, b, sa, sb, seed, f.speed, &fine) };
let (cut_uv, pieces) = pieces::cut(f.uvs, f.ranges, &segments, f.speed, &seeds)?;
let n = f.uvs.len();
let mut uv_all: Vec<[f64; 2]> = f.uvs.to_vec();
uv_all.extend_from_slice(&cut_uv);
let mut pos: Vec<Vec3> = f.fixed.to_vec();
for q in &cut_uv {
pos.push(s.derivs(q[0], q[1])?.0);
}
let mut extra: Vec<[f64; 2]> = cut_uv;
let mut tris: Vec<[u32; 3]> = Vec::new();
for pc in &pieces {
// The patch's own loops, numbered from zero.
let mut ids: Vec<u32> = pc.ring.clone();
let mut ranges = vec![std::ops::Range { start: 0, end: pc.ring.len() }];
for hole in &pc.holes {
ranges.push(ids.len()..ids.len() + hole.len());
ids.extend_from_slice(hole);
}
let luv: Vec<[f64; 2]> = ids.iter().map(|&k| uv_all.get(k as usize).copied()).collect::<Option<_>>()?;
let lpos: Vec<Vec3> = ids.iter().map(|&k| pos.get(k as usize).copied()).collect::<Option<_>>()?;
let mut bbox = [(f64::MAX, f64::MIN); 2];
for p in &luv {
for d in 0..2 {
bbox[d] = (bbox[d].0.min(p[d]), bbox[d].1.max(p[d]));
}
}
let clip = |lines: &[f64], (a, b): (f64, f64)| -> Vec<f64> {
std::iter::once(a).chain(lines.iter().copied().filter(|x| *x > a && *x < b)).chain(std::iter::once(b)).collect()
};
// A patch whose outline changes between boundary and cut at exactly four nodes (a
// quarter of a ring, a butterfly wing round a hole) takes those as its corners, so each
// side is all boundary or all cut; any other patch takes the best four of its corners.
let is_cut = |a: u32, b: u32| -> bool {
let (a, b) = (a as usize, b as usize);
a >= n || b >= n || !f.ranges.iter().any(|r| r.contains(&a) && r.contains(&b) && (a.abs_diff(b) == 1 || a.abs_diff(b) == r.len() - 1))
};
let ring = &pc.ring;
let switches: Vec<usize> = (0..ring.len())
.filter(|&k| {
let (p, c, x) = (ring.get((k + ring.len() - 1) % ring.len()), ring.get(k), ring.get((k + 1) % ring.len()));
matches!((p, c, x), (Some(&p), Some(&c), Some(&x)) if is_cut(p, c) != is_cut(c, x))
})
.collect();
// The patch is a rectangle in (u, v) with corners cut by free boundary (Lyon et al. §4;
// completed virtually in §4.2): its bounding box. A grid needs four real sides, so each
// corner is the node furthest out towards the box's corner (in the box's own
// proportions): an uncut corner is the corner itself, and on an arc cutting a corner it
// is the arc's 45° point, so the arc becomes a rounded corner shared by two sides.
let completed = || -> Option<[usize; 4]> {
let m = ring.len();
let at = |k: usize| luv.get(k).map(|q| [q[0] * f.speed[0], q[1] * f.speed[1]]);
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
for k in 0..m {
let q = at(k)?;
for d in 0..2 {
lo[d] = lo[d].min(q[d]);
hi[d] = hi[d].max(q[d]);
}
}
let (w, h) = (hi[0] - lo[0], hi[1] - lo[1]);
if !(w > 0.0 && h > 0.0) {
return None;
}
let mut pick = [0usize; 4];
for (slot, dir) in pick.iter_mut().zip([[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]) {
*slot = (0..m).max_by(|&x, &y| {
let reach = |k: usize| at(k).map_or(f64::MIN, |q| dir[0] * (q[0] - lo[0]) / w + dir[1] * (q[1] - lo[1]) / h);
reach(x).total_cmp(&reach(y))
})?;
}
// Four distinct corners in the loop's (counter-clockwise) order.
let start = pick[0];
let rel = pick.map(|k| (k + m - start) % m);
(rel[0] < rel[1] && rel[1] < rel[2] && rel[2] < rel[3]).then_some(pick)
};
let quad = if pc.holes.is_empty() {
// Every patch is four-sided by construction: its corners are where its outline turns.
coons::coons(&luv, &lpos, f.speed, grid::MAX_FACE_GRID, f.h, None).or_else(|| {
match switches[..] {
[a, b, c, d] => coons::coons_at(&luv, &lpos, [a, b, c, d], grid::MAX_FACE_GRID, f.h, None),
_ => None,
}
.or_else(|| completed().and_then(|c| coons::coons_at(&luv, &lpos, c, grid::MAX_FACE_GRID, f.h, None)))
})
} else {
None
};
let local = quad
.or_else(|| if pc.holes.is_empty() { coons::coons_best(&luv, &lpos, f.speed, grid::MAX_FACE_GRID, f.h) } else { None })
.or_else(|| cutgrid::cut_grid(&luv, &ranges, &clip(f.us, bbox[0]), &clip(f.vs, bbox[1]), &[], f.speed))
.or_else(|| {
let polys: Vec<&[[f64; 2]]> = ranges.iter().filter_map(|r| luv.get(r.clone())).collect();
let seeds = cdt::lattice(bbox, [f.h / f.speed[0], f.h / f.speed[1]], &polys, f.speed);
let eval = |u: f64, v: f64| s.derivs(u, v).map(|(p, su, sv)| (p, su.cross(sv).normalized()));
let sag = cdt::Sag { eval: &eval, tol: f.rules.tol, h: f.h, turn: f.rules.turn, scale: f.speed };
cdt::triangulate(&luv, &ranges, cdt::Fill { seeds: &seeds, refine: Some((sag, MAX_REFINE)), shape: true })
})?;
let (lextra, ltris) = local;
let base = u32::try_from(n + extra.len()).ok()?;
let m = ids.len();
for t in ltris {
let map = |i: u32| -> Option<u32> {
let i = i as usize;
if i < m { ids.get(i).copied() } else { base.checked_add(u32::try_from(i - m).ok()?) }
};
tris.push([map(t[0])?, map(t[1])?, map(t[2])?]);
}
extra.extend(lextra);
}
Some((extra, tris))
}
/// Seeds on the segment `a`–`b` in (u, v) (fractions strictly between 0 and 1): node spacing
/// `sa` at `a` and `sb` at `b` (in the metric `speed`, each at most `h`), growing geometrically
/// between them (sizes `sa · q^k` summing to the length), then each interval split in half while
/// `fine` fails between its ends.
fn graded(a: [f64; 2], b: [f64; 2], sa: f64, sb: f64, h: f64, speed: [f64; 2], fine: &dyn Fn([f64; 2], [f64; 2]) -> bool) -> Vec<f64> {
let len = ((b[0] - a[0]) * speed[0]).hypot((b[1] - a[1]) * speed[1]);
let cap = |x: f64| {
if x.is_finite() && x > 0.0 {
x.min(if h > 0.0 { h } else { f64::INFINITY })
} else if h > 0.0 {
h
} else {
len
}
};
let (sa, sb) = (cap(sa), cap(sb));
let mut cuts: Vec<f64> = Vec::new();
if len.is_finite() && len > sa.max(sb) && sa > 0.0 && sb > 0.0 {
let q = (len - sa) / (len - sb);
let sizes: Vec<f64> = if (q - 1.0).abs() < 1e-3 || !(q > 0.0) || !q.is_finite() {
// Even spacing at the mean of the two.
let k = (len / (0.5 * (sa + sb))).round().clamp(1.0, 4096.0) as usize;
vec![len / k as f64; k]
} else {
let k = (1.0 + (sb / sa).ln() / q.ln()).round().clamp(1.0, 4096.0) as usize;
let raw: Vec<f64> = (0..k).map(|j| sa * q.powi(j as i32)).collect();
let total: f64 = raw.iter().sum();
raw.into_iter().map(|x| x * len / total).collect()
};
let mut at = 0.0;
for s in sizes.iter().take(sizes.len().saturating_sub(1)) {
at += s;
cuts.push(at / len);
}
}
// The size rules between seeds (sag, facet angle).
let point = |t: f64| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
let mut all: Vec<f64> = std::iter::once(0.0).chain(cuts).chain(std::iter::once(1.0)).collect();
for _ in 0..12 {
let mut next = Vec::with_capacity(all.len() * 2);
let mut split = false;
for w in all.windows(2) {
let (Some(&x), Some(&y)) = (w.first(), w.get(1)) else { continue };
next.push(x);
if !fine(point(x), point(y)) {
next.push(0.5 * (x + y));
split = true;
}
}
next.push(1.0);
all = next;
if !split || all.len() > 4096 {
break;
}
}
all.into_iter().filter(|t| *t > 0.0 && *t < 1.0).collect()
}
/// Every boundary segment (consecutive nodes of a loop) is the side of exactly one triangle:
/// the face covers its boundary with nothing missing or doubled, so it meets its neighbours
/// exactly. Pole copies of one node (consecutive in a loop, at one point) are one vertex: the
+354
View File
@@ -0,0 +1,354 @@
//! The free-boundary motorcycle graph of a trimmed face (Lyon, Campen, Bommes & Kobbelt,
//! "Parametrization Quantization with Free Boundaries for Trimmed Quad Meshing", ACM TOG 38(4),
//! 2019, §4; after Eppstein, Goodrich, Kim & Tamstorf, "Motorcycle Graphs: Canonical Quad Mesh
//! Partitioning", SGP 2008).
//!
//! Peaks (Def. 4.1) are boundary nodes where u or v is a local extremum; a node in a flat run
//! counts only where the value differs on one side. A peak is concave where the face lies
//! beyond the extremum. Only concave peaks spawn motorcycles (Def. 4.2): a u-peak one along u,
//! a v-peak one along v, into the face. All move at once at the same speed (in the surface's
//! metric) along their iso-line; each stops where it reaches the boundary, a trace another
//! motorcycle laid earlier, or another motorcycle head-on (both stop where they meet). The
//! traces cut the face into patches that are rectangles with corners cut by free boundary.
/// A trace: the iso-line `axis` = `at` (0: u = at, running along v; 1: v = at, running along u)
/// from `from` to `to` in the other parameter (`from < to`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Trace {
pub axis: usize,
pub at: f64,
pub from: f64,
pub to: f64,
/// The boundary node (peak) it starts from.
pub start: usize,
/// Its start and end points in (u, v).
pub a: [f64; 2],
pub b: [f64; 2],
/// Where it stopped.
pub end: End,
}
/// Where a motorcycle stopped.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum End {
/// On the boundary segment between these two boundary nodes.
Boundary(usize, usize),
/// On the trace with this index (a T-node).
Trace(usize),
/// Head-on with the trace with this index (both end at one point).
HeadOn(usize),
}
/// Most motorcycles one face spawns (the pairwise events grow with their square).
const MAX_MOTORCYCLES: usize = 512;
struct Moto {
/// Origin (metric).
o: [f64; 2],
/// Direction of travel: the parameter (0: u, 1: v) and its sign.
d: usize,
sign: f64,
/// Boundary node it starts on (its own segments are not a crash).
node: usize,
/// Distance to the boundary along its way, and the segment it reaches there.
reach: f64,
hit: (usize, usize),
}
/// The traces of the face bounded by `loops` (ranges of `boundary`, even–odd), in (u, v),
/// measured in the metric `scale`. Two motorcycles coming at each other on lines less than
/// `snap` apart (in the metric) meet head-on: a polygon's extreme node is only near the curve's
/// true extreme, so the two extremes facing each other across a gap (one hole above another)
/// are rarely on exactly one line. Empty when there is no concave peak.
pub(crate) fn traces(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>], scale: [f64; 2], snap: f64) -> Vec<Trace> {
if !scale.iter().all(|s| s.is_finite() && *s > 0.0) {
return Vec::new();
}
let p: Vec<[f64; 2]> = boundary.iter().map(|q| [q[0] * scale[0], q[1] * scale[1]]).collect();
let mut lo = [f64::MAX; 2];
let mut hi = [f64::MIN; 2];
for q in &p {
for d in 0..2 {
lo[d] = lo[d].min(q[d]);
hi[d] = hi[d].max(q[d]);
}
}
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]);
if !(extent.is_finite() && extent > 0.0) {
return Vec::new();
}
// Values this close are one (a node on an iso-line edge carries projection rounding).
let eps = extent * 1e-9;
let segs: Vec<(usize, usize)> = loops
.iter()
.flat_map(|r| {
let n = r.len();
(0..n).map(move |k| (r.start + k, r.start + (k + 1) % n))
})
.collect();
let inside = |q: [f64; 2]| {
let mut odd = false;
for &(a, b) in &segs {
let (Some(a), Some(b)) = (p.get(a), p.get(b)) else { continue };
if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < a[0] + (q[1] - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
odd = !odd;
}
}
odd
};
// Peaks and their motorcycles.
let mut motos: Vec<Moto> = Vec::new();
for r in loops {
let n = r.len();
if n < 3 {
continue;
}
for k in 0..n {
let (Some(&a), Some(&b), Some(&c)) = (p.get(r.start + (k + n - 1) % n), p.get(r.start + k), p.get(r.start + (k + 1) % n)) else {
continue;
};
for d in 0..2 {
let (before, after) = (a[d] - b[d], c[d] - b[d]);
let flat = |x: f64| x.abs() <= eps;
// A (non-strict) minimum higher on one side, or maximum lower on one side.
let min = (before > eps || flat(before)) && (after > eps || flat(after)) && !(flat(before) && flat(after));
let max = (before < -eps || flat(before)) && (after < -eps || flat(after)) && !(flat(before) && flat(after));
let sign = match (min, max) {
(true, false) => -1.0,
(false, true) => 1.0,
_ => continue,
};
// Concave: the face lies beyond the extremum.
let mut probe = b;
probe[d] += sign * extent * 1e-6;
if !inside(probe) {
continue;
}
if motos.len() >= MAX_MOTORCYCLES {
return Vec::new();
}
motos.push(Moto { o: b, d, sign, node: r.start + k, reach: f64::INFINITY, hit: (0, 0) });
}
}
}
if motos.is_empty() {
return Vec::new();
}
// Where each would reach the boundary.
for m in &mut motos {
let e = 1 - m.d;
for &(ia, ib) in &segs {
if ia == m.node || ib == m.node {
continue;
}
let (Some(a), Some(b)) = (p.get(ia), p.get(ib)) else { continue };
// The segment crosses the line other = o[e] at parameter s along d.
let (lo_e, hi_e) = (a[e].min(b[e]), a[e].max(b[e]));
if m.o[e] < lo_e || m.o[e] > hi_e {
continue;
}
// A segment along the line itself is reached at its nearer end ahead.
let ahead = |x: f64| (x - m.o[m.d]) * m.sign;
let t = if (b[e] - a[e]).abs() <= eps {
[ahead(a[m.d]), ahead(b[m.d])].into_iter().filter(|t| *t > eps).fold(f64::INFINITY, f64::min)
} else {
ahead(a[m.d] + (m.o[e] - a[e]) / (b[e] - a[e]) * (b[m.d] - a[m.d]))
};
if t > eps && t < m.reach {
m.reach = t;
m.hit = (ia, ib);
}
}
}
// Events in time order: (time, the motorcycle that stops, where, the one whose trace stops
// it and when it passed there).
struct Event {
t: f64,
who: usize,
at: f64,
cause: Option<(usize, f64)>,
partner: Option<usize>,
}
let mut events: Vec<Event> = Vec::new();
for (i, m) in motos.iter().enumerate() {
if m.reach.is_finite() {
events.push(Event { t: m.reach, who: i, at: m.reach, cause: None, partner: None });
}
for (j, n) in motos.iter().enumerate().skip(i + 1) {
if m.d != n.d {
// Crossing: m's line is other = m.o[1 - m.d]; n travels along n.d = 1 - m.d.
let x_on_m = (n.o[m.d] - m.o[m.d]) * m.sign;
let x_on_n = (m.o[n.d] - n.o[n.d]) * n.sign;
if x_on_m > eps && x_on_m < m.reach && x_on_n > eps && x_on_n < n.reach {
// The later one stops on the earlier one's trace (a tie stops both).
if x_on_m >= x_on_n {
events.push(Event { t: x_on_m, who: i, at: x_on_m, cause: Some((j, x_on_n)), partner: None });
}
if x_on_n >= x_on_m {
events.push(Event { t: x_on_n, who: j, at: x_on_n, cause: Some((i, x_on_m)), partner: None });
}
}
} else if m.sign != n.sign && (m.o[1 - m.d] - n.o[1 - n.d]).abs() <= eps.max(snap) {
// Head-on on one line: they meet halfway if each would get there.
let gap = (n.o[m.d] - m.o[m.d]) * m.sign;
let half = 0.5 * gap;
if gap > eps && half < m.reach && half < n.reach {
events.push(Event { t: half, who: i, at: half, cause: None, partner: Some(j) });
}
}
}
}
events.sort_by(|a, b| a.t.total_cmp(&b.t));
let mut stop: Vec<Option<(f64, Stop)>> = vec![None; motos.len()];
for ev in &events {
if stop.get(ev.who).is_none_or(|s| s.is_some()) {
continue;
}
if let Some((c, passed)) = ev.cause {
// The trace is there only if its motorcycle got that far.
if stop.get(c).copied().flatten().is_some_and(|(s, _)| s < passed) {
continue;
}
}
let how = match (ev.cause, ev.partner) {
(Some((c, _)), _) => Stop::Trace(c),
(None, Some(j)) => Stop::HeadOn(j),
(None, None) => Stop::Boundary,
};
if let Some(j) = ev.partner {
if stop.get(j).is_none_or(|s| s.is_some()) {
continue;
}
if let Some(s) = stop.get_mut(j) {
*s = Some((ev.at, Stop::HeadOn(ev.who)));
}
}
if let Some(s) = stop.get_mut(ev.who) {
*s = Some((ev.at, how));
}
}
// Output indices of the motorcycles that stopped (all do: each reaches the boundary).
let mut out_index = vec![usize::MAX; motos.len()];
let mut k = 0;
for (i, s) in stop.iter().enumerate() {
if s.is_some() {
if let Some(slot) = out_index.get_mut(i) {
*slot = k;
}
k += 1;
}
}
let uv = |q: [f64; 2]| [q[0] / scale[0], q[1] / scale[1]];
motos
.iter()
.zip(&stop)
.filter_map(|(m, s)| {
let (len, how) = (*s)?;
let (a, b) = (m.o[m.d], m.o[m.d] + m.sign * len);
let (from, to) = (a.min(b) / scale[m.d], a.max(b) / scale[m.d]);
// The trace runs along d, so it lies on the iso-line of the other parameter.
let axis = 1 - m.d;
let mut endp = m.o;
endp[m.d] = b;
let end = match how {
Stop::Boundary => End::Boundary(m.hit.0, m.hit.1),
Stop::Trace(c) => End::Trace(*out_index.get(c)?),
Stop::HeadOn(j) => End::HeadOn(*out_index.get(j)?),
};
Some(Trace { axis, at: m.o[axis] / scale[axis], from, to, start: m.node, a: uv(m.o), b: uv(endp), end })
})
.collect()
}
#[derive(Clone, Copy, Debug)]
enum Stop {
Boundary,
Trace(usize),
HeadOn(usize),
}
#[cfg(test)]
mod tests {
use super::*;
fn circle(c: [f64; 2], r: f64, n: usize, ccw: bool) -> Vec<[f64; 2]> {
(0..n)
.map(|k| {
let a = std::f64::consts::TAU * k as f64 / n as f64 * if ccw { 1.0 } else { -1.0 };
[c[0] + r * a.cos(), c[1] + r * a.sin()]
})
.collect()
}
#[test]
fn a_convex_face_has_no_traces() {
let b = circle([0.0, 0.0], 1.0, 32, true);
assert!(traces(&b, &[std::ops::Range { start: 0, end: 32 }], [1.0, 1.0], 0.0).is_empty());
let sq = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
assert!(traces(&sq, &[std::ops::Range { start: 0, end: 4 }], [1.0, 1.0], 0.0).is_empty());
}
/// A centred round hole: its four extremes are concave peaks, and each motorcycle runs
/// straight to the outer square (none meets another).
#[test]
fn a_hole_sends_four_traces_to_the_outside() {
let mut b = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
b.extend(circle([2.0, 2.0], 1.0, 32, false));
let t = traces(&b, &[0..4, 4..36], [1.0, 1.0], 0.0);
assert_eq!(t.len(), 4, "{t:?}");
let on = |axis: usize, at: f64, from: f64, to: f64| {
t.iter().any(|x| x.axis == axis && (x.at - at).abs() < 1e-12 && (x.from - from).abs() < 1e-9 && (x.to - to).abs() < 1e-9)
};
assert!(on(1, 2.0, 0.0, 1.0), "left: v = 2 from u = 0 to the hole");
assert!(on(1, 2.0, 3.0, 4.0), "right");
assert!(on(0, 2.0, 0.0, 1.0), "below");
assert!(on(0, 2.0, 3.0, 4.0), "above");
}
/// An L: the reflex corner is a concave peak in both u and v, and its two motorcycles
/// cut the L into three rectangles.
#[test]
fn an_l_is_cut_at_its_reflex_corner() {
let b = [[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0], [1.0, 2.0], [0.0, 2.0]];
let t = traces(&b, &[std::ops::Range { start: 0, end: 6 }], [1.0, 1.0], 0.0);
assert_eq!(t.len(), 2, "{t:?}");
let has = |axis: usize| {
t.iter().any(|x| x.axis == axis && x.at == 1.0 && x.from == 0.0 && x.to == 1.0 && x.start == 3 && matches!(x.end, End::Boundary(..)))
};
assert!(has(0) && has(1), "{t:?}");
}
/// Two holes side by side: the inner motorcycles meet head-on halfway, and a later
/// motorcycle stops on an earlier one's trace (no crossings).
#[test]
fn traces_meet_head_on_and_never_cross() {
let mut b = vec![[0.0, 0.0], [8.0, 0.0], [8.0, 4.0], [0.0, 4.0]];
b.extend(circle([2.0, 2.0], 1.0, 32, false));
b.extend(circle([6.0, 2.5], 1.0, 32, false));
let t = traces(&b, &[0..4, 4..36, 36..68], [1.0, 1.0], 0.0);
// Hole 1's right extreme (3, 2) and hole 2's left extreme (5, 2.5) are on different
// lines; each runs on until it meets the other hole or a trace.
for (i, a) in t.iter().enumerate() {
for c in t.iter().skip(i + 1) {
if a.axis != c.axis {
// a on axis line a.at; c crosses it if c.at is strictly within a's span
// and a.at strictly within c's.
let cross = c.at > a.from + 1e-9 && c.at < a.to - 1e-9 && a.at > c.from + 1e-9 && a.at < c.to - 1e-9;
assert!(!cross, "{a:?} crosses {c:?}");
}
}
}
// Same-line pair: two holes at the same height meet halfway.
let mut b = vec![[0.0, 0.0], [8.0, 0.0], [8.0, 4.0], [0.0, 4.0]];
b.extend(circle([2.0, 2.0], 1.0, 32, false));
b.extend(circle([6.0, 2.0], 1.0, 32, false));
let t = traces(&b, &[0..4, 4..36, 36..68], [1.0, 1.0], 0.0);
assert!(t.iter().any(|x| x.axis == 1 && (x.at - 2.0).abs() < 1e-12 && (x.from - 3.0).abs() < 1e-9 && (x.to - 4.0).abs() < 1e-9), "{t:?}");
assert!(t.iter().any(|x| x.axis == 1 && (x.at - 2.0).abs() < 1e-12 && (x.from - 4.0).abs() < 1e-9 && (x.to - 5.0).abs() < 1e-9), "{t:?}");
// Those two ended head-on with each other.
let i = t.iter().position(|x| x.axis == 1 && (x.from - 3.0).abs() < 1e-9).unwrap_or(usize::MAX);
let j = t.iter().position(|x| x.axis == 1 && (x.from - 4.0).abs() < 1e-9).unwrap_or(usize::MAX);
assert_eq!(t.get(i).map(|x| x.end), Some(End::HeadOn(j)), "{t:?}");
assert_eq!(t.get(j).map(|x| x.end), Some(End::HeadOn(i)), "{t:?}");
}
}
+348
View File
@@ -0,0 +1,348 @@
//! A trimmed face cut into four-sided patches. The cuts are segments in (u, v): the squares
//! round the face's holes (each joined to its hole at four points, so the ring between them is
//! four collar patches) and the motorcycle traces that extend the squares' sides. Each cut is
//! sampled once and both patches beside it use those nodes (as faces share their edges' nodes),
//! so the patches meet exactly. A cut never adds a node to the face's boundary: it ends on a
//! boundary node, at a point inside the face (a square's corner, or where two traces met
//! head-on), or on another cut (a T-node, which that cut then passes through).
use std::collections::HashMap;
/// Where a cut segment ends.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum End {
/// A boundary node.
Node(u32),
/// A point inside the face; points at the same place are one node.
Point([f64; 2]),
/// A point on cut segment `.0` (a T-node: that segment passes through it).
On(usize, [f64; 2]),
}
/// A cut from `a` to `b`, straight in (u, v).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Segment {
pub a: End,
pub b: End,
/// Segments to sample it in (spread over its pieces between T-nodes by length), instead of
/// the seed rule: a butterfly's square side takes the count of the hole arc facing it.
pub count: Option<usize>,
}
/// A patch: its outer ring and any holes inside it, as indices into the boundary nodes followed
/// by the cut nodes, counter-clockwise (holes clockwise) in (u, v).
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Piece {
pub ring: Vec<u32>,
pub holes: Vec<Vec<u32>>,
}
/// Cut the face bounded by `loops` (ranges of `boundary`) along `segments`, measured in the
/// metric `scale`. `seeds(a, b, sa, sb)`: where to put nodes between two nodes of a cut (in
/// (u, v)), as fractions strictly between 0 and 1, given the spacing wanted at each (in the
/// metric): the boundary's own spacing at a boundary node, unbounded inside the face (the seed
/// rule then sets it). Returns the cut nodes (numbered after the boundary) and the patches;
/// `None` when the cuts do not make a planar subdivision (the face is then meshed whole).
pub(crate) fn cut(
boundary: &[[f64; 2]],
loops: &[std::ops::Range<usize>],
segments: &[Segment],
scale: [f64; 2],
seeds: &dyn Fn([f64; 2], [f64; 2], f64, f64) -> Vec<f64>,
) -> Option<(Vec<[f64; 2]>, Vec<Piece>)> {
let n = boundary.len();
let metric = |q: [f64; 2]| [q[0] * scale[0], q[1] * scale[1]];
let dist = |a: [f64; 2], b: [f64; 2]| {
let (a, b) = (metric(a), metric(b));
(a[0] - b[0]).hypot(a[1] - b[1])
};
let mut cut_uv: Vec<[f64; 2]> = Vec::new();
let mut points: HashMap<[u64; 2], u32> = HashMap::new();
// Each segment's end nodes, and the T-nodes other segments put on it.
let mut ends: Vec<(u32, u32)> = Vec::with_capacity(segments.len());
let mut along: Vec<Vec<u32>> = vec![Vec::new(); segments.len()];
for s in segments {
let mut key = |e: End| -> Option<u32> {
let mut point = |q: [f64; 2]| -> Option<u32> {
let bits = [q[0].to_bits(), q[1].to_bits()];
if let Some(&k) = points.get(&bits) {
return Some(k);
}
let k = u32::try_from(n + cut_uv.len()).ok()?;
cut_uv.push(q);
points.insert(bits, k);
Some(k)
};
match e {
End::Node(k) => ((k as usize) < n).then_some(k),
End::Point(q) => point(q),
End::On(on, q) => {
let k = point(q)?;
along.get_mut(on)?.push(k);
Some(k)
}
}
};
let (a, b) = (key(s.a)?, key(s.b)?);
ends.push((a, b));
}
let at_in = |cut_uv: &[[f64; 2]], k: u32| -> Option<[f64; 2]> {
let k = k as usize;
if k < n { boundary.get(k).copied() } else { cut_uv.get(k - n).copied() }
};
// The spacing wanted at a node: at a boundary node the mean of its two boundary segments.
let spacing = |k: u32| -> f64 {
let k = k as usize;
let Some(r) = loops.iter().find(|r| r.contains(&k)) else { return f64::INFINITY };
let m = r.len();
let i = k - r.start;
let (prev, next) = (r.start + (i + m - 1) % m, r.start + (i + 1) % m);
match (boundary.get(prev), boundary.get(k), boundary.get(next)) {
(Some(&p), Some(&q), Some(&x)) => 0.5 * (dist(p, q) + dist(q, x)),
_ => f64::INFINITY,
}
};
// Boundary sides, so a cut that is one of them is left out.
let mut sides: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
for r in loops {
let m = r.len();
for k in 0..m {
let (a, b) = (u32::try_from(r.start + k).ok()?, u32::try_from(r.start + (k + 1) % m).ok()?);
sides.insert((a.min(b), a.max(b)));
}
}
let mut cuts: Vec<(u32, u32)> = Vec::new();
let mut taken: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
for (i, &(s0, s1)) in ends.iter().enumerate() {
if s0 == s1 {
continue;
}
let p0 = at_in(&cut_uv, s0)?;
let mut nodes: Vec<(f64, u32)> = vec![(0.0, s0)];
for &k in along.get(i)? {
nodes.push((dist(p0, at_in(&cut_uv, k)?), k));
}
let total = dist(p0, at_in(&cut_uv, s1)?);
nodes.push((total, s1));
nodes.sort_by(|a, b| a.0.total_cmp(&b.0));
nodes.dedup_by_key(|x| x.1);
let count = segments.get(i).and_then(|s| s.count);
for w in nodes.windows(2) {
let (Some(&(_, a)), Some(&(_, b))) = (w.first(), w.get(1)) else { continue };
if a == b {
continue;
}
// The same cut twice (two segments between the same nodes) is one cut.
let key = (a.min(b), a.max(b));
if sides.contains(&key) || !taken.insert(key) {
continue;
}
let (pa, pb) = (at_in(&cut_uv, a)?, at_in(&cut_uv, b)?);
let fractions: Vec<f64> = match count {
Some(k) if total > 0.0 => {
let share = dist(pa, pb) / total;
let k = ((k as f64 * share).round() as usize).clamp(1, 4096);
(1..k).map(|j| j as f64 / k as f64).collect()
}
_ => seeds(pa, pb, spacing(a), spacing(b)),
};
let mut prev = a;
for f in fractions.into_iter().take(4096) {
let k = u32::try_from(n + cut_uv.len()).ok()?;
cut_uv.push([pa[0] + (pb[0] - pa[0]) * f, pa[1] + (pb[1] - pa[1]) * f]);
cuts.push((prev, k));
prev = k;
}
cuts.push((prev, b));
}
}
if cuts.is_empty() {
return None;
}
let at = |k: u32| at_in(&cut_uv, k);
let pos = |k: u32| at(k).map(metric);
// Half-edges: boundary sides with the face on their left, cut sides both ways.
let inside = |q: [f64; 2]| {
let mut odd = false;
for r in loops {
let m = r.len();
for k in 0..m {
let (Some(a), Some(b)) = (boundary.get(r.start + k).map(|p| metric(*p)), boundary.get(r.start + (k + 1) % m).map(|p| metric(*p)))
else {
continue;
};
if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < a[0] + (q[1] - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
odd = !odd;
}
}
}
odd
};
let mut half: Vec<(u32, u32)> = Vec::new();
for r in loops {
let m = r.len();
let side = |k: usize| -> Option<(u32, u32)> { Some((u32::try_from(r.start + k).ok()?, u32::try_from(r.start + (k + 1) % m).ok()?)) };
// Which way round the face lies to the left: test beside the loop's longest side.
let longest = (0..m).max_by(|&x, &y| {
let l = |k: usize| side(k).and_then(|(a, b)| Some(dist(at(a)?, at(b)?))).unwrap_or(0.0);
l(x).total_cmp(&l(y))
})?;
let (a, b) = side(longest)?;
let (pa, pb) = (pos(a)?, pos(b)?);
let len = (pb[0] - pa[0]).hypot(pb[1] - pa[1]);
if !(len > 0.0) {
return None;
}
let left = [0.5 * (pa[0] + pb[0]) - (pb[1] - pa[1]) * 1e-4, 0.5 * (pa[1] + pb[1]) + (pb[0] - pa[0]) * 1e-4];
let forward = inside(left);
for k in 0..m {
let (a, b) = side(k)?;
if a == b {
continue;
}
half.push(if forward { (a, b) } else { (b, a) });
}
}
for &(a, b) in &cuts {
half.push((a, b));
half.push((b, a));
}
// Outgoing half-edges per node, by angle.
let angle = |a: u32, b: u32| -> Option<f64> {
let (p, q) = (pos(a)?, pos(b)?);
Some((q[1] - p[1]).atan2(q[0] - p[0]))
};
let mut out: HashMap<u32, Vec<(f64, usize)>> = HashMap::new();
for (h, &(a, b)) in half.iter().enumerate() {
out.entry(a).or_default().push((angle(a, b)?, h));
}
for v in out.values_mut() {
v.sort_by(|x, y| x.0.total_cmp(&y.0));
}
// The face on the left of u→v continues at v with the first outgoing side clockwise from
// v→u.
let next = |h: usize| -> Option<usize> {
let &(u, v) = half.get(h)?;
let back = angle(v, u)?;
let list = out.get(&v)?;
let before = list
.iter()
.rev()
.find(|(a, e)| *a < back - 1e-12 && half.get(*e).is_some_and(|x| x.1 != u))
.or_else(|| list.iter().rev().find(|(_, e)| half.get(*e).is_some_and(|x| x.1 != u)));
before.map(|(_, e)| *e).or_else(|| list.iter().find(|(_, e)| half.get(*e).is_some_and(|x| x.1 == u)).map(|(_, e)| *e))
};
let mut seen = vec![false; half.len()];
let mut rings: Vec<Vec<u32>> = Vec::new();
for h0 in 0..half.len() {
if *seen.get(h0)? {
continue;
}
let mut ring = Vec::new();
let mut h = h0;
loop {
*seen.get_mut(h)? = true;
ring.push(half.get(h)?.0);
h = next(h)?;
if h == h0 {
break;
}
if ring.len() > half.len() || *seen.get(h)? {
return None;
}
}
rings.push(ring);
}
let area = |ring: &[u32]| -> Option<f64> {
let mut s = 0.0;
for k in 0..ring.len() {
let (a, b) = (pos(*ring.get(k)?)?, pos(*ring.get((k + 1) % ring.len())?)?);
s += a[0] * b[1] - b[0] * a[1];
}
Some(0.5 * s)
};
let mut pieces: Vec<Piece> = Vec::new();
let mut holes: Vec<Vec<u32>> = Vec::new();
for r in rings {
if area(&r)? > 0.0 {
pieces.push(Piece { ring: r, holes: Vec::new() });
} else {
holes.push(r);
}
}
// A loop no cut reached is a hole of the patch around it.
for h in holes {
let p = pos(*h.first()?)?;
let contains = |ring: &[u32]| {
let mut odd = false;
for k in 0..ring.len() {
let (Some(a), Some(b)) = (ring.get(k).and_then(|x| pos(*x)), ring.get((k + 1) % ring.len()).and_then(|x| pos(*x))) else { continue };
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < a[0] + (p[1] - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
odd = !odd;
}
}
odd
};
let host = pieces.iter_mut().filter(|pc| contains(&pc.ring)).min_by(|a, b| {
let s = |pc: &Piece| area(&pc.ring).unwrap_or(f64::MAX);
s(a).total_cmp(&s(b))
})?;
host.holes.push(h);
}
Some((cut_uv, pieces))
}
#[cfg(test)]
mod tests {
use super::*;
fn area(all: &[[f64; 2]], ring: &[u32]) -> f64 {
(0..ring.len())
.map(|k| {
let (a, b) = (all[ring[k] as usize], all[ring[(k + 1) % ring.len()] as usize]);
0.5 * (a[0] * b[1] - b[0] * a[1])
})
.sum()
}
/// A square cut by four segments meeting at its centre (one point inside the face, shared)
/// gives four quarters covering it.
#[test]
fn a_cross_cuts_a_square_into_four() {
let b = vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0], [0.0, 2.0], [0.0, 1.0]];
let loops = [std::ops::Range { start: 0, end: 8 }];
let c = End::Point([1.0, 1.0]);
let segs = [
Segment { a: End::Node(1), b: c, count: None },
Segment { a: End::Node(3), b: c, count: None },
Segment { a: End::Node(5), b: c, count: None },
Segment { a: End::Node(7), b: c, count: None },
];
let Some((cut_uv, pieces)) = cut(&b, &loops, &segs, [1.0, 1.0], &|_, _, _, _| vec![0.5]) else { panic!("cuts") };
assert_eq!(pieces.len(), 4);
let mut all = b.clone();
all.extend(cut_uv);
let total: f64 = pieces.iter().map(|p| area(&all, &p.ring)).sum();
assert!((total - 4.0).abs() < 1e-12, "{total}");
}
/// A segment ending on another (a T-node) splits it there: three patches.
#[test]
fn a_t_node_splits_the_segment_it_lands_on() {
let b = vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [2.0, 2.0], [0.0, 2.0], [0.0, 1.0]];
let loops = [std::ops::Range { start: 0, end: 7 }];
let segs = [Segment { a: End::Node(6), b: End::Node(3), count: None }, Segment { a: End::Node(1), b: End::On(0, [1.0, 1.0]), count: None }];
let Some((cut_uv, pieces)) = cut(&b, &loops, &segs, [1.0, 1.0], &|_, _, _, _| Vec::new()) else { panic!("cuts") };
assert_eq!(pieces.len(), 3, "{pieces:?}");
let mut all = b.clone();
all.extend(cut_uv);
let total: f64 = pieces.iter().map(|p| area(&all, &p.ring)).sum();
assert!((total - 4.0).abs() < 1e-12, "{total}");
}
#[test]
fn no_segments_no_cut() {
let b = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
assert!(cut(&b, &[std::ops::Range { start: 0, end: 4 }], &[], [1.0, 1.0], &|_, _, _, _| Vec::new()).is_none());
}
}
+8 -8
View File
@@ -14,10 +14,9 @@ use super::uv::Domain;
/// Longest side, as a fraction of the body's extent.
const MAX_LEN_FRACTION: f64 = 1.0 / 5.0;
/// Longest side relative to the side the curvature asks for: along a straight direction (a
/// cylinder's axis) triangles may be this many times longer than across it. With a 30° facet
/// angle and [`MAX_LEN_FRACTION`] this puts the oracle parts' display meshes at about the
/// reference mesher's triangle count (1.2×; 5× with equilateral triangles and 15°).
const ASPECT: f64 = 4.0;
/// cylinder's axis) cells may be this many times longer than across it. 2: cells stay close to
/// square (patches mesh as grids) without doubling the rows along every straight surface.
const ASPECT: f64 = 2.0;
/// Shortest side the aspect limit may ask for, as a fraction of the longest side. A bend whose
/// radius is tiny next to the body (a hem) would otherwise tie the whole length of its
/// straight edges to that radius; along a straight direction the normal does not turn, so
@@ -172,15 +171,16 @@ mod tests {
transform: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]],
swap: false,
};
// A 20 mm body: sides from 0.5 mm (the floor) to 4 mm.
// A 20 mm body: sides from 1 mm (the floor, a quarter of the longest) to 4 mm.
let rules = Rules::new(0.01, 20.0, THIRTY);
let (h, speed) = face_size(&s, &super::super::uv::domain(&s), &rules);
// r = 3: 30° of turn is 1.57 mm; the 0.01 mm sag chord is √(8·3·0.01) = 0.49 mm.
assert!((h - ASPECT * (8.0 * 3.0 * 0.01f64).sqrt()).abs() < 0.01, "{h}");
// r = 3: 30° of turn is 1.57 mm; the 0.01 mm sag chord is √(8·3·0.01) = 0.49 mm, times the
// aspect, unless that is under the floor.
assert!((h - (ASPECT * (8.0 * 3.0 * 0.01f64).sqrt()).max(20.0 * MAX_LEN_FRACTION * MIN_LEN_FRACTION)).abs() < 0.01, "{h}");
assert!((speed[0] - 40.0).abs() < 1e-6 && (speed[1] - 3.0).abs() < 1e-6, "{speed:?}");
// Coarser: the turn limit governs.
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, THIRTY));
assert!((h - ASPECT * 3.0 * std::f64::consts::PI / 6.0).abs() < 0.01, "{h}");
assert!((h - (ASPECT * 3.0 * std::f64::consts::PI / 6.0).max(100.0 * MAX_LEN_FRACTION * MIN_LEN_FRACTION)).abs() < 0.01, "{h}");
// Without a facet angle the sag alone: √(8·3·0.5) = 3.46 mm, four times along.
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, None));
assert!((h - ASPECT * (8.0 * 3.0 * 0.5f64).sqrt()).abs() < 0.01, "{h}");
+19 -2
View File
@@ -108,13 +108,14 @@ fn holes_and_trimmed_faces_are_watertight() {
let bx = ok(crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 4.0)));
let pin = ok(crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 6.0));
check("box with a hole", &cut(&bx, &pin), tol, 400.0 - PI * 4.0 * 4.0);
// A cylinder with a cross hole: trimmed cylinder halves (Delaunay) and intersection-curve walls.
// A cylinder with a cross hole: trimmed cylinder halves (an iso-line grid cut by the hole)
// and intersection-curve walls; no face needs Delaunay.
let cyl = ok(crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 6.0));
let cross = ok(crate::cylinder(Vec3::new(5.0, -1.0, 2.0), Vec3::Y, 1.0, 12.0));
let body = cut(&cyl, &cross);
let (m, stats) = plan_body(&body, tol);
assert_watertight(&m, "cross hole");
assert!(stats.delaunay > 0, "{stats:?}");
assert_eq!(stats.delaunay, 0, "{stats:?}");
// Against the reference mesher's volume (no closed form for the two-cylinder intersection).
let Ok(reference) = body.tessellate(tol * 0.2) else { panic!("reference mesh") };
let (a, b) = (m.measure().volume, reference.measure().volume);
@@ -279,3 +280,19 @@ fn a_placed_body_meshes_like_the_original() {
let (p, _) = plan_body(&placed, 0.005);
assert_eq!(m.tri_face, p.tri_face);
}
/// Seeds on a motorcycle trace start at the spacing of the boundary it leaves and grow
/// geometrically to the spacing at its other end (here 0.1 to 1 over 5 mm).
#[test]
fn trace_seeds_grade_from_one_end_to_the_other() {
let t = graded([0.0, 0.0], [5.0, 0.0], 0.1, 1.0, 10.0, [1.0, 1.0], &|_, _| true);
let all: Vec<f64> = std::iter::once(0.0).chain(t.iter().copied()).chain(std::iter::once(1.0)).map(|f| f * 5.0).collect();
let sizes: Vec<f64> = all.windows(2).map(|w| w[1] - w[0]).collect();
assert!((sizes[0] - 0.1).abs() < 0.03, "first {}", sizes[0]);
assert!((sizes[sizes.len() - 1] - 1.0).abs() < 0.3, "last {}", sizes[sizes.len() - 1]);
assert!(sizes.windows(2).all(|w| w[1] >= w[0] - 1e-12), "growing: {sizes:?}");
// Equal ends: even; a segment no longer than its spacing: no seeds.
let even = graded([0.0, 0.0], [4.0, 0.0], 1.0, 1.0, 10.0, [1.0, 1.0], &|_, _| true);
assert_eq!(even.len(), 3);
assert!(graded([0.0, 0.0], [0.5, 0.0], 1.0, 1.0, 10.0, [1.0, 1.0], &|_, _| true).is_empty());
}