From 732303ef971692fb3131da33737140e360e86b89 Mon Sep 17 00:00:00 2001 From: DrHepa <162889656+DrHepa@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:42:51 +0200 Subject: [PATCH] feat(mesh): add unified mesh operations registry --- api/routers/optimize.py | 285 +++++------- api/services/mesh_ops/__init__.py | 26 ++ api/services/mesh_ops/builtin.py | 131 ++++++ api/services/mesh_ops/meshopt_runner.cjs | 120 +++++ api/services/mesh_ops/operations.py | 419 ++++++++++++++++++ api/services/mesh_ops/processor.py | 71 +++ api/services/mesh_ops/registry.py | 58 +++ api/services/mesh_ops/types.py | 77 ++++ api/tests/test_mesh_ops_operations.py | 323 ++++++++++++++ api/tests/test_mesh_ops_processor.py | 76 ++++ api/tests/test_mesh_ops_registry.py | 123 +++++ api/tests/test_optimize_mesh_ops.py | 125 ++++++ electron/main/process-runner.ts | 22 +- electron/main/python-bridge.ts | 3 + .../nodes/mesh-optimizer/manifest.json | 2 +- .../nodes/mesh-optimizer/processor.py | 22 + .../nodes/mesh-optimizer/processor.ts | 88 ---- .../workflows/nodes/mesh-repair/processor.py | 155 +------ .../nodes/mesh-smoother/processor.py | 126 +----- tsconfig.builtins.json | 3 +- 20 files changed, 1720 insertions(+), 535 deletions(-) create mode 100644 api/services/mesh_ops/__init__.py create mode 100644 api/services/mesh_ops/builtin.py create mode 100644 api/services/mesh_ops/meshopt_runner.cjs create mode 100644 api/services/mesh_ops/operations.py create mode 100644 api/services/mesh_ops/processor.py create mode 100644 api/services/mesh_ops/registry.py create mode 100644 api/services/mesh_ops/types.py create mode 100644 api/tests/test_mesh_ops_operations.py create mode 100644 api/tests/test_mesh_ops_processor.py create mode 100644 api/tests/test_mesh_ops_registry.py create mode 100644 api/tests/test_optimize_mesh_ops.py create mode 100644 src/areas/workflows/nodes/mesh-optimizer/processor.py delete mode 100644 src/areas/workflows/nodes/mesh-optimizer/processor.ts diff --git a/api/routers/optimize.py b/api/routers/optimize.py index 6081c704..44178a09 100644 --- a/api/routers/optimize.py +++ b/api/routers/optimize.py @@ -1,27 +1,24 @@ import hashlib import os -import re -import shutil 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 pydantic import BaseModel, Field from services.generator_registry import WORKSPACE_DIR +from services.mesh_ops import ( + MeshOpContext, + MeshOpNotFoundError, + MeshOpResult, + MeshOpUnavailableError, + mesh_ops_registry, +) router = APIRouter(tags=["optimize"]) @@ -36,16 +33,16 @@ class SmoothRequest(BaseModel): iterations: int +class MeshOpRequest(BaseModel): + path: str + params: dict[str, object] = Field(default_factory=dict) + + 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(): @@ -62,142 +59,109 @@ def _resolve_input_path(raw_path: str) -> Path: return resolved -@router.post("/mesh") -def optimize_mesh(body: OptimizeRequest): - _require_pymeshlab() - target_faces = max(100, min(500_000, body.target_faces)) +def _operation_output_path(input_path: Path, output_name: str) -> Path: + workspace = WORKSPACE_DIR.resolve() + resolved_input = input_path.resolve() + output_dir = ( + input_path.parent + if resolved_input == workspace or workspace in resolved_input.parents + else WORKSPACE_DIR / "Workflows" + ) + output_dir.mkdir(parents=True, exist_ok=True) + return output_dir / output_name + + +def _run_operation( + operation_id: str, + input_path: Path, + params: dict[str, object], + output_path: Path | None = None, + preserve_visuals: bool = False, +) -> MeshOpResult: + context = MeshOpContext( + workspace_dir=WORKSPACE_DIR, + temp_dir=Path(tempfile.gettempdir()), + output_path=output_path, + preserve_visuals=preserve_visuals, + ) + try: + return mesh_ops_registry.run(operation_id, input_path, params, context) + except MeshOpNotFoundError as exc: + raise HTTPException(404, f"Unknown mesh operation: {operation_id}") from exc + except MeshOpUnavailableError as exc: + raise HTTPException(503, str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(400, str(exc)) from exc - input_path = _resolve_input_path(body.path) - tmp_dir = tempfile.mkdtemp() +def _operation_response(result: MeshOpResult) -> dict[str, object]: + output_path = result.file_path.resolve() 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)) + relative_path = output_path.relative_to(WORKSPACE_DIR.resolve()).as_posix() + except ValueError: + payload: dict[str, object] = {"path": str(output_path)} + else: + payload = { + "path": relative_path, + "url": f"/workspace/{relative_path}", + } + payload.update(result.details) + return payload - face_count = len(result.faces) - rel = output_path.relative_to(WORKSPACE_DIR).as_posix() - return {"url": f"/workspace/{rel}", "face_count": face_count} +@router.get("/ops") +def list_mesh_operations(): + return mesh_ops_registry.describe() -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) +@router.post("/op/{op_name}") +def run_mesh_operation(op_name: str, body: MeshOpRequest): + input_path = _resolve_input_path(body.path) + return _operation_response( + _run_operation(op_name, input_path, body.params) + ) - 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, +@router.post("/mesh") +def optimize_mesh(body: OptimizeRequest): + target_faces = max(100, min(500_000, body.target_faces)) + input_path = _resolve_input_path(body.path) + output_path = _operation_output_path( + input_path, + f"{input_path.stem}_opt{target_faces}.glb", + ) + response = _operation_response( + _run_operation( + "decimate", + input_path, + {"target_faces": target_faces}, + output_path, ) - ms.save_current_mesh(ply_out) - return trimesh.load(ply_out, force="mesh") + ) + return { + "url": response.get("url"), + "face_count": response.get("face_count", 0), + } @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}"} + output_path = _operation_output_path( + input_path, + f"{input_path.stem}_smooth{iterations}.glb", + ) + response = _operation_response( + _run_operation( + "smooth", + input_path, + {"iterations": iterations, "lambda_": 0.5, "mode": "laplacian"}, + output_path, + preserve_visuals=True, + ) + ) + return {"url": response.get("url")} @router.post("/transform") @@ -228,53 +192,6 @@ def transform_mesh(body: TransformRequest): 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") - - class ImportByPathRequest(BaseModel): path: str # absolute path on disk @@ -503,4 +420,4 @@ def export_mesh(path: str, format: str): content=data, media_type=mime, headers={"Content-Disposition": f'attachment; filename="{stem}.{format}"'}, - ) \ No newline at end of file + ) diff --git a/api/services/mesh_ops/__init__.py b/api/services/mesh_ops/__init__.py new file mode 100644 index 00000000..40f9e61c --- /dev/null +++ b/api/services/mesh_ops/__init__.py @@ -0,0 +1,26 @@ +"""Unified mesh operation registry used by the API and workflow nodes.""" + +from .builtin import BUILTIN_MESH_OPS +from .registry import MeshOpsRegistry +from .types import ( + MeshOp, + MeshOpContext, + MeshOpExecutionError, + MeshOpNotFoundError, + MeshOpResult, + MeshOpUnavailableError, +) + + +mesh_ops_registry = MeshOpsRegistry(BUILTIN_MESH_OPS) + +__all__ = [ + "MeshOp", + "MeshOpContext", + "MeshOpExecutionError", + "MeshOpNotFoundError", + "MeshOpResult", + "MeshOpsRegistry", + "MeshOpUnavailableError", + "mesh_ops_registry", +] diff --git a/api/services/mesh_ops/builtin.py b/api/services/mesh_ops/builtin.py new file mode 100644 index 00000000..900bd193 --- /dev/null +++ b/api/services/mesh_ops/builtin.py @@ -0,0 +1,131 @@ +"""Built-in mesh operation definitions.""" + +from .operations import decimate_mesh, repair_mesh, smooth_mesh +from .types import MeshOp + + +REPAIR_PARAMS = ( + { + "id": "remove_duplicates", + "label": "Remove Duplicates", + "type": "boolean", + "default": True, + "tooltip": "Remove duplicate vertices and faces.", + }, + { + "id": "remove_degenerate", + "label": "Remove Degenerate Faces", + "type": "boolean", + "default": True, + "tooltip": "Remove zero-area faces and collapsed edges.", + }, + { + "id": "fix_non_manifold", + "label": "Fix Non-Manifold", + "type": "boolean", + "default": True, + "tooltip": "Detach faces causing non-manifold edges.", + }, + { + "id": "fill_holes", + "label": "Fill Holes", + "type": "boolean", + "default": True, + "tooltip": ( + "Fill simple boundary holes. Structural holes from AI generation " + "may not be fillable in post-processing." + ), + }, + { + "id": "max_hole_size", + "label": "Max Hole Size", + "type": "int", + "default": 2000, + "min": 10, + "max": 10000, + "tooltip": ( + "Maximum number of boundary edges of a hole to be filled. " + "Increase if large holes remain open." + ), + }, +) + +DECIMATE_PARAMS = ( + { + "id": "target_faces", + "label": "Target Triangles", + "type": "int", + "default": 10000, + "min": 100, + "max": 1000000, + "tooltip": "Target number of triangles after simplification.", + }, +) + +SMOOTH_PARAMS = ( + { + "id": "iterations", + "label": "Iterations", + "type": "int", + "default": 5, + "min": 1, + "max": 50, + "tooltip": ( + "Number of smoothing passes. More iterations = smoother result " + "but may lose fine details." + ), + }, + { + "id": "lambda_", + "label": "Smoothing Strength", + "type": "float", + "default": 0.5, + "min": 0.1, + "max": 1.0, + "step": 0.05, + "tooltip": ( + "Controls how far each vertex moves toward its neighbours per " + "iteration. Lower = more conservative." + ), + }, + { + "id": "mode", + "label": "Mode", + "type": "select", + "default": "taubin", + "options": [ + {"value": "taubin", "label": "Taubin (volume-preserving)"}, + {"value": "laplacian", "label": "Laplacian (stronger, may shrink)"}, + ], + "tooltip": ( + "Taubin alternates positive/negative steps to prevent mesh " + "shrinkage. Laplacian is simpler but tends to shrink the mesh over " + "many iterations." + ), + }, +) + + +BUILTIN_MESH_OPS = ( + MeshOp( + id="repair", + label="Repair Mesh", + params_schema=REPAIR_PARAMS, + fn=repair_mesh, + category="repair", + ), + MeshOp( + id="decimate", + label="Optimize Mesh", + params_schema=DECIMATE_PARAMS, + fn=decimate_mesh, + category="optimization", + ), + MeshOp( + id="smooth", + label="Smooth Mesh", + params_schema=SMOOTH_PARAMS, + fn=smooth_mesh, + category="optimization", + ), +) diff --git a/api/services/mesh_ops/meshopt_runner.cjs b/api/services/mesh_ops/meshopt_runner.cjs new file mode 100644 index 00000000..3436be44 --- /dev/null +++ b/api/services/mesh_ops/meshopt_runner.cjs @@ -0,0 +1,120 @@ +/** + * meshoptimizer backend for the Python mesh-op registry. + * + * Dependencies are resolved from the built-in mesh-optimizer extension so the + * packaged app keeps one copy of glTF Transform and meshoptimizer. + */ +const fs = require('fs') +const path = require('path') +const Module = require('module') + +function emit(message) { + process.stdout.write(`${JSON.stringify(message)}\n`) +} + +function progress(percent, label) { + emit({ type: 'progress', percent, label }) +} + +function log(message) { + emit({ type: 'log', message: String(message) }) +} + +function countTriangles(document) { + let count = 0 + for (const mesh of document.getRoot().listMeshes()) { + for (const primitive of mesh.listPrimitives()) { + const indices = primitive.getIndices() + if (indices) { + count += Math.round(indices.getCount() / 3) + } else { + const positions = primitive.getAttribute('POSITION') + if (positions) count += Math.round(positions.getCount() / 3) + } + } + } + return count +} + +async function run(payload) { + const requireExtension = Module.createRequire( + path.join(payload.dependencyDir, 'package.json'), + ) + const { NodeIO } = requireExtension('@gltf-transform/core') + const { ALL_EXTENSIONS } = requireExtension('@gltf-transform/extensions') + const { simplify, weld } = requireExtension('@gltf-transform/functions') + const { MeshoptSimplifier } = requireExtension('meshoptimizer') + + const targetFaces = Math.max( + 100, + Math.round(Number(payload.params?.target_faces ?? 10000)), + ) + log(`Target: ${targetFaces} triangles — input: ${payload.inputPath}`) + + await MeshoptSimplifier.ready + + progress(10, 'Loading mesh…') + const io = new NodeIO().registerExtensions(ALL_EXTENSIONS) + const document = await io.read(payload.inputPath) + const currentFaces = countTriangles(document) + log(`Current triangles: ${currentFaces}`) + + if (currentFaces <= targetFaces) { + log('Already within target — skipping simplification') + if (!payload.outputPath) { + progress(100, 'Done') + return { filePath: payload.inputPath, faceCount: currentFaces } + } + + fs.mkdirSync(path.dirname(payload.outputPath), { recursive: true }) + progress(85, 'Writing output…') + await io.write(payload.outputPath, document) + progress(100, 'Done') + log(`Output: ${payload.outputPath}`) + return { filePath: payload.outputPath, faceCount: currentFaces } + } + + const ratio = Math.min(1, targetFaces / currentFaces) + log( + `Simplification ratio: ${ratio.toFixed(4)} ` + + `(~${Math.round(currentFaces * ratio)} triangles)`, + ) + const error = Math.max(0.001, 1 - ratio) + + if (currentFaces < 500000) { + progress(25, 'Welding vertices…') + await document.transform(weld()) + } else { + log(`Skipping weld (${currentFaces} faces > 500k threshold)`) + } + + progress(55, 'Simplifying mesh…') + await document.transform( + simplify({ simplifier: MeshoptSimplifier, ratio, error, lockBorder: false }), + ) + + progress(85, 'Writing output…') + const outputPath = payload.outputPath || path.join( + payload.workspaceDir, + 'Workflows', + `mesh-optimizer-${Date.now()}.glb`, + ) + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + await io.write(outputPath, document) + + progress(100, 'Done') + log(`Output: ${outputPath}`) + return { filePath: outputPath, faceCount: countTriangles(document) } +} + +async function main() { + const raw = fs.readFileSync(0, 'utf8').trim() + if (!raw) throw new Error('mesh-optimizer: missing request payload') + const result = await run(JSON.parse(raw)) + emit({ type: 'done', result }) +} + +main().catch((error) => { + emit({ type: 'error', message: String(error) }) + process.exitCode = 1 +}) diff --git a/api/services/mesh_ops/operations.py b/api/services/mesh_ops/operations.py new file mode 100644 index 00000000..7fae3845 --- /dev/null +++ b/api/services/mesh_ops/operations.py @@ -0,0 +1,419 @@ +"""Canonical implementations for Modly's built-in mesh operations.""" + +import json +import os +import re +import shutil +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Any, Mapping + +from .types import ( + MeshOpContext, + MeshOpExecutionError, + MeshOpResult, + MeshOpUnavailableError, +) + + +def _output_path(context: MeshOpContext, prefix: str) -> Path: + if context.output_path is not None: + output = Path(context.output_path) + else: + output = ( + context.workspace_dir + / "Workflows" + / f"{prefix}-{int(time.time() * 1000)}.glb" + ) + output.parent.mkdir(parents=True, exist_ok=True) + return output + + +def _load_single_mesh(input_path: Path, trimesh_module): + loaded = trimesh_module.load(str(input_path)) + if isinstance(loaded, trimesh_module.Scene): + geometries = list(loaded.geometry.values()) + return ( + trimesh_module.util.concatenate(geometries) + if len(geometries) > 1 + else geometries[0] + ) + return loaded + + +def _raw_geometry(mesh, trimesh_module): + loaded = trimesh_module.load(mesh, process=False) + if isinstance(loaded, trimesh_module.Scene): + geometries = list(loaded.geometry.values()) + loaded = ( + geometries[0] + if len(geometries) == 1 + else trimesh_module.util.concatenate(geometries) + ) + return trimesh_module.Trimesh( + vertices=loaded.vertices, + faces=loaded.faces, + process=False, + ) + + +def _face_count(mesh, trimesh_module) -> int: + if isinstance(mesh, trimesh_module.Scene): + return sum(len(geometry.faces) for geometry in mesh.geometry.values()) + return int(len(mesh.faces)) + + +def _has_texture(geometry, trimesh_module) -> bool: + if not isinstance(geometry.visual, trimesh_module.visual.TextureVisuals): + return False + material = geometry.visual.material + if material is None: + return False + return ( + getattr(material, "image", None) is not None + or getattr(material, "baseColorTexture", None) is not None + ) + + +def _texture_image(geometry): + material = geometry.visual.material + image = getattr(material, "image", None) + return image if image is not None else getattr(material, "baseColorTexture", None) + + +def _point_mtl_at_texture(mtl_path: str) -> None: + path = Path(mtl_path) + if not path.exists(): + return + contents = path.read_text(encoding="utf-8") + path.write_text( + re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", contents), + encoding="utf-8", + ) + + +def _mesh_libraries(operation_name: str): + try: + import pymeshlab + except ImportError as exc: + raise MeshOpUnavailableError( + f"{operation_name}: pymeshlab is not available on this system" + ) from exc + + try: + import trimesh + except ImportError as exc: + raise MeshOpUnavailableError( + f"{operation_name}: trimesh is not available on this system" + ) from exc + + return pymeshlab, trimesh + + +def repair_mesh( + input_path: Path, + params: Mapping[str, Any], + context: MeshOpContext, +) -> MeshOpResult: + """Run the exact repair pipeline previously owned by mesh-repair.""" + pymeshlab, trimesh = _mesh_libraries("mesh-repair") + + remove_duplicates = bool(params.get("remove_duplicates", True)) + fix_non_manifold = bool(params.get("fix_non_manifold", True)) + remove_degenerate = bool(params.get("remove_degenerate", True)) + fill_holes = bool(params.get("fill_holes", True)) + max_hole_size = int(params.get("max_hole_size", 2000)) + output_path = _output_path(context, "mesh-repair") + + context.progress(10, "Loading mesh…") + geometry = _load_single_mesh(input_path, trimesh) + + temporary_dir = tempfile.mkdtemp() + try: + ply_input = os.path.join(temporary_dir, "input.ply") + ply_output = os.path.join(temporary_dir, "output.ply") + geometry.export(ply_input) + + mesh_set = pymeshlab.MeshSet() + mesh_set.load_new_mesh(ply_input) + + current = mesh_set.current_mesh() + context.log( + f"Input: {current.vertex_number()} verts, " + f"{current.face_number()} faces" + ) + + if remove_duplicates: + context.progress(20, "Removing duplicates…") + mesh_set.meshing_remove_duplicate_vertices() + mesh_set.meshing_remove_duplicate_faces() + + if remove_degenerate: + context.progress(40, "Removing degenerate faces…") + mesh_set.meshing_remove_null_faces() + mesh_set.meshing_remove_folded_faces() + + if fix_non_manifold: + context.progress(60, "Fixing non-manifold edges…") + try: + mesh_set.meshing_repair_non_manifold_edges(method=0) + except Exception as exc: + context.log(f"Non-manifold edge repair skipped: {exc}") + try: + mesh_set.meshing_repair_non_manifold_vertices() + except Exception as exc: + context.log(f"Non-manifold vertex repair skipped: {exc}") + + if fill_holes: + context.progress(75, "Filling holes…") + try: + mesh_set.meshing_close_holes( + maxholesize=max_hole_size, + newfaceselected=False, + selfintersection=False, + ) + except Exception as exc: + context.log( + "Hole fill skipped (mesh may still be non-manifold): " + f"{exc}" + ) + + current = mesh_set.current_mesh() + context.log( + f"Output: {current.vertex_number()} verts, " + f"{current.face_number()} faces" + ) + + context.progress(85, "Exporting…") + mesh_set.save_current_mesh(ply_output) + result = _raw_geometry(ply_output, trimesh) + finally: + shutil.rmtree(temporary_dir, ignore_errors=True) + + result.export(str(output_path)) + context.progress(100, "Done") + return MeshOpResult( + file_path=output_path, + details={"face_count": int(len(result.faces))}, + ) + + +def smooth_mesh( + input_path: Path, + params: Mapping[str, Any], + context: MeshOpContext, +) -> MeshOpResult: + """Run the exact Taubin/Laplacian pipeline previously owned by mesh-smoother.""" + pymeshlab, trimesh = _mesh_libraries("mesh-smoother") + + iterations = int(params.get("iterations", 5)) + strength = float(params.get("lambda_", 0.5)) + mode = str(params.get("mode", "taubin")) + output_path = _output_path(context, "mesh-smoother") + + context.log( + f"Mode: {mode}, iterations: {iterations}, strength: {strength}" + ) + context.progress(10, "Loading mesh…") + geometry = _load_single_mesh(input_path, trimesh) + + temporary_dir = tempfile.mkdtemp() + try: + mesh_set = pymeshlab.MeshSet() + if context.preserve_visuals: + context.progress(30, "Smoothing (laplacian)…") + if _has_texture(geometry, trimesh): + obj_input = os.path.join(temporary_dir, "input.obj") + texture_input = os.path.join(temporary_dir, "texture.png") + obj_output = os.path.join(temporary_dir, "output.obj") + + _texture_image(geometry).save(texture_input) + geometry.export(obj_input) + _point_mtl_at_texture(os.path.join(temporary_dir, "input.mtl")) + + mesh_set.load_new_mesh(obj_input) + mesh_set.apply_coord_laplacian_smoothing( + stepsmoothnum=iterations, + ) + context.progress(80, "Exporting…") + mesh_set.save_current_mesh(obj_output) + _point_mtl_at_texture(obj_output.replace(".obj", ".mtl")) + result = trimesh.load(obj_output) + else: + ply_input = os.path.join(temporary_dir, "input.ply") + ply_output = os.path.join(temporary_dir, "output.ply") + geometry.export(ply_input) + mesh_set.load_new_mesh(ply_input) + mesh_set.apply_coord_laplacian_smoothing( + stepsmoothnum=iterations, + ) + context.progress(80, "Exporting…") + mesh_set.save_current_mesh(ply_output) + result = trimesh.load(ply_output, force="mesh") + else: + ply_input = os.path.join(temporary_dir, "input.ply") + ply_output = os.path.join(temporary_dir, "output.ply") + geometry.export(ply_input) + + mesh_set.load_new_mesh(ply_input) + context.progress(30, f"Smoothing ({mode})…") + + if mode == "taubin": + mesh_set.apply_coord_taubin_smoothing( + lambda_=strength, + mu=-strength - 0.01, + stepsmoothnum=iterations, + ) + else: + mesh_set.apply_coord_laplacian_smoothing( + stepsmoothnum=iterations, + cotangentweight=False, + ) + + context.progress(80, "Exporting…") + mesh_set.save_current_mesh(ply_output) + result = _raw_geometry(ply_output, trimesh) + finally: + shutil.rmtree(temporary_dir, ignore_errors=True) + + result.export(str(output_path)) + face_count = _face_count(result, trimesh) + context.log(f"Output: {output_path} ({face_count} faces)") + context.progress(100, "Done") + return MeshOpResult( + file_path=output_path, + details={"face_count": face_count}, + ) + + +def _node_executable() -> tuple[str, bool]: + configured = os.environ.get("MODLY_NODE_EXECUTABLE") + if configured: + executable = Path(configured) + if not executable.is_file(): + raise MeshOpUnavailableError( + f"Configured Node runtime does not exist: {configured}" + ) + return str(executable), True + + executable = shutil.which("node") or shutil.which("nodejs") + if executable is None: + raise MeshOpUnavailableError( + "mesh-optimizer requires Node.js (or Modly's Electron runtime)" + ) + return executable, False + + +def _meshopt_dependency_dir() -> Path: + candidates: list[Path] = [] + extension_dir = os.environ.get("EXTENSION_DIR") + if extension_dir: + candidates.append(Path(extension_dir)) + + app_root = Path(__file__).resolve().parents[3] + candidates.extend( + [ + app_root / "builtin-extensions" / "mesh-optimizer", + app_root / "out" / "builtin-extensions" / "mesh-optimizer", + app_root / "src" / "areas" / "workflows" / "nodes" / "mesh-optimizer", + ] + ) + + for candidate in candidates: + if (candidate / "node_modules" / "meshoptimizer").exists(): + return candidate + + raise MeshOpUnavailableError( + "mesh-optimizer dependencies are unavailable; run `npm run build` " + "before starting Modly from source" + ) + + +def decimate_mesh( + input_path: Path, + params: Mapping[str, Any], + context: MeshOpContext, +) -> MeshOpResult: + """Run the existing glTF Transform + meshoptimizer implementation.""" + executable, electron_runtime = _node_executable() + dependency_dir = _meshopt_dependency_dir() + runner_path = Path(__file__).with_name("meshopt_runner.cjs") + + environment = os.environ.copy() + if electron_runtime: + environment["ELECTRON_RUN_AS_NODE"] = "1" + + payload = { + "inputPath": str(input_path), + "params": dict(params), + "workspaceDir": str(context.workspace_dir), + "dependencyDir": str(dependency_dir), + "outputPath": ( + str(context.output_path) if context.output_path is not None else None + ), + } + + process = subprocess.Popen( + [executable, str(runner_path)], + cwd=str(dependency_dir), + env=environment, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + if process.stdin is None or process.stdout is None or process.stderr is None: + process.kill() + raise MeshOpExecutionError("mesh-optimizer failed to open its I/O pipes") + + process.stdin.write(json.dumps(payload) + "\n") + process.stdin.close() + + result_payload: dict[str, Any] | None = None + backend_error: str | None = None + for raw_line in process.stdout: + line = raw_line.strip() + if not line: + continue + try: + message = json.loads(line) + except json.JSONDecodeError: + context.log(line) + continue + + message_type = message.get("type") + if message_type == "progress": + context.progress( + int(message.get("percent", 0)), + str(message.get("label", "")), + ) + elif message_type == "log": + context.log(str(message.get("message", ""))) + elif message_type == "done": + result_payload = message.get("result") or {} + elif message_type == "error": + backend_error = str(message.get("message", "Unknown error")) + + stderr = process.stderr.read().strip() + return_code = process.wait() + if backend_error is not None: + raise MeshOpExecutionError(backend_error) + if return_code != 0: + raise MeshOpExecutionError( + stderr or f"mesh-optimizer exited with code {return_code}" + ) + if result_payload is None or not result_payload.get("filePath"): + raise MeshOpExecutionError("mesh-optimizer returned no output file") + + details: dict[str, Any] = {} + if result_payload.get("faceCount") is not None: + details["face_count"] = int(result_payload["faceCount"]) + return MeshOpResult( + file_path=Path(result_payload["filePath"]), + details=details, + ) diff --git a/api/services/mesh_ops/processor.py b/api/services/mesh_ops/processor.py new file mode 100644 index 00000000..9af04353 --- /dev/null +++ b/api/services/mesh_ops/processor.py @@ -0,0 +1,71 @@ +"""Adapter between workflow process-node NDJSON and the mesh-op registry.""" + +import json +import os +import sys +import tempfile +import traceback +from pathlib import Path + +from . import MeshOpContext, mesh_ops_registry + + +def _emit(message: dict) -> None: + print(json.dumps(message), flush=True) + + +def run_processor(operation_id: str, processor_id: str) -> None: + """Read one workflow request, run a registered op, and emit its result.""" + try: + raw = sys.stdin.readline() + if not raw: + raise ValueError(f"{processor_id}: missing request payload") + data = json.loads(raw) + input_data = data.get("input") or {} + input_path = input_data.get("filePath") + if not input_path or not Path(input_path).is_file(): + if processor_id == "mesh-optimizer": + raise FileNotFoundError( + "mesh-optimizer: input.filePath is required" + ) + raise FileNotFoundError( + f"{processor_id}: input file not found: {input_path}" + ) + + workspace_dir = Path( + data.get("workspaceDir") + or os.environ.get("WORKSPACE_DIR") + or Path.home() / ".modly" / "workspace" + ) + temp_dir = Path( + data.get("tempDir") + or os.environ.get("TEMP_DIR") + or tempfile.gettempdir() + ) + context = MeshOpContext( + workspace_dir=workspace_dir, + temp_dir=temp_dir, + progress_cb=lambda percent, label: _emit( + {"type": "progress", "percent": percent, "label": label} + ), + log_cb=lambda message: _emit({"type": "log", "message": message}), + ) + result = mesh_ops_registry.run( + operation_id, + Path(input_path), + data.get("params") or {}, + context, + ) + _emit( + { + "type": "done", + "result": {"filePath": str(result.file_path)}, + } + ) + except Exception as exc: + _emit( + { + "type": "error", + "message": f"{exc}\n{traceback.format_exc()}", + } + ) diff --git a/api/services/mesh_ops/registry.py b/api/services/mesh_ops/registry.py new file mode 100644 index 00000000..02f3f901 --- /dev/null +++ b/api/services/mesh_ops/registry.py @@ -0,0 +1,58 @@ +"""Registry and dispatcher for mesh-editing operations.""" + +import re +from copy import deepcopy +from pathlib import Path +from typing import Any, Iterable, Mapping, Optional + +from .types import MeshOp, MeshOpContext, MeshOpNotFoundError, MeshOpResult + + +_OP_ID = re.compile(r"^[a-z][a-z0-9_-]*$") + + +class MeshOpsRegistry: + """Stores mesh operations and provides one invocation path for every caller.""" + + def __init__(self, operations: Iterable[MeshOp] = ()) -> None: + self._operations: dict[str, MeshOp] = {} + for operation in operations: + self.register(operation) + + def register(self, operation: MeshOp) -> None: + if not _OP_ID.fullmatch(operation.id): + raise ValueError(f"Invalid mesh operation id: {operation.id!r}") + if operation.id in self._operations: + raise ValueError(f"Duplicate mesh operation id: {operation.id!r}") + self._operations[operation.id] = operation + + def get(self, operation_id: str) -> MeshOp: + try: + return self._operations[operation_id] + except KeyError as exc: + raise MeshOpNotFoundError(operation_id) from exc + + def describe(self) -> list[dict[str, Any]]: + return [operation.describe() for operation in self._operations.values()] + + def run( + self, + operation_id: str, + input_path: Path, + params: Optional[Mapping[str, Any]], + context: MeshOpContext, + ) -> MeshOpResult: + operation = self.get(operation_id) + path = Path(input_path) + if not path.is_file(): + raise FileNotFoundError(f"Input mesh not found: {path}") + + resolved_params = { + schema["id"]: deepcopy(schema["default"]) + for schema in operation.params_schema + if "id" in schema and "default" in schema + } + if params: + resolved_params.update(params) + + return operation.fn(path, resolved_params, context) diff --git a/api/services/mesh_ops/types.py b/api/services/mesh_ops/types.py new file mode 100644 index 00000000..5951bfe1 --- /dev/null +++ b/api/services/mesh_ops/types.py @@ -0,0 +1,77 @@ +"""Shared types for Modly mesh operations.""" + +from copy import deepcopy +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Mapping, Optional + + +ProgressCallback = Callable[[int, str], None] +LogCallback = Callable[[str], None] + + +class MeshOpNotFoundError(LookupError): + """Raised when a caller requests an operation that is not registered.""" + + +class MeshOpUnavailableError(RuntimeError): + """Raised when an operation's runtime dependency is unavailable.""" + + +class MeshOpExecutionError(RuntimeError): + """Raised when an operation backend fails while processing a mesh.""" + + +@dataclass(frozen=True) +class MeshOpContext: + """Runtime paths and optional workflow-protocol callbacks for an operation.""" + + workspace_dir: Path + temp_dir: Path + output_path: Optional[Path] = None + preserve_visuals: bool = False + progress_cb: Optional[ProgressCallback] = None + log_cb: Optional[LogCallback] = None + + def progress(self, percent: int, label: str) -> None: + if self.progress_cb is not None: + self.progress_cb(percent, label) + + def log(self, message: str) -> None: + if self.log_cb is not None: + self.log_cb(message) + + +@dataclass(frozen=True) +class MeshOpResult: + """The file produced by an operation and optional JSON-safe measurements.""" + + file_path: Path + details: Mapping[str, Any] = field(default_factory=dict) + + +MeshOpFn = Callable[[Path, Mapping[str, Any], MeshOpContext], MeshOpResult] + + +@dataclass(frozen=True) +class MeshOp: + """One callable operation and the metadata consumed by the UI and agent.""" + + id: str + label: str + params_schema: tuple[Mapping[str, Any], ...] + fn: MeshOpFn + category: str + destructive: bool = False + undoable: bool = True + + def describe(self) -> dict[str, Any]: + """Return the public, serializable part of this registry entry.""" + return { + "id": self.id, + "label": self.label, + "params_schema": deepcopy(list(self.params_schema)), + "destructive": self.destructive, + "undoable": self.undoable, + "category": self.category, + } diff --git a/api/tests/test_mesh_ops_operations.py b/api/tests/test_mesh_ops_operations.py new file mode 100644 index 00000000..b7ffeff0 --- /dev/null +++ b/api/tests/test_mesh_ops_operations.py @@ -0,0 +1,323 @@ +import io +import json +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from services.mesh_ops import MeshOpContext +from services.mesh_ops import operations + + +class _FakeGeometry: + def __init__(self, faces=None) -> None: + self.faces = faces if faces is not None else [1, 2, 3] + self.vertices = [1, 2, 3, 4] + self.exports = [] + + def export(self, path) -> None: + self.exports.append(str(path)) + Path(path).touch() + + +class _FakeScene: + pass + + +class _FakeMesh: + def vertex_number(self) -> int: + return 4 + + def face_number(self) -> int: + return 3 + + +class _FakeMeshSet: + def __init__(self) -> None: + self.calls = [] + + def current_mesh(self): + return _FakeMesh() + + def __getattr__(self, name): + def call(*args, **kwargs): + self.calls.append((name, args, kwargs)) + if name == "save_current_mesh": + Path(args[0]).touch() + + return call + + +class _InputCapture: + def __init__(self) -> None: + self.value = "" + + def write(self, value) -> None: + self.value += value + + def close(self) -> None: + pass + + +class _FakeProcess: + def __init__(self, messages, return_code=0, stderr="") -> None: + self.stdin = _InputCapture() + self.stdout = iter(f"{json.dumps(message)}\n" for message in messages) + self.stderr = io.StringIO(stderr) + self.return_code = return_code + self.killed = False + + def wait(self) -> int: + return self.return_code + + def kill(self) -> None: + self.killed = True + + +class MeshOpOperationRegressionTests(unittest.TestCase): + def test_meshopt_runner_is_valid_javascript(self) -> None: + node = shutil.which("node") or shutil.which("nodejs") + if node is None: + self.skipTest("Node.js is unavailable") + runner = Path(operations.__file__).with_name("meshopt_runner.cjs") + result = subprocess.run( + [node, "--check", str(runner)], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_repair_keeps_the_original_filter_order_and_arguments(self) -> None: + mesh_set = _FakeMeshSet() + source = _FakeGeometry() + result_geometry = _FakeGeometry(faces=[1, 2]) + events = [] + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + input_path = root / "input.glb" + output_path = root / "output.glb" + input_path.touch() + context = MeshOpContext( + workspace_dir=root, + temp_dir=root, + output_path=output_path, + progress_cb=lambda percent, label: events.append( + ("progress", percent, label) + ), + log_cb=lambda message: events.append(("log", message)), + ) + + with ( + patch.object( + operations, + "_mesh_libraries", + return_value=( + SimpleNamespace(MeshSet=lambda: mesh_set), + object(), + ), + ), + patch.object(operations, "_load_single_mesh", return_value=source), + patch.object( + operations, + "_raw_geometry", + return_value=result_geometry, + ), + ): + result = operations.repair_mesh(input_path, {}, context) + + self.assertEqual(result.file_path, output_path) + self.assertEqual(result.details, {"face_count": 2}) + self.assertEqual( + [call[0] for call in mesh_set.calls], + [ + "load_new_mesh", + "meshing_remove_duplicate_vertices", + "meshing_remove_duplicate_faces", + "meshing_remove_null_faces", + "meshing_remove_folded_faces", + "meshing_repair_non_manifold_edges", + "meshing_repair_non_manifold_vertices", + "meshing_close_holes", + "save_current_mesh", + ], + ) + self.assertEqual(mesh_set.calls[5][2], {"method": 0}) + self.assertEqual( + mesh_set.calls[7][2], + { + "maxholesize": 2000, + "newfaceselected": False, + "selfintersection": False, + }, + ) + self.assertIn(("progress", 100, "Done"), events) + + def test_smooth_keeps_taubin_and_laplacian_parameter_semantics(self) -> None: + for mode, expected_method, expected_arguments in ( + ( + "taubin", + "apply_coord_taubin_smoothing", + {"lambda_": 0.4, "mu": -0.41000000000000003, "stepsmoothnum": 7}, + ), + ( + "laplacian", + "apply_coord_laplacian_smoothing", + {"stepsmoothnum": 7, "cotangentweight": False}, + ), + ): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory: + mesh_set = _FakeMeshSet() + geometry = _FakeGeometry() + root = Path(directory) + input_path = root / "input.glb" + input_path.touch() + context = MeshOpContext( + workspace_dir=root, + temp_dir=root, + output_path=root / "output.glb", + ) + + with ( + patch.object( + operations, + "_mesh_libraries", + return_value=( + SimpleNamespace(MeshSet=lambda: mesh_set), + SimpleNamespace(Scene=_FakeScene), + ), + ), + patch.object( + operations, + "_load_single_mesh", + return_value=geometry, + ), + patch.object( + operations, + "_raw_geometry", + return_value=geometry, + ), + ): + operations.smooth_mesh( + input_path, + {"iterations": 7, "lambda_": 0.4, "mode": mode}, + context, + ) + + smoothing_call = next( + call for call in mesh_set.calls if call[0] == expected_method + ) + self.assertEqual(smoothing_call[2], expected_arguments) + + def test_legacy_smooth_keeps_its_original_laplacian_arguments(self) -> None: + mesh_set = _FakeMeshSet() + geometry = _FakeGeometry() + fake_trimesh = SimpleNamespace( + Scene=_FakeScene, + load=lambda path, **kwargs: geometry, + ) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + input_path = root / "input.glb" + input_path.touch() + context = MeshOpContext( + workspace_dir=root, + temp_dir=root, + output_path=root / "output.glb", + preserve_visuals=True, + ) + with ( + patch.object( + operations, + "_mesh_libraries", + return_value=( + SimpleNamespace(MeshSet=lambda: mesh_set), + fake_trimesh, + ), + ), + patch.object( + operations, + "_load_single_mesh", + return_value=geometry, + ), + patch.object(operations, "_has_texture", return_value=False), + ): + operations.smooth_mesh( + input_path, + {"iterations": 9, "lambda_": 0.5, "mode": "laplacian"}, + context, + ) + + smoothing_call = next( + call + for call in mesh_set.calls + if call[0] == "apply_coord_laplacian_smoothing" + ) + self.assertEqual(smoothing_call[2], {"stepsmoothnum": 9}) + + def test_decimate_forwards_meshopt_progress_logs_and_result(self) -> None: + messages = [ + {"type": "log", "message": "Current triangles: 12"}, + {"type": "progress", "percent": 55, "label": "Simplifying mesh…"}, + { + "type": "done", + "result": {"filePath": "/workspace/result.glb", "faceCount": 5}, + }, + ] + fake_process = _FakeProcess(messages) + events = [] + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + input_path = root / "input.glb" + input_path.touch() + context = MeshOpContext( + workspace_dir=root, + temp_dir=root, + progress_cb=lambda percent, label: events.append( + ("progress", percent, label) + ), + log_cb=lambda message: events.append(("log", message)), + ) + with ( + patch.object( + operations, + "_node_executable", + return_value=("node", False), + ), + patch.object( + operations, + "_meshopt_dependency_dir", + return_value=root, + ), + patch.object( + operations.subprocess, + "Popen", + return_value=fake_process, + ) as popen, + ): + result = operations.decimate_mesh( + input_path, + {"target_faces": 5}, + context, + ) + + self.assertEqual(result.file_path, Path("/workspace/result.glb")) + self.assertEqual(result.details, {"face_count": 5}) + self.assertIn(("log", "Current triangles: 12"), events) + self.assertIn(("progress", 55, "Simplifying mesh…"), events) + command = popen.call_args.args[0] + self.assertEqual(command[0], "node") + self.assertTrue(command[1].endswith("meshopt_runner.cjs")) + payload = json.loads(fake_process.stdin.value) + self.assertEqual(payload["inputPath"], str(input_path)) + self.assertEqual(payload["params"], {"target_faces": 5}) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_mesh_ops_processor.py b/api/tests/test_mesh_ops_processor.py new file mode 100644 index 00000000..2f8a6659 --- /dev/null +++ b/api/tests/test_mesh_ops_processor.py @@ -0,0 +1,76 @@ +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from services.mesh_ops import MeshOpResult +from services.mesh_ops import processor + + +class _FakeRegistry: + def __init__(self, output_path: Path) -> None: + self.output_path = output_path + self.calls = [] + + def run(self, operation_id, input_path, params, context): + self.calls.append((operation_id, input_path, params, context)) + context.progress(35, "Working…") + context.log("shared implementation") + return MeshOpResult(self.output_path) + + +class MeshOpProcessorTests(unittest.TestCase): + def test_workflow_protocol_forwards_to_registry_and_preserves_events(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + input_path = root / "input.glb" + output_path = root / "output.glb" + input_path.touch() + registry = _FakeRegistry(output_path) + request = { + "input": {"filePath": str(input_path)}, + "params": {"iterations": 7}, + "workspaceDir": str(root), + "tempDir": str(root / "tmp"), + } + + stdout = io.StringIO() + with ( + patch.object(processor, "mesh_ops_registry", registry), + patch.object(processor.sys, "stdin", io.StringIO(json.dumps(request))), + redirect_stdout(stdout), + ): + processor.run_processor("smooth", "mesh-smoother") + + messages = [json.loads(line) for line in stdout.getvalue().splitlines()] + self.assertEqual( + [message["type"] for message in messages], + ["progress", "log", "done"], + ) + self.assertEqual(messages[-1]["result"]["filePath"], str(output_path)) + self.assertEqual(registry.calls[0][0:3], ( + "smooth", + input_path, + {"iterations": 7}, + )) + self.assertEqual(registry.calls[0][3].workspace_dir, root) + + def test_missing_input_uses_existing_node_error_contract(self) -> None: + stdout = io.StringIO() + request = {"input": {}, "params": {}} + with ( + patch.object(processor.sys, "stdin", io.StringIO(json.dumps(request))), + redirect_stdout(stdout), + ): + processor.run_processor("repair", "mesh-repair") + + message = json.loads(stdout.getvalue()) + self.assertEqual(message["type"], "error") + self.assertIn("mesh-repair: input file not found: None", message["message"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_mesh_ops_registry.py b/api/tests/test_mesh_ops_registry.py new file mode 100644 index 00000000..7274c9dd --- /dev/null +++ b/api/tests/test_mesh_ops_registry.py @@ -0,0 +1,123 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from services.mesh_ops import ( + MeshOp, + MeshOpContext, + MeshOpNotFoundError, + MeshOpResult, + MeshOpsRegistry, + mesh_ops_registry, +) + + +class MeshOpsRegistryTests(unittest.TestCase): + def test_builtin_metadata_is_serializable_and_complete(self) -> None: + descriptions = mesh_ops_registry.describe() + + self.assertEqual( + [description["id"] for description in descriptions], + ["repair", "decimate", "smooth"], + ) + for description in descriptions: + self.assertIn(description["category"], {"repair", "optimization"}) + self.assertFalse(description["destructive"]) + self.assertTrue(description["undoable"]) + self.assertIsInstance(description["params_schema"], list) + json.dumps(descriptions) + + def test_run_applies_schema_defaults_without_mutating_metadata(self) -> None: + calls = [] + + def operation(input_path, params, context): + calls.append((input_path, params, context)) + return MeshOpResult(input_path) + + registry = MeshOpsRegistry( + [ + MeshOp( + id="example", + label="Example", + params_schema=( + {"id": "amount", "type": "int", "default": 3}, + ), + fn=operation, + category="test", + ) + ] + ) + + with tempfile.TemporaryDirectory() as directory: + input_path = Path(directory) / "mesh.glb" + input_path.touch() + context = MeshOpContext(Path(directory), Path(directory)) + registry.run("example", input_path, {"extra": True}, context) + + self.assertEqual(calls[0][1], {"amount": 3, "extra": True}) + calls[0][1]["amount"] = 99 + self.assertEqual( + registry.describe()[0]["params_schema"][0]["default"], + 3, + ) + + def test_invalid_duplicate_and_unknown_ids_are_rejected(self) -> None: + operation = MeshOp( + id="valid", + label="Valid", + params_schema=(), + fn=lambda path, params, context: MeshOpResult(path), + category="test", + ) + registry = MeshOpsRegistry([operation]) + + with self.assertRaisesRegex(ValueError, "Duplicate"): + registry.register(operation) + with self.assertRaisesRegex(ValueError, "Invalid"): + registry.register( + MeshOp( + id="Not Valid", + label="Invalid", + params_schema=(), + fn=operation.fn, + category="test", + ) + ) + with self.assertRaises(MeshOpNotFoundError): + registry.get("missing") + + def test_workflow_manifests_share_registry_schemas_and_thin_adapters(self) -> None: + repository_root = Path(__file__).resolve().parents[2] + nodes_root = repository_root / "src" / "areas" / "workflows" / "nodes" + cases = { + "repair": ("mesh-repair", "repair"), + "decimate": ("mesh-optimizer", "decimate"), + "smooth": ("mesh-smoother", "smooth"), + } + + descriptions = { + description["id"]: description + for description in mesh_ops_registry.describe() + } + for operation_id, (extension_id, wrapper_operation_id) in cases.items(): + extension_dir = nodes_root / extension_id + manifest = json.loads( + (extension_dir / "manifest.json").read_text(encoding="utf-8") + ) + self.assertEqual(manifest["entry"], "processor.py") + self.assertEqual( + manifest["nodes"][0]["params_schema"], + descriptions[operation_id]["params_schema"], + ) + + wrapper = (extension_dir / "processor.py").read_text(encoding="utf-8") + self.assertIn( + f'run_processor("{wrapper_operation_id}", "{extension_id}")', + wrapper, + ) + self.assertLess(len(wrapper.splitlines()), 30) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_optimize_mesh_ops.py b/api/tests/test_optimize_mesh_ops.py new file mode 100644 index 00000000..6e46dd9f --- /dev/null +++ b/api/tests/test_optimize_mesh_ops.py @@ -0,0 +1,125 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from fastapi import HTTPException + +from routers import optimize +from services.mesh_ops import MeshOpNotFoundError, MeshOpResult + + +class _FakeRegistry: + def __init__(self, output_path: Path) -> None: + self.output_path = output_path + self.calls = [] + + def describe(self): + return [{"id": "repair", "category": "repair", "params_schema": []}] + + def run(self, operation_id, input_path, params, context): + self.calls.append((operation_id, input_path, params, context)) + output_path = context.output_path or self.output_path + return MeshOpResult(output_path, {"face_count": 42}) + + +class OptimizeMeshOpsRouteTests(unittest.TestCase): + def test_generic_list_and_run_routes_use_the_shared_registry(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory) + input_path = workspace / "input.glb" + output_path = workspace / "Workflows" / "output.glb" + input_path.touch() + registry = _FakeRegistry(output_path) + + with ( + patch.object(optimize, "WORKSPACE_DIR", workspace), + patch.object(optimize, "mesh_ops_registry", registry), + ): + descriptions = optimize.list_mesh_operations() + response = optimize.run_mesh_operation( + "repair", + optimize.MeshOpRequest( + path="input.glb", + params={"fill_holes": False}, + ), + ) + + self.assertEqual(descriptions[0]["id"], "repair") + self.assertEqual( + response, + { + "path": "Workflows/output.glb", + "url": "/workspace/Workflows/output.glb", + "face_count": 42, + }, + ) + self.assertEqual(registry.calls[0][0:3], ( + "repair", + input_path, + {"fill_holes": False}, + )) + + def test_legacy_routes_delegate_with_their_existing_clamps_and_names(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory) + input_path = workspace / "model.glb" + fallback_output = workspace / "Workflows" / "unused.glb" + input_path.touch() + registry = _FakeRegistry(fallback_output) + + with ( + patch.object(optimize, "WORKSPACE_DIR", workspace), + patch.object(optimize, "mesh_ops_registry", registry), + ): + optimize_response = optimize.optimize_mesh( + optimize.OptimizeRequest(path="model.glb", target_faces=2) + ) + smooth_response = optimize.smooth_mesh( + optimize.SmoothRequest(path="model.glb", iterations=99) + ) + + optimize_call, smooth_call = registry.calls + self.assertEqual(optimize_call[0], "decimate") + self.assertEqual(optimize_call[2], {"target_faces": 100}) + self.assertEqual( + optimize_call[3].output_path, + workspace / "model_opt100.glb", + ) + self.assertEqual(smooth_call[0], "smooth") + self.assertEqual( + smooth_call[2], + {"iterations": 20, "lambda_": 0.5, "mode": "laplacian"}, + ) + self.assertEqual( + smooth_call[3].output_path, + workspace / "model_smooth20.glb", + ) + self.assertTrue(smooth_call[3].preserve_visuals) + self.assertEqual(optimize_response["face_count"], 42) + self.assertEqual(smooth_response["url"], "/workspace/model_smooth20.glb") + + def test_unknown_generic_operation_is_a_404(self) -> None: + class MissingRegistry: + def run(self, operation_id, input_path, params, context): + raise MeshOpNotFoundError(operation_id) + + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory) + input_path = workspace / "input.glb" + input_path.touch() + with ( + patch.object(optimize, "WORKSPACE_DIR", workspace), + patch.object(optimize, "mesh_ops_registry", MissingRegistry()), + self.assertRaises(HTTPException) as raised, + ): + optimize.run_mesh_operation( + "missing", + optimize.MeshOpRequest(path="input.glb"), + ) + + self.assertEqual(raised.exception.status_code, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/electron/main/process-runner.ts b/electron/main/process-runner.ts index 758f62d2..0039f5ff 100644 --- a/electron/main/process-runner.ts +++ b/electron/main/process-runner.ts @@ -2,6 +2,7 @@ import { Worker } from 'worker_threads' import { spawn } from 'child_process' import { existsSync } from 'fs' import { join } from 'path' +import { app } from 'electron' // ─── Worker code for JS process extensions ──────────────────────────────────── @@ -160,12 +161,14 @@ export class ProcessRunner implements IProcessRunner { export class PythonProcessRunner implements IProcessRunner { private pythonExe: string + private extDir: string private scriptPath: string private workspaceDir: string private tempDir: string constructor(pythonExe: string, extDir: string, entry: string, workspaceDir: string, tempDir: string) { this.pythonExe = pythonExe + this.extDir = extDir this.scriptPath = join(extDir, entry) this.workspaceDir = workspaceDir this.tempDir = tempDir @@ -180,9 +183,22 @@ export class PythonProcessRunner implements IProcessRunner { return new Promise((resolve, reject) => { const proc = spawn(this.pythonExe, [this.scriptPath], { stdio: ['pipe', 'pipe', 'pipe'], - // Force UTF-8 stdio so Unicode prints from process extensions do not - // crash under legacy Windows codepages (cp1252/cp932). - env: { ...process.env, PYTHONUTF8: '1' }, + env: { + ...process.env, + // Force UTF-8 stdio so Unicode prints from process extensions do not + // crash under legacy Windows codepages (cp1252/cp932). + PYTHONUTF8: '1', + // Built-in process nodes may import shared services from the backend. + MODLY_API_DIR: app.isPackaged + ? join(process.resourcesPath, 'api') + : join(app.getAppPath(), 'api'), + // Electron is also the packaged Node runtime. meshopt_runner.cjs uses + // ELECTRON_RUN_AS_NODE when it launches this executable. + MODLY_NODE_EXECUTABLE: process.execPath, + EXTENSION_DIR: this.extDir, + WORKSPACE_DIR: this.workspaceDir, + TEMP_DIR: this.tempDir, + }, }) // Send input as a single JSON line on stdin diff --git a/electron/main/python-bridge.ts b/electron/main/python-bridge.ts index 94dcfb63..0f8a7d82 100644 --- a/electron/main/python-bridge.ts +++ b/electron/main/python-bridge.ts @@ -52,6 +52,9 @@ export class PythonBridge { env: { ...cleanPythonEnv(), PYTHONUNBUFFERED: '1', + // mesh_ops uses Electron in Node mode for the existing meshoptimizer + // backend, so packaged builds do not depend on a system Node install. + MODLY_NODE_EXECUTABLE: process.execPath, // No PYTHONPATH needed - the venv's Python has its own isolated site-packages MODELS_DIR: this.resolveModelsDir(), WORKSPACE_DIR: this.resolveWorkspaceDir(), diff --git a/src/areas/workflows/nodes/mesh-optimizer/manifest.json b/src/areas/workflows/nodes/mesh-optimizer/manifest.json index 1b13004e..e2480dda 100644 --- a/src/areas/workflows/nodes/mesh-optimizer/manifest.json +++ b/src/areas/workflows/nodes/mesh-optimizer/manifest.json @@ -2,7 +2,7 @@ "id": "mesh-optimizer", "name": "Mesh Optimizer", "type": "process", - "entry": "processor.js", + "entry": "processor.py", "version": "1.0.0", "author": "Modly", "description": "Reduces mesh triangle count using quadric simplification (meshoptimizer).", diff --git a/src/areas/workflows/nodes/mesh-optimizer/processor.py b/src/areas/workflows/nodes/mesh-optimizer/processor.py new file mode 100644 index 00000000..c2842873 --- /dev/null +++ b/src/areas/workflows/nodes/mesh-optimizer/processor.py @@ -0,0 +1,22 @@ +"""Thin workflow adapter for the shared meshoptimizer operation.""" + +import os +import sys +from pathlib import Path + + +api_dir = os.environ.get("MODLY_API_DIR") +if not api_dir: + for parent in Path(__file__).resolve().parents: + candidate = parent / "api" + if candidate.is_dir(): + api_dir = str(candidate) + break +if api_dir and api_dir not in sys.path: + sys.path.insert(0, api_dir) + +from services.mesh_ops.processor import run_processor + + +if __name__ == "__main__": + run_processor("decimate", "mesh-optimizer") diff --git a/src/areas/workflows/nodes/mesh-optimizer/processor.ts b/src/areas/workflows/nodes/mesh-optimizer/processor.ts deleted file mode 100644 index f6f16d4e..00000000 --- a/src/areas/workflows/nodes/mesh-optimizer/processor.ts +++ /dev/null @@ -1,88 +0,0 @@ -import path = require('path') - -interface ProcessInput { filePath?: string; text?: string } -interface ProcessResult { filePath?: string; text?: string } -interface ProcessContext { - workspaceDir: string - tempDir: string - log: (msg: string) => void - progress: (pct: number, label: string) => void -} - -const processor = async ( - input: ProcessInput, - params: Record, - context: ProcessContext, -): Promise => { - if (!input.filePath) throw new Error('mesh-optimizer: input.filePath is required') - - const targetFaces = Math.max(100, Math.round(Number(params['target_faces'] ?? 10000))) - context.log(`Target: ${targetFaces} triangles — input: ${input.filePath}`) - - // Lazy requires — resolved from the extension's own node_modules - const { NodeIO } = require('@gltf-transform/core') - const { ALL_EXTENSIONS } = require('@gltf-transform/extensions') - const { simplify, weld } = require('@gltf-transform/functions') - const { MeshoptSimplifier } = require('meshoptimizer') - - // MeshoptSimplifier loads a WASM binary asynchronously - await MeshoptSimplifier.ready - - context.progress(10, 'Loading mesh…') - const io = new NodeIO().registerExtensions(ALL_EXTENSIONS) - const doc = await io.read(input.filePath) - - // Count current triangles across all primitives - let currentFaces = 0 - for (const mesh of doc.getRoot().listMeshes()) { - for (const prim of mesh.listPrimitives()) { - const indices = prim.getIndices() - if (indices) { - currentFaces += Math.round(indices.getCount() / 3) - } else { - const pos = prim.getAttribute('POSITION') - if (pos) currentFaces += Math.round(pos.getCount() / 3) - } - } - } - context.log(`Current triangles: ${currentFaces}`) - - if (currentFaces <= targetFaces) { - context.log('Already within target — skipping simplification') - context.progress(100, 'Done') - return { filePath: input.filePath } - } - - const ratio = Math.min(1, targetFaces / currentFaces) - context.log(`Simplification ratio: ${ratio.toFixed(4)} (~${Math.round(currentFaces * ratio)} triangles)`) - - // error tolerance scales with aggressiveness: tighter simplification needs more room - const error = Math.max(0.001, 1 - ratio) - - // Skip weld on large meshes — deduplication is O(N²) and stalls for millions of faces - if (currentFaces < 500_000) { - context.progress(25, 'Welding vertices…') - await doc.transform(weld()) - } else { - context.log(`Skipping weld (${currentFaces} faces > 500k threshold)`) - } - - context.progress(55, 'Simplifying mesh…') - await doc.transform( - simplify({ simplifier: MeshoptSimplifier, ratio, error, lockBorder: false }), - ) - - context.progress(85, 'Writing output…') - // Save to workspaceDir/Workflows/ so the result lands in the workspace - const outDir = path.join(context.workspaceDir, 'Workflows') - require('fs').mkdirSync(outDir, { recursive: true }) - const outPath = path.join(outDir, `mesh-optimizer-${Date.now()}.glb`) - await io.write(outPath, doc) - - context.progress(100, 'Done') - context.log(`Output: ${outPath}`) - - return { filePath: outPath } -} - -export = processor diff --git a/src/areas/workflows/nodes/mesh-repair/processor.py b/src/areas/workflows/nodes/mesh-repair/processor.py index b09979e4..6ff43d02 100644 --- a/src/areas/workflows/nodes/mesh-repair/processor.py +++ b/src/areas/workflows/nodes/mesh-repair/processor.py @@ -1,153 +1,22 @@ -""" -Mesh Repair — built-in process extension. +"""Thin workflow adapter for the shared repair mesh operation.""" -Fixes common topology issues in AI-generated meshes: - - Duplicate vertices and faces - - Non-manifold edges - - Degenerate (zero-area) faces - - Simple boundary holes - -Note: structural holes from FlexiCubes/TRELLIS voxel extraction cannot be -reliably closed in post-processing. Increase the generator's remesh resolution -to reduce them at the source. - -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-repair: input file not found: {input_path}") - return - - do_remove_dupes = bool(params.get("remove_duplicates", True)) - do_fix_non_manifold = bool(params.get("fix_non_manifold", True)) - do_remove_degen = bool(params.get("remove_degenerate", True)) - do_fill_holes = bool(params.get("fill_holes", True)) - max_hole_size = int(params.get("max_hole_size", 2000)) - - 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-repair-{int(time() * 1000)}.glb") - - try: - import pymeshlab - except ImportError: - error("mesh-repair: 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) - - log(f"Input: {ms.current_mesh().vertex_number()} verts, {ms.current_mesh().face_number()} faces") - - if do_remove_dupes: - progress(20, "Removing duplicates…") - ms.meshing_remove_duplicate_vertices() - ms.meshing_remove_duplicate_faces() - - if do_remove_degen: - progress(40, "Removing degenerate faces…") - ms.meshing_remove_null_faces() - ms.meshing_remove_folded_faces() - - if do_fix_non_manifold: - progress(60, "Fixing non-manifold edges…") - # method=0 removes offending faces (low memory); method=1 detaches (OOMs on dense meshes) - try: - ms.meshing_repair_non_manifold_edges(method=0) - except Exception as e: - log(f"Non-manifold edge repair skipped: {e}") - try: - ms.meshing_repair_non_manifold_vertices() - except Exception as e: - log(f"Non-manifold vertex repair skipped: {e}") - - if do_fill_holes: - progress(75, "Filling holes…") - try: - ms.meshing_close_holes( - maxholesize=max_hole_size, - newfaceselected=False, - selfintersection=False, - ) - except Exception as e: - log(f"Hole fill skipped (mesh may still be non-manifold): {e}") - - after = ms.current_mesh().face_number() - log(f"Output: {ms.current_mesh().vertex_number()} verts, {after} faces") - - progress(85, "Exporting…") - ms.save_current_mesh(ply_out) - _loaded = trimesh.load(ply_out, process=False) - if isinstance(_loaded, trimesh.Scene): - _geoms = list(_loaded.geometry.values()) - _loaded = _geoms[0] if len(_geoms) == 1 else trimesh.util.concatenate(_geoms) - result = trimesh.Trimesh(vertices=_loaded.vertices, faces=_loaded.faces, process=False) - finally: - shutil.rmtree(tmp_dir, ignore_errors=True) +api_dir = os.environ.get("MODLY_API_DIR") +if not api_dir: + for parent in Path(__file__).resolve().parents: + candidate = parent / "api" + if candidate.is_dir(): + api_dir = str(candidate) + break +if api_dir and api_dir not in sys.path: + sys.path.insert(0, api_dir) - result.export(out_path) - progress(100, "Done") - done(out_path) +from services.mesh_ops.processor import run_processor if __name__ == "__main__": - try: - main() - except Exception as exc: - import traceback - error(f"{exc}\n{traceback.format_exc()}") + run_processor("repair", "mesh-repair") diff --git a/src/areas/workflows/nodes/mesh-smoother/processor.py b/src/areas/workflows/nodes/mesh-smoother/processor.py index 3ae78b61..b59dcb38 100644 --- a/src/areas/workflows/nodes/mesh-smoother/processor.py +++ b/src/areas/workflows/nodes/mesh-smoother/processor.py @@ -1,124 +1,22 @@ -""" -Mesh Smoother — built-in process extension. +"""Thin workflow adapter for the shared smooth mesh operation.""" -Reduces sharp artifacts (zipper triangles, sawtooth edges) produced by -AI mesh generators via Taubin or Laplacian smoothing. - -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-smoother: input file not found: {input_path}") - return - - iterations = int(params.get("iterations", 5)) - lambda_ = float(params.get("lambda_", 0.5)) - mode = str(params.get("mode", "taubin")) - - 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-smoother-{int(time() * 1000)}.glb") - - log(f"Mode: {mode}, iterations: {iterations}, strength: {lambda_}") - - try: - import pymeshlab - except ImportError: - error("mesh-smoother: 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) - - progress(30, f"Smoothing ({mode})…") - - if mode == "taubin": - ms.apply_coord_taubin_smoothing( - lambda_=lambda_, - mu=-lambda_ - 0.01, - stepsmoothnum=iterations, - ) - else: - ms.apply_coord_laplacian_smoothing( - stepsmoothnum=iterations, - cotangentweight=False, - ) - - progress(80, "Exporting…") - ms.save_current_mesh(ply_out) - # Load raw geometry only — avoids scipy dependency triggered by face→vertex color conversion - _loaded = trimesh.load(ply_out, process=False) - if isinstance(_loaded, trimesh.Scene): - _geoms = list(_loaded.geometry.values()) - _loaded = _geoms[0] if len(_geoms) == 1 else trimesh.util.concatenate(_geoms) - result = trimesh.Trimesh(vertices=_loaded.vertices, faces=_loaded.faces, process=False) - finally: - shutil.rmtree(tmp_dir, ignore_errors=True) +api_dir = os.environ.get("MODLY_API_DIR") +if not api_dir: + for parent in Path(__file__).resolve().parents: + candidate = parent / "api" + if candidate.is_dir(): + api_dir = str(candidate) + break +if api_dir and api_dir not in sys.path: + sys.path.insert(0, api_dir) - result.export(out_path) - log(f"Output: {out_path} ({len(result.faces)} faces)") - progress(100, "Done") - done(out_path) +from services.mesh_ops.processor import run_processor if __name__ == "__main__": - try: - main() - except Exception as exc: - import traceback - error(f"{exc}\n{traceback.format_exc()}") + run_processor("smooth", "mesh-smoother") diff --git a/tsconfig.builtins.json b/tsconfig.builtins.json index 5a7a957a..15c7e29d 100644 --- a/tsconfig.builtins.json +++ b/tsconfig.builtins.json @@ -12,7 +12,6 @@ "esModuleInterop": true }, "include": [ - "src/areas/workflows/nodes/mesh-exporter/**/*.ts", - "src/areas/workflows/nodes/mesh-optimizer/**/*.ts" + "src/areas/workflows/nodes/mesh-exporter/**/*.ts" ] }