Skip to main content

eulumdat_plugin/
lib.rs

1//! Eulumdat WASM Plugin
2//!
3//! A lightweight WASM plugin for photometric analysis that can be embedded in GLDF files
4//! and discovered by the plugin system. Provides parsing, calculations, and diagram generation.
5//!
6//! ## Plugin Manifest
7//!
8//! This plugin self-describes its capabilities via manifest.json, allowing the host
9//! to discover available functions dynamically.
10
11use eulumdat::{
12    diagram::{
13        ButterflyDiagram, CartesianDiagram, ConeDiagram, HeatmapDiagram, PolarDiagram, SvgTheme,
14    },
15    validate, BugDiagram, BugRating, CieFluxCodes, CuTable, Eulumdat, IesExporter, IesParser,
16    PhotometricCalculations, PhotometricSummary, UgrTable, ZonalLumens30, ZoneLumens,
17};
18use serde::{Deserialize, Serialize};
19use wasm_bindgen::prelude::*;
20
21// Initialize panic hook for better error messages
22#[wasm_bindgen(start)]
23pub fn init() {
24    console_error_panic_hook::set_once();
25}
26
27/// Plugin engine holding parsed photometric data
28#[wasm_bindgen]
29pub struct EulumdatEngine {
30    data: Option<Eulumdat>,
31}
32
33#[wasm_bindgen]
34impl EulumdatEngine {
35    /// Create a new engine instance
36    #[wasm_bindgen(constructor)]
37    pub fn new() -> Self {
38        Self { data: None }
39    }
40
41    /// Check if data is loaded
42    pub fn has_data(&self) -> bool {
43        self.data.is_some()
44    }
45
46    /// Parse LDT (EULUMDAT) file content
47    pub fn parse_ldt(&mut self, content: &str) -> Result<String, JsValue> {
48        let ldt = Eulumdat::parse(content).map_err(|e| JsValue::from_str(&e.to_string()))?;
49        let json = serde_json::to_string(&ldt).map_err(|e| JsValue::from_str(&e.to_string()))?;
50        self.data = Some(ldt);
51        Ok(json)
52    }
53
54    /// Parse IES file content
55    pub fn parse_ies(&mut self, content: &str) -> Result<String, JsValue> {
56        let ldt = IesParser::parse(content).map_err(|e| JsValue::from_str(&e.to_string()))?;
57        let json = serde_json::to_string(&ldt).map_err(|e| JsValue::from_str(&e.to_string()))?;
58        self.data = Some(ldt);
59        Ok(json)
60    }
61
62    /// Export current data to LDT format
63    pub fn export_ldt(&self) -> Result<String, JsValue> {
64        let ldt = self.get_data()?;
65        Ok(ldt.to_ldt())
66    }
67
68    /// Export current data to IES format
69    pub fn export_ies(&self) -> Result<String, JsValue> {
70        let ldt = self.get_data()?;
71        Ok(IesExporter::export(ldt))
72    }
73
74    /// Get luminaire name
75    pub fn get_name(&self) -> Result<String, JsValue> {
76        let ldt = self.get_data()?;
77        Ok(ldt.luminaire_name.clone())
78    }
79
80    /// Get current data as JSON
81    pub fn get_data_json(&self) -> Result<String, JsValue> {
82        let ldt = self.get_data()?;
83        serde_json::to_string(ldt).map_err(|e| JsValue::from_str(&e.to_string()))
84    }
85
86    /// Clear loaded data
87    pub fn clear(&mut self) {
88        self.data = None;
89    }
90
91    // =========================================================================
92    // Calculations
93    // =========================================================================
94
95    /// Calculate beam angle (angle at 50% max intensity)
96    pub fn beam_angle(&self) -> Result<f64, JsValue> {
97        let ldt = self.get_data()?;
98        Ok(PhotometricCalculations::beam_angle(ldt))
99    }
100
101    /// Calculate field angle (angle at 10% max intensity)
102    pub fn field_angle(&self) -> Result<f64, JsValue> {
103        let ldt = self.get_data()?;
104        Ok(PhotometricCalculations::field_angle(ldt))
105    }
106
107    /// Calculate half beam angle
108    pub fn half_beam_angle(&self) -> Result<f64, JsValue> {
109        let ldt = self.get_data()?;
110        Ok(PhotometricCalculations::half_beam_angle(ldt))
111    }
112
113    /// Calculate half field angle
114    pub fn half_field_angle(&self) -> Result<f64, JsValue> {
115        let ldt = self.get_data()?;
116        Ok(PhotometricCalculations::half_field_angle(ldt))
117    }
118
119    /// Calculate total luminous flux
120    pub fn total_flux(&self) -> Result<f64, JsValue> {
121        let ldt = self.get_data()?;
122        Ok(PhotometricCalculations::total_output(ldt))
123    }
124
125    /// Calculate downward flux to a specific angle
126    pub fn downward_flux(&self, angle: f64) -> Result<f64, JsValue> {
127        let ldt = self.get_data()?;
128        Ok(PhotometricCalculations::downward_flux(ldt, angle))
129    }
130
131    /// Calculate luminaire efficacy (lm/W)
132    pub fn efficacy(&self) -> Result<f64, JsValue> {
133        let ldt = self.get_data()?;
134        Ok(PhotometricCalculations::luminaire_efficacy(ldt))
135    }
136
137    /// Calculate luminaire efficiency (downward flux fraction)
138    pub fn efficiency(&self) -> Result<f64, JsValue> {
139        let ldt = self.get_data()?;
140        Ok(PhotometricCalculations::luminaire_efficiency(ldt))
141    }
142
143    /// Calculate cut-off angle
144    pub fn cut_off_angle(&self) -> Result<f64, JsValue> {
145        let ldt = self.get_data()?;
146        Ok(PhotometricCalculations::cut_off_angle(ldt))
147    }
148
149    /// Get photometric summary (all key metrics)
150    pub fn get_summary(&self) -> Result<String, JsValue> {
151        let ldt = self.get_data()?;
152        let summary = PhotometricSummary::from_eulumdat(ldt);
153        let wrapper = SummaryWrapper::from(&summary);
154        serde_json::to_string(&wrapper).map_err(|e| JsValue::from_str(&e.to_string()))
155    }
156
157    /// Get beam/field analysis
158    pub fn beam_field_analysis(&self) -> Result<String, JsValue> {
159        let ldt = self.get_data()?;
160        let analysis = PhotometricCalculations::beam_field_analysis(ldt);
161        let wrapper = BeamFieldWrapper::from(&analysis);
162        serde_json::to_string(&wrapper).map_err(|e| JsValue::from_str(&e.to_string()))
163    }
164
165    /// Calculate zonal lumens (30-degree zones)
166    pub fn zonal_lumens_30(&self) -> Result<String, JsValue> {
167        let ldt = self.get_data()?;
168        let zonal = PhotometricCalculations::zonal_lumens_30deg(ldt);
169        let wrapper = ZonalWrapper::from(&zonal);
170        serde_json::to_string(&wrapper).map_err(|e| JsValue::from_str(&e.to_string()))
171    }
172
173    /// Calculate CIE flux codes
174    pub fn cie_flux_codes(&self) -> Result<String, JsValue> {
175        let ldt = self.get_data()?;
176        let codes = PhotometricCalculations::cie_flux_codes(ldt);
177        let wrapper = CieFluxWrapper::from(&codes);
178        serde_json::to_string(&wrapper).map_err(|e| JsValue::from_str(&e.to_string()))
179    }
180
181    /// Calculate spacing criteria
182    pub fn spacing_criteria(&self) -> Result<String, JsValue> {
183        let ldt = self.get_data()?;
184        let (forward, backward) = PhotometricCalculations::spacing_criteria(ldt);
185        let result = SpacingCriteria { forward, backward };
186        serde_json::to_string(&result).map_err(|e| JsValue::from_str(&e.to_string()))
187    }
188
189    // =========================================================================
190    // Tables
191    // =========================================================================
192
193    /// Calculate coefficient of utilization (CU) table
194    pub fn cu_table(&self) -> Result<String, JsValue> {
195        let ldt = self.get_data()?;
196        let table = CuTable::calculate(ldt);
197        let wrapper = CuTableWrapper::from(&table);
198        serde_json::to_string(&wrapper).map_err(|e| JsValue::from_str(&e.to_string()))
199    }
200
201    /// Calculate UGR (Unified Glare Rating) table
202    pub fn ugr_table(&self) -> Result<String, JsValue> {
203        let ldt = self.get_data()?;
204        let table = UgrTable::calculate(ldt);
205        let wrapper = UgrTableWrapper::from(&table);
206        serde_json::to_string(&wrapper).map_err(|e| JsValue::from_str(&e.to_string()))
207    }
208
209    /// Calculate direct ratios for standard room indices
210    /// shr: Spacing to Height Ratio ("1.0", "1.5", "2.0", etc.)
211    pub fn direct_ratios(&self, shr: &str) -> Result<String, JsValue> {
212        let ldt = self.get_data()?;
213        let ratios = PhotometricCalculations::calculate_direct_ratios(ldt, shr);
214        serde_json::to_string(&ratios).map_err(|e| JsValue::from_str(&e.to_string()))
215    }
216
217    // =========================================================================
218    // BUG Rating
219    // =========================================================================
220
221    /// Calculate BUG rating
222    pub fn bug_rating(&self) -> Result<String, JsValue> {
223        let ldt = self.get_data()?;
224        let rating = BugRating::from_eulumdat(ldt);
225        serde_json::to_string(&rating).map_err(|e| JsValue::from_str(&e.to_string()))
226    }
227
228    /// Calculate zone lumens for BUG rating
229    pub fn zone_lumens(&self) -> Result<String, JsValue> {
230        let ldt = self.get_data()?;
231        let zones = ZoneLumens::from_eulumdat(ldt);
232        serde_json::to_string(&zones).map_err(|e| JsValue::from_str(&e.to_string()))
233    }
234
235    /// Get BUG diagram data
236    pub fn bug_diagram_data(&self) -> Result<String, JsValue> {
237        let ldt = self.get_data()?;
238        let diagram = BugDiagram::from_eulumdat(ldt);
239        serde_json::to_string(&diagram).map_err(|e| JsValue::from_str(&e.to_string()))
240    }
241
242    // =========================================================================
243    // Validation
244    // =========================================================================
245
246    /// Validate photometric data
247    pub fn validate(&self) -> Result<String, JsValue> {
248        let ldt = self.get_data()?;
249        let warnings = validate(ldt);
250        let wrappers: Vec<_> = warnings.iter().map(ValidationWrapper::from).collect();
251        serde_json::to_string(&wrappers).map_err(|e| JsValue::from_str(&e.to_string()))
252    }
253
254    // =========================================================================
255    // SVG Diagram Generation
256    // =========================================================================
257
258    /// Generate polar diagram SVG
259    pub fn polar_svg(&self, width: f64, height: f64, theme: &str) -> Result<String, JsValue> {
260        let ldt = self.get_data()?;
261        let diagram = PolarDiagram::from_eulumdat(ldt);
262        let theme = parse_theme(theme);
263        Ok(diagram.to_svg(width, height, &theme))
264    }
265
266    /// Generate Cartesian diagram SVG
267    pub fn cartesian_svg(
268        &self,
269        width: f64,
270        height: f64,
271        theme: &str,
272        max_curves: usize,
273    ) -> Result<String, JsValue> {
274        let ldt = self.get_data()?;
275        let diagram = CartesianDiagram::from_eulumdat(ldt, width, height, max_curves);
276        let theme = parse_theme(theme);
277        Ok(diagram.to_svg(width, height, &theme))
278    }
279
280    /// Generate butterfly diagram SVG
281    pub fn butterfly_svg(
282        &self,
283        width: f64,
284        height: f64,
285        theme: &str,
286        rotation: f64,
287    ) -> Result<String, JsValue> {
288        let ldt = self.get_data()?;
289        let diagram = ButterflyDiagram::from_eulumdat(ldt, width, height, rotation);
290        let theme = parse_theme(theme);
291        Ok(diagram.to_svg(width, height, &theme))
292    }
293
294    /// Generate heatmap diagram SVG
295    pub fn heatmap_svg(&self, width: f64, height: f64, theme: &str) -> Result<String, JsValue> {
296        let ldt = self.get_data()?;
297        let diagram = HeatmapDiagram::from_eulumdat(ldt, width, height);
298        let theme = parse_theme(theme);
299        Ok(diagram.to_svg(width, height, &theme))
300    }
301
302    /// Generate cone diagram SVG
303    /// mounting_height: luminaire mounting height in meters (default 3.0)
304    pub fn cone_svg(
305        &self,
306        width: f64,
307        height: f64,
308        theme: &str,
309        mounting_height: f64,
310    ) -> Result<String, JsValue> {
311        let ldt = self.get_data()?;
312        let diagram = ConeDiagram::from_eulumdat(ldt, mounting_height);
313        let theme = parse_theme(theme);
314        Ok(diagram.to_svg(width, height, &theme))
315    }
316
317    /// Generate BUG rating diagram SVG (TM-15-11)
318    pub fn bug_svg(&self, width: f64, height: f64, theme: &str) -> Result<String, JsValue> {
319        let ldt = self.get_data()?;
320        let diagram = BugDiagram::from_eulumdat(ldt);
321        let theme = parse_theme(theme);
322        Ok(diagram.to_svg(width, height, &theme))
323    }
324
325    /// Generate LCS classification diagram SVG (TM-15-07)
326    pub fn lcs_svg(&self, width: f64, height: f64, theme: &str) -> Result<String, JsValue> {
327        let ldt = self.get_data()?;
328        let diagram = BugDiagram::from_eulumdat(ldt);
329        let theme = parse_theme(theme);
330        Ok(diagram.to_lcs_svg(width, height, &theme))
331    }
332
333    // =========================================================================
334    // Helper
335    // =========================================================================
336
337    fn get_data(&self) -> Result<&Eulumdat, JsValue> {
338        self.data.as_ref().ok_or_else(|| {
339            JsValue::from_str("No data loaded. Call parse_ldt() or parse_ies() first.")
340        })
341    }
342}
343
344impl Default for EulumdatEngine {
345    fn default() -> Self {
346        Self::new()
347    }
348}
349
350// =============================================================================
351// Static Functions (stateless operations)
352// =============================================================================
353
354/// Parse LDT content (stateless)
355#[wasm_bindgen]
356pub fn parse_ldt(content: &str) -> Result<String, JsValue> {
357    let ldt = Eulumdat::parse(content).map_err(|e| JsValue::from_str(&e.to_string()))?;
358    serde_json::to_string(&ldt).map_err(|e| JsValue::from_str(&e.to_string()))
359}
360
361/// Parse IES content (stateless)
362#[wasm_bindgen]
363pub fn parse_ies(content: &str) -> Result<String, JsValue> {
364    let ldt = IesParser::parse(content).map_err(|e| JsValue::from_str(&e.to_string()))?;
365    serde_json::to_string(&ldt).map_err(|e| JsValue::from_str(&e.to_string()))
366}
367
368/// Validate LDT content (stateless)
369#[wasm_bindgen]
370pub fn validate_ldt(content: &str) -> String {
371    match Eulumdat::parse(content) {
372        Ok(ldt) => {
373            let warnings = validate(&ldt);
374            let wrappers: Vec<_> = warnings.iter().map(ValidationWrapper::from).collect();
375            serde_json::to_string(&wrappers).unwrap_or_else(|_| "[]".to_string())
376        }
377        Err(e) => {
378            let errors = vec![ValidationWrapper {
379                code: "PARSE_ERROR".to_string(),
380                message: e.to_string(),
381            }];
382            serde_json::to_string(&errors).unwrap_or_else(|_| "[]".to_string())
383        }
384    }
385}
386
387/// Convert LDT to IES (stateless)
388#[wasm_bindgen]
389pub fn ldt_to_ies(content: &str) -> Result<String, JsValue> {
390    let ldt = Eulumdat::parse(content).map_err(|e| JsValue::from_str(&e.to_string()))?;
391    Ok(IesExporter::export(&ldt))
392}
393
394/// Convert IES to LDT (stateless)
395#[wasm_bindgen]
396pub fn ies_to_ldt(content: &str) -> Result<String, JsValue> {
397    let ldt = IesParser::parse(content).map_err(|e| JsValue::from_str(&e.to_string()))?;
398    Ok(ldt.to_ldt())
399}
400
401/// Get plugin info
402#[wasm_bindgen]
403pub fn engine_info() -> String {
404    serde_json::to_string(&EngineInfo {
405        name: "Eulumdat Photometric Engine".to_string(),
406        version: env!("CARGO_PKG_VERSION").to_string(),
407        description: "Parse, analyze, and visualize LDT/IES photometric files".to_string(),
408    })
409    .unwrap()
410}
411
412// =============================================================================
413// Serializable Wrapper Types
414// =============================================================================
415
416#[derive(Serialize, Deserialize)]
417struct SpacingCriteria {
418    forward: f64,
419    backward: f64,
420}
421
422#[derive(Serialize, Deserialize)]
423struct EngineInfo {
424    name: String,
425    version: String,
426    description: String,
427}
428
429#[derive(Serialize, Deserialize)]
430struct ValidationWrapper {
431    code: String,
432    message: String,
433}
434
435impl From<&eulumdat::ValidationWarning> for ValidationWrapper {
436    fn from(w: &eulumdat::ValidationWarning) -> Self {
437        Self {
438            code: w.code.to_string(),
439            message: w.message.clone(),
440        }
441    }
442}
443
444#[derive(Serialize, Deserialize)]
445struct SummaryWrapper {
446    total_lamp_flux: f64,
447    calculated_flux: f64,
448    lor: f64,
449    dlor: f64,
450    ulor: f64,
451    lamp_efficacy: f64,
452    luminaire_efficacy: f64,
453    total_wattage: f64,
454    beam_angle: f64,
455    field_angle: f64,
456    beam_angle_cie: f64,
457    field_angle_cie: f64,
458    is_batwing: bool,
459    max_intensity: f64,
460    min_intensity: f64,
461    avg_intensity: f64,
462    spacing_c0: f64,
463    spacing_c90: f64,
464}
465
466impl From<&PhotometricSummary> for SummaryWrapper {
467    fn from(s: &PhotometricSummary) -> Self {
468        Self {
469            total_lamp_flux: s.total_lamp_flux,
470            calculated_flux: s.calculated_flux,
471            lor: s.lor,
472            dlor: s.dlor,
473            ulor: s.ulor,
474            lamp_efficacy: s.lamp_efficacy,
475            luminaire_efficacy: s.luminaire_efficacy,
476            total_wattage: s.total_wattage,
477            beam_angle: s.beam_angle,
478            field_angle: s.field_angle,
479            beam_angle_cie: s.beam_angle_cie,
480            field_angle_cie: s.field_angle_cie,
481            is_batwing: s.is_batwing,
482            max_intensity: s.max_intensity,
483            min_intensity: s.min_intensity,
484            avg_intensity: s.avg_intensity,
485            spacing_c0: s.spacing_c0,
486            spacing_c90: s.spacing_c90,
487        }
488    }
489}
490
491#[derive(Serialize, Deserialize)]
492struct BeamFieldWrapper {
493    beam_angle_ies: f64,
494    field_angle_ies: f64,
495    beam_angle_cie: f64,
496    field_angle_cie: f64,
497    max_intensity: f64,
498    center_intensity: f64,
499    max_intensity_gamma: f64,
500    is_batwing: bool,
501    beam_threshold_ies: f64,
502    beam_threshold_cie: f64,
503    field_threshold_ies: f64,
504    field_threshold_cie: f64,
505}
506
507impl From<&eulumdat::BeamFieldAnalysis> for BeamFieldWrapper {
508    fn from(a: &eulumdat::BeamFieldAnalysis) -> Self {
509        Self {
510            beam_angle_ies: a.beam_angle_ies,
511            field_angle_ies: a.field_angle_ies,
512            beam_angle_cie: a.beam_angle_cie,
513            field_angle_cie: a.field_angle_cie,
514            max_intensity: a.max_intensity,
515            center_intensity: a.center_intensity,
516            max_intensity_gamma: a.max_intensity_gamma,
517            is_batwing: a.is_batwing,
518            beam_threshold_ies: a.beam_threshold_ies,
519            beam_threshold_cie: a.beam_threshold_cie,
520            field_threshold_ies: a.field_threshold_ies,
521            field_threshold_cie: a.field_threshold_cie,
522        }
523    }
524}
525
526#[derive(Serialize, Deserialize)]
527struct ZonalWrapper {
528    zone_0_30: f64,
529    zone_30_60: f64,
530    zone_60_90: f64,
531    zone_90_120: f64,
532    zone_120_150: f64,
533    zone_150_180: f64,
534    downward_total: f64,
535    upward_total: f64,
536}
537
538impl From<&ZonalLumens30> for ZonalWrapper {
539    fn from(z: &ZonalLumens30) -> Self {
540        Self {
541            zone_0_30: z.zone_0_30,
542            zone_30_60: z.zone_30_60,
543            zone_60_90: z.zone_60_90,
544            zone_90_120: z.zone_90_120,
545            zone_120_150: z.zone_120_150,
546            zone_150_180: z.zone_150_180,
547            downward_total: z.downward_total(),
548            upward_total: z.upward_total(),
549        }
550    }
551}
552
553#[derive(Serialize, Deserialize)]
554struct CieFluxWrapper {
555    n1: f64,
556    n2: f64,
557    n3: f64,
558    n4: f64,
559    n5: f64,
560}
561
562impl From<&CieFluxCodes> for CieFluxWrapper {
563    fn from(c: &CieFluxCodes) -> Self {
564        Self {
565            n1: c.n1,
566            n2: c.n2,
567            n3: c.n3,
568            n4: c.n4,
569            n5: c.n5,
570        }
571    }
572}
573
574#[derive(Serialize, Deserialize)]
575struct CuTableWrapper {
576    rcr_values: Vec<u8>,
577    reflectances: Vec<ReflectanceSet>,
578    values: Vec<Vec<f64>>,
579}
580
581#[derive(Serialize, Deserialize)]
582struct ReflectanceSet {
583    ceiling: u8,
584    wall: u8,
585    floor: u8,
586}
587
588impl From<&CuTable> for CuTableWrapper {
589    fn from(t: &CuTable) -> Self {
590        Self {
591            rcr_values: t.rcr_values.clone(),
592            reflectances: t
593                .reflectances
594                .iter()
595                .map(|r| ReflectanceSet {
596                    ceiling: r.0,
597                    wall: r.1,
598                    floor: r.2,
599                })
600                .collect(),
601            values: t.values.clone(),
602        }
603    }
604}
605
606#[derive(Serialize, Deserialize)]
607struct UgrTableWrapper {
608    room_sizes: Vec<RoomSize>,
609    reflectances: Vec<ReflectanceSet>,
610    crosswise: Vec<Vec<f64>>,
611    endwise: Vec<Vec<f64>>,
612    max_ugr: f64,
613}
614
615#[derive(Serialize, Deserialize)]
616struct RoomSize {
617    x: f64,
618    y: f64,
619}
620
621impl From<&UgrTable> for UgrTableWrapper {
622    fn from(t: &UgrTable) -> Self {
623        Self {
624            room_sizes: t
625                .room_sizes
626                .iter()
627                .map(|r| RoomSize { x: r.0, y: r.1 })
628                .collect(),
629            reflectances: t
630                .reflectances
631                .iter()
632                .map(|r| ReflectanceSet {
633                    ceiling: r.0,
634                    wall: r.1,
635                    floor: r.2,
636                })
637                .collect(),
638            crosswise: t.crosswise.clone(),
639            endwise: t.endwise.clone(),
640            max_ugr: t.max_ugr,
641        }
642    }
643}
644
645/// Parse theme string to SvgTheme
646fn parse_theme(theme: &str) -> SvgTheme {
647    match theme.to_lowercase().as_str() {
648        "dark" => SvgTheme::dark(),
649        "css" => SvgTheme::css_variables(),
650        _ => SvgTheme::light(),
651    }
652}
653
654// =============================================================================
655// Tests
656// =============================================================================
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn test_engine_creation() {
664        let engine = EulumdatEngine::new();
665        assert!(!engine.has_data());
666    }
667
668    #[test]
669    fn test_engine_info() {
670        let info = engine_info();
671        assert!(info.contains("Eulumdat"));
672    }
673}