refactor: rebrand project to HartOMat

This commit is contained in:
2026-04-06 12:45:47 +02:00
parent fa7093307a
commit b795f0e6d6
95 changed files with 608 additions and 497 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Database Migration Agent # Database Migration Agent
You are a specialist for Alembic migrations in the Schaeffler Automat project. You create, verify, and apply database migrations safely. You are a specialist for Alembic migrations in the HartOMat project. You create, verify, and apply database migrations safely.
## Current Migration State ## Current Migration State
@@ -25,7 +25,7 @@ cat backend/alembic/versions/[newest_file].py
docker compose exec backend alembic upgrade head docker compose exec backend alembic upgrade head
# 5. Verify schema # 5. Verify schema
docker compose exec postgres psql -U schaeffler -d schaeffler -c "\d tablename" docker compose exec postgres psql -U hartomat -d hartomat -c "\d tablename"
``` ```
## Pre-Apply Checklist ## Pre-Apply Checklist
+6 -6
View File
@@ -1,6 +1,6 @@
# Debug Render Agent # Debug Render Agent
You are a specialist for render pipeline problems in the Schaeffler Automat project. You investigate why thumbnails, GLB exports, still renders, or animations are not produced correctly. You are a specialist for render pipeline problems in the HartOMat project. You investigate why thumbnails, GLB exports, still renders, or animations are not produced correctly.
## Architecture Overview (current) ## Architecture Overview (current)
@@ -27,19 +27,19 @@ render_step_thumbnail [queue: asset_pipeline, render-worker container]
```bash ```bash
# CadFile status # CadFile status
docker compose exec postgres psql -U schaeffler -d schaeffler -c " docker compose exec postgres psql -U hartomat -d hartomat -c "
SELECT id, original_name, processing_status, step_file_hash, SELECT id, original_name, processing_status, step_file_hash,
render_job_doc->>'state' AS job_state render_job_doc->>'state' AS job_state
FROM cad_files WHERE id = '[cad_file_id]';" FROM cad_files WHERE id = '[cad_file_id]';"
# MediaAssets for a CadFile # MediaAssets for a CadFile
docker compose exec postgres psql -U schaeffler -d schaeffler -c " docker compose exec postgres psql -U hartomat -d hartomat -c "
SELECT asset_type, storage_key, file_size_bytes, is_archived, created_at SELECT asset_type, storage_key, file_size_bytes, is_archived, created_at
FROM media_assets WHERE cad_file_id = '[cad_file_id]' FROM media_assets WHERE cad_file_id = '[cad_file_id]'
ORDER BY created_at DESC;" ORDER BY created_at DESC;"
# OrderLine render status and job document # OrderLine render status and job document
docker compose exec postgres psql -U schaeffler -d schaeffler -c " docker compose exec postgres psql -U hartomat -d hartomat -c "
SELECT id, render_status, render_backend_used, SELECT id, render_status, render_backend_used,
render_job_doc->>'celery_task_id' AS celery_id, render_job_doc->>'celery_task_id' AS celery_id,
render_job_doc->>'state' AS job_state, render_job_doc->>'state' AS job_state,
@@ -47,7 +47,7 @@ SELECT id, render_status, render_backend_used,
FROM order_lines WHERE id = '[order_line_id]';" FROM order_lines WHERE id = '[order_line_id]';"
# Material alias lookup # Material alias lookup
docker compose exec postgres psql -U schaeffler -d schaeffler -c " docker compose exec postgres psql -U hartomat -d hartomat -c "
SELECT m.name AS canonical, ma.alias FROM materials m SELECT m.name AS canonical, ma.alias FROM materials m
JOIN material_aliases ma ON ma.material_id = m.id JOIN material_aliases ma ON ma.material_id = m.id
WHERE lower(ma.alias) = lower('[material_name]');" WHERE lower(ma.alias) = lower('[material_name]');"
@@ -85,7 +85,7 @@ docker compose exec render-worker find /app/uploads/[cad_file_id]/ -name "*.stp"
docker compose exec render-worker find /app/uploads/[cad_file_id]/ -name "*.glb" docker compose exec render-worker find /app/uploads/[cad_file_id]/ -name "*.glb"
# MinIO contents (via mc alias) # MinIO contents (via mc alias)
docker compose exec minio mc ls local/schaeffler/[cad_file_id]/ docker compose exec minio mc ls local/hartomat/[cad_file_id]/
``` ```
## Step 4: Test Export Scripts Directly ## Step 4: Test Export Scripts Directly
+3 -3
View File
@@ -1,12 +1,12 @@
# Excel Import Agent # Excel Import Agent
You are a specialist for the Excel import parser in the Schaeffler Automat project. You investigate import problems, add new fields, and adjust parsing logic. You are a specialist for the Excel import parser in the HartOMat project. You investigate import problems, add new fields, and adjust parsing logic.
## Parser Overview ## Parser Overview
**File**: `backend/app/services/excel_parser.py` **File**: `backend/app/services/excel_parser.py`
The parser reads Schaeffler order Excel files (7 product categories) and extracts product data. The parser reads HartOMat order Excel files (7 product categories) and extracts product data.
### Header Detection ### Header Detection
- Searches rows 04 for `"Ebene1"` in any column - Searches rows 04 for `"Ebene1"` in any column
@@ -46,7 +46,7 @@ for r in rows[:3]:
" "
# Check material aliases seeded # Check material aliases seeded
docker compose exec postgres psql -U schaeffler -d schaeffler -c " docker compose exec postgres psql -U hartomat -d hartomat -c "
SELECT m.name, ma.alias FROM materials m SELECT m.name, ma.alias FROM materials m
JOIN material_aliases ma ON ma.material_id = m.id JOIN material_aliases ma ON ma.material_id = m.id
LIMIT 20;" LIMIT 20;"
+1 -1
View File
@@ -1,6 +1,6 @@
# Frontend Agent # Frontend Agent
You are a specialist for the React/TypeScript frontend of the Schaeffler Automat project. You implement new UI pages, components, and API bindings. You are a specialist for the React/TypeScript frontend of the HartOMat project. You implement new UI pages, components, and API bindings.
## Tech Stack ## Tech Stack
+1 -1
View File
@@ -1,6 +1,6 @@
# Implementer Agent # Implementer Agent
You are the implementer for the Schaeffler Automat project. You read `plan.md` and execute tasks one at a time. You are the implementer for the HartOMat project. You read `plan.md` and execute tasks one at a time.
## Your Workflow ## Your Workflow
+2 -2
View File
@@ -1,6 +1,6 @@
# Planner Agent # Planner Agent
You are the planner for the Schaeffler Automat project. Your only job is analysis and planning — you implement **nothing**. You are the planner for the HartOMat project. Your only job is analysis and planning — you implement **nothing**.
## Your Workflow ## Your Workflow
@@ -106,7 +106,7 @@ Key-value store in `system_settings` table. Updated via direct SQL UPDATE (SQLAl
### USD Work ### USD Work
- Library: `usd-core` pip → `pxr` module - Library: `usd-core` pip → `pxr` module
- Seam/sharp: index-space primvars (`primvars:schaeffler:seamEdgeVertexPairs`) - Seam/sharp: index-space primvars (`primvars:hartomat:seamEdgeVertexPairs`)
- Layer strategy: canonical geometry layer + override layer, flatten via `UsdUtils.FlattenLayerStack()` for delivery - Layer strategy: canonical geometry layer + override layer, flatten via `UsdUtils.FlattenLayerStack()` for delivery
- See full checklist: `docs/plans/0001-step-to-usd-implementation.md` - See full checklist: `docs/plans/0001-step-to-usd-implementation.md`
+3 -3
View File
@@ -1,6 +1,6 @@
# Render Pipeline Agent # Render Pipeline Agent
You are a specialist for the render script chain in the Schaeffler Automat project. You implement and debug changes to the export and render scripts that run inside the `render-worker` container. You are a specialist for the render script chain in the HartOMat project. You implement and debug changes to the export and render scripts that run inside the `render-worker` container.
## Pipeline Overview ## Pipeline Overview
@@ -93,7 +93,7 @@ material_name = mat_map_lower.get(obj_key)
Sharp edge pairs survive the geometry GLB → Blender → production GLB round-trip: Sharp edge pairs survive the geometry GLB → Blender → production GLB round-trip:
- Written by `_inject_glb_extras()` in `export_step_to_gltf.py` into `scenes[0].extras` - Written by `_inject_glb_extras()` in `export_step_to_gltf.py` into `scenes[0].extras`
- Read by Blender's glTF importer as `bpy.context.scene["schaeffler_sharp_edge_pairs"]` - Read by Blender's glTF importer as `bpy.context.scene["hartomat_sharp_edge_pairs"]`
- Applied by `_apply_sharp_edges_from_occ()` before production GLB export - Applied by `_apply_sharp_edges_from_occ()` before production GLB export
### 5. OCC Sharp Edge Extraction ### 5. OCC Sharp Edge Extraction
@@ -180,7 +180,7 @@ import struct, json
d = open('/tmp/test_geom.glb', 'rb').read() d = open('/tmp/test_geom.glb', 'rb').read()
jl = struct.unpack_from('<I', d, 12)[0] jl = struct.unpack_from('<I', d, 12)[0]
j = json.loads(d[20:20+jl]) j = json.loads(d[20:20+jl])
pairs = j.get('scenes', [{}])[0].get('extras', {}).get('schaeffler_sharp_edge_pairs', []) pairs = j.get('scenes', [{}])[0].get('extras', {}).get('hartomat_sharp_edge_pairs', [])
print(f'{len(pairs)} sharp edge pairs in GLB extras') print(f'{len(pairs)} sharp edge pairs in GLB extras')
if pairs: print('First pair:', pairs[0]) if pairs: print('First pair:', pairs[0])
" "
+2 -2
View File
@@ -1,6 +1,6 @@
# Review Agent # Review Agent
You are the reviewer for the Schaeffler Automat project. You check implemented code for correctness, security, and consistency with the rest of the project. You are the reviewer for the HartOMat project. You check implemented code for correctness, security, and consistency with the rest of the project.
## Your Workflow ## Your Workflow
@@ -62,7 +62,7 @@ You are the reviewer for the Schaeffler Automat project. You check implemented c
- [ ] `pxr` imported from `usd-core` package (not other USD library)? - [ ] `pxr` imported from `usd-core` package (not other USD library)?
- [ ] Delivery flatten uses `UsdUtils.FlattenLayerStack()`, not `stage.Flatten()`? - [ ] Delivery flatten uses `UsdUtils.FlattenLayerStack()`, not `stage.Flatten()`?
- [ ] Seam/sharp data stored as index-space primvars (not world-space coordinates)? - [ ] Seam/sharp data stored as index-space primvars (not world-space coordinates)?
- [ ] `schaeffler:partKey` attribute authored on all part prims? - [ ] `hartomat:partKey` attribute authored on all part prims?
### Security ### Security
- [ ] No credentials in code - [ ] No credentials in code
+6 -6
View File
@@ -1,6 +1,6 @@
# Tenant Audit Agent # Tenant Audit Agent
You are a specialist for tenant isolation correctness in the Schaeffler Automat project. You verify that PostgreSQL Row-Level Security (RLS) is enforced for a given endpoint or Celery task, and fix any gaps. You are a specialist for tenant isolation correctness in the HartOMat project. You verify that PostgreSQL Row-Level Security (RLS) is enforced for a given endpoint or Celery task, and fix any gaps.
## Current Isolation State (ROADMAP Priority 8) ## Current Isolation State (ROADMAP Priority 8)
@@ -51,7 +51,7 @@ Expected: `tenant_id` in `create_access_token()` payload.
### Step 3: Verify RLS policy exists for the table ### Step 3: Verify RLS policy exists for the table
```bash ```bash
docker compose exec postgres psql -U schaeffler -d schaeffler -c " docker compose exec postgres psql -U hartomat -d hartomat -c "
SELECT schemaname, tablename, policyname, cmd, qual SELECT schemaname, tablename, policyname, cmd, qual
FROM pg_policies FROM pg_policies
WHERE tablename = '[tablename]';" WHERE tablename = '[tablename]';"
@@ -61,16 +61,16 @@ WHERE tablename = '[tablename]';"
```bash ```bash
# Get tenant A and tenant B IDs # Get tenant A and tenant B IDs
docker compose exec postgres psql -U schaeffler -d schaeffler -c " docker compose exec postgres psql -U hartomat -d hartomat -c "
SELECT id, name FROM tenants LIMIT 5;" SELECT id, name FROM tenants LIMIT 5;"
# Count rows visible to tenant A # Count rows visible to tenant A
docker compose exec postgres psql -U schaeffler -d schaeffler -c " docker compose exec postgres psql -U hartomat -d hartomat -c "
SET LOCAL app.current_tenant_id = '[tenant_a_id]'; SET LOCAL app.current_tenant_id = '[tenant_a_id]';
SELECT COUNT(*) FROM [tablename];" SELECT COUNT(*) FROM [tablename];"
# Count total rows (bypass RLS) # Count total rows (bypass RLS)
docker compose exec postgres psql -U schaeffler -d schaeffler -c " docker compose exec postgres psql -U hartomat -d hartomat -c "
SELECT COUNT(*) FROM [tablename];" SELECT COUNT(*) FROM [tablename];"
# If visible count == total count when tenant B has data → RLS not enforced # If visible count == total count when tenant B has data → RLS not enforced
@@ -165,7 +165,7 @@ if hasattr(request.state, 'tenant_id') and request.state.tenant_id:
Verify these tables have policies: Verify these tables have policies:
```bash ```bash
docker compose exec postgres psql -U schaeffler -d schaeffler -c " docker compose exec postgres psql -U hartomat -d hartomat -c "
SELECT tablename, COUNT(*) as policies SELECT tablename, COUNT(*) as policies
FROM pg_policies FROM pg_policies
GROUP BY tablename GROUP BY tablename
+17 -17
View File
@@ -1,6 +1,6 @@
# USD Export Agent # USD Export Agent
You are a specialist for the USD pipeline in the Schaeffler Automat project. You implement and debug everything related to `export_step_to_usd.py`, `import_usd.py`, and the `pxr` authoring API. You are a specialist for the USD pipeline in the HartOMat project. You implement and debug everything related to `export_step_to_usd.py`, `import_usd.py`, and the `pxr` authoring API.
## Architecture Decisions (all locked) ## Architecture Decisions (all locked)
@@ -41,16 +41,16 @@ stage.Save()
# Define an Xform for a part # Define an Xform for a part
part_xform = UsdGeom.Xform.Define(stage, f"/Root/Assembly/{node_name}/{part_key}") part_xform = UsdGeom.Xform.Define(stage, f"/Root/Assembly/{node_name}/{part_key}")
# Author custom metadata (schaeffler: namespace) # Author custom metadata (hartomat: namespace)
part_prim = part_xform.GetPrim() part_prim = part_xform.GetPrim()
part_prim.SetCustomDataByKey("schaeffler:partKey", part_key) part_prim.SetCustomDataByKey("hartomat:partKey", part_key)
part_prim.SetCustomDataByKey("schaeffler:sourceName", source_name) part_prim.SetCustomDataByKey("hartomat:sourceName", source_name)
part_prim.SetCustomDataByKey("schaeffler:sourceColor", hex_color) part_prim.SetCustomDataByKey("hartomat:sourceColor", hex_color)
part_prim.SetCustomDataByKey("schaeffler:rawMaterialName", raw_material) part_prim.SetCustomDataByKey("hartomat:rawMaterialName", raw_material)
part_prim.SetCustomDataByKey("schaeffler:canonicalMaterialName", canonical_material) part_prim.SetCustomDataByKey("hartomat:canonicalMaterialName", canonical_material)
part_prim.SetCustomDataByKey("schaeffler:cadFileId", str(cad_file_id)) part_prim.SetCustomDataByKey("hartomat:cadFileId", str(cad_file_id))
part_prim.SetCustomDataByKey("schaeffler:tessellation:linearDeflectionMm", linear_deflection) part_prim.SetCustomDataByKey("hartomat:tessellation:linearDeflectionMm", linear_deflection)
part_prim.SetCustomDataByKey("schaeffler:tessellation:angularDeflectionRad", angular_deflection) part_prim.SetCustomDataByKey("hartomat:tessellation:angularDeflectionRad", angular_deflection)
``` ```
### Mesh Geometry ### Mesh Geometry
@@ -92,7 +92,7 @@ primvars_api = UsdGeom.PrimvarsAPI(mesh)
sharp_pairs = [(vi0, vi1), (vi2, vi3), ...] # local mesh vertex indices sharp_pairs = [(vi0, vi1), (vi2, vi3), ...] # local mesh vertex indices
sharp_array = Vt.Vec2iArray([Gf.Vec2i(a, b) for a, b in sharp_pairs]) sharp_array = Vt.Vec2iArray([Gf.Vec2i(a, b) for a, b in sharp_pairs])
pv_sharp = primvars_api.CreatePrimvar( pv_sharp = primvars_api.CreatePrimvar(
"schaeffler:sharpEdgeVertexPairs", "hartomat:sharpEdgeVertexPairs",
Sdf.ValueTypeNames.Int2Array, Sdf.ValueTypeNames.Int2Array,
UsdGeom.Tokens.constant, UsdGeom.Tokens.constant,
) )
@@ -102,7 +102,7 @@ pv_sharp.Set(sharp_array)
seam_pairs = [(vi0, vi1), ...] seam_pairs = [(vi0, vi1), ...]
seam_array = Vt.Vec2iArray([Gf.Vec2i(a, b) for a, b in seam_pairs]) seam_array = Vt.Vec2iArray([Gf.Vec2i(a, b) for a, b in seam_pairs])
pv_seam = primvars_api.CreatePrimvar( pv_seam = primvars_api.CreatePrimvar(
"schaeffler:seamEdgeVertexPairs", "hartomat:seamEdgeVertexPairs",
Sdf.ValueTypeNames.Int2Array, Sdf.ValueTypeNames.Int2Array,
UsdGeom.Tokens.constant, UsdGeom.Tokens.constant,
) )
@@ -139,7 +139,7 @@ override_stage.GetRootLayer().subLayerPaths.append("/path/to/overrides.usd")
# Author override opinions # Author override opinions
with Usd.EditContext(override_stage, override_stage.GetRootLayer()): with Usd.EditContext(override_stage, override_stage.GetRootLayer()):
prim = override_stage.GetPrimAtPath("/Root/Assembly/Node/ring_outer") prim = override_stage.GetPrimAtPath("/Root/Assembly/Node/ring_outer")
prim.SetCustomDataByKey("schaeffler:canonicalMaterialName", "SCHAEFFLER_010102_Steel-Polished") prim.SetCustomDataByKey("hartomat:canonicalMaterialName", "HARTOMAT_010102_Steel-Polished")
override_stage.Save() override_stage.Save()
@@ -159,8 +159,8 @@ for obj in bpy.context.scene.objects:
if obj.type != 'MESH': if obj.type != 'MESH':
continue continue
# Blender maps USD primvars to custom attributes # Blender maps USD primvars to custom attributes
seam_attr = obj.data.attributes.get("schaeffler:seamEdgeVertexPairs") seam_attr = obj.data.attributes.get("hartomat:seamEdgeVertexPairs")
sharp_attr = obj.data.attributes.get("schaeffler:sharpEdgeVertexPairs") sharp_attr = obj.data.attributes.get("hartomat:sharpEdgeVertexPairs")
if seam_attr: if seam_attr:
_mark_seams_from_index_pairs(obj, seam_attr.data) _mark_seams_from_index_pairs(obj, seam_attr.data)
if sharp_attr: if sharp_attr:
@@ -260,14 +260,14 @@ from pxr import Usd
stage = Usd.Stage.Open('/tmp/test.usd') stage = Usd.Stage.Open('/tmp/test.usd')
for prim in stage.Traverse(): for prim in stage.Traverse():
if prim.GetTypeName() == 'Mesh': if prim.GetTypeName() == 'Mesh':
print(prim.GetPath(), '| partKey:', prim.GetCustomDataByKey('schaeffler:partKey')) print(prim.GetPath(), '| partKey:', prim.GetCustomDataByKey('hartomat:partKey'))
" "
# Count parts with partKey # Count parts with partKey
docker compose exec render-worker python3 -c " docker compose exec render-worker python3 -c "
from pxr import Usd from pxr import Usd
stage = Usd.Stage.Open('/tmp/test.usd') stage = Usd.Stage.Open('/tmp/test.usd')
parts = [p for p in stage.Traverse() if p.GetCustomDataByKey('schaeffler:partKey')] parts = [p for p in stage.Traverse() if p.GetCustomDataByKey('hartomat:partKey')]
print(f'{len(parts)} parts with partKey') print(f'{len(parts)} parts with partKey')
" "
``` ```
+1 -1
View File
@@ -88,7 +88,7 @@ For each suggestion include:
Structure your final report as follows: Structure your final report as follows:
# Schaeffler Automat — UX & Quality Audit Report # HartOMat — UX & Quality Audit Report
**Date**: [date] **Date**: [date]
**Overall Score**: [X/10] **Overall Score**: [X/10]
+1 -1
View File
@@ -10,7 +10,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "python3 /home/hartmut/Documents/Copilot/schaefflerautomat/.claude/hooks/pre_tool_use.py" "command": "python3 /home/hartmut/Documents/Copilot/hartomat/.claude/hooks/pre_tool_use.py"
} }
] ]
} }
+3 -3
View File
@@ -1,7 +1,7 @@
# Database # Database
POSTGRES_DB=schaeffler POSTGRES_DB=hartomat
POSTGRES_USER=schaeffler POSTGRES_USER=hartomat
POSTGRES_PASSWORD=schaeffler POSTGRES_PASSWORD=hartomat
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"mcpServers": { "mcpServers": {
"schaeffler": { "hartomat": {
"command": "uv", "command": "uv",
"args": ["run", "--with", "mcp[cli]", "--with", "psycopg2-binary", "--with", "httpx", "python", "schaeffler_mcp_server.py"], "args": ["run", "--with", "mcp[cli]", "--with", "psycopg2-binary", "--with", "httpx", "python", "hartomat_mcp_server.py"],
"env": { "env": {
"DATABASE_URL": "postgresql://schaeffler:schaeffler@localhost:5432/schaeffler", "DATABASE_URL": "postgresql://hartomat:hartomat@localhost:5432/hartomat",
"API_URL": "http://localhost:8888", "API_URL": "http://localhost:8888",
"API_EMAIL": "admin@schaeffler.com", "API_EMAIL": "admin@hartomat.com",
"API_PASSWORD": "Admin1234!" "API_PASSWORD": "Admin1234!"
} }
} }
+5 -5
View File
@@ -1,8 +1,8 @@
# Schaeffler Automat # HartOMat
## Ziel ## Ziel
Automatisiertes Render-System für Schaeffler-Produktbilder. Kunden (intern) laden Excel-Auftragslisten hoch, das System extrahiert Produktdaten, verknüpft STEP-CAD-Dateien, rendert Thumbnails und Animationen über Blender (Cycles/EEVEE), und liefert fertige PNG/MP4-Ausgaben. Automatisiertes Render-System für HartOMat-Produktbilder. Kunden (intern) laden Excel-Auftragslisten hoch, das System extrahiert Produktdaten, verknüpft STEP-CAD-Dateien, rendert Thumbnails und Animationen über Blender (Cycles/EEVEE), und liefert fertige PNG/MP4-Ausgaben.
## Tech Stack ## Tech Stack
@@ -48,14 +48,14 @@ docker compose up -d --build backend worker render-worker beat
## Standard-Zugangsdaten (Entwicklung) ## Standard-Zugangsdaten (Entwicklung)
- **Admin**: admin@schaeffler.com / Admin1234! - **Admin**: admin@hartomat.com / Admin1234!
- **Backend API**: http://localhost:8888/docs - **Backend API**: http://localhost:8888/docs
- **Frontend**: http://localhost:5173 - **Frontend**: http://localhost:5173
## Projektstruktur ## Projektstruktur
``` ```
schaefflerautomat/ hartomat/
├── backend/ ├── backend/
│ ├── app/ │ ├── app/
│ │ ├── api/routers/ # FastAPI Router (admin, cad, orders, products, ...) │ │ ├── api/routers/ # FastAPI Router (admin, cad, orders, products, ...)
@@ -138,7 +138,7 @@ docker compose exec backend alembic current
## Material-Alias-System ## Material-Alias-System
- Materialien werden per STEP-Part-Name auf Schaeffler-Bibliotheksmaterialien (`SCHAEFFLER_...`) gemappt - Materialien werden per STEP-Part-Name auf HartOMat-Bibliotheksmaterialien (`HARTOMAT_...`) gemappt
- Lookup-Reihenfolge: **Alias-Tabelle zuerst**, dann exakter `Material.name`-Match, dann Pass-through - Lookup-Reihenfolge: **Alias-Tabelle zuerst**, dann exakter `Material.name`-Match, dann Pass-through
- Alias-Seeding: Admin → "Seed Aliases" oder via `POST /api/materials/seed-aliases` - Alias-Seeding: Admin → "Seed Aliases" oder via `POST /api/materials/seed-aliases`
+4 -4
View File
@@ -1,4 +1,4 @@
# Projekt-Learnings — Schaeffler Automat # Projekt-Learnings — HartOMat
## Format ## Format
**Datum | Kategorie | Problem → Lösung** **Datum | Kategorie | Problem → Lösung**
@@ -52,7 +52,7 @@ STEP in mm, Blender in m → 50mm-Lager erscheint 50m breit. `_scale_mm_to_m(par
**Lösung:** Compositing entfernt; bg_color via FFmpeg (`-f lavfi -i color=...` + overlay). Pflicht: `try: main() except SystemExit: raise except Exception: traceback; sys.exit(1)` in allen Blender-Scripts. **Lösung:** Compositing entfernt; bg_color via FFmpeg (`-f lavfi -i color=...` + overlay). Pflicht: `try: main() except SystemExit: raise except Exception: traceback; sys.exit(1)` in allen Blender-Scripts.
### 2026-02-05 | Material-System | Material-Alias-Lookup-Reihenfolge falsch ### 2026-02-05 | Material-System | Material-Alias-Lookup-Reihenfolge falsch
`Steel--Stahl` war sowohl `Material.name` als auch Alias für `SCHAEFFLER_010101_Steel-Bare`. Lookup fand zuerst den Namen → Blender konnte ihn nicht in der Library finden. `Steel--Stahl` war sowohl `Material.name` als auch Alias für `HARTOMAT_010101_Steel-Bare`. Lookup fand zuerst den Namen → Blender konnte ihn nicht in der Library finden.
**Lösung:** `material_service.py`: **Aliases zuerst**, dann exakter Name, dann Pass-through. **Lösung:** `material_service.py`: **Aliases zuerst**, dann exakter Name, dann Pass-through.
### 2026-02-10 | Render-Pipeline | Blender-Template überschreibt HDRI/World ### 2026-02-10 | Render-Pipeline | Blender-Template überschreibt HDRI/World
@@ -282,7 +282,7 @@ Absolute Pfade in `storage_key` → nach Volume-Umzug 398 Assets nicht erreichba
**Lösung:** `api.get(..., { responseType: 'blob' })``URL.createObjectURL()` + programmatischer `<a>.click()`. Gilt für alle geschützten Download-Endpoints. **Lösung:** `api.get(..., { responseType: 'blob' })``URL.createObjectURL()` + programmatischer `<a>.click()`. Gilt für alle geschützten Download-Endpoints.
### 2026-03-07 | PostgreSQL RLS | SET LOCAL muss in jeder Transaktion gesetzt werden ### 2026-03-07 | PostgreSQL RLS | SET LOCAL muss in jeder Transaktion gesetzt werden
`GRANT BYPASSRLS TO schaeffler` schlug still fehl → Admin-Endpoints bekamen 0 Zeilen. `GRANT BYPASSRLS TO hartomat` schlug still fehl → Admin-Endpoints bekamen 0 Zeilen.
**Lösung:** `await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))` direkt vor jede RLS-geschützte Query in internen/Admin-Endpoints. **Lösung:** `await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))` direkt vor jede RLS-geschützte Query in internen/Admin-Endpoints.
### 2026-03-07 | trimesh | GLB-Export-Scale: STL in mm → Three.js in Metern ### 2026-03-07 | trimesh | GLB-Export-Scale: STL in mm → Three.js in Metern
@@ -378,7 +378,7 @@ Bei Mesh-Name-Mismatch-Bugs: GLB-Datei direkt parsen statt im Browser debuggen.
```python ```python
import urllib.request, json, struct import urllib.request, json, struct
# Login # Login
data = json.dumps({'email':'admin@schaeffler.com','password':'Admin1234!'}).encode() data = json.dumps({'email':'admin@hartomat.com','password':'Admin1234!'}).encode()
req = urllib.request.Request('http://localhost:8888/api/auth/login', data=data, headers={'Content-Type':'application/json'}) req = urllib.request.Request('http://localhost:8888/api/auth/login', data=data, headers={'Content-Type':'application/json'})
token = json.load(urllib.request.urlopen(req))['access_token'] token = json.load(urllib.request.urlopen(req))['access_token']
# Media-Assets für CAD-File # Media-Assets für CAD-File
+38 -38
View File
@@ -1,4 +1,4 @@
"""Generate material_library.blend with all 35 Schaeffler standard materials. """Generate material_library.blend with all 35 HartOMat standard materials.
Run with: blender --background --python generate_blend.py Run with: blender --background --python generate_blend.py
""" """
@@ -9,51 +9,51 @@ import os
# Format: (R, G, B, A) linear color, metallic, roughness # Format: (R, G, B, A) linear color, metallic, roughness
MATERIALS = [ MATERIALS = [
# --- 01 Metals --- # --- 01 Metals ---
("SCHAEFFLER_010101_Steel-Bare", (0.55, 0.56, 0.58, 1.0), 1.0, 0.35), ("HARTOMAT_010101_Steel-Bare", (0.55, 0.56, 0.58, 1.0), 1.0, 0.35),
("SCHAEFFLER_010102_Steel-Burnished", (0.15, 0.12, 0.10, 1.0), 1.0, 0.25), ("HARTOMAT_010102_Steel-Burnished", (0.15, 0.12, 0.10, 1.0), 1.0, 0.25),
("SCHAEFFLER_010103_Steel-Galvanized", (0.65, 0.67, 0.70, 1.0), 1.0, 0.40), ("HARTOMAT_010103_Steel-Galvanized", (0.65, 0.67, 0.70, 1.0), 1.0, 0.40),
("SCHAEFFLER_010104_Steel-Casted", (0.35, 0.33, 0.31, 1.0), 1.0, 0.60), ("HARTOMAT_010104_Steel-Casted", (0.35, 0.33, 0.31, 1.0), 1.0, 0.60),
("SCHAEFFLER_010105_Steel-Plate", (0.50, 0.51, 0.53, 1.0), 1.0, 0.30), ("HARTOMAT_010105_Steel-Plate", (0.50, 0.51, 0.53, 1.0), 1.0, 0.30),
("SCHAEFFLER_010201_Niro", (0.70, 0.72, 0.74, 1.0), 1.0, 0.20), ("HARTOMAT_010201_Niro", (0.70, 0.72, 0.74, 1.0), 1.0, 0.20),
("SCHAEFFLER_010301_Tin", (0.75, 0.75, 0.73, 1.0), 1.0, 0.30), ("HARTOMAT_010301_Tin", (0.75, 0.75, 0.73, 1.0), 1.0, 0.30),
("SCHAEFFLER_010401_Aluminium", (0.80, 0.80, 0.82, 1.0), 1.0, 0.25), ("HARTOMAT_010401_Aluminium", (0.80, 0.80, 0.82, 1.0), 1.0, 0.25),
("SCHAEFFLER_010501_Brass", (0.70, 0.55, 0.20, 1.0), 1.0, 0.25), ("HARTOMAT_010501_Brass", (0.70, 0.55, 0.20, 1.0), 1.0, 0.25),
("SCHAEFFLER_010601_Bronze", (0.55, 0.35, 0.15, 1.0), 1.0, 0.30), ("HARTOMAT_010601_Bronze", (0.55, 0.35, 0.15, 1.0), 1.0, 0.30),
# --- 02 Coatings --- # --- 02 Coatings ---
("SCHAEFFLER_020101_Durotect-Blue", (0.15, 0.25, 0.50, 1.0), 0.8, 0.20), ("HARTOMAT_020101_Durotect-Blue", (0.15, 0.25, 0.50, 1.0), 0.8, 0.20),
("SCHAEFFLER_020102_Durotect-Black", (0.05, 0.05, 0.06, 1.0), 0.8, 0.15), ("HARTOMAT_020102_Durotect-Black", (0.05, 0.05, 0.06, 1.0), 0.8, 0.15),
("SCHAEFFLER_020201_Coat-Black", (0.03, 0.03, 0.03, 1.0), 0.6, 0.10), ("HARTOMAT_020201_Coat-Black", (0.03, 0.03, 0.03, 1.0), 0.6, 0.10),
# --- 03 Non-metals --- # --- 03 Non-metals ---
("SCHAEFFLER_030101_Elastomer-Brown", (0.30, 0.18, 0.08, 1.0), 0.0, 0.55), ("HARTOMAT_030101_Elastomer-Brown", (0.30, 0.18, 0.08, 1.0), 0.0, 0.55),
("SCHAEFFLER_030102_Elastomer-Green", (0.10, 0.30, 0.10, 1.0), 0.0, 0.55), ("HARTOMAT_030102_Elastomer-Green", (0.10, 0.30, 0.10, 1.0), 0.0, 0.55),
("SCHAEFFLER_030103_Elastomer-Black", (0.04, 0.04, 0.04, 1.0), 0.0, 0.55), ("HARTOMAT_030103_Elastomer-Black", (0.04, 0.04, 0.04, 1.0), 0.0, 0.55),
("SCHAEFFLER_030201_Plastic-Brown", (0.35, 0.22, 0.10, 1.0), 0.0, 0.40), ("HARTOMAT_030201_Plastic-Brown", (0.35, 0.22, 0.10, 1.0), 0.0, 0.40),
("SCHAEFFLER_030202_Plastic-Green", (0.08, 0.35, 0.12, 1.0), 0.0, 0.40), ("HARTOMAT_030202_Plastic-Green", (0.08, 0.35, 0.12, 1.0), 0.0, 0.40),
("SCHAEFFLER_030203_Plastic-Black", (0.02, 0.02, 0.02, 1.0), 0.0, 0.40), ("HARTOMAT_030203_Plastic-Black", (0.02, 0.02, 0.02, 1.0), 0.0, 0.40),
("SCHAEFFLER_030204_Plastic-Blue", (0.10, 0.20, 0.50, 1.0), 0.0, 0.40), ("HARTOMAT_030204_Plastic-Blue", (0.10, 0.20, 0.50, 1.0), 0.0, 0.40),
("SCHAEFFLER_030205_Plastic-White", (0.85, 0.85, 0.85, 1.0), 0.0, 0.40), ("HARTOMAT_030205_Plastic-White", (0.85, 0.85, 0.85, 1.0), 0.0, 0.40),
("SCHAEFFLER_030301_Plastic-Clear", (0.90, 0.90, 0.92, 1.0), 0.0, 0.10), # + transmission ("HARTOMAT_030301_Plastic-Clear", (0.90, 0.90, 0.92, 1.0), 0.0, 0.10), # + transmission
("SCHAEFFLER_030302_Plastic-Translucent-White", (0.80, 0.80, 0.82, 1.0), 0.0, 0.20), # + transmission ("HARTOMAT_030302_Plastic-Translucent-White", (0.80, 0.80, 0.82, 1.0), 0.0, 0.20), # + transmission
("SCHAEFFLER_030401_TPU-Blue", (0.12, 0.25, 0.55, 1.0), 0.0, 0.45), ("HARTOMAT_030401_TPU-Blue", (0.12, 0.25, 0.55, 1.0), 0.0, 0.45),
("SCHAEFFLER_030501_Ceramic-Black", (0.03, 0.03, 0.04, 1.0), 0.0, 0.15), ("HARTOMAT_030501_Ceramic-Black", (0.03, 0.03, 0.04, 1.0), 0.0, 0.15),
# --- 04 Compounds --- # --- 04 Compounds ---
("SCHAEFFLER_040101_E40", (0.25, 0.22, 0.18, 1.0), 0.0, 0.50), ("HARTOMAT_040101_E40", (0.25, 0.22, 0.18, 1.0), 0.0, 0.50),
("SCHAEFFLER_040102_E50", (0.28, 0.25, 0.20, 1.0), 0.0, 0.50), ("HARTOMAT_040102_E50", (0.28, 0.25, 0.20, 1.0), 0.0, 0.50),
("SCHAEFFLER_040201_Elgoglide", (0.20, 0.22, 0.25, 1.0), 0.0, 0.35), ("HARTOMAT_040201_Elgoglide", (0.20, 0.22, 0.25, 1.0), 0.0, 0.35),
("SCHAEFFLER_040202_Elgotex", (0.05, 0.05, 0.06, 1.0), 0.0, 0.35), ("HARTOMAT_040202_Elgotex", (0.05, 0.05, 0.06, 1.0), 0.0, 0.35),
("SCHAEFFLER_040301_PTFE-Niro-Compound", (0.60, 0.62, 0.65, 1.0), 0.3, 0.25), ("HARTOMAT_040301_PTFE-Niro-Compound", (0.60, 0.62, 0.65, 1.0), 0.3, 0.25),
("SCHAEFFLER_040302_PTFE-Foil", (0.85, 0.85, 0.82, 1.0), 0.0, 0.15), ("HARTOMAT_040302_PTFE-Foil", (0.85, 0.85, 0.82, 1.0), 0.0, 0.15),
("SCHAEFFLER_040303_PTFE-Compound-Black", (0.04, 0.04, 0.05, 1.0), 0.0, 0.30), ("HARTOMAT_040303_PTFE-Compound-Black", (0.04, 0.04, 0.05, 1.0), 0.0, 0.30),
("SCHAEFFLER_040304_PTFE-Compound-Orange", (0.70, 0.35, 0.08, 1.0), 0.0, 0.30), ("HARTOMAT_040304_PTFE-Compound-Orange", (0.70, 0.35, 0.08, 1.0), 0.0, 0.30),
("SCHAEFFLER_040305_GFK-PTFE-Compound", (0.08, 0.10, 0.08, 1.0), 0.0, 0.45), ("HARTOMAT_040305_GFK-PTFE-Compound", (0.08, 0.10, 0.08, 1.0), 0.0, 0.45),
# --- 05 Misc --- # --- 05 Misc ---
("SCHAEFFLER_059999_FailedMaterial", (1.00, 0.00, 0.50, 1.0), 0.0, 0.50), ("HARTOMAT_059999_FailedMaterial", (1.00, 0.00, 0.50, 1.0), 0.0, 0.50),
] ]
# Translucent materials that need transmission # Translucent materials that need transmission
TRANSLUCENT = { TRANSLUCENT = {
"SCHAEFFLER_030301_Plastic-Clear": 0.9, "HARTOMAT_030301_Plastic-Clear": 0.9,
"SCHAEFFLER_030302_Plastic-Translucent-White": 0.5, "HARTOMAT_030302_Plastic-Translucent-White": 0.5,
} }
+10 -10
View File
@@ -1,4 +1,4 @@
# Schaeffler Automat — Master Roadmap # HartOMat — Master Roadmap
> **Consolidated:** 2026-03-11 > **Consolidated:** 2026-03-11
> **Branch:** `refactor/v2` > **Branch:** `refactor/v2`
@@ -53,7 +53,7 @@ Verified against the repository on `2026-03-13`.
This roadmap now treats the USD refactor as an implementation workstream, not as a blocked strategic idea. This roadmap now treats the USD refactor as an implementation workstream, not as a blocked strategic idea.
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: The key architectural clarification from [docs/rfcs/0001-step-to-usd-workflow.md](/home/hartmut/Documents/Copilot/hartomat/docs/rfcs/0001-step-to-usd-workflow.md#L139) is:
- USD becomes the canonical persisted scene asset - USD becomes the canonical persisted scene asset
- the browser does not need to render USD directly - the browser does not need to render USD directly
@@ -112,7 +112,7 @@ This priority combines dead-code deletion and task decomposition because both ar
**Status:** Not started in code. Architecture decisions are documented, but repo work has not begun. **Status:** Not started in code. Architecture decisions are documented, but repo work has not begun.
**Milestones:** **Milestones:**
- M1: `export_step_to_usd.py` produces valid USD with part hierarchy and `schaeffler:partKey` on every prim - M1: `export_step_to_usd.py` produces valid USD with part hierarchy and `hartomat:partKey` on every prim
- M2: `usd_master` MediaAsset type exists in DB and is stored after each export - 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 - 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 - M4: `PUT /api/cad/{id}/part-materials` accepts `{partKey → materialName}` map and persists it
@@ -139,7 +139,7 @@ This priority combines dead-code deletion and task decomposition because both ar
- None blocking at the architecture level. The roadmap decisions for `usd-core` and index-space seam/sharp primvars are already captured in `docs/plans/0001-step-to-usd-implementation.md`. - None blocking at the architecture level. The roadmap decisions for `usd-core` and index-space seam/sharp primvars are already captured in `docs/plans/0001-step-to-usd-implementation.md`.
**Acceptance gates:** **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 - `python3 export_step_to_usd.py --step_path 81113-l_cut.stp` → valid `.usd` file, 25 part prims, each has `hartomat:partKey` attribute
- `GET /api/cad/{id}/scene-manifest` returns `parts[]` array with `part_key`, `source_name`, `effective_material`, `is_unassigned` - `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) - 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 - CAD file with mismatched Excel names: UI shows `unmatched_source_rows` count > 0 and unassigned parts highlighted
@@ -226,7 +226,7 @@ This priority combines dead-code deletion and task decomposition because both ar
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. 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) - 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 - Write `hartomat: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) - `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 - Acceptance: Blender log shows `25/25 parts matched` for material assignment from USD
@@ -244,20 +244,20 @@ Currently `generate_usd_master_task` does not resolve the material map before pa
- `generate_usd_master_task` must resolve `material_map` via `material_service.resolve_material_map()` and pass to subprocess - `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 - 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 - Acceptance: USD file inspected via `pxr` shows `hartomat:canonicalMaterialName` on every mesh prim
**File targets:** **File targets:**
| Action | Path | | Action | Path |
|---|---| |---|---|
| CREATE | `render-worker/scripts/export_step_to_usd.py` — STEP→USD exporter (seam/sharp payload on mesh prims) | | 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 | | CREATE | `render-worker/scripts/import_usd.py` — Blender USD import helper: reads `primvars:hartomat:seamEdgeVertexPairs`, marks seam+sharp |
| MODIFY | `render-worker/scripts/blender_render.py` — accept `--usd_path` flag alongside `--glb_path` | | 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/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 | | 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 | | 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/export_step_to_usd.py` — accept `--material_map` CLI arg, write `hartomat: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 (M5) | `render-worker/scripts/import_usd.py` — read `hartomat: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 (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 | | MODIFY (M7) | `backend/app/domains/pipeline/tasks/export_glb.py` (or USD task) — call `resolve_material_map()` and pass result to export subprocess |
@@ -268,7 +268,7 @@ Currently `generate_usd_master_task` does not resolve the material map before pa
- Turntable MP4 plays without texture or material pop artifacts - 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) - (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) - (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 - (M7) `python3 -c "from pxr import Usd; stage=Usd.Stage.Open('usd_master.usd'); [print(p.GetAttribute('primvars:hartomat:canonicalMaterialName').Get()) for p in stage.Traverse()]"` → prints canonical material name for every mesh prim
### Priority 6 — Admin and Product Surface Simplification ### Priority 6 — Admin and Product Surface Simplification
+1 -1
View File
@@ -2,7 +2,7 @@
script_location = alembic script_location = alembic
prepend_sys_path = . prepend_sys_path = .
version_path_separator = os version_path_separator = os
sqlalchemy.url = postgresql://schaeffler:schaeffler@localhost:5432/schaeffler sqlalchemy.url = postgresql://hartomat:hartomat@localhost:5432/hartomat
[post_write_hooks] [post_write_hooks]
@@ -1,4 +1,4 @@
"""Schaeffler standard materials — add schaeffler_code column and seed 35 materials """HartOMat standard materials — add hartomat_code column and seed 35 materials
Revision ID: 019 Revision ID: 019
Revises: 018 Revises: 018
@@ -16,23 +16,23 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
op.add_column("materials", sa.Column("schaeffler_code", sa.Integer(), nullable=True)) op.add_column("materials", sa.Column("hartomat_code", sa.Integer(), nullable=True))
from app.data.schaeffler_materials import SCHAEFFLER_MATERIALS from app.data.hartomat_materials import HARTOMAT_MATERIALS
conn = op.get_bind() conn = op.get_bind()
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
for mat in SCHAEFFLER_MATERIALS: for mat in HARTOMAT_MATERIALS:
desc = mat["description"].replace("'", "''") desc = mat["description"].replace("'", "''")
name = mat["name"].replace("'", "''") name = mat["name"].replace("'", "''")
conn.execute(sa.text( conn.execute(sa.text(
f"INSERT INTO materials (id, name, description, source, schaeffler_code, created_at, updated_at) " f"INSERT INTO materials (id, name, description, source, hartomat_code, created_at, updated_at) "
f"VALUES ('{uuid.uuid4()}', '{name}', '{desc}', '{mat['source']}', " f"VALUES ('{uuid.uuid4()}', '{name}', '{desc}', '{mat['source']}', "
f"{mat['schaeffler_code']}, '{now}', '{now}') " f"{mat['hartomat_code']}, '{now}', '{now}') "
f"ON CONFLICT (name) DO NOTHING" f"ON CONFLICT (name) DO NOTHING"
)) ))
def downgrade() -> None: def downgrade() -> None:
op.execute("DELETE FROM materials WHERE source = 'schaeffler_standard'") op.execute("DELETE FROM materials WHERE source = 'hartomat_standard'")
op.drop_column("materials", "schaeffler_code") op.drop_column("materials", "hartomat_code")
+1 -1
View File
@@ -28,7 +28,7 @@ def upgrade():
# Seed default tenant — all existing data will be assigned to this tenant # Seed default tenant — all existing data will be assigned to this tenant
op.execute(""" op.execute("""
INSERT INTO tenants (name, slug, is_active) INSERT INTO tenants (name, slug, is_active)
VALUES ('Schaeffler', 'schaeffler', true) VALUES ('HartOMat', 'hartomat', true)
""") """)
+2 -2
View File
@@ -74,10 +74,10 @@ def upgrade():
), ),
) )
# 2. Backfill with the default 'schaeffler' tenant # 2. Backfill with the default 'hartomat' tenant
op.execute( op.execute(
f"UPDATE {table} " f"UPDATE {table} "
"SET tenant_id = (SELECT id FROM tenants WHERE slug = 'schaeffler')" "SET tenant_id = (SELECT id FROM tenants WHERE slug = 'hartomat')"
) )
# 3. Make NOT NULL now that every row has a value # 3. Make NOT NULL now that every row has a value
@@ -20,7 +20,7 @@ _DEFAULT_CONFIG = """{
"max_concurrent_renders": 3, "max_concurrent_renders": 3,
"render_engines_allowed": ["cycles", "eevee"], "render_engines_allowed": ["cycles", "eevee"],
"max_order_size": 500, "max_order_size": 500,
"fallback_material": "SCHAEFFLER_059999_FailedMaterial", "fallback_material": "HARTOMAT_059999_FailedMaterial",
"notifications_enabled": true, "notifications_enabled": true,
"invoice_prefix": "INV" "invoice_prefix": "INV"
}""" }"""
@@ -0,0 +1,106 @@
"""Backfill persisted Schaeffler branding to HartOMat.
Revision ID: 063
Revises: 062
"""
from alembic import op
import sqlalchemy as sa
revision = "063"
down_revision = "062"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
material_columns = {column["name"] for column in inspector.get_columns("materials")}
if "schaeffler_code" in material_columns and "hartomat_code" not in material_columns:
op.alter_column("materials", "schaeffler_code", new_column_name="hartomat_code")
op.execute(
"""
UPDATE materials
SET name = REPLACE(name, 'SCHAEFFLER_', 'HARTOMAT_')
WHERE name LIKE 'SCHAEFFLER_%'
"""
)
op.execute(
"""
UPDATE materials
SET source = 'hartomat_standard'
WHERE source = 'schaeffler_standard'
"""
)
op.execute(
"""
UPDATE tenants
SET name = 'HartOMat',
slug = 'hartomat'
WHERE lower(name) = 'schaeffler'
OR lower(slug) = 'schaeffler'
"""
)
op.execute(
"""
UPDATE tenants
SET tenant_config = jsonb_set(
tenant_config,
'{fallback_material}',
'\"HARTOMAT_059999_FailedMaterial\"'::jsonb,
true
)
WHERE tenant_config ? 'fallback_material'
"""
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
material_columns = {column["name"] for column in inspector.get_columns("materials")}
op.execute(
"""
UPDATE materials
SET name = REPLACE(name, 'HARTOMAT_', 'SCHAEFFLER_')
WHERE name LIKE 'HARTOMAT_%'
"""
)
op.execute(
"""
UPDATE materials
SET source = 'schaeffler_standard'
WHERE source = 'hartomat_standard'
"""
)
op.execute(
"""
UPDATE tenants
SET name = 'Schaeffler',
slug = 'schaeffler'
WHERE lower(name) = 'hartomat'
OR lower(slug) = 'hartomat'
"""
)
op.execute(
"""
UPDATE tenants
SET tenant_config = jsonb_set(
tenant_config,
'{fallback_material}',
'\"SCHAEFFLER_059999_FailedMaterial\"'::jsonb,
true
)
WHERE tenant_config ? 'fallback_material'
"""
)
if "hartomat_code" in material_columns and "schaeffler_code" not in material_columns:
op.alter_column("materials", "hartomat_code", new_column_name="schaeffler_code")
+2 -2
View File
@@ -1014,9 +1014,9 @@ async def get_dashboard_stats(
if isinstance(entry, dict) and entry.get("material"): if isinstance(entry, dict) and entry.get("material"):
all_mat_names.add(entry["material"]) all_mat_names.add(entry["material"])
# Library materials (name starts with SCHAEFFLER_) # Library materials (name starts with HARTOMAT_)
lib_count_result = await db.execute( lib_count_result = await db.execute(
select(func.count(Material.id)).where(Material.name.like("SCHAEFFLER_%")) select(func.count(Material.id)).where(Material.name.like("HARTOMAT_%"))
) )
library_material_count = lib_count_result.scalar() or 0 library_material_count = lib_count_result.scalar() or 0
+1 -1
View File
@@ -89,7 +89,7 @@ async def get_material_pbr_map(db: AsyncSession = Depends(get_db)):
} }
# Also index by aliases so frontend can look up by raw Excel names # Also index by aliases so frontend can look up by raw Excel names
# (e.g. "Steel--Stahl" → same PBR as "SCHAEFFLER_010101_Steel-Bare") # (e.g. "Steel--Stahl" → same PBR as "HARTOMAT_010101_Steel-Bare")
# Bypass RLS — this is public data and aliases may have NULL tenant_id # Bypass RLS — this is public data and aliases may have NULL tenant_id
if pbr_map: if pbr_map:
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'")) await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
+14 -14
View File
@@ -23,7 +23,7 @@ class MaterialOut(BaseModel):
name: str name: str
description: str | None description: str | None
source: str source: str
schaeffler_code: int | None = None hartomat_code: int | None = None
created_by_name: str | None = None created_by_name: str | None = None
aliases: list[str] = [] aliases: list[str] = []
created_at: datetime created_at: datetime
@@ -42,7 +42,7 @@ class MaterialCreate(BaseModel):
name: str name: str
description: str | None = None description: str | None = None
source: str = "manual" source: str = "manual"
schaeffler_code: int | None = None hartomat_code: int | None = None
class MaterialUpdate(BaseModel): class MaterialUpdate(BaseModel):
@@ -64,7 +64,7 @@ def _to_out(mat: Material) -> MaterialOut:
name=mat.name, name=mat.name,
description=mat.description, description=mat.description,
source=mat.source, source=mat.source,
schaeffler_code=mat.schaeffler_code, hartomat_code=mat.hartomat_code,
created_by_name=creator_name, created_by_name=creator_name,
aliases=alias_names, aliases=alias_names,
created_at=mat.created_at, created_at=mat.created_at,
@@ -94,9 +94,9 @@ async def get_next_code(
range_end = prefix_int + 99 range_end = prefix_int + 99
result = await db.execute( result = await db.execute(
select(func.max(Material.schaeffler_code)).where( select(func.max(Material.hartomat_code)).where(
Material.schaeffler_code >= range_start, Material.hartomat_code >= range_start,
Material.schaeffler_code <= range_end, Material.hartomat_code <= range_end,
) )
) )
max_code = result.scalar_one_or_none() max_code = result.scalar_one_or_none()
@@ -113,16 +113,16 @@ async def get_next_code(
} }
@router.post("/seed-schaeffler") @router.post("/seed-hartomat")
async def seed_schaeffler_materials( async def seed_hartomat_materials(
user: User = Depends(require_admin_or_pm), user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Bulk-create the 35 standard Schaeffler materials. Skips existing by name.""" """Bulk-create the 35 standard HartOMat materials. Skips existing by name."""
from app.data.schaeffler_materials import SCHAEFFLER_MATERIALS from app.data.hartomat_materials import HARTOMAT_MATERIALS
inserted = 0 inserted = 0
for mat_data in SCHAEFFLER_MATERIALS: for mat_data in HARTOMAT_MATERIALS:
existing = await db.execute( existing = await db.execute(
select(Material).where(Material.name == mat_data["name"]) select(Material).where(Material.name == mat_data["name"])
) )
@@ -132,14 +132,14 @@ async def seed_schaeffler_materials(
name=mat_data["name"], name=mat_data["name"],
description=mat_data["description"], description=mat_data["description"],
source=mat_data["source"], source=mat_data["source"],
schaeffler_code=mat_data["schaeffler_code"], hartomat_code=mat_data["hartomat_code"],
created_by=user.id, created_by=user.id,
) )
db.add(mat) db.add(mat)
inserted += 1 inserted += 1
await db.commit() await db.commit()
return {"inserted": inserted, "total": len(SCHAEFFLER_MATERIALS)} return {"inserted": inserted, "total": len(HARTOMAT_MATERIALS)}
@router.post("/seed-aliases") @router.post("/seed-aliases")
@@ -273,7 +273,7 @@ async def create_material(
name=body.name, name=body.name,
description=body.description, description=body.description,
source=body.source, source=body.source,
schaeffler_code=body.schaeffler_code, hartomat_code=body.hartomat_code,
created_by=user.id, created_by=user.id,
) )
db.add(mat) db.add(mat)
+2 -2
View File
@@ -475,8 +475,8 @@ async def scale_workers(
compose_file = os.path.join(compose_dir, "docker-compose.yml") compose_file = os.path.join(compose_dir, "docker-compose.yml")
# Derive project name from compose dir on host (directory name = project name). # Derive project name from compose dir on host (directory name = project name).
# Inside the container the compose file is at /compose, but the host project # Inside the container the compose file is at /compose, but the host project
# dir name determines the container naming prefix (e.g. "schaefflerautomat"). # dir name determines the container naming prefix (e.g. "hartomat").
compose_project = os.environ.get("COMPOSE_PROJECT_NAME", "schaefflerautomat") compose_project = os.environ.get("COMPOSE_PROJECT_NAME", "hartomat")
def _scale() -> subprocess.CompletedProcess: def _scale() -> subprocess.CompletedProcess:
return subprocess.run( return subprocess.run(
+3 -3
View File
@@ -4,9 +4,9 @@ from typing import Optional
class Settings(BaseSettings): class Settings(BaseSettings):
# Database # Database
postgres_db: str = "schaeffler" postgres_db: str = "hartomat"
postgres_user: str = "schaeffler" postgres_user: str = "hartomat"
postgres_password: str = "schaeffler" postgres_password: str = "hartomat"
postgres_host: str = "localhost" postgres_host: str = "localhost"
postgres_port: int = 5432 postgres_port: int = 5432
+48
View File
@@ -0,0 +1,48 @@
"""HartOMat standard materials — single source of truth.
Naming convention: HARTOMAT_[TypeCode(2)][SubType(2)][Consecutive(2)]_[Name-Parts-Dashed]
Type codes: 01=Metals, 02=Coatings, 03=Non-metals, 04=Compounds, 05=Misc
"""
HARTOMAT_MATERIALS: list[dict] = [
# --- 01 Metals ---
{"name": "HARTOMAT_010101_Steel-Bare", "description": "Stahl / Stahl, glänzend / Stahl, konserviert", "hartomat_code": 10101, "source": "hartomat_standard"},
{"name": "HARTOMAT_010102_Steel-Burnished", "description": "Stahl, brüniert", "hartomat_code": 10102, "source": "hartomat_standard"},
{"name": "HARTOMAT_010103_Steel-Galvanized", "description": "Stahl, verzinkt", "hartomat_code": 10103, "source": "hartomat_standard"},
{"name": "HARTOMAT_010104_Steel-Casted", "description": "Stahl Körnung", "hartomat_code": 10104, "source": "hartomat_standard"},
{"name": "HARTOMAT_010105_Steel-Plate", "description": "Stahlblech", "hartomat_code": 10105, "source": "hartomat_standard"},
{"name": "HARTOMAT_010201_Niro", "description": "Niro", "hartomat_code": 10201, "source": "hartomat_standard"},
{"name": "HARTOMAT_010301_Tin", "description": "MU-Stahl, Zinnüberzug / MX-Stahl, Zinnüberzug", "hartomat_code": 10301, "source": "hartomat_standard"},
{"name": "HARTOMAT_010401_Aluminium", "description": "Aluminium", "hartomat_code": 10401, "source": "hartomat_standard"},
{"name": "HARTOMAT_010501_Brass", "description": "Messing", "hartomat_code": 10501, "source": "hartomat_standard"},
{"name": "HARTOMAT_010601_Bronze", "description": "MU-B, Bronze", "hartomat_code": 10601, "source": "hartomat_standard"},
# --- 02 Coatings ---
{"name": "HARTOMAT_020101_Durotect-Blue", "description": "Stahl, Durotect CMT", "hartomat_code": 20101, "source": "hartomat_standard"},
{"name": "HARTOMAT_020102_Durotect-Black", "description": "Stahl, Durotect M", "hartomat_code": 20102, "source": "hartomat_standard"},
{"name": "HARTOMAT_020201_Coat-Black", "description": "", "hartomat_code": 20201, "source": "hartomat_standard"},
# --- 03 Non-metals ---
{"name": "HARTOMAT_030101_Elastomer-Brown", "description": "Elastomer, braun", "hartomat_code": 30101, "source": "hartomat_standard"},
{"name": "HARTOMAT_030102_Elastomer-Green", "description": "Elastomer, grün", "hartomat_code": 30102, "source": "hartomat_standard"},
{"name": "HARTOMAT_030103_Elastomer-Black", "description": "Elastomer, schwarz", "hartomat_code": 30103, "source": "hartomat_standard"},
{"name": "HARTOMAT_030201_Plastic-Brown", "description": "Kunststoff, braun", "hartomat_code": 30201, "source": "hartomat_standard"},
{"name": "HARTOMAT_030202_Plastic-Green", "description": "Kunststoff, grün", "hartomat_code": 30202, "source": "hartomat_standard"},
{"name": "HARTOMAT_030203_Plastic-Black", "description": "Kunststoff, schwarz", "hartomat_code": 30203, "source": "hartomat_standard"},
{"name": "HARTOMAT_030204_Plastic-Blue", "description": "Kunststoff, blau", "hartomat_code": 30204, "source": "hartomat_standard"},
{"name": "HARTOMAT_030205_Plastic-White", "description": "Kunststoff, weiß", "hartomat_code": 30205, "source": "hartomat_standard"},
{"name": "HARTOMAT_030301_Plastic-Clear", "description": "Kunststoff, durchsichtig", "hartomat_code": 30301, "source": "hartomat_standard"},
{"name": "HARTOMAT_030302_Plastic-Translucent-White", "description": "", "hartomat_code": 30302, "source": "hartomat_standard"},
{"name": "HARTOMAT_030401_TPU-Blue", "description": "TPU, blau", "hartomat_code": 30401, "source": "hartomat_standard"},
{"name": "HARTOMAT_030501_Ceramic-Black", "description": "Keramik, schwarz", "hartomat_code": 30501, "source": "hartomat_standard"},
# --- 04 Compounds ---
{"name": "HARTOMAT_040101_E40", "description": "E40", "hartomat_code": 40101, "source": "hartomat_standard"},
{"name": "HARTOMAT_040102_E50", "description": "E50", "hartomat_code": 40102, "source": "hartomat_standard"},
{"name": "HARTOMAT_040201_Elgoglide", "description": "Elgoglide", "hartomat_code": 40201, "source": "hartomat_standard"},
{"name": "HARTOMAT_040202_Elgotex", "description": "Elgotex, schwarz", "hartomat_code": 40202, "source": "hartomat_standard"},
{"name": "HARTOMAT_040301_PTFE-Niro-Compound", "description": "PTFE-Compound, Niro-Verbund", "hartomat_code": 40301, "source": "hartomat_standard"},
{"name": "HARTOMAT_040302_PTFE-Foil", "description": "PTFE-Folie", "hartomat_code": 40302, "source": "hartomat_standard"},
{"name": "HARTOMAT_040303_PTFE-Compound-Black", "description": "PTFE-Verbund, schwarz", "hartomat_code": 40303, "source": "hartomat_standard"},
{"name": "HARTOMAT_040304_PTFE-Compound-Orange", "description": "PTFE-Verbundwerkstoff", "hartomat_code": 40304, "source": "hartomat_standard"},
{"name": "HARTOMAT_040305_GFK-PTFE-Compound", "description": "GFK+PTFE Verbundwerkstoff, schwarz / TPU, schwarz", "hartomat_code": 40305, "source": "hartomat_standard"},
# --- 05 Misc ---
{"name": "HARTOMAT_059999_FailedMaterial", "description": "", "hartomat_code": 59999, "source": "hartomat_standard"},
]
+36 -36
View File
@@ -1,9 +1,9 @@
"""Material alias seed data — derived from naming_scheme.xlsx Materialmapping sheet. """Material alias seed data — derived from naming_scheme.xlsx Materialmapping sheet.
Each entry maps a SCHAEFFLER library material name to its known aliases: Each entry maps a HARTOMAT library material name to its known aliases:
- German description (Col A from Materialmapping) - German description (Col A from Materialmapping)
- Intermediate identifier (Col B, e.g. "Steel_black_oxided--Stahl_brueniert") - Intermediate identifier (Col B, e.g. "Steel_black_oxided--Stahl_brueniert")
- Schaeffler code as string (e.g. "10102") - HartOMat code as string (e.g. "10102")
- German variants (singular/plural, abbreviations, industry terms, DIN/EN standards) - German variants (singular/plural, abbreviations, industry terms, DIN/EN standards)
- English equivalents commonly used in German engineering contexts - English equivalents commonly used in German engineering contexts
""" """
@@ -13,7 +13,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
# --- 01 Metals --- # --- 01 Metals ---
# ===================================================================== # =====================================================================
{ {
"material_name": "SCHAEFFLER_010101_Steel-Bare", "material_name": "HARTOMAT_010101_Steel-Bare",
"aliases": [ "aliases": [
"Stahl", "Stahl",
"Stahl, glänzend", "Stahl, glänzend",
@@ -66,7 +66,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_010102_Steel-Burnished", "material_name": "HARTOMAT_010102_Steel-Burnished",
"aliases": [ "aliases": [
"Stahl, brüniert", "Stahl, brüniert",
"Steel_black_oxided--Stahl_brueniert", "Steel_black_oxided--Stahl_brueniert",
@@ -94,7 +94,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_010103_Steel-Galvanized", "material_name": "HARTOMAT_010103_Steel-Galvanized",
"aliases": [ "aliases": [
"Stahl, verzinkt", "Stahl, verzinkt",
"Steel_galvanized--Stahl_verzinkt", "Steel_galvanized--Stahl_verzinkt",
@@ -130,7 +130,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_010104_Steel-Casted", "material_name": "HARTOMAT_010104_Steel-Casted",
"aliases": [ "aliases": [
"Stahl Körnung", "Stahl Körnung",
"Guss", "Guss",
@@ -169,7 +169,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_010105_Steel-Plate", "material_name": "HARTOMAT_010105_Steel-Plate",
"aliases": [ "aliases": [
"Stahlblech", "Stahlblech",
"Steel_sheet--Stahlblech", "Steel_sheet--Stahlblech",
@@ -204,7 +204,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_010201_Niro", "material_name": "HARTOMAT_010201_Niro",
"aliases": [ "aliases": [
"Niro", "Niro",
"Steel_stainless--Niro", "Steel_stainless--Niro",
@@ -248,7 +248,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_010301_Tin", "material_name": "HARTOMAT_010301_Tin",
"aliases": [ "aliases": [
"Zinnüberzug", "Zinnüberzug",
"Tin--Zinn", "Tin--Zinn",
@@ -278,7 +278,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_010401_Aluminium", "material_name": "HARTOMAT_010401_Aluminium",
"aliases": [ "aliases": [
"Aluminium", "Aluminium",
"Aluminium--Aluminium", "Aluminium--Aluminium",
@@ -319,7 +319,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_010501_Brass", "material_name": "HARTOMAT_010501_Brass",
"aliases": [ "aliases": [
"Messing", "Messing",
"Brass--Messing", "Brass--Messing",
@@ -351,7 +351,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_010601_Bronze", "material_name": "HARTOMAT_010601_Bronze",
"aliases": [ "aliases": [
"MU-B; Bronze", "MU-B; Bronze",
"Bronze", "Bronze",
@@ -393,7 +393,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
# --- 02 Coatings --- # --- 02 Coatings ---
# ===================================================================== # =====================================================================
{ {
"material_name": "SCHAEFFLER_020101_Durotect-Blue", "material_name": "HARTOMAT_020101_Durotect-Blue",
"aliases": [ "aliases": [
"Stahl, Durotect CMT", "Stahl, Durotect CMT",
"Durotect_CMT--Durotect_CMT", "Durotect_CMT--Durotect_CMT",
@@ -414,7 +414,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_020102_Durotect-Black", "material_name": "HARTOMAT_020102_Durotect-Black",
"aliases": [ "aliases": [
"Stahl, Durotect M", "Stahl, Durotect M",
"Stahl; Durotect M", "Stahl; Durotect M",
@@ -435,7 +435,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_020201_Coat-Black", "material_name": "HARTOMAT_020201_Coat-Black",
"aliases": [ "aliases": [
"Stahl, schwarz", "Stahl, schwarz",
"Steel_coated_black--Stahl_beschichtet_schwarz", "Steel_coated_black--Stahl_beschichtet_schwarz",
@@ -468,7 +468,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
# --- 03 Non-metals --- # --- 03 Non-metals ---
# ===================================================================== # =====================================================================
{ {
"material_name": "SCHAEFFLER_030101_Elastomer-Brown", "material_name": "HARTOMAT_030101_Elastomer-Brown",
"aliases": [ "aliases": [
"Elastomer, braun", "Elastomer, braun",
"Elastomer_brown--Elastomer_braun", "Elastomer_brown--Elastomer_braun",
@@ -493,7 +493,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030102_Elastomer-Green", "material_name": "HARTOMAT_030102_Elastomer-Green",
"aliases": [ "aliases": [
"Elastomer, grün", "Elastomer, grün",
"Elastomer_green--Elastomer_gruen", "Elastomer_green--Elastomer_gruen",
@@ -518,7 +518,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030103_Elastomer-Black", "material_name": "HARTOMAT_030103_Elastomer-Black",
"aliases": [ "aliases": [
"Elastomer, schwarz", "Elastomer, schwarz",
"Eslastomer_black--Elastomer_schwarz", "Eslastomer_black--Elastomer_schwarz",
@@ -557,7 +557,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030201_Plastic-Brown", "material_name": "HARTOMAT_030201_Plastic-Brown",
"aliases": [ "aliases": [
"Kunststoff, braun", "Kunststoff, braun",
"Plastic_brown--Kunststoff_braun", "Plastic_brown--Kunststoff_braun",
@@ -585,7 +585,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030202_Plastic-Green", "material_name": "HARTOMAT_030202_Plastic-Green",
"aliases": [ "aliases": [
"Kunststoff, grün", "Kunststoff, grün",
"Plastic_green--Kunststoff_gruen", "Plastic_green--Kunststoff_gruen",
@@ -612,7 +612,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030203_Plastic-Black", "material_name": "HARTOMAT_030203_Plastic-Black",
"aliases": [ "aliases": [
"Kunststoff, schwarz", "Kunststoff, schwarz",
"Plastic_black--Kunststoff_schwarz", "Plastic_black--Kunststoff_schwarz",
@@ -642,7 +642,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030204_Plastic-Blue", "material_name": "HARTOMAT_030204_Plastic-Blue",
"aliases": [ "aliases": [
"Kunststoff, blau", "Kunststoff, blau",
"Plastic_blue--Kunststoff_blau", "Plastic_blue--Kunststoff_blau",
@@ -668,7 +668,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030205_Plastic-White", "material_name": "HARTOMAT_030205_Plastic-White",
"aliases": [ "aliases": [
"Kunststoff, weiß", "Kunststoff, weiß",
"Plastic_white--Kunststoff_weiss", "Plastic_white--Kunststoff_weiss",
@@ -702,7 +702,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030301_Plastic-Clear", "material_name": "HARTOMAT_030301_Plastic-Clear",
"aliases": [ "aliases": [
"Kunststoff, durchsichtig", "Kunststoff, durchsichtig",
"Plastic_clear--Kunststoff_durchsichtig", "Plastic_clear--Kunststoff_durchsichtig",
@@ -738,7 +738,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030302_Plastic-Translucent-White", "material_name": "HARTOMAT_030302_Plastic-Translucent-White",
"aliases": [ "aliases": [
"Plastic_translucent_white--Kunststoff_transluzent_weiss", "Plastic_translucent_white--Kunststoff_transluzent_weiss",
"30302", "30302",
@@ -769,7 +769,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030401_TPU-Blue", "material_name": "HARTOMAT_030401_TPU-Blue",
"aliases": [ "aliases": [
"TPU, blau", "TPU, blau",
"Elastomer_blue--Elastomer_blau", "Elastomer_blue--Elastomer_blau",
@@ -798,7 +798,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_030501_Ceramic-Black", "material_name": "HARTOMAT_030501_Ceramic-Black",
"aliases": [ "aliases": [
"Keramik, schwarz", "Keramik, schwarz",
"Ceramics_black--Keramik_schwarz", "Ceramics_black--Keramik_schwarz",
@@ -834,7 +834,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
# --- 04 Compounds --- # --- 04 Compounds ---
# ===================================================================== # =====================================================================
{ {
"material_name": "SCHAEFFLER_040101_E40", "material_name": "HARTOMAT_040101_E40",
"aliases": [ "aliases": [
"E40", "E40",
"E40--E40", "E40--E40",
@@ -851,7 +851,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_040102_E50", "material_name": "HARTOMAT_040102_E50",
"aliases": [ "aliases": [
"E50", "E50",
"E50--E50", "E50--E50",
@@ -868,7 +868,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_040201_Elgoglide", "material_name": "HARTOMAT_040201_Elgoglide",
"aliases": [ "aliases": [
"Elgoglide", "Elgoglide",
"Elgoglide--Elgoglide", "Elgoglide--Elgoglide",
@@ -886,7 +886,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_040202_Elgotex", "material_name": "HARTOMAT_040202_Elgotex",
"aliases": [ "aliases": [
"Elgotex, schwarz", "Elgotex, schwarz",
"Elgotex--Elgotex", "Elgotex--Elgotex",
@@ -907,7 +907,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_040301_PTFE-Niro-Compound", "material_name": "HARTOMAT_040301_PTFE-Niro-Compound",
"aliases": [ "aliases": [
"PTFE-Compound, Niro-Verbund", "PTFE-Compound, Niro-Verbund",
"PTFE_compound_stainless_steel_composite--PTFE_Compound_Niro_Verbund", "PTFE_compound_stainless_steel_composite--PTFE_Compound_Niro_Verbund",
@@ -933,7 +933,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_040302_PTFE-Foil", "material_name": "HARTOMAT_040302_PTFE-Foil",
"aliases": [ "aliases": [
"PTFE-Folie", "PTFE-Folie",
"PTFE_film--PTFE_Folie", "PTFE_film--PTFE_Folie",
@@ -962,7 +962,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_040303_PTFE-Compound-Black", "material_name": "HARTOMAT_040303_PTFE-Compound-Black",
"aliases": [ "aliases": [
"PTFE-Verbund, schwarz", "PTFE-Verbund, schwarz",
"PTFE_compound_black--PTFE_Verbund_schwarz", "PTFE_compound_black--PTFE_Verbund_schwarz",
@@ -987,7 +987,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_040304_PTFE-Compound-Orange", "material_name": "HARTOMAT_040304_PTFE-Compound-Orange",
"aliases": [ "aliases": [
"PTFE-Verbundwerkstoff", "PTFE-Verbundwerkstoff",
"PTFE_composite_material_orange--PTFE_Verbundwerkstoff_orange", "PTFE_composite_material_orange--PTFE_Verbundwerkstoff_orange",
@@ -1014,7 +1014,7 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
], ],
}, },
{ {
"material_name": "SCHAEFFLER_040305_GFK-PTFE-Compound", "material_name": "HARTOMAT_040305_GFK-PTFE-Compound",
"aliases": [ "aliases": [
"GFK+PTFE Verbundwerkstoff, schwarz", "GFK+PTFE Verbundwerkstoff, schwarz",
"GFK_PTFE_compound--GFK_PTFE_Verbundwerkstoff", "GFK_PTFE_compound--GFK_PTFE_Verbundwerkstoff",
-48
View File
@@ -1,48 +0,0 @@
"""Schaeffler standard materials — single source of truth.
Naming convention: SCHAEFFLER_[TypeCode(2)][SubType(2)][Consecutive(2)]_[Name-Parts-Dashed]
Type codes: 01=Metals, 02=Coatings, 03=Non-metals, 04=Compounds, 05=Misc
"""
SCHAEFFLER_MATERIALS: list[dict] = [
# --- 01 Metals ---
{"name": "SCHAEFFLER_010101_Steel-Bare", "description": "Stahl / Stahl, glänzend / Stahl, konserviert", "schaeffler_code": 10101, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_010102_Steel-Burnished", "description": "Stahl, brüniert", "schaeffler_code": 10102, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_010103_Steel-Galvanized", "description": "Stahl, verzinkt", "schaeffler_code": 10103, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_010104_Steel-Casted", "description": "Stahl Körnung", "schaeffler_code": 10104, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_010105_Steel-Plate", "description": "Stahlblech", "schaeffler_code": 10105, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_010201_Niro", "description": "Niro", "schaeffler_code": 10201, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_010301_Tin", "description": "MU-Stahl, Zinnüberzug / MX-Stahl, Zinnüberzug", "schaeffler_code": 10301, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_010401_Aluminium", "description": "Aluminium", "schaeffler_code": 10401, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_010501_Brass", "description": "Messing", "schaeffler_code": 10501, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_010601_Bronze", "description": "MU-B, Bronze", "schaeffler_code": 10601, "source": "schaeffler_standard"},
# --- 02 Coatings ---
{"name": "SCHAEFFLER_020101_Durotect-Blue", "description": "Stahl, Durotect CMT", "schaeffler_code": 20101, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_020102_Durotect-Black", "description": "Stahl, Durotect M", "schaeffler_code": 20102, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_020201_Coat-Black", "description": "", "schaeffler_code": 20201, "source": "schaeffler_standard"},
# --- 03 Non-metals ---
{"name": "SCHAEFFLER_030101_Elastomer-Brown", "description": "Elastomer, braun", "schaeffler_code": 30101, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030102_Elastomer-Green", "description": "Elastomer, grün", "schaeffler_code": 30102, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030103_Elastomer-Black", "description": "Elastomer, schwarz", "schaeffler_code": 30103, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030201_Plastic-Brown", "description": "Kunststoff, braun", "schaeffler_code": 30201, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030202_Plastic-Green", "description": "Kunststoff, grün", "schaeffler_code": 30202, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030203_Plastic-Black", "description": "Kunststoff, schwarz", "schaeffler_code": 30203, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030204_Plastic-Blue", "description": "Kunststoff, blau", "schaeffler_code": 30204, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030205_Plastic-White", "description": "Kunststoff, weiß", "schaeffler_code": 30205, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030301_Plastic-Clear", "description": "Kunststoff, durchsichtig", "schaeffler_code": 30301, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030302_Plastic-Translucent-White", "description": "", "schaeffler_code": 30302, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030401_TPU-Blue", "description": "TPU, blau", "schaeffler_code": 30401, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_030501_Ceramic-Black", "description": "Keramik, schwarz", "schaeffler_code": 30501, "source": "schaeffler_standard"},
# --- 04 Compounds ---
{"name": "SCHAEFFLER_040101_E40", "description": "E40", "schaeffler_code": 40101, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_040102_E50", "description": "E50", "schaeffler_code": 40102, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_040201_Elgoglide", "description": "Elgoglide", "schaeffler_code": 40201, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_040202_Elgotex", "description": "Elgotex, schwarz", "schaeffler_code": 40202, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_040301_PTFE-Niro-Compound", "description": "PTFE-Compound, Niro-Verbund", "schaeffler_code": 40301, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_040302_PTFE-Foil", "description": "PTFE-Folie", "schaeffler_code": 40302, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_040303_PTFE-Compound-Black", "description": "PTFE-Verbund, schwarz", "schaeffler_code": 40303, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_040304_PTFE-Compound-Orange", "description": "PTFE-Verbundwerkstoff", "schaeffler_code": 40304, "source": "schaeffler_standard"},
{"name": "SCHAEFFLER_040305_GFK-PTFE-Compound", "description": "GFK+PTFE Verbundwerkstoff, schwarz / TPU, schwarz", "schaeffler_code": 40305, "source": "schaeffler_standard"},
# --- 05 Misc ---
{"name": "SCHAEFFLER_059999_FailedMaterial", "description": "", "schaeffler_code": 59999, "source": "schaeffler_standard"},
]
+1 -1
View File
@@ -17,7 +17,7 @@ class Material(Base):
name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True)
description: Mapped[str] = mapped_column(Text, nullable=True) description: Mapped[str] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual") source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
schaeffler_code: Mapped[int | None] = mapped_column(Integer, nullable=True) hartomat_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column( created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
) )
+8 -8
View File
@@ -1,7 +1,7 @@
"""Material alias resolution service. """Material alias resolution service.
Used from Celery tasks (sync context) to resolve raw material names Used from Celery tasks (sync context) to resolve raw material names
(from Excel / user input) to SCHAEFFLER library material names via aliases. (from Excel / user input) to HARTOMAT library material names via aliases.
Resolution chain: Resolution chain:
1. Alias lookup (case-insensitive) → use alias.material.name 1. Alias lookup (case-insensitive) → use alias.material.name
@@ -31,7 +31,7 @@ def _get_engine():
def resolve_material_map(raw_map: dict[str, str]) -> dict[str, str]: def resolve_material_map(raw_map: dict[str, str]) -> dict[str, str]:
"""Resolve raw material names to SCHAEFFLER library names via aliases. """Resolve raw material names to HARTOMAT library names via aliases.
For each value in raw_map: For each value in raw_map:
1. Alias lookup (case-insensitive) → return alias.material.name 1. Alias lookup (case-insensitive) → return alias.material.name
@@ -66,7 +66,7 @@ def resolve_material_map(raw_map: dict[str, str]) -> dict[str, str]:
raw_lower = raw_material.lower() raw_lower = raw_material.lower()
# 1. Alias lookup first — aliases explicitly map intermediate/display names # 1. Alias lookup first — aliases explicitly map intermediate/display names
# to the canonical SCHAEFFLER library names # to the canonical HARTOMAT library names
if raw_lower in alias_lookup: if raw_lower in alias_lookup:
target = alias_lookup[raw_lower] target = alias_lookup[raw_lower]
logger.info("resolved '%s''%s' (alias match)", raw_material, target) logger.info("resolved '%s''%s' (alias match)", raw_material, target)
@@ -147,7 +147,7 @@ async def find_unmapped_materials(
"""Find material names that have no alias or library match. """Find material names that have no alias or library match.
Returns a list of {"raw_name": str, "suggestions": [...]} for each Returns a list of {"raw_name": str, "suggestions": [...]} for each
unmapped name. Suggestions are the top 5 SCHAEFFLER library materials unmapped name. Suggestions are the top 5 HARTOMAT library materials
by string similarity. by string similarity.
""" """
if not material_names: if not material_names:
@@ -159,8 +159,8 @@ async def find_unmapped_materials(
# Load all materials # Load all materials
mat_rows = (await db.execute(select(Material))).scalars().all() mat_rows = (await db.execute(select(Material))).scalars().all()
# Library materials have a schaeffler_code # Library materials have a hartomat_code
library_mats = [m for m in mat_rows if m.schaeffler_code is not None] library_mats = [m for m in mat_rows if m.hartomat_code is not None]
# All material names (case-insensitive) for exact-match check # All material names (case-insensitive) for exact-match check
name_lookup: dict[str, Material] = {m.name.lower(): m for m in mat_rows} name_lookup: dict[str, Material] = {m.name.lower(): m for m in mat_rows}
@@ -179,7 +179,7 @@ async def find_unmapped_materials(
# 2. Exact name match with a library material → mapped # 2. Exact name match with a library material → mapped
matched_mat = name_lookup.get(raw_lower) matched_mat = name_lookup.get(raw_lower)
if matched_mat and matched_mat.schaeffler_code is not None: if matched_mat and matched_mat.hartomat_code is not None:
continue continue
# Unmapped — compute suggestions from library materials # Unmapped — compute suggestions from library materials
@@ -194,7 +194,7 @@ async def find_unmapped_materials(
{ {
"id": str(m.id), "id": str(m.id),
"name": m.name, "name": m.name,
"schaeffler_code": str(m.schaeffler_code), "hartomat_code": str(m.hartomat_code),
} }
for _, m in scored[:5] for _, m in scored[:5]
] ]
+1 -1
View File
@@ -235,7 +235,7 @@ def send_email_notification_stub(
from email.mime.text import MIMEText from email.mime.text import MIMEText
msg = MIMEText(body, "plain", "utf-8") msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = cfg.get("smtp_from_address") or cfg.get("smtp_user", "noreply@schaeffler.com") msg["From"] = cfg.get("smtp_from_address") or cfg.get("smtp_user", "noreply@hartomat.com")
msg["To"] = to_address msg["To"] = to_address
port = int(cfg.get("smtp_port", "587")) port = int(cfg.get("smtp_port", "587"))
with smtplib.SMTP(smtp_host, port) as smtp: with smtplib.SMTP(smtp_host, port) as smtp:
@@ -330,7 +330,7 @@ def generate_usd_master_task(self, cad_file_id: str) -> dict:
if part_name and raw_material: if part_name and raw_material:
raw_mat_map[part_name] = raw_material raw_mat_map[part_name] = raw_material
# Resolve raw material names to SCHAEFFLER library names via aliases # Resolve raw material names to HARTOMAT library names via aliases
material_map: dict[str, str] = {} material_map: dict[str, str] = {}
if raw_mat_map: if raw_mat_map:
material_map = resolve_material_map(raw_mat_map) material_map = resolve_material_map(raw_mat_map)
@@ -244,7 +244,7 @@ def render_order_line_task(self, order_line_id: str):
if m.get("part_name") and m.get("material") if m.get("part_name") and m.get("material")
} }
# Resolve raw material names to SCHAEFFLER library names via aliases # Resolve raw material names to HARTOMAT library names via aliases
from app.services.material_service import resolve_material_map from app.services.material_service import resolve_material_map
material_map = resolve_material_map(material_map) material_map = resolve_material_map(material_map)
@@ -61,7 +61,7 @@ _STEP_DESCRIPTIONS: dict[StepName, str] = {
StepName.OCC_OBJECT_EXTRACT: "Extract part objects and metadata from the STEP file using cadquery/OCC", StepName.OCC_OBJECT_EXTRACT: "Extract part objects and metadata from the STEP file using cadquery/OCC",
StepName.OCC_GLB_EXPORT: "Convert STEP geometry to glTF/GLB via cadquery", StepName.OCC_GLB_EXPORT: "Convert STEP geometry to glTF/GLB via cadquery",
StepName.GLB_BBOX: "Compute bounding-box from the exported GLB for camera framing", StepName.GLB_BBOX: "Compute bounding-box from the exported GLB for camera framing",
StepName.MATERIAL_MAP_RESOLVE: "Resolve raw part-material names to SCHAEFFLER library materials via alias table", StepName.MATERIAL_MAP_RESOLVE: "Resolve raw part-material names to HARTOMAT library materials via alias table",
StepName.AUTO_POPULATE_MATERIALS: "Auto-create Material records for any newly discovered part names", StepName.AUTO_POPULATE_MATERIALS: "Auto-create Material records for any newly discovered part names",
StepName.BLENDER_RENDER: "Render a thumbnail PNG using Blender (Cycles or EEVEE)", StepName.BLENDER_RENDER: "Render a thumbnail PNG using Blender (Cycles or EEVEE)",
StepName.THREEJS_RENDER: "Render a thumbnail PNG using Three.js / Playwright headless browser", StepName.THREEJS_RENDER: "Render a thumbnail PNG using Three.js / Playwright headless browser",
+1 -1
View File
@@ -9,7 +9,7 @@ _DEFAULT_TENANT_CONFIG = {
"max_concurrent_renders": 3, "max_concurrent_renders": 3,
"render_engines_allowed": ["cycles", "eevee"], "render_engines_allowed": ["cycles", "eevee"],
"max_order_size": 500, "max_order_size": 500,
"fallback_material": "SCHAEFFLER_059999_FailedMaterial", "fallback_material": "HARTOMAT_059999_FailedMaterial",
"notifications_enabled": True, "notifications_enabled": True,
"invoice_prefix": "INV", "invoice_prefix": "INV",
# Azure AI validation (per-tenant) # Azure AI validation (per-tenant)
+3 -3
View File
@@ -41,9 +41,9 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="Schaeffler Automat API", title="HartOMat API",
version="0.1.0", version="0.1.0",
description="Media-creation pipeline for Schaeffler CAD/bearing product orders", description="Media-creation pipeline for HartOMat CAD/bearing product orders",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -101,7 +101,7 @@ app.include_router(chat_router, prefix="/api")
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "ok", "service": "schaefflerautomat-backend"} return {"status": "ok", "service": "hartomat-backend"}
@app.websocket("/api/ws") @app.websocket("/api/ws")
+2 -2
View File
@@ -8,12 +8,12 @@ from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
VALIDATION_PROMPT = """You are a quality control expert for Schaeffler bearing product catalog images. VALIDATION_PROMPT = """You are a quality control expert for HartOMat bearing product catalog images.
Analyze this thumbnail of a bearing/mechanical component and evaluate: Analyze this thumbnail of a bearing/mechanical component and evaluate:
1. Is the component orientation correct for a standard product catalog? (typically isometric view, 30° elevation, 45° rotation) 1. Is the component orientation correct for a standard product catalog? (typically isometric view, 30° elevation, 45° rotation)
2. Are the key features visible? (rolling elements, rings, cage if present) 2. Are the key features visible? (rolling elements, rings, cage if present)
3. Does it match standard Schaeffler catalog angle conventions? 3. Does it match standard HartOMat catalog angle conventions?
Respond in JSON with exactly these fields: Respond in JSON with exactly these fields:
{ {
+9 -9
View File
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
# ── System prompt ──────────────────────────────────────────────────────────── # ── System prompt ────────────────────────────────────────────────────────────
SYSTEM_PROMPT = """You are the Schaeffler Automat AI assistant. You help users manage their automated render pipeline for Schaeffler product images. SYSTEM_PROMPT = """You are the HartOMat AI assistant. You help users manage their automated render pipeline for HartOMat product images.
You can: You can:
- List and search orders and products - List and search orders and products
@@ -39,8 +39,8 @@ RULES:
7. Respond in the same language the user writes in. 7. Respond in the same language the user writes in.
8. Be concise — short answers are better than long ones. 8. Be concise — short answers are better than long ones.
9. When the user says "beliebig", "any", "random", "irgendein" — just pick one yourself, don't ask back. 9. When the user says "beliebig", "any", "random", "irgendein" — just pick one yourself, don't ask back.
10. Material system: Materials have SCHAEFFLER library names (e.g. SCHAEFFLER_020101_Durotect-Blue). Common names like "Durotect", "Stahl", "Bronze" are aliases that map to these library names. When the user asks for a material by a common name, use list_materials to find the correct SCHAEFFLER name, then use that for material_override. 10. Material system: Materials have HARTOMAT library names (e.g. HARTOMAT_020101_Durotect-Blue). Common names like "Durotect", "Stahl", "Bronze" are aliases that map to these library names. When the user asks for a material by a common name, use list_materials to find the correct HARTOMAT name, then use that for material_override.
11. When setting material_override, always use the full SCHAEFFLER library name (e.g. SCHAEFFLER_020101_Durotect-Blue), never the alias. 11. When setting material_override, always use the full HARTOMAT library name (e.g. HARTOMAT_020101_Durotect-Blue), never the alias.
12. When mentioning a product, ALWAYS link to it: [ProductName](/products/UUID). When mentioning an order, link to it: [OrderNumber](/orders/UUID). This makes the response navigable. 12. When mentioning a product, ALWAYS link to it: [ProductName](/products/UUID). When mentioning an order, link to it: [OrderNumber](/orders/UUID). This makes the response navigable.
13. Materials exist at TWO levels: (a) product_material — materials assigned to the product's CAD parts (from STEP/Excel import, e.g. "Durotect_M", "Stahl"), and (b) material_override — a single material applied to ALL parts at render time. When user asks for a product "with Durotect material", search product_material FIRST (products that naturally have Durotect parts). Only use material_override filter if they specifically say "override" or "alle Teile in". 13. Materials exist at TWO levels: (a) product_material — materials assigned to the product's CAD parts (from STEP/Excel import, e.g. "Durotect_M", "Stahl"), and (b) material_override — a single material applied to ALL parts at render time. When user asks for a product "with Durotect material", search product_material FIRST (products that naturally have Durotect parts). Only use material_override filter if they specifically say "override" or "alle Teile in".
14. NEVER say "no renders found" when renders DO exist. If no exact match, show the closest match and explain what's different.""" 14. NEVER say "no renders found" when renders DO exist. If no exact match, show the closest match and explain what's different."""
@@ -120,7 +120,7 @@ TOOLS = [
}, },
"material_override": { "material_override": {
"type": "string", "type": "string",
"description": "Optional SCHAEFFLER library material name to apply to all lines.", "description": "Optional HARTOMAT library material name to apply to all lines.",
"default": "", "default": "",
}, },
}, },
@@ -176,7 +176,7 @@ TOOLS = [
}, },
"material_name": { "material_name": {
"type": "string", "type": "string",
"description": "SCHAEFFLER library material name, or empty string to clear.", "description": "HARTOMAT library material name, or empty string to clear.",
}, },
}, },
"required": ["order_id", "material_name"], "required": ["order_id", "material_name"],
@@ -250,7 +250,7 @@ TOOLS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "list_materials", "name": "list_materials",
"description": "List available SCHAEFFLER library materials with their aliases. Use this to find the correct material name for material_override. Materials have names like SCHAEFFLER_010101_Steel-Bare. Aliases map common names (Stahl, Bronze, Durotect, etc.) to these library materials. When user asks for a material by a common name, search aliases to find the correct SCHAEFFLER library material name.", "description": "List available HARTOMAT library materials with their aliases. Use this to find the correct material name for material_override. Materials have names like HARTOMAT_010101_Steel-Bare. Aliases map common names (Stahl, Bronze, Durotect, etc.) to these library materials. When user asks for a material by a common name, search aliases to find the correct HARTOMAT library material name.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -671,13 +671,13 @@ async def _tool_check_materials(db: AsyncSession, tenant_id: str, user_id: str =
async def _tool_list_materials(db: AsyncSession, tenant_id: str, query: str = "") -> str: async def _tool_list_materials(db: AsyncSession, tenant_id: str, query: str = "") -> str:
"""List library materials with their aliases.""" """List library materials with their aliases."""
sql = """ sql = """
SELECT m.id, m.name, m.schaeffler_code, m.description, SELECT m.id, m.name, m.hartomat_code, m.description,
COALESCE( COALESCE(
(SELECT json_agg(ma.alias) FROM material_aliases ma WHERE ma.material_id = m.id), (SELECT json_agg(ma.alias) FROM material_aliases ma WHERE ma.material_id = m.id),
'[]'::json '[]'::json
) AS aliases ) AS aliases
FROM materials m FROM materials m
WHERE m.schaeffler_code IS NOT NULL WHERE m.hartomat_code IS NOT NULL
""" """
params: dict = {} params: dict = {}
if query: if query:
@@ -698,7 +698,7 @@ async def _tool_list_materials(db: AsyncSession, tenant_id: str, query: str = ""
aliases = r["aliases"] if isinstance(r["aliases"], list) else [] aliases = r["aliases"] if isinstance(r["aliases"], list) else []
materials.append({ materials.append({
"name": r["name"], "name": r["name"],
"schaeffler_code": r["schaeffler_code"], "hartomat_code": r["hartomat_code"],
"description": r["description"], "description": r["description"],
"aliases": aliases[:10], # cap to avoid token bloat "aliases": aliases[:10], # cap to avoid token bloat
}) })
+2 -2
View File
@@ -1,5 +1,5 @@
""" """
Excel parser for Schaeffler CAD order lists. Excel parser for HartOMat CAD order lists.
Supports two formats: Supports two formats:
@@ -294,7 +294,7 @@ def _parse_material_mapping(wb) -> list[dict]:
def parse_excel(file_path: str | Path) -> ParsedExcel: def parse_excel(file_path: str | Path) -> ParsedExcel:
""" """
Parse a Schaeffler order list Excel file. Parse a HartOMat order list Excel file.
Returns a ParsedExcel with all data rows extracted. Returns a ParsedExcel with all data rows extracted.
Header-driven: finds "Ebene1" in any column within first 5 rows, Header-driven: finds "Ebene1" in any column within first 5 rows,
+2 -2
View File
@@ -25,9 +25,9 @@ def build_part_colors(
""" """
Build {part_name: material_name} for Blender rendering. Build {part_name: material_name} for Blender rendering.
Returns a mapping of part name → Schaeffler material name (e.g. SCHAEFFLER_010101_Steel-Bare). Returns a mapping of part name → HartOMat material name (e.g. HARTOMAT_010101_Steel-Bare).
Parts with no material assignment are omitted; Blender will use the fallback material Parts with no material assignment are omitted; Blender will use the fallback material
(SCHAEFFLER_059999_FailedMaterial) for unrecognised parts. (HARTOMAT_059999_FailedMaterial) for unrecognised parts.
Args: Args:
cad_parsed_objects: List of part names from cad_file.parsed_objects["objects"]. cad_parsed_objects: List of part names from cad_file.parsed_objects["objects"].
+1 -1
View File
@@ -3,7 +3,7 @@ from celery.schedules import crontab
from app.config import settings from app.config import settings
celery_app = Celery( celery_app = Celery(
"schaefflerautomat", "hartomat",
broker=settings.redis_url, broker=settings.redis_url,
backend=settings.redis_url, backend=settings.redis_url,
include=[ include=[
+3 -3
View File
@@ -1,4 +1,4 @@
"""Seed database with 7 Schaeffler product category templates.""" """Seed database with 7 HartOMat product category templates."""
import asyncio import asyncio
import uuid import uuid
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
@@ -133,7 +133,7 @@ TEMPLATES = [
] ]
async def seed(db_url: str, admin_email: str = "admin@schaeffler.com", admin_password: str = "Admin1234!"): async def seed(db_url: str, admin_email: str = "admin@hartomat.com", admin_password: str = "Admin1234!"):
from app.models.template import Template from app.models.template import Template
from app.models.user import User, UserRole from app.models.user import User, UserRole
from app.utils.auth import hash_password from app.utils.auth import hash_password
@@ -162,7 +162,7 @@ async def seed(db_url: str, admin_email: str = "admin@schaeffler.com", admin_pas
email=admin_email, email=admin_email,
password_hash=hash_password(admin_password), password_hash=hash_password(admin_password),
role=UserRole.global_admin, role=UserRole.global_admin,
full_name="Schaeffler Admin", full_name="HartOMat Admin",
) )
session.add(admin) session.add(admin)
print(f" + Admin user: {admin_email}") print(f" + Admin user: {admin_email}")
+1 -1
View File
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
packages = ["app"] packages = ["app"]
[project] [project]
name = "schaefflerautomat-backend" name = "hartomat-backend"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+2 -2
View File
@@ -1,5 +1,5 @@
""" """
Pytest fixtures for the Schaeffler Automat backend test suite. Pytest fixtures for the HartOMat backend test suite.
The tests in this suite are divided into: The tests in this suite are divided into:
- Unit tests (no DB / network required): excel_parser, models, schemas - Unit tests (no DB / network required): excel_parser, models, schemas
@@ -122,7 +122,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess
TEST_DB_URL = os.environ.get( TEST_DB_URL = os.environ.get(
"TEST_DATABASE_URL", "TEST_DATABASE_URL",
"postgresql+asyncpg://schaeffler:schaeffler@localhost:5432/schaeffler_test" "postgresql+asyncpg://hartomat:hartomat@localhost:5432/hartomat_test"
) )
+3 -3
View File
@@ -16,13 +16,13 @@
services: services:
render-worker: render-worker:
image: schaefflerautomat-render-worker:latest image: hartomat-render-worker:latest
# Or build locally: build: { context: ./render-worker, dockerfile: Dockerfile } # Or build locally: build: { context: ./render-worker, dockerfile: Dockerfile }
environment: environment:
- REDIS_URL=${REDIS_URL:?Set REDIS_URL to the main server Redis URL} - REDIS_URL=${REDIS_URL:?Set REDIS_URL to the main server Redis URL}
- POSTGRES_HOST=${POSTGRES_HOST:?Set POSTGRES_HOST to the main server DB host} - POSTGRES_HOST=${POSTGRES_HOST:?Set POSTGRES_HOST to the main server DB host}
- POSTGRES_DB=${POSTGRES_DB:-schaeffler} - POSTGRES_DB=${POSTGRES_DB:-hartomat}
- POSTGRES_USER=${POSTGRES_USER:-schaeffler} - POSTGRES_USER=${POSTGRES_USER:-hartomat}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
- POSTGRES_PORT=${POSTGRES_PORT:-5432} - POSTGRES_PORT=${POSTGRES_PORT:-5432}
- MINIO_URL=${MINIO_URL:?Set MINIO_URL to the main server MinIO URL} - MINIO_URL=${MINIO_URL:?Set MINIO_URL to the main server MinIO URL}
+19 -19
View File
@@ -2,15 +2,15 @@ services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-schaeffler} POSTGRES_DB: ${POSTGRES_DB:-hartomat}
POSTGRES_USER: ${POSTGRES_USER:-schaeffler} POSTGRES_USER: ${POSTGRES_USER:-hartomat}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-schaeffler} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-hartomat}
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-schaeffler}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-hartomat}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -48,9 +48,9 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
command: /start.sh command: /start.sh
environment: environment:
- POSTGRES_DB=${POSTGRES_DB:-schaeffler} - POSTGRES_DB=${POSTGRES_DB:-hartomat}
- POSTGRES_USER=${POSTGRES_USER:-schaeffler} - POSTGRES_USER=${POSTGRES_USER:-hartomat}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-schaeffler} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-hartomat}
- POSTGRES_HOST=postgres - POSTGRES_HOST=postgres
- POSTGRES_PORT=5432 - POSTGRES_PORT=5432
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0} - REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
@@ -89,9 +89,9 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
command: celery -A app.tasks.celery_app worker --loglevel=info -Q step_processing,ai_validation --autoscale=${MAX_CONCURRENCY:-8},${MIN_CONCURRENCY:-2} --concurrency=${MIN_CONCURRENCY:-2} command: celery -A app.tasks.celery_app worker --loglevel=info -Q step_processing,ai_validation --autoscale=${MAX_CONCURRENCY:-8},${MIN_CONCURRENCY:-2} --concurrency=${MIN_CONCURRENCY:-2}
environment: environment:
- POSTGRES_DB=${POSTGRES_DB:-schaeffler} - POSTGRES_DB=${POSTGRES_DB:-hartomat}
- POSTGRES_USER=${POSTGRES_USER:-schaeffler} - POSTGRES_USER=${POSTGRES_USER:-hartomat}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-schaeffler} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-hartomat}
- POSTGRES_HOST=postgres - POSTGRES_HOST=postgres
- POSTGRES_PORT=5432 - POSTGRES_PORT=5432
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0} - REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
@@ -123,9 +123,9 @@ services:
- BLENDER_VERSION=${BLENDER_VERSION:-5.0.1} - BLENDER_VERSION=${BLENDER_VERSION:-5.0.1}
command: bash -c "python3 /check_version.py && celery -A app.tasks.celery_app worker --loglevel=info -Q asset_pipeline --autoscale=1,1 --concurrency=1" command: bash -c "python3 /check_version.py && celery -A app.tasks.celery_app worker --loglevel=info -Q asset_pipeline --autoscale=1,1 --concurrency=1"
environment: environment:
- POSTGRES_DB=${POSTGRES_DB:-schaeffler} - POSTGRES_DB=${POSTGRES_DB:-hartomat}
- POSTGRES_USER=${POSTGRES_USER:-schaeffler} - POSTGRES_USER=${POSTGRES_USER:-hartomat}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-schaeffler} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-hartomat}
- POSTGRES_HOST=postgres - POSTGRES_HOST=postgres
- POSTGRES_PORT=5432 - POSTGRES_PORT=5432
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0} - REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
@@ -165,9 +165,9 @@ services:
- BLENDER_VERSION=${BLENDER_VERSION:-5.0.1} - BLENDER_VERSION=${BLENDER_VERSION:-5.0.1}
command: bash -c "python3 /check_version.py && celery -A app.tasks.celery_app worker --loglevel=info -Q asset_pipeline_light --autoscale=2,2 --concurrency=2" command: bash -c "python3 /check_version.py && celery -A app.tasks.celery_app worker --loglevel=info -Q asset_pipeline_light --autoscale=2,2 --concurrency=2"
environment: environment:
- POSTGRES_DB=${POSTGRES_DB:-schaeffler} - POSTGRES_DB=${POSTGRES_DB:-hartomat}
- POSTGRES_USER=${POSTGRES_USER:-schaeffler} - POSTGRES_USER=${POSTGRES_USER:-hartomat}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-schaeffler} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-hartomat}
- POSTGRES_HOST=postgres - POSTGRES_HOST=postgres
- POSTGRES_PORT=5432 - POSTGRES_PORT=5432
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0} - REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
@@ -204,9 +204,9 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
command: celery -A app.tasks.celery_app beat --loglevel=info command: celery -A app.tasks.celery_app beat --loglevel=info
environment: environment:
- POSTGRES_DB=${POSTGRES_DB:-schaeffler} - POSTGRES_DB=${POSTGRES_DB:-hartomat}
- POSTGRES_USER=${POSTGRES_USER:-schaeffler} - POSTGRES_USER=${POSTGRES_USER:-hartomat}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-schaeffler} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-hartomat}
- POSTGRES_HOST=postgres - POSTGRES_HOST=postgres
- POSTGRES_PORT=5432 - POSTGRES_PORT=5432
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0} - REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
+16 -16
View File
@@ -1,6 +1,6 @@
# Schaeffler Automat MCP Server # HartOMat MCP Server
An MCP (Model Context Protocol) server that gives Claude Code direct access to the Schaeffler Automat render pipeline, product library, and database. An MCP (Model Context Protocol) server that gives Claude Code direct access to the HartOMat render pipeline, product library, and database.
## Quick Start ## Quick Start
@@ -17,9 +17,9 @@ The project includes `.mcp.json` which automatically registers the MCP server wh
### Setup (manual) ### Setup (manual)
```bash ```bash
claude mcp add schaeffler -- uv run \ claude mcp add hartomat -- uv run \
--with "mcp[cli]" --with psycopg2-binary --with httpx \ --with "mcp[cli]" --with psycopg2-binary --with httpx \
python schaeffler_mcp_server.py python hartomat_mcp_server.py
``` ```
### Verify ### Verify
@@ -29,7 +29,7 @@ Inside Claude Code, run:
/mcp /mcp
``` ```
You should see `schaeffler` listed with status "connected". You should see `hartomat` listed with status "connected".
## Available Tools ## Available Tools
@@ -61,8 +61,8 @@ You should see `schaeffler` listed with status "connected".
| Resource URI | Description | | Resource URI | Description |
|---|---| |---|---|
| `schaeffler://schema` | Full database schema (tables + columns) | | `hartomat://schema` | Full database schema (tables + columns) |
| `schaeffler://output-types` | All configured output types | | `hartomat://output-types` | All configured output types |
## Usage Examples ## Usage Examples
@@ -98,9 +98,9 @@ The server connects to your local Docker services by default. Override via envir
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `DATABASE_URL` | `postgresql://schaeffler:schaeffler@localhost:5432/schaeffler` | PostgreSQL connection string | | `DATABASE_URL` | `postgresql://hartomat:hartomat@localhost:5432/hartomat` | PostgreSQL connection string |
| `API_URL` | `http://localhost:8888` | Backend API base URL | | `API_URL` | `http://localhost:8888` | Backend API base URL |
| `API_EMAIL` | `admin@schaeffler.com` | API login email | | `API_EMAIL` | `admin@hartomat.com` | API login email |
| `API_PASSWORD` | `Admin1234!` | API login password | | `API_PASSWORD` | `Admin1234!` | API login password |
### Custom configuration ### Custom configuration
@@ -108,11 +108,11 @@ The server connects to your local Docker services by default. Override via envir
Edit `.mcp.json` in the project root to change defaults, or use `claude mcp add` with `--env` flags: Edit `.mcp.json` in the project root to change defaults, or use `claude mcp add` with `--env` flags:
```bash ```bash
claude mcp add schaeffler \ claude mcp add hartomat \
--env DATABASE_URL=postgresql://user:pass@host/db \ --env DATABASE_URL=postgresql://user:pass@host/db \
--env API_URL=https://staging.example.com \ --env API_URL=https://staging.example.com \
-- uv run --with "mcp[cli]" --with psycopg2-binary --with httpx \ -- uv run --with "mcp[cli]" --with psycopg2-binary --with httpx \
python schaeffler_mcp_server.py python hartomat_mcp_server.py
``` ```
## Security Notes ## Security Notes
@@ -127,7 +127,7 @@ claude mcp add schaeffler \
### Server not connecting ### Server not connecting
1. Check Docker services are running: `docker compose ps` 1. Check Docker services are running: `docker compose ps`
2. Check PostgreSQL is accessible: `psql postgresql://schaeffler:schaeffler@localhost:5432/schaeffler -c "SELECT 1"` 2. Check PostgreSQL is accessible: `psql postgresql://hartomat:hartomat@localhost:5432/hartomat -c "SELECT 1"`
3. Check backend API is up: `curl http://localhost:8888/api/auth/login` 3. Check backend API is up: `curl http://localhost:8888/api/auth/login`
### Dependencies missing ### Dependencies missing
@@ -141,14 +141,14 @@ uv pip install "mcp[cli]" psycopg2-binary httpx
```bash ```bash
# Run manually to see errors # Run manually to see errors
uv run --with "mcp[cli]" --with psycopg2-binary --with httpx \ uv run --with "mcp[cli]" --with psycopg2-binary --with httpx \
python schaeffler_mcp_server.py python hartomat_mcp_server.py
``` ```
### Reset MCP connection ### Reset MCP connection
```bash ```bash
claude mcp remove schaeffler claude mcp remove hartomat
claude mcp add schaeffler -- uv run \ claude mcp add hartomat -- uv run \
--with "mcp[cli]" --with psycopg2-binary --with httpx \ --with "mcp[cli]" --with psycopg2-binary --with httpx \
python schaeffler_mcp_server.py python hartomat_mcp_server.py
``` ```
+17 -17
View File
@@ -12,7 +12,7 @@
- [ ] `blender_render.py` decomposition is still pending; current file remains monolithic - [ ] `blender_render.py` decomposition is still pending; current file remains monolithic
- [ ] Legacy STL-era cleanup is still pending (`stl_quality`, STL endpoints, orphaned directories) - [ ] Legacy STL-era cleanup is still pending (`stl_quality`, STL endpoints, orphaned directories)
- [x] Decision: USD authoring library → **`usd-core` (pip)** — provides `pxr` module, no GPU tools needed, pip-installable in render-worker - [x] Decision: USD authoring library → **`usd-core` (pip)** — provides `pxr` module, no GPU tools needed, pip-installable in render-worker
- [x] Decision: seam/sharp payload encoding → **index-space primvars** (`primvars:schaeffler:seamEdgeVertexPairs`, `primvars:schaeffler:sharpEdgeVertexPairs`) — survives transforms, no KD-tree needed - [x] Decision: seam/sharp payload encoding → **index-space primvars** (`primvars:hartomat:seamEdgeVertexPairs`, `primvars:hartomat:sharpEdgeVertexPairs`) — survives transforms, no KD-tree needed
- [x] Decision: preview GLB derivation → **co-author from same tessellation pass** during migration (avoid round-trip loss from USD→GLB export) - [x] Decision: preview GLB derivation → **co-author from same tessellation pass** during migration (avoid round-trip loss from USD→GLB export)
- [x] Decision: single-file vs override layers → **Option B: canonical geometry layer + material override layer, flattened via `UsdUtils.FlattenLayerStack()` for delivery** — preserves hierarchy AND allows instancing later (`FlattenLayerStack` keeps `instanceable` prims; `UsdStage.Flatten` would expand them). Note: Phase 1 uses no instancing (matching current GLB pipeline), but the delivery path is already instancing-safe. - [x] Decision: single-file vs override layers → **Option B: canonical geometry layer + material override layer, flattened via `UsdUtils.FlattenLayerStack()` for delivery** — preserves hierarchy AND allows instancing later (`FlattenLayerStack` keeps `instanceable` prims; `UsdStage.Flatten` would expand them). Note: Phase 1 uses no instancing (matching current GLB pipeline), but the delivery path is already instancing-safe.
@@ -63,14 +63,14 @@ Add after the `gmsh` line. `usd-core` is the Pixar-maintained pip distribution o
| Attribute | Value source | | Attribute | Value source |
|---|---| |---|---|
| `schaeffler:partKey` | `generate_part_key(xcaf_label_path)` | | `hartomat:partKey` | `generate_part_key(xcaf_label_path)` |
| `schaeffler:sourceName` | XCAF `TDataStd_Name` attribute | | `hartomat:sourceName` | XCAF `TDataStd_Name` attribute |
| `schaeffler:sourceColor` | XCAF embedded color (hex string) | | `hartomat:sourceColor` | XCAF embedded color (hex string) |
| `schaeffler:rawMaterialName` | from `CadFile.part_materials` if available | | `hartomat:rawMaterialName` | from `CadFile.part_materials` if available |
| `schaeffler:tessellation:linearDeflectionMm` | CLI arg value | | `hartomat:tessellation:linearDeflectionMm` | CLI arg value |
| `schaeffler:tessellation:angularDeflectionRad` | CLI arg value | | `hartomat:tessellation:angularDeflectionRad` | CLI arg value |
| `primvars:schaeffler:seamEdgeVertexPairs` | OCC B-rep seam edges (index pairs in mesh-local space) | | `primvars:hartomat:seamEdgeVertexPairs` | OCC B-rep seam edges (index pairs in mesh-local space) |
| `primvars:schaeffler:sharpEdgeVertexPairs` | sharp edges from `_extract_sharp_edge_pairs()` | | `primvars:hartomat:sharpEdgeVertexPairs` | sharp edges from `_extract_sharp_edge_pairs()` |
**CLI interface:** **CLI interface:**
@@ -86,7 +86,7 @@ python3 export_step_to_usd.py \
**Acceptance gate:** `python3 export_step_to_usd.py --step_path 81113-l_cut.stp --output_path /tmp/test.usd` **Acceptance gate:** `python3 export_step_to_usd.py --step_path 81113-l_cut.stp --output_path /tmp/test.usd`
- File exists, parseable - File exists, parseable
- 25 part prims with `schaeffler:partKey` attribute - 25 part prims with `hartomat:partKey` attribute
- Part count matches `export_step_to_gltf.py` output for same file - Part count matches `export_step_to_gltf.py` output for same file
### Task 1.2 — `usd_master` MediaAsset type ### Task 1.2 — `usd_master` MediaAsset type
@@ -150,7 +150,7 @@ def build_scene_manifest(cad_file: CadFile, usd_asset: MediaAsset) -> dict:
"part_key": "ring_outer", "part_key": "ring_outer",
"source_name": "RingOuter_AF0", "source_name": "RingOuter_AF0",
"prim_path": "/Root/Assembly/Bearing/RingOuter", "prim_path": "/Root/Assembly/Bearing/RingOuter",
"effective_material": "SCHAEFFLER_010102_...", "effective_material": "HARTOMAT_010102_...",
"assignment_provenance": "manual|auto|default", "assignment_provenance": "manual|auto|default",
"is_unassigned": false "is_unassigned": false
} }
@@ -198,7 +198,7 @@ New endpoint returning `SceneManifest`. Calls `build_scene_manifest()` — reads
**File:** `backend/app/api/routers/cad.py` **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). Accept `{ "part_key": "ring_outer", "material": "HARTOMAT_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"`. **Acceptance gate:** PUT with `partKey` → subsequent GET `/scene-manifest` shows that part's `assignment_provenance: "manual"`.
@@ -218,10 +218,10 @@ After tessellation (OCC or GMSH), for each mesh face:
Write to USD mesh prim: Write to USD mesh prim:
```python ```python
mesh_prim.GetPrimvar("schaeffler:seamEdgeVertexPairs").Set( mesh_prim.GetPrimvar("hartomat:seamEdgeVertexPairs").Set(
Vt.Vec2iArray(seam_pairs), # [(vi0, vi1), ...] Vt.Vec2iArray(seam_pairs), # [(vi0, vi1), ...]
) )
mesh_prim.GetPrimvar("schaeffler:sharpEdgeVertexPairs").Set( mesh_prim.GetPrimvar("hartomat:sharpEdgeVertexPairs").Set(
Vt.Vec2iArray(sharp_pairs), Vt.Vec2iArray(sharp_pairs),
) )
``` ```
@@ -243,8 +243,8 @@ def import_usd_and_restore_topology(usd_path: str) -> list:
if obj.type != 'MESH': if obj.type != 'MESH':
continue continue
# Read custom attributes set by USD importer # Read custom attributes set by USD importer
seam_pairs = obj.get("schaeffler_seamEdgeVertexPairs") or [] seam_pairs = obj.get("hartomat_seamEdgeVertexPairs") or []
sharp_pairs = obj.get("schaeffler_sharpEdgeVertexPairs") or [] sharp_pairs = obj.get("hartomat_sharpEdgeVertexPairs") or []
_mark_seams_from_index_pairs(obj, seam_pairs) _mark_seams_from_index_pairs(obj, seam_pairs)
_mark_sharp_from_index_pairs(obj, sharp_pairs) _mark_sharp_from_index_pairs(obj, sharp_pairs)
... ...
@@ -262,7 +262,7 @@ def import_usd_and_restore_topology(usd_path: str) -> list:
Add `--usd_path` argument. When provided: Add `--usd_path` argument. When provided:
1. Call `import_usd.py` instead of `export_gltf.py` GLB import 1. Call `import_usd.py` instead of `export_gltf.py` GLB import
2. Read `schaeffler:partKey` and `schaeffler:canonicalMaterialName` per mesh object after import 2. Read `hartomat:partKey` and `hartomat:canonicalMaterialName` per mesh object after import
3. Apply materials by `partKey → material library name` lookup instead of object-name heuristics 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. **Migration:** Keep `--glb_path` working in parallel; switch production task to prefer `--usd_path` when `usd_master` asset exists.
+41 -41
View File
@@ -35,12 +35,12 @@ That split introduces avoidable duplication, fragility, and impedance mismatches
The code confirms this architecture: The code confirms this architecture:
- Geometry export task: [backend/app/domains/pipeline/tasks/export_glb.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/pipeline/tasks/export_glb.py#L16) - Geometry export task: [backend/app/domains/pipeline/tasks/export_glb.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/domains/pipeline/tasks/export_glb.py#L16)
- Production export task: [backend/app/domains/pipeline/tasks/export_glb.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/pipeline/tasks/export_glb.py#L176) - Production export task: [backend/app/domains/pipeline/tasks/export_glb.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/domains/pipeline/tasks/export_glb.py#L176)
- Blender production export script: [render-worker/scripts/export_gltf.py](/home/hartmut/Documents/Copilot/schaefflerautomat/render-worker/scripts/export_gltf.py#L106) - Blender production export script: [render-worker/scripts/export_gltf.py](/home/hartmut/Documents/Copilot/hartomat/render-worker/scripts/export_gltf.py#L106)
- OCC GLB exporter with XCAF name/color preservation and sharp-edge extras: [render-worker/scripts/export_step_to_gltf.py](/home/hartmut/Documents/Copilot/schaefflerautomat/render-worker/scripts/export_step_to_gltf.py#L301) - OCC GLB exporter with XCAF name/color preservation and sharp-edge extras: [render-worker/scripts/export_step_to_gltf.py](/home/hartmut/Documents/Copilot/hartomat/render-worker/scripts/export_step_to_gltf.py#L301)
- Media asset model: [backend/app/domains/media/models.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/media/models.py#L11) - Media asset model: [backend/app/domains/media/models.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/domains/media/models.py#L11)
- Frontend viewer contract: [frontend/src/components/cad/ThreeDViewer.tsx](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/cad/ThreeDViewer.tsx#L40) - Frontend viewer contract: [frontend/src/components/cad/ThreeDViewer.tsx](/home/hartmut/Documents/Copilot/hartomat/frontend/src/components/cad/ThreeDViewer.tsx#L40)
## Goals ## Goals
@@ -95,18 +95,18 @@ However, this data is not represented in a durable scene data model. Some of it
Material mapping already exists conceptually in the domain model: Material mapping already exists conceptually in the domain model:
- product-level `cad_part_materials` - product-level `cad_part_materials`
- canonical material/alias resolution in [backend/app/domains/materials/service.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/materials/service.py#L32) - canonical material/alias resolution in [backend/app/domains/materials/service.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/domains/materials/service.py#L32)
The weak point is the last mile: materials are currently assigned in Blender by matching imported object names from a GLB round-trip in [render-worker/scripts/export_gltf.py](/home/hartmut/Documents/Copilot/schaefflerautomat/render-worker/scripts/export_gltf.py#L192). The weak point is the last mile: materials are currently assigned in Blender by matching imported object names from a GLB round-trip in [render-worker/scripts/export_gltf.py](/home/hartmut/Documents/Copilot/hartomat/render-worker/scripts/export_gltf.py#L192).
### Admin and Settings Surface ### Admin and Settings Surface
The admin/backend model still mirrors the dual-GLB architecture: The admin/backend model still mirrors the dual-GLB architecture:
- separate preview and production tessellation settings in [backend/app/api/routers/admin.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/api/routers/admin.py#L24) - separate preview and production tessellation settings in [backend/app/api/routers/admin.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/api/routers/admin.py#L24)
- a bulk action specifically for missing geometry GLBs in [backend/app/api/routers/admin.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/api/routers/admin.py#L536) - a bulk action specifically for missing geometry GLBs in [backend/app/api/routers/admin.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/api/routers/admin.py#L536)
- an admin UI that exposes preview-vs-production GLB tessellation controls in [frontend/src/pages/Admin.tsx](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/pages/Admin.tsx#L1400) - an admin UI that exposes preview-vs-production GLB tessellation controls in [frontend/src/pages/Admin.tsx](/home/hartmut/Documents/Copilot/hartomat/frontend/src/pages/Admin.tsx#L1400)
- product detail logic that queries both `gltf_geometry` and `gltf_production` assets in [frontend/src/pages/ProductDetail.tsx](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/pages/ProductDetail.tsx#L182) - product detail logic that queries both `gltf_geometry` and `gltf_production` assets in [frontend/src/pages/ProductDetail.tsx](/home/hartmut/Documents/Copilot/hartomat/frontend/src/pages/ProductDetail.tsx#L182)
That duplication is operationally expensive and should be reduced as part of the refactor, not carried forward under new names. That duplication is operationally expensive and should be reduced as part of the refactor, not carried forward under new names.
@@ -216,17 +216,17 @@ Object names imported into Blender must no longer be the primary identity mechan
Each part prim should carry: Each part prim should carry:
- `schaeffler:partKey` - `hartomat:partKey`
- `schaeffler:sourceName` - `hartomat:sourceName`
- `schaeffler:sourceAssemblyPath` - `hartomat:sourceAssemblyPath`
- `schaeffler:sourceColor` - `hartomat:sourceColor`
- `schaeffler:rawMaterialName` - `hartomat:rawMaterialName`
- `schaeffler:canonicalMaterialName` - `hartomat:canonicalMaterialName`
- `schaeffler:tessellation:linearDeflectionMm` - `hartomat:tessellation:linearDeflectionMm`
- `schaeffler:tessellation:angularDeflectionRad` - `hartomat:tessellation:angularDeflectionRad`
- `schaeffler:cadFileId` - `hartomat:cadFileId`
- `schaeffler:productId` when available - `hartomat:productId` when available
- `schaeffler:mesh:topologyHash` - `hartomat:mesh:topologyHash`
Each mesh prim should carry: Each mesh prim should carry:
@@ -241,10 +241,10 @@ Each mesh prim should carry:
USD does not have a first-class standard seam concept for Blender UV editing, so this RFC proposes storing authored topology support data as custom primvars or custom attributes on the mesh prim: USD does not have a first-class standard seam concept for Blender UV editing, so this RFC proposes storing authored topology support data as custom primvars or custom attributes on the mesh prim:
- `primvars:schaeffler:seamEdgeVertexPairs` - `primvars:hartomat:seamEdgeVertexPairs`
- `primvars:schaeffler:sharpEdgeVertexPairs` - `primvars:hartomat:sharpEdgeVertexPairs`
- `primvars:schaeffler:faceBatchIds` - `primvars:hartomat:faceBatchIds`
- `primvars:schaeffler:sourceUv` - `primvars:hartomat:sourceUv`
The exact encoding can evolve, but the initial implementation should optimize for deterministic Blender reconstruction rather than elegance. The exact encoding can evolve, but the initial implementation should optimize for deterministic Blender reconstruction rather than elegance.
@@ -321,10 +321,10 @@ The current viewer supports several behaviors that must not regress:
Those behaviors currently operate on GLB mesh objects in: Those behaviors currently operate on GLB mesh objects in:
- [frontend/src/components/cad/ThreeDViewer.tsx](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/cad/ThreeDViewer.tsx#L488) - [frontend/src/components/cad/ThreeDViewer.tsx](/home/hartmut/Documents/Copilot/hartomat/frontend/src/components/cad/ThreeDViewer.tsx#L488)
- [frontend/src/components/cad/ThreeDViewer.tsx](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/cad/ThreeDViewer.tsx#L553) - [frontend/src/components/cad/ThreeDViewer.tsx](/home/hartmut/Documents/Copilot/hartomat/frontend/src/components/cad/ThreeDViewer.tsx#L553)
- [frontend/src/components/cad/ThreeDViewer.tsx](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/cad/ThreeDViewer.tsx#L675) - [frontend/src/components/cad/ThreeDViewer.tsx](/home/hartmut/Documents/Copilot/hartomat/frontend/src/components/cad/ThreeDViewer.tsx#L675)
- [frontend/src/components/cad/MaterialPanel.tsx](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/cad/MaterialPanel.tsx#L123) - [frontend/src/components/cad/MaterialPanel.tsx](/home/hartmut/Documents/Copilot/hartomat/frontend/src/components/cad/MaterialPanel.tsx#L123)
The USD refactor must preserve these capabilities. The replacement is not "browser renders USD directly". The replacement is: The USD refactor must preserve these capabilities. The replacement is not "browser renders USD directly". The replacement is:
@@ -393,10 +393,10 @@ That makes missing assignments visible instead of silently failing.
The current backend already has the right conceptual split, but the naming is misleading: The current backend already has the right conceptual split, but the naming is misleading:
- [backend/app/domains/products/models.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/products/models.py#L62) `Product.cad_part_materials` behaves like imported or product-authored source material rows - [backend/app/domains/products/models.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/domains/products/models.py#L62) `Product.cad_part_materials` behaves like imported or product-authored source material rows
- [backend/app/domains/products/models.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/products/models.py#L34) `CadFile.part_materials` behaves like viewer-side manual assignments or overrides - [backend/app/domains/products/models.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/domains/products/models.py#L34) `CadFile.part_materials` behaves like viewer-side manual assignments or overrides
- [backend/app/api/routers/cad.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/api/routers/cad.py#L395) still presents those overrides as a part-name keyed map - [backend/app/api/routers/cad.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/api/routers/cad.py#L395) still presents those overrides as a part-name keyed map
- [backend/app/api/routers/products.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/api/routers/products.py#L433) performs Excel reconciliation directly into the product-side list - [backend/app/api/routers/products.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/api/routers/products.py#L433) performs Excel reconciliation directly into the product-side list
The USD refactor should formalize this into three explicit layers: The USD refactor should formalize this into three explicit layers:
@@ -567,7 +567,7 @@ The existing `export_step_to_gltf.py` can remain temporarily for migration and f
## 2. Media Asset Model ## 2. Media Asset Model
Extend [backend/app/domains/media/models.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/media/models.py#L11) with at least: Extend [backend/app/domains/media/models.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/domains/media/models.py#L11) with at least:
- `usd_master` - `usd_master`
@@ -580,7 +580,7 @@ The important change is that `usd_master` becomes the canonical CAD scene artifa
## 3. Pipeline Tasks ## 3. Pipeline Tasks
Replace the current dual-export mental model in [backend/app/domains/pipeline/tasks/export_glb.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/pipeline/tasks/export_glb.py#L16) with: Replace the current dual-export mental model in [backend/app/domains/pipeline/tasks/export_glb.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/domains/pipeline/tasks/export_glb.py#L16) with:
- `generate_usd_master_task` - `generate_usd_master_task`
- optional `generate_preview_glb_task` - optional `generate_preview_glb_task`
@@ -590,7 +590,7 @@ The production GLB task should be retired once Blender can render from USD and b
## 4. Render Service ## 4. Render Service
The render service in [backend/app/services/render_blender.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/services/render_blender.py#L18) currently converts `STEP -> GLB -> Blender render`. The render service in [backend/app/services/render_blender.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/services/render_blender.py#L18) currently converts `STEP -> GLB -> Blender render`.
Target flow: Target flow:
@@ -608,7 +608,7 @@ This removes the need for a production GLB as an intermediate render artifact.
## 5. Frontend ## 5. Frontend
The current viewer API in [frontend/src/components/cad/ThreeDViewer.tsx](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/cad/ThreeDViewer.tsx#L40) assumes: The current viewer API in [frontend/src/components/cad/ThreeDViewer.tsx](/home/hartmut/Documents/Copilot/hartomat/frontend/src/components/cad/ThreeDViewer.tsx#L40) assumes:
- one geometry GLB - one geometry GLB
- one production GLB - one production GLB
@@ -633,7 +633,7 @@ The frontend should stop encoding the architectural distinction between geometry
## 6. Viewer Assignment API ## 6. Viewer Assignment API
The current viewer override endpoint in [backend/app/api/routers/cad.py](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/api/routers/cad.py#L395) should evolve from a raw part-name keyed map into a canonical scene-assignment endpoint. The current viewer override endpoint in [backend/app/api/routers/cad.py](/home/hartmut/Documents/Copilot/hartomat/backend/app/api/routers/cad.py#L395) should evolve from a raw part-name keyed map into a canonical scene-assignment endpoint.
Target behavior: Target behavior:
@@ -720,7 +720,7 @@ Example shape:
"part_key": "ring_outer", "part_key": "ring_outer",
"source_name": "RingOuter_AF0", "source_name": "RingOuter_AF0",
"prim_path": "/Root/Assembly/Bearing/RingOuter", "prim_path": "/Root/Assembly/Bearing/RingOuter",
"effective_material": "SCHAEFFLER_010102_Steel-Polished", "effective_material": "HARTOMAT_010102_Steel-Polished",
"assignment_provenance": "manual", "assignment_provenance": "manual",
"is_unassigned": false "is_unassigned": false
} }
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en" class="" data-accent="green"> <html lang="en" class="" data-accent="green">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/schaeffler.svg" /> <link rel="icon" type="image/svg+xml" href="/hartomat.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hart.O.Mat — Hartomatisierung</title> <title>HartOMat</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "schaefflerautomat-frontend", "name": "hartomat-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "schaefflerautomat-frontend", "name": "hartomat-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@react-three/drei": "^9.102.3", "@react-three/drei": "^9.102.3",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "schaefflerautomat-frontend", "name": "hartomat-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
<title>HartOMat</title>
<rect width="128" height="128" rx="28" fill="#0b3d2e"/>
<path d="M28 32h18v24h36V32h18v64H82V72H46v24H28z" fill="#f3efe2"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

+1 -1
View File
@@ -9,7 +9,7 @@ const api = axios.create({
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token const token = useAuthStore.getState().token
if (token) config.headers.Authorization = `Bearer ${token}` if (token) config.headers.Authorization = `Bearer ${token}`
const tenantId = localStorage.getItem('schaeffler_tenant_id') const tenantId = localStorage.getItem('hartomat_tenant_id')
if (tenantId) config.headers['X-Tenant-ID'] = tenantId if (tenantId) config.headers['X-Tenant-ID'] = tenantId
return config return config
}) })
+5 -5
View File
@@ -5,7 +5,7 @@ export interface Material {
name: string name: string
description: string | null description: string | null
source: string source: string
schaeffler_code: number | null hartomat_code: number | null
created_by_name: string | null created_by_name: string | null
aliases: string[] aliases: string[]
created_at: string created_at: string
@@ -27,7 +27,7 @@ export async function createMaterial(data: {
name: string name: string
description?: string description?: string
source?: string source?: string
schaeffler_code?: number | null hartomat_code?: number | null
}) { }) {
const res = await api.post<Material>('/materials', data) const res = await api.post<Material>('/materials', data)
return res.data return res.data
@@ -54,8 +54,8 @@ export async function saveCadPartMaterials(
return res.data return res.data
} }
export async function seedSchaefflerMaterials() { export async function seedHartOMatMaterials() {
const res = await api.post<{ inserted: number; total: number }>('/materials/seed-schaeffler') const res = await api.post<{ inserted: number; total: number }>('/materials/seed-hartomat')
return res.data return res.data
} }
@@ -93,7 +93,7 @@ export async function seedAliases(): Promise<{ inserted: number; total: number }
export interface MaterialSuggestion { export interface MaterialSuggestion {
id: string id: string
name: string name: string
schaeffler_code: string hartomat_code: string
} }
export interface UnmappedMaterial { export interface UnmappedMaterial {
+5 -5
View File
@@ -98,10 +98,10 @@ export default function MaterialWizard({ open, onClose, onCreated }: Props) {
.replace(/^-|-$/g, '') .replace(/^-|-$/g, '')
const fullMaterialName = fullCode && sanitizedName const fullMaterialName = fullCode && sanitizedName
? `SCHAEFFLER_${fullCode}_${sanitizedName}` ? `HARTOMAT_${fullCode}_${sanitizedName}`
: null : null
const schaefflerCodeInt = fullCode ? parseInt(fullCode, 10) : null const hartomatCodeInt = fullCode ? parseInt(fullCode, 10) : null
const createMut = useMutation({ const createMut = useMutation({
mutationFn: () => mutationFn: () =>
@@ -109,7 +109,7 @@ export default function MaterialWizard({ open, onClose, onCreated }: Props) {
name: fullMaterialName!, name: fullMaterialName!,
description: description.trim() || undefined, description: description.trim() || undefined,
source: 'manual', source: 'manual',
schaeffler_code: schaefflerCodeInt, hartomat_code: hartomatCodeInt,
}), }),
onSuccess: () => { onSuccess: () => {
toast.success('Material created') toast.success('Material created')
@@ -133,7 +133,7 @@ export default function MaterialWizard({ open, onClose, onCreated }: Props) {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-default"> <div className="flex items-center justify-between px-6 py-4 border-b border-border-default">
<div> <div>
<h2 className="text-lg font-semibold text-content">Schaeffler Material Wizard</h2> <h2 className="text-lg font-semibold text-content">HartOMat Material Wizard</h2>
<p className="text-xs text-content-muted mt-0.5">Step {step} of 3</p> <p className="text-xs text-content-muted mt-0.5">Step {step} of 3</p>
</div> </div>
<button onClick={onClose} className="text-content-muted hover:text-content-secondary"> <button onClick={onClose} className="text-content-muted hover:text-content-secondary">
@@ -267,7 +267,7 @@ export default function MaterialWizard({ open, onClose, onCreated }: Props) {
<p className="font-mono text-sm font-semibold text-content truncate"> <p className="font-mono text-sm font-semibold text-content truncate">
{fullMaterialName || ( {fullMaterialName || (
<span className="text-content-muted"> <span className="text-content-muted">
SCHAEFFLER_{typeCode || 'XX'}{effectiveSubType || 'YY'}{consecutive !== null ? String(consecutive).padStart(2, '0') : 'ZZ'}_{sanitizedName || 'Name'} HARTOMAT_{typeCode || 'XX'}{effectiveSubType || 'YY'}{consecutive !== null ? String(consecutive).padStart(2, '0') : 'ZZ'}_{sanitizedName || 'Name'}
</span> </span>
)} )}
</p> </p>
@@ -47,7 +47,7 @@ export default function OutputTypeTable() {
queryKey: ['materials'], queryKey: ['materials'],
queryFn: listMaterials, queryFn: listMaterials,
}) })
const libraryMaterials = (allMaterials ?? []).filter((m: Material) => m.schaeffler_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name)) const libraryMaterials = (allMaterials ?? []).filter((m: Material) => m.hartomat_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name))
const { data: workflows } = useQuery({ const { data: workflows } = useQuery({
queryKey: ['workflows'], queryKey: ['workflows'],
@@ -856,7 +856,7 @@ export default function OutputTypeTable() {
<td className="px-4 py-2"> <td className="px-4 py-2">
{ot.material_override ? ( {ot.material_override ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 font-mono truncate block max-w-[140px]" title={ot.material_override}> <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 font-mono truncate block max-w-[140px]" title={ot.material_override}>
{ot.material_override.replace('SCHAEFFLER_', '').replace(/_/g, ' ')} {ot.material_override.replace('HARTOMAT_', '').replace(/_/g, ' ')}
</span> </span>
) : ( ) : (
<span className="text-xs text-content-muted"></span> <span className="text-xs text-content-muted"></span>
@@ -15,7 +15,7 @@ export interface MaterialOut {
id: string id: string
name: string name: string
description: string | null description: string | null
schaeffler_code: number | null hartomat_code: number | null
source: string source: string
} }
+1 -1
View File
@@ -159,7 +159,7 @@ export function pbrColorHex(pbr: MaterialPBR): string {
/** /**
* Get a preview hex color for a material entry, using PBR data when available. * Get a preview hex color for a material entry, using PBR data when available.
* Replaces the old hardcoded SCHAEFFLER_COLORS lookup. * Replaces the old hardcoded HARTOMAT_COLORS lookup.
*/ */
export function previewColorForEntry( export function previewColorForEntry(
entry: PartMaterialEntry, entry: PartMaterialEntry,
+3 -3
View File
@@ -23,7 +23,7 @@ export default function ChatPanel({ open, onClose, contextType, contextId }: Cha
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [sessionId, setSessionId] = useState<string | undefined>(() => { const [sessionId, setSessionId] = useState<string | undefined>(() => {
// Restore last session from localStorage // Restore last session from localStorage
try { return localStorage.getItem('schaeffler-chat-session') || undefined } catch { return undefined } try { return localStorage.getItem('hartomat-chat-session') || undefined } catch { return undefined }
}) })
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [showSessions, setShowSessions] = useState(false) const [showSessions, setShowSessions] = useState(false)
@@ -31,8 +31,8 @@ export default function ChatPanel({ open, onClose, contextType, contextId }: Cha
// Persist sessionId to localStorage // Persist sessionId to localStorage
useEffect(() => { useEffect(() => {
try { try {
if (sessionId) localStorage.setItem('schaeffler-chat-session', sessionId) if (sessionId) localStorage.setItem('hartomat-chat-session', sessionId)
else localStorage.removeItem('schaeffler-chat-session') else localStorage.removeItem('hartomat-chat-session')
} catch { /* ignore */ } } catch { /* ignore */ }
}, [sessionId]) }, [sessionId])
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -27,7 +27,7 @@ export default function UnmappedMaterialsDialog({ unmapped, onResolved, onCancel
}) })
const libraryMaterials = (allMaterials ?? []).filter( const libraryMaterials = (allMaterials ?? []).filter(
(m: Material) => m.schaeffler_code !== null (m: Material) => m.hartomat_code !== null
) )
const allMapped = unmapped.every((u) => mappings[u.raw_name]) const allMapped = unmapped.every((u) => mappings[u.raw_name])
@@ -11,14 +11,14 @@ const TYPE_GROUPS: Record<string, { label: string; color: string }> = {
} }
function getTypeCode(mat: Material): string | null { function getTypeCode(mat: Material): string | null {
if (mat.schaeffler_code == null) return null if (mat.hartomat_code == null) return null
const s = String(mat.schaeffler_code).padStart(6, '0') const s = String(mat.hartomat_code).padStart(6, '0')
return s.slice(0, 2) return s.slice(0, 2)
} }
/** Extract the human-readable short name after the last underscore: SCHAEFFLER_010101_Steel-Bare -> Steel-Bare */ /** Extract the human-readable short name after the last underscore: HARTOMAT_010101_Steel-Bare -> Steel-Bare */
function shortName(name: string): string { function shortName(name: string): string {
const match = name.match(/^SCHAEFFLER_\d{6}_(.+)$/) const match = name.match(/^HARTOMAT_\d{6}_(.+)$/)
return match ? match[1].replace(/-/g, ' ') : name return match ? match[1].replace(/-/g, ' ') : name
} }
@@ -52,7 +52,7 @@ export default function MaterialInput({ value, onChange, library, missing, onOpe
buckets.get(tc)!.push(m) buckets.get(tc)!.push(m)
} }
// Sorted type codes first, then non-schaeffler // Sorted type codes first, then non-hartomat
const sortedKeys = [...buckets.keys()].sort((a, b) => { const sortedKeys = [...buckets.keys()].sort((a, b) => {
if (a === null) return 1 if (a === null) return 1
if (b === null) return -1 if (b === null) return -1
+2 -2
View File
@@ -50,7 +50,7 @@ export const HELP_TEXTS: Record<string, HelpText> = {
}, },
'action.seed_aliases': { 'action.seed_aliases': {
title: 'Seed Material Aliases', title: 'Seed Material Aliases',
body: 'Loads the default Schaeffler material alias mappings (Steel→SCHAEFFLER_010101_Steel-Bare, etc). Safe to run multiple times — existing aliases are not overwritten.', body: 'Loads the default HartOMat material alias mappings (Steel→HARTOMAT_010101_Steel-Bare, etc). Safe to run multiple times — existing aliases are not overwritten.',
}, },
// Template fields // Template fields
'template.lighting_only': { 'template.lighting_only': {
@@ -64,7 +64,7 @@ export const HELP_TEXTS: Record<string, HelpText> = {
}, },
'template.material_replace_enabled': { 'template.material_replace_enabled': {
title: 'Material Replacement', title: 'Material Replacement',
body: 'When enabled, Blender will replace part materials with the mapped Schaeffler library materials. When disabled, the original .blend materials are used.', body: 'When enabled, Blender will replace part materials with the mapped HartOMat library materials. When disabled, the original .blend materials are used.',
}, },
// Wizard fields // Wizard fields
'wizard.output_type': { 'wizard.output_type': {
+1 -1
View File
@@ -7,7 +7,7 @@
Applied via data-accent="<key>" on <html> Applied via data-accent="<key>" on <html>
============================================================ */ ============================================================ */
/* Default / Schaeffler Green */ /* Default / HartOMat Green */
:root, :root,
[data-accent="green"] { [data-accent="green"] {
--color-accent: #00893d; --color-accent: #00893d;
+1 -1
View File
@@ -12,7 +12,7 @@ import { useThemeStore, applyTheme, resolveTheme, type ThemeMode, type AccentKey
--------------------------------------------------------------- */ --------------------------------------------------------------- */
;(function () { ;(function () {
try { try {
const raw = localStorage.getItem('schaeffler-theme') const raw = localStorage.getItem('hartomat-theme')
if (raw) { if (raw) {
const { state } = JSON.parse(raw) as { state: { mode: ThemeMode; accent: AccentKey; customHex?: string } } const { state } = JSON.parse(raw) as { state: { mode: ThemeMode; accent: AccentKey; customHex?: string } }
applyTheme(state.mode ?? 'light', state.accent ?? 'green', state.customHex) applyTheme(state.mode ?? 'light', state.accent ?? 'green', state.customHex)
+1 -1
View File
@@ -1549,7 +1549,7 @@ export default function AdminPage() {
type="email" type="email"
value={smtp.smtp_from_address ?? ''} value={smtp.smtp_from_address ?? ''}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_from_address: e.target.value }))} onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_from_address: e.target.value }))}
placeholder="noreply@schaeffler.com" placeholder="noreply@hartomat.com"
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
+1 -1
View File
@@ -55,7 +55,7 @@ function UploadModal({ onClose }: { onClose: () => void }) {
</label> </label>
<input <input
className="input-base" className="input-base"
placeholder="e.g. Schaeffler Materials v2" placeholder="e.g. HartOMat Materials v2"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
+2 -2
View File
@@ -36,7 +36,7 @@ export default function LoginPage() {
<div className="w-16 h-16 bg-accent rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-accent rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-2xl font-bold">S</span> <span className="text-white text-2xl font-bold">S</span>
</div> </div>
<h1 className="text-2xl font-bold text-content">Schaeffler Automat</h1> <h1 className="text-2xl font-bold text-content">HartOMat</h1>
<p className="text-content-muted text-sm mt-1">Media Creation Pipeline</p> <p className="text-content-muted text-sm mt-1">Media Creation Pipeline</p>
</div> </div>
@@ -49,7 +49,7 @@ export default function LoginPage() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
className="input-base w-full" className="input-base w-full"
placeholder="admin@schaeffler.com" placeholder="admin@hartomat.com"
/> />
</div> </div>
<div> <div>
+18 -18
View File
@@ -8,7 +8,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { import {
listMaterials, createMaterial, updateMaterial, deleteMaterial, listMaterials, createMaterial, updateMaterial, deleteMaterial,
seedSchaefflerMaterials, addAlias, deleteAlias, seedAliases, seedHartOMatMaterials, addAlias, deleteAlias, seedAliases,
batchCreateAliases, batchCreateAliases,
} from '../api/materials' } from '../api/materials'
import type { Material } from '../api/materials' import type { Material } from '../api/materials'
@@ -24,8 +24,8 @@ const TYPE_GROUPS = [
] as const ] as const
function getTypeCode(mat: Material): string | null { function getTypeCode(mat: Material): string | null {
if (mat.schaeffler_code == null) return null if (mat.hartomat_code == null) return null
return String(mat.schaeffler_code).padStart(6, '0').slice(0, 2) return String(mat.hartomat_code).padStart(6, '0').slice(0, 2)
} }
interface MaterialGroup { interface MaterialGroup {
@@ -90,12 +90,12 @@ export default function MaterialsPage() {
}) })
const seedMut = useMutation({ const seedMut = useMutation({
mutationFn: seedSchaefflerMaterials, mutationFn: seedHartOMatMaterials,
onSuccess: (data) => { onSuccess: (data) => {
if (data.inserted > 0) { if (data.inserted > 0) {
toast.success(`Imported ${data.inserted} of ${data.total} Schaeffler standard materials`) toast.success(`Imported ${data.inserted} of ${data.total} HartOMat standard materials`)
} else { } else {
toast.info('All Schaeffler standard materials already exist') toast.info('All HartOMat standard materials already exist')
} }
qc.invalidateQueries({ queryKey: ['materials'] }) qc.invalidateQueries({ queryKey: ['materials'] })
}, },
@@ -147,9 +147,9 @@ export default function MaterialsPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create alias'), onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create alias'),
}) })
// Library materials (have schaeffler_code) for quick-map dropdown // Library materials (have hartomat_code) for quick-map dropdown
const libraryMaterials = useMemo( const libraryMaterials = useMemo(
() => materials.filter((m) => m.schaeffler_code !== null).sort((a, b) => a.name.localeCompare(b.name)), () => materials.filter((m) => m.hartomat_code !== null).sort((a, b) => a.name.localeCompare(b.name)),
[materials] [materials]
) )
@@ -203,7 +203,7 @@ export default function MaterialsPage() {
buckets.delete(tg.code) buckets.delete(tg.code)
} }
} }
// Custom / non-schaeffler materials // Custom / non-hartomat materials
const custom = buckets.get(null) const custom = buckets.get(null)
if (custom && custom.length > 0) { if (custom && custom.length > 0) {
result.push({ code: null, label: 'Custom', icon: Plus, bg: 'bg-surface-alt', border: 'border-border-default', text: 'text-content-secondary', items: custom }) result.push({ code: null, label: 'Custom', icon: Plus, bg: 'bg-surface-alt', border: 'border-border-default', text: 'text-content-secondary', items: custom })
@@ -239,7 +239,7 @@ export default function MaterialsPage() {
setConfirmState({ setConfirmState({
open: true, open: true,
title: 'Import Standard Materials', title: 'Import Standard Materials',
message: 'Import 35 Schaeffler standard materials? Existing entries will be skipped.', message: 'Import 35 HartOMat standard materials? Existing entries will be skipped.',
onConfirm: () => { onConfirm: () => {
seedMut.mutate() seedMut.mutate()
setConfirmState((s) => ({ ...s, open: false })) setConfirmState((s) => ({ ...s, open: false }))
@@ -248,7 +248,7 @@ export default function MaterialsPage() {
}} }}
disabled={seedMut.isPending} disabled={seedMut.isPending}
className="btn-secondary text-sm flex items-center gap-1.5" className="btn-secondary text-sm flex items-center gap-1.5"
title="Import the 35 standard Schaeffler SCHAEFFLER_... materials used in Blender material libraries. Existing entries are skipped." title="Import the 35 standard HartOMat HARTOMAT_... materials used in Blender material libraries. Existing entries are skipped."
> >
<Download size={14} /> {seedMut.isPending ? 'Importing...' : 'Import Standards'} <Download size={14} /> {seedMut.isPending ? 'Importing...' : 'Import Standards'}
</button> </button>
@@ -266,16 +266,16 @@ export default function MaterialsPage() {
}} }}
disabled={seedAliasMut.isPending} disabled={seedAliasMut.isPending}
className="btn-secondary text-sm flex items-center gap-1.5" className="btn-secondary text-sm flex items-center gap-1.5"
title="Seed ~100 material aliases from the Schaeffler naming scheme (German descriptions, intermediate codes → SCHAEFFLER_... library names). Existing aliases are skipped." title="Seed ~100 material aliases from the HartOMat naming scheme (German descriptions, intermediate codes → HARTOMAT_... library names). Existing aliases are skipped."
> >
<Tag size={14} /> {seedAliasMut.isPending ? 'Seeding...' : 'Seed Aliases'} <Tag size={14} /> {seedAliasMut.isPending ? 'Seeding...' : 'Seed Aliases'}
</button> </button>
<button <button
onClick={() => setShowWizard(true)} onClick={() => setShowWizard(true)}
className="btn-secondary text-sm flex items-center gap-1.5" className="btn-secondary text-sm flex items-center gap-1.5"
title="Open the Schaeffler Wizard — guided tool to set up SCHAEFFLER_... materials and aliases from the standard naming scheme" title="Open the HartOMat Wizard — guided tool to set up HARTOMAT_... materials and aliases from the standard naming scheme"
> >
<Wand2 size={14} /> Schaeffler Wizard <Wand2 size={14} /> HartOMat Wizard
</button> </button>
<button onClick={() => setShowAdd(!showAdd)} className="btn-primary"> <button onClick={() => setShowAdd(!showAdd)} className="btn-primary">
<Plus size={16} /> Add Material <Plus size={16} /> Add Material
@@ -407,10 +407,10 @@ export default function MaterialsPage() {
<> <>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-content truncate">{mat.name}</p> <p className="text-sm font-medium text-content truncate">{mat.name}</p>
{mat.schaeffler_code != null && ( {mat.hartomat_code != null && (
<p className="text-xs text-content-muted font-mono">Nr: {mat.schaeffler_code}</p> <p className="text-xs text-content-muted font-mono">Nr: {mat.hartomat_code}</p>
)} )}
{mat.schaeffler_code == null && mat.aliases.length === 0 && ( {mat.hartomat_code == null && mat.aliases.length === 0 && (
<div className="flex items-center gap-1.5 mt-1"> <div className="flex items-center gap-1.5 mt-1">
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded"> <span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
<AlertTriangle size={10} /> No alias <AlertTriangle size={10} /> No alias
@@ -614,7 +614,7 @@ function AliasPill({
} }
function SourceBadge({ source }: { source: string }) { function SourceBadge({ source }: { source: string }) {
if (source === 'schaeffler_standard') { if (source === 'hartomat_standard') {
return ( return (
<span className="inline-flex items-center gap-1 text-xs font-medium bg-status-success-bg text-status-success-text px-2 py-0.5 rounded-full"> <span className="inline-flex items-center gap-1 text-xs font-medium bg-status-success-bg text-status-success-text px-2 py-0.5 rounded-full">
Standard Standard
+3 -3
View File
@@ -81,7 +81,7 @@ export default function NewProductOrderPage() {
queryFn: listMaterials, queryFn: listMaterials,
enabled: step >= 3, enabled: step >= 3,
}) })
const libMaterials = (allMaterials ?? []).filter((m: Material) => m.schaeffler_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name)) const libMaterials = (allMaterials ?? []).filter((m: Material) => m.hartomat_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name))
function initPositionsForProduct(product: Product, globals: GlobalRenderPosition[] = []) { function initPositionsForProduct(product: Product, globals: GlobalRenderPosition[] = []) {
// Pre-select all per-product positions (if any) // Pre-select all per-product positions (if any)
@@ -822,10 +822,10 @@ export default function NewProductOrderPage() {
value={lineOverrides[line.key] ?? ''} value={lineOverrides[line.key] ?? ''}
onChange={(e) => setLineOverrides((prev) => ({ ...prev, [line.key]: e.target.value }))} onChange={(e) => setLineOverrides((prev) => ({ ...prev, [line.key]: e.target.value }))}
> >
<option value="">{materialOverride ? `Global: ${materialOverride.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}` : 'No override'}</option> <option value="">{materialOverride ? `Global: ${materialOverride.replace('HARTOMAT_', '').replace(/_/g, ' ')}` : 'No override'}</option>
{materialOverride && <option value="__none__"> No override (clear) </option>} {materialOverride && <option value="__none__"> No override (clear) </option>}
{libMaterials.map((m: Material) => ( {libMaterials.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option> <option key={m.id} value={m.name}>{m.name.replace('HARTOMAT_', '').replace(/_/g, ' ')}</option>
))} ))}
</select> </select>
</td> </td>
+6 -6
View File
@@ -140,7 +140,7 @@ export default function OrderDetailPage() {
} }
const { data: matList } = useQuery({ queryKey: ['materials'], queryFn: listMaterials }) const { data: matList } = useQuery({ queryKey: ['materials'], queryFn: listMaterials })
const orderLibMats = (matList ?? []).filter((m: Material) => m.schaeffler_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name)) const orderLibMats = (matList ?? []).filter((m: Material) => m.hartomat_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name))
const batchOverrideMut = useMutation({ const batchOverrideMut = useMutation({
mutationFn: (val: string | null) => batchMaterialOverride(id!, val), mutationFn: (val: string | null) => batchMaterialOverride(id!, val),
@@ -660,7 +660,7 @@ export default function OrderDetailPage() {
<option value="">Apply to all lines</option> <option value="">Apply to all lines</option>
<option value="__clear__"> Clear all overrides </option> <option value="__clear__"> Clear all overrides </option>
{orderLibMats.map((m: Material) => ( {orderLibMats.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option> <option key={m.id} value={m.name}>{m.name.replace('HARTOMAT_', '').replace(/_/g, ' ')}</option>
))} ))}
</select> </select>
{batchOverrideMut.isPending && <Loader2 size={14} className="animate-spin text-accent" />} {batchOverrideMut.isPending && <Loader2 size={14} className="animate-spin text-accent" />}
@@ -1017,7 +1017,7 @@ function OrderLineRow({
}) })
const { data: allMats } = useQuery({ queryKey: ['materials'], queryFn: listMaterials }) const { data: allMats } = useQuery({ queryKey: ['materials'], queryFn: listMaterials })
const libMats = (allMats ?? []).filter((m: Material) => m.schaeffler_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name)) const libMats = (allMats ?? []).filter((m: Material) => m.hartomat_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name))
const overrideMut = useMutation({ const overrideMut = useMutation({
mutationFn: (val: string | null) => patchOrderLine(orderId, line.id, { material_override: val }), mutationFn: (val: string | null) => patchOrderLine(orderId, line.id, { material_override: val }),
@@ -1112,7 +1112,7 @@ function OrderLineRow({
onClick={() => setShowOverride(!showOverride)} onClick={() => setShowOverride(!showOverride)}
title="Click to change material override" title="Click to change material override"
> >
{line.material_override.replace('SCHAEFFLER_', '').replace(/_/g, ' ')} {line.material_override.replace('HARTOMAT_', '').replace(/_/g, ' ')}
</span> </span>
<button <button
onClick={() => overrideMut.mutate(null)} onClick={() => overrideMut.mutate(null)}
@@ -1131,7 +1131,7 @@ function OrderLineRow({
> >
<option value="">No material override</option> <option value="">No material override</option>
{libMats.map((m: Material) => ( {libMats.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option> <option key={m.id} value={m.name}>{m.name.replace('HARTOMAT_', '').replace(/_/g, ' ')}</option>
))} ))}
</select> </select>
)} )}
@@ -1147,7 +1147,7 @@ function OrderLineRow({
> >
<option value="">No material override</option> <option value="">No material override</option>
{libMats.map((m: Material) => ( {libMats.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option> <option key={m.id} value={m.name}>{m.name.replace('HARTOMAT_', '').replace(/_/g, ' ')}</option>
))} ))}
</select> </select>
) : ( ) : (
+1 -1
View File
@@ -14,7 +14,7 @@ export default function PreferencesPage() {
return ( return (
<div className="p-8 max-w-2xl"> <div className="p-8 max-w-2xl">
<h1 className="text-2xl font-bold text-content mb-1">Preferences</h1> <h1 className="text-2xl font-bold text-content mb-1">Preferences</h1>
<p className="text-sm text-content-muted mb-8">Customize your Schaeffler Automat experience.</p> <p className="text-sm text-content-muted mb-8">Customize your HartOMat experience.</p>
{/* Appearance */} {/* Appearance */}
<section className="card p-6 space-y-6"> <section className="card p-6 space-y-6">
+4 -4
View File
@@ -11,7 +11,7 @@ import {
} from '../api/tenants' } from '../api/tenants'
import type { Tenant, TenantCreate, TenantUpdate, TenantAIConfig, TenantAIConfigUpdate } from '../api/tenants' import type { Tenant, TenantCreate, TenantUpdate, TenantAIConfig, TenantAIConfigUpdate } from '../api/tenants'
const TENANT_CONTEXT_KEY = 'schaeffler_tenant_id' const TENANT_CONTEXT_KEY = 'hartomat_tenant_id'
function slugify(name: string): string { function slugify(name: string): string {
return name return name
@@ -392,7 +392,7 @@ export default function TenantsPage() {
type="text" type="text"
value={createForm.name} value={createForm.name}
onChange={(e) => handleCreateNameChange(e.target.value)} onChange={(e) => handleCreateNameChange(e.target.value)}
placeholder="e.g. Schaeffler GmbH" placeholder="e.g. HartOMat GmbH"
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50" className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
/> />
</div> </div>
@@ -406,7 +406,7 @@ export default function TenantsPage() {
type="text" type="text"
value={createForm.slug} value={createForm.slug}
onChange={(e) => handleCreateSlugChange(e.target.value)} onChange={(e) => handleCreateSlugChange(e.target.value)}
placeholder="e.g. schaeffler-gmbh" placeholder="e.g. hartomat-gmbh"
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
/> />
</div> </div>
@@ -677,7 +677,7 @@ export default function TenantsPage() {
rows={4} rows={4}
value={aiForm.ai_validation_prompt ?? ''} value={aiForm.ai_validation_prompt ?? ''}
onChange={(e) => setAIForm((prev) => ({ ...prev, ai_validation_prompt: e.target.value || null }))} onChange={(e) => setAIForm((prev) => ({ ...prev, ai_validation_prompt: e.target.value || null }))}
placeholder="Optional: override the default Schaeffler bearing analysis prompt" placeholder="Optional: override the default HartOMat bearing analysis prompt"
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm resize-y focus:outline-none focus:ring-2 focus:ring-accent/50" className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm resize-y focus:outline-none focus:ring-2 focus:ring-accent/50"
/> />
</div> </div>
+1 -1
View File
@@ -32,6 +32,6 @@ export const useAuthStore = create<AuthState>()(
setAuth: (token, user) => set({ token, user }), setAuth: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null }), logout: () => set({ token: null, user: null }),
}), }),
{ name: 'schaeffler-auth' }, { name: 'hartomat-auth' },
), ),
) )
+2 -2
View File
@@ -5,7 +5,7 @@ export type ThemeMode = 'light' | 'dark' | 'system'
export type AccentKey = 'green' | 'blue' | 'purple' | 'amber' | 'teal' | 'custom' export type AccentKey = 'green' | 'blue' | 'purple' | 'amber' | 'teal' | 'custom'
export const ACCENT_PRESETS: { key: AccentKey; label: string; hex: string }[] = [ export const ACCENT_PRESETS: { key: AccentKey; label: string; hex: string }[] = [
{ key: 'green', label: 'Schaeffler Green', hex: '#00893d' }, { key: 'green', label: 'HartOMat Green', hex: '#00893d' },
{ key: 'blue', label: 'Blue', hex: '#2563eb' }, { key: 'blue', label: 'Blue', hex: '#2563eb' },
{ key: 'purple', label: 'Purple', hex: '#7c3aed' }, { key: 'purple', label: 'Purple', hex: '#7c3aed' },
{ key: 'amber', label: 'Amber', hex: '#d97706' }, { key: 'amber', label: 'Amber', hex: '#d97706' },
@@ -115,6 +115,6 @@ export const useThemeStore = create<ThemeState>()(
applyTheme(get().mode, 'custom', hex) applyTheme(get().mode, 'custom', hex)
}, },
}), }),
{ name: 'schaeffler-theme' }, { name: 'hartomat-theme' },
), ),
) )
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Schaeffler Automat MCP Server. """HartOMat MCP Server.
Exposes the render pipeline, product library, material system, and order Exposes the render pipeline, product library, material system, and order
management as MCP tools for Claude Code. management as MCP tools for Claude Code.
@@ -8,7 +8,7 @@ Requirements (install once):
uv pip install "mcp[cli]" psycopg2-binary httpx uv pip install "mcp[cli]" psycopg2-binary httpx
Register in Claude Code: Register in Claude Code:
claude mcp add schaeffler -- python schaeffler_mcp_server.py claude mcp add hartomat -- python hartomat_mcp_server.py
""" """
import json import json
import os import os
@@ -23,18 +23,18 @@ from mcp.server.fastmcp import FastMCP
DB_URL = os.environ.get( DB_URL = os.environ.get(
"DATABASE_URL", "DATABASE_URL",
"postgresql://schaeffler:schaeffler@localhost:5432/schaeffler", "postgresql://hartomat:hartomat@localhost:5432/hartomat",
) )
API_URL = os.environ.get("API_URL", "http://localhost:8888") API_URL = os.environ.get("API_URL", "http://localhost:8888")
API_EMAIL = os.environ.get("API_EMAIL", "admin@schaeffler.com") API_EMAIL = os.environ.get("API_EMAIL", "admin@hartomat.com")
API_PASSWORD = os.environ.get("API_PASSWORD", "Admin1234!") API_PASSWORD = os.environ.get("API_PASSWORD", "Admin1234!")
# ── Server setup ───────────────────────────────────────────────────────────── # ── Server setup ─────────────────────────────────────────────────────────────
mcp = FastMCP( mcp = FastMCP(
"Schaeffler Automat", "HartOMat",
instructions=( instructions=(
"MCP server for the Schaeffler Automat render pipeline. " "MCP server for the HartOMat render pipeline. "
"Provides tools to query orders, products, materials, render status, " "Provides tools to query orders, products, materials, render status, "
"worker health, and run read-only SQL against the PostgreSQL database." "worker health, and run read-only SQL against the PostgreSQL database."
), ),
@@ -112,7 +112,7 @@ def _api_post(path: str, body: dict | None = None) -> dict | list:
@mcp.tool() @mcp.tool()
def query_database(sql: str) -> str: def query_database(sql: str) -> str:
"""Execute a read-only SQL query against the Schaeffler PostgreSQL database. """Execute a read-only SQL query against the HartOMat PostgreSQL database.
Only SELECT queries are allowed. The database contains tables for orders, Only SELECT queries are allowed. The database contains tables for orders,
order_lines, products, cad_files, materials, material_aliases, order_lines, products, cad_files, materials, material_aliases,
@@ -285,7 +285,7 @@ def set_material_override(order_id: str, material_name: str = "") -> str:
Args: Args:
order_id: UUID of the order. order_id: UUID of the order.
material_name: SCHAEFFLER library material name, or empty to clear. material_name: HARTOMAT library material name, or empty to clear.
""" """
data = _api_post( data = _api_post(
f"/api/orders/{order_id}/batch-material-override", f"/api/orders/{order_id}/batch-material-override",
@@ -314,7 +314,7 @@ def create_order(
output_type_id: UUID of the output type (takes priority over name). output_type_id: UUID of the output type (takes priority over name).
output_type_name: Name of the output type (used if output_type_id is empty). output_type_name: Name of the output type (used if output_type_id is empty).
render_overrides: Optional dict of render setting overrides (e.g. {"output_format": "webp", "width": 2048, "height": 2048}). render_overrides: Optional dict of render setting overrides (e.g. {"output_format": "webp", "width": 2048, "height": 2048}).
material_override: Optional SCHAEFFLER library material name to apply to all lines. material_override: Optional HARTOMAT library material name to apply to all lines.
notes: Optional notes for the order. notes: Optional notes for the order.
""" """
# Resolve output_type_id from name if needed # Resolve output_type_id from name if needed
@@ -609,7 +609,7 @@ def get_failed_renders(limit: int = 20) -> str:
# ── Resources ──────────────────────────────────────────────────────────────── # ── Resources ────────────────────────────────────────────────────────────────
@mcp.resource("schaeffler://schema") @mcp.resource("hartomat://schema")
def get_database_schema() -> str: def get_database_schema() -> str:
"""Database schema overview — table names and column types.""" """Database schema overview — table names and column types."""
rows = _db_query(""" rows = _db_query("""
@@ -634,7 +634,7 @@ def get_database_schema() -> str:
return "\n".join(lines) return "\n".join(lines)
@mcp.resource("schaeffler://output-types") @mcp.resource("hartomat://output-types")
def get_output_types_resource() -> str: def get_output_types_resource() -> str:
"""All configured output types with settings.""" """All configured output types with settings."""
data = _api_get("/api/output-types?include_inactive=true") data = _api_get("/api/output-types?include_inactive=true")
+1 -1
View File
@@ -89,7 +89,7 @@ def import_usd_file(usd_path: str) -> tuple[list, dict]:
"""Import USD stage into current Blender scene — delegates to import_usd module. """Import USD stage into current Blender scene — delegates to import_usd module.
Returns (parts, material_lookup) where material_lookup maps Returns (parts, material_lookup) where material_lookup maps
blender_object_name canonical SCHAEFFLER material name (from USD primvars). blender_object_name canonical HARTOMAT material name (from USD primvars).
""" """
from import_usd import import_usd_file as _impl from import_usd import import_usd_file as _impl
result = _impl(usd_path) result = _impl(usd_path)
+3 -3
View File
@@ -5,7 +5,7 @@ import os
import re as _re import re as _re
import time as _time import time as _time
FAILED_MATERIAL_NAME = "SCHAEFFLER_059999_FailedMaterial" FAILED_MATERIAL_NAME = "HARTOMAT_059999_FailedMaterial"
def _find_material_with_nodes(base_name: str): def _find_material_with_nodes(base_name: str):
@@ -100,7 +100,7 @@ def _batch_append_materials(mat_lib_path: str, names: set[str]) -> dict:
def assign_failed_material(part_obj) -> None: def assign_failed_material(part_obj) -> None:
"""Assign the standard fallback material (magenta) when no library material matches. """Assign the standard fallback material (magenta) when no library material matches.
Reuses SCHAEFFLER_059999_FailedMaterial if already loaded; otherwise Reuses HARTOMAT_059999_FailedMaterial if already loaded; otherwise
creates a simple magenta Principled BSDF node tree. creates a simple magenta Principled BSDF node tree.
""" """
import bpy # type: ignore[import] import bpy # type: ignore[import]
@@ -157,7 +157,7 @@ def apply_material_library_direct(
"""Assign materials from library using a direct object_name → material_name mapping. """Assign materials from library using a direct object_name → material_name mapping.
This bypasses all name-matching heuristics the mapping comes from USD This bypasses all name-matching heuristics the mapping comes from USD
customData (schaeffler:canonicalMaterialName) read via pxr after Blender import. customData (hartomat:canonicalMaterialName) read via pxr after Blender import.
Parts not present in material_lookup receive FAILED_MATERIAL_NAME. Parts not present in material_lookup receive FAILED_MATERIAL_NAME.
material_lookup: {blender_object_name: canonical_material_name} material_lookup: {blender_object_name: canonical_material_name}
@@ -83,7 +83,7 @@ def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
_unassigned = [p for p in parts if not p.data.materials or _unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and (len(p.data.materials) == 1 and
p.data.materials[0] and p.data.materials[0] and
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")] p.data.materials[0].name == "HARTOMAT_059999_FailedMaterial")]
if _unassigned: if _unassigned:
print(f"[blender_render] {len(_unassigned)} parts without USD primvar — " print(f"[blender_render] {len(_unassigned)} parts without USD primvar — "
f"falling back to name-matching", flush=True) f"falling back to name-matching", flush=True)
@@ -162,7 +162,7 @@ def _setup_mode_a(args) -> None:
_unassigned = [p for p in parts if not p.data.materials or _unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and (len(p.data.materials) == 1 and
p.data.materials[0] and p.data.materials[0] and
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")] p.data.materials[0].name == "HARTOMAT_059999_FailedMaterial")]
if _unassigned: if _unassigned:
apply_material_library( apply_material_library(
_unassigned, args.material_library_path, _unassigned, args.material_library_path,
+1 -1
View File
@@ -19,7 +19,7 @@ def apply_asset_library_materials(blend_path: str, material_map: dict, link: boo
Args: Args:
blend_path: Absolute path to the .blend library file. blend_path: Absolute path to the .blend library file.
material_map: Mapping of current slot material name -> library material name. material_map: Mapping of current slot material name -> library material name.
E.g. {"Steel--Stahl": "SCHAEFFLER_010101_Steel-Bare"} E.g. {"Steel--Stahl": "HARTOMAT_010101_Steel-Bare"}
link: If True (default), link materials (external reference, good for rendering). link: If True (default), link materials (external reference, good for rendering).
If False, append materials (local copy required for GLB/GLTF export so If False, append materials (local copy required for GLB/GLTF export so
that the exporter can traverse Principled BSDF node trees for PBR values). that the exporter can traverse Principled BSDF node trees for PBR values).
+2 -2
View File
@@ -577,7 +577,7 @@ def main():
if material_map: if material_map:
_unassigned = [p for p in parts if not p.data.materials or _unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and p.data.materials[0] and (len(p.data.materials) == 1 and p.data.materials[0] and
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")] p.data.materials[0].name == "HARTOMAT_059999_FailedMaterial")]
if _unassigned: if _unassigned:
print(f"[cinematic_render] {len(_unassigned)} parts without USD primvar -- " print(f"[cinematic_render] {len(_unassigned)} parts without USD primvar -- "
f"falling back to name-matching", flush=True) f"falling back to name-matching", flush=True)
@@ -654,7 +654,7 @@ def main():
if material_map: if material_map:
_unassigned = [p for p in parts if not p.data.materials or _unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and p.data.materials[0] and (len(p.data.materials) == 1 and p.data.materials[0] and
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")] p.data.materials[0].name == "HARTOMAT_059999_FailedMaterial")]
if _unassigned: if _unassigned:
_apply_material_library_shared( _apply_material_library_shared(
_unassigned, material_library_path, _unassigned, material_library_path,
+2 -2
View File
@@ -754,8 +754,8 @@ def main() -> None:
try: try:
extras_payload: dict = {} extras_payload: dict = {}
if sharp_pairs: if sharp_pairs:
extras_payload["schaeffler_sharp_edge_pairs"] = sharp_pairs extras_payload["hartomat_sharp_edge_pairs"] = sharp_pairs
extras_payload["schaeffler_sharp_threshold_deg"] = args.sharp_threshold extras_payload["hartomat_sharp_threshold_deg"] = args.sharp_threshold
if part_key_map: if part_key_map:
extras_payload["partKeyMap"] = part_key_map extras_payload["partKeyMap"] = part_key_map
if extras_payload: if extras_payload:
+16 -16
View File
@@ -1,4 +1,4 @@
"""STEP → USD exporter for Schaeffler Automat. """STEP → USD exporter for HartOMat.
Reads a STEP file via OCP/XCAF (preserving part names + embedded colors), Reads a STEP file via OCP/XCAF (preserving part names + embedded colors),
tessellates with BRepMesh, builds a USD stage mirroring the full XCAF tessellates with BRepMesh, builds a USD stage mirroring the full XCAF
@@ -18,7 +18,7 @@ Usage:
[--color_map '{"Ring": "#4C9BE8"}'] \\ [--color_map '{"Ring": "#4C9BE8"}'] \\
[--sharp_threshold 20.0] \\ [--sharp_threshold 20.0] \\
[--cad_file_id uuid] \\ [--cad_file_id uuid] \\
[--material_map '{"part_name": "SCHAEFFLER_010101_Steel-Bare", ...}'] [--material_map '{"part_name": "HARTOMAT_010101_Steel-Bare", ...}']
Exit 0 on success, exit 1 on failure. Exit 0 on success, exit 1 on failure.
Prints MANIFEST_JSON: {...} to stdout before exit. Prints MANIFEST_JSON: {...} to stdout before exit.
@@ -418,8 +418,8 @@ def _author_xcaf_to_usd(
_occ_trsf_to_usd_matrix(local_loc.Transformation())) _occ_trsf_to_usd_matrix(local_loc.Transformation()))
prim = xform.GetPrim() prim = xform.GetPrim()
prim.SetCustomDataByKey("schaeffler:sourceName", source_name) prim.SetCustomDataByKey("hartomat:sourceName", source_name)
prim.SetCustomDataByKey("schaeffler:sourceAssemblyPath", xcaf_path) prim.SetCustomDataByKey("hartomat:sourceAssemblyPath", xcaf_path)
print(f" {' ' * depth}[asm] {source_name}{xform_path}" print(f" {' ' * depth}[asm] {source_name}{xform_path}"
f"{' (transform)' if has_local_trsf else ''}") f"{' (transform)' if has_local_trsf else ''}")
@@ -484,16 +484,16 @@ def _author_xcaf_to_usd(
_occ_trsf_to_usd_matrix(local_loc.Transformation())) _occ_trsf_to_usd_matrix(local_loc.Transformation()))
prim = xform.GetPrim() prim = xform.GetPrim()
prim.SetCustomDataByKey("schaeffler:partKey", part_key) prim.SetCustomDataByKey("hartomat:partKey", part_key)
prim.SetCustomDataByKey("schaeffler:sourceName", source_name) prim.SetCustomDataByKey("hartomat:sourceName", source_name)
prim.SetCustomDataByKey("schaeffler:sourceAssemblyPath", xcaf_path) prim.SetCustomDataByKey("hartomat:sourceAssemblyPath", xcaf_path)
prim.SetCustomDataByKey("schaeffler:sourceColor", hex_color) prim.SetCustomDataByKey("hartomat:sourceColor", hex_color)
prim.SetCustomDataByKey("schaeffler:tessellation:linearDeflectionMm", prim.SetCustomDataByKey("hartomat:tessellation:linearDeflectionMm",
args.linear_deflection) args.linear_deflection)
prim.SetCustomDataByKey("schaeffler:tessellation:angularDeflectionRad", prim.SetCustomDataByKey("hartomat:tessellation:angularDeflectionRad",
args.angular_deflection) args.angular_deflection)
if args.cad_file_id: if args.cad_file_id:
prim.SetCustomDataByKey("schaeffler:cadFileId", args.cad_file_id) prim.SetCustomDataByKey("hartomat:cadFileId", args.cad_file_id)
# ── UsdGeomMesh ──────────────────────────────────────────── # ── UsdGeomMesh ────────────────────────────────────────────
mesh = UsdGeom.Mesh.Define(stage, mesh_path) mesh = UsdGeom.Mesh.Define(stage, mesh_path)
@@ -525,13 +525,13 @@ def _author_xcaf_to_usd(
# ── Material metadata on mesh prim (customData) ─────────── # ── Material metadata on mesh prim (customData) ───────────
mesh_prim = mesh.GetPrim() mesh_prim = mesh.GetPrim()
mesh_prim.SetCustomDataByKey("schaeffler:partKey", part_key) mesh_prim.SetCustomDataByKey("hartomat:partKey", part_key)
mesh_prim.SetCustomDataByKey("schaeffler:sourceName", source_name) mesh_prim.SetCustomDataByKey("hartomat:sourceName", source_name)
canonical_mat = _lookup_material(source_name, part_key, mat_map_lower) canonical_mat = _lookup_material(source_name, part_key, mat_map_lower)
if canonical_mat: if canonical_mat:
mesh_prim.SetCustomDataByKey( mesh_prim.SetCustomDataByKey(
"schaeffler:canonicalMaterialName", canonical_mat) "hartomat:canonicalMaterialName", canonical_mat)
primvars_api = UsdGeom.PrimvarsAPI(mesh) primvars_api = UsdGeom.PrimvarsAPI(mesh)
@@ -542,7 +542,7 @@ def _author_xcaf_to_usd(
idx_pairs = _world_to_index_pairs(vertices, sharp_pairs) idx_pairs = _world_to_index_pairs(vertices, sharp_pairs)
if idx_pairs: if idx_pairs:
pv = primvars_api.CreatePrimvar( pv = primvars_api.CreatePrimvar(
"schaeffler:sharpEdgeVertexPairs", "hartomat:sharpEdgeVertexPairs",
Sdf.ValueTypeNames.Int2Array, Sdf.ValueTypeNames.Int2Array,
UsdGeom.Tokens.constant, UsdGeom.Tokens.constant,
) )
@@ -556,7 +556,7 @@ def _author_xcaf_to_usd(
seam_idx_pairs = _world_to_index_pairs(vertices, seam_pairs) seam_idx_pairs = _world_to_index_pairs(vertices, seam_pairs)
if seam_idx_pairs: if seam_idx_pairs:
pv_seam = primvars_api.CreatePrimvar( pv_seam = primvars_api.CreatePrimvar(
"schaeffler:seamEdgeVertexPairs", "hartomat:seamEdgeVertexPairs",
Sdf.ValueTypeNames.Int2Array, Sdf.ValueTypeNames.Int2Array,
UsdGeom.Tokens.constant, UsdGeom.Tokens.constant,
) )
+9 -9
View File
@@ -2,7 +2,7 @@
Runs inside Blender's Python environment (bpy available). Runs inside Blender's Python environment (bpy available).
Imports a USD stage and restores seam + sharp edges from Imports a USD stage and restores seam + sharp edges from
schaeffler:*EdgeVertexPairs primvars. Blender's built-in USD importer does hartomat:*EdgeVertexPairs primvars. Blender's built-in USD importer does
NOT map arbitrary custom primvars (constant Int2Array) to mesh attributes, NOT map arbitrary custom primvars (constant Int2Array) to mesh attributes,
so we read them directly via the pxr module and apply via bmesh. so we read them directly via the pxr module and apply via bmesh.
@@ -22,7 +22,7 @@ def import_usd_file(usd_path: str) -> list | tuple:
Returns a tuple of (parts, material_lookup) where: Returns a tuple of (parts, material_lookup) where:
- parts: list of imported mesh objects, centred at world origin - parts: list of imported mesh objects, centred at world origin
- material_lookup: dict mapping blender_object_name canonical_material_name - material_lookup: dict mapping blender_object_name canonical_material_name
(populated from schaeffler:canonicalMaterialName customData, empty dict if absent) (populated from hartomat:canonicalMaterialName customData, empty dict if absent)
USD stage is mm Y-up with metersPerUnit=0.001 Blender scales to metres. USD stage is mm Y-up with metersPerUnit=0.001 Blender scales to metres.
""" """
@@ -49,20 +49,20 @@ def import_usd_file(usd_path: str) -> list | tuple:
for prim in stage.Traverse(): for prim in stage.Traverse():
if prim.GetTypeName() != "Mesh": if prim.GetTypeName() != "Mesh":
continue continue
part_key = prim.GetCustomDataByKey("schaeffler:partKey") or "" part_key = prim.GetCustomDataByKey("hartomat:partKey") or ""
mat_name = prim.GetCustomDataByKey("schaeffler:canonicalMaterialName") or "" mat_name = prim.GetCustomDataByKey("hartomat:canonicalMaterialName") or ""
if not part_key or not mat_name: if not part_key or not mat_name:
parent = prim.GetParent() parent = prim.GetParent()
if parent: if parent:
part_key = part_key or (parent.GetCustomDataByKey("schaeffler:partKey") or "") part_key = part_key or (parent.GetCustomDataByKey("hartomat:partKey") or "")
mat_name = mat_name or (parent.GetCustomDataByKey("schaeffler:canonicalMaterialName") or "") mat_name = mat_name or (parent.GetCustomDataByKey("hartomat:canonicalMaterialName") or "")
if part_key and mat_name: if part_key and mat_name:
material_lookup[part_key] = mat_name material_lookup[part_key] = mat_name
# Read seam/sharp primvars from USD mesh prim # Read seam/sharp primvars from USD mesh prim
pvs_api = UsdGeom.PrimvarsAPI(prim) pvs_api = UsdGeom.PrimvarsAPI(prim)
sharp_pv = pvs_api.GetPrimvar("schaeffler:sharpEdgeVertexPairs") sharp_pv = pvs_api.GetPrimvar("hartomat:sharpEdgeVertexPairs")
seam_pv = pvs_api.GetPrimvar("schaeffler:seamEdgeVertexPairs") seam_pv = pvs_api.GetPrimvar("hartomat:seamEdgeVertexPairs")
sharp_list = [] sharp_list = []
seam_list = [] seam_list = []
if sharp_pv and sharp_pv.HasValue(): if sharp_pv and sharp_pv.HasValue():
@@ -83,7 +83,7 @@ def import_usd_file(usd_path: str) -> list | tuple:
print(f"[import_usd] pxr material lookup: {len(material_lookup)}/{len(parts)} parts", print(f"[import_usd] pxr material lookup: {len(material_lookup)}/{len(parts)} parts",
flush=True) flush=True)
else: else:
print("[import_usd] no schaeffler:canonicalMaterialName metadata found (legacy USD)", print("[import_usd] no hartomat:canonicalMaterialName metadata found (legacy USD)",
flush=True) flush=True)
if edge_data: if edge_data:
+1 -1
View File
@@ -345,7 +345,7 @@ def main():
part_colors_json = args[6] if len(args) > 6 else "{}" part_colors_json = args[6] if len(args) > 6 else "{}"
transparent_bg = args[7] == "1" if len(args) > 7 else False transparent_bg = args[7] == "1" if len(args) > 7 else False
# Template + material library args (passed by schaeffler-still.js) # Template + material library args (passed by hartomat-still.js)
template_path = args[8] if len(args) > 8 and args[8] else "" template_path = args[8] if len(args) > 8 and args[8] else ""
target_collection = args[9] if len(args) > 9 else "Product" target_collection = args[9] if len(args) > 9 else "Product"
material_library_path = args[10] if len(args) > 10 and args[10] else "" material_library_path = args[10] if len(args) > 10 and args[10] else ""
+3 -3
View File
@@ -317,7 +317,7 @@ def main():
samples = int(args[7]) samples = int(args[7])
part_colors_json = args[8] if len(args) > 8 else "{}" part_colors_json = args[8] if len(args) > 8 else "{}"
# Template + material library args (passed by schaeffler-turntable.js) # Template + material library args (passed by hartomat-turntable.js)
template_path = args[9] if len(args) > 9 and args[9] else "" template_path = args[9] if len(args) > 9 and args[9] else ""
target_collection = args[10] if len(args) > 10 else "Product" target_collection = args[10] if len(args) > 10 else "Product"
material_library_path = args[11] if len(args) > 11 and args[11] else "" material_library_path = args[11] if len(args) > 11 and args[11] else ""
@@ -468,7 +468,7 @@ def main():
if material_map: if material_map:
_unassigned = [p for p in parts if not p.data.materials or _unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and p.data.materials[0] and (len(p.data.materials) == 1 and p.data.materials[0] and
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")] p.data.materials[0].name == "HARTOMAT_059999_FailedMaterial")]
if _unassigned: if _unassigned:
print(f"[turntable_render] {len(_unassigned)} parts without USD primvar — " print(f"[turntable_render] {len(_unassigned)} parts without USD primvar — "
f"falling back to name-matching", flush=True) f"falling back to name-matching", flush=True)
@@ -559,7 +559,7 @@ def main():
if material_map: if material_map:
_unassigned = [p for p in parts if not p.data.materials or _unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and p.data.materials[0] and (len(p.data.materials) == 1 and p.data.materials[0] and
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")] p.data.materials[0].name == "HARTOMAT_059999_FailedMaterial")]
if _unassigned: if _unassigned:
_apply_material_library_shared( _apply_material_library_shared(
_unassigned, material_library_path, _unassigned, material_library_path,
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
echo "Starting Schaeffler Automat..." echo "Starting HartOMat..."
docker compose up -d docker compose up -d
echo "" echo ""
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
echo "Stopping Schaeffler Automat..." echo "Stopping HartOMat..."
docker compose down docker compose down
echo "" echo ""
+2 -2
View File
@@ -16,7 +16,7 @@ Usage:
# Custom credentials / host # Custom credentials / host
python scripts/test_render_pipeline.py --sample --host http://localhost:8888 \ python scripts/test_render_pipeline.py --sample --host http://localhost:8888 \
--email admin@schaeffler.com --password Admin1234! --email admin@hartomat.com --password Admin1234!
Environment variables (alternative to flags): Environment variables (alternative to flags):
TEST_HOST, TEST_EMAIL, TEST_PASSWORD TEST_HOST, TEST_EMAIL, TEST_PASSWORD
@@ -34,7 +34,7 @@ from pathlib import Path
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DEFAULT_HOST = os.environ.get("TEST_HOST", "http://localhost:8888") DEFAULT_HOST = os.environ.get("TEST_HOST", "http://localhost:8888")
DEFAULT_EMAIL = os.environ.get("TEST_EMAIL", "admin@schaeffler.com") DEFAULT_EMAIL = os.environ.get("TEST_EMAIL", "admin@hartomat.com")
DEFAULT_PASSWORD = os.environ.get("TEST_PASSWORD", "Admin1234!") DEFAULT_PASSWORD = os.environ.get("TEST_PASSWORD", "Admin1234!")
SAMPLE_STEP = Path(__file__).parent.parent / "step-sample-file" / "81113-l_cut.stp" SAMPLE_STEP = Path(__file__).parent.parent / "step-sample-file" / "81113-l_cut.stp"
+5 -5
View File
@@ -1,6 +1,6 @@
"""Blender companion script: restore sharp + seam edge marks after importing a production GLB. """Blender companion script: restore sharp + seam edge marks after importing a production GLB.
After importing a Schaeffler production GLB in Blender, run this script once via After importing a HartOMat production GLB in Blender, run this script once via
the Scripting workspace (Text Editor Run Script). It reads the sharp angle AND the the Scripting workspace (Text Editor Run Script). It reads the sharp angle AND the
OCC B-rep sharp edge pairs baked into the GLB at export time, and re-applies OCC B-rep sharp edge pairs baked into the GLB at export time, and re-applies
mark_sharp() + mark_seam() on every mesh object. mark_sharp() + mark_seam() on every mesh object.
@@ -26,7 +26,7 @@ if not mesh_objects:
print("No mesh objects found in scene.") print("No mesh objects found in scene.")
else: else:
# --- Pass 1: dihedral-angle-based sharp/seam marks --- # --- Pass 1: dihedral-angle-based sharp/seam marks ---
angle_deg = bpy.context.scene.get("schaeffler_sharp_angle_deg", 30.0) angle_deg = bpy.context.scene.get("hartomat_sharp_angle_deg", 30.0)
smooth_rad = math.radians(float(angle_deg)) smooth_rad = math.radians(float(angle_deg))
total_sharp = 0 total_sharp = 0
@@ -48,7 +48,7 @@ else:
print(f"Pass 1 (dihedral {angle_deg}°): {total_sharp} sharp/seam edges across {len(mesh_objects)} objects.") print(f"Pass 1 (dihedral {angle_deg}°): {total_sharp} sharp/seam edges across {len(mesh_objects)} objects.")
# --- Pass 2: OCC B-rep sharp edges from GLB extras --- # --- Pass 2: OCC B-rep sharp edges from GLB extras ---
# The production GLB embeds schaeffler_sharp_edge_pairs (OCC B-rep topology, # The production GLB embeds hartomat_sharp_edge_pairs (OCC B-rep topology,
# dense curve samples at 0.3mm) in scenes[0].extras, which Blender maps to # dense curve samples at 0.3mm) in scenes[0].extras, which Blender maps to
# scene custom properties on import. These cover geometrically sharp edges # scene custom properties on import. These cover geometrically sharp edges
# that the dihedral-angle pass misses due to tessellation noise. # that the dihedral-angle pass misses due to tessellation noise.
@@ -56,7 +56,7 @@ else:
# Coordinate convention (mirrors export_gltf.py _apply_sharp_edges_from_occ): # Coordinate convention (mirrors export_gltf.py _apply_sharp_edges_from_occ):
# OCC STEP space (Z-up, mm) → Blender (Z-up, m): # OCC STEP space (Z-up, mm) → Blender (Z-up, m):
# Blender(X, Y, Z) = OCC(X*0.001, -Z*0.001, Y*0.001) # Blender(X, Y, Z) = OCC(X*0.001, -Z*0.001, Y*0.001)
occ_pairs = bpy.context.scene.get("schaeffler_sharp_edge_pairs") or [] occ_pairs = bpy.context.scene.get("hartomat_sharp_edge_pairs") or []
if occ_pairs: if occ_pairs:
print(f"Pass 2 (OCC B-rep): applying {len(occ_pairs)} sharp edge segment pairs...") print(f"Pass 2 (OCC B-rep): applying {len(occ_pairs)} sharp edge segment pairs...")
@@ -99,4 +99,4 @@ else:
print(f"Pass 2 (OCC B-rep): {marked_total} additional edges marked across {len(mesh_objects)} objects.") print(f"Pass 2 (OCC B-rep): {marked_total} additional edges marked across {len(mesh_objects)} objects.")
else: else:
print("Pass 2 (OCC B-rep): no schaeffler_sharp_edge_pairs in GLB extras — skipped.") print("Pass 2 (OCC B-rep): no hartomat_sharp_edge_pairs in GLB extras — skipped.")
+2 -2
View File
@@ -1,4 +1,4 @@
# Schaeffler Automat — UX & Quality Audit Report # HartOMat — UX & Quality Audit Report
**Date**: 2026-03-08 **Date**: 2026-03-08
**Overall Score**: 6.5/10 **Overall Score**: 6.5/10
@@ -6,7 +6,7 @@
## Executive Summary ## Executive Summary
Schaeffler Automat is a functionally complete internal tool with a solid design system foundation (semantic CSS variables, Tailwind tokens, dark/light theming, role-aware navigation). The core workflows — Excel import → STEP upload → render dispatch — are implemented end-to-end. However, the UI suffers from **information density without hierarchy**: the Admin page is an undifferentiated ~3000px scroll, the Upload wizard has no progress indicator, and the Activity page mixes live queue data with historical records without clear separation. The most impactful improvements are a redesigned Admin hub with tab navigation, an Upload wizard progress bar, and standardized interaction patterns (replacing `window.confirm()`, adding debounce to search, and skeleton loading states). HartOMat is a functionally complete internal tool with a solid design system foundation (semantic CSS variables, Tailwind tokens, dark/light theming, role-aware navigation). The core workflows — Excel import → STEP upload → render dispatch — are implemented end-to-end. However, the UI suffers from **information density without hierarchy**: the Admin page is an undifferentiated ~3000px scroll, the Upload wizard has no progress indicator, and the Activity page mixes live queue data with historical records without clear separation. The most impactful improvements are a redesigned Admin hub with tab navigation, an Upload wizard progress bar, and standardized interaction patterns (replacing `window.confirm()`, adding debounce to search, and skeleton loading states).
--- ---