gldf_ffi/
lib.rs

1// gldf-rs-ffi/src/lib.rs
2//! FFI bindings for GLDF library
3//! Provides iOS/macOS/Android support via UniFFI
4
5use serde::Deserialize;
6use std::io::{Cursor, Read};
7use std::sync::{Arc, RwLock};
8use zip::ZipArchive;
9
10// Setup UniFFI scaffolding
11uniffi::setup_scaffolding!();
12
13// -----------------------------------------------------------------------------
14// Error Handling
15// -----------------------------------------------------------------------------
16
17#[derive(Debug, uniffi::Error, thiserror::Error)]
18pub enum GldfError {
19    #[error("Failed to parse GLDF: {msg}")]
20    ParseError { msg: String },
21
22    #[error("Failed to serialize: {msg}")]
23    SerializeError { msg: String },
24
25    #[error("File not found: {msg}")]
26    FileNotFound { msg: String },
27
28    #[error("Invalid data: {msg}")]
29    InvalidData { msg: String },
30}
31
32// -----------------------------------------------------------------------------
33// DTOs (Data Transfer Objects)
34// -----------------------------------------------------------------------------
35
36/// Header information from GLDF file
37#[derive(uniffi::Record, Debug, Clone)]
38pub struct GldfHeader {
39    pub manufacturer: String,
40    pub author: String,
41    pub format_version: String,
42    pub created_with_application: String,
43    pub creation_time_code: String,
44}
45
46/// File definition from GLDF
47#[derive(uniffi::Record, Debug, Clone)]
48pub struct GldfFile {
49    pub id: String,
50    pub file_name: String,
51    pub content_type: String,
52    pub file_type: String, // "localFileName" or "url"
53}
54
55/// Light source information (simplified - covers both fixed and changeable)
56#[derive(uniffi::Record, Debug, Clone)]
57pub struct GldfLightSource {
58    pub id: String,
59    pub name: String,
60    pub light_source_type: String, // "fixed" or "changeable"
61}
62
63/// Product variant information
64#[derive(uniffi::Record, Debug, Clone)]
65pub struct GldfVariant {
66    pub id: String,
67    pub name: String,
68    pub description: String,
69}
70
71/// Statistics about loaded GLDF
72#[derive(uniffi::Record, Debug, Clone)]
73pub struct GldfStats {
74    pub files_count: u64,
75    pub fixed_light_sources_count: u64,
76    pub changeable_light_sources_count: u64,
77    pub variants_count: u64,
78    pub photometries_count: u64,
79    pub simple_geometries_count: u64,
80    pub model_geometries_count: u64,
81}
82
83// -----------------------------------------------------------------------------
84// Main Engine
85// -----------------------------------------------------------------------------
86
87/// Extracted file content from GLDF archive
88#[derive(uniffi::Record, Debug, Clone)]
89pub struct GldfFileContent {
90    pub file_id: String,
91    pub file_name: String,
92    pub content_type: String,
93    pub data: Vec<u8>,
94}
95
96/// GLDF Engine for parsing and manipulating GLDF files
97#[derive(uniffi::Object)]
98pub struct GldfEngine {
99    product: RwLock<gldf_rs::GldfProduct>,
100    raw_data: RwLock<Option<Vec<u8>>>,
101    is_modified: RwLock<bool>,
102}
103
104#[uniffi::export]
105impl GldfEngine {
106    // =========================================================================
107    // Constructors
108    // =========================================================================
109
110    /// Create a new GLDF engine from raw GLDF file bytes (ZIP archive)
111    #[uniffi::constructor]
112    pub fn from_bytes(data: Vec<u8>) -> Result<Arc<Self>, GldfError> {
113        let product = gldf_rs::GldfProduct::load_gldf_from_buf(data.clone())
114            .map_err(|e| GldfError::ParseError { msg: e.to_string() })?;
115
116        Ok(Arc::new(Self {
117            product: RwLock::new(product),
118            raw_data: RwLock::new(Some(data)),
119            is_modified: RwLock::new(false),
120        }))
121    }
122
123    /// Create a new GLDF engine from JSON string
124    #[uniffi::constructor]
125    pub fn from_json(json: String) -> Result<Arc<Self>, GldfError> {
126        let product = gldf_rs::GldfProduct::from_json(&json)
127            .map_err(|e| GldfError::ParseError { msg: e.to_string() })?;
128
129        Ok(Arc::new(Self {
130            product: RwLock::new(product),
131            raw_data: RwLock::new(None),
132            is_modified: RwLock::new(false),
133        }))
134    }
135
136    /// Create a new empty GLDF engine
137    #[uniffi::constructor]
138    pub fn new_empty() -> Arc<Self> {
139        Arc::new(Self {
140            product: RwLock::new(gldf_rs::GldfProduct::default()),
141            raw_data: RwLock::new(None),
142            is_modified: RwLock::new(false),
143        })
144    }
145
146    // =========================================================================
147    // Read Methods
148    // =========================================================================
149
150    /// Check if the product has been modified
151    pub fn is_modified(&self) -> bool {
152        *self.is_modified.read().unwrap()
153    }
154
155    /// Get header information
156    pub fn get_header(&self) -> GldfHeader {
157        let product = self.product.read().unwrap();
158        GldfHeader {
159            manufacturer: product.header.manufacturer.clone(),
160            author: product.header.author.clone(),
161            format_version: product.header.format_version.to_version_string(),
162            created_with_application: product.header.created_with_application.clone(),
163            creation_time_code: product.header.creation_time_code.clone(),
164        }
165    }
166
167    /// Get all file definitions
168    pub fn get_files(&self) -> Vec<GldfFile> {
169        let product = self.product.read().unwrap();
170        product
171            .general_definitions
172            .files
173            .file
174            .iter()
175            .map(|f| GldfFile {
176                id: f.id.clone(),
177                file_name: f.file_name.clone(),
178                content_type: f.content_type.clone(),
179                file_type: f.type_attr.clone(),
180            })
181            .collect()
182    }
183
184    /// Get photometric files (LDT, IES)
185    pub fn get_photometric_files(&self) -> Vec<GldfFile> {
186        let product = self.product.read().unwrap();
187        product
188            .general_definitions
189            .files
190            .file
191            .iter()
192            .filter(|f| f.content_type.starts_with("ldc"))
193            .map(|f| GldfFile {
194                id: f.id.clone(),
195                file_name: f.file_name.clone(),
196                content_type: f.content_type.clone(),
197                file_type: f.type_attr.clone(),
198            })
199            .collect()
200    }
201
202    /// Get image files
203    pub fn get_image_files(&self) -> Vec<GldfFile> {
204        let product = self.product.read().unwrap();
205        product
206            .general_definitions
207            .files
208            .file
209            .iter()
210            .filter(|f| f.content_type.starts_with("image"))
211            .map(|f| GldfFile {
212                id: f.id.clone(),
213                file_name: f.file_name.clone(),
214                content_type: f.content_type.clone(),
215                file_type: f.type_attr.clone(),
216            })
217            .collect()
218    }
219
220    /// Get geometry (L3D) files
221    pub fn get_geometry_files(&self) -> Vec<GldfFile> {
222        let product = self.product.read().unwrap();
223        product
224            .general_definitions
225            .files
226            .file
227            .iter()
228            .filter(|f| f.content_type == "geo/l3d")
229            .map(|f| GldfFile {
230                id: f.id.clone(),
231                file_name: f.file_name.clone(),
232                content_type: f.content_type.clone(),
233                file_type: f.type_attr.clone(),
234            })
235            .collect()
236    }
237
238    /// Get light sources (both fixed and changeable)
239    pub fn get_light_sources(&self) -> Vec<GldfLightSource> {
240        let product = self.product.read().unwrap();
241        let mut result = Vec::new();
242
243        if let Some(ref ls) = product.general_definitions.light_sources {
244            for fixed in &ls.fixed_light_source {
245                result.push(GldfLightSource {
246                    id: fixed.id.clone(),
247                    name: fixed
248                        .name
249                        .locale
250                        .first()
251                        .map(|n| n.value.clone())
252                        .unwrap_or_default(),
253                    light_source_type: "fixed".to_string(),
254                });
255            }
256
257            for changeable in &ls.changeable_light_source {
258                result.push(GldfLightSource {
259                    id: changeable.id.clone(),
260                    name: changeable.name.value.clone(),
261                    light_source_type: "changeable".to_string(),
262                });
263            }
264        }
265
266        result
267    }
268
269    /// Get product variants
270    pub fn get_variants(&self) -> Vec<GldfVariant> {
271        let product = self.product.read().unwrap();
272        product
273            .product_definitions
274            .variants
275            .as_ref()
276            .map(|variants| {
277                variants
278                    .variant
279                    .iter()
280                    .map(|v| GldfVariant {
281                        id: v.id.clone(),
282                        name: v
283                            .name
284                            .as_ref()
285                            .and_then(|n| n.locale.first())
286                            .map(|l| l.value.clone())
287                            .unwrap_or_default(),
288                        description: v
289                            .description
290                            .as_ref()
291                            .and_then(|d| d.locale.first())
292                            .map(|l| l.value.clone())
293                            .unwrap_or_default(),
294                    })
295                    .collect()
296            })
297            .unwrap_or_default()
298    }
299
300    /// Get statistics about the loaded GLDF
301    pub fn get_stats(&self) -> GldfStats {
302        let product = self.product.read().unwrap();
303        let ls = product.general_definitions.light_sources.as_ref();
304
305        GldfStats {
306            files_count: product.general_definitions.files.file.len() as u64,
307            fixed_light_sources_count: ls.map(|l| l.fixed_light_source.len()).unwrap_or(0) as u64,
308            changeable_light_sources_count: ls.map(|l| l.changeable_light_source.len()).unwrap_or(0)
309                as u64,
310            variants_count: product
311                .product_definitions
312                .variants
313                .as_ref()
314                .map(|v| v.variant.len())
315                .unwrap_or(0) as u64,
316            photometries_count: product
317                .general_definitions
318                .photometries
319                .as_ref()
320                .map(|p| p.photometry.len())
321                .unwrap_or(0) as u64,
322            simple_geometries_count: product
323                .general_definitions
324                .geometries
325                .as_ref()
326                .map(|g| g.simple_geometry.len())
327                .unwrap_or(0) as u64,
328            model_geometries_count: product
329                .general_definitions
330                .geometries
331                .as_ref()
332                .map(|g| g.model_geometry.len())
333                .unwrap_or(0) as u64,
334        }
335    }
336
337    // =========================================================================
338    // File Extraction Methods
339    // =========================================================================
340
341    /// Check if raw archive data is available for file extraction
342    pub fn has_archive_data(&self) -> bool {
343        self.raw_data.read().unwrap().is_some()
344    }
345
346    /// Extract file content by file ID
347    /// Returns the binary content of the file from the GLDF archive
348    pub fn get_file_content(&self, file_id: String) -> Result<GldfFileContent, GldfError> {
349        let raw_data = self.raw_data.read().unwrap();
350        let data = raw_data.as_ref().ok_or_else(|| GldfError::InvalidData {
351            msg: "No archive data available (loaded from JSON)".to_string(),
352        })?;
353
354        // Find the file definition
355        let product = self.product.read().unwrap();
356        let file_def = product
357            .general_definitions
358            .files
359            .file
360            .iter()
361            .find(|f| f.id == file_id)
362            .ok_or_else(|| GldfError::FileNotFound {
363                msg: format!("File with ID '{}' not found", file_id),
364            })?;
365
366        let file_name = file_def.file_name.clone();
367        let content_type = file_def.content_type.clone();
368
369        // Determine the path in the archive based on content type
370        let archive_path = get_archive_path(&content_type, &file_name);
371        drop(product);
372
373        // Extract from ZIP
374        let cursor = Cursor::new(data.clone());
375        let mut zip = ZipArchive::new(cursor)
376            .map_err(|e: zip::result::ZipError| GldfError::ParseError { msg: e.to_string() })?;
377
378        let mut zip_file = zip
379            .by_name(&archive_path)
380            .map_err(|_| GldfError::FileNotFound {
381                msg: format!("File '{}' not found in archive", archive_path),
382            })?;
383
384        let mut content = Vec::new();
385        zip_file
386            .read_to_end(&mut content)
387            .map_err(|e: std::io::Error| GldfError::ParseError { msg: e.to_string() })?;
388
389        Ok(GldfFileContent {
390            file_id,
391            file_name,
392            content_type,
393            data: content,
394        })
395    }
396
397    /// Extract file content as string (for text-based files like LDT, IES)
398    pub fn get_file_content_as_string(&self, file_id: String) -> Result<String, GldfError> {
399        let content = self.get_file_content(file_id)?;
400        String::from_utf8(content.data).map_err(|e| GldfError::InvalidData { msg: e.to_string() })
401    }
402
403    /// List all files in the archive with their paths
404    pub fn list_archive_files(&self) -> Result<Vec<String>, GldfError> {
405        let raw_data = self.raw_data.read().unwrap();
406        let data = raw_data.as_ref().ok_or_else(|| GldfError::InvalidData {
407            msg: "No archive data available".to_string(),
408        })?;
409
410        let cursor = Cursor::new(data.clone());
411        let mut zip = ZipArchive::new(cursor)
412            .map_err(|e: zip::result::ZipError| GldfError::ParseError { msg: e.to_string() })?;
413
414        let mut result = Vec::new();
415        for i in 0..zip.len() {
416            if let Ok(file) = zip.by_index(i) {
417                result.push(file.name().to_string());
418            }
419        }
420        Ok(result)
421    }
422
423    /// Extract raw file from archive by path
424    pub fn get_archive_file(&self, path: String) -> Result<Vec<u8>, GldfError> {
425        let raw_data = self.raw_data.read().unwrap();
426        let data = raw_data.as_ref().ok_or_else(|| GldfError::InvalidData {
427            msg: "No archive data available".to_string(),
428        })?;
429
430        let cursor = Cursor::new(data.clone());
431        let mut zip = ZipArchive::new(cursor)
432            .map_err(|e: zip::result::ZipError| GldfError::ParseError { msg: e.to_string() })?;
433
434        let mut zip_file = zip.by_name(&path).map_err(|_| GldfError::FileNotFound {
435            msg: format!("File '{}' not found in archive", path),
436        })?;
437
438        let mut content = Vec::new();
439        zip_file
440            .read_to_end(&mut content)
441            .map_err(|e: std::io::Error| GldfError::ParseError { msg: e.to_string() })?;
442
443        Ok(content)
444    }
445
446    // =========================================================================
447    // Edit Methods - Header
448    // =========================================================================
449
450    /// Set the author
451    pub fn set_author(&self, author: String) {
452        let mut product = self.product.write().unwrap();
453        product.header.author = author;
454        *self.is_modified.write().unwrap() = true;
455    }
456
457    /// Set the manufacturer
458    pub fn set_manufacturer(&self, manufacturer: String) {
459        let mut product = self.product.write().unwrap();
460        product.header.manufacturer = manufacturer;
461        *self.is_modified.write().unwrap() = true;
462    }
463
464    /// Set the creation time code
465    pub fn set_creation_time_code(&self, time_code: String) {
466        let mut product = self.product.write().unwrap();
467        product.header.creation_time_code = time_code;
468        *self.is_modified.write().unwrap() = true;
469    }
470
471    /// Set the created with application
472    pub fn set_created_with_application(&self, app: String) {
473        let mut product = self.product.write().unwrap();
474        product.header.created_with_application = app;
475        *self.is_modified.write().unwrap() = true;
476    }
477
478    /// Set the default language
479    pub fn set_default_language(&self, language: Option<String>) {
480        let mut product = self.product.write().unwrap();
481        product.header.default_language = language;
482        *self.is_modified.write().unwrap() = true;
483    }
484
485    /// Set the format version (e.g., "1.0.0-rc.3")
486    pub fn set_format_version(&self, version: String) {
487        use gldf_rs::gldf::FormatVersion;
488        let mut product = self.product.write().unwrap();
489        product.header.format_version = FormatVersion::from_string(&version);
490        *self.is_modified.write().unwrap() = true;
491    }
492
493    // =========================================================================
494    // Edit Methods - Files
495    // =========================================================================
496
497    /// Add a file definition
498    pub fn add_file(&self, id: String, file_name: String, content_type: String, file_type: String) {
499        use gldf_rs::gldf::general_definitions::files::File;
500        let mut product = self.product.write().unwrap();
501        product.general_definitions.files.file.push(File {
502            id,
503            file_name,
504            content_type,
505            type_attr: file_type,
506            language: String::new(),
507        });
508        *self.is_modified.write().unwrap() = true;
509    }
510
511    /// Remove a file by ID
512    pub fn remove_file(&self, id: String) {
513        let mut product = self.product.write().unwrap();
514        product
515            .general_definitions
516            .files
517            .file
518            .retain(|f| f.id != id);
519        *self.is_modified.write().unwrap() = true;
520    }
521
522    /// Update a file definition
523    pub fn update_file(
524        &self,
525        id: String,
526        file_name: String,
527        content_type: String,
528        file_type: String,
529    ) {
530        let mut product = self.product.write().unwrap();
531        if let Some(file) = product
532            .general_definitions
533            .files
534            .file
535            .iter_mut()
536            .find(|f| f.id == id)
537        {
538            file.file_name = file_name;
539            file.content_type = content_type;
540            file.type_attr = file_type;
541            *self.is_modified.write().unwrap() = true;
542        }
543    }
544
545    // =========================================================================
546    // Export Methods
547    // =========================================================================
548
549    /// Export to JSON string
550    pub fn to_json(&self) -> Result<String, GldfError> {
551        let product = self.product.read().unwrap();
552        product
553            .to_json()
554            .map_err(|e| GldfError::SerializeError { msg: e.to_string() })
555    }
556
557    /// Export to pretty JSON string
558    pub fn to_pretty_json(&self) -> Result<String, GldfError> {
559        let product = self.product.read().unwrap();
560        product
561            .to_pretty_json()
562            .map_err(|e| GldfError::SerializeError { msg: e.to_string() })
563    }
564
565    /// Export to XML string
566    pub fn to_xml(&self) -> Result<String, GldfError> {
567        let product = self.product.read().unwrap();
568        product
569            .to_xml()
570            .map_err(|e| GldfError::SerializeError { msg: e.to_string() })
571    }
572
573    /// Mark as saved (clears modified flag)
574    pub fn mark_saved(&self) {
575        *self.is_modified.write().unwrap() = false;
576    }
577}
578
579// -----------------------------------------------------------------------------
580// Utility Functions
581// -----------------------------------------------------------------------------
582
583/// Parse GLDF from bytes and return JSON string
584#[uniffi::export]
585pub fn gldf_to_json(data: Vec<u8>) -> Result<String, GldfError> {
586    let engine = GldfEngine::from_bytes(data)?;
587    engine.to_json()
588}
589
590/// Get GLDF library version string
591#[uniffi::export]
592pub fn gldf_library_version() -> String {
593    env!("CARGO_PKG_VERSION").to_string()
594}
595
596// -----------------------------------------------------------------------------
597// Internal Helper Functions
598// -----------------------------------------------------------------------------
599
600/// Helper to determine archive path from content type
601fn get_archive_path(content_type: &str, file_name: &str) -> String {
602    let folder = if content_type.starts_with("ldc") {
603        "ldc"
604    } else if content_type.starts_with("image") {
605        "image"
606    } else if content_type == "geo/l3d" {
607        "geo"
608    } else if content_type.starts_with("document") {
609        "document"
610    } else if content_type.starts_with("sensor") {
611        "sensor"
612    } else if content_type.starts_with("symbol") {
613        "symbol"
614    } else if content_type.starts_with("spectrum") {
615        "spectrum"
616    } else {
617        "other"
618    };
619    format!("{}/{}", folder, file_name)
620}
621
622// =============================================================================
623// EULUMDAT (LDT) Parser
624// =============================================================================
625
626/// Parsed EULUMDAT photometric data
627#[derive(uniffi::Record, Debug, Clone, Default)]
628pub struct EulumdatData {
629    /// Manufacturer/Header string
630    pub manufacturer: String,
631    /// Luminaire name
632    pub luminaire_name: String,
633    /// Luminaire number
634    pub luminaire_number: String,
635    /// Lamp type description
636    pub lamp_type: String,
637    /// Total luminous flux in lumens
638    pub total_lumens: f64,
639    /// Light Output Ratio Luminaire (%)
640    pub lorl: f64,
641    /// Number of C planes
642    pub c_plane_count: i32,
643    /// Number of gamma angles
644    pub gamma_count: i32,
645    /// Symmetry indicator (0-4)
646    pub symmetry: i32,
647    /// C plane angles in degrees
648    pub c_angles: Vec<f64>,
649    /// Gamma angles in degrees
650    pub gamma_angles: Vec<f64>,
651    /// Intensity values organized by C plane, then by gamma
652    /// Access as: intensities[c_plane_index * gamma_count + gamma_index]
653    pub intensities: Vec<f64>,
654    /// Maximum intensity value (for normalization)
655    pub max_intensity: f64,
656    /// Unit conversion factor
657    pub conversion_factor: f64,
658    /// Luminaire dimensions in mm [length, width, height]
659    pub luminaire_dimensions: Vec<f64>,
660    /// Luminous area dimensions in mm [length, width]
661    pub luminous_area_dimensions: Vec<f64>,
662    /// Downward flux fraction (%)
663    pub dff: f64,
664    /// Color temperature (K)
665    pub color_temperature: String,
666    /// Wattage (W)
667    pub wattage: f64,
668}
669
670/// Parse EULUMDAT (LDT) file from string content
671#[uniffi::export]
672pub fn parse_eulumdat(content: String) -> EulumdatData {
673    let lines: Vec<&str> = content.lines().map(|l| l.trim()).collect();
674
675    let mut data = EulumdatData::default();
676
677    if lines.len() < 27 {
678        return data;
679    }
680
681    // Parse header (lines are 1-indexed in spec, 0-indexed here)
682    data.manufacturer = lines.first().unwrap_or(&"").to_string();
683    data.symmetry = lines.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
684    data.c_plane_count = lines.get(3).and_then(|s| s.parse().ok()).unwrap_or(0);
685    let _dc = lines
686        .get(4)
687        .and_then(|s| s.parse::<f64>().ok())
688        .unwrap_or(0.0);
689    data.gamma_count = lines.get(5).and_then(|s| s.parse().ok()).unwrap_or(0);
690    let _dg = lines
691        .get(6)
692        .and_then(|s| s.parse::<f64>().ok())
693        .unwrap_or(0.0);
694    data.luminaire_name = lines.get(8).unwrap_or(&"").to_string();
695    data.luminaire_number = lines.get(9).unwrap_or(&"").to_string();
696
697    // Luminaire dimensions
698    let l_length = lines.get(12).and_then(|s| s.parse().ok()).unwrap_or(0.0);
699    let l_width = lines.get(13).and_then(|s| s.parse().ok()).unwrap_or(0.0);
700    let l_height = lines.get(14).and_then(|s| s.parse().ok()).unwrap_or(0.0);
701    data.luminaire_dimensions = vec![l_length, l_width, l_height];
702
703    // Luminous area
704    let la_length = lines.get(15).and_then(|s| s.parse().ok()).unwrap_or(0.0);
705    let la_width = lines.get(16).and_then(|s| s.parse().ok()).unwrap_or(0.0);
706    data.luminous_area_dimensions = vec![la_length, la_width];
707
708    // DFF and LORL
709    data.dff = lines.get(21).and_then(|s| s.parse().ok()).unwrap_or(0.0);
710    data.lorl = lines.get(22).and_then(|s| s.parse().ok()).unwrap_or(100.0);
711    data.conversion_factor = lines.get(23).and_then(|s| s.parse().ok()).unwrap_or(1.0);
712
713    // Number of lamp sets
714    let n_lamp_sets: usize = lines.get(25).and_then(|s| s.parse().ok()).unwrap_or(1);
715
716    // Lamp section starts at line 27 (index 26)
717    let lamp_section_start = 26;
718    let n_lamp_params = 6;
719
720    // Get lamp info from first lamp set
721    if lamp_section_start + 1 < lines.len() {
722        data.lamp_type = lines[lamp_section_start + 1].to_string();
723    }
724    if lamp_section_start + 2 < lines.len() {
725        data.total_lumens = lines[lamp_section_start + 2].parse().unwrap_or(0.0);
726    }
727    if lamp_section_start + 3 < lines.len() {
728        data.color_temperature = lines[lamp_section_start + 3].to_string();
729    }
730    if lamp_section_start + 5 < lines.len() {
731        data.wattage = lines[lamp_section_start + 5].parse().unwrap_or(0.0);
732    }
733
734    // Calculate offsets
735    let direct_ratios_start = lamp_section_start + n_lamp_params * n_lamp_sets;
736    let c_angles_start = direct_ratios_start + 10;
737    let g_angles_start = c_angles_start + data.c_plane_count as usize;
738    let intensities_start = g_angles_start + data.gamma_count as usize;
739
740    // Parse C angles
741    for i in 0..data.c_plane_count as usize {
742        let idx = c_angles_start + i;
743        if idx < lines.len() {
744            if let Ok(angle) = lines[idx].parse() {
745                data.c_angles.push(angle);
746            }
747        }
748    }
749
750    // Parse gamma angles
751    for i in 0..data.gamma_count as usize {
752        let idx = g_angles_start + i;
753        if idx < lines.len() {
754            if let Ok(angle) = lines[idx].parse() {
755                data.gamma_angles.push(angle);
756            }
757        }
758    }
759
760    // Calculate actual number of C planes with data based on symmetry
761    let (mc1, mc2) = calculate_mc_range(data.symmetry, data.c_plane_count);
762    let actual_c_planes = (mc2 - mc1 + 1) as usize;
763
764    // Parse intensities
765    let mut max_val: f64 = 0.0;
766    let mut line_idx = intensities_start;
767
768    for _ in 0..actual_c_planes {
769        for _ in 0..data.gamma_count as usize {
770            if line_idx < lines.len() {
771                if let Ok(intensity) = lines[line_idx].parse::<f64>() {
772                    data.intensities.push(intensity);
773                    if intensity > max_val {
774                        max_val = intensity;
775                    }
776                }
777            }
778            line_idx += 1;
779        }
780    }
781
782    data.max_intensity = if max_val > 0.0 { max_val } else { 1000.0 };
783
784    data
785}
786
787/// Calculate mc1 and mc2 range based on symmetry
788fn calculate_mc_range(symmetry: i32, n_c_planes: i32) -> (i32, i32) {
789    match symmetry {
790        0 => (1, n_c_planes),         // No symmetry
791        1 => (1, 1),                  // Symmetry about vertical axis
792        2 => (1, n_c_planes / 2 + 1), // C0-C180 plane symmetry
793        3 => {
794            // C90-C270 plane symmetry
795            let mc1 = 3 * (n_c_planes / 4) + 1;
796            (mc1, mc1 + n_c_planes / 2)
797        }
798        4 => (1, n_c_planes / 4 + 1), // C0-C180 and C90-C270 symmetry
799        _ => (1, n_c_planes.max(1)),
800    }
801}
802
803/// Parse EULUMDAT from raw bytes
804#[uniffi::export]
805pub fn parse_eulumdat_bytes(data: Vec<u8>) -> EulumdatData {
806    let content = String::from_utf8_lossy(&data).to_string();
807    parse_eulumdat(content)
808}
809
810// =============================================================================
811// L3D (3D Geometry) Parser
812// =============================================================================
813
814/// 3D vector
815#[derive(uniffi::Record, Debug, Clone, Default)]
816pub struct Vec3 {
817    pub x: f64,
818    pub y: f64,
819    pub z: f64,
820}
821
822/// 4x4 transformation matrix (column-major for OpenGL/Metal/SceneKit)
823#[derive(uniffi::Record, Debug, Clone)]
824pub struct Matrix4 {
825    /// Matrix values in column-major order (m00, m10, m20, m30, m01, m11, ...)
826    pub values: Vec<f64>,
827}
828
829impl Default for Matrix4 {
830    fn default() -> Self {
831        Self::identity()
832    }
833}
834
835impl Matrix4 {
836    fn identity() -> Self {
837        Self {
838            values: vec![
839                1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
840            ],
841        }
842    }
843
844    fn from_translation(x: f64, y: f64, z: f64) -> Self {
845        Self {
846            values: vec![
847                1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, x, y, z, 1.0,
848            ],
849        }
850    }
851
852    fn from_scale(s: f64) -> Self {
853        Self {
854            values: vec![
855                s, 0.0, 0.0, 0.0, 0.0, s, 0.0, 0.0, 0.0, 0.0, s, 0.0, 0.0, 0.0, 0.0, 1.0,
856            ],
857        }
858    }
859
860    fn from_rotation_xyz(rx: f64, ry: f64, rz: f64) -> Self {
861        let rx = rx.to_radians();
862        let ry = ry.to_radians();
863        let rz = rz.to_radians();
864
865        let (sx, cx) = (rx.sin(), rx.cos());
866        let (sy, cy) = (ry.sin(), ry.cos());
867        let (sz, cz) = (rz.sin(), rz.cos());
868
869        // Combined rotation matrix: Rz * Ry * Rx
870        Self {
871            values: vec![
872                cy * cz,
873                cx * sz + sx * sy * cz,
874                sx * sz - cx * sy * cz,
875                0.0,
876                -cy * sz,
877                cx * cz - sx * sy * sz,
878                sx * cz + cx * sy * sz,
879                0.0,
880                sy,
881                -sx * cy,
882                cx * cy,
883                0.0,
884                0.0,
885                0.0,
886                0.0,
887                1.0,
888            ],
889        }
890    }
891
892    fn multiply(&self, other: &Matrix4) -> Matrix4 {
893        let mut result = vec![0.0; 16];
894        for col in 0..4 {
895            for row in 0..4 {
896                let mut sum = 0.0;
897                for k in 0..4 {
898                    sum += self.values[k * 4 + row] * other.values[col * 4 + k];
899                }
900                result[col * 4 + row] = sum;
901            }
902        }
903        Matrix4 { values: result }
904    }
905}
906
907/// Geometry file definition in L3D
908#[derive(uniffi::Record, Debug, Clone, Default)]
909pub struct L3dGeometryDef {
910    /// Unique ID for this geometry
911    pub id: String,
912    /// Filename of the OBJ file
913    pub filename: String,
914    /// Units: "m", "mm", "in"
915    pub units: String,
916}
917
918/// A joint axis definition (for articulated luminaires)
919#[derive(uniffi::Record, Debug, Clone, Default)]
920pub struct L3dJointAxis {
921    /// Axis type: "x", "y", or "z"
922    pub axis: String,
923    /// Minimum angle in degrees
924    pub min: f64,
925    /// Maximum angle in degrees
926    pub max: f64,
927    /// Step size in degrees
928    pub step: f64,
929}
930
931/// Light emitting object (LEO) in L3D
932#[derive(uniffi::Record, Debug, Clone, Default)]
933pub struct L3dLightEmittingObject {
934    /// Part name
935    pub part_name: String,
936    /// Position relative to parent
937    pub position: Vec3,
938    /// Rotation in degrees
939    pub rotation: Vec3,
940    /// Shape type: "circle" or "rectangle"
941    pub shape_type: String,
942    /// Diameter for circle, or [width, height] for rectangle
943    pub shape_dimensions: Vec<f64>,
944}
945
946/// Face assignment for light emitting surfaces
947#[derive(uniffi::Record, Debug, Clone, Default)]
948pub struct L3dFaceAssignment {
949    /// Light emitting object part name
950    pub leo_part_name: String,
951    /// Starting face index
952    pub face_index_begin: i32,
953    /// Ending face index
954    pub face_index_end: i32,
955}
956
957/// A geometry part in the L3D scene hierarchy
958#[derive(uniffi::Record, Debug, Clone, Default)]
959pub struct L3dScenePart {
960    /// Part name
961    pub part_name: String,
962    /// Geometry definition ID
963    pub geometry_id: String,
964    /// Local position
965    pub position: Vec3,
966    /// Local rotation (degrees)
967    pub rotation: Vec3,
968    /// Pre-computed world transform matrix (column-major)
969    pub world_transform: Matrix4,
970    /// Scale factor based on units
971    pub scale: f64,
972    /// Light emitting objects attached to this part
973    pub light_emitting_objects: Vec<L3dLightEmittingObject>,
974    /// Face assignments for LEOs
975    pub face_assignments: Vec<L3dFaceAssignment>,
976    /// Child joint names (for reference)
977    pub joint_names: Vec<String>,
978}
979
980/// Joint definition for articulated parts
981#[derive(uniffi::Record, Debug, Clone, Default)]
982pub struct L3dJoint {
983    /// Joint part name
984    pub part_name: String,
985    /// Position relative to parent
986    pub position: Vec3,
987    /// Rotation in degrees
988    pub rotation: Vec3,
989    /// Axis constraints
990    pub axis: Option<L3dJointAxis>,
991    /// Default rotation value (if specified)
992    pub default_rotation: Option<Vec3>,
993}
994
995/// Complete L3D scene information
996#[derive(uniffi::Record, Debug, Clone, Default)]
997pub struct L3dScene {
998    /// Application that created this file
999    pub created_with_application: String,
1000    /// Creation timestamp
1001    pub creation_time_code: String,
1002    /// All geometry file definitions
1003    pub geometry_definitions: Vec<L3dGeometryDef>,
1004    /// Flattened list of all scene parts with world transforms
1005    pub parts: Vec<L3dScenePart>,
1006    /// Joint definitions (for articulated luminaires)
1007    pub joints: Vec<L3dJoint>,
1008    /// Raw structure.xml content (for debugging)
1009    pub raw_structure_xml: String,
1010}
1011
1012/// Asset file extracted from L3D archive
1013#[derive(uniffi::Record, Debug, Clone, Default)]
1014pub struct L3dAsset {
1015    /// File name/path in archive
1016    pub name: String,
1017    /// File content (OBJ, MTL, textures)
1018    pub data: Vec<u8>,
1019}
1020
1021/// Complete L3D file with scene and assets
1022#[derive(uniffi::Record, Debug, Clone, Default)]
1023pub struct L3dFile {
1024    /// Parsed scene information
1025    pub scene: L3dScene,
1026    /// All assets (OBJ files, MTL files, textures)
1027    pub assets: Vec<L3dAsset>,
1028}
1029
1030// Internal XML parsing structures
1031#[derive(Debug, Deserialize)]
1032#[serde(rename_all = "PascalCase")]
1033struct XmlLuminaire {
1034    header: XmlHeader,
1035    geometry_definitions: XmlGeometryDefinitions,
1036    structure: XmlStructure,
1037}
1038
1039#[allow(dead_code)]
1040#[derive(Debug, Deserialize)]
1041#[serde(rename_all = "PascalCase")]
1042struct XmlHeader {
1043    name: Option<String>,
1044    description: Option<String>,
1045    created_with_application: Option<String>,
1046    creation_time_code: Option<String>,
1047}
1048
1049#[derive(Debug, Deserialize)]
1050#[serde(rename_all = "PascalCase")]
1051struct XmlGeometryDefinitions {
1052    geometry_file_definition: Vec<XmlGeometryFileDefinition>,
1053}
1054
1055#[derive(Debug, Deserialize)]
1056struct XmlGeometryFileDefinition {
1057    id: String,
1058    filename: String,
1059    units: String,
1060}
1061
1062#[derive(Debug, Deserialize)]
1063#[serde(rename_all = "PascalCase")]
1064struct XmlStructure {
1065    geometry: XmlGeometry,
1066}
1067
1068#[derive(Debug, Deserialize)]
1069#[serde(rename_all = "PascalCase")]
1070struct XmlGeometry {
1071    #[serde(rename = "partName")]
1072    part_name: String,
1073    position: XmlVec3,
1074    rotation: XmlVec3,
1075    geometry_reference: XmlGeometryReference,
1076    joints: Option<XmlJoints>,
1077    light_emitting_objects: Option<XmlLightEmittingObjects>,
1078    light_emitting_face_assignments: Option<XmlLightEmittingFaceAssignments>,
1079}
1080
1081#[derive(Debug, Deserialize)]
1082struct XmlVec3 {
1083    x: f64,
1084    y: f64,
1085    z: f64,
1086}
1087
1088#[derive(Debug, Deserialize)]
1089#[serde(rename_all = "camelCase")]
1090struct XmlGeometryReference {
1091    geometry_id: String,
1092}
1093
1094#[derive(Debug, Deserialize)]
1095#[serde(rename_all = "PascalCase")]
1096struct XmlJoints {
1097    joint: Vec<XmlJoint>,
1098}
1099
1100#[derive(Debug, Deserialize)]
1101#[serde(rename_all = "PascalCase")]
1102struct XmlJoint {
1103    #[serde(rename = "partName")]
1104    part_name: String,
1105    position: XmlVec3,
1106    rotation: XmlVec3,
1107    #[serde(rename = "XAxis")]
1108    x_axis: Option<XmlAxis>,
1109    #[serde(rename = "YAxis")]
1110    y_axis: Option<XmlAxis>,
1111    #[serde(rename = "ZAxis")]
1112    z_axis: Option<XmlAxis>,
1113    default_rotation: Option<XmlVec3>,
1114    geometries: XmlGeometries,
1115}
1116
1117#[derive(Debug, Deserialize)]
1118struct XmlAxis {
1119    min: f64,
1120    max: f64,
1121    step: f64,
1122}
1123
1124#[derive(Debug, Deserialize)]
1125#[serde(rename_all = "PascalCase")]
1126struct XmlGeometries {
1127    geometry: Vec<XmlGeometry>,
1128}
1129
1130#[derive(Debug, Deserialize)]
1131#[serde(rename_all = "PascalCase")]
1132struct XmlLightEmittingObjects {
1133    light_emitting_object: Vec<XmlLightEmittingObject>,
1134}
1135
1136#[derive(Debug, Deserialize)]
1137#[serde(rename_all = "PascalCase")]
1138struct XmlLightEmittingObject {
1139    #[serde(rename = "partName")]
1140    part_name: String,
1141    position: XmlVec3,
1142    rotation: XmlVec3,
1143    circle: Option<XmlCircle>,
1144    rectangle: Option<XmlRectangle>,
1145}
1146
1147#[derive(Debug, Deserialize)]
1148struct XmlCircle {
1149    diameter: f64,
1150}
1151
1152#[derive(Debug, Deserialize)]
1153#[serde(rename_all = "camelCase")]
1154struct XmlRectangle {
1155    size_x: f64,
1156    size_y: f64,
1157}
1158
1159#[derive(Debug, Deserialize)]
1160#[serde(rename_all = "PascalCase")]
1161struct XmlLightEmittingFaceAssignments {
1162    range_assignment: Option<Vec<XmlRangeAssignment>>,
1163}
1164
1165#[derive(Debug, Deserialize)]
1166#[serde(rename_all = "camelCase")]
1167struct XmlRangeAssignment {
1168    light_emitting_part_name: String,
1169    face_index_begin: i32,
1170    face_index_end: i32,
1171}
1172
1173/// Parse L3D file from raw bytes (ZIP archive)
1174#[uniffi::export]
1175pub fn parse_l3d(data: Vec<u8>) -> Result<L3dFile, GldfError> {
1176    let cursor = Cursor::new(&data);
1177    let mut zip = ZipArchive::new(cursor).map_err(|e| GldfError::ParseError {
1178        msg: format!("Invalid L3D archive: {}", e),
1179    })?;
1180
1181    let mut structure_xml = String::new();
1182    let mut assets = Vec::new();
1183
1184    // Extract all files
1185    for i in 0..zip.len() {
1186        let mut file = zip
1187            .by_index(i)
1188            .map_err(|e| GldfError::ParseError { msg: e.to_string() })?;
1189
1190        if file.is_file() {
1191            let mut buf = Vec::new();
1192            file.read_to_end(&mut buf)
1193                .map_err(|e| GldfError::ParseError { msg: e.to_string() })?;
1194
1195            if file.name() == "structure.xml" {
1196                structure_xml = String::from_utf8_lossy(&buf).to_string();
1197            } else {
1198                assets.push(L3dAsset {
1199                    name: file.name().to_string(),
1200                    data: buf,
1201                });
1202            }
1203        }
1204    }
1205
1206    if structure_xml.is_empty() {
1207        return Err(GldfError::ParseError {
1208            msg: "structure.xml not found in L3D archive".to_string(),
1209        });
1210    }
1211
1212    // Parse structure.xml
1213    let scene = parse_l3d_structure(structure_xml.clone())?;
1214
1215    Ok(L3dFile {
1216        scene: L3dScene {
1217            raw_structure_xml: structure_xml,
1218            ..scene
1219        },
1220        assets,
1221    })
1222}
1223
1224/// Parse L3D structure.xml content
1225#[uniffi::export]
1226pub fn parse_l3d_structure(xml_content: String) -> Result<L3dScene, GldfError> {
1227    let luminaire: XmlLuminaire =
1228        serde_xml_rs::from_str(&xml_content).map_err(|e| GldfError::ParseError {
1229            msg: format!("Invalid structure.xml: {}", e),
1230        })?;
1231
1232    let mut scene = L3dScene {
1233        created_with_application: luminaire
1234            .header
1235            .created_with_application
1236            .unwrap_or_default(),
1237        creation_time_code: luminaire.header.creation_time_code.unwrap_or_default(),
1238        geometry_definitions: luminaire
1239            .geometry_definitions
1240            .geometry_file_definition
1241            .iter()
1242            .map(|g| L3dGeometryDef {
1243                id: g.id.clone(),
1244                filename: g.filename.clone(),
1245                units: g.units.clone(),
1246            })
1247            .collect(),
1248        parts: Vec::new(),
1249        joints: Vec::new(),
1250        raw_structure_xml: String::new(),
1251    };
1252
1253    // Build a map of geometry IDs to units for scale calculation
1254    let units_map: std::collections::HashMap<String, String> = luminaire
1255        .geometry_definitions
1256        .geometry_file_definition
1257        .iter()
1258        .map(|g| (g.id.clone(), g.units.clone()))
1259        .collect();
1260
1261    // Recursively parse geometry hierarchy
1262    let root_transform = Matrix4::identity();
1263    parse_geometry_recursive(
1264        &luminaire.structure.geometry,
1265        &root_transform,
1266        &units_map,
1267        &mut scene.parts,
1268        &mut scene.joints,
1269    );
1270
1271    Ok(scene)
1272}
1273
1274fn parse_geometry_recursive(
1275    geo: &XmlGeometry,
1276    parent_transform: &Matrix4,
1277    units_map: &std::collections::HashMap<String, String>,
1278    parts: &mut Vec<L3dScenePart>,
1279    joints: &mut Vec<L3dJoint>,
1280) {
1281    // Calculate local transform
1282    let translation = Matrix4::from_translation(geo.position.x, geo.position.y, geo.position.z);
1283    let rotation = Matrix4::from_rotation_xyz(geo.rotation.x, geo.rotation.y, geo.rotation.z);
1284    let local_transform = translation.multiply(&rotation);
1285    let world_transform = parent_transform.multiply(&local_transform);
1286
1287    // Get scale from units
1288    let scale = units_map
1289        .get(&geo.geometry_reference.geometry_id)
1290        .map(|u| get_unit_scale(u))
1291        .unwrap_or(1.0);
1292
1293    // Create scale transform for final world transform
1294    let scale_transform = Matrix4::from_scale(scale);
1295    let final_transform = world_transform.multiply(&scale_transform);
1296
1297    // Parse light emitting objects
1298    let leos: Vec<L3dLightEmittingObject> = geo
1299        .light_emitting_objects
1300        .as_ref()
1301        .map(|leos| {
1302            leos.light_emitting_object
1303                .iter()
1304                .map(|leo| {
1305                    let (shape_type, shape_dimensions) = if let Some(circle) = &leo.circle {
1306                        ("circle".to_string(), vec![circle.diameter])
1307                    } else if let Some(rect) = &leo.rectangle {
1308                        ("rectangle".to_string(), vec![rect.size_x, rect.size_y])
1309                    } else {
1310                        ("unknown".to_string(), vec![])
1311                    };
1312
1313                    L3dLightEmittingObject {
1314                        part_name: leo.part_name.clone(),
1315                        position: Vec3 {
1316                            x: leo.position.x,
1317                            y: leo.position.y,
1318                            z: leo.position.z,
1319                        },
1320                        rotation: Vec3 {
1321                            x: leo.rotation.x,
1322                            y: leo.rotation.y,
1323                            z: leo.rotation.z,
1324                        },
1325                        shape_type,
1326                        shape_dimensions,
1327                    }
1328                })
1329                .collect()
1330        })
1331        .unwrap_or_default();
1332
1333    // Parse face assignments
1334    let face_assignments: Vec<L3dFaceAssignment> = geo
1335        .light_emitting_face_assignments
1336        .as_ref()
1337        .and_then(|fa| fa.range_assignment.as_ref())
1338        .map(|assignments| {
1339            assignments
1340                .iter()
1341                .map(|ra| L3dFaceAssignment {
1342                    leo_part_name: ra.light_emitting_part_name.clone(),
1343                    face_index_begin: ra.face_index_begin,
1344                    face_index_end: ra.face_index_end,
1345                })
1346                .collect()
1347        })
1348        .unwrap_or_default();
1349
1350    // Collect joint names
1351    let joint_names: Vec<String> = geo
1352        .joints
1353        .as_ref()
1354        .map(|j| j.joint.iter().map(|jt| jt.part_name.clone()).collect())
1355        .unwrap_or_default();
1356
1357    // Add this part
1358    parts.push(L3dScenePart {
1359        part_name: geo.part_name.clone(),
1360        geometry_id: geo.geometry_reference.geometry_id.clone(),
1361        position: Vec3 {
1362            x: geo.position.x,
1363            y: geo.position.y,
1364            z: geo.position.z,
1365        },
1366        rotation: Vec3 {
1367            x: geo.rotation.x,
1368            y: geo.rotation.y,
1369            z: geo.rotation.z,
1370        },
1371        world_transform: final_transform.clone(),
1372        scale,
1373        light_emitting_objects: leos,
1374        face_assignments,
1375        joint_names,
1376    });
1377
1378    // Process joints and child geometries
1379    if let Some(ref joint_list) = geo.joints {
1380        for joint in &joint_list.joint {
1381            // Calculate joint transform
1382            let joint_translation =
1383                Matrix4::from_translation(joint.position.x, joint.position.y, joint.position.z);
1384            let joint_rotation =
1385                Matrix4::from_rotation_xyz(joint.rotation.x, joint.rotation.y, joint.rotation.z);
1386            let joint_transform = world_transform
1387                .multiply(&joint_translation)
1388                .multiply(&joint_rotation);
1389
1390            // Parse axis
1391            #[allow(clippy::manual_map)]
1392            let axis = if let Some(ref x) = joint.x_axis {
1393                Some(L3dJointAxis {
1394                    axis: "x".to_string(),
1395                    min: x.min,
1396                    max: x.max,
1397                    step: x.step,
1398                })
1399            } else if let Some(ref y) = joint.y_axis {
1400                Some(L3dJointAxis {
1401                    axis: "y".to_string(),
1402                    min: y.min,
1403                    max: y.max,
1404                    step: y.step,
1405                })
1406            } else if let Some(ref z) = joint.z_axis {
1407                Some(L3dJointAxis {
1408                    axis: "z".to_string(),
1409                    min: z.min,
1410                    max: z.max,
1411                    step: z.step,
1412                })
1413            } else {
1414                None
1415            };
1416
1417            // Add joint info
1418            joints.push(L3dJoint {
1419                part_name: joint.part_name.clone(),
1420                position: Vec3 {
1421                    x: joint.position.x,
1422                    y: joint.position.y,
1423                    z: joint.position.z,
1424                },
1425                rotation: Vec3 {
1426                    x: joint.rotation.x,
1427                    y: joint.rotation.y,
1428                    z: joint.rotation.z,
1429                },
1430                axis,
1431                default_rotation: joint.default_rotation.as_ref().map(|r| Vec3 {
1432                    x: r.x,
1433                    y: r.y,
1434                    z: r.z,
1435                }),
1436            });
1437
1438            // Recurse into child geometries
1439            for child_geo in &joint.geometries.geometry {
1440                parse_geometry_recursive(child_geo, &joint_transform, units_map, parts, joints);
1441            }
1442        }
1443    }
1444}
1445
1446fn get_unit_scale(unit: &str) -> f64 {
1447    match unit {
1448        "mm" => 0.001,
1449        "in" => 0.0254,
1450        _ => 1.0, // "m" or default
1451    }
1452}
1453
1454/// Get asset from L3D file by filename
1455#[uniffi::export]
1456pub fn get_l3d_asset(l3d_file: &L3dFile, filename: String) -> Option<Vec<u8>> {
1457    l3d_file
1458        .assets
1459        .iter()
1460        .find(|a| a.name == filename || a.name.ends_with(&format!("/{}", filename)))
1461        .map(|a| a.data.clone())
1462}