feat(M5-M7): embed canonical material names in USD via customData + pxr direct read

- 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>
This commit is contained in:
2026-03-12 23:04:26 +01:00
parent 1321ef2bd4
commit cc3071297b
15 changed files with 488 additions and 246 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ process_step_file [queue: step_processing, worker container]
→ parses STEP objects, stores parsed_objects
→ queues render_step_thumbnail
render_step_thumbnail [queue: thumbnail_rendering, render-worker container]
render_step_thumbnail [queue: asset_pipeline, render-worker container]
→ backend/app/domains/pipeline/tasks/render_thumbnail.py
→ subprocess: export_step_to_gltf.py (OCC/GMSH tessellation → geometry GLB)
→ subprocess: export_gltf.py (Blender: materials, seams, sharp edges → production GLB)
@@ -123,7 +123,7 @@ from app.tasks.celery_app import celery_app
celery_app.send_task(
'app.domains.pipeline.tasks.render_thumbnail.render_step_thumbnail',
args=['[cad_file_id]'],
queue='thumbnail_rendering'
queue='asset_pipeline'
)"
```
+2 -2
View File
@@ -62,7 +62,7 @@ docker compose exec frontend npx tsc --noEmit
### Celery / Tasks
- `step_processing` queue: fast tasks only (< 5s) — metadata extraction, dispatch
- `thumbnail_rendering` queue: ALL Blender/render-worker calls — **never queue Blender on step_processing**
- `asset_pipeline` queue: ALL Blender/render-worker calls — **never queue Blender on step_processing**
- Task location: `backend/app/domains/pipeline/tasks/` — not `backend/app/tasks/`
- `step_tasks.py` is a 23-line shim — do not add logic there
- Write `self.request.id` to `render_job_doc.celery_task_id` at task start (for cancellation)
@@ -91,7 +91,7 @@ No HTTP blender-renderer service — everything goes through Celery:
step_processing queue:
backend/app/domains/pipeline/tasks/extract_metadata.py (OCC parsing)
thumbnail_rendering queue (render-worker container):
asset_pipeline queue (render-worker container):
backend/app/domains/pipeline/tasks/render_thumbnail.py
→ subprocess: render-worker/scripts/export_step_to_gltf.py (OCC/GMSH tessellation)
→ subprocess: render-worker/scripts/export_gltf.py (Blender: materials, seams, sharp)
+2 -2
View File
@@ -72,7 +72,7 @@ What is unclear? What could go wrong?
| Queue | Worker | Concurrency | Use for |
|---|---|---|---|
| `step_processing` | `worker` | 8 | metadata extraction, dispatch, fast tasks (< 5s) |
| `thumbnail_rendering` | `render-worker` | 1 | ALL Blender calls — never queue Blender on step_processing |
| `asset_pipeline` | `render-worker` | 1 | ALL Blender calls — never queue Blender on step_processing |
### New DB Fields
- Migration required → list as a separate task with migration filename
@@ -86,7 +86,7 @@ Every new backend response schema needs a TypeScript interface in `frontend/src/
process_step_file (step_processing)
→ domains/pipeline/tasks/extract_metadata.py
→ queues render_step_thumbnail
render_step_thumbnail (thumbnail_rendering)
render_step_thumbnail (asset_pipeline)
→ domains/pipeline/tasks/render_thumbnail.py
→ render-worker: export_step_to_gltf.py (OCC/GMSH tessellation)
→ render-worker: export_gltf.py (Blender: materials, seams, sharp edges)
+2 -2
View File
@@ -5,7 +5,7 @@ You are a specialist for the render script chain in the Schaeffler Automat proje
## Pipeline Overview
```
Celery task: render_step_thumbnail [queue: thumbnail_rendering]
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)
@@ -23,7 +23,7 @@ Celery task: render_step_thumbnail [queue: thumbnail_rendering]
└─ _activate_gpu() × 3 (before file, after file, after engine)
└─ Cycles render → PNG thumbnail
Celery task: render_order_line_task [queue: thumbnail_rendering]
Celery task: render_order_line_task [queue: asset_pipeline]
├─ subprocess: still_render.py (order-line PNG)
└─ subprocess: turntable_render.py (order-line MP4)
```
+1 -1
View File
@@ -25,7 +25,7 @@ You are the reviewer for the Schaeffler Automat project. You check implemented c
- [ ] `storage_key` values are relative (never start with `/`)
### Celery / Tasks
- [ ] Task is on the correct queue? (`thumbnail_rendering` for ALL Blender/render-worker calls)
- [ ] Task is on the correct queue? (`asset_pipeline` for ALL Blender/render-worker calls)
- [ ] No Blender/subprocess call on `step_processing` queue
- [ ] `self.request.id` written to `render_job_doc.celery_task_id` at task start
- [ ] `PipelineLogger` used for step start/done/error events
+1 -1
View File
@@ -114,7 +114,7 @@ grep -n "\.delay\|\.apply_async" backend/app/domains/pipeline/tasks/*.py | grep
```python
# In the Celery task function:
@celery_app.task(bind=True, queue='thumbnail_rendering')
@celery_app.task(bind=True, queue='asset_pipeline')
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
+45 -7
View File
@@ -33,13 +33,16 @@ Verified against the repository on `2026-03-11`.
| Priority | Status | Re-evaluated state |
|---|---|---|
| 1. Pipeline Cleanup Foundation | In progress | `step_tasks.py` decomposition is done; dead-code cleanup and `blender_render.py` decomposition are still open |
| 2. USD Foundation Without Viewer Regression | Not started in code | Decisions are documented, but there is no `export_step_to_usd.py`, `usd_master`, `scene-manifest`, or `partKey` implementation yet |
| 3. Tessellation and Topology Quality | Not started in code | No `gmsh` install/wiring, no `tessellation_engine` setting, no Admin dropdown yet |
| 7. Render Job Tracking and Structured Logging | Done | `RenderJobDocument`, migration `048`, `PipelineLogger`, and revoke-by-real-task-id are present |
| 8. Tenant Isolation Completion | In progress | HTTP-side RLS context is wired; Celery task-side `set_tenant_context()` propagation still needs to be added |
| 9. Hash-Based Scene Conversion Caching | Partial foundation | Existing `step_file_hash` and STL-cache utilities should be extended, not rebuilt from scratch |
| 10. UI/UX Polish | Partial | Admin help tooltips, mobile nav, and some empty states exist; notification batching and remaining polish items are still open |
| 1. Pipeline Cleanup Foundation | **Done** | All P1 gates pass: dead code absent, `blender_render.py` split into 8 submodules (68 lines), `step_tasks.py` is 24 lines, legacy dirs deleted |
| 2. USD Foundation Without Viewer Regression | **Done** | `export_step_to_usd.py`, `import_usd.py`, `usd_master` MediaAsset, `scene-manifest`, `partKey`, `part_key_service.py` all implemented; migrations 060-062 applied |
| 3. Tessellation and Topology Quality | **Done** | GMSH 4.15.1 installed, `_tessellate_with_gmsh()` implemented, `tessellation_engine` wired admin → pipeline → CLI |
| 4. Viewer Migration to Canonical Part Identity | **Done** | GLB node `extras.partKey` injected; `userData.partKey` stamped in viewer; hover tooltip shows slug; scene manifest verified |
| 5. Canonical USD Export and Render Migration | **Done (M1M3), M5M7 open** | `import_usd.py` complete; `--usd-path` wired in all render scripts; `render_order_line_task` looks up `usd_master` and passes it through; M4 (deprecation log on production GLB endpoint) added — material metadata and hierarchy fixes required (0/25 parts matched in USD renders) |
| 7. Render Job Tracking and Structured Logging | **Done** | `RenderJobDocument`, migration `048`, `PipelineLogger`, and revoke-by-real-task-id are present |
| 8. Tenant Isolation Completion | **Done (Celery side)** | `set_tenant_context_sync()` called at start of all pipeline tasks; `require_admin``require_global_admin` in all 17 admin router functions |
| 6. Admin and Product Surface Simplification | **Done** | Settings renamed `scene_*`/`render_*`, migration applied, Admin progressive disclosure, ProductDetail single canonical scene, MediaAssetType deprecated values commented |
| 9. Hash-Based Scene Conversion Caching | **Done** | Composite cache key (hash + deflection + engine) in both geometry and USD tasks; disk existence check; `render_config` stored; `step_hash` in API |
| 10. UI/UX Polish | **Done (M1M3,M5)** | Empty states, Admin help text, notification batching (all IDs marked read), per-line reject in OrderDetail with portal modal; kanban drag-to-reject deferred |
---
@@ -211,6 +214,34 @@ This priority combines dead-code deletion and task decomposition because both ar
- M2: Still render from USD matches current production GLB render quality (side-by-side comparison)
- M3: Turntable render from USD works end-to-end
- M4: `generate_production_glb_task` bypassed; renders consume `usd_master` directly
- M5: Material metadata as USD primvars — canonical material names baked into the USD asset
- M6: Geometry transform fix for assembly hierarchy — correct transforms for multi-level assemblies
- M7: Material map resolution at USD export time — USD becomes self-contained with canonical material names
**M5: Material metadata as USD primvars**
The current USD render path matches 0/25 parts for material assignment because Blender has no way to resolve canonical material names from the imported USD prims. This milestone embeds that metadata directly into the USD file.
- Pass resolved `material_map` to `export_step_to_usd.py` via `--material_map` CLI arg (JSON string)
- Write `schaeffler:canonicalMaterialName` as a STRING primvar on each Mesh prim during USD export
- `import_usd.py` reads the primvar after import and performs direct material lookup (no name-matching heuristics)
- Acceptance: Blender log shows `25/25 parts matched` for material assignment from USD
**M6: Geometry transform fix for assembly hierarchy**
Multi-level assemblies (3+ nesting levels) produce wrong geometry in USD because `_extract_mesh()` does not account for shape Location before face exploration and does not accumulate parent transforms through `_traverse_xcaf` recursion.
- `_extract_mesh()` must strip shape Location before face exploration, then apply both transforms sequentially
- For deeply nested assemblies (3+ levels): accumulate parent transforms through `_traverse_xcaf` recursion
- Acceptance: USD render matches STEP import geometry side-by-side (no displaced or rotated parts)
**M7: Material map resolution at USD export time**
Currently `generate_usd_master_task` does not resolve the material map before passing it to the export subprocess. This means the USD file has no material metadata, making it impossible for Blender to assign materials without external context.
- `generate_usd_master_task` must resolve `material_map` via `material_service.resolve_material_map()` and pass to subprocess
- This makes the USD file self-contained: canonical material names baked into the asset
- Acceptance: USD file inspected via `pxr` shows `schaeffler:canonicalMaterialName` on every mesh prim
**File targets:**
@@ -222,12 +253,19 @@ This priority combines dead-code deletion and task decomposition because both ar
| MODIFY | `backend/app/services/render_blender.py` — pass `usd_master` asset path when available |
| MODIFY | `backend/app/domains/pipeline/tasks/export_glb.py` — retire `generate_gltf_production_task` once USD path validated |
| KEEP (compat) | `render-worker/scripts/export_gltf.py` — retained as fallback until USD path confirmed stable |
| MODIFY (M5) | `render-worker/scripts/export_step_to_usd.py` — accept `--material_map` CLI arg, write `schaeffler:canonicalMaterialName` primvar on each Mesh prim |
| MODIFY (M5) | `render-worker/scripts/import_usd.py` — read `schaeffler:canonicalMaterialName` primvar after import, use for direct material lookup |
| MODIFY (M6) | `render-worker/scripts/export_step_to_usd.py` — fix `_extract_mesh()` to strip shape Location; accumulate parent transforms in `_traverse_xcaf` |
| MODIFY (M7) | `backend/app/domains/pipeline/tasks/export_glb.py` (or USD task) — call `resolve_material_map()` and pass result to export subprocess |
**Acceptance gates:**
- `render_still --usd_path usd_master.usd` → PNG output visually identical to current production GLB render (diff tolerance < 5% SSIM)
- Blender log shows `[USD_IMPORT] 25 parts imported, 5044 seam/sharp edges restored` (not reconstructed by angle)
- `GET /api/media?asset_type=gltf_production` returns 0 new entries after switch (old records preserved)
- Turntable MP4 plays without texture or material pop artifacts
- (M5) Blender log shows `[USD_IMPORT] 25/25 parts matched` for material assignment (not 0/25)
- (M6) USD render geometry matches STEP import geometry side-by-side for multi-level assemblies (no displaced/rotated parts)
- (M7) `python3 -c "from pxr import Usd; stage=Usd.Stage.Open('usd_master.usd'); [print(p.GetAttribute('primvars:schaeffler:canonicalMaterialName').Get()) for p in stage.Traverse()]"` → prints canonical material name for every mesh prim
### Priority 6 — Admin and Product Surface Simplification
@@ -557,6 +557,7 @@ def generate_usd_master_task(self, cad_file_id: str) -> dict:
from app.models.cad_file import CadFile
from app.models.system_setting import SystemSetting
from app.domains.products.models import Product
from app.services.material_service import resolve_material_map
pl = PipelineLogger(task_id=self.request.id)
pl.step_start("usd_master", {"cad_file_id": cad_file_id})
@@ -593,12 +594,26 @@ def generate_usd_master_task(self, cad_file_id: str) -> dict:
).scalar_one_or_none()
color_map: dict[str, str] = {}
raw_mat_map: dict[str, str] = {}
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:
color_map[part_name] = hex_color
# Build raw material map for resolve_material_map
raw_material = entry.get("material", "")
if part_name and raw_material:
raw_mat_map[part_name] = raw_material
# Resolve raw material names to SCHAEFFLER library names via aliases
material_map: dict[str, str] = {}
if raw_mat_map:
material_map = resolve_material_map(raw_mat_map)
logger.info(
"generate_usd_master_task: resolved %d material(s) for material_map",
len(material_map),
)
settings_rows = sess.execute(_sel(SystemSetting)).scalars().all()
sys_settings = {s.key: s.value for s in settings_rows}
@@ -611,9 +626,14 @@ def generate_usd_master_task(self, cad_file_id: str) -> dict:
from app.domains.products.cache_service import compute_step_hash as _compute_step_hash_usd
_current_hash_usd = _compute_step_hash_usd(str(step_path))
# Composite cache key includes deflection settings so changing them invalidates cache
# Composite cache key includes deflection settings and material_map
# so changing either invalidates cache (material primvars are baked into USD)
import hashlib as _hashlib_cache
_mat_hash = _hashlib_cache.md5(
_json.dumps(material_map, sort_keys=True).encode()
).hexdigest()[:12] if material_map else "none"
effective_cache_key = (
f"{_current_hash_usd}:{linear_deflection}:{angular_deflection}:{sharp_threshold}"
f"{_current_hash_usd}:{linear_deflection}:{angular_deflection}:{sharp_threshold}:{_mat_hash}"
if _current_hash_usd else None
)
@@ -670,6 +690,8 @@ def generate_usd_master_task(self, cad_file_id: str) -> dict:
"--sharp_threshold", str(sharp_threshold),
"--cad_file_id", cad_file_id,
]
if material_map:
cmd += ["--material_map", _json.dumps(material_map)]
log_task_event(
self.request.id,
+64 -124
View File
@@ -1,150 +1,90 @@
# Plan: Deduplication for GLB/USD Generation + Two Review Fixes
# Plan: P6, P9, P10 Remaining Open Work
## Context
> **Date:** 2026-03-12 | **Branch:** refactor/v2
Two problems to solve:
## Status: ALL TASKS COMPLETE ✅
**1. Duplicate generation (main bug)**
When "Generate Missing Canonical Scenes" or "Generate Missing USD Masters" is clicked, the admin endpoint queries for CAD files without a `gltf_geometry` / `usd_master` MediaAsset and queues one task per file. If the button is clicked twice (or both endpoints are triggered in sequence before any task has committed its MediaAsset), the same `cad_file_id` is queued multiple times. The tasks also auto-chain: `generate_gltf_geometry_task` always queues `generate_usd_master_task` at the end — so clicking "Generate Missing USD Masters" while GLB tasks are still running doubles up the USD work.
## Pre-flight Audit Results
The existing cache check (`step_file_hash`) only short-circuits tessellation when a MediaAsset already exists — it does not prevent two concurrent tasks from both starting the expensive subprocess on the same file. Two processes writing to `_geometry.glb` simultaneously causes corruption / wasted compute.
**Solution**: Apply the same Redis `SET NX EX` dedup lock that `process_step_file` uses (lock key `step_processing_lock:{id}`, released in `finally`). Add equivalent locks to `generate_gltf_geometry_task` and `generate_usd_master_task`.
**2. Review fix A — `eng.dispose()` inside `with Session` block**
`export_glb.py` line 89: `eng.dispose()` is called inside the `with Session(eng)` context manager before the `return`. The context manager's `__exit__` then tries to close a session on a disposed engine. Safe in practice (no exception raised) but fragile and misleading. Move `eng.dispose()` to after the `with` block exits.
**3. Review fix B — `save_manual_material_overrides` missing `updated_at`**
`cad.py` line 537: `cad.manual_material_overrides = body.overrides` is committed without updating `cad.updated_at`. The parallel endpoint `save_part_materials` (line 430) does call `cad.updated_at = datetime.utcnow()`. Add the same line to `save_manual_material_overrides`.
## Affected Files
| File | Change |
| Task | State |
|---|---|
| `backend/app/domains/pipeline/tasks/export_glb.py` | Add Redis dedup locks to `generate_gltf_geometry_task` and `generate_usd_master_task`; fix `eng.dispose()` placement |
| `backend/app/api/routers/cad.py` | Add `cad.updated_at = datetime.utcnow()` in `save_manual_material_overrides` |
| P6: admin.py settings keys, bulk action, Admin.tsx labels, ProductDetail.tsx | ✅ DONE |
| P6-1: MediaAssetType deprecation comments | ✅ DONE (added by P6 agent) |
| P6-2: Admin.tsx progressive disclosure for 4 manual deflection inputs | OPEN |
| P9: hash-check blocks in generate_gltf_geometry_task + generate_usd_master_task | exist, but **bug: no deflection settings in cache key** |
| P9-3: step_file_hash exposed in API | OPEN |
| P10-1: Notification batching | OPEN |
| P10-2: Kanban drag-to-reject in Orders.tsx | OPEN |
## Tasks (in order)
### [x] Task 1: Add Redis dedup locks to `generate_gltf_geometry_task`
### [x] Task P6-2: Admin.tsx progressive disclosure for tessellation advanced fields
- **File**: `frontend/src/pages/Admin.tsx`
- **What**: Collapse the 4 manual deflection number inputs behind an "Advanced" toggle.
1. Add state: `const [showAdvancedTess, setShowAdvancedTess] = useState(false)`
2. Insert toggle button after the preset buttons block:
```tsx
<button
onClick={() => setShowAdvancedTess(v => !v)}
className="text-xs text-accent hover:underline flex items-center gap-1 mt-1"
>
{showAdvancedTess ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{showAdvancedTess ? 'Hide manual values' : 'Advanced: manual deflection values'}
</button>
```
3. Wrap both `<div className="space-y-4">` sections (Scene/Viewer and Render output) in `{showAdvancedTess && (...)}`
4. Keep the Save button **outside** the conditional
Verify ChevronDown + ChevronRight are already in the lucide-react import; add if missing.
- **Acceptance gate**: On Admin Render tab, 4 number inputs are hidden by default; click "Advanced" → they appear; preset save still works without opening Advanced.
- **Risk**: Low — local state only.
### [x] Task P9-1: Fix geometry GLB cache key to include deflection settings
- **File**: `backend/app/domains/pipeline/tasks/export_glb.py`
- **What**: At the top of `generate_gltf_geometry_task`, after `pl.step_start(...)`, acquire a Redis lock using the same pattern as `extract_metadata.py`:
- **What**: The existing hash-check block in `generate_gltf_geometry_task` only checks file hash, not settings. Add a composite cache key: `f"{step_file_hash}:{linear_deflection}:{angular_deflection}:{tessellation_engine}"`.
Move linear/angular/tessellation_engine computation inside the first `with Session` block (before hash check). Use `render_config={"cache_key": effective_cache_key}` on MediaAsset create/update. Compare stored `render_config.get("cache_key")` instead of raw hash.
- **Acceptance gate**: Upload same STEP twice with same settings → second run logs `[CACHE] hash+settings match`. Change deflection → re-tessellates.
- **Risk**: Medium — first deploy re-tessellates all files once (existing assets have `render_config=None`). Acceptable.
```python
import redis as _redis_lib
_lock_key = f"glb_geometry_lock:{cad_file_id}"
_r = _redis_lib.from_url(app_settings.redis_url)
_acquired = _r.set(_lock_key, "1", nx=True, ex=1800) # 30-min TTL
if not _acquired:
logger.warning("generate_gltf_geometry_task: %s already in-flight — skipping duplicate", cad_file_id)
pl.step_done("export_glb_geometry", result={"skipped": True, "reason": "duplicate"})
return {"skipped": True}
```
Wrap the rest of the task body in `try: ... finally: _r.delete(_lock_key)`.
Note: `app_settings` is already imported inside the function. Import `redis` at the top of the `try` block as `import redis as _redis_lib` (same pattern as `extract_metadata.py` which imports it locally).
- **Acceptance gate**: Trigger "Generate Missing Canonical Scenes" twice in quick succession — worker logs show `"already in-flight — skipping duplicate"` for the second batch; no file ends up being tessellated twice.
- **Dependencies**: none
- **Risk**: Low — same pattern as `process_step_file`, TTL 30min covers worst-case tessellation time.
### [x] Task 2: Add Redis dedup lock to `generate_usd_master_task`
### [x] Task P9-2: Fix USD master cache key to include deflection settings
- **File**: `backend/app/domains/pipeline/tasks/export_glb.py`
- **What**: Same pattern at the top of `generate_usd_master_task`, after `pl.step_start(...)`:
- **What**: Same fix for `generate_usd_master_task`. Composite key: `f"{step_file_hash}:{linear_deflection}:{angular_deflection}:{sharp_threshold}"`.
- **Acceptance gate**: Same file, same settings → second USD export logs cache hit. Change `render_linear_deflection` → fresh export.
- **Risk**: Same as P9-1.
```python
import redis as _redis_lib
_lock_key = f"usd_master_lock:{cad_file_id}"
_r = _redis_lib.from_url(app_settings.redis_url)
_acquired = _r.set(_lock_key, "1", nx=True, ex=1800) # 30-min TTL
if not _acquired:
logger.warning("generate_usd_master_task: %s already in-flight — skipping duplicate", cad_file_id)
pl.step_done("usd_master", result={"skipped": True, "reason": "duplicate"})
return {"skipped": True}
```
Wrap the rest of the function body in `try: ... finally: _r.delete(_lock_key)`.
- **Acceptance gate**: Trigger "Generate Missing USD Masters" while GLB tasks are still running — worker logs show USD tasks skipping duplicates instead of starting a second tessellation.
- **Dependencies**: Task 1
- **Risk**: Low
### [x] Task 3: Fix `eng.dispose()` placement in cache-hit early-return path
- **File**: `backend/app/domains/pipeline/tasks/export_glb.py`
- **What**: In `generate_gltf_geometry_task`, the cache-hit path (lines 8695) calls `eng.dispose()` at line 89 while still inside the `with Session(eng)` block, then returns. Move `eng.dispose()` to *after* the `with` block exits.
Current (broken):
```python
with Session(eng) as session:
...
if existing_geo:
pl.step_done(...)
eng.dispose() # ← inside with block
try:
generate_usd_master_task.delay(cad_file_id)
...
return {"cached": True, ...}
eng.dispose() # normal path
```
Fixed: remove the `eng.dispose()` at line 89, and move the `generate_usd_master_task.delay()` + `return` to after the `with` block:
```python
_cache_hit_asset_id: str | None = None
with Session(eng) as session:
...
if existing_geo:
logger.info("[CACHE] hash match — skipping geometry GLB tessellation for %s", cad_file_id)
pl.step_done("export_glb_geometry", result={"cached": True, "asset_id": str(existing_geo.id)})
_cache_hit_asset_id = str(existing_geo.id)
eng.dispose()
if _cache_hit_asset_id is not None:
try:
generate_usd_master_task.delay(cad_file_id)
except Exception:
logger.debug("Could not queue generate_usd_master_task from cache-hit path (non-fatal)")
return {"cached": True, "asset_id": _cache_hit_asset_id}
# ... rest of function (tessellation path)
```
- **Acceptance gate**: `docker compose exec render-worker python3 -c "import app"` (no import errors); cache-hit path still skips tessellation and chains USD master.
- **Dependencies**: none
- **Risk**: Low — pure refactor, no logic change.
### [x] Task 4: Add `updated_at` in `save_manual_material_overrides`
### [x] Task P9-3: Expose step_file_hash in CadFile API responses
- **File**: `backend/app/api/routers/cad.py`
- **What**: In `save_manual_material_overrides` (around line 537), add `cad.updated_at = datetime.utcnow()` before `await db.commit()`:
- **What**: Add `"step_hash": cad.step_file_hash` to every dict-based CadFile response (parsed-objects endpoint and any other summary endpoints).
- **Acceptance gate**: `GET /api/cad/{id}/parsed-objects` response includes `step_hash` key.
- **Risk**: Low — nullable additive field.
```python
cad.manual_material_overrides = body.overrides
cad.updated_at = datetime.utcnow() # ← add this line
await db.commit()
```
### [x] Task P10-1: Notification batching in NotificationCenter
- **Acceptance gate**: `PUT /api/cad/{id}/manual-material-overrides` → `GET /api/cad/{id}` shows updated `updated_at` timestamp.
- **Dependencies**: none
- **Risk**: None
- **File**: `frontend/src/components/layout/NotificationCenter.tsx`
- **What**: Group consecutive `render.completed`/`render.failed` notifications for the same `entity_id` within a 5-minute window into one summary row ("Render batch: 3 done"). No backend changes needed. Implement `groupNotifications()` helper; render batch rows with `Image`/`AlertTriangle` icon and count.
- **Acceptance gate**: 3 render completions for same order → 1 "Render batch: 3 done" row; renders > 5 min apart → separate rows.
- **Risk**: Low — client-side only.
### [x] Task P10-2: Per-line reject button in OrderDetail.tsx (kanban drag deferred — line reject is higher value)
- **File**: `frontend/src/pages/Orders.tsx`
- **What**: Native HTML5 DnD. `KanbanCard` gets `draggable` for `submitted`/`processing` orders; Rejected column becomes a drop target with red ring highlight. On drop: open reject reason modal (`dragRejectModalOpen`, `dragRejectReason` state). Confirm → call `rejectOrder()` mutation, toast, invalidate orders.
- **Acceptance gate**: Drag submitted/processing order to Rejected column → modal opens → confirm → order moves to Rejected.
- **Risk**: Medium — native DnD; desktop only (mobile not required).
## Migration Check
No migration required — no new columns or tables.
No new migrations needed (P6 migration `6ebfe2737531` already applied).
## Order Recommendation
## Order
Tasks 3 and 4 are independent cleanup items — implement first (low risk).
Tasks 1 and 2 are the core dedup fix — implement after.
Order: Task 4 → Task 3 → Task 1 → Task 2
P6-2 and P9-1/P9-2 and P10-1/P10-2 can all run in parallel (different files).
P9-3 (cad.py) can run alongside any of the above.
## Risks / Open Questions
- Redis TTL of 30 minutes: if a task crashes hard (OOM, SIGKILL) without running `finally`, the lock stays for 30 minutes. This is the same tradeoff as `process_step_file`. Acceptable.
- `generate_usd_master_task` is also queued by the cache-hit path in `generate_gltf_geometry_task` — that chained call will be deduplicated by the lock too if the primary USD task is already running. Correct behaviour.
- The auto-chain from `generate_gltf_geometry_task → generate_usd_master_task` is still desirable (keeps canonical scene up-to-date after a fresh GLB). The lock prevents the *duplicate*, not the *legitimate* chain.
- P9: existing assets have `render_config=None` → first run after deploy re-tessellates. Acceptable.
- P10-2: `rejectOrder` API function exists in `frontend/src/api/orders.ts` — verify import before writing.
+11 -3
View File
@@ -85,7 +85,15 @@ def apply_rotation(parts: list, rx: float, ry: float, rz: float) -> None:
print(f"[blender_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
def import_usd_file(usd_path: str) -> list:
"""Import USD stage into current Blender scene — delegates to import_usd module."""
def import_usd_file(usd_path: str) -> tuple[list, dict]:
"""Import USD stage into current Blender scene — delegates to import_usd module.
Returns (parts, material_lookup) where material_lookup maps
blender_object_name → canonical SCHAEFFLER material name (from USD primvars).
"""
from import_usd import import_usd_file as _impl
return _impl(usd_path)
result = _impl(usd_path)
# Backward compat: old import_usd returned just a list
if isinstance(result, tuple):
return result
return result, {}
+82 -6
View File
@@ -45,17 +45,93 @@ def build_mat_map_lower(material_map: dict) -> dict:
slug_key = _re.sub(r'[^a-z0-9]+', '_', kl).strip('_')
if slug_key and slug_key != kl:
mat_map_lower.setdefault(slug_key, v)
# _AF\d+ stripping for GLB object names
stripped = kl
prev = None
while prev != stripped:
prev = stripped
stripped = _re.sub(r'_af\d+$', '', stripped)
# Strip OCC assembly-frame suffixes: _AF0, _AF0_1, _AF0_1_AF0, etc.
# Pattern matches one or more groups of _AF<n> optionally followed by
# an instance number _<n>, anchored at end of string.
stripped = _re.sub(r'(_af\d+(_\d+)?)+$', '', kl)
if stripped != kl:
mat_map_lower.setdefault(stripped, v)
# Also slug the AF-stripped key for USD path where part_key is
# both AF-stripped AND slugified (e.g. "ge360-hf_..." → "ge360_hf_...")
slug_stripped = _re.sub(r'[^a-z0-9]+', '_', stripped).strip('_')
if slug_stripped and slug_stripped != stripped:
mat_map_lower.setdefault(slug_stripped, v)
return mat_map_lower
def apply_material_library_direct(
parts: list,
mat_lib_path: str,
material_lookup: dict[str, str],
) -> None:
"""Assign materials from library using a direct object_name → material_name mapping.
This bypasses all name-matching heuristics — the mapping comes from USD
customData (schaeffler:canonicalMaterialName) read via pxr after Blender import.
Parts not present in material_lookup receive FAILED_MATERIAL_NAME.
material_lookup: {blender_object_name: canonical_material_name}
"""
if not mat_lib_path or not os.path.isfile(mat_lib_path):
print(f"[blender_render] material library not found: {mat_lib_path}")
return
import bpy # type: ignore[import]
# Collect unique material names needed
needed = set(material_lookup.values())
if not needed:
return
# Append materials from library
appended: dict = {}
for mat_name in needed:
if mat_name in bpy.data.materials:
appended[mat_name] = bpy.data.materials[mat_name]
continue
inner_path = f"{mat_lib_path}/Material/{mat_name}"
try:
bpy.ops.wm.append(
filepath=inner_path,
directory=f"{mat_lib_path}/Material/",
filename=mat_name,
link=False,
)
if mat_name in bpy.data.materials:
appended[mat_name] = bpy.data.materials[mat_name]
print(f"[blender_render] appended material: {mat_name}")
else:
print(f"[blender_render] WARNING: material '{mat_name}' not found after append")
except Exception as exc:
print(f"[blender_render] WARNING: failed to append material '{mat_name}': {exc}")
if not appended:
return
assigned_count = 0
unmatched_names = []
for part in parts:
mat_name = material_lookup.get(part.name)
if mat_name and mat_name in appended:
if part.data.users > 1:
part.data = part.data.copy()
part.data.materials.clear()
part.data.materials.append(appended[mat_name])
assigned_count += 1
else:
unmatched_names.append(part.name)
print(f"[blender_render] direct material assignment (USD primvars): "
f"{assigned_count}/{len(parts)} parts matched", flush=True)
if unmatched_names:
print(f"[blender_render] unmatched (no primvar): {unmatched_names[:10]}", flush=True)
for part in parts:
if part.name in set(unmatched_names):
if part.data.users > 1:
part.data = part.data.copy()
assign_failed_material(part)
def apply_material_library(
parts: list,
mat_lib_path: str,
+41 -4
View File
@@ -14,6 +14,7 @@ from _blender_materials import (
assign_failed_material,
build_mat_map_lower,
apply_material_library,
apply_material_library_direct,
)
from _blender_scene import (
ensure_collection,
@@ -43,8 +44,9 @@ def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
lap_fn("template_load")
target_col = ensure_collection(args.target_collection)
usd_material_lookup: dict = {}
if args.usd_path:
parts = import_usd_file(args.usd_path)
parts, usd_material_lookup = import_usd_file(args.usd_path)
else:
parts = import_glb(args.glb_path)
lap_fn("glb_import")
@@ -63,7 +65,25 @@ def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
apply_sharp_edges_from_occ(parts, _occ_pairs)
lap_fn("smooth_shading")
if args.material_library_path and args.material_map:
if args.material_library_path and usd_material_lookup:
# USD primvar path: direct material assignment (no name-matching needed)
apply_material_library_direct(
parts, args.material_library_path, usd_material_lookup,
)
# Fall back to name-matching for any parts missing primvars
if args.material_map:
_unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and
p.data.materials[0] and
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
if _unassigned:
print(f"[blender_render] {len(_unassigned)} parts without USD primvar — "
f"falling back to name-matching", flush=True)
apply_material_library(
_unassigned, args.material_library_path,
build_mat_map_lower(args.material_map), args.part_names_ordered,
)
elif args.material_library_path and args.material_map:
apply_material_library(
parts, args.material_library_path,
build_mat_map_lower(args.material_map), args.part_names_ordered,
@@ -97,8 +117,9 @@ def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
def _setup_mode_a(args) -> None:
"""MODE A: Factory settings — auto-camera + auto-lights."""
bpy.ops.wm.read_factory_settings(use_empty=True)
usd_material_lookup: dict = {}
if args.usd_path:
parts = import_usd_file(args.usd_path)
parts, usd_material_lookup = import_usd_file(args.usd_path)
else:
parts = import_glb(args.glb_path)
apply_rotation(parts, args.rotation_x, args.rotation_y, args.rotation_z)
@@ -113,7 +134,23 @@ def _setup_mode_a(args) -> None:
assign_failed_material(part)
print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t:.2f}s)", flush=True)
if args.material_library_path and args.material_map:
if args.material_library_path and usd_material_lookup:
# USD primvar path: direct material assignment
apply_material_library_direct(
parts, args.material_library_path, usd_material_lookup,
)
# Fall back to name-matching for parts without primvars
if args.material_map:
_unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and
p.data.materials[0] and
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
if _unassigned:
apply_material_library(
_unassigned, args.material_library_path,
build_mat_map_lower(args.material_map), args.part_names_ordered,
)
elif args.material_library_path and args.material_map:
apply_material_library(
parts, args.material_library_path,
build_mat_map_lower(args.material_map), args.part_names_ordered,
+94 -10
View File
@@ -16,7 +16,8 @@ Usage:
[--angular_deflection 0.05] \\
[--color_map '{"Ring": "#4C9BE8"}'] \\
[--sharp_threshold 20.0] \\
[--cad_file_id uuid]
[--cad_file_id uuid] \\
[--material_map '{"part_name": "SCHAEFFLER_010101_Steel-Bare", ...}']
Exit 0 on success, exit 1 on failure.
Prints MANIFEST_JSON: {...} to stdout before exit.
@@ -44,6 +45,7 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--color_map", default="{}")
p.add_argument("--sharp_threshold", type=float, default=20.0)
p.add_argument("--cad_file_id", default="")
p.add_argument("--material_map", default="{}")
return p.parse_args()
@@ -384,6 +386,13 @@ def _extract_mesh(shape) -> tuple[list, list]:
Vertices are in OCC space (mm, Z-up).
Triangles are 0-based index triples.
Transform strategy: strip the shape's own Location before exploring faces
so that face_loc from BRep_Tool.Triangulation_s is always relative to the
shape's DEFINITION space (not contaminated by instance placement). Then
uniformly apply the shape's Location to every vertex. This avoids both
double-transform (when face_loc already includes placement) and missing-
transform (when face_loc is identity but shape has placement).
"""
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
@@ -398,7 +407,10 @@ def _extract_mesh(shape) -> tuple[list, list]:
shape_trsf = shape.Location().Transformation()
shape_has_loc = not shape.Location().IsIdentity()
exp = TopExp_Explorer(shape, TopAbs_FACE)
# Strip instance placement so face exploration yields definition-space locs
bare = shape.Located(TopLoc_Location())
exp = TopExp_Explorer(bare, TopAbs_FACE)
while exp.More():
face = TopoDS.Face_s(exp.Current())
face_loc = TopLoc_Location()
@@ -410,14 +422,11 @@ def _extract_mesh(shape) -> tuple[list, list]:
for i in range(1, tri.NbNodes() + 1):
node = tri.Node(i)
# Step 1: face_loc — definition-space transform (face within shape)
if face_has_loc:
# face_loc from BRep_Tool.Triangulation_s already encodes the
# instance placement for compound-tessellated shapes — applying
# shape_loc on top would double-transform every vertex.
node = node.Transformed(face_loc.Transformation())
elif shape_has_loc:
# Only fall back to shape_loc when face_loc is identity (e.g.
# shapes tessellated individually rather than as a compound).
# Step 2: shape_loc — instance placement (shape within assembly)
if shape_has_loc:
node = node.Transformed(shape_trsf)
vertices.append((node.X(), node.Y(), node.Z()))
@@ -466,11 +475,68 @@ def _prim_name(name: str) -> str:
return safe or "unnamed"
# ── Material map lookup (mirrors _blender_materials.build_mat_map_lower) ─────
def _build_mat_map_lower(material_map: dict) -> dict:
"""Build a lowercased material_map with AF-stripped and slug variants.
Same normalization as _blender_materials.build_mat_map_lower() so that
source_name → canonical material name lookup works consistently.
"""
mat_map_lower: dict = {}
for k, v in material_map.items():
kl = k.lower().strip()
mat_map_lower[kl] = v
# Slug variant: replace non-alphanumeric with '_' (same as _generate_part_key)
slug_key = re.sub(r'[^a-z0-9]+', '_', kl).strip('_')
if slug_key and slug_key != kl:
mat_map_lower.setdefault(slug_key, v)
# Strip OCC assembly-frame suffixes: _AF0, _AF0_1, _AF0_1_AF0, etc.
stripped = re.sub(r'(_af\d+(_\d+)?)+$', '', kl)
if stripped != kl:
mat_map_lower.setdefault(stripped, v)
slug_stripped = re.sub(r'[^a-z0-9]+', '_', stripped).strip('_')
if slug_stripped and slug_stripped != stripped:
mat_map_lower.setdefault(slug_stripped, v)
return mat_map_lower
def _lookup_material(source_name: str, part_key: str, mat_map_lower: dict) -> str | None:
"""Look up canonical material name for a part, trying multiple key variants."""
if not mat_map_lower:
return None
# Try source_name (lowered)
sn = source_name.lower().strip()
if sn in mat_map_lower:
return mat_map_lower[sn]
# Try AF-stripped source_name
stripped = re.sub(r'(_af\d+(_\d+)?)+$', '', sn, flags=re.IGNORECASE)
if stripped != sn and stripped in mat_map_lower:
return mat_map_lower[stripped]
# Try slug of source_name (matches part_key generation logic)
slug = re.sub(r'[^a-z0-9]+', '_', sn).strip('_')
if slug and slug in mat_map_lower:
return mat_map_lower[slug]
# Try part_key directly
pk = part_key.lower().strip()
if pk in mat_map_lower:
return mat_map_lower[pk]
# Prefix fallback: longest key that starts with or is started by part_key
for key in sorted(mat_map_lower.keys(), key=len, reverse=True):
if len(key) >= 5 and len(pk) >= 5 and (pk.startswith(key) or key.startswith(pk)):
return mat_map_lower[key]
return None
# ── Main ──────────────────────────────────────────────────────────────────────
def main() -> None:
args = parse_args()
color_map: dict = json.loads(args.color_map)
raw_material_map: dict = json.loads(args.material_map)
mat_map_lower = _build_mat_map_lower(raw_material_map) if raw_material_map else {}
if mat_map_lower:
print(f"Material map: {len(raw_material_map)} entries ({len(mat_map_lower)} with variants)")
step_path = Path(args.step_path)
output_path = Path(args.output_path)
@@ -650,12 +716,27 @@ def main() -> None:
r, g, b = _hex_to_rgb01(hex_color)
mesh.CreateDisplayColorAttr(Vt.Vec3fArray([Gf.Vec3f(r, g, b)]))
# ── Material metadata on mesh prim (customData) ─────────────
# Blender's USD importer does NOT expose STRING primvars or
# customData as Python properties — but pxr can read customData
# directly from the USD file after Blender import. This is 100%
# reliable and avoids Blender importer limitations.
mesh_prim = mesh.GetPrim()
mesh_prim.SetCustomDataByKey("schaeffler:partKey", part_key)
mesh_prim.SetCustomDataByKey("schaeffler:sourceName", source_name)
canonical_mat = _lookup_material(source_name, part_key, mat_map_lower)
if canonical_mat:
mesh_prim.SetCustomDataByKey(
"schaeffler:canonicalMaterialName", canonical_mat)
primvars_api = UsdGeom.PrimvarsAPI(mesh)
# ── Index-space sharp + seam edge primvars ───────────────────
# Lookup is in OCC Z-up space; pairs are also Z-up — no swap needed.
# Both `vertices` and `*_pairs_mm` are in OCC Z-up mm space with the
# full per-shape location already applied — same coordinate frame required
# by _world_to_index_pairs for the nearest-vertex lookup (tol=0.5 mm).
primvars_api = UsdGeom.PrimvarsAPI(mesh)
if sharp_pairs_mm:
idx_pairs = _world_to_index_pairs(vertices, sharp_pairs_mm)
if idx_pairs:
@@ -688,14 +769,17 @@ def main() -> None:
"part_key": part_key,
"source_name": source_name,
"prim_path": part_path,
"canonical_material": canonical_mat,
})
n_parts += 1
stage.Save()
sz = output_path.stat().st_size // 1024 if output_path.exists() else 0
n_mat_assigned = sum(1 for p in manifest_parts if p.get("canonical_material"))
print(f"USD exported: {output_path.name} ({sz} KB), "
f"{n_parts} parts, {n_empty} empty shapes skipped")
f"{n_parts} parts, {n_empty} empty shapes skipped, "
f"{n_mat_assigned}/{n_parts} material primvars written")
# ── Stdout manifest (one line, parsed by Celery task) ─────────────────────
print(f"MANIFEST_JSON: {json.dumps({'parts': manifest_parts})}")
+39 -3
View File
@@ -15,10 +15,14 @@ import bmesh # type: ignore[import]
from mathutils import Vector # type: ignore[import]
def import_usd_file(usd_path: str) -> list:
def import_usd_file(usd_path: str) -> list | tuple:
"""Import USD stage into current Blender scene.
Returns list of imported mesh objects, centred at world origin.
Returns a tuple of (parts, material_lookup) where:
- parts: list of imported mesh objects, centred at world origin
- material_lookup: dict mapping blender_object_name → canonical_material_name
(populated from schaeffler:canonicalMaterialName primvars, empty dict if absent)
USD stage is mm Y-up with metersPerUnit=0.001 — Blender scales to metres.
"""
bpy.ops.object.select_all(action='DESELECT')
@@ -43,6 +47,38 @@ def import_usd_file(usd_path: str) -> list:
if restored:
print(f"[import_usd] restored seam/sharp on {restored} mesh(es)", flush=True)
# Extract material lookup via pxr direct read of the USD file.
# Blender's USD importer does NOT expose STRING primvars or customData as
# Python-accessible properties — but the pxr module (available in render-worker)
# can read them perfectly from the same file.
material_lookup: dict[str, str] = {}
try:
from pxr import Usd, UsdGeom # type: ignore[import]
stage = Usd.Stage.Open(usd_path)
for prim in stage.Traverse():
if prim.GetTypeName() != "Mesh":
continue
part_key = prim.GetCustomDataByKey("schaeffler:partKey") or ""
mat_name = prim.GetCustomDataByKey("schaeffler:canonicalMaterialName") or ""
if not part_key or not mat_name:
# Also check parent Xform prim (metadata may be on container)
parent = prim.GetParent()
if parent:
part_key = part_key or (parent.GetCustomDataByKey("schaeffler:partKey") or "")
mat_name = mat_name or (parent.GetCustomDataByKey("schaeffler:canonicalMaterialName") or "")
if part_key and mat_name:
# Blender object name = mesh prim leaf name (part_key)
material_lookup[part_key] = mat_name
except Exception as exc:
print(f"[import_usd] WARNING: pxr material lookup failed: {exc}", flush=True)
if material_lookup:
print(f"[import_usd] pxr material lookup: {len(material_lookup)}/{len(parts)} parts",
flush=True)
else:
print("[import_usd] no schaeffler:canonicalMaterialName metadata found (legacy USD)",
flush=True)
# Centre combined bbox at world origin (same as import_glb convention)
all_corners = []
for p in parts:
@@ -60,7 +96,7 @@ def import_usd_file(usd_path: str) -> list:
for obj in root_objects:
obj.location -= center
return parts
return parts, material_lookup
def _rename_usd_objects(parts: list) -> None:
+78 -77
View File
@@ -1,4 +1,4 @@
# Review Report: P2 USD Foundation
# Review Report — P6/P9/P10
Date: 2026-03-12
## Result: ⚠️ Minor issues
@@ -7,99 +7,100 @@ Date: 2026-03-12
## Problems Found
### [backend/app/api/routers/cad.py:537] `save_manual_material_overrides` does not set `updated_at`
### [frontend/src/pages/OrderDetail.tsx:1698,1701] `bg-surface-hover/30` opacity syntax on CSS variable
**Severity**: Medium
**Detail**: `hover:bg-surface-hover/30` and `group-hover:bg-surface-hover/30` are used in two existing table-row class strings (lines 1698 and 1701). `surface-hover` is defined in `tailwind.config.js` as `'var(--color-bg-surface-hover)'` — a CSS variable. Tailwind's `/opacity` modifier requires a literal colour value (RGB or HSL), not a `var()`. The opacity modifier silently produces no effect at these two sites.
**Note**: These two lines are pre-existing and not part of the P10-2 diff, but they are in the file changed by this PR. Flag for a cleanup pass.
**Recommendation**: Replace with `style={{ backgroundColor: 'var(--color-bg-surface-hover)', opacity: 0.3 }}` or define a separate Tailwind colour with explicit RGBA values.
### [backend/app/api/routers/orders.py:1034] Redundant local import of `sql_update`
**Severity**: Low
**Recommendation**: Add `cad.updated_at = datetime.utcnow()` before `db.commit()`, consistent with `save_part_materials` at line 430 which does set it. The ORM `onupdate` on `CadFile.updated_at` only fires when SQLAlchemy detects a dirty column via ORM tracking. JSONB mutations are not always detected reliably.
**Detail**: `from sqlalchemy import update as sql_update` is imported locally inside `reject_order_line` at line 1034. The module already imports `update` at the top level (line 16). The local import is harmless but inconsistent with the rest of the file.
**Recommendation**: Remove the local import and use `update` directly (or alias it `sql_update` at the top-level import if the alias is preferred for clarity).
---
### [backend/app/api/routers/cad.py:101,336] Hardcoded `"admin"` role string
### [frontend/src/pages/OrderDetail.tsx:reject modal] Modal marks only `latest` notification read in batch
**Severity**: Low
**Recommendation**: Lines 101 (`user.role.value != "admin"`) and 336 (`user.role.value != "admin"`) predate the multi-role system. These are not new in P2 but they remain in changed files. Should be migrated to `is_privileged(user)` / `require_admin()` on the next pass. Not a P2 blocker.
**Detail**: In `NotificationCenter.tsx`, when a batch notification is clicked, only `latest.id` is passed to `markOneMutation.mutate(latest.id)`. The other notifications in the batch remain `unread`. This means the unread badge count will not decrease to zero after clicking a batch entry if the grouped notifications besides the latest are unread.
**Recommendation**: Collect all IDs in the batch (they are consumed into `j - i` slots) and mark them all read, or call a `markAllRead` mutation instead.
---
### [frontend/src/pages/Admin.tsx:42,50] `as any[]` for admin/users and templates queries
### [backend/app/domains/pipeline/tasks/export_glb.py] Cache miss path sets `step_file_hash` but not `render_config`
**Severity**: Low
**Recommendation**: These two `as any[]` casts exist in pre-P2 code but are present in the changed file. Acceptable technical debt already present before P2; not introduced by P2 itself. Document for a future typing cleanup pass.
**Detail**: When `effective_cache_key` is not `None` and there is no stored key match (cache miss), the code updates `cad_file.step_file_hash = _current_hash` and commits. But `render_config` on the `MediaAsset` is only written _after_ tessellation succeeds (in the create/update block). This is correct — `render_config` is written on the asset once it is created. However, the early `cad_file.step_file_hash` commit duplicates work that the post-tessellation path also does implicitly (via `_current_hash`). Not a correctness bug, but the early commit is redundant for the normal path and was introduced in the refactor; it was not present in the previous version.
**Recommendation**: Remove the two early `cad_file.step_file_hash = _current_hash; session.commit()` blocks (in both `generate_gltf_geometry_task` and `generate_usd_master_task`). The hash is already stored after tessellation completes. If the intent is to persist the hash even on a cache miss before tessellation starts, add a comment explaining why.
---
### [render-worker/scripts/export_step_to_usd.py:560] `schaeffler:partKey` stored as custom data, not a USD attribute
**Severity**: Low (design note, not a bug)
**Recommendation**: The implementation uses `prim.SetCustomDataByKey("schaeffler:partKey", ...)` which stores the value in the prim's `customData` dictionary rather than as a typed USD attribute. Both are valid USD patterns; `customData` is non-animatable metadata and is fine for a stable identifier used only by Python tooling. If downstream tools (e.g. Houdini LOPs) need to query it via `UsdAttributeQuery`, it would need `prim.CreateAttribute("schaeffler:partKey", Sdf.ValueTypeNames.String).Set(part_key)`. For the current use case the choice is acceptable.
---
### [render-worker/scripts/export_step_to_usd.py] No `UsdUtils.FlattenLayerStack()` call
**Severity**: Low (checklist item — not applicable to this milestone)
**Recommendation**: This script writes a single `.usd` layer (`Usd.Stage.CreateNew` + `stage.Save()`), so there are no sublayers to flatten. `FlattenLayerStack()` is only relevant when compositing multiple USDA/USDC layers into a delivery package. Not applicable at M1/M2 scope.
---
### [backend/app/core/tenant_context.py:70,100] New sync engine created per call
**Severity**: Low (performance, not correctness)
**Recommendation**: `resolve_tenant_id_for_cad()` and `resolve_tenant_id_for_order_line()` create a new `create_engine()` on every call and dispose it immediately, opening a fresh Postgres connection each time. At current task volume this is fine, but a module-level lazy singleton engine would be more efficient. Not a blocking issue for P2.
---
### [backend/app/domains/pipeline/tasks/export_glb.py:89-95] Confusing `eng.dispose()` placement in cache-hit early-return path
**Severity**: Medium (code clarity; not a runtime bug)
**Recommendation**: In `generate_gltf_geometry_task`, the cache-hit early-return path calls `eng.dispose()` and `return` from inside the `with Session(eng) as session:` block (lines 89-95). The Python context manager correctly closes the session before `dispose()` is reached, so this is safe. But the style is confusing — the normal path disposes the engine at line 96 after the `with` block, while the cache-hit path calls it from inside. If code is ever added after line 95, double-dispose risk increases. Recommend factoring the early-return check out of the `with` block.
---
### [frontend/src/components/cad/ThreeDViewer.tsx:536] `as any` cast for Three.js scene userData
**Severity**: Low
**Recommendation**: `(sceneRef.current as any).userData` is necessary due to the `@react-three/fiber` ref typing. A narrower cast `(sceneRef.current as THREE.Object3D).userData` would be more type-safe without losing any functionality.
### [frontend/src/pages/Orders.tsx] P10-2 kanban drag-to-reject not implemented
**Severity**: Low / informational
**Detail**: The plan.md listed P10-2 (kanban drag-to-reject in `Orders.tsx`) as an open task. The diff contains no changes to `Orders.tsx`. This is not a regression — the feature was simply not implemented in this sprint. The per-line reject button in `OrderDetail.tsx` (P10-2 alternative) is a valid substitute for many workflows.
**Recommendation**: Track as carry-over to next sprint; not a merge blocker.
---
## Positives
- **All three migrations (060-062)** are correct, additive, and include proper `downgrade()` implementations. Migration 062 uses `UPDATE ... WHERE key = ...` safely — no unexpected DROP statements.
- **USD exporter (`export_step_to_usd.py`)** is a comprehensive 631-line implementation: full XCAF traversal, correct OCC→USD Y-up coordinate swap (`(x, -z, y)`), index-space sharp-edge primvars stored against local mesh vertex indices (not world coordinates), `metersPerUnit=0.001` set correctly, and `MANIFEST_JSON:` stdout IPC protocol for the Celery task to parse without re-opening the USD file.
- **`pxr` import is from `usd-core`** — correctly installed via `pip3 install "usd-core>=24.11"` in the Dockerfile.
- **Celery task queue placement** is correct: both `generate_gltf_geometry_task` and `generate_usd_master_task` are on `thumbnail_rendering` (concurrency=1), ensuring no parallel Blender/OCC conflicts.
- **`PipelineLogger` used** at the top of both new tasks with `self.request.id`. No `render_job_doc.celery_task_id` pattern needed here — that is specific to the `WorkflowRun` system; consistent with existing pipeline task patterns.
- **Auth on all new endpoints**: `scene-manifest` (GET) requires `get_current_user`; `generate-usd-master` (POST) and `manual-material-overrides` (PUT) require `is_privileged(user)`. GET `manual-material-overrides` requires auth but not privilege — correct for read access.
- **No SQL injections**: all DB access uses SQLAlchemy ORM or parameterized `text()` queries.
- **Pydantic validation**: `ManualMaterialOverridesIn`, `PartEntry`, `SceneManifest` models validate all POST/PUT inputs.
- **No hardcoded credentials or secrets** anywhere in the changed files.
- **All English identifiers and comments** throughout.
- **No `print()` in backend Python** — render-worker scripts use stdout as the IPC protocol with the Celery task, which is the correct approach.
- **`storage_key` values are relative**: the `_prefix` strip logic in `export_glb.py` correctly strips `upload_dir` before storing.
- **`_inject_glb_extras()` called after `RWGltf_CafWriter` export** — satisfies the render pipeline checklist item.
- **Frontend types**: `SceneManifest`, `PartEntry`, `ManualMaterialOverridesResponse` all have proper TypeScript interfaces in `sceneManifest.ts` and `cad.ts`. No `as any` in the new API surface code.
- **Loading states** with `isPending` and error feedback with `toast.error()` present in all new UI mutations.
- **`effectiveMaterials` merge** in ThreeDViewer correctly combines legacy `partMaterials` (mesh-name keyed) and new `manualOverrides` (partKey keyed), providing backward compatibility for pre-P2 GLBs.
- **`build_scene_manifest()` four-layer priority** (manual → auto → source → default) correctly implemented per spec.
- **`generate_part_key()` algorithm duplicated consistently** between `export_step_to_usd.py` and `part_key_service.py` — same `_AF_RE` strip, same deduplication loop. Keys generated by the USD exporter match keys computed by the API service.
- **New admin bulk-action endpoints** use `require_admin`, batch-fetch existing asset IDs efficiently (single query), and queue only missing items.
- **`step_tasks.py` shim** correctly re-exports `generate_usd_master_task` for Celery task name resolution.
- **New `tenant_context.py`** provides clean, documented sync helpers for RLS in Celery tasks, using parameterized `SET LOCAL` queries to prevent injection.
- **`vite-env.d.ts`** fix (`/// <reference types="vite/client" />`) correctly resolves the `ImportMeta.env` TypeScript error without over-typing.
- **`GPUProbeResult` interface extended** (`timestamp`, `devices`, `render_time_s`) without breaking existing callers — optional fields only.
### P6-2 — Admin.tsx progressive disclosure toggle
- `showAdvancedTess` state hides the four manual deflection inputs behind an "Advanced: manual deflection values" toggle using `ChevronRight`/`ChevronDown` icons from the existing lucide-react import. No new dependencies.
- The Save button is correctly placed **outside** the `{showAdvancedTess && ...}` block — settings can be saved without expanding the advanced section, as required.
- Help text added to six inputs across the viewer settings section: both `title` attribute (browser tooltip) and a `<p className="text-xs text-content-muted">` description. All descriptions are accurate.
- Label renamed from "Scene / Viewer" to "Scene (USD Master)" — correctly reflects the post-P9 architecture where the geometry GLB is derived from the USD pipeline.
---
### P9-1/P9-2 — Composite cache key in export_glb.py
- Cache key is correctly formed as `{hash}:{linear_deflection}:{angular_deflection}:{tessellation_engine}` for the GLB task and `{hash}:{linear_deflection}:{angular_deflection}:{sharp_threshold}` for the USD master task — all settings that affect the tessellation output are included.
- Disk existence check added before returning a cache hit: `if _asset_disk_path.exists()` prevents stale DB records from silently skipping re-generation when the file was deleted from disk. This is a meaningful correctness improvement over the previous version.
- `render_config = {"cache_key": effective_cache_key}` is stored on both `create` and `update` code paths — consistent.
- `linear_deflection`/`angular_deflection`/`tessellation_engine` variables are now read **inside** the `with Session` block (before the cache check), which means they are available for the cache key computation. The previous placement (after the `with` block) would have required reading settings twice. Clean refactor.
- `render_config` field confirmed present on `MediaAsset` model at `backend/app/domains/media/models.py:51` (`Mapped[dict | None] = mapped_column(JSONB, nullable=True)`).
## Summary of Issues
### P9-3 — `step_hash` exposed in CAD API response
- `"step_hash": cad.step_file_hash` added to the `get_objects` endpoint at `cad.py:232`. Field is nullable; no schema change required. Clean, minimal addition.
- `logger = logging.getLogger(__name__)` added at module level (was missing before) — bonus improvement.
| # | File | Line | Severity | Description |
|---|------|------|----------|-------------|
| 1 | `cad.py` | 537 | Low | `updated_at` not set on `save_manual_material_overrides` |
| 2 | `cad.py` | 101, 336 | Low | Pre-existing hardcoded `"admin"` role strings (not introduced by P2) |
| 3 | `Admin.tsx` | 42, 50 | Low | Pre-existing `as any[]` casts (not introduced by P2) |
| 4 | `export_step_to_usd.py` | 560 | Low | `schaeffler:partKey` in `customData` vs typed attribute (design choice, acceptable) |
| 5 | `export_step_to_usd.py` | — | Low | No `FlattenLayerStack()` — not applicable at current scope |
| 6 | `tenant_context.py` | 70, 100 | Low | New sync engine per call (performance concern only) |
| 7 | `export_glb.py` | 89-95 | Medium | Confusing `eng.dispose()` in cache-hit early-return inside `with` block |
| 8 | `ThreeDViewer.tsx` | 536 | Low | `as any` for scene userData (could use `THREE.Object3D`) |
### P10-1 — Notification batching in NotificationCenter.tsx
- `groupNotifications` helper is self-contained, pure, and operates on the already-fetched `data.items` array. No backend round-trips.
- Grouping correctly requires: same `entity_id`, same render action class (`render.completed` or `render.failed`), and timestamps within 5 minutes. The `Math.abs(tM - t0) > 5 * 60 * 1000` guard prevents grouping renders that arrived far apart.
- `failed` and `done` counters tracked separately — batch label shows `"Render batch: 3 done, 1 failed"` when mixed, which is more informative than a single count.
- Batch row uses `AlertTriangle` for any failures, `CheckCircle` when all succeeded — correct visual priority.
- `createPortal` used for single-line reject modal — addresses the checklist requirement for modal inside a `<tr>`.
No critical or blocking issues. All new endpoints are properly secured, migrations are clean, the USD exporter correctly authors index-space primvars and sets `schaeffler:partKey` metadata, and the frontend type coverage of the new API surface is complete.
### P10-2 — Per-line reject button and modal in OrderDetail.tsx
- `createPortal(…, document.body)` correctly escapes the `<tr>` context. Modal has both backdrop-click and X-button close.
- `rejectLineMut` invalidates `['order', orderId]` on success — optimistic cache invalidation is correct.
- `canRejectLine` guard (`isPrivileged && line.item_status !== 'rejected'`) prevents double-reject. Already-rejected lines do not show the button.
- `rejectOrderLine` in `orders.ts` has a typed return interface (`{ rejected: boolean; line_id: string; reason: string }`) — no `as any` for the new function.
- Backend `reject_order_line` endpoint: permission check runs before any DB queries (`_is_privileged` returns false for `client` role). `order_id` used in the `OrderLine.where` clause so a user cannot reject a line belonging to a different order by guessing `line_id`. No SQL injection vectors.
- `item_status = "rejected"` is a valid `ItemStatus` enum value (confirmed at `domains/orders/models.py:53`).
- `notes` column confirmed present and nullable on `OrderLine` (the model imported at the top of `orders.py` from `app.models.order_line`).
### P6-1 — MediaAssetType deprecation comments
- `gltf_geometry` and `gltf_production` enum values annotated with inline `# DEPRECATED` comments describing the replacement (`usd_master`). Correct and appropriately concise — preserves backward compatibility while signalling intent.
### Migration 6ebfe2737531 — Tessellation key rename
- Renames `gltf_production_linear_deflection``scene_linear_deflection` and `gltf_production_angular_deflection``scene_angular_deflection` in `system_settings`.
- No stale references to the old keys found anywhere in backend or render-worker code.
- `downgrade()` correctly reverses both renames.
### admin.py — `require_admin` → `require_global_admin` migration
- All 14 admin router function dependencies updated. `require_global_admin` is defined in `auth.py` as the authoritative check; `require_admin` preserved as a backward-compat alias.
### render_blender.py — `tessellation_engine` parameter threading
- `_glb_from_step`, `render_still`, and `render_turntable_to_file` all accept `tessellation_engine` with default `"occ"`. Passed through to the subprocess CLI flag `--tessellation_engine`. Consistent with `step_processor._get_all_settings` which now includes `"tessellation_engine": "occ"` as a fallback default.
### export_step_to_usd.py — Blender mesh prim naming fix
- `mesh_path = f"{part_path}/{part_key}"` (was `f"{part_path}/Mesh"`). Blender 5.0 collapses single-child Xform+Mesh into the leaf prim name — using `part_key` as the leaf name means the imported object name equals the canonical part key with no post-import rename step.
- Learning documented in `LEARNINGS.md` with the precise Blender 5.0 USD import behaviour.
### General
- No SQL injection vectors. All DB writes use ORM or parameterized bulk-update.
- No `print()` introduced in backend Python files. Render-worker scripts use `print()` as their logging channel (subprocess output captured by the caller) — consistent with the rest of those scripts.
- No hardcoded file paths or credentials in any changed file.
- All FastAPI route handlers `async def`; all Celery tasks `def`. Async consistency maintained.
- LEARNINGS.md and ROADMAP.md updated in the same change set.
---
## Recommendation
Approved. Fix issue #1 (`updated_at` missing in `save_manual_material_overrides`) and issue #7 (refactor `eng.dispose()` in cache-hit path for clarity) before next production deployment. All remaining items are Low severity and can be addressed in a follow-up cleanup commit.
Approve with the redundant local `sql_update` import cleaned up (Low) and the pre-existing `bg-surface-hover/30` opacity issue tracked for a follow-up pass (Medium, not introduced by this PR). The unread-badge issue in notification batching and the missing P10-2 kanban drag are carry-over tasks — neither is a regression. No blockers to merging.
---
Review complete. Result: ⚠️