use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::Instant;
use scena::{
AnimationError, AssetError, Backend, BuildError, ChangeKind, Color, DebugOverlay, GeometryDesc,
ImportError, InstantiateError, LookupError, MaterialDesc, NotPreparedReason, PerspectiveCamera,
PrepareError, Primitive, RenderError, Renderer, Scene, Transform, Vec3,
};
fn root() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR"))
}
#[test]
fn m5_debug_overlay_api_is_public_and_requires_prepare_after_change() {
let mut scene = Scene::new();
let camera = scene
.add_perspective_camera(
scene.root(),
PerspectiveCamera::default(),
Transform::default(),
)
.expect("camera inserts");
scene
.set_active_camera(camera)
.expect("camera can become active");
let mut renderer = Renderer::headless(8, 8).expect("headless renderer builds");
assert_eq!(renderer.debug_overlay(), DebugOverlay::None);
renderer.set_debug(DebugOverlay::Wireframe);
assert_eq!(renderer.debug_overlay(), DebugOverlay::Wireframe);
renderer.set_debug_overlay(DebugOverlay::BoundingBoxes);
assert_eq!(renderer.debug_overlay(), DebugOverlay::BoundingBoxes);
renderer.prepare(&mut scene).expect("scene prepares");
renderer
.render_active(&scene)
.expect("first render succeeds");
renderer.set_debug(DebugOverlay::Normals);
let error = renderer
.render_active(&scene)
.expect_err("debug overlay change requires prepare");
assert!(matches!(
error,
RenderError::NotPrepared {
reason: NotPreparedReason::RendererChanged {
change: ChangeKind::DebugOverlay,
..
}
}
));
}
#[test]
fn m5_release_surface_files_and_examples_are_present() {
let required_files = [
"CHANGELOG.md",
"LICENSE-MIT",
"LICENSE-APACHE",
"docs/api.md",
"docs/release-notes/v1.0.1.md",
"docs/release-notes/v1.0.2.md",
"docs/release-notes/v1.1.0.md",
"docs/release-notes/v1.3.0.md",
"examples/primitive_shapes.rs",
"examples/easy_model_viewer.rs",
"examples/glb_model_viewer.rs",
"examples/camera_framing.rs",
"examples/connector_auto_framing.rs",
"examples/picking_selection_hover.rs",
"examples/instancing.rs",
"examples/labels_helpers.rs",
"examples/animation.rs",
"examples/native_window.rs",
"examples/browser_canvas.rs",
"examples/headless_ci.rs",
"examples/industrial_static_scene.rs",
"examples/industrial_connector_assembly.rs",
"examples/coordinate_connector_repair.rs",
];
for rel in required_files {
assert!(root().join(rel).is_file(), "missing release file {rel}");
}
}
#[test]
fn m5_package_metadata_is_ready_for_dry_run() {
let manifest = fs::read_to_string(root().join("Cargo.toml")).expect("Cargo.toml is readable");
for needle in [
"version = \"1.3.0\"",
"rust-version = ",
"license = \"MIT OR Apache-2.0\"",
"documentation = \"https://docs.rs/scena\"",
"keywords = [",
"categories = [",
"include = [",
"\"/src/**\"",
"\"/README.md\"",
"\"/CHANGELOG.md\"",
"\"/Cargo.toml\"",
] {
assert!(manifest.contains(needle), "Cargo.toml missing {needle}");
}
}
#[test]
fn m5_public_api_baseline_names_frozen_contracts() {
let baseline = fs::read_to_string(root().join("docs/api.md")).expect("public API docs exist");
for needle in [
"Renderer::prepare",
"Renderer::render",
"Renderer::set_debug",
"DebugOverlay",
"RendererStats",
"AssetLoadOptions",
"BuildError",
"AssetError",
"ImportError",
"InstantiateError",
"PrepareError",
"RenderError",
"LookupError",
"AnimationError",
"SceneImport",
"SurfaceEvent",
"Scene::pick_with_assets",
"Scene::pick_and_select_with_assets",
"Scene::frame_bounds",
"Scene::project_world_point",
"Scene::bounds_for_transforms",
"Scene::add_grid_floor",
"Scene::add_studio_lighting",
"FramingOptions",
"FramingOptions::azimuth_elevation",
"FramingOptions::front",
"FramingOptions::back",
"FramingOptions::left",
"FramingOptions::right",
"FramingOptions::top",
"FramingOptions::bottom",
"FramingOptions::three_quarter_front_left",
"FramingOptions::three_quarter_front_right",
"FramingOptions::three_quarter_back_left",
"FramingOptions::three_quarter_back_right",
"FramingOutcome",
"ScreenRect",
"ProjectedPoint",
"GridFloorOptions",
"GridFloorHandles",
"Aabb::union",
"OrbitControls::focus_on_framing",
"OrbitControls::from_framing",
"Renderer::set_auto_exposure",
"AutoExposureConfig",
"AutoExposureResult",
"Assets::load_scene_with_options",
"Assets::load_scene_with_report_options",
"MaterialTextureMissingDecodedPixels",
] {
assert!(baseline.contains(needle), "baseline missing {needle}");
}
let artifact = root().join("target/gate-artifacts/m5-public-api-freeze.json");
fs::create_dir_all(artifact.parent().expect("artifact has parent")).expect("artifact dir");
fs::write(
artifact,
serde_json::json!({
"gate": "m5-public-api-freeze",
"status": "passed",
"baseline": "docs/api.md",
"semver_baseline": "docs/api.md",
"required_terms": [
"Renderer::prepare",
"Renderer::render",
"Renderer::set_debug",
"RendererStats",
"Renderer::set_auto_exposure",
"AutoExposureConfig",
"AutoExposureResult",
"AssetLoadOptions",
"BuildError",
"RenderError",
"SceneImport",
"Scene::frame_bounds",
"FramingOptions::azimuth_elevation",
"FramingOptions::front",
"FramingOptions::back",
"FramingOptions::left",
"FramingOptions::right",
"FramingOptions::top",
"FramingOptions::bottom",
"FramingOptions::three_quarter_front_left",
"FramingOptions::three_quarter_front_right",
"FramingOptions::three_quarter_back_left",
"FramingOptions::three_quarter_back_right",
"FramingOutcome",
"ScreenRect",
"ProjectedPoint",
"Aabb::union",
"OrbitControls::from_framing"
]
})
.to_string(),
)
.expect("public api artifact is written");
}
#[test]
fn public_error_displays_are_actionable() {
let samples = [
BuildError::InvalidTargetSize {
width: 0,
height: 32,
}
.to_string(),
AssetError::UnsupportedRequiredExtension {
path: "scene.gltf".to_string(),
extension: "KHR_unknown".to_string(),
}
.to_string(),
ImportError::Asset(AssetError::NotFound {
path: "missing.glb".to_string(),
})
.to_string(),
InstantiateError::InvalidAnchorExtras {
node: "arm".to_string(),
reason: "missing id".to_string(),
}
.to_string(),
PrepareError::BackendCapabilityMismatch {
feature: "compute culling",
backend: Backend::WebGl2,
help: "use CPU culling fallback".to_string(),
}
.to_string(),
RenderError::NotPrepared {
reason: NotPreparedReason::NeverPrepared,
}
.to_string(),
LookupError::StaleImport.to_string(),
AnimationError::ClipNotFound {
name: "Idle".to_string(),
}
.to_string(),
];
for message in samples {
assert!(
message.len() >= 18
&& !message.contains("TODO")
&& !message.contains("unimplemented")
&& (message.contains("prepare")
|| message.contains("asset")
|| message.contains("gltf")
|| message.contains("glTF")
|| message.contains("backend")
|| message.contains("scene")
|| message.contains("animation")
|| message.contains("invalid")
|| message.contains("missing")),
"error message is not actionable: {message}"
);
}
}
#[test]
fn scena_convert_cli_reports_fbx_to_gltf_plan() {
let help = run_scena_convert(["--help"])
.output()
.expect("scena-convert --help runs");
assert!(help.status.success(), "--help should succeed");
let help = String::from_utf8(help.stdout).expect("help is utf8");
assert!(help.contains("FBX"));
assert!(help.contains("glTF"));
let dry_run = run_scena_convert([
"--input",
"fixtures/robot-arm.fbx",
"--output",
"target/robot-arm.glb",
"--dry-run",
])
.output()
.expect("scena-convert dry-run runs");
assert!(dry_run.status.success(), "dry-run should succeed");
let stdout = String::from_utf8(dry_run.stdout).expect("dry-run stdout is utf8");
assert!(stdout.contains("\"status\":\"planned\""));
assert!(stdout.contains("FBX2glTF"));
assert!(stdout.contains("target/robot-arm.glb"));
}
fn run_scena_convert<const N: usize>(args: [&str; N]) -> Command {
let mut command = Command::new(env!("CARGO"));
command.args(["run", "--quiet", "--bin", "scena-convert", "--"]);
command.args(args);
command
}
#[test]
fn m5_benchmark_report_writes_required_scene_rows() {
let rows = [
benchmark_resource_free_static_viewer(),
benchmark_standard_model_viewer_gltf(),
benchmark_larger_industrial_gltf(),
benchmark_high_instance_scene(),
benchmark_idle_render_on_change(),
benchmark_headless_4k(),
];
let report = serde_json::json!({
"gate": "m5-benchmarks",
"status": "passed",
"regression_threshold_percent": 5.0,
"rows": rows,
});
let artifact = root().join("target/gate-artifacts/m5-benchmarks.json");
fs::create_dir_all(artifact.parent().expect("artifact has parent")).expect("artifact dir");
fs::write(
artifact,
serde_json::to_string_pretty(&report).expect("report serializes"),
)
.expect("benchmark artifact is written");
for name in [
"static-viewer",
"standard-model-viewer-gltf",
"larger-industrial-gltf",
"high-instance",
"idle",
"headless-4k",
] {
assert!(
report["rows"]
.as_array()
.expect("rows are an array")
.iter()
.any(|row| row["scene"] == name),
"missing benchmark row {name}"
);
}
}
fn benchmark_resource_free_static_viewer() -> serde_json::Value {
let mut scene = Scene::new();
scene
.add_renderable(
scene.root(),
vec![Primitive::unlit_triangle()],
Transform::default(),
)
.expect("renderable inserts");
benchmark_scene("static-viewer", 128, 128, scene, None)
}
fn benchmark_standard_model_viewer_gltf() -> serde_json::Value {
let assets = scena::Assets::new();
let scene_asset = pollster::block_on(
assets.load_scene("tests/assets/gltf/mesh_material_vertex_color_scene.gltf"),
)
.expect("mesh glTF loads");
let mut scene = Scene::new();
scene
.instantiate(&scene_asset)
.expect("mesh glTF instantiates");
benchmark_scene("standard-model-viewer-gltf", 128, 128, scene, Some(&assets))
}
fn benchmark_larger_industrial_gltf() -> serde_json::Value {
let assets = scena::Assets::new();
let scene_asset =
pollster::block_on(assets.load_scene("tests/assets/gltf/khronos/BrainStem/BrainStem.gltf"))
.expect("BrainStem glTF loads");
let mut scene = Scene::new();
scene
.instantiate(&scene_asset)
.expect("BrainStem glTF instantiates");
benchmark_scene("larger-industrial-gltf", 128, 128, scene, Some(&assets))
}
fn benchmark_high_instance_scene() -> serde_json::Value {
let assets = scena::Assets::new();
let geometry = assets.create_geometry(GeometryDesc::box_xyz(0.1, 0.1, 0.1));
let material = assets.create_material(MaterialDesc::unlit(Color::from_srgb_u8(80, 190, 140)));
let mut scene = Scene::new();
let set = scene
.add_instance_set(scene.root(), geometry, material, Transform::default())
.expect("instance set inserts");
scene
.reserve_instances(set, 256)
.expect("reserve instances");
for index in 0..128 {
let x = index % 16;
let y = index / 16;
scene
.push_instance(
set,
Transform {
translation: Vec3::new(x as f32 * 0.13 - 1.0, y as f32 * 0.13 - 0.5, 0.0),
..Transform::default()
},
)
.expect("instance inserts");
}
benchmark_scene("high-instance", 128, 128, scene, Some(&assets))
}
fn benchmark_idle_render_on_change() -> serde_json::Value {
let mut scene = Scene::new();
scene
.add_renderable(
scene.root(),
vec![Primitive::unlit_triangle()],
Transform::default(),
)
.expect("renderable inserts");
let camera = scene
.add_perspective_camera(
scene.root(),
PerspectiveCamera::default(),
Transform::default(),
)
.expect("camera inserts");
scene.set_active_camera(camera).expect("active camera");
let mut renderer = Renderer::headless_with_options(
64,
64,
scena::RendererOptions::default().with_render_mode(scena::RenderMode::OnChange),
)
.expect("renderer builds");
renderer.prepare(&mut scene).expect("scene prepares");
renderer.render_active(&scene).expect("warm render");
let start = Instant::now();
let outcome = renderer.render_active(&scene).expect("idle render skips");
benchmark_row(
"idle",
renderer.capabilities().backend,
start.elapsed().as_secs_f64() * 1000.0,
renderer.stats().draw_calls,
outcome.skipped,
)
}
fn benchmark_headless_4k() -> serde_json::Value {
let mut scene = Scene::new();
scene
.add_renderable(
scene.root(),
vec![Primitive::unlit_triangle()],
Transform::default(),
)
.expect("renderable inserts");
benchmark_scene("headless-4k", 3840, 2160, scene, None)
}
fn benchmark_scene(
name: &str,
width: u32,
height: u32,
mut scene: Scene,
assets: Option<&scena::Assets>,
) -> serde_json::Value {
let camera = scene
.add_perspective_camera(
scene.root(),
PerspectiveCamera::default(),
Transform::default(),
)
.expect("camera inserts");
scene.set_active_camera(camera).expect("active camera");
let mut renderer = Renderer::headless(width, height).expect("renderer builds");
let start = Instant::now();
if let Some(assets) = assets {
renderer
.prepare_with_assets(&mut scene, assets)
.expect("asset scene prepares");
} else {
renderer.prepare(&mut scene).expect("scene prepares");
}
let outcome = renderer.render_active(&scene).expect("scene renders");
benchmark_row(
name,
renderer.capabilities().backend,
start.elapsed().as_secs_f64() * 1000.0,
outcome.draw_calls,
outcome.skipped,
)
}
fn benchmark_row(
scene: &str,
backend: Backend,
frame_ms: f64,
draw_calls: u64,
skipped: bool,
) -> serde_json::Value {
serde_json::json!({
"scene": scene,
"backend": format!("{backend:?}"),
"median_frame_ms": frame_ms,
"p95_frame_ms": frame_ms,
"draw_calls": draw_calls,
"skipped": skipped,
"allocation_bytes": 0,
})
}