Skip to main content

dioxus_three/
desktop.rs

1//! Desktop implementation of ThreeView using WebView iframe
2//!
3//! Enhanced with eval bridge for iframe -> Rust event communication.
4
5use crate::{
6    generate_three_js_html, EntityId, GizmoEvent, GizmoMode, GizmoSpace, GizmoTransform, HitInfo,
7    PointerEvent, ThreeViewProps, Vector2, Vector3,
8};
9use dioxus::document::eval;
10use dioxus::prelude::*;
11
12/// Parse a hit info from serde_json::Value
13fn parse_hit_info(val: &serde_json::Value) -> Option<HitInfo> {
14    let hit = val.get("hit")?;
15    if hit.is_null() {
16        return None;
17    }
18    let entity_id = hit.get("entityId")?.as_u64()? as usize;
19    let point = parse_vec3(hit.get("point")?)?;
20    let normal = parse_vec3(hit.get("normal")?)?;
21    let uv = hit.get("uv").and_then(|uv| {
22        if uv.is_null() {
23            None
24        } else {
25            Some(Vector2::new(
26                uv.get("x")?.as_f64()? as f32,
27                uv.get("y")?.as_f64()? as f32,
28            ))
29        }
30    });
31    let distance = hit.get("distance")?.as_f64()? as f32;
32    let face_index = hit
33        .get("faceIndex")
34        .and_then(|v| v.as_u64())
35        .map(|v| v as usize);
36    let instance_id = hit
37        .get("instanceId")
38        .and_then(|v| v.as_u64())
39        .map(|v| v as usize);
40
41    Some(HitInfo {
42        entity_id: EntityId(entity_id),
43        point,
44        normal,
45        uv,
46        distance,
47        face_index,
48        instance_id,
49    })
50}
51
52fn parse_vec2(val: &serde_json::Value) -> Option<Vector2> {
53    Some(Vector2::new(
54        val.get("x")?.as_f64()? as f32,
55        val.get("y")?.as_f64()? as f32,
56    ))
57}
58
59fn parse_vec3(val: &serde_json::Value) -> Option<Vector3> {
60    Some(Vector3::new(
61        val.get("x")?.as_f64()? as f32,
62        val.get("y")?.as_f64()? as f32,
63        val.get("z")?.as_f64()? as f32,
64    ))
65}
66
67fn parse_pointer_event(data: &serde_json::Value) -> Option<PointerEvent> {
68    let hit = parse_hit_info(data);
69    let screen_position = parse_vec2(data.get("screenPosition")?)?;
70    let ndc_position = parse_vec2(data.get("ndcPosition")?)?;
71    let button = data.get("button").and_then(|b| match b.as_str()? {
72        "Left" => Some(crate::MouseButton::Left),
73        "Right" => Some(crate::MouseButton::Right),
74        "Middle" => Some(crate::MouseButton::Middle),
75        _ => None,
76    });
77    let shift_key = data
78        .get("shiftKey")
79        .and_then(|v| v.as_bool())
80        .unwrap_or(false);
81    let ctrl_key = data
82        .get("ctrlKey")
83        .and_then(|v| v.as_bool())
84        .unwrap_or(false);
85    let alt_key = data
86        .get("altKey")
87        .and_then(|v| v.as_bool())
88        .unwrap_or(false);
89
90    Some(PointerEvent {
91        hit,
92        screen_position,
93        ndc_position,
94        button,
95        shift_key,
96        ctrl_key,
97        alt_key,
98    })
99}
100
101fn parse_gizmo_event(data: &serde_json::Value) -> Option<GizmoEvent> {
102    let target = data.get("target")?.as_u64()? as usize;
103    let mode = data
104        .get("mode")
105        .and_then(|m| match m.as_str()?.to_lowercase().as_str() {
106            "translate" => Some(GizmoMode::Translate),
107            "rotate" => Some(GizmoMode::Rotate),
108            "scale" => Some(GizmoMode::Scale),
109            _ => Some(GizmoMode::Translate),
110        })
111        .unwrap_or(GizmoMode::Translate);
112    let space = data
113        .get("space")
114        .and_then(|s| match s.as_str()?.to_lowercase().as_str() {
115            "local" => Some(GizmoSpace::Local),
116            _ => Some(GizmoSpace::World),
117        })
118        .unwrap_or(GizmoSpace::World);
119
120    let transform_obj = data.get("transform")?;
121    let position = parse_vec3(transform_obj.get("position")?)?;
122    let rotation = parse_vec3(transform_obj.get("rotation")?)?;
123    let scale = parse_vec3(transform_obj.get("scale")?)?;
124    let is_finished = data
125        .get("isFinished")
126        .and_then(|v| v.as_bool())
127        .unwrap_or(false);
128
129    Some(GizmoEvent {
130        target: EntityId(target),
131        mode,
132        space,
133        transform: GizmoTransform {
134            position,
135            rotation,
136            scale,
137        },
138        is_finished,
139    })
140}
141
142/// A Three.js 3D viewer component for Dioxus Desktop
143///
144/// Uses a WebView iframe to render the Three.js scene.
145/// Supports all features including multiple models, shaders, animations,
146/// raycasting, selection, and transform gizmos.
147#[component]
148pub fn ThreeView(mut props: ThreeViewProps) -> Element {
149    // Generate initial HTML once - subsequent updates go via postMessage
150    let mut html_signal = use_signal(|| generate_three_js_html(&props));
151    let mut prev_models_len = use_signal(|| props.models.len());
152
153    // Take callbacks for event handling
154    let on_pointer_down = props.on_pointer_down.take();
155    let on_pointer_up = props.on_pointer_up.take();
156    let on_pointer_move = props.on_pointer_move.take();
157    let on_gizmo_drag = props.on_gizmo_drag.take();
158    let on_selection_change = props.on_selection_change.take();
159
160    // Set up eval bridge for iframe events
161    use_hook(move || {
162        spawn(async move {
163            let mut eval = eval(
164                r#"
165                window._dioxusThreeEvents = [];
166                window.addEventListener('message', function(e) {
167                    // Only process messages from our Three.js iframe
168                    var iframe = document.querySelector('iframe[srcdoc]');
169                    if (iframe && iframe.contentWindow && e.source !== iframe.contentWindow) {
170                        // Not from our iframe, but still allow if it looks like our event format
171                        // (some WebView implementations don't set e.source correctly)
172                        if (!e.data || !e.data.type) return;
173                    }
174                    if (e.data && (
175                        e.data.type === 'gizmo-drag' || 
176                        e.data.type === 'pointer-down' ||
177                        e.data.type === 'pointer-up' ||
178                        e.data.type === 'pointer-move' ||
179                        e.data.type === 'selection-change'
180                    )) {
181                        console.log('[EVAL BRIDGE] received:', e.data.type);
182                        window._dioxusThreeEvents.push(e.data);
183                        dioxus.send(e.data);
184                    }
185                });
186                
187                while (true) {
188                    await dioxus.recv();
189                }
190                "#,
191            );
192
193            loop {
194                match eval.recv::<serde_json::Value>().await {
195                    Ok(event) => {
196                        let event_type = event.get("type").and_then(|v| v.as_str());
197                        let data = event.get("data");
198
199                        println!("[DESKTOP BRIDGE] received event: {:?}", event_type);
200
201                        match event_type {
202                            Some("gizmo-drag") => {
203                                println!("[DESKTOP BRIDGE] gizmo-drag event");
204                                if let (Some(cb), Some(data)) = (&on_gizmo_drag, data) {
205                                    if let Some(gizmo_event) = parse_gizmo_event(data) {
206                                        cb.call(gizmo_event);
207                                    }
208                                }
209                            }
210                            Some("pointer-down") => {
211                                println!("[DESKTOP BRIDGE] pointer-down event");
212                                if let (Some(cb), Some(data)) = (&on_pointer_down, data) {
213                                    if let Some(ptr_event) = parse_pointer_event(data) {
214                                        println!(
215                                            "[DESKTOP BRIDGE] parsed pointer-down: hit={:?}",
216                                            ptr_event.hit.as_ref().map(|h| h.entity_id)
217                                        );
218                                        cb.call(ptr_event);
219                                    } else {
220                                        println!("[DESKTOP BRIDGE] failed to parse pointer-down");
221                                    }
222                                } else {
223                                    println!(
224                                        "[DESKTOP BRIDGE] no pointer-down callback registered"
225                                    );
226                                }
227                            }
228                            Some("pointer-up") => {
229                                if let (Some(cb), Some(data)) = (&on_pointer_up, data) {
230                                    if let Some(ptr_event) = parse_pointer_event(data) {
231                                        cb.call(ptr_event);
232                                    }
233                                }
234                            }
235                            Some("pointer-move") => {
236                                if let (Some(cb), Some(data)) = (&on_pointer_move, data) {
237                                    if let Some(ptr_event) = parse_pointer_event(data) {
238                                        cb.call(ptr_event);
239                                    }
240                                }
241                            }
242                            Some("selection-change") => {
243                                if let (Some(cb), Some(data)) = (&on_selection_change, data) {
244                                    if let Some(selection_ids) = data.get("selection") {
245                                        let ids: Vec<EntityId> = selection_ids
246                                            .as_array()
247                                            .unwrap_or(&vec![])
248                                            .iter()
249                                            .filter_map(|v| {
250                                                v.as_u64().map(|id| EntityId(id as usize))
251                                            })
252                                            .collect();
253                                        let mut selection =
254                                            crate::Selection::with_mode(props.selection_mode);
255                                        for id in ids {
256                                            selection.select(id);
257                                        }
258                                        cb.call(selection);
259                                    }
260                                }
261                            }
262                            _ => {}
263                        }
264                    }
265                    Err(e) => {
266                        println!("[DESKTOP BRIDGE] eval recv error: {:?}", e);
267                        // Eval finished or error, break the loop
268                        break;
269                    }
270                }
271            }
272        });
273    });
274
275    // Send state updates to iframe via postMessage (avoids full reloads)
276    use_effect(use_reactive((&props,), move |(new_props,)| {
277        let old_len = prev_models_len();
278        let new_len = new_props.models.len();
279        if old_len != new_len {
280            html_signal.set(generate_three_js_html(&new_props));
281            prev_models_len.set(new_len);
282            return;
283        }
284
285        spawn(async move {
286            let selection_json = match &new_props.selection {
287                Some(s) => format!(
288                    "[{}]",
289                    s.iter()
290                        .map(|e| e.0.to_string())
291                        .collect::<Vec<_>>()
292                        .join(",")
293                ),
294                None => "[]".to_string(),
295            };
296
297            let gizmo_json = match &new_props.gizmo {
298                Some(g) => format!(
299                    r#"{{"target":{},"mode":"{:?}","space":"{:?}"}}"#,
300                    g.target.0, g.mode, g.space
301                ),
302                None => "null".to_string(),
303            };
304
305            println!(
306                "[DESKTOP] Sending postMessage - selection: {}, gizmo: {}",
307                selection_json, gizmo_json
308            );
309
310            let js = format!(
311                r#"
312                (function() {{
313                    const iframe = document.querySelector('iframe[srcdoc]');
314                    if (iframe && iframe.contentWindow) {{
315                        console.log('[POSTMSG] sending update-state');
316                        iframe.contentWindow.postMessage({{
317                            type: 'update-state',
318                            camX: {},
319                            camY: {},
320                            camZ: {},
321                            targetX: {},
322                            targetY: {},
323                            targetZ: {},
324                            autoRotate: {},
325                            rotSpeed: {},
326                            scale: {},
327                            color: '{}',
328                            background: '{}',
329                            showGrid: {},
330                            showAxes: {},
331                            wireframe: {},
332                            selection: {},
333                            gizmo: {}
334                        }}, '*');
335                    }} else {{
336                        console.warn('[POSTMSG] iframe not found');
337                    }}
338                }})();
339            "#,
340                new_props.cam_x,
341                new_props.cam_y,
342                new_props.cam_z,
343                new_props.target_x,
344                new_props.target_y,
345                new_props.target_z,
346                new_props.auto_rotate.to_string().to_lowercase(),
347                new_props.rot_speed,
348                new_props.scale,
349                new_props.color.replace('\\', "\\\\").replace('\'', "\\'"),
350                new_props
351                    .background
352                    .replace('\\', "\\\\")
353                    .replace('\'', "\\'"),
354                new_props.show_grid.to_string().to_lowercase(),
355                new_props.show_axes.to_string().to_lowercase(),
356                new_props.wireframe.to_string().to_lowercase(),
357                selection_json,
358                gizmo_json
359            );
360            let _ = eval(&js).await;
361            println!("[DESKTOP] postMessage sent");
362        });
363    }));
364
365    rsx! {
366        iframe {
367            class: "{props.class}",
368            style: "width: 100%; height: 100%; border: none;",
369            srcdoc: "{html_signal}",
370        }
371    }
372}