nmarshall commited on
Commit
4536b63
·
1 Parent(s): a987df5

geometry: patch tessellation

Browse files
crates/nanobrush-geometry/src/lib.rs CHANGED
@@ -102,3 +102,4 @@ impl Budget {
102
  Ok(())
103
  }
104
  }
 
 
102
  Ok(())
103
  }
104
  }
105
+ pub mod patch;
crates/nanobrush-geometry/src/patch.rs ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::{math::*, *};
2
+ use mb3_dat::Patch9;
3
+ #[derive(Clone, Copy, Debug)]
4
+ pub struct Sample {
5
+ pub point: DVec3,
6
+ pub normal: DVec3,
7
+ }
8
+ #[derive(Debug)]
9
+ pub struct Mesh {
10
+ pub points: Vec<DVec3>,
11
+ pub normals: Vec<DVec3>,
12
+ pub columns: usize,
13
+ pub rows: usize,
14
+ pub bounds: Aabb,
15
+ pub stats: Stats,
16
+ }
17
+ fn shape(p: &Patch9) -> Result<(usize, usize)> {
18
+ let au = p.anchors_u as usize;
19
+ let av = p.anchors_v as usize;
20
+ if au < 3 || av < 3 || au % 2 == 0 || av % 2 == 0 || p.quads_u < 2 || p.quads_v < 2 {
21
+ return Err(Error::InvalidDimensions);
22
+ }
23
+ if p.anchors.len() != au.checked_mul(av).ok_or(Error::Overflow)? {
24
+ return Err(Error::InconsistentData);
25
+ }
26
+ Ok((au / 2, av / 2))
27
+ }
28
+ fn basis(t: f64) -> ([f64; 3], [f64; 3]) {
29
+ (
30
+ [(1. - t) * (1. - t), 2. * t * (1. - t), t * t],
31
+ [-2. * (1. - t), 2. - 4. * t, 2. * t],
32
+ )
33
+ }
34
+ fn evaluate(
35
+ p: &Patch9,
36
+ tile_u: usize,
37
+ tile_v: usize,
38
+ u: f64,
39
+ v: f64,
40
+ policy: Policy,
41
+ ) -> Result<Sample> {
42
+ let (bu, du) = basis(u);
43
+ let (bv, dv) = basis(v);
44
+ let mut point = [0.; 3];
45
+ let mut tangent_u = [0.; 3];
46
+ let mut tangent_v = [0.; 3];
47
+ for j in 0..3 {
48
+ for i in 0..3 {
49
+ let index = (tile_u * 2 + i) + (tile_v * 2 + j) * (p.anchors_u as usize);
50
+ let a = from_f32(*p.anchors.get(index).ok_or(Error::InconsistentData)?)?;
51
+ point = add(point, scale(a, bu[i] * bv[j]));
52
+ tangent_u = add(tangent_u, scale(a, du[i] * bv[j]));
53
+ tangent_v = add(tangent_v, scale(a, bu[i] * dv[j]));
54
+ }
55
+ }
56
+ let n = cross(unit(tangent_v)?, unit(tangent_u)?);
57
+ if length(n) <= policy.angular_epsilon {
58
+ return Err(Error::Degenerate);
59
+ }
60
+ Ok(Sample {
61
+ point: finite(point)?,
62
+ normal: unit(n)?,
63
+ })
64
+ }
65
+ pub fn sample(
66
+ p: &Patch9,
67
+ tile_u: usize,
68
+ tile_v: usize,
69
+ u: f64,
70
+ v: f64,
71
+ policy: Policy,
72
+ ) -> Result<Sample> {
73
+ let policy = policy.validate()?;
74
+ let (tu, tv) = shape(p)?;
75
+ if tile_u >= tu
76
+ || tile_v >= tv
77
+ || !u.is_finite()
78
+ || !v.is_finite()
79
+ || !(0. ..=1.).contains(&u)
80
+ || !(0. ..=1.).contains(&v)
81
+ {
82
+ return Err(Error::InvalidDimensions);
83
+ }
84
+ evaluate(p, tile_u, tile_v, u, v, policy)
85
+ }
86
+ pub fn tessellate(p: &Patch9, policy: Policy, limits: ComputeLimits) -> Result<Mesh> {
87
+ let policy = policy.validate()?;
88
+ let (tu, tv) = shape(p)?;
89
+ let qu = p.quads_u as usize;
90
+ let qv = p.quads_v as usize;
91
+ let columns = tu
92
+ .checked_mul(qu)
93
+ .and_then(|n| n.checked_add(1))
94
+ .ok_or(Error::Overflow)?;
95
+ let rows = tv
96
+ .checked_mul(qv)
97
+ .and_then(|n| n.checked_add(1))
98
+ .ok_or(Error::Overflow)?;
99
+ let n = columns.checked_mul(rows).ok_or(Error::Overflow)?;
100
+ if n > limits.max_vertices {
101
+ return Err(Error::Limit);
102
+ }
103
+ let mut b = Budget {
104
+ limits,
105
+ stats: Stats::default(),
106
+ };
107
+ b.work(p.anchors.len())?;
108
+ for v in &p.anchors {
109
+ from_f32(*v)?;
110
+ }
111
+ let mut points = b.reserve(n)?;
112
+ let mut normals = b.reserve(n)?;
113
+ for y in 0..rows {
114
+ let ty = (y / qv).min(tv - 1);
115
+ let v = (y - ty * qv) as f64 / qv as f64;
116
+ for x in 0..columns {
117
+ b.work(9)?;
118
+ let tx = (x / qu).min(tu - 1);
119
+ let u = (x - tx * qu) as f64 / qu as f64;
120
+ let s = evaluate(p, tx, ty, u, v, policy)?;
121
+ points.push(s.point);
122
+ normals.push(s.normal);
123
+ }
124
+ }
125
+ let bounds = Aabb::from_points(points.iter().copied())?.ok_or(Error::Degenerate)?;
126
+ b.stats.vertices = n;
127
+ Ok(Mesh {
128
+ points,
129
+ normals,
130
+ columns,
131
+ rows,
132
+ bounds,
133
+ stats: b.stats,
134
+ })
135
+ }
136
+ #[cfg(test)]
137
+ pub(crate) fn flat(au: u32, av: u32) -> Patch9 {
138
+ Patch9 {
139
+ anchors_u: au,
140
+ anchors_v: av,
141
+ quads_u: 2,
142
+ quads_v: 2,
143
+ anchors: (0..av)
144
+ .flat_map(|v| (0..au).map(move |u| [u as f32, 0., v as f32]))
145
+ .collect(),
146
+ computed: None,
147
+ }
148
+ }
149
+ #[cfg(test)]
150
+ mod tests {
151
+ use super::*;
152
+ #[test]
153
+ fn flat_patch_endpoints_grid_and_normals() {
154
+ let p = flat(3, 3);
155
+ let m = tessellate(&p, Policy::default(), ComputeLimits::default()).unwrap();
156
+ assert_eq!((m.columns, m.rows), (3, 3));
157
+ assert_eq!(m.points[0], [0., 0., 0.]);
158
+ assert_eq!(m.points[8], [2., 0., 2.]);
159
+ assert!(m.normals.iter().all(|v| *v == [0., 1., 0.]));
160
+ assert_eq!(m.bounds.min, [0.; 3]);
161
+ }
162
+ #[test]
163
+ fn curvature_midpoint_and_shared_seams() {
164
+ let mut p = flat(5, 3);
165
+ p.anchors[1][1] = 4.;
166
+ p.anchors[6][1] = 4.;
167
+ p.anchors[11][1] = 4.;
168
+ let left = sample(&p, 0, 0, 1., 0.5, Policy::default()).unwrap();
169
+ let right = sample(&p, 1, 0, 0., 0.5, Policy::default()).unwrap();
170
+ assert_eq!(left.point, right.point);
171
+ assert_eq!(
172
+ sample(&p, 0, 0, 0.5, 0.5, Policy::default()).unwrap().point,
173
+ [1., 2., 1.]
174
+ );
175
+ let m = tessellate(&p, Policy::default(), ComputeLimits::default()).unwrap();
176
+ assert_eq!((m.columns, m.rows), (5, 3));
177
+ }
178
+ #[test]
179
+ fn bad_shapes_degeneracy_and_limits() {
180
+ let mut p = flat(3, 3);
181
+ p.anchors.pop();
182
+ assert!(tessellate(&p, Policy::default(), ComputeLimits::default()).is_err());
183
+ p = flat(3, 3);
184
+ p.anchors.fill([0.; 3]);
185
+ assert!(tessellate(&p, Policy::default(), ComputeLimits::default()).is_err());
186
+ p = flat(3, 3);
187
+ for l in [
188
+ ComputeLimits {
189
+ max_vertices: 8,
190
+ ..Default::default()
191
+ },
192
+ ComputeLimits {
193
+ max_work: 1,
194
+ ..Default::default()
195
+ },
196
+ ComputeLimits {
197
+ max_owned_bytes: 1,
198
+ ..Default::default()
199
+ },
200
+ ] {
201
+ assert!(tessellate(&p, Policy::default(), l).is_err());
202
+ }
203
+ assert!(sample(&p, 1, 0, 0., 0., Policy::default()).is_err());
204
+ p.quads_u = u32::MAX;
205
+ p.quads_v = u32::MAX;
206
+ assert!(tessellate(&p, Policy::default(), ComputeLimits::default()).is_err());
207
+ }
208
+ }
docs/GEOMETRY.md CHANGED
@@ -11,3 +11,9 @@ Tests cover signed basis orientation, asymmetric three-plane intersections, para
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.
 
 
 
 
 
 
 
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.
14
+
15
+ ## Patch9 tessellation
16
+
17
+ `patch::sample` evaluates a quadratic 3×3 tile and analytic derivatives; `tessellate` spans odd anchor grids with overlapping tiles every two anchors, U varying fastest. The evaluator uses the tensor product of three-control-point quadratic curves ([W3C quadratic curve definition](https://www.w3.org/TR/SVG/paths.html#PathDataQuadraticBezierCommands)); this tensor-product choice is native policy, not recovered host implementation. Basis is `[(1-t)^2,2t(1-t),t²]`; derivatives are `[-2(1-t),2-4t,2t]`.
18
+
19
+ Anchor dimensions must be odd and at least three, and quads per tile at least two, matching source API declarations. Normals use `dv×du` to agree with exported OBJ quad winding. At an interior tile boundary, the right/upper tile supplies the derivative; positional seams are shared but normals are not averaged. Degenerate tangents/normals fail rather than inventing a direction. Sample count/products, work (nine control evaluations per sample) and requested point/normal allocations are bounded. Flat XZ patches, endpoints, exact quadratic midpoints, multi-tile seams and invalid/budget cases pass. Folded/self-intersecting surfaces and legacy seam-normal parity remain open.
docs/PROGRESS_2026-10-26.md CHANGED
@@ -10,7 +10,7 @@ Scope: extend native headless formats and geometry. Each stage is independently
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 |
16
  | 10 | P5.20 | Property table edits/reindexing | Pending |
 
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 | Complete: tiled tensor-quadratic positions, analytic derivatives, OBJ-consistent normals, shared-position seam policy and bounded allocations/work. Planar/curved/multi-tile/degenerate/limit tests pass. |
14
  | 8 | P5.18 | Computed world pipeline/CLI | Pending |
15
  | 9 | P5.19 | Nearest-hit ray queries | Pending |
16
  | 10 | P5.20 | Property table edits/reindexing | Pending |
docs/TODO.md CHANGED
@@ -130,6 +130,8 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
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.
 
130
 
131
  - [x] P5.16 Implement bounded native convex brush contour reconstruction, topology checks and analytic regressions ([GEOMETRY.md](GEOMETRY.md)).
132
 
133
+ - [x] P5.17 Implement bounded native Patch9 tensor-quadratic sampling/tessellation and analytic normals ([GEOMETRY.md](GEOMETRY.md)).
134
+
135
  ## P6 — File formats and converters
136
 
137
  **Depends on:** P3 oracle where available, P5 document model. **Exit:** standalone format specifications, declared compatibility subsets, fixture corpus, and native codecs.