cli: guarded obj export
Browse files- Cargo.lock +1 -0
- crates/nanobrush-cli/Cargo.toml +2 -0
- crates/nanobrush-cli/src/lib.rs +24 -1
- crates/nanobrush-cli/src/preflight.rs +61 -1
- crates/nanobrush-cli/src/tests.rs +35 -0
- docs/CLI.md +2 -0
- docs/PROGRESS_2026-10-26.md +1 -1
- docs/TODO.md +2 -0
Cargo.lock
CHANGED
|
@@ -26,5 +26,6 @@ name = "nanobrush-cli"
|
|
| 26 |
version = "0.1.0"
|
| 27 |
dependencies = [
|
| 28 |
"mb3-dat",
|
|
|
|
| 29 |
"mb3-txt",
|
| 30 |
]
|
|
|
|
| 26 |
version = "0.1.0"
|
| 27 |
dependencies = [
|
| 28 |
"mb3-dat",
|
| 29 |
+
"mb3-obj",
|
| 30 |
"mb3-txt",
|
| 31 |
]
|
crates/nanobrush-cli/Cargo.toml
CHANGED
|
@@ -14,5 +14,7 @@ path = "src/main.rs"
|
|
| 14 |
mb3-dat = { path = "../mb3-dat" }
|
| 15 |
mb3-txt = { path = "../mb3-txt" }
|
| 16 |
|
|
|
|
|
|
|
| 17 |
[lints]
|
| 18 |
workspace = true
|
|
|
|
| 14 |
mb3-dat = { path = "../mb3-dat" }
|
| 15 |
mb3-txt = { path = "../mb3-txt" }
|
| 16 |
|
| 17 |
+
mb3-obj = { path = "../mb3-obj" }
|
| 18 |
+
|
| 19 |
[lints]
|
| 20 |
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 [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 [--from auto|txt|dat] [--force] [LIMITS]\nLIMITS: --max-bytes N --max-items N --max-string-bytes N --max-owned-bytes N\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,
|
|
@@ -47,6 +47,7 @@ enum InputFormat {
|
|
| 47 |
#[derive(Clone, Copy)]
|
| 48 |
enum To {
|
| 49 |
Txt,
|
|
|
|
| 50 |
Dat(Version),
|
| 51 |
}
|
| 52 |
struct Options {
|
|
@@ -55,6 +56,7 @@ struct Options {
|
|
| 55 |
from: InputFormat,
|
| 56 |
to: Option<To>,
|
| 57 |
force: bool,
|
|
|
|
| 58 |
limits: Limits,
|
| 59 |
encoding: mb3_txt::Encoding,
|
| 60 |
}
|
|
@@ -81,6 +83,7 @@ fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Option<Options>> {
|
|
| 81 |
from: InputFormat::Auto,
|
| 82 |
to: None,
|
| 83 |
force: false,
|
|
|
|
| 84 |
limits: Limits::default(),
|
| 85 |
encoding: mb3_txt::Encoding::Utf8,
|
| 86 |
};
|
|
@@ -99,6 +102,10 @@ fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Option<Options>> {
|
|
| 99 |
if !seen.insert(flag.to_owned()) {
|
| 100 |
return Err(usage(format!("duplicate option {flag}")));
|
| 101 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
if flag == "--force" {
|
| 103 |
options.force = true;
|
| 104 |
continue;
|
|
@@ -140,6 +147,7 @@ fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Option<Options>> {
|
|
| 140 |
"--to" => {
|
| 141 |
options.to = Some(match value {
|
| 142 |
"txt" => To::Txt,
|
|
|
|
| 143 |
"dat-201501280" => To::Dat(Version::V201501280),
|
| 144 |
"dat-201512200" => To::Dat(Version::V201512200),
|
| 145 |
_ => {
|
|
@@ -179,6 +187,10 @@ fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Option<Options>> {
|
|
| 179 |
"inspect/validate require one INPUT and no --to/--force",
|
| 180 |
));
|
| 181 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
Ok(Some(options))
|
| 183 |
}
|
| 184 |
struct Input {
|
|
@@ -318,6 +330,7 @@ pub fn run(args: impl IntoIterator<Item = OsString>, stdout: &mut dyn Write) ->
|
|
| 318 |
"preflight" => {
|
| 319 |
let target = match o.to.ok_or_else(|| usage("missing --to"))? {
|
| 320 |
To::Txt => preflight::Target::Txt,
|
|
|
|
| 321 |
To::Dat(v) => preflight::Target::Dat(v),
|
| 322 |
};
|
| 323 |
writeln!(
|
|
@@ -381,6 +394,16 @@ pub fn run(args: impl IntoIterator<Item = OsString>, stdout: &mut dyn Write) ->
|
|
| 381 |
)?,
|
| 382 |
_ => {
|
| 383 |
let bytes = match o.to.ok_or_else(|| usage("missing --to"))? {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
To::Txt => {
|
| 385 |
mb3_txt::encode(&input.world, &o.limits).map_err(|e| fail(e.to_string()))?
|
| 386 |
}
|
|
|
|
| 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,
|
|
|
|
| 47 |
#[derive(Clone, Copy)]
|
| 48 |
enum To {
|
| 49 |
Txt,
|
| 50 |
+
Obj,
|
| 51 |
Dat(Version),
|
| 52 |
}
|
| 53 |
struct Options {
|
|
|
|
| 56 |
from: InputFormat,
|
| 57 |
to: Option<To>,
|
| 58 |
force: bool,
|
| 59 |
+
allow_lossy: bool,
|
| 60 |
limits: Limits,
|
| 61 |
encoding: mb3_txt::Encoding,
|
| 62 |
}
|
|
|
|
| 83 |
from: InputFormat::Auto,
|
| 84 |
to: None,
|
| 85 |
force: false,
|
| 86 |
+
allow_lossy: false,
|
| 87 |
limits: Limits::default(),
|
| 88 |
encoding: mb3_txt::Encoding::Utf8,
|
| 89 |
};
|
|
|
|
| 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;
|
| 108 |
+
}
|
| 109 |
if flag == "--force" {
|
| 110 |
options.force = true;
|
| 111 |
continue;
|
|
|
|
| 147 |
"--to" => {
|
| 148 |
options.to = Some(match value {
|
| 149 |
"txt" => To::Txt,
|
| 150 |
+
"obj" => To::Obj,
|
| 151 |
"dat-201501280" => To::Dat(Version::V201501280),
|
| 152 |
"dat-201512200" => To::Dat(Version::V201512200),
|
| 153 |
_ => {
|
|
|
|
| 187 |
"inspect/validate require one INPUT and no --to/--force",
|
| 188 |
));
|
| 189 |
}
|
| 190 |
+
if options.allow_lossy && (options.command != "convert" || !matches!(options.to, Some(To::Obj)))
|
| 191 |
+
{
|
| 192 |
+
return Err(usage("--allow-lossy requires convert --to obj"));
|
| 193 |
+
}
|
| 194 |
Ok(Some(options))
|
| 195 |
}
|
| 196 |
struct Input {
|
|
|
|
| 330 |
"preflight" => {
|
| 331 |
let target = match o.to.ok_or_else(|| usage("missing --to"))? {
|
| 332 |
To::Txt => preflight::Target::Txt,
|
| 333 |
+
To::Obj => preflight::Target::Obj,
|
| 334 |
To::Dat(v) => preflight::Target::Dat(v),
|
| 335 |
};
|
| 336 |
writeln!(
|
|
|
|
| 394 |
)?,
|
| 395 |
_ => {
|
| 396 |
let bytes = match o.to.ok_or_else(|| usage("missing --to"))? {
|
| 397 |
+
To::Obj => {
|
| 398 |
+
let report = preflight::analyze(&input.world, preflight::Target::Obj);
|
| 399 |
+
if report.blocked() || (!report.compatible() && !o.allow_lossy) {
|
| 400 |
+
return Err(fail(format!(
|
| 401 |
+
"OBJ export requires compatible computed geometry and --allow-lossy: {}",
|
| 402 |
+
report.json()
|
| 403 |
+
)));
|
| 404 |
+
}
|
| 405 |
+
mb3_obj::encode(&input.world, &o.limits).map_err(|e| fail(e.to_string()))?
|
| 406 |
+
}
|
| 407 |
To::Txt => {
|
| 408 |
mb3_txt::encode(&input.world, &o.limits).map_err(|e| fail(e.to_string()))?
|
| 409 |
}
|
crates/nanobrush-cli/src/preflight.rs
CHANGED
|
@@ -4,12 +4,14 @@ use std::collections::HashSet;
|
|
| 4 |
#[derive(Clone, Copy, Debug)]
|
| 5 |
pub enum Target {
|
| 6 |
Txt,
|
|
|
|
| 7 |
Dat(Version),
|
| 8 |
}
|
| 9 |
impl Target {
|
| 10 |
pub fn name(self) -> &'static str {
|
| 11 |
match self {
|
| 12 |
Self::Txt => "txt",
|
|
|
|
| 13 |
Self::Dat(Version::V201501280) => "dat-201501280",
|
| 14 |
Self::Dat(Version::V201512200) => "dat-201512200",
|
| 15 |
}
|
|
@@ -19,6 +21,7 @@ impl Target {
|
|
| 19 |
pub struct Issue {
|
| 20 |
pub code: &'static str,
|
| 21 |
pub count: usize,
|
|
|
|
| 22 |
}
|
| 23 |
#[derive(Clone, Debug)]
|
| 24 |
pub struct Report {
|
|
@@ -26,6 +29,9 @@ pub struct Report {
|
|
| 26 |
pub issues: Vec<Issue>,
|
| 27 |
}
|
| 28 |
impl Report {
|
|
|
|
|
|
|
|
|
|
| 29 |
pub fn compatible(&self) -> bool {
|
| 30 |
self.issues.is_empty()
|
| 31 |
}
|
|
@@ -33,7 +39,12 @@ impl Report {
|
|
| 33 |
let issues = self
|
| 34 |
.issues
|
| 35 |
.iter()
|
| 36 |
-
.map(|i|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
.collect::<Vec<_>>()
|
| 38 |
.join(",");
|
| 39 |
format!(
|
|
@@ -48,6 +59,52 @@ pub fn analyze(world: &World, target: Target) -> Report {
|
|
| 48 |
let mut issues = Vec::new();
|
| 49 |
let planes = || world.brushes.iter().flat_map(|b| &b.planes);
|
| 50 |
match target {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
Target::Dat(version) => {
|
| 52 |
let n = world
|
| 53 |
.string_properties
|
|
@@ -63,6 +120,7 @@ pub fn analyze(world: &World, target: Target) -> Report {
|
|
| 63 |
);
|
| 64 |
if n != 0 {
|
| 65 |
issues.push(Issue {
|
|
|
|
| 66 |
code: "non_ascii",
|
| 67 |
count: n,
|
| 68 |
});
|
|
@@ -79,6 +137,7 @@ pub fn analyze(world: &World, target: Target) -> Report {
|
|
| 79 |
);
|
| 80 |
if n != 0 {
|
| 81 |
issues.push(Issue {
|
|
|
|
| 82 |
code: "legacy_properties",
|
| 83 |
count: n,
|
| 84 |
});
|
|
@@ -97,6 +156,7 @@ pub fn analyze(world: &World, target: Target) -> Report {
|
|
| 97 |
}
|
| 98 |
if n != 0 {
|
| 99 |
issues.push(Issue {
|
|
|
|
| 100 |
code: "duplicate_names",
|
| 101 |
count: n,
|
| 102 |
});
|
|
|
|
| 4 |
#[derive(Clone, Copy, Debug)]
|
| 5 |
pub enum Target {
|
| 6 |
Txt,
|
| 7 |
+
Obj,
|
| 8 |
Dat(Version),
|
| 9 |
}
|
| 10 |
impl Target {
|
| 11 |
pub fn name(self) -> &'static str {
|
| 12 |
match self {
|
| 13 |
Self::Txt => "txt",
|
| 14 |
+
Self::Obj => "obj",
|
| 15 |
Self::Dat(Version::V201501280) => "dat-201501280",
|
| 16 |
Self::Dat(Version::V201512200) => "dat-201512200",
|
| 17 |
}
|
|
|
|
| 21 |
pub struct Issue {
|
| 22 |
pub code: &'static str,
|
| 23 |
pub count: usize,
|
| 24 |
+
pub lossy: bool,
|
| 25 |
}
|
| 26 |
#[derive(Clone, Debug)]
|
| 27 |
pub struct Report {
|
|
|
|
| 29 |
pub issues: Vec<Issue>,
|
| 30 |
}
|
| 31 |
impl Report {
|
| 32 |
+
pub fn blocked(&self) -> bool {
|
| 33 |
+
self.issues.iter().any(|i| !i.lossy)
|
| 34 |
+
}
|
| 35 |
pub fn compatible(&self) -> bool {
|
| 36 |
self.issues.is_empty()
|
| 37 |
}
|
|
|
|
| 39 |
let issues = self
|
| 40 |
.issues
|
| 41 |
.iter()
|
| 42 |
+
.map(|i| {
|
| 43 |
+
format!(
|
| 44 |
+
"{{\"code\":\"{}\",\"count\":{},\"lossy\":{}}}",
|
| 45 |
+
i.code, i.count, i.lossy
|
| 46 |
+
)
|
| 47 |
+
})
|
| 48 |
.collect::<Vec<_>>()
|
| 49 |
.join(",");
|
| 50 |
format!(
|
|
|
|
| 59 |
let mut issues = Vec::new();
|
| 60 |
let planes = || world.brushes.iter().flat_map(|b| &b.planes);
|
| 61 |
match target {
|
| 62 |
+
Target::Obj => {
|
| 63 |
+
if !world.computed {
|
| 64 |
+
issues.push(Issue {
|
| 65 |
+
code: "computed_required",
|
| 66 |
+
count: 1,
|
| 67 |
+
lossy: false,
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
let n = planes().count().saturating_add(world.patches.len());
|
| 71 |
+
if n != 0 {
|
| 72 |
+
issues.push(Issue {
|
| 73 |
+
code: "editable_geometry",
|
| 74 |
+
count: n,
|
| 75 |
+
lossy: true,
|
| 76 |
+
});
|
| 77 |
+
}
|
| 78 |
+
let n = world
|
| 79 |
+
.string_properties
|
| 80 |
+
.len()
|
| 81 |
+
.saturating_add(world.uv_properties.len())
|
| 82 |
+
.saturating_add(
|
| 83 |
+
planes()
|
| 84 |
+
.map(|p| p.strings.len().saturating_add(p.uv.len()))
|
| 85 |
+
.fold(0usize, usize::saturating_add),
|
| 86 |
+
);
|
| 87 |
+
if n != 0 {
|
| 88 |
+
issues.push(Issue {
|
| 89 |
+
code: "properties",
|
| 90 |
+
count: n,
|
| 91 |
+
lossy: true,
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
let n = world
|
| 95 |
+
.patches
|
| 96 |
+
.iter()
|
| 97 |
+
.filter_map(|p| p.computed.as_ref())
|
| 98 |
+
.map(|p| p.normals.len())
|
| 99 |
+
.fold(0usize, usize::saturating_add);
|
| 100 |
+
if n != 0 {
|
| 101 |
+
issues.push(Issue {
|
| 102 |
+
code: "stored_normals",
|
| 103 |
+
count: n,
|
| 104 |
+
lossy: true,
|
| 105 |
+
});
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
Target::Dat(version) => {
|
| 109 |
let n = world
|
| 110 |
.string_properties
|
|
|
|
| 120 |
);
|
| 121 |
if n != 0 {
|
| 122 |
issues.push(Issue {
|
| 123 |
+
lossy: false,
|
| 124 |
code: "non_ascii",
|
| 125 |
count: n,
|
| 126 |
});
|
|
|
|
| 137 |
);
|
| 138 |
if n != 0 {
|
| 139 |
issues.push(Issue {
|
| 140 |
+
lossy: false,
|
| 141 |
code: "legacy_properties",
|
| 142 |
count: n,
|
| 143 |
});
|
|
|
|
| 156 |
}
|
| 157 |
if n != 0 {
|
| 158 |
issues.push(Issue {
|
| 159 |
+
lossy: false,
|
| 160 |
code: "duplicate_names",
|
| 161 |
count: n,
|
| 162 |
});
|
crates/nanobrush-cli/src/tests.rs
CHANGED
|
@@ -196,3 +196,38 @@ fn invalid_conversion_with_force_keeps_existing_destination() {
|
|
| 196 |
assert_eq!(fs::read(output).unwrap(), b"KEEP");
|
| 197 |
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2);
|
| 198 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
assert_eq!(fs::read(output).unwrap(), b"KEEP");
|
| 197 |
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2);
|
| 198 |
}
|
| 199 |
+
|
| 200 |
+
#[test]
|
| 201 |
+
fn obj_acknowledgement_and_preflight() {
|
| 202 |
+
let d = Dir::new();
|
| 203 |
+
let input = d.file(
|
| 204 |
+
"triangle.txt",
|
| 205 |
+
b"world(computedFeatures true brush(plane(0 0 0,1 0 0,0 1 0 polygon[0 0 0,1 0 0,0 1 0])))",
|
| 206 |
+
);
|
| 207 |
+
let output = d.0.join("out.obj");
|
| 208 |
+
let args = [
|
| 209 |
+
"convert".as_ref(),
|
| 210 |
+
input.as_os_str(),
|
| 211 |
+
output.as_os_str(),
|
| 212 |
+
"--to".as_ref(),
|
| 213 |
+
"obj".as_ref(),
|
| 214 |
+
];
|
| 215 |
+
assert!(call(&args).is_err());
|
| 216 |
+
assert!(!output.exists());
|
| 217 |
+
let mut allowed = args.to_vec();
|
| 218 |
+
allowed.push("--allow-lossy".as_ref());
|
| 219 |
+
call(&allowed).unwrap();
|
| 220 |
+
assert!(fs::read(&output).unwrap().ends_with(b"f 1 2 3\n"));
|
| 221 |
+
let report = call(&[
|
| 222 |
+
"preflight".as_ref(),
|
| 223 |
+
input.as_os_str(),
|
| 224 |
+
"--to".as_ref(),
|
| 225 |
+
"obj".as_ref(),
|
| 226 |
+
])
|
| 227 |
+
.unwrap();
|
| 228 |
+
assert!(
|
| 229 |
+
String::from_utf8(report)
|
| 230 |
+
.unwrap()
|
| 231 |
+
.contains("editable_geometry")
|
| 232 |
+
);
|
| 233 |
+
}
|
docs/CLI.md
CHANGED
|
@@ -56,3 +56,5 @@ Incompatible exports fail before publication: for example, Unicode TXT→ASCII D
|
|
| 56 |
Eight asset-free unit tests cover options, limits, inspection, both DAT directions, Unicode paths, overwrite/same-file/hard-link/symlink guards, validation before any output, preservation of existing destinations after incompatible conversion, exclusive publication races and temporary cleanup. Two process integration tests check documented exit streams/codes and invoke the real binary for inspect, DAT→TXT, validate and TXT→DAT against **all 58 DAT outputs** (232 successful commands), verifying exact final DAT bytes. Fixture tests fail when assets are missing. See [E04](experiments/txt-cli-2026-10-21.md).
|
| 57 |
|
| 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.
|
|
|
|
|
|
|
|
|
| 56 |
Eight asset-free unit tests cover options, limits, inspection, both DAT directions, Unicode paths, overwrite/same-file/hard-link/symlink guards, validation before any output, preservation of existing destinations after incompatible conversion, exclusive publication races and temporary cleanup. Two process integration tests check documented exit streams/codes and invoke the real binary for inspect, DAT→TXT, validate and TXT→DAT against **all 58 DAT outputs** (232 successful commands), verifying exact final DAT bytes. Fixture tests fail when assets are missing. See [E04](experiments/txt-cli-2026-10-21.md).
|
| 57 |
|
| 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.
|
docs/PROGRESS_2026-10-26.md
CHANGED
|
@@ -7,7 +7,7 @@ Scope: extend native headless formats and geometry. Each stage is independently
|
|
| 7 |
| 1 | P6.23 | Structured preflight reports | Complete: `preflight INPUT --to FORMAT` emits schema-1 JSON with grouped ASCII/downgrade/duplicate-name incompatibilities; reports never mutate documents. Parser/encoder validation remains authoritative. Unit tests and existing CLI unit regressions pass. |
|
| 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 |
|
| 11 |
| 5 | P5.15 | Geometry primitives/bounds | Pending |
|
| 12 |
| 6 | P5.16 | Convex brush contours | Pending |
|
| 13 |
| 7 | P5.17 | Patch9 tessellation | Pending |
|
|
|
|
| 7 |
| 1 | P6.23 | Structured preflight reports | Complete: `preflight INPUT --to FORMAT` emits schema-1 JSON with grouped ASCII/downgrade/duplicate-name incompatibilities; reports never mutate documents. Parser/encoder validation remains authoritative. Unit tests and existing CLI unit regressions pass. |
|
| 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 |
|
docs/TODO.md
CHANGED
|
@@ -163,6 +163,8 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
|
|
| 163 |
|
| 164 |
- [x] P6.25 Implement bounded computed OBJ export and verify all four E02 OBJ goldens ([ledger](PROGRESS_2026-10-26.md)).
|
| 165 |
|
|
|
|
|
|
|
| 166 |
## P7 — Input, cameras, grids, views, and editing workflows
|
| 167 |
|
| 168 |
**Depends on:** P4 dispatch and P5 geometry. **Exit:** complete interaction/state-machine specifications and reproducible replays.
|
|
|
|
| 163 |
|
| 164 |
- [x] P6.25 Implement bounded computed OBJ export and verify all four E02 OBJ goldens ([ledger](PROGRESS_2026-10-26.md)).
|
| 165 |
|
| 166 |
+
- [x] P6.26 Add guarded CLI OBJ export and explicit lossy-export acknowledgement ([ledger](PROGRESS_2026-10-26.md)).
|
| 167 |
+
|
| 168 |
## P7 — Input, cameras, grids, views, and editing workflows
|
| 169 |
|
| 170 |
**Depends on:** P4 dispatch and P5 geometry. **Exit:** complete interaction/state-machine specifications and reproducible replays.
|