#![allow(dead_code)]
#![allow(clippy::cast_precision_loss)]
#[derive(Debug, Clone, PartialEq)]
pub struct LedPanelSpec {
pub width_px: u32,
pub height_px: u32,
pub pitch_mm: f32,
pub nits_max: u32,
pub refresh_hz: f32,
}
impl LedPanelSpec {
#[must_use]
pub fn new(
width_px: u32,
height_px: u32,
pitch_mm: f32,
nits_max: u32,
refresh_hz: f32,
) -> Self {
Self {
width_px,
height_px,
pitch_mm,
nits_max,
refresh_hz,
}
}
#[must_use]
pub fn physical_width_mm(&self) -> f32 {
self.width_px as f32 * self.pitch_mm
}
#[must_use]
pub fn physical_height_mm(&self) -> f32 {
self.height_px as f32 * self.pitch_mm
}
#[must_use]
pub fn pixel_count(&self) -> u64 {
u64::from(self.width_px) * u64::from(self.height_px)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LedWallSegment {
pub id: u32,
pub panels_wide: u32,
pub panels_high: u32,
pub spec: LedPanelSpec,
pub curved: bool,
pub curvature_deg: f32,
}
impl LedWallSegment {
#[must_use]
pub fn flat(id: u32, panels_wide: u32, panels_high: u32, spec: LedPanelSpec) -> Self {
Self {
id,
panels_wide,
panels_high,
spec,
curved: false,
curvature_deg: 0.0,
}
}
#[must_use]
pub fn curved(
id: u32,
panels_wide: u32,
panels_high: u32,
spec: LedPanelSpec,
curvature_deg: f32,
) -> Self {
Self {
id,
panels_wide,
panels_high,
spec,
curved: true,
curvature_deg,
}
}
#[must_use]
pub fn total_width_mm(&self) -> f32 {
self.panels_wide as f32 * self.spec.physical_width_mm()
}
#[must_use]
pub fn total_height_mm(&self) -> f32 {
self.panels_high as f32 * self.spec.physical_height_mm()
}
#[must_use]
pub fn total_pixels(&self) -> u64 {
u64::from(self.panels_wide) * u64::from(self.panels_high) * self.spec.pixel_count()
}
}
#[derive(Debug, Clone)]
pub struct LedVolume {
pub segments: Vec<LedWallSegment>,
pub ceiling: Option<LedWallSegment>,
pub floor: Option<LedWallSegment>,
}
impl LedVolume {
#[must_use]
pub fn new() -> Self {
Self {
segments: Vec::new(),
ceiling: None,
floor: None,
}
}
pub fn add_segment(&mut self, seg: LedWallSegment) {
self.segments.push(seg);
}
#[must_use]
pub fn total_nits_max(&self) -> u32 {
let mut max_nits = 0u32;
for seg in &self.segments {
if seg.spec.nits_max > max_nits {
max_nits = seg.spec.nits_max;
}
}
if let Some(c) = &self.ceiling {
if c.spec.nits_max > max_nits {
max_nits = c.spec.nits_max;
}
}
if let Some(f) = &self.floor {
if f.spec.nits_max > max_nits {
max_nits = f.spec.nits_max;
}
}
max_nits
}
#[must_use]
pub fn total_pixels(&self) -> u64 {
let mut total: u64 = self.segments.iter().map(LedWallSegment::total_pixels).sum();
if let Some(c) = &self.ceiling {
total += c.total_pixels();
}
if let Some(f) = &self.floor {
total += f.total_pixels();
}
total
}
#[must_use]
pub fn has_ceiling(&self) -> bool {
self.ceiling.is_some()
}
}
impl Default for LedVolume {
fn default() -> Self {
Self::new()
}
}
pub struct MoireRiskAssessor;
impl MoireRiskAssessor {
#[must_use]
pub fn assess(panel: &LedPanelSpec, camera_distance_m: f64, lens_focal_mm: f32) -> f32 {
if camera_distance_m <= 0.0 || lens_focal_mm <= 0.0 {
return 1.0; }
let pitch_m = f64::from(panel.pitch_mm) / 1_000.0;
let apparent_mm = (pitch_m / camera_distance_m) * f64::from(lens_focal_mm);
let sensor_pixel_pitch_mm = 0.006_f64;
let ratio = apparent_mm / sensor_pixel_pitch_mm;
let frac = ratio - ratio.floor();
let risk = 1.0 - (2.0 * (frac - 0.5).abs());
risk.clamp(0.0, 1.0) as f32
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PanelGamut {
Rec709,
DciP3,
Rec2020,
}
#[derive(Debug, Clone, PartialEq)]
pub enum WallFace {
Front,
Left,
Right,
Ceiling,
Floor,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PanelPosition {
pub x_mm: f32,
pub y_mm: f32,
pub z_mm: f32,
pub rotation_deg: f32,
pub face: WallFace,
}
#[derive(Debug, Clone)]
pub struct LedPanel {
pub id: String,
pub width_pixels: u32,
pub height_pixels: u32,
pub pixel_pitch_mm: f32,
pub nits_peak: f32,
pub refresh_rate_hz: f32,
pub color_gamut: PanelGamut,
pub position: PanelPosition,
}
impl LedPanel {
#[must_use]
pub fn new(id: &str, width: u32, height: u32, pitch_mm: f32, nits: f32) -> Self {
Self {
id: id.to_owned(),
width_pixels: width,
height_pixels: height,
pixel_pitch_mm: pitch_mm,
nits_peak: nits,
refresh_rate_hz: 60.0,
color_gamut: PanelGamut::Rec709,
position: PanelPosition {
x_mm: 0.0,
y_mm: 0.0,
z_mm: 0.0,
rotation_deg: 0.0,
face: WallFace::Front,
},
}
}
#[must_use]
pub fn physical_width_mm(&self) -> f32 {
self.width_pixels as f32 * self.pixel_pitch_mm
}
#[must_use]
pub fn physical_height_mm(&self) -> f32 {
self.height_pixels as f32 * self.pixel_pitch_mm
}
#[must_use]
pub fn resolution_mpx(&self) -> f32 {
(self.width_pixels as f32 * self.height_pixels as f32) / 1_000_000.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ColorProcessingMode {
Linear,
DisplayGamma,
PqHdr,
}
#[derive(Debug, Clone)]
pub struct LedVolumeV2 {
pub id: String,
pub name: String,
pub panels: Vec<LedPanel>,
pub total_width_pixels: u32,
pub total_height_pixels: u32,
pub driving_fps: f32,
pub color_processing: ColorProcessingMode,
}
impl LedVolumeV2 {
#[must_use]
pub fn new(id: &str, name: &str) -> Self {
Self {
id: id.to_owned(),
name: name.to_owned(),
panels: Vec::new(),
total_width_pixels: 0,
total_height_pixels: 0,
driving_fps: 24.0,
color_processing: ColorProcessingMode::Linear,
}
}
pub fn add_panel(&mut self, panel: LedPanel) {
self.panels.push(panel);
}
pub fn remove_panel(&mut self, id: &str) -> bool {
let before = self.panels.len();
self.panels.retain(|p| p.id != id);
self.panels.len() < before
}
pub fn compute_total_resolution(&mut self) {
let front_width: u32 = self
.panels
.iter()
.filter(|p| p.position.face == WallFace::Front)
.map(|p| p.width_pixels)
.sum();
let max_height: u32 = self
.panels
.iter()
.map(|p| p.height_pixels)
.max()
.unwrap_or(0);
self.total_width_pixels = front_width;
self.total_height_pixels = max_height;
}
#[must_use]
pub fn panels_by_face(&self, face: &WallFace) -> Vec<&LedPanel> {
self.panels
.iter()
.filter(|p| &p.position.face == face)
.collect()
}
#[must_use]
pub fn peak_nits(&self) -> f32 {
self.panels
.iter()
.map(|p| p.nits_peak)
.reduce(f32::min)
.unwrap_or(0.0)
}
#[must_use]
pub fn requires_hdr(&self) -> bool {
self.color_processing == ColorProcessingMode::PqHdr
&& self.panels.iter().any(|p| p.nits_peak > 1000.0)
}
#[must_use]
pub fn validate(&self) -> Vec<String> {
let mut errors: Vec<String> = Vec::new();
if self.panels.is_empty() {
errors.push("LED volume has no panels".to_owned());
return errors;
}
let first_hz = self.panels[0].refresh_rate_hz;
let mismatched_refresh = self
.panels
.iter()
.any(|p| (p.refresh_rate_hz - first_hz).abs() > 0.1);
if mismatched_refresh {
errors.push("Panels have mismatched refresh rates".to_owned());
}
let first_pitch = self.panels[0].pixel_pitch_mm;
let mismatched_pitch = self
.panels
.iter()
.any(|p| (p.pixel_pitch_mm - first_pitch).abs() > 0.01);
if mismatched_pitch {
errors.push("Panels have inconsistent pixel pitch values".to_owned());
}
errors
}
}
pub struct MoireChecker {
pub camera_sensor_pixels: (u32, u32),
pub lens_focal_length_mm: f32,
}
impl MoireChecker {
#[must_use]
pub fn risk_score(&self, panel: &LedPanel, camera_distance_m: f32) -> f32 {
if camera_distance_m <= 0.0
|| self.lens_focal_length_mm <= 0.0
|| panel.pixel_pitch_mm <= 0.0
{
return 1.0;
}
let sensor_width_mm = 36.0_f32;
let sensor_ppi = self.camera_sensor_pixels.0 as f32 / sensor_width_mm;
let panel_ppi = 1.0_f32 / panel.pixel_pitch_mm;
let distance_mm = camera_distance_m * 1_000.0_f32;
let focal = self.lens_focal_length_mm;
let denom = distance_mm - focal;
if denom.abs() < 1e-6 {
return 1.0;
}
let apparent_ppi = panel_ppi * (focal / denom).abs();
if sensor_ppi <= 0.0 {
return 1.0;
}
let diff = (sensor_ppi - apparent_ppi).abs();
(diff / sensor_ppi).clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_panel() -> LedPanelSpec {
LedPanelSpec::new(256, 128, 2.8, 1500, 3840.0)
}
#[test]
fn test_panel_physical_width() {
let p = LedPanelSpec::new(256, 128, 2.8, 1500, 3840.0);
let expected = 256.0_f32 * 2.8_f32;
assert!((p.physical_width_mm() - expected).abs() < 1e-3);
}
#[test]
fn test_panel_physical_height() {
let p = LedPanelSpec::new(256, 128, 2.8, 1500, 3840.0);
let expected = 128.0_f32 * 2.8_f32;
assert!((p.physical_height_mm() - expected).abs() < 1e-3);
}
#[test]
fn test_panel_pixel_count() {
let p = LedPanelSpec::new(256, 128, 2.8, 1500, 3840.0);
assert_eq!(p.pixel_count(), 256 * 128);
}
#[test]
fn test_segment_flat_creation() {
let seg = LedWallSegment::flat(1, 4, 2, sample_panel());
assert!(!seg.curved);
assert_eq!(seg.curvature_deg, 0.0);
}
#[test]
fn test_segment_curved_creation() {
let seg = LedWallSegment::curved(2, 6, 3, sample_panel(), 180.0);
assert!(seg.curved);
assert!((seg.curvature_deg - 180.0).abs() < 1e-5);
}
#[test]
fn test_segment_total_width() {
let spec = LedPanelSpec::new(256, 128, 2.8, 1500, 3840.0);
let seg = LedWallSegment::flat(1, 4, 2, spec);
let expected = 4.0_f32 * 256.0_f32 * 2.8_f32;
assert!((seg.total_width_mm() - expected).abs() < 1e-2);
}
#[test]
fn test_segment_total_height() {
let spec = LedPanelSpec::new(256, 128, 2.8, 1500, 3840.0);
let seg = LedWallSegment::flat(1, 4, 2, spec);
let expected = 2.0_f32 * 128.0_f32 * 2.8_f32;
assert!((seg.total_height_mm() - expected).abs() < 1e-2);
}
#[test]
fn test_segment_total_pixels() {
let spec = LedPanelSpec::new(256, 128, 2.8, 1500, 3840.0);
let seg = LedWallSegment::flat(1, 4, 2, spec);
assert_eq!(seg.total_pixels(), 4 * 2 * 256 * 128);
}
#[test]
fn test_volume_add_segment() {
let mut vol = LedVolume::new();
vol.add_segment(LedWallSegment::flat(1, 4, 2, sample_panel()));
assert_eq!(vol.segments.len(), 1);
}
#[test]
fn test_volume_total_nits_max_empty() {
let vol = LedVolume::new();
assert_eq!(vol.total_nits_max(), 0);
}
#[test]
fn test_volume_total_nits_max_segments() {
let mut vol = LedVolume::new();
let spec_hi = LedPanelSpec::new(256, 128, 2.8, 2000, 3840.0);
vol.add_segment(LedWallSegment::flat(1, 4, 2, sample_panel())); vol.add_segment(LedWallSegment::flat(2, 4, 2, spec_hi)); assert_eq!(vol.total_nits_max(), 2000);
}
#[test]
fn test_volume_has_ceiling_false() {
let vol = LedVolume::new();
assert!(!vol.has_ceiling());
}
#[test]
fn test_volume_has_ceiling_true() {
let mut vol = LedVolume::new();
vol.ceiling = Some(LedWallSegment::flat(99, 4, 2, sample_panel()));
assert!(vol.has_ceiling());
}
#[test]
fn test_volume_total_pixels_with_ceiling() {
let mut vol = LedVolume::new();
let seg = LedWallSegment::flat(1, 1, 1, LedPanelSpec::new(10, 10, 1.0, 1000, 60.0));
let ceil = LedWallSegment::flat(2, 1, 1, LedPanelSpec::new(10, 10, 1.0, 1000, 60.0));
vol.add_segment(seg);
vol.ceiling = Some(ceil);
assert_eq!(vol.total_pixels(), 200);
}
#[test]
fn test_moire_risk_assessor_range() {
let panel = sample_panel();
let risk = MoireRiskAssessor::assess(&panel, 5.0, 50.0);
assert!((0.0..=1.0).contains(&risk));
}
#[test]
fn test_moire_risk_assessor_degenerate() {
let panel = sample_panel();
let risk = MoireRiskAssessor::assess(&panel, 0.0, 50.0);
assert_eq!(risk, 1.0);
}
#[test]
fn test_led_panel_new_defaults() {
let p = LedPanel::new("P1", 320, 160, 2.5, 1800.0);
assert_eq!(p.id, "P1");
assert_eq!(p.width_pixels, 320);
assert_eq!(p.height_pixels, 160);
assert!((p.pixel_pitch_mm - 2.5).abs() < 1e-5);
assert!((p.nits_peak - 1800.0).abs() < 1e-5);
assert_eq!(p.color_gamut, PanelGamut::Rec709);
assert_eq!(p.position.face, WallFace::Front);
}
#[test]
fn test_led_panel_physical_width() {
let p = LedPanel::new("P2", 256, 128, 2.8, 1500.0);
let expected = 256.0_f32 * 2.8_f32;
assert!((p.physical_width_mm() - expected).abs() < 1e-3);
}
#[test]
fn test_led_panel_physical_height() {
let p = LedPanel::new("P3", 256, 128, 2.8, 1500.0);
let expected = 128.0_f32 * 2.8_f32;
assert!((p.physical_height_mm() - expected).abs() < 1e-3);
}
#[test]
fn test_led_panel_resolution_mpx() {
let p = LedPanel::new("P4", 1000, 1000, 1.0, 1000.0);
assert!((p.resolution_mpx() - 1.0).abs() < 1e-5);
}
#[test]
fn test_led_volume_v2_add_remove_panel() {
let mut vol = LedVolumeV2::new("V1", "Stage A");
let p = LedPanel::new("PA", 256, 128, 2.5, 1200.0);
vol.add_panel(p);
assert_eq!(vol.panels.len(), 1);
let removed = vol.remove_panel("PA");
assert!(removed);
assert!(vol.panels.is_empty());
}
#[test]
fn test_led_volume_v2_remove_nonexistent() {
let mut vol = LedVolumeV2::new("V2", "Stage B");
let removed = vol.remove_panel("ghost");
assert!(!removed);
}
#[test]
fn test_led_volume_v2_compute_total_resolution() {
let mut vol = LedVolumeV2::new("V3", "Stage C");
let mut p1 = LedPanel::new("F1", 256, 128, 2.5, 1200.0);
p1.position.face = WallFace::Front;
let mut p2 = LedPanel::new("F2", 256, 256, 2.5, 1200.0);
p2.position.face = WallFace::Front;
vol.add_panel(p1);
vol.add_panel(p2);
vol.compute_total_resolution();
assert_eq!(vol.total_width_pixels, 512);
assert_eq!(vol.total_height_pixels, 256);
}
#[test]
fn test_led_volume_v2_panels_by_face() {
let mut vol = LedVolumeV2::new("V4", "Stage D");
let mut lp = LedPanel::new("L1", 128, 128, 2.5, 1000.0);
lp.position.face = WallFace::Left;
vol.add_panel(LedPanel::new("F1", 256, 128, 2.5, 1000.0));
vol.add_panel(lp);
let left = vol.panels_by_face(&WallFace::Left);
assert_eq!(left.len(), 1);
assert_eq!(left[0].id, "L1");
}
#[test]
fn test_led_volume_v2_peak_nits_weakest_link() {
let mut vol = LedVolumeV2::new("V5", "Stage E");
vol.add_panel(LedPanel::new("A", 100, 100, 2.0, 2000.0));
vol.add_panel(LedPanel::new("B", 100, 100, 2.0, 800.0));
assert!((vol.peak_nits() - 800.0).abs() < 1e-5);
}
#[test]
fn test_led_volume_v2_requires_hdr() {
let mut vol = LedVolumeV2::new("V6", "Stage F");
vol.color_processing = ColorProcessingMode::PqHdr;
vol.add_panel(LedPanel::new("H1", 100, 100, 2.0, 1500.0));
assert!(vol.requires_hdr());
vol.color_processing = ColorProcessingMode::Linear;
assert!(!vol.requires_hdr());
}
#[test]
fn test_led_volume_v2_validate_empty() {
let vol = LedVolumeV2::new("V7", "Stage G");
let errors = vol.validate();
assert!(!errors.is_empty());
}
#[test]
fn test_led_volume_v2_validate_mismatched_refresh() {
let mut vol = LedVolumeV2::new("V8", "Stage H");
let mut p1 = LedPanel::new("R1", 128, 128, 2.5, 1000.0);
p1.refresh_rate_hz = 60.0;
let mut p2 = LedPanel::new("R2", 128, 128, 2.5, 1000.0);
p2.refresh_rate_hz = 120.0;
vol.add_panel(p1);
vol.add_panel(p2);
let errors = vol.validate();
assert!(errors.iter().any(|e| e.contains("refresh")));
}
#[test]
fn test_moire_checker_risk_score_range() {
let checker = MoireChecker {
camera_sensor_pixels: (6000, 4000),
lens_focal_length_mm: 50.0,
};
let panel = LedPanel::new("MC1", 256, 128, 2.8, 1500.0);
let score = checker.risk_score(&panel, 5.0);
assert!((0.0..=1.0).contains(&score));
}
#[test]
fn test_moire_checker_degenerate_distance() {
let checker = MoireChecker {
camera_sensor_pixels: (6000, 4000),
lens_focal_length_mm: 50.0,
};
let panel = LedPanel::new("MC2", 256, 128, 2.8, 1500.0);
assert_eq!(checker.risk_score(&panel, 0.0), 1.0);
}
}