diff --git a/crates/polymodel-ldraw-core/src/mpd.rs b/crates/polymodel-ldraw-core/src/mpd.rs index 69385b7..a92d114 100644 --- a/crates/polymodel-ldraw-core/src/mpd.rs +++ b/crates/polymodel-ldraw-core/src/mpd.rs @@ -55,7 +55,7 @@ pub type MpdDocument<'src> = Vec>; /// /// The core owns scanning and MPD partitioning; callers decide how the /// filename is joined to their own resolver roots. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct MpdReference { pub file_name: String, pub file_ordinal: usize, @@ -237,8 +237,111 @@ pub fn official_library_candidates( #[derive(Clone, Debug, Eq, PartialEq)] pub struct TexmapReference { + /// The MPD section (or standalone source) that references these textures. + pub source: NormalizedPath, + /// The resolver namespace containing the referencing source. + pub root: RootId, pub pngfile: String, pub glossmap: Option, + /// A `!TEXMAP FALLBACK` command was observed in this source section. + pub fallback_available: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum ResourceKind { + Include, + TexmapAlbedo, + TexmapGlossmap, + LdConfig, +} + +/// One host-resolvable resource admitted by a [`ResourceClosure`]. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ResourceDependency { + pub kind: ResourceKind, + pub path: NormalizedPath, + /// Source model/MPD section that introduced the dependency. + pub source: NormalizedPath, + pub root: RootId, + /// Missing required dependencies invalidate a render/bundle; optional + /// resources may use the fallback described by `fallback_available`. + pub required: bool, + pub fallback_available: bool, +} + +/// Typed, resolver-independent description of every resource needed by an +/// LDraw render. Native preview and server bundling resolve these same entries +/// through their respective filesystem/database adapters. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ResourceClosure { + pub include_references: Vec, + pub dependencies: Vec, + /// Official libraries may provide standard colour definitions. The flag is + /// intentionally optional at resolution time because uploaded sources can + /// render with their inline colours and built-in fallback palette. + pub needs_ldconfig: bool, +} + +impl ResourceClosure { + pub fn from_inspection( + inspection: &CompoundSourceInspection, + current_path: &NormalizedPath, + current_root: RootId, + ) -> Self { + let mut dependencies = Vec::new(); + for texture in inspection.texmap_references() { + let source = texture.source.clone(); + let texture_path = |name: &str| { + let normalized = name.replace('\\', "/"); + let logical = if normalized + .split('/') + .next() + .is_some_and(|segment| segment.eq_ignore_ascii_case("textures")) + { + normalized + } else { + format!("textures/{normalized}") + }; + NormalizedPath::new(&logical).ok() + }; + if let Some(path) = texture_path(&texture.pngfile) { + dependencies.push(ResourceDependency { + kind: ResourceKind::TexmapAlbedo, + path, + source: source.clone(), + root: texture.root, + required: !texture.fallback_available, + fallback_available: texture.fallback_available, + }); + } + if let Some(glossmap) = texture.glossmap.as_deref().and_then(texture_path) { + dependencies.push(ResourceDependency { + kind: ResourceKind::TexmapGlossmap, + path: glossmap, + source, + root: texture.root, + required: false, + fallback_available: true, + }); + } + } + let _ = current_path; + let _ = current_root; + Self { + include_references: inspection.references.clone(), + dependencies, + needs_ldconfig: true, + } + } + + pub fn texture_dependencies(&self) -> impl Iterator { + self.dependencies.iter().filter(|dependency| { + matches!( + dependency.kind, + ResourceKind::TexmapAlbedo | ResourceKind::TexmapGlossmap + ) + }) + } } #[derive(Clone, Debug)] @@ -249,9 +352,26 @@ pub struct CompoundSourceInspection { pub has_virtual_files: bool, } -fn extract_texmap_references_from_files(files: &[VirtualFile<'_>]) -> Vec { +impl CompoundSourceInspection { + pub fn texmap_references(&self) -> &[TexmapReference] { + &self.texmap_references + } +} + +fn extract_texmap_references_from_files( + files: &[VirtualFile<'_>], + current_path: &NormalizedPath, + current_root: RootId, +) -> Vec { let mut references = Vec::new(); for file in files { + let source = + NormalizedPath::new(file.name.as_ref()).unwrap_or_else(|_| current_path.clone()); + let fallback_available = file.lines.iter().any(|line| { + line.line_type == Some(LineType::Zero) + && line.tokens.get(1).map(|token| token.kind) == Some(TokenKind::Texmap) + && line.tokens.get(2).map(|token| token.kind) == Some(TokenKind::Fallback) + }); for line in &file.lines { if line.line_type != Some(LineType::Zero) || line.tokens.get(1).map(|token| token.kind) != Some(TokenKind::Texmap) @@ -288,8 +408,11 @@ fn extract_texmap_references_from_files(files: &[VirtualFile<'_>]) -> Vec, _>>()?; Ok(CompoundSourceInspection { references: extract_references_from_files(&files, has_virtual_files)?, - texmap_references: extract_texmap_references_from_files(&files), + texmap_references: extract_texmap_references_from_files(&files, current_path, current_root), source_keys, has_virtual_files, }) @@ -601,6 +724,30 @@ mod tests { } } + #[test] + fn resource_closure_tracks_texmap_assets_and_fallback_policy() { + let bytes = b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png GLOSSMAP gloss.png\n3 16 0 0 0 1 0 0 0 1 0\n0 !TEXMAP END\n"; + let source = NormalizedPath::new("models/root.ldr").unwrap(); + let inspection = inspect_compound_source(bytes, &source, RootId::UploadedLdraw) + .expect("valid TEXMAP source"); + let closure = ResourceClosure::from_inspection(&inspection, &source, RootId::UploadedLdraw); + assert_eq!(closure.dependencies.len(), 2); + assert_eq!(closure.dependencies[0].kind, ResourceKind::TexmapAlbedo); + assert_eq!( + closure.dependencies[0].path.as_str(), + "textures/sticker.png" + ); + assert_eq!(closure.dependencies[0].source.as_str(), "model.ldr"); + assert_eq!(closure.dependencies[0].root, RootId::UploadedLdraw); + assert!(closure.dependencies[0].required); + assert!(!closure.dependencies[0].fallback_available); + assert_eq!(closure.dependencies[1].kind, ResourceKind::TexmapGlossmap); + assert_eq!(closure.dependencies[1].path.as_str(), "textures/gloss.png"); + assert!(!closure.dependencies[1].required); + assert!(closure.dependencies[1].fallback_available); + assert!(closure.needs_ldconfig); + } + #[test] fn malformed_type_one_and_missing_target_fail_closed() { let malformed = b"1 16 0 0 0 1 0 0 0 1 0 0 0 child.dat\n"; diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index 709c4d0..21c9739 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -1875,6 +1875,7 @@ pub fn preview_limits() -> polymodel_ldraw_core::LdrawLimits { pub struct OfficialClosure { pub materializations: Vec, pub target_selections: Vec, + pub resource_closure: polymodel_ldraw_core::ResourceClosure, } #[cfg(not(target_arch = "wasm32"))] @@ -1922,6 +1923,11 @@ pub fn official_materializations( let mut seen = HashSet::::new(); let mut materializations = Vec::new(); let mut target_selections = Vec::new(); + let mut resource_closure = polymodel_ldraw_core::ResourceClosure { + include_references: Vec::new(), + dependencies: Vec::new(), + needs_ldconfig: false, + }; let mut total_bytes = root_bytes.len(); while let Some(current) = queue.pop_front() { @@ -1934,9 +1940,21 @@ pub fn official_materializations( current.key.resolved_root, ) .map_err(|error| format!("inspect {}: {error}", current.path.as_str()))?; - let source_keys = inspection.source_keys; + let source_keys = inspection.source_keys.clone(); let has_virtual_files = inspection.has_virtual_files; - let references = inspection.references; + let inspection_closure = polymodel_ldraw_core::ResourceClosure::from_inspection( + &inspection, + ¤t.path, + current.key.resolved_root, + ); + resource_closure + .include_references + .extend(inspection_closure.include_references.iter().cloned()); + resource_closure + .dependencies + .extend(inspection_closure.dependencies.iter().cloned()); + resource_closure.needs_ldconfig |= inspection_closure.needs_ldconfig; + let references = inspection_closure.include_references.clone(); for reference in references { if reference.target_virtual_file { continue; @@ -2109,28 +2127,42 @@ pub fn official_materializations( } } - // Seed the colour table from LDConfig.ldr at the library root. - // Standard LDraw colours (red=4, blue=1, etc.) are defined here, not in - // individual model files. Without this, all non-inline colours fall back to grey. - let ldconfig_path = library_root.join("LDConfig.ldr"); - if let Ok(ldconfig_bytes) = std::fs::read(&ldconfig_path) { - if let Ok(ldconfig_normalized) = NormalizedPath::new("LDConfig.ldr") { - let ldconfig_key = CacheKey::new( - ldconfig_normalized.clone(), - RootId::OfficialLibrary, - &ldconfig_bytes, - ); - if seen.insert(ldconfig_key) { - materializations.insert( - 0, - Materialization::new( - ldconfig_normalized, - RootId::OfficialLibrary, - ldconfig_bytes, - true, - Some("official-ldraw-library".into()), - ), + // Resolve auxiliary resources through the same typed closure consumed by + // server bundling. Textures are host assets rather than parser + // materializations, so native preview validates their availability here. + for dependency in resource_closure.texture_dependencies() { + let candidate = library_root.join(dependency.path.as_str()); + if dependency.required && !candidate.is_file() { + return Err(format!( + "required TEXMAP resource is missing: {} (source {})", + dependency.path, dependency.source + )); + } + } + + // Seed the colour table from LDConfig.ldr at the library root when the + // closure requests the optional official-library colour seed. + if resource_closure.needs_ldconfig { + let ldconfig_path = library_root.join("LDConfig.ldr"); + if let Ok(ldconfig_bytes) = std::fs::read(&ldconfig_path) { + if let Ok(ldconfig_normalized) = NormalizedPath::new("LDConfig.ldr") { + let ldconfig_key = CacheKey::new( + ldconfig_normalized.clone(), + RootId::OfficialLibrary, + &ldconfig_bytes, ); + if seen.insert(ldconfig_key) { + materializations.insert( + 0, + Materialization::new( + ldconfig_normalized, + RootId::OfficialLibrary, + ldconfig_bytes, + true, + Some("official-ldraw-library".into()), + ), + ); + } } } } @@ -2138,6 +2170,7 @@ pub fn official_materializations( Ok(OfficialClosure { materializations, target_selections, + resource_closure, }) } diff --git a/justfile b/justfile index 53b424b..c232a4d 100644 --- a/justfile +++ b/justfile @@ -105,6 +105,14 @@ test-renderer-native: preview-ldraw *ARGS: @tools/in-dev-shell _preview-ldraw {{ ARGS }} +# Emit machine-readable parser/adapter benchmark evidence for the pinned model. +ldraw-bench: + @tools/in-dev-shell _ldraw-bench + +# Capture native preview timing and persistent readback evidence for the pinned model. +ldraw-visual: + @tools/in-dev-shell _ldraw-visual + # Run all local validation expected before review. Keep the PM-77 recipes explicit # so corpus, WASM, fuzz, and Nix-backed differential gates remain visible. test-all: @@ -252,6 +260,29 @@ _test-renderer-native: _preview-ldraw *ARGS: set -eu; test -n "${LDRAWDIR:-}" || { echo "[preview] missing LDRAWDIR" >&2; exit 2; }; test -d "$LDRAWDIR" || { echo "[preview] invalid LDRAWDIR=$LDRAWDIR" >&2; exit 2; }; echo "[preview] inherited official library: $LDRAWDIR"; echo "[preview] launching native renderer; child output follows"; cargo run -p polymodel-renderer-worker --bin ldraw_preview -- {{ ARGS }} +_ldraw-bench: + # Thresholds are written before measurement so regressions cannot be hidden + # by a benchmark run that changes its own acceptance criteria. + @set -eu; \ + output="${LDRAW_BENCH_OUTPUT:-$PWD/target/ldraw-evidence/bench.json}"; \ + mkdir -p "$(dirname "$output")"; \ + printf '%s\n' '{"schema":"polymodel-ldraw-bench-v1","model":"omr-10030-1.mpd","thresholds":{"parse_ms":5000,"scene_adaptation_ms":5000,"steady_state_frame_allocations":0,"indexed_lookup":"O(1)"},"measurement":"cargo test -p polymodel-renderer-worker --test ldraw_preview ldraw_preview_uses_pinned_corpus_and_scene_stats -- --nocapture"}' >"$output"; \ + POLYMODEL_LDRAW_TIMING=1 cargo test -p polymodel-renderer-worker --test ldraw_preview ldraw_preview_uses_pinned_corpus_and_scene_stats -- --nocapture 2>&1 | tee "${output%.json}.log"; \ + python3 -c 'import json,pathlib,sys; p=pathlib.Path(sys.argv[1]); report=json.loads(p.read_text()); log=pathlib.Path(sys.argv[2]); report["observed"]={"timing_lines":[line for line in log.read_text().splitlines() if "[ldraw-metrics]" in line],"log":sys.argv[2]}; p.write_text(json.dumps(report,indent=2)+chr(10))' "$output" "${output%.json}.log" + +_ldraw-visual: + @set -eu; \ + test -n "${LDRAWDIR:-}" || { echo "[visual] unavailable: LDRAWDIR is not configured" >&2; exit 2; }; \ + test -d "$LDRAWDIR" || { echo "[visual] unavailable: invalid LDRAWDIR=$LDRAWDIR" >&2; exit 2; }; \ + output_dir="${LDRAW_VISUAL_OUTPUT:-$PWD/target/ldraw-evidence/visual}"; mkdir -p "$output_dir"; \ + log="$output_dir/native-preview.log"; prefix="$output_dir/readback"; \ + printf '%s\n' '{"schema":"polymodel-ldraw-visual-v1","model":"omr-10030-1.mpd","proof_ms":1500,"readback_prefix":"readback","thresholds":{"stage_markers":["preparation-stage=closure","native-build-stage=plan","native-build-stage=static","native-build-stage=picker","readback label=frame-1"]}}' >"$output_dir/baseline.json"; \ + Xvfb :99 -screen 0 2560x1440x24 -ac +extension GLX +render -noreset >/tmp/polymodel-xvfb-visual.log 2>&1 & xvfb_pid=$!; \ + trap 'kill "$xvfb_pid" 2>/dev/null || true' EXIT; \ + attempt=0; while test "$attempt" -lt 100; do xset -display :99 q >/dev/null 2>&1 && break; sleep 0.1; attempt=$((attempt + 1)); done; \ + DISPLAY=:99 WINIT_UNIX_BACKEND=x11 WAYLAND_DISPLAY= XDG_RUNTIME_DIR= LIBGL_ALWAYS_SOFTWARE=1 LDRAWDIR="$LDRAWDIR" cargo run -p polymodel-renderer-worker --bin ldraw_preview -- --width 2560 --height 1440 --camera-case identity_front --scene-variant identity --proof-ms 1500 --readback-prefix "$prefix" >"$log" 2>&1; \ + python3 -c 'import json,pathlib,sys; p=pathlib.Path(sys.argv[1]); report=json.loads(p.read_text()); report["log"]=sys.argv[2]; report["readbacks"]=[str(x) for x in pathlib.Path(sys.argv[2]).parent.glob("readback-*.png")]; p.write_text(json.dumps(report,indent=2)+chr(10))' "$output_dir/baseline.json" "$log" + _ldraw-corpus: _ldraw-oracle-integrity _ldraw-oracle-check cargo test -p polymodel-ldraw-testkit cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- validate diff --git a/src/appview/ldraw.rs b/src/appview/ldraw.rs index 9cbba24..835ce64 100644 --- a/src/appview/ldraw.rs +++ b/src/appview/ldraw.rs @@ -469,7 +469,7 @@ async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult AppResult "projection", BundleNode::Official(_) => "official-library", @@ -680,18 +686,34 @@ async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult>(); + if resource_closure.needs_ldconfig { + auxiliary_dependencies.push(polymodel_ldraw_core::ResourceDependency { + kind: polymodel_ldraw_core::ResourceKind::LdConfig, + path: polymodel_ldraw_core::NormalizedPath::new("LDConfig.ldr") + .map_err(|_| invalid_request("invalid LDConfig path"))?, + source: polymodel_ldraw_core::NormalizedPath::new(current_path.as_str()) + .map_err(|_| invalid_request("invalid bundled source path"))?, + root: polymodel_ldraw_core::RootId::OfficialLibrary, + required: false, + fallback_available: true, + }); + } + for dependency in auxiliary_dependencies { let Some(directory) = libdir else { break; }; - let Some(official) = resolve_official_asset(&reference, directory).await else { + let Some(official) = resolve_official_asset(dependency.path.as_str(), directory).await + else { + if dependency.required { + return Err(not_found()); + } continue; }; let path = official.path.clone();