use serde::Deserialize;
use crate as wt;
pub const EXTENSION_NAME: &str = "ext-cinematography";
pub const EXTENSION_VERSION: &str = "0.3.0";
pub const DEFAULT_SENSOR: [f64; 2] = [24.89, 18.66];
pub const DEFAULT_FOCAL_LENGTH: f64 = 35.0;
#[derive(Debug, Clone, PartialEq)]
pub struct CameraComponent {
pub sensor_mm: [f64; 2],
pub focal_length_mm: f64,
pub aspect_ratio: Option<f64>,
pub squeeze: f64,
pub aim: Option<[f64; 3]>,
pub aim_entity: Option<wt::EntityRef>,
pub focus_distance_m: Option<f64>,
pub focus_entity: Option<wt::EntityRef>,
pub f_stop: Option<f64>,
}
impl CameraComponent {
pub fn of(entity: &wt::WorldEntity) -> Option<Self> {
let camera = entity.extra.get(EXTENSION_NAME)?.get("camera")?.as_object()?;
let num = |k: &str, d: f64| camera.get(k).and_then(serde_json::Value::as_f64).unwrap_or(d);
let opt = |k: &str| camera.get(k).and_then(serde_json::Value::as_f64);
let vec2 = |k: &str| -> Option<[f64; 2]> {
let a: Vec<f64> = camera
.get(k)?
.as_array()?
.iter()
.filter_map(serde_json::Value::as_f64)
.collect();
(a.len() == 2).then_some([a[0], a[1]])
};
let vec3 = |k: &str| -> Option<[f64; 3]> {
let a: Vec<f64> = camera
.get(k)?
.as_array()?
.iter()
.filter_map(serde_json::Value::as_f64)
.collect();
(a.len() == 3).then_some([a[0], a[1], a[2]])
};
let reference = |k: &str| -> Option<wt::EntityRef> {
serde_json::from_value(camera.get(k)?.clone()).ok()
};
Some(CameraComponent {
sensor_mm: vec2("sensor_mm").unwrap_or(DEFAULT_SENSOR),
focal_length_mm: num("focal_length_mm", DEFAULT_FOCAL_LENGTH),
aspect_ratio: opt("aspect_ratio"),
squeeze: num("squeeze", 1.0),
aim: vec3("aim"),
aim_entity: reference("aim_entity"),
focus_distance_m: opt("focus_distance_m"),
focus_entity: reference("focus_entity"),
f_stop: opt("f_stop"),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CameraFrame {
pub width_mm: f64,
pub height_mm: f64,
pub hfov_degrees: f64,
pub vfov_degrees: f64,
pub aspect: f64,
}
pub fn frame_of(camera: &CameraComponent) -> CameraFrame {
let [w, h] = camera.sensor_mm;
let s = camera.squeeze;
let f = camera.focal_length_mm;
let a = w * s / h;
let (width_mm, height_mm) = match camera.aspect_ratio {
Some(aspect) if aspect != 0.0 => (
w * s * (aspect / a).min(1.0),
h * (a / aspect).min(1.0),
),
_ => (w * s, h),
};
let deg = |rad: f64| rad * 180.0 / std::f64::consts::PI;
CameraFrame {
width_mm,
height_mm,
hfov_degrees: deg(2.0 * (width_mm / (2.0 * f)).atan()),
vfov_degrees: deg(2.0 * (height_mm / (2.0 * f)).atan()),
aspect: width_mm / height_mm,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CameraView {
pub position: [f64; 3],
pub forward: [f64; 3],
pub right: [f64; 3],
pub up: [f64; 3],
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn norm(v: [f64; 3]) -> [f64; 3] {
let l = dot(v, v).sqrt();
[v[0] / l, v[1] / l, v[2] / l]
}
fn rotate_intrinsic_xyz(v: [f64; 3], degrees: [f64; 3]) -> [f64; 3] {
let rad = |d: f64| d * std::f64::consts::PI / 180.0;
let (rx, ry, rz) = (rad(degrees[0]), rad(degrees[1]), rad(degrees[2]));
let [x, y, z] = v;
let (c, s) = (rz.cos(), rz.sin());
let (x, y) = (c * x - s * y, s * x + c * y);
let (c, s) = (ry.cos(), ry.sin());
let (x, z) = (c * x + s * z, -s * x + c * z);
let (c, s) = (rx.cos(), rx.sin());
let (y, z) = (c * y - s * z, s * y + c * z);
[x, y, z]
}
pub fn entity_of<'a>(
reference: &wt::EntityRef,
manifest: &'a wt::WorldManifest,
) -> Option<&'a wt::WorldEntity> {
manifest.entities.iter().find(|entity| match reference {
wt::EntityRef::Id(id) => entity.id == *id,
wt::EntityRef::Name(name) => entity.name.as_str() == name.as_str(),
})
}
pub fn world_origin(entity: &wt::WorldEntity, manifest: &wt::WorldManifest) -> [f64; 3] {
let mut point: [f64; 3] = entity.transform.position.map(|p| p as f64);
let mut current = entity;
for _ in 0..4096 {
let Some(parent_id) = current.parent else {
break;
};
let Some(parent) = manifest.entities.iter().find(|e| e.id == parent_id) else {
break;
};
let p: [f64; 3] = parent.transform.position.map(|v| v as f64);
let r: [f64; 3] = parent.transform.rotation_degrees.map(|v| v as f64);
let s: [f64; 3] = parent.transform.scale.map(|v| v as f64);
point = add(
p,
rotate_intrinsic_xyz([point[0] * s[0], point[1] * s[1], point[2] * s[2]], r),
);
current = parent;
}
point
}
pub fn aim_point_of(camera: &CameraComponent, manifest: &wt::WorldManifest) -> Option<[f64; 3]> {
if let Some(target) = camera
.aim_entity
.as_ref()
.and_then(|reference| entity_of(reference, manifest))
{
return Some(world_origin(target, manifest));
}
camera.aim
}
pub fn focus_distance_of(
entity: &wt::WorldEntity,
camera: &CameraComponent,
manifest: &wt::WorldManifest,
) -> Option<f64> {
if let Some(target) = camera
.focus_entity
.as_ref()
.and_then(|reference| entity_of(reference, manifest))
{
let d = sub(world_origin(target, manifest), world_origin(entity, manifest));
return Some(dot(d, d).sqrt());
}
camera.focus_distance_m
}
pub fn view_of(entity: &wt::WorldEntity, camera: &CameraComponent) -> CameraView {
let position: [f64; 3] = entity.transform.position.map(|p| p as f64);
view_from(entity, position, camera.aim)
}
pub fn view_in(
entity: &wt::WorldEntity,
camera: &CameraComponent,
manifest: &wt::WorldManifest,
) -> CameraView {
view_from(entity, world_origin(entity, manifest), aim_point_of(camera, manifest))
}
fn view_from(entity: &wt::WorldEntity, position: [f64; 3], aim: Option<[f64; 3]>) -> CameraView {
if let Some(aim) = aim {
let forward = norm(sub(aim, position));
let right = norm(cross(forward, [0.0, 1.0, 0.0]));
let up = cross(right, forward);
return CameraView {
position,
forward,
right,
up,
};
}
let rotation: [f64; 3] = entity.transform.rotation_degrees.map(|r| r as f64);
CameraView {
position,
forward: rotate_intrinsic_xyz([0.0, 0.0, -1.0], rotation),
right: rotate_intrinsic_xyz([1.0, 0.0, 0.0], rotation),
up: rotate_intrinsic_xyz([0.0, 1.0, 0.0], rotation),
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Projection {
pub x: f64,
pub y: f64,
pub z: f64,
}
pub fn project(
view: &CameraView,
frame: &CameraFrame,
camera: &CameraComponent,
point: [f64; 3],
) -> Projection {
let d = sub(point, view.position);
let z = dot(d, view.forward);
let scale = camera.focal_length_mm / z;
Projection {
x: (dot(d, view.right) * scale) / (frame.width_mm / 2.0),
y: (dot(d, view.up) * scale) / (frame.height_mm / 2.0),
z,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ShotEntry {
pub id: u64,
pub name: String,
pub shot: serde_json::Value,
}
pub fn shot_list(manifest: &wt::WorldManifest) -> Vec<ShotEntry> {
let mut shots: Vec<ShotEntry> = manifest
.entities
.iter()
.filter_map(|entity| {
let shot = entity.extra.get(EXTENSION_NAME)?.get("shot")?;
if !shot.is_object() {
return None;
}
Some(ShotEntry {
id: entity.id.0,
name: entity.name.as_str().to_string(),
shot: shot.clone(),
})
})
.collect();
let rank =
|s: &ShotEntry| s.shot.get("order").and_then(serde_json::Value::as_f64).unwrap_or(f64::MAX);
shots.sort_by(|a, b| {
rank(a)
.partial_cmp(&rank(b))
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.id.cmp(&b.id))
});
shots
}
#[derive(Debug, Clone, Deserialize)]
pub struct OutcomesDoc {
#[serde(default)]
pub world: String,
pub expect: Vec<Assertion>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Assertion {
Fov {
fov: FovAssertion,
},
InFrame {
in_frame: FrameAssertion,
},
OutOfFrame {
out_of_frame: FrameAssertion,
},
Centred {
centred: CentredAssertion,
},
Focus {
focus: FocusAssertion,
},
ShotList {
shot_list: Vec<String>,
},
Unknown(serde_json::Value),
}
#[derive(Debug, Clone, Deserialize)]
pub struct FovAssertion {
pub camera: String,
pub hfov_degrees: f64,
pub vfov_degrees: f64,
pub aspect: f64,
#[serde(default)]
pub tolerance: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FrameAssertion {
pub camera: String,
pub entity: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CentredAssertion {
pub camera: String,
pub entity: String,
#[serde(default)]
pub tolerance: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FocusAssertion {
pub camera: String,
pub distance_m: f64,
#[serde(default)]
pub tolerance: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OutcomeResult {
pub ok: bool,
pub failures: Vec<String>,
}
pub fn run_outcomes(manifest: &wt::WorldManifest, outcomes: &OutcomesDoc) -> OutcomeResult {
let mut failures = Vec::new();
for assertion in &outcomes.expect {
match assertion {
Assertion::Fov { fov } => {
let Some((_, _, frame, _)) = camera_setup(manifest, &fov.camera, &mut failures)
else {
continue;
};
let tolerance = fov.tolerance.unwrap_or(0.001);
if (frame.hfov_degrees - fov.hfov_degrees).abs() > tolerance {
failures.push(format!(
"{}: hfov {} != {} (tolerance {tolerance})",
fov.camera, frame.hfov_degrees, fov.hfov_degrees
));
}
if (frame.vfov_degrees - fov.vfov_degrees).abs() > tolerance {
failures.push(format!(
"{}: vfov {} != {} (tolerance {tolerance})",
fov.camera, frame.vfov_degrees, fov.vfov_degrees
));
}
if (frame.aspect - fov.aspect).abs() > tolerance {
failures.push(format!(
"{}: aspect {} != {} (tolerance {tolerance})",
fov.camera, frame.aspect, fov.aspect
));
}
}
Assertion::InFrame { in_frame: target }
| Assertion::OutOfFrame {
out_of_frame: target,
} => {
let expect_inside = matches!(assertion, Assertion::InFrame { .. });
let Some((_, camera, frame, view)) =
camera_setup(manifest, &target.camera, &mut failures)
else {
continue;
};
let origin = origin_of(manifest, &target.entity, &mut failures);
let p = project(&view, &frame, &camera, origin);
let inside = p.z > 0.0 && p.x.abs() <= 1.0 && p.y.abs() <= 1.0;
if expect_inside && !inside {
failures.push(format!(
"{}: {} projects outside the frame ({:.3}, {:.3})",
target.camera, target.entity, p.x, p.y
));
}
if !expect_inside && inside {
failures.push(format!(
"{}: {} projects inside the frame ({:.3}, {:.3})",
target.camera, target.entity, p.x, p.y
));
}
}
Assertion::Centred { centred } => {
let Some((_, camera, frame, view)) =
camera_setup(manifest, ¢red.camera, &mut failures)
else {
continue;
};
let tolerance = centred.tolerance.unwrap_or(0.001);
let origin = origin_of(manifest, ¢red.entity, &mut failures);
let p = project(&view, &frame, &camera, origin);
if !(p.z > 0.0 && p.x.abs() <= tolerance && p.y.abs() <= tolerance) {
failures.push(format!(
"{}: {} projects at ({:.4}, {:.4}), z {:.3} — not the frame's centre (tolerance {tolerance})",
centred.camera, centred.entity, p.x, p.y, p.z
));
}
}
Assertion::Focus { focus } => {
let Some((entity, camera, _, _)) =
camera_setup(manifest, &focus.camera, &mut failures)
else {
continue;
};
let tolerance = focus.tolerance.unwrap_or(0.001);
match focus_distance_of(entity, &camera, manifest) {
None => failures.push(format!("{}: no focus distance", focus.camera)),
Some(actual) if (actual - focus.distance_m).abs() > tolerance => {
failures.push(format!(
"{}: focus distance {} != {} (tolerance {tolerance})",
focus.camera, actual, focus.distance_m
));
}
Some(_) => {}
}
}
Assertion::ShotList { shot_list: expected } => {
let shots = shot_list(manifest);
let actual: Vec<&str> = shots.iter().map(|s| s.name.as_str()).collect();
let expected: Vec<&str> = expected.iter().map(String::as_str).collect();
if actual != expected {
failures.push(format!("shot list {actual:?} != {expected:?}"));
}
}
Assertion::Unknown(value) => {
failures.push(format!("unknown assertion {value}"));
}
}
}
OutcomeResult {
ok: failures.is_empty(),
failures,
}
}
fn camera_setup<'a>(
manifest: &'a wt::WorldManifest,
name: &str,
failures: &mut Vec<String>,
) -> Option<(&'a wt::WorldEntity, CameraComponent, CameraFrame, CameraView)> {
let entity = manifest.entities.iter().find(|e| e.name.as_str() == name);
let camera = entity.and_then(CameraComponent::of);
let (Some(entity), Some(camera)) = (entity, camera) else {
failures.push(format!("no camera named {name}"));
return None;
};
let frame = frame_of(&camera);
let view = view_in(entity, &camera, manifest);
Some((entity, camera, frame, view))
}
fn origin_of(manifest: &wt::WorldManifest, name: &str, failures: &mut Vec<String>) -> [f64; 3] {
match manifest.entities.iter().find(|e| e.name.as_str() == name) {
Some(entity) => world_origin(entity, manifest),
None => {
failures.push(format!("no entity named {name}"));
[0.0, 0.0, 0.0]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{WorldEntity, WorldManifest};
fn repo(relative: &str) -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../")
.join(relative)
}
fn cinema_world() -> WorldManifest {
serde_json::from_str(
&std::fs::read_to_string(repo("conformance/cinematography.json")).unwrap(),
)
.unwrap()
}
fn by_name<'a>(manifest: &'a WorldManifest, name: &str) -> &'a WorldEntity {
manifest
.entities
.iter()
.find(|e| e.name.as_str() == name)
.unwrap()
}
fn entity(id: u64, name: &str, ext: serde_json::Value) -> WorldEntity {
let mut e = WorldEntity::new(id, name);
e.extra.insert(EXTENSION_NAME.to_string(), ext);
e
}
#[test]
fn camera_of_applies_the_defaults_per_absent_field_and_none_for_non_cameras() {
let manifest = cinema_world();
assert!(CameraComponent::of(by_name(&manifest, "maya")).is_none());
let bare = CameraComponent::of(&entity(1, "c", serde_json::json!({"camera": {}}))).unwrap();
assert_eq!(bare.sensor_mm, DEFAULT_SENSOR);
assert_eq!(bare.focal_length_mm, DEFAULT_FOCAL_LENGTH);
assert_eq!(bare.squeeze, 1.0);
assert_eq!(bare.aspect_ratio, None);
assert_eq!(bare.aim_entity, None);
assert_eq!(bare.focus_entity, None);
let tracked = CameraComponent::of(by_name(&manifest, "4A")).unwrap();
assert_eq!(tracked.aim_entity, Some(wt::EntityRef::id(4)));
assert_eq!(tracked.focus_entity, Some(wt::EntityRef::id(4)));
let named = CameraComponent::of(&entity(
2,
"n",
serde_json::json!({"camera": {"aim_entity": "maya"}}),
))
.unwrap();
assert!(matches!(named.aim_entity, Some(wt::EntityRef::Name(_))));
assert_eq!(EXTENSION_VERSION, "0.3.0");
}
#[test]
fn frame_of_is_the_normative_crop_math_cropping_trims_never_widens() {
let manifest = cinema_world();
let full = frame_of(&CameraComponent::of(by_name(&manifest, "2A")).unwrap());
assert!((full.aspect - 24.89 / 18.66).abs() < 1e-9);
let cropped = frame_of(&CameraComponent::of(by_name(&manifest, "1A")).unwrap());
assert_eq!(cropped.width_mm, 24.89);
assert!(cropped.height_mm < 18.66);
assert!((cropped.aspect - 2.39).abs() < 1e-9);
let ana = frame_of(&CameraComponent::of(by_name(&manifest, "3A")).unwrap());
assert!(ana.width_mm < 24.89 * 2.0);
assert_eq!(ana.height_mm, 18.66);
assert!((ana.aspect - 2.39).abs() < 1e-9);
}
#[test]
fn view_of_looks_at_the_aim_with_plus_y_up_else_down_the_local_minus_z() {
let manifest = cinema_world();
let wide = view_of(
by_name(&manifest, "1A"),
&CameraComponent::of(by_name(&manifest, "1A")).unwrap(),
);
assert_eq!(wide.position[0], 0.0);
assert!((wide.position[1] - 1.6).abs() < 1e-6); assert_eq!(wide.position[2], 6.0);
assert!(wide.forward[0].abs() < 1e-9 && wide.forward[2] < 0.0 && wide.forward[1] < 0.0);
assert!(wide.up[1].abs() > 0.9);
let mut turned_entity = entity(9, "t", serde_json::json!({"camera": {}}));
turned_entity.transform.rotation_degrees = [0.0, 90.0, 0.0];
let turned = view_of(&turned_entity, &CameraComponent::of(&turned_entity).unwrap());
assert!(
(turned.forward[0] + 1.0).abs() < 1e-9
&& turned.forward[1].abs() < 1e-9
&& turned.forward[2].abs() < 1e-9
);
}
#[test]
fn project_puts_the_aim_point_at_the_frames_center() {
let manifest = cinema_world();
let single = by_name(&manifest, "2A");
let camera = CameraComponent::of(single).unwrap();
let view = view_of(single, &camera);
let frame = frame_of(&camera);
let p = project(&view, &frame, &camera, [-1.5, 1.2, 0.0]);
assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6 && p.z > 0.0);
}
#[test]
fn world_origin_composes_the_position_through_the_parents() {
let manifest = cinema_world();
let mug = world_origin(by_name(&manifest, "mug"), &manifest);
let expected = [0.8 + 0.3 * 30f64.to_radians().cos(), 1.05, -1.0 - 0.3 * 30f64.to_radians().sin()];
for i in 0..3 {
assert!((mug[i] - expected[i]).abs() < 1e-5, "axis {i}: {} vs {}", mug[i], expected[i]);
}
let maya = world_origin(by_name(&manifest, "maya"), &manifest);
assert!((maya[0] + 1.5).abs() < 1e-6 && (maya[1] - 0.85).abs() < 1e-6);
}
#[test]
fn aim_entity_centres_the_target_and_focus_entity_measures_to_it() {
let manifest = cinema_world();
let tracked = by_name(&manifest, "4A");
let camera = CameraComponent::of(tracked).unwrap();
let view = view_in(tracked, &camera, &manifest);
let frame = frame_of(&camera);
let maya = world_origin(by_name(&manifest, "maya"), &manifest);
let p = project(&view, &frame, &camera, maya);
assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6 && p.z > 0.0);
assert_eq!(aim_point_of(&camera, &manifest), Some(maya));
let focus = focus_distance_of(tracked, &camera, &manifest).unwrap();
assert!((focus - 4.3843).abs() < 1e-3, "{focus}");
let insert = by_name(&manifest, "insert");
let camera = CameraComponent::of(insert).unwrap();
let view = view_in(insert, &camera, &manifest);
let p = project(&view, &frame_of(&camera), &camera, world_origin(by_name(&manifest, "mug"), &manifest));
assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6 && p.z > 0.0);
let focus = focus_distance_of(insert, &camera, &manifest).unwrap();
assert!((focus - 1.4956).abs() < 1e-3, "{focus}");
}
#[test]
fn a_gone_target_leaves_aim_then_the_rotation_and_focus_distance_m() {
let manifest = cinema_world();
let mut orphan = entity(
40,
"orphan",
serde_json::json!({"camera": {"aim_entity": 999, "aim": [0.0, 1.0, 0.0],
"focus_entity": 999, "focus_distance_m": 2.5}}),
);
orphan.transform.position = [0.0, 1.0, 5.0];
let camera = CameraComponent::of(&orphan).unwrap();
assert_eq!(aim_point_of(&camera, &manifest), Some([0.0, 1.0, 0.0]));
assert_eq!(focus_distance_of(&orphan, &camera, &manifest), Some(2.5));
let view = view_in(&orphan, &camera, &manifest);
assert!((view.forward[2] + 1.0).abs() < 1e-9, "looks down −Z at the aim point");
}
#[test]
fn shot_list_orders_by_shot_order_ties_by_entity_id_and_skips_shot_less_cameras() {
let manifest = cinema_world();
let shots = shot_list(&manifest);
let names: Vec<&str> = shots.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["1A", "2A", "2B", "3A", "4A"]); let mut world = WorldManifest::new("t");
world.entities.push(entity(7, "b", serde_json::json!({"shot": {}})));
world.entities.push(entity(3, "a", serde_json::json!({"shot": {}})));
world
.entities
.push(entity(5, "c", serde_json::json!({"shot": {"order": 1}})));
let shots = shot_list(&world);
let names: Vec<&str> = shots.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["c", "a", "b"]);
}
}