geometry: compute worlds
Browse files- Cargo.lock +1 -0
- crates/nanobrush-cli/Cargo.toml +2 -0
- crates/nanobrush-cli/src/lib.rs +22 -2
- crates/nanobrush-cli/src/tests.rs +34 -0
- crates/nanobrush-geometry/src/lib.rs +1 -0
- crates/nanobrush-geometry/src/world.rs +268 -0
- docs/CLI.md +2 -0
- docs/GEOMETRY.md +6 -0
- docs/PROGRESS_2026-10-26.md +1 -1
- docs/TODO.md +2 -0
Cargo.lock
CHANGED
|
@@ -28,6 +28,7 @@ dependencies = [
|
|
| 28 |
"mb3-dat",
|
| 29 |
"mb3-obj",
|
| 30 |
"mb3-txt",
|
|
|
|
| 31 |
]
|
| 32 |
|
| 33 |
[[package]]
|
|
|
|
| 28 |
"mb3-dat",
|
| 29 |
"mb3-obj",
|
| 30 |
"mb3-txt",
|
| 31 |
+
"nanobrush-geometry",
|
| 32 |
]
|
| 33 |
|
| 34 |
[[package]]
|
crates/nanobrush-cli/Cargo.toml
CHANGED
|
@@ -16,5 +16,7 @@ mb3-txt = { path = "../mb3-txt" }
|
|
| 16 |
|
| 17 |
mb3-obj = { path = "../mb3-obj" }
|
| 18 |
|
|
|
|
|
|
|
| 19 |
[lints]
|
| 20 |
workspace = true
|
|
|
|
| 16 |
|
| 17 |
mb3-obj = { path = "../mb3-obj" }
|
| 18 |
|
| 19 |
+
nanobrush-geometry = { path = "../nanobrush-geometry" }
|
| 20 |
+
|
| 21 |
[lints]
|
| 22 |
workspace = true
|
crates/nanobrush-cli/src/lib.rs
CHANGED
|
@@ -8,7 +8,7 @@ use std::io::{self, BufRead, BufReader, Write};
|
|
| 8 |
use std::path::{Path, PathBuf};
|
| 9 |
use std::sync::atomic::{AtomicU64, Ordering};
|
| 10 |
|
| 11 |
-
const HELP: &str = "nanobrush preflight INPUT --to txt|dat-201501280|dat-201512200|obj [LIMITS]\nnanobrush inspect INPUT [--from auto|txt|dat] [LIMITS]\nnanobrush validate INPUT [--from auto|txt|dat] [LIMITS]\nnanobrush convert INPUT OUTPUT --to txt|dat-201501280|dat-201512200|obj [--from auto|txt|dat] [--force] [LIMITS]\nLIMITS: --max-bytes N --max-items N --max-string-bytes N --max-owned-bytes N\nOBJ requires --allow-lossy when editable data is discarded.\nTXT option: --text-encoding utf8|latin1 (default utf8).\nUse -- before positional paths beginning with a dash. TXT input is UTF-8; output has a BOM.\nExit: 0 success/help, 1 invalid data or I/O failure, 2 usage error.\n";
|
| 12 |
#[derive(Debug)]
|
| 13 |
pub struct Error {
|
| 14 |
pub usage: bool,
|
|
@@ -57,6 +57,7 @@ struct Options {
|
|
| 57 |
to: Option<To>,
|
| 58 |
force: bool,
|
| 59 |
allow_lossy: bool,
|
|
|
|
| 60 |
limits: Limits,
|
| 61 |
encoding: mb3_txt::Encoding,
|
| 62 |
}
|
|
@@ -84,6 +85,7 @@ fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Option<Options>> {
|
|
| 84 |
to: None,
|
| 85 |
force: false,
|
| 86 |
allow_lossy: false,
|
|
|
|
| 87 |
limits: Limits::default(),
|
| 88 |
encoding: mb3_txt::Encoding::Utf8,
|
| 89 |
};
|
|
@@ -102,6 +104,10 @@ fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Option<Options>> {
|
|
| 102 |
if !seen.insert(flag.to_owned()) {
|
| 103 |
return Err(usage(format!("duplicate option {flag}")));
|
| 104 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
if flag == "--allow-lossy" {
|
| 106 |
options.allow_lossy = true;
|
| 107 |
continue;
|
|
@@ -191,6 +197,9 @@ fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Option<Options>> {
|
|
| 191 |
{
|
| 192 |
return Err(usage("--allow-lossy requires convert --to obj"));
|
| 193 |
}
|
|
|
|
|
|
|
|
|
|
| 194 |
Ok(Some(options))
|
| 195 |
}
|
| 196 |
struct Input {
|
|
@@ -325,7 +334,18 @@ pub fn run(args: impl IntoIterator<Item = OsString>, stdout: &mut dyn Write) ->
|
|
| 325 |
stdout.write_all(HELP.as_bytes())?;
|
| 326 |
return Ok(());
|
| 327 |
};
|
| 328 |
-
let input = load(&o.paths[0], o.from, o.encoding, &o.limits)?;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
match o.command.as_str() {
|
| 330 |
"preflight" => {
|
| 331 |
let target = match o.to.ok_or_else(|| usage("missing --to"))? {
|
|
|
|
| 8 |
use std::path::{Path, PathBuf};
|
| 9 |
use std::sync::atomic::{AtomicU64, Ordering};
|
| 10 |
|
| 11 |
+
const HELP: &str = "nanobrush preflight INPUT --to txt|dat-201501280|dat-201512200|obj [LIMITS]\nnanobrush inspect INPUT [--from auto|txt|dat] [LIMITS]\nnanobrush validate INPUT [--from auto|txt|dat] [LIMITS]\nnanobrush convert INPUT OUTPUT --to txt|dat-201501280|dat-201512200|obj [--from auto|txt|dat] [--force] [LIMITS]\nLIMITS: --max-bytes N --max-items N --max-string-bytes N --max-owned-bytes N\nUse --compute with convert/preflight to regenerate native geometry.\nOBJ requires --allow-lossy when editable data is discarded.\nTXT option: --text-encoding utf8|latin1 (default utf8).\nUse -- before positional paths beginning with a dash. TXT input is UTF-8; output has a BOM.\nExit: 0 success/help, 1 invalid data or I/O failure, 2 usage error.\n";
|
| 12 |
#[derive(Debug)]
|
| 13 |
pub struct Error {
|
| 14 |
pub usage: bool,
|
|
|
|
| 57 |
to: Option<To>,
|
| 58 |
force: bool,
|
| 59 |
allow_lossy: bool,
|
| 60 |
+
compute: bool,
|
| 61 |
limits: Limits,
|
| 62 |
encoding: mb3_txt::Encoding,
|
| 63 |
}
|
|
|
|
| 85 |
to: None,
|
| 86 |
force: false,
|
| 87 |
allow_lossy: false,
|
| 88 |
+
compute: false,
|
| 89 |
limits: Limits::default(),
|
| 90 |
encoding: mb3_txt::Encoding::Utf8,
|
| 91 |
};
|
|
|
|
| 104 |
if !seen.insert(flag.to_owned()) {
|
| 105 |
return Err(usage(format!("duplicate option {flag}")));
|
| 106 |
}
|
| 107 |
+
if flag == "--compute" {
|
| 108 |
+
options.compute = true;
|
| 109 |
+
continue;
|
| 110 |
+
}
|
| 111 |
if flag == "--allow-lossy" {
|
| 112 |
options.allow_lossy = true;
|
| 113 |
continue;
|
|
|
|
| 197 |
{
|
| 198 |
return Err(usage("--allow-lossy requires convert --to obj"));
|
| 199 |
}
|
| 200 |
+
if options.compute && !matches!(options.command.as_str(), "convert" | "preflight") {
|
| 201 |
+
return Err(usage("--compute requires convert or preflight"));
|
| 202 |
+
}
|
| 203 |
Ok(Some(options))
|
| 204 |
}
|
| 205 |
struct Input {
|
|
|
|
| 334 |
stdout.write_all(HELP.as_bytes())?;
|
| 335 |
return Ok(());
|
| 336 |
};
|
| 337 |
+
let mut input = load(&o.paths[0], o.from, o.encoding, &o.limits)?;
|
| 338 |
+
if o.compute {
|
| 339 |
+
let limits = nanobrush_geometry::ComputeLimits {
|
| 340 |
+
max_vertices: o.limits.max_items.min(100_000),
|
| 341 |
+
max_owned_bytes: o.limits.max_owned_bytes.min(128 * 1024 * 1024),
|
| 342 |
+
..Default::default()
|
| 343 |
+
};
|
| 344 |
+
input.world =
|
| 345 |
+
nanobrush_geometry::world::compute(&input.world, Default::default(), limits, o.limits)
|
| 346 |
+
.map_err(|e| fail(e.to_string()))?
|
| 347 |
+
.0;
|
| 348 |
+
}
|
| 349 |
match o.command.as_str() {
|
| 350 |
"preflight" => {
|
| 351 |
let target = match o.to.ok_or_else(|| usage("missing --to"))? {
|
crates/nanobrush-cli/src/tests.rs
CHANGED
|
@@ -231,3 +231,37 @@ fn obj_acknowledgement_and_preflight() {
|
|
| 231 |
.contains("editable_geometry")
|
| 232 |
);
|
| 233 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
.contains("editable_geometry")
|
| 232 |
);
|
| 233 |
}
|
| 234 |
+
|
| 235 |
+
#[test]
|
| 236 |
+
fn compute_cube_to_obj() {
|
| 237 |
+
let d = Dir::new();
|
| 238 |
+
let mut w = World::default();
|
| 239 |
+
let mut planes = Vec::new();
|
| 240 |
+
for axis in 0..3 {
|
| 241 |
+
for sign in [-1., 1.] {
|
| 242 |
+
let mut p = mb3_dat::Plane::default();
|
| 243 |
+
p.p0[axis] = sign;
|
| 244 |
+
p.u[(axis + 1) % 3] = 1.;
|
| 245 |
+
p.v[(axis + 2) % 3] = sign;
|
| 246 |
+
planes.push(p);
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
w.brushes.push(mb3_dat::Brush { planes });
|
| 250 |
+
let bytes = mb3_txt::encode(&w, &Limits::default()).unwrap();
|
| 251 |
+
let input = d.file("cube.txt", &bytes);
|
| 252 |
+
let output = d.0.join("cube.obj");
|
| 253 |
+
call(&[
|
| 254 |
+
"convert".as_ref(),
|
| 255 |
+
input.as_os_str(),
|
| 256 |
+
output.as_os_str(),
|
| 257 |
+
"--to".as_ref(),
|
| 258 |
+
"obj".as_ref(),
|
| 259 |
+
"--compute".as_ref(),
|
| 260 |
+
"--allow-lossy".as_ref(),
|
| 261 |
+
])
|
| 262 |
+
.unwrap();
|
| 263 |
+
let obj = fs::read_to_string(output).unwrap();
|
| 264 |
+
assert_eq!(obj.lines().filter(|l| l.starts_with("v ")).count(), 8);
|
| 265 |
+
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), 6);
|
| 266 |
+
assert_eq!(fs::read(input).unwrap(), bytes);
|
| 267 |
+
}
|
crates/nanobrush-geometry/src/lib.rs
CHANGED
|
@@ -103,3 +103,4 @@ impl Budget {
|
|
| 103 |
}
|
| 104 |
}
|
| 105 |
pub mod patch;
|
|
|
|
|
|
| 103 |
}
|
| 104 |
}
|
| 105 |
pub mod patch;
|
| 106 |
+
pub mod world;
|
crates/nanobrush-geometry/src/world.rs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::*;
|
| 2 |
+
use mb3_dat::{Brush, Limits, Patch9, PatchComputed, Plane, Property, Uv, World};
|
| 3 |
+
struct CopyBudget {
|
| 4 |
+
items: usize,
|
| 5 |
+
bytes: usize,
|
| 6 |
+
limits: Limits,
|
| 7 |
+
}
|
| 8 |
+
impl CopyBudget {
|
| 9 |
+
fn bytes(&mut self, n: usize) -> Result<()> {
|
| 10 |
+
self.bytes = self.bytes.checked_add(n).ok_or(Error::Overflow)?;
|
| 11 |
+
if self.bytes > self.limits.max_owned_bytes {
|
| 12 |
+
Err(Error::Limit)
|
| 13 |
+
} else {
|
| 14 |
+
Ok(())
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
fn vec<T>(&mut self, n: usize) -> Result<Vec<T>> {
|
| 18 |
+
self.items = self.items.checked_add(n).ok_or(Error::Overflow)?;
|
| 19 |
+
if self.items > self.limits.max_items {
|
| 20 |
+
return Err(Error::Limit);
|
| 21 |
+
}
|
| 22 |
+
self.bytes(
|
| 23 |
+
n.checked_mul(std::mem::size_of::<T>())
|
| 24 |
+
.ok_or(Error::Overflow)?,
|
| 25 |
+
)?;
|
| 26 |
+
alloc(n)
|
| 27 |
+
}
|
| 28 |
+
fn string(&mut self, s: &str) -> Result<String> {
|
| 29 |
+
if s.len() > self.limits.max_string_bytes {
|
| 30 |
+
return Err(Error::Limit);
|
| 31 |
+
}
|
| 32 |
+
self.bytes(s.len())?;
|
| 33 |
+
let mut out = String::new();
|
| 34 |
+
out.try_reserve_exact(s.len())
|
| 35 |
+
.map_err(|_| Error::Allocation)?;
|
| 36 |
+
out.push_str(s);
|
| 37 |
+
Ok(out)
|
| 38 |
+
}
|
| 39 |
+
fn names(&mut self, names: &[String]) -> Result<Vec<String>> {
|
| 40 |
+
let mut out = self.vec(names.len())?;
|
| 41 |
+
for s in names {
|
| 42 |
+
out.push(self.string(s)?);
|
| 43 |
+
}
|
| 44 |
+
Ok(out)
|
| 45 |
+
}
|
| 46 |
+
fn base(&mut self, w: &World) -> Result<World> {
|
| 47 |
+
let string_properties = self.names(&w.string_properties)?;
|
| 48 |
+
let uv_properties = self.names(&w.uv_properties)?;
|
| 49 |
+
let mut brushes = self.vec(w.brushes.len())?;
|
| 50 |
+
for b in &w.brushes {
|
| 51 |
+
let mut planes = self.vec(b.planes.len())?;
|
| 52 |
+
for p in &b.planes {
|
| 53 |
+
let mut strings = self.vec(p.strings.len())?;
|
| 54 |
+
for s in &p.strings {
|
| 55 |
+
strings.push(Property {
|
| 56 |
+
index: s.index,
|
| 57 |
+
value: self.string(&s.value)?,
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
let mut uv: Vec<Property<Uv>> = self.vec(p.uv.len())?;
|
| 61 |
+
uv.extend(p.uv.iter().cloned());
|
| 62 |
+
planes.push(Plane {
|
| 63 |
+
p0: p.p0,
|
| 64 |
+
u: p.u,
|
| 65 |
+
v: p.v,
|
| 66 |
+
strings,
|
| 67 |
+
uv,
|
| 68 |
+
polygon: None,
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
brushes.push(Brush { planes });
|
| 72 |
+
}
|
| 73 |
+
let mut patches = self.vec(w.patches.len())?;
|
| 74 |
+
for p in &w.patches {
|
| 75 |
+
let mut anchors = self.vec(p.anchors.len())?;
|
| 76 |
+
anchors.extend_from_slice(&p.anchors);
|
| 77 |
+
patches.push(Patch9 {
|
| 78 |
+
anchors_u: p.anchors_u,
|
| 79 |
+
anchors_v: p.anchors_v,
|
| 80 |
+
quads_u: p.quads_u,
|
| 81 |
+
quads_v: p.quads_v,
|
| 82 |
+
anchors,
|
| 83 |
+
computed: None,
|
| 84 |
+
});
|
| 85 |
+
}
|
| 86 |
+
Ok(World {
|
| 87 |
+
computed: false,
|
| 88 |
+
string_properties,
|
| 89 |
+
uv_properties,
|
| 90 |
+
brushes,
|
| 91 |
+
patches,
|
| 92 |
+
})
|
| 93 |
+
}
|
| 94 |
+
fn vectors(&mut self, source: &[DVec3]) -> Result<Vec<mb3_dat::Vec3>> {
|
| 95 |
+
let mut out = self.vec(source.len())?;
|
| 96 |
+
for v in source {
|
| 97 |
+
out.push(math::to_f32(*v)?);
|
| 98 |
+
}
|
| 99 |
+
Ok(out)
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
/// Transactional by ownership: source is never mutated, and no partial world is
|
| 103 |
+
/// returned. Existing derived arrays are recomputed rather than copied.
|
| 104 |
+
pub fn compute(
|
| 105 |
+
source: &World,
|
| 106 |
+
policy: Policy,
|
| 107 |
+
limits: ComputeLimits,
|
| 108 |
+
codec: Limits,
|
| 109 |
+
) -> Result<(World, Stats)> {
|
| 110 |
+
policy.validate()?;
|
| 111 |
+
let mut b = CopyBudget {
|
| 112 |
+
items: 0,
|
| 113 |
+
bytes: 0,
|
| 114 |
+
limits: Limits {
|
| 115 |
+
max_owned_bytes: codec.max_owned_bytes.min(limits.max_owned_bytes),
|
| 116 |
+
..codec
|
| 117 |
+
},
|
| 118 |
+
};
|
| 119 |
+
let limits = ComputeLimits {
|
| 120 |
+
max_owned_bytes: b.limits.max_owned_bytes,
|
| 121 |
+
..limits
|
| 122 |
+
};
|
| 123 |
+
let mut out = b.base(source)?;
|
| 124 |
+
let mut stats = Stats::default();
|
| 125 |
+
for brush in &mut out.brushes {
|
| 126 |
+
let remaining = remaining(limits, &stats, b.bytes)?;
|
| 127 |
+
let mesh = crate::brush::contours(brush, policy, remaining)?;
|
| 128 |
+
stats.work = stats
|
| 129 |
+
.work
|
| 130 |
+
.checked_add(mesh.stats.work)
|
| 131 |
+
.ok_or(Error::Overflow)?;
|
| 132 |
+
stats.vertices = stats
|
| 133 |
+
.vertices
|
| 134 |
+
.checked_add(mesh.stats.vertices)
|
| 135 |
+
.ok_or(Error::Overflow)?;
|
| 136 |
+
b.bytes(mesh.stats.owned)?;
|
| 137 |
+
for (p, polygon) in brush.planes.iter_mut().zip(&mesh.contours) {
|
| 138 |
+
p.polygon = Some(b.vectors(polygon)?);
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
for patch in &mut out.patches {
|
| 142 |
+
let remaining = remaining(limits, &stats, b.bytes)?;
|
| 143 |
+
let mesh = crate::patch::tessellate(patch, policy, remaining)?;
|
| 144 |
+
stats.work = stats
|
| 145 |
+
.work
|
| 146 |
+
.checked_add(mesh.stats.work)
|
| 147 |
+
.ok_or(Error::Overflow)?;
|
| 148 |
+
stats.vertices = stats
|
| 149 |
+
.vertices
|
| 150 |
+
.checked_add(mesh.stats.vertices)
|
| 151 |
+
.ok_or(Error::Overflow)?;
|
| 152 |
+
b.bytes(mesh.stats.owned)?;
|
| 153 |
+
patch.computed = Some(PatchComputed {
|
| 154 |
+
points: b.vectors(&mesh.points)?,
|
| 155 |
+
normals: b.vectors(&mesh.normals)?,
|
| 156 |
+
});
|
| 157 |
+
}
|
| 158 |
+
out.computed = true;
|
| 159 |
+
stats.owned = b.bytes;
|
| 160 |
+
Ok((out, stats))
|
| 161 |
+
}
|
| 162 |
+
fn remaining(limits: ComputeLimits, stats: &Stats, owned: usize) -> Result<ComputeLimits> {
|
| 163 |
+
Ok(ComputeLimits {
|
| 164 |
+
max_vertices: limits
|
| 165 |
+
.max_vertices
|
| 166 |
+
.checked_sub(stats.vertices)
|
| 167 |
+
.ok_or(Error::Limit)?,
|
| 168 |
+
max_work: limits
|
| 169 |
+
.max_work
|
| 170 |
+
.checked_sub(stats.work)
|
| 171 |
+
.ok_or(Error::Limit)?,
|
| 172 |
+
max_owned_bytes: limits
|
| 173 |
+
.max_owned_bytes
|
| 174 |
+
.checked_sub(owned)
|
| 175 |
+
.ok_or(Error::Limit)?,
|
| 176 |
+
..limits
|
| 177 |
+
})
|
| 178 |
+
}
|
| 179 |
+
#[cfg(test)]
|
| 180 |
+
mod tests {
|
| 181 |
+
use super::*;
|
| 182 |
+
#[test]
|
| 183 |
+
fn mixed_world_roundtrip_properties_and_source_immutable() {
|
| 184 |
+
let mut w = World {
|
| 185 |
+
brushes: vec![crate::brush::cube()],
|
| 186 |
+
patches: vec![crate::patch::flat(3, 3)],
|
| 187 |
+
string_properties: vec!["name".into()],
|
| 188 |
+
..World::default()
|
| 189 |
+
};
|
| 190 |
+
w.brushes[0].planes[0].strings.push(Property {
|
| 191 |
+
index: 0,
|
| 192 |
+
value: "value".into(),
|
| 193 |
+
});
|
| 194 |
+
let before = w.clone();
|
| 195 |
+
let (out, s) = compute(
|
| 196 |
+
&w,
|
| 197 |
+
Policy::default(),
|
| 198 |
+
ComputeLimits::default(),
|
| 199 |
+
Limits::default(),
|
| 200 |
+
)
|
| 201 |
+
.unwrap();
|
| 202 |
+
assert_eq!(w, before);
|
| 203 |
+
assert!(out.computed);
|
| 204 |
+
assert_eq!(s.vertices, 33);
|
| 205 |
+
assert_eq!(
|
| 206 |
+
out.brushes[0].planes[0].strings,
|
| 207 |
+
w.brushes[0].planes[0].strings
|
| 208 |
+
);
|
| 209 |
+
let d = mb3_dat::Document {
|
| 210 |
+
world: out,
|
| 211 |
+
version: mb3_dat::Version::V201512200,
|
| 212 |
+
};
|
| 213 |
+
assert_eq!(
|
| 214 |
+
mb3_dat::decode(
|
| 215 |
+
&mb3_dat::encode(&d, &Limits::default()).unwrap(),
|
| 216 |
+
&Limits::default()
|
| 217 |
+
)
|
| 218 |
+
.unwrap(),
|
| 219 |
+
d
|
| 220 |
+
);
|
| 221 |
+
}
|
| 222 |
+
#[test]
|
| 223 |
+
fn late_failure_and_global_budgets() {
|
| 224 |
+
let w = World {
|
| 225 |
+
brushes: vec![crate::brush::cube(), Brush { planes: vec![] }],
|
| 226 |
+
..World::default()
|
| 227 |
+
};
|
| 228 |
+
let before = w.clone();
|
| 229 |
+
assert!(
|
| 230 |
+
compute(
|
| 231 |
+
&w,
|
| 232 |
+
Policy::default(),
|
| 233 |
+
ComputeLimits::default(),
|
| 234 |
+
Limits::default()
|
| 235 |
+
)
|
| 236 |
+
.is_err()
|
| 237 |
+
);
|
| 238 |
+
assert_eq!(w, before);
|
| 239 |
+
let w = World {
|
| 240 |
+
brushes: vec![crate::brush::cube(), crate::brush::cube()],
|
| 241 |
+
..World::default()
|
| 242 |
+
};
|
| 243 |
+
assert!(
|
| 244 |
+
compute(
|
| 245 |
+
&w,
|
| 246 |
+
Policy::default(),
|
| 247 |
+
ComputeLimits {
|
| 248 |
+
max_vertices: 30,
|
| 249 |
+
..Default::default()
|
| 250 |
+
},
|
| 251 |
+
Limits::default()
|
| 252 |
+
)
|
| 253 |
+
.is_err()
|
| 254 |
+
);
|
| 255 |
+
assert!(
|
| 256 |
+
compute(
|
| 257 |
+
&w,
|
| 258 |
+
Policy::default(),
|
| 259 |
+
ComputeLimits::default(),
|
| 260 |
+
Limits {
|
| 261 |
+
max_owned_bytes: 1,
|
| 262 |
+
..Default::default()
|
| 263 |
+
}
|
| 264 |
+
)
|
| 265 |
+
.is_err()
|
| 266 |
+
);
|
| 267 |
+
}
|
| 268 |
+
}
|
docs/CLI.md
CHANGED
|
@@ -58,3 +58,5 @@ Eight asset-free unit tests cover options, limits, inspection, both DAT directio
|
|
| 58 |
`preflight INPUT --to FORMAT` emits schema-1 compatibility JSON without writing a document. A successful report command exits 0 even when `compatible` is false. Grouped issues cover non-ASCII DAT values, property-bearing old-DAT exports and duplicate TXT table names. Counts refer to affected names/entries, not lost bytes. Full structural/size validation remains the encoder’s responsibility.
|
| 59 |
|
| 60 |
Computed OBJ export: `convert INPUT OUTPUT --to obj --allow-lossy`. `preflight INPUT --to obj` lists discarded editable geometry/properties/normals as `lossy:true`; missing computed data is blocking. Acknowledgement never overrides malformed data or limits. OBJ has no native import route.
|
|
|
|
|
|
|
|
|
| 58 |
`preflight INPUT --to FORMAT` emits schema-1 compatibility JSON without writing a document. A successful report command exits 0 even when `compatible` is false. Grouped issues cover non-ASCII DAT values, property-bearing old-DAT exports and duplicate TXT table names. Counts refer to affected names/entries, not lost bytes. Full structural/size validation remains the encoder’s responsibility.
|
| 59 |
|
| 60 |
Computed OBJ export: `convert INPUT OUTPUT --to obj --allow-lossy`. `preflight INPUT --to obj` lists discarded editable geometry/properties/normals as `lossy:true`; missing computed data is blocking. Acknowledgement never overrides malformed data or limits. OBJ has no native import route.
|
| 61 |
+
|
| 62 |
+
`convert/preflight --compute` explicitly regenerates native brush contours and Patch9 samples before export/reporting. See [native geometry policy](GEOMETRY.md); it is not a claim of editor-identical geometry. It reuses guarded publication, and geometry failure leaves both source and destination untouched.
|
docs/GEOMETRY.md
CHANGED
|
@@ -17,3 +17,9 @@ Defaults cap 64 planes, 100,000 generated face vertices, 10 million work units a
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 20 |
+
|
| 21 |
+
## Computed world pipeline
|
| 22 |
+
|
| 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.
|
docs/PROGRESS_2026-10-26.md
CHANGED
|
@@ -11,6 +11,6 @@ Scope: extend native headless formats and geometry. Each stage is independently
|
|
| 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 |
|
| 15 |
| 9 | P5.19 | Nearest-hit ray queries | Pending |
|
| 16 |
| 10 | P5.20 | Property table edits/reindexing | Pending |
|
|
|
|
| 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 | 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 |
|
docs/TODO.md
CHANGED
|
@@ -132,6 +132,8 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
|
|
| 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.
|
|
|
|
| 132 |
|
| 133 |
- [x] P5.17 Implement bounded native Patch9 tensor-quadratic sampling/tessellation and analytic normals ([GEOMETRY.md](GEOMETRY.md)).
|
| 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.
|