Skip to main content

dioxus_three/
lib.rs

1//! Dioxus Three - A Three.js component for Dioxus
2//!
3//! Provides a simple component for embedding interactive 3D scenes
4//! using Three.js within Dioxus applications.
5//!
6//! ## Platform Support
7//!
8//! - **Desktop** (Windows, macOS, Linux): Uses WebView with iframe
9//! - **Web** (WASM): Renders directly to canvas element
10//! - **Mobile** (iOS, Android): Uses WebView (similar to desktop)
11//!
12//! Supports multiple 3D formats: OBJ, FBX, GLTF, GLB, STL, PLY, and more.
13//! Also supports custom GLSL shaders for advanced visual effects.
14
15use dioxus::prelude::*;
16use std::collections::HashMap;
17
18// Input handling and raycasting
19pub mod gizmos;
20pub mod input;
21pub mod selection;
22
23// Re-export input types
24pub use gizmos::{Gizmo, GizmoEvent, GizmoMode, GizmoSpace, GizmoTransform};
25pub use input::{
26    Camera, CursorStyle, EntityId, GestureEvent, HitInfo, MouseButton, PointerDragEvent,
27    PointerEvent, RaycastConfig, Raycaster, Vector2, Vector3,
28};
29pub use selection::{Selection, SelectionMode, SelectionStyle};
30
31// Platform-specific modules
32#[cfg(not(target_arch = "wasm32"))]
33mod desktop;
34#[cfg(target_arch = "wasm32")]
35mod web;
36
37// Re-export platform-specific ThreeView
38#[cfg(not(target_arch = "wasm32"))]
39pub use desktop::ThreeView;
40#[cfg(target_arch = "wasm32")]
41pub use web::ThreeView;
42
43/// Custom shader configuration
44#[derive(Clone, PartialEq, Debug, Default)]
45pub struct ShaderConfig {
46    /// Vertex shader GLSL code (optional - uses default if not provided)
47    pub vertex_shader: Option<String>,
48    /// Fragment shader GLSL code (optional - uses default if not provided)
49    pub fragment_shader: Option<String>,
50    /// Uniform values to pass to shaders (float values)
51    pub uniforms: HashMap<String, f32>,
52    /// Time-based animation (automatically sets `u_time` uniform)
53    pub animated: bool,
54}
55
56/// Built-in shader presets
57#[derive(Clone, PartialEq, Debug)]
58pub enum ShaderPreset {
59    /// No custom shader (default StandardMaterial)
60    None,
61    /// Animated gradient
62    Gradient,
63    /// Water/wave effect
64    Water,
65    /// Hologram effect
66    Hologram,
67    /// Toon/cel shading
68    Toon,
69    /// Heat map visualization
70    Heatmap,
71    /// Custom shader with provided config
72    Custom(ShaderConfig),
73}
74
75/// 3D model format types
76#[derive(Clone, PartialEq, Debug)]
77pub enum ModelFormat {
78    /// Wavefront OBJ format
79    Obj,
80    /// Autodesk FBX format
81    Fbx,
82    /// glTF 2.0 format (JSON)
83    Gltf,
84    /// glTF 2.0 binary format
85    Glb,
86    /// STL format (StereoLithography)
87    Stl,
88    /// Stanford PLY format
89    Ply,
90    /// Collada DAE format
91    Dae,
92    /// Three.js JSON format
93    Json,
94    /// Default cube (no file)
95    Cube,
96}
97
98impl ModelFormat {
99    /// Get the format identifier string
100    pub fn as_str(&self) -> &'static str {
101        match self {
102            ModelFormat::Obj => "obj",
103            ModelFormat::Fbx => "fbx",
104            ModelFormat::Gltf => "gltf",
105            ModelFormat::Glb => "glb",
106            ModelFormat::Stl => "stl",
107            ModelFormat::Ply => "ply",
108            ModelFormat::Dae => "dae",
109            ModelFormat::Json => "json",
110            ModelFormat::Cube => "cube",
111        }
112    }
113
114    fn loader_js(&self) -> &'static str {
115        match self {
116            ModelFormat::Obj => "OBJLoader",
117            ModelFormat::Fbx => "FBXLoader",
118            ModelFormat::Gltf | ModelFormat::Glb => "GLTFLoader",
119            ModelFormat::Stl => "STLLoader",
120            ModelFormat::Ply => "PLYLoader",
121            ModelFormat::Dae => "ColladaLoader",
122            ModelFormat::Json => "ObjectLoader",
123            ModelFormat::Cube => "",
124        }
125    }
126
127    fn loader_url(&self) -> &'static str {
128        match self {
129            ModelFormat::Obj => {
130                "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/OBJLoader.js"
131            }
132            ModelFormat::Fbx => {
133                "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FBXLoader.js"
134            }
135            ModelFormat::Gltf | ModelFormat::Glb => {
136                "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"
137            }
138            ModelFormat::Stl => {
139                "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js"
140            }
141            ModelFormat::Ply => {
142                "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/PLYLoader.js"
143            }
144            ModelFormat::Dae => {
145                "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/ColladaLoader.js"
146            }
147            ModelFormat::Json => "",
148            ModelFormat::Cube => "",
149        }
150    }
151
152    /// Get additional dependency URLs required by this loader
153    fn extra_scripts(&self) -> Vec<&'static str> {
154        match self {
155            // FBXLoader requires fflate for decompression
156            ModelFormat::Fbx => vec!["https://cdn.jsdelivr.net/npm/fflate@0.8.0/umd/index.js"],
157            _ => vec![],
158        }
159    }
160}
161
162/// Configuration for a single model in a multi-model scene
163#[derive(Clone, PartialEq, Debug)]
164pub struct ModelConfig {
165    /// URL or path to the model file
166    pub url: String,
167    /// Format of the model
168    pub format: ModelFormat,
169    /// Position X
170    pub pos_x: f32,
171    /// Position Y
172    pub pos_y: f32,
173    /// Position Z
174    pub pos_z: f32,
175    /// Rotation X (degrees)
176    pub rot_x: f32,
177    /// Rotation Y (degrees)
178    pub rot_y: f32,
179    /// Rotation Z (degrees)
180    pub rot_z: f32,
181    /// Scale
182    pub scale: f32,
183    /// Color (hex string)
184    pub color: String,
185}
186
187impl Default for ModelConfig {
188    fn default() -> Self {
189        Self {
190            url: String::new(),
191            format: ModelFormat::Cube,
192            pos_x: 0.0,
193            pos_y: 0.0,
194            pos_z: 0.0,
195            rot_x: 0.0,
196            rot_y: 0.0,
197            rot_z: 0.0,
198            scale: 1.0,
199            color: "#ff6b6b".to_string(),
200        }
201    }
202}
203
204impl ModelConfig {
205    /// Create a new model config with just URL and format
206    pub fn new(url: impl Into<String>, format: ModelFormat) -> Self {
207        Self {
208            url: url.into(),
209            format,
210            ..Default::default()
211        }
212    }
213
214    /// Set position
215    pub fn with_position(mut self, x: f32, y: f32, z: f32) -> Self {
216        self.pos_x = x;
217        self.pos_y = y;
218        self.pos_z = z;
219        self
220    }
221
222    /// Set rotation
223    pub fn with_rotation(mut self, x: f32, y: f32, z: f32) -> Self {
224        self.rot_x = x;
225        self.rot_y = y;
226        self.rot_z = z;
227        self
228    }
229
230    /// Set scale
231    pub fn with_scale(mut self, scale: f32) -> Self {
232        self.scale = scale;
233        self
234    }
235
236    /// Set color
237    pub fn with_color(mut self, color: impl Into<String>) -> Self {
238        self.color = color.into();
239        self
240    }
241}
242
243/// Properties for the ThreeView component
244#[derive(Props, Clone, PartialEq)]
245pub struct ThreeViewProps {
246    /// Model file path or URL (optional - uses cube if not provided)
247    #[props(default = None)]
248    pub model_url: Option<String>,
249    /// Model format
250    #[props(default = ModelFormat::Cube)]
251    pub format: ModelFormat,
252    /// Model position X
253    #[props(default = 0.0)]
254    pub pos_x: f32,
255    /// Model position Y
256    #[props(default = 0.0)]
257    pub pos_y: f32,
258    /// Model position Z
259    #[props(default = 0.0)]
260    pub pos_z: f32,
261    /// Model rotation X (degrees)
262    #[props(default = 0.0)]
263    pub rot_x: f32,
264    /// Model rotation Y (degrees)
265    #[props(default = 0.0)]
266    pub rot_y: f32,
267    /// Model rotation Z (degrees)
268    #[props(default = 0.0)]
269    pub rot_z: f32,
270    /// Model scale
271    #[props(default = 1.0)]
272    pub scale: f32,
273    /// Model color/material (hex string like "#ff6b6b")
274    #[props(default = "#ff6b6b".to_string())]
275    pub color: String,
276    /// Multiple models to load (optional - if set, model_url/format are ignored)
277    #[props(default = Vec::new())]
278    pub models: Vec<ModelConfig>,
279    /// Auto-center the model
280    #[props(default = true)]
281    pub auto_center: bool,
282    /// Auto-scale to fit viewport
283    #[props(default = false)]
284    pub auto_scale: bool,
285    /// Camera position X
286    #[props(default = 5.0)]
287    pub cam_x: f32,
288    /// Camera position Y
289    #[props(default = 5.0)]
290    pub cam_y: f32,
291    /// Camera position Z
292    #[props(default = 5.0)]
293    pub cam_z: f32,
294    /// Camera target X
295    #[props(default = 0.0)]
296    pub target_x: f32,
297    /// Camera target Y
298    #[props(default = 0.0)]
299    pub target_y: f32,
300    /// Camera target Z
301    #[props(default = 0.0)]
302    pub target_z: f32,
303    /// Auto-rotate the model
304    #[props(default = true)]
305    pub auto_rotate: bool,
306    /// Auto-rotation speed
307    #[props(default = 1.0)]
308    pub rot_speed: f32,
309    /// Show grid helper
310    #[props(default = true)]
311    pub show_grid: bool,
312    /// Show axes helper
313    #[props(default = true)]
314    pub show_axes: bool,
315    /// Background color
316    #[props(default = "#1a1a2e".to_string())]
317    pub background: String,
318    /// Additional CSS class for the container
319    #[props(default = String::new())]
320    pub class: String,
321    /// Enable shadows
322    #[props(default = true)]
323    pub shadows: bool,
324    /// Wireframe mode
325    #[props(default = false)]
326    pub wireframe: bool,
327    /// Shader preset or custom shader
328    #[props(default = ShaderPreset::None)]
329    pub shader: ShaderPreset,
330
331    // === Phase 1: Input & Selection Features ===
332    /// Unique ID for this view (needed for pointer event routing)
333    #[props(default = None)]
334    pub id: Option<String>,
335
336    /// Raycast configuration
337    #[props(default = RaycastConfig::default())]
338    pub raycast: RaycastConfig,
339
340    /// Callback for pointer down events
341    #[props(default = None)]
342    pub on_pointer_down: Option<Callback<PointerEvent>>,
343
344    /// Callback for pointer up events
345    #[props(default = None)]
346    pub on_pointer_up: Option<Callback<PointerEvent>>,
347
348    /// Callback for pointer move events (hover)
349    #[props(default = None)]
350    pub on_pointer_move: Option<Callback<PointerEvent>>,
351
352    /// Callback for pointer drag events
353    #[props(default = None)]
354    pub on_pointer_drag: Option<Callback<PointerDragEvent>>,
355
356    /// Callback for gesture events (pinch, rotate, pan)
357    #[props(default = None)]
358    pub on_gesture: Option<Callback<GestureEvent>>,
359
360    /// Current selection state
361    #[props(default = None)]
362    pub selection: Option<Selection>,
363
364    /// Selection mode
365    #[props(default = SelectionMode::Single)]
366    pub selection_mode: SelectionMode,
367
368    /// Visual style for selection
369    #[props(default = SelectionStyle::default())]
370    pub selection_style: SelectionStyle,
371
372    /// Callback when selection changes
373    #[props(default = None)]
374    pub on_selection_change: Option<Callback<Selection>>,
375
376    /// Gizmo configuration for transform manipulation
377    #[props(default = None)]
378    pub gizmo: Option<Gizmo>,
379
380    /// Callback during gizmo drag
381    #[props(default = None)]
382    pub on_gizmo_drag: Option<Callback<GizmoEvent>>,
383
384    /// Enable debug overlay
385    #[props(default = false)]
386    pub debug: bool,
387}
388
389impl ShaderPreset {
390    /// Get the vertex shader code for this preset
391    fn vertex_shader(&self) -> Option<String> {
392        match self {
393            ShaderPreset::None => None,
394            ShaderPreset::Gradient => Some(include_str!("shaders/gradient.vert").to_string()),
395            ShaderPreset::Water => Some(include_str!("shaders/water.vert").to_string()),
396            ShaderPreset::Hologram => Some(include_str!("shaders/hologram.vert").to_string()),
397            ShaderPreset::Toon => Some(include_str!("shaders/toon.vert").to_string()),
398            ShaderPreset::Heatmap => Some(include_str!("shaders/heatmap.vert").to_string()),
399            ShaderPreset::Custom(config) => config.vertex_shader.clone(),
400        }
401    }
402
403    /// Get the fragment shader code for this preset
404    fn fragment_shader(&self) -> Option<String> {
405        match self {
406            ShaderPreset::None => None,
407            ShaderPreset::Gradient => Some(include_str!("shaders/gradient.frag").to_string()),
408            ShaderPreset::Water => Some(include_str!("shaders/water.frag").to_string()),
409            ShaderPreset::Hologram => Some(include_str!("shaders/hologram.frag").to_string()),
410            ShaderPreset::Toon => Some(include_str!("shaders/toon.frag").to_string()),
411            ShaderPreset::Heatmap => Some(include_str!("shaders/heatmap.frag").to_string()),
412            ShaderPreset::Custom(config) => config.fragment_shader.clone(),
413        }
414    }
415
416    /// Check if this shader uses time animation
417    fn is_animated(&self) -> bool {
418        match self {
419            ShaderPreset::None => false,
420            ShaderPreset::Gradient | ShaderPreset::Water | ShaderPreset::Hologram => true,
421            ShaderPreset::Custom(config) => config.animated,
422            _ => false,
423        }
424    }
425}
426
427/// Build loader scripts for multiple models
428pub fn build_loader_scripts_for_models(models: &[ModelConfig]) -> String {
429    let mut scripts: Vec<String> = vec![];
430    let mut seen_formats: Vec<ModelFormat> = vec![];
431
432    for model in models {
433        if seen_formats.contains(&model.format) {
434            continue;
435        }
436        seen_formats.push(model.format.clone());
437
438        let loader_url = model.format.loader_url();
439        if loader_url.is_empty() {
440            continue;
441        }
442
443        for extra in model.format.extra_scripts() {
444            let script = format!(r#"<script src="{}"></script>"#, extra);
445            if !scripts.contains(&script) {
446                scripts.push(script);
447            }
448        }
449
450        scripts.push(format!(r#"<script src="{}"></script>"#, loader_url));
451    }
452
453    scripts.join("\n    ")
454}
455
456/// Build loader scripts for single model
457pub fn build_loader_scripts_single(format: &ModelFormat, model_url: &Option<String>) -> String {
458    let url = model_url.clone().unwrap_or_default();
459    let has_model = !url.is_empty() && *format != ModelFormat::Cube;
460    let loader_url = format.loader_url();
461
462    if !has_model || loader_url.is_empty() {
463        return String::new();
464    }
465
466    let mut scripts: Vec<String> = format
467        .extra_scripts()
468        .iter()
469        .map(|url| format!(r#"<script src="{}"></script>"#, url))
470        .collect();
471
472    scripts.push(format!(r#"<script src="{}"></script>"#, loader_url));
473    scripts.join("\n    ")
474}
475
476/// Build JavaScript code for loading multiple models (interactive version with entity IDs)
477pub fn build_multi_model_loading_interactive(models: &[ModelConfig], shadows: bool) -> String {
478    let shadows_str = shadows.to_string().to_lowercase();
479
480    let load_calls: Vec<String> = models.iter().enumerate().map(|(idx, model)| {
481        let loader_class = model.format.loader_js();
482        let is_geometry_loader = matches!(model.format, ModelFormat::Stl | ModelFormat::Ply);
483        let url = &model.url;
484        let pos_x = model.pos_x;
485        let pos_y = model.pos_y;
486        let pos_z = model.pos_z;
487        let rot_x = model.rot_x.to_radians();
488        let rot_y = model.rot_y.to_radians();
489        let rot_z = model.rot_z.to_radians();
490        let scale = model.scale;
491        let color = &model.color;
492        let default_color = "#ff6b6b";
493
494        if model.format == ModelFormat::Cube {
495            format!(
496                r#"(function() {{ 
497                    const entityId = {idx};
498                    const geometry = new THREE.BoxGeometry(1, 1, 1); 
499                    const material = new THREE.MeshStandardMaterial({{ color: "{color}", roughness: 0.5, metalness: 0.3 }}); 
500                    const mesh = new THREE.Mesh(geometry, material); 
501                    mesh.position.set({pos_x}, {pos_y}, {pos_z}); 
502                    mesh.rotation.set({rot_x}, {rot_y}, {rot_z}); 
503                    mesh.scale.setScalar({scale}); 
504                    mesh.castShadow = {shadows_str}; 
505                    mesh.receiveShadow = {shadows_str};
506                    mesh.userData = {{ entityId: entityId }};
507                    modelContainer.add(mesh); 
508                    entityMap.set(entityId, mesh);
509                    nextEntityId = Math.max(nextEntityId, entityId + 1);
510                }})();"#
511            )
512        } else if is_geometry_loader {
513            format!(
514                r#"(function() {{ 
515                    const entityId = {idx};
516                    const loader = new THREE.{loader_class}(); 
517                    loader.load("{url}", function(geometry) {{ 
518                        const material = new THREE.MeshStandardMaterial({{ color: "{color}", roughness: 0.5, metalness: 0.1, side: THREE.DoubleSide }}); 
519                        const mesh = new THREE.Mesh(geometry, material); 
520                        mesh.position.set({pos_x}, {pos_y}, {pos_z}); 
521                        mesh.rotation.set({rot_x}, {rot_y}, {rot_z}); 
522                        mesh.scale.setScalar({scale}); 
523                        mesh.castShadow = {shadows_str}; 
524                        mesh.receiveShadow = {shadows_str};
525                        mesh.userData = {{ entityId: entityId }};
526                        modelContainer.add(mesh); 
527                        entityMap.set(entityId, mesh);
528                        nextEntityId = Math.max(nextEntityId, entityId + 1);
529                    }}, undefined, function(err) {{ console.error('Failed to load model {idx}:', err); }}); 
530                }})();"#
531            )
532        } else {
533            let color_js = if color != default_color {
534                format!(
535                    r#"if (child.material) {{ if (Array.isArray(child.material)) {{ child.material.forEach(m => m.color.set("{color}")); }} else {{ child.material.color.set("{color}"); }} }}"#,
536                    color = color
537                )
538            } else {
539                String::new()
540            };
541            format!(
542                r#"(function() {{ 
543                    const entityId = {idx};
544                    const loader = new THREE.{loader_class}(); 
545                    loader.load("{url}", function(object) {{ 
546                        let model = object.scene || object.dae || object; 
547                        model.position.set({pos_x}, {pos_y}, {pos_z}); 
548                        model.rotation.set({rot_x}, {rot_y}, {rot_z}); 
549                        model.scale.setScalar({scale}); 
550                        model.traverse(function(child) {{ 
551                            if (child.isMesh) {{ 
552                                child.castShadow = {shadows_str}; 
553                                child.receiveShadow = {shadows_str}; 
554                                child.userData = {{ entityId: entityId }};
555                                {color_js} 
556                            }} 
557                        }}); 
558                        model.userData = {{ entityId: entityId }};
559                        modelContainer.add(model); 
560                        entityMap.set(entityId, model);
561                        nextEntityId = Math.max(nextEntityId, entityId + 1);
562                    }}, undefined, function(err) {{ console.error('Failed to load model {idx}:', err); }}); 
563                }})();"#,
564                loader_class = loader_class,
565                url = url,
566                pos_x = pos_x,
567                pos_y = pos_y,
568                pos_z = pos_z,
569                rot_x = rot_x,
570                rot_y = rot_y,
571                rot_z = rot_z,
572                scale = scale,
573                shadows_str = shadows_str,
574                color_js = color_js,
575                idx = idx
576            )
577        }
578    }).collect();
579
580    format!("loadingEl.style.display = 'none'; {}", load_calls.join(" "))
581}
582
583/// Build JavaScript code for loading a single model (interactive version with entity ID)
584pub fn build_single_model_loading_interactive(
585    format: &ModelFormat,
586    model_url: &Option<String>,
587    auto_center: bool,
588    auto_scale: bool,
589    shadows: bool,
590) -> String {
591    let url = model_url.clone().unwrap_or_default();
592    let has_model = !url.is_empty() && *format != ModelFormat::Cube;
593    let loader_class = format.loader_js();
594    let is_geometry_loader = matches!(format, ModelFormat::Stl | ModelFormat::Ply);
595    let auto_center_str = auto_center.to_string().to_lowercase();
596    let auto_scale_str = auto_scale.to_string().to_lowercase();
597    let shadows_str = shadows.to_string().to_lowercase();
598
599    if !has_model {
600        return r#"const entityId = 0; const geometry = new THREE.BoxGeometry(1, 1, 1); let material = new THREE.MeshStandardMaterial({ color: state.color, roughness: 0.5, metalness: 0.3, wireframe: state.wireframe }); model = new THREE.Mesh(geometry, material); model.castShadow = true; model.receiveShadow = true; model.userData = { entityId: entityId }; modelContainer.add(model); entityMap.set(entityId, model); nextEntityId = 1; loadingEl.style.display = 'none';"#.to_string();
601    }
602
603    if is_geometry_loader {
604        format!(
605            r#"const entityId = 0; const loader = new THREE.{loader_class}(); loader.load("{url}", function(geometry) {{ loadingEl.style.display = 'none'; const material = new THREE.MeshStandardMaterial({{ color: state.color, roughness: 0.5, metalness: 0.1, wireframe: state.wireframe, side: THREE.DoubleSide }}); model = new THREE.Mesh(geometry, material); model.castShadow = {shadows_str}; model.receiveShadow = {shadows_str}; model.userData = {{ entityId: entityId }}; if ({auto_center_str}) {{ const box = new THREE.Box3().setFromObject(model); const center = box.getCenter(new THREE.Vector3()); model.position.sub(center); }} if ({auto_scale_str}) {{ const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); if (maxDim > 0) {{ const s = 2 / maxDim; model.scale.setScalar(s); }} }} modelContainer.add(model); entityMap.set(entityId, model); nextEntityId = 1; updateTransform(); }}, function(xhr) {{ const percent = xhr.loaded / xhr.total * 100; loadingEl.textContent = 'Loading: ' + Math.round(percent) + '%'; }}, function(error) {{ console.error('Error loading model:', error); loadingEl.style.display = 'none'; errorEl.style.display = 'block'; errorEl.textContent = 'Failed to load model: ' + (error.message || 'Unknown error'); const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshStandardMaterial({{ color: 0xff6b6b }}); model = new THREE.Mesh(geometry, material); model.userData = {{ entityId: entityId }}; modelContainer.add(model); entityMap.set(entityId, model); nextEntityId = 1; }});"#
606        )
607    } else {
608        format!(
609            r#"const entityId = 0; const loader = new THREE.{loader_class}(); loader.load("{url}", function(object) {{ loadingEl.style.display = 'none'; if (object.scene) {{ model = object.scene; }} else if (object.dae) {{ model = object.scene; }} else {{ model = object; }} model.traverse(function(child) {{ if (child.isMesh) {{ child.castShadow = {shadows_str}; child.receiveShadow = {shadows_str}; child.userData = {{ entityId: entityId }}; if (!child.material) {{ child.material = new THREE.MeshStandardMaterial({{ color: state.color, roughness: 0.5, metalness: 0.3 }}); }} const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach(m => {{ if (m.opacity !== undefined && m.opacity < 0.1) m.opacity = 1.0; if (m.transparent === true && m.opacity < 0.1) m.transparent = false; if (state.color !== '#ff6b6b' && m.color) {{ m.color.set(state.color); }} m.wireframe = state.wireframe; }}); }} }}); model.userData = {{ entityId: entityId }}; if ({auto_center_str}) {{ const box = new THREE.Box3().setFromObject(model); const center = box.getCenter(new THREE.Vector3()); model.position.sub(center); }} if ({auto_scale_str}) {{ const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); if (maxDim > 0) {{ const s = 2 / maxDim; model.scale.setScalar(s); }} }} modelContainer.add(model); entityMap.set(entityId, model); nextEntityId = 1; updateTransform(); }}, function(xhr) {{ const percent = xhr.loaded / xhr.total * 100; loadingEl.textContent = 'Loading: ' + Math.round(percent) + '%'; }}, function(error) {{ console.error('Error loading model:', error); loadingEl.style.display = 'none'; errorEl.style.display = 'block'; errorEl.textContent = 'Failed to load model: ' + (error.message || 'Unknown error'); const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshStandardMaterial({{ color: 0xff6b6b }}); model = new THREE.Mesh(geometry, material); model.userData = {{ entityId: entityId }}; modelContainer.add(model); entityMap.set(entityId, model); nextEntityId = 1; }});"#
610        )
611    }
612}
613
614/// Generate the HTML with embedded Three.js
615/// Includes full raycasting, selection, and gizmo interaction support
616pub fn generate_three_js_html(props: &ThreeViewProps) -> String {
617    let rot_x_rad = props.rot_x.to_radians();
618    let rot_y_rad = props.rot_y.to_radians();
619    let rot_z_rad = props.rot_z.to_radians();
620
621    // Legacy single-model variables (for backward compatibility with template)
622    let _loader_url = props.format.loader_url();
623    let _loader_class = props.format.loader_js();
624    let format_str = props.format.as_str();
625    let model_url = props.model_url.clone().unwrap_or_default();
626    let _has_model = !model_url.is_empty() && props.format != ModelFormat::Cube;
627
628    // Check if using multiple models
629    let use_multiple_models = !props.models.is_empty();
630
631    // Build loader script tags
632    let loader_script = if use_multiple_models {
633        build_loader_scripts_for_models(&props.models)
634    } else {
635        build_loader_scripts_single(&props.format, &props.model_url)
636    };
637
638    // Build model loading JavaScript code
639    let model_loading_code = if use_multiple_models {
640        build_multi_model_loading_interactive(&props.models, props.shadows)
641    } else {
642        build_single_model_loading_interactive(
643            &props.format,
644            &props.model_url,
645            props.auto_center,
646            props.auto_scale,
647            props.shadows,
648        )
649    };
650
651    // Build shader code if needed
652    let (_shader_material_code, shader_uniforms, _shader_animated) =
653        build_shader_code(&props.shader);
654
655    // Serialize selection and gizmo state for JavaScript
656    let selection_ids_json = props
657        .selection
658        .as_ref()
659        .map(|s| {
660            let ids: Vec<String> = s.iter().map(|e| e.0.to_string()).collect();
661            format!("[{}]", ids.join(", "))
662        })
663        .unwrap_or_else(|| "[]".to_string());
664
665    let gizmo_config_json = props
666        .gizmo
667        .as_ref()
668        .map(|g| {
669            format!(
670                r#"{{"target": {}, "mode": "{:?}", "space": "{:?}"}}"#,
671                g.target.0, g.mode, g.space
672            )
673        })
674        .unwrap_or_else(|| "null".to_string());
675
676    let selection_style_json = format!(
677        r#"{{"outline": {}, "outline_color": "{}", "outline_width": {}, "highlight": {}, "highlight_color": "{}", "highlight_opacity": {}, "show_gizmo": {}}}"#,
678        props.selection_style.outline.to_string().to_lowercase(),
679        props.selection_style.outline_color,
680        props.selection_style.outline_width,
681        props.selection_style.highlight.to_string().to_lowercase(),
682        props.selection_style.highlight_color,
683        props.selection_style.highlight_opacity,
684        props.selection_style.show_gizmo.to_string().to_lowercase(),
685    );
686
687    let raycast_enabled = props.raycast.enabled;
688    let selection_enabled = props.selection.is_some();
689
690    // Build the HTML with full interaction support
691    let html = format!(
692        r##"<!DOCTYPE html>
693<html>
694<head>
695    <meta charset="UTF-8">
696    <style>
697        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
698        html, body {{ width: 100%; height: 100%; overflow: hidden; background: {bg}; }}
699        #canvas-container {{ width: 100%; height: 100%; }}
700        canvas {{ display: block; }}
701        #loading {{ 
702            position: absolute; 
703            top: 50%; 
704            left: 50%; 
705            transform: translate(-50%, -50%); 
706            color: white; 
707            font-family: sans-serif;
708            font-size: 14px;
709        }}
710        #error {{
711            position: absolute;
712            top: 50%;
713            left: 50%;
714            transform: translate(-50%, -50%);
715            color: #ff6b6b;
716            font-family: sans-serif;
717            font-size: 14px;
718            text-align: center;
719            display: none;
720        }}
721        canvas {{
722            cursor: default;
723        }}
724        canvas.hovering {{
725            cursor: pointer;
726        }}
727        canvas.dragging {{
728            cursor: grabbing;
729        }}
730    </style>
731</head>
732<body>
733    <div id="canvas-container"></div>
734    <div id="loading">Loading 3D model...</div>
735    <div id="error"></div>
736    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
737    <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/TransformControls.js"></script>
738    {loader_script}
739    <script>
740        console.log("Dioxus Three: Initializing ({fmt})...");
741        
742        const container = document.getElementById('canvas-container');
743        const loadingEl = document.getElementById('loading');
744        const errorEl = document.getElementById('error');
745        const width = container.clientWidth || window.innerWidth;
746        const height = container.clientHeight || window.innerHeight;
747        
748        const scene = new THREE.Scene();
749        scene.background = new THREE.Color('{bg}');
750        
751        const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
752        camera.position.set({cam_x}, {cam_y}, {cam_z});
753        camera.lookAt({target_x}, {target_y}, {target_z});
754        
755        const renderer = new THREE.WebGLRenderer({{ antialias: true }});
756        renderer.setSize(width, height);
757        renderer.setPixelRatio(window.devicePixelRatio);
758        renderer.shadowMap.enabled = {shadows};
759        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
760        container.appendChild(renderer.domElement);
761        
762        // Brighter ambient light for better material visibility
763        const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
764        scene.add(ambientLight);
765        
766        // Main directional light (sun)
767        const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
768        dirLight.position.set(10, 20, 10);
769        dirLight.castShadow = {shadows};
770        dirLight.shadow.mapSize.width = 2048;
771        dirLight.shadow.mapSize.height = 2048;
772        scene.add(dirLight);
773        
774        // Fill light from opposite side
775        const fillLight = new THREE.DirectionalLight(0xffffff, 0.4);
776        fillLight.position.set(-10, 10, -10);
777        scene.add(fillLight);
778        
779        // Back light for rim lighting
780        const backLight = new THREE.DirectionalLight(0xffffff, 0.3);
781        backLight.position.set(0, 5, -10);
782        scene.add(backLight);
783        
784        if ({show_grid}) {{
785            const gridHelper = new THREE.GridHelper(20, 20, 0x444444, 0x222222);
786            scene.add(gridHelper);
787        }}
788        if ({show_axes}) {{
789            const axesHelper = new THREE.AxesHelper(2);
790            scene.add(axesHelper);
791        }}
792        
793        let model = null;
794        let modelContainer = new THREE.Group();
795        scene.add(modelContainer);
796        
797        // ============ INTERACTIVE SYSTEMS ============
798        
799        // Entity management
800        const entityMap = new Map(); // entityId -> object
801        let nextEntityId = 0;
802        
803        // Selection system
804        let selectedEntities = new Set({selection_ids_json});
805        let selectionOutlines = new Map(); // entityId -> outline mesh
806        let outlineToMeshMap = new Map(); // outline group -> source mesh
807        
808        // Gizmo system
809        let transformControl = null;
810        let gizmoTarget = null;
811        let currentGizmoMode = 'translate';
812        let currentGizmoSpace = 'world';
813        
814        // Raycasting system
815        const raycaster = new THREE.Raycaster();
816        const mouse = new THREE.Vector2();
817        let isDragging = false;
818        let isGizmoDragging = false;
819        
820        const raycastEnabled = {raycast_enabled};
821        const selectionEnabled = {selection_enabled};
822        
823        // State
824        let selectionStyle = {selection_style_json};
825        
826        let state = {{
827            rotX: {rot_x},
828            rotY: {rot_y},
829            rotZ: {rot_z},
830            scale: {scale},
831            color: "{color}",
832            autoRotate: {auto_rotate},
833            rotSpeed: {rot_speed},
834            wireframe: {wireframe}
835        }};
836        let autoRotY = 0;
837        
838        // Initialize TransformControls
839        function initTransformControls() {{
840            if (typeof THREE.TransformControls === 'undefined') {{
841                console.warn('TransformControls not available');
842                return null;
843            }}
844            const control = new THREE.TransformControls(camera, renderer.domElement);
845            
846            // Ensure gizmo handles render on top of everything (including scaled objects)
847            function fixGizmoDepth(gizmo) {{
848                gizmo.traverse(function(child) {{
849                    if (child.material) {{
850                        if (Array.isArray(child.material)) {{
851                            child.material.forEach(function(m) {{
852                                m.depthTest = false;
853                                m.depthWrite = false;
854                            }});
855                        }} else {{
856                            child.material.depthTest = false;
857                            child.material.depthWrite = false;
858                        }}
859                    }}
860                    child.renderOrder = 999;
861                }});
862            }}
863            control.addEventListener('dragging-changed', function(event) {{
864                isGizmoDragging = event.value;
865                console.log('[GIZMO] dragging-changed:', event.value, 'attached to:', gizmoTarget);
866                if (event.value) {{
867                    renderer.domElement.classList.add('dragging');
868                }} else {{
869                    renderer.domElement.classList.remove('dragging');
870                }}
871            }});
872            control.addEventListener('change', function() {{
873                if (gizmoTarget && control.object) {{
874                    console.log('[GIZMO] change event - target:', gizmoTarget, 'scale:', control.object.scale.x.toFixed(3), control.object.scale.y.toFixed(3), control.object.scale.z.toFixed(3));
875                    // Notify parent about transform change
876                    notifyGizmoDrag(gizmoTarget, control.object, false);
877                }}
878            }});
879            control.addEventListener('mouseUp', function() {{
880                if (gizmoTarget && control.object) {{
881                    notifyGizmoDrag(gizmoTarget, control.object, true);
882                }}
883            }});
884            scene.add(control);
885            return control;
886        }}
887        
888        // Create selection outline for an object
889        function createSelectionOutline(object, entityId) {{
890            // Remove existing outline
891            removeSelectionOutline(entityId);
892            
893            const outlineColorHex = selectionStyle.outline_color || '#FFD700';
894            const outlineColor = new THREE.Color(outlineColorHex).getHex();
895            
896            const box = new THREE.Box3().setFromObject(object);
897            const size = box.getSize(new THREE.Vector3());
898            const center = box.getCenter(new THREE.Vector3());
899            const maxDim = Math.max(size.x, size.y, size.z);
900            
901            // Create a group for the outline
902            const outlineGroup = new THREE.Group();
903            
904            // Main wireframe box - thicker and more visible
905            const outlineGeometry = new THREE.BoxGeometry(size.x * 1.08, size.y * 1.08, size.z * 1.08);
906            const outlineMaterial = new THREE.MeshBasicMaterial({{
907                color: outlineColor,
908                wireframe: true,
909                transparent: true,
910                opacity: 1.0
911            }});
912            const outline = new THREE.Mesh(outlineGeometry, outlineMaterial);
913            outlineGroup.add(outline);
914            
915            // Inner glow effect - semi-transparent fill
916            const glowGeometry = new THREE.BoxGeometry(size.x * 1.04, size.y * 1.04, size.z * 1.04);
917            const glowMaterial = new THREE.MeshBasicMaterial({{
918                color: outlineColor,
919                transparent: true,
920                opacity: 0.15,
921                side: THREE.BackSide
922            }});
923            const glow = new THREE.Mesh(glowGeometry, glowMaterial);
924            outlineGroup.add(glow);
925            
926            // Store original size for scale tracking
927            outlineGroup.userData = {{ originalSize: size.clone() }};
928            
929            // Position the outline group
930            outlineGroup.position.copy(center);
931            
932            // If object is a mesh, match its world transform
933            if (object.parent) {{
934                object.parent.add(outlineGroup);
935            }} else {{
936                scene.add(outlineGroup);
937            }}
938            
939            selectionOutlines.set(entityId, outlineGroup);
940            outlineToMeshMap.set(outlineGroup, object);
941        }}
942        
943        // Remove selection outline
944        function removeSelectionOutline(entityId) {{
945            const outline = selectionOutlines.get(entityId);
946            if (outline) {{
947                if (outline.parent) outline.parent.remove(outline);
948                outline.traverse(function(child) {{
949                    if (child.geometry) child.geometry.dispose();
950                    if (child.material) {{
951                        if (Array.isArray(child.material)) {{
952                            child.material.forEach(m => m.dispose());
953                        }} else {{
954                            child.material.dispose();
955                        }}
956                    }}
957                }});
958                selectionOutlines.delete(entityId);
959            }}
960        }}
961        
962        // Update all selection visualizations
963        function updateSelectionVisuals() {{
964            // Clear all outlines
965            for (const [entityId, outline] of selectionOutlines) {{
966                if (outline.parent) outline.parent.remove(outline);
967                outline.traverse(function(child) {{
968                    if (child.geometry) child.geometry.dispose();
969                    if (child.material) {{
970                        if (Array.isArray(child.material)) {{
971                            child.material.forEach(m => m.dispose());
972                        }} else {{
973                            child.material.dispose();
974                        }}
975                    }}
976                }});
977            }}
978            selectionOutlines.clear();
979            outlineToMeshMap.clear();
980            
981            // Create outlines for selected entities
982            for (const entityId of selectedEntities) {{
983                const obj = entityMap.get(entityId);
984                if (obj) {{
985                    createSelectionOutline(obj, entityId);
986                }}
987            }}
988        }}
989        
990        // Update gizmo position and mode
991        function updateGizmo(gizmoConfig) {{
992            if (!gizmoConfig) gizmoConfig = {gizmo_config_json};
993            if (!gizmoConfig || !transformControl) {{
994                if (transformControl) transformControl.detach();
995                gizmoTarget = null;
996                return;
997            }}
998            
999            const targetObj = entityMap.get(gizmoConfig.target);
1000            if (!targetObj) return;
1001            
1002            // Set mode
1003            const mode = gizmoConfig.mode.toLowerCase();
1004            if (mode !== currentGizmoMode) {{
1005                currentGizmoMode = mode;
1006                transformControl.setMode(mode === 'translate' ? 'translate' : mode === 'rotate' ? 'rotate' : 'scale');
1007            }}
1008            
1009            // Set space
1010            const space = gizmoConfig.space.toLowerCase();
1011            if (space !== currentGizmoSpace) {{
1012                currentGizmoSpace = space;
1013                transformControl.setSpace(space === 'local' ? 'local' : 'world');
1014            }}
1015            
1016            // Attach to target
1017            if (gizmoTarget !== gizmoConfig.target) {{
1018                gizmoTarget = gizmoConfig.target;
1019                transformControl.attach(targetObj);
1020            }}
1021            
1022            // Ensure gizmo handles always render on top of the object
1023            if (transformControl) {{
1024                transformControl.traverse(function(child) {{
1025                    if (child.material) {{
1026                        if (Array.isArray(child.material)) {{
1027                            child.material.forEach(function(m) {{
1028                                m.depthTest = false;
1029                                m.depthWrite = false;
1030                            }});
1031                        }} else {{
1032                            child.material.depthTest = false;
1033                            child.material.depthWrite = false;
1034                        }}
1035                    }}
1036                    child.renderOrder = 999;
1037                }});
1038            }}
1039        }}
1040        
1041        // Notify parent window about gizmo drag
1042        function notifyGizmoDrag(entityId, object, isFinished) {{
1043            const eventData = {{
1044                target: entityId,
1045                mode: currentGizmoMode,
1046                space: currentGizmoSpace,
1047                transform: {{
1048                    position: {{
1049                        x: object.position.x,
1050                        y: object.position.y,
1051                        z: object.position.z
1052                    }},
1053                    rotation: {{
1054                        x: object.rotation.x,
1055                        y: object.rotation.y,
1056                        z: object.rotation.z
1057                    }},
1058                    scale: {{
1059                        x: object.scale.x,
1060                        y: object.scale.y,
1061                        z: object.scale.z
1062                    }}
1063                }},
1064                isFinished: !!isFinished
1065            }};
1066            
1067            console.log('[GIZMO] notifyGizmoDrag - entity:', entityId, 'finished:', isFinished, 'scale:', object.scale.x.toFixed(3), object.scale.y.toFixed(3), object.scale.z.toFixed(3));
1068            
1069            // Send message to parent window
1070            if (window.parent !== window) {{
1071                window.parent.postMessage({{
1072                    type: 'gizmo-drag',
1073                    data: eventData
1074                }}, '*');
1075            }}
1076        }}
1077        
1078        // Notify parent about pointer event
1079        function notifyPointerEvent(type, event, hit) {{
1080            const eventData = {{
1081                hit: hit,
1082                screenPosition: {{ x: event.clientX, y: event.clientY }},
1083                ndcPosition: {{ x: mouse.x, y: mouse.y }},
1084                button: event.button === 0 ? 'Left' : event.button === 2 ? 'Right' : 'Middle',
1085                shiftKey: event.shiftKey,
1086                ctrlKey: event.ctrlKey,
1087                altKey: event.altKey
1088            }};
1089            
1090            if (window.parent !== window) {{
1091                window.parent.postMessage({{
1092                    type: type,
1093                    data: eventData
1094                }}, '*');
1095            }}
1096        }}
1097        
1098        // Raycast to find intersected entity
1099        function raycastEntity(clientX, clientY) {{
1100            const rect = renderer.domElement.getBoundingClientRect();
1101            mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
1102            mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
1103            
1104            raycaster.setFromCamera(mouse, camera);
1105            
1106            // Collect all selectable objects
1107            const selectableObjects = [];
1108            for (const [entityId, obj] of entityMap) {{
1109                if (obj) selectableObjects.push(obj);
1110            }}
1111            
1112            console.log('[RAYCAST] checking', selectableObjects.length, 'objects, mouse:', mouse.x.toFixed(3), mouse.y.toFixed(3));
1113            
1114            const intersects = raycaster.intersectObjects(selectableObjects, true);
1115            console.log('[RAYCAST] intersects:', intersects.length);
1116            if (intersects.length > 0) {{
1117                // Find the entity ID for this intersection
1118                let targetObj = intersects[0].object;
1119                let entityId = null;
1120                
1121                // Traverse up to find entity with userData
1122                while (targetObj) {{
1123                    for (const [id, obj] of entityMap) {{
1124                        if (obj === targetObj || isDescendant(obj, targetObj)) {{
1125                            entityId = id;
1126                            break;
1127                        }}
1128                    }}
1129                    if (entityId !== null) break;
1130                    targetObj = targetObj.parent;
1131                }}
1132                
1133                if (entityId !== null) {{
1134                    return {{
1135                        entityId: entityId,
1136                        point: intersects[0].point,
1137                        normal: intersects[0].face ? intersects[0].face.normal : new THREE.Vector3(0, 1, 0),
1138                        distance: intersects[0].distance
1139                    }};
1140                }}
1141            }}
1142            return null;
1143        }}
1144        
1145        // Check if target is descendant of parent
1146        function isDescendant(parent, target) {{
1147            if (!parent || !target) return false;
1148            let found = false;
1149            parent.traverse(function(child) {{
1150                if (child === target) found = true;
1151            }});
1152            return found;
1153        }}
1154        
1155        // Check if clicking on TransformControls gizmo
1156        function isClickOnGizmo(event) {{
1157            if (!transformControl || !transformControl.object) return false;
1158            
1159            const rect = renderer.domElement.getBoundingClientRect();
1160            mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
1161            mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
1162            
1163            raycaster.setFromCamera(mouse, camera);
1164            const intersects = raycaster.intersectObjects(transformControl.children, true);
1165            
1166            for (let i = 0; i < intersects.length; i++) {{
1167                // Only count clicks on actual interactive handles (Mesh), not lines
1168                if (intersects[i].object.visible && intersects[i].object.isMesh) {{
1169                    console.log('[GIZMO] Clicked on handle:', intersects[i].object.type, intersects[i].object.name || '');
1170                    return true;
1171                }}
1172            }}
1173            if (intersects.length > 0) {{
1174                console.log('[GIZMO] Clicked on non-handle:', intersects.map(i => ({{ type: i.object.type, visible: i.object.visible, isMesh: i.object.isMesh }})));
1175            }}
1176            return false;
1177        }}
1178        
1179        // Pointer event handlers
1180        function onPointerDown(event) {{
1181            if (!raycastEnabled) {{
1182                console.log('[POINTER] raycast disabled');
1183                return;
1184            }}
1185            
1186            // Don't process if clicking on gizmo
1187            if (transformControl && transformControl.dragging) {{
1188                console.log('[POINTER] gizmo is dragging, skipping');
1189                return;
1190            }}
1191            
1192            // Check if clicking on gizmo handle - let TransformControls handle it
1193            if (isClickOnGizmo(event)) {{
1194                console.log('[POINTER] click on gizmo, skipping');
1195                return;
1196            }}
1197            
1198            isDragging = false;
1199            const hit = raycastEntity(event.clientX, event.clientY);
1200            console.log('[POINTER] down at', event.clientX, event.clientY, 'hit:', hit);
1201            
1202            // Handle selection within the iframe
1203            if (selectionEnabled && hit) {{
1204                if (event.shiftKey) {{
1205                    if (selectedEntities.has(hit.entityId)) {{
1206                        selectedEntities.delete(hit.entityId);
1207                    }} else {{
1208                        selectedEntities.add(hit.entityId);
1209                    }}
1210                }} else {{
1211                    selectedEntities.clear();
1212                    selectedEntities.add(hit.entityId);
1213                }}
1214                updateSelectionVisuals();
1215                updateGizmo();
1216                
1217                // Notify parent about selection change
1218                if (window.parent !== window) {{
1219                    window.parent.postMessage({{
1220                        type: 'selection-change',
1221                        selection: Array.from(selectedEntities)
1222                    }}, '*');
1223                }}
1224            }} else if (selectionEnabled && !event.shiftKey) {{
1225                selectedEntities.clear();
1226                updateSelectionVisuals();
1227                updateGizmo();
1228                
1229                if (window.parent !== window) {{
1230                    window.parent.postMessage({{
1231                        type: 'selection-change',
1232                        selection: Array.from(selectedEntities)
1233                    }}, '*');
1234                }}
1235            }}
1236            
1237            notifyPointerEvent('pointer-down', event, hit);
1238        }}
1239        
1240        function onPointerMove(event) {{
1241            if (!raycastEnabled) return;
1242            
1243            const rect = renderer.domElement.getBoundingClientRect();
1244            mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
1245            mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
1246            
1247            raycaster.setFromCamera(mouse, camera);
1248            
1249            // Check for hover
1250            const selectableObjects = [];
1251            for (const [entityId, obj] of entityMap) {{
1252                if (obj) selectableObjects.push(obj);
1253            }}
1254            
1255            const intersects = raycaster.intersectObjects(selectableObjects, true);
1256            if (intersects.length > 0 && !isGizmoDragging) {{
1257                renderer.domElement.classList.add('hovering');
1258            }} else {{
1259                renderer.domElement.classList.remove('hovering');
1260            }}
1261            
1262            if (event.buttons > 0) {{
1263                isDragging = true;
1264            }}
1265        }}
1266        
1267        function onPointerUp(event) {{
1268            if (!raycastEnabled) return;
1269            notifyPointerEvent('pointer-up', event, null);
1270        }}
1271        
1272        // Add event listeners
1273        renderer.domElement.addEventListener('pointerdown', onPointerDown);
1274        renderer.domElement.addEventListener('pointermove', onPointerMove);
1275        renderer.domElement.addEventListener('pointerup', onPointerUp);
1276        renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
1277        
1278        // Listen for messages from parent
1279        window.addEventListener('message', function(event) {{
1280            if (event.data && event.data.type === 'update-selection') {{
1281                selectedEntities = new Set(event.data.selection);
1282                updateSelectionVisuals();
1283                updateGizmo();
1284            }} else if (event.data && event.data.type === 'update-gizmo') {{
1285                updateGizmo();
1286            }} else if (event.data && event.data.type === 'update-selection-style') {{
1287                selectionStyle = {{ ...selectionStyle, ...event.data.style }};
1288                updateSelectionVisuals();
1289            }} else if (event.data && event.data.type === 'update-state') {{
1290                const data = event.data;
1291                
1292                // Camera
1293                if (data.camX !== undefined) {{
1294                    state.camX = data.camX;
1295                    state.camY = data.camY;
1296                    state.camZ = data.camZ;
1297                    camera.position.set(state.camX, state.camY, state.camZ);
1298                }}
1299                if (data.targetX !== undefined) {{
1300                    state.targetX = data.targetX;
1301                    state.targetY = data.targetY;
1302                    state.targetZ = data.targetZ;
1303                    camera.lookAt(state.targetX, state.targetY, state.targetZ);
1304                }}
1305                
1306                // Scene settings
1307                if (data.autoRotate !== undefined) state.autoRotate = data.autoRotate;
1308                if (data.rotSpeed !== undefined) state.rotSpeed = data.rotSpeed;
1309                if (data.scale !== undefined) state.scale = data.scale;
1310                if (data.color !== undefined) state.color = data.color;
1311                if (data.background !== undefined) {{
1312                    state.background = data.background;
1313                    scene.background = new THREE.Color(state.background);
1314                }}
1315                if (data.showGrid !== undefined) state.showGrid = data.showGrid;
1316                if (data.showAxes !== undefined) state.showAxes = data.showAxes;
1317                if (data.wireframe !== undefined) state.wireframe = data.wireframe;
1318                
1319                updateTransform();
1320                
1321                if (data.selection !== undefined) {{
1322                    selectedEntities = new Set(data.selection);
1323                    updateSelectionVisuals();
1324                }}
1325                if (data.gizmo !== undefined) {{
1326                    updateGizmo(data.gizmo);
1327                }}
1328                // NOTE: We intentionally do NOT update models via postMessage
1329                // because TransformControls handles transforms directly in the iframe.
1330                // Sending model transforms would create a race condition where
1331                // updateModels resets the object while the user is dragging.
1332                // Model changes (add/remove) are handled by iframe reload instead.
1333            }}
1334        }});
1335        
1336        async function loadModel() {{
1337            try {{
1338                {model_loading_code}
1339                
1340                // Initialize after models are loaded
1341                transformControl = initTransformControls();
1342                updateSelectionVisuals();
1343                updateGizmo();
1344            }} catch (e) {{
1345                console.error('Error:', e);
1346                loadingEl.style.display = 'none';
1347                errorEl.style.display = 'block';
1348                errorEl.textContent = 'Error: ' + e.message;
1349            }}
1350        }}
1351        
1352        function updateTransform() {{
1353            if (!modelContainer) return;
1354            modelContainer.position.set({pos_x}, {pos_y}, {pos_z});
1355            modelContainer.scale.setScalar(state.scale);
1356            if (!state.autoRotate) {{
1357                modelContainer.rotation.set(state.rotX, state.rotY, state.rotZ);
1358            }} else {{
1359                modelContainer.rotation.x = state.rotX;
1360                modelContainer.rotation.z = state.rotZ;
1361            }}
1362            modelContainer.traverse(function (child) {{
1363                if (child.isMesh && child.material) {{
1364                    child.material.wireframe = state.wireframe;
1365                }}
1366            }});
1367        }}
1368        
1369        // Update model transforms without full reload
1370        function updateModels(modelsData) {{
1371            if (!modelsData || !Array.isArray(modelsData)) return;
1372            
1373            const seenIds = new Set();
1374            
1375            for (const model of modelsData) {{
1376                const entityId = model.entityId;
1377                seenIds.add(entityId);
1378                
1379                let obj = entityMap.get(entityId);
1380                if (!obj) {{
1381                    // Create new cube for missing objects
1382                    if (model.format === 'cube') {{
1383                        const geometry = new THREE.BoxGeometry(1, 1, 1);
1384                        const material = new THREE.MeshStandardMaterial({{ 
1385                            color: model.color || '#ffffff',
1386                            roughness: 0.5, 
1387                            metalness: 0.3 
1388                        }});
1389                        obj = new THREE.Mesh(geometry, material);
1390                        obj.castShadow = true;
1391                        obj.receiveShadow = true;
1392                    }} else {{
1393                        continue;
1394                    }}
1395                    obj.userData = {{ entityId: entityId }};
1396                    modelContainer.add(obj);
1397                    entityMap.set(entityId, obj);
1398                }}
1399                
1400                if (obj) {{
1401                    obj.position.set(model.posX || 0, model.posY || 0, model.posZ || 0);
1402                    obj.rotation.set(
1403                        (model.rotX || 0) * Math.PI / 180,
1404                        (model.rotY || 0) * Math.PI / 180,
1405                        (model.rotZ || 0) * Math.PI / 180
1406                    );
1407                    obj.scale.setScalar(model.scale !== undefined ? model.scale : 1);
1408                    
1409                    if (obj.material && obj.material.color && model.color) {{
1410                        obj.material.color.set(model.color);
1411                    }}
1412                }}
1413            }}
1414            
1415            // Remove deleted models
1416            for (const [id, obj] of entityMap) {{
1417                if (!seenIds.has(id)) {{
1418                    if (obj.parent) obj.parent.remove(obj);
1419                    if (obj.geometry) obj.geometry.dispose();
1420                    if (obj.material) {{
1421                        if (Array.isArray(obj.material)) {{
1422                            obj.material.forEach(m => m.dispose());
1423                        }} else {{
1424                            obj.material.dispose();
1425                        }}
1426                    }}
1427                    entityMap.delete(id);
1428                }}
1429            }}
1430            
1431            updateSelectionVisuals();
1432            updateGizmo();
1433        }}
1434        
1435        window.updateThreeView = function(params) {{
1436            state = {{ ...state, ...params }};
1437            if (params.camX !== undefined) camera.position.set(state.camX, state.camY, state.camZ);
1438            if (params.targetX !== undefined) camera.lookAt(state.targetX, state.targetY, state.targetZ);
1439            if (params.background !== undefined) scene.background = new THREE.Color(state.background);
1440            updateTransform();
1441        }};
1442        
1443        // Expose functions for external control
1444        window.setSelection = function(selection) {{
1445            selectedEntities = new Set(selection);
1446            updateSelectionVisuals();
1447            updateGizmo();
1448        }};
1449        
1450        window.setGizmoConfig = function(config) {{
1451            updateGizmo();
1452        }};
1453        
1454        function animate() {{
1455            requestAnimationFrame(animate);
1456            if (state.autoRotate && modelContainer) {{
1457                autoRotY += state.rotSpeed * 0.01;
1458                modelContainer.rotation.y = state.rotY + autoRotY;
1459            }}
1460            
1461            // Update selection outlines to track their source meshes
1462            for (const [outlineGroup, sourceMesh] of outlineToMeshMap) {{
1463                if (sourceMesh.parent) {{
1464                    const box = new THREE.Box3().setFromObject(sourceMesh);
1465                    const center = box.getCenter(new THREE.Vector3());
1466                    const currentSize = box.getSize(new THREE.Vector3());
1467                    const originalSize = outlineGroup.userData.originalSize;
1468                    
1469                    outlineGroup.position.copy(center);
1470                    outlineGroup.rotation.copy(sourceMesh.rotation);
1471                    
1472                    // Scale outline to match object's current bounding box
1473                    if (originalSize && originalSize.x > 0 && originalSize.y > 0 && originalSize.z > 0) {{
1474                        outlineGroup.scale.set(
1475                            currentSize.x / originalSize.x,
1476                            currentSize.y / originalSize.y,
1477                            currentSize.z / originalSize.z
1478                        );
1479                    }}
1480                }}
1481            }}
1482            
1483            {shader_uniforms}
1484            renderer.render(scene, camera);
1485        }}
1486        
1487        window.addEventListener('resize', () => {{
1488            const w = container.clientWidth || window.innerWidth;
1489            const h = container.clientHeight || window.innerHeight;
1490            camera.aspect = w / h;
1491            camera.updateProjectionMatrix();
1492            renderer.setSize(w, h);
1493        }});
1494        
1495        loadModel();
1496        updateTransform();
1497        animate();
1498        console.log("Dioxus Three: Running with interaction support");
1499    </script>
1500</body>
1501</html>"##,
1502        bg = props.background,
1503        loader_script = loader_script,
1504        fmt = format_str,
1505        cam_x = props.cam_x,
1506        cam_y = props.cam_y,
1507        cam_z = props.cam_z,
1508        target_x = props.target_x,
1509        target_y = props.target_y,
1510        target_z = props.target_z,
1511        shadows = props.shadows.to_string().to_lowercase(),
1512        show_grid = props.show_grid.to_string().to_lowercase(),
1513        show_axes = props.show_axes.to_string().to_lowercase(),
1514        rot_x = rot_x_rad,
1515        rot_y = rot_y_rad,
1516        rot_z = rot_z_rad,
1517        scale = props.scale,
1518        color = props.color,
1519        auto_rotate = props.auto_rotate.to_string().to_lowercase(),
1520        rot_speed = props.rot_speed,
1521        wireframe = props.wireframe.to_string().to_lowercase(),
1522        pos_x = props.pos_x,
1523        pos_y = props.pos_y,
1524        pos_z = props.pos_z,
1525        shader_uniforms = shader_uniforms,
1526        model_loading_code = model_loading_code,
1527        selection_ids_json = selection_ids_json,
1528        gizmo_config_json = gizmo_config_json,
1529        selection_style_json = selection_style_json,
1530        raycast_enabled = raycast_enabled.to_string().to_lowercase(),
1531        selection_enabled = selection_enabled.to_string().to_lowercase(),
1532    );
1533
1534    html
1535}
1536
1537/// Build shader code for the Three.js scene
1538pub fn build_shader_code(shader: &ShaderPreset) -> (String, String, bool) {
1539    match shader {
1540        ShaderPreset::None => (String::new(), String::new(), false),
1541        _ => {
1542            let vert = shader.vertex_shader().unwrap_or_default();
1543            let frag = shader.fragment_shader().unwrap_or_default();
1544            let animated = shader.is_animated();
1545
1546            let material_code = format!(
1547                r#"
1548            // Shader material
1549            const shaderMaterial = new THREE.ShaderMaterial({{
1550                uniforms: {{
1551                    u_time: {{ value: 0 }},
1552                    u_color: {{ value: new THREE.Color(state.color) }}
1553                }},
1554                vertexShader: `{}`,
1555                fragmentShader: `{}`,
1556                transparent: true,
1557                side: THREE.DoubleSide
1558            }});
1559            material = shaderMaterial;
1560            "#,
1561                vert.replace("`", "\\`"),
1562                frag.replace("`", "\\`")
1563            );
1564
1565            let uniforms_code = r#"
1566            // Update shader uniforms
1567            if (material && material.uniforms) {
1568                material.uniforms.u_time.value = performance.now() * 0.001;
1569                material.uniforms.u_color.value.set(state.color);
1570            }
1571            "#
1572            .to_string();
1573
1574            (material_code, uniforms_code, animated)
1575        }
1576    }
1577}
1578
1579/// Serialize model configs to JSON for postMessage updates (desktop iframe)
1580pub fn models_to_json(models: &[ModelConfig]) -> String {
1581    let mut parts = Vec::new();
1582    for (idx, model) in models.iter().enumerate() {
1583        parts.push(format!(
1584            r#"{{"entityId":{},"url":"{}","format":"{}","posX":{},"posY":{},"posZ":{},"rotX":{},"rotY":{},"rotZ":{},"scale":{},"color":"{}"}}"#,
1585            idx,
1586            model.url.replace('\\', "\\\\").replace('"', "\\\""),
1587            model.format.as_str(),
1588            model.pos_x,
1589            model.pos_y,
1590            model.pos_z,
1591            model.rot_x,
1592            model.rot_y,
1593            model.rot_z,
1594            model.scale,
1595            model.color.replace('\\', "\\\\").replace('"', "\\\"")
1596        ));
1597    }
1598    format!("[{}]", parts.join(","))
1599}