Phase 1: FastAPI backend with async job model
- backend/app: FastAPI API wrapping the CAD skill modules - upload -> job -> poll -> model / BOM / artifacts -> geometry query - SQLite via SQLModel (Model, Job, BomRow, QueryLog) - ThreadPoolExecutor worker, serialized, with live stage updates - docker-compose.yml: dev server (mounts source, --reload) on :8000 - api-test.sh: end-to-end live validation script - requirements.txt: add fastapi, uvicorn, python-multipart, sqlmodel - external_diagram.py: port active-area detection OCC.Core -> OCP - .gitignore, PHASE1.md Validated live: MR16 round-trip passes (28 BOM rows, 12 artifacts, bounding-box query, xlsx download; active-area detection working). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""Pipeline orchestration — wraps the CAD skill modules.
|
||||
|
||||
Mirrors step_processor.main() but writes artifacts into a per-model directory and
|
||||
returns structured metadata + BOM rows for the DB instead of printing a summary.
|
||||
Heavy kernel imports happen lazily inside each stage.
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from . import skill_bridge # noqa: F401 — sets sys.path so `modules.*` imports resolve
|
||||
|
||||
log = logging.getLogger("step_parser.processing")
|
||||
|
||||
Progress = Callable[[str], None]
|
||||
|
||||
|
||||
def _f(v) -> Optional[float]:
|
||||
try:
|
||||
x = float(v)
|
||||
return None if math.isnan(x) else round(x, 2)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _i(v) -> Optional[int]:
|
||||
try:
|
||||
x = float(v)
|
||||
return None if math.isnan(x) else int(x)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _s(v) -> Optional[str]:
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def _bom_df_to_rows(df) -> list[dict]:
|
||||
rows = []
|
||||
for _, r in df.iterrows():
|
||||
rows.append({
|
||||
"part_number": _s(r.get("part_number")),
|
||||
"part_name_original": _s(r.get("part_name_original")),
|
||||
"part_name_english": _s(r.get("part_name_english")),
|
||||
"quantity": _i(r.get("quantity")),
|
||||
"level": _i(r.get("level")),
|
||||
"parent": _s(r.get("parent")),
|
||||
"bbox_x_mm": _f(r.get("bbox_x_mm")),
|
||||
"bbox_y_mm": _f(r.get("bbox_y_mm")),
|
||||
"bbox_z_mm": _f(r.get("bbox_z_mm")),
|
||||
"notes": _s(r.get("notes")),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _collect_artifacts(out_dir: Path) -> list[str]:
|
||||
"""All downloadable files in the model dir (original upload + generated outputs)."""
|
||||
return sorted(p.name for p in out_dir.iterdir() if p.is_file())
|
||||
|
||||
|
||||
def run_pipeline(step_path: Path, out_dir: Path, options: dict, progress: Progress) -> dict:
|
||||
"""Run the requested pipeline stages. Returns metadata dict for the DB."""
|
||||
meta: dict = {
|
||||
"backend": None, "face_count": None, "part_count": None,
|
||||
"has_chinese": False, "bbox": (None, None, None),
|
||||
"bom_rows": [], "artifacts": [],
|
||||
}
|
||||
|
||||
progress("loading")
|
||||
import modules.loader as loader_mod
|
||||
model = loader_mod.load_step(step_path)
|
||||
if model is None:
|
||||
raise RuntimeError("Failed to load STEP — build123d/FreeCAD unavailable or file invalid")
|
||||
meta["backend"] = model.backend
|
||||
meta["face_count"] = model.face_count
|
||||
meta["part_count"] = len(model.parts) if model.parts else None
|
||||
|
||||
bom_df = None
|
||||
if options.get("bom", True):
|
||||
progress("bom")
|
||||
from modules.bom import extract_bom, save_bom_xlsx
|
||||
bom_df = extract_bom(model)
|
||||
save_bom_xlsx(bom_df, step_path)
|
||||
|
||||
if bom_df is not None:
|
||||
from modules.translator import has_chinese
|
||||
meta["has_chinese"] = bool(bom_df["part_name_original"].apply(has_chinese).any())
|
||||
|
||||
if options.get("translate") and meta["has_chinese"]:
|
||||
if os.environ.get("ANTHROPIC_API_KEY"):
|
||||
progress("translate")
|
||||
from modules.translator import get_translation_map, translate_bom
|
||||
bom_df = translate_bom(bom_df, model_name=step_path.stem)
|
||||
save_bom_xlsx(bom_df, step_path)
|
||||
tmap = get_translation_map(bom_df)
|
||||
if tmap:
|
||||
from modules.rewriter import rewrite_step
|
||||
rewrite_step(step_path, tmap)
|
||||
else:
|
||||
log.warning("translate requested but ANTHROPIC_API_KEY not set — skipping")
|
||||
|
||||
meta["bom_rows"] = _bom_df_to_rows(bom_df)
|
||||
try:
|
||||
root = bom_df[bom_df["level"] == 0]
|
||||
if len(root):
|
||||
r0 = root.iloc[0]
|
||||
meta["bbox"] = (_f(r0["bbox_x_mm"]), _f(r0["bbox_y_mm"]), _f(r0["bbox_z_mm"]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if options.get("thumbnails", True):
|
||||
progress("thumbnails")
|
||||
from modules.renderer import render_views
|
||||
render_views(model, step_path)
|
||||
|
||||
if options.get("diagram", False):
|
||||
progress("diagram")
|
||||
from modules.external_diagram import step_external_diagram
|
||||
step_external_diagram(
|
||||
path=str(step_path),
|
||||
mode=options.get("diagram_mode", "enclosure_only"),
|
||||
options={"pdf": bool(options.get("diagram_pdf", False))},
|
||||
)
|
||||
|
||||
progress("collect")
|
||||
meta["artifacts"] = _collect_artifacts(out_dir)
|
||||
return meta
|
||||
|
||||
|
||||
def run_query(step_path: Path, query: str) -> str:
|
||||
"""Load a model and run a single geometry query (synchronous)."""
|
||||
import modules.loader as loader_mod
|
||||
from modules.query_engine import run_query as _run_query
|
||||
model = loader_mod.load_step(step_path)
|
||||
if model is None:
|
||||
raise RuntimeError("Failed to load STEP for query")
|
||||
return _run_query(model, query)
|
||||
Reference in New Issue
Block a user