nmarshall commited on
Commit
49ec522
·
1 Parent(s): 3f3b143

cli: guarded obj export

Browse files
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| format!("{{\"code\":\"{}\",\"count\":{}}}", i.code, i.count))
 
 
 
 
 
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 | Pending |
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.