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:
@@ -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.001–10.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.05–1.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.001–10.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.05–1.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.001–10.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.05–1.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.001–10.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.05–1.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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user