Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
c449794
tech(archi): rework extension archi
Mar 28, 2026
2b4a287
Merge pull request #67 from lightningpixel/tech/rework-extension-arch…
lightningpixel Mar 28, 2026
9d237f2
add workflow system
Mar 28, 2026
0c5824e
feat(workflow): improve workflow page
Mar 28, 2026
b8761f2
feat(workflow): redesign workflow system
Mar 28, 2026
d942aee
feat: add workflow process in geenrate page
Mar 29, 2026
5debf81
feat(generate): improve generate workflow panel
Mar 29, 2026
71c4a9f
feat(workflow): improve workflow ui/ux
Mar 29, 2026
c841f7a
fix generator registry
Mar 29, 2026
fdaa6a7
fix(workflows): remove + button in tab manager
Mar 29, 2026
d46c7b5
fix(generate): fix workspace panel position and collapsed
Mar 29, 2026
0d1e8f1
feat : add mesh exporter and improve generate page
Mar 30, 2026
847cff1
feat: add node workflow system
Mar 31, 2026
79c01d4
feat(workflow): add view generate page nodes system
Apr 1, 2026
3396d96
feat: improve workflow and generate page
Apr 1, 2026
a0dab3b
feat(extensions): add new system that allow n nodes for 1 extension
Apr 1, 2026
2b3bb6d
feat: improve workflow page
Apr 1, 2026
39fdf6a
fix(runner): fix model dir path
Apr 2, 2026
7c0f48f
fix(runner): fix runner for multiple nodes
Apr 2, 2026
0ab5c46
Merge pull request #69 from lightningpixel/feat/workflow-system
lightningpixel Apr 2, 2026
f672c73
feat(extensions): rework models page into extension page
Apr 2, 2026
553d6c5
Merge pull request #70 from lightningpixel/feat/redesign-models-page
lightningpixel Apr 2, 2026
e262445
tech(workspace): remove workspace system
Apr 2, 2026
4d45ad1
Merge pull request #71 from lightningpixel/tech/remove-workspace-system
lightningpixel Apr 2, 2026
9c5dad0
feat(workflow): improve workflow page + fix auto updater
Apr 2, 2026
3ba6354
Merge pull request #72 from lightningpixel/feat/improve-workflow-page
lightningpixel Apr 2, 2026
39e07a2
feat(viewer3d): add gizmo controller
Apr 2, 2026
8d928a7
Merge pull request #73 from lightningpixel/feat/viewer-3d-gizmo
lightningpixel Apr 2, 2026
07c9b66
feat(generate): add tools in headerbar
Apr 2, 2026
78033a7
Merge pull request #74 from lightningpixel/feat/add-tools-headerbar
lightningpixel Apr 2, 2026
fcb6bfe
feat(generate): improve generate page
Apr 2, 2026
16b7d34
Merge pull request #75 from lightningpixel/feat/improve-generate-page
lightningpixel Apr 2, 2026
361d12e
feat(workflow): add general node and rework architecture
Apr 2, 2026
8df28f7
Merge pull request #76 from lightningpixel/feat/add-general-nodes
lightningpixel Apr 2, 2026
6daced8
feat(workflow): improve workflow page add helper
Apr 2, 2026
d3c699a
feat(workflow): improve workflow page
Apr 2, 2026
47b4a01
Merge pull request #77 from lightningpixel/feat/improve-workflow-page
lightningpixel Apr 2, 2026
5496d78
feat(workflow): improve workflow page and performance
Apr 3, 2026
83b6d1c
Merge pull request #78 from lightningpixel/feat/improve-workflow-and-…
lightningpixel Apr 3, 2026
217184c
tech/add-different-language-support-process
Apr 3, 2026
6d759c0
tech(extensions): improve node system extensions
Apr 3, 2026
229d607
Merge pull request #79 from lightningpixel/tech/improve-node-extensio…
lightningpixel Apr 3, 2026
19bc0e3
tech(extension): fix archietcture node runner
Apr 3, 2026
7d19a26
Merge pull request #80 from lightningpixel/tech/fix-extension-archite…
lightningpixel Apr 3, 2026
fb8327c
tech(github): improve readme
Apr 3, 2026
a28030d
Merge pull request #81 from lightningpixel/tech/improve-github-readme
lightningpixel Apr 3, 2026
2f9eade
fix(nodex): fix settings workflow nodes
Apr 3, 2026
ed58466
Merge pull request #82 from lightningpixel/fix/workflow-nodes-settings
lightningpixel Apr 3, 2026
c135e03
fix(workflow): improve options workflow
Apr 3, 2026
8041d8f
Merge pull request #83 from lightningpixel/fix/improve-options-workflow
lightningpixel Apr 3, 2026
66770f2
dump version 0.3.0
Apr 3, 2026
def27f3
Merge branch 'main' into release/v0.3.0
lightningpixel Apr 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: lightningpixel
2 changes: 1 addition & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async def lifespan(app: FastAPI):

app = FastAPI(
title="Modly API",
version="0.2.1",
version="0.3.0",
lifespan=lifespan,
)

Expand Down
32 changes: 5 additions & 27 deletions api/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,36 +1,14 @@
# PyTorch CUDA — index primaire pour torch/torchvision
# cu128 = CUDA 12.8 (driver ≥520, RTX 30xx/40xx/50xx)
# Changer cu128 → cu126 si le driver est plus ancien
--index-url https://download.pytorch.org/whl/cu128
--extra-index-url https://pypi.org/simple

# Web server
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-multipart==0.0.20

# AI / ML
torch>=2.1.0
torchvision>=0.16.0
huggingface_hub>=0.27.0
hf_xet>=0.1.0
diffusers>=0.32.0
transformers>=4.48.0,<5.0.0
accelerate>=1.3.0
pillow>=11.0.0
# Mesh processing (optimize + export endpoints)
trimesh>=4.5.0
numpy>=1.26.0
rembg>=2.0.0 # background removal (SF3D + Hunyuan3D)
onnxruntime-gpu>=1.17.0 # runtime ONNX requis par rembg (GPU-accelerated)
einops>=0.7.0 # tensor ops used by Hunyuan3D / hy3dshape pipeline
pymeshlab>=2023.12 # mesh processing required by hy3dshape postprocessors
xatlas>=0.0.8 # UV unwrapping for Hunyuan3D texture generation
pygltflib>=1.15.0 # GLB/GLTF I/O for Hunyuan3D texture pipeline
pymeshlab>=2023.12

opencv-python-headless>=4.8.0 # required by hy3dgen.shapegen.preprocessors (cv2)
peft>=0.10.0 # LoRA/adapter support required by TripoSG transformer
scikit-image>=0.21.0 # image processing utilities required by TripoSG
# Model downloads (HuggingFace)
huggingface_hub>=0.27.0
hf_xet>=0.1.0

# Utils
aiofiles==24.1.0
cryptography>=42.0.0
62 changes: 61 additions & 1 deletion api/routers/extensions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from fastapi import APIRouter
import asyncio
import subprocess
import sys
from fastapi import APIRouter, HTTPException

router = APIRouter(tags=["extensions"])

Expand All @@ -18,8 +21,65 @@ async def reload_extensions():
}


@router.post("/setup/{ext_id}")
async def setup_extension(ext_id: str):
"""
Creates the isolated venv for an extension by running its setup.py.
Called automatically after installing an extension from GitHub.
Runs setup.py with Modly's embedded Python and the detected GPU SM.
"""
from services.generator_registry import EXTENSIONS_DIR

if EXTENSIONS_DIR is None or not EXTENSIONS_DIR.exists():
raise HTTPException(400, "EXTENSIONS_DIR not configured")

ext_dir = EXTENSIONS_DIR / ext_id
setup_py = ext_dir / "setup.py"

if not ext_dir.exists():
raise HTTPException(404, f"Extension '{ext_id}' not found in {EXTENSIONS_DIR}")
if not setup_py.exists():
# No setup.py → legacy extension, nothing to do
return {"status": "skipped", "reason": "no setup.py"}

# Detect GPU compute capability
gpu_sm = _detect_gpu_sm()

# Run setup.py using Modly's embedded Python (sys.executable)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None,
lambda: subprocess.run(
[sys.executable, str(setup_py), sys.executable, str(ext_dir), str(gpu_sm)],
capture_output=True,
text=True,
)
)

if result.returncode != 0:
raise HTTPException(500, f"setup.py failed:\n{result.stderr}")

return {
"status": "ok",
"gpu_sm": gpu_sm,
"output": result.stdout,
}


@router.get("/errors")
async def extension_errors():
"""Returns extension loading errors (invalid manifest, failed import, etc.)."""
from services.generator_registry import generator_registry
return generator_registry.load_errors()


def _detect_gpu_sm() -> int:
"""Returns GPU compute capability as integer (e.g. 86 for SM 8.6), or 0 if no GPU."""
try:
import torch
if torch.cuda.is_available():
major, minor = torch.cuda.get_device_capability(0)
return major * 10 + minor
except Exception:
pass
return 0
21 changes: 18 additions & 3 deletions api/routers/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ async def cancel_job(job_id: str):
_cancel_events[job_id].set()
if job.status in ("pending", "running"):
job.status = "cancelled"
# Kill the active generator subprocess immediately so inference stops now.
# _run_generation will catch the resulting exception, see job_id in _cancelled,
# and return cleanly without setting an error status.
try:
gen = generator_registry._generators.get(generator_registry._active_id)
if gen is not None and hasattr(gen, "_proc") and gen._proc and gen._proc.poll() is None:
gen._proc.kill()
gen._loaded = False
gen._proc = None
except Exception:
pass
return {"cancelled": True}


Expand Down Expand Up @@ -149,9 +160,13 @@ def progress_cb(pct: int, step: str = "") -> None:
if job_id in _cancelled:
return

job.status = "done"
job.progress = 100
job.output_url = f"/workspace/{collection}/{output_path.name}"
job.status = "done"
job.progress = 100
try:
rel = output_path.relative_to(WORKSPACE_DIR)
job.output_url = f"/workspace/{rel.as_posix()}"
except ValueError:
job.output_url = f"/workspace/{collection}/{output_path.name}"

except GenerationCancelled:
job.status = "cancelled"
Expand Down
165 changes: 162 additions & 3 deletions api/routers/optimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@
import re
import shutil
import tempfile
import uuid

try:
import pymeshlab as _pymeshlab
_PYMESHLAB_AVAILABLE = True
except ImportError:
_pymeshlab = None
_PYMESHLAB_AVAILABLE = False

import pymeshlab
import trimesh
import trimesh.visual
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import FileResponse, Response
from pathlib import Path
from urllib.parse import quote
from pydantic import BaseModel

from services.generator_registry import WORKSPACE_DIR
Expand All @@ -19,8 +29,19 @@ class OptimizeRequest(BaseModel):
target_faces: int


class SmoothRequest(BaseModel):
path: str # format: "{collection}/{filename}"
iterations: int


def _require_pymeshlab():
if not _PYMESHLAB_AVAILABLE:
raise HTTPException(503, "pymeshlab is unavailable on this system (DLL blocked by Windows Application Control policy)")


@router.post("/mesh")
def optimize_mesh(body: OptimizeRequest):
_require_pymeshlab()
target_faces = max(100, min(500_000, body.target_faces))

# Security: prevent path traversal
Expand Down Expand Up @@ -63,7 +84,7 @@ def _decimate(input_path: str, target_faces: int, tmp_dir: str) -> trimesh.Trime
else:
geom = loaded

ms = pymeshlab.MeshSet()
ms = _pymeshlab.MeshSet()

if _has_texture(geom):
# ── Textured path: OBJ intermediate to preserve UV coordinates ──────
Expand Down Expand Up @@ -118,3 +139,141 @@ def _decimate(input_path: str, target_faces: int, tmp_dir: str) -> trimesh.Trime
)
ms.save_current_mesh(ply_out)
return trimesh.load(ply_out, force="mesh")


@router.post("/smooth")
def smooth_mesh(body: SmoothRequest):
_require_pymeshlab()
iterations = max(1, min(20, body.iterations))

input_path = (WORKSPACE_DIR / body.path).resolve()
if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())):
raise HTTPException(400, "Invalid path")
if not input_path.exists():
raise HTTPException(404, f"File not found: {body.path}")

tmp_dir = tempfile.mkdtemp()
try:
result = _smooth(str(input_path), iterations, tmp_dir)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)

stem = input_path.stem
output_name = f"{stem}_smooth{iterations}.glb"
output_path = input_path.parent / output_name
result.export(str(output_path))

collection_name = body.path.split("/")[0]
return {"url": f"/workspace/{collection_name}/{output_name}"}


def _smooth(input_path: str, iterations: int, tmp_dir: str) -> trimesh.Trimesh:
loaded = trimesh.load(input_path)
if isinstance(loaded, trimesh.Scene):
geoms = list(loaded.geometry.values())
geom = trimesh.util.concatenate(geoms) if len(geoms) > 1 else geoms[0]
else:
geom = loaded

ms = _pymeshlab.MeshSet()

if _has_texture(geom):
obj_in = os.path.join(tmp_dir, "input.obj")
mtl_in = os.path.join(tmp_dir, "input.mtl")
tex_in = os.path.join(tmp_dir, "texture.png")
obj_out = os.path.join(tmp_dir, "output.obj")

geom.visual.material.image.save(tex_in)
geom.export(obj_in)

if os.path.exists(mtl_in):
mtl = open(mtl_in).read()
mtl = re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", mtl)
open(mtl_in, "w").write(mtl)

ms.load_new_mesh(obj_in)
ms.apply_coord_laplacian_smoothing(stepsmoothnum=iterations)
ms.save_current_mesh(obj_out)

mtl_out = obj_out.replace(".obj", ".mtl")
if os.path.exists(mtl_out):
mtl = open(mtl_out).read()
mtl = re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", mtl)
open(mtl_out, "w").write(mtl)

return trimesh.load(obj_out)

else:
ply_in = os.path.join(tmp_dir, "input.ply")
ply_out = os.path.join(tmp_dir, "output.ply")

geom.export(ply_in)
ms.load_new_mesh(ply_in)
ms.apply_coord_laplacian_smoothing(stepsmoothnum=iterations)
ms.save_current_mesh(ply_out)
return trimesh.load(ply_out, force="mesh")


class ImportByPathRequest(BaseModel):
path: str # absolute path on disk


@router.post("/import-by-path")
async def import_mesh_by_path(body: ImportByPathRequest):
file_path = Path(body.path)
if not file_path.is_file():
raise HTTPException(400, "File not found")

ext = file_path.suffix.lstrip(".").lower()
if ext not in ("glb", "obj", "stl", "ply"):
raise HTTPException(400, f"Unsupported format: {ext}")

if ext == "glb":
# Serve the original file directly — no copy
return {"url": f"/optimize/serve-file?path={quote(str(file_path))}"}

# Non-GLB: convert to GLB in a temp directory (not the workspace)
tmp_dir = tempfile.mkdtemp(prefix="modly_import_")
output_path = os.path.join(tmp_dir, "mesh.glb")
loaded = trimesh.load(str(file_path))
loaded.export(output_path)
return {"url": f"/optimize/serve-file?path={quote(output_path)}"}


@router.get("/serve-file")
def serve_file(path: str):
file_path = Path(path)
if not file_path.is_file():
raise HTTPException(404, "File not found")
if file_path.suffix.lower() != ".glb":
raise HTTPException(400, "Only GLB files can be served")
return FileResponse(str(file_path), media_type="model/gltf-binary")


@router.get("/export")
def export_mesh(path: str, format: str):
if format not in ("obj", "stl", "ply"):
raise HTTPException(400, "Supported formats: obj, stl, ply")

input_path = (WORKSPACE_DIR / path).resolve()
if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())):
raise HTTPException(400, "Invalid path")
if not input_path.exists():
raise HTTPException(404, f"File not found: {path}")

loaded = trimesh.load(str(input_path))
if isinstance(loaded, trimesh.Scene):
geoms = list(loaded.geometry.values())
mesh = trimesh.util.concatenate(geoms) if len(geoms) > 1 else geoms[0]
else:
mesh = loaded

data = mesh.export(file_type=format)
stem = input_path.stem
mime = "text/plain" if format == "obj" else "application/octet-stream"
# trimesh exports ply as bytes even in text mode — octet-stream is fine for all binary formats
return Response(
content=data,
media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{stem}.{format}"'},
)
Loading
Loading