diff --git a/ROADMAP.md b/ROADMAP.md index 67c0831..6be828b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,229 +23,357 @@ --- -## πŸ—Ί Open Work β€” Prioritized +## πŸ—Ί Open Work β€” Reconciled Priorities -### Priority 1 β€” Tessellation Quality (Blocking visual quality) +This roadmap now treats the USD refactor as an implementation workstream, not as a blocked strategic idea. -**Goal:** Eliminate fan triangles and faceting on cylindrical surfaces (rings, bearings). -**Plan:** `plan.md` (6 tasks, GMSH Frontal-Delaunay as BRepMesh replacement) +The key architectural clarification from [docs/rfcs/0001-step-to-usd-workflow.md](/home/hartmut/Documents/Copilot/schaefflerautomat/docs/rfcs/0001-step-to-usd-workflow.md#L139) is: -| Task | File | What | -|---|---|---| -| T1 | `render-worker/Dockerfile` | `pip install gmsh>=4.15.0` | -| T2 | `export_step_to_gltf.py` | `--tessellation_engine occ\|gmsh` CLI arg | -| T3 | `export_step_to_gltf.py` | `_tessellate_with_gmsh()`: BREP β†’ GMSH β†’ Poly_Triangulation write-back | -| T4 | `admin.py` | `tessellation_engine` setting in SETTINGS_DEFAULTS + SettingsOut | -| T5 | `export_glb.py` | Read setting, pass `--tessellation_engine` to CLI | -| T6 | `Admin.tsx` | Dropdown: OCC vs. GMSH with description | +- USD becomes the canonical persisted scene asset +- the browser does not need to render USD directly +- the current 3D viewer workflow is preserved through a derived preview asset plus canonical `partKey` -**Risk:** GMSH Surface-Tag ↔ OCC Face mapping must be verified experimentally. -**Status:** Not started. `/implement` to begin. +That removes the old assumption that USD work must wait for a Three.js USD loader. ---- +### Priority 1 β€” Pipeline Cleanup Foundation -### Priority 2 β€” Dead Code Deletion (Quick wins, no risk) +**Goal:** Reduce refactor risk by simplifying the current pipeline before introducing canonical scene concepts. -**Goal:** Remove code that is provably dead and confuses future readers. +This priority combines dead-code deletion and task decomposition because both are prerequisites for a controlled cut-over to USD. -| Item | Location | Why Dead | -|---|---|---| -| Pillow overlay block | `blender_render.py` lines 798–851 | `transparent_bg=True` always, `else:` branch never runs | -| STL workflow | `admin.py`, `cad.py`, multiple tasks | Pipeline is GLB-only; `stl_quality`, `VALID_STL_QUALITIES`, `stl_size_bytes` all orphaned | -| `render_order_line_task` in step_tasks | `step_tasks.py` lines 705–1050 | Duplicates `rendering/tasks.render_order_line_still_task` | -| `blender-renderer/` directory | repo root | Removed from docker-compose.yml already | -| `threejs-renderer/` directory | repo root | Migration 033 removed it from services | -| `flamenco/` directory | repo root | Migration 032 removed Flamenco | -| `renderproblems_tmp/` directory | repo root | Temp debugging screenshots, not code | +**Milestones:** +- M1: Dead code deleted β€” Pillow block, STL settings, orphaned directories +- M2: `step_tasks.py` decomposed into `backend/app/tasks/pipeline/` submodules +- M3: `blender_render.py` decomposed into `render-worker/scripts/_blender_*.py` submodules -**Estimated effort per agent:** 1 session -**Can run in parallel with Priority 3.** +**File targets:** ---- +| Action | Path | +|---|---| +| DELETE lines 798–851 | `render-worker/scripts/blender_render.py` (Pillow overlay, `else:` branch never runs) | +| DELETE | `blender-renderer/` directory | +| DELETE | `threejs-renderer/` directory | +| DELETE | `flamenco/` directory | +| DELETE | `renderproblems_tmp/` | +| DELETE lines 705–1050 | `backend/app/tasks/step_tasks.py` (`render_order_line_task` β€” duplicates `rendering/tasks`) | +| REMOVE settings | `admin.py`: `VALID_STL_QUALITIES`, `stl_quality`, `generate-missing-stls` endpoint | +| REMOVE endpoint | `cad.py`: `POST /cad/{id}/generate-stl/{quality}` | +| CREATE | `backend/app/tasks/pipeline/extract.py` β€” metadata extraction (OCC parsing, < 2s) | +| CREATE | `backend/app/tasks/pipeline/thumbnail.py` β€” Blender thumbnail render | +| CREATE | `backend/app/tasks/pipeline/stills.py` β€” still render task | +| CREATE | `backend/app/tasks/pipeline/turntable.py` β€” turntable render task | +| THIN (< 80 lines) | `backend/app/tasks/step_tasks.py` β€” dispatch only | +| CREATE | `render-worker/scripts/_blender_gpu.py` | +| CREATE | `render-worker/scripts/_blender_import.py` | +| CREATE | `render-worker/scripts/_blender_materials.py` | +| CREATE | `render-worker/scripts/_blender_camera.py` | +| CREATE | `render-worker/scripts/_blender_scene.py` | +| THIN (< 80 lines) | `render-worker/scripts/blender_render.py` β€” entry point only | -### Priority 3 β€” Celery Task Decomposition (Maintainability, parallel-safe) +**Acceptance gates:** +- `grep -r "VALID_STL_QUALITIES\|stl_quality\|from PIL\|from Pillow" backend/ render-worker/` β†’ 0 matches +- `wc -l backend/app/tasks/step_tasks.py` β†’ < 100 lines +- `wc -l render-worker/scripts/blender_render.py` β†’ < 80 lines +- Upload `81113-l_cut.stp`, trigger thumbnail β†’ still renders correctly (no regression) +- `ls blender-renderer/ threejs-renderer/ flamenco/` β†’ all return "No such file" -**Goal:** Split `step_tasks.py` (1,170 lines, 8+ pipeline steps) into focused modules. +### Priority 2 β€” USD Foundation Without Viewer Regression -**Target structure:** -``` -backend/app/tasks/ -β”œβ”€β”€ step_tasks.py β†’ keep only: process_step_file (thin dispatch) -β”œβ”€β”€ pipeline/ -β”‚ β”œβ”€β”€ extract.py β†’ extract_cad_metadata (OCC parsing, 0.1s) -β”‚ β”œβ”€β”€ thumbnail.py β†’ render_step_thumbnail (Blender call) -β”‚ β”œβ”€β”€ stills.py β†’ render_order_line_still_task (Cycles still renders) -β”‚ └── turntable.py β†’ render_order_line_turntable_task -``` +**Goal:** Introduce canonical part identity and the three-layer material assignment model while keeping the current GLB-based browser UX working end-to-end. -Also: split `render-worker/scripts/blender_render.py` (853 lines) into sub-modules: -``` -render-worker/scripts/ -β”œβ”€β”€ blender_render.py β†’ entry point only (~80 lines) -β”œβ”€β”€ _blender_gpu.py β†’ GPU probe + activation -β”œβ”€β”€ _blender_import.py β†’ GLB import, rotation, smooth shading -β”œβ”€β”€ _blender_materials.py β†’ material library application + fallback -β”œβ”€β”€ _blender_camera.py β†’ auto camera from bbox, clip planes -└── _blender_scene.py β†’ scene setup (Mode A vs Mode B) -``` +**Milestones:** +- M1: `export_step_to_usd.py` produces valid USD with part hierarchy and `schaeffler:partKey` on every prim +- M2: `usd_master` MediaAsset type exists in DB and is stored after each export +- M3: `GET /api/cad/{id}/scene-manifest` returns partKey list with effective assignments +- M4: `PUT /api/cad/{id}/part-materials` accepts `{partKey β†’ materialName}` map and persists it +- M5: Browser ThreeDViewer saves material overrides keyed by `partKey`, survives page reload -**Estimated effort:** 2 sessions -**Depends on:** Priority 2 (delete duplicate task first) +**File targets:** ---- +| Action | Path | +|---|---| +| CREATE | `render-worker/scripts/export_step_to_usd.py` β€” XCAF β†’ USD, hierarchy + metadata + partKey | +| ADD enum value | `backend/app/domains/media/models.py` β€” `usd_master` to `MediaAssetType` | +| CREATE migration | `backend/alembic/versions/060_usd_master_asset_type.py` | +| ADD JSONB columns | `backend/app/domains/products/models.py` β€” `CadFile.source_material_assignments`, `resolved_material_assignments`, `manual_material_overrides` | +| CREATE migration | `backend/alembic/versions/061_material_assignment_layers.py` | +| CREATE | `backend/app/services/part_key_service.py` β€” `generate_part_key(xcaf_label)`, `build_scene_manifest()` | +| CREATE | `backend/app/domains/products/schemas.py` β€” `SceneManifest`, `PartEntry` Pydantic models | +| ADD endpoint | `backend/app/api/routers/cad.py` β€” `GET /cad/{id}/scene-manifest` | +| MODIFY endpoint | `backend/app/api/routers/cad.py` β€” `GET/PUT /cad/{id}/part-materials` β†’ partKey-keyed | +| ADD task | `backend/app/domains/pipeline/tasks/export_glb.py` β€” `generate_usd_master_task` (dual-writes beside GLB) | +| CREATE | `frontend/src/api/sceneManifest.ts` β€” `SceneManifest` interface, `fetchSceneManifest()` | -### Priority 4 β€” Render Job Tracking (Correctness bug) +**Open questions to decide before M1:** +- USD authoring library: `pxr` (OpenUSD full Python SDK) vs. plain USDA text templating vs. `usd-core` pip package +- seam/sharp payload encoding: custom primvars (`primvars:schaeffler:seamEdgeVertexPairs`) or a separate JSON sidecar -**Goal:** Fix the broken render job cancellation (synthetic `render-{line_id}` ID never matches real Celery task ID β†’ `revoke()` is a no-op). +**Acceptance gates:** +- `python3 export_step_to_usd.py --step_path 81113-l_cut.stp` β†’ valid `.usd` file, 25 part prims, each has `schaeffler:partKey` attribute +- `GET /api/cad/{id}/scene-manifest` returns `parts[]` array with `part_key`, `source_name`, `effective_material`, `is_unassigned` +- Click part in ThreeDViewer β†’ assign material β†’ reload page β†’ material still assigned (persisted via `partKey`, not mesh name) +- CAD file with mismatched Excel names: UI shows `unmatched_source_rows` count > 0 and unassigned parts highlighted +- No regression: existing click/select/isolate/ghost/hide still works in browser -**What to build:** -- `RenderJobDocument` Pydantic schema stored in `order_lines.render_job_doc` (JSONB) -- Fields: `celery_task_id`, `state` FSM, `steps[]` with timing, `gpu_info` -- Migration: `alembic revision` β€” add `render_job_doc JSONB` to `order_lines` -- Update render tasks to write real `self.request.id` into the document -- Fix `orders.py` cancel endpoint to read `celery_task_id` from the document +**References:** +- RFC: `docs/rfcs/0001-step-to-usd-workflow.md` +- Execution checklist: `docs/plans/0001-step-to-usd-implementation.md` -**Files:** -- New: `backend/app/domains/rendering/job_document.py` -- New migration `06x_render_job_document.py` -- Modified: `render_order_line.py`, `render_thumbnail.py`, `orders.py` +### Priority 3 β€” Tessellation and Topology Quality -**Estimated effort:** 1 session -**Depends on:** Priority 3 (need clean task structure first) +**Goal:** Eliminate fan triangles on cylindrical surfaces (rings, bearings) and produce clean seams for UV unwrap. ---- +**Milestones:** +- M1: GMSH 4.15+ installed in render-worker container +- M2: `export_step_to_gltf.py --tessellation_engine gmsh` produces fan-free GLB +- M3: `tessellation_engine` system setting wired through CLI β†’ Admin UI dropdown -### Priority 5 β€” Structured Logging (Observability) +**File targets:** -**Goal:** Replace inconsistent `logger.info(f"...")` / `emit()` / `log_task_event()` mix with a `PipelineLogger` that writes to Python logging + Redis SSE + DB. +| Action | Path | +|---|---| +| ADD pip install | `render-worker/Dockerfile` β€” `gmsh>=4.15.0` | +| ADD arg + function | `render-worker/scripts/export_step_to_gltf.py` β€” `--tessellation_engine`, `_tessellate_with_gmsh()` | +| ADD setting | `backend/app/api/routers/admin.py` β€” `tessellation_engine` in `SETTINGS_DEFAULTS` + `SettingsOut` | +| MODIFY task | `backend/app/domains/pipeline/tasks/export_glb.py` β€” read setting, pass to CLI | +| ADD UI | `frontend/src/pages/Admin.tsx` β€” dropdown: OCC vs. GMSH | -**What to build:** -- `backend/app/core/pipeline_logger.py` β€” `PipelineLogger` class - - `step_start(step, context)` β†’ `[STEP_NAME] starting β€” context` - - `step_done(step, duration_s, result)` β†’ `[STEP_NAME] done in 0.34s` - - `step_error(step, error, exc)` β†’ `[STEP_NAME] ERROR: ...` -- Optional new table: `pipeline_events(task_id, step_name, level, message, duration_s, context JSONB)` -- Migrate all task files to use PipelineLogger +*(Full task breakdown in `plan.md`)* -**Estimated effort:** 1-2 sessions -**Can start immediately, but has broad blast radius (touches all task files).** +**Acceptance gates:** +- `docker compose exec render-worker python3 -c "import gmsh; print(gmsh.__version__)"` β†’ `4.15.x` +- `python3 export_step_to_gltf.py --step_path 81113-l_cut.stp --tessellation_engine gmsh` β†’ no vertex with valence > 10 at cylinder seam edges (inspect via Blender Mesh Analysis overlay) +- Standard preset output size with GMSH ≀ 2Γ— size with OCC at same deflection +- Sharp edge pairs still extracted and injected into GLB extras after GMSH tessellation ---- +**Note:** Better tessellation directly benefits Priority 2 (USD seam/sharp payload) and Priority UV-unwrap work. -### Priority 6 β€” Tenant Isolation Completion (Security/correctness) +### Priority 4 β€” Viewer Migration to Canonical Part Identity -**Goal:** Make RLS actually work. Currently `build_tenant_db_dep()` yields `db` without calling `set_tenant_context()`, so all tenant isolation is silent no-op. +**Goal:** Move browser interactions from raw GLB mesh-name matching to canonical `partKey` without any UX regression. -**What to build:** -- `TenantContextMiddleware` in `backend/app/core/middleware.py` - - Extracts `tenant_id` from JWT, stores in `request.state` - - After DB session acquired: `SET LOCAL app.current_tenant_id = '...'` -- Celery tasks: add `set_tenant_context(db, tenant_id)` call at start of each task (Celery bypasses HTTP middleware) -- Role hierarchy migration: `admin` β†’ `global_admin`, add `tenant_admin` +**Milestones:** +- M1: Preview GLB derivation embeds `partKey` as mesh `extras.partKey` on every selectable object +- M2: `ThreeDViewer` reads `partKey` from scene manifest + mesh extras on click, no longer uses raw `mesh.name` +- M3: `MaterialPanel` shows `partKey`, source name, assignment provenance; saves overrides by `partKey` +- M4: Unmatched source rows and unassigned parts surfaced in `MaterialPanel` reconciliation section -**Files:** -- New: `backend/app/core/middleware.py` -- Migration: `06x_role_hierarchy.py` -- Modified: `backend/app/main.py` (register middleware), `utils/auth.py`, all routers +**File targets:** -**Estimated effort:** 2 sessions -**Depends on:** Stable auth layer (Priority 3 dead code removal first) +| Action | Path | +|---|---| +| CREATE | `frontend/src/api/sceneManifest.ts` β€” `SceneManifest` + `PartEntry` interfaces | +| MODIFY | `frontend/src/components/cad/ThreeDViewer.tsx` β€” use `partKey` from scene manifest for selection, isolation, ghost | +| MODIFY | `frontend/src/components/cad/MaterialPanel.tsx` β€” show provenance, unmatched/unassigned sections | +| MODIFY | `frontend/src/api/cad.ts` β€” update `PartMaterialsMap` interface to `{ [partKey: string]: string }` | +| MODIFY | `backend/app/api/routers/cad.py` β€” `GET/PUT /cad/{id}/part-materials` keyed by partKey with provenance | +| ADD util | `render-worker/scripts/export_step_to_gltf.py` β€” embed `partKey` into mesh extras during GLB export | ---- +**Acceptance gates:** +- `mesh.userData.partKey` exists on every mesh object in the Three.js scene after GLB load +- Select a part β†’ DevTools shows the assignment payload contains `part_key: "ring_outer"`, not `mesh_name: "RingOuter_AF0"` +- Upload file with 3 unmatched Excel rows β†’ MaterialPanel shows "3 unmatched source rows" +- After full page reload: all manual material assignments are restored correctly +- Isolation, hide, and ghost still work as before (no regression) -### Priority 7 β€” UV Unwrap Workflow (New feature, user-requested) +### Priority 5 β€” Canonical USD Export and Render Migration -**Goal:** Produce UV-unwrapped geometry GLBs with clean seams for downstream texture authoring. -**Depends on:** Priority 1 (GMSH tessellation must produce conforming seams first). +**Goal:** Switch Blender still/turntable renders to consume the canonical USD stage, retiring the production GLB as an intermediate render artifact. -**What to build:** -- New Blender script `_blender_uvunwrap.py` called from `export_gltf.py` after sharp edge marking -- UV unwrap via `bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.001)` -- Seams are already set by `_apply_sharp_edges_from_occ()` (edge.seam=True) -- UV coordinates embedded in the production GLB -- Admin toggle: `uv_unwrap_enabled` in system_settings +**Milestones:** +- M1: `render-worker/scripts/import_usd.py` β€” Blender can import USD + restore seam/sharp from primvars +- 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 -**Estimated effort:** 1 session -**Depends on:** Priority 1 (GMSH) for clean seams +**File targets:** ---- +| Action | Path | +|---|---| +| CREATE | `render-worker/scripts/export_step_to_usd.py` β€” STEPβ†’USD exporter (seam/sharp payload on mesh prims) | +| CREATE | `render-worker/scripts/import_usd.py` β€” Blender USD import helper: reads `primvars:schaeffler:seamEdgeVertexPairs`, marks seam+sharp | +| MODIFY | `render-worker/scripts/blender_render.py` β€” accept `--usd_path` flag alongside `--glb_path` | +| 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 | -### Priority 8 β€” UI/UX Polish (from visual-audit-report.md) +**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 -**Top actionable items from the audit:** +### Priority 6 β€” Admin and Product Surface Simplification -| Item | Where | Fix | -|---|---|---| -| Tooltip system | All settings pages | Add `title` or tooltip component to every input | -| Empty state messages | MediaBrowser, ProductLibrary | "No assets yet β€” upload a STEP file" | -| Notification batching | NotificationCenter | Group per-render noise into job summaries | -| Mobile navigation | Layout.tsx | Hamburger menu for viewport < 768px | -| Kanban rejection flow | OrderDetail | Drag-to-reject with reason field | +**Goal:** Remove the geometry-GLB vs production-GLB mental model from admin, product detail, and repair flows. -**Estimated effort:** 2 sessions -**Independent of all backend priorities.** +**Milestones:** +- M1: Admin tessellation settings collapsed from 4 knobs to `scene_*` + `preview_*` (2 or 3 knobs max) +- M2: Bulk actions renamed from `generate-missing-geometry-glbs` β†’ `generate-missing-canonical-scenes` +- M3: ProductDetail shows single canonical scene status card, not dual-GLB ---- +**File targets:** -### Priority 9 β€” Phase F: Hash-based Conversion Caching (Performance) +| Action | Path | +|---|---| +| MODIFY | `backend/app/api/routers/admin.py` β€” rename/collapse tessellation settings; new bulk action labels | +| CREATE migration | `backend/alembic/versions/06x_rename_tessellation_settings.py` β€” UPDATE system_settings SET key = 'scene_linear_deflection' WHERE key = 'gltf_production_linear_deflection' | +| MODIFY | `frontend/src/pages/Admin.tsx` β€” simplified tessellation panel: scene quality + optional preview override | +| MODIFY | `frontend/src/pages/ProductDetail.tsx` β€” single canonical scene card (status, regenerate button) | +| MODIFY | `backend/app/domains/media/models.py` β€” deprecate `gltf_geometry` / `gltf_production` (keep values, add `deprecated=True` metadata) | -**Goal:** Skip re-tessellation if STEP file hash hasn't changed. Cache geometry GLB by `sha256(step_file)`. +**Acceptance gates:** +- Admin Settings page has max 3 tessellation quality fields (down from 4) +- ProductDetail page: one "Canonical Scene" status section, no separate geometry/production rows +- `GET /api/admin/settings` no longer exposes `gltf_preview_linear_deflection` key (replaced by `scene_linear_deflection`) -**What to build:** -- `cad_files.step_hash` column (SHA256, nullable) -- Before GLB generation: compute hash, check if cached GLB exists for same hash -- If hit: copy cached GLB, skip OCC+GMSH, skip Blender -- Migration: `06x_step_hash_column.py` +### Priority 7 β€” Render Job Tracking and Structured Logging -**Estimated effort:** 1 session -**Depends on:** Priority 1 (GMSH) stable first +**Goal:** Fix broken render job cancellation (synthetic `render-{line_id}` ID never matches real Celery task ID) and establish structured per-step logging. ---- +**Milestones:** +- M1: `RenderJobDocument` schema + migration; tasks write real `self.request.id` to DB +- M2: Cancel endpoint reads `celery_task_id` from job doc and calls `revoke()` β€” actually stops task +- M3: `PipelineLogger` integrated in all task files; every step emits `[STEP] start/done/error` with duration -### Priority 10 β€” USD Workflow RFC (Strategic, long-term) +**File targets:** -**Document:** `docs/rfcs/0001-step-to-usd-workflow.md` -**Status:** Proposed only β€” not planned for implementation yet. +| Action | Path | +|---|---| +| CREATE | `backend/app/domains/rendering/job_document.py` β€” `RenderJobDocument` Pydantic model, `update_step()`, `set_state()` | +| CREATE | `backend/app/core/pipeline_logger.py` β€” `PipelineLogger(step_start/done/error)` writing to logging + Redis SSE | +| CREATE migration | `backend/alembic/versions/062_render_job_document.py` β€” add `render_job_doc JSONB` to `order_lines` | +| MODIFY | `backend/app/domains/pipeline/tasks/render_order_line.py` β€” write `celery_task_id` + step events to job doc | +| MODIFY | `backend/app/domains/pipeline/tasks/render_thumbnail.py` β€” same | +| MODIFY | `backend/app/api/routers/orders.py` β€” cancel reads `render_job_doc.celery_task_id`, calls `celery.control.revoke()` | -**Summary:** Replace dual-GLB pipeline (geometry GLB β†’ production GLB) with a single USD canonical scene. Three.js has no USD loader, so this requires either switching viewers or waiting for Three.js USD support. +**Acceptance gates:** +- Start a 60s render task β†’ click Cancel β†’ `celery inspect active` shows task is gone within 15s +- `GET /api/orders/{id}/lines/{line_id}` response includes `render_job_doc.steps[]` with per-step `duration_s` +- Worker log shows `[THUMBNAIL] done in 34.2s` format (not bare f-strings) -**Decision needed:** Is the viewer constraint acceptable? Deferred until Priority 1–4 are complete. +### Priority 8 β€” Tenant Isolation Completion + +**Goal:** Make PostgreSQL RLS enforcement real. Currently `build_tenant_db_dep()` yields `db` without calling `SET LOCAL app.current_tenant_id`, making all tenant isolation a silent no-op. + +**Milestones:** +- M1: `TenantContextMiddleware` registered; all HTTP requests set RLS context from JWT +- M2: All Celery tasks call `set_tenant_context(db, tenant_id)` at task start +- M3: `global_admin` + `tenant_admin` roles in DB; `require_admin()` β†’ `require_global_admin()` + +**File targets:** + +| Action | Path | +|---|---| +| CREATE | `backend/app/core/middleware.py` β€” `TenantContextMiddleware(BaseHTTPMiddleware)` | +| MODIFY | `backend/app/main.py` β€” `app.add_middleware(TenantContextMiddleware)` | +| MODIFY | `backend/app/utils/auth.py` β€” `create_access_token()` embeds `tenant_id` in JWT claims | +| MODIFY | `backend/app/tasks/pipeline/thumbnail.py`, `extract.py`, `stills.py`, `turntable.py` β€” `set_tenant_context()` at start | +| CREATE migration | `backend/alembic/versions/063_role_hierarchy.py` β€” rename `admin` β†’ `global_admin`, add `tenant_admin` | +| MODIFY | All routers using `require_admin()` β†’ `require_global_admin()` | + +**Acceptance gates:** +- Login as tenant A user β†’ `GET /api/products` returns 0 results when tenant A has no products, even if tenant B has 50 +- Verify via: `SELECT count(*) FROM products WHERE tenant_id != ''` returns 0 from within tenant A session +- Celery task logs show `[TENANT] context set: tenant_id=` at start +- `GET /api/admin/users` returns 403 for `tenant_admin` role (only `global_admin` can list all users) + +### Priority 9 β€” Hash-Based Scene Conversion Caching + +**Goal:** Skip re-tessellation when the STEP file has not changed. Cache canonical scene + preview derivatives by `SHA256(step_file)`. + +**Milestones:** +- M1: `cad_files.step_hash` column in DB; hash computed and stored on each export +- M2: Export task checks hash before processing β€” returns cached asset UUID on hit +- M3: Hash invalidated correctly when admin forces reprocess or deflection settings change + +**File targets:** + +| Action | Path | +|---|---| +| ADD column | `backend/app/domains/products/models.py` β€” `CadFile.step_hash: str \| None` | +| CREATE migration | `backend/alembic/versions/064_step_hash.py` β€” `ADD COLUMN step_hash VARCHAR(64)` | +| MODIFY | `backend/app/domains/pipeline/tasks/export_glb.py` (or future USD task) β€” hash check before subprocess call | +| ADD util | `backend/app/services/step_processor.py` β€” `compute_step_hash(file_path) -> str` | + +**Acceptance gates:** +- Upload same STEP file twice β†’ second task completes in < 2s (cache hit logged: `[CACHE] hash match, skipping tessellation`) +- Change deflection setting β†’ force reprocess β†’ new export runs fresh (hash same but settings changed, cache bypassed) +- `GET /api/cad/{id}` response includes `step_hash` field + +### Priority 10 β€” UI/UX Polish + +**Goal:** Address independent UI items from `visual-audit-report.md` that require no backend changes. + +**Milestones:** +- M1: Tooltip/help text on every Admin settings input +- M2: Empty state messages in MediaBrowser, ProductLibrary, Orders +- M3: Notification batching β€” group per-render noise into job summaries +- M4: Mobile navigation β€” hamburger menu at < 768px +- M5: Kanban rejection flow β€” drag-to-reject with reason field + +**File targets:** + +| Action | Path | +|---|---| +| MODIFY | `frontend/src/pages/Admin.tsx` β€” `title` attributes on all inputs; help text below complex settings | +| MODIFY | `frontend/src/pages/MediaBrowser.tsx` β€” empty state: "No assets yet β€” upload a STEP file to get started" | +| MODIFY | `frontend/src/pages/ProductLibrary.tsx` β€” empty state | +| MODIFY | `frontend/src/pages/Orders.tsx` β€” empty state | +| MODIFY | `frontend/src/components/layout/Layout.tsx` β€” hamburger + slide-in nav for mobile | +| MODIFY | `frontend/src/pages/OrderDetail.tsx` β€” reject button with reason modal | +| CREATE | `frontend/src/components/shared/Tooltip.tsx` β€” reusable tooltip wrapper | + +**Acceptance gates:** +- All Admin settings inputs have visible help text or tooltip (manual check: no input label without explanation) +- Mobile viewport (375px): no horizontal scroll, nav accessible via hamburger +- Submit a render β†’ NotificationCenter shows one "Render complete (3 files)" summary, not 3 individual toasts --- ## Dependency Graph ``` -Priority 1 (GMSH) - └── Priority 7 (UV Unwrap) - └── Priority 9 (Hash Cache) +Priority 1 (Pipeline Cleanup Foundation) + └── Priority 2 (USD Foundation Without Viewer Regression) + └── Priority 4 (Viewer Migration to Canonical Part Identity) + └── Priority 5 (Canonical USD Export and Render Migration) + └── Priority 6 (Admin and Product Surface Simplification) + └── Priority 9 (Hash-Based Scene Conversion Caching) -Priority 2 (Dead Code) - └── Priority 3 (Task Decomposition) - └── Priority 4 (Render Job Tracking) - └── Priority 6 (Tenant Isolation) +Priority 3 (Tessellation and Topology Quality) + └── Priority 5 (Canonical USD Export and Render Migration) -Priority 5 (Logging) β€” independent, can start anytime -Priority 8 (UI/UX) β€” independent, can start anytime -Priority 10 (USD) β€” deferred +Priority 7 (Render Job Tracking and Structured Logging) β€” can run in parallel +Priority 8 (Tenant Isolation Completion) β€” can run in parallel +Priority 10 (UI/UX Polish) β€” independent ``` --- ## What To Do Next -**Option A β€” Fix visual quality first:** -β†’ `/implement` on `plan.md` (Priority 1: GMSH tessellation) +**Recommended execution path:** +1. Do Priority 1 first: clean up and split the current pipeline. +2. Start Priority 2 immediately after: add `partKey`, assignment-layer semantics, and scene manifest without changing the browser UX. +3. Run Priority 3 in parallel or immediately after, depending on whether current tessellation quality blocks scene-authoring confidence. +4. Use the implementation plan in `docs/plans/0001-step-to-usd-implementation.md` as the execution checklist for the USD workstream. -**Option B β€” Clean up dead code first (low risk, fast wins):** -β†’ Start Priority 2 dead code deletion, then Priority 3 task decomposition +**Parallel sprint option (2 agents):** +- Agent 1: Priority 1 (pipeline cleanup foundation) +- Agent 2: Priority 3 (tessellation and topology quality) -**Option C β€” Parallel sprint (2 agents):** -β†’ Agent 1: Priority 1 (GMSH) in worktree -β†’ Agent 2: Priority 2+3 (dead code + task split) in separate worktree +**Parallel sprint option (3 agents):** +- Agent 1: Priority 1 +- Agent 2: Priority 3 +- Agent 3: Priority 7 or Priority 8 -**Option D β€” UI/UX sprint:** -β†’ Priority 8 audit items, completely independent of backend +**Do not defer anymore:** +- canonical `partKey` +- part-keyed browser material overrides +- scene manifest / preview contract + +These are now considered implementation prerequisites for the long-term refactor, not optional strategy work. --- @@ -256,5 +384,6 @@ Old planning files are kept for reference but superseded by this document: - `PLAN_REFACTOR.md` β€” 1,173-line architectural plan (Phases 1–8 mapped to Priorities 2–8 above) - `plan.md` β€” active GMSH implementation plan (Priority 1) - `docs/rfcs/0001-step-to-usd-workflow.md` β€” USD RFC (Priority 10) +- `docs/plans/0001-step-to-usd-implementation.md` β€” actionable USD implementation plan - `review-report.md` β€” latest code review results - `visual-audit-report.md` β€” UX audit results diff --git a/docs/plans/0001-step-to-usd-implementation.md b/docs/plans/0001-step-to-usd-implementation.md new file mode 100644 index 0000000..8f82cd0 --- /dev/null +++ b/docs/plans/0001-step-to-usd-implementation.md @@ -0,0 +1,326 @@ +# Implementation Plan: STEP β†’ USD Canonical Scene Workflow + +> Execution checklist for ROADMAP.md Priorities 2, 4, and 5. +> RFC: `docs/rfcs/0001-step-to-usd-workflow.md` +> Date: 2026-03-11 + +--- + +## Prerequisites + +- [ ] Priority 1 complete (step_tasks.py decomposed, blender_render.py decomposed) +- [ ] Decision: USD authoring library (see Open Questions below) +- [ ] Decision: seam/sharp payload encoding (primvars vs. JSON sidecar) + +--- + +## Phase 1 β€” Dual-Write USD Beside GLB (Priority 2, M1–M2) + +**Goal:** Export a valid `usd_master` alongside the existing GLB pipeline without changing any browser behavior. + +### Task 1.1 β€” `export_step_to_usd.py` scaffolding + +**File:** `render-worker/scripts/export_step_to_usd.py` + +**What it must produce:** + +``` +/Root β€” Stage root +/Root/Assembly β€” Top-level assembly prim (Xform) +/Root/Assembly/ β€” Per-component prim (Xform) +/Root/Assembly// β€” Leaf part prim (Xform) +/Root/Assembly///Mesh β€” UsdGeomMesh +/Root/Looks/ β€” UsdShadeMaterial (placeholder binding) +``` + +**Required per-mesh prim attributes:** + +| Attribute | Value source | +|---|---| +| `schaeffler:partKey` | `generate_part_key(xcaf_label_path)` | +| `schaeffler:sourceName` | XCAF `TDataStd_Name` attribute | +| `schaeffler:sourceColor` | XCAF embedded color (hex string) | +| `schaeffler:rawMaterialName` | from `CadFile.part_materials` if available | +| `schaeffler:tessellation:linearDeflectionMm` | CLI arg value | +| `schaeffler:tessellation:angularDeflectionRad` | CLI arg value | +| `primvars:schaeffler:seamEdgeVertexPairs` | OCC B-rep seam edges (index pairs in mesh-local space) | +| `primvars:schaeffler:sharpEdgeVertexPairs` | sharp edges from `_extract_sharp_edge_pairs()` | + +**CLI interface:** + +```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] \ + [--color_map '{"Ring": "#4C9BE8"}'] \ + [--tessellation_engine occ|gmsh] +``` + +**Acceptance gate:** `python3 export_step_to_usd.py --step_path 81113-l_cut.stp --output_path /tmp/test.usd` β†’ +- File exists, parseable +- 25 part prims with `schaeffler:partKey` attribute +- Part count matches `export_step_to_gltf.py` output for same file + +### Task 1.2 β€” `usd_master` MediaAsset type + +**File:** `backend/app/domains/media/models.py` + +Add `usd_master = "usd_master"` to `MediaAssetType` enum. + +**Migration:** `backend/alembic/versions/060_usd_master_asset_type.py` + +```sql +ALTER TYPE mediaassettype ADD VALUE IF NOT EXISTS 'usd_master'; +``` + +**Acceptance gate:** `GET /api/media?asset_type=usd_master` returns 200 (not 422 validation error). + +### Task 1.3 β€” `generate_usd_master_task` Celery task + +**File:** `backend/app/domains/pipeline/tasks/export_glb.py` + +New task that: +1. Resolves STEP file path from `CadFile` +2. Calls `export_step_to_usd.py` subprocess +3. Stores resulting `.usd` as a `usd_master` `MediaAsset` +4. Does NOT touch existing GLB tasks (dual-write, no removal yet) + +**Queue:** `step_processing` (fast, < 30s β€” tessellation only, no Blender) + +**Called by:** `render_step_thumbnail` task after `render_step_thumbnail` succeeds, OR triggered independently via admin action. + +**Acceptance gate:** After triggering task for a CadFile, `GET /api/cad/{id}/media` includes an asset with `asset_type: "usd_master"`. + +--- + +## Phase 2 β€” Canonical Part Identity and Assignment Layers (Priority 2, M3–M5) + +### Task 2.1 β€” `part_key_service.py` + +**File:** `backend/app/services/part_key_service.py` + +```python +def generate_part_key(xcaf_label_path: str, source_name: str) -> str: + """Deterministic slug from XCAF path + source name. + + Format: lowercase alphanumeric + underscores, max 64 chars. + E.g.: 'ring_outer', 'ball_af0', 'cage_inner_ring' + + For duplicate names: append _2, _3, etc. + For unnamed parts: 'part_{sha256(xcaf_path)[:8]}' + """ + ... + +def build_scene_manifest(cad_file: CadFile, usd_asset: MediaAsset) -> dict: + """Read part metadata from USD or from CadFile.parsed_objects. + + Returns dict matching SceneManifest Pydantic schema: + { + "cad_file_id": "...", + "parts": [ + { + "part_key": "ring_outer", + "source_name": "RingOuter_AF0", + "prim_path": "/Root/Assembly/Bearing/RingOuter", + "effective_material": "SCHAEFFLER_010102_...", + "assignment_provenance": "manual|auto|default", + "is_unassigned": false + } + ], + "unmatched_source_rows": [...], + "unassigned_parts": [...] + } + """ + ... +``` + +### Task 2.2 β€” Three-layer material assignment model + +**File:** `backend/app/domains/products/models.py` + +Add three JSONB columns to `CadFile`: + +```python +source_material_assignments: dict | None # from Excel / product import; keyed by source name +resolved_material_assignments: dict | None # auto-matched; keyed by partKey +manual_material_overrides: dict | None # browser-authored; keyed by partKey +``` + +**Migration:** `backend/alembic/versions/061_material_assignment_layers.py` + +Keep existing `part_materials` column for backward compat during transition. + +**Service helper:** + +```python +def get_effective_assignments(cad_file: CadFile) -> dict: + """Priority: manual_overrides > resolved > source color > 'unassigned'.""" + ... +``` + +### Task 2.3 β€” `GET /cad/{id}/scene-manifest` endpoint + +**File:** `backend/app/api/routers/cad.py` + +New endpoint returning `SceneManifest`. Calls `build_scene_manifest()` β€” reads from USD metadata if `usd_master` asset exists, otherwise reads from `CadFile.parsed_objects`. + +**Acceptance gate:** Returns HTTP 200 with parts array; each part has `part_key`, `effective_material`, `is_unassigned`, `assignment_provenance`. + +### Task 2.4 β€” Update `PUT /cad/{id}/part-materials` + +**File:** `backend/app/api/routers/cad.py` + +Accept `{ "part_key": "ring_outer", "material": "SCHAEFFLER_010102_..." }` body (or bulk map). Write to `manual_material_overrides` column (not the old `part_materials` column). + +**Acceptance gate:** PUT with `partKey` β†’ subsequent GET `/scene-manifest` shows that part's `assignment_provenance: "manual"`. + +--- + +## Phase 3 β€” Seam/Sharp Payload to USD Mesh Prims (Priority 3 integration) + +### Task 3.1 β€” Port seam derivation into USD exporter + +**File:** `render-worker/scripts/export_step_to_usd.py` + +After tessellation (OCC or GMSH), for each mesh face: +1. Identify seam edges from face/batch boundaries (STEPper approach: adjacent faces with different batch IDs) +2. Identify sharp edges from `_extract_sharp_edge_pairs()` (already implemented) +3. Convert both to **mesh-local vertex index pairs** (not world-space coordinates) + +Write to USD mesh prim: + +```python +mesh_prim.GetPrimvar("schaeffler:seamEdgeVertexPairs").Set( + Vt.Vec2iArray(seam_pairs), # [(vi0, vi1), ...] +) +mesh_prim.GetPrimvar("schaeffler:sharpEdgeVertexPairs").Set( + Vt.Vec2iArray(sharp_pairs), +) +``` + +**Why index-space:** Survives transforms cleanly; avoids KD-tree matching workaround in `_apply_sharp_edges_from_occ()`. + +### Task 3.2 β€” `import_usd.py` Blender helper + +**File:** `render-worker/scripts/import_usd.py` + +```python +def import_usd_and_restore_topology(usd_path: str) -> list: + """Import USD stage into Blender, restore seam/sharp from primvars. + + Returns list of imported mesh objects. + """ + bpy.ops.wm.usd_import(filepath=usd_path) + for obj in bpy.context.scene.objects: + if obj.type != 'MESH': + continue + # Read custom attributes set by USD importer + seam_pairs = obj.get("schaeffler_seamEdgeVertexPairs") or [] + sharp_pairs = obj.get("schaeffler_sharpEdgeVertexPairs") or [] + _mark_seams_from_index_pairs(obj, seam_pairs) + _mark_sharp_from_index_pairs(obj, sharp_pairs) + ... +``` + +**Acceptance gate:** Blender log shows `[USD_IMPORT] 25 parts, 5044 seam edges restored from primvars` β€” no KD-tree needed. + +--- + +## Phase 4 β€” Blender Render from USD (Priority 5) + +### Task 4.1 β€” `blender_render.py` USD path + +**File:** `render-worker/scripts/blender_render.py` + +Add `--usd_path` argument. When provided: +1. Call `import_usd.py` instead of `export_gltf.py` GLB import +2. Read `schaeffler:partKey` and `schaeffler:canonicalMaterialName` per mesh object after import +3. Apply materials by `partKey β†’ material library name` lookup instead of object-name heuristics + +**Migration:** Keep `--glb_path` working in parallel; switch production task to prefer `--usd_path` when `usd_master` asset exists. + +### Task 4.2 β€” Backend render service update + +**File:** `backend/app/services/render_blender.py` + +When building `blender_cmd`, check if `usd_master` MediaAsset exists for the CadFile: +- If yes: pass `--usd_path ` +- If no: fall back to `--glb_path ` (unchanged) + +--- + +## Phase 5 β€” Frontend Migration (Priority 4) + +### Task 5.1 β€” Scene manifest fetch on product load + +**File:** `frontend/src/api/sceneManifest.ts` + +```typescript +export interface PartEntry { + part_key: string; + source_name: string; + prim_path: string; + effective_material: string | null; + assignment_provenance: 'manual' | 'auto' | 'default'; + is_unassigned: boolean; +} + +export interface SceneManifest { + cad_file_id: string; + parts: PartEntry[]; + unmatched_source_rows: string[]; + unassigned_parts: string[]; +} + +export async function fetchSceneManifest(cadFileId: string): Promise { + const res = await api.get(`/cad/${cadFileId}/scene-manifest`); + return res.data; +} +``` + +### Task 5.2 β€” ThreeDViewer partKey integration + +**File:** `frontend/src/components/cad/ThreeDViewer.tsx` + +- On GLB load: iterate `scene.children`, read `mesh.userData.partKey` (set by preview GLB derivation) +- On click: identify `partKey` from `userData`, not from `mesh.name` +- Pass `partKey` to `MaterialPanel` for display and persistence + +### Task 5.3 β€” MaterialPanel reconciliation section + +**File:** `frontend/src/components/cad/MaterialPanel.tsx` + +Add reconciliation section showing: +- "N unmatched Excel rows" (source names with no partKey match) +- "M unassigned parts" (partKeys with no resolved material) + +Clicking an unassigned part in the viewer auto-focuses it in the MaterialPanel. + +--- + +## Open Questions (must be decided before Phase 1 coding starts) + +| # | Question | Options | Default recommendation | +|---|---|---|---| +| 1 | USD authoring library | `pxr` (full OpenUSD, heavy) / `usda` text templating (no deps) / `usd-core` pip | Start with `pxr` β€” pip-installable, same OCC kernel available | +| 2 | Seam/sharp payload encoding | Custom primvars on mesh prim / separate JSON sidecar / GLB extras (current) | Index-space primvars β€” cleaner, survives transforms | +| 3 | Preview GLB derivation | USD β†’ GLB export pass / co-author from same tessellation pass | Co-author during migration (avoid round-trip loss) | +| 4 | Single-file USD or override layers | Flat single file / canonical + overlay layers (flattened for delivery) | RFC recommends Option B (overlay layers, flatten for delivery) | +| 5 | `pxr` install in render-worker | Add to `render-worker/Dockerfile` / use system package | `pip install usd-core` β€” no NVIDIA/Pixar GPU tools needed | + +--- + +## Non-Regression Checklist + +Before merging any Priority 2–5 work: + +- [ ] Click a part in ThreeDViewer β†’ selection resolves to stable `partKey` +- [ ] Pin selection β†’ isolate, hide, ghost all work as before +- [ ] Unassigned parts are visually highlighted +- [ ] Assign a Blender asset-library material name via browser β†’ persisted by `partKey` +- [ ] Reload page β†’ same part still assigned +- [ ] Subsequent Blender render uses the same assignment +- [ ] CAD file with mismatched Excel names β†’ system produces canonical scene, preview asset, unmatched row count +- [ ] `geometryGltfUrl` / `productionGltfUrl` distinction no longer required by frontend (removed from API contract)