feat: extract workflow bbox services phase 3

This commit is contained in:
2026-04-07 09:42:06 +02:00
parent 8f8d2e68b7
commit 9c93ecef49
6 changed files with 172 additions and 54 deletions
@@ -17,59 +17,17 @@ logger = logging.getLogger(__name__)
def _bbox_from_glb(glb_path: str) -> dict | None: 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. return extract_bbox_from_glb(glb_path)
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
def _bbox_from_step_cadquery(step_path: str) -> dict | None: def _bbox_from_step_cadquery(step_path: str) -> dict | None:
"""Fallback: extract bounding box by re-parsing STEP via cadquery.""" """Backward-compatible wrapper for STEP bbox fallback extraction."""
try: from app.domains.rendering.workflow_runtime_services import extract_bbox_from_step_cadquery
import cadquery as cq
bb = cq.importers.importStep(step_path).val().BoundingBox() return extract_bbox_from_step_cadquery(step_path)
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
@celery_app.task(bind=True, name="app.tasks.step_tasks.process_step_file", queue="step_processing") @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 sqlalchemy.orm import Session
from app.config import settings as app_settings from app.config import settings as app_settings
from app.models.cad_file import CadFile from app.models.cad_file import CadFile
from app.domains.rendering.workflow_runtime_services import resolve_cad_bbox
pl = PipelineLogger(task_id=None) pl = PipelineLogger(task_id=None)
pl.step_start("reextract_cad_metadata", {"cad_file_id": cad_file_id}) 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: try:
p = Path(step_path) p = Path(step_path)
glb_path = p.parent / f"{p.stem}_thumbnail.glb" 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: if patch:
with Session(eng) as session: with Session(eng) as session:
set_tenant_context_sync(session, _tenant_id) set_tenant_context_sync(session, _tenant_id)
@@ -117,7 +117,7 @@ def render_step_thumbnail(self, cad_file_id: str):
# ── Post-render: bbox + sharp edges + materials (single session) ────── # ── Post-render: bbox + sharp edges + materials (single session) ──────
try: try:
from app.models.cad_file import CadFile 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: with _pipeline_session(_tenant_id) as session:
cad = session.get(CadFile, cad_file_id) 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"): if step_path and not attrs.get("dimensions_mm"):
_step = Path(step_path) _step = Path(step_path)
_glb = _step.parent / f"{_step.stem}_thumbnail.glb" _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: if bbox_data:
cad.mesh_attributes = {**attrs, **bbox_data} cad.mesh_attributes = {**attrs, **bbox_data}
attrs = cad.mesh_attributes attrs = cad.mesh_attributes
@@ -87,6 +87,18 @@ class AutoPopulateMaterialsResult:
cad_parts: list[str] = field(default_factory=list) 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: def _emit(emit: EmitFn, order_line_id: str, message: str, level: str | None = None) -> None:
if emit is None: if emit is None:
return return
@@ -105,6 +117,86 @@ def _resolve_asset_path(storage_key: str | None) -> Path | None:
return 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( def prepare_order_line_render_context(
session: Session, session: Session,
order_line_id: str, order_line_id: str,
@@ -17,6 +17,7 @@ from app.domains.products.models import CadFile, Product
from app.domains.rendering.models import OutputType, RenderTemplate from app.domains.rendering.models import OutputType, RenderTemplate
from app.domains.rendering.workflow_runtime_services import ( from app.domains.rendering.workflow_runtime_services import (
auto_populate_materials_for_cad, auto_populate_materials_for_cad,
resolve_cad_bbox,
prepare_order_line_render_context, prepare_order_line_render_context,
resolve_order_line_material_map, resolve_order_line_material_map,
resolve_order_line_template_context, 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": "InnerRing", "material": "Steel raw"},
{"part_name": "OuterRing", "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}
}
@@ -22,7 +22,7 @@
- [ ] Missing legacy steps extracted into reusable executors - [ ] Missing legacy steps extracted into reusable executors
- [ ] Extracted node behavior matches legacy services - [ ] Extracted node behavior matches legacy services
- [ ] Node-level tests cover success and failure paths - [ ] 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 ### Phase 4
@@ -55,7 +55,7 @@
- `E3-T3` Extract `resolve_template`. `completed` - `E3-T3` Extract `resolve_template`. `completed`
- `E3-T4` Extract `material_map_resolve`. `completed` - `E3-T4` Extract `material_map_resolve`. `completed`
- `E3-T5` Extract `auto_populate_materials`. `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-T7` Extract `output_save`.
- `E3-T8` Extract `notify`. - `E3-T8` Extract `notify`.
- `E3-T9` Add executor tests for all extracted nodes. - `E3-T9` Add executor tests for all extracted nodes.