diff --git a/addon.py b/addon.py index 2941627..d84a1d9 100644 --- a/addon.py +++ b/addon.py @@ -1,4 +1,5 @@ import bpy +import mathutils import json import threading import socket @@ -162,6 +163,7 @@ class BlenderMCPServer: "execute_code": self.execute_code, "set_material": self.set_material, "get_polyhaven_status": self.get_polyhaven_status, + "get_hyper3d_status": self.get_hyper3d_status, } # Add Polyhaven handlers only if enabled @@ -174,6 +176,15 @@ class BlenderMCPServer: } handlers.update(polyhaven_handlers) + # Add Hyper3d handlers only if enabled + if bpy.context.scene.blendermcp_use_hyper3d: + polyhaven_handlers = { + "create_rodin_job": self.create_rodin_job, + "poll_rodin_job_status": self.poll_rodin_job_status, + "import_generated_asset": self.import_generated_asset, + } + handlers.update(polyhaven_handlers) + handler = handlers.get(cmd_type) if handler: try: @@ -231,6 +242,26 @@ class BlenderMCPServer: traceback.print_exc() return {"error": str(e)} + @staticmethod + def _get_aabb(obj): + """ Returns the world-space axis-aligned bounding box (AABB) of an object. """ + if obj.type != 'MESH': + raise TypeError("Object must be a mesh") + + # Get the bounding box corners in local space + local_bbox_corners = [mathutils.Vector(corner) for corner in obj.bound_box] + + # Convert to world coordinates + world_bbox_corners = [obj.matrix_world @ corner for corner in local_bbox_corners] + + # Compute axis-aligned min/max coordinates + min_corner = mathutils.Vector(map(min, zip(*world_bbox_corners))) + max_corner = mathutils.Vector(map(max, zip(*world_bbox_corners))) + + return [ + [*min_corner], [*max_corner] + ] + def create_object(self, type="CUBE", name=None, location=(0, 0, 0), rotation=(0, 0, 0), scale=(1, 1, 1), align="WORLD", major_segments=48, minor_segments=12, mode="MAJOR_MINOR", major_radius=1.0, minor_radius=0.25, abso_major_rad=1.25, abso_minor_rad=0.75, generate_uvs=True): @@ -272,13 +303,14 @@ class BlenderMCPServer: raise ValueError(f"Unsupported object type: {type}") # Get the created object - obj = bpy.context.active_object + bpy.context.view_layer.update() + obj = bpy.context.view_layer.objects.active # Rename the object if a name is provided if name: obj.name = name - return { + result = { "name": obj.name, "type": obj.type, "location": [obj.location.x, obj.location.y, obj.location.z], @@ -286,6 +318,11 @@ class BlenderMCPServer: "scale": [obj.scale.x, obj.scale.y, obj.scale.z], } + if obj.type == "MESH": + bounding_box = self._get_aabb(obj) + result["world_bounding_box"] = bounding_box + + return result def modify_object(self, name, location=None, rotation=None, scale=None, visible=None): """Modify an existing object in the scene""" @@ -308,7 +345,7 @@ class BlenderMCPServer: obj.hide_viewport = not visible obj.hide_render = not visible - return { + result = { "name": obj.name, "type": obj.type, "location": [obj.location.x, obj.location.y, obj.location.z], @@ -316,7 +353,13 @@ class BlenderMCPServer: "scale": [obj.scale.x, obj.scale.y, obj.scale.z], "visible": obj.visible_get(), } - + + if obj.type == "MESH": + bounding_box = self._get_aabb(obj) + result["world_bounding_box"] = bounding_box + + return result + def delete_object(self, name): """Delete an object from the scene""" obj = bpy.data.objects.get(name) @@ -327,9 +370,8 @@ class BlenderMCPServer: obj_name = obj.name # Select and delete the object - bpy.ops.object.select_all(action='DESELECT') - obj.select_set(True) - bpy.ops.object.delete() + if obj: + bpy.data.objects.remove(obj, do_unlink=True) return {"deleted": obj_name} @@ -349,6 +391,10 @@ class BlenderMCPServer: "visible": obj.visible_get(), "materials": [], } + + if obj.type == "MESH": + bounding_box = self._get_aabb(obj) + obj_info["world_bounding_box"] = bounding_box # Add material slots for slot in obj.material_slots: @@ -1168,6 +1214,322 @@ class BlenderMCPServer: 3. Restart the connection to Claude""" } + #region Hyper3D + def get_hyper3d_status(self): + """Get the current status of Hyper3D Rodin integration""" + enabled = bpy.context.scene.blendermcp_use_hyper3d + if enabled: + if not bpy.context.scene.blendermcp_hyper3d_api_key: + return { + "enabled": False, + "message": """Hyper3D Rodin integration is currently enabled, but API key is not given. To enable it: + 1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden) + 2. Keep the 'Use Hyper3D Rodin 3D model generation' checkbox checked + 3. Choose the right plaform and fill in the API Key + 4. Restart the connection to Claude""" + } + mode = bpy.context.scene.blendermcp_hyper3d_mode + message = f"Hyper3D Rodin integration is enabled and ready to use. Mode: {mode}" + return { + "enabled": True, + "message": message + } + else: + return { + "enabled": False, + "message": """Hyper3D Rodin integration is currently disabled. To enable it: + 1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden) + 2. Check the 'Use Hyper3D Rodin 3D model generation' checkbox + 3. Restart the connection to Claude""" + } + + def create_rodin_job(self, *args, **kwargs): + match bpy.context.scene.blendermcp_hyper3d_mode: + case "MAIN_SITE": + return self.create_rodin_job_main_site(*args, **kwargs) + case "FAL_AI": + return self.create_rodin_job_fal_ai(*args, **kwargs) + case _: + return f"Error: Unknown Hyper3D Rodin mode!" + + def create_rodin_job_main_site( + self, + text_prompt: str=None, + images: list[tuple[str, str]]=None, + bbox_condition=None + ): + try: + if images is None: + images = [] + """Call Rodin API, get the job uuid and subscription key""" + files = [ + *[("images", (f"{i:04d}{img_suffix}", img)) for i, (img_suffix, img) in enumerate(images)], + ("tier", (None, "Sketch")), + ("mesh_mode", (None, "Raw")), + ] + if text_prompt: + files.append(("prompt", (None, text_prompt))) + if bbox_condition: + files.append(("bbox_condition", (None, json.dumps(bbox_condition)))) + response = requests.post( + "https://hyperhuman.deemos.com/api/v2/rodin", + headers={ + "Authorization": f"Bearer {bpy.context.scene.blendermcp_hyper3d_api_key}", + }, + files=files + ) + data = response.json() + return data + except Exception as e: + return {"error": str(e)} + + def create_rodin_job_fal_ai( + self, + text_prompt: str=None, + images: list[tuple[str, str]]=None, + bbox_condition=None + ): + try: + req_data = { + "tier": "Sketch", + } + if images: + req_data["input_image_urls"] = images + if text_prompt: + req_data["prompt"] = text_prompt + if bbox_condition: + req_data["bbox_condition"] = bbox_condition + response = requests.post( + "https://queue.fal.run/fal-ai/hyper3d/rodin", + headers={ + "Authorization": f"Key {bpy.context.scene.blendermcp_hyper3d_api_key}", + "Content-Type": "application/json", + }, + json=req_data + ) + data = response.json() + return data + except Exception as e: + return {"error": str(e)} + + def poll_rodin_job_status(self, *args, **kwargs): + match bpy.context.scene.blendermcp_hyper3d_mode: + case "MAIN_SITE": + return self.poll_rodin_job_status_main_site(*args, **kwargs) + case "FAL_AI": + return self.poll_rodin_job_status_fal_ai(*args, **kwargs) + case _: + return f"Error: Unknown Hyper3D Rodin mode!" + + def poll_rodin_job_status_main_site(self, subscription_key: str): + """Call the job status API to get the job status""" + response = requests.post( + "https://hyperhuman.deemos.com/api/v2/status", + headers={ + "Authorization": f"Bearer {bpy.context.scene.blendermcp_hyper3d_api_key}", + }, + json={ + "subscription_key": subscription_key, + }, + ) + data = response.json() + return { + "status_list": [i["status"] for i in data["jobs"]] + } + + def poll_rodin_job_status_fal_ai(self, request_id: str): + """Call the job status API to get the job status""" + response = requests.get( + f"https://queue.fal.run/fal-ai/hyper3d/requests/{request_id}/status", + headers={ + "Authorization": f"KEY {bpy.context.scene.blendermcp_hyper3d_api_key}", + }, + ) + data = response.json() + return data + + @staticmethod + def _clean_imported_glb(filepath, mesh_name=None): + # Import the GLB file + bpy.ops.import_scene.gltf(filepath=filepath) + + # Ensure the context is updated + bpy.context.view_layer.update() + + # Get all imported objects + imported_objects = [obj for obj in bpy.context.view_layer.objects if obj.select_get()] + + if not imported_objects: + print("Error: No objects were imported.") + return + + # Identify the mesh object + mesh_obj = None + + if len(imported_objects) == 1 and imported_objects[0].type == 'MESH': + mesh_obj = imported_objects[0] + print("Single mesh imported, no cleanup needed.") + else: + parent_obj = imported_objects[0] + if parent_obj.type == 'EMPTY' and len(parent_obj.children) == 1: + potential_mesh = parent_obj.children[0] + if potential_mesh.type == 'MESH': + print("GLB structure confirmed: Empty node with one mesh child.") + + # Unparent the mesh from the empty node + potential_mesh.parent = None + + # Remove the empty node + bpy.data.objects.remove(parent_obj) + print("Removed empty node, keeping only the mesh.") + + mesh_obj = potential_mesh + else: + print("Error: Child is not a mesh object.") + return + else: + print("Error: Expected an empty node with one mesh child or a single mesh object.") + return + + # Rename the mesh if needed + if mesh_obj and mesh_name: + mesh_obj.name = mesh_name + print(f"Mesh renamed to: {mesh_name}") + + return mesh_obj + + def import_generated_asset(self, *args, **kwargs): + match bpy.context.scene.blendermcp_hyper3d_mode: + case "MAIN_SITE": + return self.import_generated_asset_main_site(*args, **kwargs) + case "FAL_AI": + return self.import_generated_asset_fal_ai(*args, **kwargs) + case _: + return f"Error: Unknown Hyper3D Rodin mode!" + + def import_generated_asset_main_site(self, task_uuid: str, name: str): + """Fetch the generated asset, import into blender""" + response = requests.post( + "https://hyperhuman.deemos.com/api/v2/download", + headers={ + "Authorization": f"Bearer {bpy.context.scene.blendermcp_hyper3d_api_key}", + }, + json={ + 'task_uuid': task_uuid + } + ) + data_ = response.json() + temp_file = None + for i in data_["list"]: + if i["name"].endswith(".glb"): + temp_file = tempfile.NamedTemporaryFile( + delete=False, + prefix=task_uuid, + suffix=".glb", + ) + + try: + # Download the content + response = requests.get(i["url"], stream=True) + response.raise_for_status() # Raise an exception for HTTP errors + + # Write the content to the temporary file + for chunk in response.iter_content(chunk_size=8192): + temp_file.write(chunk) + + # Close the file + temp_file.close() + + except Exception as e: + # Clean up the file if there's an error + temp_file.close() + os.unlink(temp_file.name) + return {"succeed": False, "error": str(e)} + + break + + try: + obj = self._clean_imported_glb( + filepath=temp_file.name, + mesh_name=name + ) + result = { + "name": obj.name, + "type": obj.type, + "location": [obj.location.x, obj.location.y, obj.location.z], + "rotation": [obj.rotation_euler.x, obj.rotation_euler.y, obj.rotation_euler.z], + "scale": [obj.scale.x, obj.scale.y, obj.scale.z], + } + + if obj.type == "MESH": + bounding_box = self._get_aabb(obj) + result["world_bounding_box"] = bounding_box + + return { + "succeed": True, **result + } + except Exception as e: + return {"succeed": False, "error": str(e)} + + def import_generated_asset_fal_ai(self, request_id: str, name: str): + """Fetch the generated asset, import into blender""" + response = requests.get( + f"https://queue.fal.run/fal-ai/hyper3d/requests/{request_id}", + headers={ + "Authorization": f"Key {bpy.context.scene.blendermcp_hyper3d_api_key}", + } + ) + data_ = response.json() + temp_file = None + + temp_file = tempfile.NamedTemporaryFile( + delete=False, + prefix=request_id, + suffix=".glb", + ) + + try: + # Download the content + response = requests.get(data_["model_mesh"]["url"], stream=True) + response.raise_for_status() # Raise an exception for HTTP errors + + # Write the content to the temporary file + for chunk in response.iter_content(chunk_size=8192): + temp_file.write(chunk) + + # Close the file + temp_file.close() + + except Exception as e: + # Clean up the file if there's an error + temp_file.close() + os.unlink(temp_file.name) + return {"succeed": False, "error": str(e)} + + try: + obj = self._clean_imported_glb( + filepath=temp_file.name, + mesh_name=name + ) + result = { + "name": obj.name, + "type": obj.type, + "location": [obj.location.x, obj.location.y, obj.location.z], + "rotation": [obj.rotation_euler.x, obj.rotation_euler.y, obj.rotation_euler.z], + "scale": [obj.scale.x, obj.scale.y, obj.scale.z], + } + + if obj.type == "MESH": + bounding_box = self._get_aabb(obj) + result["world_bounding_box"] = bounding_box + + return { + "succeed": True, **result + } + except Exception as e: + return {"succeed": False, "error": str(e)} + #endregion + # Blender UI Panel class BLENDERMCP_PT_Panel(bpy.types.Panel): bl_label = "Blender MCP" @@ -1182,6 +1544,11 @@ class BLENDERMCP_PT_Panel(bpy.types.Panel): layout.prop(scene, "blendermcp_port") layout.prop(scene, "blendermcp_use_polyhaven", text="Use assets from Poly Haven") + + layout.prop(scene, "blendermcp_use_hyper3d", text="Use Hyper3D Rodin 3D model generation") + if scene.blendermcp_use_hyper3d: + layout.prop(scene, "blendermcp_hyper3d_mode", text="Rodin Mode") + layout.prop(scene, "blendermcp_hyper3d_api_key", text="API Key") if not scene.blendermcp_server_running: layout.operator("blendermcp.start_server", text="Start MCP Server") @@ -1246,6 +1613,28 @@ def register(): description="Enable Poly Haven asset integration", default=False ) + + bpy.types.Scene.blendermcp_use_hyper3d = bpy.props.BoolProperty( + name="Use Hyper3D Rodin", + description="Enable Hyper3D Rodin generatino integration", + default=False + ) + + bpy.types.Scene.blendermcp_hyper3d_mode = bpy.props.EnumProperty( + name="Rodin Mode", + description="Choose the platform used to call Rodin APIs", + items=[ + ("MAIN_SITE", "hyper3d.ai", "hyper3d.ai"), + ("FAL_AI", "fal.ai", "fal.ai"), + ], + default="MAIN_SITE" + ) + + bpy.types.Scene.blendermcp_hyper3d_api_key = bpy.props.StringProperty( + name="Hyper3D API Key", + description="API Key provided by Hyper3D", + default="" + ) bpy.utils.register_class(BLENDERMCP_PT_Panel) bpy.utils.register_class(BLENDERMCP_OT_StartServer) @@ -1266,7 +1655,10 @@ def unregister(): del bpy.types.Scene.blendermcp_port del bpy.types.Scene.blendermcp_server_running del bpy.types.Scene.blendermcp_use_polyhaven - + del bpy.types.Scene.blendermcp_use_hyper3d + del bpy.types.Scene.blendermcp_hyper3d_mode + del bpy.types.Scene.blendermcp_hyper3d_api_key + print("BlenderMCP addon unregistered") if __name__ == "__main__": diff --git a/src/blender_mcp/server.py b/src/blender_mcp/server.py index df7fcb3..3c52609 100644 --- a/src/blender_mcp/server.py +++ b/src/blender_mcp/server.py @@ -7,6 +7,10 @@ import logging from dataclasses import dataclass from contextlib import asynccontextmanager from typing import AsyncIterator, Dict, Any, List +import os +from pathlib import Path +import base64 +from urllib.parse import urlparse # Configure logging logging.basicConfig(level=logging.INFO, @@ -667,27 +671,247 @@ def get_polyhaven_status(ctx: Context) -> str: logger.error(f"Error checking PolyHaven status: {str(e)}") return f"Error checking PolyHaven status: {str(e)}" +@mcp.tool() +def get_hyper3d_status(ctx: Context) -> str: + """ + Check if Hyper3D Rodin integration is enabled in Blender. + Returns a message indicating whether Hyper3D Rodin features are available. + """ + try: + blender = get_blender_connection() + result = blender.send_command("get_hyper3d_status") + enabled = result.get("enabled", False) + message = result.get("message", "") + if enabled: + message += "" + return message + except Exception as e: + logger.error(f"Error checking Hyper3D status: {str(e)}") + return f"Error checking Hyper3D status: {str(e)}" + +@mcp.tool() +def generate_hyper3d_model_via_text( + ctx: Context, + text_prompt: str, + bbox_condition: list[float]=None +) -> str: + """ + Generate 3D asset using Hyper3D by giving description of the desired asset, and import the asset into Blender. + The 3D asset has built-in materials. + The generated model has a normalized size, so re-scaling after generation can be useful. + + Parameters: + - text_prompt: A short description of the desired model in **English**. + - bbox_condition: Optional. If given, it has to be a list of floats of length 3. Controls the ratio between [Length, Width, Height] of the model. The final size of the model is normalized. + + Returns a message indicating success or failure. + """ + try: + blender = get_blender_connection() + result = blender.send_command("create_rodin_job", { + "text_prompt": text_prompt, + "images": None, + "bbox_condition": bbox_condition, + }) + succeed = result.get("submit_time", False) + if succeed: + return json.dumps({ + "task_uuid": result["uuid"], + "subscription_key": result["jobs"]["subscription_key"], + }) + else: + return json.dumps(result) + except Exception as e: + logger.error(f"Error generating Hyper3D task: {str(e)}") + return f"Error generating Hyper3D task: {str(e)}" + return f"Placeholder, under development, not implemented yet." + +@mcp.tool() +def generate_hyper3d_model_via_images( + ctx: Context, + input_image_paths: list[str]=None, + input_image_urls: list[str]=None, + bbox_condition: list[float]=None +) -> str: + """ + Generate 3D asset using Hyper3D by giving images of the wanted asset, and import the generated asset into Blender. + The 3D asset has built-in materials. + The generated model has a normalized size, so re-scaling after generation can be useful. + + Parameters: + - input_image_paths: The **absolute** paths of input images. Even if only one image is provided, wrap it into a list. Required if Hyper3D Rodin in MAIN_SITE mode. + - input_image_urls: The URLs of input images. Even if only one image is provided, wrap it into a list. Required if Hyper3D Rodin in FAL_AI mode. + - bbox_condition: Optional. If given, it has to be a list of ints of length 3. Controls the ratio between [Length, Width, Height] of the model. The final size of the model is normalized. + + Only one of {input_image_paths, input_image_urls} should be given at a time, depending on the Hyper3D Rodin's current mode. + Returns a message indicating success or failure. + """ + if input_image_paths is not None and input_image_urls is not None: + return f"Error: Conflict parameters given!" + if input_image_paths is None and input_image_urls is None: + return f"Error: No image given!" + if input_image_paths is not None: + if not all(os.path.exists(i) for i in input_image_paths): + return "Error: not all image paths are valid!" + images = [] + for path in input_image_paths: + with open(path, "rb") as f: + images.append( + (Path(path).suffix, base64.b64encode(f.read()).decode("ascii")) + ) + elif input_image_urls is not None: + if not all(urlparse(i) for i in input_image_paths): + return "Error: not all image URLs are valid!" + images = input_image_urls.copy() + try: + blender = get_blender_connection() + result = blender.send_command("create_rodin_job", { + "text_prompt": None, + "images": images, + "bbox_condition": bbox_condition, + }) + succeed = result.get("submit_time", False) + if succeed: + return json.dumps({ + "task_uuid": result["uuid"], + "subscription_key": result["jobs"]["subscription_key"], + }) + else: + return json.dumps(result) + except Exception as e: + logger.error(f"Error generating Hyper3D task: {str(e)}") + return f"Error generating Hyper3D task: {str(e)}" + +@mcp.tool() +def poll_rodin_job_status( + ctx: Context, + subscription_key: str=None, + request_id: str=None, +): + """ + Check if the Hyper3D Rodin generation task is completed. + + For Hyper3D Rodin mode MAIN_SITE: + Parameters: + - subscription_key: The subscription_key given in the generate model step. + + Returns a list of status. The task is done if all status are "Done". + If "Failed" showed up, the generating process failed. + This is a polling API, so only proceed if the status are finally determined ("Done" or "Canceled"). + + For Hyper3D Rodin mode FAL_AI: + Parameters: + - request_id: The request_id given in the generate model step. + + Returns the generation task status. The task is done if status is "COMPLETED". + The task is in progress if status is "IN_PROGRESS". + If status other than "COMPLETED", "IN_PROGRESS", "IN_QUEUE" showed up, the generating process might be failed. + This is a polling API, so only proceed if the status are finally determined ("COMPLETED" or some failed state). + """ + try: + blender = get_blender_connection() + kwargs = {} + if subscription_key: + kwargs = { + "subscription_key": subscription_key, + } + elif request_id: + kwargs = { + "request_id": request_id, + } + result = blender.send_command("poll_rodin_job_status", kwargs) + return result + except Exception as e: + logger.error(f"Error generating Hyper3D task: {str(e)}") + return f"Error generating Hyper3D task: {str(e)}" + +@mcp.tool() +def import_generated_asset( + ctx: Context, + name: str, + task_uuid: str=None, + request_id: str=None, +): + """ + Import the asset generated by Hyper3D Rodin after the generation task is completed. + + Parameters: + - name: The name of the object in scene + - task_uuid: For Hyper3D Rodin mode MAIN_SITE: The task_uuid given in the generate model step. + - request_id: For Hyper3D Rodin mode FAL_AI: The request_id given in the generate model step. + + Only give one of {task_uuid, request_id} based on the Hyper3D Rodin Mode! + Return if the asset has been imported successfully. + """ + try: + blender = get_blender_connection() + kwargs = { + "name": name + } + if task_uuid: + kwargs["task_uuid"] = task_uuid + elif request_id: + kwargs["request_id"] = request_id + result = blender.send_command("import_generated_asset", kwargs) + return result + except Exception as e: + logger.error(f"Error generating Hyper3D task: {str(e)}") + return f"Error generating Hyper3D task: {str(e)}" + @mcp.prompt() def asset_creation_strategy() -> str: """Defines the preferred strategy for creating assets in Blender""" - return """When creating 3D content in Blender, always start by checking if PolyHaven is available: + return """When creating 3D content in Blender, always start by checking if integrations are available: 0. Before anything, always check the scene from get_scene_info() - 1. First use get_polyhaven_status() to verify if PolyHaven integration is enabled. + 1. First use the following tools to verify if the following integrations are enabled: + 1. PolyHaven + Use get_polyhaven_status() to verify its status + If PolyHaven is enabled: + - For objects/models: Use download_polyhaven_asset() with asset_type="models" + - For materials/textures: Use download_polyhaven_asset() with asset_type="textures" + - For environment lighting: Use download_polyhaven_asset() with asset_type="hdris" + 2. Hyper3D(Rodin) + Hyper3D Rodin is good at generating 3D models for single item. + So don't try to: + 1. Generate the whole scene with one shot + 2. Generate ground using Rodin + 3. Generate parts of the items separately and put them together afterwards - 2. If PolyHaven is enabled: - - For objects/models: Use download_polyhaven_asset() with asset_type="models" - - For materials/textures: Use download_polyhaven_asset() with asset_type="textures" - - For environment lighting: Use download_polyhaven_asset() with asset_type="hdris" + Use get_hyper3d_status() to verify its status + If Hyper3D is enabled: + - For objects/models, do the following steps: + 1. Create the model generation task + - Use generate_hyper3d_model_via_images() if image(s) is/are given + - Use generate_hyper3d_model_via_text() if generating 3D asset using text prompt + 2. Poll the status + - Use poll_rodin_job_status() to check if the generation task has completed or failed + 3. Import the asset + - Use import_generated_asset() to import the generated GLB model the asset + 4. After importing the asset, ALWAYS check the world_bounding_box of the imported mesh, and adjust the mesh's location and size + Adjust the imported mesh's location, scale, rotation, so that the mesh is on the right spot. - 3. If PolyHaven is disabled or when falling back to basic tools: + You can reuse assets previous generated by repeating step 3 and 4 using the previous task_uuid without creating another generation task. + + 2. If all integrations are disabled or when falling back to basic tools: - create_object() for basic primitives (CUBE, SPHERE, CYLINDER, etc.) - set_material() for basic colors and materials + + 3. When including an object into scene, ALWAYS make sure that the name of the object is meanful. + + 4. Always check the world_bounding_box for each item so that: + - Ensure that all objects that should not be clipping are not clipping. + - Items have right spatial relationship. + + 5. After giving the tool location/scale/rotation information (via create_object() and modify_object()), + double check the related object's location, scale, rotation, and world_bounding_box using get_object_info(), + so that the object is in the desired location. Only fall back to basic creation tools when: - - PolyHaven is disabled + - PolyHaven and Hyper3D are disabled - A simple primitive is explicitly requested - No suitable PolyHaven asset exists + - Hyper3D Rodin failed to generate the desired asset - The task specifically requires a basic material/color """