feat: unify order-line render invocation paths

This commit is contained in:
2026-04-08 21:57:37 +02:00
parent 042f62fe55
commit dde04fcaa5
5 changed files with 3016 additions and 278 deletions
@@ -17,28 +17,30 @@ 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,
build_order_line_render_invocation,
emit_order_line_render_notifications,
MaterialResolutionResult,
OrderLineRenderSetupResult,
persist_order_line_media_asset,
persist_order_line_output,
resolve_cad_bbox,
prepare_order_line_render_context,
resolve_order_line_material_map,
resolve_order_line_template_context,
RenderPositionContext,
TemplateResolutionResult,
)
from app.domains.tenants.models import Tenant
import app.models # noqa: F401
TEST_DB_URL = os.environ.get(
"TEST_DATABASE_URL",
"postgresql+asyncpg://hartomat:hartomat@localhost:5432/hartomat_test",
).replace("+asyncpg", "")
from tests.db_test_utils import reset_public_schema_sync, resolve_test_db_url
@pytest.fixture
def sync_session():
engine = create_engine(TEST_DB_URL)
engine = create_engine(resolve_test_db_url(async_driver=False))
with engine.begin() as conn:
reset_public_schema_sync(conn)
Base.metadata.create_all(conn)
session = Session(engine)
@@ -47,8 +49,7 @@ def sync_session():
finally:
session.close()
with engine.begin() as conn:
conn.execute(text("DROP SCHEMA public CASCADE"))
conn.execute(text("CREATE SCHEMA public"))
reset_public_schema_sync(conn)
engine.dispose()
@@ -121,6 +122,13 @@ def test_prepare_order_line_render_context_marks_line_processing_and_prefers_usd
upload_dir.mkdir(parents=True, exist_ok=True)
line = _seed_order_line_graph(sync_session, tmp_path)
line.product.cad_file.resolved_material_assignments = {
"inner_ring": {
"source_name": "InnerRing",
"prim_path": "/Root/Assembly/inner_ring",
"canonical_material": "HARTOMAT_010101_Steel-Bare",
}
}
usd_asset_path = upload_dir / "usd" / "bearing.usd"
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
usd_asset_path.write_text("USD", encoding="utf-8")
@@ -159,6 +167,82 @@ def test_prepare_order_line_render_context_marks_line_processing_and_prefers_usd
assert any("Using USD master for render" in message for message in messages)
def test_prepare_order_line_render_context_queues_refresh_for_legacy_usd(sync_session, tmp_path, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
upload_dir = Path(settings.upload_dir)
upload_dir.mkdir(parents=True, exist_ok=True)
line = _seed_order_line_graph(sync_session, tmp_path)
line.product.cad_file.resolved_material_assignments = {
"inner_ring": {
"source_name": "InnerRing",
"prim_path": "/Root/Assembly/inner_ring",
"canonical_material": "SCHAEFFLER_010101_Steel-Bare",
}
}
usd_asset_path = upload_dir / "usd" / "bearing.usd"
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
usd_asset_path.write_text("USD", encoding="utf-8")
glb_asset_path = upload_dir / "step_files" / "bearing_thumbnail.glb"
glb_asset_path.parent.mkdir(parents=True, exist_ok=True)
glb_asset_path.write_text("GLB", encoding="utf-8")
sync_session.add_all(
[
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.usd_master,
storage_key="usd/bearing.usd",
),
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.gltf_geometry,
storage_key="step_files/bearing_thumbnail.glb",
),
]
)
sync_session.commit()
queued: list[str] = []
messages: list[str] = []
class _Task:
@staticmethod
def delay(cad_file_id: str) -> None:
queued.append(cad_file_id)
monkeypatch.setattr(
"app.tasks.step_tasks.generate_usd_master_task",
_Task(),
)
result = prepare_order_line_render_context(
sync_session,
str(line.id),
emit=lambda order_line_id, message, level=None: messages.append(message),
)
sync_session.refresh(line)
expected_glb = tmp_path / "parts" / "bearing_thumbnail.glb"
assert result.is_ready
assert result.usd_render_path is None
assert result.glb_reuse_path == expected_glb
assert expected_glb.exists()
assert queued == [str(line.product.cad_file_id)]
assert any("stale" in message for message in messages)
assert any("Queued USD master regeneration" in message for message in messages)
assert any("Reusing cached GLB geometry" in message for message in messages)
assert line.render_status == "processing"
def test_prepare_order_line_render_context_skips_closed_orders(sync_session, tmp_path, monkeypatch):
from app.config import settings
@@ -175,18 +259,262 @@ def test_prepare_order_line_render_context_skips_closed_orders(sync_session, tmp
assert line.render_status == "cancelled"
def test_build_order_line_render_invocation_applies_output_and_line_overrides(tmp_path):
step_path = tmp_path / "parts" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
output_type = OutputType(
id=uuid.uuid4(),
name="Still Preview",
renderer="blender",
output_format="png",
render_settings={"width": 1600, "height": 900},
transparent_bg=False,
cycles_device="cpu",
)
output_type.invocation_overrides = {
"engine": "cycles",
"samples": 128,
"bg_color": "#202020",
"turntable_axis": "world_y",
"noise_threshold": "0.05",
}
cad_file = CadFile(
id=uuid.uuid4(),
original_name="bearing.step",
stored_path=str(step_path),
file_hash="hash-1",
parsed_objects={"objects": ["InnerRing", "OuterRing"]},
)
product = Product(
id=uuid.uuid4(),
pim_id="P-1000",
name="Bearing A",
category_key="bearings",
cad_file_id=cad_file.id,
cad_file=cad_file,
)
line = OrderLine(
id=uuid.uuid4(),
order_id=uuid.uuid4(),
product_id=product.id,
product=product,
output_type_id=output_type.id,
output_type=output_type,
render_overrides={
"height": 800,
"samples": 48,
"transparent_bg": True,
"cycles_device": "cuda",
"denoiser": "OPENIMAGEDENOISE",
"output_format": "webp",
},
)
setup = OrderLineRenderSetupResult(
status="ready",
order_line=line,
cad_file=cad_file,
part_colors={"InnerRing": "Steel raw", "OuterRing": "Steel raw"},
)
template = RenderTemplate(
id=uuid.uuid4(),
name="Studio",
blend_file_path="/templates/studio.blend",
original_filename="studio.blend",
target_collection="Assembly",
lighting_only=True,
shadow_catcher_enabled=True,
camera_orbit=False,
)
invocation = build_order_line_render_invocation(
setup,
template_context=TemplateResolutionResult(
template=template,
material_library="/libraries/materials.blend",
material_map={"InnerRing": "SteelPolished"},
use_materials=True,
override_material="Studio White",
category_key="bearings",
output_type_id=str(output_type.id),
),
position_context=RenderPositionContext(
rotation_x=12.0,
rotation_y=24.0,
rotation_z=36.0,
focal_length_mm=50.0,
sensor_width_mm=36.0,
),
)
assert invocation.output_extension == "webp"
assert invocation.output_filename.endswith(".webp")
assert invocation.width == 1600
assert invocation.height == 800
assert invocation.engine == "cycles"
assert invocation.samples == 48
assert invocation.noise_threshold == "0.05"
assert invocation.denoiser == "OPENIMAGEDENOISE"
assert invocation.transparent_bg is True
assert invocation.cycles_device == "cuda"
assert invocation.bg_color == "#202020"
assert invocation.turntable_axis == "world_y"
assert invocation.template_path == "/templates/studio.blend"
assert invocation.target_collection == "Assembly"
assert invocation.material_library_path == "/libraries/materials.blend"
assert invocation.material_map == {"InnerRing": "SteelPolished"}
assert invocation.material_override == "Studio White"
assert invocation.lighting_only is True
assert invocation.shadow_catcher is True
assert invocation.camera_orbit is False
assert invocation.part_names_ordered == ["InnerRing", "OuterRing"]
assert invocation.rotation_x == 12.0
assert invocation.focal_length_mm == 50.0
still_kwargs = invocation.as_still_renderer_kwargs(
step_path=str(step_path),
output_path=str(tmp_path / "renders" / "bearing.webp"),
job_id="job-1",
order_line_id="line-1",
)
assert still_kwargs["step_path"] == str(step_path)
assert still_kwargs["output_path"].endswith("bearing.webp")
assert still_kwargs["width"] == 1600
assert still_kwargs["height"] == 800
assert still_kwargs["engine"] == "cycles"
assert still_kwargs["samples"] == 48
assert still_kwargs["cycles_device"] == "cuda"
assert still_kwargs["material_library_path"] == "/libraries/materials.blend"
assert still_kwargs["material_override"] == "Studio White"
assert still_kwargs["job_id"] == "job-1"
assert still_kwargs["order_line_id"] == "line-1"
def test_build_order_line_render_invocation_autoscales_samples_and_prefers_material_context(
tmp_path,
):
step_path = tmp_path / "parts" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
output_type = OutputType(
id=uuid.uuid4(),
name="Still Preview",
renderer="blender",
output_format="png",
render_settings={"width": 1024, "height": 512},
)
output_type.invocation_overrides = {"samples": 128, "engine": "eevee"}
cad_file = CadFile(
id=uuid.uuid4(),
original_name="bearing.step",
stored_path=str(step_path),
file_hash="hash-2",
parsed_objects={"objects": ["InnerRing", "OuterRing"]},
)
product = Product(
id=uuid.uuid4(),
pim_id="P-1001",
name="Bearing B",
category_key="bearings",
cad_file_id=cad_file.id,
cad_file=cad_file,
)
line = OrderLine(
id=uuid.uuid4(),
order_id=uuid.uuid4(),
product_id=product.id,
product=product,
output_type_id=output_type.id,
output_type=output_type,
)
setup = OrderLineRenderSetupResult(
status="ready",
order_line=line,
cad_file=cad_file,
part_colors={"InnerRing": "Steel raw"},
)
template = RenderTemplate(
id=uuid.uuid4(),
name="Studio",
blend_file_path="/templates/studio.blend",
original_filename="studio.blend",
target_collection="Product",
)
invocation = build_order_line_render_invocation(
setup,
template_context=TemplateResolutionResult(
template=template,
material_library="/libraries/materials.blend",
material_map={"InnerRing": "TemplateSteel"},
use_materials=True,
override_material="Template White",
category_key="bearings",
output_type_id=str(output_type.id),
),
material_context=MaterialResolutionResult(
material_map={"InnerRing": "ResolvedSteel"},
use_materials=False,
override_material="Resolved White",
source_material_count=2,
resolved_material_count=1,
),
)
assert invocation.engine == "eevee"
assert invocation.samples == 64
assert invocation.material_map == {"InnerRing": "ResolvedSteel"}
assert invocation.material_override == "Resolved White"
assert invocation.material_library_path is None
turntable_kwargs = invocation.as_turntable_renderer_kwargs(
step_path=step_path,
output_path=tmp_path / "renders" / "bearing.mp4",
smooth_angle=30,
default_width=1920,
default_height=1920,
default_engine="cycles",
default_samples=256,
)
cinematic_kwargs = invocation.as_cinematic_renderer_kwargs(
step_path=step_path,
output_path=tmp_path / "renders" / "bearing-cinematic.mp4",
smooth_angle=30,
default_width=1920,
default_height=1080,
default_engine="cycles",
default_samples=256,
)
assert turntable_kwargs["width"] == 1024
assert turntable_kwargs["height"] == 512
assert turntable_kwargs["engine"] == "eevee"
assert turntable_kwargs["samples"] == 64
assert turntable_kwargs["material_map"] == {"InnerRing": "ResolvedSteel"}
assert turntable_kwargs["material_library_path"] is None
assert cinematic_kwargs["width"] == 1024
assert cinematic_kwargs["height"] == 512
assert cinematic_kwargs["engine"] == "eevee"
assert cinematic_kwargs["samples"] == 64
assert cinematic_kwargs["material_override"] == "Resolved White"
def test_resolve_order_line_template_context_uses_exact_template_and_override(sync_session, tmp_path, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
line = _seed_order_line_graph(sync_session, tmp_path)
line.material_override = "HARTOMAT_OVERRIDE"
material_library_path = tmp_path / "libraries" / "materials.blend"
material_library_path.parent.mkdir(parents=True, exist_ok=True)
material_library_path.write_text("BLEND", encoding="utf-8")
sync_session.add(
AssetLibrary(
id=uuid.uuid4(),
name="Default Library",
blend_file_path="/libraries/materials.blend",
blend_file_path=str(material_library_path),
is_active=True,
)
)
@@ -215,7 +543,7 @@ def test_resolve_order_line_template_context_uses_exact_template_and_override(sy
assert result.template is not None
assert result.template.name == "Bearing Studio"
assert result.material_library == "/libraries/materials.blend"
assert result.material_library == str(material_library_path)
assert result.override_material == "HARTOMAT_OVERRIDE"
assert result.use_materials is True
assert result.material_map == {
@@ -522,6 +850,79 @@ def test_persist_order_line_output_reuses_existing_asset(sync_session, tmp_path,
assert len(assets) == 1
def test_persist_order_line_output_canonicalizes_step_file_outputs(sync_session, tmp_path, monkeypatch):
from app.config import settings
upload_dir = tmp_path / "uploads"
monkeypatch.setattr(settings, "upload_dir", str(upload_dir))
line = _seed_order_line_graph(sync_session, tmp_path)
step_render_path = upload_dir / "step_files" / "renders" / f"line_{line.id}.png"
step_render_path.parent.mkdir(parents=True, exist_ok=True)
step_render_path.write_text("PNGDATA", encoding="utf-8")
existing = MediaAsset(
id=uuid.uuid4(),
order_line_id=line.id,
product_id=line.product_id,
asset_type=MediaAssetType.still,
storage_key=f"renders/{line.id}/bearing.png",
)
sync_session.add(existing)
sync_session.commit()
result = persist_order_line_output(
sync_session,
line,
success=True,
output_path=str(step_render_path),
render_log={"renderer": "blender", "engine_used": "cycles"},
workflow_run_id=str(uuid.uuid4()),
)
sync_session.refresh(line)
expected_path = Path(result.result_path or "")
asset = sync_session.execute(
select(MediaAsset).where(MediaAsset.id == existing.id)
).scalar_one()
assert expected_path.exists()
assert expected_path.read_text(encoding="utf-8") == "PNGDATA"
assert expected_path.parent == upload_dir / "renders" / str(line.id)
assert expected_path.name.startswith("Bearing_A_Still-")
assert expected_path.suffix == ".png"
assert result.result_path == str(expected_path)
assert result.storage_key == f"renders/{line.id}/{expected_path.name}"
assert line.result_path == str(expected_path)
assert result.asset_id == str(existing.id)
assert asset.storage_key == f"renders/{line.id}/{expected_path.name}"
def test_persist_order_line_output_checks_order_completion(sync_session, tmp_path, monkeypatch):
from app.config import settings
upload_dir = tmp_path / "uploads"
monkeypatch.setattr(settings, "upload_dir", str(upload_dir))
line = _seed_order_line_graph(sync_session, tmp_path)
rendered = tmp_path / "rendered.png"
rendered.write_text("PNGDATA", encoding="utf-8")
calls: list[str] = []
monkeypatch.setattr(
"app.domains.orders.service.check_order_completion",
lambda order_id: calls.append(order_id) or True,
)
persist_order_line_output(
sync_session,
line,
success=True,
output_path=str(rendered),
render_log={"renderer": "blender"},
)
assert calls == [str(line.order_id)]
def test_persist_order_line_output_marks_failure_without_result_path(sync_session, tmp_path):
line = _seed_order_line_graph(sync_session, tmp_path)
@@ -547,6 +948,47 @@ def test_persist_order_line_output_marks_failure_without_result_path(sync_sessio
assert assets == []
def test_persist_order_line_media_asset_creates_blend_asset_without_touching_order_line(sync_session, tmp_path, monkeypatch):
from app.config import settings
upload_dir = tmp_path / "uploads"
monkeypatch.setattr(settings, "upload_dir", str(upload_dir))
line = _seed_order_line_graph(sync_session, tmp_path)
line.render_status = "completed"
line.result_path = str(upload_dir / "renders" / str(line.id) / "bearing.png")
sync_session.commit()
output_path = upload_dir / "exports" / str(line.id) / "bearing_production.blend"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text("BLENDDATA", encoding="utf-8")
result = persist_order_line_media_asset(
sync_session,
line,
success=True,
output_path=str(output_path),
asset_type=MediaAssetType.blend_production,
render_log={"artifact_type": "blend_production"},
)
sync_session.refresh(line)
asset = sync_session.execute(
select(MediaAsset).where(MediaAsset.storage_key == f"exports/{line.id}/bearing_production.blend")
).scalar_one_or_none()
assert result.status == "completed"
assert result.result_path == str(output_path)
assert result.storage_key == f"exports/{line.id}/bearing_production.blend"
assert result.asset_type == MediaAssetType.blend_production
assert line.render_status == "completed"
assert line.result_path == str(upload_dir / "renders" / str(line.id) / "bearing.png")
assert asset is not None
assert asset.asset_type == MediaAssetType.blend_production
assert asset.mime_type == "application/x-blender"
assert asset.file_size_bytes == output_path.stat().st_size
assert asset.render_config == {"artifact_type": "blend_production"}
def test_emit_order_line_render_notifications_emits_websocket_and_activity(
sync_session,
tmp_path,