import os import json import uuid import shutil import subprocess from pathlib import Path from flask import Flask, render_template, request, abort, Response from werkzeug.utils import secure_filename import ezdxf from ezdxf.addons.drawing import Frontend, RenderContext, svg, layout, config APP_DIR = Path(__file__).resolve().parent WORKER_DIR = APP_DIR / "worker" FREECADCMD = "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" JOBS_DIR = APP_DIR / "_jobs" JOBS_DIR.mkdir(exist_ok=True) ALLOWED_MATERIALS = [ ("stainless", "Edelstahl"), ("alu", "Aluminium"), ("copper", "Kupfer"), ] app = Flask(__name__) def _safe_job_dir(job_id: str) -> Path: # minimal validation to avoid path traversal if not job_id or any(c not in "0123456789abcdef" for c in job_id.lower()): abort(404) job_dir = (JOBS_DIR / job_id).resolve() if not str(job_dir).startswith(str(JOBS_DIR.resolve())): abort(404) if not job_dir.exists(): abort(404) return job_dir def run_job(job_dir: Path, filename: str, material: str, thickness_mm: str | None): """ Runs FreeCAD headless in a job dir. Keeps original filename. Expects: job_dir/filename exists. Produces: job_dir/result.json (compat), flat.dxf (compat), plus named outputs. """ # Copy worker script into job dir shutil.copy2(WORKER_DIR / "stepanalyser.py", job_dir / "stepanalyser.py") argv = ['"stepanalyser.py"', '"--input"', f'"{filename}"', '"--material"', f'"{material}"'] if thickness_mm: argv += ['"--thickness-mm"', f'"{thickness_mm}"'] runner_text = f"""import os, sys, json, traceback def write_result(payload): try: with open("result.json", "w", encoding="utf-8") as f: json.dump(payload, f, indent=2, ensure_ascii=False) except Exception: pass try: base = os.path.dirname(os.path.abspath(__file__)) os.chdir(base) if not os.path.exists({json.dumps(filename)}): raise SystemExit("Uploaded STEP file missing: " + {json.dumps(filename)}) # Ensure FreeCAD can find user Mods (SheetMetal) mod_dir = os.path.expanduser("~/Library/Application Support/FreeCAD/Mod") if os.path.isdir(mod_dir) and mod_dir not in sys.path: sys.path.append(mod_dir) sm_dir = os.path.join(mod_dir, "SheetMetal") if os.path.isdir(sm_dir) and sm_dir not in sys.path: sys.path.append(sm_dir) sys.argv = [{", ".join(argv)}] code = open("stepanalyser.py", "r", encoding="utf-8").read() exec(compile(code, "stepanalyser.py", "exec"), {{"__name__": "__main__"}}) except BaseException as e: payload = {{ "ok": False, "error_type": type(e).__name__, "error": str(e), "traceback": traceback.format_exc() }} write_result(payload) print("RUNNER ERROR:", payload["error_type"], payload["error"], flush=True) finally: os._exit(0) """ (job_dir / "run_stepanalyser.py").write_text(runner_text, encoding="utf-8") cmd = [FREECADCMD, "run_stepanalyser.py"] env = os.environ.copy() env["HOME"] = str(Path.home()) proc = subprocess.run( cmd, cwd=str(job_dir), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180, env=env, ) log_text = proc.stdout or "" (job_dir / "run.log").write_text(log_text, encoding="utf-8") result_path = job_dir / "result.json" if result_path.exists(): result = json.loads(result_path.read_text(encoding="utf-8")) else: result = { "ok": False, "error_type": "RunnerError", "error": "result.json not created", "log_tail": log_text[-4000:], } result["_job"] = { "id": job_dir.name, "dir": str(job_dir), "exit_code": proc.returncode, "log_file": str(job_dir / "run.log"), "compat_dxf": str(job_dir / "flat.dxf"), "compat_json": str(job_dir / "result.json"), "svg_preview_url": f"/job/{job_dir.name}/dxf.svg", "dxf_download_url": f"/job/{job_dir.name}/flat.dxf", } # If analyser wrote named outputs, try to attach them (best effort) try: if "output" in result and isinstance(result["output"], dict): result["_job"]["named_dxf"] = result["output"].get("dxf_named") result["_job"]["named_json"] = result["output"].get("json_named") result["_job"]["named_fcstd"] = result["output"].get("fcstd_named") result["_job"]["compat_fcstd"] = result["output"].get("fcstd") except Exception: pass return result def dxf_to_svg_bytes(dxf_path: Path) -> bytes: doc = ezdxf.readfile(str(dxf_path)) msp = doc.modelspace() # 1) Context context = RenderContext(doc) # 2) Backend backend = svg.SVGBackend() # 3) Config: weißer Hintergrund + schwarz (super für Blechteile) cfg = config.Configuration( background_policy=config.BackgroundPolicy.WHITE, color_policy=config.ColorPolicy.BLACK, ) # 4) Frontend frontend = Frontend(context, backend, config=cfg) # 5) Zeichnen frontend.draw_layout(msp) # 6) Page: auto-detect size (0,0) + kleine Ränder page = layout.Page(0, 0, layout.Units.mm, margins=layout.Margins.all(2)) # Optional: fit to page (preview-geeignet) try: settings = layout.Settings(scale=1, fit_page=True) svg_string = backend.get_string(page, settings=settings) except TypeError: # ältere ezdxf-Version ohne settings-Parameter svg_string = backend.get_string(page) return svg_string.encode("utf-8") @app.get("/") def index(): return render_template("index.html", materials=ALLOWED_MATERIALS) @app.post("/analyse") def analyse(): f = request.files.get("stepfile") material = (request.form.get("material", "stainless") or "stainless").strip().lower() thickness = (request.form.get("thickness_mm", "") or "").strip() if material not in {m for m, _ in ALLOWED_MATERIALS}: return "Invalid material", 400 if not f or not f.filename: return "Please upload a .step or .stp file", 400 filename = secure_filename(f.filename) if not filename.lower().endswith((".step", ".stp")): return "Please upload a .step or .stp file", 400 job_id = uuid.uuid4().hex[:12] job_dir = JOBS_DIR / job_id job_dir.mkdir(parents=True, exist_ok=True) # Save upload using original (safe) filename step_dest = job_dir / filename f.save(str(step_dest)) thickness_mm = thickness if thickness else None result = run_job(job_dir, filename, material, thickness_mm) return render_template("result.html", result=result) @app.get("/job//flat.dxf") def download_flat_dxf(job_id: str): job_dir = _safe_job_dir(job_id) dxf_path = (job_dir / "flat.dxf") if not dxf_path.exists(): abort(404) data = dxf_path.read_bytes() return Response( data, mimetype="application/dxf", headers={"Content-Disposition": f'inline; filename="{job_id}_flat.dxf"'}, ) @app.get("/job//dxf.svg") def preview_dxf_svg(job_id: str): job_dir = _safe_job_dir(job_id) dxf_path = (job_dir / "flat.dxf") if not dxf_path.exists(): abort(404) try: svg_bytes = dxf_to_svg_bytes(dxf_path) except Exception as e: # Return a simple SVG with an error message msg = f"DXF->SVG failed: {type(e).__name__}: {e}" svg = f""" {msg} """ return Response(svg.encode("utf-8"), mimetype="image/svg+xml") return Response(svg_bytes, mimetype="image/svg+xml") if __name__ == "__main__": app.run(host="127.0.0.1", port=5055, debug=True)