use std::fs;
use oxideav_core::{Error, Result};
use oxideav_mesh3d::Mesh3DRegistry;
use oxideav_render::{
make_renderer, BackgroundColor, CameraSpec as RCameraSpec, LightSpec as RLightSpec, Projection,
RenderBackend, RenderOptions, RgbaImage as RRgbaImage, ShadingMode,
};
use crate::op::{Mesh3DOptions, Mesh3DRenderMode, Op, ProjectionMode};
use crate::pixel_xform::apply_pixel_transform_chain;
use crate::raster_io::{
apply_alpha_ops, classify_output, encode_raster_to_path, OutputClass, RgbaImage,
};
const DEFAULT_WIDTH: u32 = 1024;
const DEFAULT_HEIGHT: u32 = 1024;
const DEFAULT_BG: [u8; 4] = [0, 0, 0, 0];
const DEFAULT_FOV_DEG: f32 = 60.0;
pub fn run(input_path: &str, output_path: &str, ops: &[Op], options: &Mesh3DOptions) -> Result<()> {
let in_ext = ext_of(input_path)
.ok_or_else(|| {
Error::invalid(format!(
"convert: input '{input_path}' has no extension — cannot pick a 3D decoder"
))
})?
.to_ascii_lowercase();
let class = classify_output(output_path)?;
let fmt = match class {
OutputClass::Raster(f) => f,
_ => {
return Err(Error::invalid(format!(
"convert: 3D→raster runner invoked with non-raster output '{output_path}'"
)));
}
};
let mut registry = Mesh3DRegistry::new();
crate::mesh3d_runner::populate_registry(&mut registry);
let mut decoder = registry.decoder_for_extension(&in_ext).ok_or_else(|| {
Error::unsupported(format!(
"convert: no 3D decoder registered for input extension '.{in_ext}'"
))
})?;
let bytes = fs::read(input_path)
.map_err(|e| Error::invalid(format!("convert: failed to read {input_path}: {e}")))?;
let scene = decoder.decode(&bytes)?;
let (width, height) = pick_dims(ops);
let bg = pick_render_bg(ops, options);
let render_opts = to_render_options(options, width, height, bg);
let mut renderer = make_renderer(RenderBackend::Scanline)
.map_err(|e| Error::invalid(format!("convert: 3D-render: {e}")))?;
let rendered: RRgbaImage = renderer
.render(&scene, &render_opts)
.map_err(|e| Error::invalid(format!("convert: 3D-render: {e}")))?;
let mut img = into_local_image(rendered);
img = apply_pixel_transform_chain(img, ops)
.map_err(|e| Error::invalid(format!("convert: 3D-render: {e}")))?;
apply_alpha_ops(&mut img, ops, bg);
encode_raster_to_path(&img, fmt, output_path, ops)
}
fn to_render_options(
options: &Mesh3DOptions,
width: u32,
height: u32,
bg: [u8; 4],
) -> RenderOptions {
let shading = options
.render_mode
.map(shading_from_mode)
.unwrap_or(ShadingMode::Flat);
let projection = options
.projection
.map(projection_from_mode)
.unwrap_or(Projection::Perspective);
let fov_deg = options.fov_deg.unwrap_or(DEFAULT_FOV_DEG);
let light = options
.light
.map(|l| RLightSpec {
azimuth_deg: l.azimuth_deg,
elevation_deg: l.elevation_deg,
intensity: l.intensity,
})
.unwrap_or(RLightSpec::default_light());
let camera = options.camera.map(|c| RCameraSpec {
elevation_deg: c.elevation_deg,
azimuth_deg: c.azimuth_deg,
distance: c.distance,
});
let aa = options.aa.unwrap_or(1).clamp(1, 8);
RenderOptions {
width,
height,
background: BackgroundColor(bg),
shading,
projection,
fov_deg,
light,
camera,
aa,
}
}
fn shading_from_mode(mode: Mesh3DRenderMode) -> ShadingMode {
match mode {
Mesh3DRenderMode::Flat => ShadingMode::Flat,
Mesh3DRenderMode::Wireframe => ShadingMode::Wireframe,
Mesh3DRenderMode::Gouraud => ShadingMode::Gouraud,
Mesh3DRenderMode::Phong => ShadingMode::Phong,
Mesh3DRenderMode::NormalDebug => ShadingMode::NormalDebug,
Mesh3DRenderMode::DepthDebug => ShadingMode::DepthDebug,
}
}
fn projection_from_mode(mode: ProjectionMode) -> Projection {
match mode {
ProjectionMode::Perspective => Projection::Perspective,
ProjectionMode::Orthographic => Projection::Orthographic,
}
}
fn into_local_image(src: RRgbaImage) -> RgbaImage {
RgbaImage {
width: src.width,
height: src.height,
stride: src.stride,
pixels: src.pixels,
}
}
fn pick_dims(ops: &[Op]) -> (u32, u32) {
for op in ops.iter().rev() {
if let Op::Resize { width, height, .. } = op {
return (*width, *height);
}
}
(DEFAULT_WIDTH, DEFAULT_HEIGHT)
}
fn pick_render_bg(ops: &[Op], options: &Mesh3DOptions) -> [u8; 4] {
if let Some(bg) = options.bg {
return bg;
}
for op in ops.iter().rev() {
if let Op::Background(c) = op {
return *c;
}
}
DEFAULT_BG
}
fn ext_of(path: &str) -> Option<&str> {
let last = path.rsplit('/').next().unwrap_or(path);
let last = last.split('?').next().unwrap_or(last);
let dot = last.rfind('.')?;
Some(&last[dot + 1..])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::op::{CameraSpec, LightSpec, ResizeMode};
#[test]
fn pick_dims_falls_back_to_default() {
let (w, h) = pick_dims(&[]);
assert_eq!((w, h), (DEFAULT_WIDTH, DEFAULT_HEIGHT));
}
#[test]
fn pick_dims_honours_resize_op() {
let ops = vec![Op::Resize {
width: 320,
height: 240,
mode: ResizeMode::Default,
}];
let (w, h) = pick_dims(&ops);
assert_eq!((w, h), (320, 240));
}
#[test]
fn render_bg_default_is_transparent() {
let bg = pick_render_bg(&[], &Mesh3DOptions::default());
assert_eq!(bg, [0, 0, 0, 0]);
}
#[test]
fn render_bg_honours_options_first() {
let opts = Mesh3DOptions {
bg: Some([10, 20, 30, 200]),
..Mesh3DOptions::default()
};
let ops = vec![Op::Background([99, 99, 99, 255])];
let bg = pick_render_bg(&ops, &opts);
assert_eq!(bg, [10, 20, 30, 200]);
}
#[test]
fn render_bg_falls_back_to_background_op() {
let opts = Mesh3DOptions::default();
let ops = vec![Op::Background([7, 8, 9, 255])];
let bg = pick_render_bg(&ops, &opts);
assert_eq!(bg, [7, 8, 9, 255]);
}
#[test]
fn shading_mode_round_trips_every_variant() {
let modes = [
Mesh3DRenderMode::Flat,
Mesh3DRenderMode::Wireframe,
Mesh3DRenderMode::Gouraud,
Mesh3DRenderMode::Phong,
Mesh3DRenderMode::NormalDebug,
Mesh3DRenderMode::DepthDebug,
];
let expected = [
ShadingMode::Flat,
ShadingMode::Wireframe,
ShadingMode::Gouraud,
ShadingMode::Phong,
ShadingMode::NormalDebug,
ShadingMode::DepthDebug,
];
for (m, e) in modes.iter().zip(expected.iter()) {
assert_eq!(shading_from_mode(*m), *e);
}
}
#[test]
fn projection_round_trips() {
assert_eq!(
projection_from_mode(ProjectionMode::Perspective),
Projection::Perspective
);
assert_eq!(
projection_from_mode(ProjectionMode::Orthographic),
Projection::Orthographic
);
}
#[test]
fn to_render_options_carries_dims_bg_and_aa() {
let opts = Mesh3DOptions {
aa: Some(4),
..Mesh3DOptions::default()
};
let r = to_render_options(&opts, 320, 240, [10, 20, 30, 255]);
assert_eq!(r.width, 320);
assert_eq!(r.height, 240);
assert_eq!(r.background, BackgroundColor([10, 20, 30, 255]));
assert_eq!(r.aa, 4);
assert_eq!(r.shading, ShadingMode::Flat);
assert_eq!(r.projection, Projection::Perspective);
assert!((r.fov_deg - DEFAULT_FOV_DEG).abs() < 1e-6);
assert!(r.camera.is_none());
}
#[test]
fn to_render_options_propagates_camera_override() {
let opts = Mesh3DOptions {
camera: Some(CameraSpec {
elevation_deg: 30.0,
azimuth_deg: 45.0,
distance: 1.5,
}),
..Mesh3DOptions::default()
};
let r = to_render_options(&opts, 64, 64, [0, 0, 0, 255]);
let cam = r.camera.expect("camera override must propagate");
assert!((cam.elevation_deg - 30.0).abs() < 1e-6);
assert!((cam.azimuth_deg - 45.0).abs() < 1e-6);
assert!((cam.distance - 1.5).abs() < 1e-6);
}
#[test]
fn to_render_options_propagates_light_override() {
let opts = Mesh3DOptions {
light: Some(LightSpec {
azimuth_deg: 12.0,
elevation_deg: 34.0,
intensity: 0.5,
}),
..Mesh3DOptions::default()
};
let r = to_render_options(&opts, 64, 64, [0, 0, 0, 255]);
assert!((r.light.azimuth_deg - 12.0).abs() < 1e-6);
assert!((r.light.elevation_deg - 34.0).abs() < 1e-6);
assert!((r.light.intensity - 0.5).abs() < 1e-6);
}
}