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
+31
View File
@@ -27,6 +27,37 @@
**Lösung**: `_normalize_key()` Helper: strippt `UPLOAD_DIR`-Prefix. In `download_asset` Legacy-Remapping für alte Pfade als Fallback behalten. Neue Assets immer relativ speichern.
**Für künftige Projekte**: `storage_key` immer relativ zu `UPLOAD_DIR``candidate = Path(settings.upload_dir) / key`. Absolute Pfade nie in die DB schreiben.
### 2026-03-07 | OCP | `RWMesh_CoordinateSystemConverter` nicht als Python-Binding verfügbar (OCP 7.8.1.1)
**Problem**: `writer.ChangeCoordinateSystemConverter()` wirft `TypeError: Unregistered type : RWMesh_CoordinateSystemConverter` — der C++-Typ ist nicht in OCP-Python-Bindings registriert.
**Lösung**: Shapes vor dem Export mit `BRepBuilderAPI_Transform` um Faktor 0.001 skalieren (mm→m). Dann `RWGltf_CafWriter` direkt ohne Koordinatensystem-Konverter aufrufen.
```python
trsf = gp_Trsf()
trsf.SetScaleFactor(0.001)
for i in range(1, free_labels.Length() + 1):
label = free_labels.Value(i)
scaled = BRepBuilderAPI_Transform(shape_tool.GetShape_s(label), trsf, True).Shape()
shape_tool.SetShape(label, scaled)
```
**Für künftige Projekte**: Immer OCP-Methoden-Verfügbarkeit mit `hasattr()` oder `dir()` testen bevor man sie aufruft.
### 2026-03-07 | OCP | `XCAFDoc_ShapeTool.GetComponents` → `GetComponents_s` (static method suffix)
**Problem**: `shape_tool.GetComponents(label, seq)``AttributeError: 'XCAFDoc_ShapeTool' has no attribute 'GetComponents'`
**Lösung**: In OCP sind alle XCAF static-Methoden mit `_s`-Suffix: `XCAFDoc_ShapeTool.GetComponents_s(label, seq)`. Gilt für alle `XCAFDoc_*`-Klassen.
### 2026-03-07 | Pipeline | OCC-native STEP→GLB ersetzt STL-Intermediary komplett
**Problem**: Pipeline nutzte STL als Zwischenformat (STEP→STL via cadquery, STL→Blender). STL verliert Materialnamen, Farben, OCC-Topologie. Zwei parallele Konvertierungen (cadquery STL + Blender Import) = doppelter Aufwand. STL-Cache auf Disk = Dateiflut.
**Lösung**: `export_step_to_gltf.py` nutzt OCC `RWGltf_CafWriter` direkt: STEP→GLB in einem Schritt. Koordinatensystem-Konvertierung (Z-up→Y-up) + mm→m Skalierung übernimmt `conv.SetInputLengthUnit(1e-3)`. Blender importiert GLB nativ via `bpy.ops.import_scene.gltf()` — kein `_scale_mm_to_m` mehr nötig.
**OCC-API-Gotcha**: Projekt nutzt `OCP` (cadquery's pythonocc-Bindings), NICHT `OCC.Core`. Statische Methoden haben `_s`-Suffix: `XCAFApp_Application.GetApplication_s()`, `XCAFDoc_DocumentTool.ShapeTool_s()`.
**Für künftige Projekte**: `RWGltf_CafWriter` + `STEPCAFControl_Reader` sind die kanonische STEP→GLB Pipeline. `BRepMesh_IncrementalMesh` tesselliert vor dem Export. `XCAFDoc_ColorTool.SetColor(label, color, XCAFDoc_ColorSurf)` überträgt Farben in GLB-Materialien.
### 2026-03-07 | Celery | generate_gltf_geometry_task als Subprocess — kein bpy-Import-Konflikt
**Problem**: OCP und bpy können nicht im selben Python-Prozess koexistieren — OCP lädt native C++-Bibliotheken die mit Blenders internen Versionen kollidieren. Der render-worker-Container hat BEIDE (cadquery + Blender).
**Lösung**: `export_step_to_gltf.py` via `sys.executable` subprocess aus Celery-Task heraus starten. So läuft OCC in isoliertem Prozess, kein Import-Pollution. Pattern: `subprocess.run([sys.executable, script, "--arg", val], timeout=120)`.
### 2026-03-07 | Bbox | GLB statt STL für Bounding-Box Extraktion
**Problem**: `_bbox_from_stl()` nutzte numpy binary parsing des STL-Headers (schnell, aber STL existiert nicht mehr nach Pipeline-Umbau).
**Lösung**: `_bbox_from_glb()` mit trimesh: `scene = trimesh.load(glb_path, force="scene"); bounds = scene.bounds`. GLB ist in Metern → `* 1000` für mm. Fallback auf `_bbox_from_step_cadquery()` bleibt erhalten.
### 2026-03-07 | Workflow | Turntable-Workflow brauchte step_path zur Laufzeit
**Problem**: `WorkflowDefinition.config` ist statisch (JSON) — enthält keine produktspezifischen Pfade. `_build_turntable()` erwartet `step_path` + `output_dir` in params → `ValueError` bei Workflow-Dispatch.
**Lösung**: `dispatch_render_with_workflow()` löst `step_path` + `output_dir` aus dem `OrderLine → Product → CadFile` Graph auf und injiziert sie in params vor `dispatch_workflow()`.
+17 -11
View File
@@ -438,30 +438,36 @@ async def reextract_all_metadata(
return {"queued": queued, "message": f"Queued {queued} CAD file(s) for metadata re-extraction"}
@router.post("/settings/generate-missing-stls", status_code=status.HTTP_202_ACCEPTED)
async def generate_missing_stls(
@router.post("/settings/generate-missing-geometry-glbs", status_code=status.HTTP_202_ACCEPTED)
async def generate_missing_geometry_glbs(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Queue STL generation for every quality missing from each completed CAD file."""
from pathlib import Path as _Path
"""Queue geometry GLB generation for every completed CAD file that has no gltf_geometry MediaAsset."""
import uuid as _uuid
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()
from app.tasks.step_tasks import generate_stl_cache
# Bulk-fetch existing gltf_geometry assets
existing_result = await db.execute(
select(MediaAsset.cad_file_id).where(MediaAsset.asset_type == MediaAssetType.gltf_geometry)
)
existing_ids = {row[0] for row in existing_result.all()}
from app.tasks.step_tasks import generate_gltf_geometry_task
queued = 0
for cad_file in cad_files:
if not cad_file.stored_path:
continue
step = _Path(cad_file.stored_path)
for quality in ("low", "high"):
if not (step.parent / f"{step.stem}_{quality}.stl").exists():
generate_stl_cache.delay(str(cad_file.id), quality)
queued += 1
if cad_file.id not in existing_ids:
generate_gltf_geometry_task.delay(str(cad_file.id))
queued += 1
return {"queued": queued, "message": f"Queued {queued} missing STL generation task(s)"}
return {"queued": queued, "message": f"Queued {queued} missing geometry GLB task(s)"}
@router.post("/settings/seed-workflows", status_code=status.HTTP_200_OK)
+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}"},
)
-11
View File
@@ -77,20 +77,9 @@ def _product_out(product: Product, priority: list[str] | None = None) -> Product
out.cad_parsed_objects = product.cad_parsed_objects
out.cad_mesh_attributes = product.cad_file.mesh_attributes if product.cad_file else None
out.render_image_url = _best_render_url(product, priority or ["latest_render", "cad_thumbnail"])
out.stl_cached = _stl_cached_qualities(product)
return out
def _stl_cached_qualities(product: Product) -> list[str]:
"""Return list of STL qualities that are cached on disk for this product."""
from pathlib import Path as _Path
cad = product.cad_file
if not cad or not cad.stored_path:
return []
step = _Path(cad.stored_path)
return [q for q in ("low", "high") if (step.parent / f"{step.stem}_{q}.stl").exists()]
async def _load_thumbnail_priority(db: AsyncSession) -> list[str]:
"""Read product_thumbnail_priority from system_settings.
-1
View File
@@ -59,7 +59,6 @@ class ProductOut(BaseModel):
thumbnail_url: str | None = None
render_image_url: str | None = None
processing_status: str | None = None
stl_cached: list[str] = []
cad_parsed_objects: list[str] | None = None
cad_mesh_attributes: dict | None = None
arbeitspaket: str | None = None
+63 -108
View File
@@ -193,9 +193,8 @@ def render_turntable_task(
import os
import shutil
import subprocess
from app.services.render_blender import (
find_blender, convert_step_to_stl, export_per_part_stls
)
import sys
from app.services.render_blender import find_blender
blender_bin = find_blender()
if not blender_bin:
@@ -208,27 +207,25 @@ def render_turntable_task(
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
turntable_script = scripts_dir / "turntable_render.py"
# STL conversion — try MinIO cache first, then convert locally
stl_path = step.parent / f"{step.stem}_{stl_quality}.stl"
if not stl_path.exists() or stl_path.stat().st_size == 0:
try:
from app.domains.products.cache_service import compute_step_hash, check_stl_cache
step_hash = compute_step_hash(str(step))
cached = check_stl_cache(step_hash, stl_quality)
if cached:
stl_path.write_bytes(cached)
logger.info("STL restored from MinIO cache: %s", stl_path.name)
else:
convert_step_to_stl(step, stl_path, stl_quality)
except Exception as exc:
logger.warning("MinIO cache check failed (non-fatal): %s — falling back to conversion", exc)
convert_step_to_stl(step, stl_path, stl_quality)
parts_dir = step.parent / f"{step.stem}_{stl_quality}_parts"
if not (parts_dir / "manifest.json").exists():
try:
export_per_part_stls(step, parts_dir, stl_quality)
except Exception as exc:
logger.warning("per-part export non-fatal: %s", exc)
# GLB generation via OCC (replaces STL intermediary)
linear_deflection = 0.3 if stl_quality == "low" else 0.05
angular_deflection = 0.3 if stl_quality == "low" else 0.1
glb_path = step.parent / f"{step.stem}_{stl_quality}.glb"
if not glb_path.exists() or glb_path.stat().st_size == 0:
occ_script = scripts_dir / "export_step_to_gltf.py"
occ_cmd = [
sys.executable, str(occ_script),
"--step_path", str(step),
"--output_path", str(glb_path),
"--linear_deflection", str(linear_deflection),
"--angular_deflection", str(angular_deflection),
]
occ_result = subprocess.run(occ_cmd, capture_output=True, text=True, timeout=120)
if occ_result.returncode != 0:
raise RuntimeError(
f"export_step_to_gltf.py failed:\n{occ_result.stderr[-500:]}"
)
logger.info("render_turntable_task: GLB generated: %s", glb_path.name)
# Build turntable render arguments
frames_dir = out_dir / "frames"
@@ -238,7 +235,7 @@ def render_turntable_task(
blender_bin, "--background",
"--python", str(turntable_script),
"--",
str(stl_path),
str(glb_path),
str(frames_dir),
output_name,
str(width), str(height),
@@ -463,93 +460,40 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
max_retries=1,
)
def export_gltf_for_order_line_task(self, order_line_id: str) -> dict:
"""Export a GLB from the STL cache via Blender subprocess (with trimesh fallback).
"""Export a geometry GLB directly from STEP via OCC (no STL intermediary).
Publishes a MediaAsset with asset_type='gltf_geometry' (no asset lib) or
'gltf_production' (when an asset library is applied).
Requires the STL low-quality cache to exist.
Publishes a MediaAsset with asset_type='gltf_geometry'.
"""
import json
import os
import subprocess
import sys
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
if not step_path_str:
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
step = Path(step_path_str)
stl_path = step.parent / f"{step.stem}_low.stl"
if not stl_path.exists():
raise RuntimeError(
f"STL cache not found: {stl_path}. Run thumbnail generation first."
)
output_path = step.parent / f"{step.stem}_geometry.glb"
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
export_script = scripts_dir / "export_gltf.py"
occ_script = scripts_dir / "export_step_to_gltf.py"
from app.services.render_blender import find_blender, is_blender_available
if not occ_script.exists():
raise RuntimeError(f"export_step_to_gltf.py not found at {occ_script}")
asset_type = "gltf_geometry"
# Load sharp edge hints from mesh_attributes for UV seam marking
sharp_edges_json = "[]"
if cad_file_id:
try:
import asyncio as _asyncio
async def _load_mesh_attrs() -> list:
from app.database import AsyncSessionLocal
from app.models.cad_file import CadFile as _CF
from sqlalchemy import select as _sel
async with AsyncSessionLocal() as _db:
_res = await _db.execute(_sel(_CF).where(_CF.id == cad_file_id))
_cad = _res.scalar_one_or_none()
if _cad and _cad.mesh_attributes:
return _cad.mesh_attributes.get("sharp_edge_midpoints") or []
return []
_midpoints = _asyncio.get_event_loop().run_until_complete(_load_mesh_attrs())
if _midpoints:
sharp_edges_json = json.dumps(_midpoints)
except Exception as _exc:
logger.warning("Could not load sharp_edge_midpoints for %s: %s", cad_file_id, _exc)
if is_blender_available() and export_script.exists():
blender_bin = find_blender()
cmd = [
blender_bin, "--background",
"--python", str(export_script),
"--",
"--stl_path", str(stl_path),
"--output_path", str(output_path),
"--asset_library_blend", "",
"--material_map", json.dumps({}),
"--sharp_edges_json", sharp_edges_json,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
raise RuntimeError(
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
)
publish_asset.delay(order_line_id, asset_type, str(output_path))
logger.info("export_gltf_for_order_line_task completed via Blender: %s", output_path.name)
return {"glb_path": str(output_path), "method": "blender"}
except Exception as exc:
logger.warning(
"Blender GLB export failed for %s, falling back to trimesh: %s",
order_line_id, exc,
)
# Trimesh fallback
try:
import trimesh
mesh = trimesh.load(str(stl_path))
mesh.export(str(output_path))
publish_asset.delay(order_line_id, asset_type, str(output_path))
logger.info("export_gltf_for_order_line_task completed via trimesh: %s", output_path.name)
return {"glb_path": str(output_path), "method": "trimesh"}
cmd = [
sys.executable, str(occ_script),
"--step_path", str(step),
"--output_path", str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError(
f"export_step_to_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
)
publish_asset.delay(order_line_id, "gltf_geometry", str(output_path))
logger.info("export_gltf_for_order_line_task completed via OCC: %s", output_path.name)
return {"glb_path": str(output_path), "method": "occ"}
except Exception as exc:
logger.error("export_gltf_for_order_line_task failed for %s: %s", order_line_id, exc)
raise self.retry(exc=exc, countdown=15)
@@ -576,9 +520,20 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
step = Path(step_path_str)
stl_path = step.parent / f"{step.stem}_low.stl"
if not stl_path.exists():
raise RuntimeError(f"STL cache not found: {stl_path}")
# Use geometry GLB as input (generate if missing)
glb_path = step.parent / f"{step.stem}_geometry.glb"
if not glb_path.exists():
import subprocess as _sp
import sys as _sys
scripts_dir_tmp = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
occ_cmd = [
_sys.executable, str(scripts_dir_tmp / "export_step_to_gltf.py"),
"--step_path", str(step),
"--output_path", str(glb_path),
]
occ_res = _sp.run(occ_cmd, capture_output=True, text=True, timeout=120)
if occ_res.returncode != 0:
raise RuntimeError(f"GLB generation failed:\n{occ_res.stderr[-500:]}")
output_path = step.parent / f"{step.stem}_production.blend"
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
@@ -617,7 +572,7 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
blender_bin, "--background",
"--python", str(export_script),
"--",
"--stl_path", str(stl_path),
"--glb_path", str(glb_path),
"--output_path", str(output_path),
"--asset_library_blend", asset_lib_path,
"--material_map", json.dumps(mat_map),
@@ -670,7 +625,7 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i
if not product or not product.cad_file_id:
return None, None, None
cad = s.execute(sql_select(CadFile).where(CadFile.id == product.cad_file_id)).scalar_one_or_none()
stl_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_low.stl") if cad else None
glb_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_geometry.glb") if cad else None
# Resolve asset library blend path
try:
@@ -681,24 +636,24 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i
blend_path = None
mat_map = {m.get("part_name", ""): m.get("material", "") for m in (product.cad_part_materials or [])}
return stl_path, blend_path, mat_map
return glb_path, blend_path, mat_map
result = _inner()
if result is None or result[0] is None:
logger.warning("apply_asset_library_materials_task: could not resolve paths for %s", order_line_id)
return {"status": "skipped"}
stl_path, blend_path, mat_map = result
if not stl_path or not Path(stl_path).exists():
logger.warning("STL not found for %s", order_line_id)
return {"status": "skipped", "reason": "stl_not_found"}
glb_path, blend_path, mat_map = result
if not glb_path or not Path(glb_path).exists():
logger.warning("Geometry GLB not found for %s", order_line_id)
return {"status": "skipped", "reason": "glb_not_found"}
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
script = scripts_dir / "asset_library.py"
cmd = [
blender_bin, "--background", "--python", str(script), "--",
"--stl_path", stl_path,
"--glb_path", glb_path,
"--asset_library_blend", blend_path or "",
"--material_map", json.dumps(mat_map),
]
+55 -188
View File
@@ -17,24 +17,39 @@ logger = logging.getLogger(__name__)
MIN_BLENDER_VERSION = (5, 0, 1)
def _stl_from_cache_or_convert(step_path: Path, stl_path: Path, quality: str) -> None:
"""Try MinIO cache first, then fall back to local STEP→STL conversion."""
# MinIO cache check (non-fatal — cache miss just means we convert normally)
try:
from app.domains.products.cache_service import compute_step_hash, check_stl_cache
step_hash = compute_step_hash(str(step_path))
cached_bytes = check_stl_cache(step_hash, quality)
if cached_bytes:
stl_path.write_bytes(cached_bytes)
logger.info("STL restored from MinIO cache: %s (%d KB)", stl_path.name, len(cached_bytes) // 1024)
return
except Exception as exc:
logger.warning("MinIO cache check failed (non-fatal): %s", exc)
def _glb_from_step(step_path: Path, glb_path: Path, quality: str = "low") -> None:
"""Convert STEP → GLB via OCC (export_step_to_gltf.py, no Blender needed).
# Local conversion
from app.services.step_processor import convert_step_to_stl
logger.info("STL cache miss — converting: %s", step_path.name)
convert_step_to_stl(step_path, stl_path, quality)
quality: "low" → coarser mesh (~0.3 mm deflection, fast)
"high" → finer mesh (~0.05 mm deflection, slower)
"""
import subprocess
import sys as _sys
linear_deflection = 0.3 if quality == "low" else 0.05
angular_deflection = 0.3 if quality == "low" else 0.1
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
script_path = scripts_dir / "export_step_to_gltf.py"
cmd = [
_sys.executable, str(script_path),
"--step_path", str(step_path),
"--output_path", str(glb_path),
"--linear_deflection", str(linear_deflection),
"--angular_deflection", str(angular_deflection),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
for line in result.stdout.splitlines():
logger.info("[occ-gltf] %s", line)
for line in result.stderr.splitlines():
logger.warning("[occ-gltf stderr] %s", line)
if result.returncode != 0 or not glb_path.exists() or glb_path.stat().st_size == 0:
raise RuntimeError(
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
f"STDERR: {result.stderr[-1000:]}"
)
logger.info("GLB converted: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
def find_blender() -> str:
@@ -51,127 +66,6 @@ def is_blender_available() -> bool:
return bool(find_blender())
def convert_step_to_stl(step_path: Path, stl_path: Path, quality: str = "low") -> None:
"""Convert a STEP file to STL using cadquery.
Raises ImportError if cadquery is not installed (not available in backend
container — only in render-worker container).
"""
import cadquery as cq # only available in render-worker
if quality == "high":
shape = cq.importers.importStep(str(step_path))
cq.exporters.export(shape, str(stl_path), tolerance=0.01, angularTolerance=0.02)
else:
shape = cq.importers.importStep(str(step_path))
cq.exporters.export(shape, str(stl_path), tolerance=0.3, angularTolerance=0.3)
if not stl_path.exists() or stl_path.stat().st_size == 0:
raise RuntimeError("cadquery produced empty STL")
def export_per_part_stls(step_path: Path, parts_dir: Path, quality: str = "low") -> list:
"""Export one STL per named STEP leaf shape using OCP XCAF.
Returns the manifest list (may be empty on failure — non-fatal).
"""
tol = 0.01 if quality == "high" else 0.3
angular_tol = 0.05 if quality == "high" else 0.3
try:
from OCP.STEPCAFControl import STEPCAFControl_Reader
from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
from OCP.TDataStd import TDataStd_Name
from OCP.TDF import TDF_Label as TDF_Label_cls, TDF_LabelSequence
from OCP.XCAFApp import XCAFApp_Application
from OCP.TDocStd import TDocStd_Document
from OCP.TCollection import TCollection_ExtendedString
from OCP.IFSelect import IFSelect_RetDone
import cadquery as cq
except ImportError as e:
logger.warning("per-part export skipped (import error): %s", e)
return []
app = XCAFApp_Application.GetApplication_s()
doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf"))
app.InitDocument(doc)
reader = STEPCAFControl_Reader()
reader.SetNameMode(True)
status = reader.ReadFile(str(step_path))
if status != IFSelect_RetDone:
logger.warning("XCAF reader failed with status %s", status)
return []
if not reader.Transfer(doc):
logger.warning("XCAF transfer failed")
return []
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
name_id = TDataStd_Name.GetID_s()
leaves = []
def _get_label_name(label):
name_attr = TDataStd_Name()
if label.FindAttribute(name_id, name_attr):
return name_attr.Get().ToExtString()
return ""
def _collect_leaves(label):
if XCAFDoc_ShapeTool.IsAssembly_s(label):
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(label, components)
for i in range(1, components.Length() + 1):
comp_label = components.Value(i)
if XCAFDoc_ShapeTool.IsReference_s(comp_label):
ref_label = TDF_Label_cls()
XCAFDoc_ShapeTool.GetReferredShape_s(comp_label, ref_label)
comp_name = _get_label_name(comp_label)
ref_name = _get_label_name(ref_label)
name = ref_name or comp_name
if XCAFDoc_ShapeTool.IsAssembly_s(ref_label):
_collect_leaves(ref_label)
elif XCAFDoc_ShapeTool.IsSimpleShape_s(ref_label):
shape = XCAFDoc_ShapeTool.GetShape_s(comp_label)
leaves.append((name or f"unnamed_{len(leaves)}", shape))
else:
_collect_leaves(comp_label)
elif XCAFDoc_ShapeTool.IsSimpleShape_s(label):
name = _get_label_name(label)
shape = XCAFDoc_ShapeTool.GetShape_s(label)
leaves.append((name or f"unnamed_{len(leaves)}", shape))
top_labels = TDF_LabelSequence()
shape_tool.GetFreeShapes(top_labels)
for i in range(1, top_labels.Length() + 1):
_collect_leaves(top_labels.Value(i))
if not leaves:
logger.warning("no leaf shapes found via XCAF")
return []
parts_dir.mkdir(parents=True, exist_ok=True)
manifest = []
for idx, (name, shape) in enumerate(leaves):
safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_")
filename = f"{idx:02d}_{safe_name}.stl"
filepath = str(parts_dir / filename)
try:
cq_shape = cq.Shape(shape)
cq_shape.exportStl(filepath, tolerance=tol, angularTolerance=angular_tol)
manifest.append({"index": idx, "name": name, "file": filename})
except Exception as e:
logger.warning("failed to export part '%s': %s", name, e)
manifest_path = parts_dir / "manifest.json"
with open(manifest_path, "w") as f:
json.dump({"parts": manifest}, f, indent=2)
return manifest
def render_still(
step_path: Path,
output_path: Path,
@@ -202,7 +96,7 @@ def render_still(
denoising_use_gpu: str = "",
mesh_attributes: dict | None = None,
) -> dict:
"""Convert STEP → STL (cadquery) → PNG (Blender subprocess).
"""Convert STEP → GLB (OCC) → PNG (Blender subprocess).
Returns a dict with timing, sizes, engine_used, and log_lines.
Raises RuntimeError on failure.
@@ -215,7 +109,6 @@ def render_still(
script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "blender_render.py"
if not script_path.exists():
# Fallback: look next to this file (development mode)
alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "blender_render.py"
if alt.exists():
script_path = alt
@@ -224,24 +117,16 @@ def render_still(
t0 = time.monotonic()
# 1. STL conversion (cadquery)
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
# 1. GLB conversion (OCC — replaces cadquery STL)
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
t_stl = time.monotonic()
if not stl_path.exists() or stl_path.stat().st_size == 0:
_stl_from_cache_or_convert(step_path, stl_path, stl_quality)
t_glb = time.monotonic()
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(step_path, glb_path, quality=stl_quality)
else:
logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
if not (parts_dir / "manifest.json").exists():
try:
export_per_part_stls(step_path, parts_dir, stl_quality)
except Exception as exc:
logger.warning("per-part STL export failed (non-fatal): %s", exc)
stl_duration_s = round(time.monotonic() - t_stl, 2)
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Blender render
output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -263,7 +148,7 @@ def render_still(
"--background",
"--python", str(script_path),
"--",
str(stl_path),
str(glb_path),
str(output_path),
str(width), str(height),
eng, str(samples), str(smooth_angle),
@@ -332,22 +217,13 @@ def render_still(
render_duration_s = round(time.monotonic() - t_render, 2)
parts_count = 0
manifest_file = parts_dir / "manifest.json"
if manifest_file.exists():
try:
data = json.loads(manifest_file.read_text())
parts_count = len(data.get("parts", []))
except Exception:
pass
return {
"total_duration_s": round(time.monotonic() - t0, 2),
"stl_duration_s": stl_duration_s,
"stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log
"render_duration_s": render_duration_s,
"stl_size_bytes": stl_size_bytes,
"stl_size_bytes": glb_size_bytes,
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
"parts_count": parts_count,
"parts_count": 0,
"engine_used": engine_used,
"log_lines": log_lines,
}
@@ -407,24 +283,15 @@ def render_turntable_to_file(
t0 = time.monotonic()
# 1. STL conversion
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
# 1. GLB conversion (OCC — replaces cadquery STL)
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
t_stl = time.monotonic()
if not stl_path.exists() or stl_path.stat().st_size == 0:
_stl_from_cache_or_convert(step_path, stl_path, stl_quality)
t_glb = time.monotonic()
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(step_path, glb_path, quality=stl_quality)
else:
logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
if not (parts_dir / "manifest.json").exists():
try:
export_per_part_stls(step_path, parts_dir, stl_quality)
except Exception as exc:
logger.warning("per-part STL export failed (non-fatal): %s", exc)
stl_duration_s = round(time.monotonic() - t_stl, 2)
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Render frames with Blender
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
@@ -439,7 +306,7 @@ def render_turntable_to_file(
"--background",
"--python", str(script_path),
"--",
str(stl_path),
str(glb_path),
str(frames_dir),
str(frame_count),
"360", # degrees
@@ -554,10 +421,10 @@ def render_turntable_to_file(
return {
"total_duration_s": round(time.monotonic() - t0, 2),
"stl_duration_s": stl_duration_s,
"stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log
"render_duration_s": render_duration_s,
"ffmpeg_duration_s": ffmpeg_duration_s,
"stl_size_bytes": stl_size_bytes,
"stl_size_bytes": 0,
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
"frame_count": len(frame_files),
"engine_used": engine,
+215 -172
View File
@@ -1,6 +1,5 @@
"""Celery tasks for STEP file processing and thumbnail generation."""
import logging
import struct
from pathlib import Path
from app.tasks.celery_app import celery_app
from app.core.task_logs import log_task_event
@@ -8,44 +7,37 @@ from app.core.task_logs import log_task_event
logger = logging.getLogger(__name__)
def _bbox_from_stl(stl_path: str) -> dict | None:
"""Extract bounding box from a cached binary STL file.
def _bbox_from_glb(glb_path: str) -> dict | None:
"""Extract bounding box from a GLB file (meters → converted to mm).
Returns {"dimensions_mm": {x,y,z}, "bbox_center_mm": {x,y,z}} or None on failure.
Reading vertex extremes from an existing STL is ~10-100× faster than re-parsing STEP.
OCC GLB output is in meters; multiply by 1000 to get mm.
"""
try:
import numpy as np
p = Path(stl_path)
if not p.exists() or p.stat().st_size < 84:
import trimesh
p = Path(glb_path)
if not p.exists():
return None
with p.open("rb") as f:
f.seek(80) # skip 80-byte header
n = struct.unpack("<I", f.read(4))[0]
if n == 0:
return None
raw = f.read(n * 50) # 50 bytes per triangle
# Binary STL per-triangle layout: normal(12B) + v1(12B) + v2(12B) + v3(12B) + attr(2B) = 50B
# Extract vertex bytes (columns 12..48 of each 50-byte row)
arr = np.frombuffer(raw, dtype=np.uint8).reshape(n, 50)
verts = np.frombuffer(arr[:, 12:48].tobytes(), dtype=np.float32).reshape(-1, 3)
mins = verts.min(axis=0)
maxs = verts.max(axis=0)
scene = trimesh.load(str(p), force="scene")
bounds = getattr(scene, "bounds", None)
if bounds is None:
return None
mins, maxs = bounds
dims = maxs - mins
return {
"dimensions_mm": {
"x": round(float(dims[0]), 2),
"y": round(float(dims[1]), 2),
"z": round(float(dims[2]), 2),
"x": round(float(dims[0]) * 1000, 2),
"y": round(float(dims[1]) * 1000, 2),
"z": round(float(dims[2]) * 1000, 2),
},
"bbox_center_mm": {
"x": round(float((mins[0] + maxs[0]) / 2), 2),
"y": round(float((mins[1] + maxs[1]) / 2), 2),
"z": round(float((mins[2] + maxs[2]) / 2), 2),
"x": round(float((mins[0] + maxs[0]) / 2) * 1000, 2),
"y": round(float((mins[1] + maxs[1]) / 2) * 1000, 2),
"z": round(float((mins[2] + maxs[2]) / 2) * 1000, 2),
},
}
except Exception as exc:
logger.debug(f"_bbox_from_stl failed for {stl_path}: {exc}")
logger.debug(f"_bbox_from_glb failed for {glb_path}: {exc}")
return None
@@ -229,9 +221,9 @@ def render_step_thumbnail(self, cad_file_id: str):
logger.error(f"Thumbnail render failed for {cad_file_id}: {exc}")
raise self.retry(exc=exc, countdown=30, max_retries=2)
# Extract bounding box from the STL that was just cached by the renderer.
# STL binary parsing is near-instant (numpy min/max) vs re-parsing the STEP file.
# Falls back to cadquery STEP re-parse if STL is not found.
# Extract bounding box from the thumbnail GLB generated by the renderer.
# GLB bbox via trimesh is fast and avoids re-parsing the STEP file.
# Falls back to cadquery STEP re-parse if GLB is not found.
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@@ -247,8 +239,8 @@ def render_step_thumbnail(self, cad_file_id: str):
if _step_path and not (_cad2.mesh_attributes or {}).get("dimensions_mm"):
_step = Path(_step_path)
_stl = _step.parent / f"{_step.stem}_low.stl"
bbox_data = _bbox_from_stl(str(_stl)) or _bbox_from_step_cadquery(_step_path)
_glb = _step.parent / f"{_step.stem}_thumbnail.glb"
bbox_data = _bbox_from_glb(str(_glb)) or _bbox_from_step_cadquery(_step_path)
if bbox_data:
_eng2 = create_engine(_sync_url2)
with Session(_eng2) as _sess2:
@@ -295,6 +287,13 @@ def render_step_thumbnail(self, cad_file_id: str):
except Exception:
logger.debug("WebSocket publish for CAD complete skipped (non-fatal)")
# Auto-generate geometry GLB so the 3D viewer is ready without manual trigger
try:
generate_gltf_geometry_task.delay(cad_file_id)
logger.info("render_step_thumbnail: queued generate_gltf_geometry_task for %s", cad_file_id)
except Exception:
logger.debug("Could not queue generate_gltf_geometry_task (non-fatal)")
@celery_app.task(name="app.tasks.step_tasks.reextract_cad_metadata", queue="thumbnail_rendering")
def reextract_cad_metadata(cad_file_id: str):
@@ -321,8 +320,8 @@ def reextract_cad_metadata(cad_file_id: str):
try:
p = Path(step_path)
stl_path = p.parent / f"{p.stem}_low.stl"
patch = _bbox_from_stl(str(stl_path)) or _bbox_from_step_cadquery(step_path)
glb_path = p.parent / f"{p.stem}_thumbnail.glb"
patch = _bbox_from_glb(str(glb_path)) or _bbox_from_step_cadquery(step_path)
if patch:
with Session(eng) as session:
cad_file = session.get(CadFile, cad_file_id)
@@ -342,72 +341,22 @@ def reextract_cad_metadata(cad_file_id: str):
eng.dispose()
@celery_app.task(bind=True, name="app.tasks.step_tasks.generate_stl_cache", queue="thumbnail_rendering")
def generate_stl_cache(self, cad_file_id: str, quality: str):
"""Generate and cache STL for a CAD file without triggering a full render."""
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.config import settings as app_settings
from app.models.cad_file import CadFile
logger.info(f"Generating {quality}-quality STL for CAD file: {cad_file_id}")
sync_url = app_settings.database_url.replace("+asyncpg", "")
eng = create_engine(sync_url)
with Session(eng) as session:
cad_file = session.get(CadFile, cad_file_id)
if not cad_file or not cad_file.stored_path:
logger.error(f"CAD file not found or no stored_path: {cad_file_id}")
return
step_path = cad_file.stored_path
eng.dispose()
try:
from app.services.render_blender import convert_step_to_stl, export_per_part_stls
from app.domains.products.cache_service import compute_step_hash, check_stl_cache, store_stl_cache
from pathlib import Path as _Path
step = _Path(step_path)
stl_out = step.parent / f"{step.stem}_{quality}.stl"
parts_dir = step.parent / f"{step.stem}_{quality}_parts"
if not stl_out.exists() or stl_out.stat().st_size == 0:
# Check MinIO cache before running cadquery conversion
step_hash = compute_step_hash(step_path)
cached_bytes = check_stl_cache(step_hash, quality)
if cached_bytes:
stl_out.write_bytes(cached_bytes)
logger.info(f"STL cache hit for {cad_file_id} ({quality}), skipped conversion")
else:
convert_step_to_stl(step, stl_out, quality)
# Store result in MinIO for future workers
if stl_out.exists() and stl_out.stat().st_size > 0:
store_stl_cache(step_hash, quality, str(stl_out))
if not (parts_dir / "manifest.json").exists():
try:
export_per_part_stls(step, parts_dir, quality)
except Exception as pe:
logger.warning(f"Per-part STL export non-fatal: {pe}")
logger.info(f"STL cached: {stl_out} ({stl_out.stat().st_size // 1024} KB)")
except Exception as exc:
logger.error(f"STL generation failed for {cad_file_id} quality={quality}: {exc}")
raise self.retry(exc=exc, countdown=30, max_retries=2)
@celery_app.task(bind=True, name="app.tasks.step_tasks.generate_gltf_geometry_task", queue="thumbnail_rendering", max_retries=1)
def generate_gltf_geometry_task(self, cad_file_id: str):
"""Export a GLB via Blender with material substitution and OCC sharp-edge data.
"""Export a geometry GLB directly from STEP via OCC (no STL intermediary).
Pipeline:
1. Reads sharp_edge_midpoints from cad_file.mesh_attributes (from OCC extraction)
2. Resolves material_map via alias lookup (part_name → SCHAEFFLER library material)
3. Runs Blender headless with export_gltf.py: STL → GLB with library materials + sharp edges
4. Falls back to trimesh (geometry-only, no materials) if Blender is unavailable
1. Reads STEP file directly (no STL needed)
2. Builds color_map from product.cad_part_materials (hex colors)
3. Runs export_step_to_gltf.py (Python/OCP): STEP → GLB with per-part colors
4. Stores result as gltf_geometry MediaAsset (replaces any existing one)
Replaces the existing gltf_geometry MediaAsset for this CadFile on each run.
Output is in meters, Y-up (glTF convention).
"""
import json as _json
import os as _os
import subprocess as _subprocess
import sys as _sys
from pathlib import Path as _Path
from sqlalchemy import create_engine, select as _select
from sqlalchemy.orm import Session
@@ -422,115 +371,68 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
logger.error("generate_gltf_geometry_task: no stored_path for %s", cad_file_id)
return
step_path_str = cad_file.stored_path
mesh_attrs = cad_file.mesh_attributes or {}
sharp_edge_midpoints = mesh_attrs.get("sharp_edge_midpoints", [])
# Load product materials for this CAD file
# Build hex color_map from product.cad_part_materials
from app.domains.products.models import Product
product = session.execute(
_select(Product).where(Product.cad_file_id == cad_file.id)
).scalar_one_or_none()
raw_material_map: dict[str, str] = {}
color_map: dict[str, str] = {}
product_id = str(product.id) if product else 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", "")
mat_name = entry.get("material_name") or entry.get("material", "")
if part_name and mat_name:
raw_material_map[part_name] = mat_name
hex_color = entry.get("hex_color") or entry.get("color", "")
if part_name and hex_color:
color_map[part_name] = hex_color
eng.dispose()
# Resolve aliases: "Steel--Stahl" → "SCHAEFFLER_010101_Steel-Bare"
from app.services.material_service import resolve_material_map
material_map = resolve_material_map(raw_material_map)
# Get asset library .blend path from system settings
from app.services.template_service import get_material_library_path
asset_library_blend = get_material_library_path()
step = _Path(step_path_str)
stl_path = step.parent / f"{step.stem}_low.stl"
if not stl_path.exists():
log_task_event(self.request.id, f"Failed: STL cache not found: {stl_path}", "error")
raise RuntimeError(f"STL cache not found: {stl_path}")
if not step.exists():
log_task_event(self.request.id, f"Failed: STEP file not found: {step}", "error")
raise RuntimeError(f"STEP file not found: {step}")
output_path = step.parent / f"{step.stem}_geometry.glb"
log_task_event(
self.request.id,
f"Starting GLB export: {len(material_map)} materials, "
f"{len(sharp_edge_midpoints)} sharp-edge hints, "
f"library={'yes' if asset_library_blend else 'no'}",
f"Starting OCC GLB export: {len(color_map)} part colors",
"info",
)
# --- Blender path ---
blender_bin = _os.environ.get("BLENDER_BIN", "blender")
# Run export_step_to_gltf.py as a subprocess so OCP imports don't pollute worker state
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
script_path = scripts_dir / "export_gltf.py"
blender_ok = False
script_path = scripts_dir / "export_step_to_gltf.py"
if _Path(blender_bin).exists() and script_path.exists():
cmd = [
blender_bin, "--background", "--python", str(script_path), "--",
"--stl_path", str(stl_path),
"--output_path", str(output_path),
"--material_map", _json.dumps(material_map),
"--sharp_edges_json", _json.dumps(sharp_edge_midpoints),
]
if asset_library_blend and _Path(asset_library_blend).exists():
cmd += ["--asset_library_blend", asset_library_blend]
python_bin = _sys.executable
cmd = [
python_bin, str(script_path),
"--step_path", str(step),
"--output_path", str(output_path),
"--color_map", _json.dumps(color_map),
]
try:
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if result.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
blender_ok = True
logger.info("generate_gltf_geometry_task: Blender export succeeded (%s KB)",
output_path.stat().st_size // 1024)
else:
logger.warning(
"Blender GLB export failed (exit %d) — falling back to trimesh.\n"
"STDOUT: %s\nSTDERR: %s",
result.returncode, result.stdout[-1500:], result.stderr[-500:],
)
except Exception as exc:
logger.warning("Blender GLB export error (%s) — falling back to trimesh", exc)
else:
logger.warning(
"Blender not available at '%s' or script missing at '%s' — using trimesh fallback",
blender_bin, script_path,
)
try:
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=120)
for line in result.stdout.splitlines():
logger.info("[occ-gltf] %s", line)
for line in result.stderr.splitlines():
logger.warning("[occ-gltf stderr] %s", line)
# --- Trimesh fallback (geometry only, no materials) ---
if not blender_ok:
try:
import trimesh
import trimesh as _trimesh
if result.returncode != 0 or not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError(
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
f"STDERR: {result.stderr[-1000:]}"
)
except Exception as exc:
log_task_event(self.request.id, f"Failed: {exc}", "error")
logger.error("generate_gltf_geometry_task OCC export failed: %s", exc)
raise self.retry(exc=exc, countdown=15)
def _process_mesh(m):
m.apply_scale(0.001)
try:
_trimesh.smoothing.filter_laplacian(m, lamb=0.5, iterations=5)
except Exception:
pass
mesh = trimesh.load(str(stl_path))
if hasattr(mesh, 'geometry'):
for sub in mesh.geometry.values():
_process_mesh(sub)
else:
_process_mesh(mesh)
mesh.export(str(output_path))
log_task_event(self.request.id, "Trimesh fallback export completed (no materials)", "done")
except Exception as exc:
log_task_event(self.request.id, f"Failed: {exc}", "error")
logger.error("generate_gltf_geometry_task trimesh fallback failed: %s", exc)
raise self.retry(exc=exc, countdown=15)
else:
log_task_event(self.request.id, f"Blender GLB export completed: {output_path.name}", "done")
log_task_event(self.request.id, f"OCC GLB export completed: {output_path.name}", "done")
# --- Store MediaAsset (replace existing gltf_geometry for this cad_file) ---
# Use sync SQLAlchemy to avoid asyncio event-loop conflicts in Celery workers.
import uuid as _uuid
from sqlalchemy import create_engine as _ce, delete as _del
from sqlalchemy.orm import Session as _Session
@@ -551,6 +453,7 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
_key = _key[len(_prefix):]
asset = MediaAsset(
cad_file_id=_uuid.UUID(cad_file_id),
product_id=_uuid.UUID(product_id) if product_id else None,
asset_type=MediaAssetType.gltf_geometry,
storage_key=_key,
mime_type="model/gltf-binary",
@@ -565,6 +468,146 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
return {"glb_path": str(output_path), "asset_id": asset_id}
@celery_app.task(
bind=True,
name="app.tasks.step_tasks.generate_gltf_production_task",
queue="thumbnail_rendering",
max_retries=2,
)
def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None = None) -> dict:
"""Generate a production GLB (Blender + PBR materials) from a geometry GLB via export_gltf.py.
1. Ensures a gltf_geometry MediaAsset exists (runs OCC export inline if not).
2. Resolves SCHAEFFLER material map for the CadFile's product.
3. Runs Blender headless with export_gltf.py → production GLB.
4. Stores result as gltf_production MediaAsset.
"""
import json as _json
import os as _os
import subprocess as _subprocess
import uuid as _uuid
from pathlib import Path as _Path
from sqlalchemy import create_engine as _ce, delete as _del, select as _sel
from sqlalchemy.orm import Session as _Session
from app.config import settings as app_settings
from app.domains.media.models import MediaAsset, MediaAssetType
from app.services.render_blender import find_blender, is_blender_available
log_task_event(self.request.id, f"generate_gltf_production_task started for cad {cad_file_id}", "info")
_sync_url = app_settings.database_url.replace("+asyncpg", "")
_eng = _ce(_sync_url)
# --- 1. Resolve geometry GLB path from existing gltf_geometry MediaAsset ---
with _Session(_eng) as _sess:
_row = _sess.execute(
_sel(MediaAsset).where(
MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
MediaAsset.asset_type == MediaAssetType.gltf_geometry,
)
).scalar_one_or_none()
geom_glb_key = _row.storage_key if _row else None
if not geom_glb_key:
# Trigger geometry generation first and retry this task
log_task_event(self.request.id, "No gltf_geometry asset found — queuing geometry task first", "info")
generate_gltf_geometry_task.delay(cad_file_id, product_id)
raise self.retry(exc=RuntimeError("gltf_geometry not yet available"), countdown=30, max_retries=2)
geom_glb_path = _Path(app_settings.upload_dir) / geom_glb_key
if not geom_glb_path.exists():
raise RuntimeError(f"Geometry GLB not found on disk: {geom_glb_path}")
# --- 2. Resolve material map (SCHAEFFLER library names) ---
from app.services.material_service import resolve_material_map
with _Session(_eng) as _sess:
from app.models.cad_file import CadFile as _CF
_cad = _sess.execute(_sel(_CF).where(_CF.id == _uuid.UUID(cad_file_id))).scalar_one_or_none()
raw_mat_map: dict = {}
if _cad and _cad.cad_part_materials:
raw_mat_map = _cad.cad_part_materials
mat_map = resolve_material_map(raw_mat_map)
# --- 3. Resolve asset library .blend path from system settings ---
from app.models.system_setting import SystemSetting
with _Session(_eng) as _sess:
_setting = _sess.execute(
_sel(SystemSetting).where(SystemSetting.key == "asset_library_blend")
).scalar_one_or_none()
asset_library_blend = _setting.value if _setting and _setting.value else ""
_eng.dispose()
# Output path next to geometry GLB
output_path = geom_glb_path.parent / (geom_glb_path.stem.replace("_geometry", "") + "_production.glb")
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
export_script = scripts_dir / "export_gltf.py"
if not is_blender_available():
raise RuntimeError("Blender is not available — cannot generate production GLB")
if not export_script.exists():
raise RuntimeError(f"export_gltf.py not found at {export_script}")
blender_bin = find_blender()
cmd = [
blender_bin, "--background",
"--python", str(export_script),
"--",
"--glb_path", str(geom_glb_path),
"--output_path", str(output_path),
"--material_map", _json.dumps(mat_map),
]
if asset_library_blend:
cmd += ["--asset_library_blend", asset_library_blend]
log_task_event(self.request.id, f"Running Blender export_gltf.py for {geom_glb_path.name}", "info")
try:
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
raise RuntimeError(
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
)
except Exception as exc:
log_task_event(self.request.id, f"Blender production GLB failed: {exc}", "error")
logger.error("generate_gltf_production_task Blender failed for cad %s: %s", cad_file_id, exc)
raise self.retry(exc=exc, countdown=30)
log_task_event(self.request.id, f"Production GLB exported: {output_path.name}", "done")
# --- 4. Store MediaAsset (replace existing gltf_production for this cad_file) ---
_eng2 = _ce(_sync_url)
with _Session(_eng2) as _sess:
_sess.execute(
_del(MediaAsset).where(
MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
MediaAsset.asset_type == MediaAssetType.gltf_production,
)
)
_key = str(output_path)
_prefix = str(app_settings.upload_dir).rstrip("/") + "/"
if _key.startswith(_prefix):
_key = _key[len(_prefix):]
asset = MediaAsset(
cad_file_id=_uuid.UUID(cad_file_id),
product_id=_uuid.UUID(product_id) if product_id else None,
asset_type=MediaAssetType.gltf_production,
storage_key=_key,
mime_type="model/gltf-binary",
file_size_bytes=output_path.stat().st_size if output_path.exists() else None,
)
_sess.add(asset)
_sess.commit()
asset_id = str(asset.id)
_eng2.dispose()
logger.info("generate_gltf_production_task: MediaAsset %s created for cad %s", asset_id, cad_file_id)
return {"glb_path": str(output_path), "asset_id": asset_id}
@celery_app.task(bind=True, name="app.tasks.step_tasks.regenerate_thumbnail", queue="thumbnail_rendering")
def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict):
"""Regenerate thumbnail with per-part colours."""
+6 -39
View File
@@ -67,30 +67,6 @@ export async function getCadObjects(cadFileId: string): Promise<CadObjects> {
return res.data
}
/**
* Download the cached STL for a CAD file as a file-save dialog.
* quality: 'low' | 'high'
* The backend returns a human-readable filename, but we derive it client-side too.
*/
export async function downloadStl(cadFileId: string, quality: 'low' | 'high', suggestedName?: string): Promise<void> {
const res = await api.get<Blob>(`/cad/${cadFileId}/stl/${quality}`, {
responseType: 'blob',
})
const url = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = url
a.download = suggestedName ? `${suggestedName}_${quality}.stl` : `model_${quality}.stl`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export async function generateStl(cadFileId: string, quality: 'low' | 'high'): Promise<{ task_id: string }> {
const res = await api.post<{ task_id: string }>(`/cad/${cadFileId}/generate-stl/${quality}`)
return res.data
}
/**
* Ask the backend to re-queue STEP processing for a CAD file (admin only).
* Returns the Celery task_id (or null if the worker is not available).
@@ -110,23 +86,14 @@ export interface GenerateGltfResponse {
cad_file_id: string
}
/**
* Queue GLB geometry export from existing STL cache (trimesh, no Blender).
* The STL low-quality cache must already exist.
*/
/** Queue geometry GLB export directly from STEP via OCC (no Blender, no STL). */
export async function generateGltfGeometry(cadFileId: string): Promise<GenerateGltfResponse> {
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-geometry`)
return res.data
}
export const exportGltfColored = (id: string): Promise<void> =>
api.get(`/cad/${id}/export-gltf-colored`, { responseType: 'blob' }).then(r => {
const url = URL.createObjectURL(r.data)
const a = document.createElement('a')
a.href = url
a.download = `${id}_colored.glb`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
})
/** Queue production GLB export (Blender + PBR materials) from geometry GLB. */
export async function generateGltfProduction(cadFileId: string): Promise<GenerateGltfResponse> {
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-production`)
return res.data
}
-1
View File
@@ -55,7 +55,6 @@ export interface Product {
thumbnail_url: string | null
render_image_url: string | null
processing_status: string | null
stl_cached: string[]
cad_parsed_objects: string[] | null
cad_mesh_attributes?: {
dimensions_mm?: { x: number; y: number; z: number }
@@ -31,7 +31,7 @@ function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) {
if (obj instanceof THREE.Mesh && obj.geometry) {
let geo = obj.geometry.clone()
if (!geo.index) {
// Non-indexed geometry (STL→GLB via trimesh): each triangle has unique vertices,
// Non-indexed geometry: each triangle has unique vertices,
// so computeVertexNormals() would give per-face normals (flat shading).
// mergeVertices() creates an indexed geometry with shared vertices first,
// so the subsequent normal computation averages across adjacent faces → smooth.
@@ -208,7 +208,7 @@ export default function InlineCadViewer({
className="btn-secondary text-xs"
onClick={() => generateMut.mutate()}
disabled={generateMut.isPending || generating}
title="Export STL to GLB and load 3D viewer"
title="Export geometry GLB from STEP via OCC and load 3D viewer"
>
<RefreshCw size={12} className={generating ? 'animate-spin' : ''} />
{generating ? 'Generating…' : generateMut.isPending ? 'Queuing…' : 'Load 3D Model'}
-44
View File
@@ -73,7 +73,6 @@ export default function AdminPage() {
blender_eevee_samples: number
threejs_render_size: number
thumbnail_format: string
stl_quality: string
blender_smooth_angle: number
cycles_device: string
blender_max_concurrent_renders: number
@@ -159,14 +158,6 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
})
const generateMissingStlsMut = useMutation({
mutationFn: () => api.post('/admin/settings/generate-missing-stls'),
onSuccess: (res) => {
toast.success(res.data.message || 'STL generation queued')
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const reextractMetadataMut = useMutation({
mutationFn: () => api.post('/admin/settings/reextract-metadata'),
onSuccess: (res) => {
@@ -397,29 +388,6 @@ export default function AdminPage() {
</div>
</div>
{/* STL quality */}
<div className="flex items-center gap-6 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">STL quality</span>
{(['low', 'high'] as const).map((q) => (
<button
key={q}
onClick={() => setBlenderDraft((d) => ({ ...d, stl_quality: q }))}
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
blender.stl_quality === q
? 'bg-blue-600 text-white border-blue-600'
: 'bg-surface text-content-secondary border-border-default hover:border-blue-400 hover:text-blue-600'
}`}
>
{q === 'low' ? 'Low (fast)' : 'High (detailed)'}
</button>
))}
<p className="text-xs text-content-muted">
{blender.stl_quality === 'high'
? 'Fine mesh (tol=0.01) — slower STEP→STL, sharper edges.'
: 'Coarse mesh (tol=0.3) — faster, good for previews.'}
</p>
</div>
{/* Smooth by angle */}
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Smooth angle</span>
@@ -704,18 +672,6 @@ export default function AdminPage() {
</button>
<p className="text-xs text-content-muted">Registers existing renders &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => generateMissingStlsMut.mutate()}
disabled={generateMissingStlsMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Queue STL conversion for every low/high quality that is not yet cached on disk"
>
<RefreshCw size={14} className={generateMissingStlsMut.isPending ? 'animate-spin' : ''} />
{generateMissingStlsMut.isPending ? 'Queueing…' : 'Generate Missing STLs'}
</button>
<p className="text-xs text-content-muted">Generates low + high STL files for completed STEP files missing them.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => reextractMetadataMut.mutate()}
+3 -3
View File
@@ -96,8 +96,8 @@ export default function CadPreviewPage() {
<Box size={48} className="text-gray-600" />
<p className="text-white text-lg font-semibold">No 3D model available yet</p>
<p className="text-gray-400 text-sm max-w-sm">
Generate a GLB file from the STEP cache to enable the 3D viewer.
The STL cache must exist (process the STEP file first).
Generate a geometry GLB from the STEP file to enable the 3D viewer.
Process the STEP file first to make it available.
</p>
{generating ? (
<div className="flex items-center gap-2 text-gray-300 text-sm">
@@ -116,7 +116,7 @@ export default function CadPreviewPage() {
)}
{generateMutation.isError && (
<p className="text-red-400 text-sm">
Failed to start generation. Check that the STL cache exists.
Failed to start generation. Make sure the STEP file has been processed.
</p>
)}
</div>
+73 -151
View File
@@ -18,7 +18,7 @@ import { listMaterials } from '../api/materials'
import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore } from '../store/auth'
import { downloadStl, generateStl, generateGltfGeometry, exportGltfColored } from '../api/cad'
import { generateGltfGeometry, generateGltfProduction } from '../api/cad'
import InlineCadViewer from '../components/cad/InlineCadViewer'
function CadStatusBadge({ status }: { status: string | null }) {
@@ -290,11 +290,6 @@ export default function ProductDetailPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
})
const exportGltfColoredMut = useMutation({
mutationFn: () => exportGltfColored(product?.cad_file_id!),
onError: () => toast.error('GLB export failed'),
})
const [editPositionDraft, setEditPositionDraft] = useState<Partial<RenderPosition>>({})
const POSITION_PRESETS = [
@@ -526,160 +521,87 @@ export default function ProductDetailPage() {
{product.cad_file_id ? (
<div className="space-y-3">
{/* Inline 3D Viewer */}
<InlineCadViewer
cadFileId={product.cad_file_id}
thumbnailUrl={product.render_image_url || product.thumbnail_url}
/>
{/* Two-column: viewer left, actions right */}
<div className="flex gap-4 items-start">
{/* Left: Inline 3D Viewer */}
<div className="flex-1 min-w-0">
<InlineCadViewer
cadFileId={product.cad_file_id}
thumbnailUrl={product.render_image_url || product.thumbnail_url}
/>
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
{isPrivileged && (
<>
<div {...getRootProps()} className="cursor-pointer">
<input {...getInputProps()} />
<button className="btn-secondary text-xs" disabled={cadUploadMut.isPending}>
<Upload size={12} />
{cadUploadMut.isPending ? 'Uploading…' : 'Re-upload STEP'}
</button>
</div>
<button
className="btn-secondary text-xs"
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
title="Re-render the thumbnail using the current part materials and the active thumbnail renderer — keeps the existing STEP parse data"
>
<RotateCcw size={12} />
{regenerateMut.isPending ? 'Queuing…' : 'Regenerate thumbnail'}
</button>
<button
className="btn-secondary text-xs"
onClick={() => reprocessMut.mutate()}
disabled={reprocessMut.isPending}
title="Re-run full STEP processing: re-parse part names, regenerate thumbnail and glTF. Use this after re-uploading a STEP file."
>
<RotateCcw size={12} />
{reprocessMut.isPending ? 'Queuing…' : 'Re-process STEP'}
</button>
<button
className="btn-secondary text-xs"
onClick={() => navigate(`/cad/${product.cad_file_id}`)}
title="Open interactive 3D viewer in full screen"
>
<Cuboid size={12} />
View Full Screen
</button>
<button
className="btn-secondary text-xs"
onClick={() =>
generateGltfGeometry(product.cad_file_id!)
.then(() => toast.info('GLB geometry export queued'))
.catch(() => toast.error('Failed to queue GLB export'))
}
title="Export geometry-only GLB from cached STL (trimesh, no Blender). Requires STL cache."
>
<Download size={12} />
Generate GLB
</button>
{product?.processing_status === 'completed' && (
<button
onClick={() => exportGltfColoredMut.mutate()}
disabled={exportGltfColoredMut.isPending}
className="btn-secondary flex items-center gap-2 disabled:opacity-40 text-xs"
title="Download GLB with PBR colors from material assignments"
>
<Download size={12} />
{exportGltfColoredMut.isPending ? 'Exporting…' : 'GLB + Colors'}
</button>
)}
<div className="flex items-center gap-1 pl-1 border-l border-border-light">
<p className="text-xs text-content-muted font-medium">STL:</p>
{(['low', 'high'] as const).map((q) =>
product.stl_cached.includes(q) ? (
<button
key={q}
className="btn-secondary text-xs"
onClick={() => downloadStl(product.cad_file_id!, q, product.name_cad_modell || product.name || undefined)}
title={q === 'low' ? 'Coarse mesh, tolerance 0.3 mm' : 'Fine mesh, tolerance 0.01 mm'}
>
<Download size={12} /> {q === 'low' ? 'Low' : 'High'}
</button>
) : (
<button
key={q}
className="btn-secondary text-xs opacity-60"
onClick={() => generateStl(product.cad_file_id!, q).then(() => toast.info(`STL generation queued (${q} quality)`)).catch(() => toast.error('Failed to queue STL generation'))}
title={`${q === 'low' ? 'Low' : 'High'}-quality STL not cached — click to generate`}
>
<RefreshCw size={12} /> Gen {q === 'low' ? 'Low' : 'High'}
</button>
)
)}
</div>
</>
)}
{!isPrivileged && product.cad_file_id && (
{/* Right: Action buttons */}
<div className="flex flex-col gap-2 shrink-0 w-44">
<button
className="btn-secondary text-xs"
className="btn-secondary text-xs w-full justify-start"
onClick={() => navigate(`/cad/${product.cad_file_id}`)}
title="Open interactive 3D viewer in full screen"
>
<Cuboid size={12} />
View Full Screen
</button>
)}
</div>
{/* Mesh attributes */}
{(() => {
// Prefer cad_mesh_attributes (reliably populated by API) over cad_file.mesh_attributes
const mesh_attrs: Record<string, unknown> = (product.cad_mesh_attributes ?? product.cad_file?.mesh_attributes) as Record<string, unknown> ?? {}
if (Object.keys(mesh_attrs).length === 0) return null
const dims = mesh_attrs.dimensions_mm as { x: number; y: number; z: number } | undefined
const bbox = mesh_attrs.bbox as { x?: number; y?: number; z?: number } | undefined
return (
<div className="mt-3 p-3 rounded-md border border-border-default bg-surface-alt">
<p className="text-xs font-semibold text-content-muted mb-2 flex items-center gap-1">
<Ruler size={12} />
Geometry
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
{dims != null && (
<>
<span className="text-content-muted">Dimensions</span>
<span>{dims.x.toFixed(1)} × {dims.y.toFixed(1)} × {dims.z.toFixed(1)} mm</span>
</>
)}
{dims == null && bbox != null && (
<>
<span className="text-content-muted">BBox</span>
<span>
{bbox.x?.toFixed(1)} × {bbox.y?.toFixed(1)} × {bbox.z?.toFixed(1)} mm
</span>
</>
)}
{(mesh_attrs.volume_mm3 as number | undefined) != null && (
<>
<span className="text-content-muted">Volume</span>
<span>{((mesh_attrs.volume_mm3 as number) / 1000).toFixed(2)} cm³</span>
</>
)}
{(mesh_attrs.surface_area_mm2 as number | undefined) != null && (
<>
<span className="text-content-muted">Surface</span>
<span>{((mesh_attrs.surface_area_mm2 as number) / 100).toFixed(1)} cm²</span>
</>
)}
{mesh_attrs.suggested_smooth_angle !== undefined && (
<>
<span className="text-content-muted">Sharp angle</span>
<span>{mesh_attrs.suggested_smooth_angle as number}°</span>
</>
)}
</div>
</div>
)
})()}
{isPrivileged && (
<>
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
<div {...getRootProps()} className="cursor-pointer">
<input {...getInputProps()} />
<button className="btn-secondary text-xs w-full justify-start" disabled={cadUploadMut.isPending}>
<Upload size={12} />
{cadUploadMut.isPending ? 'Uploading…' : 'Re-upload STEP'}
</button>
</div>
<button
className="btn-secondary text-xs w-full justify-start"
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
title="Re-render thumbnail with current materials"
>
<RotateCcw size={12} />
{regenerateMut.isPending ? 'Queuing…' : 'Regen thumbnail'}
</button>
<button
className="btn-secondary text-xs w-full justify-start"
onClick={() => reprocessMut.mutate()}
disabled={reprocessMut.isPending}
title="Re-parse STEP + regenerate thumbnail and glTF"
>
<RotateCcw size={12} />
{reprocessMut.isPending ? 'Queuing…' : 'Re-process STEP'}
</button>
</div>
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
<button
className="btn-secondary text-xs w-full justify-start"
onClick={() =>
generateGltfGeometry(product.cad_file_id!)
.then(() => toast.info('Geometry GLB export queued'))
.catch(() => toast.error('Failed to queue GLB export'))
}
title="Export geometry GLB directly from STEP via OCC (no Blender)"
>
<Download size={12} />
Generate Geometry GLB
</button>
<button
className="btn-secondary text-xs w-full justify-start"
onClick={() =>
generateGltfProduction(product.cad_file_id!)
.then(() => toast.info('Production GLB export queued'))
.catch(() => toast.error('Failed to queue production GLB export'))
}
title="Export production GLB with PBR materials via Blender"
>
<Download size={12} />
Generate Production GLB
</button>
</div>
</>
)}
</div>
</div>
{/* Material assignments */}
{isPrivileged && (
+24 -91
View File
@@ -57,12 +57,12 @@ else:
if len(argv) < 4:
print("Usage: blender --background --python blender_render.py -- "
"<stl_path> <output_path> <width> <height> [engine] [samples] [smooth_angle] [cycles_device] [transparent_bg]")
"<glb_path> <output_path> <width> <height> [engine] [samples] [smooth_angle] [cycles_device] [transparent_bg]")
sys.exit(1)
import json as _json
stl_path = argv[0]
glb_path = argv[0]
output_path = argv[1]
width = int(argv[2])
height = int(argv[3])
@@ -173,23 +173,7 @@ def _assign_palette_material(part_obj, index):
import re as _re
def _scale_mm_to_m(parts):
"""Scale imported STL objects from mm to Blender metres (×0.001).
STEP/STL coordinates are in mm; Blender's default unit is metres.
Without scaling a 50 mm part appears as 50 m inside Blender — way too large
relative to any template environment designed in metric units.
"""
if not parts:
return
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.scale = (0.001, 0.001, 0.001)
p.location *= 0.001
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
print(f"[blender_render] scaled {len(parts)} parts mm→m (×0.001)")
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
def _apply_rotation(parts, rx, ry, rz):
@@ -276,85 +260,37 @@ def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=Non
bpy.ops.object.mode_set(mode='OBJECT')
def _import_stl(stl_file):
"""Import STL into Blender, using per-part STLs if available.
def _import_glb(glb_file):
"""Import OCC-generated GLB into Blender.
Checks for {stl_stem}_parts/manifest.json next to the STL file.
- Per-part mode: imports each part STL, names Blender object after STEP part name.
- Fallback: imports combined STL and splits by loose geometry.
OCC exports one mesh object per STEP part, already in metres.
Blender's native GLTF importer preserves part names.
Returns list of Blender mesh objects, centred at origin.
Returns list of Blender mesh objects, centred at world origin.
"""
stl_dir = os.path.dirname(stl_file)
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
manifest_path = os.path.join(parts_dir, "manifest.json")
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.import_scene.gltf(filepath=glb_file)
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
parts = []
if os.path.isfile(manifest_path):
# ── Per-part mode ────────────────────────────────────────────────
try:
with open(manifest_path, "r") as f:
manifest = _json.loads(f.read())
part_entries = manifest.get("parts", [])
except Exception as e:
print(f"[blender_render] WARNING: failed to read manifest: {e}")
part_entries = []
if part_entries:
for entry in part_entries:
part_file = os.path.join(parts_dir, entry["file"])
part_name = entry["name"]
if not os.path.isfile(part_file):
print(f"[blender_render] WARNING: part STL missing: {part_file}")
continue
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.wm.stl_import(filepath=part_file)
imported = bpy.context.selected_objects
if imported:
obj = imported[0]
obj.name = part_name
if obj.data:
obj.data.name = part_name
parts.append(obj)
if parts:
print(f"[blender_render] imported {len(parts)} named parts from per-part STLs")
# ── Fallback: combined STL + separate by loose ───────────────────────
if not parts:
bpy.ops.wm.stl_import(filepath=stl_file)
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
if obj is None:
print(f"ERROR: No objects imported from {stl_file}")
sys.exit(1)
print(f"ERROR: No mesh objects imported from {glb_file}")
sys.exit(1)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
obj.location = (0.0, 0.0, 0.0)
print(f"[blender_render] imported {len(parts)} part(s) from GLB: "
f"{[p.name for p in parts[:5]]}")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.separate(type='LOOSE')
bpy.ops.object.mode_set(mode='OBJECT')
parts = list(bpy.context.selected_objects)
print(f"[blender_render] fallback: separated into {len(parts)} part(s)")
return parts
# ── Centre per-part imports at origin (combined bbox) ────────────────
# Centre combined bbox at world origin
all_corners = []
for p in parts:
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
if all_corners:
mins = Vector((min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
maxs = Vector((max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
for p in parts:
p.location -= center
@@ -453,10 +389,8 @@ if use_template:
# Find or create target collection
target_col = _ensure_collection(target_collection)
# Import and split STL
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
# Import OCC GLB (already in metres, one object per STEP part)
parts = _import_glb(glb_path)
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
@@ -538,9 +472,8 @@ else:
# ── MODE A: Factory settings (original behavior) ─────────────────────────
needs_auto_camera = True
bpy.ops.wm.read_factory_settings(use_empty=True)
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
# Import OCC GLB (already in metres, one object per STEP part)
parts = _import_glb(glb_path)
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
+7 -12
View File
@@ -1,14 +1,14 @@
"""Blender headless script: export a STEP-derived scene as a production .blend.
"""Blender headless script: export a geometry GLB as a production .blend.
Usage:
blender --background --python export_blend.py -- \\
--stl_path /path/to/file.stl \\
--glb_path /path/to/geometry.glb \\
--output_path /path/to/output.blend \\
[--asset_library_blend /path/to/library.blend] \\
[--material_map '{"SrcMat": "LibMat"}']
The script:
1. Imports the STL file (with mm→m scale).
1. Imports the geometry GLB (already in metres, Y-up).
2. Optionally applies asset library materials from a .blend.
3. Packs all external data.
4. Saves a copy as the output .blend.
@@ -28,7 +28,8 @@ def parse_args() -> argparse.Namespace:
sys.exit(1)
rest = argv[argv.index("--") + 1:]
parser = argparse.ArgumentParser()
parser.add_argument("--stl_path", required=True)
parser.add_argument("--glb_path", required=True,
help="Geometry GLB from export_step_to_gltf.py (already in metres)")
parser.add_argument("--output_path", required=True)
parser.add_argument("--asset_library_blend", default=None)
parser.add_argument("--material_map", default="{}")
@@ -44,14 +45,8 @@ def main() -> None:
# Clean scene
bpy.ops.wm.read_factory_settings(use_empty=True)
# Import STL
bpy.ops.import_mesh.stl(filepath=args.stl_path)
# Scale mm → m
for obj in bpy.context.selected_objects:
obj.scale = (0.001, 0.001, 0.001)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(scale=True)
# Import geometry GLB (metres, Y-up — no rescaling needed)
bpy.ops.import_scene.gltf(filepath=args.glb_path)
# Apply asset library materials if provided
if args.asset_library_blend and material_map:
+13 -68
View File
@@ -27,78 +27,30 @@ def parse_args() -> argparse.Namespace:
sys.exit(1)
rest = argv[argv.index("--") + 1:]
parser = argparse.ArgumentParser()
parser.add_argument("--stl_path", required=True)
parser.add_argument("--glb_path", required=True,
help="Geometry GLB from export_step_to_gltf.py (already in metres)")
parser.add_argument("--output_path", required=True)
parser.add_argument("--asset_library_blend", default=None)
parser.add_argument("--material_map", default="{}")
parser.add_argument("--sharp_edges_json", default="[]",
help="JSON array of [x, y, z] midpoints (mm) to mark as sharp edges")
return parser.parse_args(rest)
def mark_sharp_edges_by_proximity(midpoints_mm: list, threshold_mm: float = 1.0) -> None:
"""Mark Blender mesh edges as sharp based on proximity to OCC-derived midpoints.
midpoints_mm: list of [x, y, z] in mm (from OCC coordinate space).
After STL import + scale-apply (mm→m), Blender vertices are in meters, so we
convert the edge midpoint back to mm before comparing.
threshold_mm: snap distance in mm (default 1.0 mm).
"""
if not midpoints_mm:
return
import bpy # type: ignore[import]
import math
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
mesh = obj.data
# Blender 4.1+ removed use_auto_smooth — use shade_smooth_by_angle instead
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
try:
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(30))
except Exception:
pass # fallback: stay flat-shaded
mw = obj.matrix_world
for edge in mesh.edges:
v1 = mw @ mesh.vertices[edge.vertices[0]].co
v2 = mw @ mesh.vertices[edge.vertices[1]].co
# Convert Blender meters → mm for comparison
mid_mm = [
(v1.x + v2.x) / 2 * 1000,
(v1.y + v2.y) / 2 * 1000,
(v1.z + v2.z) / 2 * 1000,
]
for hint in midpoints_mm:
dist_sq = sum((a - b) ** 2 for a, b in zip(mid_mm, hint))
if dist_sq < threshold_mm ** 2:
edge.use_edge_sharp = True
break
def main() -> None:
args = parse_args()
material_map: dict = json.loads(args.material_map)
sharp_edge_midpoints: list = json.loads(args.sharp_edges_json)
import bpy # type: ignore[import]
import math as _math
# Clean scene
bpy.ops.wm.read_factory_settings(use_empty=True)
# Import STL (bpy.ops.wm.stl_import is the Blender 4.0+ API)
bpy.ops.wm.stl_import(filepath=args.stl_path)
# Scale mm → m
for obj in bpy.context.selected_objects:
obj.scale = (0.001, 0.001, 0.001)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(scale=True)
# Import geometry GLB from export_step_to_gltf.py (already in metres, Y-up)
bpy.ops.import_scene.gltf(filepath=args.glb_path)
print(f"Imported geometry GLB: {args.glb_path} "
f"({len([o for o in bpy.data.objects if o.type == 'MESH'])} mesh objects)")
# Apply smooth shading with 30° angle threshold (Blender 4.1+ API)
import math as _math
for obj in bpy.data.objects:
if obj.type == "MESH":
bpy.context.view_layer.objects.active = obj
@@ -108,37 +60,30 @@ def main() -> None:
except Exception:
pass
# Mark sharp edges for better UV seams
if sharp_edge_midpoints:
mark_sharp_edges_by_proximity(sharp_edge_midpoints)
print(f"Marked sharp edges from {len(sharp_edge_midpoints)} hint points")
# Apply asset library materials if provided.
# link=False (append) is required for GLB export: the GLTF exporter can only
# traverse local (appended) Principled BSDF node trees to extract PBR values.
# Linked materials are external references whose node data is not accessible.
# link=False (append) is required: the GLTF exporter can only traverse
# local (appended) Principled BSDF node trees to extract PBR values.
if args.asset_library_blend and material_map:
import os
sys.path.insert(0, os.path.dirname(__file__))
from asset_library import apply_asset_library_materials
apply_asset_library_materials(args.asset_library_blend, material_map, link=False)
# Export GLB with full PBR material data
# Note: export_colors was removed in Blender 4.x — do not pass it.
# Export production GLB with full PBR material data
try:
bpy.ops.export_scene.gltf(
filepath=args.output_path,
export_format="GLB",
export_apply=True,
use_selection=False,
export_materials="EXPORT", # export all materials (Principled BSDF → glTF PBR)
export_image_format="AUTO", # embed textures (base color, normal, roughness maps)
export_materials="EXPORT",
export_image_format="AUTO",
)
except Exception as exc:
print(f"GLB export failed: {exc}", file=sys.stderr)
sys.exit(1)
print(f"GLB exported to {args.output_path}")
print(f"Production GLB exported to {args.output_path}")
try:
@@ -0,0 +1,232 @@
"""OCC-native STEP → GLB export script.
Reads a STEP file via OCP/XCAF (preserving part names and embedded colors),
tessellates with BRepMesh, optionally applies per-part hex colors, and writes
a binary GLB in meters (Y-up, glTF convention).
No Blender required. Uses the same OCP bindings that cadquery ships with.
Usage:
python3 export_step_to_gltf.py \
--step_path /path/to/file.stp \
--output_path /path/to/output.glb \
[--linear_deflection 0.1] \
[--angular_deflection 0.5] \
[--color_map '{"RingInner": "#4C9BE8", "RingOuter": "#E85B4C"}']
Exit 0 on success, exit 1 on failure.
"""
from __future__ import annotations
import argparse
import json
import sys
import traceback
from pathlib import Path
PALETTE_HEX = [
"#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8",
"#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--step_path", required=True)
parser.add_argument("--output_path", required=True)
parser.add_argument(
"--linear_deflection", type=float, default=0.1,
help="OCC linear deflection for tessellation (mm). Smaller = finer mesh. Default 0.1",
)
parser.add_argument(
"--angular_deflection", type=float, default=0.5,
help="OCC angular deflection (radians). Default 0.5",
)
parser.add_argument(
"--color_map", default="{}",
help='JSON dict mapping part name → hex color, e.g. \'{"Ring": "#4C9BE8"}\'',
)
return parser.parse_args()
def _hex_to_occ_color(hex_color: str):
"""Convert '#RRGGBB' → Quantity_Color (linear float)."""
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
h = hex_color.lstrip("#")
if len(h) < 6:
return Quantity_Color(0.7, 0.7, 0.7, Quantity_TOC_RGB)
r = int(h[0:2], 16) / 255.0
g = int(h[2:4], 16) / 255.0
b = int(h[4:6], 16) / 255.0
return Quantity_Color(r, g, b, Quantity_TOC_RGB)
def _apply_color_map(shape_tool, color_tool, free_labels, color_map: dict) -> None:
"""Apply hex colors from color_map to matching shapes by name (case-insensitive substring)."""
from OCP.TDF import TDF_LabelSequence
from OCP.TDataStd import TDataStd_Name
from OCP.XCAFDoc import XCAFDoc_ShapeTool
# XCAFDoc_ColorType: XCAFDoc_ColorGen=0, XCAFDoc_ColorSurf=1, XCAFDoc_ColorCurv=2
try:
from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF
except ImportError:
COLOR_SURF = 1 # integer fallback
def _visit(label) -> None:
name_attr = TDataStd_Name()
name = ""
if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr):
name = name_attr.Get().ToExtString()
if name:
for part_name, hex_color in color_map.items():
if part_name.lower() in name.lower() or name.lower() in part_name.lower():
color_tool.SetColor(label, _hex_to_occ_color(hex_color), COLOR_SURF)
break
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(label, components)
for i in range(1, components.Length() + 1):
_visit(components.Value(i))
for i in range(1, free_labels.Length() + 1):
_visit(free_labels.Value(i))
def _apply_palette_colors(shape_tool, color_tool, free_labels) -> None:
"""Assign palette colors to leaf shapes when no color_map is provided."""
from OCP.TDF import TDF_LabelSequence
from OCP.XCAFDoc import XCAFDoc_ShapeTool
try:
from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF
except ImportError:
COLOR_SURF = 1
leaves: list = []
def _collect(label) -> None:
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(label, components)
if components.Length() == 0:
leaves.append(label)
else:
for i in range(1, components.Length() + 1):
_collect(components.Value(i))
for i in range(1, free_labels.Length() + 1):
_collect(free_labels.Value(i))
for idx, label in enumerate(leaves):
occ_color = _hex_to_occ_color(PALETTE_HEX[idx % len(PALETTE_HEX)])
color_tool.SetColor(label, occ_color, COLOR_SURF)
def main() -> None:
args = parse_args()
color_map: dict = json.loads(args.color_map)
from OCP.STEPCAFControl import STEPCAFControl_Reader
from OCP.TDocStd import TDocStd_Document
from OCP.XCAFApp import XCAFApp_Application
from OCP.XCAFDoc import XCAFDoc_DocumentTool
from OCP.TCollection import TCollection_ExtendedString, TCollection_AsciiString
from OCP.TDF import TDF_LabelSequence
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.IFSelect import IFSelect_RetDone
from OCP.Message import Message_ProgressRange
# --- Init XDE document ---
app = XCAFApp_Application.GetApplication_s()
doc = TDocStd_Document(TCollection_ExtendedString("MDTV-CAF"))
app.InitDocument(doc)
# --- Read STEP into XDE (preserves part names + embedded colors) ---
reader = STEPCAFControl_Reader()
reader.SetNameMode(True)
reader.SetColorMode(True)
reader.SetLayerMode(True)
status = reader.ReadFile(args.step_path)
if status != IFSelect_RetDone:
print(f"ERROR: STEPCAFControl_Reader failed (status={status})", file=sys.stderr)
sys.exit(1)
reader.Transfer(doc)
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
# --- Tessellate all free shapes ---
free_labels = TDF_LabelSequence()
shape_tool.GetFreeShapes(free_labels)
print(f"Found {free_labels.Length()} root shape(s), tessellating "
f"(linear={args.linear_deflection}mm, angular={args.angular_deflection}rad) …")
for i in range(1, free_labels.Length() + 1):
shape = shape_tool.GetShape_s(free_labels.Value(i))
if not shape.IsNull():
BRepMesh_IncrementalMesh(
shape,
args.linear_deflection,
False, # isRelative
args.angular_deflection,
True, # isInParallel
)
# --- Apply colors ---
if color_map:
_apply_color_map(shape_tool, color_tool, free_labels, color_map)
print(f"Applied color_map ({len(color_map)} entries)")
else:
_apply_palette_colors(shape_tool, color_tool, free_labels)
print("Applied palette colors (no color_map provided)")
# --- Scale shapes mm → m before GLB export ---
# RWMesh_CoordinateSystemConverter is not wrapped in OCP Python bindings.
# Pre-scale each free shape by 0.001 (mm → m) using BRepBuilderAPI_Transform.
from OCP.gp import gp_Trsf
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
trsf = gp_Trsf()
trsf.SetScaleFactor(0.001)
for i in range(1, free_labels.Length() + 1):
label = free_labels.Value(i)
orig_shape = shape_tool.GetShape_s(label)
if not orig_shape.IsNull():
scaled = BRepBuilderAPI_Transform(orig_shape, trsf, True).Shape()
shape_tool.SetShape(label, scaled)
print("Shapes scaled mm → m")
# --- Export GLB via RWGltf_CafWriter ---
from OCP.RWGltf import RWGltf_CafWriter
writer = RWGltf_CafWriter(TCollection_AsciiString(args.output_path), True) # True = binary GLB
# Z-up → Y-up rotation is applied by RWGltf_CafWriter by default (OCC 7.6+).
# Perform export
try:
from OCP.TColStd import TColStd_IndexedDataMapOfStringString
metadata = TColStd_IndexedDataMapOfStringString()
ok = writer.Perform(doc, metadata, Message_ProgressRange())
except TypeError:
# Older API without metadata dict
ok = writer.Perform(doc, Message_ProgressRange())
out = Path(args.output_path)
if not ok or not out.exists() or out.stat().st_size == 0:
print(f"ERROR: RWGltf_CafWriter.Perform returned ok={ok}, file exists={out.exists()}",
file=sys.stderr)
sys.exit(1)
print(f"GLB exported: {out.name} ({out.stat().st_size // 1024} KB)")
try:
main()
except SystemExit:
raise
except Exception:
traceback.print_exc()
sys.exit(1)
+22 -92
View File
@@ -87,23 +87,7 @@ def _apply_smooth(part_obj, angle_deg):
import re as _re
def _scale_mm_to_m(parts):
"""Scale imported STL objects from mm to Blender metres (×0.001).
STEP/STL coordinates are in mm; Blender's default unit is metres.
Without scaling a 50 mm part appears as 50 m inside Blender — way too large
relative to any template environment designed in metric units.
"""
if not parts:
return
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.scale = (0.001, 0.001, 0.001)
p.location *= 0.001
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
print(f"[still_render] scaled {len(parts)} parts mm→m (×0.001)")
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
def _apply_rotation(parts, rx, ry, rz):
@@ -209,85 +193,35 @@ def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=Non
bpy.ops.object.mode_set(mode='OBJECT')
def _import_stl(stl_file):
"""Import STL into Blender, using per-part STLs if available.
def _import_glb(glb_file):
"""Import OCC-generated GLB into Blender.
Checks for {stl_stem}_parts/manifest.json next to the STL file.
- Per-part mode: imports each part STL, names Blender object after STEP part name.
- Fallback: imports combined STL and splits by loose geometry.
Returns list of Blender mesh objects, centred at origin.
OCC exports one mesh object per STEP part, already in metres.
Returns list of Blender mesh objects, centred at world origin.
"""
stl_dir = os.path.dirname(stl_file)
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
manifest_path = os.path.join(parts_dir, "manifest.json")
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.import_scene.gltf(filepath=glb_file)
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
parts = []
if os.path.isfile(manifest_path):
# ── Per-part mode ────────────────────────────────────────────────
try:
with open(manifest_path, "r") as f:
manifest = json.loads(f.read())
part_entries = manifest.get("parts", [])
except Exception as e:
print(f"[still_render] WARNING: failed to read manifest: {e}")
part_entries = []
if part_entries:
for entry in part_entries:
part_file = os.path.join(parts_dir, entry["file"])
part_name = entry["name"]
if not os.path.isfile(part_file):
print(f"[still_render] WARNING: part STL missing: {part_file}")
continue
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.wm.stl_import(filepath=part_file)
imported = bpy.context.selected_objects
if imported:
obj = imported[0]
obj.name = part_name
if obj.data:
obj.data.name = part_name
parts.append(obj)
if parts:
print(f"[still_render] imported {len(parts)} named parts from per-part STLs")
# ── Fallback: combined STL + separate by loose ───────────────────────
if not parts:
bpy.ops.wm.stl_import(filepath=stl_file)
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
if obj is None:
print(f"ERROR: No objects imported from {stl_file}")
sys.exit(1)
print(f"ERROR: No mesh objects imported from {glb_file}")
sys.exit(1)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
obj.location = (0.0, 0.0, 0.0)
print(f"[still_render] imported {len(parts)} part(s) from GLB: "
f"{[p.name for p in parts[:5]]}")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.separate(type='LOOSE')
bpy.ops.object.mode_set(mode='OBJECT')
parts = list(bpy.context.selected_objects)
print(f"[still_render] fallback: separated into {len(parts)} part(s)")
return parts
# ── Centre per-part imports at origin (combined bbox) ────────────────
# Centre combined bbox at world origin
all_corners = []
for p in parts:
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
if all_corners:
mins = Vector((min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
maxs = Vector((max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
for p in parts:
p.location -= center
@@ -376,7 +310,7 @@ def main():
argv = sys.argv
args = argv[argv.index("--") + 1:]
stl_path = args[0]
glb_path = args[0]
output_path = args[1]
width = int(args[2])
height = int(args[3])
@@ -460,10 +394,8 @@ def main():
# Find or create target collection
target_col = _ensure_collection(target_collection)
# Import and split STL
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
# Import OCC GLB (already in metres, one object per STEP part)
parts = _import_glb(glb_path)
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Apply OCC topology-based shading overrides
@@ -562,9 +494,7 @@ def main():
needs_auto_camera = True
bpy.ops.wm.read_factory_settings(use_empty=True)
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
parts = _import_glb(glb_path)
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Apply OCC topology-based shading overrides
@@ -839,7 +769,7 @@ def main():
draw.rectangle([0, 0, W - 1, bar_h - 1], fill=(0, 137, 61, 255))
# Model name strip at bottom
model_name = os.path.splitext(os.path.basename(stl_path))[0]
model_name = os.path.splitext(os.path.basename(glb_path))[0]
label_h = max(20, H // 20)
img.alpha_composite(
Image.new("RGBA", (W, label_h), (30, 30, 30, 180)),
+21 -91
View File
@@ -138,23 +138,7 @@ def _set_fcurves_linear(action):
kp.interpolation = 'LINEAR'
def _scale_mm_to_m(parts):
"""Scale imported STL objects from mm to Blender metres (×0.001).
STEP/STL coordinates are in mm; Blender's default unit is metres.
Without scaling a 50 mm part appears as 50 m inside Blender — way too large
relative to any template environment designed in metric units.
"""
if not parts:
return
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.scale = (0.001, 0.001, 0.001)
p.location *= 0.001
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
print(f"[turntable_render] scaled {len(parts)} parts mm→m (×0.001)")
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
@@ -179,85 +163,35 @@ def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
obj.data.auto_smooth_angle = threshold_rad
def _import_stl(stl_file):
"""Import STL into Blender, using per-part STLs if available.
def _import_glb(glb_file):
"""Import OCC-generated GLB into Blender.
Checks for {stl_stem}_parts/manifest.json next to the STL file.
- Per-part mode: imports each part STL, names Blender object after STEP part name.
- Fallback: imports combined STL and splits by loose geometry.
Returns list of Blender mesh objects, centred at origin.
OCC exports one mesh object per STEP part, already in metres.
Returns list of Blender mesh objects, centred at world origin.
"""
stl_dir = os.path.dirname(stl_file)
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
manifest_path = os.path.join(parts_dir, "manifest.json")
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.import_scene.gltf(filepath=glb_file)
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
parts = []
if os.path.isfile(manifest_path):
# ── Per-part mode ────────────────────────────────────────────────
try:
with open(manifest_path, "r") as f:
manifest = json.loads(f.read())
part_entries = manifest.get("parts", [])
except Exception as e:
print(f"[turntable_render] WARNING: failed to read manifest: {e}")
part_entries = []
if part_entries:
for entry in part_entries:
part_file = os.path.join(parts_dir, entry["file"])
part_name = entry["name"]
if not os.path.isfile(part_file):
print(f"[turntable_render] WARNING: part STL missing: {part_file}")
continue
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.wm.stl_import(filepath=part_file)
imported = bpy.context.selected_objects
if imported:
obj = imported[0]
obj.name = part_name
if obj.data:
obj.data.name = part_name
parts.append(obj)
if parts:
print(f"[turntable_render] imported {len(parts)} named parts from per-part STLs")
# ── Fallback: combined STL + separate by loose ───────────────────────
if not parts:
bpy.ops.wm.stl_import(filepath=stl_file)
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
if obj is None:
print(f"ERROR: No objects imported from {stl_file}")
sys.exit(1)
print(f"ERROR: No mesh objects imported from {glb_file}")
sys.exit(1)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
obj.location = (0.0, 0.0, 0.0)
print(f"[turntable_render] imported {len(parts)} part(s) from GLB: "
f"{[p.name for p in parts[:5]]}")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.separate(type='LOOSE')
bpy.ops.object.mode_set(mode='OBJECT')
parts = list(bpy.context.selected_objects)
print(f"[turntable_render] fallback: separated into {len(parts)} part(s)")
return parts
# ── Centre per-part imports at origin (combined bbox) ────────────────
# Centre combined bbox at world origin
all_corners = []
for p in parts:
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
if all_corners:
mins = Vector((min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
maxs = Vector((max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
for p in parts:
p.location -= center
@@ -347,7 +281,7 @@ def main():
# Everything after "--" is our args
args = argv[argv.index("--") + 1:]
stl_path = args[0]
glb_path = args[0]
frames_dir = args[1]
frame_count = int(args[2])
degrees = int(args[3])
@@ -427,10 +361,8 @@ def main():
# Find or create target collection
target_col = _ensure_collection(target_collection)
# Import and split STL
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
# Import OCC GLB (already in metres, one object per STEP part)
parts = _import_glb(glb_path)
# Apply render position rotation before material/camera setup
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Apply OCC topology-based shading overrides
@@ -508,9 +440,7 @@ def main():
needs_auto_camera = True
bpy.ops.wm.read_factory_settings(use_empty=True)
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
parts = _import_glb(glb_path)
# Apply render position rotation before material/camera setup
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Apply OCC topology-based shading overrides