feat: extract workflow runtime phase 3 foundation

This commit is contained in:
2026-04-07 09:09:40 +02:00
parent 56ee5fc5bf
commit e3cda1c9f7
7 changed files with 732 additions and 284 deletions
@@ -71,8 +71,13 @@ def render_order_line_task(self, order_line_id: str):
emit(order_line_id, "Celery render task started")
try:
from sqlalchemy import create_engine, select, update as sql_update
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session
from app.config import settings as app_settings
from app.domains.rendering.workflow_runtime_services import (
prepare_order_line_render_context,
resolve_order_line_template_context,
resolve_render_position_context,
)
# Use sync session for Celery (no async event loop)
sync_url = app_settings.database_url.replace("+asyncpg", "")
@@ -81,219 +86,42 @@ def render_order_line_task(self, order_line_id: str):
with Session(engine) as session:
set_tenant_context_sync(session, _tenant_id)
from app.models.order_line import OrderLine
from app.models.product import Product
emit(order_line_id, "Loading order line from database")
line = session.execute(
select(OrderLine)
.where(OrderLine.id == order_line_id)
.options(
joinedload(OrderLine.product).joinedload(Product.cad_file),
joinedload(OrderLine.output_type),
)
).scalar_one_or_none()
if line is None:
emit(order_line_id, "Order line not found in database", "error")
logger.error(f"OrderLine {order_line_id} not found")
return
# Skip if line was cancelled or order was rejected/completed
if line.render_status == "cancelled":
emit(order_line_id, "Order line already cancelled — skipping render")
logger.info(f"OrderLine {order_line_id} cancelled — skipping")
return
from app.domains.orders.models import Order, OrderStatus
order = session.execute(
select(Order).where(Order.id == line.order_id)
).scalar_one_or_none()
if order and order.status in (OrderStatus.rejected, OrderStatus.completed):
emit(order_line_id, f"Order {order.status.value} — skipping render")
logger.info(f"OrderLine {order_line_id}: order {order.status.value} — skipping")
if line.render_status in ("pending", "processing"):
session.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(render_status="cancelled")
)
session.commit()
return
if line.product.cad_file_id is None:
emit(order_line_id, "Product has no CAD file — marking as failed", "error")
logger.warning(f"OrderLine {order_line_id}: product has no CAD file")
session.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(render_status="failed")
)
session.commit()
return
# Mark as processing with timing
from datetime import datetime
render_start = datetime.utcnow()
session.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(
render_status="processing",
render_backend_used="celery",
render_started_at=render_start,
)
)
session.commit()
cad_file = line.product.cad_file
materials_source = line.product.cad_part_materials
# Look up USD master asset for this CAD file — used when rendering
# via USD path instead of production GLB
from app.domains.media.models import MediaAsset, MediaAssetType
from pathlib import Path as _Path
usd_render_path = None
if cad_file:
_usd_asset = session.execute(
select(MediaAsset)
.where(
MediaAsset.cad_file_id == cad_file.id,
MediaAsset.asset_type == MediaAssetType.usd_master,
)
.order_by(MediaAsset.created_at.desc())
.limit(1)
).scalar_one_or_none()
if _usd_asset and _usd_asset.storage_key:
_usd_candidate = _Path(app_settings.upload_dir) / _usd_asset.storage_key
if _usd_candidate.exists():
usd_render_path = _usd_candidate
logger.info(
"render_order_line: using usd_master %s for cad %s",
_usd_candidate.name, cad_file.id,
)
# Look up existing GLB geometry asset — reuse to skip re-tessellation
# when rendering via the GLB path (non-USD fallback).
glb_reuse_path = None
if cad_file and not usd_render_path:
_glb_asset = session.execute(
select(MediaAsset)
.where(
MediaAsset.cad_file_id == cad_file.id,
MediaAsset.asset_type == MediaAssetType.gltf_geometry,
)
.order_by(MediaAsset.created_at.desc())
.limit(1)
).scalar_one_or_none()
if _glb_asset and _glb_asset.storage_key:
_glb_candidate = _Path(app_settings.upload_dir) / _glb_asset.storage_key
if _glb_candidate.exists() and _glb_candidate.stat().st_size > 0:
# Copy to the path render_blender.py expects so its
# local cache check (`glb_path.exists()`) finds it.
_step_path = _Path(cad_file.stored_path)
_expected_glb = _step_path.parent / f"{_step_path.stem}_thumbnail.glb"
if not _expected_glb.exists() or _expected_glb.stat().st_size == 0:
try:
import shutil as _shutil
_shutil.copy2(str(_glb_candidate), str(_expected_glb))
logger.info(
"render_order_line: reused gltf_geometry asset %s -> %s",
_glb_candidate.name, _expected_glb.name,
)
glb_reuse_path = _expected_glb
except Exception as _copy_exc:
logger.warning(
"render_order_line: failed to copy GLB asset: %s", _copy_exc,
)
else:
glb_reuse_path = _expected_glb
setup = prepare_order_line_render_context(
session,
order_line_id,
emit=emit,
)
if not setup.is_ready:
return
if usd_render_path:
emit(order_line_id, "Using USD master for render (skipping GLB tessellation)")
elif glb_reuse_path:
emit(order_line_id, f"Reusing cached GLB geometry ({glb_reuse_path.name}) — skipping re-tessellation")
else:
emit(order_line_id, "No USD master or cached GLB — will tessellate STEP -> GLB")
line = setup.order_line
cad_file = setup.cad_file
materials_source = setup.materials_source
usd_render_path = setup.usd_render_path
glb_reuse_path = setup.glb_reuse_path
part_colors = setup.part_colors
render_start = setup.render_start
part_colors = {}
if cad_file and cad_file.parsed_objects:
parsed_names = cad_file.parsed_objects.get("objects", [])
if materials_source:
from app.services.step_processor import build_part_colors
part_colors = build_part_colors(parsed_names, materials_source)
# Resolve render template + material library
from app.services.template_service import resolve_template, get_material_library_path
category_key = line.product.category_key if line.product else None
ot_id = str(line.output_type_id) if line.output_type_id else None
template = resolve_template(category_key=category_key, output_type_id=ot_id)
material_library = get_material_library_path()
# Build material_map (part_name → material_name) for material replacement.
# Works with or without a render template — only suppressed if a
# template explicitly has material_replace_enabled=False.
material_map = None
use_materials = bool(material_library and materials_source)
if template and not template.material_replace_enabled:
use_materials = False
if use_materials:
material_map = {
m["part_name"]: m["material"]
for m in materials_source
if m.get("part_name") and m.get("material")
}
# Resolve raw material names to HARTOMAT library names via aliases
from app.services.material_service import resolve_material_map
material_map = resolve_material_map(material_map)
# Apply material override: per-line override takes priority over output type override
_line_override = getattr(line, 'material_override', None)
_ot_override = line.output_type.material_override if line.output_type else None
override_mat = _line_override or _ot_override
if override_mat:
# Build override map from existing material_map keys or from parsed STEP parts
override_keys = set()
if material_map:
override_keys = set(material_map.keys())
if cad_file and cad_file.parsed_objects:
for part_name in cad_file.parsed_objects.get("objects", []):
override_keys.add(part_name)
material_map = {k: override_mat for k in override_keys}
use_materials = True
emit(order_line_id, f"Material override active: {len(material_map)} parts → {override_mat}")
if template:
emit(order_line_id, f"Using render template: {template.name} (collection={template.target_collection}, material_replace={template.material_replace_enabled}, lighting_only={template.lighting_only})")
logger.info(f"Render template resolved: '{template.name}' path={template.blend_file_path}, lighting_only={template.lighting_only}")
else:
emit(order_line_id, "No render template found — using factory settings (Mode A)")
logger.info(f"No render template for category_key={category_key!r}, output_type_id={ot_id!r}")
template_context = resolve_order_line_template_context(
session,
setup,
emit=emit,
)
template = template_context.template
material_library = template_context.material_library
material_map = template_context.material_map
use_materials = template_context.use_materials
override_mat = template_context.override_material
cad_name = cad_file.original_name if cad_file else "?"
# Load render_position for rotation values (per-product takes priority, falls back to global)
rotation_x = rotation_y = rotation_z = 0.0
focal_length_mm = None
sensor_width_mm = None
if line.render_position_id:
from app.models.render_position import ProductRenderPosition
rp = session.get(ProductRenderPosition, line.render_position_id)
if rp:
rotation_x, rotation_y, rotation_z = rp.rotation_x, rp.rotation_y, rp.rotation_z
focal_length_mm = rp.focal_length_mm
sensor_width_mm = rp.sensor_width_mm
emit(order_line_id, f"Render position: '{rp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)" +
(f" focal_length={focal_length_mm}mm" if focal_length_mm else ""))
elif line.global_render_position_id:
from app.models import GlobalRenderPosition
grp = session.get(GlobalRenderPosition, line.global_render_position_id)
if grp:
rotation_x, rotation_y, rotation_z = grp.rotation_x, grp.rotation_y, grp.rotation_z
focal_length_mm = grp.focal_length_mm
sensor_width_mm = grp.sensor_width_mm
emit(order_line_id, f"Global render position: '{grp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)" +
(f" focal_length={focal_length_mm}mm" if focal_length_mm else ""))
position_context = resolve_render_position_context(session, line, emit=emit)
rotation_x = position_context.rotation_x
rotation_y = position_context.rotation_y
rotation_z = position_context.rotation_z
focal_length_mm = position_context.focal_length_mm
sensor_width_mm = position_context.sensor_width_mm
emit(order_line_id, f"Starting render for {cad_name} ({len(part_colors)} coloured parts)")