geometry: brush contours
Browse files- crates/nanobrush-geometry/src/brush.rs +258 -0
- crates/nanobrush-geometry/src/lib.rs +75 -0
- docs/GEOMETRY.md +6 -0
- docs/PROGRESS_2026-10-26.md +1 -1
- docs/TODO.md +2 -0
crates/nanobrush-geometry/src/brush.rs
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::{math::*, *};
|
| 2 |
+
#[derive(Debug)]
|
| 3 |
+
pub struct Mesh {
|
| 4 |
+
pub contours: Vec<Vec<DVec3>>,
|
| 5 |
+
pub bounds: Aabb,
|
| 6 |
+
pub stats: Stats,
|
| 7 |
+
}
|
| 8 |
+
fn recession(eq: &[Equation], dir: DVec3, budget: &mut Budget, p: Policy) -> Result<bool> {
|
| 9 |
+
if length(dir) <= p.angular_epsilon {
|
| 10 |
+
return Ok(false);
|
| 11 |
+
}
|
| 12 |
+
let d = unit(dir)?;
|
| 13 |
+
for e in eq {
|
| 14 |
+
budget.work(1)?;
|
| 15 |
+
if dot(e.normal(), d) > p.angular_epsilon {
|
| 16 |
+
return Ok(false);
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
Ok(true)
|
| 20 |
+
}
|
| 21 |
+
/// Strict bounded, full-dimensional convex brush. Redundant/duplicate faces are
|
| 22 |
+
/// rejected rather than dropping persistent plane identities.
|
| 23 |
+
pub fn contours(brush: &mb3_dat::Brush, policy: Policy, limits: ComputeLimits) -> Result<Mesh> {
|
| 24 |
+
let p = policy.validate()?;
|
| 25 |
+
let n = brush.planes.len();
|
| 26 |
+
if n < 4 {
|
| 27 |
+
return Err(Error::InvalidBrush);
|
| 28 |
+
}
|
| 29 |
+
if n > limits.max_planes {
|
| 30 |
+
return Err(Error::Limit);
|
| 31 |
+
}
|
| 32 |
+
let mut b = Budget {
|
| 33 |
+
limits,
|
| 34 |
+
stats: Stats::default(),
|
| 35 |
+
};
|
| 36 |
+
let mut eq = b.reserve(n)?;
|
| 37 |
+
for plane in &brush.planes {
|
| 38 |
+
eq.push(Equation::from_basis(plane, p)?);
|
| 39 |
+
}
|
| 40 |
+
// A nonzero feasible recession direction proves unboundedness. Pairwise normal
|
| 41 |
+
// crosses cover pointed cone rays; normal-axis crosses cover rank-one slabs.
|
| 42 |
+
for i in 0..n {
|
| 43 |
+
for axis in [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]] {
|
| 44 |
+
let r = cross(eq[i].normal(), axis);
|
| 45 |
+
if recession(&eq, r, &mut b, p)? || recession(&eq, scale(r, -1.), &mut b, p)? {
|
| 46 |
+
return Err(Error::InvalidBrush);
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
for j in i + 1..n {
|
| 50 |
+
b.work(1)?;
|
| 51 |
+
if length(sub(eq[i].normal(), eq[j].normal())) <= p.angular_epsilon
|
| 52 |
+
&& (eq[i].offset() - eq[j].offset()).abs() <= p.distance_epsilon
|
| 53 |
+
{
|
| 54 |
+
return Err(Error::InvalidBrush);
|
| 55 |
+
}
|
| 56 |
+
let r = cross(eq[i].normal(), eq[j].normal());
|
| 57 |
+
if recession(&eq, r, &mut b, p)? || recession(&eq, scale(r, -1.), &mut b, p)? {
|
| 58 |
+
return Err(Error::InvalidBrush);
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
let mut vertices = Vec::new();
|
| 63 |
+
for i in 0..n {
|
| 64 |
+
for j in i + 1..n {
|
| 65 |
+
for k in j + 1..n {
|
| 66 |
+
b.work(1)?;
|
| 67 |
+
if let Some(v) = Equation::intersection(eq[i], eq[j], eq[k], p)? {
|
| 68 |
+
let mut inside = true;
|
| 69 |
+
for e in &eq {
|
| 70 |
+
b.work(1)?;
|
| 71 |
+
if e.distance(v)? > p.distance_epsilon {
|
| 72 |
+
inside = false;
|
| 73 |
+
break;
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
if !inside {
|
| 77 |
+
continue;
|
| 78 |
+
}
|
| 79 |
+
let mut duplicate = false;
|
| 80 |
+
for old in &vertices {
|
| 81 |
+
b.work(1)?;
|
| 82 |
+
if length(sub(*old, v)) <= p.distance_epsilon {
|
| 83 |
+
duplicate = true;
|
| 84 |
+
break;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
if !duplicate {
|
| 88 |
+
b.push(&mut vertices, v)?;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
if vertices.len() < 4 {
|
| 95 |
+
return Err(Error::InvalidBrush);
|
| 96 |
+
}
|
| 97 |
+
let bounds = Aabb::from_points(vertices.iter().copied())?.ok_or(Error::InvalidBrush)?;
|
| 98 |
+
let mut out = b.reserve(n)?;
|
| 99 |
+
let mut edges = Vec::new();
|
| 100 |
+
let mut total = 0usize;
|
| 101 |
+
let mut volume = 0.;
|
| 102 |
+
let reference = vertices[0];
|
| 103 |
+
for (i, e) in eq.iter().enumerate() {
|
| 104 |
+
let mut face = Vec::new();
|
| 105 |
+
for (id, v) in vertices.iter().enumerate() {
|
| 106 |
+
b.work(1)?;
|
| 107 |
+
if e.distance(*v)?.abs() <= p.distance_epsilon {
|
| 108 |
+
b.push(&mut face, id)?;
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
if face.len() < 3 {
|
| 112 |
+
return Err(Error::InvalidBrush);
|
| 113 |
+
}
|
| 114 |
+
total = total.checked_add(face.len()).ok_or(Error::Overflow)?;
|
| 115 |
+
if total > limits.max_vertices {
|
| 116 |
+
return Err(Error::Limit);
|
| 117 |
+
}
|
| 118 |
+
let center = scale(
|
| 119 |
+
face.iter().fold([0.; 3], |sum, id| add(sum, vertices[*id])),
|
| 120 |
+
1. / face.len() as f64,
|
| 121 |
+
);
|
| 122 |
+
let u = unit(from_f32(brush.planes[i].u)?)?;
|
| 123 |
+
let v = cross(e.normal(), u);
|
| 124 |
+
face.sort_unstable_by(|a, c| {
|
| 125 |
+
let a0 = sub(vertices[*a], center);
|
| 126 |
+
let c0 = sub(vertices[*c], center);
|
| 127 |
+
dot(a0, v)
|
| 128 |
+
.atan2(dot(a0, u))
|
| 129 |
+
.total_cmp(&dot(c0, v).atan2(dot(c0, u)))
|
| 130 |
+
.then(a.cmp(c))
|
| 131 |
+
});
|
| 132 |
+
let start = (0..face.len())
|
| 133 |
+
.min_by(|a, c| {
|
| 134 |
+
for d in 0..3 {
|
| 135 |
+
let order = vertices[face[*a]][d].total_cmp(&vertices[face[*c]][d]);
|
| 136 |
+
if !order.is_eq() {
|
| 137 |
+
return order;
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
std::cmp::Ordering::Equal
|
| 141 |
+
})
|
| 142 |
+
.ok_or(Error::InvalidBrush)?;
|
| 143 |
+
face.rotate_left(start);
|
| 144 |
+
let mut polygon = b.reserve(face.len())?;
|
| 145 |
+
for j in 0..face.len() {
|
| 146 |
+
let a = face[j];
|
| 147 |
+
let c = face[(j + 1) % face.len()];
|
| 148 |
+
b.push(&mut edges, if a < c { (a, c) } else { (c, a) })?;
|
| 149 |
+
polygon.push(vertices[a]);
|
| 150 |
+
}
|
| 151 |
+
for j in 1..polygon.len() - 1 {
|
| 152 |
+
volume += dot(
|
| 153 |
+
sub(polygon[0], reference),
|
| 154 |
+
cross(sub(polygon[j], reference), sub(polygon[j + 1], reference)),
|
| 155 |
+
) / 6.;
|
| 156 |
+
}
|
| 157 |
+
out.push(polygon);
|
| 158 |
+
}
|
| 159 |
+
edges.sort_unstable();
|
| 160 |
+
if edges.len() % 2 != 0
|
| 161 |
+
|| edges
|
| 162 |
+
.chunks(2)
|
| 163 |
+
.any(|pair| pair.len() != 2 || pair[0] != pair[1])
|
| 164 |
+
|| edges.windows(3).any(|a| a[0] == a[2])
|
| 165 |
+
{
|
| 166 |
+
return Err(Error::InvalidBrush);
|
| 167 |
+
}
|
| 168 |
+
if !volume.is_finite() || volume <= p.distance_epsilon.powi(3) {
|
| 169 |
+
return Err(Error::InvalidBrush);
|
| 170 |
+
}
|
| 171 |
+
b.stats.vertices = total;
|
| 172 |
+
Ok(Mesh {
|
| 173 |
+
contours: out,
|
| 174 |
+
bounds,
|
| 175 |
+
stats: b.stats,
|
| 176 |
+
})
|
| 177 |
+
}
|
| 178 |
+
#[cfg(test)]
|
| 179 |
+
pub(crate) fn cube() -> mb3_dat::Brush {
|
| 180 |
+
use mb3_dat::Plane;
|
| 181 |
+
let mut planes = Vec::new();
|
| 182 |
+
for axis in 0..3 {
|
| 183 |
+
for sign in [-1., 1.] {
|
| 184 |
+
let mut p = Plane::default();
|
| 185 |
+
p.p0[axis] = sign;
|
| 186 |
+
let a = (axis + 1) % 3;
|
| 187 |
+
let c = (axis + 2) % 3;
|
| 188 |
+
p.u[a] = 1.;
|
| 189 |
+
p.v[c] = sign;
|
| 190 |
+
planes.push(p);
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
mb3_dat::Brush { planes }
|
| 194 |
+
}
|
| 195 |
+
#[cfg(test)]
|
| 196 |
+
mod tests {
|
| 197 |
+
use super::*;
|
| 198 |
+
#[test]
|
| 199 |
+
fn cube_topology_winding_determinism() {
|
| 200 |
+
let brush = cube();
|
| 201 |
+
let m = contours(&brush, Policy::default(), ComputeLimits::default()).unwrap();
|
| 202 |
+
assert_eq!(m.contours.len(), 6);
|
| 203 |
+
assert!(m.contours.iter().all(|p| p.len() == 4));
|
| 204 |
+
assert_eq!(m.bounds, Aabb::new([-1.; 3], [1.; 3]).unwrap());
|
| 205 |
+
for (p, poly) in brush.planes.iter().zip(&m.contours) {
|
| 206 |
+
let n = Equation::from_basis(p, Policy::default()).unwrap().normal();
|
| 207 |
+
assert!(dot(cross(sub(poly[1], poly[0]), sub(poly[2], poly[1])), n) > 0.);
|
| 208 |
+
}
|
| 209 |
+
assert_eq!(
|
| 210 |
+
m.contours,
|
| 211 |
+
contours(&brush, Policy::default(), ComputeLimits::default())
|
| 212 |
+
.unwrap()
|
| 213 |
+
.contours
|
| 214 |
+
);
|
| 215 |
+
}
|
| 216 |
+
#[test]
|
| 217 |
+
fn invalid_unbounded_redundant_and_limits() {
|
| 218 |
+
let mut b = cube();
|
| 219 |
+
b.planes.pop();
|
| 220 |
+
assert!(contours(&b, Policy::default(), ComputeLimits::default()).is_err());
|
| 221 |
+
b = cube();
|
| 222 |
+
b.planes.push(b.planes[0].clone());
|
| 223 |
+
assert!(contours(&b, Policy::default(), ComputeLimits::default()).is_err());
|
| 224 |
+
b = cube();
|
| 225 |
+
for l in [
|
| 226 |
+
ComputeLimits {
|
| 227 |
+
max_planes: 5,
|
| 228 |
+
..Default::default()
|
| 229 |
+
},
|
| 230 |
+
ComputeLimits {
|
| 231 |
+
max_work: 1,
|
| 232 |
+
..Default::default()
|
| 233 |
+
},
|
| 234 |
+
ComputeLimits {
|
| 235 |
+
max_vertices: 7,
|
| 236 |
+
..Default::default()
|
| 237 |
+
},
|
| 238 |
+
ComputeLimits {
|
| 239 |
+
max_owned_bytes: 1,
|
| 240 |
+
..Default::default()
|
| 241 |
+
},
|
| 242 |
+
] {
|
| 243 |
+
assert!(contours(&b, Policy::default(), l).is_err());
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
#[test]
|
| 247 |
+
fn translated_box_and_inverted_halfspace() {
|
| 248 |
+
let mut b = cube();
|
| 249 |
+
for p in &mut b.planes {
|
| 250 |
+
p.p0[0] += 100.;
|
| 251 |
+
p.p0[1] -= 50.;
|
| 252 |
+
}
|
| 253 |
+
let m = contours(&b, Policy::default(), ComputeLimits::default()).unwrap();
|
| 254 |
+
assert_eq!(m.bounds.min, [99., -51., -1.]);
|
| 255 |
+
b.planes[0].v = b.planes[0].v.map(|x| -x);
|
| 256 |
+
assert!(contours(&b, Policy::default(), ComputeLimits::default()).is_err());
|
| 257 |
+
}
|
| 258 |
+
}
|
crates/nanobrush-geometry/src/lib.rs
CHANGED
|
@@ -27,3 +27,78 @@ pub(crate) fn alloc<T>(n: usize) -> Result<Vec<T>> {
|
|
| 27 |
out.try_reserve_exact(n).map_err(|_| Error::Allocation)?;
|
| 28 |
Ok(out)
|
| 29 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
out.try_reserve_exact(n).map_err(|_| Error::Allocation)?;
|
| 28 |
Ok(out)
|
| 29 |
}
|
| 30 |
+
|
| 31 |
+
pub mod brush;
|
| 32 |
+
#[derive(Clone, Copy, Debug)]
|
| 33 |
+
pub struct ComputeLimits {
|
| 34 |
+
pub max_planes: usize,
|
| 35 |
+
pub max_vertices: usize,
|
| 36 |
+
pub max_work: usize,
|
| 37 |
+
pub max_owned_bytes: usize,
|
| 38 |
+
}
|
| 39 |
+
impl Default for ComputeLimits {
|
| 40 |
+
fn default() -> Self {
|
| 41 |
+
Self {
|
| 42 |
+
max_planes: 64,
|
| 43 |
+
max_vertices: 100_000,
|
| 44 |
+
max_work: 10_000_000,
|
| 45 |
+
max_owned_bytes: 128 * 1024 * 1024,
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
#[derive(Clone, Copy, Debug, Default)]
|
| 50 |
+
pub struct Stats {
|
| 51 |
+
pub work: usize,
|
| 52 |
+
pub owned: usize,
|
| 53 |
+
pub vertices: usize,
|
| 54 |
+
}
|
| 55 |
+
pub(crate) struct Budget {
|
| 56 |
+
pub limits: ComputeLimits,
|
| 57 |
+
pub stats: Stats,
|
| 58 |
+
}
|
| 59 |
+
impl Budget {
|
| 60 |
+
fn work(&mut self, n: usize) -> Result<()> {
|
| 61 |
+
self.stats.work = self.stats.work.checked_add(n).ok_or(Error::Overflow)?;
|
| 62 |
+
if self.stats.work > self.limits.max_work {
|
| 63 |
+
Err(Error::Limit)
|
| 64 |
+
} else {
|
| 65 |
+
Ok(())
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
fn bytes(&mut self, n: usize) -> Result<()> {
|
| 69 |
+
self.stats.owned = self.stats.owned.checked_add(n).ok_or(Error::Overflow)?;
|
| 70 |
+
if self.stats.owned > self.limits.max_owned_bytes {
|
| 71 |
+
Err(Error::Limit)
|
| 72 |
+
} else {
|
| 73 |
+
Ok(())
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
fn reserve<T>(&mut self, n: usize) -> Result<Vec<T>> {
|
| 77 |
+
self.bytes(
|
| 78 |
+
n.checked_mul(std::mem::size_of::<T>())
|
| 79 |
+
.ok_or(Error::Overflow)?,
|
| 80 |
+
)?;
|
| 81 |
+
alloc(n)
|
| 82 |
+
}
|
| 83 |
+
fn push<T>(&mut self, out: &mut Vec<T>, v: T) -> Result<()> {
|
| 84 |
+
if out.len() == out.capacity() {
|
| 85 |
+
let cap = out
|
| 86 |
+
.capacity()
|
| 87 |
+
.saturating_mul(2)
|
| 88 |
+
.max(1)
|
| 89 |
+
.min(self.limits.max_vertices);
|
| 90 |
+
if cap <= out.len() {
|
| 91 |
+
return Err(Error::Limit);
|
| 92 |
+
}
|
| 93 |
+
self.bytes(
|
| 94 |
+
(cap - out.capacity())
|
| 95 |
+
.checked_mul(std::mem::size_of::<T>())
|
| 96 |
+
.ok_or(Error::Overflow)?,
|
| 97 |
+
)?;
|
| 98 |
+
out.try_reserve_exact(cap - out.len())
|
| 99 |
+
.map_err(|_| Error::Allocation)?;
|
| 100 |
+
}
|
| 101 |
+
out.push(v);
|
| 102 |
+
Ok(())
|
| 103 |
+
}
|
| 104 |
+
}
|
docs/GEOMETRY.md
CHANGED
|
@@ -5,3 +5,9 @@ This is an independently authored native policy, not verified editor parity. The
|
|
| 5 |
Default distance tolerance is 1e-6 world units; angular/determinant tolerance is 1e-10. Both must be finite and positive, with angular tolerance below one. These are explicit native choices, not a claim to reproduce the source helper’s raw determinant threshold. Parallel/nearly parallel triples return no intersection. AABBs retain finite min/max values and include an explicit empty result. MAP/native axes follow the converter mapping `(x,y,z) -> (x,z,-y)` and its inverse. Invalid vectors/policies/ranges return errors.
|
| 6 |
|
| 7 |
Tests cover signed basis orientation, asymmetric three-plane intersections, parallel triples, scaled bases, boxes, empty bounds, axis round trips and invalid values. See the [ten-task ledger](PROGRESS_2026-10-26.md) for staged extensions and remaining parity limits.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
Default distance tolerance is 1e-6 world units; angular/determinant tolerance is 1e-10. Both must be finite and positive, with angular tolerance below one. These are explicit native choices, not a claim to reproduce the source helper’s raw determinant threshold. Parallel/nearly parallel triples return no intersection. AABBs retain finite min/max values and include an explicit empty result. MAP/native axes follow the converter mapping `(x,y,z) -> (x,z,-y)` and its inverse. Invalid vectors/policies/ranges return errors.
|
| 6 |
|
| 7 |
Tests cover signed basis orientation, asymmetric three-plane intersections, parallel triples, scaled bases, boxes, empty bounds, axis round trips and invalid values. See the [ten-task ledger](PROGRESS_2026-10-26.md) for staged extensions and remaining parity limits.
|
| 8 |
+
|
| 9 |
+
## Convex brush contours
|
| 10 |
+
|
| 11 |
+
`brush::contours` enumerates checked plane triples, keeps interior intersections, welds points within distance epsilon and orders each face around its outward normal. Starts are rotated to lexicographically smallest coordinates. Every original plane must contribute a face: duplicate/redundant/degenerate faces are errors, preserving plane identity rather than silently dropping them. Feasible recession directions, missing/invalid edges and nonpositive volume reject unbounded or collapsed objects. Every edge must occur twice. This is a strict native convex profile, not the host's collapse/discard behavior.
|
| 12 |
+
|
| 13 |
+
Defaults cap 64 planes, 100,000 generated face vertices, 10 million work units and 128 MiB requested scratch/output storage. Work includes recession checks, triple candidates, containment and welding; face sorting is additionally bounded by vertex caps. Allocations are fallible; errors return no partial mesh. Analytic cube, translated box, winding/topology/determinism, contradictory/open/redundant and resource-limit tests pass. Near-degenerate geometry can be rejected conservatively; exact legacy tolerances remain unverified.
|
docs/PROGRESS_2026-10-26.md
CHANGED
|
@@ -9,7 +9,7 @@ Scope: extend native headless formats and geometry. Each stage is independently
|
|
| 9 |
| 3 | P6.25 | Bounded computed OBJ writer | Complete: explicit contours and patch grids, stable per-brush exact position deduplication, global 1-based indices and quad winding. Four E02 OBJ outputs match exact bytes; invalid geometry/limits tests pass. |
|
| 10 |
| 4 | P6.26 | CLI OBJ with loss acknowledgement | Complete: `--to obj`, grouped preflight losses and explicit `--allow-lossy`; missing computed data remains blocking. Existing atomic publication is reused. CLI acknowledgement/preflight/regression tests pass. |
|
| 11 |
| 5 | P5.15 | Geometry primitives/bounds | Complete: f64 vector helpers, checked unit plane equations/triple intersections, f32 boundary checks, MAP axes and AABBs. Analytic/degenerate/scaled-basis tests pass; native epsilon/interior policy documented separately from legacy parity. |
|
| 12 |
-
| 6 | P5.16 | Convex brush contours |
|
| 13 |
| 7 | P5.17 | Patch9 tessellation | Pending |
|
| 14 |
| 8 | P5.18 | Computed world pipeline/CLI | Pending |
|
| 15 |
| 9 | P5.19 | Nearest-hit ray queries | Pending |
|
|
|
|
| 9 |
| 3 | P6.25 | Bounded computed OBJ writer | Complete: explicit contours and patch grids, stable per-brush exact position deduplication, global 1-based indices and quad winding. Four E02 OBJ outputs match exact bytes; invalid geometry/limits tests pass. |
|
| 10 |
| 4 | P6.26 | CLI OBJ with loss acknowledgement | Complete: `--to obj`, grouped preflight losses and explicit `--allow-lossy`; missing computed data remains blocking. Existing atomic publication is reused. CLI acknowledgement/preflight/regression tests pass. |
|
| 11 |
| 5 | P5.15 | Geometry primitives/bounds | Complete: f64 vector helpers, checked unit plane equations/triple intersections, f32 boundary checks, MAP axes and AABBs. Analytic/degenerate/scaled-basis tests pass; native epsilon/interior policy documented separately from legacy parity. |
|
| 12 |
+
| 6 | P5.16 | Convex brush contours | Complete: bounded half-space intersections, recession rejection, deterministic outward contours, closed-edge and volume checks. Cube/translation/winding/invalid/redundant/budget tests pass. |
|
| 13 |
| 7 | P5.17 | Patch9 tessellation | Pending |
|
| 14 |
| 8 | P5.18 | Computed world pipeline/CLI | Pending |
|
| 15 |
| 9 | P5.19 | Nearest-hit ray queries | Pending |
|
docs/TODO.md
CHANGED
|
@@ -128,6 +128,8 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
|
|
| 128 |
|
| 129 |
- [x] P5.15 Implement native checked geometry primitives, plane equations/intersections, numeric policy and bounds ([GEOMETRY.md](GEOMETRY.md)).
|
| 130 |
|
|
|
|
|
|
|
| 131 |
## P6 — File formats and converters
|
| 132 |
|
| 133 |
**Depends on:** P3 oracle where available, P5 document model. **Exit:** standalone format specifications, declared compatibility subsets, fixture corpus, and native codecs.
|
|
|
|
| 128 |
|
| 129 |
- [x] P5.15 Implement native checked geometry primitives, plane equations/intersections, numeric policy and bounds ([GEOMETRY.md](GEOMETRY.md)).
|
| 130 |
|
| 131 |
+
- [x] P5.16 Implement bounded native convex brush contour reconstruction, topology checks and analytic regressions ([GEOMETRY.md](GEOMETRY.md)).
|
| 132 |
+
|
| 133 |
## P6 — File formats and converters
|
| 134 |
|
| 135 |
**Depends on:** P3 oracle where available, P5 document model. **Exit:** standalone format specifications, declared compatibility subsets, fixture corpus, and native codecs.
|