605 lines
20 KiB
Python
605 lines
20 KiB
Python
"""CAD file router - serve thumbnails, glTF models, parsed objects, and trigger reprocessing."""
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.database import get_db
|
|
from app.core.render_paths import resolve_result_path
|
|
from app.config import settings
|
|
from app.domains.media.models import MediaAsset, MediaAssetType
|
|
from app.models.cad_file import CadFile, ProcessingStatus
|
|
from app.models.order import Order
|
|
from app.models.order_item import OrderItem
|
|
from app.models.user import User
|
|
from app.utils.auth import get_current_user, is_privileged
|
|
from app.services.product_service import link_cad_to_product, lookup_product
|
|
|
|
router = APIRouter(prefix="/cad", tags=["cad"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part-materials schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class PartMaterialEntry(BaseModel):
|
|
type: Literal["library", "hex"]
|
|
value: str # material name or hex color string
|
|
|
|
|
|
class PartMaterialsResponse(BaseModel):
|
|
cad_file_id: str
|
|
part_materials: dict[str, PartMaterialEntry] | None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schemas for match-to-order
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class MatchToOrderRequest(BaseModel):
|
|
order_id: uuid.UUID
|
|
cad_file_ids: list[str]
|
|
|
|
|
|
class MatchedItem(BaseModel):
|
|
item_id: str
|
|
cad_file_id: str
|
|
item_name: str
|
|
cad_name: str
|
|
|
|
|
|
class MatchToOrderResponse(BaseModel):
|
|
matched: list[MatchedItem]
|
|
unmatched_cad: list[str]
|
|
unmatched_items: list[str]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Matching helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _normalize_stem(name: str) -> str:
|
|
"""Lowercase stem, strip .stp/.step extension for comparison."""
|
|
stem = name.strip()
|
|
for ext in (".step", ".stp"):
|
|
if stem.lower().endswith(ext):
|
|
stem = stem[: -len(ext)]
|
|
break
|
|
return stem.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/match-to-order", response_model=MatchToOrderResponse)
|
|
async def match_cad_files_to_order(
|
|
body: MatchToOrderRequest,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Match uploaded CAD files to order items by filename similarity.
|
|
|
|
For each CAD file, compares the stem of original_name (case-insensitive,
|
|
.stp/.step normalised) to the stem of each item's name_cad_modell field.
|
|
Updates order_item.cad_file_id for successful matches.
|
|
"""
|
|
# Load order with items
|
|
order_result = await db.execute(
|
|
select(Order)
|
|
.where(Order.id == body.order_id)
|
|
.options(selectinload(Order.items))
|
|
)
|
|
order = order_result.scalar_one_or_none()
|
|
if not order:
|
|
raise HTTPException(404, detail="Order not found")
|
|
if user.role.value != "admin" and order.created_by != user.id:
|
|
raise HTTPException(403, detail="Access denied")
|
|
|
|
# Parse and validate CAD file IDs
|
|
cad_uuids: list[uuid.UUID] = []
|
|
for raw_id in body.cad_file_ids:
|
|
try:
|
|
cad_uuids.append(uuid.UUID(raw_id))
|
|
except ValueError:
|
|
raise HTTPException(400, detail=f"Invalid cad_file_id: {raw_id}")
|
|
|
|
# Load CAD files from DB
|
|
cad_result = await db.execute(
|
|
select(CadFile).where(CadFile.id.in_(cad_uuids))
|
|
)
|
|
cad_files: list[CadFile] = list(cad_result.scalars().all())
|
|
|
|
found_ids = {str(cf.id) for cf in cad_files}
|
|
missing = [i for i in body.cad_file_ids if i not in found_ids]
|
|
if missing:
|
|
raise HTTPException(404, detail=f"CAD files not found: {missing}")
|
|
|
|
# Build lookup: normalized stem -> first OrderItem with that stem
|
|
items: list[OrderItem] = order.items
|
|
item_by_stem: dict[str, OrderItem] = {}
|
|
for item in items:
|
|
if item.name_cad_modell:
|
|
stem = _normalize_stem(item.name_cad_modell)
|
|
if stem not in item_by_stem:
|
|
item_by_stem[stem] = item
|
|
|
|
matched: list[MatchedItem] = []
|
|
unmatched_cad: list[str] = []
|
|
matched_item_ids: set[str] = set()
|
|
|
|
for cad_file in cad_files:
|
|
cad_stem = _normalize_stem(cad_file.original_name or "")
|
|
if cad_stem in item_by_stem:
|
|
item = item_by_stem[cad_stem]
|
|
item.cad_file_id = cad_file.id
|
|
item.updated_at = datetime.utcnow()
|
|
matched.append(
|
|
MatchedItem(
|
|
item_id=str(item.id),
|
|
cad_file_id=str(cad_file.id),
|
|
item_name=item.name_cad_modell or "",
|
|
cad_name=cad_file.original_name or "",
|
|
)
|
|
)
|
|
matched_item_ids.add(str(item.id))
|
|
|
|
# Propagate the STEP link to the product so that:
|
|
# (a) the render pipeline can find it via product.cad_file_id
|
|
# (b) future orders for the same product inherit the STEP automatically
|
|
# (c) the split-missing-step correctly identifies which products have STEP
|
|
try:
|
|
product = await lookup_product(db, item.pim_id, item.produkt_baureihe)
|
|
if product and product.cad_file_id is None:
|
|
await link_cad_to_product(db, product.id, cad_file.id)
|
|
except Exception:
|
|
pass # non-critical — item link already set above
|
|
else:
|
|
unmatched_cad.append(str(cad_file.id))
|
|
|
|
await db.commit()
|
|
|
|
unmatched_items = [
|
|
str(item.id)
|
|
for item in items
|
|
if str(item.id) not in matched_item_ids
|
|
]
|
|
|
|
return MatchToOrderResponse(
|
|
matched=matched,
|
|
unmatched_cad=unmatched_cad,
|
|
unmatched_items=unmatched_items,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _get_cad_file(cad_id: uuid.UUID, db: AsyncSession) -> CadFile:
|
|
result = await db.execute(select(CadFile).where(CadFile.id == cad_id))
|
|
cad = result.scalar_one_or_none()
|
|
if not cad:
|
|
raise HTTPException(status_code=404, detail="CAD file not found")
|
|
return cad
|
|
|
|
|
|
async def _resolve_gltf_path(cad: CadFile, db: AsyncSession) -> Path | None:
|
|
"""Resolve the best available GLTF/GLB path for a CAD file.
|
|
|
|
Prefer the legacy cad_files.gltf_path for compatibility, but fall back to
|
|
the canonical media_assets.gltf_geometry record written by the newer export
|
|
pipeline.
|
|
"""
|
|
if cad.gltf_path:
|
|
legacy_path = resolve_result_path(cad.gltf_path) or Path(cad.gltf_path)
|
|
if legacy_path.exists():
|
|
return legacy_path
|
|
|
|
asset_result = await db.execute(
|
|
select(MediaAsset)
|
|
.where(
|
|
MediaAsset.cad_file_id == cad.id,
|
|
MediaAsset.asset_type == MediaAssetType.gltf_geometry,
|
|
MediaAsset.is_archived == False, # noqa: E712
|
|
)
|
|
.order_by(MediaAsset.created_at.desc())
|
|
)
|
|
asset = asset_result.scalars().first()
|
|
if asset and asset.storage_key:
|
|
asset_path = resolve_result_path(asset.storage_key)
|
|
if asset_path is None:
|
|
asset_path = Path(settings.upload_dir) / asset.storage_key.lstrip("/")
|
|
if asset_path.exists():
|
|
return asset_path
|
|
|
|
return None
|
|
|
|
|
|
@router.get("/{id}/thumbnail")
|
|
async def get_thumbnail(
|
|
id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Serve the thumbnail image for a CAD file (no auth — UUID is opaque enough)."""
|
|
from sqlalchemy import text
|
|
# Bypass RLS for this public endpoint (cad_files has tenant RLS but thumbnails are public)
|
|
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
|
|
cad = await _get_cad_file(id, db)
|
|
|
|
if not cad.thumbnail_path:
|
|
raise HTTPException(404, detail="Thumbnail not yet generated for this CAD file")
|
|
|
|
thumb_path = Path(cad.thumbnail_path)
|
|
if not thumb_path.exists():
|
|
raise HTTPException(404, detail="Thumbnail file missing from storage")
|
|
|
|
ext = thumb_path.suffix.lower()
|
|
media_type = "image/jpeg" if ext in (".jpg", ".jpeg") else "image/png"
|
|
|
|
return FileResponse(
|
|
path=str(thumb_path),
|
|
media_type=media_type,
|
|
filename=f"{id}{ext}",
|
|
headers={"Cache-Control": "max-age=3600, public"},
|
|
)
|
|
|
|
|
|
@router.get("/{id}/model")
|
|
async def get_model(
|
|
id: uuid.UUID,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Serve the glTF file for a CAD file."""
|
|
cad = await _get_cad_file(id, db)
|
|
gltf_path = await _resolve_gltf_path(cad, db)
|
|
if gltf_path is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="glTF model not yet generated for this CAD file",
|
|
)
|
|
|
|
# glTF files may be either .gltf (JSON) or .glb (binary)
|
|
suffix = gltf_path.suffix.lower()
|
|
if suffix == ".glb":
|
|
media_type = "model/gltf-binary"
|
|
else:
|
|
media_type = "model/gltf+json"
|
|
|
|
return FileResponse(
|
|
path=str(gltf_path),
|
|
media_type=media_type,
|
|
filename=f"{id}{suffix}",
|
|
)
|
|
|
|
|
|
@router.get("/{id}/objects")
|
|
async def get_objects(
|
|
id: uuid.UUID,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Return the parsed_objects JSON extracted from the STEP file."""
|
|
cad = await _get_cad_file(id, db)
|
|
|
|
if cad.parsed_objects is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Parsed objects not yet available for this CAD file",
|
|
)
|
|
|
|
return {
|
|
"cad_file_id": str(cad.id),
|
|
"original_name": cad.original_name,
|
|
"processing_status": cad.processing_status.value,
|
|
"step_hash": cad.step_file_hash,
|
|
"parsed_objects": cad.parsed_objects,
|
|
}
|
|
|
|
|
|
@router.post("/{id}/generate-gltf-geometry", status_code=status.HTTP_202_ACCEPTED)
|
|
async def generate_gltf_geometry(
|
|
id: uuid.UUID,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Queue GLB geometry export directly from STEP via OCC (no STL required).
|
|
|
|
Stores the result as a MediaAsset with asset_type='gltf_geometry'.
|
|
Uses export_step_to_gltf.py (OCP/pythonocc) — no Blender needed.
|
|
"""
|
|
if not is_privileged(user):
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
|
|
cad = await _get_cad_file(id, db)
|
|
if not cad.stored_path:
|
|
raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")
|
|
|
|
from app.tasks.step_tasks import generate_gltf_geometry_task
|
|
task = generate_gltf_geometry_task.delay(str(id))
|
|
return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)}
|
|
|
|
|
|
@router.post(
|
|
"/{id}/regenerate-thumbnail",
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
)
|
|
async def regenerate_thumbnail(
|
|
id: uuid.UUID,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Queue a Celery task to reprocess the STEP file and regenerate its thumbnail."""
|
|
if user.role.value not in ("admin", "global_admin", "tenant_admin"):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Only admins can trigger thumbnail regeneration",
|
|
)
|
|
|
|
cad = await _get_cad_file(id, db)
|
|
|
|
# Reset processing status so the worker will reprocess
|
|
cad.processing_status = ProcessingStatus.pending
|
|
await db.commit()
|
|
|
|
# Enqueue Celery task
|
|
task_id: str | None = None
|
|
try:
|
|
from app.tasks.step_tasks import process_step_file
|
|
result = process_step_file.delay(str(cad.id))
|
|
task_id = result.id
|
|
except Exception:
|
|
# Worker may not be running; status is already reset so it will pick up later
|
|
pass
|
|
|
|
return {
|
|
"cad_file_id": str(cad.id),
|
|
"original_name": cad.original_name,
|
|
"status": "queued",
|
|
"task_id": task_id,
|
|
}
|
|
|
|
|
|
@router.post("/{id}/reset-stuck", status_code=status.HTTP_200_OK)
|
|
async def reset_stuck_processing(
|
|
id: uuid.UUID,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Force-reset a CAD file that is stuck in 'processing' to 'failed'.
|
|
|
|
Use when a file shows 'processing' indefinitely due to a worker crash.
|
|
After resetting, click 'Regen thumbnail' to retry.
|
|
"""
|
|
if not is_privileged(user):
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
|
|
cad = await _get_cad_file(id, db)
|
|
|
|
if cad.processing_status != ProcessingStatus.processing:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"CAD file is not stuck — current status: {cad.processing_status.value}",
|
|
)
|
|
|
|
cad.processing_status = ProcessingStatus.failed
|
|
cad.error_message = "Manually reset — worker may have crashed. Use 'Regen thumbnail' to retry."
|
|
await db.commit()
|
|
|
|
return {"cad_file_id": str(cad.id), "status": "failed", "message": "Reset to 'failed'. Use 'Regen thumbnail' to retry."}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part-material assignment endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/{id}/part-materials", response_model=PartMaterialsResponse)
|
|
async def get_part_materials(
|
|
id: uuid.UUID,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Return the saved part-material assignments for a CAD file."""
|
|
cad = await _get_cad_file(id, db)
|
|
return PartMaterialsResponse(
|
|
cad_file_id=str(cad.id),
|
|
part_materials=cad.part_materials,
|
|
)
|
|
|
|
|
|
def _normalize_part_name(name: str) -> str:
|
|
"""Strip OCC _AF\\d+ suffixes and lowercase for comparison."""
|
|
import re
|
|
n = name.strip().lower()
|
|
prev = ""
|
|
while prev != n:
|
|
prev = n
|
|
n = re.sub(r"_af\d+(_asm)?$", "", n)
|
|
return n
|
|
|
|
|
|
def _valid_part_names(cad) -> set[str] | None:
|
|
"""Return normalized part names from parsed_objects, or None if unavailable."""
|
|
po = cad.parsed_objects
|
|
if not po or not isinstance(po, dict):
|
|
return None
|
|
objects = po.get("objects")
|
|
if not objects or not isinstance(objects, list):
|
|
return None
|
|
return {_normalize_part_name(n) for n in objects if isinstance(n, str)}
|
|
|
|
|
|
@router.put("/{id}/part-materials", response_model=PartMaterialsResponse)
|
|
async def save_part_materials(
|
|
id: uuid.UUID,
|
|
body: dict[str, PartMaterialEntry],
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Replace the part-material assignment map for a CAD file.
|
|
|
|
Accepts a full dict of part-name -> {type, value} and overwrites the existing
|
|
assignment. Pass an empty dict to clear all assignments.
|
|
|
|
Keys are validated against parsed_objects — unknown part names are rejected.
|
|
"""
|
|
if not is_privileged(user):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
|
cad = await _get_cad_file(id, db)
|
|
|
|
# Validate keys against known part names from STEP extraction
|
|
valid_names = _valid_part_names(cad)
|
|
if valid_names is not None and body:
|
|
invalid_keys = [
|
|
k for k in body
|
|
if _normalize_part_name(k) not in valid_names
|
|
]
|
|
if invalid_keys:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Unknown part names (not in parsed_objects): {invalid_keys[:10]}",
|
|
)
|
|
|
|
# Serialise Pydantic models to plain dicts for JSONB storage
|
|
cad.part_materials = {name: entry.model_dump() for name, entry in body.items()}
|
|
cad.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
await db.refresh(cad)
|
|
return PartMaterialsResponse(
|
|
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)
|
|
|
|
# Validate keys against known part names (slugified form)
|
|
valid_names = _valid_part_names(cad)
|
|
if valid_names is not None and body.overrides:
|
|
invalid_keys = [
|
|
k for k in body.overrides
|
|
if _normalize_part_name(k) not in valid_names
|
|
]
|
|
if invalid_keys:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Unknown part keys (not in parsed_objects): {invalid_keys[:10]}",
|
|
)
|
|
|
|
cad.manual_material_overrides = body.overrides
|
|
cad.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
await db.refresh(cad)
|
|
return ManualMaterialOverridesOut(
|
|
cad_file_id=str(id),
|
|
manual_material_overrides=cad.manual_material_overrides,
|
|
)
|