From 99e3e258e82805ed2f296fcbcd41223594c45d8a Mon Sep 17 00:00:00 2001 From: Orual Date: Thu, 6 Aug 2026 20:32:58 -0400 Subject: [PATCH] PM-79: repair LDraw renderer lifecycle and visibility Epic: PM-86 Task: PM-79 --- .../src/bin/ldraw_preview.rs | 51 +++- crates/polymodel-renderer-worker/src/lib.rs | 288 ++++++++++++++++-- .../polymodel-renderer-worker/src/worker.rs | 219 +++++++++---- .../tests/ldraw_preview.rs | 4 +- src/viewer.rs | 3 +- 5 files changed, 465 insertions(+), 100 deletions(-) diff --git a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs index 5f2134b..e47a156 100644 --- a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs @@ -9,16 +9,16 @@ use std::{ use polymodel_renderer_protocol::scene::Scene; #[cfg(not(target_arch = "wasm32"))] use polymodel_renderer_worker::{ - NativeLights, NativeRenderOptions, NativeStage, NativeTerminal, NativeTrace, PickRay, - 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, + NativeLights, NativeRenderOptions, NativeStage, NativeTerminal, NativeTrace, + build_scene_model_parts_for_view, 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"))] use three_d::{ BoundingBox, Camera, ClearState, ColorMaterial, Gm, InnerSpace, InstancedMesh, OrbitControl, - PhysicalMaterial, SquareMatrix, SurfaceSettings, Vector4, WindowedContext, pick, + PhysicalMaterial, SurfaceSettings, WindowedContext, pick, }; #[cfg(not(target_arch = "wasm32"))] @@ -275,7 +275,9 @@ fn run_visible_preview( }); let lights = NativeLights::new(&context); - let mut model: Option>> = None; + let mut static_model: Option>> = None; + let mut conditional_model: Option>> = None; + let mut last_view: Option<[f64; 3]> = None; let mut selection_bounds: Option> = None; let mut scene: Option = None; let mut pick_lookup = std::collections::HashMap::new(); @@ -311,8 +313,8 @@ fn run_visible_preview( } }; loop_trace.record(NativeStage::NativeModelBuildStarted); - match build_scene_model(&context, &prepared_scene) { - Ok(instanced) => { + match build_scene_model_parts_for_view(&context, &prepared_scene, [0.0, 0.0, 1.0]) { + Ok((static_instanced, conditional_instanced)) => { loop_trace.record(NativeStage::NativeModelBuildComplete); let fit = scene_camera_fit(bounds, width, height); let position = yaw_camera_position(fit.center, fit.position, yaw_degrees); @@ -343,7 +345,9 @@ fn run_visible_preview( fit.control_max, )); scene_bounds = Some(bounds); - model = Some(instanced); + static_model = Some(static_instanced); + conditional_model = Some(conditional_instanced); + last_view = Some([0.0, 0.0, 1.0]); loop_trace.record(NativeStage::PickIndexBuildStarted); match occurrence_gpu_ranges(&prepared_scene) { Ok(ranges) => { @@ -394,14 +398,35 @@ fn run_visible_preview( camera.set_perspective_projection(three_d::degrees(45.0), near, far); } } + if let (Some(active_scene), Some(active_camera)) = (scene.as_ref(), camera.as_ref()) { + let direction = active_camera.position() - active_camera.target(); + let view = [ + f64::from(direction.x), + f64::from(direction.y), + f64::from(direction.z), + ]; + let changed = last_view.is_none_or(|previous| { + let dot = previous[0] * view[0] + previous[1] * view[1] + previous[2] * view[2]; + dot < 0.9995 + }); + if changed { + if let Ok(model) = build_scene_model_parts_for_view(&context, active_scene, view) { + conditional_model = Some(model.1); + last_view = Some(view); + } + } + } let target = frame.screen(); let screen = target.clear(ClearState::color_and_depth(0.78, 0.80, 0.84, 1.0, 1.0)); - if let (Some(model), Some(camera)) = (model.as_mut(), camera.as_mut()) { + if let (Some(static_model), Some(camera)) = (static_model.as_mut(), camera.as_mut()) { if !rendered { loop_trace.record(NativeStage::BaseRenderStarted); } let light_refs = lights.as_refs(); - screen.render(&mut *camera, model, &light_refs); + screen.render(&mut *camera, static_model, &light_refs); + if let Some(conditional_model) = conditional_model.as_mut() { + screen.render(&mut *camera, conditional_model, &light_refs); + } if !rendered { loop_trace.record(NativeStage::BaseRenderComplete); } @@ -450,7 +475,7 @@ fn run_visible_preview( *control_flow = ControlFlow::Exit; } else if rendered && frame_number == 1 { loop_trace.record(NativeStage::FirstSwapComplete); - } else if model.is_some() + } else if static_model.is_some() && proof_deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { loop_trace.finish(NativeTerminal::Success); @@ -525,7 +550,7 @@ fn run_visible_preview( let result = (|| -> Result, String> { let (x, y) = cursor.ok_or("missing-cursor")?; let active_camera = camera.as_ref().ok_or("missing-camera")?; - let active_model = model.as_ref().ok_or("missing-model")?; + let active_model = static_model.as_ref().ok_or("missing-model")?; let viewport = active_camera.viewport(); let gpu_y = viewport.y as f64 + viewport.height as f64 - y; let hit = pick( diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index aa135bf..9b3da36 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -3,8 +3,6 @@ use polymodel_ldraw_core::{LdMeshSemantic, ModelSummary, OwnedParseResult}; #[cfg(not(target_arch = "wasm32"))] use polymodel_ldraw_core::{Materialization, NormalizedPath, RootId}; -#[cfg(test)] -use polymodel_renderer_protocol::scene::SceneProvenance; use polymodel_renderer_protocol::scene::{ GeometryId, MaterialId, NodeId, OccurrenceId, ProvenanceId, Scene, SceneBounds, SceneGeometry, SceneId, SceneMaterial, SceneOccurrence, SceneStats, @@ -710,6 +708,51 @@ fn transform_reflection(transform: [[f64; 4]; 4]) -> bool { determinant < 0.0 } +/// Convert a world-space camera direction to the local space of one occurrence. +/// Conditional-line classification is defined in the part's projected plane, so +/// translation is intentionally ignored and only the affine linear component is inverted. +pub fn conditional_line_view_for_transform( + transform: [[f64; 4]; 4], + world_view: [f64; 3], +) -> Option<[f64; 3]> { + let a = transform[0][0]; + let b = transform[1][0]; + let c = transform[2][0]; + let d = transform[0][1]; + let e = transform[1][1]; + let f = transform[2][1]; + let g = transform[0][2]; + let h = transform[1][2]; + let i = transform[2][2]; + let determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); + if !determinant.is_finite() || determinant.abs() <= f64::EPSILON { + return None; + } + let inverse = [ + [e * i - f * h, c * h - b * i, b * f - c * e], + [f * g - d * i, a * i - c * g, c * d - a * f], + [d * h - e * g, b * g - a * h, a * e - b * d], + ]; + let result = [ + (inverse[0][0] * world_view[0] + + inverse[0][1] * world_view[1] + + inverse[0][2] * world_view[2]) + / determinant, + (inverse[1][0] * world_view[0] + + inverse[1][1] * world_view[1] + + inverse[1][2] * world_view[2]) + / determinant, + (inverse[2][0] * world_view[0] + + inverse[2][1] * world_view[1] + + inverse[2][2] * world_view[2]) + / determinant, + ]; + result + .iter() + .all(|value| value.is_finite()) + .then_some(result) +} + fn scene_transform_to_mat4(matrix: [[f64; 4]; 4]) -> Mat4 { Mat4::new( matrix[0][0] as f32, @@ -1275,13 +1318,16 @@ pub fn scene_oriented_mesh_topology_for_view( view: [f64; 3], ) -> Result { let mut topology = scene_mesh_topology_for_view(geometry, view)?; - let reverse = bfc_inverted ^ !bfc_ccw ^ reflection; + // `bfc_inverted` is the authoritative effective winding bit produced by + // traversal. It already includes accumulated transform reflection; applying + // `reflection` here would cancel that parity for reflected occurrences. + let reverse = bfc_inverted ^ !bfc_ccw; if reverse { for triangle in topology.indices.chunks_exact_mut(3) { triangle.swap(0, 2); } } - let _ = bfc_cull; + let _ = (bfc_cull, reflection); Ok(topology) } @@ -1292,6 +1338,14 @@ pub fn scene_mesh_topology(geometry: &SceneGeometry) -> Result Result { + scene_mesh_topology_for_view_kind(geometry, view, true) +} + +fn scene_mesh_topology_for_view_kind( + geometry: &SceneGeometry, + view: [f64; 3], + include_conditional: bool, ) -> Result { let mut positions = geometry .points @@ -1350,10 +1404,35 @@ pub fn scene_mesh_topology_for_view( 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]]; + if !side_length.is_finite() || side_length <= f64::EPSILON { + // Pick the least-parallel cardinal axis, then cross it with the + // line. This handles view-parallel Y edges as well as arbitrary + // finite camera directions without a zero-length fallback. + let axis = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + .into_iter() + .min_by(|left, right| { + let left_dot = + (direction[0] * left[0] + direction[1] * left[1] + direction[2] * left[2]) + .abs(); + let right_dot = (direction[0] * right[0] + + direction[1] * right[1] + + direction[2] * right[2]) + .abs(); + left_dot + .partial_cmp(&right_dot) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or([1.0, 0.0, 0.0]); + side = [ + direction[1] * axis[2] - direction[2] * axis[1], + direction[2] * axis[0] - direction[0] * axis[2], + direction[0] * axis[1] - direction[1] * axis[0], + ]; } let side_length = (side[0] * side[0] + side[1] * side[1] + side[2] * side[2]).sqrt(); + if !side_length.is_finite() || side_length <= f64::EPSILON { + return Ok(()); + } let radius = 0.02_f64; let offset = [ (side[0] / side_length * radius) as f32, @@ -1382,8 +1461,9 @@ pub fn scene_mesh_topology_for_view( for line in geometry.lines.iter().copied() { add_line(line)?; } - for conditional in &geometry.conditional_lines { - let points = conditional + if include_conditional { + for conditional in &geometry.conditional_lines { + let points = conditional .iter() .map(|index| { geometry.points.get(*index as usize).copied().ok_or_else(|| { @@ -1395,10 +1475,11 @@ pub fn scene_mesh_topology_for_view( }) }) .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]])?; + 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 { @@ -1696,8 +1777,13 @@ pub fn scene_instance_batches(scene: &Scene) -> Result, )); } let material = occurrence.material.unwrap_or(default_material); + let has_conditional = scene + .geometries + .get(geometry_index) + .is_some_and(|geometry| !geometry.conditional_lines.is_empty()); if let Some(batch) = batches.iter_mut().find(|batch| { - batch.node == occurrence.node + !has_conditional + && batch.node == occurrence.node && batch.material == material && batch.bfc_cull == occurrence.bfc_cull && batch.bfc_ccw == occurrence.bfc_ccw @@ -1797,6 +1883,130 @@ pub fn build_scene_model( build_scene_model_for_view(context, scene, [0.0, 0.0, 1.0]) } +pub fn build_scene_model_parts_for_view( + context: &three_d::Context, + scene: &Scene, + view: [f64; 3], +) -> Result< + ( + Vec>, + Vec>, + ), + String, +> { + let static_model = build_scene_model_for_view_kind(context, scene, view, false)?; + let conditional_model = build_scene_model_for_view_kind(context, scene, view, true)?; + Ok((static_model, conditional_model)) +} + +pub fn build_conditional_model_for_view( + context: &three_d::Context, + scene: &Scene, + view: [f64; 3], +) -> Result>, String> { + build_scene_model_for_view_kind(context, scene, view, true) +} + +fn build_scene_model_for_view_kind( + context: &three_d::Context, + scene: &Scene, + view: [f64; 3], + conditional_only: bool, +) -> Result>, String> { + let batches = scene_instance_batches(scene)?; + let mut model = Vec::new(); + for batch in batches { + let geometry_index = batch + .node + .0 + .checked_sub(1) + .ok_or("native batch node zero is invalid")? as usize; + let geometry = scene.geometries.get(geometry_index).ok_or_else(|| { + format!( + "native batch node {} is outside the geometry table", + batch.node.0 + ) + })?; + let has_conditional = !geometry.conditional_lines.is_empty(); + if conditional_only && !has_conditional { + continue; + } + let conditional_view = if conditional_only { + batch + .transforms + .first() + .and_then(|transform| conditional_line_view_for_transform(*transform, view)) + .unwrap_or(view) + } else { + view + }; + let transformations = batch + .transforms + .into_iter() + .map(scene_transform_to_mat4) + .collect::>(); + if transformations.is_empty() { + continue; + } + let colour = scene + .materials + .iter() + .find(|material| material.id == batch.material) + .map(|material| material.colour) + .unwrap_or(16); + let mut material = PhysicalMaterial::new_opaque( + context, + &CpuMaterial { + albedo: ldraw_colour(colour), + roughness: 0.72, + metallic: 0.0, + ..Default::default() + }, + ); + material.render_states = RenderStates { + cull: if batch.bfc_cull { + Cull::Back + } else { + Cull::None + }, + ..Default::default() + }; + let topology = + scene_mesh_topology_for_view_kind(geometry, conditional_view, conditional_only)?; + if topology.indices.is_empty() { + continue; + } + let mut cpu_mesh = CpuMesh { + positions: Positions::F32( + topology + .positions + .iter() + .map(|point| three_d::vec3(point[0], point[1], point[2])) + .collect(), + ), + indices: Indices::U32(topology.indices), + ..Default::default() + }; + cpu_mesh.compute_normals(); + let object = Gm::new( + InstancedMesh::new( + context, + &Instances { + transformations, + ..Default::default() + }, + &cpu_mesh, + ), + material, + ); + model.push(object); + } + if model.is_empty() { + return Err("scene contains no renderable instanced geometry".to_owned()); + } + Ok(model) +} + pub fn build_scene_model_for_view( context: &three_d::Context, scene: &Scene, @@ -2062,17 +2272,22 @@ pub fn conditional_line_visible( endpoints[1][1] - endpoints[0][1], endpoints[1][2] - endpoints[0][2], ]; - let to_control = [ - controls[0][0] - endpoints[0][0], - controls[0][1] - endpoints[0][1], - controls[0][2] - endpoints[0][2], - ]; - let cross = [ - edge[1] * to_control[2] - edge[2] * to_control[1], - edge[2] * to_control[0] - edge[0] * to_control[2], - edge[0] * to_control[1] - edge[1] * to_control[0], - ]; - cross[0] * view[0] + cross[1] * view[1] + cross[2] * view[2] >= 0.0 + let projected_side = |point: [f64; 3]| { + let to_control = [ + point[0] - endpoints[0][0], + point[1] - endpoints[0][1], + point[2] - endpoints[0][2], + ]; + let cross = [ + edge[1] * to_control[2] - edge[2] * to_control[1], + edge[2] * to_control[0] - edge[0] * to_control[2], + edge[0] * to_control[1] - edge[1] * to_control[0], + ]; + cross[0] * view[0] + cross[1] * view[1] + cross[2] * view[2] + }; + let first = projected_side(controls[0]); + let second = projected_side(controls[1]); + first.is_finite() && second.is_finite() && (first >= 0.0) == (second >= 0.0) } #[cfg(test)] @@ -2377,8 +2592,8 @@ mod tests { ); assert_eq!( hidden.triangle_count(), - 2, - "ordinary line remains when conditional line is hidden" + 4, + "reversing the view preserves the two-control projected-side result" ); assert!(visible.positions.len() > geometry.points.len()); } @@ -2405,9 +2620,24 @@ mod tests { #[test] fn conditional_line_rule_is_deterministic() { let edge = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; - let controls = [[0.0, 1.0, 0.0], [1.0, 1.0, 0.0]]; - assert!(conditional_line_visible(edge, controls, [0.0, 0.0, 1.0])); - assert!(!conditional_line_visible(edge, controls, [0.0, 0.0, -1.0])); + let same_side_controls = [[0.0, 1.0, 0.0], [1.0, 1.0, 0.0]]; + assert!(conditional_line_visible( + edge, + same_side_controls, + [0.0, 0.0, 1.0] + )); + assert!(conditional_line_visible( + edge, + same_side_controls, + [0.0, 0.0, -1.0] + )); + + let opposite_side_controls = [[0.0, 1.0, 0.0], [1.0, -1.0, 0.0]]; + assert!(!conditional_line_visible( + edge, + opposite_side_controls, + [0.0, 0.0, 1.0] + )); } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 6917e2b..0dfe31a 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -5,6 +5,7 @@ //! and the postcard message protocol bridge to the Dioxus main thread. use std::cell::{Cell, RefCell}; +use std::collections::HashSet; use std::rc::Rc; use std::sync::Arc; @@ -63,8 +64,11 @@ struct InputState { struct RendererState { context: three_d::Context, model: Option>, - ldraw_model: Option>>, - ldraw_scene: Option, + ldraw_static_model: Option>>, + ldraw_conditional_model: + Option>>, + ldraw_scene: Option>, + ldraw_last_view: Option<[f64; 3]>, ambient_light: three_d::AmbientLight, key_light: three_d::DirectionalLight, fill_light: three_d::DirectionalLight, @@ -95,6 +99,35 @@ fn descriptor_root(root: &str) -> Result { } } +fn validate_compound_descriptors( + primary: &str, + descriptors: &[polymodel_renderer_protocol::WorkerFetchDescriptor], +) -> Result<(), String> { + let mut normalized = HashSet::with_capacity(descriptors.len()); + let mut primary_found = false; + for descriptor in descriptors { + descriptor + .validate() + .map_err(|error| format!("compound descriptor rejected: {error}"))?; + let path = NormalizedPath::new(&descriptor.path).map_err(|error| error.to_string())?; + let root = descriptor_root(&descriptor.root)?; + let identity = format!("{root:?}:{}", path.as_str()); + if !normalized.insert(identity) { + return Err(format!( + "duplicate normalized descriptor identity: {}", + descriptor.path + )); + } + if descriptor.path == primary { + primary_found = true; + } + } + if !primary_found { + return Err("compound primary descriptor missing".to_owned()); + } + Ok(()) +} + fn parse_compound_source( source: VerifiedCompoundSource, owner: polymodel_renderer_ledger::ReservationOwner, @@ -367,8 +400,10 @@ impl WorkerState { self.renderer = Some(RendererState { context, model: None, - ldraw_model: None, + ldraw_static_model: None, + ldraw_conditional_model: None, ldraw_scene: None, + ldraw_last_view: None, ambient_light, key_light, fill_light, @@ -1048,21 +1083,30 @@ 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() { + if let Some(scene) = renderer.ldraw_scene.as_ref() { 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 changed = renderer.ldraw_last_view.is_none_or(|previous| { + let dot = previous[0] * view[0] + previous[1] * view[1] + previous[2] * view[2]; + dot < 0.9995 + }); + if changed { + match polymodel_renderer_worker::build_conditional_model_for_view( + &renderer.context, + scene, + view, + ) { + Ok(model) => { + renderer.ldraw_conditional_model = Some(model); + renderer.ldraw_last_view = Some(view); + } + Err(error) => { + tracing::warn!(%error, "renderer worker: conditional scene update failed") + } } } } @@ -1094,7 +1138,10 @@ fn consume_input_and_render(st: &mut WorkerState) { if let Some(model) = &renderer.model { let _ = screen.render(&renderer.camera, model.iter(), &lights); } - if let Some(model) = &renderer.ldraw_model { + if let Some(model) = &renderer.ldraw_static_model { + let _ = screen.render(&renderer.camera, model, &lights); + } + if let Some(model) = &renderer.ldraw_conditional_model { let _ = screen.render(&renderer.camera, model, &lights); } } @@ -1324,7 +1371,10 @@ fn handle_load_mesh( if let Some((render_model, camera, control)) = st.pending_scene.take() { if let Some(renderer) = st.renderer.as_mut() { renderer.model = Some(render_model); - renderer.ldraw_model = None; + renderer.ldraw_static_model = None; + renderer.ldraw_conditional_model = None; + renderer.ldraw_scene = None; + renderer.ldraw_last_view = None; renderer.scene_bounds = None; renderer.camera = camera; renderer.control = control; @@ -1511,6 +1561,7 @@ async fn load_compound_source( .map_err(|error| format!("compound plan rejected: {error:?}"))?; plan.encoded_size() .map_err(|error| format!("compound plan encoding rejected: {error:?}"))?; + validate_compound_descriptors(&plan.primary, &plan.descriptors)?; compound_is_current(state, namespace, identity, abort.as_ref())?; let descriptors = plan.descriptors; let owner = load_owner(namespace, identity); @@ -1573,6 +1624,34 @@ fn finish_compound_source( } match result { Ok(source) => { + if compound_is_current(state, namespace, identity, None).is_err() { + return; + } + if namespace == RendererNamespace::Preview { + let detail = "compound preview must use the temporary RenderPreviewImage path" + .to_owned(); + let transition = st.preview_reducer.reduce(LoadEvent::LoadFailed { + namespace, + identity, + error: LoadError { + code: LoadErrorCode::Install, + detail: detail.clone(), + }, + candidate: None, + }); + if matches!( + transition.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Install, + detail, + }); + } + return; + } let parse_owner = load_owner(namespace, identity); let (stats, scene) = match parse_compound_source(source, parse_owner, &st.ledger) { Ok(result) => result, @@ -1605,11 +1684,23 @@ fn finish_compound_source( return; } }; - if namespace == RendererNamespace::Interactive { + let bounds = match scene.bounds { + Some(bounds) => bounds, + None => { + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Install, + detail: "LDraw scene has no bounds".to_owned(), + }); + return; + } + }; + let (candidate_models, fit) = if namespace == RendererNamespace::Interactive { let pixel_width = st.pixel_width; let pixel_height = st.pixel_height; - let renderer = match st.renderer.as_mut() { - Some(renderer) => renderer, + let context = match st.renderer.as_ref() { + Some(renderer) => renderer.context.clone(), None => { st.post_event(&RendererEvent::LoadFailed { namespace, @@ -1620,11 +1711,13 @@ fn finish_compound_source( return; } }; - let model = match polymodel_renderer_worker::build_scene_model( - &renderer.context, - &scene, + let scene_ref = std::rc::Rc::new(scene.clone()); + let models = match polymodel_renderer_worker::build_scene_model_parts_for_view( + &context, + &scene_ref, + [0.0, 0.0, 1.0], ) { - Ok(model) => model, + Ok(models) => models, Err(detail) => { st.post_event(&RendererEvent::LoadFailed { namespace, @@ -1635,25 +1728,54 @@ fn finish_compound_source( return; } }; - let bounds = match scene.bounds { - Some(bounds) => bounds, - None => { - st.post_event(&RendererEvent::LoadFailed { - namespace, - identity, - code: LoadErrorCode::Install, - detail: "LDraw scene has no bounds".to_owned(), - }); - return; - } - }; let fit = polymodel_renderer_worker::scene_camera_fit( bounds, pixel_width, pixel_height, ); + (Some((models, scene_ref)), Some(fit)) + } else { + (None, None) + }; + let reducer = match namespace { + RendererNamespace::Interactive => &mut st.load_reducer, + RendererNamespace::Preview => &mut st.preview_reducer, + }; + let loaded = reducer.reduce(LoadEvent::LoadSucceeded { + namespace, + identity, + candidate: None, + }); + if !matches!( + loaded.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + return; + } + if let Some((static_model, conditional_model, scene)) = + candidate_models.map(|((static_model, conditional_model), scene)| { + (static_model, conditional_model, scene) + }) + { + if st.load_reducer.current_identity() != Some(identity) || st.disposed { + return; + } + let installed = st.load_reducer.reduce(LoadEvent::InstallSucceeded { + namespace, + identity, + scene: None, + }); + if !matches!( + installed.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + return; + } + let fit = fit.expect("interactive candidate has camera fit"); + let viewport = three_d::Viewport::new_at_origo(st.pixel_width, st.pixel_height); + let renderer = st.renderer.as_mut().expect("context checked above"); renderer.camera = three_d::Camera::new_perspective( - three_d::Viewport::new_at_origo(pixel_width, pixel_height), + viewport, fit.position, fit.center, three_d::vec3(0.0, 0.0, 1.0), @@ -1665,30 +1787,17 @@ fn finish_compound_source( three_d::OrbitControl::new(fit.center, fit.control_min, fit.control_max); renderer.scene_bounds = Some(bounds); renderer.model = None; - renderer.ldraw_model = Some(model); + renderer.ldraw_static_model = Some(static_model); + renderer.ldraw_conditional_model = Some(conditional_model); + renderer.ldraw_last_view = Some([0.0, 0.0, 1.0]); renderer.ldraw_scene = Some(scene); st.needs_render = true; + st.post_event(&RendererEvent::InstallSucceeded { + namespace, + identity, + stats, + }); } - let reducer = match namespace { - RendererNamespace::Interactive => &mut st.load_reducer, - RendererNamespace::Preview => &mut st.preview_reducer, - }; - let transition = reducer.reduce(LoadEvent::LoadSucceeded { - namespace, - identity, - candidate: None, - }); - if !matches!( - transition.outcome, - polymodel_renderer_protocol::TransitionOutcome::Committed - ) { - return; - } - st.post_event(&RendererEvent::InstallSucceeded { - namespace, - identity, - stats, - }); } Err(detail) => { let reducer = match namespace { diff --git a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs index 03b4a58..2829026 100644 --- a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs @@ -780,7 +780,9 @@ fn identity_and_reflected_batches_preserve_outward_normal_and_material() { (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) }; let identity = scene_oriented_mesh_topology(&geometry, true, true, false, false).unwrap(); - let reflected = scene_oriented_mesh_topology(&geometry, true, true, false, true).unwrap(); + // Traversal has already folded the transform determinant into this bit; + // the topology helper must not apply `reflection` a second time. + let reflected = scene_oriented_mesh_topology(&geometry, true, true, true, 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/src/viewer.rs b/src/viewer.rs index daf5194..74173ed 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1540,7 +1540,7 @@ impl WorkerBridgeState { worker_session_id, worker_capabilities, cancelled_loads, - compound_boundary, + compound_boundary: _, canvas_mounted, canvas_mount_epoch, canvas_mount_requested, @@ -1595,7 +1595,6 @@ impl WorkerBridgeState { let mut worker_session_id_msg = worker_session_id; let mut worker_capabilities_msg = worker_capabilities; let mut cancelled_loads_msg = cancelled_loads; - let mut compound_boundary_msg = compound_boundary; let reducer_msg = self.load_reducer.clone(); let current_session_msg = self.current_session.clone(); let handshake_msg = self.handshake.clone(); -- knotmirror 0.0.1