feat(P2): USD Foundation — canonical part identity + material overrides

M1 — USD exporter:
- render-worker/scripts/export_step_to_usd.py (631 lines)
  Full XCAF traversal, one UsdGeom.Mesh per leaf part,
  schaeffler:partKey on every prim, index-space sharpEdgeVertexPairs
- render-worker/Dockerfile: usd-core>=24.11 installed (USD 0.26.3)

M2 — usd_master MediaAsset + pipeline auto-chain:
- migrations 060 (usd_master enum), 061 (3 JSONB columns),
  062 (rename tessellation settings keys)
- generate_usd_master_task: runs export_step_to_usd.py, upserts
  usd_master MediaAsset, writes resolved_material_assignments to CadFile
- Auto-chained from generate_gltf_geometry_task after every GLB export
- step_tasks.py shim re-exports generate_usd_master_task

M3 — scene-manifest API:
- part_key_service.py: build_scene_manifest(), generate_part_key(),
  four-layer material priority resolution with provenance
- SceneManifest / PartEntry Pydantic models in products/schemas.py
- GET /api/cad/{id}/scene-manifest endpoint (graceful fallback to
  parsed_objects when USD not yet generated)
- POST /api/cad/{id}/generate-usd-master endpoint
- frontend/src/api/sceneManifest.ts: fetchSceneManifest(),
  triggerUsdMasterGeneration()

M4 — manual-material-overrides API:
- GET/PUT /api/cad/{id}/manual-material-overrides endpoints
- CadFile.manual_material_overrides JSONB column (migration 061)
- getManualOverrides() / saveManualOverrides() in cad.ts

M5 — ThreeDViewer partKey integration:
- export_step_to_gltf.py injects partKeyMap into GLB extras
- ThreeDViewer: partKeyMap extraction, resolvePartKey(), effectiveMaterials
  merges legacy partMaterials + new manualOverrides (server-side persistence)
- MaterialPanel: dual-path save (partKey vs legacy), provenance badge,
  reconciliation panel for unmatched/unassigned parts

Also:
- Admin.tsx: generate-missing-usd-masters + canonical scenes bulk actions
- ProductDetail.tsx: usd_master row in asset table
- vite-env.d.ts: fix ImportMeta.env TypeScript error
- GPUProbeResult: add timestamp/devices/render_time_s fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 13:11:09 +01:00
parent 47b5d42bb5
commit 409fb92899
33 changed files with 2070 additions and 303 deletions
+107
View File
@@ -434,3 +434,110 @@ async def save_part_materials(
cad_file_id=str(cad.id),
part_materials=cad.part_materials,
)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Manual material overrides schemas (partKey-keyed)
# ---------------------------------------------------------------------------
class ManualMaterialOverridesIn(BaseModel):
overrides: dict[str, str] # { partKey: materialName }
class ManualMaterialOverridesOut(BaseModel):
cad_file_id: str
manual_material_overrides: dict[str, str] | None
# ---------------------------------------------------------------------------
# USD master endpoints
# ---------------------------------------------------------------------------
@router.get("/{id}/scene-manifest")
async def get_scene_manifest(
id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Return scene manifest for a CAD file (part keys, material assignments)."""
from app.domains.products.schemas import SceneManifest
from app.services.part_key_service import build_scene_manifest
from app.domains.media.models import MediaAsset, MediaAssetType
cad = await _get_cad_file(id, db)
usd_result = await db.execute(
select(MediaAsset).where(
MediaAsset.cad_file_id == id,
MediaAsset.asset_type == MediaAssetType.usd_master,
)
)
usd_asset = usd_result.scalars().first()
if not usd_asset and not cad.parsed_objects:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scene manifest not yet available — run generate-usd-master first",
)
manifest_dict = build_scene_manifest(cad, usd_asset)
return SceneManifest(**manifest_dict)
@router.post("/{id}/generate-usd-master", status_code=status.HTTP_202_ACCEPTED)
async def generate_usd_master(
id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Queue a USD master export for a CAD file."""
if not is_privileged(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
cad = await _get_cad_file(id, db)
if not cad.stored_path:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No STEP file stored")
from app.tasks.step_tasks import generate_usd_master_task
task = generate_usd_master_task.delay(str(id))
return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)}
@router.get("/{id}/manual-material-overrides", response_model=ManualMaterialOverridesOut)
async def get_manual_material_overrides(
id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Return manual material overrides (partKey → materialName) for a CAD file."""
cad = await _get_cad_file(id, db)
return ManualMaterialOverridesOut(
cad_file_id=str(id),
manual_material_overrides=cad.manual_material_overrides,
)
@router.put("/{id}/manual-material-overrides", response_model=ManualMaterialOverridesOut)
async def save_manual_material_overrides(
id: uuid.UUID,
body: ManualMaterialOverridesIn,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Save manual material overrides keyed by partKey.
Writes to CadFile.manual_material_overrides (JSONB).
Takes priority over auto-resolved and source-matched materials in build_scene_manifest().
"""
if not is_privileged(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
cad = await _get_cad_file(id, db)
cad.manual_material_overrides = body.overrides
await db.commit()
await db.refresh(cad)
return ManualMaterialOverridesOut(
cad_file_id=str(id),
manual_material_overrides=cad.manual_material_overrides,
)