bfd58e3419
Bug A: Media Library thumbnails were gray because <img src> cannot send JWT auth headers. Added useAuthBlob() hook (fetch + createObjectURL) in MediaBrowser.tsx. Also fixed publish_asset Celery task to populate product_id + cad_file_id on MediaAsset for thumbnail fallback resolution. Bug B: Product dimensions now shown in Product Details card with Ruler icon and "from CAD" label when cad_mesh_attributes.dimensions_mm exists. Bug C: Replaced 128×128 CAD thumbnail with InlineCadViewer component. Queries gltf_geometry MediaAssets, fetches GLB via auth fetch → blob URL → Three.js Canvas with OrbitControls. Falls back to thumbnail + "Load 3D Model" button. Polling when GLB generation is in progress. Bug D: trimesh was in [cad] optional extra but Dockerfile only installed [dev]. Changed to pip install -e ".[dev,cad]" — trimesh now available in backend container, GLB + Colors export works. Also added bbox extraction (STL-first numpy parsing) in render_step_thumbnail and admin "Re-extract CAD Metadata" bulk endpoint. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
542 lines
18 KiB
Python
542 lines
18 KiB
Python
"""CAD file router - serve thumbnails, glTF models, parsed objects, and trigger reprocessing."""
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
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.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
|
|
from app.services.product_service import link_cad_to_product, lookup_product
|
|
|
|
router = APIRouter(prefix="/cad", tags=["cad"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
@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)
|
|
|
|
if not cad.gltf_path:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="glTF model not yet generated for this CAD file",
|
|
)
|
|
|
|
gltf_path = Path(cad.gltf_path)
|
|
if not gltf_path.exists():
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="glTF file missing from storage",
|
|
)
|
|
|
|
# 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,
|
|
"parsed_objects": cad.parsed_objects,
|
|
}
|
|
|
|
|
|
@router.get("/{id}/stl/{quality}")
|
|
async def download_stl(
|
|
id: uuid.UUID,
|
|
quality: str,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Download the cached STL for a CAD file with a human-readable filename.
|
|
|
|
The STL is cached next to the STEP file on first render.
|
|
quality must be 'low' or 'high'.
|
|
"""
|
|
if quality not in ("low", "high"):
|
|
raise HTTPException(400, detail="quality must be 'low' or 'high'")
|
|
|
|
cad = await _get_cad_file(id, db)
|
|
|
|
if not cad.stored_path:
|
|
raise HTTPException(404, detail="STEP file not uploaded for this CAD file")
|
|
|
|
step_path = Path(cad.stored_path)
|
|
stl_path = step_path.parent / f"{step_path.stem}_{quality}.stl"
|
|
|
|
if not stl_path.exists():
|
|
raise HTTPException(
|
|
404,
|
|
detail=f"STL cache not found for quality '{quality}'. Trigger a render first to generate it.",
|
|
)
|
|
|
|
original_stem = Path(cad.original_name or "model").stem
|
|
filename = f"{original_stem}_{quality}.stl"
|
|
|
|
return FileResponse(
|
|
path=str(stl_path),
|
|
media_type="application/octet-stream",
|
|
filename=filename,
|
|
)
|
|
|
|
|
|
@router.post("/{id}/generate-stl/{quality}", status_code=status.HTTP_202_ACCEPTED)
|
|
async def generate_stl(
|
|
id: uuid.UUID,
|
|
quality: str,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Queue STL generation for the given quality without triggering a full render."""
|
|
if user.role.value not in ("admin", "project_manager"):
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
if quality not in ("low", "high"):
|
|
raise HTTPException(status_code=400, detail="quality must be 'low' or 'high'")
|
|
|
|
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_stl_cache
|
|
task = generate_stl_cache.delay(str(id), quality)
|
|
return {"status": "queued", "task_id": task.id, "quality": quality}
|
|
|
|
|
|
@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 from the existing STL cache (trimesh, no Blender).
|
|
|
|
Stores the result as a MediaAsset with asset_type='gltf_geometry'.
|
|
The STL low-quality cache must already exist (run a thumbnail render first).
|
|
"""
|
|
if user.role.value not in ("admin", "project_manager"):
|
|
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")
|
|
|
|
step_path = Path(cad.stored_path)
|
|
stl_path = step_path.parent / f"{step_path.stem}_low.stl"
|
|
if not stl_path.exists():
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="STL low-quality cache not found. Trigger a render first to generate it.",
|
|
)
|
|
|
|
# Queue as a thumbnail_rendering task (trimesh available in render-worker)
|
|
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 != "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.get("/{id}/export-gltf-colored")
|
|
async def export_gltf_colored(
|
|
id: uuid.UUID,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Export a GLB with PBR colors from part_colors (material alias mapping).
|
|
|
|
Loads per-part STLs from the low-quality parts cache directory and applies
|
|
PBR materials based on the product's cad_part_materials color assignments.
|
|
Falls back to the combined STL with a single grey material.
|
|
"""
|
|
from fastapi.responses import Response
|
|
from sqlalchemy import text, select
|
|
import trimesh
|
|
import io
|
|
|
|
if user.role.value not in ("admin", "project_manager"):
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
|
|
# Bypass RLS for cad_files + products
|
|
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
|
|
cad = await _get_cad_file(id, db)
|
|
|
|
if not cad.stored_path:
|
|
raise HTTPException(404, detail="STEP file not uploaded")
|
|
|
|
step_path = Path(cad.stored_path)
|
|
stl_path = step_path.parent / f"{step_path.stem}_low.stl"
|
|
parts_dir = step_path.parent / f"{step_path.stem}_low_parts"
|
|
|
|
if not stl_path.exists():
|
|
raise HTTPException(404, detail="STL cache not found. Trigger a render first.")
|
|
|
|
# Load settings
|
|
from app.models.system_setting import SystemSetting
|
|
settings_result = await db.execute(
|
|
select(SystemSetting.key, SystemSetting.value).where(
|
|
SystemSetting.key.in_([
|
|
"gltf_scale_factor", "gltf_smooth_normals",
|
|
"gltf_pbr_roughness", "gltf_pbr_metallic",
|
|
])
|
|
)
|
|
)
|
|
raw_settings = {k: v for k, v in settings_result.all()}
|
|
scale = float(raw_settings.get("gltf_scale_factor", "0.001"))
|
|
smooth = raw_settings.get("gltf_smooth_normals", "true") == "true"
|
|
roughness = float(raw_settings.get("gltf_pbr_roughness", "0.4"))
|
|
metallic = float(raw_settings.get("gltf_pbr_metallic", "0.6"))
|
|
|
|
# Load part colors from product
|
|
from app.domains.products.models import Product
|
|
part_colors: dict[str, str] = {}
|
|
if cad.id:
|
|
prod_result = await db.execute(
|
|
select(Product).where(Product.cad_file_id == cad.id).limit(1)
|
|
)
|
|
product = prod_result.scalar_one_or_none()
|
|
if product and product.cad_part_materials:
|
|
for entry in product.cad_part_materials:
|
|
part_name = entry.get("part_name") or entry.get("name", "")
|
|
hex_color = entry.get("hex_color") or entry.get("color", "")
|
|
if part_name and hex_color:
|
|
part_colors[part_name] = hex_color
|
|
|
|
def _hex_to_rgba(h: str) -> list:
|
|
h = h.lstrip("#")
|
|
if len(h) < 6:
|
|
return [0.7, 0.7, 0.7, 1.0]
|
|
try:
|
|
return [int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4)] + [1.0]
|
|
except Exception:
|
|
return [0.7, 0.7, 0.7, 1.0]
|
|
|
|
def _make_material(hex_color: str | None = None):
|
|
rgba = _hex_to_rgba(hex_color) if hex_color else [0.7, 0.7, 0.7, 1.0]
|
|
return trimesh.visual.material.PBRMaterial(
|
|
baseColorFactor=rgba,
|
|
roughnessFactor=roughness,
|
|
metallicFactor=metallic,
|
|
)
|
|
|
|
def _apply_mesh(mesh, color=None):
|
|
mesh.apply_scale(scale)
|
|
if smooth:
|
|
try:
|
|
trimesh.smoothing.filter_laplacian(mesh, lamb=0.5, iterations=5)
|
|
except Exception:
|
|
pass
|
|
mesh.visual = trimesh.visual.TextureVisuals(material=_make_material(color))
|
|
return mesh
|
|
|
|
# Try per-part STLs first
|
|
scene = trimesh.Scene()
|
|
used_parts = False
|
|
|
|
if parts_dir.exists() and part_colors:
|
|
for part_name, hex_color in part_colors.items():
|
|
# Sanitize part name for filesystem
|
|
safe_name = part_name.replace("/", "_").replace("\\", "_")
|
|
part_stl = parts_dir / f"{safe_name}.stl"
|
|
if not part_stl.exists():
|
|
# Try lowercase / partial match
|
|
candidates = list(parts_dir.glob(f"{safe_name}*.stl"))
|
|
if not candidates:
|
|
candidates = list(parts_dir.glob("*.stl"))
|
|
candidates = [c for c in candidates if safe_name.lower() in c.stem.lower()]
|
|
if candidates:
|
|
part_stl = candidates[0]
|
|
else:
|
|
continue
|
|
try:
|
|
m = trimesh.load(str(part_stl), force="mesh")
|
|
_apply_mesh(m, hex_color)
|
|
scene.add_geometry(m, geom_name=part_name)
|
|
used_parts = True
|
|
except Exception:
|
|
pass
|
|
|
|
if not used_parts:
|
|
# Fallback: combined STL, single color
|
|
combined = trimesh.load(str(stl_path))
|
|
if hasattr(combined, 'geometry'):
|
|
for name, m in combined.geometry.items():
|
|
_apply_mesh(m, next(iter(part_colors.values()), None))
|
|
scene.add_geometry(m, geom_name=name)
|
|
else:
|
|
_apply_mesh(combined, next(iter(part_colors.values()), None))
|
|
scene.add_geometry(combined)
|
|
|
|
# Export to bytes
|
|
buf = io.BytesIO()
|
|
scene.export(buf, file_type="glb")
|
|
glb_bytes = buf.getvalue()
|
|
|
|
original_stem = Path(cad.original_name or "model").stem
|
|
filename = f"{original_stem}_colored.glb"
|
|
|
|
return Response(
|
|
content=glb_bytes,
|
|
media_type="model/gltf-binary",
|
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
)
|