From a00b5c93c49160fdf419a7a6e85a194977eb54fa Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 2 Aug 2026 05:09:24 -0400 Subject: [PATCH] PM-78: Thomas-compatible Rust LDraw semantic core Implement scanner, MPD partitioner, typed semantic parser, model arena/cache, iterative graph traversal with cycle detection, BFC state machine, TEXMAP 4-state machine, checked limits, reservation ownership, and harness adapter. PM-76: wire LDraw verification and resource routes Epic: PM-86 Task: PM-76 --- ...c2ece40e973d6d9607e3e8e9747d117b0f806.json | 12 + ...397c9fb45c4fd9b8420004b36cc5aa2648c01.json | 50 + ...af8a5a53e4471253857005ec009686a7bc0a9.json | 12 + Cargo.lock | 10 + crates/polymodel-ldraw-core/Cargo.toml | 14 + crates/polymodel-ldraw-core/src/lib.rs | 2268 +++++++++++++++++ justfile | 7 +- tools/ldraw-compat-harness/Cargo.lock | 16 + tools/ldraw-compat-harness/Cargo.toml | 2 + tools/ldraw-compat-harness/src/main.rs | 73 +- 10 files changed, 2435 insertions(+), 29 deletions(-) create mode 100644 .sqlx/query-0dd7d6c305d747bd45f7c04c5f6c2ece40e973d6d9607e3e8e9747d117b0f806.json create mode 100644 .sqlx/query-993ddd19315c0c0d93925def404397c9fb45c4fd9b8420004b36cc5aa2648c01.json create mode 100644 .sqlx/query-dc55a59c3ee37cb600702aee282af8a5a53e4471253857005ec009686a7bc0a9.json create mode 100644 crates/polymodel-ldraw-core/Cargo.toml create mode 100644 crates/polymodel-ldraw-core/src/lib.rs diff --git a/.sqlx/query-0dd7d6c305d747bd45f7c04c5f6c2ece40e973d6d9607e3e8e9747d117b0f806.json b/.sqlx/query-0dd7d6c305d747bd45f7c04c5f6c2ece40e973d6d9607e3e8e9747d117b0f806.json new file mode 100644 index 0000000..9774e76 --- /dev/null +++ b/.sqlx/query-0dd7d6c305d747bd45f7c04c5f6c2ece40e973d6d9607e3e8e9747d117b0f806.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE ldraw_verification SET state = 'rejected', diagnostic = 'integrity-mismatch-v1', attempt_token = NULL, lease_expires_at = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND attempt_token = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "0dd7d6c305d747bd45f7c04c5f6c2ece40e973d6d9607e3e8e9747d117b0f806" +} diff --git a/.sqlx/query-993ddd19315c0c0d93925def404397c9fb45c4fd9b8420004b36cc5aa2648c01.json b/.sqlx/query-993ddd19315c0c0d93925def404397c9fb45c4fd9b8420004b36cc5aa2648c01.json new file mode 100644 index 0000000..191379a --- /dev/null +++ b/.sqlx/query-993ddd19315c0c0d93925def404397c9fb45c4fd9b8420004b36cc5aa2648c01.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT state, attempt_token, lease_expires_at FROM ldraw_verification WHERE resource_uri = ?", + "describe": { + "columns": [ + { + "name": "state", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "state" + } + } + }, + { + "name": "attempt_token", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "attempt_token" + } + } + }, + { + "name": "lease_expires_at", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "lease_expires_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true + ] + }, + "hash": "993ddd19315c0c0d93925def404397c9fb45c4fd9b8420004b36cc5aa2648c01" +} diff --git a/.sqlx/query-dc55a59c3ee37cb600702aee282af8a5a53e4471253857005ec009686a7bc0a9.json b/.sqlx/query-dc55a59c3ee37cb600702aee282af8a5a53e4471253857005ec009686a7bc0a9.json new file mode 100644 index 0000000..75d6978 --- /dev/null +++ b/.sqlx/query-dc55a59c3ee37cb600702aee282af8a5a53e4471253857005ec009686a7bc0a9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE ldraw_verification SET state = 'verifying', attempt_token = ?, lease_expires_at = ?, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND (state = 'unverified' OR (state = 'verifying' AND (lease_expires_at IS NULL OR lease_expires_at <= ?)))", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "dc55a59c3ee37cb600702aee282af8a5a53e4471253857005ec009686a7bc0a9" +} diff --git a/Cargo.lock b/Cargo.lock index 874d156..5354854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6393,6 +6393,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "polymodel-ldraw-core" +version = "0.1.0" +dependencies = [ + "polymodel-renderer-ledger", + "serde", + "sha2 0.10.9", + "thiserror 2.0.18", +] + [[package]] name = "polymodel-ldraw-testkit" version = "0.1.0" diff --git a/crates/polymodel-ldraw-core/Cargo.toml b/crates/polymodel-ldraw-core/Cargo.toml new file mode 100644 index 0000000..f2c6374 --- /dev/null +++ b/crates/polymodel-ldraw-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "polymodel-ldraw-core" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Pure Rust/WASM LDraw syntax and semantic core." + +[dependencies] +polymodel-renderer-ledger = { path = "../polymodel-renderer-ledger" } +serde = { workspace = true } +sha2 = "0.10" +thiserror = { workspace = true } + +[dev-dependencies] diff --git a/crates/polymodel-ldraw-core/src/lib.rs b/crates/polymodel-ldraw-core/src/lib.rs new file mode 100644 index 0000000..e76eac2 --- /dev/null +++ b/crates/polymodel-ldraw-core/src/lib.rs @@ -0,0 +1,2268 @@ +#![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() +} + +#[cfg(test)] +mod tests { + use super::*; + fn options<'a>(ledger: &'a ReservationLedger) -> ParseOptions<'a> { + ParseOptions::new(ReservationOwner::preview(1, 1, 1), ledger) + } + #[test] + fn scanner_preserves_case_quotes_and_endings() { + let lines = scan_lines( + b"0 STEP\r\n0 step\n0 \"quoted identifier\"", + &LdrawLimits::default(), + ) + .unwrap(); + assert_eq!(lines[0].tokens[1].kind, TokenKind::Step); + assert_eq!(lines[1].tokens[1].kind, TokenKind::Identifier); + assert_eq!(lines[2].tokens[1].kind, TokenKind::QuotedIdentifier); + assert_eq!(lines[0].ending, LineEnding::CrLf); + } + #[test] + fn mpd_discards_outside_file_content() { + let lines = scan_lines( + b"garbage\n0 FILE root.ldr\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\nignored", + &LdrawLimits::default(), + ) + .unwrap(); + let files = split_mpd(&lines, &LdrawLimits::default()).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].lines.len(), 1); + } + #[test] + fn reservation_lives_until_result_drop() { + let ledger = ReservationLedger::new(); + let owner = ReservationOwner::preview(1, 2, 3); + let result = LdrawParser + .parse_bytes( + b"3 16 0 0 0 1 0 0 0 1 0\n", + ParseOptions::new(owner, &ledger), + ) + .unwrap(); + assert!(ledger.snapshot().reservations > 0); + drop(result); + assert_eq!(ledger.snapshot().reservations, 0); + } + #[test] + fn bfc_invertnext_is_one_shot() { + let ledger = ReservationLedger::new(); + let bytes = b"0 BFC CERTIFY CCW\n0 BFC INVERTNEXT\n3 16 0 0 0 1 0 0 0 1 0\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n"; + let result = LdrawParser.parse_bytes(bytes, options(&ledger)); + assert!(result.is_ok()); + } + #[test] + fn graph_ids_are_stable() { + let ledger = ReservationLedger::new(); + 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())); + } + #[test] + fn limits_reject_before_publication() { + let ledger = ReservationLedger::new(); + let mut o = options(&ledger); + o.limits.triangles = 0; + assert!( + LdrawParser + .parse_bytes(b"3 16 0 0 0 1 0 0 0 1 0\n", o) + .is_err() + ); + } +} diff --git a/justfile b/justfile index 2ef3f89..128d307 100644 --- a/justfile +++ b/justfile @@ -66,10 +66,15 @@ ldraw-corpus: cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- inventory cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- corpus -# Compile reusable corpus/gate code for the E-facing WASM target. +# Compile the semantic core and reusable corpus/gate code for the E-facing WASM target. ldraw-wasm-check: + cargo check --tests -p polymodel-ldraw-core --target wasm32-unknown-unknown cargo check --tests -p polymodel-ldraw-testkit --target wasm32-unknown-unknown +# Ensure expected/gate/harness/fuzz paths use authored data and the real core adapter. +ldraw-oracle-integrity: + @if grep -RInE 'canonical[(]|parse_syntax[(]' crates/polymodel-ldraw-testkit tools/ldraw-compat-harness --include='*.rs' --include='*.json'; then echo 'FAIL: heuristic oracle helper call found'; exit 1; else echo 'PASS: no canonical/parse_syntax helper calls'; fi + # Run bounded, deterministic native adversarial/property smoke. ldraw-fuzz-smoke: cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- fuzz diff --git a/tools/ldraw-compat-harness/Cargo.lock b/tools/ldraw-compat-harness/Cargo.lock index 56b4b87..4b54259 100644 --- a/tools/ldraw-compat-harness/Cargo.lock +++ b/tools/ldraw-compat-harness/Cargo.lock @@ -66,7 +66,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" name = "ldraw-compat-harness" version = "0.1.0" dependencies = [ + "polymodel-ldraw-core", "polymodel-ldraw-testkit", + "polymodel-renderer-ledger", "serde", "serde_json", "sha2", @@ -85,6 +87,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "polymodel-ldraw-core" +version = "0.1.0" +dependencies = [ + "polymodel-renderer-ledger", + "serde", + "sha2", + "thiserror", +] + [[package]] name = "polymodel-ldraw-testkit" version = "0.1.0" @@ -95,6 +107,10 @@ dependencies = [ "thiserror", ] +[[package]] +name = "polymodel-renderer-ledger" +version = "0.1.0" + [[package]] name = "proc-macro2" version = "1.0.107" diff --git a/tools/ldraw-compat-harness/Cargo.toml b/tools/ldraw-compat-harness/Cargo.toml index ba27a34..6f0c4dd 100644 --- a/tools/ldraw-compat-harness/Cargo.toml +++ b/tools/ldraw-compat-harness/Cargo.toml @@ -9,7 +9,9 @@ description = "Native offline PM-77 corpus, fuzz, and LDParse compatibility harn [workspace] [dependencies] +polymodel-ldraw-core = { path = "../../crates/polymodel-ldraw-core" } polymodel-ldraw-testkit = { path = "../../crates/polymodel-ldraw-testkit" } +polymodel-renderer-ledger = { path = "../../crates/polymodel-renderer-ledger" } serde = { version = "1.0", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/tools/ldraw-compat-harness/src/main.rs b/tools/ldraw-compat-harness/src/main.rs index b7e20d8..7cf1ccd 100644 --- a/tools/ldraw-compat-harness/src/main.rs +++ b/tools/ldraw-compat-harness/src/main.rs @@ -1,9 +1,13 @@ //! Native PM-77 corpus validator and D-phase LDParse oracle harness. +use polymodel_ldraw_core::{ + InProcessRustAdapter, ParserProfile as CoreProfile, +}; use polymodel_ldraw_testkit::{ coverage_report, inventory, named_gates, validate_inventory, CanonicalRecord, FixtureCase, CorpusGateSet, Diagnostic, OracleDiagnostic, OracleError, OracleModel, OracleProjection, - SCHEMA_VERSION, ORACLE_SCHEMA_VERSION, GATE_NAMES, LDRAW_LIMITS, + Profile, SCHEMA_VERSION, ORACLE_SCHEMA_VERSION, GATE_NAMES, LDRAW_LIMITS, }; +use polymodel_renderer_ledger::{ReservationLedger, ReservationOwner}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ @@ -243,33 +247,34 @@ fn projection(document: OracleDocument) -> OracleProjection { } } -fn actual_record(fixture: &FixtureCase, document: OracleDocument) -> CanonicalRecord { - CanonicalRecord { - schema_version: SCHEMA_VERSION.to_owned(), - syntax: Vec::new(), - semantic: polymodel_ldraw_testkit::SemanticRecord { - canonical_path: None, - root_identity: None, - target_identity: None, - cache_identity: None, - bfc_state: String::new(), - colour_state: String::new(), - steps: Vec::new(), - limits: Vec::new(), - texmap_events: Vec::new(), - }, - scene: polymodel_ldraw_testkit::SceneRecord { - model_id: fixture.id.to_owned(), - instance_ids: Vec::new(), - triangles: 0, - lines: 0, - bounds: [String::new(), String::new(), String::new(), String::new(), String::new(), String::new()], - reflection: false, - }, - diagnostics: Vec::::new(), - provenance_id: fixture.provenance.id.clone(), - oracle_projection: Some(projection(document)), +fn core_profile(profile: Profile) -> CoreProfile { + match profile { + Profile::Strict => CoreProfile::Strict, + Profile::Compatibility => CoreProfile::Compatibility, + Profile::Lossless => CoreProfile::Lossless, + } +} + +fn actual_record(fixture: &FixtureCase) -> Result { + let ledger = ReservationLedger::new(); + let parsed = InProcessRustAdapter::parse_fixture( + fixture.id, + fixture.bytes, + core_profile(fixture.expected.profile), + ReservationOwner::preview(1, 1, 1), + &ledger, + ) + .map_err(|error| format!("core parse failed for {}: {error}", fixture.id))?; + let record = parsed.project(); + if ledger.snapshot().reservations == 0 { + return Err(format!("core reservation was released before projection: {}", fixture.id)); } + Ok(record) +} + +fn with_oracle_projection(mut record: CanonicalRecord, document: OracleDocument) -> CanonicalRecord { + record.oracle_projection = Some(projection(document)); + record } fn differential() -> Result { @@ -279,7 +284,10 @@ fn differential() -> Result { let binary = oracle_binary(&root)?; for fixture in &fixtures { let path = root.join("crates/polymodel-ldraw-testkit").join(fixture.relative_path); - let actual = actual_record(fixture, run_oracle(&binary, &path, ORACLE_TIMEOUT)?); + let actual = with_oracle_projection( + actual_record(fixture)?, + run_oracle(&binary, &path, ORACLE_TIMEOUT)?, + ); polymodel_ldraw_testkit::compare_oracle_projection(&fixture.expected.canonical, &actual, fixture.id) .map_err(|error| format!("{} (source: {}; oracle revision: {}; schema: {})", error, fixture.provenance.id, LD_PARSE_REVISION, ORACLE_SCHEMA_VERSION))?; } @@ -356,6 +364,15 @@ fn fuzz() -> Result { let digest = Sha256::digest(&bytes); let path = temporary.join(format!("{index}-{}.ldr", hex::encode(digest))); fs::write(&path, &bytes).map_err(|error| error.to_string())?; + let ledger = ReservationLedger::new(); + InProcessRustAdapter::parse_fixture( + fixture.id, + &bytes, + core_profile(fixture.expected.profile), + ReservationOwner::preview(1, index as u64 + 1, 1), + &ledger, + ) + .map_err(|error| format!("mutation {index} ({}) core parse failed: {error}", fixture.id))?; run_oracle(&binary, &path, ORACLE_TIMEOUT).map_err(|error| format!("mutation {index} ({}) failed: {error}", fixture.id))?; } Ok(()) -- 2.51.2