#![allow(clippy::too_many_arguments)] use crate::BfcFrame; use crate::bfc::{BfcState, bfc}; use crate::cache::{CacheKey, NormalizedPath}; use crate::geom::{parse_primitive, process_geometry}; use crate::model::{ ColourScope, ColourTable, DataPayload, DataPayloadBuilder, ModelData, ModelEvent, ParseResult, SemanticRecord, SyntaxRecord, parse_colour, }; use crate::mpd::{OwnedVirtualFile, VirtualFile, split_mpd}; use crate::scanner::{LineType, ScannedLine, TokenKind, scan_lines}; use crate::semantic::{SemanticEvent, SemanticSink, TraversalContext}; use crate::texmap::{TexmapState, TextureDescriptor, texmap}; use crate::traversal::traverse; use crate::types::{ DEFAULT_COLOUR, DEFAULT_PROVENANCE, Diagnostic, DiagnosticCode, LdrawLimits, LimitCounters, LimitKind, Materialization, ParseError, ParseOptions, ParserProfile, Span, add_diag, limit_error, }; use std::borrow::Cow; use std::collections::{BTreeSet, HashMap}; use web_time::Instant; #[derive(Clone, Debug, Default)] pub(crate) struct ParseMetrics { pub(crate) unique_materializations_scanned: u64, pub(crate) materialization_dedup_hits: u64, pub(crate) scan_cache_hits: u64, pub(crate) scan_cache_misses: u64, pub(crate) include_edges: u64, pub(crate) selection_lookups: u64, pub(crate) selection_hits: u64, pub(crate) unique_models_parsed: u64, pub(crate) subtree_occurrences_traversed: u64, pub(crate) definition_visits: u64, pub(crate) geometry_records_emitted: u64, pub(crate) geometry_records_merged: u64, pub(crate) bytes_hashed: u64, pub(crate) bytes_cloned: u64, pub(crate) bytes_read: u64, pub(crate) traversal_bounds_ns: u128, pub(crate) traversal_instances_ns: u128, pub(crate) traversal_children_ns: u128, } impl ParseMetrics { fn report(&self, started: &Instant, phase: &str) { if std::env::var_os("POLYMODEL_LDRAW_TIMING").is_some() { eprintln!( "[ldraw-metrics] phase={phase} elapsed_ms={} unique_materializations_scanned={} materialization_dedup_hits={} scan_cache_hits={} scan_cache_misses={} include_edges={} selection_lookups={} selection_hits={} unique_models_parsed={} subtree_occurrences_traversed={} definition_visits={} geometry_records_emitted={} geometry_records_merged={} bytes_hashed={} bytes_cloned={} bytes_read={} traversal_bounds_ms={} traversal_instances_ms={} traversal_children_ms={}", started.elapsed().as_millis(), self.unique_materializations_scanned, self.materialization_dedup_hits, self.scan_cache_hits, self.scan_cache_misses, self.include_edges, self.selection_lookups, self.selection_hits, self.unique_models_parsed, self.subtree_occurrences_traversed, self.definition_visits, self.geometry_records_emitted, self.geometry_records_merged, self.bytes_hashed, self.bytes_cloned, self.bytes_read, self.traversal_bounds_ns / 1_000_000, self.traversal_instances_ns / 1_000_000, self.traversal_children_ns / 1_000_000, ); } } } pub struct LdrawParser; impl Default for LdrawParser { fn default() -> Self { Self } } impl LdrawParser { pub fn parse_bytes_with_sink<'src, 'a>( &self, bytes: &'src [u8], options: ParseOptions<'a>, materializations: &[Materialization], sink: &mut impl SemanticSink, ) -> Result { let started = Instant::now(); let mut metrics = ParseMetrics::default(); let mut counters = LimitCounters::default(); let input_len = u64::try_from(bytes.len()).map_err(|_| ParseError::Overflow("resource bytes"))?; counters .add(LimitKind::ResourceBytes, input_len, &options.limits) .map_err(|name| limit_error(name, None))?; if options.fetch_count > options.limits.fetches { return Err(limit_error(LimitKind::Fetches, None)); } if options.cancellation.checkpoint() { return Err(ParseError::Cancelled); } let scanned = scan_lines(bytes, &options.limits)?; for line in &scanned { if options.cancellation.checkpoint() { return Err(ParseError::Cancelled); } let line_bytes = u64::try_from(line.raw.len()).map_err(|_| ParseError::Overflow("line bytes"))?; if line_bytes > options.limits.line_bytes { return Err(limit_error(LimitKind::LineBytes, Some(line.span))); } } metrics.report(&started, "scanned-root"); let mut files = split_mpd(&scanned, &options.limits)?; let has_mpd_file = scanned.iter().any(|line| { line.line_type == Some(LineType::Zero) && line.tokens.get(1).map(|token| token.kind) == Some(TokenKind::File) && line.tokens.len() >= 3 }); if has_mpd_file { for line in &scanned { let is_file = line.line_type == Some(LineType::Zero) && line.tokens.get(1).map(|token| token.kind) == Some(TokenKind::File) && line.tokens.len() >= 3; let is_nofile = line.line_type == Some(LineType::Zero) && line.tokens.get(1).map(|token| token.kind) == Some(TokenKind::NoFile); if is_file || is_nofile { counters .add(LimitKind::Commands, 1, &options.limits) .map_err(|name| limit_error(name, Some(line.span)))?; } } } else { files[0].name = Cow::Owned(options.root_name.clone()); } if options.cancellation.checkpoint() { return Err(ParseError::Cancelled); } let file_count = u64::try_from(files.len()) .map_err(|_| ParseError::Overflow("file count conversion"))?; counters .add(LimitKind::Files, file_count, &options.limits) .map_err(|name| limit_error(name, None))?; let mut diagnostics = Vec::new(); for span in crate::mpd::duplicate_file_name_spans(&files) { add_diag( options.profile, &mut diagnostics, &mut counters, &options.limits, DiagnosticCode::DuplicateFileName, span, "MPD contains a duplicate FILE name", false, )?; } let mut models = Vec::new(); let mut scanned_cache = HashMap::>>::new(); let mut syntax = Vec::new(); for line in &scanned { syntax.push(syntax_record(line)); } let mut colours = ColourTable::new(); let mut texture_ids = BTreeSet::new(); metrics.report(&started, "split-root"); for file in &files { let mut scope = ColourScope::with_first_local_slot(colours.next_local_slot()); let model_result = parse_model( file, &options, &mut counters, &mut diagnostics, &mut scope, &mut texture_ids, None, ); let model = match model_result { Ok(model) => model, Err(error) => { scope.rollback(); return Err(error); } }; scope.commit(); colours.merge_scope(scope); metrics.unique_models_parsed += 1; metrics.geometry_records_emitted += model.geometry.len() as u64; models.push(model); } metrics.report(&started, "parsed-root-models"); let source_model_count = models.len(); let mut materialized_keys = BTreeSet::new(); for materialization in materializations { metrics.unique_materializations_scanned += 1; metrics.bytes_read = metrics .bytes_read .saturating_add(materialization.bytes.len() as u64); if !materialization.hash_is_verified() && !materialization.has_valid_hash() { return Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::MaterializationHashMismatch, severity: crate::types::Severity::Error, message: format!("materialization hash mismatch for {}", materialization.path), span: None, })); } if !materialization.available || materialization.bytes.is_empty() { continue; } let normalized_path = materialization.path.clone(); let normalized_name = normalized_path.to_string(); let materialization_key = ( normalized_path.clone(), materialization.root, materialization.content_hash.clone(), ); if !materialized_keys.insert(materialization_key) { metrics.materialization_dedup_hits += 1; continue; } metrics.bytes_cloned = metrics .bytes_cloned .saturating_add(materialization.bytes.len() as u64); counters .add( LimitKind::ResourceBytes, u64::try_from(materialization.bytes.len()) .map_err(|_| ParseError::Overflow("resource bytes"))?, &options.limits, ) .map_err(|name| limit_error(name, None))?; let materialization_key = materialization.key(); let resolved_scanned = if let Some(scanned) = scanned_cache.get(&materialization_key) { metrics.scan_cache_hits += 1; scanned.clone() } else { metrics.scan_cache_misses += 1; let scanned = scan_lines(&materialization.bytes, &options.limits)?; scanned_cache.insert(materialization_key.clone(), scanned.clone()); scanned }; let file = VirtualFile { id: u32::try_from(files.len()) .map_err(|_| ParseError::Overflow("virtual file id"))?, name: Cow::Owned(normalized_name), lines: resolved_scanned, source_span: Span { start: 0, end: u32::try_from(materialization.bytes.len()) .map_err(|_| ParseError::Overflow("source span"))?, line: 1, column: 1, }, }; counters .add(LimitKind::Files, 1, &options.limits) .map_err(|name| limit_error(name, None))?; files.push(file.clone()); let mut scope = ColourScope::with_first_local_slot(colours.next_local_slot()); let mut model = match parse_model( &file, &options, &mut counters, &mut diagnostics, &mut scope, &mut texture_ids, Some(materialization_key.clone()), ) { Ok(model) => model, Err(error) => { scope.rollback(); return Err(error); } }; scope.commit(); colours.merge_scope(scope); metrics.unique_models_parsed += 1; metrics.geometry_records_emitted += model.geometry.len() as u64; for geometry in &mut model.geometry { geometry.source = model.key.clone(); } models.push(model); } metrics.report(&started, "parsed-materializations"); project_ldparse_step_ends(&mut models, source_model_count); for diagnostic in &diagnostics { sink.on_event( &SemanticEvent::Diagnostic(diagnostic.clone()), &TraversalContext::for_model( crate::Transform::default(), DEFAULT_COLOUR, BfcFrame::default(), TexmapState::Inactive, None, 0, diagnostic.span, ), ); } for model in &models { if let Some(colour) = colours.get(model.colour) { sink.on_event( &SemanticEvent::ColourDefined { code: colour.code, data: colour.data, }, &TraversalContext::for_model( crate::Transform::default(), model.colour, model.bfc, model.texmap_state, model.texmap_descriptor.as_ref(), model.file_id, None, ), ); } for texmap in &model.texmap_events { let descriptor = texmap.mode.map(|mode| TextureDescriptor { mode, parameters: texmap .parameters .iter() .map(|value| Cow::Owned(value.to_string())) .collect(), pngfile: texmap .reference .as_ref() .map(|value| Cow::Owned(value.to_string())) .unwrap_or_else(|| Cow::Borrowed("")), glossmap: None, }); sink.on_event( &SemanticEvent::TexmapTransition { kind: match texmap.kind { crate::TexmapKind::Start => crate::TexmapKind::Start, crate::TexmapKind::Next => crate::TexmapKind::Next, crate::TexmapKind::Fallback => crate::TexmapKind::Fallback, crate::TexmapKind::End => crate::TexmapKind::End, }, descriptor, }, &TraversalContext::for_model( crate::Transform::default(), model.colour, model.bfc, texmap.state, model.texmap_descriptor.as_ref(), model.file_id, Some(texmap.span), ), ); } for payload in &model.data_payloads { sink.on_event( &SemanticEvent::DataPayload { filename: payload.filename.as_str().into(), bytes: payload.bytes.clone(), }, &TraversalContext::for_model( crate::Transform::default(), model.colour, model.bfc, model.texmap_state, model.texmap_descriptor.as_ref(), model.file_id, None, ), ); } } let (scene, summaries) = traverse( &models, &options, &mut counters, &mut diagnostics, &mut metrics, sink, )?; metrics.report(&started, "traversed"); let root = models.first(); let semantic = SemanticRecord { canonical_path: root.map(|m| Cow::Owned(m.path.to_string())), root_identity: Some(Cow::Owned( format!("{:?}", options.resolved_root).to_ascii_lowercase(), )), target_identity: root.map(|m| Cow::Owned(m.path.to_string())), cache_identity: root.map(|m| Cow::Owned(m.key.content_hash.to_string())), bfc_state: root.map(|m| m.bfc.state).unwrap_or(BfcState::Unknown), colour_state: root.map(|m| m.colour).unwrap_or(DEFAULT_COLOUR), steps: root .map(|m| m.steps.iter().map(|s| s.to_string()).collect()) .unwrap_or_default(), limits: LdrawLimits::NAMES .iter() .map(|name| Cow::Borrowed(*name)) .collect(), texmap_events: root .map(|m| { m.texmap_events .clone() .into_iter() .map(|event| event.into_owned()) .collect() }) .unwrap_or_default(), texmap_geometry: root.map(|m| m.texmap_geometry.clone()).unwrap_or_default(), data_payloads: root.map(|m| m.data_payloads.clone()).unwrap_or_default(), }; Ok(crate::OwnedParseResult::from_parse_result(ParseResult { syntax, semantic, scene, diagnostics, counters, files: files.into_iter().map(OwnedVirtualFile::from).collect(), models: summaries, colours: colours.into_owned(), provenance_id: Cow::Borrowed(DEFAULT_PROVENANCE), })) } } fn parse_model<'src>( file: &VirtualFile<'src>, options: &ParseOptions<'_>, counters: &mut LimitCounters, diagnostics: &mut Vec, colours: &mut ColourScope<'src>, texture_ids: &mut BTreeSet>, known_key: Option, ) -> Result, ParseError> { if options.cancellation.checkpoint() { return Err(ParseError::Cancelled); } let path = NormalizedPath::new(&file.name)?; let key = known_key.unwrap_or_else(|| { CacheKey::new_from_parts( path.clone(), options.resolved_root, file.lines .iter() .flat_map(|line| [line.raw, line.ending.as_str().as_bytes()]), ) }); let line_count = u64::try_from(file.lines.len()).map_err(|_| ParseError::Overflow("model line count"))?; let _ = line_count; let mut model = ModelData { file_id: file.id, path, key, bfc: BfcFrame::default(), colour: DEFAULT_COLOUR, steps: Vec::new(), step_ends: Vec::new(), mesh_index_count: 0, events: Vec::new(), texmap_state: TexmapState::Inactive, texmap_descriptor: None, texmap_stack: Vec::new(), texmap_next_span: None, texmap_fallback_seen: false, texmap_events: Vec::new(), texmap_geometry: Vec::new(), data_payload: None, data_payloads: Vec::new(), includes: Vec::new(), triangles: 0, quads: 0, lines: 0, conditional_lines: 0, geometry: Vec::new(), bounds: None, }; for line in &file.lines { if line.blank { continue; } if options.cancellation.checkpoint() { return Err(ParseError::Cancelled); } if counters .add(LimitKind::Commands, 1, &options.limits) .is_err() { return Err(limit_error(LimitKind::Commands, Some(line.span))); } let before = diagnostics.len(); let result = process_line( &mut model, line, options, diagnostics, counters, colours, texture_ids, ); if let Err(error) = result { let continue_diagnostic = match &error { ParseError::Diagnostic(diagnostic) => { options.profile != ParserProfile::Strict && !matches!( diagnostic.code, DiagnosticCode::LineBytesLimit | DiagnosticCode::FilesLimit | DiagnosticCode::IncludeDepthLimit | DiagnosticCode::CommandsLimit | DiagnosticCode::InstancesLimit | DiagnosticCode::TrianglesLimit | DiagnosticCode::LinesLimit | DiagnosticCode::TexturesLimit | DiagnosticCode::FetchesLimit | DiagnosticCode::DiagnosticsLimit | DiagnosticCode::ResourceBytesLimit ) } _ => false, }; if !continue_diagnostic { return Err(error); } } let diagnostic_count = u64::try_from(diagnostics.len()) .map_err(|_| ParseError::Overflow("diagnostic count conversion"))?; if diagnostics.len() > before && diagnostic_count > options.limits.diagnostics { return Err(limit_error(LimitKind::Diagnostics, Some(line.span))); } } if model.data_payload.is_some() { finalize_data(&mut model, options, diagnostics, counters, file.source_span)?; } Ok(model) } fn project_ldparse_step_ends(models: &mut [ModelData<'_>], source_model_count: usize) { #[derive(Clone, Copy)] struct ScanFrame { source: usize, next_event: usize, } let source_limit = source_model_count.min(models.len()); let source_index = models .iter() .take(source_limit) .enumerate() .map(|(index, model)| (model.path.clone(), index)) .collect::>(); for model in models.iter_mut() { model.step_ends.clear(); } // LDParse's callbacks are stateful pointers, not a tree-fold over model // contents. Keep the parser's source cursor stack separate from the // callback target: EOF always retargets callbacks to the root, while the // saved include cursor is replayed against the now-cached child. let mut callback_indices = vec![0_u64; models.len()]; let mut cached = BTreeSet::new(); let mut completed = vec![false; source_limit]; let mut scan_stack = Vec::new(); for outer in 0..source_limit { if completed[outer] { continue; } cached.insert(outer); let mut frame = ScanFrame { source: outer, next_event: 0, }; let mut callback_target = outer; loop { if frame.next_event >= models[frame.source].events.len() { completed[frame.source] = true; // ModelBuilder::handleEOF calls recordTo(&target), where the // EOF callback was retained on the root model. callback_target = 0; let Some(parent) = scan_stack.pop() else { break; }; frame = parent; continue; } let event = models[frame.source].events[frame.next_event].clone(); match event { ModelEvent::Step => { let offset = callback_indices[callback_target]; models[callback_target].step_ends.push(offset); frame.next_event += 1; } ModelEvent::Indices(count) => { callback_indices[callback_target] = callback_indices[callback_target].saturating_add(count); frame.next_event += 1; } ModelEvent::Include(name) => { let child = NormalizedPath::new(name.as_ref()) .ok() .and_then(|path| source_index.get(&path).copied()); if let Some(child) = child && !cached.contains(&child) { // Do not advance the include cursor. The real // parser saves this line and reprocesses it after // the child reaches EOF. scan_stack.push(frame); cached.insert(child); frame = ScanFrame { source: child, next_event: 0, }; callback_target = child; continue; } // A cached include is an ordinary callback and consumes // its source line without changing the callback target. frame.next_event += 1; } } } } // Caller-admitted materializations are parsed independently of the MPD // stream and therefore retain their file-local callback target. for model in models.iter_mut().skip(source_limit) { let mut indices = 0_u64; let mut step_ends = Vec::new(); for event in &model.events { match event { ModelEvent::Step => step_ends.push(indices), ModelEvent::Indices(count) => indices = indices.saturating_add(*count), ModelEvent::Include(_) => {} } } model.step_ends.extend(step_ends); } } fn finalize_data<'src>( model: &mut ModelData<'src>, options: &ParseOptions<'_>, diagnostics: &mut Vec, counters: &mut LimitCounters, _span: Span, ) -> Result<(), ParseError> { let Some(payload) = model.data_payload.take() else { return Ok(()); }; let decoded = match base64::Engine::decode( &base64::engine::general_purpose::STANDARD, payload.encoded.as_bytes(), ) { Ok(decoded) if !decoded.is_empty() => decoded, Ok(_) => { add_diag( options.profile, diagnostics, counters, &options.limits, DiagnosticCode::DataTruncated, payload.span, "DATA payload is empty", true, )?; return Ok(()); } Err(_) => { add_diag( options.profile, diagnostics, counters, &options.limits, DiagnosticCode::DataInvalid, payload.span, "DATA payload is not valid base64", true, )?; return Ok(()); } }; if options.cancellation.checkpoint() { return Err(ParseError::Cancelled); } let decoded_len = u64::try_from(decoded.len()).map_err(|_| ParseError::Overflow("DATA resource bytes"))?; counters .add(LimitKind::ResourceBytes, decoded_len, &options.limits) .map_err(|kind| limit_error(kind, Some(payload.span)))?; model.data_payloads.push(DataPayload { filename: payload.filename, bytes: decoded, }); Ok(()) } fn syntax_record<'src>(line: &ScannedLine<'src>) -> SyntaxRecord<'src> { let raw_tail = std::str::from_utf8(line.raw_tail) .expect("scanner validates UTF-8 before producing a scanned line"); SyntaxRecord { line_type: line.line_type, raw_tail: Cow::Borrowed(raw_tail), span: line.span, line_ending: Cow::Owned(line.ending.as_str().to_owned()), fields: line .tokens .iter() .map(|token| Cow::Borrowed(token.text)) .collect(), } } fn process_line<'src>( model: &mut ModelData<'src>, line: &ScannedLine<'src>, options: &ParseOptions<'_>, diagnostics: &mut Vec, counters: &mut LimitCounters, colours: &mut ColourScope<'src>, texture_ids: &mut BTreeSet>, ) -> Result<(), ParseError> { let profile = options.profile; let limits = &options.limits; let primitive = parse_primitive(line); let bang_colon = line.line_type == Some(LineType::Zero) && line.tokens.get(1).map(|token| token.text) == Some("!:"); if let Some(payload) = model.data_payload.as_mut() { let continuation = bang_colon && model.texmap_state == TexmapState::Inactive; if continuation { let encoded = line .tokens .get(2..) .unwrap_or_default() .iter() .map(|token| token.text) .collect::(); payload.encoded.push_str(&encoded); return Ok(()); } finalize_data(model, options, diagnostics, counters, line.span)?; } if model.texmap_state == TexmapState::AwaitingNext && !line.blank { if line.line_type == Some(LineType::Zero) { let span = model.texmap_next_span.take().unwrap_or(line.span); model.texmap_state = TexmapState::Inactive; model.texmap_descriptor = None; add_diag( profile, diagnostics, counters, limits, DiagnosticCode::TexmapNextType0, span, "NEXT was cancelled by a type-0 line", true, )?; } else if matches!( line.line_type, Some(LineType::One | LineType::Two | LineType::Three | LineType::Four | LineType::Five) ) && primitive.is_some() { let descriptor = model.texmap_descriptor.clone(); model.texmap_state = TexmapState::Active; model.texmap_descriptor = descriptor.clone(); let diagnostic_count = diagnostics.len(); process_geometry( model, line, primitive, profile, diagnostics, counters, limits, )?; if diagnostics.len() != diagnostic_count { model.texmap_state = TexmapState::AwaitingNext; model.texmap_descriptor = descriptor; return Ok(()); } model.texmap_state = TexmapState::Inactive; model.texmap_descriptor = None; if let Some(descriptor) = descriptor { let _ = texture_ids.insert(descriptor); } model.texmap_next_span = None; return Ok(()); } } if bang_colon { return process_bang_colon(model, line, profile, diagnostics, counters, limits); } if line.line_type == Some(LineType::Zero) { let preserves_invert = line.tokens.get(1).map(|token| token.kind) == Some(TokenKind::Bfc) && line.tokens.get(2).map(|token| token.kind) == Some(TokenKind::InvertNext) && line.tokens.len() == 3; if !preserves_invert { model.bfc.invert_next = false; } return process_meta( model, line, options, diagnostics, counters, colours, texture_ids, ); } process_geometry( model, line, primitive, profile, diagnostics, counters, limits, ) } fn process_bang_colon<'src>( model: &mut ModelData<'src>, line: &ScannedLine<'src>, profile: ParserProfile, diagnostics: &mut Vec, counters: &mut LimitCounters, limits: &LdrawLimits, ) -> Result<(), ParseError> { if !matches!( model.texmap_state, TexmapState::Active | TexmapState::Fallback ) { return add_diag( profile, diagnostics, counters, limits, DiagnosticCode::TexmapBangColonOutOfScope, line.span, "bang-colon geometry requires an active TEXMAP frame", true, ); } let Some(embedded_type) = line.tokens.get(2).and_then(|token| match token.kind { TokenKind::One => Some(LineType::One), TokenKind::Two => Some(LineType::Two), TokenKind::Three => Some(LineType::Three), TokenKind::Four => Some(LineType::Four), TokenKind::Five => Some(LineType::Five), _ => None, }) else { return add_diag( profile, diagnostics, counters, limits, DiagnosticCode::TexmapInvalidBangColonGeometry, line.span, "bang-colon requires an embedded line type 1 through 5", true, ); }; if matches!( model.texmap_state, TexmapState::Active | TexmapState::Fallback ) && line.tokens.iter().skip(2).any(|token| token.text == "!:") { return add_diag( profile, diagnostics, counters, limits, DiagnosticCode::TexmapNestedBangColon, line.span, "bang-colon geometry cannot be nested", true, ); } let embedded_line = ScannedLine { line_type: Some(embedded_type), raw: line.raw, raw_tail: line.raw_tail, tokens: line.tokens[2..].to_vec(), span: line.span, ending: line.ending.clone(), blank: false, }; let embedded_primitive = crate::geom::parse_primitive(&embedded_line); if embedded_primitive.is_none() || embedded_primitive.as_ref().is_some_and(|primitive| { matches!( (embedded_type, primitive), (LineType::One, crate::geom::ParsedPrimitive::Points(_)) | ( LineType::Two | LineType::Three | LineType::Four | LineType::Five, crate::geom::ParsedPrimitive::Include(_) ) ) }) { return add_diag( profile, diagnostics, counters, limits, DiagnosticCode::TexmapInvalidBangColonGeometry, line.span, "malformed bang-colon geometry", true, ); } process_geometry( model, &embedded_line, embedded_primitive, profile, diagnostics, counters, limits, ) } fn process_meta<'src>( model: &mut ModelData<'src>, line: &ScannedLine<'src>, options: &ParseOptions<'_>, diagnostics: &mut Vec, counters: &mut LimitCounters, colours: &mut ColourScope<'src>, texture_ids: &mut BTreeSet>, ) -> Result<(), ParseError> { let profile = options.profile; let limits = &options.limits; let tokens = &line.tokens; if tokens.len() < 2 { if profile == ParserProfile::Strict { return add_diag( profile, diagnostics, counters, limits, DiagnosticCode::MetaEmpty, line.span, "empty META command", true, ); } return Ok(()); } if tokens.get(1).map(|t| t.kind) == Some(TokenKind::Bfc) { return bfc(model, line, profile, diagnostics, counters, limits); } if tokens.get(1).map(|t| t.kind) == Some(TokenKind::Texmap) { return texmap( model, line, profile, diagnostics, counters, limits, texture_ids, ); } if tokens.get(1).map(|t| t.text) == Some("!DATA") { let Some(filename) = tokens.get(2).filter(|token| !token.text.is_empty()) else { add_diag( profile, diagnostics, counters, limits, DiagnosticCode::DataInvalid, line.span, "DATA requires a filename", true, )?; return Ok(()); }; if tokens.len() != 3 { add_diag( profile, diagnostics, counters, limits, DiagnosticCode::DataInvalid, line.span, "DATA accepts exactly one filename", true, )?; return Ok(()); } model.data_payload = Some(DataPayloadBuilder { filename: filename.text.to_owned(), encoded: String::new(), span: line.span, }); return Ok(()); } match tokens[1].kind { TokenKind::Step | TokenKind::Rotstep => { let step = model .steps .len() .checked_add(1) .ok_or(ParseError::Overflow("step number"))?; model.steps.push(format!("step-{step:08}").into()); model.step_ends.push(model.mesh_index_count); model.events.push(ModelEvent::Step); model.bfc.invert_next = false; model.texmap_state = TexmapState::Inactive; model.texmap_descriptor = None; model.texmap_stack.clear(); model.texmap_next_span = None; model.texmap_fallback_seen = false; } TokenKind::Colour => { if tokens.get(2).is_some_and(|token| token.text.ends_with(';')) { model.bfc.invert_next = false; return Ok(()); } if let Some(colour) = parse_colour(tokens) { let code = colour.code; if colours.record(colour).is_err() { add_diag( profile, diagnostics, counters, limits, DiagnosticCode::ColourSlotsLimit, line.span, "local colour slot table is exhausted", true, )?; } else { model.colour = code; } } else { add_diag( profile, diagnostics, counters, limits, DiagnosticCode::InvalidColour, line.span, "malformed !COLOUR directive", true, )?; } model.bfc.invert_next = false; } TokenKind::Stop => { add_diag( profile, diagnostics, counters, limits, DiagnosticCode::TexmapUnknownMetaStop, line.span, "literal STOP is unknown META, not END", true, )?; } _ => { if model.bfc.invert_next { model.bfc.invert_next = false; } } } Ok(()) }