1use dioxus::prelude::*;
16use std::collections::HashMap;
17
18pub mod gizmos;
20pub mod input;
21pub mod selection;
22
23pub 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#[cfg(not(target_arch = "wasm32"))]
33mod desktop;
34#[cfg(target_arch = "wasm32")]
35mod web;
36
37#[cfg(not(target_arch = "wasm32"))]
39pub use desktop::ThreeView;
40#[cfg(target_arch = "wasm32")]
41pub use web::ThreeView;
42
43#[derive(Clone, PartialEq, Debug, Default)]
45pub struct ShaderConfig {
46 pub vertex_shader: Option<String>,
48 pub fragment_shader: Option<String>,
50 pub uniforms: HashMap<String, f32>,
52 pub animated: bool,
54}
55
56#[derive(Clone, PartialEq, Debug)]
58pub enum ShaderPreset {
59 None,
61 Gradient,
63 Water,
65 Hologram,
67 Toon,
69 Heatmap,
71 Custom(ShaderConfig),
73}
74
75#[derive(Clone, PartialEq, Debug)]
77pub enum ModelFormat {
78 Obj,
80 Fbx,
82 Gltf,
84 Glb,
86 Stl,
88 Ply,
90 Dae,
92 Json,
94 Cube,
96}
97
98impl ModelFormat {
99 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 fn extra_scripts(&self) -> Vec<&'static str> {
154 match self {
155 ModelFormat::Fbx => vec!["https://cdn.jsdelivr.net/npm/fflate@0.8.0/umd/index.js"],
157 _ => vec![],
158 }
159 }
160}
161
162#[derive(Clone, PartialEq, Debug)]
164pub struct ModelConfig {
165 pub url: String,
167 pub format: ModelFormat,
169 pub pos_x: f32,
171 pub pos_y: f32,
173 pub pos_z: f32,
175 pub rot_x: f32,
177 pub rot_y: f32,
179 pub rot_z: f32,
181 pub scale: f32,
183 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 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 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 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 pub fn with_scale(mut self, scale: f32) -> Self {
232 self.scale = scale;
233 self
234 }
235
236 pub fn with_color(mut self, color: impl Into<String>) -> Self {
238 self.color = color.into();
239 self
240 }
241}
242
243#[derive(Props, Clone, PartialEq)]
245pub struct ThreeViewProps {
246 #[props(default = None)]
248 pub model_url: Option<String>,
249 #[props(default = ModelFormat::Cube)]
251 pub format: ModelFormat,
252 #[props(default = 0.0)]
254 pub pos_x: f32,
255 #[props(default = 0.0)]
257 pub pos_y: f32,
258 #[props(default = 0.0)]
260 pub pos_z: f32,
261 #[props(default = 0.0)]
263 pub rot_x: f32,
264 #[props(default = 0.0)]
266 pub rot_y: f32,
267 #[props(default = 0.0)]
269 pub rot_z: f32,
270 #[props(default = 1.0)]
272 pub scale: f32,
273 #[props(default = "#ff6b6b".to_string())]
275 pub color: String,
276 #[props(default = Vec::new())]
278 pub models: Vec<ModelConfig>,
279 #[props(default = true)]
281 pub auto_center: bool,
282 #[props(default = false)]
284 pub auto_scale: bool,
285 #[props(default = 5.0)]
287 pub cam_x: f32,
288 #[props(default = 5.0)]
290 pub cam_y: f32,
291 #[props(default = 5.0)]
293 pub cam_z: f32,
294 #[props(default = 0.0)]
296 pub target_x: f32,
297 #[props(default = 0.0)]
299 pub target_y: f32,
300 #[props(default = 0.0)]
302 pub target_z: f32,
303 #[props(default = true)]
305 pub auto_rotate: bool,
306 #[props(default = 1.0)]
308 pub rot_speed: f32,
309 #[props(default = true)]
311 pub show_grid: bool,
312 #[props(default = true)]
314 pub show_axes: bool,
315 #[props(default = "#1a1a2e".to_string())]
317 pub background: String,
318 #[props(default = String::new())]
320 pub class: String,
321 #[props(default = true)]
323 pub shadows: bool,
324 #[props(default = false)]
326 pub wireframe: bool,
327 #[props(default = ShaderPreset::None)]
329 pub shader: ShaderPreset,
330
331 #[props(default = None)]
334 pub id: Option<String>,
335
336 #[props(default = RaycastConfig::default())]
338 pub raycast: RaycastConfig,
339
340 #[props(default = None)]
342 pub on_pointer_down: Option<Callback<PointerEvent>>,
343
344 #[props(default = None)]
346 pub on_pointer_up: Option<Callback<PointerEvent>>,
347
348 #[props(default = None)]
350 pub on_pointer_move: Option<Callback<PointerEvent>>,
351
352 #[props(default = None)]
354 pub on_pointer_drag: Option<Callback<PointerDragEvent>>,
355
356 #[props(default = None)]
358 pub on_gesture: Option<Callback<GestureEvent>>,
359
360 #[props(default = None)]
362 pub selection: Option<Selection>,
363
364 #[props(default = SelectionMode::Single)]
366 pub selection_mode: SelectionMode,
367
368 #[props(default = SelectionStyle::default())]
370 pub selection_style: SelectionStyle,
371
372 #[props(default = None)]
374 pub on_selection_change: Option<Callback<Selection>>,
375
376 #[props(default = None)]
378 pub gizmo: Option<Gizmo>,
379
380 #[props(default = None)]
382 pub on_gizmo_drag: Option<Callback<GizmoEvent>>,
383
384 #[props(default = false)]
386 pub debug: bool,
387}
388
389impl ShaderPreset {
390 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 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 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
427pub 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
456pub 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
476pub 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
583pub 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
614pub 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 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 let use_multiple_models = !props.models.is_empty();
630
631 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 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 let (_shader_material_code, shader_uniforms, _shader_animated) =
653 build_shader_code(&props.shader);
654
655 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 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
1537pub 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
1579pub 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}