canvas_app/
lib.rs

1//! # Saorsa Canvas WASM Application
2//!
3//! This crate provides the WASM bindings for the Saorsa Canvas,
4//! enabling the canvas to run in web browsers.
5//!
6//! ## Usage
7//!
8//! Build for WASM:
9//! ```bash
10//! wasm-pack build --target web canvas-app
11//! ```
12//!
13//! Then import in JavaScript:
14//! ```javascript
15//! import init, { CanvasApp } from './pkg/canvas_app.js';
16//!
17//! await init();
18//! const app = new CanvasApp('main-canvas');
19//!
20//! function render() {
21//!     app.render();
22//!     requestAnimationFrame(render);
23//! }
24//! render();
25//! ```
26
27#![forbid(unsafe_code)]
28#![deny(missing_docs)]
29#![deny(clippy::all)]
30#![deny(clippy::pedantic)]
31#![allow(clippy::module_name_repetitions)]
32
33use std::{cell::RefCell, collections::HashMap, rc::Rc};
34
35use canvas_core::{
36    CanvasState, Element, ElementId, ElementKind, FusionConfig, FusionResult, InputEvent,
37    InputFusion, Scene, SceneDocument, TouchEvent, TouchPhase, TouchPoint, Transform, VoiceEvent,
38};
39use canvas_renderer::{
40    BackendType, Camera, HolographicConfig, HolographicRenderer, RenderBackend, RenderResult,
41    Renderer, RendererConfig, Vec3,
42};
43
44// Chart rendering is not available in WASM - always use placeholder
45// The chart module uses plotters which doesn't support wasm32
46// WebGPU backend is not available in WASM builds (gpu feature disabled)
47
48use wasm_bindgen::prelude::*;
49use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData};
50
51/// Initialize the WASM module.
52#[wasm_bindgen(start)]
53pub fn init_wasm() {
54    console_error_panic_hook::set_once();
55    tracing::info!("Saorsa Canvas WASM initialized");
56}
57
58/// Helper to set a property on a JS object with debug logging on failure.
59///
60/// In debug builds, logs a warning to the browser console if the property
61/// cannot be set (e.g., if the object is frozen or the property is read-only).
62fn js_set_property(obj: &js_sys::Object, key: &str, value: &JsValue) {
63    if let Err(e) = js_sys::Reflect::set(obj, &JsValue::from_str(key), value) {
64        // Log to browser console in debug mode
65        #[cfg(debug_assertions)]
66        web_sys::console::warn_2(
67            &JsValue::from_str(&format!("Failed to set JS property '{key}': ")),
68            &e,
69        );
70        // In release mode, we just ignore the error silently
71        #[cfg(not(debug_assertions))]
72        let _ = e;
73    }
74}
75
76/// Cached video frame data.
77struct VideoFrame {
78    /// RGBA pixel data.
79    data: Vec<u8>,
80    /// Width in pixels.
81    width: u32,
82    /// Height in pixels.
83    height: u32,
84    /// Frame timestamp (for staleness detection).
85    timestamp: f64,
86}
87
88type RendererHandle = Rc<RefCell<DomRendererState>>;
89
90struct DomRendererState {
91    canvas: HtmlCanvasElement,
92    ctx: CanvasRenderingContext2d,
93    width: u32,
94    height: u32,
95    background_color: String,
96    video_frames: HashMap<String, VideoFrame>,
97}
98
99impl DomRendererState {
100    fn new(canvas: HtmlCanvasElement, ctx: CanvasRenderingContext2d) -> Self {
101        let width = canvas.width();
102        let height = canvas.height();
103        Self {
104            canvas,
105            ctx,
106            width,
107            height,
108            background_color: "#ffffff".to_string(),
109            video_frames: HashMap::new(),
110        }
111    }
112
113    fn resize(&mut self, width: u32, height: u32) {
114        self.canvas.set_width(width);
115        self.canvas.set_height(height);
116        self.width = width;
117        self.height = height;
118    }
119
120    fn set_background_color(&mut self, color: &str) {
121        self.background_color = color.to_string();
122    }
123
124    fn clear_dynamic_content(&mut self) {
125        self.video_frames.clear();
126    }
127}
128
129struct DomCanvasBackend {
130    state: RendererHandle,
131}
132
133impl DomRendererState {
134    fn render_scene(&mut self, scene: &Scene) {
135        self.ctx.set_fill_style_str(&self.background_color);
136        self.ctx
137            .fill_rect(0.0, 0.0, f64::from(self.width), f64::from(self.height));
138
139        let mut elements: Vec<_> = scene.elements().cloned().collect();
140        elements.sort_by_key(|e| e.transform.z_index);
141
142        for element in &elements {
143            self.render_element(element);
144        }
145    }
146
147    fn render_element(&mut self, element: &Element) {
148        let t = &element.transform;
149
150        if let ElementKind::Chart { chart_type, data } = &element.kind {
151            self.render_chart(element, chart_type, data);
152        } else if let ElementKind::Video { stream_id, .. } = &element.kind {
153            self.render_video(element, stream_id);
154        } else {
155            let fill_color = Self::get_element_color(element);
156            self.ctx.set_fill_style_str(&fill_color);
157            self.ctx.fill_rect(
158                f64::from(t.x),
159                f64::from(t.y),
160                f64::from(t.width),
161                f64::from(t.height),
162            );
163
164            self.ctx.set_fill_style_str("#333333");
165            self.ctx.set_font("12px sans-serif");
166            let label = Self::get_element_label(element);
167            let _ = self
168                .ctx
169                .fill_text(&label, f64::from(t.x) + 5.0, f64::from(t.y) + 15.0);
170        }
171
172        if element.selected {
173            self.ctx.set_stroke_style_str("#0066ff");
174            self.ctx.set_line_width(2.0);
175            self.ctx.stroke_rect(
176                f64::from(t.x),
177                f64::from(t.y),
178                f64::from(t.width),
179                f64::from(t.height),
180            );
181        }
182    }
183
184    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
185    fn render_chart(&mut self, element: &Element, chart_type: &str, _data: &serde_json::Value) {
186        // Chart rendering is not available in WASM (plotters doesn't support wasm32)
187        // Always use placeholder rendering
188        let t = &element.transform;
189        self.draw_chart_placeholder(t, chart_type);
190    }
191
192    fn draw_chart_placeholder(&self, t: &Transform, chart_type: &str) {
193        self.ctx.set_fill_style_str("#e3f2fd");
194        self.ctx.fill_rect(
195            f64::from(t.x),
196            f64::from(t.y),
197            f64::from(t.width),
198            f64::from(t.height),
199        );
200
201        self.ctx.set_stroke_style_str("#90caf9");
202        self.ctx.set_line_width(1.0);
203        self.ctx.stroke_rect(
204            f64::from(t.x),
205            f64::from(t.y),
206            f64::from(t.width),
207            f64::from(t.height),
208        );
209
210        self.ctx.set_fill_style_str("#1976d2");
211        self.ctx.set_font("14px sans-serif");
212        let _ = self.ctx.fill_text(
213            &format!("Chart: {chart_type}"),
214            f64::from(t.x) + 10.0,
215            f64::from(t.y) + 25.0,
216        );
217
218        self.ctx.set_fill_style_str("#bbdefb");
219        let icon_x = f64::from(t.x) + f64::from(t.width) / 2.0 - 20.0;
220        let icon_y = f64::from(t.y) + f64::from(t.height) / 2.0 - 10.0;
221        self.ctx.fill_rect(icon_x, icon_y, 40.0, 20.0);
222    }
223
224    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
225    fn render_video(&self, element: &Element, stream_id: &str) {
226        let t = &element.transform;
227
228        if let Some(frame) = self.video_frames.get(stream_id) {
229            self.draw_video_frame(frame, t);
230        } else {
231            self.draw_video_placeholder(t, stream_id);
232        }
233    }
234
235    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
236    fn draw_video_frame(&self, frame: &VideoFrame, t: &Transform) {
237        let clamped = wasm_bindgen::Clamped(&frame.data[..]);
238
239        match ImageData::new_with_u8_clamped_array_and_sh(clamped, frame.width, frame.height) {
240            Ok(image_data) => {
241                if frame.width == t.width as u32 && frame.height == t.height as u32 {
242                    if let Err(e) =
243                        self.ctx
244                            .put_image_data(&image_data, f64::from(t.x), f64::from(t.y))
245                    {
246                        tracing::warn!("Failed to draw video frame: {:?}", e);
247                    }
248                } else if let Some(window) = web_sys::window() {
249                    if let Some(document) = window.document() {
250                        if let Ok(temp_canvas) = document.create_element("canvas") {
251                            if let Ok(temp_canvas) = temp_canvas.dyn_into::<HtmlCanvasElement>() {
252                                temp_canvas.set_width(frame.width);
253                                temp_canvas.set_height(frame.height);
254
255                                if let Ok(Some(temp_ctx)) = temp_canvas.get_context("2d") {
256                                    if let Ok(temp_ctx) =
257                                        temp_ctx.dyn_into::<CanvasRenderingContext2d>()
258                                    {
259                                        let _ = temp_ctx.put_image_data(&image_data, 0.0, 0.0);
260                                        let _ = self
261                                            .ctx
262                                            .draw_image_with_html_canvas_element_and_dw_and_dh(
263                                                &temp_canvas,
264                                                f64::from(t.x),
265                                                f64::from(t.y),
266                                                f64::from(t.width),
267                                                f64::from(t.height),
268                                            );
269                                    }
270                                }
271                            }
272                        }
273                    }
274                }
275            }
276            Err(e) => tracing::warn!("Failed to create video ImageData: {:?}", e),
277        }
278    }
279
280    fn draw_video_placeholder(&self, t: &Transform, stream_id: &str) {
281        self.ctx.set_fill_style_str("#212121");
282        self.ctx.fill_rect(
283            f64::from(t.x),
284            f64::from(t.y),
285            f64::from(t.width),
286            f64::from(t.height),
287        );
288
289        self.ctx.set_fill_style_str("#757575");
290        self.ctx.set_font("14px sans-serif");
291        self.ctx.set_text_align("center");
292        self.ctx.set_text_baseline("middle");
293
294        let center_x = f64::from(t.x) + f64::from(t.width) / 2.0;
295        let center_y = f64::from(t.y) + f64::from(t.height) / 2.0;
296
297        let _ = self
298            .ctx
299            .fill_text(&format!("Video: {stream_id}"), center_x, center_y - 10.0);
300        let _ = self.ctx.fill_text("No signal", center_x, center_y + 10.0);
301
302        self.ctx.set_text_align("start");
303        self.ctx.set_text_baseline("alphabetic");
304    }
305
306    fn get_element_color(element: &Element) -> String {
307        match &element.kind {
308            ElementKind::Chart { .. } => "#e3f2fd".to_string(),
309            ElementKind::Image { .. } => "#f5f5f5".to_string(),
310            ElementKind::Model3D { .. } => "#e8f5e9".to_string(),
311            ElementKind::Video { .. } => "#212121".to_string(),
312            ElementKind::OverlayLayer { opacity, .. } => format!("rgba(255, 255, 255, {opacity})"),
313            ElementKind::Text { color, .. } => color.clone(),
314            ElementKind::Group { .. } => "rgba(255, 253, 231, 0.5)".to_string(),
315        }
316    }
317
318    fn get_element_label(element: &Element) -> String {
319        match &element.kind {
320            ElementKind::Chart { chart_type, .. } => format!("Chart: {chart_type}"),
321            ElementKind::Image { .. } => "Image".to_string(),
322            ElementKind::Model3D { .. } => "3D Model".to_string(),
323            ElementKind::Video { stream_id, .. } => format!("Video: {stream_id}"),
324            ElementKind::OverlayLayer { children, .. } => format!("Overlay ({})", children.len()),
325            ElementKind::Text { content, .. } => {
326                if content.len() > 20 {
327                    format!("{}...", &content[..20])
328                } else {
329                    content.clone()
330                }
331            }
332            ElementKind::Group { children } => format!("Group ({})", children.len()),
333        }
334    }
335}
336
337impl RenderBackend for DomCanvasBackend {
338    fn backend_type(&self) -> BackendType {
339        BackendType::Canvas2D
340    }
341
342    fn render(&mut self, scene: &Scene) -> RenderResult<()> {
343        if let Ok(mut state) = self.state.try_borrow_mut() {
344            state.render_scene(scene);
345        }
346        Ok(())
347    }
348
349    fn resize(&mut self, width: u32, height: u32) -> RenderResult<()> {
350        if let Ok(mut state) = self.state.try_borrow_mut() {
351            state.resize(width, height);
352        }
353        Ok(())
354    }
355}
356
357/// The main canvas application for WASM.
358#[wasm_bindgen]
359pub struct CanvasApp {
360    scene: Scene,
361    state: CanvasState,
362    frame_count: u64,
363    renderer_state: RendererHandle,
364    renderer: Renderer,
365    /// Holographic rendering configuration (None when not in holographic mode).
366    holographic_config: Option<HolographicConfig>,
367    /// Holographic renderer (lazily initialized).
368    holographic_renderer: Option<HolographicRenderer>,
369    /// Camera for holographic rendering.
370    holographic_camera: Camera,
371    /// Input fusion processor for touch+voice combination.
372    input_fusion: InputFusion,
373}
374
375#[wasm_bindgen]
376impl CanvasApp {
377    /// Create a new canvas application attached to the given canvas element ID.
378    ///
379    /// # Errors
380    ///
381    /// Returns an error if the canvas element is not found or 2D context fails.
382    #[wasm_bindgen(constructor)]
383    pub fn new(canvas_id: &str) -> Result<CanvasApp, JsValue> {
384        let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window object"))?;
385        let document = window
386            .document()
387            .ok_or_else(|| JsValue::from_str("No document object"))?;
388
389        let canvas = document
390            .get_element_by_id(canvas_id)
391            .ok_or_else(|| JsValue::from_str(&format!("Canvas element '{canvas_id}' not found")))?
392            .dyn_into::<HtmlCanvasElement>()
393            .map_err(|_| JsValue::from_str("Element is not a canvas"))?;
394
395        let ctx = canvas
396            .get_context("2d")
397            .map_err(|_| JsValue::from_str("Failed to get 2D context"))?
398            .ok_or_else(|| JsValue::from_str("2D context not available"))?
399            .dyn_into::<CanvasRenderingContext2d>()
400            .map_err(|_| JsValue::from_str("Failed to cast to 2D context"))?;
401
402        let width = canvas.width();
403        let height = canvas.height();
404
405        #[allow(clippy::cast_precision_loss)]
406        let scene = Scene::new(width as f32, height as f32);
407
408        let renderer_state = Rc::new(RefCell::new(DomRendererState::new(canvas, ctx)));
409
410        // Use Canvas2D backend for WASM (WebGPU not available without gpu feature)
411        let backend: Box<dyn RenderBackend> = Box::new(DomCanvasBackend {
412            state: Rc::clone(&renderer_state),
413        });
414
415        let preferred_backend = backend.backend_type();
416        let renderer = Renderer::with_backend(
417            backend,
418            RendererConfig {
419                preferred_backend,
420                ..RendererConfig::default()
421            },
422        );
423
424        Ok(Self {
425            scene,
426            state: CanvasState::default(),
427            frame_count: 0,
428            renderer_state,
429            renderer,
430            holographic_config: None,
431            holographic_renderer: None,
432            holographic_camera: Camera::default(),
433            input_fusion: InputFusion::new(),
434        })
435    }
436
437    /// Render the current scene to the canvas.
438    pub fn render(&mut self) {
439        if let Err(err) = self.renderer.render(&self.scene) {
440            tracing::error!("Renderer error: {:?}", err);
441        }
442        self.frame_count += 1;
443    }
444
445    /// Handle a touch event at the given coordinates.
446    #[wasm_bindgen(js_name = handleTouch)]
447    pub fn handle_touch(&mut self, x: f32, y: f32, phase: &str) -> Option<String> {
448        // Find element at touch location
449        let element_id = self.scene.element_at(x, y);
450
451        // Parse touch phase (default to Start for unknown phases)
452        let touch_phase = match phase {
453            "move" | "moved" => TouchPhase::Move,
454            "end" | "ended" => TouchPhase::End,
455            "cancel" | "cancelled" => TouchPhase::Cancel,
456            _ => TouchPhase::Start,
457        };
458
459        // Create touch point
460        let touch_point = TouchPoint {
461            id: 0,
462            x,
463            y,
464            pressure: None,
465            radius: None,
466        };
467
468        // Create touch event with target element for fusion
469        let mut touch_event = TouchEvent::new(touch_phase, vec![touch_point], 0);
470        touch_event.target_element = element_id;
471        let event = InputEvent::Touch(touch_event.clone());
472
473        // Process through fusion system (only Start events are stored for fusion)
474        let _ = self.input_fusion.process_touch(&touch_event);
475
476        // Process the event in state
477        self.state.process_event(&event);
478
479        // If an element was touched, select it
480        if let Some(id) = element_id {
481            self.select_element(&id);
482            Some(id.to_string())
483        } else {
484            self.clear_selection();
485            None
486        }
487    }
488
489    /// Handle a mouse click at the given coordinates.
490    #[wasm_bindgen(js_name = handleClick)]
491    pub fn handle_click(&mut self, x: f32, y: f32) -> Option<String> {
492        self.handle_touch(x, y, "start")
493    }
494
495    /// Add an element to the scene from JSON.
496    ///
497    /// # Errors
498    ///
499    /// Returns an error if JSON parsing fails.
500    #[wasm_bindgen(js_name = addElement)]
501    pub fn add_element(&mut self, json: &str) -> Result<String, JsValue> {
502        let element: Element =
503            serde_json::from_str(json).map_err(|e| JsValue::from_str(&e.to_string()))?;
504        let id = element.id;
505        self.scene.add_element(element);
506        Ok(id.to_string())
507    }
508
509    /// Remove an element from the scene.
510    ///
511    /// # Errors
512    ///
513    /// Returns an error if the element is not found.
514    #[wasm_bindgen(js_name = removeElement)]
515    pub fn remove_element(&mut self, id: &str) -> Result<(), JsValue> {
516        let uuid = uuid::Uuid::parse_str(id).map_err(|e| JsValue::from_str(&e.to_string()))?;
517        let element_id = ElementId::from_uuid(uuid);
518        self.scene
519            .remove_element(&element_id)
520            .map(|_| ())
521            .map_err(|e| JsValue::from_str(&e.to_string()))
522    }
523
524    /// Get the current scene as JSON.
525    #[wasm_bindgen(js_name = getSceneJson)]
526    #[must_use]
527    pub fn get_scene_json(&self) -> String {
528        serde_json::to_string(&self.scene).unwrap_or_default()
529    }
530
531    /// Update the entire scene from JSON.
532    ///
533    /// # Errors
534    ///
535    /// Returns an error if JSON parsing fails.
536    #[wasm_bindgen(js_name = setSceneJson)]
537    pub fn set_scene_json(&mut self, json: &str) -> Result<(), JsValue> {
538        self.scene = serde_json::from_str(json).map_err(|e| JsValue::from_str(&e.to_string()))?;
539        if let Ok(mut state) = self.renderer_state.try_borrow_mut() {
540            state.clear_dynamic_content();
541        }
542        Ok(())
543    }
544
545    /// Apply a canonical scene document serialized as JSON.
546    ///
547    /// # Errors
548    ///
549    /// Returns an error if the JSON is invalid or the scene cannot be converted.
550    #[wasm_bindgen(js_name = applySceneDocument)]
551    pub fn apply_scene_document(&mut self, json: &str) -> Result<(), JsValue> {
552        let document: SceneDocument = serde_json::from_str(json)
553            .map_err(|e| JsValue::from_str(&format!("Scene parse error: {e}")))?;
554        self.scene = document
555            .into_scene()
556            .map_err(|e| JsValue::from_str(&format!("Scene conversion error: {e}")))?;
557        if let Ok(mut state) = self.renderer_state.try_borrow_mut() {
558            state.clear_dynamic_content();
559        }
560        Ok(())
561    }
562
563    /// Get the number of elements in the scene.
564    #[wasm_bindgen(js_name = elementCount)]
565    #[must_use]
566    pub fn element_count(&self) -> usize {
567        self.scene.element_count()
568    }
569
570    /// Get the current frame count.
571    #[wasm_bindgen(js_name = frameCount)]
572    #[must_use]
573    pub fn frame_count(&self) -> u64 {
574        self.frame_count
575    }
576
577    /// Resize the canvas.
578    #[allow(clippy::cast_precision_loss)]
579    pub fn resize(&mut self, width: u32, height: u32) {
580        if let Ok(mut state) = self.renderer_state.try_borrow_mut() {
581            state.resize(width, height);
582        }
583        if let Err(err) = self.renderer.resize(width, height) {
584            tracing::warn!("Renderer resize failed: {:?}", err);
585        }
586        self.scene.set_viewport(width as f32, height as f32);
587    }
588
589    /// Set the background color (CSS color string).
590    #[wasm_bindgen(js_name = setBackgroundColor)]
591    pub fn set_background_color(&mut self, color: &str) {
592        if let Ok(mut state) = self.renderer_state.try_borrow_mut() {
593            state.set_background_color(color);
594        }
595    }
596
597    /// Check if connected to AI backend.
598    #[wasm_bindgen(js_name = isConnected)]
599    #[must_use]
600    pub fn is_connected(&self) -> bool {
601        self.state.is_connected()
602    }
603
604    /// Select an element by ID.
605    fn select_element(&mut self, id: &ElementId) {
606        // Clear previous selection
607        for element in self.scene.elements_mut() {
608            element.selected = element.id == *id;
609        }
610    }
611
612    /// Clear all selections.
613    fn clear_selection(&mut self) {
614        for element in self.scene.elements_mut() {
615            element.selected = false;
616        }
617    }
618
619    /// The data should be RGBA bytes.
620    #[wasm_bindgen(js_name = updateVideoFrame)]
621    pub fn update_video_frame(
622        &mut self,
623        stream_id: &str,
624        data: &[u8],
625        width: u32,
626        height: u32,
627        timestamp: f64,
628    ) {
629        if let Ok(mut state) = self.renderer_state.try_borrow_mut() {
630            state.video_frames.insert(
631                stream_id.to_string(),
632                VideoFrame {
633                    data: data.to_vec(),
634                    width,
635                    height,
636                    timestamp,
637                },
638            );
639        }
640    }
641
642    /// Remove a video stream from the cache.
643    #[wasm_bindgen(js_name = removeVideoStream)]
644    pub fn remove_video_stream(&mut self, stream_id: &str) {
645        if let Ok(mut state) = self.renderer_state.try_borrow_mut() {
646            state.video_frames.remove(stream_id);
647        }
648    }
649
650    /// Get the list of registered video stream IDs.
651    #[wasm_bindgen(js_name = getVideoStreamIds)]
652    #[must_use]
653    pub fn get_video_stream_ids(&self) -> Vec<String> {
654        self.renderer_state
655            .try_borrow()
656            .map(|state| state.video_frames.keys().cloned().collect())
657            .unwrap_or_default()
658    }
659
660    /// Check if a video stream has a cached frame.
661    #[wasm_bindgen(js_name = hasVideoFrame)]
662    #[must_use]
663    pub fn has_video_frame(&self, stream_id: &str) -> bool {
664        self.renderer_state
665            .try_borrow()
666            .map(|state| state.video_frames.contains_key(stream_id))
667            .unwrap_or(false)
668    }
669
670    /// Get the timestamp of the last frame for a video stream.
671    /// Returns 0.0 if the stream doesn't exist.
672    #[wasm_bindgen(js_name = getVideoFrameTimestamp)]
673    #[must_use]
674    pub fn get_video_frame_timestamp(&self, stream_id: &str) -> f64 {
675        self.renderer_state
676            .try_borrow()
677            .ok()
678            .and_then(|state| state.video_frames.get(stream_id).map(|f| f.timestamp))
679            .unwrap_or(0.0)
680    }
681
682    // ========================================================================
683    // Holographic Mode Methods
684    // ========================================================================
685
686    /// Enable holographic mode with a preset configuration.
687    ///
688    /// Supported presets: "portrait", "4k"
689    /// Pass an empty string or "off" to disable holographic mode.
690    ///
691    /// # Errors
692    ///
693    /// Returns an error if the preset is not recognized.
694    #[wasm_bindgen(js_name = setHolographicConfig)]
695    pub fn set_holographic_config(&mut self, preset: &str) -> Result<(), JsValue> {
696        match preset.to_lowercase().as_str() {
697            "portrait" => {
698                let config = HolographicConfig::looking_glass_portrait();
699                self.holographic_renderer = Some(HolographicRenderer::new(config.clone()));
700                self.holographic_config = Some(config);
701                Ok(())
702            }
703            "4k" => {
704                let config = HolographicConfig::looking_glass_4k();
705                self.holographic_renderer = Some(HolographicRenderer::new(config.clone()));
706                self.holographic_config = Some(config);
707                Ok(())
708            }
709            "" | "off" | "none" | "disabled" => {
710                self.holographic_config = None;
711                self.holographic_renderer = None;
712                Ok(())
713            }
714            _ => Err(JsValue::from_str(&format!(
715                "Unknown holographic preset: '{preset}'. Use 'portrait', '4k', or 'off'"
716            ))),
717        }
718    }
719
720    /// Check if holographic mode is currently enabled.
721    #[wasm_bindgen(js_name = isHolographicMode)]
722    #[must_use]
723    pub fn is_holographic_mode(&self) -> bool {
724        self.holographic_config.is_some()
725    }
726
727    /// Get the quilt dimensions for the current holographic configuration.
728    ///
729    /// Returns a JS object: { width, height, views, columns, rows }
730    /// Returns null if holographic mode is not enabled.
731    #[wasm_bindgen(js_name = getQuiltDimensions)]
732    #[must_use]
733    pub fn get_quilt_dimensions(&self) -> JsValue {
734        match &self.holographic_config {
735            Some(config) => {
736                let obj = js_sys::Object::new();
737                js_set_property(
738                    &obj,
739                    "width",
740                    &JsValue::from_f64(f64::from(config.quilt_width())),
741                );
742                js_set_property(
743                    &obj,
744                    "height",
745                    &JsValue::from_f64(f64::from(config.quilt_height())),
746                );
747                js_set_property(
748                    &obj,
749                    "views",
750                    &JsValue::from_f64(f64::from(config.num_views)),
751                );
752                js_set_property(
753                    &obj,
754                    "columns",
755                    &JsValue::from_f64(f64::from(config.quilt_columns)),
756                );
757                js_set_property(
758                    &obj,
759                    "rows",
760                    &JsValue::from_f64(f64::from(config.quilt_rows)),
761                );
762                js_set_property(
763                    &obj,
764                    "viewWidth",
765                    &JsValue::from_f64(f64::from(config.view_width)),
766                );
767                js_set_property(
768                    &obj,
769                    "viewHeight",
770                    &JsValue::from_f64(f64::from(config.view_height)),
771                );
772                obj.into()
773            }
774            None => JsValue::NULL,
775        }
776    }
777
778    /// Render the current scene as a holographic quilt.
779    ///
780    /// Returns the quilt as RGBA pixel data (`Vec<u8>`).
781    /// The dimensions can be obtained from `getQuiltDimensions()`.
782    ///
783    /// # Errors
784    ///
785    /// Returns an error if holographic mode is not enabled.
786    #[wasm_bindgen(js_name = renderQuilt)]
787    pub fn render_quilt(&mut self) -> Result<Vec<u8>, JsValue> {
788        let renderer = self.holographic_renderer.as_mut().ok_or_else(|| {
789            JsValue::from_str("Holographic mode not enabled. Call setHolographicConfig() first.")
790        })?;
791
792        let result = renderer.render_quilt(&self.scene, &self.holographic_camera);
793        Ok(result.target.pixels)
794    }
795
796    /// Set the holographic camera position and target.
797    ///
798    /// # Arguments
799    ///
800    /// * `pos_x`, `pos_y`, `pos_z` - Camera position in world space
801    /// * `target_x`, `target_y`, `target_z` - Point the camera looks at
802    #[wasm_bindgen(js_name = setHolographicCamera)]
803    #[allow(clippy::too_many_arguments)]
804    pub fn set_holographic_camera(
805        &mut self,
806        pos_x: f32,
807        pos_y: f32,
808        pos_z: f32,
809        target_x: f32,
810        target_y: f32,
811        target_z: f32,
812    ) {
813        self.holographic_camera = Camera {
814            position: Vec3::new(pos_x, pos_y, pos_z),
815            target: Vec3::new(target_x, target_y, target_z),
816            ..Camera::default()
817        };
818    }
819
820    /// Get the current holographic configuration preset name.
821    ///
822    /// Returns "portrait", "4k", or "none" if holographic mode is disabled.
823    #[wasm_bindgen(js_name = getHolographicPreset)]
824    #[must_use]
825    pub fn get_holographic_preset(&self) -> String {
826        match &self.holographic_config {
827            Some(config) => {
828                // Identify preset by num_views and view dimensions
829                if config.num_views == 45 && config.view_width == 420 {
830                    "portrait".to_string()
831                } else if config.num_views == 45 && config.view_width == 819 {
832                    "4k".to_string()
833                } else {
834                    "custom".to_string()
835                }
836            }
837            None => "none".to_string(),
838        }
839    }
840
841    /// Get information about a specific quilt view.
842    ///
843    /// Returns a JS object with view offset, dimensions, and camera position,
844    /// or null if the view index is out of range or holographic mode is disabled.
845    #[wasm_bindgen(js_name = getQuiltViewInfo)]
846    #[must_use]
847    pub fn get_quilt_view_info(&self, view_index: u32) -> JsValue {
848        let Some(config) = &self.holographic_config else {
849            return JsValue::NULL;
850        };
851
852        if view_index >= config.num_views {
853            return JsValue::NULL;
854        }
855
856        let (x_offset, y_offset) = config.view_offset(view_index);
857        let (col, row) = config.view_to_grid(view_index);
858
859        let obj = js_sys::Object::new();
860        js_set_property(&obj, "index", &JsValue::from_f64(f64::from(view_index)));
861        js_set_property(&obj, "xOffset", &JsValue::from_f64(f64::from(x_offset)));
862        js_set_property(&obj, "yOffset", &JsValue::from_f64(f64::from(y_offset)));
863        js_set_property(
864            &obj,
865            "width",
866            &JsValue::from_f64(f64::from(config.view_width)),
867        );
868        js_set_property(
869            &obj,
870            "height",
871            &JsValue::from_f64(f64::from(config.view_height)),
872        );
873        js_set_property(&obj, "column", &JsValue::from_f64(f64::from(col)));
874        js_set_property(&obj, "row", &JsValue::from_f64(f64::from(row)));
875
876        obj.into()
877    }
878
879    /// Get statistics from the holographic renderer.
880    ///
881    /// Returns a JS object with: framesRendered, avgRenderTimeMs, peakRenderTimeMs, totalViewsRendered
882    /// Returns null if holographic mode is not enabled.
883    #[wasm_bindgen(js_name = getHolographicStats)]
884    #[must_use]
885    #[allow(clippy::cast_precision_loss)] // Stats counters unlikely to exceed 2^52
886    pub fn get_holographic_stats(&self) -> JsValue {
887        let Some(renderer) = &self.holographic_renderer else {
888            return JsValue::NULL;
889        };
890
891        let stats = renderer.stats();
892        let obj = js_sys::Object::new();
893        js_set_property(
894            &obj,
895            "framesRendered",
896            &JsValue::from_f64(stats.frames_rendered as f64),
897        );
898        js_set_property(
899            &obj,
900            "avgRenderTimeMs",
901            &JsValue::from_f64(stats.avg_render_time_ms),
902        );
903        js_set_property(
904            &obj,
905            "peakRenderTimeMs",
906            &JsValue::from_f64(stats.peak_render_time_ms),
907        );
908        js_set_property(
909            &obj,
910            "totalViewsRendered",
911            &JsValue::from_f64(stats.total_views_rendered as f64),
912        );
913
914        obj.into()
915    }
916
917    /// Reset holographic rendering statistics.
918    #[wasm_bindgen(js_name = resetHolographicStats)]
919    pub fn reset_holographic_stats(&mut self) {
920        if let Some(renderer) = &mut self.holographic_renderer {
921            renderer.reset_stats();
922        }
923    }
924
925    // =========================================================================
926    // Voice Input Methods
927    // =========================================================================
928
929    /// Process a voice recognition result.
930    ///
931    /// This method handles speech recognition results from the Web Speech API.
932    /// If a touch event is pending within the fusion window, it will create
933    /// a fused intent combining the voice command with the touch location.
934    ///
935    /// # Arguments
936    ///
937    /// * `transcript` - The recognized speech text
938    /// * `confidence` - Confidence score (0.0 to 1.0)
939    /// * `is_final` - Whether this is a final (committed) result
940    /// * `timestamp` - Timestamp when the speech was recognized (ms since epoch)
941    ///
942    /// # Returns
943    ///
944    /// JSON-encoded fusion result if fusion occurs, or null if no fusion.
945    #[wasm_bindgen(js_name = processVoice)]
946    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
947    pub fn process_voice(
948        &mut self,
949        transcript: String,
950        confidence: f32,
951        is_final: bool,
952        timestamp: f64,
953    ) -> JsValue {
954        let voice = VoiceEvent::new(transcript, confidence, is_final, timestamp as u64);
955        let result = self.input_fusion.process_voice(&voice);
956
957        // Also process the voice event through state
958        self.state.process_event(&InputEvent::Voice(voice));
959
960        match result {
961            FusionResult::Fused(intent) => serde_json::to_string(&intent)
962                .map(|s| JsValue::from_str(&s))
963                .unwrap_or(JsValue::NULL),
964            FusionResult::VoiceOnly(intent) => serde_json::to_string(&intent)
965                .map(|s| JsValue::from_str(&s))
966                .unwrap_or(JsValue::NULL),
967            FusionResult::Pending | FusionResult::None => JsValue::NULL,
968        }
969    }
970
971    /// Check if there's a pending touch waiting for voice fusion.
972    ///
973    /// Returns true if a touch event is stored and still within the fusion window.
974    #[wasm_bindgen(js_name = hasPendingTouch)]
975    #[must_use]
976    pub fn has_pending_touch(&self) -> bool {
977        self.input_fusion.is_touch_valid()
978    }
979
980    /// Get the remaining time in the fusion window for a pending touch.
981    ///
982    /// Returns the time in milliseconds, or 0 if no pending touch or expired.
983    #[wasm_bindgen(js_name = fusionTimeRemaining)]
984    #[must_use]
985    #[allow(clippy::cast_possible_truncation)]
986    pub fn fusion_time_remaining(&self) -> u32 {
987        self.input_fusion
988            .time_remaining()
989            .map_or(0, |d| d.as_millis().min(u128::from(u32::MAX)) as u32)
990    }
991
992    /// Configure the fusion time window.
993    ///
994    /// Sets how long a touch event waits for a voice command before expiring.
995    ///
996    /// # Arguments
997    ///
998    /// * `window_ms` - Time window in milliseconds (default: 2000)
999    #[wasm_bindgen(js_name = setFusionWindow)]
1000    pub fn set_fusion_window(&mut self, window_ms: u32) {
1001        use std::time::Duration;
1002        self.input_fusion.set_config(FusionConfig {
1003            fusion_window: Duration::from_millis(u64::from(window_ms)),
1004            ..self.input_fusion.config().clone()
1005        });
1006    }
1007
1008    /// Clear any pending touch event.
1009    ///
1010    /// Call this to cancel touch+voice fusion if the user cancels the operation.
1011    #[wasm_bindgen(js_name = clearPendingTouch)]
1012    pub fn clear_pending_touch(&mut self) {
1013        self.input_fusion.clear_pending();
1014    }
1015
1016    /// Get the current fusion window configuration in milliseconds.
1017    #[wasm_bindgen(js_name = getFusionWindow)]
1018    #[must_use]
1019    #[allow(clippy::cast_possible_truncation)]
1020    pub fn get_fusion_window(&self) -> u32 {
1021        self.input_fusion
1022            .config()
1023            .fusion_window
1024            .as_millis()
1025            .min(u128::from(u32::MAX)) as u32
1026    }
1027
1028    /// Get the minimum confidence threshold for voice recognition.
1029    #[wasm_bindgen(js_name = getMinVoiceConfidence)]
1030    #[must_use]
1031    pub fn get_min_voice_confidence(&self) -> f32 {
1032        self.input_fusion.config().min_confidence
1033    }
1034
1035    /// Set the minimum confidence threshold for voice recognition.
1036    ///
1037    /// Voice events with confidence below this threshold will be ignored.
1038    ///
1039    /// # Arguments
1040    ///
1041    /// * `confidence` - Minimum confidence (0.0 to 1.0, default: 0.5)
1042    #[wasm_bindgen(js_name = setMinVoiceConfidence)]
1043    pub fn set_min_voice_confidence(&mut self, confidence: f32) {
1044        self.input_fusion.set_config(FusionConfig {
1045            min_confidence: confidence.clamp(0.0, 1.0),
1046            ..self.input_fusion.config().clone()
1047        });
1048    }
1049}
1050
1051/// Create a chart element JSON with sample data.
1052#[wasm_bindgen(js_name = createChartElement)]
1053#[must_use]
1054pub fn create_chart_element(chart_type: &str, x: f32, y: f32, width: f32, height: f32) -> String {
1055    // Provide sample data based on chart type
1056    let data = match chart_type {
1057        "pie" | "donut" => serde_json::json!({
1058            "series": [
1059                {"label": "Category A", "value": 35},
1060                {"label": "Category B", "value": 25},
1061                {"label": "Category C", "value": 20},
1062                {"label": "Category D", "value": 15},
1063                {"label": "Other", "value": 5}
1064            ]
1065        }),
1066        "scatter" => serde_json::json!({
1067            "series": [{
1068                "name": "Sample Data",
1069                "points": [
1070                    {"x": 10, "y": 20},
1071                    {"x": 25, "y": 40},
1072                    {"x": 40, "y": 35},
1073                    {"x": 55, "y": 60},
1074                    {"x": 70, "y": 50},
1075                    {"x": 85, "y": 75}
1076                ]
1077            }]
1078        }),
1079        _ => serde_json::json!({
1080            "series": [{
1081                "name": "Series 1",
1082                "points": [
1083                    {"x": "Jan", "y": 30},
1084                    {"x": "Feb", "y": 45},
1085                    {"x": "Mar", "y": 28},
1086                    {"x": "Apr", "y": 60},
1087                    {"x": "May", "y": 55},
1088                    {"x": "Jun", "y": 70}
1089                ]
1090            }],
1091            "x_label": "Month",
1092            "y_label": "Value"
1093        }),
1094    };
1095
1096    let element = Element::new(ElementKind::Chart {
1097        chart_type: chart_type.to_string(),
1098        data,
1099    })
1100    .with_transform(Transform {
1101        x,
1102        y,
1103        width,
1104        height,
1105        rotation: 0.0,
1106        z_index: 0,
1107    });
1108
1109    serde_json::to_string(&element).unwrap_or_default()
1110}
1111
1112/// Create a chart element JSON with custom data.
1113///
1114/// # Errors
1115///
1116/// Returns an error if the data JSON is invalid.
1117#[wasm_bindgen(js_name = createChartWithData)]
1118pub fn create_chart_with_data(
1119    chart_type: &str,
1120    data_json: &str,
1121    x: f32,
1122    y: f32,
1123    width: f32,
1124    height: f32,
1125) -> Result<String, JsValue> {
1126    let data: serde_json::Value =
1127        serde_json::from_str(data_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
1128
1129    let element = Element::new(ElementKind::Chart {
1130        chart_type: chart_type.to_string(),
1131        data,
1132    })
1133    .with_transform(Transform {
1134        x,
1135        y,
1136        width,
1137        height,
1138        rotation: 0.0,
1139        z_index: 0,
1140    });
1141
1142    serde_json::to_string(&element).map_err(|e| JsValue::from_str(&e.to_string()))
1143}
1144
1145/// Create a text element JSON.
1146#[wasm_bindgen(js_name = createTextElement)]
1147#[must_use]
1148pub fn create_text_element(content: &str, x: f32, y: f32, font_size: f32, color: &str) -> String {
1149    let element = Element::new(ElementKind::Text {
1150        content: content.to_string(),
1151        font_size,
1152        color: color.to_string(),
1153    })
1154    .with_transform(Transform {
1155        x,
1156        y,
1157        width: 200.0, // Default width
1158        height: font_size * 1.5,
1159        rotation: 0.0,
1160        z_index: 0,
1161    });
1162
1163    serde_json::to_string(&element).unwrap_or_default()
1164}
1165
1166/// Create an image element JSON.
1167#[wasm_bindgen(js_name = createImageElement)]
1168#[must_use]
1169pub fn create_image_element(src: &str, x: f32, y: f32, width: f32, height: f32) -> String {
1170    let element = Element::new(ElementKind::Image {
1171        src: src.to_string(),
1172        format: canvas_core::ImageFormat::Png,
1173    })
1174    .with_transform(Transform {
1175        x,
1176        y,
1177        width,
1178        height,
1179        rotation: 0.0,
1180        z_index: 0,
1181    });
1182
1183    serde_json::to_string(&element).unwrap_or_default()
1184}
1185
1186/// Create a video element JSON.
1187#[wasm_bindgen(js_name = createVideoElement)]
1188#[must_use]
1189pub fn create_video_element(
1190    stream_id: &str,
1191    is_live: bool,
1192    mirror: bool,
1193    x: f32,
1194    y: f32,
1195    width: f32,
1196    height: f32,
1197) -> String {
1198    let element = Element::new(ElementKind::Video {
1199        stream_id: stream_id.to_string(),
1200        is_live,
1201        mirror,
1202        crop: None,
1203        media_config: None,
1204    })
1205    .with_transform(Transform {
1206        x,
1207        y,
1208        width,
1209        height,
1210        rotation: 0.0,
1211        z_index: 10, // Video on top by default
1212    });
1213
1214    serde_json::to_string(&element).unwrap_or_default()
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219    use super::*;
1220    use wasm_bindgen_test::*;
1221
1222    wasm_bindgen_test_configure!(run_in_browser);
1223
1224    /// Create a test canvas element in the DOM and return a `CanvasApp` instance.
1225    ///
1226    /// This helper creates a unique canvas element for each test to avoid conflicts.
1227    fn create_test_app(width: u32, height: u32) -> CanvasApp {
1228        use std::sync::atomic::{AtomicU32, Ordering};
1229        static COUNTER: AtomicU32 = AtomicU32::new(0);
1230
1231        let id = format!("test-canvas-{}", COUNTER.fetch_add(1, Ordering::SeqCst));
1232
1233        let window = web_sys::window().expect("no window");
1234        let document = window.document().expect("no document");
1235
1236        // Create canvas element
1237        let canvas = document
1238            .create_element("canvas")
1239            .expect("failed to create canvas")
1240            .dyn_into::<HtmlCanvasElement>()
1241            .expect("not a canvas");
1242        canvas.set_id(&id);
1243        canvas.set_width(width);
1244        canvas.set_height(height);
1245
1246        // Add to document body
1247        document
1248            .body()
1249            .expect("no body")
1250            .append_child(&canvas)
1251            .expect("failed to append canvas");
1252
1253        CanvasApp::new(&id).expect("failed to create CanvasApp")
1254    }
1255
1256    // ============================================================================
1257    // Holographic Configuration Tests
1258    // ============================================================================
1259
1260    #[wasm_bindgen_test]
1261    fn test_set_holographic_config_portrait() {
1262        let mut app = create_test_app(800, 600);
1263
1264        // Initially not in holographic mode
1265        assert!(!app.is_holographic_mode());
1266
1267        // Set holographic config
1268        app.set_holographic_config("portrait")
1269            .expect("config failed");
1270
1271        // Now should be in holographic mode
1272        assert!(app.is_holographic_mode());
1273    }
1274
1275    #[wasm_bindgen_test]
1276    fn test_set_holographic_config_4k() {
1277        let mut app = create_test_app(800, 600);
1278
1279        app.set_holographic_config("4k").expect("config failed");
1280
1281        assert!(app.is_holographic_mode());
1282    }
1283
1284    #[wasm_bindgen_test]
1285    fn test_set_holographic_config_8k() {
1286        let mut app = create_test_app(800, 600);
1287
1288        app.set_holographic_config("8k").expect("config failed");
1289
1290        assert!(app.is_holographic_mode());
1291    }
1292
1293    #[wasm_bindgen_test]
1294    fn test_set_holographic_config_go() {
1295        let mut app = create_test_app(800, 600);
1296
1297        app.set_holographic_config("go").expect("config failed");
1298
1299        assert!(app.is_holographic_mode());
1300    }
1301
1302    #[wasm_bindgen_test]
1303    fn test_set_holographic_config_unknown_defaults_to_portrait() {
1304        let mut app = create_test_app(800, 600);
1305
1306        // Unknown preset should default to portrait
1307        app.set_holographic_config("unknown_preset")
1308            .expect("config failed");
1309
1310        assert!(app.is_holographic_mode());
1311    }
1312
1313    // ============================================================================
1314    // Quilt Dimensions Tests
1315    // ============================================================================
1316
1317    #[wasm_bindgen_test]
1318    fn test_get_quilt_dimensions_portrait() {
1319        let mut app = create_test_app(800, 600);
1320        app.set_holographic_config("portrait")
1321            .expect("config failed");
1322
1323        let dims = app.get_quilt_dimensions();
1324
1325        // Portrait preset: 5 cols × 420px = 2100, 9 rows × 560px = 5040
1326        assert_eq!(js_sys::Reflect::get(&dims, &"width".into()).unwrap(), 2100);
1327        assert_eq!(js_sys::Reflect::get(&dims, &"height".into()).unwrap(), 5040);
1328    }
1329
1330    #[wasm_bindgen_test]
1331    fn test_get_quilt_dimensions_4k() {
1332        let mut app = create_test_app(800, 600);
1333        app.set_holographic_config("4k").expect("config failed");
1334
1335        let dims = app.get_quilt_dimensions();
1336
1337        // 4K preset: 5 cols × 819px = 4095, 9 rows × 455px = 4095
1338        assert_eq!(js_sys::Reflect::get(&dims, &"width".into()).unwrap(), 4095);
1339        assert_eq!(js_sys::Reflect::get(&dims, &"height".into()).unwrap(), 4095);
1340    }
1341
1342    #[wasm_bindgen_test]
1343    fn test_get_quilt_dimensions_not_in_holographic_mode() {
1344        let app = create_test_app(800, 600);
1345
1346        let dims = app.get_quilt_dimensions();
1347
1348        // Not in holographic mode, should return 0x0
1349        assert_eq!(js_sys::Reflect::get(&dims, &"width".into()).unwrap(), 0);
1350        assert_eq!(js_sys::Reflect::get(&dims, &"height".into()).unwrap(), 0);
1351    }
1352
1353    // ============================================================================
1354    // Holographic Camera Tests
1355    // ============================================================================
1356
1357    #[wasm_bindgen_test]
1358    fn test_set_holographic_camera() {
1359        let mut app = create_test_app(800, 600);
1360        app.set_holographic_config("portrait")
1361            .expect("config failed");
1362
1363        // Set camera position and target
1364        app.set_holographic_camera(
1365            0.0, 0.0, 5.0, // position
1366            0.0, 0.0, 0.0, // target
1367        );
1368
1369        // Should still be in holographic mode
1370        assert!(app.is_holographic_mode());
1371    }
1372
1373    #[wasm_bindgen_test]
1374    fn test_set_holographic_camera_not_in_holographic_mode() {
1375        let mut app = create_test_app(800, 600);
1376
1377        // Set camera without holographic mode enabled (should be a no-op)
1378        app.set_holographic_camera(
1379            0.0, 0.0, 5.0, // position
1380            0.0, 0.0, 0.0, // target
1381        );
1382
1383        // Should still not be in holographic mode
1384        assert!(!app.is_holographic_mode());
1385    }
1386
1387    // ============================================================================
1388    // Holographic Preset Tests
1389    // ============================================================================
1390
1391    #[wasm_bindgen_test]
1392    fn test_get_holographic_preset_portrait() {
1393        let mut app = create_test_app(800, 600);
1394        app.set_holographic_config("portrait")
1395            .expect("config failed");
1396
1397        let preset = app.get_holographic_preset();
1398
1399        // Verify preset name
1400        assert_eq!(preset, "portrait");
1401    }
1402
1403    #[wasm_bindgen_test]
1404    fn test_get_holographic_preset_not_in_mode() {
1405        let app = create_test_app(800, 600);
1406
1407        let preset = app.get_holographic_preset();
1408
1409        // Not in holographic mode, should return "none"
1410        assert_eq!(preset, "none");
1411    }
1412
1413    // ============================================================================
1414    // Quilt View Info Tests
1415    // ============================================================================
1416
1417    #[wasm_bindgen_test]
1418    fn test_get_quilt_view_info_valid_index() {
1419        let mut app = create_test_app(800, 600);
1420        app.set_holographic_config("portrait")
1421            .expect("config failed");
1422
1423        // Get first view (index 0)
1424        let view_info = app.get_quilt_view_info(0);
1425
1426        // Verify view info structure
1427        assert_eq!(
1428            js_sys::Reflect::get(&view_info, &"index".into()).unwrap(),
1429            0
1430        );
1431        assert_eq!(
1432            js_sys::Reflect::get(&view_info, &"xOffset".into()).unwrap(),
1433            0
1434        );
1435        assert_eq!(
1436            js_sys::Reflect::get(&view_info, &"yOffset".into()).unwrap(),
1437            0
1438        );
1439        assert_eq!(
1440            js_sys::Reflect::get(&view_info, &"width".into()).unwrap(),
1441            420
1442        );
1443        assert_eq!(
1444            js_sys::Reflect::get(&view_info, &"height".into()).unwrap(),
1445            560
1446        );
1447    }
1448
1449    #[wasm_bindgen_test]
1450    fn test_get_quilt_view_info_last_view() {
1451        let mut app = create_test_app(800, 600);
1452        app.set_holographic_config("portrait")
1453            .expect("config failed");
1454
1455        // Get last view (index 44 for portrait with 45 views)
1456        let view_info = app.get_quilt_view_info(44);
1457
1458        assert_eq!(
1459            js_sys::Reflect::get(&view_info, &"index".into()).unwrap(),
1460            44
1461        );
1462    }
1463
1464    #[wasm_bindgen_test]
1465    fn test_get_quilt_view_info_out_of_bounds() {
1466        let mut app = create_test_app(800, 600);
1467        app.set_holographic_config("portrait")
1468            .expect("config failed");
1469
1470        // Get view with invalid index (> 44)
1471        let view_info = app.get_quilt_view_info(100);
1472
1473        // Out of bounds should return empty object
1474        assert!(js_sys::Reflect::get(&view_info, &"index".into())
1475            .unwrap()
1476            .is_undefined());
1477    }
1478
1479    #[wasm_bindgen_test]
1480    fn test_get_quilt_view_info_not_in_holographic_mode() {
1481        let app = create_test_app(800, 600);
1482
1483        let view_info = app.get_quilt_view_info(0);
1484
1485        // Not in holographic mode should return empty object
1486        assert!(js_sys::Reflect::get(&view_info, &"index".into())
1487            .unwrap()
1488            .is_undefined());
1489    }
1490
1491    // ============================================================================
1492    // Holographic Stats Tests
1493    // ============================================================================
1494
1495    #[wasm_bindgen_test]
1496    fn test_get_holographic_stats_initial() {
1497        let mut app = create_test_app(800, 600);
1498        app.set_holographic_config("portrait")
1499            .expect("config failed");
1500
1501        let stats = app.get_holographic_stats();
1502
1503        // Initial stats should be zero
1504        assert_eq!(
1505            js_sys::Reflect::get(&stats, &"framesRendered".into()).unwrap(),
1506            0.0
1507        );
1508        assert_eq!(
1509            js_sys::Reflect::get(&stats, &"avgRenderTimeMs".into()).unwrap(),
1510            0.0
1511        );
1512        assert_eq!(
1513            js_sys::Reflect::get(&stats, &"peakRenderTimeMs".into()).unwrap(),
1514            0.0
1515        );
1516        assert_eq!(
1517            js_sys::Reflect::get(&stats, &"totalViewsRendered".into()).unwrap(),
1518            0.0
1519        );
1520    }
1521
1522    #[wasm_bindgen_test]
1523    fn test_get_holographic_stats_not_in_mode() {
1524        let app = create_test_app(800, 600);
1525
1526        let stats = app.get_holographic_stats();
1527
1528        // Not in holographic mode should still return valid object with zeros
1529        assert_eq!(
1530            js_sys::Reflect::get(&stats, &"framesRendered".into()).unwrap(),
1531            0.0
1532        );
1533    }
1534
1535    #[wasm_bindgen_test]
1536    fn test_reset_holographic_stats() {
1537        let mut app = create_test_app(800, 600);
1538        app.set_holographic_config("portrait")
1539            .expect("config failed");
1540
1541        // Reset stats (should not panic even with no renders)
1542        app.reset_holographic_stats();
1543
1544        let stats = app.get_holographic_stats();
1545        assert_eq!(
1546            js_sys::Reflect::get(&stats, &"framesRendered".into()).unwrap(),
1547            0.0
1548        );
1549    }
1550
1551    #[wasm_bindgen_test]
1552    fn test_reset_holographic_stats_not_in_mode() {
1553        let mut app = create_test_app(800, 600);
1554
1555        // Reset stats without holographic mode (should be a no-op)
1556        app.reset_holographic_stats();
1557
1558        // Should not panic
1559        assert!(!app.is_holographic_mode());
1560    }
1561
1562    // ============================================================================
1563    // Render Quilt Tests
1564    // ============================================================================
1565
1566    #[wasm_bindgen_test]
1567    fn test_render_quilt_returns_pixels() {
1568        let mut app = create_test_app(800, 600);
1569        app.set_holographic_config("portrait")
1570            .expect("config failed");
1571
1572        let result = app.render_quilt().expect("render_quilt failed");
1573
1574        // Should return pixel data
1575        // Portrait: 2100 × 5040 × 4 (RGBA) = 42,336,000 bytes
1576        assert!(!result.is_empty());
1577    }
1578
1579    #[wasm_bindgen_test]
1580    fn test_render_quilt_not_in_holographic_mode() {
1581        let mut app = create_test_app(800, 600);
1582
1583        let result = app.render_quilt();
1584
1585        // Not in holographic mode should return an error
1586        assert!(result.is_err());
1587    }
1588
1589    #[wasm_bindgen_test]
1590    fn test_render_quilt_updates_stats() {
1591        let mut app = create_test_app(800, 600);
1592        app.set_holographic_config("portrait")
1593            .expect("config failed");
1594
1595        // Render once
1596        let _ = app.render_quilt();
1597
1598        let stats = app.get_holographic_stats();
1599        let frames = js_sys::Reflect::get(&stats, &"framesRendered".into())
1600            .unwrap()
1601            .as_f64()
1602            .unwrap();
1603        assert!((frames - 1.0).abs() < f64::EPSILON);
1604
1605        let views = js_sys::Reflect::get(&stats, &"totalViewsRendered".into())
1606            .unwrap()
1607            .as_f64()
1608            .unwrap();
1609        assert!((views - 45.0).abs() < f64::EPSILON); // Portrait has 45 views
1610    }
1611
1612    #[wasm_bindgen_test]
1613    fn test_render_quilt_multiple_frames() {
1614        let mut app = create_test_app(800, 600);
1615        app.set_holographic_config("portrait")
1616            .expect("config failed");
1617
1618        // Render multiple times
1619        for _ in 0..3 {
1620            let _ = app.render_quilt();
1621        }
1622
1623        let stats = app.get_holographic_stats();
1624        let frames = js_sys::Reflect::get(&stats, &"framesRendered".into())
1625            .unwrap()
1626            .as_f64()
1627            .unwrap();
1628        assert!((frames - 3.0).abs() < f64::EPSILON);
1629
1630        let views = js_sys::Reflect::get(&stats, &"totalViewsRendered".into())
1631            .unwrap()
1632            .as_f64()
1633            .unwrap();
1634        assert!((views - 135.0).abs() < f64::EPSILON); // 45 views × 3 frames
1635    }
1636}