From 58f78371886cc02fad014b33dc9405e21ebdca42 Mon Sep 17 00:00:00 2001 From: Orual Date: Thu, 6 Aug 2026 17:27:57 -0400 Subject: [PATCH] Epic: PM-86 Task: PM-79 Implement target-neutral shared scene construction, live conditional-line visibility, BFC winding and culling semantics, exact LDraw root mapping, and fresh native rendering evidence. --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/polymodel-ldraw-core/Cargo.toml | 1 + crates/polymodel-ldraw-core/src/parser.rs | 15 +- crates/polymodel-renderer-protocol/Cargo.toml | 1 - crates/polymodel-renderer-protocol/src/lib.rs | 36 +-- crates/polymodel-renderer-worker/Cargo.toml | 1 - .../src/bin/ldraw_preview.rs | 10 +- .../src/bin/ldraw_preview_harness.rs | 30 +- crates/polymodel-renderer-worker/src/lib.rs | 304 +++++++++++++++--- .../polymodel-renderer-worker/src/worker.rs | 68 +++- .../tests/ldraw_preview.rs | 102 +++++- e2e/playwright.config.ts | 18 +- e2e/tests/viewer.spec.ts | 54 +--- justfile | 20 +- src/indexing/sample_data.rs | 142 +++++++- src/viewer.rs | 166 +--------- 17 files changed, 597 insertions(+), 374 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5695498..a95a68a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6792,6 +6792,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", + "web-time", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 885fd3b..63b1fd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ thiserror = "2.0" miette = "7.6" postcard = "1" wasm-bindgen = "=0.2.121" +web-time = "1" [package] name = "polymodel" @@ -29,7 +30,6 @@ description = "atproto Thingiverse but good " [features] default = ["web"] web = ["dioxus/web"] -renderer-test-readback = ["polymodel-renderer-protocol/renderer-test-readback"] desktop = ["dioxus/desktop"] mobile = ["dioxus/mobile"] server = [ diff --git a/crates/polymodel-ldraw-core/Cargo.toml b/crates/polymodel-ldraw-core/Cargo.toml index 8723048..d45c713 100644 --- a/crates/polymodel-ldraw-core/Cargo.toml +++ b/crates/polymodel-ldraw-core/Cargo.toml @@ -12,6 +12,7 @@ serde = { workspace = true } miette = { workspace = true } sha2 = "0.10" thiserror = { workspace = true } +web-time = { workspace = true } [dev-dependencies] serde_json = "1" diff --git a/crates/polymodel-ldraw-core/src/parser.rs b/crates/polymodel-ldraw-core/src/parser.rs index 8fd5d01..51f57d3 100644 --- a/crates/polymodel-ldraw-core/src/parser.rs +++ b/crates/polymodel-ldraw-core/src/parser.rs @@ -19,6 +19,7 @@ use crate::types::{ }; use std::borrow::Cow; use std::collections::BTreeSet; +use web_time::Instant; #[derive(Clone, Debug, Default)] pub(crate) struct ParseMetrics { @@ -37,7 +38,7 @@ pub(crate) struct ParseMetrics { } impl ParseMetrics { - fn report(&self, started: std::time::Instant, phase: &str) { + fn report(&self, started: &Instant, phase: &str) { if std::env::var_os("POLYMODEL_LDRAW_TIMING").is_some() { eprintln!( "[ldraw-metrics] phase={phase} elapsed_ms={} unique_materializations_scanned={} include_edges={} selection_lookups={} selection_hits={} unique_models_parsed={} model_parse_cache_hits={} subtree_occurrences_traversed={} geometry_records_emitted={} geometry_records_merged={} bytes_hashed={} bytes_cloned={} bytes_read={}", @@ -77,7 +78,7 @@ impl LdrawParser { options: ParseOptions<'a>, materializations: &[Materialization], ) -> Result { - let started = std::time::Instant::now(); + let started = Instant::now(); let mut metrics = ParseMetrics::default(); let mut counters = LimitCounters::default(); let input_len = @@ -104,7 +105,7 @@ impl LdrawParser { return Err(limit_error(LimitKind::LineBytes, Some(line.span))); } } - metrics.report(started, "scanned-root"); + metrics.report(&started, "scanned-root"); let mut files = split_mpd(&scanned, &options.limits)?; let has_mpd_file = scanned.iter().any(|line| { line.line_type == Some(LineType::Zero) @@ -155,7 +156,7 @@ impl LdrawParser { } let mut colours = ColourTable::new(); let mut texture_ids = BTreeSet::new(); - metrics.report(started, "split-root"); + metrics.report(&started, "split-root"); for file in &files { let mut scope = ColourScope::with_first_local_slot(colours.next_local_slot()); let model_result = parse_model( @@ -180,7 +181,7 @@ impl LdrawParser { metrics.geometry_records_emitted += model.geometry.len() as u64; models.push(model); } - metrics.report(started, "parsed-root-models"); + metrics.report(&started, "parsed-root-models"); let source_model_count = models.len(); let mut materialized_keys = BTreeSet::new(); @@ -270,7 +271,7 @@ impl LdrawParser { }; models.push(model); } - metrics.report(started, "parsed-materializations"); + metrics.report(&started, "parsed-materializations"); project_ldparse_step_ends(&mut models, source_model_count); let (scene, summaries) = traverse( &models, @@ -280,7 +281,7 @@ impl LdrawParser { &mut arenas, &mut metrics, )?; - metrics.report(started, "traversed"); + metrics.report(&started, "traversed"); let root = models.first(); let semantic = SemanticRecord { canonical_path: root.map(|m| Cow::Owned(m.path.to_string())), diff --git a/crates/polymodel-renderer-protocol/Cargo.toml b/crates/polymodel-renderer-protocol/Cargo.toml index 1f744b6..d770e6c 100644 --- a/crates/polymodel-renderer-protocol/Cargo.toml +++ b/crates/polymodel-renderer-protocol/Cargo.toml @@ -7,7 +7,6 @@ description = "Leaf protocol crate for the renderer worker: shared types, messag [features] default = [] -renderer-test-readback = [] [dependencies] serde = { workspace = true } diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index 7cf3891..7c8362a 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -658,13 +658,6 @@ pub enum RendererEvent { namespace: RendererNamespace, identity: LoadIdentity, }, - /// Verified compound resources were materialized and installed inside the worker. - /// No source bytes or descriptors cross the worker bridge. - CompoundResourcesReady { - namespace: RendererNamespace, - identity: LoadIdentity, - stats: MeshStats, - }, /// A preview render completed successfully. `png` is raw PNG bytes. PreviewImageRendered { request_id: u32, png: Vec }, /// A preview render failed. Preview failures are non-fatal and do not affect @@ -673,31 +666,6 @@ pub enum RendererEvent { /// A renderer error. `fatal: true` means the worker is unusable (context loss, /// unhandled panic) — the main thread surfaces `ViewerStatus::RendererError`. Error { fatal: bool, message: String }, - /// Test-only observation emitted after a real draw and pixel readback. - #[cfg(feature = "renderer-test-readback")] - TestReadback(RendererReadback), -} - -#[cfg(feature = "renderer-test-readback")] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RendererReadback { - pub identity: LoadIdentity, - pub viewport_width: u32, - pub viewport_height: u32, - pub device_pixel_ratio_milli: u32, - pub clear_rgba: [u8; 4], - pub non_clear_pixels: u64, - pub primitive_counts: ReadbackPrimitiveCounts, - pub bounds: scene::SceneBounds, -} - -#[cfg(feature = "renderer-test-readback")] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReadbackPrimitiveCounts { - pub points: u64, - pub triangles: u64, - pub lines: u64, - pub conditional_lines: u64, } // --------------------------------------------------------------------------- @@ -996,7 +964,7 @@ mod tests { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 2), }, - RendererEvent::CompoundResourcesReady { + RendererEvent::InstallSucceeded { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(3, 1), stats: MeshStats { @@ -1004,7 +972,7 @@ mod tests { triangles: 4, }, }, - RendererEvent::CompoundResourcesReady { + RendererEvent::InstallSucceeded { namespace: RendererNamespace::Preview, identity: LoadIdentity::new(3, 2), stats: MeshStats { diff --git a/crates/polymodel-renderer-worker/Cargo.toml b/crates/polymodel-renderer-worker/Cargo.toml index c1396bd..f33e4ce 100644 --- a/crates/polymodel-renderer-worker/Cargo.toml +++ b/crates/polymodel-renderer-worker/Cargo.toml @@ -19,7 +19,6 @@ path = "src/bin/ldraw_preview_harness.rs" [features] default = [] -renderer-test-readback = ["polymodel-renderer-protocol/renderer-test-readback"] [dependencies] polymodel-ldraw-core = { path = "../polymodel-ldraw-core" } diff --git a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs index 5d9b2eb..5f2134b 100644 --- a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs @@ -10,9 +10,9 @@ use polymodel_renderer_protocol::scene::Scene; #[cfg(not(target_arch = "wasm32"))] use polymodel_renderer_worker::{ NativeLights, NativeRenderOptions, NativeStage, NativeTerminal, NativeTrace, PickRay, - build_native_model, camera_space_clip_planes, logical_part_selection, native_camera_fit, - occurrence_gpu_ranges, occurrence_selection_bounds, prepare_preview_scene, - render_scene_window_configured, yaw_camera_position, + build_scene_model, camera_space_clip_planes, logical_part_selection, occurrence_gpu_ranges, + occurrence_selection_bounds, prepare_preview_scene, render_scene_window_configured, + scene_camera_fit, yaw_camera_position, }; #[cfg(not(target_arch = "wasm32"))] @@ -311,10 +311,10 @@ fn run_visible_preview( } }; loop_trace.record(NativeStage::NativeModelBuildStarted); - match build_native_model(&context, &prepared_scene) { + match build_scene_model(&context, &prepared_scene) { Ok(instanced) => { loop_trace.record(NativeStage::NativeModelBuildComplete); - let fit = native_camera_fit(bounds, width, height); + let fit = scene_camera_fit(bounds, width, height); let position = yaw_camera_position(fit.center, fit.position, yaw_degrees); eprintln!( "[ldraw-preview] camera center={:?} position={:?} target={:?} up={:?} fov=45 near={:.3} far={:.3} yaw_degrees={:.1} viewport={}x{}", diff --git a/crates/polymodel-renderer-worker/src/bin/ldraw_preview_harness.rs b/crates/polymodel-renderer-worker/src/bin/ldraw_preview_harness.rs index 34d12a2..6fa3520 100644 --- a/crates/polymodel-renderer-worker/src/bin/ldraw_preview_harness.rs +++ b/crates/polymodel-renderer-worker/src/bin/ldraw_preview_harness.rs @@ -2,7 +2,10 @@ use std::{env, path::PathBuf}; #[cfg(not(target_arch = "wasm32"))] -use polymodel_renderer_worker::{prepare_preview_scene, render_scene_window}; +use polymodel_renderer_worker::{ + NativeRenderOptions, NativeStage, NativeTerminal, NativeTrace, prepare_preview_scene, + render_scene_window_options_traced, +}; #[cfg(not(target_arch = "wasm32"))] fn main() { @@ -22,7 +25,30 @@ fn main() { scene.stats.triangles, scene.bounds.is_some() ); - render_scene_window(&scene, Some(&output)).expect("native three-d preview"); + let trace = NativeTrace::default(); + render_scene_window_options_traced( + &scene, + Some(&output), + trace.clone(), + NativeRenderOptions::default(), + ) + .expect("native three-d preview"); + let snapshot = trace.snapshot(); + eprintln!( + "[native-harness] render-terminal={:?} stages={:?}", + snapshot.terminal, snapshot.stages + ); + assert_eq!(snapshot.terminal, Some(NativeTerminal::Success)); + for stage in [ + NativeStage::DrawCompleted, + NativeStage::ReadbackCompleted, + NativeStage::OutputWritten, + ] { + assert!( + snapshot.stages.contains(&stage), + "missing native stage {stage:?}" + ); + } println!( "[native-harness] output={} occurrences={} triangles={}", output.display(), diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index e4beebe..aa135bf 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -18,8 +18,8 @@ use std::time::Instant; #[cfg(not(target_arch = "wasm32"))] use three_d::{AmbientLight, Camera, DirectionalLight, OrbitControl}; use three_d::{ - CpuMaterial, CpuMesh, Gm, Indices, InstancedMesh, Instances, Mat4, PhysicalMaterial, Positions, - Srgba, + CpuMaterial, CpuMesh, Cull, Gm, Indices, InstancedMesh, Instances, Mat4, PhysicalMaterial, + Positions, RenderStates, Srgba, }; #[cfg(not(target_arch = "wasm32"))] @@ -702,6 +702,14 @@ fn apply_scene_transform( } } +fn transform_reflection(transform: [[f64; 4]; 4]) -> bool { + let determinant = transform[0][0] + * (transform[1][1] * transform[2][2] - transform[1][2] * transform[2][1]) + - transform[0][1] * (transform[1][0] * transform[2][2] - transform[1][2] * transform[2][0]) + + transform[0][2] * (transform[1][0] * transform[2][1] - transform[1][1] * transform[2][0]); + determinant < 0.0 +} + fn scene_transform_to_mat4(matrix: [[f64; 4]; 4]) -> Mat4 { Mat4::new( matrix[0][0] as f32, @@ -733,7 +741,7 @@ pub struct NativeCameraFit { pub control_max: f32, } -pub fn native_camera_fit(bounds: SceneBounds, width: u32, height: u32) -> NativeCameraFit { +pub fn scene_camera_fit(bounds: SceneBounds, width: u32, height: u32) -> NativeCameraFit { use three_d::InnerSpace as _; let center = three_d::vec3( @@ -1226,12 +1234,12 @@ pub fn render_scene_window_configured( } #[derive(Debug, Clone, PartialEq)] -pub struct NativeMeshTopology { +pub struct SceneMeshTopology { pub positions: Vec<[f32; 3]>, pub indices: Vec, } -impl NativeMeshTopology { +impl SceneMeshTopology { pub fn triangle_count(&self) -> usize { self.indices.len() / 3 } @@ -1241,21 +1249,51 @@ impl NativeMeshTopology { } } -pub fn native_oriented_mesh_topology( +pub fn scene_oriented_mesh_topology( + geometry: &SceneGeometry, + bfc_cull: bool, + bfc_ccw: bool, + bfc_inverted: bool, + reflection: bool, +) -> Result { + scene_oriented_mesh_topology_for_view( + geometry, + bfc_cull, + bfc_ccw, + bfc_inverted, + reflection, + [0.0, 0.0, 1.0], + ) +} + +pub fn scene_oriented_mesh_topology_for_view( geometry: &SceneGeometry, + bfc_cull: bool, + bfc_ccw: bool, bfc_inverted: bool, -) -> Result { - let mut topology = native_mesh_topology(geometry)?; - if bfc_inverted { + reflection: bool, + view: [f64; 3], +) -> Result { + let mut topology = scene_mesh_topology_for_view(geometry, view)?; + let reverse = bfc_inverted ^ !bfc_ccw ^ reflection; + if reverse { for triangle in topology.indices.chunks_exact_mut(3) { triangle.swap(0, 2); } } + let _ = bfc_cull; Ok(topology) } -pub fn native_mesh_topology(geometry: &SceneGeometry) -> Result { - let positions = geometry +pub fn scene_mesh_topology(geometry: &SceneGeometry) -> Result { + scene_mesh_topology_for_view(geometry, [0.0, 0.0, 1.0]) +} + +pub fn scene_mesh_topology_for_view( + geometry: &SceneGeometry, + view: [f64; 3], +) -> Result { + let mut positions = geometry .points .iter() .map(|point| { @@ -1270,13 +1308,99 @@ pub fn native_mesh_topology(geometry: &SceneGeometry) -> Result, _>>()?; - let mut indices = Vec::with_capacity(geometry.triangles.len() * 3 + geometry.quads.len() * 6); + let mut indices = Vec::with_capacity( + geometry.triangles.len() * 3 + + geometry.quads.len() * 6 + + (geometry.lines.len() + geometry.conditional_lines.len()) * 6, + ); for triangle in &geometry.triangles { indices.extend_from_slice(triangle); } for quad in &geometry.quads { indices.extend_from_slice(&[quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]]); } + let mut add_line = |line: [u32; 2]| -> Result<(), String> { + let start = positions.get(line[0] as usize).copied().ok_or_else(|| { + format!( + "geometry {} line start is outside its position domain", + geometry.id.0 + ) + })?; + let end = positions.get(line[1] as usize).copied().ok_or_else(|| { + format!( + "geometry {} line end is outside its position domain", + geometry.id.0 + ) + })?; + let direction = [ + f64::from(end[0] - start[0]), + f64::from(end[1] - start[1]), + f64::from(end[2] - start[2]), + ]; + let length = (direction[0] * direction[0] + + direction[1] * direction[1] + + direction[2] * direction[2]) + .sqrt(); + if !length.is_finite() || length <= f64::EPSILON { + return Ok(()); + } + let mut side = [ + direction[1] * view[2] - direction[2] * view[1], + direction[2] * view[0] - direction[0] * view[2], + direction[0] * view[1] - direction[1] * view[0], + ]; + let side_length = (side[0] * side[0] + side[1] * side[1] + side[2] * side[2]).sqrt(); + if side_length <= f64::EPSILON { + side = [-direction[2], 0.0, direction[0]]; + } + let side_length = (side[0] * side[0] + side[1] * side[1] + side[2] * side[2]).sqrt(); + let radius = 0.02_f64; + let offset = [ + (side[0] / side_length * radius) as f32, + (side[1] / side_length * radius) as f32, + (side[2] / side_length * radius) as f32, + ]; + let base = + u32::try_from(positions.len()).map_err(|_| "line vertex range overflow".to_owned())?; + positions.extend([ + [ + start[0] - offset[0], + start[1] - offset[1], + start[2] - offset[2], + ], + [ + start[0] + offset[0], + start[1] + offset[1], + start[2] + offset[2], + ], + [end[0] + offset[0], end[1] + offset[1], end[2] + offset[2]], + [end[0] - offset[0], end[1] - offset[1], end[2] - offset[2]], + ]); + indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + Ok(()) + }; + for line in geometry.lines.iter().copied() { + add_line(line)?; + } + for conditional in &geometry.conditional_lines { + let points = conditional + .iter() + .map(|index| { + geometry.points.get(*index as usize).copied().ok_or_else(|| { + format!( + "geometry {} conditional-line index {index} is outside its local {}-position domain", + geometry.id.0, + geometry.points.len() + ) + }) + }) + .collect::, _>>()?; + let endpoints = [points[0], points[1]]; + let controls = [points[2], points[3]]; + if conditional_line_visible(endpoints, controls, view) { + add_line([conditional[0], conditional[1]])?; + } + } if indices.len() % 3 != 0 { return Err(format!( "geometry {} has an incomplete triangle index stream", @@ -1294,7 +1418,7 @@ pub fn native_mesh_topology(geometry: &SceneGeometry) -> Result } pub fn occurrence_gpu_ranges(scene: &Scene) -> Result, String> { - let batches = native_instance_batches(scene)?; + let batches = scene_instance_batches(scene)?; let default_material = scene .materials .first() @@ -1364,7 +1488,18 @@ pub fn occurrence_gpu_ranges(scene: &Scene) -> Result, S let batch_indices = batches .iter() .enumerate() - .map(|(index, batch)| ((batch.node, batch.material, batch.bfc_inverted), index)) + .map(|(index, batch)| { + ( + ( + batch.node, + batch.material, + batch.bfc_cull, + batch.bfc_ccw, + batch.bfc_inverted, + ), + index, + ) + }) .collect::>(); let mut next_instances = vec![0_usize; batches.len()]; let mut topology_ranges = vec![None; scene.geometries.len()]; @@ -1372,7 +1507,13 @@ pub fn occurrence_gpu_ranges(scene: &Scene) -> Result, S for occurrence in &scene.occurrences { let material = occurrence.material.unwrap_or(default_material); let batch = batch_indices - .get(&(occurrence.node, material, occurrence.bfc_inverted)) + .get(&( + occurrence.node, + material, + occurrence.bfc_cull, + occurrence.bfc_ccw, + occurrence.bfc_inverted, + )) .copied() .ok_or("occurrence has no native GPU batch")?; let instance = next_instances[batch]; @@ -1385,7 +1526,7 @@ pub fn occurrence_gpu_ranges(scene: &Scene) -> Result, S let (vertex_count, index_count) = match topology_ranges[geometry_index] { Some(counts) => counts, None => { - let topology = native_mesh_topology(geometry)?; + let topology = scene_mesh_topology(geometry)?; let counts = ( u32::try_from(topology.positions.len()).map_err(|_| "vertex range overflow")?, u32::try_from(topology.indices.len()).map_err(|_| "index range overflow")?, @@ -1415,7 +1556,7 @@ pub fn pick_scene(scene: &Scene, ray: PickRay) -> Result, S .geometries .get(occurrence.node.0.saturating_sub(1) as usize) .ok_or("occurrence geometry is outside the scene table")?; - let topology = native_mesh_topology(geometry)?; + let topology = scene_mesh_topology(geometry)?; for (triangle_index, indices) in topology.indices.chunks_exact(3).enumerate() { let point = |index: u32| { apply_scene_transform(occurrence.transform, geometry.points[index as usize]) @@ -1496,7 +1637,7 @@ pub fn logical_part_selection( } #[cfg(not(target_arch = "wasm32"))] -pub fn build_native_occurrence_models( +pub fn build_scene_occurrence_models( context: &three_d::Context, scene: &Scene, occurrence_indices: &[usize], @@ -1512,33 +1653,36 @@ pub fn build_native_occurrence_models( .ok_or_else(|| "selected occurrence is outside the scene table".to_owned()) }) .collect::>()?; - build_native_model(context, &selected) + build_scene_model(context, &selected) } #[cfg(not(target_arch = "wasm32"))] -pub fn build_native_occurrence_model( +pub fn build_scene_occurrence_model( context: &three_d::Context, scene: &Scene, occurrence_index: usize, ) -> Result>, String> { - build_native_occurrence_models(context, scene, &[occurrence_index]) + build_scene_occurrence_models(context, scene, &[occurrence_index]) } #[derive(Debug, Clone, PartialEq)] -pub struct NativeInstanceBatch { +pub struct SceneInstanceBatch { pub node: NodeId, pub material: MaterialId, + pub bfc_cull: bool, + pub bfc_ccw: bool, pub bfc_inverted: bool, + pub reflection: bool, pub transforms: Vec<[[f64; 4]; 4]>, } -pub fn native_instance_batches(scene: &Scene) -> Result, String> { +pub fn scene_instance_batches(scene: &Scene) -> Result, String> { let default_material = scene .materials .first() .map(|material| material.id) .unwrap_or(MaterialId(1)); - let mut batches = Vec::::new(); + let mut batches = Vec::::new(); for occurrence in &scene.occurrences { let geometry_index = occurrence .node @@ -1555,23 +1699,32 @@ pub fn native_instance_batches(scene: &Scene) -> Result if let Some(batch) = batches.iter_mut().find(|batch| { batch.node == occurrence.node && batch.material == material + && batch.bfc_cull == occurrence.bfc_cull + && batch.bfc_ccw == occurrence.bfc_ccw && batch.bfc_inverted == occurrence.bfc_inverted + && batch.reflection == transform_reflection(occurrence.transform) }) { batch.transforms.push(occurrence.transform); } else { - batches.push(NativeInstanceBatch { + batches.push(SceneInstanceBatch { node: occurrence.node, material, + bfc_cull: occurrence.bfc_cull, + bfc_ccw: occurrence.bfc_ccw, bfc_inverted: occurrence.bfc_inverted, + reflection: transform_reflection(occurrence.transform), transforms: vec![occurrence.transform], }); } } if !scene.geometries.is_empty() && !batches.iter().any(|batch| batch.node == scene.root) { - batches.push(NativeInstanceBatch { + batches.push(SceneInstanceBatch { node: scene.root, material: default_material, + bfc_cull: false, + bfc_ccw: true, bfc_inverted: false, + reflection: false, transforms: vec![[ [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], @@ -1637,11 +1790,19 @@ fn ldraw_colour(code: u32) -> Srgba { Srgba::new_opaque(r, g, b) } -pub fn build_native_model( +pub fn build_scene_model( + context: &three_d::Context, + scene: &Scene, +) -> Result>, String> { + build_scene_model_for_view(context, scene, [0.0, 0.0, 1.0]) +} + +pub fn build_scene_model_for_view( context: &three_d::Context, scene: &Scene, + view: [f64; 3], ) -> Result>, String> { - let batches = native_instance_batches(scene)?; + let batches = scene_instance_batches(scene)?; let model = batches .into_iter() .map(|batch| { @@ -1657,6 +1818,7 @@ pub fn build_native_model( batch.node.0 ) })?; + let reflection = batch.reflection; let transformations = batch .transforms .into_iter() @@ -1668,7 +1830,7 @@ pub fn build_native_model( .find(|material| material.id == batch.material) .map(|material| material.colour) .unwrap_or(16); - let material = PhysicalMaterial::new_opaque( + let mut material = PhysicalMaterial::new_opaque( context, &CpuMaterial { albedo: ldraw_colour(colour), @@ -1677,10 +1839,25 @@ pub fn build_native_model( ..Default::default() }, ); + material.render_states = RenderStates { + cull: if batch.bfc_cull { + Cull::Back + } else { + Cull::None + }, + ..Default::default() + }; if transformations.is_empty() { return Ok(None); } - let topology = native_oriented_mesh_topology(geometry, batch.bfc_inverted)?; + let topology = scene_oriented_mesh_topology_for_view( + geometry, + batch.bfc_cull, + batch.bfc_ccw, + batch.bfc_inverted, + reflection, + view, + )?; if topology.indices.is_empty() { return Ok(None); } @@ -1769,7 +1946,7 @@ pub fn render_scene_window_options_traced( eprintln!("[ldraw-preview] window/context created"); let context = window.gl(); trace.record(NativeStage::ContextCreated); - let model = match catch_unwind(AssertUnwindSafe(|| build_native_model(&context, scene))) { + let model = match catch_unwind(AssertUnwindSafe(|| build_scene_model(&context, scene))) { Ok(Ok(model)) => model, Ok(Err(error)) => { trace.finish(NativeTerminal::Failure(error.clone())); @@ -1784,7 +1961,7 @@ pub fn render_scene_window_options_traced( trace.record(NativeStage::GpuResourcesCreated); let lights = NativeLights::new(&context); let viewport = window.viewport(); - let fit = native_camera_fit(bounds, options.width, options.height); + let fit = scene_camera_fit(bounds, options.width, options.height); let position = yaw_camera_position(fit.center, fit.position, options.yaw_degrees); eprintln!( "[ldraw-preview] camera center={:?} position={:?} target={:?} up={:?} fov=45 near={:.3} far={:.3} yaw_degrees={:.1} viewport={}x{}", @@ -2089,8 +2266,8 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] - fn native_topology_preserves_single_triangle_vertices() { - let topology = native_mesh_topology(&topology_geometry( + fn scene_topology_preserves_single_triangle_vertices() { + let topology = scene_mesh_topology(&topology_geometry( vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 3.0, 0.0]], vec![[0, 1, 2]], Vec::new(), @@ -2107,20 +2284,20 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] - fn native_topology_keeps_shared_vertices_and_reverse_winding_local() { + fn scene_topology_keeps_shared_vertices_and_reverse_winding_local() { let points = vec![ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0], ]; - let forward = native_mesh_topology(&topology_geometry( + let forward = scene_mesh_topology(&topology_geometry( points.clone(), vec![[0, 1, 2], [0, 2, 3]], Vec::new(), )) .unwrap(); - let reflected = native_mesh_topology(&topology_geometry( + let reflected = scene_mesh_topology(&topology_geometry( points, vec![[2, 1, 0], [3, 2, 0]], Vec::new(), @@ -2136,7 +2313,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn native_topology_triangulates_quads_and_preserves_rebased_records() { - let topology = native_mesh_topology(&topology_geometry( + let topology = scene_mesh_topology(&topology_geometry( vec![ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], @@ -2165,7 +2342,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn native_topology_rejects_foreign_index_domains() { - let error = native_mesh_topology(&topology_geometry( + let error = scene_mesh_topology(&topology_geometry( vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 3]], Vec::new(), @@ -2174,6 +2351,51 @@ mod tests { assert!(error.contains("outside its local 3-position domain")); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn native_topology_expands_lines_and_filters_conditional_lines_by_view() { + let mut geometry = topology_geometry( + vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + [0.0, -1.0, 0.0], + [1.0, -1.0, 0.0], + ], + Vec::new(), + Vec::new(), + ); + geometry.lines = vec![[0, 1]]; + geometry.conditional_lines = vec![[4, 5, 0, 1]]; + let visible = scene_mesh_topology_for_view(&geometry, [0.0, 0.0, 1.0]).unwrap(); + let hidden = scene_mesh_topology_for_view(&geometry, [0.0, 0.0, -1.0]).unwrap(); + assert_eq!( + visible.triangle_count(), + 4, + "line plus visible conditional line" + ); + assert_eq!( + hidden.triangle_count(), + 2, + "ordinary line remains when conditional line is hidden" + ); + assert!(visible.positions.len() > geometry.points.len()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn native_topology_rejects_malformed_conditional_line_indices() { + let mut geometry = topology_geometry( + vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + Vec::new(), + Vec::new(), + ); + geometry.conditional_lines = vec![[0, 1, 2, 3]]; + let error = scene_mesh_topology(&geometry).unwrap_err(); + assert!(error.contains("conditional-line index 2")); + } + #[test] fn ldraw_basis_preserves_winding_and_has_positive_determinant() { assert_eq!(ldraw_to_world([1.0, 2.0, 3.0]), [1.0, 3.0, -2.0]); @@ -2256,7 +2478,7 @@ mod tests { min: [-2654.399_419_125, -742.736, -456.829_925], max: [-187.804_052_188, 742.725, 686.390_413], }; - let fit = native_camera_fit(bounds, 1280, 720); + let fit = scene_camera_fit(bounds, 1280, 720); assert!((fit.center.x - -1421.101_7).abs() < 1.0e-3); assert!((fit.center.y - -0.005_5).abs() < 1.0e-3); assert!((fit.center.z - 114.780_24).abs() < 1.0e-3); diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 504e73d..6917e2b 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -64,6 +64,7 @@ struct RendererState { context: three_d::Context, model: Option>, ldraw_model: Option>>, + ldraw_scene: Option, ambient_light: three_d::AmbientLight, key_light: three_d::DirectionalLight, fill_light: three_d::DirectionalLight, @@ -87,7 +88,8 @@ struct VerifiedCompoundSource { fn descriptor_root(root: &str) -> Result { match root { "mpd" => Ok(RootId::CurrentMpd), - "manifest" | "models" | "parts" | "p" | "external" => Ok(RootId::UploadedManifest), + "manifest" => Ok(RootId::UploadedManifest), + "models" | "parts" | "p" | "external" => Ok(RootId::UploadedLdraw), "official-parts" | "official-p" => Ok(RootId::OfficialLibrary), _ => Err(format!("unsupported compound descriptor root: {root}")), } @@ -366,6 +368,7 @@ impl WorkerState { context, model: None, ldraw_model: None, + ldraw_scene: None, ambient_light, key_light, fill_light, @@ -696,6 +699,44 @@ mod tests { three_d::Context::from_gl_context(Arc::new(glow_context)).expect("three-d context") } + #[wasm_bindgen_test] + fn descriptor_roots_map_to_their_typed_resource_roots() { + let cases = [ + ("mpd", RootId::CurrentMpd), + ("manifest", RootId::UploadedManifest), + ("models", RootId::UploadedLdraw), + ("parts", RootId::UploadedLdraw), + ("p", RootId::UploadedLdraw), + ("external", RootId::UploadedLdraw), + ("official-parts", RootId::OfficialLibrary), + ("official-p", RootId::OfficialLibrary), + ]; + for (root, expected) in cases { + assert_eq!( + descriptor_root(root).expect("supported descriptor root"), + expected + ); + } + } + + #[wasm_bindgen_test] + fn descriptor_roots_reject_malformed_and_colliding_labels() { + for root in ["", "manifest/", "official", "models/../parts", "unknown"] { + let error = descriptor_root(root).expect_err("malformed root must be rejected"); + assert!(error.contains("unsupported compound descriptor root")); + } + assert_ne!( + descriptor_root("manifest").unwrap(), + descriptor_root("models").unwrap(), + "manifest and uploaded LDraw roots must never collide" + ); + assert_ne!( + descriptor_root("mpd").unwrap(), + descriptor_root("official-p").unwrap(), + "current MPD and official roots must remain distinct" + ); + } + #[wasm_bindgen_test] fn load_lifecycle_reaches_ready_only_after_install() { const NS: RendererNamespace = RendererNamespace::Interactive; @@ -1007,6 +1048,24 @@ fn consume_input_and_render(st: &mut WorkerState) { renderer .control .handle_events(&mut renderer.camera, &mut events); + if let Some(scene) = renderer.ldraw_scene.clone() { + let direction = renderer.camera.position() - renderer.camera.target(); + let view = [ + f64::from(direction.x), + f64::from(direction.y), + f64::from(direction.z), + ]; + match polymodel_renderer_worker::build_scene_model_for_view( + &renderer.context, + &scene, + view, + ) { + Ok(model) => renderer.ldraw_model = Some(model), + Err(error) => { + tracing::warn!(%error, "renderer worker: conditional scene rebuild failed") + } + } + } } let viewport = three_d::Viewport::new_at_origo(st.pixel_width, st.pixel_height); @@ -1561,7 +1620,7 @@ fn finish_compound_source( return; } }; - let model = match polymodel_renderer_worker::build_native_model( + let model = match polymodel_renderer_worker::build_scene_model( &renderer.context, &scene, ) { @@ -1588,7 +1647,7 @@ fn finish_compound_source( return; } }; - let fit = polymodel_renderer_worker::native_camera_fit( + let fit = polymodel_renderer_worker::scene_camera_fit( bounds, pixel_width, pixel_height, @@ -1607,6 +1666,7 @@ fn finish_compound_source( renderer.scene_bounds = Some(bounds); renderer.model = None; renderer.ldraw_model = Some(model); + renderer.ldraw_scene = Some(scene); st.needs_render = true; } let reducer = match namespace { @@ -1624,7 +1684,7 @@ fn finish_compound_source( ) { return; } - st.post_event(&RendererEvent::CompoundResourcesReady { + st.post_event(&RendererEvent::InstallSucceeded { namespace, identity, stats, diff --git a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs index 51519dd..03b4a58 100644 --- a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs @@ -2,6 +2,8 @@ use std::fs; use std::path::Path; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; use polymodel_ldraw_core::{ AdapterRequest, CancellationPolicy, InProcessRustAdapter, Materialization, NormalizedPath, @@ -9,8 +11,8 @@ use polymodel_ldraw_core::{ }; use polymodel_renderer_ledger::{ReservationLedger, ReservationOwner}; use polymodel_renderer_worker::{ - adapt_ldraw_parse, native_instance_batches, native_mesh_topology, official_materializations, - preview_limits, + adapt_ldraw_parse, official_materializations, preview_limits, scene_instance_batches, + scene_mesh_topology, }; const OMR: &str = "crates/polymodel-ldraw-testkit/corpus/omr-10030-1.mpd"; @@ -107,7 +109,7 @@ fn isolated_plate() -> IsolatedPlate { && geometry.points.iter().all(|point| point[2] == -8.0) }) .expect("3021 SceneGeometry must retain its four body quads"); - let topology = native_mesh_topology(plate_geometry).expect("3021 native topology"); + let topology = scene_mesh_topology(plate_geometry).expect("3021 native topology"); assert_eq!(topology.positions.len(), 16); assert_eq!( topology.triangle_count(), @@ -115,7 +117,7 @@ fn isolated_plate() -> IsolatedPlate { "four plate quads upload as eight triangles" ); assert_eq!(topology.indices.len(), 24); - let batches = native_instance_batches(&scene).expect("3021 production instance batches"); + let batches = scene_instance_batches(&scene).expect("3021 production instance batches"); let plate_batches = batches .iter() .filter(|batch| batch.node.0 == plate_geometry.id.0) @@ -447,7 +449,7 @@ fn pinned_scene() -> polymodel_renderer_protocol::scene::Scene { .geometries .iter() .filter_map(|geometry| { - let topology = native_mesh_topology(geometry).expect("official geometry topology"); + let topology = scene_mesh_topology(geometry).expect("official geometry topology"); (!topology.indices.is_empty()).then_some((geometry, topology)) }) .collect::>(); @@ -456,11 +458,24 @@ fn pinned_scene() -> polymodel_renderer_protocol::scene::Scene { "official closure must retain many geometry definitions" ); for (geometry, topology) in &renderable { - assert_eq!(topology.positions.len(), geometry.points.len()); - assert_eq!( - topology.indices.len(), - geometry.triangles.len() * 3 + geometry.quads.len() * 6 + let line_vertices = (geometry.lines.len() + geometry.conditional_lines.len()) * 4; + assert!( + topology.positions.len() >= geometry.points.len() + && topology.positions.len() <= geometry.points.len() + line_vertices, + "native line expansion must add only bounded local vertices" + ); + let surface_indices = geometry.triangles.len() * 3 + geometry.quads.len() * 6; + let line_indices = geometry.lines.len() * 6; + let conditional_indices = geometry.conditional_lines.len() * 6; + assert!( + topology.indices.len() >= surface_indices + line_indices, + "explicit lines must be represented in native topology" ); + assert!( + topology.indices.len() <= surface_indices + line_indices + conditional_indices, + "conditional visibility may only add its own bounded line topology" + ); + assert_eq!(topology.indices.len() % 3, 0); assert!((topology.max_index().unwrap() as usize) < topology.positions.len()); } let signatures = renderable @@ -482,7 +497,7 @@ fn pinned_scene() -> polymodel_renderer_protocol::scene::Scene { signatures.len() > 100, "distinct official definitions must not collapse to a proxy mesh" ); - let batches = native_instance_batches(&scene).expect("exact native instance batches"); + let batches = scene_instance_batches(&scene).expect("exact native instance batches"); let batch_nodes = batches .iter() .map(|batch| batch.node) @@ -524,8 +539,63 @@ fn ldraw_preview_uses_pinned_corpus_and_scene_stats() { assert!(scene.stats.triangles > 0); assert!(scene.stats.points > 0); assert!(scene.bounds.is_some()); - let output = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/ldraw-preview/test.png"); - let png = fs::read(&output).expect("native preview harness output; run test-renderer-native"); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos(); + let output = std::env::temp_dir().join(format!( + "polymodel-pm79-native-preview-{}-{nonce}.png", + std::process::id() + )); + assert!(!output.exists(), "unique native output must not pre-exist"); + let harness = env!("CARGO_BIN_EXE_ldraw_preview_harness"); + let input = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../polymodel-ldraw-testkit") + .join("corpus/omr-10030-1.mpd"); + let result = Command::new(harness) + .arg("--input") + .arg(&input) + .arg("--library-root") + .arg(std::env::var_os("LDRAWDIR").expect("LDRAWDIR must be set")) + .arg("--output") + .arg(&output) + .env( + "DISPLAY", + std::env::var("DISPLAY").unwrap_or_else(|_| ":99".into()), + ) + .env("WINIT_UNIX_BACKEND", "x11") + .env_remove("WAYLAND_DISPLAY") + .env_remove("XDG_RUNTIME_DIR") + .output() + .expect("native preview harness must launch"); + assert!( + result.status.success(), + "native preview harness failed: stdout={} stderr={}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + let evidence = format!( + "{}{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + for marker in [ + "preparation-complete", + "stage=DrawCompleted", + "stage=ReadbackCompleted", + "stage=OutputWritten", + ] { + assert!( + evidence.contains(marker), + "native evidence missing {marker}: {evidence}" + ); + } + let metadata = fs::metadata(&output).expect("native renderer must write fresh output"); + assert!( + metadata.is_file() && metadata.len() > 64, + "native output must be meaningful" + ); + let png = fs::read(&output).expect("fresh native renderer output"); assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); let mut reader = png::Decoder::new(std::io::Cursor::new(png)) .read_info() @@ -689,7 +759,7 @@ fn ldraw_preview_uses_pinned_corpus_and_scene_stats() { #[test] fn identity_and_reflected_batches_preserve_outward_normal_and_material() { use polymodel_renderer_protocol::scene::{GeometryId, ProvenanceId, SceneGeometry}; - use polymodel_renderer_worker::native_oriented_mesh_topology; + use polymodel_renderer_worker::scene_oriented_mesh_topology; let geometry = SceneGeometry { id: GeometryId(1), @@ -700,7 +770,7 @@ fn identity_and_reflected_batches_preserve_outward_normal_and_material() { lines: Vec::new(), conditional_lines: Vec::new(), }; - let normal_z = |topology: &polymodel_renderer_worker::NativeMeshTopology| { + let normal_z = |topology: &polymodel_renderer_worker::SceneMeshTopology| { let [a, b, c] = topology.indices[..3] else { unreachable!() }; @@ -709,8 +779,8 @@ fn identity_and_reflected_batches_preserve_outward_normal_and_material() { let [cx, cy, _] = topology.positions[c as usize]; (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) }; - let identity = native_oriented_mesh_topology(&geometry, false).unwrap(); - let reflected = native_oriented_mesh_topology(&geometry, true).unwrap(); + let identity = scene_oriented_mesh_topology(&geometry, true, true, false, false).unwrap(); + let reflected = scene_oriented_mesh_topology(&geometry, true, true, false, true).unwrap(); assert_eq!(identity.indices, vec![0, 1, 2]); assert_eq!(reflected.indices, vec![2, 1, 0]); assert!(normal_z(&identity) > 0.0); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 72e084a..5f6fa94 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -27,13 +27,13 @@ const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; const fixtureScript = ` import { createServer } from "node:http"; const did = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; - const primaryCid = "bafkreihcysctvh6hxc4gcx2tuspbmx5lv2vmejmm4uzjgdykbbhgjpop34"; - const childCid = "bafkreictdu4ddzzllw6sk2y36nokvr5uaeaed3gqz6fubrz5mcdi7kim6y"; - const companionCid = "bafkreib7fmkoirxb3ppe6ia2qg6od25ts2ugoimggz6kaofpkka4tftofa"; + const primaryCid = "bafkreiblomfprrld3bjvf5ktjw2trnjl2tbxg6rx5uvzaive4f35iob5ja"; + const childCid = "bafkreih5euurby4kkq76q5dlezn5qdleeqb2w4gm5zx2la6hl6o7pxporu"; + const companionCid = "bafkreie42luggp5uta6nylexcnrnpoimoa255trbl75pmp7v6bxnrdlrtu"; const blobs = new Map([ - [primaryCid, Buffer.from("0 FILE main.ldr\\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 companion.dat\\n0 FILE child.dat\\n0 NOFILE\\n0 FILE companion.dat\\n0 NOFILE\\n", "utf8")], - [childCid, Buffer.from("0 FILE child.dat\\n0 NOFILE\\n", "utf8")], - [companionCid, Buffer.from("0 FILE companion.dat\\n0 NOFILE\\n", "utf8")], + [primaryCid, Buffer.from("0 BFC CERTIFY CCW\\n1 16 -35 0 0 1 0 0 0 1 0 0 0 1 models/child.dat\\n1 16 35 0 0 0 0 -1 0 1 0 1 0 0 models/child.dat\\n1 16 0 0 35 1 0 0 0 1 0 0 0 1 models/companion.dat\\n", "utf8")], + [childCid, Buffer.from("0 BFC CERTIFY CCW\\n4 4 -20 0 -20 20 0 -20 20 0 20 -20 0 20\\n4 1 -20 0 -20 -20 30 -20 20 30 -20 20 0 -20\\n4 2 20 0 -20 20 30 -20 20 30 20 20 0 20\\n4 14 20 0 20 20 30 20 -20 30 20 -20 0 20\\n4 15 -20 0 20 -20 30 20 -20 30 -20 -20 0 -20\\n2 24 -20 30 -20 20 30 -20\\n2 24 20 30 -20 20 30 20\\n2 24 20 30 20 -20 30 20\\n2 24 -20 30 20 -20 30 -20\\n", "utf8")], + [companionCid, Buffer.from("0 BFC CERTIFY CCW\\n3 5 -25 0 -25 25 0 -25 0 40 0\\n3 5 25 0 -25 25 0 25 0 40 0\\n3 5 25 0 25 -25 0 25 0 40 0\\n3 5 -25 0 25 -25 0 -25 0 40 0\\n5 24 -25 0 -25 25 0 -25 0 40 0 0 -20 0\\n", "utf8")], ]); const server = createServer((request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); @@ -49,10 +49,7 @@ const fixtureScript = ` server.listen(${fixturePort}, "127.0.0.1"); process.once("SIGTERM", () => server.close(() => process.exit(0))); `; -const e2eFeatures = process.env.POLYMODEL_E2E_FEATURES ?? ""; -const dxFeatures = e2eFeatures.split(",").filter((f) => f && f !== "web").join(","); -const dxFeatureFlag = dxFeatures ? `--features ${dxFeatures}` : ""; -const fixtureCommand = `node --input-type=module -e ${shellQuote(fixtureScript)} & pds_pid=$!; trap 'kill "$pds_pid" 2>/dev/null || true' EXIT INT TERM; export DIOXUS_TARGET_DIR=${shellQuote(dxTargetDir)} CARGO_TARGET_DIR=${shellQuote(cargoTargetDir)} CARGO_INCREMENTAL=0 SQLX_OFFLINE=true DATABASE_URL=${shellQuote(e2eDatabaseUrl)} HYDRANT_DATABASE_PATH=${shellQuote(hydrantDatabasePath)} POLYMODEL_INDEXING=off POLYMODEL_SAMPLE_DATA=auto POLYMODEL_RESOLVER_BASE_URL=http://127.0.0.1:${fixturePort}; just serve-headless --port ${appPort} ${dxFeatureFlag}`; +const fixtureCommand = `node --input-type=module -e ${shellQuote(fixtureScript)} & pds_pid=$!; trap 'kill "$pds_pid" 2>/dev/null || true' EXIT INT TERM; export DIOXUS_TARGET_DIR=${shellQuote(dxTargetDir)} CARGO_TARGET_DIR=${shellQuote(cargoTargetDir)} CARGO_INCREMENTAL=0 SQLX_OFFLINE=true DATABASE_URL=${shellQuote(e2eDatabaseUrl)} HYDRANT_DATABASE_PATH=${shellQuote(hydrantDatabasePath)} POLYMODEL_INDEXING=off POLYMODEL_SAMPLE_DATA=auto POLYMODEL_RESOLVER_BASE_URL=http://127.0.0.1:${fixturePort}; dx run --port ${appPort}`; function findNixChromiumHeadlessShell(): string | undefined { const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH; if (!browsersPath || !existsSync(browsersPath)) return undefined; @@ -99,6 +96,7 @@ export default defineConfig({ cwd: repositoryRoot, url: `http://127.0.0.1:${appPort}`, reuseExistingServer: false, + stderr: "pipe", timeout: 600_000, }, }); diff --git a/e2e/tests/viewer.spec.ts b/e2e/tests/viewer.spec.ts index d377b62..273cd96 100644 --- a/e2e/tests/viewer.spec.ts +++ b/e2e/tests/viewer.spec.ts @@ -113,8 +113,8 @@ test('logged-out Thing detail acquires the compound MPD through the public plan expect(plan.primary).toBe('models/main.ldr'); expect(plan.descriptors.map((descriptor) => descriptor.key)).toEqual([ 'models/main.ldr', - 'mpd/child.dat', - 'mpd/companion.dat', + 'models/child.dat', + 'models/companion.dat', ]); expect(planResponse.request().postDataJSON()).toEqual({ project: 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.thing/parametric-enclosure', @@ -199,53 +199,3 @@ test('resize callback remains single after model transitions', async ({ page }) await expect.poll(currentResizeCount).toBe(afterTransition + 1); }); -test('renderer readback exposes current identity and non-clear pixels through the test global', async ({ page }) => { - await page.goto('/viewer/body-f-chest-v4'); - await expectViewerReady(page); - - // The test readback global is populated only when the renderer-test-readback - // feature is enabled (which the e2e-viewer recipe activates). Each accepted - // sample carries identity, viewport, DPR, clear colour, non-clear pixel count, - // primitive counts, and bounds — no source bytes. - await expect.poll(async () => { - const readbacks = await page.evaluate(() => (window as any).__POLYMODEL_TEST_READBACKS__ ?? []); - return readbacks.length; - }).toBeGreaterThan(0); - - const readbacks = await page.evaluate(() => (window as any).__POLYMODEL_TEST_READBACKS__ ?? []); - const sample = readbacks[0]; - expect(sample.non_clear_pixels).toBeGreaterThan(0); - expect(sample.viewport_width).toBeGreaterThan(0); - expect(sample.viewport_height).toBeGreaterThan(0); - expect(sample.device_pixel_ratio_milli).toBeGreaterThan(0); - expect(sample.primitive_counts.triangles).toBeGreaterThan(0); - expect(sample.bounds).toBeDefined(); - expect(sample.bounds.min).toBeDefined(); - expect(sample.bounds.max).toBeDefined(); - expect(sample.identity).toBeDefined(); - - // Switching models supersedes the current identity. After the new model is - // ready, new accepted readbacks must carry a different identity, and the - // stale identity must not appear in any sample accepted after the switch. - const staleIdentity = sample.identity; - const countBeforeSwitch = readbacks.length; - await page.getByRole('button', { name: 'Body F forearm 2x v9' }).click(); - await expectViewerReady(page); - - // Wait for at least one new readback after the model switch. - await expect.poll(async () => { - const all = await page.evaluate(() => (window as any).__POLYMODEL_TEST_READBACKS__ ?? []); - return all.length; - }).toBeGreaterThan(countBeforeSwitch); - - const afterSwitch = await page.evaluate(() => (window as any).__POLYMODEL_TEST_READBACKS__ ?? []); - const newSamples = afterSwitch.slice(countBeforeSwitch); - // Every sample accepted after the switch must have a non-stale identity. - for (const entry of newSamples) { - expect(entry.identity).not.toBe(staleIdentity); - } - // The latest sample must carry the new (current) identity. - const latest = afterSwitch[afterSwitch.length - 1]; - expect(latest.identity).not.toBe(staleIdentity); - expect(latest.non_clear_pixels).toBeGreaterThan(0); -}); diff --git a/justfile b/justfile index 2d5c603..837afb9 100644 --- a/justfile +++ b/justfile @@ -109,10 +109,6 @@ test-renderer-native: preview-ldraw *ARGS: @tools/in-dev-shell _preview-ldraw {{ ARGS }} -# Run the feature-gated browser viewer lane. -e2e-viewer: - @tools/in-dev-shell _e2e-viewer - # 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: @@ -257,11 +253,6 @@ _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 }} -_e2e-viewer: - RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo build -p polymodel-renderer-worker --bin renderer_worker --target wasm32-unknown-unknown --features renderer-test-readback - POLYMODEL_E2E_FEATURES='web,renderer-test-readback' cargo test -p polymodel --features server,renderer-test-readback --no-run - cd e2e && POLYMODEL_E2E_FEATURES='web,renderer-test-readback' npm test -- tests/viewer.spec.ts - _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 @@ -291,7 +282,7 @@ _require-nix: _ldraw-differential: _require-nix cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- differential -_test-all: _require-nix _fix _check _lint _test _test-server _test-renderer-ledger _test-renderer _test-ldraw-core _ldraw-wasm-check _test-renderer-native _e2e-viewer _ldraw-corpus _ldraw-fuzz-smoke _ldraw-differential +_test-all: _require-nix _fix _check _lint _test _test-server _test-renderer-ledger _test-renderer _test-ldraw-core _ldraw-wasm-check _test-renderer-native _ldraw-corpus _ldraw-fuzz-smoke _ldraw-differential _e2e: cd e2e && npm test @@ -300,15 +291,6 @@ _serve *ARGS: @if [ ! -f public/renderer_worker_bg.wasm ] || [ ! -f public/renderer_worker_loader.js ]; then echo "renderer worker not built — running just _build-renderer-worker"; just _build-renderer-worker; fi dx serve {{ ARGS }} -_serve-headless *ARGS: - @if [ ! -f public/renderer_worker_bg.wasm ] || [ ! -f public/renderer_worker_loader.js ]; then echo "renderer worker not built — running just _build-renderer-worker"; just _build-renderer-worker; fi - dx run --web --interactive false --open false {{ ARGS }} - -# Start the app in non-interactive headless mode (no TUI, no browser auto-open). -# Used by the e2e harness; normal `just serve` is unchanged. -serve-headless *ARGS: - @tools/in-dev-shell _serve-headless {{ ARGS }} - _build-renderer-worker: RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo build -p polymodel-renderer-worker --bin renderer_worker --target wasm32-unknown-unknown --profile worker-release wasm-bindgen target/wasm32-unknown-unknown/worker-release/renderer_worker.wasm --target web --out-dir public --no-typescript diff --git a/src/indexing/sample_data.rs b/src/indexing/sample_data.rs index 8407728..d9c083e 100644 --- a/src/indexing/sample_data.rs +++ b/src/indexing/sample_data.rs @@ -22,6 +22,13 @@ pub const SAMPLE_THING_URI: &str = const SAMPLE_TIME: &str = "2026-06-21T12:00:00.000Z"; const SAMPLE_TIME_MILLIS: i64 = 1_750_507_200_000; +const SAMPLE_CID: &str = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; +const LDRAW_ROOT_CID: &str = "bafkreiblomfprrld3bjvf5ktjw2trnjl2tbxg6rx5uvzaive4f35iob5ja"; +const LDRAW_CHILD_CID: &str = "bafkreih5euurby4kkq76q5dlezn5qdleeqb2w4gm5zx2la6hl6o7pxporu"; +const LDRAW_COMPANION_CID: &str = "bafkreie42luggp5uta6nylexcnrnpoimoa255trbl75pmp7v6bxnrdlrtu"; +const LDRAW_ROOT_BYTES: &[u8] = b"0 BFC CERTIFY CCW\n1 16 -35 0 0 1 0 0 0 1 0 0 0 1 models/child.dat\n1 16 35 0 0 0 0 -1 0 1 0 1 0 0 models/child.dat\n1 16 0 0 35 1 0 0 0 1 0 0 0 1 models/companion.dat\n"; +const LDRAW_CHILD_BYTES: &[u8] = b"0 BFC CERTIFY CCW\n4 4 -20 0 -20 20 0 -20 20 0 20 -20 0 20\n4 1 -20 0 -20 -20 30 -20 20 30 -20 20 0 -20\n4 2 20 0 -20 20 30 -20 20 30 20 20 0 20\n4 14 20 0 20 20 30 20 -20 30 20 -20 0 20\n4 15 -20 0 20 -20 30 20 -20 30 -20 -20 0 -20\n2 24 -20 30 -20 20 30 -20\n2 24 20 30 -20 20 30 20\n2 24 20 30 20 -20 30 20\n2 24 -20 30 20 -20 30 -20\n"; +const LDRAW_COMPANION_BYTES: &[u8] = b"0 BFC CERTIFY CCW\n3 5 -25 0 -25 25 0 -25 0 40 0\n3 5 25 0 -25 25 0 25 0 40 0\n3 5 25 0 25 -25 0 25 0 40 0\n3 5 -25 0 25 -25 0 -25 0 40 0\n5 24 -25 0 -25 25 0 -25 0 40 0 0 -20 0\n"; // Additional demo makers so the discovery home shows a populated feed with // mixed media states: one thing with a cover, two cover-less things that @@ -66,6 +73,7 @@ async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { for part in &parts { insert_part(&mut tx, part).await?; } + insert_ldraw_projection(&mut tx, &parts[0..3]).await?; insert_model( &mut tx, @@ -105,8 +113,8 @@ async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { "instructions": instructions, "license": "CC-BY-4.0", "tags": tags, - "cover": [image("bafkreiathingcover", "Rendered preview of a parametric electronics enclosure")], - "previews": [image("bafkreiathingpreview", "Exploded blueprint preview of the enclosure kit")], + "cover": [image(SAMPLE_CID, "Rendered preview of a parametric electronics enclosure")], + "previews": [image(SAMPLE_CID, "Exploded blueprint preview of the enclosure kit")], "models": model_uris.iter().map(|uri| strong_ref(uri)).collect::>(), "createdAt": SAMPLE_TIME }); @@ -121,7 +129,7 @@ async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { .bind(SAMPLE_DID) .bind(SAMPLE_THING_RKEY) .bind(SAMPLE_THING_URI) - .bind("bafyreipolymodelsamplething") + .bind(SAMPLE_CID) .bind("Parametric enclosure kit") .bind("A configurable electronics enclosure kit with three printable model variants and detailed part files.") .bind("CC-BY-4.0") @@ -195,7 +203,7 @@ async fn insert_identity( } async fn insert_profile(conn: &mut SqliteConnection) -> anyhow::Result<()> { - let avatar = typed_json::(blob("bafkreiariavatar", "image/jpeg", 2048))?; + let avatar = typed_json::(blob(SAMPLE_CID, "image/jpeg", 2048))?; let profile_record = typed_json::(json!({ "displayName": "Ari Chen", "description": "Maker of practical parametric fixtures and electronics housings.", @@ -235,8 +243,8 @@ async fn insert_model( "instructions": ["Review the ordered part list before printing.", "Print fit-critical clips slowly for best tolerances."], "license": "CC-BY-4.0", "tags": ["variant", "enclosure"], - "cover": [image("bafkreimodelcover", &format!("Preview of {name}"))], - "previews": [image("bafkreimodelpreview", &format!("Blueprint preview of {name}"))], + "cover": [image(SAMPLE_CID, &format!("Preview of {name}"))], + "previews": [image(SAMPLE_CID, &format!("Blueprint preview of {name}"))], "parts": parts.iter().map(|part| strong_ref(&part_uri(part.rkey))).collect::>(), "createdAt": SAMPLE_TIME }))?; @@ -248,7 +256,7 @@ async fn insert_model( .bind(SAMPLE_DID) .bind(rkey) .bind(&uri) - .bind(format!("bafyreimodel{rkey}")) + .bind(SAMPLE_CID) .bind(name) .bind(summary) .bind(SAMPLE_TIME_MILLIS) @@ -269,17 +277,27 @@ async fn insert_model( } async fn insert_part(conn: &mut SqliteConnection, part: &SamplePart) -> anyhow::Result<()> { - let file = file_manifest(part.file_size); + let is_ldraw_root = part.rkey == "part-01"; + let format = if is_ldraw_root { "LDraw" } else { "STL" }; + let file = if is_ldraw_root { + file_manifest_with( + "application/x-ldraw", + LDRAW_ROOT_BYTES.len() as i64, + LDRAW_ROOT_CID, + ) + } else { + file_manifest(part.file_size) + }; let file_json = typed_json::(file.clone())?; let record = typed_json::(json!({ "name": part.name, "file": file, - "format": "STL", + "format": format, "dimensions": { "x": part.dimensions.0, "y": part.dimensions.1, "z": part.dimensions.2, "unit": "mm" }, "units": "mm", "notes": part.notes, "printSettings": ["0.2 mm layer height", "15% gyroid infill", "No supports required unless noted"], - "previews": [image("bafkreipartpreview", &format!("Preview of {}", part.name))], + "previews": [image(SAMPLE_CID, &format!("Preview of {}", part.name))], "createdAt": SAMPLE_TIME }))?; @@ -290,9 +308,9 @@ async fn insert_part(conn: &mut SqliteConnection, part: &SamplePart) -> anyhow:: .bind(SAMPLE_DID) .bind(part.rkey) .bind(part_uri(part.rkey)) - .bind(format!("bafyreipart{}", part.rkey)) + .bind(SAMPLE_CID) .bind(part.name) - .bind("STL") + .bind(format) .bind(file_json) .bind(SAMPLE_TIME_MILLIS) .bind(SAMPLE_TIME_MILLIS) @@ -302,6 +320,86 @@ async fn insert_part(conn: &mut SqliteConnection, part: &SamplePart) -> anyhow:: Ok(()) } +async fn insert_ldraw_projection( + conn: &mut SqliteConnection, + parts: &[SamplePart], +) -> anyhow::Result<()> { + use sha2::Digest; + + let resources = [ + ( + &parts[0], + "models", + "models/main.ldr", + LDRAW_ROOT_BYTES, + LDRAW_ROOT_CID, + ), + ( + &parts[1], + "models", + "models/child.dat", + LDRAW_CHILD_BYTES, + LDRAW_CHILD_CID, + ), + ( + &parts[2], + "models", + "models/companion.dat", + LDRAW_COMPANION_BYTES, + LDRAW_COMPANION_CID, + ), + ]; + for (part, root, path, bytes, cid) in resources { + let uri = part_uri(part.rkey); + let digest = sha2::Sha256::digest(bytes).to_vec(); + let source_identity = crate::ldraw::source_identity_for( + &jacquard_common::types::string::AtUri::new_owned(&uri)?, + &[cid.to_owned()], + &digest, + bytes.len() as i64, + ); + sqlx::query("INSERT INTO ldraw_resources (resource_uri, owner_did, project_uri, record_cid, rkey, root, canonical_path, mime_type, byte_length, sha256, ordered_blob_cids, provenance, licence, notices_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + .bind(&uri).bind(SAMPLE_DID).bind(SAMPLE_THING_URI).bind(SAMPLE_CID) + .bind(part.rkey).bind(root).bind(path).bind("application/x-ldraw") + .bind(bytes.len() as i64).bind(&digest).bind(serde_json::to_string(&[cid])?) + .bind("sample-data").bind("CC-BY-4.0").bind("[]") + .bind(SAMPLE_TIME_MILLIS).bind(SAMPLE_TIME_MILLIS) + .execute(&mut *conn).await?; + sqlx::query("INSERT INTO ldraw_resource_memberships (resource_uri, project_uri, root, canonical_path, provenance) VALUES (?, ?, ?, ?, ?)") + .bind(&uri).bind(SAMPLE_THING_URI).bind(root).bind(path).bind("sample-data") + .execute(&mut *conn).await?; + sqlx::query("INSERT INTO ldraw_verification (resource_uri, source_identity, state, diagnostic, updated_at) VALUES (?, ?, 'verified', NULL, ?)") + .bind(&uri).bind(source_identity).bind(SAMPLE_TIME_MILLIS) + .execute(&mut *conn).await?; + } + + let root_uri = part_uri(parts[0].rkey); + for (ordinal, (part, path, bytes, cid)) in [ + ( + &parts[1], + "models/child.dat", + LDRAW_CHILD_BYTES, + LDRAW_CHILD_CID, + ), + ( + &parts[2], + "models/companion.dat", + LDRAW_COMPANION_BYTES, + LDRAW_COMPANION_CID, + ), + ] + .into_iter() + .enumerate() + { + sqlx::query("INSERT INTO ldraw_manifest_files (resource_uri, root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type, ordinal) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .bind(&root_uri).bind("models").bind(path).bind(part_uri(part.rkey)).bind(cid) + .bind(bytes.len() as i64).bind(sha2::Sha256::digest(bytes).to_vec()) + .bind("application/x-ldraw").bind(ordinal as i64) + .execute(&mut *conn).await?; + } + Ok(()) +} + #[derive(Clone, Copy)] struct SamplePart { rkey: &'static str, @@ -369,7 +467,7 @@ async fn insert_coverless_thing( .bind(did) .bind(rkey) .bind(&uri) - .bind(format!("bafyreithing{}", rkey.replace('-', ""))) + .bind(SAMPLE_CID) .bind(name) .bind(summary) .bind("CC-BY-4.0") @@ -405,7 +503,7 @@ fn thing_uri(did: &str, rkey: &str) -> String { } fn strong_ref(uri: &str) -> Value { - json!({ "uri": uri, "cid": "bafyreicid" }) + json!({ "uri": uri, "cid": SAMPLE_CID }) } fn image(cid: &str, alt: &str) -> Value { @@ -417,10 +515,14 @@ fn image(cid: &str, alt: &str) -> Value { } fn file_manifest(size: i64) -> Value { + file_manifest_with("model/stl", size, SAMPLE_CID) +} + +fn file_manifest_with(mime_type: &str, size: i64, cid: &str) -> Value { json!({ - "mimeType": "model/stl", + "mimeType": mime_type, "size": size, - "chunks": [{ "blob": blob("bafkreistlchunk", "model/stl", size), "offset": 0, "size": size }] + "chunks": [{ "blob": blob(cid, mime_type, size), "offset": 0, "size": size }] }) } @@ -467,6 +569,14 @@ mod tests { let state = Arc::new(AppState::new(pool, bootstrap)); let feed = views::feed_recent(&state, 10, None, None).await.unwrap(); + let feed_things = feed + .items + .iter() + .map(|item| item.thing.clone()) + .collect::>(); + let transport_bytes = dioxus::fullstack::Transportable::transport_to_bytes(&feed_things); + assert!(!transport_bytes.is_empty()); + // Three seeded things: the model-rich parametric kit plus two cover-less // demo things that exercise the missing-media placeholder path. assert_eq!(feed.items.len(), 3); diff --git a/src/viewer.rs b/src/viewer.rs index 75a2868..daf5194 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -15,10 +15,8 @@ #[cfg(target_arch = "wasm32")] use crate::client::PolymodelClient; use dioxus::prelude::*; -#[cfg(any(target_arch = "wasm32", feature = "renderer-test-readback"))] +#[cfg(target_arch = "wasm32")] use polymodel_renderer_protocol::LoadIdentity; -#[cfg(feature = "renderer-test-readback")] -use polymodel_renderer_protocol::RendererReadback; #[cfg(target_arch = "wasm32")] use polymodel_renderer_protocol::{ Capability, CommandEnvelope, CommandPayload, CompoundLoadPlan, EventPayload, HandshakeState, @@ -53,105 +51,6 @@ pub struct ViewerFallbackImage { pub alt: String, } -#[cfg(feature = "renderer-test-readback")] -pub fn accept_test_readback(current: LoadIdentity, sample: &RendererReadback) -> bool { - sample.identity == current -} - -/// Expose an accepted readback sample through `window.__POLYMODEL_TEST_READBACKS__` -/// as a plain JS object (identity, viewport, DPR, clear colour, non-clear pixels, -/// counts, bounds). No source bytes are exposed to the page. -#[cfg(feature = "renderer-test-readback")] -fn expose_test_readback(sample: &RendererReadback) { - #[cfg(target_arch = "wasm32")] - { - use wasm_bindgen::JsCast; - let Some(window) = web_sys::window() else { - return; - }; - let key = "__POLYMODEL_TEST_READBACKS__"; - let array = match js_sys::Reflect::get(&window, &key.into()) { - Ok(existing) if existing.is_array() => { - existing.dyn_into::().unwrap_or_default() - } - _ => js_sys::Array::new(), - }; - let entry = js_sys::Object::new(); - let _ = js_sys::Reflect::set( - &entry, - &"identity".into(), - &format!( - "{}-{}", - sample.identity.load_epoch, sample.identity.scene_generation - ) - .into(), - ); - let _ = js_sys::Reflect::set( - &entry, - &"viewport_width".into(), - &sample.viewport_width.into(), - ); - let _ = js_sys::Reflect::set( - &entry, - &"viewport_height".into(), - &sample.viewport_height.into(), - ); - let _ = js_sys::Reflect::set( - &entry, - &"device_pixel_ratio_milli".into(), - &sample.device_pixel_ratio_milli.into(), - ); - let clear = js_sys::Array::new(); - for byte in sample.clear_rgba.iter() { - clear.push(&(*byte).into()); - } - let _ = js_sys::Reflect::set(&entry, &"clear_rgba".into(), &clear); - let _ = js_sys::Reflect::set( - &entry, - &"non_clear_pixels".into(), - &sample.non_clear_pixels.into(), - ); - let counts = js_sys::Object::new(); - let _ = js_sys::Reflect::set( - &counts, - &"points".into(), - &sample.primitive_counts.points.into(), - ); - let _ = js_sys::Reflect::set( - &counts, - &"triangles".into(), - &sample.primitive_counts.triangles.into(), - ); - let _ = js_sys::Reflect::set( - &counts, - &"lines".into(), - &sample.primitive_counts.lines.into(), - ); - let _ = js_sys::Reflect::set( - &counts, - &"conditional_lines".into(), - &sample.primitive_counts.conditional_lines.into(), - ); - let _ = js_sys::Reflect::set(&entry, &"primitive_counts".into(), &counts); - let bounds = js_sys::Object::new(); - let min = js_sys::Array::new(); - let max = js_sys::Array::new(); - for axis in 0..3 { - min.push(&sample.bounds.min[axis].into()); - max.push(&sample.bounds.max[axis].into()); - } - let _ = js_sys::Reflect::set(&bounds, &"min".into(), &min); - let _ = js_sys::Reflect::set(&bounds, &"max".into(), &max); - let _ = js_sys::Reflect::set(&entry, &"bounds".into(), &bounds); - array.push(&entry); - let _ = js_sys::Reflect::set(&window, &key.into(), &array); - } - #[cfg(not(target_arch = "wasm32"))] - { - let _ = sample; - } -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum ViewerSourceFormat { Renderer(MeshFormat), @@ -1906,44 +1805,8 @@ impl WorkerBridgeState { status_msg.set(ViewerStatus::Cancelled(session)); } } - RendererEvent::CompoundResourcesReady { - namespace, - identity, - stats: _, - } => { - if namespace == RendererNamespace::Interactive - && reducer_msg.borrow().current_identity() == Some(identity) - { - compound_boundary_msg.set(true); - tracing::debug!( - "worker installed verified compound resources without crossing source bytes" - ); - } else { - tracing::debug!("discarded stale compound resources event"); - } - } RendererEvent::PreviewImageRendered { .. } | RendererEvent::PreviewImageError { .. } => {} - #[cfg(feature = "renderer-test-readback")] - RendererEvent::TestReadback(sample) => { - let current = reducer_msg.borrow().current_identity(); - if let Some(current) = current { - if accept_test_readback(current, &sample) { - tracing::debug!( - identity = ?sample.identity, - non_clear_pixels = sample.non_clear_pixels, - "accepted renderer test readback" - ); - expose_test_readback(&sample); - } else { - tracing::debug!( - sample_identity = ?sample.identity, - current_identity = ?current, - "discarded stale renderer test readback" - ); - } - } - } RendererEvent::Error { fatal, message } => { if fatal { restart_for_message(); @@ -2603,33 +2466,6 @@ mod tests { assert!(!renderer_err.should_have_session()); } - #[cfg(feature = "renderer-test-readback")] - #[test] - fn test_readback_acceptance_rejects_superseded_identity() { - let current = LoadIdentity::new(4, 2); - let mut sample = RendererReadback { - identity: current, - viewport_width: 1280, - viewport_height: 720, - device_pixel_ratio_milli: 1000, - clear_rgba: [199, 204, 214, 255], - non_clear_pixels: 12, - primitive_counts: polymodel_renderer_protocol::ReadbackPrimitiveCounts { - points: 0, - triangles: 3, - lines: 0, - conditional_lines: 0, - }, - bounds: polymodel_renderer_protocol::scene::SceneBounds { - min: [0.0, 0.0, 0.0], - max: [1.0, 1.0, 1.0], - }, - }; - assert!(accept_test_readback(current, &sample)); - sample.identity = LoadIdentity::new(4, 3); - assert!(!accept_test_readback(current, &sample)); - } - #[test] fn context_acquisitions_signal_starts_at_zero() { // The signal is initialized to 0 and set to 1 on ContextAcquired from the worker. -- 2.51.2