Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
66 kB · 1748 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749#![forbid(unsafe_code)]//! A deterministic, resolver-independent LDraw syntax and semantic core.
mod adapter;mod bfc;mod cache;mod geom;mod model;mod mpd;mod parser;mod scanner;mod semantic;mod texmap;mod traversal;mod types;
pub use adapter::*;pub use bfc::*;pub use cache::*;pub use geom::*;pub use model::*;pub use mpd::*;pub use parser::*;pub use scanner::*;pub use semantic::*;pub use texmap::*;
pub use types::*;
#[cfg(test)]mod tests { use super::*; fn options<'a>() -> ParseOptions<'a> { ParseOptions::new() }
#[derive(Default)] struct RecordingSink { geometry: std::collections::HashMap<CacheKey, Vec<GeometryRecord>>, }
impl SemanticSink for RecordingSink { fn on_event(&mut self, event: &SemanticEvent<'_>, _context: &TraversalContext) { let SemanticEvent::GeometryEmitted { primitive_id, source, span, line_type, colour, vertices, bfc, texmap, inverted, } = event else { return; }; self.geometry .entry(source.clone()) .or_default() .push(GeometryRecord { primitive_id: *primitive_id, source: source.clone(), span: *span, line_type: *line_type, colour: *colour, vertices: vertices.to_vec(), bfc: *bfc, texmap: texmap.cloned(), inverted: *inverted, }); } }
#[derive(Debug)] struct TestParseResult { result: OwnedParseResult, geometry: std::collections::HashMap<CacheKey, Vec<GeometryRecord>>, }
impl std::ops::Deref for TestParseResult { type Target = OwnedParseResult;
fn deref(&self) -> &Self::Target { &self.result } }
trait LdrawParserTestExt { fn parse_bytes<'src, 'a>( &self, bytes: &'src [u8], options: ParseOptions<'a>, materializations: &[Materialization], ) -> Result<TestParseResult, ParseError>; }
impl LdrawParserTestExt for LdrawParser { fn parse_bytes<'src, 'a>( &self, bytes: &'src [u8], options: ParseOptions<'a>, materializations: &[Materialization], ) -> Result<TestParseResult, ParseError> { let mut sink = RecordingSink::default(); let result = self.parse_bytes_with_sink(bytes, options, materializations, &mut sink)?; Ok(TestParseResult { result, geometry: sink.geometry, }) } }
fn adapter_parse<'src>(request: AdapterRequest<'src>) -> Result<TestParseResult, ParseError> { let mut sink = RecordingSink::default(); let result = InProcessRustAdapter::parse_with_limits_and_sink( request, LdrawLimits::default(), &mut sink, )?; Ok(TestParseResult { result, geometry: sink.geometry, }) }
fn adapter_parse_fixture<'a>( fixture_id: &'a str, bytes: &'a [u8], profile: ParserProfile, ) -> Result<TestParseResult, ParseError> { adapter_parse(AdapterRequest { bytes, profile, provenance_id: fixture_id, root_name: fixture_id, root: RootId::UploadedManifest, materializations: &[], target_selections: &[], cancellation: CancellationPolicy::default(), }) } #[test] fn normalized_paths_reject_rooted_include_forms() { for path in [ "/parts/a.dat", "\\\\server\\share\\a.dat", "C:/parts/a.dat", "z:\\parts\\a.dat", ] { assert!( matches!(NormalizedPath::new(path), Err(ResolverPathError::Absolute)), "path should be rejected: {path}" ); } assert_eq!( NormalizedPath::new("parts/./a.dat").unwrap().as_str(), "parts/a.dat" ); }
#[test] fn cancellation_zero_targets_next_checkpoint_and_default_is_disabled() { let cancellation = CancellationPolicy::default(); assert!(!cancellation.checkpoint()); cancellation.cancel_after_checkpoints(0); assert!(cancellation.checkpoint());
let cancellation = CancellationPolicy::default(); cancellation.cancel_after_checkpoints(2); assert!(!cancellation.checkpoint()); assert!(cancellation.checkpoint()); }
#[test] fn scanner_preserves_case_quotes_and_endings() { let lines = scan_lines( b"0 STEP\r\n0 step\n0 \"quoted identifier\"", &LdrawLimits::default(), ) .unwrap(); assert_eq!(lines[0].tokens[1].kind, TokenKind::Step); assert_eq!(lines[1].tokens[1].kind, TokenKind::Identifier); let rotstep = scan_lines(b"0 ROTSTEP 90 0 0", &LdrawLimits::default()).unwrap(); assert_eq!(rotstep[0].tokens[1].kind, TokenKind::Rotstep); assert_eq!(lines[2].tokens[1].kind, TokenKind::QuotedIdentifier); assert_eq!(lines[0].ending, LineEnding::CrLf); } #[test] fn mpd_discards_outside_file_content() { let lines = scan_lines( b"garbage\n0 FILE root.ldr\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\nignored", &LdrawLimits::default(), ) .unwrap(); let files = split_mpd(&lines, &LdrawLimits::default()).unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].lines.len(), 1); }
#[test] fn unterminated_quotes_are_rejected() { let error = scan_lines(b"0 FILE \"root.ldr\n", &LdrawLimits::default()) .expect_err("unterminated quote must not become a token"); assert!(matches!( error, LdrawError::Diagnostic(Diagnostic { message, .. }) if message.contains("unterminated quoted token") )); }
#[test] fn line_limit_is_per_physical_line() { let limits = LdrawLimits { line_bytes: 3, ..LdrawLimits::default() }; assert!(scan_lines(b"abc\ndef", &limits).is_ok()); assert!(scan_lines(b"abcd\n", &limits).is_err()); }
#[test] fn invalid_geometry_colour_does_not_publish_geometry_or_counts() { let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes( b"3 invalid 0 0 0 1 0 0 0 1 0\n3 16 0 0 0 1 0 0 0 1 0\n", parse_options, &[], ) .expect("compatibility profile should continue after invalid colour"); assert_eq!(event_geometry(&result.geometry, &result.models[0]).len(), 1); assert_eq!(result.models[0].triangles, 1); assert_eq!(result.models[0].quads, 0); } #[test] fn caller_materialization_controls_external_model_identity() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n"; let child = b"3 16 0 0 0 1 0 0 0 1 0\n"; let child_path = NormalizedPath::new("child.dat").unwrap(); let _materialization_key = CacheKey::new(child_path.clone(), RootId::OfficialLibrary, child); let owned_materialization = Materialization::new( child_path.clone(), RootId::OfficialLibrary, child.to_vec(), true, Some("caller-materialization".into()), ); let result = adapter_parse(AdapterRequest { bytes, profile: ParserProfile::Compatibility, provenance_id: "caller-provenance", root_name: "root.ldr", root: RootId::UploadedManifest, materializations: std::slice::from_ref(&owned_materialization), target_selections: &[], cancellation: CancellationPolicy::default(), }) .unwrap(); assert!(result.models.iter().any(|model| model.path == "child.dat")); assert_eq!(result.provenance_id, "caller-provenance"); }
#[test] fn semantic_events_are_emitted_and_include_context() { let bytes = b"0 FILE root.ldr\n1 4 1 2 3 1 0 0 0 1 0 0 0 1 child.dat\n3 16 0 0 0 1 0 0 0 1 0\n0 STEP\n"; let child = b"3 16 0 0 0 1 0 0 0 1 0\n"; let child_path = NormalizedPath::new("child.dat").unwrap(); let materialization = Materialization::new( child_path, RootId::UploadedLdraw, child.to_vec(), true, None, ); #[derive(Default)] struct RecordingSink { geometry: usize, includes: Vec<(Transform, Option<Span>)>, } impl SemanticSink for RecordingSink { fn on_event(&mut self, event: &SemanticEvent<'_>, context: &TraversalContext) { match event { SemanticEvent::GeometryEmitted { .. } => self.geometry += 1, SemanticEvent::IncludeInstantiated { transform, .. } => { self.includes.push((*transform, context.source_span)) } _ => {} } } } let mut sink = RecordingSink::default(); LdrawParser .parse_bytes_with_sink(bytes, options(), &[materialization], &mut sink) .expect("semantic event fixture should parse"); assert_eq!(sink.geometry, 2); assert_eq!(sink.includes.len(), 1); let include = sink .includes .first() .copied() .expect("include event should be present"); assert_eq!(include.0.translation, Point3::new(1.0, 2.0, 3.0)); assert!(include.1.is_some()); }
#[test] fn type1_instance_budget_accepts_many_instances() { const N: usize = 128; let bytes = (0..N) .map(|index| format!("1 16 0 0 0 1 0 0 0 1 0 0 0 0 missing-{index:03}.dat\n")) .collect::<String>(); let parse_options = options(); let result = LdrawParser .parse_bytes(bytes.as_bytes(), parse_options, &[]) .expect("type-1 instances should parse within the semantic budget"); assert_eq!(result.models[0].includes.len(), N); }
#[test] fn conditional_geometry_exposes_endpoints_and_controls() { let result = LdrawParser .parse_bytes(b"5 16 0 0 0 1 0 0 0 1 0 2 0 0\n", options(), &[]) .unwrap(); let model_geometry = event_geometry(&result.geometry, &result.models[0]); let geometry = &model_geometry[0]; let (endpoints, controls) = geometry .conditional_endpoints() .expect("type-5 geometry should expose its point roles"); assert_eq!(endpoints[0], Point3::new(0.0, 0.0, 0.0)); assert_eq!(endpoints[1], Point3::new(1.0, 0.0, 0.0)); assert_eq!(controls[0], Point3::new(0.0, 1.0, 0.0)); assert_eq!(controls[1], Point3::new(2.0, 0.0, 0.0)); let mut ordinary_line = geometry.clone(); ordinary_line.line_type = LineType::Two; ordinary_line.vertices.clear(); assert!(ordinary_line.conditional_endpoints().is_none()); } #[test] fn bfc_state_machine_certify_then_nocertify() { let fixture = corpus_fixture("bfc-directives"); let bytes = fixture.bytes; let nocertify = b"0 BFC NOCERTIFY\n"; let nocertify_start = bytes .windows(nocertify.len()) .position(|window| window == nocertify) .expect("BFC fixture NOCERTIFY line");
let certified = LdrawParser .parse_bytes(&bytes[..nocertify_start], options(), &[]) .expect("CERTIFY should certify the model before NOCERTIFY"); assert_eq!(certified.semantic.bfc_state, BfcState::Certified);
let result = LdrawParser .parse_bytes(bytes, options(), &[]) .expect("complete BFC fixture should parse"); assert_eq!(result.semantic.bfc_state, BfcState::Uncertified); assert_eq!(result.scene.triangles, 2); assert_eq!(result.scene.lines, 0); assert_eq!(result.semantic.steps.len(), 1); assert!(result.scene.instance_ids.is_empty()); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::UnresolvedReference) ); } #[test] fn graph_ids_are_stable() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n0 FILE child.dat\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\n"; let first = LdrawParser.parse_bytes(bytes, options(), &[]).unwrap(); assert_eq!(first.scene.model_id, "model-00000001"); assert_eq!(first.scene.instance_ids, vec!["instance-00000001"]); assert_eq!( first .models .iter() .map(|model| model.id.as_str()) .collect::<Vec<_>>(), vec!["model-00000001", "model-00000002"] );
let second = LdrawParser.parse_bytes(bytes, options(), &[]).unwrap(); assert_eq!(second.scene.model_id, first.scene.model_id); assert_eq!(second.scene.instance_ids, first.scene.instance_ids); } #[test] fn repeated_instances_expand_subtrees() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n1 16 4 0 0 -1 0 0 0 1 0 0 0 1 child.dat\n0 NOFILE\n0 FILE child.dat\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 grand.dat\n0 NOFILE\n0 FILE grand.dat\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\n"; let result = LdrawParser .parse_bytes(bytes, options(), &[]) .expect("repeated child instances should parse");
assert_eq!(result.scene.triangles, 2); assert_eq!( result.scene.instance_ids, vec![ "instance-00000001", "instance-00000002", "instance-00000003", "instance-00000004", ] ); assert_eq!(result.scene.instances.len(), 4); assert_eq!( result.models.len(), 3, "each cache key is materialized once" ); assert!(result.scene.reflection); assert_eq!( result.scene.bounds, Some(Bounds3 { min: Point3::new(0.0, 0.0, 0.0), max: Point3::new(4.0, 1.0, 0.0), }) ); assert!(!result.scene.instances[0].inverted); assert!(result.scene.instances[1].inverted); assert!(!result.scene.instances[2].inverted); assert!(result.scene.instances[3].inverted); }
#[test] fn materialization_selection_preserves_exact_root_and_hash() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n"; let child_path = NormalizedPath::new("child.dat").unwrap(); let wrong_root = b"2 16 0 0 0 1 0 0\n"; let selected = b"3 16 0 0 0 1 0 0 0 1 0\n"; let materializations = [ Materialization::new( child_path.clone(), RootId::UploadedManifest, wrong_root.to_vec(), true, Some("wrong-root".into()), ), Materialization::new( child_path.clone(), RootId::OfficialLibrary, selected.to_vec(), true, Some("selected-root".into()), ), ]; let mut parse_options = options(); parse_options.resolved_root = RootId::OfficialLibrary; let result = LdrawParser .parse_bytes(bytes, parse_options, &materializations) .expect("root-preferred materialization should parse");
assert_eq!(result.scene.triangles, 1); assert_eq!(result.scene.quads, 0); let selected_key = CacheKey::new(child_path, RootId::OfficialLibrary, selected); let child_summary = result .models .iter() .find(|model| model.path == "child.dat") .expect("selected child should be traversed"); assert_eq!(child_summary.cache_key, selected_key); assert_eq!( child_summary.cache_key.resolved_root, RootId::OfficialLibrary ); assert_eq!( child_summary.cache_key.content_hash, selected_key.content_hash ); }
#[test] fn limits_reject_before_publication() { let mut o = options(); o.limits.triangles = 0; let error = match LdrawParser.parse_bytes(b"3 16 0 0 0 1 0 0 0 1 0\n", o, &[]) { Ok(_) => panic!("triangle limit should reject before publication"), Err(error) => error, }; assert!(matches!( error, LdrawError::Diagnostic(Diagnostic { code: DiagnosticCode::TrianglesLimit, .. }) )); }
#[test] fn quad_geometry_counts_as_two_triangles_for_limits() { let mut parse_options = options(); parse_options.limits.triangles = 1; let error = LdrawParser .parse_bytes(b"4 16 0 0 0 1 0 0 0 1 0 0 0 1\n", parse_options, &[]) .expect_err("a quad must consume two triangle-limit units"); assert!(matches!( error, LdrawError::Diagnostic(Diagnostic { code: DiagnosticCode::TrianglesLimit, .. }) )); }
#[test] fn repeated_instances_charge_only_rendered_geometry_against_limits() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n1 16 2 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n0 FILE child.dat\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\n";
let mut passing_options = options(); passing_options.limits.triangles = 2; LdrawParser .parse_bytes(bytes, passing_options, &[]) .expect("two child occurrences render two triangles");
let mut failing_options = options(); failing_options.limits.triangles = 1; let error = LdrawParser .parse_bytes(bytes, failing_options, &[]) .expect_err("two rendered occurrences must exceed a one-triangle limit"); assert!(matches!( error, LdrawError::Diagnostic(Diagnostic { code: DiagnosticCode::TrianglesLimit, .. }) )); }
#[test] fn colour_scope_commit_rollback_and_edge_lookup_are_transactional() { let mut scope = ColourScope::new(); let base = Colour { code: ColourCode(700), name: Some("base".into()), data: ColourData { r: 1, g: 2, b: 3, alpha: None, luminance: None, }, edge: ColourEdge::Data(ColourData { r: 4, g: 5, b: 6, alpha: None, luminance: None, }), finishes: Vec::new(), material: None, }; let base_slot = scope.record(base.clone()).expect("first local slot"); assert_eq!(base_slot, 512); assert!( scope.get(700_u16).is_none(), "active definitions are hidden" ); scope.commit(); assert_eq!(scope.get(700_u16).unwrap().data.r, 1); assert_eq!(scope.find(700_u16, true).unwrap().r, 4);
let staged = Colour { code: ColourCode(701), ..base }; assert_eq!(scope.record(staged).unwrap(), 513); assert!( scope.get(700_u16).is_none(), "lookups stay isolated during a transaction" ); scope.rollback(); assert!(scope.get(701_u16).is_none()); let next = Colour { code: ColourCode(702), name: Some("next".into()), data: ColourData { r: 7, g: 8, b: 9, alpha: None, luminance: None, }, edge: ColourEdge::Code(ColourCode::from(700_u16)), finishes: Vec::new(), material: None, }; assert_eq!( scope.record(next).unwrap(), 513, "rollback must not leak a slot" ); scope.commit(); assert_eq!(scope.find(702_u16, true).unwrap().r, 1); }
#[test] fn colour_scope_has_deterministic_512_slot_boundary_and_exhaustion() { let mut scope = ColourScope::new(); for index in 0..65_023_u16 { let colour = Colour { code: ColourCode(700_u32 + u32::from(index)), name: None, data: ColourData { r: 1, g: 2, b: 3, alpha: None, luminance: None, }, edge: ColourEdge::Code(ColourCode::from(16_u16)), finishes: Vec::new(), material: None, }; let expected_slot = 512_u32 + u32::from(index); assert_eq!(scope.record(colour).unwrap(), expected_slot as u16); } assert_eq!(scope.local_slot(700_u16), Some(512)); assert_eq!(scope.local_slot(ColourCode(65_722)), Some(65_534)); scope.commit(); assert!( scope .record(Colour { code: ColourCode(65_723), name: None, data: ColourData { r: 1, g: 2, b: 3, alpha: None, luminance: None, }, edge: ColourEdge::Code(ColourCode::from(16_u16)), finishes: Vec::new(), material: None, }) .is_err() );
let mut rolled_back = ColourScope::new(); rolled_back .record(Colour { code: ColourCode(900), name: None, data: ColourData { r: 1, g: 2, b: 3, alpha: None, luminance: None, }, edge: ColourEdge::Code(ColourCode::from(16_u16)), finishes: Vec::new(), material: None, }) .unwrap(); rolled_back.rollback(); assert_eq!( rolled_back .record(Colour { code: ColourCode(901), name: None, data: ColourData { r: 1, g: 2, b: 3, alpha: None, luminance: None, }, edge: ColourEdge::Code(ColourCode::from(16_u16)), finishes: Vec::new(), material: None, }) .unwrap(), 512 ); }
#[test] fn same_root_hash_requires_and_honours_exact_selection() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n"; let path = NormalizedPath::new("child.dat").unwrap(); let first = b"3 16 0 0 0 1 0 0 0 1 0\n"; let second = b"4 16 0 0 0 1 0 0 0 1 0 0 1 0\n"; let materializations = [ Materialization::new( path.clone(), RootId::OfficialLibrary, first.to_vec(), true, Some("first".into()), ), Materialization::new( path.clone(), RootId::OfficialLibrary, second.to_vec(), true, Some("second".into()), ), ]; let error = LdrawParser .parse_bytes(bytes, options(), &materializations) .expect_err("same-root candidates must not be ordered implicitly"); assert!(matches!( error, ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::AmbiguousMaterialization, .. }) ));
let source = CacheKey::new_from_parts( NormalizedPath::new("root.ldr").unwrap(), RootId::UploadedManifest, [b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n".as_slice()], ); let selected_key = CacheKey::new(path, RootId::OfficialLibrary, second); let selection = TargetSelection { source, span: Span { start: 16, end: 54, line: 2, column: 1, }, target: selected_key.clone(), }; let mut parse_options = options(); parse_options.target_selections = std::slice::from_ref(&selection); let result = LdrawParser .parse_bytes(bytes, parse_options, &materializations) .expect("exact selected same-root target should parse"); assert_eq!(result.scene.instances[0].target_key, Some(selected_key)); assert_eq!(result.scene.quads, 1); }
#[test] fn cancellation_unwinds_partial_graph() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n0 FILE child.dat\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 grand.dat\n0 NOFILE\n0 FILE grand.dat\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\n"; let cancellation = CancellationPolicy::new(); cancellation.cancel_after_checkpoints(1); let mut parse_options = options(); parse_options.cancellation = cancellation; assert!(matches!( LdrawParser.parse_bytes(bytes, parse_options, &[]), Err(ParseError::Cancelled) )); }
#[test] fn include_occurrences_snapshot_parent_semantic_state() { let bytes = b"0 FILE root.ldr\n0 BFC CERTIFY CW\n0 !COLOUR Red CODE 4 VALUE #ff0000 EDGE #333333\n0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 red.png\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 !TEXMAP END\n0 !COLOUR Blue CODE 2 VALUE #0000ff EDGE #333333\n0 BFC NOCERTIFY\n1 16 2 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n"; let child = Materialization::new( NormalizedPath::new("child.dat").unwrap(), RootId::OfficialLibrary, b"3 16 0 0 0 1 0 0 0 1 0\n".to_vec(), true, None, ); let result = adapter_parse(AdapterRequest { bytes, profile: ParserProfile::Compatibility, provenance_id: "occurrence-state", root_name: "root.ldr", root: RootId::UploadedManifest, materializations: std::slice::from_ref(&child), target_selections: &[], cancellation: CancellationPolicy::default(), }) .expect("occurrence state fixture should parse"); let instances = &result.scene.instances; assert_eq!(instances.len(), 2); assert_eq!(instances[0].colour, Some(ColourCode::from(4_u16))); assert_eq!(instances[1].colour, Some(ColourCode::from(2_u16))); assert_eq!(instances[0].texmap_state, Some(TexmapState::Active)); assert_eq!(instances[0].texmap_reference.as_deref(), Some("red.png")); assert_eq!(instances[1].texmap_state, Some(TexmapState::Inactive)); assert_ne!(instances[0].bfc, instances[1].bfc); assert_ne!(instances[0].transform, instances[1].transform); }
#[test] fn corpus_fixtures_follow_declared_outcomes() { for fixture in polymodel_ldraw_testkit::inventory() { let profile = match fixture.expected.profile { polymodel_ldraw_testkit::Profile::Strict => ParserProfile::Strict, polymodel_ldraw_testkit::Profile::Compatibility => ParserProfile::Compatibility, polymodel_ldraw_testkit::Profile::Lossless => ParserProfile::Lossless, }; let result = adapter_parse_fixture(fixture.id, fixture.bytes, profile); match fixture.expected.outcome { polymodel_ldraw_testkit::ExpectedOutcome::Rejected => { assert_eq!( fixture.expected.oracle_expected.status, "error", "{} rejected fixture must have an oracle error projection", fixture.id ); let error = result.err().unwrap_or_else(|| { panic!("{} rejected fixture unexpectedly published", fixture.id) }); match error { LdrawError::Diagnostic(_) => {} other => panic!("{} rejected with unexpected error: {other}", fixture.id), } } polymodel_ldraw_testkit::ExpectedOutcome::Accepted | polymodel_ldraw_testkit::ExpectedOutcome::Preserved | polymodel_ldraw_testkit::ExpectedOutcome::Limited | polymodel_ldraw_testkit::ExpectedOutcome::Cancelled => { let parsed = result.unwrap_or_else(|error| { panic!("{} expected a published parse result: {error}", fixture.id) }); assert_eq!( parsed.provenance_id, fixture.id, "fixture provenance must survive adapter parsing" ); let projected = parsed.project(); assert_eq!(projected.schema_version, SCHEMA_VERSION); assert_eq!( projected.provenance_id, fixture.id, "{} provenance", fixture.id ); if fixture.expected.syntax_expected.is_empty() { assert!( !projected.syntax.is_empty(), "{} accepted fixture should produce syntax records", fixture.id ); } else { let actual_syntax = projected .syntax .iter() .map(|record| { serde_json::to_value(record).expect("syntax records serialize") }) .collect::<Vec<_>>(); polymodel_ldraw_testkit::compare_syntax_records( fixture.id, &fixture.expected.syntax_expected, &actual_syntax, ) .unwrap_or_else(|error| panic!("{error}")); } let expected_policy = &fixture.expected.polymodel_policy_expected; let actual_events = serde_json::Value::Array( projected .semantic .texmap_events .iter() .map(|event| { serde_json::json!({ "kind": event.kind, "mode": event.mode, "parameters": event.parameters, "reference": event.reference, "state": event.state, }) }) .collect(), ); let actual_geometry = serde_json::Value::Array( projected .semantic .texmap_geometry .iter() .map(|geometry| { serde_json::json!({ "line_type": geometry.line_type, "association": geometry.association, }) }) .collect(), ); let actual_data = serde_json::to_value(&projected.semantic.data_payloads) .expect("DATA payloads should serialize"); if fixture.id.starts_with("pm82-") { assert_eq!( actual_events, serde_json::Value::Array(expected_policy.texmap_events.clone()), "{} TEXMAP events", fixture.id ); assert_eq!( actual_geometry, serde_json::Value::Array(expected_policy.texmap_geometry.clone()), "{} TEXMAP geometry", fixture.id ); assert_eq!( actual_data, serde_json::Value::Array(expected_policy.data_payloads.clone()), "{} DATA payloads", fixture.id ); assert_eq!( projected .semantic .limits .iter() .map(ToString::to_string) .collect::<Vec<_>>(), expected_policy.limits, "{} policy limits", fixture.id ); } if fixture.expected.outcome == polymodel_ldraw_testkit::ExpectedOutcome::Cancelled { assert!( !parsed.diagnostics.is_empty(), "cancelled fixture must retain its diagnostic evidence" ); } } } } }
#[test] fn syntax_projection_comparison_rejects_empty_and_mutated_records() { let fixture = corpus_fixture("syntax-types"); let parsed = adapter_parse_fixture(fixture.id, fixture.bytes, ParserProfile::Strict) .expect("syntax fixture should parse"); let projected = parsed.project(); let empty = Vec::new(); let error = polymodel_ldraw_testkit::compare_syntax_records( fixture.id, &fixture.expected.syntax_expected, &empty, ) .expect_err("empty candidate syntax projection must fail"); assert!(error.contains("record count mismatch"));
let mut expected = fixture.expected.syntax_expected.clone(); expected[1].fields[1].push_str("-tampered"); let actual_syntax = projected .syntax .iter() .map(|record| serde_json::to_value(record).expect("syntax records serialize")) .collect::<Vec<_>>(); let error = polymodel_ldraw_testkit::compare_syntax_records(fixture.id, &expected, &actual_syntax) .expect_err("mutated syntax field must fail"); assert!(error.contains("fields mismatch")); }
#[test] fn syntax_comparison_rejects_non_string_fields() { let malformed = vec![serde_json::json!({ "line_type": 1u8, "raw_tail": " data", "fields": ["ok", 42, "bad"] })]; let expected = vec![polymodel_ldraw_testkit::ExpectedSyntaxRecord { line_type: 1u8, raw_tail: " data".to_owned(), fields: vec!["ok".to_owned(), "42".to_owned(), "bad".to_owned()], }]; let error = polymodel_ldraw_testkit::compare_syntax_records("test", &expected, &malformed) .expect_err("non-string field element must be rejected"); assert!(error.contains("not a string")); }
fn corpus_fixture(id: &str) -> polymodel_ldraw_testkit::FixtureCase { polymodel_ldraw_testkit::inventory() .into_iter() .find(|fixture| fixture.id == id) .unwrap_or_else(|| panic!("missing corpus fixture {id}")) }
fn assert_geometry(geometry: &[GeometryRecord], expected: &[(u8, u16, &[Point3])]) { const EPSILON: f64 = 1e-9;
assert_eq!(geometry.len(), expected.len(), "geometry record count"); for (index, (record, (line_type, colour, vertices))) in geometry.iter().zip(expected).enumerate() { let line_type = LineType::from(*line_type); assert_eq!(record.line_type, line_type, "geometry[{index}] line type"); assert_eq!( record.colour, ColourCode::from(*colour), "geometry[{index}] colour" ); assert_eq!( record.vertices.len(), vertices.len(), "geometry[{index}] vertex count" ); for (vertex_index, (actual, expected)) in record.vertices.iter().zip(*vertices).enumerate() { for (axis, actual, expected) in [ ("x", actual.x, expected.x), ("y", actual.y, expected.y), ("z", actual.z, expected.z), ] { assert!( (actual - expected).abs() <= EPSILON, "geometry[{index}].vertices[{vertex_index}].{axis}: got {actual}, expected {expected}" ); } } } }
fn event_geometry( geometry: &std::collections::HashMap<CacheKey, Vec<GeometryRecord>>, model: &ModelSummary, ) -> Vec<GeometryRecord> { geometry.get(&model.cache_key).cloned().unwrap_or_default() }
fn assert_scene_counts( result: &ParseResult<'_>, triangles: u64, quads: u64, lines: u64, conditional_lines: u64, ) { assert_eq!(result.scene.triangles, triangles, "triangle count"); assert_eq!(result.scene.quads, quads, "quad count"); assert_eq!(result.scene.lines, lines, "line count"); assert_eq!( result.scene.conditional_lines, conditional_lines, "conditional line count" ); }
fn assert_sticker_geometry(result: &TestParseResult) { let sticker = result .models .iter() .find(|model| model.path == "sticker.ldr") .expect("sticker sub-model"); let sticker_geometry = event_geometry(&result.geometry, sticker); assert_geometry( &sticker_geometry, &[( 4, 16, &[ Point3::new(-20.0, -0.25, 30.0), Point3::new(-20.0, -0.25, -30.0), Point3::new(20.0, -0.25, -30.0), Point3::new(20.0, -0.25, 30.0), ], )], ); for model in &result.models { if model.path != "sticker.ldr" { assert!( event_geometry(&result.geometry, model).is_empty(), "non-sticker model {} unexpectedly contains geometry", model.path ); } } }
#[test] fn corpus_bfc_transitions_from_certified_to_uncertified() { let fixture = corpus_fixture("bfc-invertnext"); let result = adapter_parse_fixture(fixture.id, fixture.bytes, ParserProfile::Strict).unwrap(); assert_eq!(result.models.len(), 1); assert_eq!(result.models[0].bfc, BfcState::Uncertified); assert_eq!(result.semantic.bfc_state, BfcState::Uncertified); assert_eq!(result.scene.triangles, 0); }
#[test] fn corpus_bfc_directives_have_oracle_geometry() { let fixture = corpus_fixture("bfc-directives"); let result = LdrawParser .parse_bytes(fixture.bytes, options(), &[]) .expect("BFC directives fixture should parse");
assert_scene_counts(&result, 2, 0, 0, 0); assert_geometry( &event_geometry(&result.geometry, &result.models[0]), &[ ( 3, 16, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 1.0, 0.0), ], ), ( 3, 16, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(2.0, 0.0, 0.0), Point3::new(0.0, 2.0, 0.0), ], ), ], ); }
#[test] fn corpus_mpd_contains_two_virtual_files() { let fixture = corpus_fixture("mpd-data"); let result = adapter_parse_fixture(fixture.id, fixture.bytes, ParserProfile::Strict).unwrap(); assert_eq!(result.files.len(), 3); assert_eq!( result .files .iter() .map(|file| file.name.as_str()) .collect::<Vec<_>>(), vec!["main.ldr", "house.ldr", "sticker.ldr"] ); assert_scene_counts(&result, 0, 5, 0, 0); assert_sticker_geometry(&result); }
#[test] fn literal_stop_is_not_rewritten_to_end() { let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes( b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png\n0 !TEXMAP STOP\n", parse_options, &[], ) .expect("compatibility profile should preserve unknown STOP"); assert_eq!( result .semantic .texmap_events .iter() .map(|event| event.kind) .collect::<Vec<_>>(), vec![TexmapKind::Start] ); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::TexmapUnknownMetaStop) ); }
#[test] fn corpus_texmap_emits_ordered_events() { let fixture = corpus_fixture("texmap-events"); let bytes = fixture.bytes; let result = LdrawParser .parse_bytes(bytes, options(), &[]) .expect("TEXMAP fixture should parse"); assert_eq!(result.semantic.texmap_events.len(), 4); assert_eq!( result .semantic .texmap_events .iter() .map(|event| event.kind) .collect::<Vec<_>>(), vec![ TexmapKind::Start, TexmapKind::Next, TexmapKind::Fallback, TexmapKind::End ] ); let start = &result.semantic.texmap_events[0]; assert_eq!(start.mode, Some(TexmapProjectionMode::Planar)); assert_eq!(start.reference.as_deref(), Some("sticker.png")); assert_scene_counts(&result, 2, 1, 0, 0); assert_geometry( &event_geometry(&result.geometry, &result.models[0]), &[ ( 3, 16, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(0.0, 10.0, 0.0), ], ), ( 3, 16, &[ Point3::new(0.0, 0.0, 1.0), Point3::new(10.0, 0.0, 1.0), Point3::new(0.0, 10.0, 1.0), ], ), ( 4, 16, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(10.0, 0.0, 10.0), Point3::new(10.0, 0.0, 0.0), ], ), ], ); }
#[test] fn texmap_bang_colon_projects_geometry_ownership() { let cases = [ ("1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat", LineType::One), ("2 16 0 0 0 10 0 0", LineType::Two), ("3 16 0 0 0 10 0 0 0 10 0", LineType::Three), ("4 16 0 0 0 10 0 0 0 10 0 0 0 10", LineType::Four), ("5 16 0 0 0 10 0 0 0 10 0 0 0 10", LineType::Five), ]; for (embedded, expected_type) in cases { let bytes = format!( "0 !TEXMAP START PLANAR 0 0 0 10 0 0 0 10 0 sticker.png\n0 !: {embedded}\n0 !TEXMAP END\n" ); let result = LdrawParser .parse_bytes(bytes.as_bytes(), options(), &[]) .expect("valid bang-colon geometry should parse"); let model_geometry = event_geometry(&result.geometry, &result.models[0]); assert_eq!( model_geometry.len(), usize::from(expected_type != LineType::One) ); if expected_type == LineType::One { assert_eq!(result.models[0].includes.len(), 1); assert!(result.scene.instances.is_empty()); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::UnresolvedReference) ); } else { let geometry = model_geometry.last().unwrap(); assert_eq!(geometry.line_type, expected_type); assert_eq!( geometry.texmap.as_ref().map(|value| value.pngfile.as_str()), Some("sticker.png") ); assert_eq!(result.semantic.texmap_geometry.len(), 1); assert_eq!(result.semantic.texmap_geometry[0].line_type, expected_type); } } let result = LdrawParser .parse_bytes( b"0 !TEXMAP START PLANAR 0 0 0 10 0 0 0 10 0 sticker.png\n0 !TEXMAP FALLBACK\n0 !: 3 16 0 0 0 10 0 0 0 10 0\n0 !TEXMAP END\n", options(), &[], ) .expect("fallback bang-colon geometry should parse"); let model_geometry = event_geometry(&result.geometry, &result.models[0]); assert!(model_geometry[0].texmap.as_ref().unwrap().fallback); }
#[test] fn texmap_bang_colon_rejects_invalid_without_partial_geometry() { let inputs = [ b"0 !: 3 16 0 0 0 1 0 0 0 1 0\n".as_slice(), b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png\n0 !: 3 16 0 0 0\n0 !TEXMAP END\n".as_slice(), b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png\n0 !: 6 16 0 0 0\n0 !TEXMAP END\n".as_slice(), b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png\n0 !: 3 16 0 0 0 !: 1\n0 !TEXMAP END\n".as_slice(), ]; let expected = [ DiagnosticCode::TexmapBangColonOutOfScope, DiagnosticCode::TexmapInvalidBangColonGeometry, DiagnosticCode::TexmapInvalidBangColonGeometry, DiagnosticCode::TexmapNestedBangColon, ]; for (bytes, code) in inputs.into_iter().zip(expected) { let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes(bytes, parse_options, &[]) .expect("compatibility should retain typed diagnostics"); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == code) ); assert!(event_geometry(&result.geometry, &result.models[0]).is_empty()); } }
#[test] fn texmap_next_requires_parameters_and_is_transactional() { let bare = b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png0 !TEXMAP NEXT"; let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes(bare, parse_options, &[]) .expect("compatibility should preserve bare NEXT diagnostic"); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::TexmapNextNested) ); assert_eq!(result.semantic.texmap_events.len(), 1);
let nested = b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png0 !TEXMAP NEXT"; let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes(nested, parse_options, &[]) .expect("compatibility should preserve nested NEXT diagnostic"); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::TexmapNextNested) ); assert_eq!(result.semantic.texmap_events.len(), 1); assert_eq!( result.semantic.texmap_events[0].reference.as_deref(), Some("sticker.png") );
let nested_parameterized = b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png0 !TEXMAP NEXT CYLINDRICAL 0 0 0 0 1 0 0 0 1 360 other.png"; let result = LdrawParser .parse_bytes(nested_parameterized, options(), &[]) .expect("parameterized nested NEXT should replace the active mapping"); assert!(result.diagnostics.is_empty()); assert_eq!(result.semantic.texmap_events.len(), 2); assert_eq!( result.semantic.texmap_events[1].reference.as_deref(), Some("other.png") );
for (method, parameters) in [ ("PLANAR", "0 0 0 1 0 0 0 1 0"), ("CYLINDRICAL", "0 0 0 0 1 0 0 0 1 360"), ("SPHERICAL", "0 0 0 0 1 0 0 0 1 360 180"), ] { let bytes = format!( "0 !TEXMAP NEXT {method} {parameters} next.png GLOSSMAP gloss.png\n3 16 0 0 0 10 0 0 0 10 0\n" ); let result = LdrawParser .parse_bytes(bytes.as_bytes(), options(), &[]) .expect("parameterized NEXT should parse"); assert_eq!(result.semantic.texmap_events[0].kind, TexmapKind::Next); assert_eq!( result.semantic.texmap_events[0].reference.as_deref(), Some("next.png") ); let model_geometry = event_geometry(&result.geometry, &result.models[0]); assert_eq!( model_geometry[0] .texmap .as_ref() .and_then(|value| value.glossmap.as_deref()), Some("gloss.png") ); assert_eq!( result.semantic.texmap_geometry[0] .association .glossmap .as_deref(), Some("gloss.png") ); } let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes( b"0 !TEXMAP NEXT PLANAR 0 0 0 1 0 0 0 1 0 next.png\n3 invalid 0 0 0 10 0 0 0 10 0\n3 16 0 0 0 10 0 0 0 10 0\n", parse_options, &[], ) .expect("compatibility should recover a malformed NEXT geometry line"); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::InvalidColour) ); let model_geometry = event_geometry(&result.geometry, &result.models[0]); assert_eq!(model_geometry.len(), 1); assert_eq!( model_geometry[0] .texmap .as_ref() .map(|value| value.pngfile.as_str()), Some("next.png") ); }
#[test] fn corpus_syntax_recognizes_all_line_types() { let fixture = corpus_fixture("syntax-all-types"); let bytes = fixture.bytes; let result = LdrawParser .parse_bytes(bytes, options(), &[]) .expect("all-types fixture should parse"); assert_eq!(result.syntax.len(), 6); assert_eq!( result .syntax .iter() .map(|line| line.line_type) .collect::<Vec<_>>(), vec![ Some(LineType::Zero), Some(LineType::One), Some(LineType::Two), Some(LineType::Three), Some(LineType::Four), Some(LineType::Five), ] ); assert_scene_counts(&result, 1, 1, 1, 1); assert_geometry( &event_geometry(&result.geometry, &result.models[0]), &[ ( 2, 24, &[Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0)], ), ( 3, 4, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(0.0, 10.0, 0.0), ], ), ( 4, 2, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(10.0, 0.0, 10.0), Point3::new(10.0, 0.0, 0.0), ], ), ( 5, 16, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(0.0, 10.0, 0.0), Point3::new(0.0, 10.0, 0.0), ], ), ], ); assert_eq!(result.models[0].includes.len(), 1); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::UnresolvedReference) ); }
#[test] fn colour_finish_and_material_forms_are_typed_and_malformed_forms_reject() { let valid = [ ("CHROME", "finishes", ""), ("PEARLESCENT", "finishes", ""), ("RUBBER", "finishes", ""), ("MATTE_METALLIC", "finishes", ""), ("METAL", "finishes", ""), ( "MATERIAL GLITTER VALUE #010203 FRACTION .5 VFRACTION .25 SIZE 2", "material", "", ), ( "MATERIAL SPECKLE VALUE #010203 FRACTION .5 MINSIZE 1 MAXSIZE 2", "material", "", ), ("MATERIAL FABRIC VELVET", "material", ""), ("MATERIAL FABRIC", "material", ""), ]; for (suffix, _, _) in valid { let bytes = format!("0 !COLOUR Test CODE 700 VALUE #112233 EDGE #445566 {suffix}\n"); let result = LdrawParser .parse_bytes(bytes.as_bytes(), options(), &[]) .unwrap_or_else(|error| panic!("valid colour form {suffix:?} rejected: {error}")); let colour = result.colours.get(700_u16).unwrap(); assert!(colour.material.is_some() || !colour.finishes.is_empty()); match suffix { "MATERIAL GLITTER VALUE #010203 FRACTION .5 VFRACTION .25 SIZE 2" => { let material = colour.material.as_ref().unwrap(); assert_eq!(material.typed_kind, ColourMaterialKind::Glitter); assert!(material.vfraction.is_some()); assert!(material.size.is_some()); assert!(material.min_size.is_none()); assert!(material.max_size.is_none()); } "MATERIAL SPECKLE VALUE #010203 FRACTION .5 MINSIZE 1 MAXSIZE 2" => { let material = colour.material.as_ref().unwrap(); assert_eq!(material.typed_kind, ColourMaterialKind::Speckle); assert!(material.vfraction.is_none()); assert!(material.size.is_none()); assert!(material.min_size.is_some()); assert!(material.max_size.is_some()); } _ => {} } } for suffix in [ "CHROME METAL", "MATERIAL GLITTER VALUE #010203 FRACTION .5 SIZE 2", "MATERIAL SPECKLE VALUE #010203 FRACTION .5 VFRACTION .25 SIZE 2", "MATERIAL GLITTER VALUE #010203 FRACTION .5 VFRACTION .25 MINSIZE 1", "MATERIAL FABRIC UNKNOWN", ] { let bytes = format!("0 !COLOUR Test CODE 700 VALUE #112233 EDGE #445566 {suffix}\n"); let mut parse_options = options(); parse_options.profile = ParserProfile::Strict; let error = match LdrawParser.parse_bytes(bytes.as_bytes(), parse_options, &[]) { Ok(result) => { panic!("malformed material/finish {suffix:?} was accepted: {result:?}") } Err(error) => error, }; assert!(matches!( error, ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::InvalidColour, .. }) )); } }
#[test] fn data_payloads_decode_and_invalid_payloads_diagnose() { let result = LdrawParser .parse_bytes( b"0 !DATA sample.bin\n0 !: aGVsbG8=\n0 STEP\n", options(), &[], ) .expect("valid DATA should parse"); assert_eq!(result.semantic.data_payloads.len(), 1); assert_eq!(result.semantic.data_payloads[0].filename, "sample.bin"); assert_eq!(result.semantic.data_payloads[0].bytes, b"hello"); let error = match LdrawParser.parse_bytes(b"0 !DATA sample.bin\n", options(), &[]) { Ok(_) => panic!("truncated DATA should reject in strict mode"), Err(error) => error, }; assert!(matches!( error, LdrawError::Diagnostic(Diagnostic { code: DiagnosticCode::DataTruncated, .. }) )); }
#[test] fn corpus_colours_registers_ldconfig_code_300() { let fixture = corpus_fixture("colour-definitions"); let bytes = fixture.bytes; let result = LdrawParser .parse_bytes(bytes, options(), &[]) .expect("colour definitions fixture should parse");
let bright_red = result.colours.get(300u16).expect("colour code 300"); assert_eq!(bright_red.data.r, 0xC9); assert_eq!(bright_red.data.g, 0x1A); assert_eq!(bright_red.data.b, 0x09); assert_eq!(bright_red.data.alpha, Some(128)); assert_eq!(bright_red.data.luminance, Some(50)); assert_eq!(bright_red.edge, ColourEdge::Code(ColourCode(0)));
let edge_data = result.colours.get(301u16).expect("colour code 301"); assert_eq!(edge_data.data.r, 0x12); assert_eq!(edge_data.data.g, 0x34); assert_eq!(edge_data.data.b, 0x56); assert_eq!(edge_data.data.alpha, Some(200)); assert_eq!(edge_data.data.luminance, Some(7)); assert_eq!( edge_data.edge, ColourEdge::Data(ColourData { r: 0x65, g: 0x43, b: 0x21, alpha: None, luminance: None, }) ); assert_eq!(result.semantic.colour_state, crate::ColourCode(301)); assert_scene_counts(&result, 2, 0, 0, 0); assert_geometry( &event_geometry(&result.geometry, &result.models[0]), &[ ( 3, 300, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(0.0, 10.0, 0.0), ], ), ( 3, 301, &[ Point3::new(0.0, 0.0, 1.0), Point3::new(10.0, 0.0, 1.0), Point3::new(0.0, 10.0, 1.0), ], ), ], ); }
#[test] fn external_ldr_tools_step_ends_match_oracle() { let fixture = corpus_fixture("external-ldr-tools"); let result = adapter_parse_fixture(fixture.id, fixture.bytes, ParserProfile::Compatibility) .expect("external-ldr-tools should parse"); let oracle = &fixture.expected.oracle_expected; assert_eq!(result.models.len(), oracle.models.len()); let root = oracle.root_model.as_ref().expect("oracle root model"); assert_eq!(result.models[0].step_ends, root.step_ends); for expected in &oracle.models { let actual = result .models .iter() .find(|model| model.path.eq_ignore_ascii_case(&expected.name)) .unwrap_or_else(|| panic!("missing model {}", expected.name)); assert_eq!( actual.step_ends, expected.step_ends, "{} actual={:?} expected={:?}", expected.name, actual.step_ends, expected.step_ends ); } let submodels_total: usize = oracle .models .iter() .skip(1) .map(|model| model.step_ends.len()) .sum(); assert_eq!(root.step_ends.len(), 52); assert_eq!(submodels_total, 57); assert_eq!(root.step_ends.len() + submodels_total, 109); }
#[test] fn real_models_match_oracle_ground_truth() { let cases: &[(&str, &str, usize, usize, usize, u64, u64)] = &[ // (fixture_id, oracle_root, file_count, min_includes, min_steps, triangles, quads) // Oracle: tri=N quad=M → rust triangles=N, quads=M ("graph-instances", "ViperMain.ldr", 9, 250, 0, 0, 0), ("external-thomas", "ViperMain.ldr", 9, 250, 0, 0, 0), ("external-weldr", "main.ldr", 3, 5, 0, 0, 5), ( "external-ldr-tools", "hatching-grounds.ldr", 17, 553, 12, 0, 0, ), ("mpd-data", "main.ldr", 3, 7, 0, 0, 5), ];
for ( fixture_id, oracle_root, expected_files, expected_includes, expected_steps, expected_tri, expected_quads, ) in cases { let fixture = corpus_fixture(fixture_id); let result = adapter_parse_fixture(fixture.id, fixture.bytes, ParserProfile::Compatibility) .unwrap_or_else(|e| panic!("{fixture_id} should parse: {e}"));
let target = result .semantic .target_identity .as_ref() .map(|s| s.as_ref().to_ascii_lowercase()) .unwrap_or_default(); assert!( target.contains(&oracle_root.to_ascii_lowercase()) || oracle_root.to_ascii_lowercase().contains(&target) || target.is_empty(), "{fixture_id}: root identity '{target}' should relate to oracle root '{oracle_root}'" );
assert_eq!( result.files.len(), *expected_files, "{fixture_id}: file count" );
let total_includes: usize = result.models.iter().map(|m| m.includes.len()).sum(); assert!( total_includes >= *expected_includes, "{fixture_id}: expected >={expected_includes} includes, got {total_includes}" );
if *expected_steps > 0 { assert!( result.semantic.steps.len() >= *expected_steps, "{fixture_id}: expected >={expected_steps} steps, got {}", result.semantic.steps.len() ); }
assert_eq!( result.scene.triangles, *expected_tri, "{fixture_id}: triangle count" ); assert_eq!( result.scene.quads, *expected_quads, "{fixture_id}: quad count" ); } }}