diff --git a/crates/polymodel-renderer-worker/src/tests.rs b/crates/polymodel-renderer-worker/src/tests.rs index ce15345..8b31ce2 100644 --- a/crates/polymodel-renderer-worker/src/tests.rs +++ b/crates/polymodel-renderer-worker/src/tests.rs @@ -1899,6 +1899,7 @@ fn ldraw_policy_lights_top_and_bottom_faces_differently() { assert!((bottom - 0.73).abs() < 1.0e-6); } +// This is a white-box policy assertion, not a visual regression test. #[test] fn seeded_ldconfig_red_is_not_the_grey_material_fallback() { let bytes = b"0 !COLOUR Red CODE 4 VALUE #C91A09 EDGE #212121\n3 4 0 0 0 10 0 0 0 10 0\n"; diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index d3148ca..a248468 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -79,10 +79,20 @@ struct RendererState { struct PreviewAbortSlot { identity: LoadIdentity, controller: AbortController, - #[allow(dead_code)] listener: Option>, } +impl Drop for PreviewAbortSlot { + fn drop(&mut self) { + if let Some(listener) = self.listener.take() { + let _ = self + .controller + .signal() + .remove_event_listener_with_callback("abort", listener.as_ref().unchecked_ref()); + } + } +} + fn compound_abort_slot( identity: LoadIdentity, controller: AbortController, @@ -182,7 +192,7 @@ fn parse_compound_source( polymodel_renderer_protocol::scene::Scene, Vec, ), - String, + CompoundLoadError, > { let primary = source .resources @@ -211,9 +221,14 @@ fn parse_compound_source( target_selections: &target_selections, cancellation, }) - .map_err(|error| error.to_string())?; + .map_err(|error| match error { + polymodel_ldraw_core::ParseError::Cancelled => CompoundLoadError::Cancelled, + // This function is the boundary from the parser's typed errors to the + // worker's load error; preserve cancellation, stringify other details. + error => CompoundLoadError::Failed(error.to_string()), + })?; let scene = adapt_ldraw_parse_with_resources(&result, Some(&source.resources)) - .map_err(|error| error.to_string())?; + .map_err(|error| CompoundLoadError::Failed(error.to_string()))?; let stats = polymodel_renderer_protocol::MeshStats { vertices: scene.stats.points.min(u64::from(u32::MAX)) as u32, triangles: scene.stats.triangles.min(u64::from(u32::MAX)) as u32, @@ -2002,10 +2017,6 @@ fn finish_compound_source( ) { { let mut st = state.borrow_mut(); - let slot = match namespace { - RendererNamespace::Interactive => &mut st.interactive_compound_abort, - RendererNamespace::Preview => &mut st.preview_abort, - }; match result { Ok(source) => { let reducer = match namespace { @@ -2042,7 +2053,32 @@ fn finish_compound_source( ); (stats, scene, source_resources) } - Err(detail) => { + Err( + CompoundLoadError::Cancelled + | CompoundLoadError::Superseded + | CompoundLoadError::Disposed, + ) => { + let reducer = match namespace { + RendererNamespace::Interactive => &mut st.load_reducer, + RendererNamespace::Preview => &mut st.preview_reducer, + }; + let transition = reducer.reduce(LoadEvent::Cancel { + namespace, + identity, + candidate: None, + }); + if matches!( + transition.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + st.post_event(&RendererEvent::LoadCancelled { + namespace, + identity, + }); + } + return; + } + Err(CompoundLoadError::Failed(detail)) => { tracing::error!( ?namespace, ?identity, diff --git a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs index a163053..fcf1ba7 100644 --- a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs @@ -273,6 +273,68 @@ fn with_candidate_library( result } +#[test] +fn official_materializations_seed_ldconfig_colour_table() { + let root = std::env::temp_dir().join(format!( + "polymodel-ldraw-ldconfig-{}-{}", + 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"); + let root_bytes = b"3 4 0 0 0 10 0 0 0 10 0\n"; + let ldconfig_bytes = b"0 !COLOUR Red CODE 4 VALUE #C91A09 EDGE #212121\n"; + fs::write(root.join("LDConfig.ldr"), ldconfig_bytes).expect("write LDConfig"); + + let path = NormalizedPath::new("parts/root.dat").expect("root path"); + let closure = official_materializations(&root, &path, root_bytes) + .expect("official materializations should load LDConfig"); + let ldconfig = closure + .materializations + .iter() + .find(|materialization| materialization.bytes == ldconfig_bytes) + .expect("official closure must include LDConfig.ldr bytes"); + assert_eq!(ldconfig.bytes, ldconfig_bytes); + + let mut materializations = closure.materializations; + let root_materialization = Materialization::new( + path.clone(), + RootId::OfficialLibrary, + root_bytes.to_vec(), + true, + Some("official-ldraw-library".into()), + ); + let root_key = root_materialization.key(); + materializations.insert(0, root_materialization); + let parsed = InProcessRustAdapter::parse(AdapterRequest { + bytes: root_bytes, + profile: ParserProfile::Compatibility, + provenance_id: "official-ldconfig-root", + root_name: path.as_str(), + root: RootId::OfficialLibrary, + materializations: &materializations, + target_selections: &closure.target_selections, + cancellation: CancellationPolicy::default(), + }) + .expect("model should parse with seeded LDConfig"); + parsed + .models + .iter() + .find(|model| model.cache_key == root_key) + .expect("root materialization must own parsed model"); + let scene = adapt_ldraw_parse(&parsed).expect("seeded colour should adapt"); + let material = scene + .materials + .iter() + .find(|material| material.colour == 4) + .expect("scene should contain code-4 material"); + assert_eq!(material.colour_rgb, Some([201, 26, 9])); + + fs::remove_dir_all(root).expect("remove temporary LDConfig library"); +} + #[test] fn official_candidate_paths_preserve_physical_identity_and_precedence() { let triangle = b"3 16 0 0 0 1 0 0 0 1 0\n"; diff --git a/justfile b/justfile index d051084..53b424b 100644 --- a/justfile +++ b/justfile @@ -323,7 +323,7 @@ _build-web-release: _build-renderer-worker _build-opencascade-step _verify-renderer-split: _build-web @echo "==> AC.1: checking app dependency tree for forbidden crates..." - @if cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normal 2>/dev/null | grep -qE '(^| )three-d v|three-d-asset v|stl_io v|polymodel-mesh v|polymodel-ldraw-core v'; then echo "FAIL: forbidden crate found in app dependency tree"; exit 1; else echo "PASS: no forbidden crates"; fi + @if cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normal 2>/dev/null | grep -qE '(^| )three-d v|three-d-asset v|stl_io v|polymodel-mesh v'; then echo "FAIL: forbidden crate found in app dependency tree"; exit 1; else echo "PASS: no forbidden crates"; fi @echo "==> AC.1b: checking app Cargo.toml for forbidden deps..." @if grep -qE '^\s*(three-d|three-d-asset|stl_io|polymodel-mesh)\b' Cargo.toml; then echo "FAIL: forbidden dep found in Cargo.toml"; exit 1; else echo "PASS: no forbidden deps in Cargo.toml"; fi @echo "==> AC.2: checking app wasm bundle size (debug build, debuginfo-dominated)..." diff --git a/tools/ldraw-oracle-generator/src/main.rs b/tools/ldraw-oracle-generator/src/main.rs index 31ec251..9f1e7f6 100644 --- a/tools/ldraw-oracle-generator/src/main.rs +++ b/tools/ldraw-oracle-generator/src/main.rs @@ -540,7 +540,11 @@ fn policy_expected(bytes: &[u8]) -> Value { let fields = split_ldraw_fields(geometry_line); if let Some(kind) = fields.first().and_then(|field| field.parse::().ok()) && matches!(kind, 3..=5) - && let Some((mode, parameters, pngfile, glossmap)) = &texmap_descriptor + && let Some((mode, parameters, pngfile, glossmap)) = if texmap_active { + texmap_descriptor.clone() + } else { + texmap_descriptor.take() + } { texmap_geometry.push(serde_json::json!({ "line_type": kind, @@ -601,7 +605,7 @@ fn policy_expected(bytes: &[u8]) -> Value { } else { "awaiting_next" }; - if kind == "NEXT" && texmap_active { + if kind == "NEXT" { texmap_descriptor = mode.clone().map(|mode| ( mode, parameters.clone(),