diff --git a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs index 30657a4..3674690 100644 --- a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs @@ -12,12 +12,11 @@ use polymodel_renderer_worker::{ NativeCaptureMetadata, NativeLights, NativeSelectionSlots, NativeStage, NativeTerminal, NativeTrace, PickRay, RenderStyle, ScenePicker, SceneRenderPlan, SceneVariantBinding, SelectionFallback, SelectionInteraction, SelectionPointerAction, - build_conditional_edge_models_for_view, - build_scene_model_parts_for_style, build_selected_conditional_edge_models_for_view, - build_selected_edge_models_for_view, camera_space_clip_planes, diagnostic_edge_model_slot, - diagnostic_edge_models, emit_lighting_diagnostic, logical_part_selection, named_camera_case, - pm90_fixture_hash, prepare_preview_scene, scene_camera_fit, try_native_png_bytes, - update_edge_model_culling_bounds, yaw_camera_position, + build_conditional_edge_models_for_view, build_scene_model_parts_for_style, + build_selected_conditional_edge_models_for_view, build_selected_edge_models_for_view, + camera_space_clip_planes, diagnostic_edge_model_slot, diagnostic_edge_models, + emit_lighting_diagnostic, named_camera_case, pm90_fixture_hash, prepare_preview_scene, + scene_camera_fit, try_native_png_bytes, update_edge_model_culling_bounds, yaw_camera_position, }; #[cfg(not(target_arch = "wasm32"))] @@ -693,16 +692,27 @@ fn run_visible_preview( selection_slots.live_renderable_count(), ); if let Some(bounds) = scene_bounds { - let (near, far, min_depth, max_depth) = camera_space_clip_planes( + let projection = match camera_space_clip_planes( bounds, camera.position(), camera.target(), - ); + ) { + Ok(projection) => projection, + Err(error) => { + loop_trace.finish(NativeTerminal::Failure(error.to_string())); + *control_flow = ControlFlow::Exit; + return; + } + }; eprintln!( - "[ldraw-runtime] frame={} camera position={:?} target={:?} clip_near={near:.6} clip_far={far:.6} depth_interval=[{min_depth:.6},{max_depth:.6}] fov=45 viewport={}x{}", + "[ldraw-runtime] frame={} camera position={:?} target={:?} clip_near={:.6} clip_far={:.6} depth_interval=[{:.6},{:.6}] fov=45 viewport={}x{}", frame_number + 1, camera.position(), camera.target(), + projection.near, + projection.far, + projection.min_depth, + projection.max_depth, camera.viewport().width, camera.viewport().height, ); diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index 28430f5..acd7694 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -3468,6 +3468,30 @@ fn compact_segment_aabb( } } +fn validate_compact_segment_cardinality( + segment_count: u64, + control_count: Option, + transform_count: u64, + local_view_count: Option, +) -> Result<(u64, u32), String> { + let vertex_count = segment_count + .checked_mul(6) + .ok_or_else(|| "compact segment vertex count overflow".to_owned())?; + if let Some(control_count) = control_count + && control_count != segment_count + { + return Err("compact segment controls must match segment count".to_owned()); + } + if let Some(local_view_count) = local_view_count + && local_view_count != transform_count + { + return Err("compact segment local views must match transform count".to_owned()); + } + let instance_count = u32::try_from(transform_count) + .map_err(|_| "compact segment instance count exceeds GPU range".to_owned())?; + Ok((vertex_count, instance_count)) +} + impl CompactSegmentGeometry { fn new( context: &three_d::Context, @@ -3477,11 +3501,29 @@ impl CompactSegmentGeometry { local_views: Option<&[[f32; 3]]>, ribbon_width: f32, ) -> Result { - let mut endpoints = Vec::with_capacity(segments.len() * 6); - let mut paired_endpoints = Vec::with_capacity(segments.len() * 6); - let mut control1s = Vec::with_capacity(segments.len() * 6); - let mut control2s = Vec::with_capacity(segments.len() * 6); - let mut sides = Vec::with_capacity(segments.len() * 6); + let (vertex_count, instance_count) = validate_compact_segment_cardinality( + u64::try_from(segments.len()) + .map_err(|_| "compact segment count exceeds logical range".to_owned())?, + controls + .map(<[_]>::len) + .map(u64::try_from) + .transpose() + .map_err(|_| "compact segment control count exceeds logical range".to_owned())?, + u64::try_from(transforms.len()) + .map_err(|_| "compact segment transform count exceeds logical range".to_owned())?, + local_views + .map(<[_]>::len) + .map(u64::try_from) + .transpose() + .map_err(|_| "compact segment local-view count exceeds logical range".to_owned())?, + )?; + let vertex_count = usize::try_from(vertex_count) + .map_err(|_| "compact segment vertex count exceeds addressable memory".to_owned())?; + let mut endpoints = Vec::with_capacity(vertex_count); + let mut paired_endpoints = Vec::with_capacity(vertex_count); + let mut control1s = Vec::with_capacity(vertex_count); + let mut control2s = Vec::with_capacity(vertex_count); + let mut sides = Vec::with_capacity(vertex_count); for segment in segments { let a = three_d::vec3(segment[0][0], segment[0][1], segment[0][2]); let b = three_d::vec3(segment[1][0], segment[1][1], segment[1][2]); @@ -3534,7 +3576,7 @@ impl CompactSegmentGeometry { .collect::>(), ) }), - instance_count: transforms.len() as u32, + instance_count, base_aabb, aabb: base_aabb, }) @@ -4802,7 +4844,7 @@ mod tests { (b_ndc[1] - a_ndc[1]) * 0.5 * viewport[1], ]; let length = tangent[0].hypot(tangent[1]); - if !(length > 0.0) { + if length <= 0.0 || !length.is_finite() { return None; } let normal = [-tangent[1] / length, tangent[0] / length]; @@ -5030,6 +5072,31 @@ mod tests { )); } + #[test] + fn compact_segment_cardinality_rejects_every_gpu_boundary_mismatch() { + assert_eq!( + validate_compact_segment_cardinality(2, Some(2), 3, Some(3)), + Ok((12, 3)) + ); + assert_eq!( + validate_compact_segment_cardinality(u64::MAX / 6 + 1, None, 1, None).unwrap_err(), + "compact segment vertex count overflow" + ); + assert_eq!( + validate_compact_segment_cardinality(2, Some(1), 1, None).unwrap_err(), + "compact segment controls must match segment count" + ); + assert_eq!( + validate_compact_segment_cardinality(1, None, 2, Some(1)).unwrap_err(), + "compact segment local views must match transform count" + ); + assert_eq!( + validate_compact_segment_cardinality(1, None, u64::from(u32::MAX) + 1, None) + .unwrap_err(), + "compact segment instance count exceeds GPU range" + ); + } + #[test] fn compact_segment_aabb_contains_every_transformed_instance_endpoint() { let endpoints = [ @@ -5981,18 +6048,22 @@ mod tests { fn selected_production_inputs_isolate_repeated_logical_occurrences_and_rc_cache_identity() { let mut scene = picker_scene(); scene.geometries[0].lines = vec![[0, 1]]; - scene.occurrences[0].logical_part_occurrence_id = Some("shared-part".into()); - scene.occurrences[1].logical_part_occurrence_id = Some("shared-part".into()); - scene.occurrences[0].canonical_occurrence_id = "shared/+12".into(); - scene.occurrences[1].canonical_occurrence_id = "shared/-12".into(); + scene.occurrences[0].logical_part_occurrence_id = Some("instance-a".into()); + scene.occurrences[1].logical_part_occurrence_id = Some("instance-b".into()); + scene.occurrences[0].canonical_occurrence_id = "root/instance-a".into(); + scene.occurrences[1].canonical_occurrence_id = "root/instance-b".into(); scene.occurrences[0].transform[3][0] = 12.0; scene.occurrences[1].transform[3][0] = -12.0; let plan = scene_render_plan(&scene).expect("render plan"); let picker = ScenePicker::new(&scene, plan.clone()).expect("picker"); - let selection = picker.selection(0).expect("logical selection"); - assert_eq!(selection.occurrence_indices, vec![0, 1]); - assert_eq!(selection.transforms.len(), 2); - assert_eq!(selection.canonical_occurrence_id, "shared/+12"); + let first = picker.selection(0).expect("first logical selection"); + let second = picker.selection(1).expect("second logical selection"); + assert_eq!(first.occurrence_indices, vec![0]); + assert_eq!(second.occurrence_indices, vec![1]); + assert_eq!(first.canonical_occurrence_id, "root/instance-a"); + assert_eq!(second.canonical_occurrence_id, "root/instance-b"); + assert_eq!(first.transforms[0][3][0], 12.0); + assert_eq!(second.transforms[0][3][0], -12.0); assert_eq!( picker.planned_occurrences().as_ptr(), plan.planned_occurrences.as_ptr() @@ -6000,10 +6071,22 @@ mod tests { assert_eq!(picker.edge_sources().as_ptr(), plan.edge_sources.as_ptr()); assert_eq!(picker.topologies.as_ptr(), plan.topologies.as_ptr()); - let (selected, _) = compact_edge_inputs_for_membership(&plan, [0.0, 0.0, 1.0], Some(&[1])) - .expect("one selected subtree occurrence"); - assert_eq!(selected.batches[0].transforms.len(), 1); - assert_eq!(selected.batches[0].transforms[0].w.x, -12.0); + let (first_edges, _) = compact_edge_inputs_for_membership( + &plan, + [0.0, 0.0, 1.0], + Some(&first.occurrence_indices), + ) + .expect("first selected subtree"); + let (second_edges, _) = compact_edge_inputs_for_membership( + &plan, + [0.0, 0.0, 1.0], + Some(&second.occurrence_indices), + ) + .expect("second selected subtree"); + assert_eq!(first_edges.batches[0].transforms.len(), 1); + assert_eq!(second_edges.batches[0].transforms.len(), 1); + assert_eq!(first_edges.batches[0].transforms[0].w.x, 12.0); + assert_eq!(second_edges.batches[0].transforms[0].w.x, -12.0); } #[cfg(not(target_arch = "wasm32"))] @@ -6069,14 +6152,8 @@ mod tests { let selected = compact_segment_material(4, true); assert_ne!(base.color, selected.color); assert_ne!(base.material_type(), selected.material_type()); - assert_eq!( - compact_segment_vertex_shader(BASE_RIBBON_WIDTH_PIXELS).contains("1.5000000"), - true - ); - assert_eq!( - compact_segment_vertex_shader(SELECTED_RIBBON_WIDTH_PIXELS).contains("3.0000000"), - true - ); + assert!(compact_segment_vertex_shader(BASE_RIBBON_WIDTH_PIXELS).contains("1.5000000")); + assert!(compact_segment_vertex_shader(SELECTED_RIBBON_WIDTH_PIXELS).contains("3.0000000")); } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs index b0eaff8..b71dd6a 100644 --- a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs @@ -574,11 +574,11 @@ fn ldraw_preview_uses_pinned_corpus_and_scene_stats() { "polymodel-pm90-native-preview-{}-{nonce}", std::process::id() )); - let harness = env!("CARGO_BIN_EXE_ldraw_preview"); + let preview = env!("CARGO_BIN_EXE_ldraw_preview"); 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) + let result = Command::new(preview) .arg(&input) .arg("--library-root") .arg(std::env::var_os("LDRAWDIR").expect("LDRAWDIR must be set")) @@ -612,9 +612,13 @@ fn ldraw_preview_uses_pinned_corpus_and_scene_stats() { String::from_utf8_lossy(&result.stderr) ); for marker in [ + "preparation-stage=closure", "viewport requested=1000x600 actual=", "camera-fit actual=", "readback label=frame-1", + "stage=BaseRenderComplete", + "stage=Rendered", + "stage=FirstSwapComplete", ] { assert!( evidence.contains(marker), diff --git a/justfile b/justfile index 0c07d67..0565383 100644 --- a/justfile +++ b/justfile @@ -245,6 +245,14 @@ _test-renderer-native: readback_prefix="$PWD/target/ldraw-preview/persistent"; \ rm -f "$preview_log" "$test_log" "$readback_prefix"-*.png; \ trap 'status=$?; kill $xvfb_pid 2>/dev/null || true; echo "[native-test] Xvfb stderr:"; tail -80 /tmp/polymodel-xvfb.log; echo "[native-test] preview log:"; tail -160 "$preview_log" 2>/dev/null || true; echo "[native-test] test log:"; tail -160 "$test_log" 2>/dev/null || true; exit $status' EXIT; \ + ready=0; \ + for attempt in $(seq 1 100); do \ + kill -0 "$xvfb_pid" 2>/dev/null || { echo "[native-test] Xvfb exited before display :99 became ready" >&2; exit 1; }; \ + if xset -display :99 q >/dev/null 2>&1; then ready=1; break; fi; \ + sleep 0.1; \ + done; \ + test "$ready" -eq 1 || { echo "[native-test] timed out waiting for display :99" >&2; exit 1; }; \ + echo "[native-test] display-ready=:99"; \ echo "[native-test] preview-start"; \ set +e; DISPLAY=:99 WINIT_UNIX_BACKEND=x11 WAYLAND_DISPLAY= XDG_RUNTIME_DIR= LIBGL_ALWAYS_SOFTWARE=1 LDRAWDIR="$LDRAWDIR" timeout --signal=TERM --kill-after=5s 180s cargo run -p polymodel-renderer-worker --bin ldraw_preview -- --width 2560 --height 1440 --camera-case identity_front --scene-variant identity --proof-ms 1500 --readback-prefix "$readback_prefix" >"$preview_log" 2>&1; status=$?; set -e; cat "$preview_log"; test "$status" -eq 0; \ test -n "$(find "$PWD/target/ldraw-preview" -maxdepth 1 -name 'persistent-*.png' -type f -print -quit)"; \