Cosio commited on
Commit
aed45c3
·
verified ·
1 Parent(s): e20cecd

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +756 -0
app.py ADDED
@@ -0,0 +1,756 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ STL Voxel Remesh - Hugging Face Space
3
+ ======================================
4
+ Main application combining Gradio UI + FastAPI + MCP Server.
5
+
6
+ Voxel remeshing via trimesh + scipy marching cubes (same algorithm as Blender).
7
+ Lightweight alternative: ~50MB dependencies vs ~300MB+ for Blender headless.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import json
13
+ import logging
14
+ import tempfile
15
+ import traceback
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ import numpy as np
20
+ import trimesh
21
+ from fastapi import FastAPI, File, UploadFile, HTTPException
22
+ from fastapi.responses import FileResponse, JSONResponse
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from fastapi_sse import sse
25
+ import gradio as gr
26
+ import plotly.graph_objects as go
27
+
28
+ # Import our core module
29
+ from voxel_remesh import (
30
+ load_stl,
31
+ voxel_remesh,
32
+ save_stl,
33
+ get_mesh_stats,
34
+ RemeshResult
35
+ )
36
+
37
+ # ─── Logging ────────────────────────────────────────────────────────────────
38
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # ─── FastAPI App ────────────────────────────────────────────────────────────
42
+ app = FastAPI(
43
+ title="STL Voxel Remesh API",
44
+ description="API for voxel-based remeshing of STL files. Includes MCP-compatible endpoints.",
45
+ version="1.0.0"
46
+ )
47
+
48
+ app.add_middleware(
49
+ CORSMiddleware,
50
+ allow_origins=["*"],
51
+ allow_credentials=True,
52
+ allow_methods=["*"],
53
+ allow_headers=["*"],
54
+ )
55
+
56
+
57
+ # ─── MCP Server Implementation ──────────────────────────────────────────────
58
+ # MCP (Model Context Protocol) allows AI agents to use this tool.
59
+ # We implement MCP-compatible JSON-RPC endpoints on the FastAPI server.
60
+
61
+ MCP_SERVER_NAME = "stl-voxel-remesh"
62
+ MCP_SERVER_VERSION = "1.0.0"
63
+
64
+ MCP_TOOLS = [
65
+ {
66
+ "name": "voxel_remesh",
67
+ "description": "Perform voxel-based remeshing on an STL file to fix triangles, repair topology, and produce a clean mesh with uniform triangles without deforming the model.",
68
+ "inputSchema": {
69
+ "type": "object",
70
+ "properties": {
71
+ "voxel_size": {
72
+ "type": "number",
73
+ "description": "Size of each voxel cube in world units. Smaller = more detail preserved, more triangles. Default: auto-estimated.",
74
+ "default": None
75
+ },
76
+ "adapt_voxels": {
77
+ "type": "boolean",
78
+ "description": "Auto-estimate voxel size based on mesh analysis. Recommended for most cases.",
79
+ "default": True
80
+ },
81
+ "target_reduction": {
82
+ "type": "number",
83
+ "description": "Target face count reduction ratio (0.0-0.9). 0.5 = reduce faces by ~50%.",
84
+ "default": 0.5,
85
+ "minimum": 0.0,
86
+ "maximum": 0.9
87
+ },
88
+ "smooth_iterations": {
89
+ "type": "number",
90
+ "description": "Number of smoothing passes (0-10). 0 = no smoothing, preserves sharp edges.",
91
+ "default": 2,
92
+ "minimum": 0,
93
+ "maximum": 10
94
+ },
95
+ "fill_holes": {
96
+ "type": "boolean",
97
+ "description": "Attempt to close holes during voxelization.",
98
+ "default": True
99
+ }
100
+ },
101
+ "required": []
102
+ }
103
+ },
104
+ {
105
+ "name": "mesh_info",
106
+ "description": "Get comprehensive statistics about an STL mesh: vertex/face count, watertight status, volume, bounding box, edge quality metrics.",
107
+ "inputSchema": {
108
+ "type": "object",
109
+ "properties": {},
110
+ "required": []
111
+ }
112
+ }
113
+ ]
114
+
115
+
116
+ @app.post("/mcp", tags=["MCP"])
117
+ async def mcp_handler(request: dict):
118
+ """MCP-compatible JSON-RPC endpoint.
119
+
120
+ Supports the Model Context Protocol for AI agent tool integration.
121
+ Implements: initialize, tools/list, tools/call
122
+
123
+ Usage:
124
+ POST /mcp
125
+ {
126
+ "jsonrpc": "2.0",
127
+ "method": "tools/list",
128
+ "id": 1
129
+ }
130
+ """
131
+ method = request.get("method", "")
132
+ request_id = request.get("id")
133
+ params = request.get("params", {})
134
+
135
+ logger.info(f"MCP request: {method} (id={request_id})")
136
+
137
+ try:
138
+ if method == "initialize":
139
+ return {
140
+ "jsonrpc": "2.0",
141
+ "id": request_id,
142
+ "result": {
143
+ "protocolVersion": "2024-11-05",
144
+ "capabilities": {
145
+ "tools": {}
146
+ },
147
+ "serverInfo": {
148
+ "name": MCP_SERVER_NAME,
149
+ "version": MCP_SERVER_VERSION
150
+ }
151
+ }
152
+ }
153
+
154
+ elif method == "notifications/initialized":
155
+ return {"jsonrpc": "2.0", "id": request_id, "result": {}}
156
+
157
+ elif method == "tools/list":
158
+ return {
159
+ "jsonrpc": "2.0",
160
+ "id": request_id,
161
+ "result": {
162
+ "tools": MCP_TOOLS
163
+ }
164
+ }
165
+
166
+ elif method == "tools/call":
167
+ tool_name = params.get("name", "")
168
+ arguments = params.get("arguments", {})
169
+
170
+ if tool_name == "voxel_remesh":
171
+ return await _mcp_voxel_remesh(params, arguments, request_id)
172
+ elif tool_name == "mesh_info":
173
+ return await _mcp_mesh_info(params, request_id)
174
+ else:
175
+ return _mcp_error(request_id, -32601, f"Unknown tool: {tool_name}")
176
+
177
+ elif method == "ping":
178
+ return {"jsonrpc": "2.0", "id": request_id, "result": {}}
179
+
180
+ else:
181
+ return _mcp_error(request_id, -32601, f"Method not found: {method}")
182
+
183
+ except Exception as e:
184
+ logger.error(f"MCP error: {traceback.format_exc()}")
185
+ return _mcp_error(request_id, -32603, str(e))
186
+
187
+
188
+ async def _mcp_voxel_remesh(params: dict, arguments: dict, request_id):
189
+ """Handle MCP voxel_remesh tool call."""
190
+ # The file reference is passed via _meta or as a URL/base64
191
+ meta = params.get("_meta", {})
192
+ file_path = meta.get("file_path") or arguments.get("file_path")
193
+
194
+ if not file_path:
195
+ return _mcp_error(request_id, -32602,
196
+ "No file provided. Upload an STL file first via /api/remesh or use the Gradio UI.")
197
+
198
+ try:
199
+ mesh = load_stl(file_path)
200
+ result = voxel_remesh(
201
+ mesh,
202
+ voxel_size=arguments.get("voxel_size"),
203
+ adapt_voxels=arguments.get("adapt_voxels", True),
204
+ target_reduction=arguments.get("target_reduction", 0.5),
205
+ smooth_iterations=arguments.get("smooth_iterations", 2),
206
+ fill_holes=arguments.get("fill_holes", True),
207
+ )
208
+
209
+ # Save result
210
+ out_path = file_path.replace(".stl", "_remeshed.stl")
211
+ save_stl(result.mesh, out_path)
212
+
213
+ return {
214
+ "jsonrpc": "2.0",
215
+ "id": request_id,
216
+ "result": {
217
+ "content": [
218
+ {
219
+ "type": "text",
220
+ "text": json.dumps({
221
+ "status": "success",
222
+ "output_file": out_path,
223
+ "statistics": {
224
+ "original_vertices": result.original_vertices,
225
+ "original_faces": result.original_faces,
226
+ "new_vertices": result.new_vertices,
227
+ "new_faces": result.new_faces,
228
+ "face_reduction_pct": round((1 - result.new_faces / max(result.original_faces, 1)) * 100, 1),
229
+ "voxel_size": result.voxel_size,
230
+ "was_watertight": result.was_watertight_before,
231
+ "is_watertight": result.is_watertight_after,
232
+ "volume_before": result.volume_before,
233
+ "volume_after": result.volume_after,
234
+ }
235
+ }, indent=2)
236
+ }
237
+ ]
238
+ }
239
+ }
240
+ except Exception as e:
241
+ return _mcp_error(request_id, -32603, f"Remesh failed: {str(e)}")
242
+
243
+
244
+ async def _mcp_mesh_info(params: dict, request_id):
245
+ """Handle MCP mesh_info tool call."""
246
+ meta = params.get("_meta", {})
247
+ file_path = meta.get("file_path") or params.get("arguments", {}).get("file_path")
248
+
249
+ if not file_path:
250
+ return _mcp_error(request_id, -32602, "No file provided.")
251
+
252
+ try:
253
+ mesh = load_stl(file_path)
254
+ stats = get_mesh_stats(mesh)
255
+
256
+ return {
257
+ "jsonrpc": "2.0",
258
+ "id": request_id,
259
+ "result": {
260
+ "content": [
261
+ {
262
+ "type": "text",
263
+ "text": json.dumps({
264
+ "status": "success",
265
+ "mesh_statistics": stats
266
+ }, indent=2)
267
+ }
268
+ ]
269
+ }
270
+ }
271
+ except Exception as e:
272
+ return _mcp_error(request_id, -32603, f"Mesh analysis failed: {str(e)}")
273
+
274
+
275
+ def _mcp_error(request_id, code: int, message: str) -> dict:
276
+ return {
277
+ "jsonrpc": "2.0",
278
+ "id": request_id,
279
+ "error": {
280
+ "code": code,
281
+ "message": message
282
+ }
283
+ }
284
+
285
+
286
+ # ─── REST API Endpoints ─────────────────────────────────────────────────────
287
+
288
+ @app.post("/api/remesh", tags=["API"])
289
+ async def api_remesh(
290
+ file: UploadFile = File(...),
291
+ voxel_size: Optional[float] = None,
292
+ adapt_voxels: bool = True,
293
+ target_reduction: float = 0.5,
294
+ smooth_iterations: int = 2,
295
+ fill_holes: bool = True
296
+ ):
297
+ """REST endpoint for voxel remeshing. Upload STL, get remeshed STL."""
298
+ if not file.filename.lower().endswith('.stl'):
299
+ raise HTTPException(400, "Only STL files are supported")
300
+
301
+ with tempfile.NamedTemporaryFile(suffix='.stl', delete=False) as tmp:
302
+ content = await file.read()
303
+ tmp.write(content)
304
+ tmp_path = tmp.name
305
+
306
+ try:
307
+ mesh = load_stl(tmp_path)
308
+ result = voxel_remesh(
309
+ mesh,
310
+ voxel_size=voxel_size,
311
+ adapt_voxels=adapt_voxels,
312
+ target_reduction=target_reduction,
313
+ smooth_iterations=smooth_iterations,
314
+ fill_holes=fill_holes
315
+ )
316
+
317
+ out_path = tmp_path.replace('.stl', '_remeshed.stl')
318
+ save_stl(result.mesh, out_path)
319
+
320
+ stats = {
321
+ "original_vertices": result.original_vertices,
322
+ "original_faces": result.original_faces,
323
+ "new_vertices": result.new_vertices,
324
+ "new_faces": result.new_faces,
325
+ "face_reduction_pct": round((1 - result.new_faces / max(result.original_faces, 1)) * 100, 1),
326
+ "voxel_size_used": result.voxel_size,
327
+ "was_watertight": result.was_watertight_before,
328
+ "is_watertight_after": result.is_watertight_after
329
+ }
330
+
331
+ return FileResponse(
332
+ out_path,
333
+ media_type="model/stl",
334
+ filename=file.filename.replace('.stl', '_remeshed.stl'),
335
+ headers={"X-Mesh-Stats": json.dumps(stats)}
336
+ )
337
+ except Exception as e:
338
+ logger.error(f"API remesh error: {traceback.format_exc()}")
339
+ raise HTTPException(500, f"Remeshing failed: {str(e)}")
340
+ finally:
341
+ try:
342
+ os.unlink(tmp_path)
343
+ except:
344
+ pass
345
+
346
+
347
+ @app.post("/api/info", tags=["API"])
348
+ async def api_info(file: UploadFile = File(...)):
349
+ """REST endpoint to get mesh statistics."""
350
+ if not file.filename.lower().endswith('.stl'):
351
+ raise HTTPException(400, "Only STL files are supported")
352
+
353
+ with tempfile.NamedTemporaryFile(suffix='.stl', delete=False) as tmp:
354
+ content = await file.read()
355
+ tmp.write(content)
356
+ tmp_path = tmp.name
357
+
358
+ try:
359
+ mesh = load_stl(tmp_path)
360
+ stats = get_mesh_stats(mesh)
361
+ return JSONResponse(content=stats)
362
+ except Exception as e:
363
+ raise HTTPException(500, str(e))
364
+ finally:
365
+ try:
366
+ os.unlink(tmp_path)
367
+ except:
368
+ pass
369
+
370
+
371
+ @app.get("/api/health", tags=["API"])
372
+ async def health():
373
+ return {"status": "healthy", "service": "stl-voxel-remesh", "mcp_available": True}
374
+
375
+
376
+ # ─── Gradio UI ──────────────────────────────────────────────────────────────
377
+
378
+ def mesh_to_plotly(mesh: trimesh.Trimesh, title: str = "Mesh") -> go.Figure:
379
+ """Convert a trimesh object to a Plotly 3D mesh figure."""
380
+ # Simplify mesh for rendering if too many faces (>100k)
381
+ render_mesh = mesh
382
+ if len(mesh.faces) > 100000:
383
+ render_mesh = mesh.simplify_quadratic_error_decimation(100000)
384
+
385
+ vertices = render_mesh.vertices
386
+ faces = render_mesh.faces
387
+
388
+ # Create Plotly mesh3d trace
389
+ fig = go.Figure(data=[go.Mesh3d(
390
+ x=vertices[:, 0],
391
+ y=vertices[:, 1],
392
+ z=vertices[:, 2],
393
+ i=faces[:, 0],
394
+ j=faces[:, 1],
395
+ k=faces[:, 2],
396
+ color='#4facfe',
397
+ opacity=0.9,
398
+ flatshading=True,
399
+ lighting=dict(
400
+ ambient=0.4,
401
+ diffuse=0.6,
402
+ specular=0.2,
403
+ roughness=0.5,
404
+ fresnel=0.3
405
+ ),
406
+ lightposition=dict(
407
+ x=1000,
408
+ y=1000,
409
+ z=2000
410
+ ),
411
+ hoverinfo='skip'
412
+ )])
413
+
414
+ fig.update_layout(
415
+ title=dict(text=title, font=dict(size=14)),
416
+ scene=dict(
417
+ aspectmode='data',
418
+ bgcolor='rgba(0,0,0,0)',
419
+ xaxis=dict(showgrid=False, showticklabels=False, title=''),
420
+ yaxis=dict(showgrid=False, showticklabels=False, title=''),
421
+ zaxis=dict(showgrid=False, showticklabels=False, title=''),
422
+ ),
423
+ margin=dict(l=0, r=0, t=40, b=0),
424
+ height=400,
425
+ paper_bgcolor='rgba(0,0,0,0)',
426
+ plot_bgcolor='rgba(0,0,0,0)',
427
+ )
428
+
429
+ return fig
430
+
431
+
432
+ def build_comparison_html(result: RemeshResult) -> str:
433
+ """Build an HTML comparison card showing before/after statistics."""
434
+ reduction_pct = (1 - result.new_faces / max(result.original_faces, 1)) * 100
435
+
436
+ watertight_before = "✅ Sí" if result.was_watertight_before else "❌ No"
437
+ watertight_after = "✅ Sí" if result.is_watertight_after else "❌ No"
438
+
439
+ # Volume comparison if available
440
+ volume_info = ""
441
+ if result.volume_before and result.volume_after:
442
+ vol_diff = abs(result.volume_before - result.volume_after)
443
+ vol_pct = (vol_diff / result.volume_before) * 100 if result.volume_before > 0 else 0
444
+ volume_info = f"""
445
+ <div style="grid-column: span 2; padding: 8px; background: #1a1a2e; border-radius: 8px; text-align: center;">
446
+ <div style="color: #aaa; font-size: 11px;">Preservación de Volumen</div>
447
+ <div style="color: #00d4ff; font-size: 20px; font-weight: bold;">{vol_pct:.1f}%</div>
448
+ <div style="color: #aaa; font-size: 11px;">{result.volume_before:.2f} → {result.volume_after:.2f} unidades³</div>
449
+ </div>
450
+ """
451
+
452
+ html = f"""
453
+ <div style="font-family: 'Inter', system-ui, sans-serif; max-width: 600px;">
454
+ <div style="display: grid; grid-template-columns: 1fr auto 1fr; gap: 12px; align-items: center;">
455
+
456
+ <!-- Original -->
457
+ <div style="background: #1a1a2e; padding: 16px; border-radius: 12px; border: 1px solid #2a2a4a;">
458
+ <div style="text-align: center; color: #ff6b6b; font-weight: bold; font-size: 13px; margin-bottom: 12px;">
459
+ 📥 ORIGINAL
460
+ </div>
461
+ <div style="text-align: center;">
462
+ <div style="color: #ffd93d; font-size: 28px; font-weight: bold;">{result.original_faces:,}</div>
463
+ <div style="color: #aaa; font-size: 11px;">caras</div>
464
+ </div>
465
+ <div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #2a2a4a;">
466
+ <div style="color: #aaa; font-size: 11px;">Vértices: <span style="color: #eee;">{result.original_vertices:,}</span></div>
467
+ <div style="color: #aaa; font-size: 11px; margin-top: 4px;">Estanco: {watertight_before}</div>
468
+ </div>
469
+ </div>
470
+
471
+ <!-- Arrow -->
472
+ <div style="text-align: center; color: #4facfe; font-size: 24px;">→</div>
473
+
474
+ <!-- Remeshed -->
475
+ <div style="background: #1a1a2e; padding: 16px; border-radius: 12px; border: 1px solid #2a2a4a;">
476
+ <div style="text-align: center; color: #51cf66; font-weight: bold; font-size: 13px; margin-bottom: 12px;">
477
+ 🔧 REMESHED
478
+ </div>
479
+ <div style="text-align: center;">
480
+ <div style="color: #51cf66; font-size: 28px; font-weight: bold;">{result.new_faces:,}</div>
481
+ <div style="color: #aaa; font-size: 11px;">caras</div>
482
+ </div>
483
+ <div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #2a2a4a;">
484
+ <div style="color: #aaa; font-size: 11px;">Vértices: <span style="color: #eee;">{result.new_vertices:,}</span></div>
485
+ <div style="color: #aaa; font-size: 11px; margin-top: 4px;">Estanco: {watertight_after}</div>
486
+ </div>
487
+ </div>
488
+ </div>
489
+
490
+ <!-- Summary -->
491
+ <div style="display: grid; grid-template-columns: 1fr 1fr {('1fr' if volume_info else '')}; gap: 12px; margin-top: 16px;">
492
+ <div style="padding: 12px; background: linear-gradient(135deg, #1a1a2e, #16213e); border-radius: 10px; text-align: center;">
493
+ <div style="color: #aaa; font-size: 11px;">Reducción de caras</div>
494
+ <div style="color: {'#ff6b6b' if reduction_pct > 0 else '#51cf66'}; font-size: 22px; font-weight: bold;">
495
+ {'−' if reduction_pct > 0 else '+'}{abs(reduction_pct):.1f}%
496
+ </div>
497
+ </div>
498
+ <div style="padding: 12px; background: linear-gradient(135deg, #1a1a2e, #16213e); border-radius: 10px; text-align: center;">
499
+ <div style="color: #aaa; font-size: 11px;">Tamaño de voxel</div>
500
+ <div style="color: #4facfe; font-size: 22px; font-weight: bold;">{result.voxel_size:.4f}</div>
501
+ </div>
502
+ {volume_info}
503
+ </div>
504
+ </div>
505
+ """
506
+ return html
507
+
508
+
509
+ def process_file(
510
+ file,
511
+ voxel_size: float,
512
+ adapt_voxels: bool,
513
+ target_reduction: float,
514
+ smooth_iterations: int,
515
+ fill_holes: bool
516
+ ):
517
+ """Main processing function called by Gradio."""
518
+ if file is None:
519
+ return None, None, None, "⚠️ Por favor sube un archivo STL"
520
+
521
+ try:
522
+ # Load mesh
523
+ mesh = load_stl(file)
524
+
525
+ # Create original mesh visualization
526
+ fig_original = mesh_to_plotly(mesh, f"Original ({len(mesh.faces):,} caras)")
527
+
528
+ # Perform voxel remesh
529
+ vs = float(voxel_size) if voxel_size and voxel_size > 0 else None
530
+ result = voxel_remesh(
531
+ mesh,
532
+ voxel_size=vs,
533
+ adapt_voxels=adapt_voxels,
534
+ target_reduction=float(target_reduction),
535
+ smooth_iterations=int(smooth_iterations),
536
+ fill_holes=fill_holes
537
+ )
538
+
539
+ # Create remeshed visualization
540
+ fig_remeshed = mesh_to_plotly(result.mesh, f"Remeshed ({result.new_faces:,} caras)")
541
+
542
+ # Save output file
543
+ out_path = tempfile.mktemp(suffix='_remeshed.stl')
544
+ save_stl(result.mesh, out_path)
545
+
546
+ # Build stats HTML
547
+ stats_html = build_comparison_html(result)
548
+
549
+ # Build summary text
550
+ reduction = (1 - result.new_faces / max(result.original_faces, 1)) * 100
551
+ summary = (
552
+ f"✅ Remesh completado con éxito.\n\n"
553
+ f"📊 Resumen:\n"
554
+ f" • Caras: {result.original_faces:,} → {result.new_faces:,} "
555
+ f"({'−' if reduction > 0 else '+'}{abs(reduction):.1f}%)\n"
556
+ f" • Vértices: {result.original_vertices:,} → {result.new_vertices:,}\n"
557
+ f" • Voxel size: {result.voxel_size:.4f}\n"
558
+ f" • Estanco: {result.was_watertight_before} → {result.is_watertight_after}\n"
559
+ )
560
+
561
+ return fig_original, fig_remeshed, out_path, stats_html
562
+
563
+ except Exception as e:
564
+ logger.error(f"Processing error: {traceback.format_exc()}")
565
+ return None, None, None, f"❌ Error: {str(e)}"
566
+
567
+
568
+ def create_gradio_ui() -> gr.Blocks:
569
+ """Build the Gradio UI with custom styling."""
570
+
571
+ with gr.Blocks(
572
+ title="STL Voxel Remesh",
573
+ theme=gr.themes.Soft(
574
+ primary_hue="blue",
575
+ secondary_hue="slate",
576
+ ),
577
+ css="""
578
+ .gradio-container {
579
+ max-width: 1100px !important;
580
+ }
581
+ .dark .gradio-container {
582
+ background: #0d1117 !important;
583
+ }
584
+ #header-title {
585
+ text-align: center;
586
+ margin-bottom: 0.5rem;
587
+ }
588
+ #header-title h1 {
589
+ font-size: 2rem !important;
590
+ background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
591
+ -webkit-background-clip: text;
592
+ -webkit-text-fill-color: transparent;
593
+ margin-bottom: 0.25rem !important;
594
+ }
595
+ #header-title p {
596
+ color: #8b949e !important;
597
+ font-size: 0.9rem !important;
598
+ }
599
+ .panel-section {
600
+ background: rgba(22, 27, 34, 0.8);
601
+ border: 1px solid #30363d;
602
+ border-radius: 12px;
603
+ padding: 20px;
604
+ margin-bottom: 16px;
605
+ }
606
+ .api-info {
607
+ background: rgba(13, 17, 23, 0.9);
608
+ border: 1px solid #21262d;
609
+ border-radius: 10px;
610
+ padding: 16px;
611
+ margin-top: 16px;
612
+ }
613
+ .api-info code {
614
+ background: rgba(79, 172, 254, 0.15);
615
+ color: #4facfe;
616
+ padding: 2px 6px;
617
+ border-radius: 4px;
618
+ font-size: 0.85rem;
619
+ }
620
+ """
621
+ ) as demo:
622
+
623
+ # ── Header ──────────────────────────────────────────────────
624
+ with gr.Column(elem_id="header-title"):
625
+ gr.Markdown(
626
+ "# 🧊 STL Voxel Remesh"
627
+ "\nSube un archivo STL, repara la malla con voxel remesh sin deformar el modelo. "
628
+ "[MCP](#api-info) integrado para agentes AI."
629
+ )
630
+
631
+ with gr.Row():
632
+ # ── Left Column: Controls ───────────────────────────────
633
+ with gr.Column(scale=1):
634
+ gr.Markdown("### 📁 Archivo de entrada")
635
+ file_input = gr.File(
636
+ label="Archivo STL",
637
+ file_types=[".stl"],
638
+ type="filepath"
639
+ )
640
+
641
+ gr.Markdown("### ⚙️ Parámetros de Remesh")
642
+
643
+ with gr.Group():
644
+ adapt_voxels = gr.Checkbox(
645
+ label="🔄 Auto-estimar tamaño de voxel",
646
+ value=True,
647
+ info="Recomendado. Analiza la malla para encontrar el tamaño óptimo."
648
+ )
649
+
650
+ with gr.Row():
651
+ voxel_size = gr.Number(
652
+ label="Tamaño de voxel (manual)",
653
+ value=0.0,
654
+ precision=4,
655
+ minimum=0.0,
656
+ info="Ignorado si auto-estimar está activo"
657
+ )
658
+ target_reduction = gr.Slider(
659
+ label="Reducción objetivo",
660
+ minimum=0.0,
661
+ maximum=0.9,
662
+ value=0.5,
663
+ step=0.05,
664
+ info="0.5 = reducir ~50% de caras"
665
+ )
666
+
667
+ with gr.Row():
668
+ smooth_iterations = gr.Slider(
669
+ label="Suavizado",
670
+ minimum=0,
671
+ maximum=10,
672
+ value=2,
673
+ step=1,
674
+ info="0 = preservar bordes afilados"
675
+ )
676
+ fill_holes = gr.Checkbox(
677
+ label="🔧 Rellenar agujeros",
678
+ value=True,
679
+ info="Cierra pequeños agujeros topológicos"
680
+ )
681
+
682
+ process_btn = gr.Button(
683
+ "🚀 Aplicar Voxel Remesh",
684
+ variant="primary",
685
+ size="lg"
686
+ )
687
+
688
+ # ── Right Column: Results ────────────────────────────────
689
+ with gr.Column(scale=2):
690
+ gr.Markdown("### 🔍 Vista previa 3D")
691
+
692
+ with gr.Tabs():
693
+ with gr.Tab("Comparación"):
694
+ with gr.Row():
695
+ plot_original = gr.Plot(label="Original")
696
+ plot_remeshed = gr.Plot(label="Remeshed")
697
+
698
+ with gr.Tab("Estadísticas"):
699
+ stats_html = gr.HTML(
700
+ value="<div style='text-align:center; color:#666; padding:40px;'>"
701
+ "Sube un archivo STL y haz clic en 'Aplicar Voxel Remesh' para ver estadísticas.</div>"
702
+ )
703
+
704
+ download_output = gr.File(label="📥 Descargar STL remeshed")
705
+
706
+ # ── Processing ──────────────────────────────────────────────
707
+ process_btn.click(
708
+ fn=process_file,
709
+ inputs=[file_input, voxel_size, adapt_voxels, target_reduction,
710
+ smooth_iterations, fill_holes],
711
+ outputs=[plot_original, plot_remeshed, download_output, stats_html]
712
+ )
713
+
714
+ # ── API Info Section ────────────────────────────────────────
715
+ gr.HTML("""
716
+ <div class="api-info" id="api-info">
717
+ <h3 style="margin-top:0; color:#c9d1d9; font-size:14px;">🔌 API & MCP Endpoints</h3>
718
+ <p style="color:#8b949e; font-size:13px; margin-bottom:12px;">
719
+ Este Space expone endpoints REST y MCP para integración programática y agentes AI.
720
+ </p>
721
+ <div style="display:grid; gap:8px;">
722
+ <div>
723
+ <code style="font-weight:bold;">POST /api/remesh</code>
724
+ <span style="color:#8b949e; font-size:12px;"> — Sube un STL (multipart/form-data), recibe el STL remeshed</span>
725
+ </div>
726
+ <div>
727
+ <code style="font-weight:bold;">POST /api/info</code>
728
+ <span style="color:#8b949e; font-size:12px;"> — Obtiene estadísticas de la malla</span>
729
+ </div>
730
+ <div>
731
+ <code style="font-weight:bold;">POST /mcp</code>
732
+ <span style="color:#8b949e; font-size:12px;"> — MCP JSON-RPC endpoint (initialize, tools/list, tools/call)</span>
733
+ </div>
734
+ <div>
735
+ <code style="font-weight:bold;">GET /api/health</code>
736
+ <span style="color:#8b949e; font-size:12px;"> — Health check</span>
737
+ </div>
738
+ </div>
739
+ </div>
740
+ """)
741
+
742
+ return demo
743
+
744
+
745
+ # ─── Mount Gradio on FastAPI ────────────────────────────────────────────────
746
+
747
+ gradio_app = create_gradio_ui()
748
+ app = gr.mount_gradio_app(app, gradio_app, path="/")
749
+
750
+
751
+ # ─── Main Entry Point ───────────────────────────────────────────────────────
752
+ if __name__ == "__main__":
753
+ import uvicorn
754
+ port = int(os.environ.get("PORT", 7860))
755
+ logger.info(f"Starting server on port {port}...")
756
+ uvicorn.run(app, host="0.0.0.0", port=port)