diff --git a/imgui.ini b/imgui.ini index 1ff39d32..c3e4b59e 100644 --- a/imgui.ini +++ b/imgui.ini @@ -79,17 +79,17 @@ Size=93,65 Collapsed=0 [Window][新建项目] -Pos=725,358 +Pos=760,371 Size=400,300 Collapsed=0 [Window][选择路径] -Pos=625,258 +Pos=660,271 Size=600,500 Collapsed=0 [Window][打开项目] -Pos=675,308 +Pos=710,321 Size=500,400 Collapsed=0 diff --git a/project/webgl_packager.py b/project/webgl_packager.py index ad6e4b27..dca48394 100644 --- a/project/webgl_packager.py +++ b/project/webgl_packager.py @@ -412,9 +412,11 @@ class WebGLPackager: if entry: nodes_json.append(entry) + lui_manifest = self._build_lui_manifest() + manifest = { "meta": { - "format_version": "1.0", + "format_version": "1.1", "api_version": 1, "project_name": project_name, "exported_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), @@ -430,8 +432,299 @@ class WebGLPackager: "environment": self._build_environment_entry(), "nodes": nodes_json, } + if lui_manifest: + manifest["lui"] = lui_manifest return manifest + def _build_lui_manifest(self) -> Dict[str, Any]: + """Serialize scene-space LUI canvases for the WebGL viewer.""" + lui_manager = getattr(self.world, "lui_manager", None) + if not lui_manager: + return {} + + canvases = list(getattr(lui_manager, "canvases", []) or []) + components = list(getattr(lui_manager, "components", []) or []) + if not canvases: + return {} + + canvas_entries: List[Dict[str, Any]] = [] + for canvas_index, canvas in enumerate(canvases): + if not isinstance(canvas, dict): + continue + render_mode = str(canvas.get("render_mode") or "screen").lower().strip() + if render_mode != "world": + continue + + entry = self._build_lui_canvas_entry(canvas_index, canvas, components) + if entry: + canvas_entries.append(entry) + + if not canvas_entries: + return {} + return { + "version": 1, + "source": "lui_manager", + "canvases": canvas_entries, + } + + def _build_lui_canvas_entry( + self, + canvas_index: int, + canvas: Dict[str, Any], + components: List[Dict[str, Any]], + ) -> Dict[str, Any]: + mat = self._lui_canvas_matrix(canvas) + width, height = self._lui_canvas_size(canvas) + + component_entries = [] + for comp_index, comp in enumerate(components): + if not isinstance(comp, dict): + continue + try: + if int(comp.get("canvas_index", -1)) != canvas_index: + continue + except Exception: + continue + entry = self._build_lui_component_entry(comp_index, comp, components) + if entry: + component_entries.append(entry) + + component_entries.sort( + key=lambda item: ( + self._safe_float(item.get("sort"), 1.0) + self._safe_float(item.get("z_offset"), 0.0), + int(item.get("index", 0)), + ) + ) + + return { + "id": f"lui_canvas_{canvas_index}", + "name": str(canvas.get("name") or f"Canvas {canvas_index + 1}"), + "render_mode": "world", + "visible": bool(canvas.get("visible", True)), + "matrix_local_row_major": self._mat4_to_row_major_list(mat) if mat is not None else self._identity_mat4_row_major(), + "world_position": self._lui_triplet(canvas.get("world_position"), (0.0, 8.0, 2.0)), + "world_hpr": self._lui_triplet(canvas.get("world_hpr"), (0.0, 0.0, 0.0)), + "world_scale": self._lui_triplet(canvas.get("world_scale"), (1.0, 1.0, 1.0)), + "width": width, + "height": height, + "world_pixels_per_unit": max(1.0, self._safe_float(canvas.get("world_pixels_per_unit"), 280.0)), + "world_depth_test": bool(canvas.get("world_depth_test", True)), + "world_interactive": bool(canvas.get("world_interactive", True)), + "world_two_sided": bool(canvas.get("world_two_sided", True)), + "background_color": self._lui_canvas_background_color(canvas), + "title_text": self._lui_text_value(canvas.get("title_text") or self._lui_object_text(canvas.get("title_label"))), + "title_visible": bool(canvas.get("title_visible", False)), + "components": component_entries, + } + + def _build_lui_component_entry( + self, + comp_index: int, + comp: Dict[str, Any], + components: List[Dict[str, Any]], + ) -> Dict[str, Any]: + left, top = self._lui_component_abs_pos(comp_index, components) + width = max(1.0, self._safe_float(comp.get("width"), 100.0)) + height = max(1.0, self._safe_float(comp.get("height"), 30.0)) + comp_type = str(comp.get("type") or "Component") + + entry: Dict[str, Any] = { + "index": comp_index, + "type": comp_type, + "name": str(comp.get("name") or f"{comp_type}_{comp_index}"), + "left": left, + "top": top, + "width": width, + "height": height, + "sort": self._safe_float(comp.get("sort"), 1.0), + "z_offset": self._safe_float(comp.get("z_offset"), 0.0), + "text": self._lui_text_value(comp.get("text") or self._lui_object_text(comp.get("object"))), + "value": self._lui_json_scalar(comp.get("value", "")), + "placeholder": str(comp.get("placeholder") or ""), + "checked": bool(comp.get("checked", False)), + "color": self._normalize_lui_color(comp.get("color"), (1.0, 1.0, 1.0, 1.0)), + "text_color": self._normalize_lui_color(comp.get("text_color"), (1.0, 1.0, 1.0, 1.0)), + "font_size": max(8.0, self._safe_float(comp.get("font_size"), 14.0)), + "min_value": self._safe_float(comp.get("min_value", comp.get("min", 0.0)), 0.0), + "max_value": self._safe_float(comp.get("max_value", comp.get("max", 100.0)), 100.0), + "selected_option_id": self._lui_json_scalar(comp.get("selected_option_id")), + "options": self._serialize_lui_options(comp.get("options")), + } + + texture_path = str(comp.get("texture_path") or comp.get("image_path") or "").strip() + texture_abs = self._resolve_lui_asset_path(texture_path) + if texture_abs: + texture_uri = self._copy_asset_to_textures(texture_abs) + if texture_uri: + entry["texture_uri"] = texture_uri + return entry + + def _lui_canvas_matrix(self, canvas: Dict[str, Any]) -> Any: + node = canvas.get("node") + render = getattr(self.world, "render", None) + if node is not None: + try: + if not hasattr(node, "isEmpty") or not node.isEmpty(): + if render is not None: + return node.getMat(render) + return node.getMat() + except Exception: + pass + + try: + from panda3d.core import NodePath + + temp = NodePath("lui_web_export_canvas_transform") + temp.setPos(*self._lui_triplet(canvas.get("world_position"), (0.0, 8.0, 2.0))) + temp.setHpr(*self._lui_triplet(canvas.get("world_hpr"), (0.0, 0.0, 0.0))) + temp.setScale(*self._lui_triplet(canvas.get("world_scale"), (1.0, 1.0, 1.0))) + return temp.getMat() + except Exception: + return None + + def _lui_canvas_size(self, canvas: Dict[str, Any]) -> Tuple[float, float]: + panel = canvas.get("panel") + width = self._safe_float(canvas.get("width"), 0.0) + height = self._safe_float(canvas.get("height"), 0.0) + if panel is not None: + try: + width = self._safe_float(getattr(panel, "width", width), width) + height = self._safe_float(getattr(panel, "height", height), height) + except Exception: + pass + try: + if width <= 0 and hasattr(panel, "get_width"): + width = self._safe_float(panel.get_width(), width) + if height <= 0 and hasattr(panel, "get_height"): + height = self._safe_float(panel.get_height(), height) + except Exception: + pass + return max(1.0, width or 800.0), max(1.0, height or 600.0) + + def _lui_canvas_background_color(self, canvas: Dict[str, Any]) -> List[float]: + background = canvas.get("background") + try: + if background is not None and hasattr(background, "color"): + return self._normalize_lui_color(background.color, (0.1, 0.1, 0.1, 0.65)) + except Exception: + pass + return self._normalize_lui_color(canvas.get("background_color"), (0.1, 0.1, 0.1, 0.65)) + + def _lui_component_abs_pos( + self, + comp_index: int, + components: List[Dict[str, Any]], + visited: Optional[set] = None, + ) -> Tuple[float, float]: + if comp_index < 0 or comp_index >= len(components): + return 0.0, 0.0 + if visited is None: + visited = set() + if comp_index in visited: + return 0.0, 0.0 + visited.add(comp_index) + comp = components[comp_index] + left = self._safe_float(comp.get("left"), 0.0) + top = self._safe_float(comp.get("top"), 0.0) + parent_index = comp.get("parent_index") + try: + parent_index_int = int(parent_index) + except Exception: + parent_index_int = -1 + if 0 <= parent_index_int < len(components): + px, py = self._lui_component_abs_pos(parent_index_int, components, visited) + return px + left, py + top + return left, top + + def _resolve_lui_asset_path(self, path_hint: str) -> Optional[str]: + text = str(path_hint or "").strip() + if not text or text.lower() == "blank": + return None + if text.startswith(("http://", "https://", "data:", "blob:")): + return None + if os.path.isabs(text) and os.path.exists(text): + return os.path.normpath(text) + roots = [ + self._project_path, + os.path.join(self._project_path, "Resources"), + os.path.join(self._project_path, "scenes", "resources"), + os.getcwd(), + ] + for root in roots: + candidate = os.path.normpath(os.path.join(root, text)) + if os.path.exists(candidate): + return candidate + return None + + @staticmethod + def _normalize_lui_color(value: Any, fallback: Tuple[float, float, float, float]) -> List[float]: + if not isinstance(value, (list, tuple)): + value = fallback + vals = list(value[:4]) + while len(vals) < 4: + vals.append(1.0) + out = [] + for idx, fallback_value in enumerate(fallback): + try: + num = float(vals[idx]) + except Exception: + num = float(fallback_value) + out.append(max(0.0, min(1.0, num))) + return out + + @staticmethod + def _lui_triplet(value: Any, fallback: Tuple[float, float, float]) -> List[float]: + if not isinstance(value, (list, tuple)) or len(value) < 3: + value = fallback + out = [] + for idx, fallback_value in enumerate(fallback): + try: + out.append(float(value[idx])) + except Exception: + out.append(float(fallback_value)) + return out + + @staticmethod + def _lui_text_value(value: Any) -> str: + return "" if value is None else str(value) + + @staticmethod + def _lui_json_scalar(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + return str(value) + + @staticmethod + def _lui_object_text(obj: Any) -> str: + if obj is None: + return "" + for attr in ("text", "value", "label"): + try: + value = getattr(obj, attr, None) + except Exception: + value = None + if value is not None: + return str(value) + return "" + + @staticmethod + def _serialize_lui_options(options: Any) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + if not isinstance(options, (list, tuple)): + return out + for index, item in enumerate(options): + if isinstance(item, dict): + option_id = item.get("id", item.get("value", index)) + label = item.get("label", item.get("text", item.get("value", option_id))) + elif isinstance(item, (list, tuple)) and item: + option_id = item[0] + label = item[1] if len(item) > 1 else item[0] + else: + option_id = index + label = item + out.append({"id": WebGLPackager._lui_json_scalar(option_id), "label": "" if label is None else str(label)}) + return out + def _collect_model_nodes(self) -> List[Any]: if not self.scene_manager: return [] @@ -2541,6 +2834,15 @@ class WebGLPackager: 1.0, ] + @staticmethod + def _identity_mat4_row_major() -> List[float]: + return [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, + ] + @staticmethod def _safe_float(value: Any, default: float) -> float: try: diff --git a/projects/新项目/Assets/Models/box1.glb b/projects/新项目/Assets/Models/box1.glb new file mode 100644 index 00000000..06712bc4 Binary files /dev/null and b/projects/新项目/Assets/Models/box1.glb differ diff --git a/projects/新项目/Assets/Models/box1.glb.meta b/projects/新项目/Assets/Models/box1.glb.meta new file mode 100644 index 00000000..d8ed18e7 --- /dev/null +++ b/projects/新项目/Assets/Models/box1.glb.meta @@ -0,0 +1,8 @@ +{ + "guid": "431e04e8b87e4e57b92549741c51ad45", + "asset_type": "model", + "source_hash": "fc694905c47a9b0005d77b701cc41852b56ef08c7406829a306e98f3ce158a64", + "importer": "model_importer", + "import_settings": {}, + "dependency_guids": [] +} \ No newline at end of file diff --git a/projects/新项目/Scenes/Main.metascene b/projects/新项目/Scenes/Main.metascene new file mode 100644 index 00000000..5061b9e4 --- /dev/null +++ b/projects/新项目/Scenes/Main.metascene @@ -0,0 +1,502 @@ +{ + "schema_version": 2, + "scene_guid": "9810f0cbf25c4c02a8c6d514d14da9c2", + "scene_name": "Main", + "scene_file": "Scenes/Main.metascene", + "saved_at": "2026-05-25 16:10:51", + "cache": { + "scene_bam": "", + "gui_json": "Scenes/Main_gui.json", + "lui_json": "Scenes/Main_lui.json" + }, + "referenced_asset_guids": [ + "431e04e8b87e4e57b92549741c51ad45" + ], + "referenced_script_guids": [], + "scene_components": { + "camera": { + "position": [ + 0.0, + -50.0, + 20.0 + ], + "rotation": [ + 6.041667938232422, + -14.2271089553833, + 0.0 + ], + "camera_control_enabled": true + }, + "gui": { + "elements": [] + }, + "lui": { + "canvases": [ + { + "name": "1", + "visible": true, + "fill_mode": false, + "fixed": false, + "margins": { + "left": 240.0, + "right": 480.0, + "top": 89.0, + "bottom": 220.0 + }, + "render_mode": "world", + "world_position": [ + -5.400000095367432, + 8.0, + 2.0 + ], + "world_hpr": [ + 0.0, + 0.0, + 0.0 + ], + "world_scale": [ + 1.0, + 1.0, + 1.0 + ], + "world_pixels_per_unit": 280.0, + "world_depth_test": true, + "world_interactive": true, + "world_two_sided": true, + "left": 240.0, + "top": 89.0, + "width": 1200.0, + "height": 734.0, + "background_color": [ + 0.5, + 0.5, + 0.5, + 1.0 + ], + "title_visible": false, + "title_text": "Canvas: 1", + "title_pos": [ + 10.0, + 10.0 + ] + } + ], + "components": [ + { + "type": "Button", + "text": "New Button", + "left": 550.0, + "top": 352.0, + "width": 100.0, + "height": 30.0, + "canvas_index": 0, + "name": "Button_0", + "color": [ + 1.0, + 1.0, + 1.0, + 1.0 + ], + "sort": 1, + "parent_index": null, + "children_indices": [] + } + ], + "current_canvas_index": 0, + "selected_index": -1, + "root_order": [] + } + }, + "camera": { + "position": [ + 0.0, + -50.0, + 20.0 + ], + "rotation": [ + 6.041667938232422, + -14.2271089553833, + 0.0 + ], + "camera_control_enabled": true + }, + "gui": [], + "lui": { + "canvases": [ + { + "name": "1", + "visible": true, + "fill_mode": false, + "fixed": false, + "margins": { + "left": 240.0, + "right": 480.0, + "top": 89.0, + "bottom": 220.0 + }, + "render_mode": "world", + "world_position": [ + -5.400000095367432, + 8.0, + 2.0 + ], + "world_hpr": [ + 0.0, + 0.0, + 0.0 + ], + "world_scale": [ + 1.0, + 1.0, + 1.0 + ], + "world_pixels_per_unit": 280.0, + "world_depth_test": true, + "world_interactive": true, + "world_two_sided": true, + "left": 240.0, + "top": 89.0, + "width": 1200.0, + "height": 734.0, + "background_color": [ + 0.5, + 0.5, + 0.5, + 1.0 + ], + "title_visible": false, + "title_text": "Canvas: 1", + "title_pos": [ + 10.0, + 10.0 + ] + } + ], + "components": [ + { + "type": "Button", + "text": "New Button", + "left": 550.0, + "top": 352.0, + "width": 100.0, + "height": 30.0, + "canvas_index": 0, + "name": "Button_0", + "color": [ + 1.0, + 1.0, + 1.0, + 1.0 + ], + "sort": 1, + "parent_index": null, + "children_indices": [] + } + ], + "current_canvas_index": 0, + "selected_index": -1, + "root_order": [] + }, + "nodes": [ + { + "node_id": "0", + "parent_id": null, + "name": "box1.glb", + "imported_node_key": "", + "asset_guid": "431e04e8b87e4e57b92549741c51ad45", + "asset_path": "Assets/Models/box1.glb", + "node_class": "ModelRoot", + "transform": { + "position": [ + 0.0, + 0.0, + 0.0 + ], + "rotation": [ + 0.0, + 0.0, + 0.0 + ], + "scale": [ + 1.0, + 1.0, + 1.0 + ] + }, + "visibility": { + "user_visible": true + }, + "runtime_interactive": false, + "scripts": [], + "components": { + "model": { + "asset_guid": "431e04e8b87e4e57b92549741c51ad45", + "asset_path": "Assets/Models/box1.glb", + "imported_node_key": "", + "is_model_root": true + }, + "scripts": {}, + "light": {}, + "material_overrides": {}, + "metadata": { + "node_class": "ModelRoot", + "runtime_interactive": false + } + }, + "tags": { + "model_path": "projects/新项目/Assets/Models/box1.glb", + "saved_model_path": "projects/新项目/Assets/Models/box1.glb", + "original_path": "projects/新项目/Assets/Models/box1.glb", + "file": "box1.glb", + "asset_guid": "431e04e8b87e4e57b92549741c51ad45", + "asset_path": "Assets/Models/box1.glb", + "is_model_root": "1", + "is_scene_element": "1" + } + }, + { + "node_id": "0/0", + "parent_id": "0", + "name": "ROOT", + "imported_node_key": "0/0", + "asset_guid": "", + "asset_path": "", + "node_class": "PandaNode", + "transform": { + "position": [ + 0.0, + 0.0, + 0.0 + ], + "rotation": [ + 0.0, + 0.0, + 0.0 + ], + "scale": [ + 1.0, + 1.0, + 1.0 + ] + }, + "visibility": { + "user_visible": true + }, + "runtime_interactive": false, + "scripts": [], + "components": { + "model": {}, + "scripts": {}, + "light": {}, + "material_overrides": {}, + "metadata": { + "node_class": "PandaNode", + "runtime_interactive": false + } + }, + "tags": {} + }, + { + "node_id": "0/0/0", + "parent_id": "0/0", + "name": "立方体", + "imported_node_key": "0/0/0", + "asset_guid": "", + "asset_path": "", + "node_class": "GeomNode", + "transform": { + "position": [ + -1.6927927732467651, + 0.0, + -4.459673881530762 + ], + "rotation": [ + -0.0, + 0.0, + -0.0 + ], + "scale": [ + 1.0, + 1.0, + 1.0 + ] + }, + "visibility": { + "user_visible": true + }, + "runtime_interactive": false, + "scripts": [], + "components": { + "model": {}, + "scripts": {}, + "light": {}, + "material_overrides": {}, + "metadata": { + "node_class": "GeomNode", + "runtime_interactive": false + } + }, + "tags": {} + }, + { + "node_id": "0/0/0/0", + "parent_id": "0/0/0", + "name": "立方体.001", + "imported_node_key": "0/0/0/0", + "asset_guid": "", + "asset_path": "", + "node_class": "GeomNode", + "transform": { + "position": [ + 0.0, + 0.0, + -3.55507755279541 + ], + "rotation": [ + -0.0, + 0.0, + -0.0 + ], + "scale": [ + 1.0, + 1.0, + 1.0 + ] + }, + "visibility": { + "user_visible": true + }, + "runtime_interactive": false, + "scripts": [], + "components": { + "model": {}, + "scripts": {}, + "light": {}, + "material_overrides": { + "material_emission": "(0.0, 0.0, 0.0, 1.0)", + "material_basecolor": "(0.800000011920929, 0.800000011920929, 0.800000011920929, 1.0)", + "material_roughness": "0.5", + "material_metallic": "0.0", + "material_ior": "1.5", + "material_render_effect_signature": "effects/default.yaml|0|1|0|0" + }, + "metadata": { + "node_class": "GeomNode", + "runtime_interactive": false + } + }, + "tags": { + "Shadows": "3", + "Voxelize": "3", + "Envmap": "3", + "material_render_effect_signature": "effects/default.yaml|0|1|0|0", + "material_emission": "(0.0, 0.0, 0.0, 1.0)", + "material_basecolor": "(0.800000011920929, 0.800000011920929, 0.800000011920929, 1.0)", + "material_roughness": "0.5", + "material_metallic": "0.0", + "material_ior": "1.5" + } + }, + { + "node_id": "0/0/0/0/0", + "parent_id": "0/0/0/0", + "name": "立方体.007", + "imported_node_key": "0/0/0/0/0", + "asset_guid": "", + "asset_path": "", + "node_class": "GeomNode", + "transform": { + "position": [ + 4.175573348999023, + 0.0, + 0.0 + ], + "rotation": [ + -0.0, + 0.0, + -0.0 + ], + "scale": [ + 1.0, + 1.0, + 1.0 + ] + }, + "visibility": { + "user_visible": true + }, + "runtime_interactive": false, + "scripts": [], + "components": { + "model": {}, + "scripts": {}, + "light": {}, + "material_overrides": { + "material_emission": "(0.0, 0.0, 0.0, 1.0)", + "material_basecolor": "(0.800000011920929, 0.800000011920929, 0.800000011920929, 1.0)", + "material_roughness": "0.5", + "material_metallic": "0.0", + "material_ior": "1.5", + "material_render_effect_signature": "effects/default.yaml|0|1|0|0" + }, + "metadata": { + "node_class": "GeomNode", + "runtime_interactive": false + } + }, + "tags": { + "Shadows": "4", + "Voxelize": "4", + "Envmap": "4", + "material_render_effect_signature": "effects/default.yaml|0|1|0|0", + "material_emission": "(0.0, 0.0, 0.0, 1.0)", + "material_basecolor": "(0.800000011920929, 0.800000011920929, 0.800000011920929, 1.0)", + "material_roughness": "0.5", + "material_metallic": "0.0", + "material_ior": "1.5" + } + }, + { + "node_id": "0/0/1", + "parent_id": "0/0", + "name": "立方体.003", + "imported_node_key": "0/0/1", + "asset_guid": "", + "asset_path": "", + "node_class": "GeomNode", + "transform": { + "position": [ + 0.0, + 0.0, + 0.0 + ], + "rotation": [ + 0.0, + 0.0, + 0.0 + ], + "scale": [ + 1.0, + 1.0, + 1.0 + ] + }, + "visibility": { + "user_visible": true + }, + "runtime_interactive": false, + "scripts": [], + "components": { + "model": {}, + "scripts": {}, + "light": {}, + "material_overrides": {}, + "metadata": { + "node_class": "GeomNode", + "runtime_interactive": false + } + }, + "tags": {} + } + ] +} \ No newline at end of file diff --git a/projects/新项目/project.json b/projects/新项目/project.json new file mode 100644 index 00000000..e6901f24 --- /dev/null +++ b/projects/新项目/project.json @@ -0,0 +1,49 @@ +{ + "schema_version": 2, + "name": "新项目", + "path": "/home/hello/EG/projects/新项目", + "created_at": "2026-05-25 16:08:52", + "last_modified": "2026-05-25 16:10:51", + "version": "2.0.0", + "engine_name": "MetaCore", + "engine_version": "2.0.0", + "scenes": [ + { + "guid": "9810f0cbf25c4c02a8c6d514d14da9c2", + "name": "Main", + "path": "Scenes/Main.metascene", + "enabled_in_build": true + } + ], + "scene_file": "Scenes/Main.metascene", + "startup_scene_guid": "9810f0cbf25c4c02a8c6d514d14da9c2", + "last_open_scene_guid": "9810f0cbf25c4c02a8c6d514d14da9c2", + "build_settings": { + "enabled_scene_guids": [ + "9810f0cbf25c4c02a8c6d514d14da9c2" + ], + "output_format": "directory", + "windows_player": true, + "active_profile": "default", + "profiles": { + "default": { + "name": "Default", + "target_platform": "windows", + "output_format": "directory", + "output_dir": "Builds", + "windows": { + "enabled": true, + "exe_name": "", + "windowed": true + }, + "runtime": { + "startup_scene_guid": "9810f0cbf25c4c02a8c6d514d14da9c2", + "enabled_scene_guids": [ + "9810f0cbf25c4c02a8c6d514d14da9c2" + ], + "script_mode": "pyc" + } + } + } + } +} \ No newline at end of file diff --git a/templates/webgl/viewer.js b/templates/webgl/viewer.js index 0b9aad25..ba7dbf76 100644 --- a/templates/webgl/viewer.js +++ b/templates/webgl/viewer.js @@ -998,6 +998,516 @@ function applyShadowFlags(root, enabled) { }); } +function luiClamp01(value, fallback = 1) { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.max(0, Math.min(1, n)); +} + +function luiColorToCss(color, fallback = [1, 1, 1, 1]) { + const src = Array.isArray(color) ? color : fallback; + const r = Math.round(luiClamp01(src[0], fallback[0] ?? 1) * 255); + const g = Math.round(luiClamp01(src[1], fallback[1] ?? 1) * 255); + const b = Math.round(luiClamp01(src[2], fallback[2] ?? 1) * 255); + const a = luiClamp01(src[3], fallback[3] ?? 1); + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +function luiRoundRect(ctx, x, y, w, h, r) { + const radius = Math.max(0, Math.min(Number(r) || 0, Math.min(w, h) * 0.5)); + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + w - radius, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + radius); + ctx.lineTo(x + w, y + h - radius); + ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); + ctx.lineTo(x + radius, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} + +function luiDrawText(ctx, text, x, y, width, height, options = {}) { + const fontSize = Math.max(8, Number(options.fontSize ?? 14)); + ctx.font = `${fontSize}px ${options.fontFamily || "system-ui, -apple-system, Segoe UI, sans-serif"}`; + ctx.fillStyle = options.color || "rgba(245,247,250,0.95)"; + ctx.textBaseline = "middle"; + ctx.textAlign = options.align || "left"; + const maxWidth = Math.max(1, Number(width) || 1); + let tx = Number(x) || 0; + if (ctx.textAlign === "center") tx += maxWidth * 0.5; + if (ctx.textAlign === "right") tx += maxWidth; + ctx.fillText(String(text ?? ""), tx, (Number(y) || 0) + Math.max(fontSize * 0.5, (Number(height) || fontSize) * 0.5), maxWidth); +} + +function luiSelectedOptionLabel(comp) { + const options = Array.isArray(comp?.options) ? comp.options : []; + if (options.length === 0) return "Select"; + const selected = comp?.selected_option_id; + const found = options.find((item) => String(item?.id) === String(selected)); + return String((found || options[0])?.label ?? (found || options[0])?.id ?? "Select"); +} + +function drawLUIComponent(ctx, comp, state) { + const x = Number(comp.left ?? 0); + const y = Number(comp.top ?? 0); + const w = Math.max(1, Number(comp.width ?? 100)); + const h = Math.max(1, Number(comp.height ?? 30)); + const type = String(comp.type || "").toLowerCase(); + const hovered = state.hoveredIndex === comp.index; + const pressed = state.pressedIndex === comp.index; + const fontSize = Math.max(8, Number(comp.font_size ?? 14)); + + if (type === "text" || type === "label") { + luiDrawText(ctx, comp.text || "", x, y, w, h, { + fontSize, + color: luiColorToCss(comp.color, [1, 1, 1, 1]), + }); + return; + } + + if (type === "button") { + ctx.fillStyle = pressed ? "rgba(28,41,59,0.98)" : hovered ? "rgba(61,83,108,0.95)" : "rgba(46,64,86,0.92)"; + luiRoundRect(ctx, x, y, w, h, 4); + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.28)"; + ctx.stroke(); + luiDrawText(ctx, comp.text || "Button", x + 8, y, w - 16, h, { + align: "center", + fontSize: 14, + color: "rgba(245,247,250,0.96)", + }); + return; + } + + if (type === "checkbox") { + const box = Math.min(18, h); + const by = y + (h - box) * 0.5; + ctx.fillStyle = "rgba(20,27,38,0.96)"; + ctx.fillRect(x, by, box, box); + ctx.strokeStyle = "rgba(255,255,255,0.35)"; + ctx.strokeRect(x, by, box, box); + if (comp.checked) { + ctx.strokeStyle = "rgba(125,255,205,1)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x + 4, by + box * 0.55); + ctx.lineTo(x + box * 0.42, by + box - 4); + ctx.lineTo(x + box - 3, by + 4); + ctx.stroke(); + ctx.lineWidth = 1; + } + luiDrawText(ctx, comp.text || "Checkbox", x + box + 8, y, w - box - 8, h, { + fontSize: 14, + color: "rgba(245,247,250,0.96)", + }); + return; + } + + if (type === "slider") { + const minV = Number(comp.min_value ?? 0); + const maxV = Number(comp.max_value ?? 100); + const val = Number(comp.value ?? ((minV + maxV) * 0.5)); + const ratio = maxV === minV ? 0 : Math.max(0, Math.min(1, (val - minV) / (maxV - minV))); + const barH = Math.max(3, h * 0.16); + const barY = y + h * 0.5 - barH * 0.5; + ctx.fillStyle = "rgba(24,32,43,0.96)"; + ctx.fillRect(x, barY, w, barH); + ctx.fillStyle = "rgba(115,210,180,0.96)"; + ctx.fillRect(x, barY, w * ratio, barH); + const knob = Math.max(10, Math.min(18, h + 2)); + ctx.fillStyle = "rgba(238,244,255,1)"; + ctx.fillRect(x + w * ratio - knob * 0.5, y + (h - knob) * 0.5, knob, knob); + return; + } + + if (type === "inputfield") { + ctx.fillStyle = "rgba(10,15,22,0.96)"; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = state.focusedIndex === comp.index ? "rgba(125,210,185,0.86)" : "rgba(255,255,255,0.24)"; + ctx.strokeRect(x, y, w, h); + const hasText = String(comp.value ?? comp.text ?? "").length > 0; + luiDrawText(ctx, hasText ? (comp.value ?? comp.text) : (comp.placeholder || ""), x + 8, y, w - 16, h, { + fontSize: 14, + color: hasText ? "rgba(245,247,250,0.96)" : "rgba(210,215,225,0.55)", + }); + return; + } + + if (type === "selectbox") { + ctx.fillStyle = "rgba(10,15,22,0.96)"; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = "rgba(255,255,255,0.24)"; + ctx.strokeRect(x, y, w, h); + luiDrawText(ctx, luiSelectedOptionLabel(comp), x + 8, y, w - 30, h, { + fontSize: 14, + color: "rgba(245,247,250,0.96)", + }); + luiDrawText(ctx, "v", x + w - 20, y, 14, h, { + align: "center", + fontSize: 14, + color: "rgba(245,247,250,0.85)", + }); + return; + } + + if (type === "progressbar") { + const ratio = Math.max(0, Math.min(1, Number(comp.value ?? 0) / 100)); + ctx.fillStyle = "rgba(16,21,28,0.96)"; + ctx.fillRect(x, y, w, h); + ctx.fillStyle = "rgba(82,194,158,0.96)"; + ctx.fillRect(x, y, w * ratio, h); + return; + } + + if (type === "image" && comp.imageElement && comp.imageElement.complete) { + try { + ctx.drawImage(comp.imageElement, x, y, w, h); + return; + } catch (err) { + // Use fallback below when the image is not drawable. + } + } + + ctx.fillStyle = luiColorToCss(comp.color, [0.12, 0.15, 0.19, 0.86]); + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = "rgba(255,255,255,0.14)"; + ctx.strokeRect(x, y, w, h); +} + +function drawLuiCanvas(luiCanvas) { + const ctx = luiCanvas.context2d; + const w = luiCanvas.pixelWidth; + const h = luiCanvas.pixelHeight; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = luiColorToCss(luiCanvas.data.background_color, [0.1, 0.1, 0.1, 0.68]); + ctx.fillRect(0, 0, w, h); + + if (luiCanvas.data.title_visible && luiCanvas.data.title_text) { + luiDrawText(ctx, luiCanvas.data.title_text, 10, 10, w - 20, 24, { + fontSize: 16, + color: "rgba(220,226,235,0.90)", + }); + } + + for (const comp of luiCanvas.components || []) { + drawLUIComponent(ctx, comp, luiCanvas); + } + if (luiCanvas.texture) luiCanvas.texture.needsUpdate = true; +} + +function createLuiCanvasObject(THREE, runtime, canvasData) { + const width = Math.max(1, Math.round(Number(canvasData.width ?? 800))); + const height = Math.max(1, Math.round(Number(canvasData.height ?? 600))); + const ppu = Math.max(1, Number(canvasData.world_pixels_per_unit ?? 280)); + const htmlCanvas = document.createElement("canvas"); + htmlCanvas.width = width; + htmlCanvas.height = height; + const context2d = htmlCanvas.getContext("2d"); + if (!context2d) return null; + + const texture = new THREE.CanvasTexture(htmlCanvas); + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.generateMipmaps = false; + if ("colorSpace" in texture && THREE.SRGBColorSpace) texture.colorSpace = THREE.SRGBColorSpace; + else if ("encoding" in texture && THREE.sRGBEncoding) texture.encoding = THREE.sRGBEncoding; + + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + side: toBoolean(canvasData.world_two_sided, true) ? THREE.DoubleSide : THREE.FrontSide, + depthTest: toBoolean(canvasData.world_depth_test, true), + depthWrite: false, + toneMapped: false, + }); + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(width / ppu, height / ppu), material); + mesh.name = `${canvasData.name || canvasData.id || "LUI Canvas"} Mesh`; + mesh.userData.luiCanvasId = canvasData.id || ""; + + const group = new THREE.Group(); + group.name = canvasData.name || canvasData.id || "LUI Canvas"; + group.visible = toBoolean(canvasData.visible, true); + group.add(mesh); + if (Array.isArray(canvasData.matrix_local_row_major) && canvasData.matrix_local_row_major.length === 16) { + const converted = convertNodeMatrix( + THREE, + canvasData.matrix_local_row_major, + runtime.basis, + runtime.basisInv, + runtime.matrixConvention, + ); + group.matrixAutoUpdate = false; + group.matrix.copy(converted); + group.matrix.decompose(group.position, group.quaternion, group.scale); + } + + const components = (Array.isArray(canvasData.components) ? canvasData.components : []) + .map((comp) => ({ ...comp })) + .sort((a, b) => { + const za = Number(a.sort ?? 1) + Number(a.z_offset ?? 0); + const zb = Number(b.sort ?? 1) + Number(b.z_offset ?? 0); + if (za !== zb) return za - zb; + return Number(a.index ?? 0) - Number(b.index ?? 0); + }); + + const luiCanvas = { + data: canvasData, + group, + mesh, + texture, + htmlCanvas, + context2d, + pixelWidth: width, + pixelHeight: height, + components, + hoveredIndex: -1, + pressedIndex: -1, + focusedIndex: -1, + activeSliderIndex: -1, + interactive: toBoolean(canvasData.world_interactive, true), + }; + + for (const comp of components) { + if (comp.texture_uri && String(comp.type || "").toLowerCase() === "image") { + const img = new Image(); + img.onload = () => drawLuiCanvas(luiCanvas); + img.onerror = () => drawLuiCanvas(luiCanvas); + img.src = resolveAssetUrl(comp.texture_uri); + comp.imageElement = img; + } + } + + drawLuiCanvas(luiCanvas); + return luiCanvas; +} + +function hitTestLuiComponent(luiCanvas, px, py) { + const matches = []; + for (const comp of luiCanvas.components || []) { + const x = Number(comp.left ?? 0); + const y = Number(comp.top ?? 0); + const w = Math.max(1, Number(comp.width ?? 100)); + const h = Math.max(1, Number(comp.height ?? 30)); + if (px >= x && px <= x + w && py >= y && py <= y + h) matches.push(comp); + } + matches.sort((a, b) => { + const za = Number(a.sort ?? 1) + Number(a.z_offset ?? 0); + const zb = Number(b.sort ?? 1) + Number(b.z_offset ?? 0); + if (za !== zb) return zb - za; + return Number(b.index ?? 0) - Number(a.index ?? 0); + }); + return matches[0] || null; +} + +function setLuiSliderFromHit(luiCanvas, comp, px) { + if (!comp) return; + const x = Number(comp.left ?? 0); + const w = Math.max(1, Number(comp.width ?? 100)); + const minV = Number(comp.min_value ?? 0); + const maxV = Number(comp.max_value ?? 100); + const ratio = Math.max(0, Math.min(1, (px - x) / w)); + comp.value = minV + ratio * (maxV - minV); + drawLuiCanvas(luiCanvas); +} + +function createWorldLuiRuntime(THREE, runtime) { + const luiData = runtime?.manifest?.lui; + const canvasDataList = Array.isArray(luiData?.canvases) ? luiData.canvases : []; + const canvases = []; + const meshToCanvas = new Map(); + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + const active = { canvas: null, component: null, x: 0, y: 0 }; + const dom = runtime.renderer.domElement; + + for (const canvasData of canvasDataList) { + if (!canvasData || String(canvasData.render_mode || "").toLowerCase() !== "world") continue; + const luiCanvas = createLuiCanvasObject(THREE, runtime, canvasData); + if (!luiCanvas) continue; + runtime.scene.add(luiCanvas.group); + canvases.push(luiCanvas); + meshToCanvas.set(luiCanvas.mesh, luiCanvas); + } + + const updatePointer = (event) => { + const rect = dom.getBoundingClientRect(); + pointer.x = ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1; + pointer.y = -(((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 - 1); + }; + + const pick = (event) => { + if (canvases.length === 0) return null; + updatePointer(event); + raycaster.setFromCamera(pointer, runtime.camera); + const meshes = canvases.filter((c) => c.interactive && c.group.visible).map((c) => c.mesh); + const hits = raycaster.intersectObjects(meshes, false); + if (!hits.length) return null; + const hit = hits[0]; + const luiCanvas = meshToCanvas.get(hit.object); + if (!luiCanvas || !hit.uv) return null; + const px = hit.uv.x * luiCanvas.pixelWidth; + const py = (1 - hit.uv.y) * luiCanvas.pixelHeight; + const component = hitTestLuiComponent(luiCanvas, px, py); + return { canvas: luiCanvas, component, x: px, y: py }; + }; + + const emitLuiEvent = (eventName, hit, component) => emitRuntimeEvent(runtime, "luiEvent", { + event: eventName, + canvas: { id: hit?.canvas?.data?.id || "", name: hit?.canvas?.data?.name || "" }, + component: component ? { + index: component.index, + type: component.type || "", + name: component.name || "", + value: component.value, + checked: !!component.checked, + selectedOptionId: component.selected_option_id, + } : null, + pointer: hit ? { x: hit.x, y: hit.y } : null, + }); + + const clearFocusedInput = () => { + let changed = false; + for (const luiCanvas of canvases) { + if (luiCanvas.focusedIndex >= 0) { + luiCanvas.focusedIndex = -1; + drawLuiCanvas(luiCanvas); + changed = true; + } + } + return changed; + }; + + dom.addEventListener("pointermove", (event) => { + const hit = pick(event); + if (active.canvas && active.canvas.activeSliderIndex >= 0 && event.buttons) { + const comp = active.canvas.components.find((c) => c.index === active.canvas.activeSliderIndex); + setLuiSliderFromHit(active.canvas, comp, hit ? hit.x : active.x); + return; + } + const nextCanvas = hit?.canvas || null; + const nextIndex = hit?.component?.index ?? -1; + for (const luiCanvas of canvases) { + const wanted = luiCanvas === nextCanvas ? nextIndex : -1; + if (luiCanvas.hoveredIndex !== wanted) { + luiCanvas.hoveredIndex = wanted; + drawLuiCanvas(luiCanvas); + } + } + }); + + const onPointerDown = (event) => { + const hit = pick(event); + if (!hit) { + clearFocusedInput(); + return; + } + event.preventDefault(); + event.stopPropagation(); + if (typeof event.stopImmediatePropagation === "function") { + event.stopImmediatePropagation(); + } + try { + if (event.pointerId !== undefined && typeof dom.setPointerCapture === "function") { + dom.setPointerCapture(event.pointerId); + } + } catch (err) { + // Pointer capture is best-effort; interaction still works without it. + } + if (runtime.controls) runtime.controls.enabled = false; + active.canvas = hit.canvas; + active.component = hit.component; + active.x = hit.x; + active.y = hit.y; + const comp = hit.component; + hit.canvas.pressedIndex = comp?.index ?? -1; + hit.canvas.focusedIndex = String(comp?.type || "").toLowerCase() === "inputfield" ? comp.index : -1; + hit.canvas.activeSliderIndex = String(comp?.type || "").toLowerCase() === "slider" ? comp.index : -1; + if (hit.canvas.activeSliderIndex >= 0) setLuiSliderFromHit(hit.canvas, comp, hit.x); + drawLuiCanvas(hit.canvas); + emitLuiEvent("mousedown", hit, comp); + }; + dom.addEventListener("pointerdown", onPointerDown, { capture: true }); + + const finishPointer = (event) => { + if (!active.canvas) return; + const startCanvas = active.canvas; + const startComponent = active.component; + const hit = pick(event); + const releaseSame = hit && hit.canvas === startCanvas && hit.component === startComponent; + const type = String(startComponent?.type || "").toLowerCase(); + if (releaseSame && startComponent) { + if (type === "checkbox") { + startComponent.checked = !toBoolean(startComponent.checked, false); + emitLuiEvent("changed", hit, startComponent); + } else if (type === "selectbox") { + const options = Array.isArray(startComponent.options) ? startComponent.options : []; + if (options.length > 0) { + const ids = options.map((item) => item?.id); + const cur = ids.findIndex((id) => String(id) === String(startComponent.selected_option_id)); + startComponent.selected_option_id = ids[(cur + 1 + ids.length) % ids.length]; + } + emitLuiEvent("changed", hit, startComponent); + } else if (type === "slider") { + emitLuiEvent("changed", hit || active, startComponent); + } else { + emitLuiEvent("click", hit, startComponent); + } + } + startCanvas.pressedIndex = -1; + startCanvas.activeSliderIndex = -1; + drawLuiCanvas(startCanvas); + active.canvas = null; + active.component = null; + try { + if (event?.pointerId !== undefined && typeof dom.releasePointerCapture === "function") { + dom.releasePointerCapture(event.pointerId); + } + } catch (err) { + // The pointer may already be released by the browser. + } + if (runtime.controls) runtime.controls.enabled = true; + }; + dom.addEventListener("pointerup", finishPointer); + dom.addEventListener("pointercancel", finishPointer); + + window.addEventListener("keydown", (event) => { + const focusedCanvas = canvases.find((c) => c.focusedIndex >= 0); + if (!focusedCanvas) return; + const comp = focusedCanvas.components.find((c) => c.index === focusedCanvas.focusedIndex); + if (!comp || String(comp.type || "").toLowerCase() !== "inputfield") return; + if (event.key === "Backspace") { + comp.value = String(comp.value ?? comp.text ?? "").slice(0, -1); + } else if (event.key === "Enter" || event.key === "Escape") { + focusedCanvas.focusedIndex = -1; + } else if (event.key.length === 1) { + comp.value = String(comp.value ?? comp.text ?? "") + event.key; + } else { + return; + } + event.preventDefault(); + event.stopPropagation(); + drawLuiCanvas(focusedCanvas); + emitRuntimeEvent(runtime, "luiEvent", { + event: "changed", + canvas: { id: focusedCanvas.data.id || "", name: focusedCanvas.data.name || "" }, + component: { index: comp.index, type: comp.type || "", name: comp.name || "", value: comp.value }, + pointer: null, + }); + }); + + return { + canvases, + count: canvases.length, + redraw() { + for (const luiCanvas of canvases) drawLuiCanvas(luiCanvas); + }, + }; +} + function normalizeColorInput(color, fallback = [1, 1, 1]) { const out = toColorArray(color, fallback); if (out.some((v) => Number(v) > 1.0)) { @@ -2772,6 +3282,16 @@ function buildSceneInfo(runtime) { nodeCount: runtime?.nodeOrder?.length || 0, unsupportedScriptCount: runtime?.unsupportedScriptCount || 0, webSupportedScripts: listWebSupportedScripts(), + lui: { + canvasCount: runtime?.lui?.count || 0, + canvases: (runtime?.lui?.canvases || []).map((canvas) => ({ + id: canvas?.data?.id || "", + name: canvas?.data?.name || "", + visible: !!canvas?.group?.visible, + interactive: !!canvas?.interactive, + componentCount: canvas?.components?.length || 0, + })), + }, nodes: (runtime?.nodeOrder || []).map((entry) => buildNodeSummary(entry)), }; } @@ -3381,6 +3901,7 @@ async function bootstrap() { unsupportedScriptCount: 0, isReady: false, bridge: null, + lui: null, }; activeViewerRuntime = runtime; @@ -3420,6 +3941,8 @@ async function bootstrap() { scene.add(dirLight.target); } + runtime.lui = createWorldLuiRuntime(THREE, runtime); + const nodeMap = new Map(); const pendingModelLoads = []; const gltfLoader = new GLTFLoader(); @@ -3594,7 +4117,7 @@ async function bootstrap() { emitRuntimeEvent(runtime, "ready", readyInfo); setStatus( - `Scene ready. Nodes: ${sceneNodes.length}, Animations: ${runtime.animationControllers.length}, Scripts: ${scriptStates.length}${runtime.unsupportedScriptCount > 0 ? ` (unsupported: ${runtime.unsupportedScriptCount})` : ""}, Skybox: ${skyboxState.enabled ? "on" : "off"}, ToneMap: ${renderProfile.toneMappingEnabled ? "on" : "off"}, Shadows: ${renderProfile.shadowEnabled ? "on" : "off"}, Fog: ${renderProfile.fogApplied ? "on" : "off"}, Bloom: ${bloomSetup.enabled ? "on" : "off"}.\nUse mouse to orbit, wheel to zoom.`, + `Scene ready. Nodes: ${sceneNodes.length}, LUI3D: ${runtime.lui?.count || 0}, Animations: ${runtime.animationControllers.length}, Scripts: ${scriptStates.length}${runtime.unsupportedScriptCount > 0 ? ` (unsupported: ${runtime.unsupportedScriptCount})` : ""}, Skybox: ${skyboxState.enabled ? "on" : "off"}, ToneMap: ${renderProfile.toneMappingEnabled ? "on" : "off"}, Shadows: ${renderProfile.shadowEnabled ? "on" : "off"}, Fog: ${renderProfile.fogApplied ? "on" : "off"}, Bloom: ${bloomSetup.enabled ? "on" : "off"}.\nUse mouse to orbit, wheel to zoom.`, "ok", );