nmarshall commited on
Commit
72b1843
·
1 Parent(s): ab47e29

geometry: nearest ray hits

Browse files
crates/nanobrush-geometry/src/lib.rs CHANGED
@@ -103,4 +103,5 @@ impl Budget {
103
  }
104
  }
105
  pub mod patch;
 
106
  pub mod world;
 
103
  }
104
  }
105
  pub mod patch;
106
+ pub mod ray;
107
  pub mod world;
crates/nanobrush-geometry/src/ray.rs ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::{math::*, *};
2
+ #[derive(Clone, Copy, Debug)]
3
+ pub struct Ray {
4
+ origin: DVec3,
5
+ direction: DVec3,
6
+ }
7
+ impl Ray {
8
+ pub fn new(origin: DVec3, direction: DVec3) -> Result<Self> {
9
+ Ok(Self {
10
+ origin: finite(origin)?,
11
+ direction: unit(direction)?,
12
+ })
13
+ }
14
+ pub fn at(self, t: f64) -> Result<DVec3> {
15
+ if !t.is_finite() {
16
+ return Err(Error::NonFinite);
17
+ }
18
+ finite(add(self.origin, scale(self.direction, t)))
19
+ }
20
+ }
21
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
22
+ pub enum Target {
23
+ Brush {
24
+ brush: usize,
25
+ plane: usize,
26
+ },
27
+ Patch {
28
+ patch: usize,
29
+ quad: usize,
30
+ triangle: usize,
31
+ },
32
+ }
33
+ #[derive(Clone, Copy, Debug)]
34
+ pub struct Hit {
35
+ pub distance: f64,
36
+ pub point: DVec3,
37
+ pub normal: DVec3,
38
+ pub target: Target,
39
+ }
40
+ fn triangle(
41
+ ray: Ray,
42
+ a: DVec3,
43
+ b: DVec3,
44
+ c: DVec3,
45
+ max: f64,
46
+ p: Policy,
47
+ ) -> Result<Option<(f64, DVec3)>> {
48
+ finite(a)?;
49
+ finite(b)?;
50
+ finite(c)?;
51
+ let e1 = finite(sub(b, a))?;
52
+ let e2 = finite(sub(c, a))?;
53
+ let h = finite(cross(ray.direction, e2))?;
54
+ let det = dot(e1, h);
55
+ let area_scale = length(e1) * length(e2);
56
+ if !det.is_finite() || !area_scale.is_finite() {
57
+ return Err(Error::NonFinite);
58
+ }
59
+ if det.abs() <= p.angular_epsilon * area_scale {
60
+ return Ok(None);
61
+ }
62
+ let s = sub(ray.origin, a);
63
+ let u = dot(s, h) / det;
64
+ let q = finite(cross(s, e1))?;
65
+ let v = dot(ray.direction, q) / det;
66
+ let t = dot(e2, q) / det;
67
+ if !u.is_finite() || !v.is_finite() || !t.is_finite() {
68
+ return Err(Error::NonFinite);
69
+ }
70
+ if u < -p.angular_epsilon
71
+ || v < -p.angular_epsilon
72
+ || u + v > 1. + p.angular_epsilon
73
+ || t < -p.distance_epsilon
74
+ || t > max
75
+ {
76
+ return Ok(None);
77
+ }
78
+ Ok(Some((t.max(0.), unit(cross(e1, e2))?)))
79
+ }
80
+ struct Query {
81
+ ray: Ray,
82
+ max: f64,
83
+ policy: Policy,
84
+ budget: Budget,
85
+ hit: Option<Hit>,
86
+ vertices: usize,
87
+ }
88
+ impl Query {
89
+ fn vertices(&mut self, n: usize) -> Result<()> {
90
+ self.vertices = self.vertices.checked_add(n).ok_or(Error::Overflow)?;
91
+ if self.vertices > self.budget.limits.max_vertices {
92
+ Err(Error::Limit)
93
+ } else {
94
+ Ok(())
95
+ }
96
+ }
97
+ fn test(&mut self, v: [DVec3; 3], target: Target) -> Result<()> {
98
+ self.budget.work(1)?;
99
+ if let Some((distance, normal)) =
100
+ triangle(self.ray, v[0], v[1], v[2], self.max, self.policy)?
101
+ {
102
+ let replace = self.hit.is_none_or(|old| {
103
+ distance < old.distance - self.policy.distance_epsilon
104
+ || ((distance - old.distance).abs() <= self.policy.distance_epsilon
105
+ && target < old.target)
106
+ });
107
+ if replace {
108
+ self.hit = Some(Hit {
109
+ distance,
110
+ point: self.ray.at(distance)?,
111
+ normal,
112
+ target,
113
+ });
114
+ }
115
+ }
116
+ Ok(())
117
+ }
118
+ }
119
+ /// Linear double-sided nearest surface query over explicit computed geometry.
120
+ /// No selection/visibility state exists in this model. Ray directions are unit,
121
+ /// so returned parameters are world distances rather than caller vector scales.
122
+ pub fn nearest(
123
+ world: &mb3_dat::World,
124
+ ray: Ray,
125
+ max_distance: f64,
126
+ policy: Policy,
127
+ limits: ComputeLimits,
128
+ ) -> Result<Option<Hit>> {
129
+ let policy = policy.validate()?;
130
+ if !max_distance.is_finite() || max_distance < 0. {
131
+ return Err(Error::InvalidDimensions);
132
+ }
133
+ if !world.computed {
134
+ return Err(Error::InconsistentData);
135
+ }
136
+ let mut q = Query {
137
+ ray,
138
+ max: max_distance,
139
+ policy,
140
+ budget: Budget {
141
+ limits,
142
+ stats: Stats::default(),
143
+ },
144
+ hit: None,
145
+ vertices: 0,
146
+ };
147
+ for (bi, b) in world.brushes.iter().enumerate() {
148
+ q.budget.work(1)?;
149
+ for (pi, p) in b.planes.iter().enumerate() {
150
+ q.budget.work(1)?;
151
+ let vertices = p.polygon.as_ref().ok_or(Error::InconsistentData)?;
152
+ if vertices.len() < 3 {
153
+ return Err(Error::InvalidBrush);
154
+ }
155
+ q.vertices(vertices.len())?;
156
+ for i in 1..vertices.len() - 1 {
157
+ q.test(
158
+ [
159
+ from_f32(vertices[0])?,
160
+ from_f32(vertices[i])?,
161
+ from_f32(vertices[i + 1])?,
162
+ ],
163
+ Target::Brush {
164
+ brush: bi,
165
+ plane: pi,
166
+ },
167
+ )?;
168
+ }
169
+ }
170
+ }
171
+ for (pi, p) in world.patches.iter().enumerate() {
172
+ q.budget.work(1)?;
173
+ let data = p.computed.as_ref().ok_or(Error::InconsistentData)?;
174
+ let u = (p.anchors_u as usize / 2)
175
+ .checked_mul(p.quads_u as usize)
176
+ .and_then(|n| n.checked_add(1))
177
+ .ok_or(Error::Overflow)?;
178
+ let v = (p.anchors_v as usize / 2)
179
+ .checked_mul(p.quads_v as usize)
180
+ .and_then(|n| n.checked_add(1))
181
+ .ok_or(Error::Overflow)?;
182
+ let n = u.checked_mul(v).ok_or(Error::Overflow)?;
183
+ if data.points.len() != n || data.normals.len() != n {
184
+ return Err(Error::InconsistentData);
185
+ }
186
+ q.vertices(n)?;
187
+ for point in &data.points {
188
+ q.budget.work(1)?;
189
+ from_f32(*point)?;
190
+ }
191
+ for y in 0..v - 1 {
192
+ for x in 0..u - 1 {
193
+ let i = x + y * u;
194
+ let quad = x + y * (u - 1);
195
+ for (t, ids) in [[i, i + u, i + u + 1], [i, i + u + 1, i + 1]]
196
+ .into_iter()
197
+ .enumerate()
198
+ {
199
+ q.test(
200
+ [
201
+ from_f32(data.points[ids[0]])?,
202
+ from_f32(data.points[ids[1]])?,
203
+ from_f32(data.points[ids[2]])?,
204
+ ],
205
+ Target::Patch {
206
+ patch: pi,
207
+ quad,
208
+ triangle: t,
209
+ },
210
+ )?;
211
+ }
212
+ }
213
+ }
214
+ }
215
+ Ok(q.hit)
216
+ }
217
+ #[cfg(test)]
218
+ mod tests {
219
+ use super::*;
220
+ use mb3_dat::{Limits, World};
221
+ #[test]
222
+ fn cube_nearest_inside_miss_and_stable_ties() {
223
+ let source = World {
224
+ brushes: vec![crate::brush::cube(), crate::brush::cube()],
225
+ ..Default::default()
226
+ };
227
+ let (w, _) = crate::world::compute(
228
+ &source,
229
+ Policy::default(),
230
+ ComputeLimits::default(),
231
+ Limits::default(),
232
+ )
233
+ .unwrap();
234
+ let ray = Ray::new([0., 0., 3.], [0., 0., -2.]).unwrap();
235
+ let hit = nearest(&w, ray, 10., Policy::default(), ComputeLimits::default())
236
+ .unwrap()
237
+ .unwrap();
238
+ assert_eq!(hit.distance, 2.);
239
+ assert_eq!(hit.target, Target::Brush { brush: 0, plane: 5 });
240
+ assert_eq!(hit.normal, [0., 0., 1.]);
241
+ assert!(
242
+ nearest(&w, ray, 1., Policy::default(), ComputeLimits::default())
243
+ .unwrap()
244
+ .is_none()
245
+ );
246
+ let inside = nearest(
247
+ &w,
248
+ Ray::new([0.; 3], [1., 0., 0.]).unwrap(),
249
+ 10.,
250
+ Policy::default(),
251
+ ComputeLimits::default(),
252
+ )
253
+ .unwrap()
254
+ .unwrap();
255
+ assert_eq!(inside.distance, 1.);
256
+ assert!(
257
+ nearest(
258
+ &w,
259
+ Ray::new([0., 0., 3.], [0., 0., 1.]).unwrap(),
260
+ 10.,
261
+ Policy::default(),
262
+ ComputeLimits::default()
263
+ )
264
+ .unwrap()
265
+ .is_none()
266
+ );
267
+ }
268
+ #[test]
269
+ fn patch_hit_backfaces_and_budget() {
270
+ let source = World {
271
+ patches: vec![crate::patch::flat(3, 3)],
272
+ ..Default::default()
273
+ };
274
+ let (w, _) = crate::world::compute(
275
+ &source,
276
+ Policy::default(),
277
+ ComputeLimits::default(),
278
+ Limits::default(),
279
+ )
280
+ .unwrap();
281
+ for (origin, dir) in [
282
+ ([0.5, 3., 0.5], [0., -1., 0.]),
283
+ ([0.5, -3., 0.5], [0., 1., 0.]),
284
+ ] {
285
+ let ray = Ray::new(origin, dir).unwrap();
286
+ let hit = nearest(&w, ray, 10., Policy::default(), ComputeLimits::default())
287
+ .unwrap()
288
+ .unwrap();
289
+ assert_eq!(hit.distance, 3.);
290
+ assert_eq!(hit.normal, [0., 1., 0.]);
291
+ assert!(matches!(hit.target, Target::Patch { patch: 0, .. }));
292
+ assert!(
293
+ nearest(
294
+ &w,
295
+ ray,
296
+ 10.,
297
+ Policy::default(),
298
+ ComputeLimits {
299
+ max_work: 1,
300
+ ..Default::default()
301
+ }
302
+ )
303
+ .is_err()
304
+ );
305
+ }
306
+ assert!(Ray::new([0.; 3], [0.; 3]).is_err());
307
+ assert!(
308
+ nearest(
309
+ &w,
310
+ Ray::new([0.; 3], [1., 0., 0.]).unwrap(),
311
+ f64::INFINITY,
312
+ Policy::default(),
313
+ ComputeLimits::default()
314
+ )
315
+ .is_err()
316
+ );
317
+ }
318
+ }
docs/GEOMETRY.md CHANGED
@@ -23,3 +23,9 @@ Anchor dimensions must be odd and at least three, and quads per tile at least tw
23
  `world::compute` returns a new world, preserving source tables, slots, bases and anchors. It fallibly copies editable data, rebuilds every brush/patch, checks f64→f32 range, and sets computed=true only after all objects succeed. Prior derived arrays are intentionally replaced. Properties are copied unchanged, not normalized; codec-specific validation still happens at save. No source mutation or partial world escapes on a late error.
24
 
25
  Work and generated-position budgets apply across the whole world. Requested copy/geometry/output allocations are charged cumulatively (including scratch already freed, conservatively); model element/string limits also apply. CLI `convert/preflight --compute` opts into this native policy before target analysis/encoding. Defaults cap 64 planes per brush, 100,000 generated positions, 10 million work units, and the lower of codec/geometry storage limits. Empty worlds compute successfully; malformed/redundant/open brushes and degenerate patches fail instead of being dropped. Mixed cube/patch, property preservation, DAT round trip, late failure, global-budget and CLI cube-to-OBJ tests pass.
 
 
 
 
 
 
 
23
  `world::compute` returns a new world, preserving source tables, slots, bases and anchors. It fallibly copies editable data, rebuilds every brush/patch, checks f64→f32 range, and sets computed=true only after all objects succeed. Prior derived arrays are intentionally replaced. Properties are copied unchanged, not normalized; codec-specific validation still happens at save. No source mutation or partial world escapes on a late error.
24
 
25
  Work and generated-position budgets apply across the whole world. Requested copy/geometry/output allocations are charged cumulatively (including scratch already freed, conservatively); model element/string limits also apply. CLI `convert/preflight --compute` opts into this native policy before target analysis/encoding. Defaults cap 64 planes per brush, 100,000 generated positions, 10 million work units, and the lower of codec/geometry storage limits. Empty worlds compute successfully; malformed/redundant/open brushes and degenerate patches fail instead of being dropped. Mixed cube/patch, property preservation, DAT round trip, late failure, global-budget and CLI cube-to-OBJ tests pass.
26
+
27
+ ## Nearest-hit queries
28
+
29
+ `ray::nearest` scans explicit computed brush polygons (convex fans) and patch quads split along the first-to-third diagonal. Queries are double-sided. Ray directions are normalized, so hit parameters are world distances. Negative distances within distance epsilon clamp to zero; larger negative hits and hits beyond finite max distance are excluded. Parallel/degenerate triangles produce no hit; non-finite geometry and malformed arrays fail.
30
+
31
+ Ties within distance epsilon use stable target order: brush before patch, then ascending object/plane or quad/triangle indices. Returned normals follow polygon/quad winding and do not flip toward the ray. Work/vertex caps apply to the whole linear query, including parents and patch point scans. No acceleration, visibility, selection, near-clip or callback semantics are implied; unused properties/stored normals are not revalidated. Tests cover nearest/inside/miss/max-distance, duplicate-object ties, front/back patch hits, direction normalization and invalid/budget cases.
docs/PROGRESS_2026-10-26.md CHANGED
@@ -12,5 +12,5 @@ Scope: extend native headless formats and geometry. Each stage is independently
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 | Complete: fallible editable-data copying, aggregate work/storage/position budgets, complete-or-error derived data and explicit `--compute`. Mixed-world/property/DAT/late-failure/global-limit and CLI cube→OBJ tests pass. |
15
- | 9 | P5.19 | Nearest-hit ray queries | Pending |
16
  | 10 | P5.20 | Property table edits/reindexing | Pending |
 
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 | Complete: fallible editable-data copying, aggregate work/storage/position budgets, complete-or-error derived data and explicit `--compute`. Mixed-world/property/DAT/late-failure/global-limit and CLI cube→OBJ tests pass. |
15
+ | 9 | P5.19 | Nearest-hit ray queries | Complete: bounded double-sided computed-surface queries, normalized rays, stable object/face/quad ties and winding normals. Cube/inside/miss/range/tie/patch/backface/budget tests pass. |
16
  | 10 | P5.20 | Property table edits/reindexing | Pending |
docs/TODO.md CHANGED
@@ -134,6 +134,8 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
134
 
135
  - [x] P5.18 Integrate bounded complete-or-error computed worlds and explicit CLI geometry generation ([GEOMETRY.md](GEOMETRY.md)).
136
 
 
 
137
  ## P6 — File formats and converters
138
 
139
  **Depends on:** P3 oracle where available, P5 document model. **Exit:** standalone format specifications, declared compatibility subsets, fixture corpus, and native codecs.
 
134
 
135
  - [x] P5.18 Integrate bounded complete-or-error computed worlds and explicit CLI geometry generation ([GEOMETRY.md](GEOMETRY.md)).
136
 
137
+ - [x] P5.19 Implement bounded deterministic native nearest-hit ray queries over computed brush/patch surfaces ([GEOMETRY.md](GEOMETRY.md)).
138
+
139
  ## P6 — File formats and converters
140
 
141
  **Depends on:** P3 oracle where available, P5 document model. **Exit:** standalone format specifications, declared compatibility subsets, fixture corpus, and native codecs.