Skip to main content

eulumdat_bevy/viewer/
wasm_sync.rs

1//! WASM localStorage synchronization for hot-reload.
2//!
3//! This module enables real-time sync between the web editor and the 3D viewer.
4//! When the editor saves LDT data to localStorage, the viewer picks it up.
5//! ViewerSettings can also be synced via localStorage for UI controls.
6
7#[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
8use super::SceneType;
9use super::ViewerSettings;
10use bevy::prelude::*;
11use eulumdat::Eulumdat;
12
13#[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
14const LDT_STORAGE_KEY: &str = "eulumdat_current_ldt";
15#[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
16const LDT_TIMESTAMP_KEY: &str = "eulumdat_ldt_timestamp";
17#[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
18const VIEWER_SETTINGS_KEY: &str = "eulumdat_viewer_settings";
19#[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
20const VIEWER_SETTINGS_TIMESTAMP_KEY: &str = "eulumdat_viewer_settings_timestamp";
21
22/// Resource to track localStorage timestamp for hot-reload.
23#[derive(Resource, Default)]
24pub struct LdtTimestamp(pub String);
25
26/// Resource to track ViewerSettings timestamp for sync.
27#[derive(Resource, Default)]
28pub struct ViewerSettingsTimestamp(pub String);
29
30/// Load LDT from localStorage (WASM only).
31#[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
32pub fn load_from_local_storage() -> Option<Eulumdat> {
33    let window = web_sys::window()?;
34    let storage = window.local_storage().ok()??;
35    let ldt_string = storage.get_item(LDT_STORAGE_KEY).ok()??;
36
37    web_sys::console::log_1(
38        &format!(
39            "[Bevy] Loading LDT from localStorage, {} bytes",
40            ldt_string.len()
41        )
42        .into(),
43    );
44
45    match Eulumdat::parse(&ldt_string) {
46        Ok(ldt) => {
47            web_sys::console::log_1(
48                &format!(
49                    "[Bevy] Parsed LDT: {} lumens, {} cd/klm max",
50                    ldt.total_luminous_flux(),
51                    ldt.max_intensity()
52                )
53                .into(),
54            );
55            Some(ldt)
56        }
57        Err(e) => {
58            web_sys::console::error_1(&format!("[Bevy] Failed to parse LDT: {:?}", e).into());
59            None
60        }
61    }
62}
63
64#[cfg(not(all(target_arch = "wasm32", feature = "wasm-sync")))]
65pub fn load_from_local_storage() -> Option<Eulumdat> {
66    None
67}
68
69/// Get timestamp from localStorage.
70#[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
71pub fn get_ldt_timestamp() -> Option<String> {
72    let window = web_sys::window()?;
73    let storage = window.local_storage().ok()??;
74    storage.get_item(LDT_TIMESTAMP_KEY).ok()?
75}
76
77#[cfg(not(all(target_arch = "wasm32", feature = "wasm-sync")))]
78pub fn get_ldt_timestamp() -> Option<String> {
79    None
80}
81
82/// Load default LDT data.
83///
84/// For WASM with wasm-sync feature: Loads from localStorage.
85/// For native: Tries common file paths.
86pub fn load_default_ldt() -> Option<Eulumdat> {
87    // For WASM with wasm-sync feature, load from localStorage
88    #[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
89    {
90        load_from_local_storage()
91    }
92
93    // For WASM without wasm-sync, return None (data must be provided by parent app)
94    #[cfg(all(target_arch = "wasm32", not(feature = "wasm-sync")))]
95    {
96        None
97    }
98
99    // For native, try to load from common file paths
100    #[cfg(not(target_arch = "wasm32"))]
101    {
102        let sample_paths = [
103            "crates/eulumdat-wasm/templates/road_luminaire.ldt",
104            "../eulumdat-wasm/templates/road_luminaire.ldt",
105            "crates/eulumdat-wasm/templates/fluorescent_luminaire.ldt",
106            "../eulumdat-wasm/templates/fluorescent_luminaire.ldt",
107            "templates/road_luminaire.ldt",
108            "sample.ldt",
109        ];
110
111        for path in sample_paths {
112            if let Ok(ldt) = Eulumdat::from_file(path) {
113                return Some(ldt);
114            }
115        }
116        None
117    }
118}
119
120/// Poll localStorage for LDT changes.
121#[cfg(feature = "wasm-sync")]
122#[allow(unused_mut, unused_variables)]
123pub fn poll_ldt_changes(
124    mut settings: ResMut<ViewerSettings>,
125    mut last_timestamp: ResMut<LdtTimestamp>,
126) {
127    #[cfg(target_arch = "wasm32")]
128    {
129        if let Some(new_timestamp) = get_ldt_timestamp() {
130            if new_timestamp != last_timestamp.0 {
131                // Timestamp changed - reload LDT
132                web_sys::console::log_1(
133                    &format!(
134                        "[Bevy] LDT timestamp changed: {} -> {}",
135                        last_timestamp.0, new_timestamp
136                    )
137                    .into(),
138                );
139                if let Some(ldt) = load_from_local_storage() {
140                    web_sys::console::log_1(
141                        &format!("[Bevy] Updating ViewerSettings with new LDT").into(),
142                    );
143                    settings.ldt_data = Some(ldt);
144                    last_timestamp.0 = new_timestamp;
145                }
146            }
147        }
148    }
149}
150
151// Stub for when wasm-sync is disabled
152#[cfg(not(feature = "wasm-sync"))]
153#[allow(unused_mut, unused_variables, dead_code)]
154pub fn poll_ldt_changes(
155    mut settings: ResMut<ViewerSettings>,
156    mut last_timestamp: ResMut<LdtTimestamp>,
157) {
158    // No-op when wasm-sync feature is disabled
159}
160
161/// Get ViewerSettings timestamp from localStorage.
162#[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
163pub fn get_viewer_settings_timestamp() -> Option<String> {
164    let window = web_sys::window()?;
165    let storage = window.local_storage().ok()??;
166    storage.get_item(VIEWER_SETTINGS_TIMESTAMP_KEY).ok()?
167}
168
169#[cfg(not(all(target_arch = "wasm32", feature = "wasm-sync")))]
170pub fn get_viewer_settings_timestamp() -> Option<String> {
171    None
172}
173
174/// Load ViewerSettings from localStorage JSON.
175#[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
176pub fn load_viewer_settings_from_local_storage(current: &ViewerSettings) -> Option<ViewerSettings> {
177    let window = web_sys::window()?;
178    let storage = window.local_storage().ok()??;
179    let json_string = storage.get_item(VIEWER_SETTINGS_KEY).ok()??;
180
181    parse_viewer_settings_json(&json_string, current)
182}
183
184#[cfg(not(all(target_arch = "wasm32", feature = "wasm-sync")))]
185pub fn load_viewer_settings_from_local_storage(
186    _current: &ViewerSettings,
187) -> Option<ViewerSettings> {
188    None
189}
190
191/// Parse ViewerSettings from JSON string.
192/// Preserves ldt_data from current settings since it's synced separately.
193#[cfg(all(target_arch = "wasm32", feature = "wasm-sync"))]
194fn parse_viewer_settings_json(json: &str, current: &ViewerSettings) -> Option<ViewerSettings> {
195    // Simple JSON parsing without serde dependency
196    // Format: {"scene_type":0,"room_width":4.0,"room_length":5.0,...}
197
198    let get_f32 = |key: &str| -> Option<f32> {
199        let pattern = format!("\"{}\":", key);
200        let start = json.find(&pattern)? + pattern.len();
201        let rest = &json[start..];
202        let end = rest.find([',', '}'])?;
203        rest[..end].trim().parse().ok()
204    };
205
206    let get_bool = |key: &str| -> Option<bool> {
207        let pattern = format!("\"{}\":", key);
208        let start = json.find(&pattern)? + pattern.len();
209        let rest = &json[start..];
210        let end = rest.find([',', '}'])?;
211        let value = rest[..end].trim();
212        Some(value == "true")
213    };
214
215    let get_u8 = |key: &str| -> Option<u8> {
216        let pattern = format!("\"{}\":", key);
217        let start = json.find(&pattern)? + pattern.len();
218        let rest = &json[start..];
219        let end = rest.find([',', '}'])?;
220        rest[..end].trim().parse().ok()
221    };
222
223    let scene_type = match get_u8("scene_type")? {
224        0 => SceneType::Room,
225        1 => SceneType::Road,
226        2 => SceneType::Parking,
227        3 => SceneType::Outdoor,
228        _ => SceneType::Room,
229    };
230
231    Some(ViewerSettings {
232        scene_type,
233        room_width: get_f32("room_width").unwrap_or(current.room_width),
234        room_length: get_f32("room_length").unwrap_or(current.room_length),
235        room_height: get_f32("room_height").unwrap_or(current.room_height),
236        mounting_height: get_f32("mounting_height").unwrap_or(current.mounting_height),
237        pendulum_length: get_f32("pendulum_length").unwrap_or(current.pendulum_length),
238        light_intensity: get_f32("light_intensity").unwrap_or(current.light_intensity),
239        show_luminaire: get_bool("show_luminaire").unwrap_or(current.show_luminaire),
240        show_photometric_solid: get_bool("show_photometric_solid")
241            .unwrap_or(current.show_photometric_solid),
242        show_shadows: get_bool("show_shadows").unwrap_or(current.show_shadows),
243        // Preserve LDT data - it's synced separately
244        ldt_data: current.ldt_data.clone(),
245        luminaire_tilt: get_f32("luminaire_tilt").unwrap_or(current.luminaire_tilt),
246        lane_width: get_f32("lane_width").unwrap_or(current.lane_width),
247        num_lanes: get_u8("num_lanes").unwrap_or(current.num_lanes as u8) as u32,
248        sidewalk_width: get_f32("sidewalk_width").unwrap_or(current.sidewalk_width),
249        pole_spacing: get_f32("pole_spacing").unwrap_or(current.pole_spacing),
250    })
251}
252
253/// Poll localStorage for ViewerSettings changes.
254#[cfg(feature = "wasm-sync")]
255#[allow(unused_mut, unused_variables)]
256pub fn poll_viewer_settings_changes(
257    mut settings: ResMut<ViewerSettings>,
258    mut last_timestamp: ResMut<ViewerSettingsTimestamp>,
259) {
260    #[cfg(target_arch = "wasm32")]
261    {
262        if let Some(new_timestamp) = get_viewer_settings_timestamp() {
263            if new_timestamp != last_timestamp.0 {
264                // Timestamp changed - reload settings
265                if let Some(new_settings) = load_viewer_settings_from_local_storage(&settings) {
266                    *settings = new_settings;
267                    last_timestamp.0 = new_timestamp;
268                }
269            }
270        }
271    }
272}
273
274// Stub for when wasm-sync is disabled
275#[cfg(not(feature = "wasm-sync"))]
276#[allow(unused_mut, unused_variables, dead_code)]
277pub fn poll_viewer_settings_changes(
278    mut settings: ResMut<ViewerSettings>,
279    mut last_timestamp: ResMut<ViewerSettingsTimestamp>,
280) {
281    // No-op when wasm-sync feature is disabled
282}