refactor: replace STL intermediary with OCC-native STEP→GLB pipeline

- export_step_to_gltf.py: STEP→GLB via RWGltf_CafWriter + BRepBuilderAPI_Transform
  (mm→m pre-scaling, XCAFDoc_ShapeTool.GetComponents_s static method)
- Blender scripts (blender_render.py, still_render.py, turntable_render.py,
  export_gltf.py, export_blend.py): import GLB instead of STL, remove _scale_mm_to_m
- step_tasks.py: add generate_gltf_production_task, remove generate_stl_cache,
  replace _bbox_from_stl with _bbox_from_glb (trimesh), auto-queue geometry GLB
  after thumbnail render
- render_blender.py: replace _stl_from_cache_or_convert with _glb_from_step,
  remove convert_step_to_stl and export_per_part_stls
- domains/rendering/tasks.py: update render_turntable_task, export_gltf/blend tasks
  to use GLB instead of STL
- cad.py: remove STL download/generate endpoints, add generate-gltf-production
- admin.py: generate-missing-stls → generate-missing-geometry-glbs
- Frontend: replace STL cache UI with GLB generate buttons, remove stl_cached field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 16:49:18 +01:00
parent 3eba7b2d37
commit 95cfe0aa93
20 changed files with 809 additions and 1301 deletions
+25 -216
View File
@@ -262,78 +262,16 @@ async def get_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).
"""Queue GLB geometry export directly from STEP via OCC (no STL required).
Stores the result as a MediaAsset with asset_type='gltf_geometry'.
The STL low-quality cache must already exist (run a thumbnail render first).
Uses export_step_to_gltf.py (OCP/pythonocc) — no Blender needed.
"""
if user.role.value not in ("admin", "project_manager"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
@@ -342,20 +280,34 @@ async def generate_gltf_geometry(
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}/generate-gltf-production", status_code=status.HTTP_202_ACCEPTED)
async def generate_gltf_production(
id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Queue production GLB export (Blender + PBR materials) from a geometry GLB.
Requires a gltf_geometry MediaAsset to already exist (run generate-gltf-geometry first).
Stores result as a MediaAsset with asset_type='gltf_production'.
"""
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")
from app.tasks.step_tasks import generate_gltf_production_task
task = generate_gltf_production_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,
@@ -396,146 +348,3 @@ async def regenerate_thumbnail(
}
@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}"},
)