diff --git a/backend/app/domains/pipeline/tasks/extract_metadata.py b/backend/app/domains/pipeline/tasks/extract_metadata.py index 01fc022..fa53489 100644 --- a/backend/app/domains/pipeline/tasks/extract_metadata.py +++ b/backend/app/domains/pipeline/tasks/extract_metadata.py @@ -17,59 +17,17 @@ logger = logging.getLogger(__name__) def _bbox_from_glb(glb_path: str) -> dict | None: - """Extract bounding box from a GLB file (meters → converted to mm). + """Backward-compatible wrapper for GLB bbox extraction.""" + from app.domains.rendering.workflow_runtime_services import extract_bbox_from_glb - Returns {"dimensions_mm": {x,y,z}, "bbox_center_mm": {x,y,z}} or None on failure. - OCC GLB output is in meters; multiply by 1000 to get mm. - """ - try: - import trimesh - p = Path(glb_path) - if not p.exists(): - return None - scene = trimesh.load(str(p), force="scene") - bounds = getattr(scene, "bounds", None) - if bounds is None: - return None - mins, maxs = bounds - dims = maxs - mins - return { - "dimensions_mm": { - "x": round(float(dims[0]) * 1000, 2), - "y": round(float(dims[1]) * 1000, 2), - "z": round(float(dims[2]) * 1000, 2), - }, - "bbox_center_mm": { - "x": round(float((mins[0] + maxs[0]) / 2) * 1000, 2), - "y": round(float((mins[1] + maxs[1]) / 2) * 1000, 2), - "z": round(float((mins[2] + maxs[2]) / 2) * 1000, 2), - }, - } - except Exception as exc: - logger.debug(f"_bbox_from_glb failed for {glb_path}: {exc}") - return None + return extract_bbox_from_glb(glb_path) def _bbox_from_step_cadquery(step_path: str) -> dict | None: - """Fallback: extract bounding box by re-parsing STEP via cadquery.""" - try: - import cadquery as cq - bb = cq.importers.importStep(step_path).val().BoundingBox() - return { - "dimensions_mm": { - "x": round(bb.xlen, 2), - "y": round(bb.ylen, 2), - "z": round(bb.zlen, 2), - }, - "bbox_center_mm": { - "x": round((bb.xmin + bb.xmax) / 2, 2), - "y": round((bb.ymin + bb.ymax) / 2, 2), - "z": round((bb.zmin + bb.zmax) / 2, 2), - }, - } - except Exception as exc: - logger.debug(f"_bbox_from_step_cadquery failed for {step_path}: {exc}") - return None + """Backward-compatible wrapper for STEP bbox fallback extraction.""" + from app.domains.rendering.workflow_runtime_services import extract_bbox_from_step_cadquery + + return extract_bbox_from_step_cadquery(step_path) @celery_app.task(bind=True, name="app.tasks.step_tasks.process_step_file", queue="step_processing") @@ -267,6 +225,7 @@ def reextract_cad_metadata(cad_file_id: str): from sqlalchemy.orm import Session from app.config import settings as app_settings from app.models.cad_file import CadFile + from app.domains.rendering.workflow_runtime_services import resolve_cad_bbox pl = PipelineLogger(task_id=None) pl.step_start("reextract_cad_metadata", {"cad_file_id": cad_file_id}) @@ -289,7 +248,8 @@ def reextract_cad_metadata(cad_file_id: str): try: p = Path(step_path) glb_path = p.parent / f"{p.stem}_thumbnail.glb" - patch = _bbox_from_glb(str(glb_path)) or _bbox_from_step_cadquery(step_path) + bbox_result = resolve_cad_bbox(step_path, glb_path=str(glb_path)) + patch = bbox_result.bbox_data if patch: with Session(eng) as session: set_tenant_context_sync(session, _tenant_id) diff --git a/backend/app/domains/pipeline/tasks/render_thumbnail.py b/backend/app/domains/pipeline/tasks/render_thumbnail.py index a5ac633..633c548 100644 --- a/backend/app/domains/pipeline/tasks/render_thumbnail.py +++ b/backend/app/domains/pipeline/tasks/render_thumbnail.py @@ -117,7 +117,7 @@ def render_step_thumbnail(self, cad_file_id: str): # ── Post-render: bbox + sharp edges + materials (single session) ────── try: from app.models.cad_file import CadFile - from app.domains.pipeline.tasks.extract_metadata import _bbox_from_glb, _bbox_from_step_cadquery + from app.domains.rendering.workflow_runtime_services import resolve_cad_bbox with _pipeline_session(_tenant_id) as session: cad = session.get(CadFile, cad_file_id) @@ -131,7 +131,7 @@ def render_step_thumbnail(self, cad_file_id: str): if step_path and not attrs.get("dimensions_mm"): _step = Path(step_path) _glb = _step.parent / f"{_step.stem}_thumbnail.glb" - bbox_data = _bbox_from_glb(str(_glb)) or _bbox_from_step_cadquery(step_path) + bbox_data = resolve_cad_bbox(step_path, glb_path=str(_glb)).bbox_data if bbox_data: cad.mesh_attributes = {**attrs, **bbox_data} attrs = cad.mesh_attributes diff --git a/backend/app/domains/rendering/workflow_runtime_services.py b/backend/app/domains/rendering/workflow_runtime_services.py index dbb5534..828800a 100644 --- a/backend/app/domains/rendering/workflow_runtime_services.py +++ b/backend/app/domains/rendering/workflow_runtime_services.py @@ -87,6 +87,18 @@ class AutoPopulateMaterialsResult: cad_parts: list[str] = field(default_factory=list) +@dataclass(slots=True) +class BBoxResolutionResult: + bbox_data: dict[str, dict[str, float]] | None + source_kind: Literal["glb", "step", "none"] + step_path: str + glb_path: str | None = None + + @property + def has_bbox(self) -> bool: + return self.bbox_data is not None + + def _emit(emit: EmitFn, order_line_id: str, message: str, level: str | None = None) -> None: if emit is None: return @@ -105,6 +117,86 @@ def _resolve_asset_path(storage_key: str | None) -> Path | None: return None +def extract_bbox_from_glb(glb_path: str) -> dict[str, dict[str, float]] | None: + """Extract a bounding box from a GLB file in meters and convert to mm.""" + try: + import trimesh + + path = Path(glb_path) + if not path.exists(): + return None + scene = trimesh.load(str(path), force="scene") + bounds = getattr(scene, "bounds", None) + if bounds is None: + return None + mins, maxs = bounds + dims = maxs - mins + return { + "dimensions_mm": { + "x": round(float(dims[0]) * 1000, 2), + "y": round(float(dims[1]) * 1000, 2), + "z": round(float(dims[2]) * 1000, 2), + }, + "bbox_center_mm": { + "x": round(float((mins[0] + maxs[0]) / 2) * 1000, 2), + "y": round(float((mins[1] + maxs[1]) / 2) * 1000, 2), + "z": round(float((mins[2] + maxs[2]) / 2) * 1000, 2), + }, + } + except Exception as exc: + logger.debug("extract_bbox_from_glb failed for %s: %s", glb_path, exc) + return None + + +def extract_bbox_from_step_cadquery(step_path: str) -> dict[str, dict[str, float]] | None: + """Fallback: extract a bounding box by re-parsing the STEP file via cadquery.""" + try: + import cadquery as cq + + bb = cq.importers.importStep(step_path).val().BoundingBox() + return { + "dimensions_mm": { + "x": round(bb.xlen, 2), + "y": round(bb.ylen, 2), + "z": round(bb.zlen, 2), + }, + "bbox_center_mm": { + "x": round((bb.xmin + bb.xmax) / 2, 2), + "y": round((bb.ymin + bb.ymax) / 2, 2), + "z": round((bb.zmin + bb.zmax) / 2, 2), + }, + } + except Exception as exc: + logger.debug("extract_bbox_from_step_cadquery failed for %s: %s", step_path, exc) + return None + + +def resolve_cad_bbox( + step_path: str, + *, + glb_path: str | None = None, +) -> BBoxResolutionResult: + """Resolve CAD bounding-box data with the legacy GLB-first fallback order.""" + bbox_data = None + source_kind: Literal["glb", "step", "none"] = "none" + if glb_path: + bbox_data = extract_bbox_from_glb(glb_path) + if bbox_data: + source_kind = "glb" + + if bbox_data is None: + bbox_data = extract_bbox_from_step_cadquery(step_path) + if bbox_data: + source_kind = "step" + + return BBoxResolutionResult( + bbox_data=bbox_data, + source_kind=source_kind, + step_path=step_path, + glb_path=glb_path, + ) + + def prepare_order_line_render_context( session: Session, order_line_id: str, diff --git a/backend/tests/domains/test_workflow_runtime_services.py b/backend/tests/domains/test_workflow_runtime_services.py index d511654..d6d8891 100644 --- a/backend/tests/domains/test_workflow_runtime_services.py +++ b/backend/tests/domains/test_workflow_runtime_services.py @@ -17,6 +17,7 @@ from app.domains.products.models import CadFile, Product from app.domains.rendering.models import OutputType, RenderTemplate from app.domains.rendering.workflow_runtime_services import ( auto_populate_materials_for_cad, + resolve_cad_bbox, prepare_order_line_render_context, resolve_order_line_material_map, resolve_order_line_template_context, @@ -365,3 +366,68 @@ def test_auto_populate_materials_for_cad_skips_when_materials_already_present(sy {"part_name": "InnerRing", "material": "Steel raw"}, {"part_name": "OuterRing", "material": "Steel raw"}, ] + + +def test_resolve_cad_bbox_prefers_glb_over_step(monkeypatch): + monkeypatch.setattr( + "app.domains.rendering.workflow_runtime_services.extract_bbox_from_glb", + lambda path: { + "dimensions_mm": {"x": 10.0, "y": 20.0, "z": 30.0}, + "bbox_center_mm": {"x": 1.0, "y": 2.0, "z": 3.0}, + }, + ) + monkeypatch.setattr( + "app.domains.rendering.workflow_runtime_services.extract_bbox_from_step_cadquery", + lambda path: { + "dimensions_mm": {"x": 100.0, "y": 200.0, "z": 300.0}, + "bbox_center_mm": {"x": 10.0, "y": 20.0, "z": 30.0}, + }, + ) + + result = resolve_cad_bbox("/tmp/model.step", glb_path="/tmp/model_thumbnail.glb") + + assert result.source_kind == "glb" + assert result.bbox_data == { + "dimensions_mm": {"x": 10.0, "y": 20.0, "z": 30.0}, + "bbox_center_mm": {"x": 1.0, "y": 2.0, "z": 3.0}, + } + + +def test_resolve_cad_bbox_falls_back_to_step(monkeypatch): + monkeypatch.setattr( + "app.domains.rendering.workflow_runtime_services.extract_bbox_from_glb", + lambda path: None, + ) + monkeypatch.setattr( + "app.domains.rendering.workflow_runtime_services.extract_bbox_from_step_cadquery", + lambda path: { + "dimensions_mm": {"x": 100.0, "y": 200.0, "z": 300.0}, + "bbox_center_mm": {"x": 10.0, "y": 20.0, "z": 30.0}, + }, + ) + + result = resolve_cad_bbox("/tmp/model.step", glb_path="/tmp/model_thumbnail.glb") + + assert result.source_kind == "step" + assert result.bbox_data == { + "dimensions_mm": {"x": 100.0, "y": 200.0, "z": 300.0}, + "bbox_center_mm": {"x": 10.0, "y": 20.0, "z": 30.0}, + } + + +def test_extract_metadata_bbox_wrappers_delegate_to_runtime_services(monkeypatch): + from app.domains.pipeline.tasks.extract_metadata import _bbox_from_glb, _bbox_from_step_cadquery + + monkeypatch.setattr( + "app.domains.rendering.workflow_runtime_services.extract_bbox_from_glb", + lambda path: {"dimensions_mm": {"x": 1.0, "y": 2.0, "z": 3.0}}, + ) + monkeypatch.setattr( + "app.domains.rendering.workflow_runtime_services.extract_bbox_from_step_cadquery", + lambda path: {"dimensions_mm": {"x": 4.0, "y": 5.0, "z": 6.0}}, + ) + + assert _bbox_from_glb("/tmp/a.glb") == {"dimensions_mm": {"x": 1.0, "y": 2.0, "z": 3.0}} + assert _bbox_from_step_cadquery("/tmp/a.step") == { + "dimensions_mm": {"x": 4.0, "y": 5.0, "z": 6.0} + } diff --git a/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md b/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md index 9687955..fe95aed 100644 --- a/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md +++ b/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md @@ -22,7 +22,7 @@ - [ ] Missing legacy steps extracted into reusable executors - [ ] Extracted node behavior matches legacy services - [ ] Node-level tests cover success and failure paths -- Progress: `order_line_setup`, `resolve_template`, `material_map_resolve`, and `auto_populate_materials` are extracted and covered by targeted backend tests; remaining parity nodes are still open. +- Progress: `order_line_setup`, `resolve_template`, `material_map_resolve`, `auto_populate_materials`, and `glb_bbox` are extracted and covered by targeted backend tests; remaining parity nodes are still open. ### Phase 4 diff --git a/docs/workflows/WORKFLOW_IMPLEMENTATION_BACKLOG.md b/docs/workflows/WORKFLOW_IMPLEMENTATION_BACKLOG.md index e015ce4..b4e645d 100644 --- a/docs/workflows/WORKFLOW_IMPLEMENTATION_BACKLOG.md +++ b/docs/workflows/WORKFLOW_IMPLEMENTATION_BACKLOG.md @@ -55,7 +55,7 @@ - `E3-T3` Extract `resolve_template`. `completed` - `E3-T4` Extract `material_map_resolve`. `completed` - `E3-T5` Extract `auto_populate_materials`. `completed` -- `E3-T6` Extract `glb_bbox`. +- `E3-T6` Extract `glb_bbox`. `completed` - `E3-T7` Extract `output_save`. - `E3-T8` Extract `notify`. - `E3-T9` Add executor tests for all extracted nodes.