Dockerized

This commit is contained in:
Christian Anetzberger
2026-01-26 20:33:52 +01:00
parent e18c14750e
commit 1b5aa90dbe
9 changed files with 279 additions and 101 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.venv
__pycache__
_jobs
.git
.DS_Store
*.log
*.FCStd
*.dxf
*.svg
*.json

94
.gitignore vendored
View File

@@ -1,3 +1,15 @@
# =========================
# OS / Editor
# =========================
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
.vscode/
.idea/
*.code-workspace
# ========================= # =========================
# Python # Python
# ========================= # =========================
@@ -5,55 +17,81 @@ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# Virtual environments
.venv/ .venv/
venv/ venv/
env/ env/
ENV/
pip-wheel-metadata/
*.egg-info/
.eggs/
build/
dist/
.pytest_cache/
.mypy_cache/
.ruff_cache/
coverage/
.coverage*
htmlcov/
# ========================= # =========================
# macOS # Logs / runtime
# =========================
.DS_Store
.AppleDouble
.LSOverride
# =========================
# IDEs / Editors
# =========================
.vscode/
.idea/
*.swp
*.swo
# =========================
# Logs
# ========================= # =========================
*.log *.log
*.pid
# ========================= # =========================
# Job runtime data (generated) # App runtime data (IMPORTANT)
# ========================= # =========================
# Job artifacts: uploads, results, dxf/svg previews, logs etc.
_jobs/ _jobs/
_jobs/** _jobs/**
# If you keep a local config volume for dev
_config/
_config/**
# Temp files
tmp/
temp/
*.tmp
# ========================= # =========================
# Generated outputs # CAD / output files
# ========================= # =========================
*.dxf *.dxf
*.svg *.svg
*.json *.step
*.FCStd *.stp
*.iges
*.igs
*.stl
*.obj
# If you want to keep example outputs, comment out the lines above selectively # If you want to keep a sample file, commit it explicitly with:
# git add -f test.step
# ========================= # =========================
# Temporary files # Docker
# ========================= # =========================
*.tmp # Local overrides
*.bak docker-compose.override.yml
*.old
# ========================= # =========================
# OS / misc # Node (if you ever add frontend tooling)
# ========================= # =========================
Thumbs.db node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# =========================
# Secrets (safety)
# =========================
.env
.env.*
*.pem
*.key
*.crt

46
Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
FROM mambaorg/micromamba:1.5.10
WORKDIR /app
# Install FreeCAD + Python via conda-forge (headless)
RUN micromamba create -y -n fc -c conda-forge \
python=3.12 freecad \
&& micromamba clean -a -y
# Activate env for subsequent RUN/CMD
ENV MAMBA_DOCKERFILE_ACTIVATE=1
SHELL ["/bin/bash", "-lc"]
# Install small OS deps (git for SheetMetal)
# NOTE: do NOT switch users; keep it simple and reproducible
USER root
RUN mkdir -p /var/lib/apt/lists/partial \
&& apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Python deps into conda env
COPY requirements.txt /app/requirements.txt
RUN micromamba run -n fc pip install --no-cache-dir -r /app/requirements.txt
# App sources
COPY . /app
# SheetMetal workbench (we'll link it into HOME at runtime)
RUN mkdir -p /opt/freecad_mods \
&& git clone --depth 1 https://github.com/shaise/FreeCAD_SheetMetal.git /opt/freecad_mods/SheetMetal
# Make FreeCAD find SheetMetal without any host volume
ENV HOME=/opt/freecad_home
RUN mkdir -p /opt/freecad_home/.local/share/FreeCAD/Mod \
&& ln -s /opt/freecad_mods/SheetMetal /opt/freecad_home/.local/share/FreeCAD/Mod/SheetMetal
# Runtime env (overridable)
ENV FREECADCMD=/opt/conda/envs/fc/bin/freecadcmd
ENV FREECAD_TIMEOUT_SEC=1200
ENV FREECAD_LOCK_FILE=/app/_jobs/.freecad.lock
ENV HOME=/config
EXPOSE 5055
CMD ["bash", "-lc", "micromamba run -n fc gunicorn -w 1 --threads 4 -b 0.0.0.0:5055 app:app"]

View File

@@ -0,0 +1,3 @@
Internes Tool zur Kalkulation von Blechteilen.
Derzeit keine Unterstützung von Baugruppen!

200
app.py
View File

@@ -4,33 +4,37 @@ import uuid
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
import fcntl
from flask import Flask, render_template, request, abort, Response from flask import Flask, render_template, request, abort, Response
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import ezdxf import ezdxf
from ezdxf.addons.drawing import Frontend, RenderContext, svg, layout, config from ezdxf.addons.drawing import Frontend, RenderContext, svg, layout, config as ezconfig
APP_DIR = Path(__file__).resolve().parent APP_DIR = Path(__file__).resolve().parent
WORKER_DIR = APP_DIR / "worker" WORKER_DIR = APP_DIR / "worker"
FREECADCMD = "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd"
JOBS_DIR = APP_DIR / "_jobs" JOBS_DIR = APP_DIR / "_jobs"
JOBS_DIR.mkdir(exist_ok=True) JOBS_DIR.mkdir(exist_ok=True)
# In Docker: /usr/bin/freecadcmd, lokal (mac): via env setzen, wenn du willst
FREECAD_TIMEOUT_SEC = int(os.environ.get("FREECAD_TIMEOUT_SEC", "1200"))
FREECADCMD = (os.environ.get("FREECADCMD") or "").strip() or "/opt/conda/envs/fc/bin/freecadcmd"
ALLOWED_MATERIALS = [ ALLOWED_MATERIALS = [
("stainless", "Edelstahl"), ("stainless", "Edelstahl"),
("alu", "Aluminium"), ("alu", "Aluminium"),
("copper", "Kupfer"), ("copper", "Kupfer"),
] ]
# Serialize FreeCAD runs (one at a time)
LOCK_FILE = os.environ.get("FREECAD_LOCK_FILE", str(JOBS_DIR / ".freecad.lock"))
app = Flask(__name__) app = Flask(__name__)
def _safe_job_dir(job_id: str) -> Path: 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()): if not job_id or any(c not in "0123456789abcdef" for c in job_id.lower()):
abort(404) abort(404)
job_dir = (JOBS_DIR / job_id).resolve() job_dir = (JOBS_DIR / job_id).resolve()
@@ -41,13 +45,31 @@ def _safe_job_dir(job_id: str) -> Path:
return job_dir return job_dir
class FreeCADLock:
def __init__(self, lock_path: str):
self.lock_path = lock_path
self.fp = None
def __enter__(self):
self.fp = open(self.lock_path, "w")
fcntl.flock(self.fp, fcntl.LOCK_EX)
return self
def __exit__(self, exc_type, exc, tb):
try:
fcntl.flock(self.fp, fcntl.LOCK_UN)
finally:
try:
self.fp.close()
except Exception:
pass
def run_job(job_dir: Path, filename: str, material: str, thickness_mm: str | None): def run_job(job_dir: Path, filename: str, material: str, thickness_mm: str | None):
""" """
Runs FreeCAD headless in a job dir. Keeps original filename. Runs FreeCAD headless in a job dir. Keeps original filename.
Expects: job_dir/filename exists. Produces: job_dir/result.json + flat.dxf (compat), plus named outputs by analyser.
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") shutil.copy2(WORKER_DIR / "stepanalyser.py", job_dir / "stepanalyser.py")
argv = ['"stepanalyser.py"', '"--input"', f'"{filename}"', '"--material"', f'"{material}"'] argv = ['"stepanalyser.py"', '"--input"', f'"{filename}"', '"--material"', f'"{material}"']
@@ -58,7 +80,7 @@ def run_job(job_dir: Path, filename: str, material: str, thickness_mm: str | Non
def write_result(payload): def write_result(payload):
try: try:
with open("result.json", "w", encoding="utf-8") as f: with open(\"result.json\", \"w\", encoding=\"utf-8\") as f:
json.dump(payload, f, indent=2, ensure_ascii=False) json.dump(payload, f, indent=2, ensure_ascii=False)
except Exception: except Exception:
pass pass
@@ -68,87 +90,143 @@ try:
os.chdir(base) os.chdir(base)
if not os.path.exists({json.dumps(filename)}): if not os.path.exists({json.dumps(filename)}):
raise SystemExit("Uploaded STEP file missing: " + {json.dumps(filename)}) raise SystemExit(\"Uploaded STEP file missing: \" + {json.dumps(filename)})
# Ensure FreeCAD can find user Mods (SheetMetal) # Ensure FreeCAD can find user Mods (SheetMetal)
mod_dir = os.path.expanduser("~/Library/Application Support/FreeCAD/Mod") # Linux default: ~/.local/share/FreeCAD/Mod
mod_dir = os.path.expanduser("~/.local/share/FreeCAD/Mod")
if os.path.isdir(mod_dir) and mod_dir not in sys.path: if os.path.isdir(mod_dir) and mod_dir not in sys.path:
sys.path.append(mod_dir) sys.path.append(mod_dir)
sm_dir = os.path.join(mod_dir, "SheetMetal") sm_dir = os.path.join(mod_dir, \"SheetMetal\")
if os.path.isdir(sm_dir) and sm_dir not in sys.path: if os.path.isdir(sm_dir) and sm_dir not in sys.path:
sys.path.append(sm_dir) sys.path.append(sm_dir)
sys.argv = [{", ".join(argv)}] sys.argv = [{", ".join(argv)}]
code = open("stepanalyser.py", "r", encoding="utf-8").read() code = open(\"stepanalyser.py\", \"r\", encoding=\"utf-8\").read()
exec(compile(code, "stepanalyser.py", "exec"), {{"__name__": "__main__"}}) exec(compile(code, \"stepanalyser.py\", \"exec\"), {{\"__name__\": \"__main__\"}})
except BaseException as e: except BaseException as e:
payload = {{ payload = {{
"ok": False, \"ok\": False,
"error_type": type(e).__name__, \"error_type\": type(e).__name__,
"error": str(e), \"error\": str(e),
"traceback": traceback.format_exc() \"traceback\": traceback.format_exc()
}} }}
write_result(payload) write_result(payload)
print("RUNNER ERROR:", payload["error_type"], payload["error"], flush=True) print(\"RUNNER ERROR:\", payload[\"error_type\"], payload[\"error\"], flush=True)
finally: finally:
os._exit(0) os._exit(0)
""" """
(job_dir / "run_stepanalyser.py").write_text(runner_text, encoding="utf-8") (job_dir / "run_stepanalyser.py").write_text(runner_text, encoding="utf-8")
cmd = [FREECADCMD, "run_stepanalyser.py"] cmd = [FREECADCMD, "run_stepanalyser.py"]
env = os.environ.copy() env = os.environ.copy()
env["HOME"] = str(Path.home()) # In linuxserver containers the writable, persistent home is /config
env["HOME"] = env.get("HOME") or "/config"
env["HOME"] = "/config" # enforce for consistent expanduser() + Mod discovery
proc = subprocess.run( # Headless Qt hints (works on Debian-based hosts too)
cmd, env.setdefault("QT_QPA_PLATFORM", "offscreen")
cwd=str(job_dir),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=180,
env=env,
)
log_text = proc.stdout or "" timeout = FREECAD_TIMEOUT_SEC
(job_dir / "run.log").write_text(log_text, encoding="utf-8")
log_path = job_dir / "run.log"
result_path = job_dir / "result.json" result_path = job_dir / "result.json"
# One FreeCAD run at a time
with FreeCADLock(LOCK_FILE):
with open(log_path, "w", encoding="utf-8") as log_fp:
log_fp.write("=== STEPANALYSER START ===\n")
log_fp.write(f"Command: {cmd}\n")
log_fp.flush()
proc = subprocess.Popen(
cmd,
cwd=str(job_dir),
stdin=subprocess.DEVNULL,
stdout=log_fp,
stderr=subprocess.STDOUT,
text=True,
env=env,
)
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# Kill the process and persist a useful error result
try:
proc.kill()
except Exception:
pass
try:
log_fp.write(f"\nTIMEOUT: FreeCAD run exceeded {timeout} seconds and was killed.\n")
log_fp.flush()
except Exception:
pass
tail = ""
try:
tail = log_path.read_text(encoding="utf-8")[-8000:]
except Exception:
pass
err = {
"ok": False,
"error_type": "TimeoutExpired",
"error": f"FreeCAD job timed out after {timeout} seconds",
"log_tail": tail,
}
try:
result_path.write_text(json.dumps(err, indent=2, ensure_ascii=False), encoding="utf-8")
except Exception:
pass
err["_job"] = {
"id": job_dir.name,
"dir": str(job_dir),
"exit_code": None,
"log_file": str(log_path),
"compat_dxf": str(job_dir / "flat.dxf"),
"compat_json": str(result_path),
"svg_preview_url": f"/job/{job_dir.name}/dxf.svg",
"dxf_download_url": f"/job/{job_dir.name}/flat.dxf",
}
return err
# Normal completion path
exit_code = proc.returncode
if result_path.exists(): if result_path.exists():
result = json.loads(result_path.read_text(encoding="utf-8")) result = json.loads(result_path.read_text(encoding="utf-8"))
else: else:
tail = ""
try:
tail = log_path.read_text(encoding="utf-8")[-4000:]
except Exception:
pass
result = { result = {
"ok": False, "ok": False,
"error_type": "RunnerError", "error_type": "RunnerError",
"error": "result.json not created", "error": "result.json not created",
"log_tail": log_text[-4000:], "log_tail": tail,
} }
result["_job"] = { result["_job"] = {
"id": job_dir.name, "id": job_dir.name,
"dir": str(job_dir), "dir": str(job_dir),
"exit_code": proc.returncode, "exit_code": exit_code,
"log_file": str(job_dir / "run.log"), "log_file": str(log_path),
"compat_dxf": str(job_dir / "flat.dxf"), "compat_dxf": str(job_dir / "flat.dxf"),
"compat_json": str(job_dir / "result.json"), "compat_json": str(result_path),
"svg_preview_url": f"/job/{job_dir.name}/dxf.svg", "svg_preview_url": f"/job/{job_dir.name}/dxf.svg",
"dxf_download_url": f"/job/{job_dir.name}/flat.dxf", "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 return result
@@ -156,39 +234,27 @@ def dxf_to_svg_bytes(dxf_path: Path) -> bytes:
doc = ezdxf.readfile(str(dxf_path)) doc = ezdxf.readfile(str(dxf_path))
msp = doc.modelspace() msp = doc.modelspace()
# 1) Context
context = RenderContext(doc) context = RenderContext(doc)
# 2) Backend
backend = svg.SVGBackend() backend = svg.SVGBackend()
# 3) Config: weißer Hintergrund + schwarz (super für Blechteile) cfg = ezconfig.Configuration(
cfg = config.Configuration( background_policy=ezconfig.BackgroundPolicy.WHITE,
background_policy=config.BackgroundPolicy.WHITE, color_policy=ezconfig.ColorPolicy.BLACK,
color_policy=config.ColorPolicy.BLACK,
) )
# 4) Frontend
frontend = Frontend(context, backend, config=cfg) frontend = Frontend(context, backend, config=cfg)
# 5) Zeichnen
frontend.draw_layout(msp) 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)) page = layout.Page(0, 0, layout.Units.mm, margins=layout.Margins.all(2))
# Optional: fit to page (preview-geeignet)
try: try:
settings = layout.Settings(scale=1, fit_page=True) settings = layout.Settings(scale=1, fit_page=True)
svg_string = backend.get_string(page, settings=settings) svg_string = backend.get_string(page, settings=settings)
except TypeError: except TypeError:
# ältere ezdxf-Version ohne settings-Parameter
svg_string = backend.get_string(page) svg_string = backend.get_string(page)
return svg_string.encode("utf-8") return svg_string.encode("utf-8")
@app.get("/") @app.get("/")
def index(): def index():
return render_template("index.html", materials=ALLOWED_MATERIALS) return render_template("index.html", materials=ALLOWED_MATERIALS)
@@ -214,12 +280,13 @@ def analyse():
job_dir = JOBS_DIR / job_id job_dir = JOBS_DIR / job_id
job_dir.mkdir(parents=True, exist_ok=True) job_dir.mkdir(parents=True, exist_ok=True)
# Save upload using original (safe) filename
step_dest = job_dir / filename step_dest = job_dir / filename
f.save(str(step_dest)) f.save(str(step_dest))
thickness_mm = thickness if thickness else None thickness_mm = thickness if thickness else None
result = run_job(job_dir, filename, material, thickness_mm) result = run_job(job_dir, filename, material, thickness_mm)
if not isinstance(result, dict):
result = {"ok": False, "error_type": "InternalError", "error": "Unexpected result type"}
return render_template("result.html", result=result) return render_template("result.html", result=result)
@@ -248,16 +315,11 @@ def preview_dxf_svg(job_id: str):
try: try:
svg_bytes = dxf_to_svg_bytes(dxf_path) svg_bytes = dxf_to_svg_bytes(dxf_path)
except Exception as e: except Exception as e:
# Return a simple SVG with an error message
msg = f"DXF->SVG failed: {type(e).__name__}: {e}" msg = f"DXF->SVG failed: {type(e).__name__}: {e}"
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="200"> svg_fallback = f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="200">
<rect x="0" y="0" width="1200" height="200" fill="#111"/> <rect x="0" y="0" width="1200" height="200" fill="#111"/>
<text x="20" y="80" fill="#fff" font-family="system-ui, -apple-system" font-size="18">{msg}</text> <text x="20" y="80" fill="#fff" font-family="system-ui, -apple-system" font-size="18">{msg}</text>
</svg>""" </svg>"""
return Response(svg.encode("utf-8"), mimetype="image/svg+xml") return Response(svg_fallback.encode("utf-8"), mimetype="image/svg+xml")
return Response(svg_bytes, 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)

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
stepanalyser:
build: .
container_name: stepanalyser
ports:
- "5055:5055"
volumes:
- ./_jobs:/app/_jobs
environment:
- TZ=Europe/Berlin
- FREECADCMD=/opt/conda/envs/fc/bin/freecadcmd
- FREECAD_TIMEOUT_SEC=1200
- FREECAD_LOCK_FILE=/app/_jobs/.freecad.lock
command: >
bash -lc '
mkdir -p /config/.local/share/FreeCAD/Mod &&
if [ ! -e /config/.local/share/FreeCAD/Mod/SheetMetal ]; then
ln -s /opt/freecad_mods/SheetMetal /config/.local/share/FreeCAD/Mod/SheetMetal;
fi &&
micromamba run -n fc gunicorn -w 1 --threads 4 -b 0.0.0.0:5055 app:app
'
restart: unless-stopped

View File

@@ -1,2 +0,0 @@
source .venv/bin/activate
pip install -r requirements.txt

View File

@@ -1,3 +1,4 @@
flask==3.0.3 flask==3.0.3
gunicorn==22.0.0
ezdxf==1.3.4 ezdxf==1.3.4
pillow==10.4.0 pillow==10.4.0

View File

@@ -1,2 +0,0 @@
source .venv/bin/activate
python app.py