1#![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
44use wasm_bindgen::prelude::*;
49use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData};
50
51#[wasm_bindgen(start)]
53pub fn init_wasm() {
54 console_error_panic_hook::set_once();
55 tracing::info!("Saorsa Canvas WASM initialized");
56}
57
58fn 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 #[cfg(debug_assertions)]
66 web_sys::console::warn_2(
67 &JsValue::from_str(&format!("Failed to set JS property '{key}': ")),
68 &e,
69 );
70 #[cfg(not(debug_assertions))]
72 let _ = e;
73 }
74}
75
76struct VideoFrame {
78 data: Vec<u8>,
80 width: u32,
82 height: u32,
84 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 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#[wasm_bindgen]
359pub struct CanvasApp {
360 scene: Scene,
361 state: CanvasState,
362 frame_count: u64,
363 renderer_state: RendererHandle,
364 renderer: Renderer,
365 holographic_config: Option<HolographicConfig>,
367 holographic_renderer: Option<HolographicRenderer>,
369 holographic_camera: Camera,
371 input_fusion: InputFusion,
373}
374
375#[wasm_bindgen]
376impl CanvasApp {
377 #[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 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 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 #[wasm_bindgen(js_name = handleTouch)]
447 pub fn handle_touch(&mut self, x: f32, y: f32, phase: &str) -> Option<String> {
448 let element_id = self.scene.element_at(x, y);
450
451 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 let touch_point = TouchPoint {
461 id: 0,
462 x,
463 y,
464 pressure: None,
465 radius: None,
466 };
467
468 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 let _ = self.input_fusion.process_touch(&touch_event);
475
476 self.state.process_event(&event);
478
479 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 #[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 #[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 #[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 #[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 #[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 #[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 #[wasm_bindgen(js_name = elementCount)]
565 #[must_use]
566 pub fn element_count(&self) -> usize {
567 self.scene.element_count()
568 }
569
570 #[wasm_bindgen(js_name = frameCount)]
572 #[must_use]
573 pub fn frame_count(&self) -> u64 {
574 self.frame_count
575 }
576
577 #[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 #[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 #[wasm_bindgen(js_name = isConnected)]
599 #[must_use]
600 pub fn is_connected(&self) -> bool {
601 self.state.is_connected()
602 }
603
604 fn select_element(&mut self, id: &ElementId) {
606 for element in self.scene.elements_mut() {
608 element.selected = element.id == *id;
609 }
610 }
611
612 fn clear_selection(&mut self) {
614 for element in self.scene.elements_mut() {
615 element.selected = false;
616 }
617 }
618
619 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[wasm_bindgen(js_name = getHolographicStats)]
884 #[must_use]
885 #[allow(clippy::cast_precision_loss)] 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 #[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 #[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 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 #[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 #[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 #[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 #[wasm_bindgen(js_name = clearPendingTouch)]
1012 pub fn clear_pending_touch(&mut self) {
1013 self.input_fusion.clear_pending();
1014 }
1015
1016 #[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 #[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 #[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#[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 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#[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#[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, 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#[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#[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, });
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 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 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 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 #[wasm_bindgen_test]
1261 fn test_set_holographic_config_portrait() {
1262 let mut app = create_test_app(800, 600);
1263
1264 assert!(!app.is_holographic_mode());
1266
1267 app.set_holographic_config("portrait")
1269 .expect("config failed");
1270
1271 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 app.set_holographic_config("unknown_preset")
1308 .expect("config failed");
1309
1310 assert!(app.is_holographic_mode());
1311 }
1312
1313 #[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 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 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 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 #[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 app.set_holographic_camera(
1365 0.0, 0.0, 5.0, 0.0, 0.0, 0.0, );
1368
1369 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 app.set_holographic_camera(
1379 0.0, 0.0, 5.0, 0.0, 0.0, 0.0, );
1382
1383 assert!(!app.is_holographic_mode());
1385 }
1386
1387 #[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 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 assert_eq!(preset, "none");
1411 }
1412
1413 #[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 let view_info = app.get_quilt_view_info(0);
1425
1426 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 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 let view_info = app.get_quilt_view_info(100);
1472
1473 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 assert!(js_sys::Reflect::get(&view_info, &"index".into())
1487 .unwrap()
1488 .is_undefined());
1489 }
1490
1491 #[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 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 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 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 app.reset_holographic_stats();
1557
1558 assert!(!app.is_holographic_mode());
1560 }
1561
1562 #[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 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 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 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); }
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 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); }
1636}