Files
HartOMat/backend/app/api/routers/cad.py
T

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,
)