From 7255a498cf8ba4b7f3f02faf780c569057d3740d Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 10 Aug 2026 07:39:51 -0400 Subject: [PATCH] PM-84: fix native texture supply, picking, bench enforcement, UV validation --- .../src/bin/ldraw_preview.rs | 8 +- crates/polymodel-renderer-worker/src/lib.rs | 102 ++++++++++++++++-- crates/polymodel-renderer-worker/src/tests.rs | 26 +++++ .../tests/ldraw_preview.rs | 37 +++++++ justfile | 4 +- 5 files changed, 163 insertions(+), 14 deletions(-) diff --git a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs index 2d9e25d..30a14a2 100644 --- a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs @@ -59,8 +59,8 @@ struct PickerInput { } #[cfg(not(target_arch = "wasm32"))] -fn selection_interaction_enabled(style: RenderStyle) -> bool { - style == RenderStyle::Edges +fn selection_interaction_enabled(_style: RenderStyle) -> bool { + true } #[cfg(not(target_arch = "wasm32"))] @@ -1438,8 +1438,8 @@ mod tests { } #[test] - fn selection_policy_is_enabled_for_edges_only_and_base_refresh_is_edges_only() { - assert!(!selection_interaction_enabled(RenderStyle::Faces)); + fn selection_policy_is_enabled_for_faces_and_edges_while_base_refresh_is_edges_only() { + assert!(selection_interaction_enabled(RenderStyle::Faces)); assert!(selection_interaction_enabled(RenderStyle::Edges)); assert!(!base_edge_refresh_enabled(RenderStyle::Faces)); assert!(base_edge_refresh_enabled(RenderStyle::Edges)); diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index 21c9739..1764352 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -191,6 +191,16 @@ pub fn adapt_ldraw_parse(result: &OwnedParseResult) -> Result &'static str { + match root { + RootId::CurrentMpd => "mpd", + RootId::UploadedManifest => "manifest", + RootId::UploadedLdraw => "models", + RootId::OfficialLibrary => "official-parts", + } +} + fn texture_resource_for<'a>( resources: &'a [ModelResource], root: RootId, @@ -1790,6 +1800,42 @@ pub fn semantic_counts(semantic: &LdMeshSemantic) -> (u64, u64) { ) } +#[cfg(not(target_arch = "wasm32"))] +fn official_texture_resources( + closure: &polymodel_ldraw_core::ResourceClosure, + materializations: &[Materialization], +) -> Result, String> { + let mut resources = Vec::new(); + for dependency in closure.texture_dependencies() { + let Some(materialization) = materializations.iter().find(|materialization| { + materialization.root == dependency.root + && materialization.path == dependency.path + && materialization.available + }) else { + continue; + }; + let resource = ModelResource { + key: dependency.path.as_str().to_owned(), + path: dependency.path.as_str().to_owned(), + root: texture_resource_root(dependency.root).to_owned(), + byte_length: materialization.bytes.len() as u64, + sha256: materialization.content_hash.as_bytes().to_vec(), + mime_type: "image/png".to_owned(), + target_cids: Vec::new(), + bytes: materialization.bytes.clone(), + }; + resource + .validate() + .map_err(|error| format!("official TEXMAP resource rejected: {error:?}"))?; + if !resources.iter().any(|existing: &ModelResource| { + existing.root == resource.root && existing.path == resource.path + }) { + resources.push(resource); + } + } + Ok(resources) +} + #[cfg(not(target_arch = "wasm32"))] pub fn prepare_preview_scene( input: &std::path::Path, @@ -1815,8 +1861,18 @@ pub fn prepare_preview_scene( closure.materializations.len(), closure.target_selections.len(), ); + let resources = + official_texture_resources(&closure.resource_closure, &closure.materializations)?; let mut materializations = closure.materializations; let target_selections = closure.target_selections; + materializations.retain(|materialization| { + !closure + .resource_closure + .texture_dependencies() + .any(|dependency| { + materialization.root == dependency.root && materialization.path == dependency.path + }) + }); materializations.insert( 0, Materialization::new( @@ -1841,7 +1897,8 @@ pub fn prepare_preview_scene( preview_limits(), ) .map_err(|error| format!("pinned OMR parse: {error}"))?; - let scene = adapt_ldraw_parse(&result).map_err(|error| error.to_string())?; + let scene = adapt_ldraw_parse_with_resources(&result, Some(&resources)) + .map_err(|error| error.to_string())?; if scene.stats.triangles == 0 || scene.stats.occurrences == 0 || scene.bounds.is_none() { return Err(format!( "preview root scene incomplete: occurrences={} triangles={} bounds={}", @@ -2128,16 +2185,33 @@ pub fn official_materializations( } // 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. + // server bundling. Keep texture bytes in the official closure so native + // preview can pass the exact admitted assets to scene adaptation. 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 - )); + if !candidate.is_file() { + if dependency.required { + return Err(format!( + "required TEXMAP resource is missing: {} (source {})", + dependency.path, dependency.source + )); + } + continue; + } + if materializations.iter().any(|materialization| { + materialization.root == dependency.root && materialization.path == dependency.path + }) { + continue; } + let bytes = std::fs::read(&candidate) + .map_err(|error| format!("read TEXMAP resource {}: {error}", candidate.display()))?; + materializations.push(Materialization::new( + dependency.path.clone(), + dependency.root, + bytes, + true, + Some("official-ldraw-library".into()), + )); } // Seed the colour table from LDConfig.ldr at the library root when the @@ -3352,6 +3426,18 @@ pub fn scene_render_plan(scene: &Scene) -> Result, _>>()?; + for occurrence in &scene.occurrences { + if let Some(uvs) = &occurrence.uvs { + let geometry_index = index.geometry_index(occurrence.node)?; + let point_count = scene.geometries[geometry_index].points.len(); + if uvs.len() != point_count || uvs.iter().flatten().any(|value| !value.is_finite()) { + return Err(RendererError::invalid(format!( + "occurrence {} has invalid UV alignment", + occurrence.canonical_occurrence_id + ))); + } + } + } let edge_sources = scene .geometries .iter() diff --git a/crates/polymodel-renderer-worker/src/tests.rs b/crates/polymodel-renderer-worker/src/tests.rs index 08c325b..2d4fb59 100644 --- a/crates/polymodel-renderer-worker/src/tests.rs +++ b/crates/polymodel-renderer-worker/src/tests.rs @@ -693,6 +693,32 @@ fn textured_occurrences_share_geometry_and_split_uv_batches() { assert_eq!(plan.batches.len(), 2); assert_eq!(plan.batches[0].uvs, Some(uv_a)); assert_eq!(plan.batches[1].uvs, Some(uv_b)); + let picker = ScenePicker::new(&scene, plan).expect("picker with UV-split batches"); + let hit = picker + .pick(PickRay { + origin: [0.0, 0.0, 10.0], + direction: [0.0, 0.0, -1.0], + }) + .expect("UV-split picker ray") + .expect("UV-split picker hit"); + assert_eq!(hit.occurrence_index, 0); + assert!(picker.selection(hit.occurrence_index).is_ok()); +} + +#[test] +fn scene_render_plan_rejects_misaligned_occurrence_uvs() { + let mut scene = picker_scene(); + scene.occurrences[0].uvs = Some(vec![[0.0, 0.0]]); + let error = scene_render_plan(&scene).expect_err("misaligned occurrence UVs"); + assert!(error.to_string().contains("invalid UV alignment")); +} + +#[test] +fn scene_render_plan_rejects_non_finite_occurrence_uvs() { + let mut scene = picker_scene(); + scene.occurrences[0].uvs = Some(vec![[f32::NAN, 0.0]; 3]); + let error = scene_render_plan(&scene).expect_err("non-finite occurrence UVs"); + assert!(error.to_string().contains("invalid UV alignment")); } #[test] diff --git a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs index fcf1ba7..8f3022f 100644 --- a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs @@ -273,6 +273,43 @@ fn with_candidate_library( result } +#[test] +fn official_materializations_consume_resource_closure_texture_dependencies() { + let root = std::env::temp_dir().join(format!( + "polymodel-ldraw-texture-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + fs::create_dir_all(root.join("parts")).expect("temporary parts root"); + fs::create_dir_all(root.join("textures")).expect("temporary textures root"); + let root_bytes = b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 decal.png\n3 16 0 0 0 1 0 0 0 1 0\n0 !TEXMAP END\n"; + let texture_bytes = b"fake-png-bytes"; + fs::write(root.join("textures/decal.png"), texture_bytes).expect("write texture"); + + let path = NormalizedPath::new("parts/root.dat").expect("root path"); + let closure = official_materializations(&root, &path, root_bytes) + .expect("official materializations should consume texture closure"); + let dependency = closure + .resource_closure + .texture_dependencies() + .next() + .expect("closure should contain the TEXMAP dependency"); + let materialization = closure + .materializations + .iter() + .find(|materialization| { + materialization.root == dependency.root && materialization.path == dependency.path + }) + .expect("consumer path should materialize the TEXMAP bytes"); + assert_eq!(materialization.bytes, texture_bytes); + assert!(materialization.has_valid_hash()); + + fs::remove_dir_all(root).expect("remove temporary texture library"); +} + #[test] fn official_materializations_seed_ldconfig_colour_table() { let root = std::env::temp_dir().join(format!( diff --git a/justfile b/justfile index c232a4d..0f8575a 100644 --- a/justfile +++ b/justfile @@ -263,12 +263,12 @@ _preview-ldraw *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; \ + @set -eu -o pipefail; \ 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" + python3 -c 'import json,pathlib,re,sys; p=pathlib.Path(sys.argv[1]); report=json.loads(p.read_text()); log=pathlib.Path(sys.argv[2]); lines=[line for line in log.read_text().splitlines() if "[ldraw-metrics]" in line]; threshold=report["thresholds"]["parse_ms"]; observed=[]; failures=[]; pattern=re.compile(r"phase=(?P\S+).*?elapsed_ms=(?P\d+)"); [ (observed.append({"phase":m.group("phase"),"elapsed_ms":int(m.group("elapsed"))}), failures.append(line) if m.group("phase") in ("scanned-root","split-root","parsed-root-models","parsed-materializations","traversed") and int(m.group("elapsed")) > threshold else None ) for line in lines for m in [pattern.search(line)] if m ]; report["observed"]={"timing_lines":lines,"metrics":observed,"log":sys.argv[2]}; p.write_text(json.dumps(report,indent=2)+chr(10)); [print(f"benchmark threshold exceeded: {line}",file=sys.stderr) for line in failures]; sys.exit(bool(failures))' "$output" "${output%.json}.log" _ldraw-visual: @set -eu; \ -- 2.51.2