Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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.2",
version="0.3.3",
lifespan=lifespan,
)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "modly",
"version": "0.3.2",
"version": "0.3.3",
"description": "Local AI-powered 3D mesh generation from images",
"main": "./out/main/index.js",
"author": "Modly",
Expand Down
8 changes: 8 additions & 0 deletions scripts/build-builtins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ for (const id of readdirSync(srcDir)) {
})
console.log(`[build-builtins] ${id}: npm install done`)
}

// Copy any Python processor files
for (const file of readdirSync(extSrcDir)) {
if (file.endsWith('.py')) {
cpSync(join(extSrcDir, file), join(extOutDir, file))
console.log(`[build-builtins] ${id}: ${file} copied`)
}
}
}

console.log('[build-builtins] Done.')
41 changes: 41 additions & 0 deletions src/areas/workflows/nodes/mesh-remesher/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"id": "mesh-remesher",
"name": "Mesh Remesher",
"type": "process",
"entry": "processor.py",
"version": "1.0.0",
"author": "Modly",
"description": "Remeshes a mesh to triangle or quad topology using isotropic remeshing.",
"nodes": [
{
"id": "remesh",
"name": "Remesh",
"input": "mesh",
"output": "mesh",
"params_schema": [
{
"id": "mode",
"label": "Mode",
"type": "select",
"default": "triangle",
"options": [
{ "value": "triangle", "label": "Triangle" },
{ "value": "quad", "label": "Quad" },
{ "value": "none", "label": "None" }
],
"tooltip": "Triangle produces a clean uniform triangulation. Quad attempts a quad-dominant mesh. None passes the mesh through unchanged."
},
{
"id": "target_edge_length",
"label": "Target Edge Length",
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.001,
"tooltip": "Target edge length in world units. Set to 0 for automatic (uses average edge length of the input mesh)."
}
]
}
]
}
131 changes: 131 additions & 0 deletions src/areas/workflows/nodes/mesh-remesher/processor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
Mesh Remesher — built-in process extension.

Protocol: reads one JSON line from stdin, writes JSON lines to stdout.
stdin : { input, params, workspaceDir, tempDir }
stdout: { type: "progress"|"log"|"done"|"error", ... }
"""
import json
import os
import shutil
import sys
import tempfile
from pathlib import Path


def emit(obj: dict) -> None:
print(json.dumps(obj), flush=True)


def progress(pct: int, label: str) -> None:
emit({"type": "progress", "percent": pct, "label": label})


def log(msg: str) -> None:
emit({"type": "log", "message": msg})


def done(file_path: str) -> None:
emit({"type": "done", "result": {"filePath": file_path}})


def error(msg: str) -> None:
emit({"type": "error", "message": msg})


def main() -> None:
raw = sys.stdin.readline()
data = json.loads(raw)

input_data = data.get("input", {})
params = data.get("params", {})
workspace_dir = data.get("workspaceDir", "")

input_path = input_data.get("filePath")
if not input_path or not Path(input_path).is_file():
error(f"mesh-remesher: input file not found: {input_path}")
return

mode = str(params.get("mode", "triangle"))
target_edge_length = float(params.get("target_edge_length", 0.0))

out_dir = Path(workspace_dir) / "Workflows"
out_dir.mkdir(parents=True, exist_ok=True)
from time import time
out_path = str(out_dir / f"mesh-remesher-{int(time() * 1000)}.glb")

log(f"Mode: {mode}, edge length: {target_edge_length or 'auto'}")

if mode == "none":
progress(50, "Passing through…")
shutil.copy2(input_path, out_path)
progress(100, "Done")
done(out_path)
return

try:
import pymeshlab
except ImportError:
error("mesh-remesher: pymeshlab is not available on this system")
return

import trimesh

progress(10, "Loading mesh…")
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

tmp_dir = tempfile.mkdtemp()
try:
ply_in = os.path.join(tmp_dir, "input.ply")
ply_out = os.path.join(tmp_dir, "output.ply")
geom.export(ply_in)

ms = pymeshlab.MeshSet()
ms.load_new_mesh(ply_in)

if target_edge_length <= 0:
measures = ms.get_geometric_measures()
target_edge_length = float(measures.get("avg_edge_length", 0.02))
log(f"Auto edge length: {target_edge_length:.6f}")

progress(30, f"Remeshing ({mode})…")

if mode == "triangle":
ms.meshing_isotropic_explicit_remeshing(
targetlen=pymeshlab.PureValue(target_edge_length),
iterations=3,
)
elif mode == "quad":
ms.meshing_isotropic_explicit_remeshing(
targetlen=pymeshlab.PureValue(target_edge_length),
iterations=3,
)
try:
ms.generate_polygonal_mesh()
ms.meshing_poly_to_tri()
except Exception:
pass

progress(80, "Exporting…")
ms.save_current_mesh(ply_out)
result = trimesh.load(ply_out, force="mesh")
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)

result.export(out_path)
log(f"Output: {out_path} ({len(result.faces)} faces)")
progress(100, "Done")
done(out_path)


if __name__ == "__main__":
try:
main()
except Exception as exc:
import traceback
error(f"{exc}\n{traceback.format_exc()}")
Loading