chore(agents): add three new specialist agents
/usd-export — USD authoring specialist - Full pxr API reference (Stage, Mesh, Primvars, MaterialBinding, Override layers) - XCAF traversal pattern for partKey generation - Coordinate system (OCC Z-up mm → USD Y-up mm, no scaling needed) - FlattenLayerStack delivery pattern - Test commands + common errors table - Failure protocol linking to /plan /render-pipeline — Render script chain specialist - Full script chain (export_step_to_gltf → export_gltf → still_render → turntable_render) - GPU activation 6-step order (critical, open_mainfile resets compute_device_type) - AF suffix stripping for material matching - GLB extras round-trip documentation - GCPnts_UniformAbscissa requirement (Polygon3D_s returns None in XCAF) - Parameter propagation rule (admin.py → export_glb.py → script → Blender) - Direct subprocess test commands /tenant-audit — RLS correctness specialist - HTTP + Celery layer audit steps - Live cross-tenant leak test pattern (SET LOCAL + count comparison) - Fix patterns for middleware and task-side set_tenant_context - Role permission matrix - Tables requiring RLS policies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
|||||||
|
# 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: thumbnail_rendering]
|
||||||
|
│
|
||||||
|
├─ 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: thumbnail_rendering]
|
||||||
|
├─ 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`."
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Tenant Audit Agent
|
||||||
|
|
||||||
|
You are a specialist for tenant isolation correctness in the Schaeffler Automat project. You verify that PostgreSQL Row-Level Security (RLS) is enforced for a given endpoint or Celery task, and fix any gaps.
|
||||||
|
|
||||||
|
## Current Isolation State (ROADMAP Priority 8)
|
||||||
|
|
||||||
|
| Layer | Status |
|
||||||
|
|---|---|
|
||||||
|
| HTTP requests | `TenantContextMiddleware` sets `SET LOCAL app.current_tenant_id` from JWT |
|
||||||
|
| JWT claims | `tenant_id` embedded by `create_access_token()` |
|
||||||
|
| Role hierarchy | `global_admin` > `tenant_admin` > `project_manager` > `client` |
|
||||||
|
| Celery tasks | **Gap**: `set_tenant_context()` not yet called in all tasks — this is the primary open work |
|
||||||
|
| RLS policies | Defined in migration 036 for core tables |
|
||||||
|
|
||||||
|
## How RLS Works in This Project
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- RLS policy example (from migration 036):
|
||||||
|
CREATE POLICY tenant_isolation ON products
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
||||||
|
|
||||||
|
-- Set context for a session:
|
||||||
|
SET LOCAL app.current_tenant_id = 'uuid-here';
|
||||||
|
-- After this, all queries on `products` only see rows for that tenant.
|
||||||
|
|
||||||
|
-- global_admin bypasses RLS:
|
||||||
|
SET LOCAL app.current_tenant_id = 'global';
|
||||||
|
-- Or: SET LOCAL app.bypass_rls = 'true';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audit: HTTP Endpoint
|
||||||
|
|
||||||
|
For a given FastAPI endpoint, verify the full chain:
|
||||||
|
|
||||||
|
### Step 1: Check middleware registration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "TenantContextMiddleware" backend/app/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `app.add_middleware(TenantContextMiddleware)` present.
|
||||||
|
|
||||||
|
### Step 2: Check JWT contains tenant_id
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "tenant_id" backend/app/utils/auth.py | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `tenant_id` in `create_access_token()` payload.
|
||||||
|
|
||||||
|
### Step 3: Verify RLS policy exists for the table
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec postgres psql -U schaeffler -d schaeffler -c "
|
||||||
|
SELECT schemaname, tablename, policyname, cmd, qual
|
||||||
|
FROM pg_policies
|
||||||
|
WHERE tablename = '[tablename]';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Live cross-tenant leak test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get tenant A and tenant B IDs
|
||||||
|
docker compose exec postgres psql -U schaeffler -d schaeffler -c "
|
||||||
|
SELECT id, name FROM tenants LIMIT 5;"
|
||||||
|
|
||||||
|
# Count rows visible to tenant A
|
||||||
|
docker compose exec postgres psql -U schaeffler -d schaeffler -c "
|
||||||
|
SET LOCAL app.current_tenant_id = '[tenant_a_id]';
|
||||||
|
SELECT COUNT(*) FROM [tablename];"
|
||||||
|
|
||||||
|
# Count total rows (bypass RLS)
|
||||||
|
docker compose exec postgres psql -U schaeffler -d schaeffler -c "
|
||||||
|
SELECT COUNT(*) FROM [tablename];"
|
||||||
|
|
||||||
|
# If visible count == total count when tenant B has data → RLS not enforced
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: API-level verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login as tenant A user, call endpoint, check count
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:8888/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"[tenant_a_user]","password":"[password]"}' | jq -r '.access_token')
|
||||||
|
|
||||||
|
curl -s http://localhost:8888/api/products \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq 'length'
|
||||||
|
|
||||||
|
# Should return count of tenant A's products, not total across all tenants
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audit: Celery Task
|
||||||
|
|
||||||
|
For a given task, verify tenant context propagation:
|
||||||
|
|
||||||
|
### Step 1: Check task for set_tenant_context call
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "set_tenant_context\|tenant_id" backend/app/domains/pipeline/tasks/[task_file].py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `set_tenant_context(db, tenant_id)` called near the start of the task function.
|
||||||
|
|
||||||
|
### Step 2: Check tenant_id passed to task
|
||||||
|
|
||||||
|
Trace back from the Celery `.delay()` call to verify `tenant_id` is in the arguments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "\.delay\|\.apply_async" backend/app/domains/pipeline/tasks/*.py | grep "[task_name]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Add tenant context to a task (fix pattern)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In the Celery task function:
|
||||||
|
@celery_app.task(bind=True, queue='thumbnail_rendering')
|
||||||
|
def render_step_thumbnail(self, cad_file_id: str, tenant_id: str | None = None):
|
||||||
|
from app.database import SyncSessionLocal
|
||||||
|
from app.utils.tenant import set_tenant_context
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
if tenant_id:
|
||||||
|
set_tenant_context(db, tenant_id)
|
||||||
|
logger.info(f"[TENANT] context set: tenant_id={tenant_id}")
|
||||||
|
# ... rest of task ...
|
||||||
|
```
|
||||||
|
|
||||||
|
And in the caller:
|
||||||
|
```python
|
||||||
|
render_step_thumbnail.delay(
|
||||||
|
str(cad_file_id),
|
||||||
|
tenant_id=str(current_user.tenant_id) if current_user.tenant_id else None,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix: TenantContextMiddleware (if missing)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/app/core/middleware.py
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from app.utils.auth import decode_token
|
||||||
|
|
||||||
|
class TenantContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
|
||||||
|
if token:
|
||||||
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
request.state.tenant_id = payload.get("tenant_id")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return await call_next(request)
|
||||||
|
```
|
||||||
|
|
||||||
|
The actual DB context (`SET LOCAL`) is set inside the DB dependency via:
|
||||||
|
```python
|
||||||
|
# In database.py get_db():
|
||||||
|
if hasattr(request.state, 'tenant_id') and request.state.tenant_id:
|
||||||
|
await db.execute(text(f"SET LOCAL app.current_tenant_id = '{request.state.tenant_id}'"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tables with RLS Policies (from migration 036)
|
||||||
|
|
||||||
|
Verify these tables have policies:
|
||||||
|
```bash
|
||||||
|
docker compose exec postgres psql -U schaeffler -d schaeffler -c "
|
||||||
|
SELECT tablename, COUNT(*) as policies
|
||||||
|
FROM pg_policies
|
||||||
|
GROUP BY tablename
|
||||||
|
ORDER BY tablename;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Key tables that must have RLS: `products`, `cad_files`, `orders`, `order_lines`, `media_assets`, `order_items`.
|
||||||
|
|
||||||
|
## Role Permission Matrix
|
||||||
|
|
||||||
|
| Permission | global_admin | tenant_admin | project_manager | client |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| All tenants data | ✅ bypass RLS | ❌ own tenant only | ❌ | ❌ |
|
||||||
|
| System settings | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| Trigger renders | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| Create/view own orders | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Manage users (all tenants) | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| Manage users (own tenant) | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
|
||||||
|
## Audit Report Format
|
||||||
|
|
||||||
|
```
|
||||||
|
## Tenant Isolation Audit: [endpoint or task name]
|
||||||
|
Date: [today]
|
||||||
|
|
||||||
|
### Result: ✅ Isolated / ⚠️ Partial / ❌ Leaking
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
#### HTTP layer
|
||||||
|
- Middleware: [present/missing]
|
||||||
|
- JWT tenant_id: [present/missing]
|
||||||
|
- RLS policy on table: [present/missing for each table]
|
||||||
|
- Cross-tenant leak test: [pass/fail with counts]
|
||||||
|
|
||||||
|
#### Celery layer (if applicable)
|
||||||
|
- set_tenant_context called: [yes/no]
|
||||||
|
- tenant_id passed in .delay(): [yes/no]
|
||||||
|
|
||||||
|
### Fix Required
|
||||||
|
[Exact code change needed, or "None — fully isolated"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
After completing an audit or fix: "Tenant audit complete. Result: [✅/⚠️/❌]. [Summary of findings and changes]."
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# USD Export Agent
|
||||||
|
|
||||||
|
You are a specialist for the USD pipeline in the Schaeffler Automat project. You implement and debug everything related to `export_step_to_usd.py`, `import_usd.py`, and the `pxr` authoring API.
|
||||||
|
|
||||||
|
## Architecture Decisions (all locked)
|
||||||
|
|
||||||
|
| Decision | Value |
|
||||||
|
|---|---|
|
||||||
|
| USD library | `usd-core` pip → `from pxr import Usd, UsdGeom, Sdf, Vt, Gf, UsdShade, UsdUtils` |
|
||||||
|
| Seam/sharp encoding | Index-space primvars on mesh prim (not world-space coordinates) |
|
||||||
|
| Preview GLB | Co-authored from same tessellation pass (not USD→GLB round-trip) |
|
||||||
|
| Layer strategy | Canonical geometry layer + material override layer, flattened via `UsdUtils.FlattenLayerStack()` for delivery |
|
||||||
|
| Instancing | None in Phase 1 (co-author `FlattenLayerStack` is instancing-safe for later) |
|
||||||
|
|
||||||
|
Full implementation checklist: `docs/plans/0001-step-to-usd-implementation.md`
|
||||||
|
|
||||||
|
## Key pxr API Reference
|
||||||
|
|
||||||
|
### Stage and Layer Setup
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pxr import Usd, UsdGeom, UsdShade, Sdf, Vt, Gf, UsdUtils
|
||||||
|
|
||||||
|
# Create a new stage (writes to disk)
|
||||||
|
stage = Usd.Stage.CreateNew("/path/to/output.usd")
|
||||||
|
|
||||||
|
# Set up coordinate system and units
|
||||||
|
UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.y) # glTF/Blender convention
|
||||||
|
UsdGeom.SetStageMetersPerUnit(stage, 0.001) # STEP is in mm
|
||||||
|
|
||||||
|
# Define root prims
|
||||||
|
root = UsdGeom.Xform.Define(stage, "/Root")
|
||||||
|
assembly = UsdGeom.Xform.Define(stage, "/Root/Assembly")
|
||||||
|
|
||||||
|
stage.Save()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Part Hierarchy + Custom Metadata
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Define an Xform for a part
|
||||||
|
part_xform = UsdGeom.Xform.Define(stage, f"/Root/Assembly/{node_name}/{part_key}")
|
||||||
|
|
||||||
|
# Author custom metadata (schaeffler: namespace)
|
||||||
|
part_prim = part_xform.GetPrim()
|
||||||
|
part_prim.SetCustomDataByKey("schaeffler:partKey", part_key)
|
||||||
|
part_prim.SetCustomDataByKey("schaeffler:sourceName", source_name)
|
||||||
|
part_prim.SetCustomDataByKey("schaeffler:sourceColor", hex_color)
|
||||||
|
part_prim.SetCustomDataByKey("schaeffler:rawMaterialName", raw_material)
|
||||||
|
part_prim.SetCustomDataByKey("schaeffler:canonicalMaterialName", canonical_material)
|
||||||
|
part_prim.SetCustomDataByKey("schaeffler:cadFileId", str(cad_file_id))
|
||||||
|
part_prim.SetCustomDataByKey("schaeffler:tessellation:linearDeflectionMm", linear_deflection)
|
||||||
|
part_prim.SetCustomDataByKey("schaeffler:tessellation:angularDeflectionRad", angular_deflection)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mesh Geometry
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pxr import Vt, Gf
|
||||||
|
|
||||||
|
mesh = UsdGeom.Mesh.Define(stage, f"{part_path}/Mesh")
|
||||||
|
|
||||||
|
# Points (vertices) — Gf.Vec3f array
|
||||||
|
points = Vt.Vec3fArray([Gf.Vec3f(x, y, z) for x, y, z in vertex_list])
|
||||||
|
mesh.CreatePointsAttr(points)
|
||||||
|
|
||||||
|
# Face topology
|
||||||
|
face_vertex_counts = Vt.IntArray([3] * len(triangles)) # all triangles
|
||||||
|
mesh.CreateFaceVertexCountsAttr(face_vertex_counts)
|
||||||
|
|
||||||
|
face_vertex_indices = Vt.IntArray([idx for tri in triangles for idx in tri])
|
||||||
|
mesh.CreateFaceVertexIndicesAttr(face_vertex_indices)
|
||||||
|
|
||||||
|
# Normals (per-vertex or per-face)
|
||||||
|
normals = Vt.Vec3fArray([Gf.Vec3f(nx, ny, nz) for nx, ny, nz in normal_list])
|
||||||
|
mesh.CreateNormalsAttr(normals)
|
||||||
|
mesh.SetNormalsInterpolation(UsdGeom.Tokens.vertex)
|
||||||
|
|
||||||
|
# Display color from OCC
|
||||||
|
color = Vt.Vec3fArray([Gf.Vec3f(r, g, b)])
|
||||||
|
mesh.CreateDisplayColorAttr(color)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Seam and Sharp Edge Primvars (index-space)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pxr import UsdGeom, Vt, Sdf
|
||||||
|
|
||||||
|
primvars_api = UsdGeom.PrimvarsAPI(mesh)
|
||||||
|
|
||||||
|
# Sharp edges: list of (vertex_index_0, vertex_index_1) pairs
|
||||||
|
sharp_pairs = [(vi0, vi1), (vi2, vi3), ...] # local mesh vertex indices
|
||||||
|
sharp_array = Vt.Vec2iArray([Gf.Vec2i(a, b) for a, b in sharp_pairs])
|
||||||
|
pv_sharp = primvars_api.CreatePrimvar(
|
||||||
|
"schaeffler:sharpEdgeVertexPairs",
|
||||||
|
Sdf.ValueTypeNames.Int2Array,
|
||||||
|
UsdGeom.Tokens.constant,
|
||||||
|
)
|
||||||
|
pv_sharp.Set(sharp_array)
|
||||||
|
|
||||||
|
# Seam edges: same structure
|
||||||
|
seam_pairs = [(vi0, vi1), ...]
|
||||||
|
seam_array = Vt.Vec2iArray([Gf.Vec2i(a, b) for a, b in seam_pairs])
|
||||||
|
pv_seam = primvars_api.CreatePrimvar(
|
||||||
|
"schaeffler:seamEdgeVertexPairs",
|
||||||
|
Sdf.ValueTypeNames.Int2Array,
|
||||||
|
UsdGeom.Tokens.constant,
|
||||||
|
)
|
||||||
|
pv_seam.Set(seam_array)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Material Binding
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pxr import UsdShade
|
||||||
|
|
||||||
|
# Define material prim
|
||||||
|
mat_path = f"/Root/Looks/{canonical_material_name}"
|
||||||
|
material = UsdShade.Material.Define(stage, mat_path)
|
||||||
|
|
||||||
|
# Bind material to part mesh
|
||||||
|
binding_api = UsdShade.MaterialBindingAPI(mesh.GetPrim())
|
||||||
|
binding_api.Bind(material)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Override Layer (for material replacements)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Create canonical stage
|
||||||
|
canonical_stage = Usd.Stage.CreateNew("/path/to/canonical.usd")
|
||||||
|
# ... author geometry ...
|
||||||
|
canonical_stage.Save()
|
||||||
|
|
||||||
|
# Create override layer for material assignments
|
||||||
|
override_layer = Sdf.Layer.CreateNew("/path/to/overrides.usd")
|
||||||
|
override_stage = Usd.Stage.Open("/path/to/canonical.usd")
|
||||||
|
override_stage.GetRootLayer().subLayerPaths.append("/path/to/overrides.usd")
|
||||||
|
|
||||||
|
# Author override opinions
|
||||||
|
with Usd.EditContext(override_stage, override_stage.GetRootLayer()):
|
||||||
|
prim = override_stage.GetPrimAtPath("/Root/Assembly/Node/ring_outer")
|
||||||
|
prim.SetCustomDataByKey("schaeffler:canonicalMaterialName", "SCHAEFFLER_010102_Steel-Polished")
|
||||||
|
|
||||||
|
override_stage.Save()
|
||||||
|
|
||||||
|
# Flatten for delivery (preserves instanceable prims)
|
||||||
|
flat_stage = Usd.Stage.CreateNew("/path/to/delivery.usd")
|
||||||
|
UsdUtils.FlattenLayerStack(override_stage, flat_stage.GetRootLayer())
|
||||||
|
flat_stage.Save()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading Primvars in Blender (import_usd.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
# After USD import:
|
||||||
|
for obj in bpy.context.scene.objects:
|
||||||
|
if obj.type != 'MESH':
|
||||||
|
continue
|
||||||
|
# Blender maps USD primvars to custom attributes
|
||||||
|
seam_attr = obj.data.attributes.get("schaeffler:seamEdgeVertexPairs")
|
||||||
|
sharp_attr = obj.data.attributes.get("schaeffler:sharpEdgeVertexPairs")
|
||||||
|
if seam_attr:
|
||||||
|
_mark_seams_from_index_pairs(obj, seam_attr.data)
|
||||||
|
if sharp_attr:
|
||||||
|
_mark_sharp_from_index_pairs(obj, sharp_attr.data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## XCAF Traversal Pattern for Part Keys
|
||||||
|
|
||||||
|
```python
|
||||||
|
from OCP.XCAFDoc import XCAFDoc_ShapeTool, XCAFDoc_DocumentTool
|
||||||
|
from OCP.TDF import TDF_LabelSequence
|
||||||
|
from OCP.TDataStd import TDataStd_Name
|
||||||
|
|
||||||
|
def traverse_xcaf(label, shape_tool, depth=0, path=""):
|
||||||
|
"""Yield (label, part_key, source_name, xcaf_path) for each leaf shape."""
|
||||||
|
name_attr = TDataStd_Name()
|
||||||
|
source_name = ""
|
||||||
|
if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr):
|
||||||
|
source_name = name_attr.Get().ToExtString()
|
||||||
|
|
||||||
|
components = TDF_LabelSequence()
|
||||||
|
XCAFDoc_ShapeTool.GetComponents_s(label, components)
|
||||||
|
|
||||||
|
xcaf_path = f"{path}/{source_name}" if source_name else f"{path}/unnamed_{depth}"
|
||||||
|
|
||||||
|
if components.Length() == 0:
|
||||||
|
# Leaf node
|
||||||
|
part_key = generate_part_key(xcaf_path, source_name)
|
||||||
|
yield label, part_key, source_name, xcaf_path
|
||||||
|
else:
|
||||||
|
for i in range(1, components.Length() + 1):
|
||||||
|
yield from traverse_xcaf(components.Value(i), shape_tool, depth+1, xcaf_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part Key Generation
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re, hashlib
|
||||||
|
|
||||||
|
def generate_part_key(xcaf_path: str, source_name: str, seen: set) -> str:
|
||||||
|
"""Deterministic slug, max 64 chars, unique within assembly."""
|
||||||
|
base = source_name or xcaf_path.split("/")[-1] or "part"
|
||||||
|
# Normalize: lowercase, replace non-alphanumeric with underscore
|
||||||
|
slug = re.sub(r'[^a-z0-9]+', '_', base.lower()).strip('_')
|
||||||
|
slug = slug[:50] or f"part_{hashlib.sha256(xcaf_path.encode()).hexdigest()[:8]}"
|
||||||
|
# Deduplicate
|
||||||
|
key = slug
|
||||||
|
n = 2
|
||||||
|
while key in seen:
|
||||||
|
key = f"{slug}_{n}"
|
||||||
|
n += 1
|
||||||
|
seen.add(key)
|
||||||
|
return key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coordinate System
|
||||||
|
|
||||||
|
OCC STEP files are in **mm, Z-up**. USD stage is set to **mm, Y-up** (matching Blender and glTF convention).
|
||||||
|
|
||||||
|
Transform when writing points:
|
||||||
|
```python
|
||||||
|
# OCC (X, Y, Z) mm Z-up → USD (X, -Z, Y) mm Y-up
|
||||||
|
usd_x = occ_x
|
||||||
|
usd_y = -occ_z
|
||||||
|
usd_z = occ_y
|
||||||
|
```
|
||||||
|
|
||||||
|
The `BRepBuilderAPI_Transform` mm→m scaling done in `export_step_to_gltf.py` is **not needed** here — USD stage metadata carries `metersPerUnit=0.001`, so Blender handles the scale on import.
|
||||||
|
|
||||||
|
## CLI Interface for `export_step_to_usd.py`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 export_step_to_usd.py \
|
||||||
|
--step_path /path/to/file.stp \
|
||||||
|
--output_path /path/to/output.usd \
|
||||||
|
[--linear_deflection 0.03] \
|
||||||
|
[--angular_deflection 0.05] \
|
||||||
|
[--tessellation_engine occ|gmsh] \
|
||||||
|
[--color_map '{"Ring": "#4C9BE8"}'] \
|
||||||
|
[--cad_file_id uuid]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify usd-core is installed
|
||||||
|
docker compose exec render-worker python3 -c "from pxr import Usd; print(Usd.GetVersion())"
|
||||||
|
|
||||||
|
# Run USD exporter
|
||||||
|
docker compose exec render-worker python3 /render-scripts/export_step_to_usd.py \
|
||||||
|
--step_path /app/uploads/[cad_file_id]/[filename].stp \
|
||||||
|
--output_path /tmp/test.usd
|
||||||
|
|
||||||
|
# Inspect output
|
||||||
|
docker compose exec render-worker python3 -c "
|
||||||
|
from pxr import Usd
|
||||||
|
stage = Usd.Stage.Open('/tmp/test.usd')
|
||||||
|
for prim in stage.Traverse():
|
||||||
|
if prim.GetTypeName() == 'Mesh':
|
||||||
|
print(prim.GetPath(), '| partKey:', prim.GetCustomDataByKey('schaeffler:partKey'))
|
||||||
|
"
|
||||||
|
|
||||||
|
# Count parts with partKey
|
||||||
|
docker compose exec render-worker python3 -c "
|
||||||
|
from pxr import Usd
|
||||||
|
stage = Usd.Stage.Open('/tmp/test.usd')
|
||||||
|
parts = [p for p in stage.Traverse() if p.GetCustomDataByKey('schaeffler:partKey')]
|
||||||
|
print(f'{len(parts)} parts with partKey')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Errors and Fixes
|
||||||
|
|
||||||
|
| Error | Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `ModuleNotFoundError: pxr` | usd-core not installed | `docker compose build render-worker` after adding to Dockerfile |
|
||||||
|
| `ValueError: Invalid SdfPath` | Part name contains special chars | Use `generate_part_key()` — strips non-alphanumeric |
|
||||||
|
| `RuntimeError: stage is invalid` | File path doesn't exist | Ensure output directory exists before `Stage.CreateNew()` |
|
||||||
|
| Prim hierarchy too deep | Assembly has > 10 levels | Use relative paths from Assembly root, skip empty intermediate nodes |
|
||||||
|
| Blender doesn't read primvars | Attribute name mismatch | Check USD attribute name matches what Blender's USD importer maps |
|
||||||
|
| Seam pairs reference wrong vertices | Index computed before tessellation | Always compute index-space pairs AFTER BRepMesh, on the final triangulation |
|
||||||
|
|
||||||
|
## Failure Protocol
|
||||||
|
|
||||||
|
If a task fails, add `[BLOCKED]` to the task in `plan.md` with:
|
||||||
|
- exact pxr error message
|
||||||
|
- which XCAF label or mesh caused it
|
||||||
|
- what was attempted
|
||||||
|
|
||||||
|
Then invoke `/plan` for a refined task specification.
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
After implementation: "USD export complete. Test with: `python3 export_step_to_usd.py --step_path [file]`. Please verify with `/review`."
|
||||||
Reference in New Issue
Block a user