nmarshall commited on
Commit
75a77d0
·
1 Parent(s): 49ec522

geometry: analytic primitives

Browse files
Cargo.lock CHANGED
@@ -29,3 +29,10 @@ dependencies = [
29
  "mb3-obj",
30
  "mb3-txt",
31
  ]
 
 
 
 
 
 
 
 
29
  "mb3-obj",
30
  "mb3-txt",
31
  ]
32
+
33
+ [[package]]
34
+ name = "nanobrush-geometry"
35
+ version = "0.1.0"
36
+ dependencies = [
37
+ "mb3-dat",
38
+ ]
Cargo.toml CHANGED
@@ -1,5 +1,5 @@
1
  [workspace]
2
- members = ["crates/mb3-dat", "crates/mb3-txt", "crates/nanobrush-cli", "crates/mb3-obj"]
3
  resolver = "3"
4
 
5
  [workspace.package]
 
1
  [workspace]
2
+ members = ["crates/mb3-dat", "crates/mb3-txt", "crates/nanobrush-cli", "crates/mb3-obj", "crates/nanobrush-geometry"]
3
  resolver = "3"
4
 
5
  [workspace.package]
crates/nanobrush-geometry/Cargo.toml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "nanobrush-geometry"
3
+ version = "0.1.0"
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ publish = false
7
+ [dependencies]
8
+ mb3-dat = { path = "../mb3-dat" }
9
+ [lints]
10
+ workspace = true
crates/nanobrush-geometry/src/lib.rs ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Native analytic geometry policy; original-editor numerical parity is unverified.
2
+ pub mod math;
3
+ pub use math::{Aabb, DVec3, Equation, Policy};
4
+ use std::fmt;
5
+ #[derive(Clone, Debug, PartialEq, Eq)]
6
+ pub enum Error {
7
+ NonFinite,
8
+ Degenerate,
9
+ InvalidPolicy,
10
+ InvalidDimensions,
11
+ InvalidBrush,
12
+ Limit,
13
+ Allocation,
14
+ Overflow,
15
+ FloatRange,
16
+ InconsistentData,
17
+ }
18
+ impl fmt::Display for Error {
19
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20
+ write!(f, "geometry: {self:?}")
21
+ }
22
+ }
23
+ impl std::error::Error for Error {}
24
+ pub type Result<T> = std::result::Result<T, Error>;
25
+ pub(crate) fn alloc<T>(n: usize) -> Result<Vec<T>> {
26
+ let mut out = Vec::new();
27
+ out.try_reserve_exact(n).map_err(|_| Error::Allocation)?;
28
+ Ok(out)
29
+ }
crates/nanobrush-geometry/src/math.rs ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::{Error, Result};
2
+ pub type DVec3 = [f64; 3];
3
+ #[derive(Clone, Copy, Debug)]
4
+ pub struct Policy {
5
+ pub distance_epsilon: f64,
6
+ pub angular_epsilon: f64,
7
+ }
8
+ impl Default for Policy {
9
+ fn default() -> Self {
10
+ Self {
11
+ distance_epsilon: 1e-6,
12
+ angular_epsilon: 1e-10,
13
+ }
14
+ }
15
+ }
16
+ impl Policy {
17
+ pub fn validate(self) -> Result<Self> {
18
+ if !self.distance_epsilon.is_finite()
19
+ || self.distance_epsilon <= 0.
20
+ || !self.angular_epsilon.is_finite()
21
+ || self.angular_epsilon <= 0.
22
+ || self.angular_epsilon >= 1.
23
+ {
24
+ Err(Error::InvalidPolicy)
25
+ } else {
26
+ Ok(self)
27
+ }
28
+ }
29
+ }
30
+ pub fn add(a: DVec3, b: DVec3) -> DVec3 {
31
+ std::array::from_fn(|i| a[i] + b[i])
32
+ }
33
+ pub fn sub(a: DVec3, b: DVec3) -> DVec3 {
34
+ std::array::from_fn(|i| a[i] - b[i])
35
+ }
36
+ pub fn scale(a: DVec3, b: f64) -> DVec3 {
37
+ a.map(|x| x * b)
38
+ }
39
+ pub fn dot(a: DVec3, b: DVec3) -> f64 {
40
+ a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
41
+ }
42
+ pub fn cross(a: DVec3, b: DVec3) -> DVec3 {
43
+ [
44
+ a[1] * b[2] - a[2] * b[1],
45
+ a[2] * b[0] - a[0] * b[2],
46
+ a[0] * b[1] - a[1] * b[0],
47
+ ]
48
+ }
49
+ pub fn finite(v: DVec3) -> Result<DVec3> {
50
+ if v.iter().all(|x| x.is_finite()) {
51
+ Ok(v)
52
+ } else {
53
+ Err(Error::NonFinite)
54
+ }
55
+ }
56
+ pub fn length(v: DVec3) -> f64 {
57
+ v[0].hypot(v[1]).hypot(v[2])
58
+ }
59
+ pub fn unit(v: DVec3) -> Result<DVec3> {
60
+ finite(v)?;
61
+ let n = length(v);
62
+ if !n.is_finite() {
63
+ return Err(Error::NonFinite);
64
+ }
65
+ if n == 0. {
66
+ return Err(Error::Degenerate);
67
+ }
68
+ finite(scale(v, 1. / n))
69
+ }
70
+ pub fn from_f32(v: mb3_dat::Vec3) -> Result<DVec3> {
71
+ finite(v.map(f64::from))
72
+ }
73
+ pub fn to_f32(v: DVec3) -> Result<mb3_dat::Vec3> {
74
+ finite(v)?;
75
+ let out = v.map(|n| n as f32);
76
+ if out.iter().all(|n| n.is_finite()) {
77
+ Ok(out)
78
+ } else {
79
+ Err(Error::FloatRange)
80
+ }
81
+ }
82
+ pub fn map_to_native(v: DVec3) -> DVec3 {
83
+ [v[0], v[2], -v[1]]
84
+ }
85
+ pub fn native_to_map(v: DVec3) -> DVec3 {
86
+ [v[0], -v[2], v[1]]
87
+ }
88
+ #[derive(Clone, Copy, Debug)]
89
+ pub struct Equation {
90
+ normal: DVec3,
91
+ d: f64,
92
+ }
93
+ impl Equation {
94
+ pub fn new(normal: DVec3, d: f64) -> Result<Self> {
95
+ let n = length(finite(normal)?);
96
+ if n == 0. {
97
+ return Err(Error::Degenerate);
98
+ }
99
+ if !n.is_finite() || !d.is_finite() {
100
+ return Err(Error::NonFinite);
101
+ }
102
+ let out = Self {
103
+ normal: scale(normal, 1. / n),
104
+ d: d / n,
105
+ };
106
+ finite(out.normal)?;
107
+ if !out.d.is_finite() {
108
+ return Err(Error::NonFinite);
109
+ }
110
+ Ok(out)
111
+ }
112
+ pub fn from_basis(p: &mb3_dat::Plane, policy: Policy) -> Result<Self> {
113
+ let policy = policy.validate()?;
114
+ let origin = from_f32(p.p0)?;
115
+ let n = cross(unit(from_f32(p.u)?)?, unit(from_f32(p.v)?)?);
116
+ if length(n) <= policy.angular_epsilon {
117
+ return Err(Error::Degenerate);
118
+ }
119
+ let n = unit(n)?;
120
+ Self::new(n, dot(n, origin))
121
+ }
122
+ pub fn normal(self) -> DVec3 {
123
+ self.normal
124
+ }
125
+ pub fn offset(self) -> f64 {
126
+ self.d
127
+ }
128
+ pub fn distance(self, p: DVec3) -> Result<f64> {
129
+ let d = dot(self.normal, finite(p)?) - self.d;
130
+ if d.is_finite() {
131
+ Ok(d)
132
+ } else {
133
+ Err(Error::NonFinite)
134
+ }
135
+ }
136
+ pub fn intersection(a: Self, b: Self, c: Self, policy: Policy) -> Result<Option<DVec3>> {
137
+ let policy = policy.validate()?;
138
+ let det = dot(a.normal, cross(b.normal, c.normal));
139
+ if det.abs() <= policy.angular_epsilon {
140
+ return Ok(None);
141
+ }
142
+ finite(scale(
143
+ add(
144
+ add(
145
+ scale(cross(b.normal, c.normal), a.d),
146
+ scale(cross(c.normal, a.normal), b.d),
147
+ ),
148
+ scale(cross(a.normal, b.normal), c.d),
149
+ ),
150
+ 1. / det,
151
+ ))
152
+ .map(Some)
153
+ }
154
+ }
155
+ #[derive(Clone, Copy, Debug, PartialEq)]
156
+ pub struct Aabb {
157
+ pub min: DVec3,
158
+ pub max: DVec3,
159
+ }
160
+ impl Aabb {
161
+ pub fn new(min: DVec3, max: DVec3) -> Result<Self> {
162
+ finite(min)?;
163
+ finite(max)?;
164
+ if (0..3).any(|i| min[i] > max[i]) {
165
+ return Err(Error::InvalidDimensions);
166
+ }
167
+ Ok(Self { min, max })
168
+ }
169
+ pub fn from_points(points: impl IntoIterator<Item = DVec3>) -> Result<Option<Self>> {
170
+ let mut out: Option<Self> = None;
171
+ for p in points {
172
+ finite(p)?;
173
+ if let Some(b) = &mut out {
174
+ for (i, value) in p.iter().enumerate() {
175
+ b.min[i] = b.min[i].min(*value);
176
+ b.max[i] = b.max[i].max(*value);
177
+ }
178
+ } else {
179
+ out = Some(Self { min: p, max: p });
180
+ }
181
+ }
182
+ Ok(out)
183
+ }
184
+ pub fn contains(self, p: DVec3, policy: Policy) -> Result<bool> {
185
+ let e = policy.validate()?.distance_epsilon;
186
+ Self::new(self.min, self.max)?;
187
+ finite(p)?;
188
+ Ok((0..3).all(|i| p[i] >= self.min[i] - e && p[i] <= self.max[i] + e))
189
+ }
190
+ }
191
+ #[cfg(test)]
192
+ mod tests {
193
+ use super::*;
194
+ #[test]
195
+ fn basis_orientation_and_intersections() {
196
+ let p = mb3_dat::Plane {
197
+ p0: [2., 0., 0.],
198
+ u: [0., 1., 0.],
199
+ v: [0., 0., 1.],
200
+ ..Default::default()
201
+ };
202
+ let a = Equation::from_basis(&p, Policy::default()).unwrap();
203
+ assert_eq!(a.normal(), [1., 0., 0.]);
204
+ assert_eq!(a.distance([3., 0., 0.]).unwrap(), 1.);
205
+ let b = Equation::new([0., 1., 0.], 3.).unwrap();
206
+ let c = Equation::new([0., 0., 1.], 4.).unwrap();
207
+ assert_eq!(
208
+ Equation::intersection(a, b, c, Policy::default()).unwrap(),
209
+ Some([2., 3., 4.])
210
+ );
211
+ assert!(
212
+ Equation::intersection(a, a, c, Policy::default())
213
+ .unwrap()
214
+ .is_none()
215
+ );
216
+ }
217
+ #[test]
218
+ fn boxes_axes_and_empty() {
219
+ assert_eq!(Aabb::from_points([]).unwrap(), None);
220
+ let b = Aabb::from_points([[-3., 2., 1.], [4., -1., 8.]])
221
+ .unwrap()
222
+ .unwrap();
223
+ assert_eq!(b.min, [-3., -1., 1.]);
224
+ assert!(b.contains([0., 0., 2.], Policy::default()).unwrap());
225
+ let v = [1., 2., 3.];
226
+ assert_eq!(native_to_map(map_to_native(v)), v);
227
+ }
228
+ #[test]
229
+ fn invalid_and_scaled_bases() {
230
+ assert!(unit([0.; 3]).is_err());
231
+ assert!(Equation::new([f64::NAN, 0., 0.], 1.).is_err());
232
+ assert!(to_f32([f64::MAX, 0., 0.]).is_err());
233
+ assert!(
234
+ Policy {
235
+ distance_epsilon: 0.,
236
+ ..Policy::default()
237
+ }
238
+ .validate()
239
+ .is_err()
240
+ );
241
+ let p = mb3_dat::Plane {
242
+ u: [0., 1e-20, 0.],
243
+ v: [0., 0., 1e20],
244
+ ..Default::default()
245
+ };
246
+ assert_eq!(
247
+ Equation::from_basis(&p, Policy::default())
248
+ .unwrap()
249
+ .normal(),
250
+ [1., 0., 0.]
251
+ );
252
+ }
253
+ }
docs/GEOMETRY.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Native geometry contract
2
+
3
+ This is an independently authored native policy, not verified editor parity. The supplied `GeometrySolver.ixpl` uses `U×V` for signed plane distance; it does not prove which side the editor regards as interior. We adopt outward normals and interior `n·x <= d + epsilon`. Computation uses f64; persisted results must remain finite f32. Unit normals are formed from normalized U/V to avoid scale-dependent basis degeneracy.
4
+
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.
docs/PROGRESS_2026-10-26.md CHANGED
@@ -8,7 +8,7 @@ Scope: extend native headless formats and geometry. Each stage is independently
8
  | 2 | P6.24 | Explicit Latin-1 TXT input | Complete: ISO-8859-1 API and `--text-encoding latin1`, no guessing, conflicting UTF-8 BOM rejected; transcoded bytes and scratch ownership are bounded. UTF-8 remains default and output. Encoding and CLI unit suites pass. |
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 | Pending |
12
  | 6 | P5.16 | Convex brush contours | Pending |
13
  | 7 | P5.17 | Patch9 tessellation | Pending |
14
  | 8 | P5.18 | Computed world pipeline/CLI | Pending |
 
8
  | 2 | P6.24 | Explicit Latin-1 TXT input | Complete: ISO-8859-1 API and `--text-encoding latin1`, no guessing, conflicting UTF-8 BOM rejected; transcoded bytes and scratch ownership are bounded. UTF-8 remains default and output. Encoding and CLI unit suites pass. |
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 | Pending |
13
  | 7 | P5.17 | Patch9 tessellation | Pending |
14
  | 8 | P5.18 | Computed world pipeline/CLI | Pending |
docs/TODO.md CHANGED
@@ -126,6 +126,8 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
126
  - [ ] P5.13 Build analytic cases: boxes, wedges, slivers, coplanar/parallel planes, huge/tiny coordinates, reflected solids, tangent rays, patch seams, invalid brushes, and overlapping selections.
127
  - [ ] P5.14 Implement and validate a headless Rust geometry kernel with property/metamorphic tests, differential fixtures, deterministic topology, and bounded failure behavior.
128
 
 
 
129
  ## P6 — File formats and converters
130
 
131
  **Depends on:** P3 oracle where available, P5 document model. **Exit:** standalone format specifications, declared compatibility subsets, fixture corpus, and native codecs.
 
126
  - [ ] P5.13 Build analytic cases: boxes, wedges, slivers, coplanar/parallel planes, huge/tiny coordinates, reflected solids, tangent rays, patch seams, invalid brushes, and overlapping selections.
127
  - [ ] P5.14 Implement and validate a headless Rust geometry kernel with property/metamorphic tests, differential fixtures, deterministic topology, and bounded failure behavior.
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.