fix: align workflow material resolution with scene manifest

This commit is contained in:
2026-04-09 19:41:13 +02:00
parent d685031c1a
commit e5c8ac7592
3 changed files with 511 additions and 35 deletions
@@ -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
+162
View File
@@ -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