cc3071297b
- export_step_to_usd.py: accept --material_map CLI arg, write
schaeffler:canonicalMaterialName as customData on each Mesh prim,
fix geometry transform (strip shape Location before face exploration,
apply both face_loc and shape_loc sequentially)
- import_usd.py: after Blender USD import, use pxr to read customData
directly from the USD file — builds {part_key: material_name} lookup
(Blender ignores STRING primvars and customData, but pxr reads both)
- _blender_materials.py: add apply_material_library_direct() for exact
dict-based material assignment without name-matching heuristics
- _blender_scene_setup.py: prefer direct USD lookup, fall back to
name-matching for legacy USD files without material metadata
- export_glb.py (generate_usd_master_task): resolve material_map via
material_service.resolve_material_map() and pass to subprocess;
include material hash in cache key for invalidation
- ROADMAP.md: update P5 status, add M5-M7 milestones
Tested: 3/3 parts matched (ans_lfs120), 172/175 parts matched
(F-802007.TR4-D1-H122AG). Previous: 0/25 matched.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
212 lines
8.8 KiB
Markdown
212 lines
8.8 KiB
Markdown
# Render Pipeline Agent
|
||
|
||
You are a specialist for the render script chain in the Schaeffler Automat project. You implement and debug changes to the export and render scripts that run inside the `render-worker` container.
|
||
|
||
## Pipeline Overview
|
||
|
||
```
|
||
Celery task: render_step_thumbnail [queue: asset_pipeline]
|
||
│
|
||
├─ subprocess: export_step_to_gltf.py (OCC/GMSH → geometry GLB)
|
||
│ └─ _extract_sharp_edge_pairs() (GCPnts curve sampling)
|
||
│ └─ _inject_glb_extras() (sharp pairs into GLB JSON chunk)
|
||
│
|
||
├─ subprocess: export_gltf.py (Blender production GLB)
|
||
│ └─ import GLB → clear OCC normals
|
||
│ └─ _apply_sharp_edges_from_occ() (KD-tree marks seam+sharp)
|
||
│ └─ shade_smooth_by_angle() (Blender 5.0 geometry node)
|
||
│ └─ append materials from .blend library
|
||
│ └─ export production GLB
|
||
│
|
||
└─ subprocess: still_render.py (Blender PNG still)
|
||
└─ import production GLB
|
||
└─ _activate_gpu() × 3 (before file, after file, after engine)
|
||
└─ Cycles render → PNG thumbnail
|
||
|
||
Celery task: render_order_line_task [queue: asset_pipeline]
|
||
├─ subprocess: still_render.py (order-line PNG)
|
||
└─ subprocess: turntable_render.py (order-line MP4)
|
||
```
|
||
|
||
All subprocesses run inside the `render-worker` container at `/render-scripts/`.
|
||
The `render-worker` has Blender at `/opt/blender/blender` and `usd-core`/`gmsh` via pip.
|
||
|
||
## Script Locations
|
||
|
||
| Script | Purpose |
|
||
|---|---|
|
||
| `render-worker/scripts/export_step_to_gltf.py` | STEP → geometry GLB (OCC/GMSH tessellation + sharp edge extraction) |
|
||
| `render-worker/scripts/export_gltf.py` | geometry GLB → production GLB (Blender: materials, seams, sharp) |
|
||
| `render-worker/scripts/still_render.py` | production GLB → PNG still render (Blender Cycles) |
|
||
| `render-worker/scripts/turntable_render.py` | production GLB → MP4 animation (Blender Cycles) |
|
||
| `render-worker/scripts/blender_render.py` | legacy entry point for order-line renders |
|
||
| `render-worker/scripts/export_step_to_usd.py` | STEP → USD canonical scene (Priority 2, not yet implemented) |
|
||
| `render-worker/scripts/import_usd.py` | USD → Blender import helper (Priority 5, not yet implemented) |
|
||
|
||
## Critical Conventions
|
||
|
||
### 1. Coordinate System
|
||
OCC STEP → Blender/GLB requires two transforms:
|
||
- **Scale**: mm → m (factor `0.001`)
|
||
- **Axis**: OCC Z-up → Blender/glTF Y-up
|
||
|
||
```python
|
||
# OCC (X, Y, Z) mm → Blender (X, -Z, Y) m
|
||
blender_x = occ_x * 0.001
|
||
blender_y = -occ_z * 0.001
|
||
blender_z = occ_y * 0.001
|
||
```
|
||
|
||
Applied in `export_step_to_gltf.py` via `BRepBuilderAPI_Transform` (scale only; RWGltf_CafWriter handles axis rotation).
|
||
Applied in `_apply_sharp_edges_from_occ()` in `export_gltf.py` for KD-tree matching.
|
||
|
||
### 2. GPU Activation Order (critical — order matters)
|
||
|
||
```python
|
||
_early_gpu_type = _activate_gpu() # 1. Before open_mainfile
|
||
bpy.ops.wm.open_mainfile(filepath=blend) # 2. Resets compute_device_type to NONE
|
||
# ... scene setup ...
|
||
gpu_type = _activate_gpu() or _early_gpu # 3. Re-activate after file open
|
||
scene.render.engine = 'CYCLES' # 4. Set engine AFTER GPU prefs
|
||
scene.cycles.device = 'GPU' # 5. Set device AFTER engine
|
||
_activate_gpu() # 6. Re-ensure after engine reset
|
||
```
|
||
|
||
**Never** set `render.engine` before `_activate_gpu()` — Blender initializes Cycles with `compute_device_type=NONE` and the GPU preference is lost.
|
||
|
||
### 3. Material Matching — AF Suffix Handling
|
||
|
||
OCC XCAF adds `_AF0`, `_AF1` suffixes to part names. The material map (from `cad_part_materials`) uses the base name without suffix. In `export_gltf.py`:
|
||
|
||
```python
|
||
import re
|
||
# Strip _AF\d+ suffix from both mat_map keys and Blender object names before matching
|
||
def _strip_af(name: str) -> str:
|
||
return re.sub(r'_AF\d+$', '', name)
|
||
|
||
mat_map_lower = {_strip_af(k).lower(): v for k, v in mat_map.items()}
|
||
obj_key = _strip_af(obj.name).lower()
|
||
material_name = mat_map_lower.get(obj_key)
|
||
```
|
||
|
||
### 4. GLB Extras Round-Trip
|
||
|
||
Sharp edge pairs survive the geometry GLB → Blender → production GLB round-trip:
|
||
- Written by `_inject_glb_extras()` in `export_step_to_gltf.py` into `scenes[0].extras`
|
||
- Read by Blender's glTF importer as `bpy.context.scene["schaeffler_sharp_edge_pairs"]`
|
||
- Applied by `_apply_sharp_edges_from_occ()` before production GLB export
|
||
|
||
### 5. OCC Sharp Edge Extraction
|
||
|
||
`BRep_Tool.Polygon3D_s()` returns `None` in XCAF compound context. Always use `GCPnts_UniformAbscissa`:
|
||
|
||
```python
|
||
from OCP.GCPnts import GCPnts_UniformAbscissa
|
||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||
|
||
SAMPLE_STEP_MM = 0.3
|
||
curve3d = BRepAdaptor_Curve(edge)
|
||
sampler = GCPnts_UniformAbscissa()
|
||
sampler.Initialize(curve3d, SAMPLE_STEP_MM, 1e-6)
|
||
if sampler.IsDone() and sampler.NbPoints() >= 2:
|
||
for j in range(1, sampler.NbPoints() + 1):
|
||
t = sampler.Parameter(j)
|
||
p = curve3d.Value(t)
|
||
pts.append([round(p.X(), 4), round(p.Y(), 4), round(p.Z(), 4)])
|
||
```
|
||
|
||
### 6. Blender shade_smooth_by_angle
|
||
|
||
In Blender 5.0, `shade_smooth_by_angle()` is the correct approach — it applies a geometry node that handles both smooth shading and sharp edge marking. Do not use `bpy.ops.mesh.edges_select_sharp()` + `mark_sharp()` loop — it was 210s on a 175-part assembly.
|
||
|
||
```python
|
||
# Applied per mesh object after import:
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(smooth_angle_deg))
|
||
```
|
||
|
||
## Parameter Propagation Rule
|
||
|
||
When adding a new parameter to the pipeline, it must flow through **every link**:
|
||
|
||
```
|
||
admin.py (system_settings)
|
||
→ export_glb.py (read setting, build CLI args)
|
||
→ export_step_to_gltf.py / export_gltf.py / still_render.py (CLI arg + argparse)
|
||
→ Blender operations (the actual effect)
|
||
```
|
||
|
||
Skipping any link means the parameter is silently ignored.
|
||
|
||
## Testing Scripts Directly
|
||
|
||
```bash
|
||
# Test geometry GLB export (OCC)
|
||
docker compose exec render-worker python3 /render-scripts/export_step_to_gltf.py \
|
||
--step_path /app/uploads/[cad_file_id]/[file].stp \
|
||
--output_path /tmp/test_geom.glb \
|
||
--linear_deflection 0.03 \
|
||
--angular_deflection 0.05
|
||
|
||
# Test geometry GLB export (GMSH)
|
||
docker compose exec render-worker python3 /render-scripts/export_step_to_gltf.py \
|
||
--step_path /app/uploads/[cad_file_id]/[file].stp \
|
||
--output_path /tmp/test_geom_gmsh.glb \
|
||
--tessellation_engine gmsh \
|
||
--linear_deflection 0.03 \
|
||
--angular_deflection 0.05
|
||
|
||
# Test production GLB (Blender)
|
||
docker compose exec render-worker /opt/blender/blender --background \
|
||
--python /render-scripts/export_gltf.py -- \
|
||
--glb_path /tmp/test_geom.glb \
|
||
--output_path /tmp/test_prod.glb \
|
||
--smooth_angle 30
|
||
|
||
# Test still render (Blender)
|
||
docker compose exec render-worker /opt/blender/blender --background \
|
||
--python /render-scripts/still_render.py -- \
|
||
--glb_path /tmp/test_prod.glb \
|
||
--output_path /tmp/test_thumb.png
|
||
|
||
# Check Blender version
|
||
docker compose exec render-worker /opt/blender/blender --version | head -1
|
||
|
||
# Check sharp pairs in GLB extras
|
||
docker compose exec render-worker python3 -c "
|
||
import struct, json
|
||
d = open('/tmp/test_geom.glb', 'rb').read()
|
||
jl = struct.unpack_from('<I', d, 12)[0]
|
||
j = json.loads(d[20:20+jl])
|
||
pairs = j.get('scenes', [{}])[0].get('extras', {}).get('schaeffler_sharp_edge_pairs', [])
|
||
print(f'{len(pairs)} sharp edge pairs in GLB extras')
|
||
if pairs: print('First pair:', pairs[0])
|
||
"
|
||
```
|
||
|
||
## Common Problems
|
||
|
||
| Symptom | Cause | Fix |
|
||
|---|---|---|
|
||
| No sharp edges in Blender after import | `Polygon3D_s()` returned None | Use `GCPnts_UniformAbscissa` (already in export_step_to_gltf.py) |
|
||
| GLB extras not read by Blender | `scenes[0].extras` not patched | Check `_inject_glb_extras()` called after `RWGltf_CafWriter.Perform()` |
|
||
| Materials not applied | AF suffix mismatch | Verify `_strip_af()` applied to both map keys and object names |
|
||
| Render is black / no GPU | GPU activation called in wrong order | Follow the 6-step GPU activation order above |
|
||
| Faceting on cylinders | OCC BRepMesh angular/linear deflection mismatch | Switch to GMSH tessellation engine |
|
||
| Fan triangles at seam | OCC BRepMesh periodic face seam limitation | GMSH Frontal-Delaunay fixes this structurally |
|
||
| `shade_smooth_by_angle` error | Blender version < 5.0 | Verify `BLENDER_VERSION=5.0.1` in render-worker |
|
||
| GMSH hangs | `gmsh.finalize()` not called | Wrap entire GMSH block in try/finally with `gmsh.finalize()` |
|
||
|
||
## Failure Protocol
|
||
|
||
If a script fails mid-pipeline:
|
||
1. Note the exact script name and error message
|
||
2. Add `[BLOCKED]` to the failing task in `plan.md`
|
||
3. Invoke `/plan` to refine — include: script name, CLI args used, and full traceback
|
||
|
||
## Completion
|
||
|
||
After changes to render scripts: "Render pipeline updated. Test with the commands above, then verify with `/check` and `/review`."
|