diff --git a/Cargo.lock b/Cargo.lock index 5354854..e23b1c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6397,6 +6397,7 @@ dependencies = [ name = "polymodel-ldraw-core" version = "0.1.0" dependencies = [ + "miette", "polymodel-renderer-ledger", "serde", "sha2 0.10.9", diff --git a/crates/polymodel-ldraw-core/Cargo.toml b/crates/polymodel-ldraw-core/Cargo.toml index f2c6374..2a87317 100644 --- a/crates/polymodel-ldraw-core/Cargo.toml +++ b/crates/polymodel-ldraw-core/Cargo.toml @@ -8,6 +8,7 @@ description = "Pure Rust/WASM LDraw syntax and semantic core." [dependencies] polymodel-renderer-ledger = { path = "../polymodel-renderer-ledger" } serde = { workspace = true } +miette = { workspace = true } sha2 = "0.10" thiserror = { workspace = true } diff --git a/crates/polymodel-ldraw-core/src/adapter.rs b/crates/polymodel-ldraw-core/src/adapter.rs new file mode 100644 index 0000000..6f4af4e --- /dev/null +++ b/crates/polymodel-ldraw-core/src/adapter.rs @@ -0,0 +1,45 @@ +use crate::{LdrawParser, OwnedParseResult, ParseError, ParseOptions, ParserProfile}; +use polymodel_renderer_ledger::{ReservationLedger, ReservationOwner}; + +pub struct InProcessRustAdapter; +#[derive(Clone)] +pub struct AdapterRequest<'a> { + pub fixture_id: &'a str, + pub bytes: &'a [u8], + pub profile: ParserProfile, + pub provenance_id: &'a str, + pub owner: ReservationOwner, + pub ledger: &'a ReservationLedger, + pub semantic_budget: Option, + pub root_name: &'a str, +} +impl InProcessRustAdapter { + pub fn parse(request: AdapterRequest<'_>) -> Result { + let mut options = ParseOptions::new(request.owner, request.ledger); + options.profile = request.profile; + options.semantic_budget = request.semantic_budget; + options.root_name = request.root_name.into(); + let mut result = LdrawParser.parse_bytes(request.bytes, options)?; + result.provenance_id = request.provenance_id.into(); + let _ = request.fixture_id; + Ok(result) + } + pub fn parse_fixture<'a>( + fixture_id: &'a str, + bytes: &'a [u8], + profile: ParserProfile, + owner: ReservationOwner, + ledger: &'a ReservationLedger, + ) -> Result { + Self::parse(AdapterRequest { + fixture_id, + bytes, + profile, + provenance_id: fixture_id, + owner, + ledger, + semantic_budget: Some(16 * 1024 * 1024), + root_name: fixture_id, + }) + } +} diff --git a/crates/polymodel-ldraw-core/src/bfc.rs b/crates/polymodel-ldraw-core/src/bfc.rs new file mode 100644 index 0000000..b76c1d9 --- /dev/null +++ b/crates/polymodel-ldraw-core/src/bfc.rs @@ -0,0 +1,185 @@ +use crate::model::ModelData; +use crate::parser::{add_diag, limit_error}; +use crate::scanner::{ScannedLine, TokenKind}; +use crate::types::{ + Diagnostic, DiagnosticCode, LdrawLimits, LimitCounters, LimitKind, ParseError, ParserProfile, + Severity, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BfcState { + Unknown, + Certified, + Uncertified, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum Winding { + Ccw, + Cw, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BfcFrame { + pub state: BfcState, + pub clipping: bool, + pub winding: Winding, + pub invert_next: bool, +} +impl Default for BfcFrame { + fn default() -> Self { + Self { + state: BfcState::Unknown, + clipping: false, + winding: Winding::Ccw, + invert_next: false, + } + } +} + +pub(crate) fn bfc( + model: &mut ModelData, + line: &ScannedLine, + profile: ParserProfile, + diagnostics: &mut Vec, + counters: &mut LimitCounters, + limits: &LdrawLimits, +) -> Result<(), ParseError> { + if line.tokens.len() < 3 { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::BfcInvalidDirective, + line.span, + "BFC requires a directive", + true, + ); + } + let directive = line.tokens[2].kind; + match directive { + TokenKind::Certify => { + if model.bfc.state == BfcState::Uncertified { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::BfcRecertifyUncertified, + line.span, + "cannot re-certify an uncertified model", + true, + ); + } + if line.tokens.len() > 4 + || (line.tokens.len() == 4 && line.tokens[3].kind != TokenKind::Orientation) + { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::BfcInvalidCertifyArgument, + line.span, + "CERTIFY accepts at most one orientation", + true, + ); + } + model.bfc.state = BfcState::Certified; + model.bfc.clipping = true; + model.bfc.winding = Winding::Ccw; + if line.tokens.len() == 4 { + model.bfc.winding = if line.tokens[3].text == "CW" { + Winding::Cw + } else { + Winding::Ccw + }; + } + model.bfc.invert_next = false; + } + TokenKind::NoCertify => { + if line.tokens.len() != 3 { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::BfcInvalidDirective, + line.span, + "NOCERTIFY takes no argument", + true, + ); + } + model.bfc.state = BfcState::Uncertified; + model.bfc.clipping = false; + model.bfc.invert_next = false; + } + TokenKind::InvertNext => { + if line.tokens.len() != 3 { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::BfcInvalidDirective, + line.span, + "INVERTNEXT takes no argument", + true, + ); + } + if model.bfc.state == BfcState::Certified { + model.bfc.invert_next = true; + } else if profile != ParserProfile::Strict { + counters + .add(LimitKind::Diagnostics, 1, limits) + .map_err(|kind| limit_error(kind, Some(line.span)))?; + diagnostics.push(Diagnostic { + code: DiagnosticCode::BfcInvertNextOutsideCertified, + severity: Severity::Info, + message: "INVERTNEXT ignored outside Certified state".into(), + span: Some(line.span), + }); + } + } + TokenKind::Clip | TokenKind::NoClip | TokenKind::Orientation => { + if model.bfc.state != BfcState::Certified { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::BfcInvalidContext, + line.span, + "BFC clip/winding directive requires Certified state", + true, + ); + } + if directive == TokenKind::Clip { + model.bfc.clipping = true; + } else if directive == TokenKind::NoClip { + model.bfc.clipping = false; + } else { + model.bfc.winding = if line.tokens[2].text == "CW" { + Winding::Cw + } else { + Winding::Ccw + }; + } + model.bfc.invert_next = false; + } + _ => { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::BfcInvalidDirective, + line.span, + "unknown BFC directive", + true, + ); + } + } + Ok(()) +} diff --git a/crates/polymodel-ldraw-core/src/cache.rs b/crates/polymodel-ldraw-core/src/cache.rs new file mode 100644 index 0000000..2f64d35 --- /dev/null +++ b/crates/polymodel-ldraw-core/src/cache.rs @@ -0,0 +1,83 @@ +use crate::types::{Diagnostic, DiagnosticCode, ParseError, RootId, Severity}; +use crate::util::hex; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] +pub struct NormalizedPath(String); +impl NormalizedPath { + pub fn new(path: &str) -> Result { + let mut parts = Vec::new(); + for part in path.replace('\\', "/").split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + if parts.pop().is_none() { + return Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::PathOutOfRoot, + severity: Severity::Error, + message: "path escapes its configured root".into(), + span: None, + })); + } + } else if part.bytes().any(|b| b == 0 || b.is_ascii_control()) { + return Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::PathForbiddenSyntax, + severity: Severity::Error, + message: "path contains a forbidden control character".into(), + span: None, + })); + } else { + parts.push(part.to_ascii_lowercase()); + } + } + if parts.is_empty() { + return Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::PathEmpty, + severity: Severity::Error, + message: "path is empty".into(), + span: None, + })); + } + Ok(Self(parts.join("/"))) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} +impl fmt::Display for NormalizedPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct Sha256Hash([u8; 32]); +impl Sha256Hash { + fn digest(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } +} +impl fmt::Display for Sha256Hash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&hex(&self.0)) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct CacheKey { + pub canonical_path: NormalizedPath, + pub resolved_root: RootId, + pub content_hash: Sha256Hash, +} +impl CacheKey { + pub fn new(path: NormalizedPath, root: RootId, bytes: &[u8]) -> Self { + Self { + canonical_path: path, + resolved_root: root, + content_hash: Sha256Hash::digest(bytes), + } + } +} diff --git a/crates/polymodel-ldraw-core/src/geom.rs b/crates/polymodel-ldraw-core/src/geom.rs new file mode 100644 index 0000000..94f9ed4 --- /dev/null +++ b/crates/polymodel-ldraw-core/src/geom.rs @@ -0,0 +1,259 @@ +use crate::bfc::BfcState; +use crate::model::{Include, ModelData}; +use crate::parser::{add_diag, limit_error}; +use crate::scanner::{ScannedLine, Token, TokenKind}; +use crate::types::{ + Diagnostic, DiagnosticCode, LdrawLimits, LimitCounters, LimitKind, ParseError, ParserProfile, +}; +use crate::util::fmt_num; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Transform { + pub translation: [f64; 3], + pub matrix: [[f64; 3]; 3], +} +impl Default for Transform { + fn default() -> Self { + Self { + translation: [0.0; 3], + matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + } + } +} +impl Transform { + pub fn from_type1(values: &[f64]) -> Option { + if values.len() != 12 { + return None; + } + Some(Self { + translation: [values[0], values[1], values[2]], + matrix: [ + [values[3], values[4], values[5]], + [values[6], values[7], values[8]], + [values[9], values[10], values[11]], + ], + }) + } + pub fn determinant(&self) -> f64 { + self.matrix[0][0] + * (self.matrix[1][1] * self.matrix[2][2] - self.matrix[1][2] * self.matrix[2][1]) + - self.matrix[0][1] + * (self.matrix[1][0] * self.matrix[2][2] - self.matrix[1][2] * self.matrix[2][0]) + + self.matrix[0][2] + * (self.matrix[1][0] * self.matrix[2][1] - self.matrix[1][1] * self.matrix[2][0]) + } + pub fn reflection(&self) -> bool { + self.determinant() < 0.0 + } + pub fn apply(&self, p: [f64; 3]) -> [f64; 3] { + [ + self.translation[0] + + self.matrix[0][0] * p[0] + + self.matrix[0][1] * p[1] + + self.matrix[0][2] * p[2], + self.translation[1] + + self.matrix[1][0] * p[0] + + self.matrix[1][1] * p[1] + + self.matrix[1][2] * p[2], + self.translation[2] + + self.matrix[2][0] * p[0] + + self.matrix[2][1] * p[1] + + self.matrix[2][2] * p[2], + ] + } + pub fn compose(&self, child: &Self) -> Self { + let mut m = [[0.0; 3]; 3]; + for r in 0..3 { + for c in 0..3 { + m[r][c] = (0..3).map(|i| self.matrix[r][i] * child.matrix[i][c]).sum(); + } + } + let t = self.apply(child.translation); + Self { + translation: t, + matrix: m, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Bounds { + pub min: [f64; 3], + pub max: [f64; 3], +} +impl Default for Bounds { + fn default() -> Self { + Self { + min: [f64::INFINITY; 3], + max: [f64::NEG_INFINITY; 3], + } + } +} +impl Bounds { + pub(crate) fn add(&mut self, p: [f64; 3]) { + for i in 0..3 { + self.min[i] = self.min[i].min(p[i]); + self.max[i] = self.max[i].max(p[i]); + } + } + pub(crate) fn values(&self) -> [String; 6] { + [ + fmt_num(self.min[0]), + fmt_num(self.min[1]), + fmt_num(self.min[2]), + fmt_num(self.max[0]), + fmt_num(self.max[1]), + fmt_num(self.max[2]), + ] + } +} + +fn parse_number(token: Option<&Token>) -> Option { + token?.text.parse::().ok().filter(|n| n.is_finite()) +} + +fn parse_points(tokens: &[Token], count: usize) -> Option> { + let coordinate_count = count.checked_mul(3)?; + let expected = coordinate_count.checked_add(2)?; + if tokens.len() != expected || tokens.get(1)?.kind != TokenKind::Number { + return None; + } + let mut values = Vec::with_capacity(coordinate_count); + for token in tokens.iter().skip(2) { + values.push(parse_number(Some(token))?); + } + Some(values.chunks_exact(3).map(|v| [v[0], v[1], v[2]]).collect()) +} + +pub(crate) fn primitive_is_valid(line: &ScannedLine) -> bool { + match line.line_type { + Some(1) => { + line.tokens.len() >= 15 + && parse_number(line.tokens.get(1)).is_some() + && (2..14).all(|i| parse_number(line.tokens.get(i)).is_some()) + && line.tokens.len() > 14 + } + Some(2) => parse_points(&line.tokens, 2).is_some(), + Some(3) => parse_points(&line.tokens, 3).is_some(), + Some(4 | 5) => parse_points(&line.tokens, 4).is_some(), + _ => false, + } +} + +pub(crate) fn process_geometry( + model: &mut ModelData, + line: &ScannedLine, + profile: ParserProfile, + diagnostics: &mut Vec, + counters: &mut LimitCounters, + limits: &LdrawLimits, +) -> Result<(), ParseError> { + match line.line_type { + Some(1) => { + let valid = primitive_is_valid(line); + if !valid { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::InvalidType1, + line.span, + "malformed type-1 include", + true, + ); + } + let values = (2..14) + .map(|i| parse_number(line.tokens.get(i)).unwrap()) + .collect::>(); + let transform = Transform::from_type1(&values).unwrap(); + let name = line.tokens[14..] + .iter() + .map(|t| t.text.as_str()) + .collect::>() + .join(" "); + let inverted = model.bfc.invert_next; + model.bfc.invert_next = false; + model.includes.push(Include { + name, + transform, + span: line.span, + valid: true, + inverted, + }); + if model.bfc.state == BfcState::Unknown { + model.bfc.state = BfcState::Uncertified; + } + } + Some(2) | Some(3) | Some(4) | Some(5) => { + let typ = line.line_type.unwrap(); + let count = if typ == 2 { + 2 + } else if typ == 3 { + 3 + } else { + 4 + }; + let Some(points) = parse_points(&line.tokens, count) else { + model.bfc.invert_next = false; + return add_diag( + profile, + diagnostics, + counters, + limits, + match typ { + 2 => DiagnosticCode::InvalidType2, + 3 => DiagnosticCode::InvalidType3, + 4 => DiagnosticCode::InvalidType4, + _ => DiagnosticCode::InvalidType5, + }, + line.span, + "malformed primitive", + true, + ); + }; + for point in points { + model.bounds.add(point); + } + if typ == 2 || typ == 5 { + model.lines = model + .lines + .checked_add(1) + .ok_or(ParseError::Overflow("lines"))?; + counters + .add(LimitKind::Lines, 1, limits) + .map_err(|name| limit_error(name, Some(line.span)))?; + } else { + let delta = if typ == 4 { 2 } else { 1 }; + model.triangles = model + .triangles + .checked_add(delta) + .ok_or(ParseError::Overflow("triangles"))?; + counters + .add(LimitKind::Triangles, delta, limits) + .map_err(|name| limit_error(name, Some(line.span)))?; + } + if model.bfc.state == BfcState::Unknown { + model.bfc.state = BfcState::Uncertified; + } + model.bfc.invert_next = false; + } + _ => { + model.bfc.invert_next = false; + if profile == ParserProfile::Strict { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::InvalidLineType, + line.span, + "expected line type 0 through 5", + true, + ); + } + } + } + Ok(()) +} diff --git a/crates/polymodel-ldraw-core/src/lib.rs b/crates/polymodel-ldraw-core/src/lib.rs index e76eac2..c691313 100644 --- a/crates/polymodel-ldraw-core/src/lib.rs +++ b/crates/polymodel-ldraw-core/src/lib.rs @@ -1,2204 +1,35 @@ #![forbid(unsafe_code)] //! A deterministic, resolver-independent LDraw syntax and semantic core. -//! -//! The implementation follows the LDParse pipeline: line-local scanning, MPD -//! partitioning, callback-shaped semantic events, arena-owned model state, and -//! an explicit stack for graph traversal. It intentionally has no host I/O. -use polymodel_renderer_ledger::{ - AdmissionError, RejectionReason, Reservation, ReservationLedger, ReservationOwner, - ResourceClass, -}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; -use std::fmt; -use thiserror::Error; - -pub const SCHEMA_VERSION: &str = "ldraw-canonical-v1"; -pub const DEFAULT_RESOURCE_BYTES: u64 = 100 * 1024 * 1024; -pub const DEFAULT_LINE_BYTES: u64 = 1024 * 1024; -pub const DEFAULT_FILES: u64 = 16_384; -pub const DEFAULT_INCLUDE_DEPTH: u64 = 256; -pub const DEFAULT_COMMANDS: u64 = 5_000_000; -pub const DEFAULT_INSTANCES: u64 = 5_000_000; -pub const DEFAULT_TRIANGLES: u64 = 20_000_000; -pub const DEFAULT_LINES: u64 = 20_000_000; -pub const DEFAULT_TEXTURES: u64 = 1_024; -pub const DEFAULT_FETCHES: u64 = 8; -pub const DEFAULT_DIAGNOSTICS: u64 = 10_000; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ParserProfile { - Strict, - Compatibility, - Lossless, -} -impl Default for ParserProfile { - fn default() -> Self { - Self::Strict - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Severity { - Info, - Warning, - Error, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct Span { - pub start: u32, - pub end: u32, - pub line: u32, - pub column: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct Diagnostic { - pub code: String, - pub severity: Severity, - pub message: String, - pub span: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq, Error)] -pub enum ParseError { - #[error("parse rejected: {}", _0.message)] - Diagnostic(Diagnostic), - #[error("semantic arena reservation failed: {0}")] - Reservation(String), - #[error("input arithmetic overflow while accounting {0}")] - Overflow(&'static str), - #[error("parse cancelled")] - Cancelled, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct LdrawLimits { - pub resource_bytes: u64, - pub line_bytes: u64, - pub files: u64, - pub include_depth: u64, - pub commands: u64, - pub instances: u64, - pub triangles: u64, - pub lines: u64, - pub textures: u64, - pub fetches: u64, - pub diagnostics: u64, -} -impl Default for LdrawLimits { - fn default() -> Self { - Self { - resource_bytes: DEFAULT_RESOURCE_BYTES, - line_bytes: DEFAULT_LINE_BYTES, - files: DEFAULT_FILES, - include_depth: DEFAULT_INCLUDE_DEPTH, - commands: DEFAULT_COMMANDS, - instances: DEFAULT_INSTANCES, - triangles: DEFAULT_TRIANGLES, - lines: DEFAULT_LINES, - textures: DEFAULT_TEXTURES, - fetches: DEFAULT_FETCHES, - diagnostics: DEFAULT_DIAGNOSTICS, - } - } -} -impl LdrawLimits { - pub const NAMES: [&'static str; 11] = [ - "resource_bytes", - "line_bytes", - "files", - "include_depth", - "commands", - "instances", - "triangles", - "lines", - "textures", - "fetches", - "diagnostics", - ]; -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Checkpoint { - LineScan, - FilePartition, - IncludeTraversal, - TexmapPayload, - Projection, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct CancellationPolicy { - pub cancelled: bool, -} -impl Default for CancellationPolicy { - fn default() -> Self { - Self { cancelled: false } - } -} - -#[derive(Clone)] -pub struct ParseOptions<'a> { - pub profile: ParserProfile, - pub limits: LdrawLimits, - pub owner: ReservationOwner, - pub ledger: &'a ReservationLedger, - pub semantic_budget: Option, - pub root_name: String, - pub resolved_root: RootId, - pub fetch_count: u64, - pub cancellation: CancellationPolicy, -} -impl<'a> ParseOptions<'a> { - pub fn new(owner: ReservationOwner, ledger: &'a ReservationLedger) -> Self { - Self { - profile: ParserProfile::Strict, - limits: LdrawLimits::default(), - owner, - ledger, - semantic_budget: None, - root_name: "model.ldr".into(), - resolved_root: RootId::UploadedManifest, - fetch_count: 0, - cancellation: CancellationPolicy::default(), - } - } - pub fn default_budget(input_len: usize) -> Result { - u64::try_from(input_len) - .map_err(|_| ParseError::Overflow("semantic budget input length")) - .and_then(|n| { - n.checked_mul(8) - .and_then(|n| n.checked_add(8192)) - .ok_or(ParseError::Overflow("semantic budget")) - }) - } - fn budget(&self, input_len: usize) -> Result { - self.semantic_budget - .map(Ok) - .unwrap_or_else(|| Self::default_budget(input_len)) - } - fn diag( - &self, - code: &str, - severity: Severity, - message: impl Into, - span: Option, - ) -> Diagnostic { - Diagnostic { - code: code.into(), - severity, - message: message.into(), - span, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RootId { - CurrentMpd, - UploadedManifest, - UploadedLdraw, - OfficialLibrary, -} - -#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] -pub struct NormalizedPath(String); -impl NormalizedPath { - pub fn new(path: &str) -> Result { - let mut parts = Vec::new(); - for part in path.replace('\\', "/").split('/') { - if part.is_empty() || part == "." { - continue; - } - if part == ".." { - if parts.pop().is_none() { - return Err(ParseError::Diagnostic(Diagnostic { - code: "PATH_OUT_OF_ROOT".into(), - severity: Severity::Error, - message: "path escapes its configured root".into(), - span: None, - })); - } - } else if part.bytes().any(|b| b == 0 || b.is_ascii_control()) { - return Err(ParseError::Diagnostic(Diagnostic { - code: "PATH_FORBIDDEN_SYNTAX".into(), - severity: Severity::Error, - message: "path contains a forbidden control character".into(), - span: None, - })); - } else { - parts.push(part.to_ascii_lowercase()); - } - } - if parts.is_empty() { - return Err(ParseError::Diagnostic(Diagnostic { - code: "PATH_EMPTY".into(), - severity: Severity::Error, - message: "path is empty".into(), - span: None, - })); - } - Ok(Self(parts.join("/"))) - } - pub fn as_str(&self) -> &str { - &self.0 - } -} -impl fmt::Display for NormalizedPath { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct Sha256Hash([u8; 32]); -impl Sha256Hash { - fn digest(bytes: &[u8]) -> Self { - Self(Sha256::digest(bytes).into()) - } -} -impl fmt::Display for Sha256Hash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&hex(&self.0)) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct CacheKey { - pub canonical_path: NormalizedPath, - pub resolved_root: RootId, - pub content_hash: Sha256Hash, -} -impl CacheKey { - pub fn new(path: NormalizedPath, root: RootId, bytes: &[u8]) -> Self { - Self { - canonical_path: path, - resolved_root: root, - content_hash: Sha256Hash::digest(bytes), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub enum TokenKind { - Zero, - One, - Two, - Three, - Four, - Five, - Step, - Pause, - Write, - Clear, - Save, - Colour, - Code, - Value, - Edge, - Alpha, - Luminance, - Chrome, - Pearlescent, - Rubber, - MatteMetallic, - Metal, - Material, - File, - NoFile, - Bfc, - Certify, - NoCertify, - Clip, - NoClip, - InvertNext, - Orientation, - Bang, - Texmap, - Start, - Next, - Fallback, - End, - Stop, - Number, - Identifier, - QuotedIdentifier, - Garbage, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct Token { - pub kind: TokenKind, - pub text: String, - pub span: Span, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub enum LineEnding { - Lf, - CrLf, - Cr, - None, -} -impl LineEnding { - fn as_str(&self) -> &'static str { - match self { - Self::Lf => "\\n", - Self::CrLf => "\\r\\n", - Self::Cr => "\\r", - Self::None => "", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ScannedLine { - pub line_type: Option, - pub raw: Vec, - pub raw_tail: Vec, - pub tokens: Vec, - pub span: Span, - pub ending: LineEnding, - pub blank: bool, -} - -fn keyword(text: &str) -> Option { - Some(match text { - "0" => TokenKind::Zero, - "1" => TokenKind::One, - "2" => TokenKind::Two, - "3" => TokenKind::Three, - "4" => TokenKind::Four, - "5" => TokenKind::Five, - "STEP" => TokenKind::Step, - "PAUSE" => TokenKind::Pause, - "WRITE" => TokenKind::Write, - "CLEAR" => TokenKind::Clear, - "SAVE" => TokenKind::Save, - "!COLOUR" => TokenKind::Colour, - "CODE" => TokenKind::Code, - "VALUE" => TokenKind::Value, - "EDGE" => TokenKind::Edge, - "ALPHA" => TokenKind::Alpha, - "LUMINANCE" => TokenKind::Luminance, - "CHROME" => TokenKind::Chrome, - "PEARLESCENT" => TokenKind::Pearlescent, - "RUBBER" => TokenKind::Rubber, - "MATTE_METALLIC" => TokenKind::MatteMetallic, - "METAL" => TokenKind::Metal, - "MATERIAL" => TokenKind::Material, - "FILE" => TokenKind::File, - "NOFILE" => TokenKind::NoFile, - "BFC" => TokenKind::Bfc, - "CERTIFY" => TokenKind::Certify, - "NOCERTIFY" => TokenKind::NoCertify, - "CLIP" => TokenKind::Clip, - "NOCLIP" => TokenKind::NoClip, - "INVERTNEXT" => TokenKind::InvertNext, - "CW" | "CCW" => TokenKind::Orientation, - "!TEXMAP" => TokenKind::Texmap, - "START" => TokenKind::Start, - "NEXT" => TokenKind::Next, - "FALLBACK" => TokenKind::Fallback, - "END" => TokenKind::End, - "STOP" => TokenKind::Stop, - "0 !:" => TokenKind::Bang, - _ => return None, - }) -} - -pub fn scan_lines(bytes: &[u8], limits: &LdrawLimits) -> Result, ParseError> { - let mut out = Vec::new(); - let mut offset = 0usize; - let mut line_no = 1u32; - while offset < bytes.len() { - let start = offset; - let mut end = offset; - let ending; - while end < bytes.len() && bytes[end] != b'\n' && bytes[end] != b'\r' { - end += 1; - } - if end == bytes.len() { - ending = LineEnding::None; - offset = end; - } else if bytes[end] == b'\r' && end + 1 < bytes.len() && bytes[end + 1] == b'\n' { - ending = LineEnding::CrLf; - offset = end + 2; - } else { - ending = if bytes[end] == b'\r' { - LineEnding::Cr - } else { - LineEnding::Lf - }; - offset = end + 1; - } - let raw = &bytes[start..end]; - let line_len = u64::try_from(raw.len()).map_err(|_| ParseError::Overflow("line bytes"))?; - let start_u32 = u32::try_from(start).map_err(|_| ParseError::Overflow("line span start"))?; - let end_u32 = u32::try_from(end).map_err(|_| ParseError::Overflow("line span end"))?; - if line_len > limits.line_bytes { - return Err(ParseError::Diagnostic(Diagnostic { - code: "LINE_BYTES_LIMIT".into(), - severity: Severity::Error, - message: "physical line exceeds the configured limit".into(), - span: Some(Span { - start: start_u32, - end: end_u32, - line: line_no, - column: 1, - }), - })); - } - let text = std::str::from_utf8(raw).map_err(|_| { - ParseError::Diagnostic(Diagnostic { - code: "INVALID_UTF8".into(), - severity: Severity::Error, - message: "line is not valid UTF-8".into(), - span: Some(Span { - start: start_u32, - end: end_u32, - line: line_no, - column: 1, - }), - }) - })?; - out.push(scan_one(text, raw, start_u32, line_no, ending)); - line_no = line_no - .checked_add(1) - .ok_or(ParseError::Overflow("line number"))?; - } - if bytes.is_empty() { - out.push(scan_one("", &[], 0, 1, LineEnding::None)); - } - Ok(out) -} - -fn scan_one(text: &str, raw: &[u8], start: u32, line: u32, ending: LineEnding) -> ScannedLine { - let trimmed = text.trim(); - let blank = trimmed.is_empty(); - let mut tokens = Vec::new(); - let mut i = 0usize; - let bytes = text.as_bytes(); - while i < bytes.len() { - while i < bytes.len() && bytes[i].is_ascii_whitespace() { - i += 1; - } - if i >= bytes.len() { - break; - } - let begin = i; - let value = if bytes[i] == b'"' { - i += 1; - let inner = i; - while i < bytes.len() && bytes[i] != b'"' { - i += 1; - } - let value = &text[inner..i]; - if i < bytes.len() { - i += 1; - } - let span = Span { - start: start + begin as u32, - end: start + i as u32, - line, - column: begin as u32 + 1, - }; - tokens.push(Token { - kind: TokenKind::QuotedIdentifier, - text: value.into(), - span, - }); - continue; - } else { - while i < bytes.len() && !bytes[i].is_ascii_whitespace() { - i += 1; - } - &text[begin..i] - }; - let kind = keyword(value) - .or_else(|| { - value - .parse::() - .ok() - .filter(|n| n.is_finite()) - .map(|_| TokenKind::Number) - }) - .unwrap_or(TokenKind::Identifier); - tokens.push(Token { - kind, - text: value.into(), - span: Span { - start: start + begin as u32, - end: start + i as u32, - line, - column: begin as u32 + 1, - }, - }); - } - let line_type = tokens.first().and_then(|t| match t.kind { - TokenKind::Zero => Some(0), - TokenKind::One => Some(1), - TokenKind::Two => Some(2), - TokenKind::Three => Some(3), - TokenKind::Four => Some(4), - TokenKind::Five => Some(5), - _ => None, - }); - let tail_start = tokens - .first() - .and_then(|t| usize::try_from(t.span.end.checked_sub(start)?).ok()) - .unwrap_or(0); - let raw_tail = raw.get(tail_start..).unwrap_or_default().to_vec(); - ScannedLine { - line_type, - raw: raw.to_vec(), - raw_tail, - tokens, - span: Span { - start, - end: start + raw.len() as u32, - line, - column: 1, - }, - ending, - blank, - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct VirtualFile { - pub id: u32, - pub name: String, - pub lines: Vec, - pub source_span: Span, -} - -pub fn split_mpd( - lines: &[ScannedLine], - limits: &LdrawLimits, -) -> Result, ParseError> { - let has_file = lines.iter().any(|line| { - line.line_type == Some(0) - && line.tokens.get(1).map(|t| t.kind) == Some(TokenKind::File) - && line.tokens.len() >= 3 - }); - if !has_file { - return Ok(vec![VirtualFile { - id: 0, - name: "model.ldr".into(), - lines: lines.to_vec(), - source_span: lines.first().map(|l| l.span).unwrap_or(Span { - start: 0, - end: 0, - line: 1, - column: 1, - }), - }]); - } - let mut files = Vec::new(); - let mut active: Option = None; - let mut discarded = true; - for line in lines { - if line.line_type == Some(0) - && line.tokens.get(1).map(|t| t.kind) == Some(TokenKind::File) - && line.tokens.len() >= 3 - { - let count = - u64::try_from(files.len()).map_err(|_| ParseError::Overflow("file count"))?; - if count >= limits.files { - return Err(ParseError::Diagnostic(Diagnostic { - code: "FILES_LIMIT".into(), - severity: Severity::Error, - message: "virtual file limit exceeded".into(), - span: Some(line.span), - })); - } - let name = line.tokens[2..] - .iter() - .map(|t| t.text.as_str()) - .collect::>() - .join(" "); - files.push(VirtualFile { - id: u32::try_from(files.len()) - .map_err(|_| ParseError::Overflow("virtual file id"))?, - name, - lines: Vec::new(), - source_span: line.span, - }); - active = Some(files.len() - 1); - discarded = false; - continue; - } - if line.line_type == Some(0) - && line.tokens.get(1).map(|t| t.kind) == Some(TokenKind::NoFile) - { - active = None; - discarded = true; - continue; - } - if let Some(index) = active { - files[index].lines.push(line.clone()); - } else if !discarded && !files.is_empty() { /* explicit MPD discard state */ - } - } - if files.is_empty() { - return Ok(vec![VirtualFile { - id: 0, - name: "model.ldr".into(), - lines: lines.to_vec(), - source_span: lines.first().map(|l| l.span).unwrap_or(Span { - start: 0, - end: 0, - line: 1, - column: 1, - }), - }]); - } - Ok(files) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BfcState { - Unknown, - Certified, - Uncertified, -} -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Winding { - Ccw, - Cw, -} -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct BfcFrame { - pub state: BfcState, - pub clipping: bool, - pub winding: Winding, - pub invert_next: bool, -} -impl Default for BfcFrame { - fn default() -> Self { - Self { - state: BfcState::Unknown, - clipping: false, - winding: Winding::Ccw, - invert_next: false, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TexmapState { - Inactive, - AwaitingNext, - Active, - Fallback, -} -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] -pub struct TextureDescriptor { - pub mode: String, - pub parameters: Vec, - pub pngfile: String, - pub glossmap: Option, -} -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TexmapKind { - Start, - Next, - Fallback, - End, -} -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct TexmapEvent { - pub kind: TexmapKind, - pub span: Span, - pub file_id: u32, - pub mode: Option, - pub parameters: Vec, - pub reference: Option, - pub state: TexmapState, -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct Transform { - pub translation: [f64; 3], - pub matrix: [[f64; 3]; 3], -} -impl Default for Transform { - fn default() -> Self { - Self { - translation: [0.0; 3], - matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], - } - } -} -impl Transform { - pub fn from_type1(values: &[f64]) -> Option { - if values.len() != 12 { - return None; - } - Some(Self { - translation: [values[0], values[1], values[2]], - matrix: [ - [values[3], values[4], values[5]], - [values[6], values[7], values[8]], - [values[9], values[10], values[11]], - ], - }) - } - pub fn determinant(&self) -> f64 { - self.matrix[0][0] - * (self.matrix[1][1] * self.matrix[2][2] - self.matrix[1][2] * self.matrix[2][1]) - - self.matrix[0][1] - * (self.matrix[1][0] * self.matrix[2][2] - self.matrix[1][2] * self.matrix[2][0]) - + self.matrix[0][2] - * (self.matrix[1][0] * self.matrix[2][1] - self.matrix[1][1] * self.matrix[2][0]) - } - pub fn reflection(&self) -> bool { - self.determinant() < 0.0 - } - pub fn apply(&self, p: [f64; 3]) -> [f64; 3] { - [ - self.translation[0] - + self.matrix[0][0] * p[0] - + self.matrix[0][1] * p[1] - + self.matrix[0][2] * p[2], - self.translation[1] - + self.matrix[1][0] * p[0] - + self.matrix[1][1] * p[1] - + self.matrix[1][2] * p[2], - self.translation[2] - + self.matrix[2][0] * p[0] - + self.matrix[2][1] * p[1] - + self.matrix[2][2] * p[2], - ] - } - pub fn compose(&self, child: &Self) -> Self { - let mut m = [[0.0; 3]; 3]; - for r in 0..3 { - for c in 0..3 { - m[r][c] = (0..3).map(|i| self.matrix[r][i] * child.matrix[i][c]).sum(); - } - } - let t = self.apply(child.translation); - Self { - translation: t, - matrix: m, - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Bounds { - pub min: [f64; 3], - pub max: [f64; 3], -} -impl Default for Bounds { - fn default() -> Self { - Self { - min: [f64::INFINITY; 3], - max: [f64::NEG_INFINITY; 3], - } - } -} -impl Bounds { - fn add(&mut self, p: [f64; 3]) { - for i in 0..3 { - self.min[i] = self.min[i].min(p[i]); - self.max[i] = self.max[i].max(p[i]); - } - } - fn values(&self) -> [String; 6] { - [ - fmt_num(self.min[0]), - fmt_num(self.min[1]), - fmt_num(self.min[2]), - fmt_num(self.max[0]), - fmt_num(self.max[1]), - fmt_num(self.max[2]), - ] - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct SyntaxRecord { - pub line_type: u8, - pub raw_tail: String, - pub span: Span, - pub line_ending: String, - pub fields: Vec, -} -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct SemanticRecord { - pub canonical_path: Option, - pub root_identity: Option, - pub target_identity: Option, - pub cache_identity: Option, - pub bfc_state: String, - pub colour_state: String, - pub steps: Vec, - pub limits: Vec, - pub texmap_events: Vec, -} -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct SceneRecord { - pub model_id: String, - pub instance_ids: Vec, - pub triangles: u64, - pub lines: u64, - pub bounds: [String; 6], - pub reflection: bool, -} -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct CanonicalRecord { - pub schema_version: String, - pub syntax: Vec, - pub semantic: SemanticRecord, - pub scene: SceneRecord, - pub diagnostics: Vec, - pub provenance_id: String, -} - -#[derive(Debug)] -pub struct SemanticArenas { - pub admitted_budget: u64, - // Keep child reservations before root so Rust drops children first. - children: Vec, - pub root: Reservation, -} -impl SemanticArenas { - fn new(options: &ParseOptions<'_>, budget: u64) -> Result { - let root = options - .ledger - .reserve(options.owner, ResourceClass::SemanticArenas, budget) - .map_err(reservation_error)?; - Ok(Self { - admitted_budget: budget, - children: Vec::new(), - root, - }) - } - fn child(&mut self, options: &ParseOptions<'_>, bytes: u64) -> Result<(), ParseError> { - let child = options - .ledger - .reserve_child( - &self.root, - options.owner, - ResourceClass::SemanticArenas, - bytes, - ) - .map_err(reservation_error)?; - self.children.push(child); - Ok(()) - } - pub fn reservation_id(&self) -> u64 { - self.root.id() - } -} -fn reservation_error(error: AdmissionError) -> ParseError { - let message = match error.reason { - RejectionReason::ParentCapacity | RejectionReason::Overflow => { - "semantic arena child reservation exceeded the admitted budget" - } - _ => "semantic arena reservation was rejected by the ledger", - }; - ParseError::Diagnostic(Diagnostic { - code: "SEMANTIC_ARENA_RESERVATION_LIMIT".into(), - severity: Severity::Error, - message: message.into(), - span: None, - }) -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct LimitCounters { - pub resource_bytes: u64, - pub line_bytes: u64, - pub files: u64, - pub include_depth: u64, - pub commands: u64, - pub instances: u64, - pub triangles: u64, - pub lines: u64, - pub textures: u64, - pub fetches: u64, - pub diagnostics: u64, -} -impl LimitCounters { - fn add( - &mut self, - name: &'static str, - delta: u64, - limits: &LdrawLimits, - ) -> Result<(), &'static str> { - let slot = match name { - "resource_bytes" => &mut self.resource_bytes, - "line_bytes" => &mut self.line_bytes, - "files" => &mut self.files, - "include_depth" => &mut self.include_depth, - "commands" => &mut self.commands, - "instances" => &mut self.instances, - "triangles" => &mut self.triangles, - "lines" => &mut self.lines, - "textures" => &mut self.textures, - "fetches" => &mut self.fetches, - "diagnostics" => &mut self.diagnostics, - _ => return Err(name), - }; - let next = slot.checked_add(delta).ok_or(name)?; - let ceiling = match name { - "resource_bytes" => limits.resource_bytes, - "line_bytes" => limits.line_bytes, - "files" => limits.files, - "include_depth" => limits.include_depth, - "commands" => limits.commands, - "instances" => limits.instances, - "triangles" => limits.triangles, - "lines" => limits.lines, - "textures" => limits.textures, - "fetches" => limits.fetches, - "diagnostics" => limits.diagnostics, - _ => 0, - }; - if next > ceiling { - return Err(name); - } - *slot = next; - Ok(()) - } -} - -#[derive(Clone, Debug)] -struct Include { - name: String, - transform: Transform, - span: Span, - valid: bool, - inverted: bool, -} -#[derive(Clone, Debug)] -struct ModelData { - file: VirtualFile, - path: NormalizedPath, - key: CacheKey, - bfc: BfcFrame, - colour: String, - steps: Vec, - texmap_state: TexmapState, - texmap_descriptor: Option, - texmap_stack: Vec<(TexmapState, Option, bool)>, - texmap_next_span: Option, - texmap_fallback_seen: bool, - texmap_events: Vec, - includes: Vec, - triangles: u64, - lines: u64, - bounds: Bounds, - reflection: bool, -} - -#[derive(Debug)] -pub struct OwnedParseResult { - pub syntax: Vec, - pub semantic: SemanticRecord, - pub scene: SceneRecord, - pub diagnostics: Vec, - pub counters: LimitCounters, - pub files: Vec, - pub models: Vec, - pub arenas: SemanticArenas, - pub provenance_id: String, -} -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ModelSummary { - pub id: String, - pub path: String, - pub includes: Vec, - pub triangles: u64, - pub lines: u64, - pub bfc: BfcState, -} -impl OwnedParseResult { - pub fn project(&self) -> CanonicalRecord { - CanonicalRecord { - schema_version: SCHEMA_VERSION.into(), - syntax: self.syntax.clone(), - semantic: self.semantic.clone(), - scene: self.scene.clone(), - diagnostics: self.diagnostics.clone(), - provenance_id: self.provenance_id.clone(), - } - } - pub fn ledger_reservation_id(&self) -> u64 { - self.arenas.reservation_id() - } -} - -pub struct LdrawParser; -impl Default for LdrawParser { - fn default() -> Self { - Self - } -} -impl LdrawParser { - pub fn parse_bytes<'a>( - &self, - bytes: &[u8], - options: ParseOptions<'a>, - ) -> Result { - let mut counters = LimitCounters::default(); - let input_len = - u64::try_from(bytes.len()).map_err(|_| ParseError::Overflow("resource bytes"))?; - counters - .add("resource_bytes", input_len, &options.limits) - .map_err(|name| limit_error(name, None))?; - if options.fetch_count > options.limits.fetches { - return Err(limit_error("fetches", None)); - } - if options.cancellation.cancelled { - return Err(ParseError::Cancelled); - } - let budget = options.budget(bytes.len())?; - let mut arenas = SemanticArenas::new(&options, budget)?; - let scanned = scan_lines(bytes, &options.limits)?; - for line in &scanned { - if options.cancellation.cancelled { - return Err(ParseError::Cancelled); - } - let line_bytes = u64::try_from(line.raw.len()) - .map_err(|_| ParseError::Overflow("line bytes"))?; - counters - .add("line_bytes", line_bytes, &options.limits) - .map_err(|name| limit_error(name, Some(line.span)))?; - } - let mut files = split_mpd(&scanned, &options.limits)?; - let has_mpd_file = scanned.iter().any(|line| { - line.line_type == Some(0) - && line.tokens.get(1).map(|token| token.kind) == Some(TokenKind::File) - && line.tokens.len() >= 3 - }); - if !has_mpd_file { - files[0].name = options.root_name.clone(); - } - if options.cancellation.cancelled { - return Err(ParseError::Cancelled); - } - let file_count = u64::try_from(files.len()) - .map_err(|_| ParseError::Overflow("file count conversion"))?; - counters - .add("files", file_count, &options.limits) - .map_err(|name| limit_error(name, None))?; - let mut diagnostics = Vec::new(); - let mut models = Vec::new(); - let mut syntax = Vec::new(); - for line in &scanned { - syntax.push(syntax_record(line)); - } - let mut local_colours = BTreeMap::new(); - let mut texture_ids = BTreeSet::new(); - for file in files.iter().cloned() { - let colours_before = local_colours.len(); - if options.cancellation.cancelled { - return Err(ParseError::Cancelled); - } - let path = NormalizedPath::new(&file.name)?; - let file_bytes = file - .lines - .iter() - .flat_map(|line| line.raw.iter().copied().chain(line.ending.as_str().as_bytes().iter().copied())) - .collect::>(); - let key = CacheKey::new(path.clone(), options.resolved_root, &file_bytes); - let mut model = ModelData { - file: file.clone(), - path, - key, - bfc: BfcFrame::default(), - colour: "16".into(), - steps: 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(), - includes: Vec::new(), - triangles: 0, - lines: 0, - bounds: Bounds::default(), - reflection: false, - }; - for line in &file.lines { - if line.blank { - continue; - } - if options.cancellation.cancelled { - return Err(ParseError::Cancelled); - } - if counters.add("commands", 1, &options.limits).is_err() { - return Err(limit_error("commands", Some(line.span))); - } - let before = diagnostics.len(); - let result = process_line( - &mut model, - line, - options.profile, - &mut diagnostics, - &mut counters, - &options.limits, - &mut local_colours, - &mut texture_ids, - ); - if let Err(error) = result { - if options.profile == ParserProfile::Strict { - 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("diagnostics", Some(line.span))); - } - } - let include_count = u64::try_from(model.includes.len()) - .map_err(|_| ParseError::Overflow("include count conversion"))?; - let colour_entries = local_colours - .len() - .checked_sub(colours_before) - .ok_or(ParseError::Overflow("colour entry delta"))?; - let colour_entries = u64::try_from(colour_entries) - .map_err(|_| ParseError::Overflow("colour entry conversion"))?; - let graph_entries = include_count - .checked_add(1) - .ok_or(ParseError::Overflow("graph entry estimate"))?; - let texture_events = u64::try_from(model.texmap_events.len()) - .map_err(|_| ParseError::Overflow("TEXMAP event conversion"))?; - let child_charge = 256u64 - .checked_add( - include_count - .checked_mul(128) - .ok_or(ParseError::Overflow("instance arena estimate"))?, - ) - .and_then(|charge| { - charge.checked_add( - graph_entries - .checked_mul(64).unwrap_or(u64::MAX), - ) - }) - .and_then(|charge| { - charge.checked_add( - colour_entries - .checked_mul(32).unwrap_or(u64::MAX), - ) - }) - .and_then(|charge| { - charge.checked_add( - texture_events - .checked_mul(48).unwrap_or(u64::MAX), - ) - }) - .ok_or(ParseError::Overflow("model arena estimate"))?; - arenas.child(&options, child_charge)?; - models.push(model); - } - let (scene, summaries) = traverse(&models, &options, &mut counters, &mut diagnostics)?; - let root = models.first(); - let semantic = SemanticRecord { - canonical_path: root.map(|m| m.path.to_string()), - root_identity: Some(format!("{:?}", options.resolved_root).to_ascii_lowercase()), - target_identity: root.map(|m| m.path.to_string()), - cache_identity: root.map(|m| m.key.content_hash.to_string()), - bfc_state: root - .map(|m| format!("{:?}", m.bfc.state).to_ascii_lowercase()) - .unwrap_or_else(|| "unknown".into()), - colour_state: root - .map(|m| m.colour.clone()) - .unwrap_or_else(|| "16".into()), - steps: root.map(|m| m.steps.clone()).unwrap_or_default(), - limits: LdrawLimits::NAMES - .iter() - .map(|name| (*name).into()) - .collect(), - texmap_events: root.map(|m| m.texmap_events.clone()).unwrap_or_default(), - }; - Ok(OwnedParseResult { - syntax, - semantic, - scene, - diagnostics, - counters, - files, - models: summaries, - arenas, - provenance_id: "local-parse".into(), - }) - } -} - -fn limit_error(name: &'static str, span: Option) -> ParseError { - ParseError::Diagnostic(Diagnostic { - code: format!("{}_LIMIT", name.to_ascii_uppercase()), - severity: Severity::Error, - message: format!("{name} limit exceeded"), - span, - }) -} -fn syntax_record(line: &ScannedLine) -> SyntaxRecord { - SyntaxRecord { - line_type: line.line_type.unwrap_or(255), - raw_tail: String::from_utf8_lossy(&line.raw_tail).into_owned(), - span: line.span, - line_ending: line.ending.as_str().into(), - fields: line.tokens.iter().map(|t| t.text.clone()).collect(), - } -} -fn add_diag( - profile: ParserProfile, - diagnostics: &mut Vec, - counters: &mut LimitCounters, - limits: &LdrawLimits, - code: &str, - span: Span, - message: &str, - strict_reject: bool, -) -> Result<(), ParseError> { - let severity = if strict_reject && profile == ParserProfile::Strict { - Severity::Error - } else if profile == ParserProfile::Strict { - Severity::Error - } else { - Severity::Warning - }; - counters.add("diagnostics", 1, limits).map_err(|name| { - ParseError::Diagnostic(Diagnostic { - code: format!("{}_LIMIT", name.to_ascii_uppercase()), - severity: Severity::Error, - message: "diagnostic limit exceeded".into(), - span: Some(span), - }) - })?; - diagnostics.push(Diagnostic { - code: code.into(), - severity, - message: message.into(), - span: Some(span), - }); - if strict_reject && profile == ParserProfile::Strict { - return Err(ParseError::Diagnostic(diagnostics.last().cloned().unwrap())); - } - Ok(()) -} - -fn parse_number(token: Option<&Token>) -> Option { - token?.text.parse::().ok().filter(|n| n.is_finite()) -} -fn parse_points(tokens: &[Token], count: usize) -> Option> { - let coordinate_count = count.checked_mul(3)?; - let expected = coordinate_count.checked_add(2)?; - if tokens.len() != expected || tokens.get(1)?.kind != TokenKind::Number { - return None; - } - let mut values = Vec::with_capacity(coordinate_count); - for token in tokens.iter().skip(2) { - values.push(parse_number(Some(token))?); - } - Some(values.chunks_exact(3).map(|v| [v[0], v[1], v[2]]).collect()) -} -fn process_line( - model: &mut ModelData, - line: &ScannedLine, - profile: ParserProfile, - diagnostics: &mut Vec, - counters: &mut LimitCounters, - limits: &LdrawLimits, - local_colours: &mut BTreeMap, - texture_ids: &mut BTreeSet, -) -> Result<(), ParseError> { - if model.texmap_state == TexmapState::AwaitingNext && !line.blank { - if line.line_type == Some(0) { - 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, "TEXMAP_NEXT_TYPE0", span, - "NEXT was cancelled by a type-0 line", true)?; - } else if matches!(line.line_type, Some(1..=5)) { - if primitive_is_valid(line) { - let descriptor = model.texmap_descriptor.take(); - model.texmap_state = TexmapState::Active; - process_geometry(model, line, profile, diagnostics, counters, limits)?; - model.texmap_state = TexmapState::Inactive; - if let Some(descriptor) = descriptor { - let _ = texture_ids.insert(descriptor); - } - model.texmap_next_span = None; - return Ok(()); - } - } - } - if line.line_type == Some(0) { - 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, profile, diagnostics, counters, limits, local_colours, texture_ids); - } - process_geometry(model, line, profile, diagnostics, counters, limits) -} - -fn primitive_is_valid(line: &ScannedLine) -> bool { - match line.line_type { - Some(1) => line.tokens.len() >= 15 - && parse_number(line.tokens.get(1)).is_some() - && (2..14).all(|i| parse_number(line.tokens.get(i)).is_some()) - && line.tokens.len() > 14, - Some(2) => parse_points(&line.tokens, 2).is_some(), - Some(3) => parse_points(&line.tokens, 3).is_some(), - Some(4 | 5) => parse_points(&line.tokens, 4).is_some(), - _ => false, - } -} - -fn process_geometry( - model: &mut ModelData, - line: &ScannedLine, - profile: ParserProfile, - diagnostics: &mut Vec, - counters: &mut LimitCounters, - limits: &LdrawLimits, -) -> Result<(), ParseError> { - match line.line_type { - Some(1) => { - let valid = primitive_is_valid(line); - if !valid { - return add_diag(profile, diagnostics, counters, limits, "INVALID_TYPE1", line.span, - "malformed type-1 include", true); - } - let values = (2..14).map(|i| parse_number(line.tokens.get(i)).unwrap()).collect::>(); - let transform = Transform::from_type1(&values).unwrap(); - let name = line.tokens[14..].iter().map(|t| t.text.as_str()).collect::>().join(" "); - let inverted = model.bfc.invert_next; - model.bfc.invert_next = false; - model.includes.push(Include { name, transform, span: line.span, valid: true, inverted }); - if model.bfc.state == BfcState::Unknown { model.bfc.state = BfcState::Uncertified; } - } - Some(2) | Some(3) | Some(4) | Some(5) => { - let typ = line.line_type.unwrap(); - let count = if typ == 2 { 2 } else if typ == 3 { 3 } else { 4 }; - let Some(points) = parse_points(&line.tokens, count) else { - model.bfc.invert_next = false; - return add_diag(profile, diagnostics, counters, limits, - match typ { 2 => "INVALID_TYPE2", 3 => "INVALID_TYPE3", 4 => "INVALID_TYPE4", _ => "INVALID_TYPE5" }, - line.span, "malformed primitive", true); - }; - for point in points { model.bounds.add(point); } - if typ == 2 || typ == 5 { - model.lines = model.lines.checked_add(1).ok_or(ParseError::Overflow("lines"))?; - counters.add("lines", 1, limits).map_err(|name| limit_error(name, Some(line.span)))?; - } else { - let delta = if typ == 4 { 2 } else { 1 }; - model.triangles = model.triangles.checked_add(delta).ok_or(ParseError::Overflow("triangles"))?; - counters.add("triangles", delta, limits).map_err(|name| limit_error(name, Some(line.span)))?; - } - if model.bfc.state == BfcState::Unknown { model.bfc.state = BfcState::Uncertified; } - model.bfc.invert_next = false; - } - _ => { - model.bfc.invert_next = false; - if profile == ParserProfile::Strict { - return add_diag(profile, diagnostics, counters, limits, "INVALID_LINE_TYPE", line.span, - "expected line type 0 through 5", true); - } - } - } - Ok(()) -} - -fn process_meta( - model: &mut ModelData, - line: &ScannedLine, - profile: ParserProfile, - diagnostics: &mut Vec, - counters: &mut LimitCounters, - limits: &LdrawLimits, - local_colours: &mut BTreeMap, - texture_ids: &mut BTreeSet, -) -> Result<(), ParseError> { - let tokens = &line.tokens; - if tokens.len() < 2 { - if profile == ParserProfile::Strict { - return add_diag( - profile, - diagnostics, - counters, - limits, - "META_EMPTY", - 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); - } - match tokens[1].kind { - TokenKind::Step => { - let step = model.steps.len().checked_add(1).ok_or(ParseError::Overflow("step number"))?; - model.steps.push(format!("step-{step:08}")); - 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.len() >= 4 && tokens[2].kind == TokenKind::Identifier { - let code = tokens - .iter() - .find(|t| t.kind == TokenKind::Code) - .and_then(|_| tokens.iter().position(|t| t.kind == TokenKind::Number)) - .and_then(|i| tokens.get(i)) - .and_then(|t| t.text.parse::().ok()); - if let Some(code) = code { - let slot = u16::try_from(local_colours.len()) - .map_err(|_| ParseError::Overflow("colour slot"))?; - if slot >= 512 { - return add_diag( - profile, - diagnostics, - counters, - limits, - "COLOUR_SLOTS_LIMIT", - line.span, - "local colour table exhausted", - true, - ); - } - local_colours.insert(tokens[2].text.clone(), slot); - model.colour = code.to_string(); - } - } - model.bfc.invert_next = false; - } - TokenKind::Stop => { - add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_UNKNOWN_META_STOP", - line.span, - "literal STOP is unknown META, not END", - true, - )?; - } - _ => { - if model.bfc.invert_next { - model.bfc.invert_next = false; - } - } - } - Ok(()) -} -fn bfc( - model: &mut ModelData, - line: &ScannedLine, - profile: ParserProfile, - diagnostics: &mut Vec, - counters: &mut LimitCounters, - limits: &LdrawLimits, -) -> Result<(), ParseError> { - if line.tokens.len() < 3 { - return add_diag( - profile, - diagnostics, - counters, - limits, - "BFC_INVALID_DIRECTIVE", - line.span, - "BFC requires a directive", - true, - ); - } - let directive = line.tokens[2].kind; - match directive { - TokenKind::Certify => { - if model.bfc.state == BfcState::Uncertified { - return add_diag( - profile, - diagnostics, - counters, - limits, - "BFC_RECERTIFY_UNCERTIFIED", - line.span, - "cannot re-certify an uncertified model", - true, - ); - } - if line.tokens.len() > 4 - || (line.tokens.len() == 4 && line.tokens[3].kind != TokenKind::Orientation) - { - return add_diag( - profile, - diagnostics, - counters, - limits, - "BFC_INVALID_CERTIFY_ARGUMENT", - line.span, - "CERTIFY accepts at most one orientation", - true, - ); - } - model.bfc.state = BfcState::Certified; - model.bfc.clipping = true; - model.bfc.winding = Winding::Ccw; - if line.tokens.len() == 4 { - model.bfc.winding = if line.tokens[3].text == "CW" { - Winding::Cw - } else { - Winding::Ccw - }; - } - model.bfc.invert_next = false; - } - TokenKind::NoCertify => { - if line.tokens.len() != 3 { - return add_diag( - profile, - diagnostics, - counters, - limits, - "BFC_INVALID_DIRECTIVE", - line.span, - "NOCERTIFY takes no argument", - true, - ); - } - model.bfc.state = BfcState::Uncertified; - model.bfc.clipping = false; - model.bfc.invert_next = false; - } - TokenKind::InvertNext => { - if line.tokens.len() != 3 { - return add_diag( - profile, - diagnostics, - counters, - limits, - "BFC_INVALID_DIRECTIVE", - line.span, - "INVERTNEXT takes no argument", - true, - ); - } - if model.bfc.state == BfcState::Certified { - model.bfc.invert_next = true; - } else if profile != ParserProfile::Strict { - counters - .add("diagnostics", 1, limits) - .map_err(|name| limit_error(name, Some(line.span)))?; - diagnostics.push(Diagnostic { - code: "BFC_INVERTNEXT_OUTSIDE_CERTIFIED".into(), - severity: Severity::Info, - message: "INVERTNEXT ignored outside Certified state".into(), - span: Some(line.span), - }); - } - } - TokenKind::Clip | TokenKind::NoClip | TokenKind::Orientation => { - if model.bfc.state != BfcState::Certified { - return add_diag( - profile, - diagnostics, - counters, - limits, - "BFC_INVALID_CONTEXT", - line.span, - "BFC clip/winding directive requires Certified state", - true, - ); - } - if directive == TokenKind::Clip { - model.bfc.clipping = true; - } else if directive == TokenKind::NoClip { - model.bfc.clipping = false; - } else { - model.bfc.winding = if line.tokens[2].text == "CW" { - Winding::Cw - } else { - Winding::Ccw - }; - } - model.bfc.invert_next = false; - } - _ => { - return add_diag( - profile, - diagnostics, - counters, - limits, - "BFC_INVALID_DIRECTIVE", - line.span, - "unknown BFC directive", - true, - ); - } - } - Ok(()) -} -fn texmap( - model: &mut ModelData, - line: &ScannedLine, - profile: ParserProfile, - diagnostics: &mut Vec, - counters: &mut LimitCounters, - limits: &LdrawLimits, - texture_ids: &mut BTreeSet, -) -> Result<(), ParseError> { - if line.tokens.len() < 3 { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_INVALID_ARITY", - line.span, - "TEXMAP requires START, NEXT, FALLBACK, or END", - true, - ); - } - let kind = line.tokens[2].kind; - match kind { - TokenKind::Start | TokenKind::Next => { - let mode = line.tokens.get(3).map(|token| token.text.as_str()); - let Some(mode) = mode else { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_INVALID_ARITY", - line.span, - "TEXMAP mapping requires a method", - true, - ); - }; - let parameter_count = match mode { - "PLANAR" => 9, - "CYLINDRICAL" => 7, - "SPHERICAL" => 5, - _ => { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_INVALID_METHOD", - line.span, - "unsupported TEXMAP mapping method", - true, - ); - } - }; - if kind == TokenKind::Next && model.texmap_state != TexmapState::Inactive { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_NEXT_NESTED", - line.span, - "NEXT cannot nest an active START", - true, - ); - } - let base = 5usize - .checked_add(parameter_count) - .ok_or(ParseError::Overflow("TEXMAP arity"))?; - let has_gloss = line.tokens.get(base).map(|token| token.kind) == Some(TokenKind::Identifier) - && line.tokens.get(base).map(|token| token.text.as_str()) == Some("GLOSSMAP"); - let expected = base.checked_add(if has_gloss { 2 } else { 0 }) - .ok_or(ParseError::Overflow("TEXMAP arity"))?; - if line.tokens.len() != expected { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_INVALID_ARITY", - line.span, - "TEXMAP mapping has an invalid parameter or GLOSSMAP shape", - true, - ); - } - let parameters = line.tokens[4..4 + parameter_count] - .iter() - .map(|token| { - if token.kind != TokenKind::Number { - return None; - } - let value = token.text.parse::().ok()?; - value.is_finite().then(|| token.text.clone()) - }) - .collect::>>(); - let Some(parameters) = parameters else { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_INVALID_VALUE", - line.span, - "TEXMAP parameter is not finite or numeric", - true, - ); - }; - let png_index = 4 + parameter_count; - let reference = if has_gloss { line.tokens.get(png_index + 2) } else { line.tokens.get(png_index) }; - let Some(reference) = reference.filter(|token| !matches!(token.kind, TokenKind::Number | TokenKind::Garbage)) else { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_INVALID_ARITY", - line.span, - "TEXMAP requires a texture filename", - true, - ); - }; - let glossmap = if has_gloss { - line.tokens.get(png_index + 1).and_then(|token| { - (!matches!(token.kind, TokenKind::Number | TokenKind::Garbage)).then(|| token.text.clone()) - }) - } else { - None - }; - if has_gloss && glossmap.is_none() { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_INVALID_ARITY", - line.span, - "GLOSSMAP requires a texture filename", - true, - ); - } - let descriptor = TextureDescriptor { - mode: mode.into(), - parameters: parameters.clone(), - pngfile: reference.text.clone(), - glossmap, - }; - if texture_ids.insert(descriptor.clone()) { - counters - .add("textures", 1, limits) - .map_err(|name| limit_error(name, Some(line.span)))?; - } - if kind == TokenKind::Next && model.texmap_state != TexmapState::Inactive { - return Ok(()); - } - if kind == TokenKind::Start { - model.texmap_stack.push(( - model.texmap_state, - model.texmap_descriptor.clone(), - model.texmap_fallback_seen, - )); - model.texmap_state = TexmapState::Active; - model.texmap_descriptor = Some(descriptor.clone()); - model.texmap_fallback_seen = false; - } else { - model.texmap_state = TexmapState::AwaitingNext; - model.texmap_descriptor = Some(descriptor.clone()); - model.texmap_next_span = Some(line.span); - } - model.texmap_events.push(TexmapEvent { - kind: if kind == TokenKind::Start { TexmapKind::Start } else { TexmapKind::Next }, - span: line.span, - file_id: model.file.id, - mode: Some(mode.into()), - parameters, - reference: Some(reference.text.clone()), - state: if kind == TokenKind::Start { TexmapState::Active } else { TexmapState::AwaitingNext }, - }); - } - TokenKind::Fallback => { - if !matches!(model.texmap_state, TexmapState::Active | TexmapState::Fallback) { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_FALLBACK_OUT_OF_SCOPE", - line.span, - "FALLBACK has no active START", - true, - ); - } - if model.texmap_state == TexmapState::Fallback || model.texmap_fallback_seen { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_DUPLICATE_FALLBACK", - line.span, - "FALLBACK already occurred", - true, - ); - } - model.texmap_state = TexmapState::Fallback; - model.texmap_fallback_seen = true; - model.texmap_events.push(TexmapEvent { - kind: TexmapKind::Fallback, - span: line.span, - file_id: model.file.id, - mode: None, - parameters: Vec::new(), - reference: None, - state: model.texmap_state, - }); - } - TokenKind::End => { - if line.tokens.len() != 3 { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_INVALID_END", - line.span, - "END takes no argument", - true, - ); - } - if model.texmap_state == TexmapState::Inactive { - return Ok(()); - } - let previous_state = model - .texmap_stack - .pop() - .map(|(state, descriptor, fallback)| { - model.texmap_descriptor = descriptor; - model.texmap_fallback_seen = fallback; - state - }) - .unwrap_or(TexmapState::Inactive); - model.texmap_state = previous_state; - model.texmap_descriptor = if model.texmap_state == TexmapState::Inactive { - None - } else { - model.texmap_descriptor.clone() - }; - model.texmap_events.push(TexmapEvent { - kind: TexmapKind::End, - span: line.span, - file_id: model.file.id, - mode: None, - parameters: Vec::new(), - reference: None, - state: model.texmap_state, - }); - } - TokenKind::Stop => { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_UNKNOWN_META_STOP", - line.span, - "literal STOP is not TEXMAP END", - true, - ); - } - _ => { - return add_diag( - profile, - diagnostics, - counters, - limits, - "TEXMAP_INVALID_ARITY", - line.span, - "unknown TEXMAP command", - true, - ); - } - } - Ok(()) -} - -fn traverse( - models: &[ModelData], - options: &ParseOptions<'_>, - counters: &mut LimitCounters, - diagnostics: &mut Vec, -) -> Result<(SceneRecord, Vec), ParseError> { - if models.is_empty() { - return Ok(( - SceneRecord { - model_id: "model-00000001".into(), - instance_ids: Vec::new(), - triangles: 0, - lines: 0, - bounds: Bounds::default().values(), - reflection: false, - }, - Vec::new(), - )); - } - - #[derive(Clone)] - enum Visit { - Enter { - index: usize, - transform: Transform, - depth: u64, - reflected: bool, - }, - Exit { key: CacheKey }, - } - - let mut ids = HashMap::::new(); - let mut completed = HashSet::::new(); - let mut visiting = HashSet::::new(); - let mut stack = VecDeque::::new(); - let mut summaries = Vec::new(); - let mut instance_ids = Vec::new(); - let mut scene_bounds = Bounds::default(); - let mut scene_triangles = 0u64; - let mut scene_lines = 0u64; - let mut reflection = false; - - let root_key = models[0].key.clone(); - ids.insert(root_key.clone(), "model-00000001".into()); - stack.push_back(Visit::Enter { - index: 0, - transform: Transform::default(), - depth: 0, - reflected: false, - }); - - while let Some(visit) = stack.pop_back() { - if options.cancellation.cancelled { - return Err(ParseError::Cancelled); - } - match visit { - Visit::Exit { key } => { - visiting.remove(&key); - completed.insert(key); - } - Visit::Enter { - index, - transform, - depth, - reflected, - } => { - let model = &models[index]; - let key = model.key.clone(); - if !visiting.insert(key.clone()) { - continue; - } - let first_parse = !completed.contains(&key); - let model_id = ids - .get(&key) - .cloned() - .unwrap_or_else(|| format!("model-{:08}", ids.len() + 1)); - if !summaries.iter().any(|summary: &ModelSummary| summary.id == model_id) { - summaries.push(ModelSummary { - id: model_id, - path: model.path.to_string(), - includes: model.includes.iter().map(|i| i.name.clone()).collect(), - triangles: model.triangles, - lines: model.lines, - bfc: model.bfc.state, - }); - } - - let corners = [ - [model.bounds.min[0], model.bounds.min[1], model.bounds.min[2]], - [model.bounds.min[0], model.bounds.min[1], model.bounds.max[2]], - [model.bounds.min[0], model.bounds.max[1], model.bounds.min[2]], - [model.bounds.min[0], model.bounds.max[1], model.bounds.max[2]], - [model.bounds.max[0], model.bounds.min[1], model.bounds.min[2]], - [model.bounds.max[0], model.bounds.min[1], model.bounds.max[2]], - [model.bounds.max[0], model.bounds.max[1], model.bounds.min[2]], - [model.bounds.max[0], model.bounds.max[1], model.bounds.max[2]], - ]; - for corner in corners { - scene_bounds.add(transform.apply(corner)); - } - scene_triangles = scene_triangles - .checked_add(model.triangles) - .ok_or(ParseError::Overflow("scene triangles"))?; - scene_lines = scene_lines - .checked_add(model.lines) - .ok_or(ParseError::Overflow("scene lines"))?; - reflection ^= reflected; - - stack.push_back(Visit::Exit { key: key.clone() }); - if first_parse { - for include in model.includes.iter().rev() { - if options.cancellation.cancelled { - return Err(ParseError::Cancelled); - } - counters - .add("instances", 1, &options.limits) - .map_err(|name| limit_error(name, Some(include.span)))?; - instance_ids.push(format!("instance-{:08}", counters.instances)); - let target = models.iter().position(|candidate| { - candidate - .path - .as_str() - .eq_ignore_ascii_case(&include.name.replace('\\', "/")) - }); - let child_depth = depth - .checked_add(1) - .ok_or(ParseError::Overflow("include depth"))?; - if child_depth > options.limits.include_depth { - add_diag( - options.profile, - diagnostics, - counters, - &options.limits, - "INCLUDE_DEPTH_LIMIT", - include.span, - "include depth limit exceeded", - false, - )?; - continue; - } - let Some(target) = target else { - continue; - }; - let child = &models[target]; - if visiting.contains(&child.key) { - add_diag( - options.profile, - diagnostics, - counters, - &options.limits, - "GRAPH_CYCLE", - include.span, - "include cycle cut at active graph key", - false, - )?; - continue; - } - if !ids.contains_key(&child.key) { - let next_id = ids - .len() - .checked_add(1) - .ok_or(ParseError::Overflow("model id"))?; - ids.insert(child.key.clone(), format!("model-{next_id:08}")); - } - stack.push_back(Visit::Enter { - index: target, - transform: transform.compose(&include.transform), - depth: child_depth, - reflected: reflected ^ include.inverted ^ include.transform.reflection(), - }); - } - } - } - } - } - let scene = SceneRecord { - model_id: "model-00000001".into(), - instance_ids, - triangles: scene_triangles, - lines: scene_lines, - bounds: scene_bounds.values(), - reflection, - }; - Ok((scene, summaries)) -} - -pub struct InProcessRustAdapter; -#[derive(Clone)] -pub struct AdapterRequest<'a> { - pub fixture_id: &'a str, - pub bytes: &'a [u8], - pub profile: ParserProfile, - pub provenance_id: &'a str, - pub owner: ReservationOwner, - pub ledger: &'a ReservationLedger, - pub semantic_budget: Option, - pub root_name: &'a str, -} -impl InProcessRustAdapter { - pub fn parse(request: AdapterRequest<'_>) -> Result { - let mut options = ParseOptions::new(request.owner, request.ledger); - options.profile = request.profile; - options.semantic_budget = request.semantic_budget; - options.root_name = request.root_name.into(); - let mut result = LdrawParser.parse_bytes(request.bytes, options)?; - result.provenance_id = request.provenance_id.into(); - let _ = request.fixture_id; - Ok(result) - } - pub fn parse_fixture<'a>( - fixture_id: &'a str, - bytes: &'a [u8], - profile: ParserProfile, - owner: ReservationOwner, - ledger: &'a ReservationLedger, - ) -> Result { - Self::parse(AdapterRequest { - fixture_id, - bytes, - profile, - provenance_id: fixture_id, - owner, - ledger, - semantic_budget: Some(16 * 1024 * 1024), - root_name: fixture_id, - }) - } -} - -fn fmt_num(value: f64) -> String { - if !value.is_finite() { - return "0".into(); - } - if value == 0.0 { - return "0".into(); - } - let mut s = format!("{value:.6}"); - while s.contains('.') && s.ends_with('0') { - s.pop(); - } - if s.ends_with('.') { - s.pop(); - } - s -} -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} +mod adapter; +mod bfc; +mod cache; +mod geom; +mod model; +mod mpd; +mod parser; +mod scanner; +mod texmap; +mod traversal; +mod types; +mod util; + +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 texmap::*; + +pub use types::*; #[cfg(test)] mod tests { use super::*; + use polymodel_renderer_ledger::{ReservationLedger, ReservationOwner}; fn options<'a>(ledger: &'a ReservationLedger) -> ParseOptions<'a> { ParseOptions::new(ReservationOwner::preview(1, 1, 1), ledger) } @@ -2252,7 +83,12 @@ mod tests { 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 result = LdrawParser.parse_bytes(bytes, options(&ledger)).unwrap(); assert_eq!(result.scene.model_id, "model-00000001"); - assert!(result.scene.instance_ids.contains(&"instance-00000001".to_string())); + assert!( + result + .scene + .instance_ids + .contains(&"instance-00000001".to_string()) + ); } #[test] fn limits_reject_before_publication() { diff --git a/crates/polymodel-ldraw-core/src/model.rs b/crates/polymodel-ldraw-core/src/model.rs new file mode 100644 index 0000000..d184e9a --- /dev/null +++ b/crates/polymodel-ldraw-core/src/model.rs @@ -0,0 +1,111 @@ +use crate::bfc::{BfcFrame, BfcState}; +use crate::cache::{CacheKey, NormalizedPath}; +use crate::geom::{Bounds, Transform}; +use crate::mpd::VirtualFile; +use crate::texmap::{TexmapEvent, TexmapState, TextureDescriptor}; +use crate::types::{Diagnostic, LimitCounters, SCHEMA_VERSION, SemanticArenas, Span}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SyntaxRecord { + pub line_type: u8, + pub raw_tail: String, + pub span: Span, + pub line_ending: String, + pub fields: Vec, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SemanticRecord { + pub canonical_path: Option, + pub root_identity: Option, + pub target_identity: Option, + pub cache_identity: Option, + pub bfc_state: String, + pub colour_state: String, + pub steps: Vec, + pub limits: Vec, + pub texmap_events: Vec, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SceneRecord { + pub model_id: String, + pub instance_ids: Vec, + pub triangles: u64, + pub lines: u64, + pub bounds: [String; 6], + pub reflection: bool, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct CanonicalRecord { + pub schema_version: String, + pub syntax: Vec, + pub semantic: SemanticRecord, + pub scene: SceneRecord, + pub diagnostics: Vec, + pub provenance_id: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct Include { + pub(crate) name: String, + pub(crate) transform: Transform, + pub(crate) span: Span, + pub(crate) valid: bool, + pub(crate) inverted: bool, +} +#[derive(Clone, Debug)] +pub(crate) struct ModelData { + pub(crate) file: VirtualFile, + pub(crate) path: NormalizedPath, + pub(crate) key: CacheKey, + pub(crate) bfc: BfcFrame, + pub(crate) colour: String, + pub(crate) steps: Vec, + pub(crate) texmap_state: TexmapState, + pub(crate) texmap_descriptor: Option, + pub(crate) texmap_stack: Vec<(TexmapState, Option, bool)>, + pub(crate) texmap_next_span: Option, + pub(crate) texmap_fallback_seen: bool, + pub(crate) texmap_events: Vec, + pub(crate) includes: Vec, + pub(crate) triangles: u64, + pub(crate) lines: u64, + pub(crate) bounds: Bounds, + pub(crate) reflection: bool, +} + +pub struct OwnedParseResult { + pub syntax: Vec, + pub semantic: SemanticRecord, + pub scene: SceneRecord, + pub diagnostics: Vec, + pub counters: LimitCounters, + pub files: Vec, + pub models: Vec, + pub arenas: SemanticArenas, + pub provenance_id: String, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ModelSummary { + pub id: String, + pub path: String, + pub includes: Vec, + pub triangles: u64, + pub lines: u64, + pub bfc: BfcState, +} +impl OwnedParseResult { + pub fn project(&self) -> CanonicalRecord { + CanonicalRecord { + schema_version: SCHEMA_VERSION.into(), + syntax: self.syntax.clone(), + semantic: self.semantic.clone(), + scene: self.scene.clone(), + diagnostics: self.diagnostics.clone(), + provenance_id: self.provenance_id.clone(), + } + } + pub fn ledger_reservation_id(&self) -> u64 { + self.arenas.reservation_id() + } +} diff --git a/crates/polymodel-ldraw-core/src/mpd.rs b/crates/polymodel-ldraw-core/src/mpd.rs new file mode 100644 index 0000000..b750c52 --- /dev/null +++ b/crates/polymodel-ldraw-core/src/mpd.rs @@ -0,0 +1,95 @@ +use crate::scanner::{ScannedLine, TokenKind}; +use crate::types::{Diagnostic, DiagnosticCode, LdrawLimits, ParseError, Severity, Span}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct VirtualFile { + pub id: u32, + pub name: String, + pub lines: Vec, + pub source_span: Span, +} + +pub fn split_mpd( + lines: &[ScannedLine], + limits: &LdrawLimits, +) -> Result, ParseError> { + let has_file = lines.iter().any(|line| { + line.line_type == Some(0) + && line.tokens.get(1).map(|t| t.kind) == Some(TokenKind::File) + && line.tokens.len() >= 3 + }); + if !has_file { + return Ok(vec![VirtualFile { + id: 0, + name: "model.ldr".into(), + lines: lines.to_vec(), + source_span: lines.first().map(|l| l.span).unwrap_or(Span { + start: 0, + end: 0, + line: 1, + column: 1, + }), + }]); + } + let mut files = Vec::new(); + let mut active: Option = None; + let mut discarded = true; + for line in lines { + if line.line_type == Some(0) + && line.tokens.get(1).map(|t| t.kind) == Some(TokenKind::File) + && line.tokens.len() >= 3 + { + let count = + u64::try_from(files.len()).map_err(|_| ParseError::Overflow("file count"))?; + if count >= limits.files { + return Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::FilesLimit, + severity: Severity::Error, + message: "virtual file limit exceeded".into(), + span: Some(line.span), + })); + } + let name = line.tokens[2..] + .iter() + .map(|t| t.text.as_str()) + .collect::>() + .join(" "); + files.push(VirtualFile { + id: u32::try_from(files.len()) + .map_err(|_| ParseError::Overflow("virtual file id"))?, + name, + lines: Vec::new(), + source_span: line.span, + }); + active = Some(files.len() - 1); + discarded = false; + continue; + } + if line.line_type == Some(0) + && line.tokens.get(1).map(|t| t.kind) == Some(TokenKind::NoFile) + { + active = None; + discarded = true; + continue; + } + if let Some(index) = active { + files[index].lines.push(line.clone()); + } else if !discarded && !files.is_empty() { /* explicit MPD discard state */ + } + } + if files.is_empty() { + return Ok(vec![VirtualFile { + id: 0, + name: "model.ldr".into(), + lines: lines.to_vec(), + source_span: lines.first().map(|l| l.span).unwrap_or(Span { + start: 0, + end: 0, + line: 1, + column: 1, + }), + }]); + } + Ok(files) +} diff --git a/crates/polymodel-ldraw-core/src/parser.rs b/crates/polymodel-ldraw-core/src/parser.rs new file mode 100644 index 0000000..733cf92 --- /dev/null +++ b/crates/polymodel-ldraw-core/src/parser.rs @@ -0,0 +1,426 @@ +use crate::bfc::{BfcFrame, bfc}; +use crate::cache::{CacheKey, NormalizedPath}; +use crate::geom::{Bounds, primitive_is_valid, process_geometry}; +use crate::model::{ModelData, OwnedParseResult, SemanticRecord, SyntaxRecord}; +use crate::mpd::split_mpd; +use crate::scanner::{ScannedLine, TokenKind, scan_lines}; +use crate::texmap::{TexmapState, TextureDescriptor, texmap}; +use crate::traversal::traverse; +use crate::types::{ + Diagnostic, DiagnosticCode, LdrawLimits, LimitCounters, LimitKind, ParseError, ParseOptions, + ParserProfile, SemanticArenas, Severity, Span, +}; +use std::collections::{BTreeMap, BTreeSet}; + +pub struct LdrawParser; +impl Default for LdrawParser { + fn default() -> Self { + Self + } +} +impl LdrawParser { + pub fn parse_bytes<'a>( + &self, + bytes: &[u8], + options: ParseOptions<'a>, + ) -> Result { + 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.cancelled { + return Err(ParseError::Cancelled); + } + let budget = options.budget(bytes.len())?; + let mut arenas = SemanticArenas::new(&options, budget)?; + let scanned = scan_lines(bytes, &options.limits)?; + for line in &scanned { + if options.cancellation.cancelled { + return Err(ParseError::Cancelled); + } + let line_bytes = + u64::try_from(line.raw.len()).map_err(|_| ParseError::Overflow("line bytes"))?; + counters + .add(LimitKind::LineBytes, line_bytes, &options.limits) + .map_err(|name| limit_error(name, Some(line.span)))?; + } + let mut files = split_mpd(&scanned, &options.limits)?; + let has_mpd_file = scanned.iter().any(|line| { + line.line_type == Some(0) + && line.tokens.get(1).map(|token| token.kind) == Some(TokenKind::File) + && line.tokens.len() >= 3 + }); + if !has_mpd_file { + files[0].name = options.root_name.clone(); + } + if options.cancellation.cancelled { + 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(); + let mut models = Vec::new(); + let mut syntax = Vec::new(); + for line in &scanned { + syntax.push(syntax_record(line)); + } + let mut local_colours = BTreeMap::new(); + let mut texture_ids = BTreeSet::new(); + for file in files.iter().cloned() { + let colours_before = local_colours.len(); + if options.cancellation.cancelled { + return Err(ParseError::Cancelled); + } + let path = NormalizedPath::new(&file.name)?; + let file_bytes = file + .lines + .iter() + .flat_map(|line| { + line.raw + .iter() + .copied() + .chain(line.ending.as_str().as_bytes().iter().copied()) + }) + .collect::>(); + let key = CacheKey::new(path.clone(), options.resolved_root, &file_bytes); + let mut model = ModelData { + file: file.clone(), + path, + key, + bfc: BfcFrame::default(), + colour: "16".into(), + steps: 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(), + includes: Vec::new(), + triangles: 0, + lines: 0, + bounds: Bounds::default(), + reflection: false, + }; + for line in &file.lines { + if line.blank { + continue; + } + if options.cancellation.cancelled { + 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.profile, + &mut diagnostics, + &mut counters, + &options.limits, + &mut local_colours, + &mut texture_ids, + ); + if let Err(error) = result { + if options.profile == ParserProfile::Strict { + 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))); + } + } + let include_count = u64::try_from(model.includes.len()) + .map_err(|_| ParseError::Overflow("include count conversion"))?; + let colour_entries = local_colours + .len() + .checked_sub(colours_before) + .ok_or(ParseError::Overflow("colour entry delta"))?; + let colour_entries = u64::try_from(colour_entries) + .map_err(|_| ParseError::Overflow("colour entry conversion"))?; + let graph_entries = include_count + .checked_add(1) + .ok_or(ParseError::Overflow("graph entry estimate"))?; + let texture_events = u64::try_from(model.texmap_events.len()) + .map_err(|_| ParseError::Overflow("TEXMAP event conversion"))?; + let child_charge = 256u64 + .checked_add( + include_count + .checked_mul(128) + .ok_or(ParseError::Overflow("instance arena estimate"))?, + ) + .and_then(|charge| { + charge.checked_add(graph_entries.checked_mul(64).unwrap_or(u64::MAX)) + }) + .and_then(|charge| { + charge.checked_add(colour_entries.checked_mul(32).unwrap_or(u64::MAX)) + }) + .and_then(|charge| { + charge.checked_add(texture_events.checked_mul(48).unwrap_or(u64::MAX)) + }) + .ok_or(ParseError::Overflow("model arena estimate"))?; + arenas.child(&options, child_charge)?; + models.push(model); + } + let (scene, summaries) = traverse(&models, &options, &mut counters, &mut diagnostics)?; + let root = models.first(); + let semantic = SemanticRecord { + canonical_path: root.map(|m| m.path.to_string()), + root_identity: Some(format!("{:?}", options.resolved_root).to_ascii_lowercase()), + target_identity: root.map(|m| m.path.to_string()), + cache_identity: root.map(|m| m.key.content_hash.to_string()), + bfc_state: root + .map(|m| format!("{:?}", m.bfc.state).to_ascii_lowercase()) + .unwrap_or_else(|| "unknown".into()), + colour_state: root + .map(|m| m.colour.clone()) + .unwrap_or_else(|| "16".into()), + steps: root.map(|m| m.steps.clone()).unwrap_or_default(), + limits: LdrawLimits::NAMES + .iter() + .map(|name| (*name).into()) + .collect(), + texmap_events: root.map(|m| m.texmap_events.clone()).unwrap_or_default(), + }; + Ok(OwnedParseResult { + syntax, + semantic, + scene, + diagnostics, + counters, + files, + models: summaries, + arenas, + provenance_id: "local-parse".into(), + }) + } +} + +pub(crate) fn limit_error(kind: LimitKind, span: Option) -> ParseError { + ParseError::Diagnostic(Diagnostic { + code: kind.diagnostic_code(), + severity: Severity::Error, + message: format!("{kind} limit exceeded"), + span, + }) +} +fn syntax_record(line: &ScannedLine) -> SyntaxRecord { + SyntaxRecord { + line_type: line.line_type.unwrap_or(255), + raw_tail: String::from_utf8_lossy(&line.raw_tail).into_owned(), + span: line.span, + line_ending: line.ending.as_str().into(), + fields: line.tokens.iter().map(|t| t.text.clone()).collect(), + } +} +pub(crate) fn add_diag( + profile: ParserProfile, + diagnostics: &mut Vec, + counters: &mut LimitCounters, + limits: &LdrawLimits, + code: DiagnosticCode, + span: Span, + message: &str, + strict_reject: bool, +) -> Result<(), ParseError> { + let severity = if strict_reject && profile == ParserProfile::Strict { + Severity::Error + } else if profile == ParserProfile::Strict { + Severity::Error + } else { + Severity::Warning + }; + counters + .add(LimitKind::Diagnostics, 1, limits) + .map_err(|kind| limit_error(kind, Some(span)))?; + diagnostics.push(Diagnostic { + code, + severity, + message: message.into(), + span: Some(span), + }); + if strict_reject && profile == ParserProfile::Strict { + return Err(ParseError::Diagnostic(diagnostics.last().cloned().unwrap())); + } + Ok(()) +} + +fn process_line( + model: &mut ModelData, + line: &ScannedLine, + profile: ParserProfile, + diagnostics: &mut Vec, + counters: &mut LimitCounters, + limits: &LdrawLimits, + local_colours: &mut BTreeMap, + texture_ids: &mut BTreeSet, +) -> Result<(), ParseError> { + if model.texmap_state == TexmapState::AwaitingNext && !line.blank { + if line.line_type == Some(0) { + 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(1..=5)) { + if primitive_is_valid(line) { + let descriptor = model.texmap_descriptor.take(); + model.texmap_state = TexmapState::Active; + process_geometry(model, line, profile, diagnostics, counters, limits)?; + model.texmap_state = TexmapState::Inactive; + if let Some(descriptor) = descriptor { + let _ = texture_ids.insert(descriptor); + } + model.texmap_next_span = None; + return Ok(()); + } + } + } + if line.line_type == Some(0) { + 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, + profile, + diagnostics, + counters, + limits, + local_colours, + texture_ids, + ); + } + process_geometry(model, line, profile, diagnostics, counters, limits) +} + +fn process_meta( + model: &mut ModelData, + line: &ScannedLine, + profile: ParserProfile, + diagnostics: &mut Vec, + counters: &mut LimitCounters, + limits: &LdrawLimits, + local_colours: &mut BTreeMap, + texture_ids: &mut BTreeSet, +) -> Result<(), ParseError> { + 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, + ); + } + match tokens[1].kind { + TokenKind::Step => { + let step = model + .steps + .len() + .checked_add(1) + .ok_or(ParseError::Overflow("step number"))?; + model.steps.push(format!("step-{step:08}")); + 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.len() >= 4 && tokens[2].kind == TokenKind::Identifier { + let code = tokens + .iter() + .find(|t| t.kind == TokenKind::Code) + .and_then(|_| tokens.iter().position(|t| t.kind == TokenKind::Number)) + .and_then(|i| tokens.get(i)) + .and_then(|t| t.text.parse::().ok()); + if let Some(code) = code { + let slot = u16::try_from(local_colours.len()) + .map_err(|_| ParseError::Overflow("colour slot"))?; + if slot >= 512 { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::ColourSlotsLimit, + line.span, + "local colour table exhausted", + true, + ); + } + local_colours.insert(tokens[2].text.clone(), slot); + model.colour = code.to_string(); + } + } + 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(()) +} diff --git a/crates/polymodel-ldraw-core/src/scanner.rs b/crates/polymodel-ldraw-core/src/scanner.rs new file mode 100644 index 0000000..4b5aab2 --- /dev/null +++ b/crates/polymodel-ldraw-core/src/scanner.rs @@ -0,0 +1,319 @@ +use crate::types::{Diagnostic, DiagnosticCode, LdrawLimits, ParseError, Severity, Span}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum TokenKind { + Zero, + One, + Two, + Three, + Four, + Five, + Step, + Pause, + Write, + Clear, + Save, + Colour, + Code, + Value, + Edge, + Alpha, + Luminance, + Chrome, + Pearlescent, + Rubber, + MatteMetallic, + Metal, + Material, + File, + NoFile, + Bfc, + Certify, + NoCertify, + Clip, + NoClip, + InvertNext, + Orientation, + Bang, + Texmap, + Start, + Next, + Fallback, + End, + Stop, + Number, + Identifier, + QuotedIdentifier, + Garbage, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct Token { + pub kind: TokenKind, + pub text: String, + pub span: Span, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum LineEnding { + Lf, + CrLf, + Cr, + None, +} +impl LineEnding { + pub(crate) fn as_str(&self) -> &'static str { + match self { + Self::Lf => "\\n", + Self::CrLf => "\\r\\n", + Self::Cr => "\\r", + Self::None => "", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ScannedLine { + pub line_type: Option, + pub raw: Vec, + pub raw_tail: Vec, + pub tokens: Vec, + pub span: Span, + pub ending: LineEnding, + pub blank: bool, +} + +fn keyword(text: &str) -> Option { + Some(match text { + "0" => TokenKind::Zero, + "1" => TokenKind::One, + "2" => TokenKind::Two, + "3" => TokenKind::Three, + "4" => TokenKind::Four, + "5" => TokenKind::Five, + "STEP" => TokenKind::Step, + "PAUSE" => TokenKind::Pause, + "WRITE" => TokenKind::Write, + "CLEAR" => TokenKind::Clear, + "SAVE" => TokenKind::Save, + "!COLOUR" => TokenKind::Colour, + "CODE" => TokenKind::Code, + "VALUE" => TokenKind::Value, + "EDGE" => TokenKind::Edge, + "ALPHA" => TokenKind::Alpha, + "LUMINANCE" => TokenKind::Luminance, + "CHROME" => TokenKind::Chrome, + "PEARLESCENT" => TokenKind::Pearlescent, + "RUBBER" => TokenKind::Rubber, + "MATTE_METALLIC" => TokenKind::MatteMetallic, + "METAL" => TokenKind::Metal, + "MATERIAL" => TokenKind::Material, + "FILE" => TokenKind::File, + "NOFILE" => TokenKind::NoFile, + "BFC" => TokenKind::Bfc, + "CERTIFY" => TokenKind::Certify, + "NOCERTIFY" => TokenKind::NoCertify, + "CLIP" => TokenKind::Clip, + "NOCLIP" => TokenKind::NoClip, + "INVERTNEXT" => TokenKind::InvertNext, + "CW" | "CCW" => TokenKind::Orientation, + "!TEXMAP" => TokenKind::Texmap, + "START" => TokenKind::Start, + "NEXT" => TokenKind::Next, + "FALLBACK" => TokenKind::Fallback, + "END" => TokenKind::End, + "STOP" => TokenKind::Stop, + "0 !:" => TokenKind::Bang, + _ => return None, + }) +} + +pub fn scan_lines(bytes: &[u8], limits: &LdrawLimits) -> Result, ParseError> { + let mut out = Vec::new(); + let mut offset = 0usize; + let mut line_no = 1u32; + while offset < bytes.len() { + let start = offset; + let mut end = offset; + let ending; + while end < bytes.len() && bytes[end] != b'\n' && bytes[end] != b'\r' { + end += 1; + } + if end == bytes.len() { + ending = LineEnding::None; + offset = end; + } else if bytes[end] == b'\r' && end + 1 < bytes.len() && bytes[end + 1] == b'\n' { + ending = LineEnding::CrLf; + offset = end + 2; + } else { + ending = if bytes[end] == b'\r' { + LineEnding::Cr + } else { + LineEnding::Lf + }; + offset = end + 1; + } + let raw = &bytes[start..end]; + let line_len = u64::try_from(raw.len()).map_err(|_| ParseError::Overflow("line bytes"))?; + let start_u32 = + u32::try_from(start).map_err(|_| ParseError::Overflow("line span start"))?; + let end_u32 = u32::try_from(end).map_err(|_| ParseError::Overflow("line span end"))?; + if line_len > limits.line_bytes { + return Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::LineBytesLimit, + severity: Severity::Error, + message: "physical line exceeds the configured limit".into(), + span: Some(Span { + start: start_u32, + end: end_u32, + line: line_no, + column: 1, + }), + })); + } + let text = std::str::from_utf8(raw).map_err(|_| { + ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::InvalidUtf8, + severity: Severity::Error, + message: "line is not valid UTF-8".into(), + span: Some(Span { + start: start_u32, + end: end_u32, + line: line_no, + column: 1, + }), + }) + })?; + out.push(scan_one(text, raw, start_u32, line_no, ending)?); + line_no = line_no + .checked_add(1) + .ok_or(ParseError::Overflow("line number"))?; + } + if bytes.is_empty() { + out.push(scan_one("", &[], 0, 1, LineEnding::None)?); + } + Ok(out) +} + +fn scan_one( + text: &str, + raw: &[u8], + start: u32, + line: u32, + ending: LineEnding, +) -> Result { + let trimmed = text.trim(); + let blank = trimmed.is_empty(); + let mut tokens = Vec::new(); + let mut i = 0usize; + let bytes = text.as_bytes(); + while i < bytes.len() { + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= bytes.len() { + break; + } + let begin = i; + let value = if bytes[i] == b'"' { + i += 1; + let inner = i; + while i < bytes.len() && bytes[i] != b'"' { + i += 1; + } + let value = &text[inner..i]; + if i < bytes.len() { + i += 1; + } + let begin_u32 = + u32::try_from(begin).map_err(|_| ParseError::Overflow("token span start"))?; + let end_u32 = u32::try_from(i).map_err(|_| ParseError::Overflow("token span end"))?; + let column = begin_u32 + .checked_add(1) + .ok_or(ParseError::Overflow("token column"))?; + let span = Span { + start: start + .checked_add(begin_u32) + .ok_or(ParseError::Overflow("token span start"))?, + end: start + .checked_add(end_u32) + .ok_or(ParseError::Overflow("token span end"))?, + line, + column, + }; + tokens.push(Token { + kind: TokenKind::QuotedIdentifier, + text: value.into(), + span, + }); + continue; + } else { + while i < bytes.len() && !bytes[i].is_ascii_whitespace() { + i += 1; + } + &text[begin..i] + }; + let kind = keyword(value) + .or_else(|| { + value + .parse::() + .ok() + .filter(|n| n.is_finite()) + .map(|_| TokenKind::Number) + }) + .unwrap_or(TokenKind::Identifier); + let begin_u32 = + u32::try_from(begin).map_err(|_| ParseError::Overflow("token span start"))?; + let end_u32 = u32::try_from(i).map_err(|_| ParseError::Overflow("token span end"))?; + let column = begin_u32 + .checked_add(1) + .ok_or(ParseError::Overflow("token column"))?; + tokens.push(Token { + kind, + text: value.into(), + span: Span { + start: start + .checked_add(begin_u32) + .ok_or(ParseError::Overflow("token span start"))?, + end: start + .checked_add(end_u32) + .ok_or(ParseError::Overflow("token span end"))?, + line, + column, + }, + }); + } + let line_type = tokens.first().and_then(|t| match t.kind { + TokenKind::Zero => Some(0), + TokenKind::One => Some(1), + TokenKind::Two => Some(2), + TokenKind::Three => Some(3), + TokenKind::Four => Some(4), + TokenKind::Five => Some(5), + _ => None, + }); + let tail_start = tokens + .first() + .and_then(|t| usize::try_from(t.span.end.checked_sub(start)?).ok()) + .unwrap_or(0); + let raw_tail = raw.get(tail_start..).unwrap_or_default().to_vec(); + Ok(ScannedLine { + line_type, + raw: raw.to_vec(), + raw_tail, + tokens, + span: Span { + start, + end: start + .checked_add( + u32::try_from(raw.len()).map_err(|_| ParseError::Overflow("line span end"))?, + ) + .ok_or(ParseError::Overflow("line span end"))?, + line, + column: 1, + }, + ending, + blank, + }) +} diff --git a/crates/polymodel-ldraw-core/src/texmap.rs b/crates/polymodel-ldraw-core/src/texmap.rs new file mode 100644 index 0000000..607f47d --- /dev/null +++ b/crates/polymodel-ldraw-core/src/texmap.rs @@ -0,0 +1,348 @@ +use crate::types::Span; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TexmapState { + Inactive, + AwaitingNext, + Active, + Fallback, +} +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +pub struct TextureDescriptor { + pub mode: String, + pub parameters: Vec, + pub pngfile: String, + pub glossmap: Option, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TexmapKind { + Start, + Next, + Fallback, + End, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TexmapEvent { + pub kind: TexmapKind, + pub span: Span, + pub file_id: u32, + pub mode: Option, + pub parameters: Vec, + pub reference: Option, + pub state: TexmapState, +} + +use crate::model::ModelData; +use crate::parser::{add_diag, limit_error}; +use crate::scanner::{ScannedLine, TokenKind}; +use crate::types::{ + Diagnostic, DiagnosticCode, LdrawLimits, LimitCounters, LimitKind, ParseError, ParserProfile, +}; +use std::collections::BTreeSet; + +pub(crate) fn texmap( + model: &mut ModelData, + line: &ScannedLine, + profile: ParserProfile, + diagnostics: &mut Vec, + counters: &mut LimitCounters, + limits: &LdrawLimits, + texture_ids: &mut BTreeSet, +) -> Result<(), ParseError> { + if line.tokens.len() < 3 { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapInvalidArity, + line.span, + "TEXMAP requires START, NEXT, FALLBACK, or END", + true, + ); + } + let kind = line.tokens[2].kind; + match kind { + TokenKind::Start | TokenKind::Next => { + let mode = line.tokens.get(3).map(|token| token.text.as_str()); + let Some(mode) = mode else { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapInvalidArity, + line.span, + "TEXMAP mapping requires a method", + true, + ); + }; + let parameter_count = match mode { + "PLANAR" => 9, + "CYLINDRICAL" => 7, + "SPHERICAL" => 5, + _ => { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapInvalidMethod, + line.span, + "unsupported TEXMAP mapping method", + true, + ); + } + }; + if kind == TokenKind::Next && model.texmap_state != TexmapState::Inactive { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapNextNested, + line.span, + "NEXT cannot nest an active START", + true, + ); + } + let base = 5usize + .checked_add(parameter_count) + .ok_or(ParseError::Overflow("TEXMAP arity"))?; + let has_gloss = line.tokens.get(base).map(|token| token.kind) + == Some(TokenKind::Identifier) + && line.tokens.get(base).map(|token| token.text.as_str()) == Some("GLOSSMAP"); + let expected = base + .checked_add(if has_gloss { 2 } else { 0 }) + .ok_or(ParseError::Overflow("TEXMAP arity"))?; + if line.tokens.len() != expected { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapInvalidArity, + line.span, + "TEXMAP mapping has an invalid parameter or GLOSSMAP shape", + true, + ); + } + let parameters = line.tokens[4..4 + parameter_count] + .iter() + .map(|token| { + if token.kind != TokenKind::Number { + return None; + } + let value = token.text.parse::().ok()?; + value.is_finite().then(|| token.text.clone()) + }) + .collect::>>(); + let Some(parameters) = parameters else { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapInvalidValue, + line.span, + "TEXMAP parameter is not finite or numeric", + true, + ); + }; + let png_index = 4 + parameter_count; + let reference = if has_gloss { + line.tokens.get(png_index + 2) + } else { + line.tokens.get(png_index) + }; + let Some(reference) = reference + .filter(|token| !matches!(token.kind, TokenKind::Number | TokenKind::Garbage)) + else { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapInvalidArity, + line.span, + "TEXMAP requires a texture filename", + true, + ); + }; + let glossmap = if has_gloss { + line.tokens.get(png_index + 1).and_then(|token| { + (!matches!(token.kind, TokenKind::Number | TokenKind::Garbage)) + .then(|| token.text.clone()) + }) + } else { + None + }; + if has_gloss && glossmap.is_none() { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapInvalidArity, + line.span, + "GLOSSMAP requires a texture filename", + true, + ); + } + let descriptor = TextureDescriptor { + mode: mode.into(), + parameters: parameters.clone(), + pngfile: reference.text.clone(), + glossmap, + }; + if texture_ids.insert(descriptor.clone()) { + counters + .add(LimitKind::Textures, 1, limits) + .map_err(|kind| limit_error(kind, Some(line.span)))?; + } + if kind == TokenKind::Next && model.texmap_state != TexmapState::Inactive { + return Ok(()); + } + if kind == TokenKind::Start { + model.texmap_stack.push(( + model.texmap_state, + model.texmap_descriptor.clone(), + model.texmap_fallback_seen, + )); + model.texmap_state = TexmapState::Active; + model.texmap_descriptor = Some(descriptor.clone()); + model.texmap_fallback_seen = false; + } else { + model.texmap_state = TexmapState::AwaitingNext; + model.texmap_descriptor = Some(descriptor.clone()); + model.texmap_next_span = Some(line.span); + } + model.texmap_events.push(TexmapEvent { + kind: if kind == TokenKind::Start { + TexmapKind::Start + } else { + TexmapKind::Next + }, + span: line.span, + file_id: model.file.id, + mode: Some(mode.into()), + parameters, + reference: Some(reference.text.clone()), + state: if kind == TokenKind::Start { + TexmapState::Active + } else { + TexmapState::AwaitingNext + }, + }); + } + TokenKind::Fallback => { + if !matches!( + model.texmap_state, + TexmapState::Active | TexmapState::Fallback + ) { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapFallbackOutOfScope, + line.span, + "FALLBACK has no active START", + true, + ); + } + if model.texmap_state == TexmapState::Fallback || model.texmap_fallback_seen { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapDuplicateFallback, + line.span, + "FALLBACK already occurred", + true, + ); + } + model.texmap_state = TexmapState::Fallback; + model.texmap_fallback_seen = true; + model.texmap_events.push(TexmapEvent { + kind: TexmapKind::Fallback, + span: line.span, + file_id: model.file.id, + mode: None, + parameters: Vec::new(), + reference: None, + state: model.texmap_state, + }); + } + TokenKind::End => { + if line.tokens.len() != 3 { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapInvalidEnd, + line.span, + "END takes no argument", + true, + ); + } + if model.texmap_state == TexmapState::Inactive { + return Ok(()); + } + let previous_state = model + .texmap_stack + .pop() + .map(|(state, descriptor, fallback)| { + model.texmap_descriptor = descriptor; + model.texmap_fallback_seen = fallback; + state + }) + .unwrap_or(TexmapState::Inactive); + model.texmap_state = previous_state; + model.texmap_descriptor = if model.texmap_state == TexmapState::Inactive { + None + } else { + model.texmap_descriptor.clone() + }; + model.texmap_events.push(TexmapEvent { + kind: TexmapKind::End, + span: line.span, + file_id: model.file.id, + mode: None, + parameters: Vec::new(), + reference: None, + state: model.texmap_state, + }); + } + TokenKind::Stop => { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapUnknownMetaStop, + line.span, + "literal STOP is not TEXMAP END", + true, + ); + } + _ => { + return add_diag( + profile, + diagnostics, + counters, + limits, + DiagnosticCode::TexmapInvalidArity, + line.span, + "unknown TEXMAP command", + true, + ); + } + } + Ok(()) +} diff --git a/crates/polymodel-ldraw-core/src/traversal.rs b/crates/polymodel-ldraw-core/src/traversal.rs new file mode 100644 index 0000000..d9e5d57 --- /dev/null +++ b/crates/polymodel-ldraw-core/src/traversal.rs @@ -0,0 +1,233 @@ +use crate::cache::CacheKey; +use crate::geom::{Bounds, Transform}; +use crate::model::{ModelData, ModelSummary, SceneRecord}; +use crate::parser::{add_diag, limit_error}; +use crate::types::{ + Diagnostic, DiagnosticCode, LimitCounters, LimitKind, ParseError, ParseOptions, +}; +use std::collections::{HashMap, HashSet, VecDeque}; + +pub(crate) fn traverse( + models: &[ModelData], + options: &ParseOptions<'_>, + counters: &mut LimitCounters, + diagnostics: &mut Vec, +) -> Result<(SceneRecord, Vec), ParseError> { + if models.is_empty() { + return Ok(( + SceneRecord { + model_id: "model-00000001".into(), + instance_ids: Vec::new(), + triangles: 0, + lines: 0, + bounds: Bounds::default().values(), + reflection: false, + }, + Vec::new(), + )); + } + + #[derive(Clone)] + enum Visit { + Enter { + index: usize, + transform: Transform, + depth: u64, + reflected: bool, + }, + Exit { + key: CacheKey, + }, + } + + let mut ids = HashMap::::new(); + let mut completed = HashSet::::new(); + let mut visiting = HashSet::::new(); + let mut stack = VecDeque::::new(); + let mut summaries = Vec::new(); + let mut instance_ids = Vec::new(); + let mut scene_bounds = Bounds::default(); + let mut scene_triangles = 0u64; + let mut scene_lines = 0u64; + let mut reflection = false; + + let root_key = models[0].key.clone(); + ids.insert(root_key.clone(), "model-00000001".into()); + stack.push_back(Visit::Enter { + index: 0, + transform: Transform::default(), + depth: 0, + reflected: false, + }); + + while let Some(visit) = stack.pop_back() { + if options.cancellation.cancelled { + return Err(ParseError::Cancelled); + } + match visit { + Visit::Exit { key } => { + visiting.remove(&key); + completed.insert(key); + } + Visit::Enter { + index, + transform, + depth, + reflected, + } => { + let model = &models[index]; + let key = model.key.clone(); + if !visiting.insert(key.clone()) { + continue; + } + let first_parse = !completed.contains(&key); + let model_id = ids + .get(&key) + .cloned() + .unwrap_or_else(|| format!("model-{:08}", ids.len() + 1)); + if !summaries + .iter() + .any(|summary: &ModelSummary| summary.id == model_id) + { + summaries.push(ModelSummary { + id: model_id, + path: model.path.to_string(), + includes: model.includes.iter().map(|i| i.name.clone()).collect(), + triangles: model.triangles, + lines: model.lines, + bfc: model.bfc.state, + }); + } + + let corners = [ + [ + model.bounds.min[0], + model.bounds.min[1], + model.bounds.min[2], + ], + [ + model.bounds.min[0], + model.bounds.min[1], + model.bounds.max[2], + ], + [ + model.bounds.min[0], + model.bounds.max[1], + model.bounds.min[2], + ], + [ + model.bounds.min[0], + model.bounds.max[1], + model.bounds.max[2], + ], + [ + model.bounds.max[0], + model.bounds.min[1], + model.bounds.min[2], + ], + [ + model.bounds.max[0], + model.bounds.min[1], + model.bounds.max[2], + ], + [ + model.bounds.max[0], + model.bounds.max[1], + model.bounds.min[2], + ], + [ + model.bounds.max[0], + model.bounds.max[1], + model.bounds.max[2], + ], + ]; + for corner in corners { + scene_bounds.add(transform.apply(corner)); + } + scene_triangles = scene_triangles + .checked_add(model.triangles) + .ok_or(ParseError::Overflow("scene triangles"))?; + scene_lines = scene_lines + .checked_add(model.lines) + .ok_or(ParseError::Overflow("scene lines"))?; + reflection ^= reflected; + + stack.push_back(Visit::Exit { key: key.clone() }); + if first_parse { + for include in model.includes.iter().rev() { + if options.cancellation.cancelled { + return Err(ParseError::Cancelled); + } + counters + .add(LimitKind::Instances, 1, &options.limits) + .map_err(|name| limit_error(name, Some(include.span)))?; + instance_ids.push(format!("instance-{:08}", counters.instances)); + let target = models.iter().position(|candidate| { + candidate + .path + .as_str() + .eq_ignore_ascii_case(&include.name.replace('\\', "/")) + }); + let child_depth = depth + .checked_add(1) + .ok_or(ParseError::Overflow("include depth"))?; + if child_depth > options.limits.include_depth { + add_diag( + options.profile, + diagnostics, + counters, + &options.limits, + DiagnosticCode::IncludeDepthLimit, + include.span, + "include depth limit exceeded", + false, + )?; + continue; + } + let Some(target) = target else { + continue; + }; + let child = &models[target]; + if visiting.contains(&child.key) { + add_diag( + options.profile, + diagnostics, + counters, + &options.limits, + DiagnosticCode::GraphCycle, + include.span, + "include cycle cut at active graph key", + false, + )?; + continue; + } + if !ids.contains_key(&child.key) { + let next_id = ids + .len() + .checked_add(1) + .ok_or(ParseError::Overflow("model id"))?; + ids.insert(child.key.clone(), format!("model-{next_id:08}")); + } + stack.push_back(Visit::Enter { + index: target, + transform: transform.compose(&include.transform), + depth: child_depth, + reflected: reflected + ^ include.inverted + ^ include.transform.reflection(), + }); + } + } + } + } + } + let scene = SceneRecord { + model_id: "model-00000001".into(), + instance_ids, + triangles: scene_triangles, + lines: scene_lines, + bounds: scene_bounds.values(), + reflection, + }; + Ok((scene, summaries)) +} diff --git a/crates/polymodel-ldraw-core/src/types.rs b/crates/polymodel-ldraw-core/src/types.rs new file mode 100644 index 0000000..e5a4931 --- /dev/null +++ b/crates/polymodel-ldraw-core/src/types.rs @@ -0,0 +1,474 @@ +use miette::SourceSpan; +use polymodel_renderer_ledger::{ + AdmissionError, RejectionReason, Reservation, ReservationLedger, ReservationOwner, + ResourceClass, +}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use thiserror::Error; + +pub const SCHEMA_VERSION: &str = "ldraw-canonical-v1"; +pub const DEFAULT_RESOURCE_BYTES: u64 = 100 * 1024 * 1024; +pub const DEFAULT_LINE_BYTES: u64 = 1024 * 1024; +pub const DEFAULT_FILES: u64 = 16_384; +pub const DEFAULT_INCLUDE_DEPTH: u64 = 256; +pub const DEFAULT_COMMANDS: u64 = 5_000_000; +pub const DEFAULT_INSTANCES: u64 = 5_000_000; +pub const DEFAULT_TRIANGLES: u64 = 20_000_000; +pub const DEFAULT_LINES: u64 = 20_000_000; +pub const DEFAULT_TEXTURES: u64 = 1_024; +pub const DEFAULT_FETCHES: u64 = 8; +pub const DEFAULT_DIAGNOSTICS: u64 = 10_000; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParserProfile { + Strict, + Compatibility, + Lossless, +} +impl Default for ParserProfile { + fn default() -> Self { + Self::Strict + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum Severity { + Info, + Warning, + Error, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct Span { + pub start: u32, + pub end: u32, + pub line: u32, + pub column: u32, +} + +impl From for SourceSpan { + fn from(span: Span) -> Self { + let start = usize::try_from(span.start).unwrap_or(usize::MAX); + let length = usize::try_from(span.end.saturating_sub(span.start)).unwrap_or(usize::MAX); + SourceSpan::from((start, length)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DiagnosticCode { + PathOutOfRoot, + PathForbiddenSyntax, + PathEmpty, + LineBytesLimit, + InvalidUtf8, + FilesLimit, + SemanticArenaReservationLimit, + InvalidType1, + InvalidType2, + InvalidType3, + InvalidType4, + InvalidType5, + InvalidLineType, + MetaEmpty, + TexmapNextType0, + ColourSlotsLimit, + TexmapUnknownMetaStop, + BfcInvalidDirective, + BfcRecertifyUncertified, + BfcInvalidCertifyArgument, + BfcInvertNextOutsideCertified, + BfcInvalidContext, + TexmapInvalidArity, + TexmapInvalidMethod, + TexmapNextNested, + TexmapInvalidValue, + TexmapFallbackOutOfScope, + TexmapDuplicateFallback, + TexmapInvalidEnd, + IncludeDepthLimit, + GraphCycle, + ResourceBytesLimit, + CommandsLimit, + InstancesLimit, + TrianglesLimit, + LinesLimit, + TexturesLimit, + FetchesLimit, + DiagnosticsLimit, +} + +impl fmt::Display for DiagnosticCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = match self { + Self::PathOutOfRoot => "PATH_OUT_OF_ROOT", + Self::PathForbiddenSyntax => "PATH_FORBIDDEN_SYNTAX", + Self::PathEmpty => "PATH_EMPTY", + Self::LineBytesLimit => "LINE_BYTES_LIMIT", + Self::InvalidUtf8 => "INVALID_UTF8", + Self::FilesLimit => "FILES_LIMIT", + Self::SemanticArenaReservationLimit => "SEMANTIC_ARENA_RESERVATION_LIMIT", + Self::InvalidType1 => "INVALID_TYPE1", + Self::InvalidType2 => "INVALID_TYPE2", + Self::InvalidType3 => "INVALID_TYPE3", + Self::InvalidType4 => "INVALID_TYPE4", + Self::InvalidType5 => "INVALID_TYPE5", + Self::InvalidLineType => "INVALID_LINE_TYPE", + Self::MetaEmpty => "META_EMPTY", + Self::TexmapNextType0 => "TEXMAP_NEXT_TYPE0", + Self::ColourSlotsLimit => "COLOUR_SLOTS_LIMIT", + Self::TexmapUnknownMetaStop => "TEXMAP_UNKNOWN_META_STOP", + Self::BfcInvalidDirective => "BFC_INVALID_DIRECTIVE", + Self::BfcRecertifyUncertified => "BFC_RECERTIFY_UNCERTIFIED", + Self::BfcInvalidCertifyArgument => "BFC_INVALID_CERTIFY_ARGUMENT", + Self::BfcInvertNextOutsideCertified => "BFC_INVERTNEXT_OUTSIDE_CERTIFIED", + Self::BfcInvalidContext => "BFC_INVALID_CONTEXT", + Self::TexmapInvalidArity => "TEXMAP_INVALID_ARITY", + Self::TexmapInvalidMethod => "TEXMAP_INVALID_METHOD", + Self::TexmapNextNested => "TEXMAP_NEXT_NESTED", + Self::TexmapInvalidValue => "TEXMAP_INVALID_VALUE", + Self::TexmapFallbackOutOfScope => "TEXMAP_FALLBACK_OUT_OF_SCOPE", + Self::TexmapDuplicateFallback => "TEXMAP_DUPLICATE_FALLBACK", + Self::TexmapInvalidEnd => "TEXMAP_INVALID_END", + Self::IncludeDepthLimit => "INCLUDE_DEPTH_LIMIT", + Self::GraphCycle => "GRAPH_CYCLE", + Self::ResourceBytesLimit => "RESOURCE_BYTES_LIMIT", + Self::CommandsLimit => "COMMANDS_LIMIT", + Self::InstancesLimit => "INSTANCES_LIMIT", + Self::TrianglesLimit => "TRIANGLES_LIMIT", + Self::LinesLimit => "LINES_LIMIT", + Self::TexturesLimit => "TEXTURES_LIMIT", + Self::FetchesLimit => "FETCHES_LIMIT", + Self::DiagnosticsLimit => "DIAGNOSTICS_LIMIT", + }; + f.write_str(value) + } +} + +#[derive(Clone, Debug, Error, miette::Diagnostic)] +#[error("{code}: {message}")] +pub struct LdrawDiagnostic { + pub code: DiagnosticCode, + pub message: String, + #[label("source location")] + pub span: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct Diagnostic { + pub code: DiagnosticCode, + pub severity: Severity, + pub message: String, + pub span: Option, +} +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} +impl From for LdrawDiagnostic { + fn from(value: Diagnostic) -> Self { + Self { + code: value.code, + message: value.message, + span: value.span.map(Into::into), + } + } +} + +#[derive(Clone, Debug, Error)] +pub enum LdrawError { + #[error("parse rejected: {0}")] + Diagnostic(Diagnostic), + #[error(transparent)] + LdrawDiagnostic(#[from] LdrawDiagnostic), + #[error("semantic arena reservation failed: {0}")] + Reservation(String), + #[error("input arithmetic overflow while accounting {0}")] + Overflow(&'static str), + #[error("parse cancelled")] + Cancelled, +} +pub type ParseError = LdrawError; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub enum LimitKind { + ResourceBytes, + LineBytes, + Files, + IncludeDepth, + Commands, + Instances, + Triangles, + Lines, + Textures, + Fetches, + Diagnostics, +} +impl LimitKind { + pub const ALL: [Self; 11] = [ + Self::ResourceBytes, + Self::LineBytes, + Self::Files, + Self::IncludeDepth, + Self::Commands, + Self::Instances, + Self::Triangles, + Self::Lines, + Self::Textures, + Self::Fetches, + Self::Diagnostics, + ]; + pub const NAMES: [&'static str; 11] = [ + "resource_bytes", + "line_bytes", + "files", + "include_depth", + "commands", + "instances", + "triangles", + "lines", + "textures", + "fetches", + "diagnostics", + ]; + pub const fn diagnostic_code(self) -> DiagnosticCode { + match self { + Self::ResourceBytes => DiagnosticCode::ResourceBytesLimit, + Self::LineBytes => DiagnosticCode::LineBytesLimit, + Self::Files => DiagnosticCode::FilesLimit, + Self::IncludeDepth => DiagnosticCode::IncludeDepthLimit, + Self::Commands => DiagnosticCode::CommandsLimit, + Self::Instances => DiagnosticCode::InstancesLimit, + Self::Triangles => DiagnosticCode::TrianglesLimit, + Self::Lines => DiagnosticCode::LinesLimit, + Self::Textures => DiagnosticCode::TexturesLimit, + Self::Fetches => DiagnosticCode::FetchesLimit, + Self::Diagnostics => DiagnosticCode::DiagnosticsLimit, + } + } +} +impl fmt::Display for LimitKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(Self::NAMES[Self::ALL.iter().position(|kind| kind == self).unwrap_or(0)]) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct LdrawLimits { + pub resource_bytes: u64, + pub line_bytes: u64, + pub files: u64, + pub include_depth: u64, + pub commands: u64, + pub instances: u64, + pub triangles: u64, + pub lines: u64, + pub textures: u64, + pub fetches: u64, + pub diagnostics: u64, +} +impl Default for LdrawLimits { + fn default() -> Self { + Self { + resource_bytes: DEFAULT_RESOURCE_BYTES, + line_bytes: DEFAULT_LINE_BYTES, + files: DEFAULT_FILES, + include_depth: DEFAULT_INCLUDE_DEPTH, + commands: DEFAULT_COMMANDS, + instances: DEFAULT_INSTANCES, + triangles: DEFAULT_TRIANGLES, + lines: DEFAULT_LINES, + textures: DEFAULT_TEXTURES, + fetches: DEFAULT_FETCHES, + diagnostics: DEFAULT_DIAGNOSTICS, + } + } +} +impl LdrawLimits { + pub const NAMES: [&'static str; 11] = LimitKind::NAMES; + pub const fn ceiling(&self, kind: LimitKind) -> u64 { + match kind { + LimitKind::ResourceBytes => self.resource_bytes, + LimitKind::LineBytes => self.line_bytes, + LimitKind::Files => self.files, + LimitKind::IncludeDepth => self.include_depth, + LimitKind::Commands => self.commands, + LimitKind::Instances => self.instances, + LimitKind::Triangles => self.triangles, + LimitKind::Lines => self.lines, + LimitKind::Textures => self.textures, + LimitKind::Fetches => self.fetches, + LimitKind::Diagnostics => self.diagnostics, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Checkpoint { + LineScan, + FilePartition, + IncludeTraversal, + TexmapPayload, + Projection, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CancellationPolicy { + pub cancelled: bool, +} +impl Default for CancellationPolicy { + fn default() -> Self { + Self { cancelled: false } + } +} + +#[derive(Clone)] +pub struct ParseOptions<'a> { + pub profile: ParserProfile, + pub limits: LdrawLimits, + pub owner: ReservationOwner, + pub ledger: &'a ReservationLedger, + pub semantic_budget: Option, + pub root_name: String, + pub resolved_root: RootId, + pub fetch_count: u64, + pub cancellation: CancellationPolicy, +} +impl<'a> ParseOptions<'a> { + pub fn new(owner: ReservationOwner, ledger: &'a ReservationLedger) -> Self { + Self { + profile: ParserProfile::Strict, + limits: LdrawLimits::default(), + owner, + ledger, + semantic_budget: None, + root_name: "model.ldr".into(), + resolved_root: RootId::UploadedManifest, + fetch_count: 0, + cancellation: CancellationPolicy::default(), + } + } + pub fn default_budget(input_len: usize) -> Result { + u64::try_from(input_len) + .map_err(|_| ParseError::Overflow("semantic budget input length")) + .and_then(|n| { + n.checked_mul(8) + .and_then(|n| n.checked_add(8192)) + .ok_or(ParseError::Overflow("semantic budget")) + }) + } + pub(crate) fn budget(&self, input_len: usize) -> Result { + self.semantic_budget + .map(Ok) + .unwrap_or_else(|| Self::default_budget(input_len)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RootId { + CurrentMpd, + UploadedManifest, + UploadedLdraw, + OfficialLibrary, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct LimitCounters { + pub resource_bytes: u64, + pub line_bytes: u64, + pub files: u64, + pub include_depth: u64, + pub commands: u64, + pub instances: u64, + pub triangles: u64, + pub lines: u64, + pub textures: u64, + pub fetches: u64, + pub diagnostics: u64, +} +impl LimitCounters { + pub(crate) fn add( + &mut self, + kind: LimitKind, + delta: u64, + limits: &LdrawLimits, + ) -> Result<(), LimitKind> { + let slot = match kind { + LimitKind::ResourceBytes => &mut self.resource_bytes, + LimitKind::LineBytes => &mut self.line_bytes, + LimitKind::Files => &mut self.files, + LimitKind::IncludeDepth => &mut self.include_depth, + LimitKind::Commands => &mut self.commands, + LimitKind::Instances => &mut self.instances, + LimitKind::Triangles => &mut self.triangles, + LimitKind::Lines => &mut self.lines, + LimitKind::Textures => &mut self.textures, + LimitKind::Fetches => &mut self.fetches, + LimitKind::Diagnostics => &mut self.diagnostics, + }; + let next = slot.checked_add(delta).ok_or(kind)?; + if next > limits.ceiling(kind) { + return Err(kind); + } + *slot = next; + Ok(()) + } +} + +pub(crate) fn reservation_error(error: AdmissionError) -> ParseError { + let message = match error.reason { + RejectionReason::ParentCapacity | RejectionReason::Overflow => { + "semantic arena child reservation exceeded the admitted budget" + } + _ => "semantic arena reservation was rejected by the ledger", + }; + ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::SemanticArenaReservationLimit, + severity: Severity::Error, + message: message.into(), + span: None, + }) +} + +pub struct SemanticArenas { + pub admitted_budget: u64, + pub(crate) children: Vec, + pub(crate) root: Reservation, +} +impl SemanticArenas { + pub(crate) fn new(options: &ParseOptions<'_>, budget: u64) -> Result { + let root = options + .ledger + .reserve(options.owner, ResourceClass::SemanticArenas, budget) + .map_err(reservation_error)?; + Ok(Self { + admitted_budget: budget, + children: Vec::new(), + root, + }) + } + pub(crate) fn child( + &mut self, + options: &ParseOptions<'_>, + bytes: u64, + ) -> Result<(), ParseError> { + let child = options + .ledger + .reserve_child( + &self.root, + options.owner, + ResourceClass::SemanticArenas, + bytes, + ) + .map_err(reservation_error)?; + self.children.push(child); + Ok(()) + } + pub fn reservation_id(&self) -> u64 { + self.root.id() + } +} diff --git a/crates/polymodel-ldraw-core/src/util.rs b/crates/polymodel-ldraw-core/src/util.rs new file mode 100644 index 0000000..0c5b05f --- /dev/null +++ b/crates/polymodel-ldraw-core/src/util.rs @@ -0,0 +1,20 @@ +pub(crate) fn fmt_num(value: f64) -> String { + if !value.is_finite() { + return "0".into(); + } + if value == 0.0 { + return "0".into(); + } + let mut s = format!("{value:.6}"); + while s.contains('.') && s.ends_with('0') { + s.pop(); + } + if s.ends_with('.') { + s.pop(); + } + s +} + +pub(crate) fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +}