|
import streamlit as st |
|
import tempfile |
|
import subprocess |
|
import os |
|
import requests |
|
import base64 |
|
import sys |
|
import shutil |
|
from streamlit_ace import st_ace |
|
|
|
st.set_page_config(page_title="Blender 3D Viewer", layout="wide") |
|
st.title("π Blender Script β 3D Viewer") |
|
|
|
|
|
def find_blender(): |
|
|
|
paths_to_check = [ |
|
"blender", |
|
"/usr/bin/blender", |
|
"/opt/blender/blender" |
|
] |
|
|
|
for path in paths_to_check: |
|
try: |
|
result = subprocess.run([path, "--version"], |
|
capture_output=True, |
|
text=True, |
|
timeout=5) |
|
if result.returncode == 0: |
|
st.success(f"Found Blender: {result.stdout.strip()}") |
|
return path |
|
except: |
|
continue |
|
|
|
return None |
|
|
|
|
|
blender_path = find_blender() |
|
if not blender_path: |
|
st.error("β Blender not found! The app requires Blender to be installed.") |
|
st.info("Please check the Spaces configuration for proper apt.txt setup.") |
|
|
|
|
|
|
|
DEFAULT_TEXTURE_URLS = [ |
|
"https://eoimages.gsfc.nasa.gov/images/imagerecords/57000/57730/land_ocean_ice_2048.jpg", |
|
"https://svs.gsfc.nasa.gov/vis/a000000/a002900/a002915/bluemarble-2048.png" |
|
] |
|
|
|
|
|
default_script = """ |
|
import bpy |
|
import os |
|
import math |
|
|
|
# Clear the scene |
|
bpy.ops.object.select_all(action='SELECT') |
|
bpy.ops.object.delete() |
|
|
|
# Create a UV Sphere for Earth |
|
bpy.ops.mesh.primitive_uv_sphere_add(radius=1, location=(0, 0, 0)) |
|
earth = bpy.context.active_object |
|
earth.name = 'Earth' |
|
|
|
# Apply material to Earth |
|
mat = bpy.data.materials.new(name="EarthMaterial") |
|
mat.use_nodes = True |
|
nodes = mat.node_tree.nodes |
|
links = mat.node_tree.links |
|
|
|
# Clear default nodes |
|
for node in nodes: |
|
nodes.remove(node) |
|
|
|
# Create texture node |
|
texture_node = nodes.new(type='ShaderNodeTexImage') |
|
|
|
# Get texture path from environment variable if available |
|
texture_paths = os.environ.get('TEXTURE_PATHS', '').split(',') |
|
if texture_paths and texture_paths[0]: |
|
texture_node.image = bpy.data.images.load(texture_paths[0]) |
|
|
|
# Add Principled BSDF node |
|
principled = nodes.new(type='ShaderNodeBsdfPrincipled') |
|
principled.inputs['Specular'].default_value = 0.1 |
|
principled.inputs['Roughness'].default_value = 0.8 |
|
|
|
# Add Output node |
|
output = nodes.new(type='ShaderNodeOutputMaterial') |
|
|
|
# Link nodes |
|
links.new(texture_node.outputs['Color'], principled.inputs['Base Color']) |
|
links.new(principled.outputs['BSDF'], output.inputs['Surface']) |
|
|
|
# Assign material to Earth |
|
if earth.data.materials: |
|
earth.data.materials[0] = mat |
|
else: |
|
earth.data.materials.append(mat) |
|
|
|
# Add a simple animation - rotation |
|
earth.rotation_euler = (0, 0, 0) |
|
earth.keyframe_insert(data_path="rotation_euler", frame=1) |
|
|
|
# Rotate 360 degrees on Z axis |
|
earth.rotation_euler = (0, 0, math.radians(360)) |
|
earth.keyframe_insert(data_path="rotation_euler", frame=250) |
|
|
|
# Set animation interpolation to linear |
|
for fc in earth.animation_data.action.fcurves: |
|
for kf in fc.keyframe_points: |
|
kf.interpolation = 'LINEAR' |
|
|
|
# Setup camera |
|
bpy.ops.object.camera_add(location=(0, -3, 0)) |
|
camera = bpy.context.active_object |
|
camera.rotation_euler = (math.radians(90), 0, 0) |
|
bpy.context.scene.camera = camera |
|
|
|
# Setup lighting |
|
bpy.ops.object.light_add(type='SUN', location=(5, -5, 5)) |
|
light = bpy.context.active_object |
|
light.data.energy = 2.0 |
|
|
|
# Set render settings |
|
bpy.context.scene.render.engine = 'CYCLES' |
|
bpy.context.scene.cycles.device = 'CPU' |
|
bpy.context.scene.render.film_transparent = True |
|
|
|
# Save the .blend file using an absolute path |
|
output_dir = os.environ.get('BLENDER_OUTPUT_DIR', os.getcwd()) |
|
blend_file_path = os.path.join(output_dir, "scene.blend") |
|
bpy.ops.wm.save_as_mainfile(filepath=blend_file_path) |
|
""" |
|
|
|
|
|
st.sidebar.header("Blender Python Script") |
|
script_text = st_ace( |
|
value=default_script, |
|
placeholder="Paste your Blender Python script here...", |
|
language="python", |
|
theme="monokai", |
|
key="ace", |
|
min_lines=20, |
|
max_lines=100, |
|
) |
|
|
|
|
|
st.sidebar.header("Texture Uploads (JPG/PNG)") |
|
uploaded_textures = st.sidebar.file_uploader( |
|
"Upload one or more textures", type=["jpg", "jpeg", "png"], accept_multiple_files=True |
|
) |
|
|
|
|
|
st.sidebar.header("Custom Python Libraries") |
|
custom_packages = st.sidebar.text_area( |
|
"List pip packages (one per line)", |
|
height=100 |
|
) |
|
|
|
|
|
if st.sidebar.button("Run & Export GLB"): |
|
if not blender_path: |
|
st.error("Cannot proceed: Blender is not available") |
|
st.stop() |
|
|
|
if not script_text or not script_text.strip(): |
|
st.error("Please provide a valid Blender Python script.") |
|
st.stop() |
|
|
|
with st.spinner("Processing your 3D scene..."): |
|
|
|
tmp_dir = tempfile.mkdtemp(prefix="blender_app_") |
|
try: |
|
|
|
script_path = os.path.join(tmp_dir, "user_script.py") |
|
with open(script_path, "w") as f: |
|
f.write(script_text) |
|
|
|
|
|
texture_paths = [] |
|
if uploaded_textures: |
|
for idx, upload in enumerate(uploaded_textures): |
|
ext = os.path.splitext(upload.name)[1] |
|
path = os.path.join(tmp_dir, f"texture_{idx}{ext}") |
|
with open(path, "wb") as tf: |
|
tf.write(upload.read()) |
|
texture_paths.append(path) |
|
else: |
|
|
|
for url in DEFAULT_TEXTURE_URLS: |
|
try: |
|
r = requests.get(url, timeout=10) |
|
r.raise_for_status() |
|
ext = os.path.splitext(url)[-1] or ".jpg" |
|
path = os.path.join(tmp_dir, f"default{ext}") |
|
with open(path, "wb") as tf: |
|
tf.write(r.content) |
|
texture_paths.append(path) |
|
|
|
break |
|
except Exception as e: |
|
st.warning(f"Could not download texture {url}: {str(e)}") |
|
|
|
|
|
if texture_paths: |
|
st.subheader("Texture Files") |
|
for tp in texture_paths: |
|
st.code(os.path.basename(tp)) |
|
else: |
|
st.warning("No textures were loaded. Your 3D model may appear without textures.") |
|
|
|
|
|
if custom_packages and custom_packages.strip(): |
|
pkgs = [l.strip() for l in custom_packages.splitlines() if l.strip()] |
|
if pkgs: |
|
st.info(f"Installing: {', '.join(pkgs)}") |
|
try: |
|
|
|
pip_cmd = [sys.executable, "-m", "pip", "install", "--user"] + pkgs |
|
pip_res = subprocess.run( |
|
pip_cmd, |
|
check=True, |
|
capture_output=True, |
|
text=True, |
|
timeout=120 |
|
) |
|
st.text_area("pip install output", pip_res.stdout + pip_res.stderr, height=100) |
|
except Exception as e: |
|
st.warning(f"pip install failed: {str(e)}") |
|
|
|
|
|
env = os.environ.copy() |
|
env["TEXTURE_PATHS"] = ",".join(texture_paths) |
|
env["BLENDER_OUTPUT_DIR"] = tmp_dir |
|
|
|
|
|
blend_path = os.path.join(tmp_dir, "scene.blend") |
|
|
|
with st.status("Running Blender to create scene...") as status: |
|
cmd1 = [blender_path, "--background", "--python", script_path] |
|
try: |
|
r1 = subprocess.run( |
|
cmd1, |
|
cwd=tmp_dir, |
|
env=env, |
|
check=True, |
|
capture_output=True, |
|
text=True, |
|
timeout=180 |
|
) |
|
status.update(label="Blender scene created successfully", state="complete") |
|
st.text_area("Blender output", r1.stdout + r1.stderr, height=150) |
|
except subprocess.TimeoutExpired: |
|
st.error("Blender process timed out after 3 minutes.") |
|
st.stop() |
|
except subprocess.CalledProcessError as e: |
|
st.error(f"Blender build failed with error code {e.returncode}") |
|
st.text_area("Error details", e.stdout + e.stderr, height=150) |
|
st.stop() |
|
except Exception as e: |
|
st.error(f"Blender build failed: {str(e)}") |
|
st.stop() |
|
|
|
|
|
if not os.path.exists(blend_path): |
|
st.error("Blender did not create the expected scene.blend file.") |
|
st.info("This might be due to an issue with the script or Blender configuration.") |
|
st.stop() |
|
|
|
|
|
glb_path = os.path.join(tmp_dir, "animation.glb") |
|
|
|
with st.status("Exporting to GLB format...") as status: |
|
|
|
export_script = os.path.join(tmp_dir, "export_glb.py") |
|
with open(export_script, "w") as f: |
|
f.write(f""" |
|
import bpy |
|
import os |
|
|
|
# Load the blend file |
|
bpy.ops.wm.open_mainfile(filepath=r'{blend_path}') |
|
|
|
# Export to GLB |
|
bpy.ops.export_scene.gltf( |
|
filepath=r'{glb_path}', |
|
export_format='GLB', |
|
export_animations=True |
|
) |
|
""") |
|
|
|
cmd2 = [blender_path, "--background", "--python", export_script] |
|
try: |
|
r2 = subprocess.run( |
|
cmd2, |
|
cwd=tmp_dir, |
|
env=env, |
|
check=True, |
|
capture_output=True, |
|
text=True, |
|
timeout=120 |
|
) |
|
status.update(label="GLB export completed successfully", state="complete") |
|
st.text_area("Export output", r2.stdout + r2.stderr, height=100) |
|
except Exception as e: |
|
st.error(f"GLB export failed: {str(e)}") |
|
st.stop() |
|
|
|
|
|
if os.path.exists(glb_path): |
|
with open(glb_path, 'rb') as f: |
|
data = f.read() |
|
|
|
file_size = len(data) / (1024 * 1024) |
|
st.success(f"Successfully created GLB file ({file_size:.1f} MB)") |
|
|
|
|
|
if file_size > 50: |
|
st.warning("The GLB file is quite large. The viewer might be slow to load.") |
|
|
|
b64 = base64.b64encode(data).decode() |
|
|
|
|
|
st.subheader("3D Model Viewer") |
|
html = f""" |
|
<script type="module" src="https://unpkg.com/@google/model-viewer@latest/dist/model-viewer.min.js"></script> |
|
<model-viewer |
|
src="data:model/gltf-binary;base64,{b64}" |
|
alt="3D Model" |
|
auto-rotate |
|
camera-controls |
|
ar |
|
shadow-intensity="1" |
|
style="width:100%; height:600px; background-color: #f0f0f0;"> |
|
</model-viewer> |
|
""" |
|
st.components.v1.html(html, height=650) |
|
|
|
|
|
st.download_button( |
|
"β¬οΈ Download GLB File", |
|
data, |
|
file_name="animation.glb", |
|
mime="model/gltf-binary" |
|
) |
|
else: |
|
st.error("GLB file was not generated successfully.") |
|
if os.path.exists(blend_path): |
|
st.info("The Blender file was created, but the GLB export failed.") |
|
|
|
finally: |
|
|
|
try: |
|
shutil.rmtree(tmp_dir, ignore_errors=True) |
|
except Exception as e: |
|
st.warning(f"Failed to clean up temporary files: {str(e)}") |
|
|
|
|
|
st.divider() |
|
with st.expander("About this app"): |
|
st.markdown(""" |
|
**Blender 3D Viewer App** lets you: |
|
|
|
- Write Blender Python scripts directly in your browser |
|
- Upload custom textures for your 3D models |
|
- Generate and visualize 3D models with animations |
|
- Download the results as GLB files for use in other applications |
|
|
|
The example script creates a rotating Earth with the uploaded texture. |
|
|
|
### Troubleshooting |
|
|
|
- If you encounter errors, check the console output for details |
|
- Make sure your script correctly saves files using absolute paths |
|
- For complex models, allow more time for processing |
|
- If the app doesn't work at all, the Hugging Face Space might need proper Blender installation |
|
""") |
|
|
|
with st.expander("Script Tips"): |
|
st.markdown(""" |
|
### Tips for writing Blender scripts |
|
|
|
1. **Accessing textures**: Use the environment variable `TEXTURE_PATHS` to get the paths to uploaded textures |
|
|
|
```python |
|
# Example code to load the first uploaded texture |
|
texture_paths = os.environ.get('TEXTURE_PATHS', '').split(',') |
|
if texture_paths and texture_paths[0]: |
|
image = bpy.data.images.load(texture_paths[0]) |
|
``` |
|
|
|
2. **Saving files**: Always use absolute paths with the environment variable |
|
|
|
```python |
|
# Example code to save your blend file |
|
output_dir = os.environ.get('BLENDER_OUTPUT_DIR', os.getcwd()) |
|
blend_file_path = os.path.join(output_dir, "scene.blend") |
|
bpy.ops.wm.save_as_mainfile(filepath=blend_file_path) |
|
``` |
|
|
|
3. **Animations**: Make sure to set keyframes if you want your model to animate |
|
""") |