Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
64a4330
fix(extension): manifest extensions error handle
May 10, 2026
af7c4a8
fix(architecture): terminate stale process runners on extension reins…
May 10, 2026
9b93b45
fix(architecture): purge stale generation jobs to prevent memory leak
May 10, 2026
a22c295
Add Wait node and conditional param visibility
Lorchie May 10, 2026
dde0bbb
fix(extensions): harden extension path handling
DrHepa May 10, 2026
f02b4b9
fix(extensions): rollback failed GitHub installs
DrHepa May 10, 2026
bb482e6
dump version 0.3.6
May 11, 2026
166788d
Merge pull request #139 from lightningpixel/release/v0.3.6
lightningpixel May 11, 2026
54285c7
Add social media links for Modly and Lightning Pixel
lightningpixel May 12, 2026
a9334df
Fix link formatting for Modly on X
lightningpixel May 12, 2026
091a808
Add sponsors section to README
lightningpixel Jun 5, 2026
dc63471
Implement path traversal protection in optimize.py
iammojogo-sudo Jun 8, 2026
a0ba16c
Add transformMesh function to useApi hook
iammojogo-sudo Jun 8, 2026
0ad7b2c
Add GizmoMode type for transformation modes
iammojogo-sudo Jun 8, 2026
c8bb9c8
Add GizmoMode and related props to ViewerToolbar
iammojogo-sudo Jun 8, 2026
fb30b0c
Enhance MeshModel with autoCenter and resetToken
iammojogo-sudo Jun 8, 2026
2ff1835
Merge branch 'dev' into main
iammojogo-sudo Jun 9, 2026
1db6691
Implement auto-save and re-sync for WorkflowPanel
iammojogo-sudo Jun 10, 2026
62812d0
Update WorkflowsPage.tsx
iammojogo-sudo Jun 10, 2026
d614b5a
Implement GitHub extension installation feature
iammojogo-sudo Jun 10, 2026
162905c
Refactor ModelsPage component for better state management
iammojogo-sudo Jun 10, 2026
e849721
Update ModelsPage.tsx
iammojogo-sudo Jun 10, 2026
51d7cec
Update fmt.Println message from 'Hello' to 'Goodbye'
iammojogo-sudo Jun 10, 2026
1094410
Add newline at end of WorkflowPanel.tsx
iammojogo-sudo Jun 10, 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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,39 @@ See `tools/modly-cli/SKILL.md` for the agent workflow and output contract.

Join the [Discord server](https://discord.gg/BvjDCvS3yr) to stay up to date with the latest news, report bugs, and share feedback.

Follow Modly and its development on X:

- [Modly on X](https://x.com/modly3d)
- [Lightning Pixel on X](https://x.com/lightningpiixel)

---

## Sponsors

<p align="center">
Thanks to our early sponsors for believing in Modly and helping make local AI 3D generation more accessible.
</p>

<p align="center">
<kbd>
<img src="https://images.weserv.nl/?url=github.com/DrHepa.png&w=96&h=96&fit=cover&mask=circle" width="40" height="40" alt="DrHepa" />
<br />
<sub><a href="https://github.com/DrHepa">DrHepa</a></sub>
</kbd>
&nbsp;&nbsp;
<kbd>
<img src="https://images.weserv.nl/?url=github.com/benjapenjamin.png&w=96&h=96&fit=cover&mask=circle" width="40" height="40" alt="benjapenjamin" />
<br />
<sub><a href="https://github.com/benjapenjamin">benjapenjamin</a></sub>
</kbd>
&nbsp;&nbsp;
<kbd>
<img src="https://images.weserv.nl/?url=github.com/iammojogo-sudo.png&w=96&h=96&fit=cover&mask=circle" width="40" height="40" alt="iammojogo-sudo" />
<br />
<sub><a href="https://github.com/iammojogo-sudo">iammojogo-sudo</a></sub>
</kbd>
</p>

---

## License
Expand Down
2 changes: 1 addition & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def filter(self, record):

app = FastAPI(
title="Modly API",
version="0.3.5",
version="0.3.6",
lifespan=lifespan,
)

Expand Down
337 changes: 337 additions & 0 deletions api/routers/optimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,343 @@
import tempfile
import uuid

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

import numpy as np
import trimesh
import trimesh.visual
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

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


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


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


class TransformRequest(BaseModel):
path: str # format: "{collection}/{filename}"
matrix: list[list[float]] # row-major 4x4 world transform


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


def _resolve_input_path(raw_path: str) -> Path:
candidate = Path(raw_path)
if candidate.is_absolute():
resolved = candidate.resolve()
if not resolved.exists():
raise HTTPException(404, f"File not found: {raw_path}")
return resolved

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


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

input_path = _resolve_input_path(body.path)

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

stem = input_path.stem
output_name = f"{stem}_opt{target_faces}.glb"
output_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / output_name
result.export(str(output_path))

face_count = len(result.faces)
rel = output_path.relative_to(WORKSPACE_DIR).as_posix()
return {"url": f"/workspace/{rel}", "face_count": face_count}


def _has_texture(geom: trimesh.Trimesh) -> bool:
if not isinstance(geom.visual, trimesh.visual.TextureVisuals):
return False
mat = geom.visual.material
if mat is None:
return False
# Simple material (SimpleMaterial / Material)
if getattr(mat, "image", None) is not None:
return True
# PBR material (from Trellis2 SLaT texturing and GLB imports)
if getattr(mat, "baseColorTexture", None) is not None:
return True
return False


def _get_texture_image(geom: trimesh.Trimesh):
"""Return the base color texture image regardless of material type."""
mat = geom.visual.material
img = getattr(mat, "image", None)
if img is not None:
return img
return getattr(mat, "baseColorTexture", None)


def _decimate(input_path: str, target_faces: 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):
# ── Textured path: OBJ intermediate to preserve UV coordinates ──────
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")

# Save texture image under a known filename (handles PBR and simple materials)
_get_texture_image(geom).save(tex_in)

# Export OBJ (trimesh writes UV coords + MTL)
geom.export(obj_in)

# Patch MTL so any map_Kd points to our known texture filename
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.meshing_decimation_quadric_edge_collapse(
targetfacenum=target_faces,
preservetexcoord=True, # ← keeps UV coordinates intact
preservenormal=True,
preservetopology=True,
autoclean=True,
)
ms.save_current_mesh(obj_out)

# Patch output MTL too, so trimesh can find the texture on load
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:
# ── Geometry-only path: PLY (fast, no texture to worry about) ────────
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.meshing_decimation_quadric_edge_collapse(
targetfacenum=target_faces,
preservenormal=True,
preservetopology=True,
autoclean=True,
)
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 = _resolve_input_path(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_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / output_name
result.export(str(output_path))

rel = output_path.relative_to(WORKSPACE_DIR).as_posix()
return {"url": f"/workspace/{rel}"}


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")

_get_texture_image(geom).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")


@router.post("/transform")
def transform_mesh(body: TransformRequest):
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}")

matrix = np.asarray(body.matrix, dtype=float)
if matrix.shape != (4, 4):
raise HTTPException(400, "matrix must be a 4x4 array")
if not np.all(np.isfinite(matrix)):
raise HTTPException(400, "matrix contains non-finite values")

loaded = trimesh.load(str(input_path))
loaded.apply_transform(matrix)

stem = input_path.stem
output_name = f"{stem}_xf.glb"
output_path = input_path.parent / output_name
loaded.export(str(output_path))

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


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}"'},
)
import os
import re
import shutil
import tempfile
import uuid

try:
import pymeshlab as _pymeshlab
_PYMESHLAB_AVAILABLE = True
Expand Down
Loading