Gradio_CAD / app.py
Jangai's picture
Update app.py
b8da200 verified
import os, io, zipfile, math, traceback, datetime, tempfile
from pathlib import Path
import numpy as np
import pandas as pd
# Plotly optional (if not installed, app still starts)
try:
import plotly.graph_objects as go
PLOTLY_AVAILABLE = True
except Exception:
go = None
PLOTLY_AVAILABLE = False
import gradio as gr
# ------------ OpenCascade (pythonocc-core) ------------
try:
from OCC.Core.IGESControl import IGESControl_Reader
from OCC.Core.STEPControl import STEPControl_Reader, STEPControl_AsIs
from OCC.Core.IFSelect import IFSelect_ReturnStatus
# ProgressRange might not be available on some builds
try:
from OCC.Core.Message import Message_ProgressRange
HAS_PROGRESS = True
except Exception:
Message_ProgressRange = None
HAS_PROGRESS = False
from OCC.Core.TopoDS import TopoDS_Shape
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.GProp import GProp_GProps
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Sewing
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.BRep import BRep_Tool
from OCC.Extend.TopologyUtils import TopologyExplorer
from OCC.Core.TopLoc import TopLoc_Location
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE
from OCC.Core.StlAPI import StlAPI_Writer
OCC_AVAILABLE = True
except ImportError as e:
print(f"OpenCascade not available: {e}")
OCC_AVAILABLE = False
# Define dummy classes to prevent errors
class TopoDS_Shape:
def IsNull(self): return True
class TopAbs_FACE: pass
class TopAbs_EDGE: pass
# =============== Helpers geometrici ===============
def _bbox(shape):
"""Calculate bounding box of shape"""
if not OCC_AVAILABLE:
return (0, 0, 0, 100, 100, 100)
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
return bbox.Get() # xmin, ymin, zmin, xmax, ymax, zmax
def _area(shape):
"""Calculate surface area of shape"""
if not OCC_AVAILABLE:
return 1000.0
gp = GProp_GProps()
brepgprop.SurfaceProperties(shape, gp)
return gp.Mass()
def _volume(shape):
"""Calculate volume of shape"""
if not OCC_AVAILABLE:
return 100.0
gp = GProp_GProps()
brepgprop.VolumeProperties(shape, gp)
return gp.Mass()
def _try_sew(shape, tol=1e-3):
"""Try to sew shape faces together"""
if not OCC_AVAILABLE:
return shape
sew = BRepBuilderAPI_Sewing(tol, True, True, True, False)
sew.Add(shape)
sew.Perform()
return sew.SewedShape()
def _count_sub(shape, kind):
"""Count faces or edges"""
if not OCC_AVAILABLE:
return 50 if kind == TopAbs_FACE else 200
cnt = 0
topo = TopologyExplorer(shape)
if kind == TopAbs_FACE:
for _ in topo.faces():
cnt += 1
elif kind == TopAbs_EDGE:
for _ in topo.edges():
cnt += 1
return cnt
def _fmt(x):
"""Format numbers for display"""
if x is None:
return "—"
try:
if isinstance(x, (int,)) or (isinstance(x, float) and abs(x) >= 1000):
return f"{x:,.0f}".replace(",", " ")
if isinstance(x, float):
return f"{x:.4f}"
except Exception:
pass
return str(x)
# =============== File handling ===============
def _save_uploaded_to_tmp(uploaded_file):
"""Save Gradio upload to temp file and return path"""
if uploaded_file is None:
return None
# Handle different Gradio file object types
if hasattr(uploaded_file, 'name'):
file_name = Path(uploaded_file.name).name
file_path = uploaded_file.name
else:
file_name = "uploaded_file"
file_path = uploaded_file
# Copy to temp directory with proper name
dst = Path(tempfile.gettempdir()) / file_name
try:
if hasattr(uploaded_file, 'name') and os.path.exists(uploaded_file.name):
# File is already saved by Gradio
import shutil
shutil.copy2(uploaded_file.name, dst)
else:
# Read file content
if hasattr(uploaded_file, 'read'):
data = uploaded_file.read()
else:
with open(uploaded_file, 'rb') as f:
data = f.read()
with open(dst, "wb") as f:
f.write(data)
except Exception as e:
print(f"Error saving file: {e}")
return None
return str(dst)
def _maybe_unzip_and_pick(path_in_tmp):
"""Extract ZIP and find CAD file"""
p = Path(path_in_tmp)
if p.suffix.lower() != ".zip":
return str(p)
out_dir = Path(tempfile.gettempdir()) / "cad_zip"
out_dir.mkdir(parents=True, exist_ok=True)
# Clean up existing files
for c in out_dir.iterdir():
try:
if c.is_file():
c.unlink()
except Exception:
pass
try:
with zipfile.ZipFile(str(p), "r") as zf:
zf.extractall(str(out_dir))
except Exception as e:
print(f"Error extracting ZIP: {e}")
return None
# Look for CAD files
for ext in (".stp", ".step", ".igs", ".iges", ".STP", ".STEP", ".IGS", ".IGES"):
cand = list(out_dir.rglob(f"*{ext}"))
if cand:
return str(cand[0])
return None
def _read_step_with_fallback(tmp_path):
"""Read STEP file with fallback for different API versions"""
if not OCC_AVAILABLE:
return None
sreader = STEPControl_Reader()
status = sreader.ReadFile(tmp_path)
if int(status) != int(IFSelect_ReturnStatus.IFSelect_RetDone):
return None
# Try new API first (with ProgressRange)
try:
nroots = sreader.NbRootsForTransfer()
for i in range(1, nroots + 1):
if HAS_PROGRESS:
sreader.TransferRoot(i, STEPControl_AsIs, Message_ProgressRange())
else:
raise TypeError("force fallback")
except Exception:
# Fallback to classic API
try:
nroots = sreader.NbRootsForTransfer()
for i in range(1, nroots + 1):
sreader.TransferRoot(i, STEPControl_AsIs)
except Exception:
sreader.TransferRoots()
shape = sreader.OneShape()
return None if shape.IsNull() else shape
def _read_shape_any(tmp_path):
"""Read any supported CAD file format"""
if not OCC_AVAILABLE:
return None
ext = Path(tmp_path).suffix.lower()
if ext in [".igs", ".iges"]:
rdr = IGESControl_Reader()
st = rdr.ReadFile(tmp_path)
if int(st) != int(IFSelect_ReturnStatus.IFSelect_RetDone):
return None
rdr.TransferRoots()
shp = rdr.OneShape()
if shp.IsNull():
rdr.TransferAll()
shp = rdr.OneShape()
return None if shp.IsNull() else shp
if ext in [".stp", ".step"]:
return _read_step_with_fallback(tmp_path)
return None
# =============== 3D Visualization ===============
def shape_to_plotly_mesh(shape, deflection=0.8):
"""Convert CAD shape to Plotly 3D mesh"""
if not PLOTLY_AVAILABLE or not OCC_AVAILABLE:
return None
# Triangulate the shape
BRepMesh_IncrementalMesh(shape, deflection, True, 0.5, True)
vertices = []
faces_i, faces_j, faces_k = [], [], []
vmap = {}
def add_vertex(p):
key = (round(p.X(), 6), round(p.Y(), 6), round(p.Z(), 6))
idx = vmap.get(key)
if idx is None:
idx = len(vertices)
vertices.append([key[0], key[1], key[2]])
vmap[key] = idx
return idx
topo = TopologyExplorer(shape)
for face in topo.faces():
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation(face, loc)
if tri is None:
continue
trsf = loc.Transformation()
# Handle different API versions
has_nodes_attr = hasattr(tri, "Nodes") and callable(getattr(tri, "Nodes"))
has_tris_attr = hasattr(tri, "Triangles") and callable(getattr(tri, "Triangles"))
if has_nodes_attr and has_tris_attr:
nodes = tri.Nodes()
tris = tri.Triangles()
for t in range(1, tri.NbTriangles() + 1):
n1, n2, n3 = tris.Value(t).Get()
p1 = nodes.Value(n1).Transformed(trsf)
p2 = nodes.Value(n2).Transformed(trsf)
p3 = nodes.Value(n3).Transformed(trsf)
i1 = add_vertex(p1)
i2 = add_vertex(p2)
i3 = add_vertex(p3)
faces_i.append(i1)
faces_j.append(i2)
faces_k.append(i3)
else:
# Alternative API
for t in range(1, tri.NbTriangles() + 1):
tri_t = tri.Triangle(t)
n1, n2, n3 = tri_t.Get()
p1 = tri.Node(n1).Transformed(trsf)
p2 = tri.Node(n2).Transformed(trsf)
p3 = tri.Node(n3).Transformed(trsf)
i1 = add_vertex(p1)
i2 = add_vertex(p2)
i3 = add_vertex(p3)
faces_i.append(i1)
faces_j.append(i2)
faces_k.append(i3)
if len(vertices) == 0 or len(faces_i) == 0:
return None # Empty triangulation
verts = np.array(vertices, dtype=float)
fig = go.Figure(data=[
go.Mesh3d(
x=verts[:, 0], y=verts[:, 1], z=verts[:, 2],
i=faces_i, j=faces_j, k=faces_k,
flatshading=True, opacity=1.0, showscale=False
)
])
fig.update_layout(
scene_aspectmode="data",
margin=dict(l=0, r=0, t=30, b=0),
title="3D Preview"
)
fig.update_scenes(
xaxis_visible=False,
yaxis_visible=False,
zaxis_visible=False
)
return fig
# =============== Complexity Scoring ===============
def score_complessita(dimx, dimy, dimz, area, volume,
n_faces=None, n_edges=None,
aspect_ratio=None, sv_ratio=None,
solidity=None, edge_density=None,
weights=None):
"""Calculate complexity score based on various geometric parameters"""
# Default weights
w = {
"w_long": 0.25, "w_area": 0.15, "w_ar": 0.15,
"w_sv": 0.10, "w_sol": 0.10, "w_feat": 0.20, "w_edns": 0.05
}
if weights:
w.update(weights)
longest = max(dimx, dimy, dimz)
s_long = min(longest / 600.0, 2.0)
s_area = min(area / 1.5e6, 2.0)
s_ar = min((aspect_ratio or 1.0) / 3.0, 2.0)
s_sv = min((sv_ratio or 0.0) / 0.02, 2.0)
s_sol = 2.0 - min((solidity or 1.0), 2.0)
s_feat = min(((n_faces or 0) / 200.0) + ((n_edges or 0) / 1000.0), 2.0)
s_edns = min((edge_density or 0.0) / 0.005, 2.0)
score = (w["w_long"] * s_long + w["w_area"] * s_area + w["w_ar"] * s_ar +
w["w_sv"] * s_sv + w["w_sol"] * s_sol + w["w_feat"] * s_feat +
w["w_edns"] * s_edns)
return float(score)
def classe_da_score(score, th_easy=0.6, th_medium=1.2):
"""Classify complexity based on score"""
if score < th_easy:
return "facile"
if score < th_medium:
return "medio"
return "difficile"
# =============== HTML Card Rendering ===============
def render_card_html(file_name, volume, area, dimx, dimy, dimz, classe=None, score=None):
"""Render results as HTML card"""
def f(x):
return _fmt(x)
badge = f"{classe.upper()} — score {score:.2f}" if (classe and score is not None) else "—"
color = {"facile": "#22c55e", "medio": "#f59e0b", "difficile": "#ef4444"}.get((classe or ""), "#0ea5e9")
html = f"""
<div style="font-family: ui-sans-serif,system-ui; max-width: 980px; border-radius:16px; padding:20px 24px;
background: linear-gradient(135deg,#0f172a 0%,#0b132b 45%,#1b2a49 100%); color:#e5e7eb;
box-shadow:0 10px 30px rgba(0,0,0,.35);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div style="font-size:18px;opacity:.9;">CAD Analysis (IGES/STEP)</div>
<div style="font-size:12px;opacity:.6;">Units as in model</div>
</div>
<div style="font-size:22px;font-weight:700;margin-bottom:16px;">{file_name}</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;">
<div style="background:#111827;border:1px solid #1f2937;border-radius:14px;padding:16px;">
<div style="font-size:12px;color:#9ca3af;">Volume</div>
<div style="font-size:22px;font-weight:700;margin-top:4px;">{f(volume)} <span style="font-size:12px;opacity:.7;">u³</span></div>
</div>
<div style="background:#111827;border:1px solid #1f2937;border-radius:14px;padding:16px;">
<div style="font-size:12px;color:#9ca3af;">Area</div>
<div style="font-size:22px;font-weight:700;margin-top:4px;">{f(area)} <span style="font-size:12px;opacity:.7;">u²</span></div>
</div>
<div style="background:#111827;border:1px solid #1f2937;border-radius:14px;padding:16px;">
<div style="font-size:12px;color:#9ca3af;">Dims (X·Y·Z)</div>
<div style="font-size:18px;font-weight:700;margin-top:4px;">{f(dimx)} × {f(dimy)} × {f(dimz)}</div>
</div>
<div style="background:#0b1220;border:1px solid #1f2937;border-radius:14px;padding:16px;">
<div style="font-size:12px;color:#9ca3af;">Complexity</div>
<div style="font-size:18px;font-weight:800;margin-top:4px;color:{color};">{badge}</div>
</div>
</div>
</div>
"""
return html
# =============== Main Analysis Pipeline ===============
def analyze(file_obj, w_long, w_area, w_ar, w_sv, w_sol, w_feat, w_edns, th_easy, th_medium):
"""Main analysis function"""
logs = []
def log(x):
ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {x}"
logs.append(line)
print(line) # Also print to console for debugging
try:
if not OCC_AVAILABLE:
log("OpenCascade (pythonocc-core) not available - demo mode")
# Return demo data
demo_df = pd.DataFrame([{
"File": "demo_model.stp",
"Volume (u^3)": 1000.0,
"Area (u^2)": 2000.0,
"Dim X": 100.0, "Dim Y": 50.0, "Dim Z": 20.0,
"BBox xmin": 0.0, "BBox ymin": 0.0, "BBox zmin": 0.0,
"BBox xmax": 100.0, "BBox ymax": 50.0, "BBox zmax": 20.0,
"# Facce": 50, "# Spigoli": 200,
"Aspect ratio": 5.0,
"Surface/Volume": 2.0,
"Solidity (Vol/BBoxVol)": 1.0,
"Densità spigoli (edges/area)": 0.1,
"Score complessità": 0.8,
"Classe complessità": "medio"
}])
card = render_card_html("demo_model.stp", 1000, 2000, 100, 50, 20, "medio", 0.8)
return ("medio (score 0.80)", card, None, demo_df,
"\n".join(logs) + "\nDemo mode - OpenCascade not available", None, None)
if file_obj is None:
return ("Select a file", None, None, pd.DataFrame(),
"\n".join(logs), None, None)
# 1) Save temp file
tmp_path = _save_uploaded_to_tmp(file_obj)
if tmp_path is None:
log("Failed to save uploaded file")
return ("File save error", None, None, pd.DataFrame(),
"\n".join(logs), None, None)
log(f"Upload saved to: {tmp_path}")
# 2) Handle ZIP files
picked = _maybe_unzip_and_pick(tmp_path)
if picked is None:
log("ZIP without supported CAD files (.stp/.igs)")
return ("ZIP without STEP/IGES", None, None, pd.DataFrame(),
"\n".join(logs), None, None)
if picked != tmp_path:
log(f"ZIP extracted → selected: {picked}")
else:
log(f"File selected: {picked}")
# 3) Read CAD file
shape = _read_shape_any(picked)
if (shape is None) or shape.IsNull():
log("CAD reading failed (null shape)")
return ("CAD reading error", None, None, pd.DataFrame(),
"\n".join(logs), None, None)
# 4) Calculate metrics
xmin, ymin, zmin, xmax, ymax, zmax = _bbox(shape)
dimx, dimy, dimz = (xmax - xmin), (ymax - ymin), (zmax - zmin)
area = _area(shape)
log(f"Area: {area}")
volume = _volume(shape)
log(f"Volume: {volume}")
# Try sewing if volume is zero
if volume == 0.0:
log("Volume=0 → trying sewing...")
sewn = _try_sew(shape, tol=1e-3)
if not sewn.IsNull():
v2 = _volume(sewn)
log(f"Volume after sewing: {v2}")
if v2 > 0:
shape, volume = sewn, v2
n_faces = _count_sub(shape, TopAbs_FACE)
log(f"#Faces: {n_faces}")
n_edges = _count_sub(shape, TopAbs_EDGE)
log(f"#Edges: {n_edges}")
bbox_vol = (dimx * dimy * dimz) if all(v > 0 for v in (dimx, dimy, dimz)) else 0.0
sv_ratio = (area / volume) if volume and volume > 0 else None
solidity = (volume / bbox_vol) if bbox_vol and volume and volume > 0 else None
aspect_ratio = max(dimx, dimy, dimz) / max(1e-9, min(dimx, dimy, dimz))
edge_density = (n_edges / area) if area and area > 0 else None
# 5) Calculate complexity score and class
weights = dict(
w_long=w_long, w_area=w_area, w_ar=w_ar,
w_sv=w_sv, w_sol=w_sol, w_feat=w_feat, w_edns=w_edns
)
score = score_complessita(dimx, dimy, dimz, area, volume,
n_faces, n_edges, aspect_ratio, sv_ratio,
solidity, edge_density, weights=weights)
classe = classe_da_score(score, th_easy=th_easy, th_medium=th_medium)
log(f"Score: {score:.3f} | Class: {classe}")
# 6) Create DataFrame
df = pd.DataFrame([{
"File": Path(picked).name,
"Volume (u^3)": float(volume),
"Area (u^2)": float(area),
"Dim X": float(dimx), "Dim Y": float(dimy), "Dim Z": float(dimz),
"BBox xmin": float(xmin), "BBox ymin": float(ymin), "BBox zmin": float(zmin),
"BBox xmax": float(xmax), "BBox ymax": float(ymax), "BBox zmax": float(zmax),
"# Facce": int(n_faces), "# Spigoli": int(n_edges),
"Aspect ratio": float(aspect_ratio),
"Surface/Volume": float(sv_ratio) if sv_ratio is not None else None,
"Solidity (Vol/BBoxVol)": float(solidity) if solidity is not None else None,
"Densità spigoli (edges/area)": float(edge_density) if edge_density is not None else None,
"Score complessità": float(score),
"Classe complessità": classe
}])
# 7) Export CSV/XLSX (downloadable files)
tmpdir = Path(tempfile.gettempdir())
csv_path = tmpdir / "cad_report.csv"
df.to_csv(csv_path, index=False)
out_csv = str(csv_path)
xlsx_path = tmpdir / "cad_report.xlsx"
out_xlsx = None
try:
df.to_excel(xlsx_path, index=False)
out_xlsx = str(xlsx_path)
log(f"Export: {csv_path}, {xlsx_path}")
except Exception as e:
log(f"Excel not created (openpyxl missing or error): {e}")
# 8) Generate 3D visualization
fig = shape_to_plotly_mesh(shape, deflection=0.8)
if fig is None and PLOTLY_AVAILABLE:
# Empty triangulation → try STL for debug
try:
stl_path = tmpdir / "mesh_fallback.stl"
StlAPI_Writer().Write(shape, str(stl_path))
log(f"Empty triangulation → STL saved: {stl_path}")
except Exception as ee:
log(f"STL fallback failed: {ee}")
# 9) Generate HTML card
card = render_card_html(Path(picked).name, volume, area, dimx, dimy, dimz, classe, score)
# Return results
return (
f"{classe} (score {score:.2f})",
card,
fig if PLOTLY_AVAILABLE else None,
df,
"\n".join(logs),
out_csv,
out_xlsx
)
except Exception:
logs.append("EXCEPTION:\n" + traceback.format_exc())
return ("Analysis error", None, None, pd.DataFrame(),
"\n".join(logs), None, None)
# =============== Gradio Interface ===============
def create_interface():
"""Create and configure Gradio interface"""
with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue")) as demo:
gr.Markdown("# CAD Complexity Analyzer (IGES/STEP)")
if not OCC_AVAILABLE:
gr.Markdown("""
⚠️ **Demo Mode**: OpenCascade (pythonocc-core) is not available.
The interface will show demo data instead of actual CAD analysis.
To enable full functionality, install: `pip install pythonocc-core`
""")
gr.Markdown("""
Upload a **.stp/.step/.igs/.iges** file (or a **.zip** containing one),
adjust parameters (optional), and click **Analyze**.
""")
with gr.Row():
file_in = gr.File(
label="CAD File (STEP/IGES or ZIP)",
file_types=[".stp", ".step", ".igs", ".iges", ".zip"]
)
with gr.Accordion("Complexity Parameters (recommended defaults)", open=False):
gr.Markdown("**Weights (sum ≈ 1):**")
with gr.Row():
w_long = gr.Slider(0, 1, value=0.25, step=0.01, label="Max Dimension Weight (w_long)")
w_area = gr.Slider(0, 1, value=0.15, step=0.01, label="Area Weight (w_area)")
w_ar = gr.Slider(0, 1, value=0.15, step=0.01, label="Aspect Ratio Weight (w_ar)")
with gr.Row():
w_sv = gr.Slider(0, 1, value=0.10, step=0.01, label="Surface/Volume Weight (w_sv)")
w_sol = gr.Slider(0, 1, value=0.10, step=0.01, label="Solidity Weight (w_sol)")
w_feat = gr.Slider(0, 1, value=0.20, step=0.01, label="Feature Count Weight (w_feat)")
with gr.Row():
w_edns = gr.Slider(0, 1, value=0.05, step=0.01, label="Edge Density Weight (w_edns)")
gr.Markdown("**Class Thresholds:**")
with gr.Row():
th_easy = gr.Slider(0.0, 2.0, value=0.60, step=0.01, label="Easy Threshold (<)")
th_medium = gr.Slider(0.0, 2.5, value=1.20, step=0.01, label="Medium Threshold (< ; otherwise Difficult)")
btn = gr.Button("Analyze", variant="primary")
with gr.Row():
complexity_out = gr.Textbox(label="Complexity", interactive=False)
card_out = gr.HTML(label="Results Card")
mesh_out = gr.Plot(
label="3D Preview (Plotly)" if PLOTLY_AVAILABLE else "3D Preview (disabled: Plotly not installed)",
height=520
)
df_out = gr.Dataframe(label="Complexity Parameters", row_count=1, wrap=True)
logs_out = gr.Textbox(label="Log", lines=12)
with gr.Row():
csv_out = gr.File(label="Download CSV", visible=True)
xlsx_out = gr.File(label="Download XLSX", visible=True)
btn.click(
analyze,
inputs=[file_in, w_long, w_area, w_ar, w_sv, w_sol, w_feat, w_edns, th_easy, th_medium],
outputs=[complexity_out, card_out, mesh_out, df_out, logs_out, csv_out, xlsx_out]
)
return demo
# =============== Application Entry Point ===============
if __name__ == "__main__":
# Create the interface
demo = create_interface()
# Launch configuration for HuggingFace Spaces
port = int(os.environ.get("PORT", 7860))
# Launch the app
demo.launch(
server_name="0.0.0.0",
server_port=port,
share=False, # Set to True for temporary public links
show_error=True,
debug=True
)