fix: align workflow material resolution with scene manifest
This commit is contained in:
@@ -17,13 +17,14 @@ from app.core.render_paths import resolve_result_path, result_path_to_storage_ke
|
|||||||
from app.domains.media.models import MediaAsset, MediaAssetType
|
from app.domains.media.models import MediaAsset, MediaAssetType
|
||||||
from app.domains.orders.models import Order, OrderLine, OrderStatus
|
from app.domains.orders.models import Order, OrderLine, OrderStatus
|
||||||
from app.domains.products.models import CadFile, Product
|
from app.domains.products.models import CadFile, Product
|
||||||
from app.domains.rendering.output_type_contracts import merge_output_type_invocation_overrides
|
from app.domains.rendering.output_type_contracts import resolve_output_type_invocation_overrides
|
||||||
from app.domains.rendering.models import (
|
from app.domains.rendering.models import (
|
||||||
GlobalRenderPosition,
|
GlobalRenderPosition,
|
||||||
ProductRenderPosition,
|
ProductRenderPosition,
|
||||||
RenderTemplate,
|
RenderTemplate,
|
||||||
WorkflowRun,
|
WorkflowRun,
|
||||||
)
|
)
|
||||||
|
from app.services.part_key_service import build_scene_manifest
|
||||||
from app.services.material_service import resolve_material_map
|
from app.services.material_service import resolve_material_map
|
||||||
from app.services.step_processor import build_part_colors
|
from app.services.step_processor import build_part_colors
|
||||||
from app.services.template_service import (
|
from app.services.template_service import (
|
||||||
@@ -459,7 +460,11 @@ def _normalize_storage_key(output_path: str) -> str:
|
|||||||
|
|
||||||
def _resolve_output_asset_type(output_path: str) -> MediaAssetType:
|
def _resolve_output_asset_type(output_path: str) -> MediaAssetType:
|
||||||
extension = output_path.rsplit(".", 1)[-1].lower() if "." in output_path else "bin"
|
extension = output_path.rsplit(".", 1)[-1].lower() if "." in output_path else "bin"
|
||||||
return MediaAssetType.turntable if extension in ("mp4", "webm") else MediaAssetType.still
|
if extension == "blend":
|
||||||
|
return MediaAssetType.blend_production
|
||||||
|
if extension in ("mp4", "webm"):
|
||||||
|
return MediaAssetType.turntable
|
||||||
|
return MediaAssetType.still
|
||||||
|
|
||||||
|
|
||||||
def _resolve_output_mime_type(output_path: str) -> str:
|
def _resolve_output_mime_type(output_path: str) -> str:
|
||||||
@@ -541,6 +546,7 @@ def build_order_line_render_invocation(
|
|||||||
template_context: TemplateResolutionResult | None = None,
|
template_context: TemplateResolutionResult | None = None,
|
||||||
position_context: RenderPositionContext | None = None,
|
position_context: RenderPositionContext | None = None,
|
||||||
material_context: MaterialResolutionResult | None = None,
|
material_context: MaterialResolutionResult | None = None,
|
||||||
|
artifact_kind_override: str | None = None,
|
||||||
emit: EmitFn = None,
|
emit: EmitFn = None,
|
||||||
) -> OrderLineRenderInvocation:
|
) -> OrderLineRenderInvocation:
|
||||||
if not setup.is_ready or setup.order_line is None or setup.cad_file is None:
|
if not setup.is_ready or setup.order_line is None or setup.cad_file is None:
|
||||||
@@ -551,9 +557,11 @@ def build_order_line_render_invocation(
|
|||||||
output_type = line.output_type
|
output_type = line.output_type
|
||||||
position = position_context or RenderPositionContext()
|
position = position_context or RenderPositionContext()
|
||||||
render_settings = (
|
render_settings = (
|
||||||
merge_output_type_invocation_overrides(
|
resolve_output_type_invocation_overrides(
|
||||||
output_type.render_settings,
|
output_type.render_settings,
|
||||||
getattr(output_type, "invocation_overrides", None),
|
getattr(output_type, "invocation_overrides", None),
|
||||||
|
artifact_kind=artifact_kind_override or output_type.artifact_kind,
|
||||||
|
is_animation=output_type.is_animation,
|
||||||
)
|
)
|
||||||
if output_type is not None
|
if output_type is not None
|
||||||
else {}
|
else {}
|
||||||
@@ -1191,6 +1199,10 @@ def resolve_order_line_template_context(
|
|||||||
setup: OrderLineRenderSetupResult,
|
setup: OrderLineRenderSetupResult,
|
||||||
*,
|
*,
|
||||||
emit: EmitFn = None,
|
emit: EmitFn = None,
|
||||||
|
template_id_override: str | None = None,
|
||||||
|
material_library_path_override: str | None = None,
|
||||||
|
require_template: bool = False,
|
||||||
|
disable_materials: bool = False,
|
||||||
) -> TemplateResolutionResult:
|
) -> TemplateResolutionResult:
|
||||||
"""Resolve render template, material library, and material map for a prepared order line."""
|
"""Resolve render template, material library, and material map for a prepared order line."""
|
||||||
if not setup.is_ready:
|
if not setup.is_ready:
|
||||||
@@ -1204,12 +1216,32 @@ def resolve_order_line_template_context(
|
|||||||
category_key = line.product.category_key if line.product else None
|
category_key = line.product.category_key if line.product else None
|
||||||
output_type_id = str(line.output_type_id) if line.output_type_id else None
|
output_type_id = str(line.output_type_id) if line.output_type_id else None
|
||||||
|
|
||||||
template = resolve_template_for_session(
|
template = None
|
||||||
session,
|
if template_id_override:
|
||||||
category_key=category_key,
|
try:
|
||||||
output_type_id=output_type_id,
|
template_uuid = uuid.UUID(str(template_id_override))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"template_id_override is not a valid UUID: {template_id_override}") from exc
|
||||||
|
template = session.get(RenderTemplate, template_uuid)
|
||||||
|
if template is None:
|
||||||
|
raise ValueError(f"render template not found: {template_id_override}")
|
||||||
|
if not template.is_active:
|
||||||
|
raise ValueError(f"render template is inactive: {template_id_override}")
|
||||||
|
else:
|
||||||
|
template = resolve_template_for_session(
|
||||||
|
session,
|
||||||
|
category_key=category_key,
|
||||||
|
output_type_id=output_type_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if require_template and template is None:
|
||||||
|
raise ValueError("resolve_order_line_template_context requires a matching render template")
|
||||||
|
|
||||||
|
material_library = (
|
||||||
|
material_library_path_override.strip()
|
||||||
|
if isinstance(material_library_path_override, str) and material_library_path_override.strip()
|
||||||
|
else get_material_library_path_for_session(session)
|
||||||
)
|
)
|
||||||
material_library = get_material_library_path_for_session(session)
|
|
||||||
material_resolution = resolve_order_line_material_map(
|
material_resolution = resolve_order_line_material_map(
|
||||||
line,
|
line,
|
||||||
cad_file,
|
cad_file,
|
||||||
@@ -1217,6 +1249,7 @@ def resolve_order_line_template_context(
|
|||||||
material_library=material_library,
|
material_library=material_library,
|
||||||
template=template,
|
template=template,
|
||||||
emit=emit,
|
emit=emit,
|
||||||
|
disable_materials=disable_materials,
|
||||||
)
|
)
|
||||||
|
|
||||||
if template:
|
if template:
|
||||||
@@ -1261,27 +1294,36 @@ def resolve_order_line_material_map(
|
|||||||
material_library: str | None,
|
material_library: str | None,
|
||||||
template: RenderTemplate | None,
|
template: RenderTemplate | None,
|
||||||
emit: EmitFn = None,
|
emit: EmitFn = None,
|
||||||
|
material_override: str | None = None,
|
||||||
|
disable_materials: bool = False,
|
||||||
) -> MaterialResolutionResult:
|
) -> MaterialResolutionResult:
|
||||||
"""Resolve the effective order-line material map with legacy precedence rules."""
|
"""Resolve the effective order-line material map with legacy precedence rules."""
|
||||||
|
if disable_materials:
|
||||||
|
return MaterialResolutionResult(
|
||||||
|
material_map=None,
|
||||||
|
use_materials=False,
|
||||||
|
override_material=material_override,
|
||||||
|
source_material_count=0,
|
||||||
|
resolved_material_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
material_map = None
|
material_map = None
|
||||||
raw_material_count = 0
|
raw_material_count = 0
|
||||||
use_materials = bool(material_library and materials_source)
|
raw_material_map = _build_effective_material_lookup(cad_file, materials_source)
|
||||||
|
use_materials = bool(material_library and raw_material_map)
|
||||||
if template and not template.material_replace_enabled:
|
if template and not template.material_replace_enabled:
|
||||||
use_materials = False
|
use_materials = False
|
||||||
if use_materials:
|
if use_materials:
|
||||||
material_map = {
|
raw_material_count = len(raw_material_map)
|
||||||
material["part_name"]: material["material"]
|
material_map = resolve_material_map(raw_material_map)
|
||||||
for material in materials_source
|
|
||||||
if material.get("part_name") and material.get("material")
|
|
||||||
}
|
|
||||||
raw_material_count = len(material_map)
|
|
||||||
material_map = resolve_material_map(material_map)
|
|
||||||
|
|
||||||
line_override = getattr(line, "material_override", None)
|
line_override = getattr(line, "material_override", None)
|
||||||
output_override = line.output_type.material_override if line.output_type else None
|
output_override = line.output_type.material_override if line.output_type else None
|
||||||
override_material = line_override or output_override
|
override_material = material_override or line_override or output_override
|
||||||
if override_material:
|
if override_material:
|
||||||
override_keys = set(material_map.keys()) if material_map else set()
|
override_keys = set(material_map.keys()) if material_map else set()
|
||||||
|
if cad_file:
|
||||||
|
override_keys.update(_collect_cad_material_keys(cad_file))
|
||||||
if cad_file and cad_file.parsed_objects:
|
if cad_file and cad_file.parsed_objects:
|
||||||
for part_name in cad_file.parsed_objects.get("objects", []):
|
for part_name in cad_file.parsed_objects.get("objects", []):
|
||||||
override_keys.add(part_name)
|
override_keys.add(part_name)
|
||||||
@@ -1302,12 +1344,71 @@ def resolve_order_line_material_map(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_effective_material_lookup(
|
||||||
|
cad_file: CadFile | None,
|
||||||
|
materials_source: list[dict[str, Any]],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Build a renderer-compatible material lookup from all available layers.
|
||||||
|
|
||||||
|
Authoritative scene-manifest assignments win when present, but we emit both
|
||||||
|
source-name and part-key keys so USD and GLB/STEP fallback paths resolve the
|
||||||
|
same effective material map.
|
||||||
|
"""
|
||||||
|
raw_material_map: dict[str, str] = {
|
||||||
|
str(material["part_name"]): str(material["material"])
|
||||||
|
for material in materials_source
|
||||||
|
if material.get("part_name") and material.get("material")
|
||||||
|
}
|
||||||
|
|
||||||
|
if not cad_file:
|
||||||
|
return raw_material_map
|
||||||
|
|
||||||
|
manifest = build_scene_manifest(cad_file)
|
||||||
|
for part in manifest.get("parts", []):
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
effective_material = part.get("effective_material")
|
||||||
|
if not effective_material:
|
||||||
|
continue
|
||||||
|
source_name = part.get("source_name")
|
||||||
|
part_key = part.get("part_key")
|
||||||
|
if source_name:
|
||||||
|
raw_material_map[str(source_name)] = str(effective_material)
|
||||||
|
if part_key:
|
||||||
|
raw_material_map[str(part_key)] = str(effective_material)
|
||||||
|
|
||||||
|
return raw_material_map
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_cad_material_keys(cad_file: CadFile) -> set[str]:
|
||||||
|
if not (
|
||||||
|
cad_file.resolved_material_assignments
|
||||||
|
or cad_file.manual_material_overrides
|
||||||
|
or cad_file.source_material_assignments
|
||||||
|
):
|
||||||
|
return set()
|
||||||
|
|
||||||
|
keys: set[str] = set()
|
||||||
|
manifest = build_scene_manifest(cad_file)
|
||||||
|
for part in manifest.get("parts", []):
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
source_name = part.get("source_name")
|
||||||
|
part_key = part.get("part_key")
|
||||||
|
if source_name:
|
||||||
|
keys.add(str(source_name))
|
||||||
|
if part_key:
|
||||||
|
keys.add(str(part_key))
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
def auto_populate_materials_for_cad(
|
def auto_populate_materials_for_cad(
|
||||||
session: Session,
|
session: Session,
|
||||||
cad_file_id: str,
|
cad_file_id: str,
|
||||||
*,
|
*,
|
||||||
enqueue_thumbnail: QueueThumbnailFn = None,
|
enqueue_thumbnail: QueueThumbnailFn = None,
|
||||||
persist_updates: bool = True,
|
persist_updates: bool = True,
|
||||||
|
include_populated_products: bool = False,
|
||||||
) -> AutoPopulateMaterialsResult:
|
) -> AutoPopulateMaterialsResult:
|
||||||
"""Auto-fill empty CAD material mappings from Excel component data.
|
"""Auto-fill empty CAD material mappings from Excel component data.
|
||||||
|
|
||||||
@@ -1347,7 +1448,7 @@ def auto_populate_materials_for_cad(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
existing = product.cad_part_materials or []
|
existing = product.cad_part_materials or []
|
||||||
if existing and any((entry.get("material") or "").strip() for entry in existing):
|
if not include_populated_products and existing and any((entry.get("material") or "").strip() for entry in existing):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
new_materials = build_materials_from_excel(cad_parts, excel_components)
|
new_materials = build_materials_from_excel(cad_parts, excel_components)
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import uuid
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine, select, text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
from app.domains.auth.models import User, UserRole
|
from app.domains.auth.models import User, UserRole
|
||||||
from app.domains.materials.models import AssetLibrary
|
from app.domains.materials.models import AssetLibrary
|
||||||
from app.domains.media.models import MediaAsset, MediaAssetType
|
from app.domains.media.models import MediaAsset, MediaAssetType
|
||||||
@@ -32,25 +31,13 @@ from app.domains.rendering.workflow_runtime_services import (
|
|||||||
)
|
)
|
||||||
from app.domains.tenants.models import Tenant
|
from app.domains.tenants.models import Tenant
|
||||||
|
|
||||||
import app.models # noqa: F401
|
from tests.db_test_utils import sync_test_session as sync_test_session_ctx
|
||||||
from tests.db_test_utils import reset_public_schema_sync, resolve_test_db_url
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sync_session():
|
def sync_session():
|
||||||
engine = create_engine(resolve_test_db_url(async_driver=False))
|
with sync_test_session_ctx() as session:
|
||||||
with engine.begin() as conn:
|
|
||||||
reset_public_schema_sync(conn)
|
|
||||||
Base.metadata.create_all(conn)
|
|
||||||
|
|
||||||
session = Session(engine)
|
|
||||||
try:
|
|
||||||
yield session
|
yield session
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
with engine.begin() as conn:
|
|
||||||
reset_public_schema_sync(conn)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_order_line_graph(session: Session, tmp_path: Path) -> OrderLine:
|
def _seed_order_line_graph(session: Session, tmp_path: Path) -> OrderLine:
|
||||||
@@ -358,7 +345,7 @@ def test_build_order_line_render_invocation_applies_output_and_line_overrides(tm
|
|||||||
assert invocation.transparent_bg is True
|
assert invocation.transparent_bg is True
|
||||||
assert invocation.cycles_device == "cuda"
|
assert invocation.cycles_device == "cuda"
|
||||||
assert invocation.bg_color == "#202020"
|
assert invocation.bg_color == "#202020"
|
||||||
assert invocation.turntable_axis == "world_y"
|
assert invocation.turntable_axis == "world_z"
|
||||||
assert invocation.template_path == "/templates/studio.blend"
|
assert invocation.template_path == "/templates/studio.blend"
|
||||||
assert invocation.target_collection == "Assembly"
|
assert invocation.target_collection == "Assembly"
|
||||||
assert invocation.material_library_path == "/libraries/materials.blend"
|
assert invocation.material_library_path == "/libraries/materials.blend"
|
||||||
@@ -552,6 +539,70 @@ def test_resolve_order_line_template_context_uses_exact_template_and_override(sy
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_order_line_template_context_supports_explicit_template_and_library_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)
|
||||||
|
template = RenderTemplate(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="Forced Template",
|
||||||
|
category_key=None,
|
||||||
|
blend_file_path="/templates/forced.blend",
|
||||||
|
original_filename="forced.blend",
|
||||||
|
target_collection="ForcedCollection",
|
||||||
|
material_replace_enabled=True,
|
||||||
|
lighting_only=False,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
sync_session.add(template)
|
||||||
|
sync_session.commit()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.domains.rendering.workflow_runtime_services.resolve_material_map",
|
||||||
|
lambda raw_map: {key: f"resolved:{value}" for key, value in raw_map.items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
setup = prepare_order_line_render_context(sync_session, str(line.id))
|
||||||
|
result = resolve_order_line_template_context(
|
||||||
|
sync_session,
|
||||||
|
setup,
|
||||||
|
template_id_override=str(template.id),
|
||||||
|
material_library_path_override="/custom/library.blend",
|
||||||
|
require_template=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.template is not None
|
||||||
|
assert result.template.id == template.id
|
||||||
|
assert result.material_library == "/custom/library.blend"
|
||||||
|
assert result.use_materials is True
|
||||||
|
assert result.material_map == {
|
||||||
|
"InnerRing": "resolved:Steel raw",
|
||||||
|
"OuterRing": "resolved:Steel raw",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_order_line_template_context_can_disable_material_resolution(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)
|
||||||
|
setup = prepare_order_line_render_context(sync_session, str(line.id))
|
||||||
|
|
||||||
|
result = resolve_order_line_template_context(
|
||||||
|
sync_session,
|
||||||
|
setup,
|
||||||
|
disable_materials=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.use_materials is False
|
||||||
|
assert result.material_map is None
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_order_line_material_map_disables_materials_when_template_blocks_replacement(
|
def test_resolve_order_line_material_map_disables_materials_when_template_blocks_replacement(
|
||||||
sync_session,
|
sync_session,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
@@ -615,6 +666,109 @@ def test_resolve_order_line_material_map_prefers_line_override_over_output_overr
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_order_line_material_map_prefers_authoritative_scene_manifest_assignments(
|
||||||
|
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)
|
||||||
|
cad_file = line.product.cad_file
|
||||||
|
assert cad_file is not None
|
||||||
|
|
||||||
|
cad_file.resolved_material_assignments = {
|
||||||
|
"inner_ring": {
|
||||||
|
"source_name": "InnerRing",
|
||||||
|
"prim_path": "/Root/Assembly/inner_ring",
|
||||||
|
"canonical_material": "HARTOMAT_010101_Steel-Bare",
|
||||||
|
},
|
||||||
|
"outer_ring": {
|
||||||
|
"source_name": "OuterRing",
|
||||||
|
"prim_path": "/Root/Assembly/outer_ring",
|
||||||
|
"canonical_material": "HARTOMAT_020202_Rubber-Black",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cad_file.manual_material_overrides = {
|
||||||
|
"outer_ring": "HARTOMAT_020203_Rubber-Black-Gloss",
|
||||||
|
}
|
||||||
|
line.product.cad_part_materials = [
|
||||||
|
{"part_name": "InnerRing", "material": "Legacy Steel"},
|
||||||
|
{"part_name": "OuterRing", "material": "Legacy Rubber"},
|
||||||
|
]
|
||||||
|
sync_session.commit()
|
||||||
|
|
||||||
|
result = resolve_order_line_material_map(
|
||||||
|
line,
|
||||||
|
cad_file,
|
||||||
|
line.product.cad_part_materials,
|
||||||
|
material_library="/libraries/materials.blend",
|
||||||
|
template=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.use_materials is True
|
||||||
|
assert result.source_material_count == 4
|
||||||
|
assert result.material_map == {
|
||||||
|
"InnerRing": "HARTOMAT_010101_Steel-Bare",
|
||||||
|
"inner_ring": "HARTOMAT_010101_Steel-Bare",
|
||||||
|
"OuterRing": "HARTOMAT_020203_Rubber-Black-Gloss",
|
||||||
|
"outer_ring": "HARTOMAT_020203_Rubber-Black-Gloss",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_order_line_material_map_keeps_legacy_source_name_fallback_without_scene_manifest(
|
||||||
|
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)
|
||||||
|
|
||||||
|
result = resolve_order_line_material_map(
|
||||||
|
line,
|
||||||
|
line.product.cad_file,
|
||||||
|
line.product.cad_part_materials,
|
||||||
|
material_library="/libraries/materials.blend",
|
||||||
|
template=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.use_materials is True
|
||||||
|
assert result.source_material_count == 2
|
||||||
|
assert result.material_map == {
|
||||||
|
"InnerRing": "Steel raw",
|
||||||
|
"OuterRing": "Steel raw",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_order_line_material_map_allows_node_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 = "LINE_OVERRIDE"
|
||||||
|
line.output_type.material_override = "OUTPUT_OVERRIDE"
|
||||||
|
sync_session.commit()
|
||||||
|
|
||||||
|
result = resolve_order_line_material_map(
|
||||||
|
line,
|
||||||
|
line.product.cad_file,
|
||||||
|
line.product.cad_part_materials,
|
||||||
|
material_library="/libraries/materials.blend",
|
||||||
|
template=None,
|
||||||
|
material_override="NODE_OVERRIDE",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.override_material == "NODE_OVERRIDE"
|
||||||
|
assert result.use_materials is True
|
||||||
|
assert result.material_map == {
|
||||||
|
"InnerRing": "NODE_OVERRIDE",
|
||||||
|
"OuterRing": "NODE_OVERRIDE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_auto_populate_materials_for_cad_updates_only_blank_products_and_queues_once(sync_session, tmp_path):
|
def test_auto_populate_materials_for_cad_updates_only_blank_products_and_queues_once(sync_session, tmp_path):
|
||||||
line = _seed_order_line_graph(sync_session, tmp_path)
|
line = _seed_order_line_graph(sync_session, tmp_path)
|
||||||
cad_file = line.product.cad_file
|
cad_file = line.product.cad_file
|
||||||
@@ -699,6 +853,32 @@ def test_auto_populate_materials_for_cad_skips_when_materials_already_present(sy
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_populate_materials_for_cad_can_rewrite_existing_assignments(sync_session, tmp_path):
|
||||||
|
line = _seed_order_line_graph(sync_session, tmp_path)
|
||||||
|
cad_file = line.product.cad_file
|
||||||
|
assert cad_file is not None
|
||||||
|
|
||||||
|
line.product.components = [
|
||||||
|
{"part_name": "InnerRing", "material": "Ceramic"},
|
||||||
|
{"part_name": "OuterRing", "material": "Polymer"},
|
||||||
|
]
|
||||||
|
sync_session.commit()
|
||||||
|
|
||||||
|
result = auto_populate_materials_for_cad(
|
||||||
|
sync_session,
|
||||||
|
str(cad_file.id),
|
||||||
|
include_populated_products=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_session.refresh(line.product)
|
||||||
|
|
||||||
|
assert result.updated_product_ids == [str(line.product.id)]
|
||||||
|
assert line.product.cad_part_materials == [
|
||||||
|
{"part_name": "InnerRing", "material": "Ceramic"},
|
||||||
|
{"part_name": "OuterRing", "material": "Polymer"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_cad_bbox_prefers_glb_over_step(monkeypatch):
|
def test_resolve_cad_bbox_prefers_glb_over_step(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.domains.rendering.workflow_runtime_services.extract_bbox_from_glb",
|
"app.domains.rendering.workflow_runtime_services.extract_bbox_from_glb",
|
||||||
@@ -897,6 +1077,39 @@ def test_persist_order_line_output_canonicalizes_step_file_outputs(sync_session,
|
|||||||
assert asset.storage_key == f"renders/{line.id}/{expected_path.name}"
|
assert asset.storage_key == f"renders/{line.id}/{expected_path.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_order_line_output_classifies_blend_outputs_as_blend_assets(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 / "exports" / "bearing_production.blend"
|
||||||
|
rendered.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rendered.write_text("BLENDDATA", encoding="utf-8")
|
||||||
|
|
||||||
|
result = persist_order_line_output(
|
||||||
|
sync_session,
|
||||||
|
line,
|
||||||
|
success=True,
|
||||||
|
output_path=str(rendered),
|
||||||
|
render_log={"artifact_type": "blend_production"},
|
||||||
|
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 == uuid.UUID(result.asset_id))
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
assert expected_path.exists()
|
||||||
|
assert expected_path.suffix == ".blend"
|
||||||
|
assert result.asset_type == MediaAssetType.blend_production
|
||||||
|
assert asset.asset_type == MediaAssetType.blend_production
|
||||||
|
assert asset.storage_key == f"renders/{line.id}/{expected_path.name}"
|
||||||
|
assert asset.mime_type == "application/x-blender"
|
||||||
|
|
||||||
|
|
||||||
def test_persist_order_line_output_checks_order_completion(sync_session, tmp_path, monkeypatch):
|
def test_persist_order_line_output_checks_order_completion(sync_session, tmp_path, monkeypatch):
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Current Execution Batch
|
||||||
|
|
||||||
|
Stand: April 9, 2026
|
||||||
|
|
||||||
|
Dieses Batch zerlegt die verbleibende Workflow-Paritätsarbeit in 12 direkt umsetzbare Blöcke. Ziel bleibt unverändert:
|
||||||
|
|
||||||
|
- `/workflows` produktionsfähig machen
|
||||||
|
- den Legacy-Workflow jederzeit funktionsfähig halten
|
||||||
|
- Tests und Browser-Verifikation gezielt und sequenziell fahren, damit der lokale Stack nicht durch RAM-Last kippt
|
||||||
|
|
||||||
|
## Reihenfolge
|
||||||
|
|
||||||
|
### Block 1: Shared Authoring Surface
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Die Authoring-Logik für Canvas-Menu und Sidebar auf eine gemeinsame Grundlage ziehen, damit neue Node-Organisation, Stage-Führung und spätere Output-Type-Deep-Links nicht doppelt implementiert werden.
|
||||||
|
|
||||||
|
Primäre Dateien:
|
||||||
|
|
||||||
|
- `frontend/src/components/workflows/NodeCommandMenu.tsx`
|
||||||
|
- `frontend/src/components/workflows/NodeDefinitionsPanel.tsx`
|
||||||
|
- `frontend/src/components/workflows/**`
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- ein gemeinsames Authoring-Modell statt verteilter UI-spezifischer Sonderlogik
|
||||||
|
- keine Regression bei Right-Click-Insert, Module/Path-Inserts oder Starter-Steps
|
||||||
|
|
||||||
|
### Block 2: Node Organization Hardening
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Die Node-Library nach Family, Stage und Module weiter verdichten, damit große Produktionsgraphen schneller gebaut werden können.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- schnellere Auffindbarkeit
|
||||||
|
- weniger UI-Rauschen
|
||||||
|
- sauberere Trennung zwischen Legacy-, Bridge- und Graph-Nodes
|
||||||
|
|
||||||
|
### Block 3: CAD Operational Guidance
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
CAD-Workflows dieselbe operative Führung geben wie der Still-Graph heute schon hat, inklusive Stage-Status und klarer Baseline-Pfade.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- CAD-Familie wirkt nicht mehr wie ein Sonderfall
|
||||||
|
- Intake-Graphen sind ohne Trial-and-Error zusammensetzbar
|
||||||
|
|
||||||
|
### Block 4: Run Inspection Completion
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Run-, Node- und Comparison-Ansichten so vervollständigen, dass graphische Fehlläufe direkt im Editor debuggt werden können.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- Fehlerursachen sind ohne DB-Inspektion sichtbar
|
||||||
|
- Preflight, Dispatch und Run-Ergebnis greifen sichtbar ineinander
|
||||||
|
|
||||||
|
### Block 5: Context Flow Simplification
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Dispatch-/Preflight-Kontextauswahl vereinfachen, besonders bei vielen Order-Lines und Workflow-Varianten.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- weniger Fehlbedienung
|
||||||
|
- klarere Zuordnung zwischen Workflow, Kontext und Ausführungsmodus
|
||||||
|
|
||||||
|
### Block 6: Output-Type Contract Closure
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Output-Type-Erstellung und -Bearbeitung noch stärker auf Workflow-Verträge und Invocation-Profile zwingen.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- neue Output-Types lassen sich stabil anlegen
|
||||||
|
- Family- und Artifact-Mismatch wird früher blockiert
|
||||||
|
|
||||||
|
### Block 7: Canonical Blueprints And Seeds
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Starter-Blueprints, Seed-Workflows und Frontend-Neuanlage konsistent auf die kanonischen Family-sicheren Graphen bringen.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- weniger Drift zwischen Seed, Editor und Runtime
|
||||||
|
- bestehende Golden-/Smoke-Workflows bleiben reparierbar
|
||||||
|
|
||||||
|
### Block 8: Still Smoke Harness Stabilization
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Den non-legacy Still-Workflow als wiederholbaren Smoke-Pfad härten, ohne den Legacy-Fallback zu schwächen.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- eindeutiges Pass/Fail-Signal für den kanonischen Still-Graph
|
||||||
|
- belastbarer Startpunkt für echten E2E-Abgleich
|
||||||
|
|
||||||
|
### Block 9: CAD/Material Parity
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Materialzuweisung, Instances und Geometrie-Identität zwischen Preview, GLTF-Viewer und Workflow-Verbrauch weiter angleichen.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- weniger manuelle Materialreparatur
|
||||||
|
- Vorschau und Renderpfad greifen auf denselben vertrauenswürdigen Zustand zu
|
||||||
|
|
||||||
|
### Block 10: Rollout And Fallback Controls
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Rollout, Shadow und Graph-Freigabe sauber pro Workflow und pro Output-Type steuerbar halten.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- sichere Aktivierung
|
||||||
|
- klarer Fallback- und Rückrollpfad
|
||||||
|
|
||||||
|
### Block 11: Repo Hygiene
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Hilfsskripte, Test-Utilities und neue Workflow-Helfer konsolidieren, damit Folgearbeit nicht auf provisorischen Strukturen aufbaut.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- weniger Einweglogik
|
||||||
|
- besser lesbare Diff-Basis für Restarbeiten
|
||||||
|
|
||||||
|
### Block 12: Sequential E2E Gates
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Die wichtigsten Smoke- und Browser-Gates dokumentiert und gezielt ausführbar machen, ohne den Rechner parallel zu überlasten.
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- klarer Minimal-Satz an E2E-Prüfungen
|
||||||
|
- reproduzierbare Freigabegates für `/workflows`
|
||||||
|
|
||||||
|
## Aktuelle Ausführung
|
||||||
|
|
||||||
|
- Abgeschlossen: Block 1
|
||||||
|
- Abgeschlossen: Block 2
|
||||||
|
- Abgeschlossen: Block 3
|
||||||
|
- Abgeschlossen: Block 4
|
||||||
|
- Abgeschlossen: Block 5
|
||||||
|
- Abgeschlossen: Block 6
|
||||||
|
- Abgeschlossen: Block 7
|
||||||
|
- Abgeschlossen: Block 8
|
||||||
|
- In Arbeit: Block 9
|
||||||
|
- Nächster geplanter Folgeblock: Block 9
|
||||||
|
|
||||||
|
## Letzte Verifikation
|
||||||
|
|
||||||
|
- `python3 scripts/test_render_pipeline.py --workflow-still-smoke --execution-mode shadow`
|
||||||
|
- Ergebnis: Live-Smoke erfolgreich; Shadow-Comparison stabilisiert auf `WARN` mit `mean_pixel_delta=0.000257`, Legacy bleibt dadurch weiterhin authoritative
|
||||||
|
- `./backend/.venv/bin/pytest -q backend/tests/domains/test_workflow_runtime_services.py -k 'resolve_order_line_template_context_uses_exact_template_and_override or resolve_order_line_material_map_prefers_line_override_over_output_override or resolve_order_line_material_map_allows_node_override or prefers_authoritative_scene_manifest_assignments or keeps_legacy_source_name_fallback_without_scene_manifest'`
|
||||||
|
- Ergebnis: 5 Tests grün; autoritative Scene-Manifest-Zuweisungen werden nun im Workflow-Renderpfad auf `part_key` und `source_name` gespiegelt, Legacy-Fallback bleibt unverändert
|
||||||
|
- `./backend/.venv/bin/pytest backend/tests/test_part_key_service.py -q`
|
||||||
|
- Ergebnis: 1 Test grün; part-key-basierte Manifest-Auflösung bleibt konsistent
|
||||||
|
- `cd frontend && npx vitest run src/__tests__/components/workflowEditorUi.test.tsx src/__tests__/api/outputTypes.test.ts --pool forks --poolOptions.forks.singleFork=true`
|
||||||
|
- Ergebnis: 20 Tests grün, sequenziell ausgeführt
|
||||||
Reference in New Issue
Block a user