|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bl_info = { |
|
"name": "Hunyuan3D-2 Generator", |
|
"author": "Tencent Hunyuan3D", |
|
"version": (1, 0), |
|
"blender": (3, 0, 0), |
|
"location": "View3D > Sidebar > Hunyuan3D-2 3D Generator", |
|
"description": "Generate/Texturing 3D models from text descriptions or images", |
|
"category": "3D View", |
|
} |
|
import base64 |
|
import os |
|
import tempfile |
|
import threading |
|
|
|
import bpy |
|
import requests |
|
from bpy.props import StringProperty, BoolProperty, IntProperty, FloatProperty |
|
|
|
|
|
class Hunyuan3DProperties(bpy.types.PropertyGroup): |
|
prompt: StringProperty( |
|
name="Text Prompt", |
|
description="Describe what you want to generate", |
|
default="" |
|
) |
|
api_url: StringProperty( |
|
name="API URL", |
|
description="URL of the Text-to-3D API service", |
|
default="http://localhost:8080" |
|
) |
|
is_processing: BoolProperty( |
|
name="Processing", |
|
default=False |
|
) |
|
job_id: StringProperty( |
|
name="Job ID", |
|
default="" |
|
) |
|
status_message: StringProperty( |
|
name="Status Message", |
|
default="" |
|
) |
|
|
|
image_path: StringProperty( |
|
name="Image", |
|
description="Select an image to upload", |
|
subtype='FILE_PATH' |
|
) |
|
|
|
octree_resolution: IntProperty( |
|
name="Octree Resolution", |
|
description="Octree resolution for the 3D generation", |
|
default=256, |
|
min=128, |
|
max=512, |
|
) |
|
num_inference_steps: IntProperty( |
|
name="Number of Inference Steps", |
|
description="Number of inference steps for the 3D generation", |
|
default=20, |
|
min=20, |
|
max=50 |
|
) |
|
guidance_scale: FloatProperty( |
|
name="Guidance Scale", |
|
description="Guidance scale for the 3D generation", |
|
default=5.5, |
|
min=1.0, |
|
max=10.0 |
|
) |
|
|
|
texture: BoolProperty( |
|
name="Generate Texture", |
|
description="Whether to generate texture for the 3D model", |
|
default=False |
|
) |
|
|
|
|
|
class Hunyuan3DOperator(bpy.types.Operator): |
|
bl_idname = "object.generate_3d" |
|
bl_label = "Generate 3D Model" |
|
bl_description = "Generate a 3D model from text description, an image or a selected mesh" |
|
|
|
job_id = '' |
|
prompt = "" |
|
api_url = "" |
|
image_path = "" |
|
octree_resolution = 256 |
|
num_inference_steps = 20 |
|
guidance_scale = 5.5 |
|
texture = False |
|
selected_mesh_base64 = "" |
|
selected_mesh = None |
|
|
|
thread = None |
|
task_finished = False |
|
|
|
def modal(self, context, event): |
|
if event.type in {'RIGHTMOUSE', 'ESC'}: |
|
return {'CANCELLED'} |
|
|
|
if self.task_finished: |
|
print("Threaded task completed") |
|
self.task_finished = False |
|
props = context.scene.gen_3d_props |
|
props.is_processing = False |
|
|
|
return {'PASS_THROUGH'} |
|
|
|
def invoke(self, context, event): |
|
|
|
props = context.scene.gen_3d_props |
|
self.prompt = props.prompt |
|
self.api_url = props.api_url |
|
self.image_path = props.image_path |
|
self.octree_resolution = props.octree_resolution |
|
self.num_inference_steps = props.num_inference_steps |
|
self.guidance_scale = props.guidance_scale |
|
self.texture = props.texture |
|
|
|
if self.prompt == "" and self.image_path == "": |
|
self.report({'WARNING'}, "Please enter some text or select an image first.") |
|
return {'FINISHED'} |
|
|
|
|
|
for obj in context.selected_objects: |
|
if obj.type == 'MESH': |
|
self.selected_mesh = obj |
|
break |
|
|
|
if self.selected_mesh: |
|
temp_glb_file = tempfile.NamedTemporaryFile(delete=False, suffix=".glb") |
|
temp_glb_file.close() |
|
bpy.ops.export_scene.gltf(filepath=temp_glb_file.name, use_selection=True) |
|
with open(temp_glb_file.name, "rb") as file: |
|
mesh_data = file.read() |
|
mesh_b64_str = base64.b64encode(mesh_data).decode() |
|
os.unlink(temp_glb_file.name) |
|
self.selected_mesh_base64 = mesh_b64_str |
|
|
|
props.is_processing = True |
|
|
|
|
|
blend_file_dir = os.path.dirname(bpy.data.filepath) |
|
self.report({'INFO'}, f"blend_file_dir {blend_file_dir}") |
|
self.report({'INFO'}, f"image_path {self.image_path}") |
|
if self.image_path.startswith('//'): |
|
self.image_path = self.image_path[2:] |
|
self.image_path = os.path.join(blend_file_dir, self.image_path) |
|
|
|
if self.selected_mesh and self.texture: |
|
props.status_message = "Texturing Selected Mesh...\n" \ |
|
"This may take several minutes depending \n on your GPU power." |
|
else: |
|
mesh_type = 'Textured Mesh' if self.texture else 'White Mesh' |
|
prompt_type = 'Text Prompt' if self.prompt else 'Image' |
|
props.status_message = f"Generating {mesh_type} with {prompt_type}...\n" \ |
|
"This may take several minutes depending \n on your GPU power." |
|
|
|
self.thread = threading.Thread(target=self.generate_model) |
|
self.thread.start() |
|
|
|
wm = context.window_manager |
|
wm.modal_handler_add(self) |
|
return {'RUNNING_MODAL'} |
|
|
|
def generate_model(self): |
|
self.report({'INFO'}, f"Generation Start") |
|
base_url = self.api_url.rstrip('/') |
|
|
|
try: |
|
if self.selected_mesh_base64 and self.texture: |
|
|
|
if self.image_path and os.path.exists(self.image_path): |
|
self.report({'INFO'}, f"Post Texturing with Image") |
|
|
|
with open(self.image_path, "rb") as file: |
|
|
|
image_data = file.read() |
|
|
|
img_b64_str = base64.b64encode(image_data).decode() |
|
response = requests.post( |
|
f"{base_url}/generate", |
|
json={ |
|
"mesh": self.selected_mesh_base64, |
|
"image": img_b64_str, |
|
"octree_resolution": self.octree_resolution, |
|
"num_inference_steps": self.num_inference_steps, |
|
"guidance_scale": self.guidance_scale, |
|
"texture": self.texture |
|
}, |
|
) |
|
else: |
|
self.report({'INFO'}, f"Post Texturing with Text") |
|
response = requests.post( |
|
f"{base_url}/generate", |
|
json={ |
|
"mesh": self.selected_mesh_base64, |
|
"text": self.prompt, |
|
"octree_resolution": self.octree_resolution, |
|
"num_inference_steps": self.num_inference_steps, |
|
"guidance_scale": self.guidance_scale, |
|
"texture": self.texture |
|
}, |
|
) |
|
else: |
|
if self.image_path: |
|
if not os.path.exists(self.image_path): |
|
self.report({'ERROR'}, f"Image path does not exist {self.image_path}") |
|
raise Exception(f'Image path does not exist {self.image_path}') |
|
self.report({'INFO'}, f"Post Start Image to 3D") |
|
|
|
with open(self.image_path, "rb") as file: |
|
|
|
image_data = file.read() |
|
|
|
img_b64_str = base64.b64encode(image_data).decode() |
|
response = requests.post( |
|
f"{base_url}/generate", |
|
json={ |
|
"image": img_b64_str, |
|
"octree_resolution": self.octree_resolution, |
|
"num_inference_steps": self.num_inference_steps, |
|
"guidance_scale": self.guidance_scale, |
|
"texture": self.texture |
|
}, |
|
) |
|
else: |
|
self.report({'INFO'}, f"Post Start Text to 3D") |
|
response = requests.post( |
|
f"{base_url}/generate", |
|
json={ |
|
"text": self.prompt, |
|
"octree_resolution": self.octree_resolution, |
|
"num_inference_steps": self.num_inference_steps, |
|
"guidance_scale": self.guidance_scale, |
|
"texture": self.texture |
|
}, |
|
) |
|
self.report({'INFO'}, f"Post Done") |
|
|
|
if response.status_code != 200: |
|
self.report({'ERROR'}, f"Generation failed: {response.text}") |
|
return |
|
|
|
|
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".glb") |
|
temp_file.write(response.content) |
|
temp_file.close() |
|
|
|
|
|
def import_handler(): |
|
bpy.ops.import_scene.gltf(filepath=temp_file.name) |
|
os.unlink(temp_file.name) |
|
|
|
|
|
new_obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None |
|
if new_obj and self.selected_mesh and self.texture: |
|
|
|
new_obj.location = self.selected_mesh.location |
|
new_obj.rotation_euler = self.selected_mesh.rotation_euler |
|
new_obj.scale = self.selected_mesh.scale |
|
|
|
|
|
self.selected_mesh.hide_set(True) |
|
self.selected_mesh.hide_render = True |
|
|
|
return None |
|
|
|
bpy.app.timers.register(import_handler) |
|
|
|
except Exception as e: |
|
self.report({'ERROR'}, f"Error: {str(e)}") |
|
|
|
finally: |
|
self.task_finished = True |
|
self.selected_mesh_base64 = "" |
|
|
|
|
|
class Hunyuan3DPanel(bpy.types.Panel): |
|
bl_space_type = 'VIEW_3D' |
|
bl_region_type = 'UI' |
|
bl_category = 'Hunyuan3D-2' |
|
bl_label = 'Hunyuan3D-2 3D Generator' |
|
|
|
def draw(self, context): |
|
layout = self.layout |
|
props = context.scene.gen_3d_props |
|
|
|
layout.prop(props, "api_url") |
|
layout.prop(props, "prompt") |
|
|
|
layout.prop(props, "image_path") |
|
|
|
layout.prop(props, "octree_resolution") |
|
layout.prop(props, "num_inference_steps") |
|
layout.prop(props, "guidance_scale") |
|
|
|
layout.prop(props, "texture") |
|
|
|
row = layout.row() |
|
row.enabled = not props.is_processing |
|
row.operator("object.generate_3d") |
|
|
|
if props.is_processing: |
|
if props.status_message: |
|
for line in props.status_message.split("\n"): |
|
layout.label(text=line) |
|
else: |
|
layout.label("Processing...") |
|
|
|
|
|
classes = ( |
|
Hunyuan3DProperties, |
|
Hunyuan3DOperator, |
|
Hunyuan3DPanel, |
|
) |
|
|
|
|
|
def register(): |
|
for cls in classes: |
|
bpy.utils.register_class(cls) |
|
bpy.types.Scene.gen_3d_props = bpy.props.PointerProperty(type=Hunyuan3DProperties) |
|
|
|
|
|
def unregister(): |
|
for cls in reversed(classes): |
|
bpy.utils.unregister_class(cls) |
|
del bpy.types.Scene.gen_3d_props |
|
|
|
|
|
if __name__ == "__main__": |
|
register() |
|
|