"""FEA subprocess worker. Invoked by fea.py as a separate Python process so gmsh's signal handlers (which require the main thread of the interpreter) work correctly. Usage: python fea_worker.py Prints a single line of JSON to stdout with the result. """ from __future__ import annotations import json import sys import traceback import os import numpy as np def _mesh_with_gmsh(stl_path: str, mesh_size: float): import gmsh gmsh.initialize(["", "-noenv"]) try: gmsh.option.setNumber("General.Terminal", 0) gmsh.option.setNumber("Mesh.MeshSizeMax", mesh_size) gmsh.option.setNumber("Mesh.MeshSizeMin", mesh_size * 0.3) gmsh.merge(stl_path) gmsh.model.mesh.classifySurfaces(np.pi / 6, True, False, np.pi / 6) gmsh.model.mesh.createGeometry() surfs = gmsh.model.getEntities(2) sl = gmsh.model.geo.addSurfaceLoop([e[1] for e in surfs]) gmsh.model.geo.addVolume([sl]) gmsh.model.geo.synchronize() gmsh.model.mesh.generate(3) node_tags, coords, _ = gmsh.model.mesh.getNodes() coords = np.array(coords).reshape(-1, 3) tag_to_idx = {int(t): i for i, t in enumerate(node_tags)} elem_types, elem_tags, node_ids = gmsh.model.mesh.getElements(3) if not elem_types: raise RuntimeError("gmsh produced no 3D elements") tets = np.array(node_ids[0]).reshape(-1, 4) tets = np.vectorize(lambda t: tag_to_idx[int(t)])(tets) return coords, tets finally: gmsh.finalize() _MATERIALS = { "aluminum": {"E": 69e9, "nu": 0.33, "rho": 2700.0}, "steel": {"E": 210e9, "nu": 0.30, "rho": 7850.0}, "stainless": {"E": 200e9, "nu": 0.30, "rho": 7950.0}, "brass": {"E": 100e9, "nu": 0.34, "rho": 8500.0}, "titanium": {"E": 116e9, "nu": 0.34, "rho": 4500.0}, "pla": {"E": 3.5e9, "nu": 0.36, "rho": 1240.0}, "abs": {"E": 2.3e9, "nu": 0.35, "rho": 1050.0}, "default": {"E": 69e9, "nu": 0.33, "rho": 2700.0}, } def run(stl_path: str, load_N: float, axis: str, material: str) -> dict: # bbox-based mesh size from STL with open(stl_path, "rb") as f: head = f.read(80); nb = f.read(4) if len(nb) < 4: return {"error": "STL too short / empty"} import struct n = struct.unpack(" cyan -> green -> yellow -> red stops = [(0.00, (0.15, 0.10, 0.55)), (0.25, (0.10, 0.55, 0.85)), (0.50, (0.20, 0.80, 0.30)), (0.75, (0.95, 0.85, 0.10)), (1.00, (0.90, 0.15, 0.10))] for i in range(len(stops) - 1): t0, c0 = stops[i]; t1, c1 = stops[i + 1] if t <= t1: u = (t - t0) / (t1 - t0) if t1 > t0 else 0 return (c0[0] + u * (c1[0] - c0[0]), c0[1] + u * (c1[1] - c0[1]), c0[2] + u * (c1[2] - c0[2])) return stops[-1][1] # only export vertices touched by outer faces (keeps file small) used = sorted({v for tri in outer_faces for v in tri}) remap = {old: new + 1 for new, old in enumerate(used)} # OBJ is 1-based with open(obj_path, "w") as f: f.write("# chat_cad FEA stress plot — vertex colors encode von Mises stress\n") f.write(f"# stress range: {s_min:.3f} - {s_max:.3f} MPa\n") for v in used: p = pts3d[v] # optionally apply deformed-shape scaling if deform_scale > 0: p = p + disp[v] * deform_scale r, g, b = color_for(node_stress[v]) f.write(f"v {p[0]:.4f} {p[1]:.4f} {p[2]:.4f} {r:.3f} {g:.3f} {b:.3f}\n") for tri in outer_faces: f.write(f"f {remap[tri[0]]} {remap[tri[1]]} {remap[tri[2]]}\n") def run_thermal(stl_path: str, t_hot: float, t_cold: float, axis: str = "Z") -> dict: """Steady-state heat conduction: T_hot on +axis face, T_cold on -axis face. Returns max/min temperature and the maximum gradient magnitude. """ with open(stl_path, "rb") as f: f.read(80); nb = f.read(4) if len(nb) < 4: return {"error": "STL too short / empty"} import struct n = struct.unpack(" dict: """Modal analysis: free-free eigenvalue problem on the 3D tet mesh. Returns the first N natural frequencies of the part (rigid-body modes have very small omega^2 and are filtered out). """ with open(stl_path, "rb") as f: f.read(80); nb = f.read(4) if len(nb) < 4: return {"error": "STL too short"} import struct n = struct.unpack(" 1e-3][:int(n_modes)] freqs_Hz = (np.sqrt(np.abs(elastic)) / (2 * np.pi)).tolist() return { "ok": True, "n_nodes": int(pts3d.shape[0]), "n_elems": int(len(tets)), "material": material, "n_modes": len(freqs_Hz), "frequencies_Hz": [float(f) for f in freqs_Hz], } def run_cfd_2d(stl_path: str, inlet_velocity: float = 1.0, viscosity: float = 1.0e-3, axis: str = "Z") -> dict: """2D steady Stokes flow around the part's XY silhouette. The part is treated as a no-slip obstacle in a rectangular channel; inlet on -X (u = inlet_velocity), outlet on +X (p = 0), no-slip on top/bottom. Returns the maximum velocity magnitude and the inlet-to- outlet pressure drop. Real PDE solve via Taylor-Hood elements. Limitations: 2D only (XY mid-plane of the part), Stokes regime only (Re << 1, no inertia / turbulence). For turbulent or 3D CFD use OpenFOAM or similar. """ with open(stl_path, "rb") as f: f.read(80); nb = f.read(4) if len(nb) < 4: return {"error": "STL too short"} import struct n = struct.unpack(" 5 else "Z" out = run_thermal(stl_path, t_hot, t_cold, axis) elif mode == "modal": stl_path = sys.argv[2] material = sys.argv[3] if len(sys.argv) > 3 else "aluminum" n_modes = int(sys.argv[4]) if len(sys.argv) > 4 else 6 out = run_modal(stl_path, material, n_modes) elif mode == "cfd": stl_path = sys.argv[2] U = float(sys.argv[3]) if len(sys.argv) > 3 else 1.0 mu = float(sys.argv[4]) if len(sys.argv) > 4 else 1.0e-3 axis = sys.argv[5] if len(sys.argv) > 5 else "Z" out = run_cfd_2d(stl_path, U, mu, axis) else: # Legacy positional: [material] -> elasticity stl_path = sys.argv[1] load_N = float(sys.argv[2]); axis = sys.argv[3] material = sys.argv[4] if len(sys.argv) > 4 else "aluminum" out = run(stl_path, load_N, axis, material) except Exception as e: out = {"error": f"{type(e).__name__}: {e}", "trace": traceback.format_exc()[-500:]} sys.stdout.write(json.dumps(out))