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
+66 -37
View File
@@ -41,10 +41,10 @@ SETTINGS_DEFAULTS: dict[str, str] = {
"smtp_from_address": "",
# glTF tessellation quality
"tessellation_engine": "occ", # "occ" | "gmsh" — tessellation backend
"gltf_preview_linear_deflection": "0.1", # mm — geometry GLB for viewer
"gltf_preview_angular_deflection": "0.1", # rad — Standard preset
"gltf_production_linear_deflection": "0.03", # mm — production GLB
"gltf_production_angular_deflection": "0.05", # rad — Standard preset
"scene_linear_deflection": "0.1", # mm — geometry GLB for viewer
"scene_angular_deflection": "0.1", # rad — Standard preset
"render_linear_deflection": "0.03", # mm — production/render GLB
"render_angular_deflection": "0.05", # rad — Standard preset
# 3D viewer / glTF export settings
"gltf_scale_factor": "0.001",
"gltf_smooth_normals": "true",
@@ -74,10 +74,10 @@ class SettingsOut(BaseModel):
smtp_user: str = ""
smtp_password: str = ""
smtp_from_address: str = ""
gltf_preview_linear_deflection: float = 0.1
gltf_preview_angular_deflection: float = 0.1
gltf_production_linear_deflection: float = 0.03
gltf_production_angular_deflection: float = 0.05
scene_linear_deflection: float = 0.1
scene_angular_deflection: float = 0.1
render_linear_deflection: float = 0.03
render_angular_deflection: float = 0.05
gltf_scale_factor: float = 0.001
gltf_smooth_normals: bool = True
viewer_max_distance: float = 50.0
@@ -106,10 +106,10 @@ class SettingsUpdate(BaseModel):
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from_address: str | None = None
gltf_preview_linear_deflection: float | None = None
gltf_preview_angular_deflection: float | None = None
gltf_production_linear_deflection: float | None = None
gltf_production_angular_deflection: float | None = None
scene_linear_deflection: float | None = None
scene_angular_deflection: float | None = None
render_linear_deflection: float | None = None
render_angular_deflection: float | None = None
gltf_scale_factor: float | None = None
gltf_smooth_normals: bool | None = None
viewer_max_distance: float | None = None
@@ -224,10 +224,10 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
smtp_user=raw.get("smtp_user", ""),
smtp_password=raw.get("smtp_password", ""),
smtp_from_address=raw.get("smtp_from_address", ""),
gltf_preview_linear_deflection=float(raw.get("gltf_preview_linear_deflection", "0.1")),
gltf_preview_angular_deflection=float(raw.get("gltf_preview_angular_deflection", "0.5")),
gltf_production_linear_deflection=float(raw.get("gltf_production_linear_deflection", "0.03")),
gltf_production_angular_deflection=float(raw.get("gltf_production_angular_deflection", "0.2")),
scene_linear_deflection=float(raw.get("scene_linear_deflection", "0.1")),
scene_angular_deflection=float(raw.get("scene_angular_deflection", "0.5")),
render_linear_deflection=float(raw.get("render_linear_deflection", "0.03")),
render_angular_deflection=float(raw.get("render_angular_deflection", "0.2")),
gltf_scale_factor=float(raw.get("gltf_scale_factor", "0.001")),
gltf_smooth_normals=raw.get("gltf_smooth_normals", "true") == "true",
viewer_max_distance=float(raw.get("viewer_max_distance", "50")),
@@ -340,22 +340,22 @@ async def update_settings(
updates["gltf_pbr_roughness"] = str(body.gltf_pbr_roughness)
if body.gltf_pbr_metallic is not None:
updates["gltf_pbr_metallic"] = str(body.gltf_pbr_metallic)
if body.gltf_preview_linear_deflection is not None:
if not (0.001 <= body.gltf_preview_linear_deflection <= 10.0):
raise HTTPException(400, detail="gltf_preview_linear_deflection must be 0.00110.0 mm")
updates["gltf_preview_linear_deflection"] = str(body.gltf_preview_linear_deflection)
if body.gltf_preview_angular_deflection is not None:
if not (0.05 <= body.gltf_preview_angular_deflection <= 1.5):
raise HTTPException(400, detail="gltf_preview_angular_deflection must be 0.051.5 rad")
updates["gltf_preview_angular_deflection"] = str(body.gltf_preview_angular_deflection)
if body.gltf_production_linear_deflection is not None:
if not (0.001 <= body.gltf_production_linear_deflection <= 10.0):
raise HTTPException(400, detail="gltf_production_linear_deflection must be 0.00110.0 mm")
updates["gltf_production_linear_deflection"] = str(body.gltf_production_linear_deflection)
if body.gltf_production_angular_deflection is not None:
if not (0.05 <= body.gltf_production_angular_deflection <= 1.5):
raise HTTPException(400, detail="gltf_production_angular_deflection must be 0.051.5 rad")
updates["gltf_production_angular_deflection"] = str(body.gltf_production_angular_deflection)
if body.scene_linear_deflection is not None:
if not (0.001 <= body.scene_linear_deflection <= 10.0):
raise HTTPException(400, detail="scene_linear_deflection must be 0.00110.0 mm")
updates["scene_linear_deflection"] = str(body.scene_linear_deflection)
if body.scene_angular_deflection is not None:
if not (0.05 <= body.scene_angular_deflection <= 1.5):
raise HTTPException(400, detail="scene_angular_deflection must be 0.051.5 rad")
updates["scene_angular_deflection"] = str(body.scene_angular_deflection)
if body.render_linear_deflection is not None:
if not (0.001 <= body.render_linear_deflection <= 10.0):
raise HTTPException(400, detail="render_linear_deflection must be 0.00110.0 mm")
updates["render_linear_deflection"] = str(body.render_linear_deflection)
if body.render_angular_deflection is not None:
if not (0.05 <= body.render_angular_deflection <= 1.5):
raise HTTPException(400, detail="render_angular_deflection must be 0.051.5 rad")
updates["render_angular_deflection"] = str(body.render_angular_deflection)
if body.tessellation_engine is not None:
if body.tessellation_engine not in {"occ", "gmsh"}:
raise HTTPException(400, detail="tessellation_engine must be 'occ' or 'gmsh'")
@@ -532,13 +532,12 @@ async def reextract_all_metadata(
return {"queued": queued, "message": f"Queued {queued} CAD file(s) for metadata re-extraction"}
@router.post("/settings/generate-missing-geometry-glbs", status_code=status.HTTP_202_ACCEPTED)
async def generate_missing_geometry_glbs(
@router.post("/settings/generate-missing-canonical-scenes", status_code=status.HTTP_202_ACCEPTED)
async def generate_missing_canonical_scenes(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Queue geometry GLB generation for every completed CAD file that has no gltf_geometry MediaAsset."""
import uuid as _uuid
"""Queue canonical scene (geometry GLB + USD master) generation for every completed CAD file that has no gltf_geometry MediaAsset."""
from app.domains.media.models import MediaAsset, MediaAssetType
result = await db.execute(
@@ -561,7 +560,37 @@ async def generate_missing_geometry_glbs(
generate_gltf_geometry_task.delay(str(cad_file.id))
queued += 1
return {"queued": queued, "message": f"Queued {queued} missing geometry GLB task(s)"}
return {"queued": queued, "message": f"Queued {queued} missing canonical scene task(s)"}
@router.post("/settings/generate-missing-usd-masters", status_code=status.HTTP_202_ACCEPTED)
async def generate_missing_usd_masters(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Queue USD master export for every completed CAD file that has no usd_master MediaAsset."""
from app.domains.media.models import MediaAsset, MediaAssetType
result = await db.execute(
select(CadFile).where(CadFile.processing_status == ProcessingStatus.completed)
)
cad_files = result.scalars().all()
existing_result = await db.execute(
select(MediaAsset.cad_file_id).where(MediaAsset.asset_type == MediaAssetType.usd_master)
)
existing_ids = {row[0] for row in existing_result.all()}
from app.tasks.step_tasks import generate_usd_master_task
queued = 0
for cad_file in cad_files:
if not cad_file.stored_path:
continue
if cad_file.id not in existing_ids:
generate_usd_master_task.delay(str(cad_file.id))
queued += 1
return {"queued": queued, "message": f"Queued {queued} missing USD master task(s)"}
@router.post("/settings/recover-stuck-processing", status_code=status.HTTP_200_OK)