feat: extract workflow bbox services phase 3
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user