use super::*;
use crate::camera::artifact_camera;
use crate::pipeline::scene_from_history_json;
fn cube_scene() -> RenderScene {
let request = serde_json::json!({
"expressions": "",
"configurator": {},
"features": [{
"type": "P.CU",
"inputParams": {
"id": "RenderCube",
"sizeX": 10.0, "sizeY": 6.0, "sizeZ": 4.0,
"transform": {
"position": [0.0, 0.0, 0.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE" }
},
"persistentData": {}
}]
})
.to_string();
let (scene, report) = scene_from_history_json(&request).unwrap();
assert!(report.feature_errors.is_empty());
scene
}
#[test]
fn png_encode_is_valid_and_deterministic() {
let rgb = vec![7u8; 4 * 3 * 3];
let a = encode_png(&rgb, 4, 3).unwrap();
let b = encode_png(&rgb, 4, 3).unwrap();
assert_eq!(a, b);
assert_eq!(&a[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
}
#[test]
fn cube_render_is_deterministic_and_shows_geometry() {
let (device, queue, adapter) = match create_headless_device() {
Ok(ok) => ok,
Err(error) => panic!("headless device required for render tests: {error}"),
};
eprintln!("[render test] adapter: {adapter}");
let mut core = RenderCore::new(device, queue, COLOR_FORMAT);
let scene = cube_scene();
let camera = artifact_camera(&scene.bbox(), 320, 240);
let first = core.render_to_png(&scene, &camera, 320, 240).unwrap();
let second = core.render_to_png(&scene, &camera, 320, 240).unwrap();
assert_eq!(first, second, "R33: identical input must give identical PNG bytes");
let decoder = png::Decoder::new(std::io::Cursor::new(&first));
let mut reader = decoder.read_info().unwrap();
let mut buf = vec![0u8; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut buf).unwrap();
assert_eq!(info.width, 320);
let bg = [0x10u8, 0x14, 0x18];
let non_bg = buf
.chunks_exact(3)
.filter(|px| *px != bg)
.count();
assert!(
non_bg > 320 * 240 / 20,
"expected a visible cube, found {non_bg} non-background pixels"
);
}
#[test]
fn emphasis_changes_rendered_pixels_and_scene_sync_reuses_buffers() {
let (device, queue, _) = create_headless_device().unwrap();
let mut core = RenderCore::new(device, queue, COLOR_FORMAT);
let mut scene = cube_scene();
let settings = RenderSettings::default();
let mut gpu = GpuScene::default();
core.sync_scene(&mut gpu, &scene, &settings, 1);
let solid_name = scene.solids().first().unwrap().name.clone();
let face_name = scene.solid(&solid_name).unwrap().faces[0].name.clone();
assert!(!face_name.is_empty());
let before = gpu.upload_count();
core.sync_scene(&mut gpu, &scene, &settings, 1);
assert_eq!(gpu.upload_count(), before, "reused solid must not re-upload");
scene.set_color_override(&solid_name, Some([1.0, 0.0, 0.0]));
core.sync_scene(&mut gpu, &scene, &settings, 1);
assert_eq!(gpu.upload_count(), before + 1, "changed solid re-uploads once");
let camera = artifact_camera(&scene.bbox(), 160, 120);
let mut emphasis = Emphasis::default();
let render = |core: &mut RenderCore, gpu: &mut GpuScene, emphasis: &Emphasis| {
let resolve = core.device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d { width: 160, height: 120, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: COLOR_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = resolve.create_view(&Default::default());
let params = FrameParams {
camera: &camera,
width: 160,
height: 120,
dpr: 1.0,
settings: &RenderSettings::default(),
emphasis,
world_per_pixel: 0.05,
overlay: None,
};
core.render_to_view(gpu, &scene, ¶ms, &view);
core.device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
};
render(&mut core, &mut gpu, &emphasis);
emphasis
.apply_json(&format!(
r#"{{"selected": {{"faces": ["{}"]}}}}"#,
face_name.replace('"', "\\\"")
))
.unwrap();
render(&mut core, &mut gpu, &emphasis);
let boundary = gpu.solids.get(&solid_name).unwrap().boundary.as_ref().unwrap();
assert!(boundary.count > 0, "selected face produced boundary outline segments");
}
#[test]
fn hidden_faces_change_rendered_pixels_through_the_gpu_path() {
use crate::visibility::EntityKind;
let (device, queue, _) = create_headless_device().unwrap();
let mut core = RenderCore::new(device, queue, COLOR_FORMAT);
let mut scene = cube_scene();
let camera = artifact_camera(&scene.bbox(), 200, 160);
let name = scene.solids()[0].name.clone();
let bg = [0x10u8, 0x14, 0x18];
let non_bg = |png: &[u8]| -> usize {
let decoder = png::Decoder::new(std::io::Cursor::new(png));
let mut reader = decoder.read_info().unwrap();
let mut buf = vec![0u8; reader.output_buffer_size().unwrap()];
reader.next_frame(&mut buf).unwrap();
buf.chunks_exact(3).filter(|px| *px != bg).count()
};
let render = |core: &mut RenderCore, scene: &RenderScene| {
non_bg(&core.render_to_png(scene, &camera, 200, 160).unwrap())
};
let all = render(&mut core, &scene);
assert!(all > 0, "the whole cube draws some filled pixels");
assert!(scene.set_entity_visible(&name, EntityKind::Face, 0, false));
let one_hidden = render(&mut core, &scene);
assert!(one_hidden <= all, "hiding a face cannot add pixels ({one_hidden} <= {all})");
assert!(scene.set_group_visible(&name, EntityKind::Face, true)); assert!(scene.set_group_visible(&name, EntityKind::Face, false));
let no_faces = render(&mut core, &scene);
assert!(no_faces > 0, "edges still render when only faces are hidden");
assert!(
no_faces * 2 < all,
"hiding every face must drop most filled pixels ({no_faces} vs {all})"
);
assert!(scene.set_group_visible(&name, EntityKind::Face, true));
assert_eq!(render(&mut core, &scene), all, "re-showing faces restores the frame");
}
#[test]
fn engine_state_app_loop_hides_faces_shaded() {
run_app_loop_face_hide(false);
}
#[test]
fn engine_state_app_loop_hides_faces_wireframe() {
run_app_loop_face_hide(true);
}
fn run_app_loop_face_hide(wireframe: bool) {
use crate::engine_state::EngineState;
use crate::visibility::EntityKind;
let cube_history = serde_json::json!({
"expressions": "",
"configurator": {},
"features": [{
"type": "P.CU",
"inputParams": {
"id": "Box",
"sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
"transform": {
"position": [0.0, 0.0, 0.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE" }
},
"persistentData": {}
}]
})
.to_string();
let (device, queue, _) = create_headless_device().unwrap();
let mut core = RenderCore::new(device, queue, COLOR_FORMAT);
let mut state = EngineState::new();
state.set_history_json(&cube_history).unwrap();
state.settings.wireframe = wireframe;
let (w, h) = (200u32, 160u32);
let camera = artifact_camera(&state.scene.bbox(), w, h);
let mut gpu = GpuScene::default();
let mut render = |core: &mut RenderCore, state: &EngineState, gpu: &mut GpuScene| -> usize {
core.sync_scene(gpu, &state.scene, &state.settings, state.settings_generation);
let resolve = core.device.create_texture(&wgpu::TextureDescriptor {
label: Some("test resolve"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: COLOR_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = resolve.create_view(&Default::default());
let params = FrameParams {
camera: &camera,
width: w,
height: h,
dpr: 1.0,
settings: &state.settings,
emphasis: &state.emphasis,
world_per_pixel: 0.05,
overlay: None,
};
core.render_to_view(gpu, &state.scene, ¶ms, &view);
let bpr = (w * 4).div_ceil(256) * 256;
let readback = core.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("test readback"),
size: bpr as u64 * h as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = core
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &resolve,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bpr),
rows_per_image: None,
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
core.queue.submit([enc.finish()]);
let slice = readback.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
core.device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let bg = [data[0], data[1], data[2]];
let mut non_bg = 0usize;
for row in 0..h {
let start = (row * bpr) as usize;
for col in 0..w as usize {
let px = start + col * 4;
if data[px..px + 3] != bg[..] {
non_bg += 1;
}
}
}
drop(data);
readback.unmap();
non_bg
};
let all = render(&mut core, &state, &mut gpu);
assert!(all > 0, "the cube draws filled pixels (wireframe={wireframe})");
assert!(state.set_group_visible("Box", EntityKind::Face, false));
let hidden = render(&mut core, &state, &mut gpu);
assert!(
hidden < all,
"hiding every face must drop filled pixels (wireframe={wireframe}): {hidden} vs {all}"
);
if !wireframe {
assert!(
hidden * 2 < all,
"shaded: hiding every face must drop MOST filled pixels ({hidden} vs {all})"
);
}
assert!(state.set_group_visible("Box", EntityKind::Face, true));
let shown = render(&mut core, &state, &mut gpu);
assert!(
shown > all / 2,
"re-showing faces restores pixels (wireframe={wireframe}): {shown} vs {all}"
);
}
#[test]
fn empty_scene_renders_background_frame() {
let (device, queue, _) = create_headless_device().unwrap();
let mut core = RenderCore::new(device, queue, COLOR_FORMAT);
let scene = RenderScene::new();
let camera = artifact_camera(&scene.bbox(), 64, 48);
let png = core.render_to_png(&scene, &camera, 64, 48).unwrap();
assert!(!png.is_empty());
}
#[test]
fn overlay_widgets_render_without_validation_errors() {
use crate::view::ViewCamera;
use crate::widgets::{gizmo_camera, WidgetRegistry};
let (device, queue, _) = create_headless_device().unwrap();
let mut core = RenderCore::new(device, queue, COLOR_FORMAT);
let scene = cube_scene();
let mut gpu = core.upload_scene_with(&scene, &RenderSettings::default());
let mut view = ViewCamera::default();
view.width = 240.0;
view.height = 180.0;
view.zoom_to_fit(&scene.bbox(), 1.2);
let mut widgets = WidgetRegistry::new();
widgets
.set_datums_json(
r#"{"planes":[{"name":"P1","origin":[0,0,0],"x":[1,0,0],"y":[0,1,0],"size":8.0}],
"axes":[{"name":"A1","point":[-4,0,0],"direction":[1,0,0],"length":8.0}]}"#,
)
.unwrap();
widgets
.set_transform_json(r#"{"origin":[0,0,0],"x":[1,0,0],"y":[0,1,0],"z":[0,0,1]}"#)
.unwrap();
widgets
.set_dimensions_json(
r#"[{"id":"d1","type":"linear","a":[-3,0,0],"b":[3,0,0],"offsetDir":[0,-1,0],"offset":2.0}]"#,
)
.unwrap();
widgets.set_viewcube_enabled(true);
let overlay = widgets.build_overlay(&gizmo_camera(&view));
assert!(!overlay.is_empty());
let resolve = core.device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d { width: 240, height: 180, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: COLOR_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view_tex = resolve.create_view(&Default::default());
let camera = view.resolve();
let params = FrameParams {
camera: &camera,
width: 240,
height: 180,
dpr: 2.0,
settings: &RenderSettings::default(),
emphasis: &Emphasis::default(),
world_per_pixel: view.world_per_pixel(),
overlay: Some(&overlay),
};
core.render_to_view(&mut gpu, &scene, ¶ms, &view_tex);
core.device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
}