feat: sharp edge pipeline V02, tessellation presets, media cache-bust, GMSH plan

Sharp Edge Pipeline V02:
- export_step_to_gltf.py: replace BRep_Tool.Polygon3D_s (returns None in XCAF) with
  GCPnts_UniformAbscissa curve sampling at 0.3mm step — extracts 17,129 segment pairs
- Inject sharp_edge_pairs + sharp_threshold_deg into GLB extras (scenes[0].extras)
  via binary GLB JSON-chunk patching (no extra dependency)
- export_gltf.py: read schaeffler_sharp_edge_pairs from Blender scene custom props,
  apply via KD-tree to mark edges sharp=True + seam=True (OCC mm Z-up → Blender transform)
- tools/restore_sharp_marks.py: dual-pass (dihedral angle + OCC pairs), updated coordinate
  transform (X, -Z, Y) * 0.001

Tessellation:
- Admin UI: Draft / Standard / Fine preset buttons with active-state highlighting
- Default angular deflection: preview 0.5→0.1 rad, production 0.2→0.05 rad
- export_glb.py: read updated defaults from system_settings

Media / Cache:
- media/service.py: get_download_url appends ?v={file_size_bytes} cache-buster
- media/router.py: Cache-Control: no-cache for all download/thumbnail endpoints

Render pipeline:
- still_render.py / turntable_render.py: shared GPU activation + camera improvements
- render_order_line.py: global render position support
- render_thumbnail.py: updated defaults

Frontend:
- InlineCadViewer: file_size_bytes-aware URL update triggers re-fetch on regeneration
- ThreeDViewer: material panel, part selection, PBR mode improvements
- Admin.tsx: tessellation preset cards, GMSH setting dropdown
- MediaBrowser, ProductDetail, OrderDetail, Orders: various UI improvements
- New: MaterialPanel, GlobalRenderPositionsPanel, StepIndicator components
- New: renderPositions.ts API client

Plans / Docs:
- plan.md: GMSH Frontal-Delaunay tessellation plan (6 tasks)
- LEARNINGS.md: OCC Polygon3D_s None issue + GCPnts fix
- .gitignore: add backend/core (core dump from root process)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:40:36 +01:00
parent 202b06a026
commit ca62319688
70 changed files with 6551 additions and 1130 deletions
+1
View File
@@ -42,3 +42,4 @@ celerybeat.pid
*.xlsx
*.blend1
backend/core
+5
View File
@@ -417,6 +417,11 @@ for obj in mesh_objects:
obj.data.attributes.remove(obj.data.attributes["custom_normal"])
```
### 2026-03-11 | Render-Pipeline | OCC B-rep sharp edges: BRep_Tool.Polygon3D_s() gibt None zurück
`BRep_Tool.Polygon3D_s(edge, loc)` und `PolygonOnTriangulation_s()` geben in XCAF-Compound-Kontext immer `None` zurück — Tessellation-Polygon-Daten liegen auf Component-Instanzen, nicht auf den Compound-Edges. Ergebnis: 612 scharfe Kanten erkannt, 0 Segment-Paare extrahiert.
**Lösung:** `GCPnts_UniformAbscissa(curve3d, step_mm=0.3, tol=1e-6)` auf der analytischen B-rep-Kurve (`BRepAdaptor_Curve`) samplen. 0.3mm-Schritt garantiert dass konsekutive Sample-Paare die Tessellations-Kanten (~0.78-1.55mm) straddeln — die KD-Tree-Suche (TOL=0.5mm) findet dann die richtigen Blender-Mesh-Edges. Ergebnis: 17.129 Segment-Paare, 1.364 Kanten in Blender markiert.
**Imports:** `from OCP.GCPnts import GCPnts_UniformAbscissa; from OCP.BRepAdaptor import BRepAdaptor_Curve`
---
## Offene Fragen
@@ -0,0 +1,32 @@
"""Add global_render_positions table.
Revision ID: 055
Revises: 054
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "055"
down_revision = "ce21c8a67543"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"global_render_positions",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("rotation_x", sa.Float, nullable=False, server_default="0.0"),
sa.Column("rotation_y", sa.Float, nullable=False, server_default="0.0"),
sa.Column("rotation_z", sa.Float, nullable=False, server_default="0.0"),
sa.Column("is_default", sa.Boolean, nullable=False, server_default="false"),
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("now()")),
)
def downgrade() -> None:
op.drop_table("global_render_positions")
@@ -0,0 +1,29 @@
"""Add global_render_position_id FK to order_lines.
Revision ID: 056
Revises: 055
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "056"
down_revision = "055"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"order_lines",
sa.Column(
"global_render_position_id",
UUID(as_uuid=True),
sa.ForeignKey("global_render_positions.id", ondelete="SET NULL"),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("order_lines", "global_render_position_id")
@@ -0,0 +1,38 @@
"""Seed default global render positions (Beauty, 3/4 Front, 3/4 Back).
Revision ID: 057
Revises: 056
"""
from alembic import op
import sqlalchemy as sa
revision = "057"
down_revision = "056"
branch_labels = None
depends_on = None
_DEFAULT_POSITIONS = [
{"name": "Beauty", "rotation_x": 0.0, "rotation_y": 0.0, "rotation_z": 0.0, "is_default": True, "sort_order": 0},
{"name": "3/4 Front", "rotation_x": -15.0, "rotation_y": 45.0, "rotation_z": 0.0, "is_default": False, "sort_order": 1},
{"name": "3/4 Back", "rotation_x": -15.0, "rotation_y": -135.0, "rotation_z": 0.0, "is_default": False, "sort_order": 2},
]
def upgrade() -> None:
conn = op.get_bind()
for pos in _DEFAULT_POSITIONS:
conn.execute(
sa.text(
"INSERT INTO global_render_positions (id, name, rotation_x, rotation_y, rotation_z, is_default, sort_order, created_at, updated_at) "
"VALUES (gen_random_uuid(), :name, :rx, :ry, :rz, :is_default, :sort_order, now(), now())"
),
{"name": pos["name"], "rx": pos["rotation_x"], "ry": pos["rotation_y"], "rz": pos["rotation_z"],
"is_default": pos["is_default"], "sort_order": pos["sort_order"]},
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text("DELETE FROM global_render_positions WHERE name IN ('Beauty', '3/4 Front', '3/4 Back')")
)
@@ -0,0 +1,37 @@
"""Seed additional global render positions (3/4 Front mirrored, 3/4 Back mirrored).
Revision ID: 058
Revises: 057
"""
from alembic import op
import sqlalchemy as sa
revision = "058"
down_revision = "057"
branch_labels = None
depends_on = None
_ADDITIONAL_POSITIONS = [
{"name": "3/4 Front (mirrored)", "rotation_x": -15.0, "rotation_y": -45.0, "rotation_z": 0.0, "is_default": False, "sort_order": 3},
{"name": "3/4 Back (mirrored)", "rotation_x": -15.0, "rotation_y": 135.0, "rotation_z": 0.0, "is_default": False, "sort_order": 4},
]
def upgrade() -> None:
conn = op.get_bind()
for pos in _ADDITIONAL_POSITIONS:
conn.execute(
sa.text(
"INSERT INTO global_render_positions (id, name, rotation_x, rotation_y, rotation_z, is_default, sort_order, created_at, updated_at) "
"VALUES (gen_random_uuid(), :name, :rx, :ry, :rz, :is_default, :sort_order, now(), now())"
),
{"name": pos["name"], "rx": pos["rotation_x"], "ry": pos["rotation_y"], "rz": pos["rotation_z"],
"is_default": pos["is_default"], "sort_order": pos["sort_order"]},
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text("DELETE FROM global_render_positions WHERE name IN ('3/4 Front (mirrored)', '3/4 Back (mirrored)')")
)
@@ -0,0 +1,31 @@
"""Add Top global render position and clear all per-product positions.
Revision ID: 059
Revises: 058
"""
from alembic import op
import sqlalchemy as sa
revision = "059"
down_revision = "058"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Add Top perspective
conn.execute(
sa.text(
"INSERT INTO global_render_positions (id, name, rotation_x, rotation_y, rotation_z, is_default, sort_order, created_at, updated_at) "
"VALUES (gen_random_uuid(), 'Top', -90.0, 0.0, 0.0, false, 5, now(), now())"
)
)
# Remove all per-product render positions (now redundant with global ones)
conn.execute(sa.text("DELETE FROM product_render_positions"))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DELETE FROM global_render_positions WHERE name = 'Top'"))
# Per-product positions are not restored on downgrade (data was intentionally cleared)
@@ -0,0 +1,186 @@
"""add part_materials json column to cad_files
Revision ID: ce21c8a67543
Revises: 054
Create Date: 2026-03-09 20:11:52.201187
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'ce21c8a67543'
down_revision: Union[str, None] = '054'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('app_config')
op.drop_index(op.f('ix_audit_log_channel'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_notification_ts'), table_name='audit_log', postgresql_where='(notification = true)')
op.drop_index(op.f('ix_audit_log_target_notification'), table_name='audit_log')
op.add_column('cad_files', sa.Column('part_materials', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
op.alter_column('cad_files', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_constraint(op.f('cad_files_file_hash_key'), 'cad_files', type_='unique')
op.drop_index(op.f('ix_cad_files_file_hash'), table_name='cad_files')
op.create_index(op.f('ix_cad_files_file_hash'), 'cad_files', ['file_hash'], unique=True)
op.drop_index(op.f('uq_dashboard_config_tenant_default'), table_name='dashboard_configs', postgresql_where='(is_tenant_default = true)')
op.drop_index(op.f('uq_dashboard_config_user'), table_name='dashboard_configs', postgresql_where='(user_id IS NOT NULL)')
op.create_index(op.f('ix_dashboard_configs_tenant_id'), 'dashboard_configs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_dashboard_configs_user_id'), 'dashboard_configs', ['user_id'], unique=False)
op.drop_index(op.f('ix_import_validations_status'), table_name='import_validations')
op.drop_index(op.f('ix_import_validations_tenant'), table_name='import_validations')
op.create_index(op.f('ix_import_validations_tenant_id'), 'import_validations', ['tenant_id'], unique=False)
op.drop_index(op.f('ix_invoice_lines_invoice'), table_name='invoice_lines')
op.drop_index(op.f('ix_invoices_status'), table_name='invoices')
op.drop_index(op.f('ix_invoices_tenant'), table_name='invoices')
op.create_index(op.f('ix_invoices_tenant_id'), 'invoices', ['tenant_id'], unique=False)
op.drop_constraint(op.f('invoices_tenant_id_fkey'), 'invoices', type_='foreignkey')
op.create_foreign_key(None, 'invoices', 'tenants', ['tenant_id'], ['id'])
op.drop_index(op.f('ix_material_aliases_material_id'), table_name='material_aliases')
op.drop_index(op.f('uq_material_aliases_alias_lower'), table_name='material_aliases')
op.drop_index(op.f('ix_media_assets_asset_type'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_asset_type_created'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_order_line'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_product'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_tenant'), table_name='media_assets')
op.create_index(op.f('ix_media_assets_order_line_id'), 'media_assets', ['order_line_id'], unique=False)
op.create_index(op.f('ix_media_assets_product_id'), 'media_assets', ['product_id'], unique=False)
op.create_index(op.f('ix_media_assets_tenant_id'), 'media_assets', ['tenant_id'], unique=False)
op.drop_index(op.f('ix_notification_configs_user'), table_name='notification_configs')
op.drop_constraint(op.f('uq_notification_config_user_event_channel'), 'notification_configs', type_='unique')
op.create_index(op.f('ix_notification_configs_user_id'), 'notification_configs', ['user_id'], unique=False)
op.alter_column('order_items', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.alter_column('order_lines', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_index(op.f('uq_order_lines_render'), table_name='order_lines', postgresql_where='(output_type_id IS NOT NULL)')
op.drop_index(op.f('uq_order_lines_tracking'), table_name='order_lines', postgresql_where='(output_type_id IS NULL)')
op.alter_column('orders', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_constraint(op.f('orders_order_number_key'), 'orders', type_='unique')
op.drop_index(op.f('ix_orders_order_number'), table_name='orders')
op.create_index(op.f('ix_orders_order_number'), 'orders', ['order_number'], unique=True)
op.drop_index(op.f('ix_render_positions_product_id'), table_name='product_render_positions')
op.drop_index(op.f('uq_render_positions_product_name'), table_name='product_render_positions')
op.create_index(op.f('ix_product_render_positions_product_id'), 'product_render_positions', ['product_id'], unique=False)
op.alter_column('products', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_index(op.f('ix_products_category_lagertyp'), table_name='products')
op.drop_index(op.f('uq_products_produkt_baureihe'), table_name='products', postgresql_where='((produkt_baureihe IS NOT NULL) AND (is_active = true))')
op.drop_index(op.f('ix_render_templates_active_unique'), table_name='render_templates', postgresql_where='(is_active = true)')
op.drop_constraint(op.f('templates_category_key_key'), 'templates', type_='unique')
op.drop_index(op.f('ix_templates_category_key'), table_name='templates')
op.create_index(op.f('ix_templates_category_key'), 'templates', ['category_key'], unique=True)
op.alter_column('users', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_constraint(op.f('users_email_key'), 'users', type_='unique')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.drop_index(op.f('ix_workflow_node_results_run'), table_name='workflow_node_results')
op.create_index(op.f('ix_workflow_node_results_run_id'), 'workflow_node_results', ['run_id'], unique=False)
op.drop_index(op.f('ix_workflow_runs_order_line'), table_name='workflow_runs')
op.drop_index(op.f('ix_workflow_runs_status'), table_name='workflow_runs')
op.create_index(op.f('ix_workflow_runs_order_line_id'), 'workflow_runs', ['order_line_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_workflow_runs_order_line_id'), table_name='workflow_runs')
op.create_index(op.f('ix_workflow_runs_status'), 'workflow_runs', ['status'], unique=False)
op.create_index(op.f('ix_workflow_runs_order_line'), 'workflow_runs', ['order_line_id'], unique=False)
op.drop_index(op.f('ix_workflow_node_results_run_id'), table_name='workflow_node_results')
op.create_index(op.f('ix_workflow_node_results_run'), 'workflow_node_results', ['run_id'], unique=False)
op.drop_index(op.f('ix_users_email'), table_name='users')
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=False)
op.create_unique_constraint(op.f('users_email_key'), 'users', ['email'], postgresql_nulls_not_distinct=False)
op.alter_column('users', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_index(op.f('ix_templates_category_key'), table_name='templates')
op.create_index(op.f('ix_templates_category_key'), 'templates', ['category_key'], unique=False)
op.create_unique_constraint(op.f('templates_category_key_key'), 'templates', ['category_key'], postgresql_nulls_not_distinct=False)
op.create_index(op.f('ix_render_templates_active_unique'), 'render_templates', ['category_key', 'output_type_id'], unique=True, postgresql_where='(is_active = true)')
op.create_index(op.f('uq_products_produkt_baureihe'), 'products', [sa.literal_column('lower(produkt_baureihe::text)')], unique=True, postgresql_where='((produkt_baureihe IS NOT NULL) AND (is_active = true))')
op.create_index(op.f('ix_products_category_lagertyp'), 'products', ['category_key', 'lagertyp'], unique=False)
op.alter_column('products', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_index(op.f('ix_product_render_positions_product_id'), table_name='product_render_positions')
op.create_index(op.f('uq_render_positions_product_name'), 'product_render_positions', ['product_id', sa.literal_column('lower(name::text)')], unique=True)
op.create_index(op.f('ix_render_positions_product_id'), 'product_render_positions', ['product_id'], unique=False)
op.drop_index(op.f('ix_orders_order_number'), table_name='orders')
op.create_index(op.f('ix_orders_order_number'), 'orders', ['order_number'], unique=False)
op.create_unique_constraint(op.f('orders_order_number_key'), 'orders', ['order_number'], postgresql_nulls_not_distinct=False)
op.alter_column('orders', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.create_index(op.f('uq_order_lines_tracking'), 'order_lines', ['order_id', 'product_id', sa.literal_column("COALESCE(render_position_id, '00000000-0000-0000-0000-000000000000'::uuid)")], unique=True, postgresql_where='(output_type_id IS NULL)')
op.create_index(op.f('uq_order_lines_render'), 'order_lines', ['order_id', 'product_id', 'output_type_id', sa.literal_column("COALESCE(render_position_id, '00000000-0000-0000-0000-000000000000'::uuid)")], unique=True, postgresql_where='(output_type_id IS NOT NULL)')
op.alter_column('order_lines', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.alter_column('order_items', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_index(op.f('ix_notification_configs_user_id'), table_name='notification_configs')
op.create_unique_constraint(op.f('uq_notification_config_user_event_channel'), 'notification_configs', ['user_id', 'event_type', 'channel'], postgresql_nulls_not_distinct=False)
op.create_index(op.f('ix_notification_configs_user'), 'notification_configs', ['user_id'], unique=False)
op.drop_index(op.f('ix_media_assets_tenant_id'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_product_id'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_order_line_id'), table_name='media_assets')
op.create_index(op.f('ix_media_assets_tenant'), 'media_assets', ['tenant_id'], unique=False)
op.create_index(op.f('ix_media_assets_product'), 'media_assets', ['product_id'], unique=False)
op.create_index(op.f('ix_media_assets_order_line'), 'media_assets', ['order_line_id'], unique=False)
op.create_index(op.f('ix_media_assets_asset_type_created'), 'media_assets', ['asset_type', 'created_at'], unique=False)
op.create_index(op.f('ix_media_assets_asset_type'), 'media_assets', ['asset_type'], unique=False)
op.create_index(op.f('uq_material_aliases_alias_lower'), 'material_aliases', [sa.literal_column('lower(alias::text)')], unique=True)
op.create_index(op.f('ix_material_aliases_material_id'), 'material_aliases', ['material_id'], unique=False)
op.drop_constraint(None, 'invoices', type_='foreignkey')
op.create_foreign_key(op.f('invoices_tenant_id_fkey'), 'invoices', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.drop_index(op.f('ix_invoices_tenant_id'), table_name='invoices')
op.create_index(op.f('ix_invoices_tenant'), 'invoices', ['tenant_id'], unique=False)
op.create_index(op.f('ix_invoices_status'), 'invoices', ['status'], unique=False)
op.create_index(op.f('ix_invoice_lines_invoice'), 'invoice_lines', ['invoice_id'], unique=False)
op.drop_index(op.f('ix_import_validations_tenant_id'), table_name='import_validations')
op.create_index(op.f('ix_import_validations_tenant'), 'import_validations', ['tenant_id'], unique=False)
op.create_index(op.f('ix_import_validations_status'), 'import_validations', ['status'], unique=False)
op.drop_index(op.f('ix_dashboard_configs_user_id'), table_name='dashboard_configs')
op.drop_index(op.f('ix_dashboard_configs_tenant_id'), table_name='dashboard_configs')
op.create_index(op.f('uq_dashboard_config_user'), 'dashboard_configs', ['user_id'], unique=True, postgresql_where='(user_id IS NOT NULL)')
op.create_index(op.f('uq_dashboard_config_tenant_default'), 'dashboard_configs', ['tenant_id'], unique=True, postgresql_where='(is_tenant_default = true)')
op.drop_index(op.f('ix_cad_files_file_hash'), table_name='cad_files')
op.create_index(op.f('ix_cad_files_file_hash'), 'cad_files', ['file_hash'], unique=False)
op.create_unique_constraint(op.f('cad_files_file_hash_key'), 'cad_files', ['file_hash'], postgresql_nulls_not_distinct=False)
op.alter_column('cad_files', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_column('cad_files', 'part_materials')
op.create_index(op.f('ix_audit_log_target_notification'), 'audit_log', ['target_user_id', 'notification', 'read_at'], unique=False)
op.create_index(op.f('ix_audit_log_notification_ts'), 'audit_log', ['notification', 'timestamp'], unique=False, postgresql_where='(notification = true)')
op.create_index(op.f('ix_audit_log_channel'), 'audit_log', ['channel'], unique=False)
op.create_table('app_config',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('version', sa.INTEGER(), server_default=sa.text('1'), autoincrement=False, nullable=False),
sa.Column('render', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=False),
sa.Column('storage', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=False),
sa.Column('notifications', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=False),
sa.Column('worker', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=False),
sa.Column('billing', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('app_config_pkey'))
)
# ### end Alembic commands ###
+78 -7
View File
@@ -43,9 +43,9 @@ SETTINGS_DEFAULTS: dict[str, str] = {
"smtp_from_address": "",
# glTF tessellation quality (OCC BRepMesh)
"gltf_preview_linear_deflection": "0.1", # mm — geometry GLB for viewer
"gltf_preview_angular_deflection": "0.5", # rad
"gltf_preview_angular_deflection": "0.1", # rad — Standard preset
"gltf_production_linear_deflection": "0.03", # mm — production GLB
"gltf_production_angular_deflection": "0.2", # rad
"gltf_production_angular_deflection": "0.05", # rad — Standard preset
# 3D viewer / glTF export settings
"gltf_scale_factor": "0.001",
"gltf_smooth_normals": "true",
@@ -77,9 +77,9 @@ class SettingsOut(BaseModel):
smtp_password: str = ""
smtp_from_address: str = ""
gltf_preview_linear_deflection: float = 0.1
gltf_preview_angular_deflection: float = 0.5
gltf_preview_angular_deflection: float = 0.1
gltf_production_linear_deflection: float = 0.03
gltf_production_angular_deflection: float = 0.2
gltf_production_angular_deflection: float = 0.05
gltf_scale_factor: float = 0.001
gltf_smooth_normals: bool = True
viewer_max_distance: float = 50.0
@@ -420,9 +420,12 @@ async def regenerate_thumbnails(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Re-queue all completed CAD files for thumbnail regeneration."""
"""Re-queue completed CAD files that are linked to a product for thumbnail regeneration."""
from app.domains.products.models import Product
result = await db.execute(
select(CadFile).where(CadFile.processing_status == ProcessingStatus.completed)
select(CadFile)
.join(Product, Product.cad_file_id == CadFile.id)
.where(CadFile.processing_status == ProcessingStatus.completed)
)
cad_files = result.scalars().all()
@@ -435,6 +438,71 @@ async def regenerate_thumbnails(
return {"queued": queued, "message": f"Re-queued {queued} CAD file(s) for thumbnail regeneration"}
@router.get("/settings/orphaned-cad-files")
async def get_orphaned_cad_files(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Return count and total disk size of CadFiles not linked to any product."""
from sqlalchemy import func
from app.domains.products.models import Product
result = await db.execute(
select(func.count(CadFile.id), func.sum(CadFile.file_size))
.outerjoin(Product, Product.cad_file_id == CadFile.id)
.where(Product.id.is_(None))
)
count, total_bytes = result.one()
return {
"count": count or 0,
"total_mb": round((total_bytes or 0) / 1024 / 1024, 1),
}
@router.post("/settings/cleanup-orphaned-cad-files")
async def cleanup_orphaned_cad_files(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete CadFile DB records and associated files on disk for all orphaned CadFiles.
A CadFile is orphaned if no product currently references it via products.cad_file_id.
"""
import os
from app.domains.products.models import Product
result = await db.execute(
select(CadFile)
.outerjoin(Product, Product.cad_file_id == CadFile.id)
.where(Product.id.is_(None))
)
orphans = result.scalars().all()
deleted_files = 0
deleted_bytes = 0
for cad_file in orphans:
# Remove files from disk (non-fatal if missing)
for path_attr in ("stored_path", "thumbnail_path", "gltf_path"):
path = getattr(cad_file, path_attr, None)
if path:
try:
if os.path.isfile(path):
size = os.path.getsize(path)
os.remove(path)
deleted_files += 1
deleted_bytes += size
except OSError:
pass
await db.delete(cad_file)
await db.commit()
return {
"deleted_records": len(orphans),
"deleted_files": deleted_files,
"freed_mb": round(deleted_bytes / 1024 / 1024, 1),
}
@router.post("/settings/reextract-metadata", status_code=status.HTTP_202_ACCEPTED)
async def reextract_all_metadata(
admin: User = Depends(require_admin),
@@ -445,8 +513,11 @@ async def reextract_all_metadata(
Updates mesh_attributes without re-rendering thumbnails or changing processing status.
Use this after deploying bbox/edge extraction improvements.
"""
from app.domains.products.models import Product
result = await db.execute(
select(CadFile).where(
select(CadFile)
.join(Product, Product.cad_file_id == CadFile.id)
.where(
CadFile.processing_status == ProcessingStatus.completed,
CadFile.stored_path.isnot(None),
)
+61 -4
View File
@@ -2,6 +2,7 @@
import uuid
from datetime import datetime
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
@@ -15,12 +16,26 @@ from app.models.cad_file import CadFile, ProcessingStatus
from app.models.order import Order
from app.models.order_item import OrderItem
from app.models.user import User
from app.utils.auth import get_current_user
from app.utils.auth import get_current_user, is_privileged
from app.services.product_service import link_cad_to_product, lookup_product
router = APIRouter(prefix="/cad", tags=["cad"])
# ---------------------------------------------------------------------------
# Part-materials schemas
# ---------------------------------------------------------------------------
class PartMaterialEntry(BaseModel):
type: Literal["library", "hex"]
value: str # material name or hex color string
class PartMaterialsResponse(BaseModel):
cad_file_id: str
part_materials: dict[str, PartMaterialEntry] | None
# ---------------------------------------------------------------------------
# Schemas for match-to-order
# ---------------------------------------------------------------------------
@@ -273,7 +288,7 @@ async def generate_gltf_geometry(
Stores the result as a MediaAsset with asset_type='gltf_geometry'.
Uses export_step_to_gltf.py (OCP/pythonocc) — no Blender needed.
"""
if user.role.value not in ("admin", "project_manager"):
if not is_privileged(user):
raise HTTPException(status_code=403, detail="Insufficient permissions")
cad = await _get_cad_file(id, db)
@@ -296,7 +311,7 @@ async def generate_gltf_production(
Requires a gltf_geometry MediaAsset to already exist (run generate-gltf-geometry first).
Stores result as a MediaAsset with asset_type='gltf_production'.
"""
if user.role.value not in ("admin", "project_manager"):
if not is_privileged(user):
raise HTTPException(status_code=403, detail="Insufficient permissions")
cad = await _get_cad_file(id, db)
@@ -359,7 +374,7 @@ async def reset_stuck_processing(
Use when a file shows 'processing' indefinitely due to a worker crash.
After resetting, click 'Regen thumbnail' to retry.
"""
if user.role.value not in ("admin", "project_manager"):
if not is_privileged(user):
raise HTTPException(status_code=403, detail="Insufficient permissions")
cad = await _get_cad_file(id, db)
@@ -377,3 +392,45 @@ async def reset_stuck_processing(
return {"cad_file_id": str(cad.id), "status": "failed", "message": "Reset to 'failed'. Use 'Regen thumbnail' to retry."}
# ---------------------------------------------------------------------------
# Part-material assignment endpoints
# ---------------------------------------------------------------------------
@router.get("/{id}/part-materials", response_model=PartMaterialsResponse)
async def get_part_materials(
id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Return the saved part-material assignments for a CAD file."""
cad = await _get_cad_file(id, db)
return PartMaterialsResponse(
cad_file_id=str(cad.id),
part_materials=cad.part_materials,
)
@router.put("/{id}/part-materials", response_model=PartMaterialsResponse)
async def save_part_materials(
id: uuid.UUID,
body: dict[str, PartMaterialEntry],
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Replace the part-material assignment map for a CAD file.
Accepts a full dict of part-name -> {type, value} and overwrites the existing
assignment. Pass an empty dict to clear all assignments.
"""
if not is_privileged(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
cad = await _get_cad_file(id, db)
# Serialise Pydantic models to plain dicts for JSONB storage
cad.part_materials = {name: entry.model_dump() for name, entry in body.items()}
cad.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(cad)
return PartMaterialsResponse(
cad_file_id=str(cad.id),
part_materials=cad.part_materials,
)
@@ -0,0 +1,75 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import GlobalRenderPosition
from app.domains.rendering.schemas import (
GlobalRenderPositionCreate,
GlobalRenderPositionPatch,
GlobalRenderPositionOut,
)
from app.utils.auth import require_admin, get_current_user
router = APIRouter(prefix="/render-positions/global", tags=["global-render-positions"])
@router.get("", response_model=list[GlobalRenderPositionOut])
async def list_global_render_positions(
db: AsyncSession = Depends(get_db),
_user=Depends(get_current_user),
):
"""List all global render positions (available to all authenticated users)."""
result = await db.execute(
select(GlobalRenderPosition).order_by(GlobalRenderPosition.sort_order, GlobalRenderPosition.name)
)
return result.scalars().all()
@router.post("", response_model=GlobalRenderPositionOut, status_code=status.HTTP_201_CREATED)
async def create_global_render_position(
body: GlobalRenderPositionCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_admin),
):
"""Create a new global render position (admin only)."""
pos = GlobalRenderPosition(**body.model_dump())
db.add(pos)
await db.commit()
await db.refresh(pos)
return pos
@router.patch("/{pos_id}", response_model=GlobalRenderPositionOut)
async def update_global_render_position(
pos_id: uuid.UUID,
body: GlobalRenderPositionPatch,
db: AsyncSession = Depends(get_db),
_user=Depends(require_admin),
):
"""Update a global render position (admin only)."""
result = await db.execute(select(GlobalRenderPosition).where(GlobalRenderPosition.id == pos_id))
pos = result.scalar_one_or_none()
if not pos:
raise HTTPException(status_code=404, detail="Global render position not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(pos, field, value)
await db.commit()
await db.refresh(pos)
return pos
@router.delete("/{pos_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_global_render_position(
pos_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_user=Depends(require_admin),
):
"""Delete a global render position (admin only)."""
result = await db.execute(select(GlobalRenderPosition).where(GlobalRenderPosition.id == pos_id))
pos = result.scalar_one_or_none()
if not pos:
raise HTTPException(status_code=404, detail="Global render position not found")
await db.delete(pos)
await db.commit()
+5
View File
@@ -93,6 +93,9 @@ def _build_line_out(line: OrderLine) -> OrderLineOut:
unit_price=float(line.unit_price) if line.unit_price is not None else None,
render_position_id=line.render_position_id,
render_position_name=rp_name,
render_log=line.render_log if hasattr(line, 'render_log') else None,
render_started_at=line.render_started_at if hasattr(line, 'render_started_at') else None,
render_completed_at=line.render_completed_at if hasattr(line, 'render_completed_at') else None,
notes=line.notes,
created_at=line.created_at,
updated_at=line.updated_at,
@@ -384,6 +387,7 @@ async def create_order(
product_id=line_data.product_id,
output_type_id=line_data.output_type_id,
render_position_id=line_data.render_position_id,
global_render_position_id=line_data.global_render_position_id,
gewuenschte_bildnummer=line_data.gewuenschte_bildnummer,
notes=line_data.notes,
tenant_id=getattr(user, 'tenant_id', None),
@@ -827,6 +831,7 @@ async def add_order_line(
product_id=body.product_id,
output_type_id=body.output_type_id,
render_position_id=body.render_position_id,
global_render_position_id=body.global_render_position_id,
gewuenschte_bildnummer=body.gewuenschte_bildnummer,
notes=body.notes,
tenant_id=getattr(user, 'tenant_id', None),
+9
View File
@@ -76,6 +76,7 @@ def _product_out(product: Product, priority: list[str] | None = None) -> Product
out.processing_status = product.processing_status
out.cad_parsed_objects = product.cad_parsed_objects
out.cad_mesh_attributes = product.cad_file.mesh_attributes if product.cad_file else None
out.cad_render_log = product.cad_file.render_log if product.cad_file else None
out.render_image_url = _best_render_url(product, priority or ["latest_render", "cad_thumbnail"])
return out
@@ -662,6 +663,8 @@ async def get_product_renders(
.options(
joinedload(OrderLine.output_type),
joinedload(OrderLine.order),
joinedload(OrderLine.render_position),
joinedload(OrderLine.global_render_position),
)
.where(
OrderLine.product_id == product_id,
@@ -681,6 +684,11 @@ async def get_product_renders(
if disk is None or not disk.exists():
continue
ext = Path(url).suffix.lower()
position_name = (
line.render_position.name if line.render_position
else line.global_render_position.name if line.global_render_position
else None
)
renders.append({
"order_line_id": str(line.id),
"order_number": line.order.order_number if line.order else None,
@@ -689,6 +697,7 @@ async def get_product_renders(
"is_video": ext in VIDEO_EXTENSIONS,
"render_backend": line.render_backend_used,
"completed_at": line.render_completed_at.isoformat() if line.render_completed_at else None,
"render_position_name": position_name,
})
return renders
+146 -6
View File
@@ -60,7 +60,7 @@ async def _resolve_thumbnails_bulk(db: AsyncSession, assets: list) -> None:
for a in needs:
pid = str(a.product_id)
if pid in best_still:
a.thumbnail_url = f"/api/media/{best_still[pid]}/download"
a.thumbnail_url = f"/api/media/{best_still[pid]}/thumbnail"
elif pid in product_cad:
a.thumbnail_url = f"/api/cad/{product_cad[pid]}/thumbnail"
@@ -105,6 +105,7 @@ async def browse_media_assets(
category_key: str | None = None,
render_status: str | None = None,
q: str | None = None,
exclude_technical: bool = Query(True, description="Exclude GLB/STL/Blend technical assets"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
_user: User = Depends(get_current_user),
@@ -125,6 +126,12 @@ async def browse_media_assets(
Product.pim_id.label("product_pim_id"),
Product.category_key.label("category_key"),
OrderLine.render_status.label("render_status"),
Product.ebene1.label("product_ebene1"),
Product.ebene2.label("product_ebene2"),
Product.baureihe.label("product_baureihe"),
Product.produkt_baureihe.label("product_produkt_baureihe"),
Product.lagertyp.label("product_lagertyp"),
Product.name_cad_modell.label("product_name_cad_modell"),
)
.outerjoin(Product, MediaAsset.product_id == Product.id)
.outerjoin(OrderLine, MediaAsset.order_line_id == OrderLine.id)
@@ -133,12 +140,21 @@ async def browse_media_assets(
)
# Apply filters
_TECHNICAL_TYPES = (
MediaAssetType.gltf_geometry,
MediaAssetType.gltf_production,
MediaAssetType.blend_production,
MediaAssetType.stl_low,
MediaAssetType.stl_high,
)
if asset_type:
try:
at_enum = MediaAssetType(asset_type)
stmt = stmt.where(MediaAsset.asset_type == at_enum)
except ValueError:
pass # invalid type → ignore filter
elif exclude_technical:
stmt = stmt.where(MediaAsset.asset_type.notin_(_TECHNICAL_TYPES))
if category_key:
stmt = stmt.where(Product.category_key == category_key)
@@ -153,6 +169,12 @@ async def browse_media_assets(
or_(
Product.name.ilike(pattern),
Product.pim_id.ilike(pattern),
Product.ebene1.ilike(pattern),
Product.ebene2.ilike(pattern),
Product.baureihe.ilike(pattern),
Product.produkt_baureihe.ilike(pattern),
Product.lagertyp.ilike(pattern),
Product.name_cad_modell.ilike(pattern),
)
)
@@ -165,15 +187,30 @@ async def browse_media_assets(
offset = (page - 1) * page_size
stmt = stmt.offset(offset).limit(page_size)
rows = await db.execute(stmt)
all_rows = (await db.execute(stmt)).all()
# Pre-assign thumbnail_url so _resolve_thumbnails_bulk can check it
raw_assets = [row[0] for row in all_rows]
for a in raw_assets:
a.thumbnail_url = service.get_thumbnail_url(a)
# Resolve fallback thumbnails for non-image assets via product→cad lookup
await _resolve_thumbnails_bulk(db, raw_assets)
items: list[MediaAssetBrowseItem] = []
for row in rows.all():
for row in all_rows:
asset: MediaAsset = row[0]
product_name: str | None = row[1]
product_pim_id: str | None = row[2]
cat_key: str | None = row[3]
r_status: str | None = row[4]
ebene1: str | None = row[5]
ebene2: str | None = row[6]
baureihe: str | None = row[7]
produkt_baureihe: str | None = row[8]
lagertyp: str | None = row[9]
name_cad_modell: str | None = row[10]
thumb = asset.thumbnail_url
item = MediaAssetBrowseItem(
id=asset.id,
asset_type=asset.asset_type,
@@ -187,8 +224,14 @@ async def browse_media_assets(
product_pim_id=product_pim_id,
category_key=cat_key,
render_status=r_status,
product_ebene1=ebene1,
product_ebene2=ebene2,
product_baureihe=baureihe,
product_produkt_baureihe=produkt_baureihe,
product_lagertyp=lagertyp,
product_name_cad_modell=name_cad_modell,
download_url=f"/api/media/{asset.id}/download",
thumbnail_url=service.get_thumbnail_url(asset),
thumbnail_url=thumb,
)
items.append(item)
@@ -213,6 +256,48 @@ async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
return asset
@router.get("/{asset_id}/thumbnail")
async def thumbnail_asset(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
):
"""Serve asset as an inline image — no auth required (UUID is opaque enough).
Only serves image/video MIME types; returns 404 for binary files.
"""
from fastapi.responses import FileResponse, Response
from pathlib import Path
asset = await service.get_media_asset(db, asset_id)
if not asset:
raise HTTPException(404, "Asset not found")
mime = asset.mime_type or ""
if not (mime.startswith("image/") or mime.startswith("video/")):
raise HTTPException(404, "Not a previewable asset")
key = asset.storage_key
from app.config import settings
candidate = Path(key) if Path(key).is_absolute() else Path(settings.upload_dir) / key
if not candidate.exists() and "/shared/renders/" in key:
parts = key.split("/")
if len(parts) >= 2:
remapped = Path(settings.upload_dir) / "renders" / parts[-2] / parts[-1]
if remapped.exists():
candidate = remapped
if candidate.exists():
return FileResponse(
str(candidate), media_type=mime,
headers={"Cache-Control": "max-age=86400, public"},
)
try:
from app.core.storage import get_storage
data = get_storage().download_bytes(key)
return Response(content=data, media_type=mime,
headers={"Cache-Control": "max-age=86400, public"})
except Exception:
raise HTTPException(404, "File not available")
@router.api_route("/{asset_id}/download", methods=["GET", "HEAD"])
async def download_asset(
asset_id: uuid.UUID,
@@ -250,7 +335,7 @@ async def download_asset(
fname = f"{asset.asset_type.value}_{asset_id}.{ext or 'bin'}"
return FileResponse(
str(candidate), media_type=mime, filename=fname,
headers={"Cache-Control": "max-age=3600, public"},
headers={"Cache-Control": "no-cache"},
)
# Fall back to MinIO
@@ -264,7 +349,7 @@ async def download_asset(
media_type=mime,
headers={
"Content-Disposition": f"attachment; filename={fname}",
"Cache-Control": "max-age=3600, public",
"Cache-Control": "no-cache",
},
)
except Exception:
@@ -346,3 +431,58 @@ async def delete_asset_permanent(asset_id: uuid.UUID, db: AsyncSession = Depends
if not deleted:
raise HTTPException(404, "Asset not found")
return {"ok": True}
@router.post("/cleanup-orphaned")
async def cleanup_orphaned_assets(
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Delete all MediaAsset DB records whose backing file doesn't exist on disk or in MinIO.
Returns counts of checked/deleted records. Admin only.
"""
import logging
from pathlib import Path
from app.config import settings
from app.core.storage import get_storage
logger = logging.getLogger(__name__)
storage = get_storage()
def _file_exists(key: str) -> bool:
candidate = Path(key) if Path(key).is_absolute() else Path(settings.upload_dir) / key
if candidate.exists():
return True
# Legacy path remapping
if "/shared/renders/" in key:
parts = key.split("/")
if len(parts) >= 2:
remapped = Path(settings.upload_dir) / "renders" / parts[-2] / parts[-1]
if remapped.exists():
return True
# Check MinIO
try:
storage.download_bytes(key)
return True
except Exception:
return False
result = await db.execute(select(MediaAsset).where(MediaAsset.is_archived == False)) # noqa: E712
all_assets = result.scalars().all()
deleted_ids = []
for asset in all_assets:
if not _file_exists(asset.storage_key):
logger.info("Cleanup: deleting orphaned asset %s (%s)", asset.id, asset.storage_key)
await db.delete(asset)
deleted_ids.append(str(asset.id))
if deleted_ids:
await db.commit()
return {
"checked": len(all_assets),
"deleted": len(deleted_ids),
"deleted_ids": deleted_ids,
}
+7
View File
@@ -41,6 +41,13 @@ class MediaAssetBrowseItem(BaseModel):
product_pim_id: str | None
category_key: str | None
render_status: str | None
# Extended product metadata fields
product_ebene1: str | None = None
product_ebene2: str | None = None
product_baureihe: str | None = None
product_produkt_baureihe: str | None = None
product_lagertyp: str | None = None
product_name_cad_modell: str | None = None
download_url: str | None = None
thumbnail_url: str | None = None
+17 -3
View File
@@ -77,12 +77,26 @@ async def delete_media_asset(db: AsyncSession, asset_id: uuid.UUID) -> bool:
def get_download_url(asset: MediaAsset) -> str | None:
"""Return a backend proxy URL so the browser can always download the file."""
return f"/api/media/{asset.id}/download"
"""Return a backend proxy URL so the browser can always download the file.
Appends ?v={file_size_bytes} as a cache-buster: when a file is regenerated
in-place (same asset UUID, new content), the size changes and the URL changes,
which triggers a fresh fetch in InlineCadViewer's useEffect.
"""
v = asset.file_size_bytes or 0
return f"/api/media/{asset.id}/download?v={v}"
def get_thumbnail_url(asset: MediaAsset) -> str | None:
"""Return CAD thumbnail URL if asset has a cad_file_id."""
"""Return a no-auth preview URL for the asset.
Priority:
1. For image-type assets (still, thumbnail): the no-auth /thumbnail endpoint.
2. For any asset with a cad_file_id: the CAD thumbnail (also no-auth).
3. Otherwise None (caller may use _resolve_thumbnails_bulk for fallback).
"""
if asset.asset_type in (MediaAssetType.still, MediaAssetType.thumbnail):
return f"/api/media/{asset.id}/thumbnail"
if asset.cad_file_id:
return f"/api/cad/{asset.cad_file_id}/thumbnail"
return None
+8
View File
@@ -145,6 +145,11 @@ class OrderLine(Base):
ForeignKey("product_render_positions.id", ondelete="SET NULL"),
nullable=True,
)
global_render_position_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("global_render_positions.id", ondelete="SET NULL"),
nullable=True,
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
@@ -160,3 +165,6 @@ class OrderLine(Base):
render_position: Mapped["ProductRenderPosition | None"] = relationship(
"ProductRenderPosition", back_populates="order_lines"
)
global_render_position: Mapped["GlobalRenderPosition | None"] = relationship(
"GlobalRenderPosition", back_populates="order_lines"
)
+4
View File
@@ -64,6 +64,7 @@ class OrderLineCreate(BaseModel):
product_id: uuid.UUID
output_type_id: uuid.UUID | None = None
render_position_id: uuid.UUID | None = None
global_render_position_id: uuid.UUID | None = None
gewuenschte_bildnummer: str | None = None
notes: str | None = None
@@ -87,6 +88,9 @@ class OrderLineOut(BaseModel):
unit_price: float | None = None
render_position_id: uuid.UUID | None = None
render_position_name: str | None = None
render_log: dict | None = None
render_started_at: datetime | None = None
render_completed_at: datetime | None = None
notes: str | None
created_at: datetime
updated_at: datetime
@@ -69,7 +69,7 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
eng.dispose()
linear_deflection = float(sys_settings.get("gltf_preview_linear_deflection", "0.1"))
angular_deflection = float(sys_settings.get("gltf_preview_angular_deflection", "0.5"))
angular_deflection = float(sys_settings.get("gltf_preview_angular_deflection", "0.1"))
step = _Path(step_path_str)
@@ -230,7 +230,7 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
smooth_angle = float(sys_settings.get("blender_smooth_angle", "30"))
prod_linear = float(sys_settings.get("gltf_production_linear_deflection", "0.03"))
prod_angular = float(sys_settings.get("gltf_production_angular_deflection", "0.2"))
prod_angular = float(sys_settings.get("gltf_production_angular_deflection", "0.05"))
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
occ_script = scripts_dir / "export_step_to_gltf.py"
@@ -239,12 +239,14 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
prod_geom_glb = step_path.parent / f"{step_path.stem}_production_geom.glb"
python_bin = _sys.executable
sharp_threshold = float(sys_settings.get("sharp_edge_threshold", "20.0"))
occ_cmd = [
python_bin, str(occ_script),
"--step_path", str(step_path),
"--output_path", str(prod_geom_glb),
"--linear_deflection", str(prod_linear),
"--angular_deflection", str(prod_angular),
"--sharp_threshold", str(sharp_threshold),
]
log_task_event(
self.request.id,
@@ -130,7 +130,7 @@ def render_order_line_task(self, order_line_id: str):
logger.info(f"No render template for category_key={category_key!r}, output_type_id={ot_id!r}")
cad_name = cad_file.original_name if cad_file else "?"
# Load render_position for rotation values
# Load render_position for rotation values (per-product takes priority, falls back to global)
rotation_x = rotation_y = rotation_z = 0.0
if line.render_position_id:
from app.models.render_position import ProductRenderPosition
@@ -138,6 +138,12 @@ def render_order_line_task(self, order_line_id: str):
if rp:
rotation_x, rotation_y, rotation_z = rp.rotation_x, rp.rotation_y, rp.rotation_z
emit(order_line_id, f"Render position: '{rp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)")
elif line.global_render_position_id:
from app.models import GlobalRenderPosition
grp = session.get(GlobalRenderPosition, line.global_render_position_id)
if grp:
rotation_x, rotation_y, rotation_z = grp.rotation_x, grp.rotation_y, grp.rotation_z
emit(order_line_id, f"Global render position: '{grp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)")
emit(order_line_id, f"Starting render for {cad_name} ({len(part_colors)} coloured parts)")
@@ -345,6 +351,7 @@ def render_order_line_task(self, order_line_id: str):
if success:
# Create MediaAsset so the render appears in the Media Browser
try:
import os as _os
from app.domains.media.models import MediaAsset, MediaAssetType as MAT
from app.config import settings as _cfg2
_ext = str(output_path).rsplit(".", 1)[-1].lower() if "." in str(output_path) else "bin"
@@ -360,6 +367,33 @@ def render_order_line_task(self, order_line_id: str):
select(MediaAsset.id).where(MediaAsset.storage_key == _norm_key).limit(1)
).scalar_one_or_none()
if not _existing:
# Probe output file for metadata
_file_size = None
_width = None
_height = None
if _os.path.exists(output_path):
try:
_file_size = _os.path.getsize(output_path)
except OSError:
pass
if _ext in ("png", "jpg", "jpeg"):
try:
from PIL import Image as _PILImage
with _PILImage.open(output_path) as _im:
_width, _height = _im.size
except Exception:
pass
# Snapshot key render settings into render_config
_render_config = None
if isinstance(render_log, dict):
_render_config = {
k: render_log[k]
for k in (
"renderer", "engine_used", "engine", "samples",
"device_used", "compute_type", "total_duration_s",
)
if k in render_log
}
_asset = MediaAsset(
tenant_id=_tenant_id,
order_line_id=line.id,
@@ -367,6 +401,10 @@ def render_order_line_task(self, order_line_id: str):
asset_type=_at,
storage_key=_norm_key,
mime_type=_mime,
file_size_bytes=_file_size,
width=_width,
height=_height,
render_config=_render_config,
)
session.add(_asset)
session.commit()
@@ -95,6 +95,38 @@ def render_step_thumbnail(self, cad_file_id: str):
except Exception:
logger.exception(f"bbox extraction failed for {cad_file_id} (non-fatal)")
# Extract sharp edge topology (PCurve-based) if not already present.
# This runs on render-worker which has OCP (cadquery's OCC fork).
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.config import settings as _cfg3
from app.models.cad_file import CadFile as _CadFile3
from app.services.step_processor import extract_mesh_edge_data
_sync_url3 = _cfg3.database_url.replace("+asyncpg", "")
_eng3 = create_engine(_sync_url3)
with Session(_eng3) as _sess3:
_cad3 = _sess3.get(_CadFile3, cad_file_id)
_attrs = _cad3.mesh_attributes or {} if _cad3 else {}
_step_path3 = _cad3.stored_path if _cad3 else None
_eng3.dispose()
if _step_path3 and "sharp_edge_pairs" not in _attrs:
edge_data = extract_mesh_edge_data(_step_path3)
if edge_data:
_eng3 = create_engine(_sync_url3)
with Session(_eng3) as _sess3:
_cad3 = _sess3.get(_CadFile3, cad_file_id)
if _cad3:
_cad3.mesh_attributes = {**(_cad3.mesh_attributes or {}), **edge_data}
_sess3.commit()
n_pairs = len(edge_data.get("sharp_edge_pairs", []))
logger.info(f"Sharp edge data extracted for {cad_file_id}: {n_pairs} sharp edges")
_eng3.dispose()
except Exception:
logger.exception(f"Sharp edge extraction failed for {cad_file_id} (non-fatal)")
# Auto-populate materials now that parsed_objects are available
try:
from app.domains.pipeline.tasks.extract_metadata import _auto_populate_materials_for_cad
+1
View File
@@ -31,6 +31,7 @@ class CadFile(Base):
error_message: Mapped[str] = mapped_column(String(2000), nullable=True)
render_log: Mapped[dict] = mapped_column(JSONB, nullable=True)
mesh_attributes: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
part_materials: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
step_file_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
+1
View File
@@ -62,6 +62,7 @@ class ProductOut(BaseModel):
cad_parsed_objects: list[str] | None = None
cad_mesh_attributes: dict | None = None
arbeitspaket: str | None = None
cad_render_log: dict | None = None
notes: str | None
is_active: bool
source_excel: str | None
+18
View File
@@ -103,6 +103,24 @@ class ProductRenderPosition(Base):
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="render_position")
class GlobalRenderPosition(Base):
__tablename__ = "global_render_positions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
rotation_x: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_y: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="global_render_position")
class WorkflowDefinition(Base):
__tablename__ = "workflow_definitions"
+2 -1
View File
@@ -1,5 +1,6 @@
# Re-export from original routers.
from app.api.routers.render_templates import router as render_templates_router
from app.api.routers.output_types import router as output_types_router
from app.api.routers.global_render_positions import router as global_render_positions_router
__all__ = ["render_templates_router", "output_types_router"]
__all__ = ["render_templates_router", "output_types_router", "global_render_positions_router"]
+32
View File
@@ -94,6 +94,38 @@ class RenderPositionOut(BaseModel):
model_config = {"from_attributes": True}
class GlobalRenderPositionCreate(BaseModel):
name: str
rotation_x: float = 0.0
rotation_y: float = 0.0
rotation_z: float = 0.0
is_default: bool = False
sort_order: int = 0
class GlobalRenderPositionPatch(BaseModel):
name: str | None = None
rotation_x: float | None = None
rotation_y: float | None = None
rotation_z: float | None = None
is_default: bool | None = None
sort_order: int | None = None
class GlobalRenderPositionOut(BaseModel):
id: uuid.UUID
name: str
rotation_x: float
rotation_y: float
rotation_z: float
is_default: bool
sort_order: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class WorkflowDefinitionCreate(BaseModel):
name: str
output_type_id: uuid.UUID | None = None
+2 -1
View File
@@ -17,7 +17,7 @@ from app.domains.orders.router import orders_router, order_items_router
from app.domains.admin.router import admin_router, analytics_router, worker_router
from app.domains.products.router import products_router, cad_router
from app.domains.materials.router import router as materials_router
from app.domains.rendering.router import render_templates_router, output_types_router
from app.domains.rendering.router import render_templates_router, output_types_router, global_render_positions_router
from app.domains.notifications.router import router as notifications_router
from app.domains.billing.router import pricing_router, invoice_router
from app.domains.tenants.router import router as tenants_router
@@ -94,6 +94,7 @@ app.include_router(media_router)
app.include_router(asset_libraries_router, prefix="/api")
app.include_router(dashboard_router, prefix="/api")
app.include_router(task_logs_router, prefix="/api")
app.include_router(global_render_positions_router, prefix="/api")
@app.get("/health")
+2 -2
View File
@@ -10,7 +10,7 @@ from app.domains.products.models import CadFile, Product
from app.domains.orders.models import Order, OrderItem, OrderLine
from app.domains.notifications.models import AuditLog
from app.domains.billing.models import PricingTier
from app.domains.rendering.models import OutputType, RenderTemplate, ProductRenderPosition, WorkflowDefinition, WorkflowRun, WorkflowNodeResult
from app.domains.rendering.models import OutputType, RenderTemplate, ProductRenderPosition, GlobalRenderPosition, WorkflowDefinition, WorkflowRun, WorkflowNodeResult
from app.domains.materials.models import Material, MaterialAlias, AssetLibrary
from app.domains.media.models import MediaAsset, MediaAssetType
from app.domains.admin.models import DashboardConfig
@@ -21,7 +21,7 @@ from app.models.worker_config import WorkerConfig
__all__ = [
"Tenant", "User", "Template", "CadFile", "Product", "Order", "OrderItem", "OrderLine",
"AuditLog", "PricingTier", "OutputType", "RenderTemplate", "ProductRenderPosition",
"AuditLog", "PricingTier", "OutputType", "RenderTemplate", "ProductRenderPosition", "GlobalRenderPosition",
"WorkflowDefinition", "WorkflowRun", "WorkflowNodeResult",
"Material", "MaterialAlias", "AssetLibrary", "MediaAsset", "MediaAssetType", "SystemSetting",
"DashboardConfig", "WorkerConfig",
+107 -59
View File
@@ -196,26 +196,60 @@ def process_cad_file(cad_file_id: str) -> None:
def extract_mesh_edge_data(step_path: str) -> dict:
"""Extract sharp edge metrics and suggested smooth angle from STEP topology.
"""Extract sharp edge data and suggested smooth angle from STEP topology.
Uses PCurve-based normal evaluation: for each shared edge, the 2D curve of
the edge on each adjacent face (BRep_Tool.CurveOnSurface) is evaluated at
its midpoint to get the exact UV coordinates on that face. BRepLProp_SLProps
then computes the surface normal at that precise location — far more accurate
than sampling at the face's UV center.
Returns dict with:
- suggested_smooth_angle: float (degrees) — recommended auto-smooth angle
- has_mechanical_edges: bool — True if part has distinct hard edges (bearings etc.)
- sharp_edge_midpoints: list of [x, y, z] — midpoints of sharp edges in mm (max 500)
- has_mechanical_edges: bool — True if part has distinct hard edges
- sharp_edge_pairs: list of [[x0,y0,z0],[x1,y1,z1]] — vertex pairs of
sharp edges in mm (no artificial cap)
"""
try:
from OCC.Core.STEPControl import STEPControl_Reader
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
from OCC.Core.BRep import BRep_Tool
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.GProp import GProp_GProps
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.gp import gp_Pnt
# Try OCP first (cadquery's fork, available in render-worker).
# Fall back to OCC.Core (standard pythonocc, if installed elsewhere).
_using_ocp = False
try:
from OCP.STEPControl import STEPControl_Reader
from OCP.IFSelect import IFSelect_RetDone
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_FORWARD
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve, BRepAdaptor_Curve2d
from OCP.BRepLProp import BRepLProp_SLProps
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
from OCP.TopExp import TopExp as _TopExp
from OCP.TopoDS import TopoDS as _TopoDS
_using_ocp = True
except ImportError:
from OCC.Core.STEPControl import STEPControl_Reader
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_FORWARD
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve, BRepAdaptor_Curve2d
from OCC.Core.BRepLProp import BRepLProp_SLProps
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
from OCC.Core.TopExp import topexp as _TopExp
from OCC.Core.TopoDS import TopoDS as _TopoDS
import math
# OCP uses _s suffix for static methods; OCC.Core uses module-level callables.
def _map_shapes(shape, edge_type, face_type, out_map):
if _using_ocp:
_TopExp.MapShapesAndAncestors_s(shape, edge_type, face_type, out_map)
else:
_TopExp.MapShapesAndAncestors(shape, edge_type, face_type, out_map)
def _to_edge(s):
return _TopoDS.Edge_s(s) if _using_ocp else _TopoDS.Edge(s)
def _to_face(s):
return _TopoDS.Face_s(s) if _using_ocp else _TopoDS.Face(s)
reader = STEPControl_Reader()
status = reader.ReadFile(step_path)
if status != IFSelect_RetDone:
@@ -223,71 +257,88 @@ def extract_mesh_edge_data(step_path: str) -> dict:
reader.TransferRoots()
shape = reader.OneShape()
# Mesh the shape for geometry access
# Mesh at 0.5 mm deflection
BRepMesh_IncrementalMesh(shape, 0.5, False, 0.5)
# Collect face normals per edge (for dihedral angle computation)
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
from OCC.Core.TopExp import topexp
# Build edge → adjacent faces map
edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape()
topexp.MapShapesAndAncestors(shape, TopAbs_EDGE, TopAbs_FACE, edge_face_map)
_map_shapes(shape, TopAbs_EDGE, TopAbs_FACE, edge_face_map)
dihedral_angles = []
sharp_midpoints = []
sharp_pairs = []
SHARP_THRESHOLD_DEG = 20.0
for i in range(1, edge_face_map.Extent() + 1):
edge = edge_face_map.FindKey(i)
edge_shape = edge_face_map.FindKey(i)
faces = edge_face_map.FindFromIndex(i)
if faces.Size() < 2:
continue
# Get the two adjacent faces
face_list = list(faces)
if len(face_list) < 2:
face_shapes = list(faces)
if len(face_shapes) < 2:
continue
try:
surf1 = BRepAdaptor_Surface(face_list[0])
surf2 = BRepAdaptor_Surface(face_list[1])
edge = _to_edge(edge_shape)
face1 = _to_face(face_shapes[0])
face2 = _to_face(face_shapes[1])
# Get normals at midpoint of edge
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve
curve = BRepAdaptor_Curve(edge)
mid_u = (curve.FirstParameter() + curve.LastParameter()) / 2
mid_pt = curve.Value(mid_u)
# 3D edge endpoints in mm
curve3d = BRepAdaptor_Curve(edge)
pt_start = curve3d.Value(curve3d.FirstParameter())
pt_end = curve3d.Value(curve3d.LastParameter())
# Sample face normals at UV center
u1 = (surf1.FirstUParameter() + surf1.LastUParameter()) / 2
v1 = (surf1.FirstVParameter() + surf1.LastVParameter()) / 2
n1 = surf1.DN(u1, v1, 0, 1).Crossed(surf1.DN(u1, v1, 1, 0))
# PCurve-based normal evaluation: BRepAdaptor_Curve2d gives UV at the
# edge's actual location on the face — far more accurate than UV center.
c2d_1 = BRepAdaptor_Curve2d(edge, face1)
uv1 = c2d_1.Value((c2d_1.FirstParameter() + c2d_1.LastParameter()) / 2)
surf1 = BRepAdaptor_Surface(face1)
props1 = BRepLProp_SLProps(surf1, uv1.X(), uv1.Y(), 1, 1e-6)
if not props1.IsNormalDefined():
continue
n1 = props1.Normal()
if face1.Orientation() != TopAbs_FORWARD:
n1.Reverse()
u2 = (surf2.FirstUParameter() + surf2.LastUParameter()) / 2
v2 = (surf2.FirstVParameter() + surf2.LastVParameter()) / 2
n2 = surf2.DN(u2, v2, 0, 1).Crossed(surf2.DN(u2, v2, 1, 0))
c2d_2 = BRepAdaptor_Curve2d(edge, face2)
uv2 = c2d_2.Value((c2d_2.FirstParameter() + c2d_2.LastParameter()) / 2)
surf2 = BRepAdaptor_Surface(face2)
props2 = BRepLProp_SLProps(surf2, uv2.X(), uv2.Y(), 1, 1e-6)
if not props2.IsNormalDefined():
continue
n2 = props2.Normal()
if face2.Orientation() != TopAbs_FORWARD:
n2.Reverse()
if n1.Magnitude() > 1e-10 and n2.Magnitude() > 1e-10:
n1.Normalize()
n2.Normalize()
cos_angle = max(-1.0, min(1.0, n1.Dot(n2)))
angle_deg = math.degrees(math.acos(abs(cos_angle)))
dihedral_angles.append(angle_deg)
cos_angle = max(-1.0, min(1.0, n1.Dot(n2)))
angle_deg = math.degrees(math.acos(cos_angle))
# Use exterior angle (supplement when normals point same side)
if angle_deg > 90:
angle_deg = 180.0 - angle_deg
dihedral_angles.append(angle_deg)
if angle_deg > 20 and len(sharp_midpoints) < 500:
sharp_midpoints.append([
round(mid_pt.X(), 3),
round(mid_pt.Y(), 3),
round(mid_pt.Z(), 3),
])
if angle_deg > SHARP_THRESHOLD_DEG:
sharp_pairs.append([
[round(pt_start.X(), 3), round(pt_start.Y(), 3), round(pt_start.Z(), 3)],
[round(pt_end.X(), 3), round(pt_end.Y(), 3), round(pt_end.Z(), 3)],
])
except Exception:
continue
# Bounding box extraction (OCC Bnd_Box)
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.BRepBndLib import brepbndlib
# Bounding box
if _using_ocp:
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib as _brepbndlib_mod
def _brepbndlib_add(shape, bbox):
_brepbndlib_mod.Add_s(shape, bbox)
else:
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.BRepBndLib import brepbndlib as _brepbndlib_mod
def _brepbndlib_add(shape, bbox):
_brepbndlib_mod.Add(shape, bbox)
try:
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
_brepbndlib_add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
dimensions_mm = {
"x": round(xmax - xmin, 2),
@@ -311,11 +362,8 @@ def extract_mesh_edge_data(step_path: str) -> dict:
return result
import statistics
median_angle = statistics.median(dihedral_angles)
max_angle = max(dihedral_angles)
# Suggest smooth angle: slightly below the median of hard edges
hard_edges = [a for a in dihedral_angles if a > 20]
hard_edges = [a for a in dihedral_angles if a > SHARP_THRESHOLD_DEG]
if hard_edges:
suggested = max(15.0, min(60.0, statistics.median(hard_edges) * 0.8))
else:
@@ -324,7 +372,7 @@ def extract_mesh_edge_data(step_path: str) -> dict:
result = {
"suggested_smooth_angle": round(suggested, 1),
"has_mechanical_edges": max_angle > 45,
"sharp_edge_midpoints": sharp_midpoints[:500],
"sharp_edge_pairs": sharp_pairs,
}
if dimensions_mm:
result["dimensions_mm"] = dimensions_mm
+901
View File
@@ -0,0 +1,901 @@
# RFC 0001: Canonical STEP to USD Workflow for Visualization and Rendering
- Status: Proposed
- Author: Codex
- Date: 2026-03-11
## Summary
This RFC proposes replacing the current dual-GLB CAD visualization pipeline with a canonical USD-based workflow:
- `STEP -> USD` becomes the primary geometry and scene-authoring path.
- One canonical USD stage becomes the source of truth for preview, rendering, and downstream conversions.
- Part metadata is preserved directly on USD prims instead of being tunneled through GLB extras or Blender object names.
- Material assignment and replacement remain supported through USD material bindings plus Blender-time shader realization.
- The current browser-side part picking, isolation, hide/ghost, and manual material assignment workflow is preserved through a derived interactive preview asset keyed by canonical USD `partKey`.
- Admin/backend concepts are simplified around one canonical scene plus optional derived preview artifacts instead of a geometry-GLB vs production-GLB split.
- STEPper-like seam, sharp-edge, and UV-supporting topology data is computed once during tessellation/export and carried forward as USD-authored mesh metadata.
The target state is not "USD everywhere on day one". The target state is "USD is the canonical persisted scene asset", with derived formats generated only where a consumer still requires them.
## Motivation
The current pipeline is split between:
- a geometry GLB exported directly from STEP via OCC
- a production GLB exported by re-importing geometry into Blender, rebuilding topology cues, replacing materials, and exporting another GLB
That split introduces avoidable duplication, fragility, and impedance mismatches:
- STEP is tessellated multiple times for different outputs.
- Metadata preservation depends on ad hoc transport mechanisms.
- Material replacement depends on object-name matching after format round-trips.
- Sharp edges and seams are re-derived late instead of authored once near the source geometry.
- The frontend and media model are forced to treat "geometry" and "production" as separate assets even though they describe the same product.
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)
- 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)
- Blender production export script: [render-worker/scripts/export_gltf.py](/home/hartmut/Documents/Copilot/schaefflerautomat/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)
- Media asset model: [backend/app/domains/media/models.py](/home/hartmut/Documents/Copilot/schaefflerautomat/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)
## Goals
- Establish one canonical persisted scene asset for the visualization pipeline.
- Preserve STEP/XCAF hierarchy, names, colors, and relevant product metadata.
- Support material assignment and replacement without duplicating geometry assets.
- Preserve the current browser-side 3D material assignment workflow, including click-to-select, isolate, hide/ghost other geometry, and assignment of missing materials from Blender asset-library material names.
- Preserve or improve current render quality.
- Preserve or improve UV-unwrapping support via seam and sharp-edge data.
- Reduce format round-trips and late-stage topology repair.
- Allow phased migration without breaking current browser preview or render outputs.
- Simplify admin/backend settings, APIs, and operational actions around one canonical scene model.
## Non-Goals
- Replacing Blender as the final shader/render engine.
- Solving browser-native USD visualization immediately.
- Designing a full custom USD schema package in the first phase.
- Eliminating all derived preview/export assets on day one.
## Current State
### Export Pipeline
The current backend has two first-class CAD outputs:
- `gltf_geometry`
- `gltf_production`
The geometry task exports a GLB directly from STEP using OCC tessellation and optional per-part color mapping. The production task re-exports the STEP file at a higher tessellation quality, then runs Blender headless to:
- import the GLB
- clear OCC-authored custom normals
- mark seams and sharp edges
- append materials from a Blender asset library
- export another GLB
This means the "production" asset is effectively a post-processed derivative of geometry data that already existed earlier in the pipeline.
### Metadata Handling
The OCC exporter already preserves meaningful data:
- XCAF part names
- embedded colors
- sharp edge segment pairs injected into GLB extras
However, this data is not represented in a durable scene data model. Some of it is embedded in GLB extras and consumed indirectly by Blender, while some downstream logic still relies on object-name matching.
### Material Assignment
Material mapping already exists conceptually in the domain model:
- 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)
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).
### Admin and Settings Surface
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)
- 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)
- 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)
- 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)
That duplication is operationally expensive and should be reduced as part of the refactor, not carried forward under new names.
### Seam and Unwrap Support
Blender currently recreates seams by angle selection and then supplements them with OCC-derived sharp-edge segment pairs.
STEPper shows a stronger approach:
- edge seams derived from triangle batch/material boundaries
- sharpness derived from normal discontinuity across shared topology
- UV data preserved from OCC triangulation
Relevant references:
- [STEPper/trimesh.py](/home/hartmut/.config/blender/5.0/scripts/addons/STEPper/trimesh.py#L251)
- [STEPper/main.py](/home/hartmut/.config/blender/5.0/scripts/addons/STEPper/main.py#L297)
- [STEPper/importer.py](/home/hartmut/.config/blender/5.0/scripts/addons/STEPper/importer.py#L620)
## Problems With the Current Design
### 1. Duplicate canonical assets
The system treats preview geometry and production geometry as separate first-class artifacts even though they represent the same assembly. This forces duplicated media types, viewer logic, and task orchestration.
### 2. Late topology repair
Sharp edges, seams, and related unwrap hints are reconstructed in Blender after import instead of being preserved as part of the authored scene.
### 3. Name-based material matching
Material replacement currently depends on imported object naming conventions. That is brittle across exporters, instancing, suffix normalization, and hierarchy flattening.
### 4. Format-driven workflow instead of scene-driven workflow
GLB is used as both transport and canonical source. That works for browser preview, but it is not a strong scene interchange format for preserving richer metadata, overrides, and layered authoring decisions.
### 5. Frontend/backend coupling to an implementation detail
The frontend API assumes two separate GLB assets. That is a product of todays implementation, not a business requirement.
### 6. Admin coupling to the legacy artifact split
Admin settings, API vocabulary, and repair actions are more complex than necessary because they expose the current implementation detail of two first-class GLB outputs.
## Proposal
## Overview
Introduce a canonical `usd_master` asset authored directly from STEP/XCAF tessellation output. This USD stage becomes the single source of truth for:
- geometry
- hierarchy
- source metadata
- default display bindings
- seam/sharp/unwrap support data
Derived outputs become optional compatibility products:
- browser preview GLB, if needed
- Blender render session imports
- downstream packaged deliveries
Important constraint:
- the browser viewer must retain todays interactive material-assignment workflow
Therefore this RFC does not assume that the browser renders the canonical USD directly. The canonical scene is USD, but the browser may continue to consume a derived interactive preview mesh package as long as it preserves canonical part identity.
The existing dual-GLB split is replaced conceptually by:
- one canonical authored scene
- zero or more derived consumer-specific outputs
## Canonical USD Stage Structure
Recommended stage layout:
```text
/Root
/Root/Assembly
/Root/Assembly/<AssemblyNode>
/Root/Assembly/<AssemblyNode>/<PartKey>
/Root/Assembly/<AssemblyNode>/<PartKey>/Mesh
/Root/Looks
/Root/Looks/<MaterialName>
```
### Prim identity
Each part prim must have a stable identity independent of display name. Use a normalized part key derived from:
- XCAF label path or stable assembly path
- original part name
- fallback deterministic hash where required
This key becomes the systems canonical join key for:
- product material mapping
- render overrides
- frontend selection
- audit/debug output
Object names imported into Blender must no longer be the primary identity mechanism.
## USD Authored Data
Each part prim should carry:
- `schaeffler:partKey`
- `schaeffler:sourceName`
- `schaeffler:sourceAssemblyPath`
- `schaeffler:sourceColor`
- `schaeffler:rawMaterialName`
- `schaeffler:canonicalMaterialName`
- `schaeffler:tessellation:linearDeflectionMm`
- `schaeffler:tessellation:angularDeflectionRad`
- `schaeffler:cadFileId`
- `schaeffler:productId` when available
- `schaeffler:mesh:topologyHash`
Each mesh prim should carry:
- points
- face vertex counts / indices
- normals
- UV set(s)
- display color where useful
- seam/sharp topology payload
### Seam and sharp-edge payload
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:schaeffler:sharpEdgeVertexPairs`
- `primvars:schaeffler:faceBatchIds`
- `primvars:schaeffler:sourceUv`
The exact encoding can evolve, but the initial implementation should optimize for deterministic Blender reconstruction rather than elegance.
Recommended first-phase encoding:
- edge vertex index pairs in local mesh topology space
- optional fallback world-space segment pairs only if index-space authoring is not yet practical
Index-space is preferred because it survives transforms cleanly and avoids current KD-tree matching workarounds.
## Material Assignment and Replacement
### Design requirements
Material assignment must remain possible at:
- product level
- order-line/render level
- interactive replacement level
without duplicating or re-authoring geometry each time.
### Proposed model
Use USD material bindings as the durable scene-level material assignment mechanism.
The canonical USD contains:
- geometry
- default display materials or placeholder bindings
- canonical material identifiers attached to prims
Render-time or session-time replacement happens through:
- material binding overrides
- optional lightweight USD override layers
- Blender-time shader realization against the `.blend` material library
### Browser-side assignment model
Browser-side assignment must continue to work even when Excel names and tessellated part identities do not match cleanly.
The important design change is this:
- Excel names are an input source for proposed assignments.
- Tessellated USD part keys are the canonical targets that users can actually assign in the browser.
In other words, browser assignment should not depend on "fixing" the Excel name mismatch perfectly. The browser should operate on the actual tessellated scene graph that the user sees, and save overrides against canonical `partKey` values from the USD stage.
Recommended browser workflow:
1. Export USD with stable `partKey` metadata on every tessellated part.
2. Run a reconciliation pass that tries to match Excel rows to those `partKey` targets.
3. Store the result as:
- proposed auto-matches
- unmatched Excel rows
- unmatched tessellated parts
- user-authored overrides
4. Let the browser assign or repair materials by clicking actual scene parts, not by editing raw Excel names.
5. Persist browser-authored overrides by `partKey`, with provenance indicating they came from manual user assignment.
This preserves the current capability to assign missing materials in the browser, but moves the identity model from fragile name matching to stable part-targeted overrides.
### Preserve current 3D viewer behavior
The current viewer supports several behaviors that must not regress:
- clicking an individual visible geometry in the 3D scene
- pinning that selection
- hiding or ghosting other geometry
- highlighting unassigned parts
- assigning a target material from Blender asset-library material names
- fixing missing assignments interactively when automatic matching fails
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/schaefflerautomat/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/MaterialPanel.tsx](/home/hartmut/Documents/Copilot/schaefflerautomat/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:
- USD remains the canonical scene and material-binding source of truth
- the browser consumes a derived interactive preview asset
- every preview mesh carries the canonical USD `partKey`
- browser interactions save overrides against `partKey`
This means the browser continues to provide the same UX even if the underlying canonical asset is USD.
### Viewer package design
To preserve current functionality, the system should produce a browser-facing viewer package derived from the canonical USD scene:
- `usd_master`
- `preview.glb` or `preview.gltf`
- `scene_manifest.json`
The preview mesh is not a second canonical asset. It is an interaction/render surrogate for the browser.
The manifest or preview-mesh metadata should carry, per selectable mesh:
- `partKey`
- `sourceName`
- `primPath`
- `effectiveMaterial`
- `assignmentProvenance`
- `isUnassigned`
This allows the browser to continue doing:
- per-part picking
- isolation and ghost/hide of other geometry
- assigned/unassigned highlighting
- library material assignment by visible part
without relying on exporter-specific name normalization.
### Reconciliation model for Excel mismatches
The current system needs normalization and prefix heuristics because the Excel/imported names do not always match the tessellated output exactly. That problem does not disappear with USD, so the workflow needs an explicit reconciliation layer.
Recommended persistence shape:
- `source_material_assignments`
- raw imported Excel assignments keyed by source name
- `resolved_material_assignments`
- auto-matched assignments keyed by canonical `partKey`
- `manual_material_overrides`
- user-authored browser assignments keyed by canonical `partKey`
- `unmatched_source_rows`
- Excel rows that could not be mapped
- `unassigned_parts`
- tessellated parts with no resolved material
Resolution priority should be:
1. manual browser override by `partKey`
2. resolved auto-match by `partKey`
3. source color/default display material
4. explicit "unassigned" state in the UI
That makes missing assignments visible instead of silently failing.
### Material assignment data model cleanup
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/schaefflerautomat/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/products.py](/home/hartmut/Documents/Copilot/schaefflerautomat/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:
- `source_material_assignments`
- imported Excel or product-authored rows keyed by source-side names
- `resolved_material_assignments`
- auto-matched canonical assignments keyed by `partKey`
- `manual_material_overrides`
- browser-authored overrides keyed by `partKey`
`effective_material_assignments` should be computed from those layers rather than stored as a fourth conflicting source of truth.
This should be reflected in API semantics as well:
- product/admin APIs manage source assignments and reconciliation results
- viewer APIs manage manual overrides by canonical `partKey`
- scene/preview responses expose effective assignment plus provenance
The immediate migration does not require renaming every DB column on day one, but the RFC recommends moving the API contract and service-layer vocabulary to these meanings first and treating legacy field names as internal compatibility details until a later migration.
### Browser UI implications
The browser should expose a material-assignment mode based on the canonical tessellated part list:
- click a visible part in the viewer
- inspect its `partKey`, source name, current resolved material, and assignment provenance
- assign a library material or color directly to that part
- optionally bulk-apply to similar/unassigned parts after user confirmation
The browser should also show a reconciliation panel with:
- unmatched Excel material rows
- unassigned tessellated parts
- confidence or match reason for auto-resolved assignments
This is preferable to treating the viewer as a thin visual shell around a name-based import table.
### Asset-library material names in the browser
The browser must continue to present assignable materials using the existing Blender asset-library naming model. That means the user-facing picker still offers canonical library material names, and browser assignments continue to save one of those names as the selected target.
The change is only in the assignment target:
- today: assignment is effectively keyed by normalized GLB mesh name
- target state: assignment is keyed by canonical USD `partKey`
This preserves the current operational workflow for users and avoids a regression in missing-material recovery.
### Material identity
The material service remains relevant:
- raw material names from Excel or upstream systems are resolved to canonical names
- canonical names are stored on USD prims or material-binding metadata
- Blender maps canonical names to appended material assets from the existing library
The key change is that matching is done by `partKey` and binding metadata, not by imported object names.
### USD representation of browser assignments
Browser-authored assignments should be serializable as material-binding overrides against the canonical USD stage.
Internally, the system should keep:
- canonical USD geometry and metadata stable
- user overrides as `partKey -> canonicalMaterialName`
Those overrides can then be:
- applied live in the browser preview layer
- written to a USD override layer for render/export
- flattened into a single USD when a single-file artifact is required
This preserves interactive browser editing without requiring the browser to directly edit Blender assets or depend on exporter-specific naming quirks.
### Single USD versus override layers
The user requirement is one single USD for the whole workflow. There are two practical ways to satisfy that:
#### Option A: Flat single-file publish
- Material bindings are rewritten directly into the canonical root layer.
- Every material change produces a newly published single USD file.
Pros:
- simplest mental model
- easiest artifact handling
Cons:
- mutates the canonical authored stage
- harder to track override intent separately
#### Option B: Canonical USD plus override layer, flattened for delivery
- Canonical geometry and metadata stay stable.
- Material replacements are authored into a lightweight override layer.
- For consumers that require "one file", the stage is flattened on publish/export.
Pros:
- best authoring model
- clean separation between geometry truth and per-context material overrides
Cons:
- slightly more implementation work
Recommendation:
- Use Option B internally.
- Publish a flattened single USD when a single-file artifact is required externally.
This satisfies the "one single USD" requirement at delivery/runtime boundaries without sacrificing maintainability.
## STEPper-Inspired Seam and UV Integration
### What should be reused
STEPper already demonstrates three behaviors worth preserving:
1. UVs are derived from OCC triangulation nodes.
2. Seams can be inferred from face/batch boundaries.
3. Sharpness can be inferred from discontinuity across shared edge topology.
That logic currently exists inside Blender addon code, but the underlying idea belongs earlier in the pipeline.
### Proposed integration
Port or reimplement the relevant topology derivation into the exporter stage that authors USD:
- tessellate once from STEP
- generate UV coordinates directly from OCC triangulation
- compute seam candidates from batch/material/face boundaries
- compute sharp edges from discontinuity tests
- write those results to USD mesh metadata
Then add a Blender import helper that:
- imports the USD stage
- reads the authored seam/sharp payload
- marks `edge.seam` and sharp flags on imported meshes
- preserves authored UVs for unwrapping and texture workflows
### Why this is better
This moves seam and unwrap support from "best-effort Blender reconstruction" to "authored mesh semantics". Blender remains a consumer of that data, not the only place where the data exists.
## System Design Changes
## 1. Export Layer
Add a new exporter script:
- `render-worker/scripts/export_step_to_usd.py`
Responsibilities:
- read STEP with XCAF
- tessellate once
- preserve hierarchy, names, and colors
- author canonical USD stage
- attach metadata and seam/sharp payload
- optionally emit compatibility GLB derived from USD or from the same tessellation pass
The existing `export_step_to_gltf.py` can remain temporarily for migration and fallback.
## 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:
- `usd_master`
Optional later additions:
- `usd_preview`
- `usd_render_package`
The important change is that `usd_master` becomes the canonical CAD scene artifact associated with a `cad_file`.
## 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:
- `generate_usd_master_task`
- optional `generate_preview_glb_task`
- optional `generate_delivery_usd_task` if flattening/packaging is separated
The production GLB task should be retired once Blender can render from USD and browser preview is handled separately.
## 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`.
Target flow:
- `USD -> Blender render`
The render subprocess should:
- import USD
- read canonical part metadata
- apply material overrides by `partKey`
- restore seam/sharp marks from authored USD mesh metadata when needed
- render stills and turntables
This removes the need for a production GLB as an intermediate render artifact.
## 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:
- one geometry GLB
- one production GLB
Target contract:
- one canonical scene asset reference
- one derived interactive preview artifact reference for browser consumption
- one scene-manifest payload carrying canonical per-part identity and assignment state
Short term:
- keep browser rendering on GLB or glTF
- derive the preview asset from the canonical USD workflow
- preserve todays select/isolate/hide/material-assign interactions in the browser
Long term:
- evaluate a USD-capable viewer only if it is clearly worth the operational complexity
The frontend should stop encoding the architectural distinction between geometry and production assets, but it must retain the current interactive viewer workflow.
## 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.
Target behavior:
- `GET /cad/{id}/part-materials` returns manual overrides keyed by `partKey` plus effective assignment metadata
- `PUT /cad/{id}/part-materials` accepts a full override map keyed by `partKey`
- the API response includes assignment provenance so the browser can distinguish auto-match from manual repair
- the browser does not need to know whether the preview asset came from GLB, glTF, or another future format
This preserves the current operational workflow while removing exporter-specific identity assumptions.
## 7. Admin and Backend Simplification
### Settings
The current admin settings model exposes four tessellation knobs:
- `gltf_preview_linear_deflection`
- `gltf_preview_angular_deflection`
- `gltf_production_linear_deflection`
- `gltf_production_angular_deflection`
That made sense when both outputs were first-class. In the USD model, the settings should be simplified to reflect the actual scene pipeline:
- canonical scene tessellation profile for `usd_master`
- optional preview-derivation profile if the browser preview truly needs a lower-cost surrogate
- optional render-import or delivery profile only if direct USD consumption proves insufficient
Recommendation:
- collapse the current preview/production naming into `scene_*` and `preview_*` concepts
- do not expose a separate production-GLB quality preset in the target design
- keep the number of admin-visible tessellation profiles to the minimum required operationally
### Bulk actions and repair flows
Admin actions should also move from artifact-specific wording to scene pipeline wording.
Examples:
- replace "generate missing geometry GLBs" with "generate missing canonical scenes" and, if needed, "regenerate missing viewer previews"
- keep "reextract metadata" but scope it to canonical scene metadata, not GLB extras
- ensure "re-process STEP" means rebuild canonical USD, manifest, and preview derivatives together
### Frontend/admin simplification
The admin and product-detail UI should stop presenting `gltf_geometry` and `gltf_production` as equal first-class business objects.
Target UI model:
- one canonical scene status
- one preview availability status
- one effective material-assignment status, including unmatched and manually overridden counts
This should remove a class of user confusion where two 3D artifacts exist for the same product but only one is actually canonical.
## Proposed Data Contract
## Canonical scene metadata
For a `cad_file`, API responses should eventually expose:
- canonical scene asset id
- canonical scene asset type
- preview asset url if browser preview uses GLB or glTF
- scene manifest for canonical part identity and effective material state
- list of part identities and metadata extracted from the canonical scene
Example shape:
```json
{
"cad_file_id": "uuid",
"scene_asset": {
"asset_type": "usd_master",
"url": "/media/..."
},
"preview_asset": {
"asset_type": "gltf_preview",
"url": "/media/..."
},
"scene_manifest": {
"parts": [
{
"part_key": "ring_outer",
"source_name": "RingOuter_AF0",
"prim_path": "/Root/Assembly/Bearing/RingOuter",
"effective_material": "SCHAEFFLER_010102_Steel-Polished",
"assignment_provenance": "manual",
"is_unassigned": false
}
]
},
"parts": [
{
"part_key": "ring_outer",
"source_name": "RingOuter_AF0",
"canonical_material_name": "Steel_Polished"
}
]
}
```
## Migration Plan
### Phase 1: Dual-write USD beside GLB
- add `export_step_to_usd.py`
- add `usd_master` media asset type
- export USD in parallel with existing GLB path
- validate hierarchy, metadata, part identity, and tessellation parity
Exit criteria:
- USD contains all required part metadata
- part count and hierarchy are stable versus current outputs
### Phase 2: Move material identity to canonical part keys
- stop relying on Blender object-name matching
- resolve material mappings to canonical part keys
- persist canonical material identity on USD prims
Exit criteria:
- material replacement works without name heuristics
### Phase 3: Move seam/sharp/UV data into authored scene output
- port STEPper-inspired topology logic into exporter
- write seam/sharp payload to USD
- create Blender-side reconstruction helper for import
Exit criteria:
- Blender unwrap workflow can use authored seams
- current seam quality is matched or improved
### Phase 4: Switch Blender render from GLB to USD
- update Blender render scripts and backend service entrypoints
- import USD directly
- bind materials from canonical material metadata
Exit criteria:
- still and turntable outputs match current production quality
- production GLB is no longer required for rendering
### Phase 5: Decouple frontend from dual-GLB model
- update API/viewer contract to use one canonical scene reference
- retain a derived interactive preview asset
- introduce a manifest-driven browser identity model based on `partKey`
- preserve current isolate/hide/material-assignment behavior in the 3D viewer
Exit criteria:
- frontend no longer depends on `geometryGltfUrl` plus `productionGltfUrl`
- frontend still supports current browser-side material assignment workflow without regression
### Phase 6: Retire legacy GLB pipeline split
- remove `gltf_geometry` / `gltf_production` as canonical workflow states
- keep preview GLB only as a derived compatibility artifact if still needed
### Phase 7: Simplify admin and product-facing operational surfaces
- collapse admin settings and labels to canonical scene plus preview concepts
- align CAD/product APIs with source, resolved, and manual material assignment semantics
- remove product-detail assumptions that two GLB assets are first-class workflow outputs
Exit criteria:
- admin no longer exposes preview-vs-production GLB tessellation as the primary mental model
- product and CAD APIs expose canonical part-keyed assignment semantics
- operators can regenerate canonical scenes and viewer previews without reasoning about legacy GLB pipeline stages
## Non-Regression Requirements
The USD refactor is unacceptable if it removes the existing browser-assisted material repair workflow. The following capabilities are mandatory:
- a user can click a visible part in the browser preview and the selection resolves to a stable canonical `partKey`
- a user can pin a selection and isolate, hide, or ghost non-selected geometry
- the viewer can visually identify unassigned parts
- a user can assign a Blender asset-library material name to the selected part
- the assignment is persisted as a manual override keyed by `partKey`
- reloading the viewer restores the same effective assignment and unassigned-state visualization
- render/export consumers use the same effective assignment state that the browser shows
- the workflow still functions when Excel reconciliation fails for some rows
Acceptance criteria:
1. For a CAD file with mismatched Excel names, the system still produces a canonical scene, a selectable preview asset, a list of unmatched source rows, and a list of unassigned parts.
2. In the 3D viewer, selecting a part and assigning a library material updates the effective assignment immediately without depending on raw GLB mesh-name heuristics.
3. After refresh, the same part remains assigned through the persisted `partKey -> canonicalMaterialName` override mapping.
4. A subsequent Blender render or export consumes that same override and produces matching material output.
5. The preview asset exposes canonical `partKey` identity for every selectable mesh.
6. The migrated workflow does not require `geometryGltfUrl` plus `productionGltfUrl` as separate first-class viewer inputs.
## Risks
### 1. Browser preview remains GLB-bound
USD is a strong canonical scene format, but web runtime support is still weaker than GLB. The likely near-term result is a canonical USD plus a derived browser preview asset. This is acceptable and explicitly part of the design, provided the preview asset is treated as non-canonical and keeps stable `partKey` metadata for interaction.
### 2. Blender USD import behavior may not preserve custom topology semantics automatically
Seam and sharp-edge reconstruction will likely require explicit Blender-side helper code. This is expected and acceptable, but it should be treated as a planned integration task, not assumed to work out of the box.
### 3. Material library remains Blender-native
If the authoritative shaders stay in `.blend` assets, then USD carries material identity and binding intent, while final shader realization still happens in Blender. That is acceptable, but it should be acknowledged explicitly.
### 4. Single-file requirement can conflict with layered authoring
A strict "always exactly one USD file at every intermediate step" requirement would push the system toward root-layer mutation. That is possible, but it is worse than using override layers internally and flattening when needed.
## Alternatives Considered
### Keep the current GLB split and improve it incrementally
Rejected because it preserves the wrong abstraction boundary. It can reduce pain locally, but it does not solve the duplication between canonical geometry, render geometry, and metadata transport.
### Use Blender as the canonical scene authoring tool
Rejected because STEP/XCAF metadata and topology are better preserved at the OCC/export layer, not after import into Blender.
### Move directly to USD-only everywhere
Rejected for the first implementation because browser preview and some tooling still benefit from GLB compatibility outputs.
## Open Questions
- Which USD authoring library will be used in the exporter environment?
- Should seam/sharp payload use custom attributes, primvars, or a lightweight in-house schema from the start?
- Do any downstream consumers require per-face material subsets instead of per-part bindings?
- Is a flattened single-file USD required for all persisted states, or only for delivery and render handoff?
- Should preview GLB be derived from USD, or should both USD and GLB be authored from the same tessellation pass during migration?
## Recommendation
Adopt USD as the canonical persisted scene asset and migrate in phases.
The implementation stance should be:
- author geometry, hierarchy, metadata, and seam/sharp payload once from STEP
- store that in `usd_master`
- drive material replacement via part-keyed USD bindings and overrides
- keep Blender as a renderer/material-realization consumer
- keep GLB only as a compatibility derivative where needed
This is the cleanest path to a single-scene workflow without sacrificing material replacement, metadata fidelity, or unwrap support.
## Initial Work Breakdown
1. Add `usd_master` media asset type and migration.
2. Implement `export_step_to_usd.py` with hierarchy, names, colors, and part keys.
3. Add canonical part-key generation and persistence.
4. Port STEPper-inspired UV and seam/sharp derivation into exporter output.
5. Add Blender USD import helper for seam/sharp restoration and material rebinding.
6. Introduce explicit source, resolved, and manual material-assignment service semantics keyed by `partKey`.
7. Change frontend/API contract to a canonical scene asset plus optional preview asset.
8. Preserve the current browser viewer workflow through preview-manifest driven part selection and assignment.
9. Simplify admin settings and repair actions around canonical scenes and derived previews.
10. Switch still/turntable render path to USD input.
+7 -4
View File
@@ -1,8 +1,9 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './store/auth'
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore, isPrivileged as checkIsPrivileged } from './store/auth'
import { WebSocketProvider } from './contexts/WebSocketContext'
import Layout from './components/layout/Layout'
import LoginPage from './pages/Login'
import NotFoundPage from './pages/NotFound'
import DashboardPage from './pages/Dashboard'
import OrdersPage from './pages/Orders'
import OrderDetailPage from './pages/OrderDetail'
@@ -27,14 +28,15 @@ import AssetLibraryPage from './pages/AssetLibrary'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token)
if (!token) return <Navigate to="/login" replace />
const location = useLocation()
if (!token) return <Navigate to="/login" state={{ from: location.pathname }} replace />
return <>{children}</>
}
function AdminRoute({ children }: { children: React.ReactNode }) {
const { token, user } = useAuthStore()
if (!token) return <Navigate to="/login" replace />
if (user?.role !== 'admin' && user?.role !== 'project_manager') return <Navigate to="/" replace />
if (!checkIsPrivileged(user)) return <Navigate to="/" replace />
return <>{children}</>
}
@@ -123,6 +125,7 @@ export default function App() {
}
/>
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</WebSocketProvider>
</BrowserRouter>
+48
View File
@@ -98,8 +98,56 @@ export async function generateGltfProduction(cadFileId: string): Promise<Generat
return res.data
}
export interface ParsedObjectsResponse {
cad_file_id: string
original_name: string
processing_status: string
parsed_objects: {
dimensions_mm?: { x: number; y: number; z: number }
bbox_center_mm?: { x: number; y: number; z: number }
[key: string]: unknown
} | null
}
/** Return the parsed_objects metadata (dimensions, bbox) for a CAD file. */
export async function getParsedObjects(cadFileId: string): Promise<ParsedObjectsResponse> {
const res = await api.get<ParsedObjectsResponse>(`/cad/${cadFileId}/parsed-objects`)
return res.data
}
/** Force-reset a CAD file stuck in 'processing' to 'failed'. */
export async function resetStuckProcessing(cadFileId: string): Promise<{ status: string; message: string }> {
const res = await api.post<{ status: string; message: string }>(`/cad/${cadFileId}/reset-stuck`)
return res.data
}
// ---------------------------------------------------------------------------
// Part-material assignment
// ---------------------------------------------------------------------------
export interface PartMaterialEntry {
type: 'library' | 'hex'
value: string
}
export type PartMaterialMap = Record<string, PartMaterialEntry>
interface PartMaterialsResponse {
cad_file_id: string
part_materials: PartMaterialMap | null
}
/** Return the saved part-material assignments for a CAD file (empty object if none). */
export async function getPartMaterials(cadFileId: string): Promise<PartMaterialMap> {
const res = await api.get<PartMaterialsResponse>(`/cad/${cadFileId}/part-materials`)
return res.data.part_materials ?? {}
}
/** Replace the part-material assignment map for a CAD file. Returns the updated map. */
export async function savePartMaterials(
cadFileId: string,
map: PartMaterialMap,
): Promise<PartMaterialMap> {
const res = await api.put<PartMaterialsResponse>(`/cad/${cadFileId}/part-materials`, map)
return res.data.part_materials ?? {}
}
+9
View File
@@ -17,6 +17,7 @@ export interface MediaAssetFilters {
category_key?: string
render_status?: string
q?: string
exclude_technical?: boolean
page?: number
page_size?: number
}
@@ -34,6 +35,13 @@ export interface MediaAssetItem {
product_pim_id: string | null
category_key: string | null
render_status: string | null
// Extended product metadata
product_ebene1: string | null
product_ebene2: string | null
product_baureihe: string | null
product_produkt_baureihe: string | null
product_lagertyp: string | null
product_name_cad_modell: string | null
download_url: string | null
thumbnail_url: string | null
}
@@ -52,6 +60,7 @@ export function getMediaAssets(filters: MediaAssetFilters = {}): Promise<MediaAs
if (filters.category_key) params.set('category_key', filters.category_key)
if (filters.render_status) params.set('render_status', filters.render_status)
if (filters.q) params.set('q', filters.q)
if (filters.exclude_technical !== undefined) params.set('exclude_technical', String(filters.exclude_technical))
if (filters.page !== undefined) params.set('page', String(filters.page))
if (filters.page_size !== undefined) params.set('page_size', String(filters.page_size))
return api.get(`/media/assets?${params}`).then(r => r.data)
+36
View File
@@ -2,6 +2,38 @@ import api from './client'
import type { Product } from './products'
import type { OutputType } from './outputTypes'
export interface RenderLog {
renderer?: string
type?: string
format?: string
engine?: string
engine_used?: string
samples?: number
stl_quality?: string
smooth_angle?: number
total_duration_s?: number
stl_duration_s?: number
render_duration_s?: number
ffmpeg_duration_s?: number
stl_size_bytes?: number
output_size_bytes?: number
parts_count?: number
device_used?: string
compute_type?: string
gpu_fallback?: boolean
frame_count?: number
fps?: number
template?: string
lighting_only?: boolean
shadow_catcher?: boolean
material_replace?: boolean
fallback?: boolean
error?: string
started_at?: string
completed_at?: string
log_lines?: string[]
}
export interface OrderLine {
id: string
order_id: string
@@ -21,6 +53,9 @@ export interface OrderLine {
unit_price: number | null
render_position_id: string | null
render_position_name: string | null
render_log: RenderLog | null
render_started_at: string | null
render_completed_at: string | null
notes: string | null
created_at: string
updated_at: string
@@ -30,6 +65,7 @@ export interface OrderLineCreate {
product_id: string
output_type_id?: string | null
render_position_id?: string | null
global_render_position_id?: string | null
gewuenschte_bildnummer?: string | null
notes?: string | null
}
+5 -2
View File
@@ -1,5 +1,5 @@
import api from './client'
import type { Order } from './orders'
import type { Order, RenderLog } from './orders'
export interface RenderPosition {
id: string
@@ -62,9 +62,11 @@ export interface Product {
bbox_center_mm?: { x: number; y: number; z: number }
suggested_smooth_angle?: number
has_mechanical_edges?: boolean
sharp_edge_midpoints?: number[][]
sharp_edge_midpoints?: number[][] // legacy: midpoints only
sharp_edge_pairs?: number[][][] // [[start_xyz, end_xyz], ...] in mm
} | null
arbeitspaket: string | null
cad_render_log?: RenderLog | null
notes: string | null
is_active: boolean
source_excel: string | null
@@ -152,6 +154,7 @@ export interface ProductRender {
is_video: boolean
render_backend: string | null
completed_at: string | null
render_position_name: string | null
}
export async function getProductRenders(id: string): Promise<ProductRender[]> {
+50
View File
@@ -0,0 +1,50 @@
import api from './client'
export interface GlobalRenderPosition {
id: string
name: string
rotation_x: number
rotation_y: number
rotation_z: number
is_default: boolean
sort_order: number
created_at: string
updated_at: string
}
export interface GlobalRenderPositionCreate {
name: string
rotation_x?: number
rotation_y?: number
rotation_z?: number
is_default?: boolean
sort_order?: number
}
export interface GlobalRenderPositionPatch {
name?: string
rotation_x?: number
rotation_y?: number
rotation_z?: number
is_default?: boolean
sort_order?: number
}
export async function listGlobalRenderPositions(): Promise<GlobalRenderPosition[]> {
const res = await api.get<GlobalRenderPosition[]>('/render-positions/global')
return res.data
}
export async function createGlobalRenderPosition(body: GlobalRenderPositionCreate): Promise<GlobalRenderPosition> {
const res = await api.post<GlobalRenderPosition>('/render-positions/global', body)
return res.data
}
export async function updateGlobalRenderPosition(id: string, body: GlobalRenderPositionPatch): Promise<GlobalRenderPosition> {
const res = await api.patch<GlobalRenderPosition>(`/render-positions/global/${id}`, body)
return res.data
}
export async function deleteGlobalRenderPosition(id: string): Promise<void> {
await api.delete(`/render-positions/global/${id}`)
}
@@ -0,0 +1,243 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react'
import {
listGlobalRenderPositions,
createGlobalRenderPosition,
updateGlobalRenderPosition,
deleteGlobalRenderPosition,
type GlobalRenderPosition,
type GlobalRenderPositionCreate,
} from '../../api/renderPositions'
interface EditState {
id: string | null
name: string
rotation_x: number
rotation_y: number
rotation_z: number
is_default: boolean
sort_order: number
}
const EMPTY_EDIT: EditState = {
id: null,
name: '',
rotation_x: 0,
rotation_y: 0,
rotation_z: 0,
is_default: false,
sort_order: 0,
}
export default function GlobalRenderPositionsPanel() {
const qc = useQueryClient()
const [editing, setEditing] = useState<EditState | null>(null)
const [adding, setAdding] = useState(false)
const { data: positions = [], isLoading } = useQuery({
queryKey: ['global-render-positions'],
queryFn: listGlobalRenderPositions,
})
const createMut = useMutation({
mutationFn: (body: GlobalRenderPositionCreate) => createGlobalRenderPosition(body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setAdding(false) },
})
const updateMut = useMutation({
mutationFn: ({ id, body }: { id: string; body: Partial<GlobalRenderPositionCreate> }) =>
updateGlobalRenderPosition(id, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setEditing(null) },
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteGlobalRenderPosition(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['global-render-positions'] }),
})
function startEdit(pos: GlobalRenderPosition) {
setAdding(false)
setEditing({
id: pos.id,
name: pos.name,
rotation_x: pos.rotation_x,
rotation_y: pos.rotation_y,
rotation_z: pos.rotation_z,
is_default: pos.is_default,
sort_order: pos.sort_order,
})
}
function saveEdit() {
if (!editing) return
if (editing.id) {
const { id, ...body } = editing
updateMut.mutate({ id, body })
}
}
function saveNew() {
if (!editing) return
const { id, ...body } = editing
createMut.mutate(body)
}
function startAdd() {
setEditing({ ...EMPTY_EDIT, sort_order: positions.length })
setAdding(true)
}
function cancelEdit() {
setEditing(null)
setAdding(false)
}
function rotField(label: string, field: keyof Pick<EditState, 'rotation_x' | 'rotation_y' | 'rotation_z'>) {
if (!editing) return null
return (
<div className="flex flex-col gap-0.5">
<label className="text-xs text-content-muted">{label}</label>
<input
type="number"
step="5"
className="input w-20 text-sm"
value={editing[field]}
onChange={(e) => setEditing({ ...editing, [field]: parseFloat(e.target.value) || 0 })}
/>
</div>
)
}
if (isLoading) return <p className="text-sm text-content-muted">Loading</p>
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-content-muted">
Global camera rotation presets applied to all products. Per-product positions take priority.
</p>
<button className="btn btn-sm btn-primary flex items-center gap-1" onClick={startAdd}>
<Plus size={14} /> Add position
</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left text-xs text-content-muted">
<th className="pb-1 pr-3">Name</th>
<th className="pb-1 pr-3 text-center">Rot X°</th>
<th className="pb-1 pr-3 text-center">Rot Y°</th>
<th className="pb-1 pr-3 text-center">Rot Z°</th>
<th className="pb-1 pr-3 text-center">Default</th>
<th className="pb-1 pr-3 text-center">Order</th>
<th className="pb-1" />
</tr>
</thead>
<tbody>
{positions.map((pos) => {
const isEditingThis = editing && editing.id === pos.id
return (
<tr key={pos.id} className="border-b border-border-light/50 hover:bg-surface-alt/30">
{isEditingThis ? (
<>
<td className="py-1 pr-2">
<input
className="input w-32 text-sm"
value={editing!.name}
onChange={(e) => setEditing({ ...editing!, name: e.target.value })}
/>
</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
<td className="py-1 pr-2 text-center">
<input
type="checkbox"
checked={editing!.is_default}
onChange={(e) => setEditing({ ...editing!, is_default: e.target.checked })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
className="input w-14 text-sm"
value={editing!.sort_order}
onChange={(e) => setEditing({ ...editing!, sort_order: parseInt(e.target.value) || 0 })}
/>
</td>
<td className="py-1 flex items-center gap-1">
<button className="btn btn-xs btn-primary" onClick={saveEdit} disabled={updateMut.isPending}>
<Check size={12} />
</button>
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
</td>
</>
) : (
<>
<td className="py-1.5 pr-3 font-medium">{pos.name}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_x}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_y}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_z}</td>
<td className="py-1.5 pr-3 text-center">
{pos.is_default && <span className="text-accent text-xs font-medium"></span>}
</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.sort_order}</td>
<td className="py-1.5 flex items-center gap-1">
<button className="btn btn-xs" onClick={() => startEdit(pos)}><Pencil size={12} /></button>
<button
className="btn btn-xs text-red-500"
onClick={() => { if (confirm(`Delete "${pos.name}"?`)) deleteMut.mutate(pos.id) }}
disabled={deleteMut.isPending}
>
<Trash2 size={12} />
</button>
</td>
</>
)}
</tr>
)
})}
{/* New row */}
{adding && editing && (
<tr className="border-b border-border-light bg-surface-alt/20">
<td className="py-1 pr-2">
<input
className="input w-32 text-sm"
placeholder="Name"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
/>
</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
<td className="py-1 pr-2 text-center">
<input
type="checkbox"
checked={editing.is_default}
onChange={(e) => setEditing({ ...editing, is_default: e.target.checked })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
className="input w-14 text-sm"
value={editing.sort_order}
onChange={(e) => setEditing({ ...editing, sort_order: parseInt(e.target.value) || 0 })}
/>
</td>
<td className="py-1 flex items-center gap-1">
<button className="btn btn-xs btn-primary" onClick={saveNew} disabled={createMut.isPending}>
<Check size={12} />
</button>
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
+426 -67
View File
@@ -1,14 +1,16 @@
import { Suspense, useEffect, useRef, useState } from 'react'
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Canvas } from '@react-three/fiber'
import { Canvas, useThree } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, Cpu } from 'lucide-react'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, Cpu, AlertCircle, EyeOff } from 'lucide-react'
import { toast } from 'sonner'
import { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry } from '../../api/cad'
import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad'
import { useAuthStore } from '../../store/auth'
import MaterialPanel, { SCHAEFFLER_COLORS, previewColorForEntry, type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial } from './cadUtils'
type ViewMode = 'solid' | 'wireframe'
type GlbSource = 'geometry' | 'production'
@@ -22,26 +24,96 @@ const LIGHT_PRESETS: { id: LightPreset; label: string }[] = [
{ id: 'city', label: 'City' },
]
function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) {
// ---------------------------------------------------------------------------
// CameraAutoFit — auto-fits camera to model bounding box on first load
// ---------------------------------------------------------------------------
function CameraAutoFit({
sceneRef,
controlsRef,
fitTrigger,
}: {
sceneRef: React.MutableRefObject<THREE.Object3D | null>
controlsRef: React.RefObject<any>
fitTrigger: number
}) {
const { camera, size } = useThree()
useEffect(() => {
if (fitTrigger === 0 || !sceneRef.current) return
const box = new THREE.Box3()
sceneRef.current.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) box.expandByObject(obj)
})
if (box.isEmpty()) return
const center = box.getCenter(new THREE.Vector3())
const sizeVec = box.getSize(new THREE.Vector3())
const maxDim = Math.max(sizeVec.x, sizeVec.y, sizeVec.z)
const pc = camera as THREE.PerspectiveCamera
const fovRad = (pc.fov * Math.PI) / 180
const aspect = size.width / size.height
const fovH = 2 * Math.atan(Math.tan(fovRad / 2) * aspect)
const dist = (maxDim / 2) / Math.tan(Math.min(fovRad, fovH) / 2) * 1.6
camera.position.set(center.x + maxDim * 0.05, center.y + maxDim * 0.2, center.z + dist)
camera.near = maxDim * 0.001
camera.far = maxDim * 100
camera.updateProjectionMatrix()
camera.lookAt(center)
if (controlsRef.current) {
controlsRef.current.target.copy(center)
controlsRef.current.minDistance = maxDim * 0.05
controlsRef.current.maxDistance = maxDim * 20
controlsRef.current.update()
}
}, [fitTrigger]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
// ---------------------------------------------------------------------------
// GlbModelWithFit — loads GLB, stores scene ref, signals ready, pointer events
// ---------------------------------------------------------------------------
function GlbModelWithFit({
url,
wireframe,
sceneRef,
onReady,
onPointerOver,
onPointerOut,
onClick,
}: {
url: string
wireframe: boolean
sceneRef: React.MutableRefObject<THREE.Object3D | null>
onReady: () => void
onPointerOver?: (e: any) => void
onPointerOut?: () => void
onClick?: (e: any) => void
}) {
const { scene } = useGLTF(url)
const cloned = useRef<THREE.Group | null>(null)
if (!cloned.current) {
cloned.current = scene.clone(true)
cloned.current.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.geometry) {
let geo = obj.geometry.clone()
if (!geo.index) {
// Non-indexed geometry: each triangle has unique vertices,
// so computeVertexNormals() would give per-face normals (flat shading).
// mergeVertices() creates an indexed geometry with shared vertices first,
// so the subsequent normal computation averages across adjacent faces → smooth.
geo = mergeVertices(geo)
if (obj instanceof THREE.Mesh) {
if (obj.geometry) {
let geo = obj.geometry.clone()
if (!geo.index) geo = mergeVertices(geo)
geo.computeVertexNormals()
obj.geometry = geo
}
// Clone materials so emissive / color changes don't affect the shared GLTF cache
if (obj.material) {
obj.material = Array.isArray(obj.material)
? obj.material.map((m: THREE.Material) => m.clone())
: obj.material.clone()
}
// For indexed geometry (Blender GLB): normals are already baked smooth by Blender.
// Recomputing here still works correctly because shared vertices average properly.
geo.computeVertexNormals()
obj.geometry = geo
}
})
}
@@ -58,10 +130,22 @@ function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) {
})
}, [wireframe])
return <primitive object={cloned.current} />
useEffect(() => {
sceneRef.current = cloned.current
onReady()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<primitive
object={cloned.current}
onPointerOver={onPointerOver}
onPointerOut={onPointerOut}
onClick={onClick}
/>
)
}
const HEIGHT = 420
const HEIGHT = 560
function ToolbarBtn({
active, onClick, children, title,
@@ -70,7 +154,7 @@ function ToolbarBtn({
<button
title={title}
onClick={onClick}
className={`px-2 py-1 text-[11px] flex items-center gap-1 transition-colors ${
className={`px-2 py-1 text-[11px] flex items-center gap-1 transition-colors rounded ${
active ? 'bg-white/20 text-white' : 'text-white/50 hover:text-white/80 hover:bg-white/10'
}`}
>
@@ -82,19 +166,38 @@ function ToolbarBtn({
export default function InlineCadViewer({
cadFileId,
thumbnailUrl,
initialPartMaterials,
}: {
cadFileId: string
thumbnailUrl?: string | null
initialPartMaterials?: PartMaterialMap
}) {
const token = useAuthStore((s) => s.token)
const qc = useQueryClient()
// GLB source / display state
const [glbBlobUrl, setGlbBlobUrl] = useState<string | null>(null)
const [loadingGlb, setLoadingGlb] = useState(false)
const [generating, setGenerating] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('solid')
const [glbSource, setGlbSource] = useState<GlbSource>('geometry')
const [lightPreset, setLightPreset] = useState<LightPreset>('studio')
const [modelReady, setModelReady] = useState(false)
const [fitTrigger, setFitTrigger] = useState(0)
// Material assignment state
const [pinnedPart, setPinnedPart] = useState<string | null>(null)
const [showUnassigned, setShowUnassigned] = useState(false)
const [hideAssigned, setHideAssigned] = useState(false)
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
const sceneRef = useRef<THREE.Object3D | null>(null)
const controlsRef = useRef<any>(null)
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
// Media asset queries
const { data: gltfAssets } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_geometry'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId, asset_types: ['gltf_geometry'] }),
@@ -108,14 +211,33 @@ export default function InlineCadViewer({
staleTime: 0,
})
// Part-material assignments — from CadFile (manual assignments in viewer)
const { data: savedPartMaterials = {} } = useQuery({
queryKey: ['part-materials', cadFileId],
queryFn: () => getPartMaterials(cadFileId),
staleTime: 30_000,
retry: false,
})
// Merge: initialPartMaterials (from Product Excel data) as base; savedPartMaterials overrides
const partMaterials = useMemo(
() => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap),
[initialPartMaterials, savedPartMaterials],
)
// Count how many unique GLB mesh types have a resolved material assignment
const assignedCount = useMemo(
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, partMaterials)).length,
[glbMeshNames, partMaterials],
)
useEffect(() => {
if (generating && gltfAssets && gltfAssets.length > 0) setGenerating(false)
}, [generating, gltfAssets])
const hasGeometry = (gltfAssets?.length ?? 0) > 0
const hasGeometry = (gltfAssets?.length ?? 0) > 0
const hasProduction = (productionAssets?.length ?? 0) > 0
// Auto-switch to production if it's the only available source
useEffect(() => {
if (!hasGeometry && hasProduction) setGlbSource('production')
}, [hasGeometry, hasProduction])
@@ -125,9 +247,11 @@ export default function InlineCadViewer({
? productionAssets?.[0]?.download_url
: gltfAssets?.[0]?.download_url
// Fetch active GLB as blob URL (needs auth header)
useEffect(() => {
if (!activeDownloadUrl || !token) return
setGlbBlobUrl(null)
setModelReady(false)
setLoadingGlb(true)
let blobUrl = ''
fetch(activeDownloadUrl, { headers: { Authorization: `Bearer ${token}` } })
@@ -138,11 +262,119 @@ export default function InlineCadViewer({
})
.catch(() => toast.error('Failed to load 3D model'))
.finally(() => setLoadingGlb(false))
return () => {
if (blobUrl) URL.revokeObjectURL(blobUrl)
}
return () => { if (blobUrl) URL.revokeObjectURL(blobUrl) }
}, [activeDownloadUrl, token])
// Apply saved material colors after model loads or when assignments change
useEffect(() => {
if (!modelReady || !sceneRef.current) return
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const entry = resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)
if (!entry) return
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'color' in mat) mat.color.set(previewColorForEntry(entry))
})
})
}, [modelReady, partMaterials])
// Unassigned glow — only when at least one assignment exists
useEffect(() => {
if (!modelReady || !sceneRef.current) return
const hasAnyAssignment = Object.keys(partMaterials).length > 0
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
if (showUnassigned && hasAnyAssignment) {
const assigned = !!resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)
mat.emissive.set(assigned ? 0x000000 : 0xff4400)
mat.emissiveIntensity = assigned ? 0 : 0.8
} else {
mat.emissive.set(0x000000)
mat.emissiveIntensity = 0
}
})
})
}, [modelReady, showUnassigned, partMaterials])
// Reset isolateMode when no part is pinned
useEffect(() => {
if (!pinnedPart) setIsolateMode('none')
}, [pinnedPart])
// Reset hideAssigned when all assignments are cleared
useEffect(() => {
if (Object.keys(partMaterials).length === 0) setHideAssigned(false)
}, [partMaterials])
// Combined visibility effect — handles hideAssigned + isolateMode together to avoid conflicts
useEffect(() => {
if (!modelReady || !sceneRef.current) return
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const normalizedName = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
const isSelected = normalizedName === pinnedPart
const isAssigned = !!resolvePartMaterial(normalizedName, partMaterials)
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
// Default: fully visible + raycasting enabled
mesh.visible = true
mesh.raycast = THREE.Mesh.prototype.raycast
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true }
})
// hideAssigned: hide all assigned meshes (except the currently selected part)
if (hideAssigned && isAssigned && !isSelected) {
mesh.visible = false
mesh.raycast = () => {} // prevent R3F from seeing hidden meshes as hit targets
return
}
// isolateMode: ghost or hide non-selected meshes when a part is pinned
if (!isSelected && pinnedPart && isolateMode !== 'none') {
if (isolateMode === 'hide') {
mesh.visible = false
mesh.raycast = () => {} // prevent R3F from seeing hidden meshes as hit targets
} else {
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'opacity' in mat) { mat.opacity = 0.08; mat.transparent = true; mat.depthWrite = false; mat.needsUpdate = true }
})
}
}
})
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials])
// Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches
useEffect(() => {
if (!import.meta.env.DEV || !modelReady || !sceneRef.current) return
const names = new Set<string>()
sceneRef.current.traverse(o => {
if ((o as THREE.Mesh).isMesh && o.name) names.add(normalizeMeshName((o.userData?.name as string) || o.name))
})
const keys = Object.keys(partMaterials)
const matched = keys.filter(k => names.has(k))
const unmatched = keys.filter(k => !names.has(k))
console.debug('[CAD] Match status:', {
totalGlbMeshes: names.size,
totalStoredKeys: keys.length,
matched: matched.length,
unmatched: unmatched.length,
unmatchedKeys: unmatched,
glbNames: [...names].sort(),
})
}, [modelReady, partMaterials])
const generateMut = useMutation({
mutationFn: () => generateGltfGeometry(cadFileId),
onSuccess: () => {
@@ -153,63 +385,188 @@ export default function InlineCadViewer({
onError: () => toast.error('Failed to queue GLB generation'),
})
if (glbBlobUrl) {
return (
<div className="w-full rounded-lg overflow-hidden border border-border-default bg-gray-950 relative" style={{ height: HEIGHT }}>
<Canvas camera={{ position: [0, 0, 2], fov: 45 }}>
<Suspense fallback={null}>
<Environment preset={lightPreset} background={false} />
<GlbModel key={glbBlobUrl} url={glbBlobUrl} wireframe={viewMode === 'wireframe'} />
</Suspense>
<OrbitControls makeDefault />
</Canvas>
// Hover highlight
const handlePointerOver = useCallback((e: any) => {
e.stopPropagation()
const mesh = e.object as THREE.Mesh
// Restore previous hovered mesh (correctly preserve unassigned glow)
if (hoveredMeshRef.current && hoveredMeshRef.current !== mesh) {
const prev = hoveredMeshRef.current
const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material]
const hasAny = Object.keys(partMaterials).length > 0
prevMats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
if (showUnassigned && hasAny && !resolvePartMaterial(normalizeMeshName((prev.userData?.name as string) || prev.name), partMaterials as PartMaterialMap)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0
}
})
}
hoveredMeshRef.current = mesh
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
})
}, [showUnassigned, partMaterials])
{/* Toolbar */}
<div className="absolute top-2 right-2 flex flex-col gap-1 items-end">
{/* Geometry / Production toggle — only when both exist */}
const handlePointerOut = useCallback(() => {
if (hoveredMeshRef.current) {
const mesh = hoveredMeshRef.current
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const hasAnyAssignment = Object.keys(partMaterials).length > 0
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0
}
})
hoveredMeshRef.current = null
}
}, [showUnassigned, partMaterials])
const handleClick = useCallback((e: any) => {
e.stopPropagation()
const meshObj = e.object as THREE.Mesh
const name = normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || '')
if (name) setPinnedPart(name)
}, [])
// ── Render: model loaded ──────────────────────────────────────────────────
if (glbBlobUrl) {
const pm = partMaterials as PartMaterialMap
return (
<div
className="w-full rounded-lg border border-border-default bg-gray-950 flex flex-col overflow-hidden"
style={{ height: HEIGHT }}
onClick={() => setPinnedPart(null)}
>
{/* ── Toolbar row — real block element above the canvas ── */}
<div
className="shrink-0 flex items-center gap-0.5 px-2 py-1 bg-black/70 border-b border-white/10 flex-wrap"
onClick={(e) => e.stopPropagation()}
>
{/* Geo / PBR toggle */}
{hasGeometry && hasProduction && (
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<ToolbarBtn active={glbSource === 'geometry'} onClick={() => setGlbSource('geometry')} title="Geometry GLB (OCC, no materials)">
<Box size={12} /> Geo
<>
<ToolbarBtn active={glbSource === 'geometry'} onClick={() => setGlbSource('geometry')} title="Geometry GLB (OCC)">
<Box size={11} /> Geo
</ToolbarBtn>
<div className="w-px bg-white/10" />
<ToolbarBtn active={glbSource === 'production'} onClick={() => setGlbSource('production')} title="Production GLB (Blender + PBR materials)">
<Cpu size={12} /> PBR
<ToolbarBtn active={glbSource === 'production'} onClick={() => setGlbSource('production')} title="Production GLB (Blender PBR)">
<Cpu size={11} /> PBR
</ToolbarBtn>
</div>
<div className="w-px h-4 bg-white/10 mx-0.5" />
</>
)}
{/* View mode */}
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
<Layers size={12} /> Solid
</ToolbarBtn>
<div className="w-px bg-white/10" />
<ToolbarBtn active={viewMode === 'wireframe'} onClick={() => setViewMode('wireframe')} title="Wireframe">
<Grid3X3 size={12} /> Wire
</ToolbarBtn>
</div>
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
<Layers size={11} /> Solid
</ToolbarBtn>
<ToolbarBtn active={viewMode === 'wireframe'} onClick={() => setViewMode('wireframe')} title="Wireframe">
<Grid3X3 size={11} /> Wire
</ToolbarBtn>
{/* Lighting presets */}
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<span className="px-2 py-1 text-[11px] text-white/30 flex items-center">
<Sun size={11} />
</span>
<div className="w-px bg-white/10" />
{LIGHT_PRESETS.map((p, i) => (
<div key={p.id} className="flex">
{i > 0 && <div className="w-px bg-white/10" />}
<ToolbarBtn active={lightPreset === p.id} onClick={() => setLightPreset(p.id)} title={p.label}>
{p.label}
<div className="w-px h-4 bg-white/10 mx-0.5" />
{/* Lighting */}
<Sun size={11} className="text-white/30 mx-1" />
{LIGHT_PRESETS.map((p) => (
<ToolbarBtn key={p.id} active={lightPreset === p.id} onClick={() => setLightPreset(p.id)} title={p.label}>
{p.label}
</ToolbarBtn>
))}
{/* Show unassigned + hide assigned toggles */}
{modelReady && (
<>
<div className="w-px h-4 bg-white/10 mx-0.5" />
<ToolbarBtn
active={showUnassigned}
onClick={() => setShowUnassigned(v => !v)}
title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`}
>
<AlertCircle size={11} />
<span className="tabular-nums text-[10px]">{assignedCount}/{totalMeshCount}</span>
</ToolbarBtn>
{assignedCount > 0 && (
<ToolbarBtn
active={hideAssigned}
onClick={() => setHideAssigned(v => !v)}
title="Hide parts that already have a material assigned"
>
<EyeOff size={11} />
<span className="text-[10px]">Hide assigned</span>
</ToolbarBtn>
</div>
))}
)}
</>
)}
</div>
{/* ── Canvas area ── */}
<div className="flex-1 relative" onClick={(e) => e.stopPropagation()}>
<Canvas
gl={{ powerPreference: 'high-performance', antialias: true }}
dpr={[1, 1.5]}
camera={{ position: [0, 0, 2], fov: 45 }}
>
<Suspense fallback={null}>
<Environment preset={lightPreset} background={false} />
<GlbModelWithFit
key={glbBlobUrl}
url={glbBlobUrl}
wireframe={viewMode === 'wireframe'}
sceneRef={sceneRef}
onReady={() => {
const names = new Set<string>()
sceneRef.current?.traverse(o => {
if ((o as THREE.Mesh).isMesh && o.name) names.add(normalizeMeshName((o.userData?.name as string) || o.name))
})
setTotalMeshCount(names.size)
setGlbMeshNames(new Set(names))
setModelReady(true)
setFitTrigger(t => t + 1)
}}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onClick={handleClick}
/>
</Suspense>
<OrbitControls ref={controlsRef} makeDefault />
<CameraAutoFit sceneRef={sceneRef} controlsRef={controlsRef} fitTrigger={fitTrigger} />
</Canvas>
{/* Material assignment panel */}
{pinnedPart && (
<MaterialPanel
partName={pinnedPart}
cadFileId={cadFileId}
currentEntry={resolvePartMaterial(pinnedPart, pm)}
partMaterials={pm}
onClose={() => setPinnedPart(null)}
isolateMode={isolateMode}
onIsolateModeChange={setIsolateMode}
/>
)}
{/* Hint */}
<div className="absolute bottom-1.5 right-2 text-gray-600 text-[10px] pointer-events-none select-none">
click part to assign material
</div>
</div>
</div>
)
}
// ── Render: loading ───────────────────────────────────────────────────────
if (loadingGlb) {
return (
<div
@@ -224,6 +581,8 @@ export default function InlineCadViewer({
)
}
// ── Render: no GLB yet ────────────────────────────────────────────────────
return (
<div
className="w-full rounded-lg border border-border-default bg-surface-muted flex flex-col items-center justify-center gap-3"
@@ -0,0 +1,290 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Loader2, Palette, Layers, EyeOff } from 'lucide-react'
import { toast } from 'sonner'
import api from '../../api/client'
import { savePartMaterials, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad'
// ---------------------------------------------------------------------------
// SCHAEFFLER_COLORS — viewport preview colors for known library materials
// ---------------------------------------------------------------------------
export const SCHAEFFLER_COLORS: Record<string, string> = {
'SCHAEFFLER_010101_Steel-Bare': '#8a9ca8',
'SCHAEFFLER_010102_Steel-Polished': '#b0c4ce',
'SCHAEFFLER_010103_Steel-Brushed': '#9aabb5',
'SCHAEFFLER_010104_Steel-Painted': '#607080',
'SCHAEFFLER_010201_Stainless-Bare': '#adb9bf',
'SCHAEFFLER_010202_Stainless-Polished': '#cdd8dc',
'SCHAEFFLER_010301_Iron-Cast': '#696969',
'SCHAEFFLER_020101_Aluminium-Bare': '#c8c8c8',
'SCHAEFFLER_020102_Aluminium-Anodized': '#b0b8c0',
'SCHAEFFLER_030101_Brass': '#c9a84c',
'SCHAEFFLER_030201_Bronze': '#a07040',
'SCHAEFFLER_040101_Copper': '#b87333',
'SCHAEFFLER_050101_Plastic-Black': '#202020',
'SCHAEFFLER_050102_Plastic-White': '#f0f0f0',
'SCHAEFFLER_050201_Rubber-Black': '#1a1a1a',
'SCHAEFFLER_060101_Ceramic': '#e8dcc8',
'SCHAEFFLER_070101_Glass': '#88bbcc',
}
export function previewColorForEntry(entry: PartMaterialEntry): string {
if (entry.type === 'hex') return entry.value
return SCHAEFFLER_COLORS[entry.value] ?? '#888888'
}
// ---------------------------------------------------------------------------
// MaterialOut — matches GET /api/materials response
// ---------------------------------------------------------------------------
export interface MaterialOut {
id: string
name: string
description: string | null
schaeffler_code: number | null
source: string
}
// ---------------------------------------------------------------------------
// MaterialPanel — floating panel for assigning a material/color to a part
// ---------------------------------------------------------------------------
export type IsolateMode = 'none' | 'ghost' | 'hide'
export interface MaterialPanelProps {
partName: string
cadFileId: string
currentEntry: PartMaterialEntry | undefined
partMaterials: PartMaterialMap
onClose: () => void
isolateMode?: IsolateMode
onIsolateModeChange?: (mode: IsolateMode) => void
}
export default function MaterialPanel({
partName,
cadFileId,
currentEntry,
partMaterials,
onClose,
isolateMode = 'none',
onIsolateModeChange,
}: MaterialPanelProps) {
const queryClient = useQueryClient()
// Fetch all tenant materials (no filter — user sees their full library)
const { data: allMaterials = [] } = useQuery({
queryKey: ['materials'],
queryFn: async () => {
const res = await api.get<MaterialOut[]>('/materials')
return res.data
},
staleTime: 60_000,
})
const [assignType, setAssignType] = useState<'library' | 'hex'>(
currentEntry?.type ?? 'library',
)
const [hexValue, setHexValue] = useState(
currentEntry?.type === 'hex' ? currentEntry.value : '#888888',
)
const [libValue, setLibValue] = useState(
currentEntry?.type === 'library'
? currentEntry.value
: (allMaterials[0]?.name ?? ''),
)
// Set default library value once materials load
useEffect(() => {
if (!libValue && allMaterials.length > 0) setLibValue(allMaterials[0].name)
}, [allMaterials]) // eslint-disable-line react-hooks/exhaustive-deps
const saveMut = useMutation({
mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['part-materials', cadFileId] })
toast.success(`Material assigned to "${partName}"`)
onClose()
},
onError: () => toast.error('Failed to save material assignment'),
})
const removeMut = useMutation({
mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['part-materials', cadFileId] })
toast.success(`Assignment removed from "${partName}"`)
onClose()
},
onError: () => toast.error('Failed to remove assignment'),
})
function handleAssign() {
const entry: PartMaterialEntry =
assignType === 'hex'
? { type: 'hex', value: hexValue }
: { type: 'library', value: libValue }
saveMut.mutate({ ...partMaterials, [partName]: entry })
}
function handleRemove() {
const updated = { ...partMaterials }
delete updated[partName]
removeMut.mutate(updated)
}
const isBusy = saveMut.isPending || removeMut.isPending
const previewHex = assignType === 'hex'
? hexValue
: (SCHAEFFLER_COLORS[libValue] ?? '#888888')
return (
<div
className="absolute top-2 left-2 z-30 w-72 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700">
<div className="flex items-center gap-2 min-w-0">
<Palette size={13} className="text-accent shrink-0" />
<span className="text-white text-xs font-semibold truncate" title={partName}>
{partName}
</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-white p-0.5 shrink-0">
<X size={14} />
</button>
</div>
<div className="p-3 space-y-3">
{/* Isolation toggles — ghost or hide all other parts */}
{onIsolateModeChange && (
<div className="flex gap-1.5">
<button
onClick={() => onIsolateModeChange(isolateMode === 'ghost' ? 'none' : 'ghost')}
title="Ghost other parts (semi-transparent)"
className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] font-medium transition-colors ${
isolateMode === 'ghost'
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
<Layers size={11} /> Ghost
</button>
<button
onClick={() => onIsolateModeChange(isolateMode === 'hide' ? 'none' : 'hide')}
title="Hide other parts"
className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] font-medium transition-colors ${
isolateMode === 'hide'
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
<EyeOff size={11} /> Hide
</button>
</div>
)}
{/* Type tabs */}
<div className="flex rounded-md overflow-hidden border border-gray-700 text-xs">
{(['library', 'hex'] as const).map((t) => (
<button
key={t}
onClick={() => setAssignType(t)}
className={`flex-1 py-1.5 font-medium transition-colors ${
assignType === t
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{t === 'library' ? 'Library Material' : 'Hex Color'}
</button>
))}
</div>
{assignType === 'library' ? (
<div>
<label className="block text-gray-400 text-[11px] mb-1">Material</label>
<select
value={libValue}
onChange={(e) => setLibValue(e.target.value)}
className="w-full bg-gray-800 border border-gray-600 text-white text-xs rounded px-2 py-1.5 focus:outline-none focus:border-accent"
>
{allMaterials.map((m) => (
<option key={m.id} value={m.name}>
{m.name}
{m.description ? `${m.description}` : ''}
</option>
))}
{allMaterials.length === 0 && (
<option value="">No materials found</option>
)}
</select>
</div>
) : (
<div>
<label className="block text-gray-400 text-[11px] mb-1">Hex Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={hexValue}
onChange={(e) => setHexValue(e.target.value)}
className="w-10 h-8 rounded border border-gray-600 bg-gray-800 cursor-pointer p-0.5"
/>
<input
type="text"
value={hexValue}
onChange={(e) => setHexValue(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-600 text-white text-xs rounded px-2 py-1.5 font-mono focus:outline-none focus:border-accent"
placeholder="#888888"
/>
</div>
</div>
)}
{/* Preview swatch */}
<div className="flex items-center gap-2 text-[11px] text-gray-400">
<div
className="w-4 h-4 rounded-sm border border-gray-600 shrink-0"
style={{ backgroundColor: previewHex }}
/>
<span>Viewport preview color</span>
</div>
{/* Current assignment */}
{currentEntry && (
<div className="flex items-center gap-2 text-[11px] text-gray-400 bg-gray-800/60 rounded px-2 py-1.5">
<div
className="w-3 h-3 rounded-sm shrink-0 border border-gray-600"
style={{ backgroundColor: previewColorForEntry(currentEntry) }}
/>
<span className="truncate">Current: {currentEntry.value}</span>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
onClick={handleAssign}
disabled={isBusy || (assignType === 'library' && !libValue)}
className="flex-1 px-3 py-1.5 rounded bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors flex items-center justify-center gap-1"
>
{saveMut.isPending && <Loader2 size={11} className="animate-spin" />}
Assign
</button>
{currentEntry && (
<button
onClick={handleRemove}
disabled={isBusy}
className="px-3 py-1.5 rounded bg-gray-700 hover:bg-red-900 disabled:opacity-40 disabled:cursor-not-allowed text-gray-300 hover:text-white text-xs font-medium transition-colors flex items-center gap-1"
>
{removeMut.isPending && <Loader2 size={11} className="animate-spin" />}
Remove
</button>
)}
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
import type { PartMaterialEntry, PartMaterialMap } from '../../api/cad'
/**
* Normalize a GLB mesh name by stripping suffixes added by the export pipeline:
* - OCC RWGltf_CafWriter adds "_AF0", "_AF1", … for repeated assembly instances
* - Blender adds ".001", ".002", … for name deduplication on re-import
*
* Mirrors the logic in render-worker/scripts/export_gltf.py (lines 107-114).
*
* Examples:
* "Ring_AF3" → "Ring"
* "Ring_AF0_AF1" → "Ring" (nested suffixes — loop until stable)
* "Cage.001" → "Cage"
* "Cage.001_AF2" → "Cage"
* "KOMP_ASM_1_AF0_ASM" → "KOMP_ASM_1" (_AF0_ASM variant)
* "GE360-HF_000_P_ASM_1_AF0_ASM" → "GE360-HF_000_P_ASM_1"
* "PlainPart" → "PlainPart"
*/
export function normalizeMeshName(name: string): string {
// Strip Blender dedup suffix (.001, .002, …)
let n = name.replace(/\.\d{3}$/, '')
// Strip OCC assembly-instance suffix — handles _AF0, _AF1, _AF0_ASM, _AF1_ASM patterns
// The optional (_ASM)? group catches assembly-node variants like _AF0_ASM
let prev = ''
while (prev !== n) { prev = n; n = n.replace(/_AF\d+(_ASM)?$/i, '') }
return n
}
// ---------------------------------------------------------------------------
// resolvePartMaterial
// ---------------------------------------------------------------------------
/**
* Resolve a material entry for a (already-normalized) GLB mesh name.
*
* OCC's GLB exporter strips certain path suffixes (_ASM_1, _1, _AF\d+_\d+)
* that cadquery keeps when parsing the STEP topology. This means stored keys
* from Excel-imported cad_part_materials may have extra suffixes compared to
* the actual GLB mesh names.
*
* Strategy:
* 1. Exact match: partMaterials[meshKey]
* 2. Prefix match: find shortest stored key that starts with meshKey + '_'
* e.g. GLB "GE360-EIN_HAELFTE" matches stored "GE360-EIN_HAELFTE_AF0_1"
*
* Returns undefined when no match exists.
*/
export function resolvePartMaterial(
meshKey: string,
partMaterials: PartMaterialMap,
): PartMaterialEntry | undefined {
// 1. Exact match
if (partMaterials[meshKey]) return partMaterials[meshKey]
// 2. Shortest stored key that starts with meshKey + '_'
let bestKey: string | undefined
for (const key of Object.keys(partMaterials)) {
if (key.startsWith(meshKey + '_')) {
if (!bestKey || key.length < bestKey.length) bestKey = key
}
}
return bestKey ? partMaterials[bestKey] : undefined
}
// ---------------------------------------------------------------------------
// convertCadPartMaterials
// ---------------------------------------------------------------------------
/**
* Convert Product.cad_part_materials (list of {part_name, material}) to
* the PartMaterialMap format used by the 3D viewers.
*
* - Skips entries with blank part_name or material
* - Detects hex colors (starting with "#") vs library material names
* - Normalizes part names with normalizeMeshName() so they match GLB mesh keys
*/
export function convertCadPartMaterials(
items: Array<{ part_name: string; material: string }>,
): PartMaterialMap {
const result: PartMaterialMap = {}
for (const item of items) {
if (!item.part_name.trim() || !item.material.trim()) continue
const key = normalizeMeshName(item.part_name.trim())
const value = item.material.trim()
result[key] = { type: value.startsWith('#') ? 'hex' : 'library', value }
}
return result
}
@@ -158,7 +158,7 @@ function DashboardGridInner() {
className="btn-secondary text-sm flex items-center gap-1.5 ml-auto"
>
<Settings2 size={14} />
Anpassen
Customize
</button>
</div>
@@ -171,7 +171,7 @@ function DashboardGridInner() {
</div>
) : (widgets ?? []).length === 0 ? (
<div className="rounded-xl border border-border-default p-8 text-center text-content-muted text-sm">
No widgets configured. Click <strong>Anpassen</strong> to add widgets.
No widgets configured. Click <strong>Customize</strong> to add widgets.
</div>
) : (
<div
+66 -133
View File
@@ -18,6 +18,20 @@ const nav = [
{ to: '/upload', icon: Upload, label: 'Upload' },
]
const privilegedNav = [
{ to: '/admin', icon: Settings, label: 'Admin' },
{ to: '/billing', icon: Receipt, label: 'Billing' },
{ to: '/media', icon: Image, label: 'Media Browser' },
{ to: '/workers', icon: Server, label: 'Workers' },
{ to: '/workflows', icon: GitBranch, label: 'Workflows' },
{ to: '/asset-libraries', icon: Library, label: 'Asset Libraries' },
]
const adminOnlyNav = [
{ to: '/notification-settings', icon: BellRing, label: 'Notification Settings' },
{ to: '/tenants', icon: Building2, label: 'Tenants' },
]
export default function Layout() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
@@ -148,141 +162,60 @@ export default function Layout() {
)
})}
{(checkIsPrivileged(user)) && (
<NavLink
to="/admin"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Settings size={18} />
Admin
</NavLink>
)}
{(checkIsPrivileged(user)) && (
<NavLink
to="/billing"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Receipt size={18} />
Billing
</NavLink>
)}
{(checkIsPrivileged(user)) && (
<NavLink
to="/media"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Image size={18} />
Media Browser
</NavLink>
)}
{(checkIsPrivileged(user)) && (
<NavLink
to="/workers"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Server size={18} />
Workers
</NavLink>
)}
{(checkIsPrivileged(user)) && (
<NavLink
to="/workflows"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<GitBranch size={18} />
Workflows
</NavLink>
)}
{(checkIsPrivileged(user)) && (
<NavLink
to="/asset-libraries"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Library size={18} />
Asset Libraries
</NavLink>
{checkIsPrivileged(user) && (
<>
<div className="pt-2 pb-1 px-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-content-muted">
Management
</span>
</div>
{privilegedNav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Icon size={18} />
{label}
</NavLink>
))}
</>
)}
{checkIsAdmin(user) && (
<NavLink
to="/notification-settings"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<BellRing size={18} />
Notification Settings
</NavLink>
)}
{checkIsAdmin(user) && (
<NavLink
to="/tenants"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Building2 size={18} />
Tenants
</NavLink>
<>
<div className="pt-2 pb-1 px-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-content-muted">
Admin Only
</span>
</div>
{adminOnlyNav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Icon size={18} />
{label}
</NavLink>
))}
</>
)}
</nav>
@@ -0,0 +1,230 @@
import { useState } from 'react'
import { X, Cpu, ChevronDown, ChevronUp, Zap } from 'lucide-react'
import type { RenderLog } from '../../api/orders'
interface Props {
open: boolean
onClose: () => void
title: string
renderLog: RenderLog | null | undefined
renderStartedAt?: string | null
renderCompletedAt?: string | null
}
function formatBytes(n?: number | null): string {
if (n == null) return '—'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
function formatDuration(s?: number | null): string {
if (s == null) return '—'
if (s < 60) return `${s.toFixed(1)}s`
const m = Math.floor(s / 60)
const rem = (s % 60).toFixed(0)
return `${m}m ${rem}s`
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">
{children}
</p>
)
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between gap-4 text-sm py-0.5">
<span className="text-content-muted shrink-0">{label}</span>
<span className="text-content font-medium text-right">{value ?? '—'}</span>
</div>
)
}
function BoolPill({ value, trueLabel = 'Yes', falseLabel = 'No' }: { value: boolean | undefined; trueLabel?: string; falseLabel?: string }) {
if (value == null) return <span className="text-content-muted"></span>
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${value ? 'bg-status-success-bg text-status-success-text' : 'bg-surface-muted text-content-muted'}`}>
{value ? trueLabel : falseLabel}
</span>
)
}
export default function RenderInfoModal({
open,
onClose,
title,
renderLog,
renderStartedAt,
renderCompletedAt,
}: Props) {
const [logExpanded, setLogExpanded] = useState(false)
if (!open) return null
const rl = renderLog
const isAnimation = rl?.type === 'turntable'
const hasTemplate = !!rl?.template
const hasTimestamps = !!(renderStartedAt || renderCompletedAt)
const hasLog = (rl?.log_lines?.length ?? 0) > 0
const hasError = !!rl?.error
const engineLabel = rl?.engine_used || rl?.engine || '—'
const device = rl?.device_used
const isGpu = device?.toLowerCase().includes('gpu')
const isCpu = device?.toLowerCase().includes('cpu')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={onClose}
>
<div
className="relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl shadow-2xl"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-light sticky top-0"
style={{ backgroundColor: 'var(--color-bg-surface)' }}>
<h2 className="font-semibold text-content">{title} Render Info</h2>
<button
onClick={onClose}
className="text-content-muted hover:text-content transition-colors p-1 rounded"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-5">
{/* Error */}
{hasError && (
<div className="rounded-md p-3 text-sm" style={{ backgroundColor: 'var(--color-status-error-bg)', color: 'var(--color-status-error-text)' }}>
<p className="font-semibold mb-1">Render Error</p>
<pre className="whitespace-pre-wrap text-xs font-mono break-all">{rl!.error}</pre>
</div>
)}
{/* Render Settings */}
{rl && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Render Settings</SectionHeader>
<div className="space-y-0.5">
{rl.renderer && <Row label="Renderer" value={rl.renderer} />}
<Row label="Engine" value={engineLabel} />
{device && (
<Row
label="Device"
value={
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${
isGpu
? 'bg-status-success-bg text-status-success-text'
: isCpu
? 'bg-status-warning-bg text-status-warning-text'
: 'bg-surface-muted text-content-muted'
}`}>
{isGpu ? <Zap size={10} /> : <Cpu size={10} />}
{device}
</span>
}
/>
)}
{rl.samples != null && <Row label="Samples" value={rl.samples} />}
{rl.compute_type && <Row label="Compute Type" value={rl.compute_type} />}
{rl.gpu_fallback != null && (
<Row label="GPU Fallback" value={<BoolPill value={rl.gpu_fallback} trueLabel="Yes (CPU used)" falseLabel="No" />} />
)}
{rl.format && <Row label="Format" value={rl.format.toUpperCase()} />}
{rl.parts_count != null && <Row label="Parts" value={rl.parts_count} />}
{rl.stl_quality && <Row label="STL Quality" value={rl.stl_quality} />}
{rl.smooth_angle != null && <Row label="Smooth Angle" value={`${rl.smooth_angle}°`} />}
</div>
</div>
)}
{/* Timing */}
{rl && (rl.total_duration_s != null || rl.stl_duration_s != null || rl.render_duration_s != null) && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Timing</SectionHeader>
<div className="space-y-0.5">
<Row label="Total" value={formatDuration(rl.total_duration_s)} />
{rl.stl_duration_s != null && <Row label="STL Conversion" value={formatDuration(rl.stl_duration_s)} />}
{rl.render_duration_s != null && <Row label="Render" value={formatDuration(rl.render_duration_s)} />}
{isAnimation && rl.ffmpeg_duration_s != null && <Row label="FFmpeg" value={formatDuration(rl.ffmpeg_duration_s)} />}
{isAnimation && rl.frame_count != null && <Row label="Frames" value={rl.frame_count} />}
{isAnimation && rl.fps != null && <Row label="FPS" value={rl.fps} />}
</div>
</div>
)}
{/* Files */}
{rl && (rl.output_size_bytes != null || rl.stl_size_bytes != null) && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Files</SectionHeader>
<div className="space-y-0.5">
{rl.output_size_bytes != null && <Row label="Output File" value={formatBytes(rl.output_size_bytes)} />}
{rl.stl_size_bytes != null && <Row label="STL Cache" value={formatBytes(rl.stl_size_bytes)} />}
</div>
</div>
)}
{/* Template */}
{hasTemplate && rl && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Template</SectionHeader>
<div className="space-y-0.5">
<Row label="Path" value={<span className="font-mono text-xs break-all">{rl.template}</span>} />
{rl.lighting_only != null && <Row label="Lighting Only" value={<BoolPill value={rl.lighting_only} />} />}
{rl.shadow_catcher != null && <Row label="Shadow Catcher" value={<BoolPill value={rl.shadow_catcher} />} />}
{rl.material_replace != null && <Row label="Material Replace" value={<BoolPill value={rl.material_replace} />} />}
</div>
</div>
)}
{/* Timestamps */}
{hasTimestamps && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Timestamps</SectionHeader>
<div className="space-y-0.5">
{renderStartedAt && <Row label="Started" value={new Date(renderStartedAt).toLocaleString()} />}
{renderCompletedAt && <Row label="Completed" value={new Date(renderCompletedAt).toLocaleString()} />}
</div>
</div>
)}
{/* Blender Log */}
{hasLog && rl && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<button
onClick={() => setLogExpanded((v) => !v)}
className="flex items-center gap-2 w-full text-left"
>
<SectionHeader>Blender Log</SectionHeader>
<span className="ml-auto text-content-muted">
{logExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
</button>
{logExpanded && (
<pre className="mt-2 text-xs font-mono whitespace-pre-wrap break-all max-h-64 overflow-y-auto text-content-secondary leading-relaxed">
{rl.log_lines!.join('\n')}
</pre>
)}
{!logExpanded && (
<p className="text-xs text-content-muted mt-1">{rl.log_lines!.length} lines click to expand</p>
)}
</div>
)}
{!rl && (
<p className="text-sm text-content-muted text-center py-4">No render metadata available.</p>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,66 @@
import { CheckCircle2 } from 'lucide-react'
interface StepIndicatorProps {
step: number // current step (1-based)
total: number
labels: string[]
}
export default function StepIndicator({ step, total, labels }: StepIndicatorProps) {
return (
<>
{/* Mobile: simple text */}
<div className="md:hidden flex items-center justify-center py-3">
<span className="text-sm font-medium text-content-secondary">
Step {step} of {total}
{labels[step - 1] ? `${labels[step - 1]}` : ''}
</span>
</div>
{/* Desktop: full step bar */}
<div className="hidden md:flex items-center w-full mb-6">
{Array.from({ length: total }, (_, i) => {
const num = i + 1
const isCompleted = num < step
const isActive = num === step
const isFuture = num > step
return (
<div key={num} className="flex items-center flex-1 last:flex-none">
{/* Step circle + label */}
<div className="flex flex-col items-center gap-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${
isCompleted
? 'bg-accent text-accent-text'
: isActive
? 'bg-accent text-accent-text ring-4 ring-accent-light'
: 'bg-surface-muted text-content-muted border border-border-default'
}`}
>
{isCompleted ? <CheckCircle2 size={16} /> : num}
</div>
<span
className={`text-xs font-medium whitespace-nowrap ${
isActive ? 'text-accent' : isFuture ? 'text-content-muted' : 'text-content-secondary'
}`}
>
{labels[i] ?? `Step ${num}`}
</span>
</div>
{/* Connector line (not after last step) */}
{num < total && (
<div
className={`flex-1 h-0.5 mx-2 mt-[-16px] transition-colors ${
isCompleted ? 'bg-accent' : 'bg-border-default'
}`}
/>
)}
</div>
)
})}
</div>
</>
)
}
+31
View File
@@ -79,6 +79,14 @@
/* Status — Info */
--color-status-info-bg: #dbeafe;
--color-status-info-text: #1e40af;
/* Extended badge colors */
--color-badge-purple-bg: rgba(124, 58, 237, 0.1);
--color-badge-purple-text: #6d28d9;
--color-badge-orange-bg: rgba(234, 88, 12, 0.1);
--color-badge-orange-text: #c2410c;
--color-badge-teal-bg: rgba(13, 148, 136, 0.1);
--color-badge-teal-text: #0f766e;
}
/* ============================================================
@@ -117,6 +125,14 @@
/* Status — Info */
--color-status-info-bg: rgba(59, 130, 246, 0.15);
--color-status-info-text: #60a5fa;
/* Extended badge colors */
--color-badge-purple-bg: rgba(124, 58, 237, 0.2);
--color-badge-purple-text: #a78bfa;
--color-badge-orange-bg: rgba(234, 88, 12, 0.2);
--color-badge-orange-text: #fb923c;
--color-badge-teal-bg: rgba(13, 148, 136, 0.2);
--color-badge-teal-text: #2dd4bf;
}
/* Dark accent-light overrides (rgba instead of solid pastel) */
@@ -230,6 +246,21 @@
background-color: var(--color-bg-muted);
color: var(--color-text-secondary);
}
.badge-purple {
@apply badge;
background-color: var(--color-badge-purple-bg);
color: var(--color-badge-purple-text);
}
.badge-orange {
@apply badge;
background-color: var(--color-badge-orange-bg);
color: var(--color-badge-orange-text);
}
.badge-teal {
@apply badge;
background-color: var(--color-badge-teal-bg);
color: var(--color-badge-teal-text);
}
/* Input base — replaces repeated inline input patterns */
.input-base {
+399 -146
View File
@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap } from 'lucide-react'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap, AlertCircle } from 'lucide-react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
@@ -10,6 +10,7 @@ import TemplateEditor from '../components/admin/TemplateEditor'
import PricingTierTable from '../components/admin/PricingTierTable'
import OutputTypeTable from '../components/admin/OutputTypeTable'
import RenderTemplateTable from '../components/admin/RenderTemplateTable'
import GlobalRenderPositionsPanel from '../components/admin/GlobalRenderPositionsPanel'
import { useAuthStore, isAdmin as checkIsAdmin } from '../store/auth'
import { listPricingTiers } from '../api/pricing'
import { listOutputTypes } from '../api/outputTypes'
@@ -29,6 +30,8 @@ export default function AdminPage() {
const isAdmin = checkIsAdmin(user)
const [showNewUser, setShowNewUser] = useState(false)
const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' })
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true })
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
const [priorityNewEntry, setPriorityNewEntry] = useState<string>('')
@@ -68,6 +71,17 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const updateUserMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: { full_name: string; role: string; is_active: boolean } }) =>
api.patch(`/admin/users/${id}`, data),
onSuccess: () => {
toast.success('User updated')
qc.invalidateQueries({ queryKey: ['admin-users'] })
setEditingUserId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
type Settings = {
thumbnail_renderer: string
blender_engine: string
@@ -167,6 +181,15 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
})
const cleanupOrphanedMut = useMutation({
mutationFn: () => api.post('/media/cleanup-orphaned'),
onSuccess: (res) => {
toast.success(`Cleanup done: ${res.data.deleted} orphaned records deleted (${res.data.checked} checked)`)
qc.invalidateQueries({ queryKey: ['media-browser'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'),
})
const reextractMetadataMut = useMutation({
mutationFn: () => api.post('/admin/settings/reextract-metadata'),
onSuccess: (res) => {
@@ -175,6 +198,14 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const cleanupOrphanedCadMut = useMutation({
mutationFn: () => api.post('/admin/settings/cleanup-orphaned-cad-files'),
onSuccess: (res) => {
toast.success(`Deleted ${res.data.deleted_records} orphaned CAD records, freed ${res.data.freed_mb} MB`)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'),
})
const recoverStuckMut = useMutation({
mutationFn: () => api.post('/admin/settings/recover-stuck-processing'),
onSuccess: (res) => {
@@ -265,19 +296,65 @@ export default function AdminPage() {
)
}
type AdminTab = 'overview' | 'users' | 'render' | 'pricing' | 'libraries' | 'config'
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
const hasUnsavedChanges =
Object.keys(blenderDraft).length > 0 ||
Object.keys(viewerDraft).length > 0 ||
Object.keys(tessellationDraft).length > 0 ||
Object.keys(smtpDraft).length > 0
const TABS: { id: AdminTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'render', label: 'Render' },
{ id: 'pricing', label: 'Pricing' },
{ id: 'libraries', label: 'Libraries' },
{ id: 'config', label: 'Config' },
]
return (
<div>
{/* Tab header */}
<div className="px-8 pt-6 pb-0 bg-surface border-b border-border-default sticky top-0 z-10">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-content">Admin</h1>
{hasUnsavedChanges && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm dark:bg-amber-950 dark:border-amber-800 dark:text-amber-300">
<AlertCircle size={14} />
Unsaved changes
</div>
)}
</div>
<div className="flex gap-1 -mb-px">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-accent text-accent'
: 'border-transparent text-content-secondary hover:text-content hover:border-border-default'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="p-8 space-y-8">
<h1 className="text-2xl font-bold text-content">Admin</h1>
{/* ------------------------------------------------------------------ */}
{/* Pricing Summary */}
{/* ------------------------------------------------------------------ */}
<PricingSummaryCard />
{activeTab === 'overview' && <PricingSummaryCard />}
{/* ------------------------------------------------------------------ */}
{/* Users (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && <div className="card">
{activeTab === 'users' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<h2 className="font-semibold text-content">Users</h2>
<button onClick={() => setShowNewUser(!showNewUser)} className="btn-primary">
@@ -329,35 +406,102 @@ export default function AdminPage() {
)}
<div className="divide-y divide-border-light">
{users?.map((user) => (
<div key={user.id} className="flex items-center px-6 py-3">
<div className="flex-1">
<p className="text-sm font-medium text-content">{user.full_name}</p>
<p className="text-xs text-content-muted">{user.email}</p>
</div>
<span className={`badge mr-4 ${checkIsAdmin(user) ? 'badge-green' : 'badge-gray'}`}>
{user.role}
</span>
<span className={`badge mr-4 ${user.is_active ? 'badge-green' : 'badge-red'}`}>
{user.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => {
setConfirmState({
open: true,
title: 'Delete User',
message: `Delete user "${user.email}"? This cannot be undone.`,
onConfirm: () => {
deleteUserMut.mutate(user.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
className="text-content-muted hover:text-red-500 transition-colors"
title="Delete user"
>
<Trash2 size={16} />
</button>
{users?.map((u) => (
<div key={u.id}>
{editingUserId === u.id ? (
<div className="px-6 py-3 bg-surface-alt space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-content-muted block mb-1">Full name</label>
<input
value={editUserDraft.full_name}
onChange={(e) => setEditUserDraft((d) => ({ ...d, full_name: e.target.value }))}
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
/>
</div>
<div>
<label className="text-xs text-content-muted block mb-1">Role</label>
<select
value={editUserDraft.role}
onChange={(e) => setEditUserDraft((d) => ({ ...d, role: e.target.value }))}
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
>
<option value="client">Client</option>
<option value="project_manager">Project Manager</option>
<option value="admin">Admin</option>
<option value="global_admin">Global Admin</option>
<option value="tenant_admin">Tenant Admin</option>
</select>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-content cursor-pointer">
<input
type="checkbox"
checked={editUserDraft.is_active}
onChange={(e) => setEditUserDraft((d) => ({ ...d, is_active: e.target.checked }))}
className="rounded"
/>
Active
</label>
<div className="flex gap-2 ml-auto">
<button
onClick={() => setEditingUserId(null)}
className="btn-secondary text-sm"
>
Cancel
</button>
<button
onClick={() => updateUserMut.mutate({ id: u.id, data: editUserDraft })}
disabled={updateUserMut.isPending}
className="btn-primary text-sm"
>
{updateUserMut.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
) : (
<div className="flex items-center px-6 py-3">
<div className="flex-1">
<p className="text-sm font-medium text-content">{u.full_name}</p>
<p className="text-xs text-content-muted">{u.email}</p>
</div>
<span className={`badge mr-4 ${checkIsAdmin(u) ? 'badge-green' : 'badge-gray'}`}>
{u.role}
</span>
<span className={`badge mr-4 ${u.is_active ? 'badge-green' : 'badge-red'}`}>
{u.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => {
setEditingUserId(u.id)
setEditUserDraft({ full_name: u.full_name, role: u.role, is_active: u.is_active })
}}
className="text-content-muted hover:text-accent transition-colors mr-3"
title="Edit user"
>
<Pencil size={16} />
</button>
<button
onClick={() => {
setConfirmState({
open: true,
title: 'Delete User',
message: `Delete user "${u.email}"? This cannot be undone.`,
onConfirm: () => {
deleteUserMut.mutate(u.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
className="text-content-muted hover:text-red-500 transition-colors"
title="Delete user"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
))}
</div>
@@ -366,7 +510,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* Blender Render Settings (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && <div className="card">
{activeTab === 'render' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings size={16} className="text-content-muted" />
@@ -788,6 +932,34 @@ export default function AdminPage() {
</button>
<p className="text-xs text-content-muted">Registers existing renders &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => cleanupOrphanedMut.mutate()}
disabled={cleanupOrphanedMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Find and delete all MediaAsset DB records whose backing file is missing on disk"
>
<Trash2 size={14} className={cleanupOrphanedMut.isPending ? 'animate-spin' : ''} />
{cleanupOrphanedMut.isPending ? 'Checking files…' : 'Clean Up Orphaned Media'}
</button>
<p className="text-xs text-content-muted">Removes DB records for renders whose files no longer exist on disk.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => {
if (window.confirm('Delete all orphaned STEP files (not linked to any product)? This cannot be undone.')) {
cleanupOrphanedCadMut.mutate()
}
}}
disabled={cleanupOrphanedCadMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Delete STEP files and thumbnails that are no longer linked to any product"
>
<Trash2 size={14} className={cleanupOrphanedCadMut.isPending ? 'animate-spin' : ''} />
{cleanupOrphanedCadMut.isPending ? 'Deleting…' : 'Clean Up Orphaned STEP Files'}
</button>
<p className="text-xs text-content-muted">Removes STEP files, thumbnails, and DB records not linked to any product.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => reextractMetadataMut.mutate()}
@@ -817,10 +989,28 @@ export default function AdminPage() {
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Global Render Positions (admin only) */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<Settings size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Global Render Positions</h2>
<p className="text-xs text-content-muted mt-0.5">
Camera rotation presets available to all products. Per-product positions override these.
</p>
</div>
</div>
<div className="p-4">
<GlobalRenderPositionsPanel />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Render Templates (admin/PM) */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<FileBox size={16} className="text-content-muted" />
<div>
@@ -836,17 +1026,17 @@ export default function AdminPage() {
<div className="border-t border-border-light p-4">
<MaterialLibraryPanel />
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Asset Libraries */}
{/* ------------------------------------------------------------------ */}
<AssetLibraryPanel />
{activeTab === 'libraries' && <AssetLibraryPanel />}
{/* ------------------------------------------------------------------ */}
{/* Output Types */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'pricing' && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<Layers size={16} className="text-content-muted" />
<div>
@@ -857,12 +1047,12 @@ export default function AdminPage() {
</div>
</div>
<OutputTypeTable />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Pricing Tiers */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'pricing' && <div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<DollarSign size={16} className="text-content-muted" />
<div>
@@ -873,12 +1063,12 @@ export default function AdminPage() {
</div>
</div>
<PricingTierTable />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* E-Mail / SMTP Settings */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
{activeTab === 'config' && isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">E-Mail Notifications (SMTP)</h2>
@@ -966,7 +1156,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* Templates */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'libraries' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">Templates</h2>
<p className="text-xs text-content-muted mt-0.5">
@@ -1017,12 +1207,12 @@ export default function AdminPage() {
)
})}
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Dashboard Widget Configuration (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
{activeTab === 'config' && isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<LayoutDashboard size={16} className="text-content-muted" />
@@ -1066,7 +1256,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* 3D Viewer & GLB Export Settings */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">3D Viewer & GLB Export</h2>
<p className="text-sm text-content-muted mt-0.5">
@@ -1205,12 +1395,12 @@ export default function AdminPage() {
)}
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Tessellation Quality */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">Tessellation Quality</h2>
<p className="text-sm text-content-muted mt-0.5">
@@ -1218,6 +1408,58 @@ export default function AdminPage() {
</p>
</div>
<div className="p-4 space-y-6">
{/* Presets */}
{(() => {
const PRESETS = [
{
label: 'Draft',
description: 'Fast export, visible faceting on large curves',
color: 'border-amber-400 text-amber-700',
values: { gltf_preview_linear_deflection: 0.2, gltf_preview_angular_deflection: 0.3, gltf_production_linear_deflection: 0.05, gltf_production_angular_deflection: 0.1 },
},
{
label: 'Standard',
description: 'Smooth curves, no fan artifacts recommended',
color: 'border-blue-400 text-blue-700',
values: { gltf_preview_linear_deflection: 0.1, gltf_preview_angular_deflection: 0.1, gltf_production_linear_deflection: 0.03, gltf_production_angular_deflection: 0.05 },
},
{
label: 'Fine',
description: 'Maximum quality, very large files, slow export',
color: 'border-emerald-400 text-emerald-700',
values: { gltf_preview_linear_deflection: 0.05, gltf_preview_angular_deflection: 0.05, gltf_production_linear_deflection: 0.01, gltf_production_angular_deflection: 0.02 },
},
]
const isActive = (preset: typeof PRESETS[0]) =>
tess.gltf_preview_linear_deflection === preset.values.gltf_preview_linear_deflection &&
tess.gltf_preview_angular_deflection === preset.values.gltf_preview_angular_deflection &&
tess.gltf_production_linear_deflection === preset.values.gltf_production_linear_deflection &&
tess.gltf_production_angular_deflection === preset.values.gltf_production_angular_deflection
return (
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">Presets</p>
<div className="flex gap-3">
{PRESETS.map(preset => (
<button
key={preset.label}
onClick={() => setTessellationDraft(preset.values)}
className={`flex-1 p-3 rounded-lg border-2 text-left transition-colors ${isActive(preset) ? preset.color + ' bg-opacity-10' : 'border-border-default text-content hover:border-blue-300'}`}
style={isActive(preset) ? { backgroundColor: 'var(--color-bg-surface-alt)' } : undefined}
>
<div className="font-semibold text-sm">{preset.label}</div>
<div className="text-xs text-content-muted mt-0.5">{preset.description}</div>
<div className="text-xs font-mono text-content-secondary mt-1 space-y-0.5">
<div>preview: {preset.values.gltf_preview_angular_deflection} rad / {preset.values.gltf_preview_linear_deflection} mm</div>
<div>prod: {preset.values.gltf_production_angular_deflection} rad / {preset.values.gltf_production_linear_deflection} mm</div>
</div>
</button>
))}
</div>
</div>
)
})()}
{/* Manual inputs */}
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Preview (Geometry GLB)</p>
@@ -1238,10 +1480,10 @@ export default function AdminPage() {
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.05"
min="0.05"
step="0.01"
min="0.01"
max="1.5"
value={tess.gltf_preview_angular_deflection ?? 0.5}
value={tess.gltf_preview_angular_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_preview_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
@@ -1268,10 +1510,10 @@ export default function AdminPage() {
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.05"
min="0.05"
step="0.005"
min="0.005"
max="1.5"
value={tess.gltf_production_angular_deflection ?? 0.2}
value={tess.gltf_production_angular_deflection ?? 0.05}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_production_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
@@ -1293,12 +1535,12 @@ export default function AdminPage() {
)}
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Material Library link */}
{/* ------------------------------------------------------------------ */}
<div className="card p-5 flex items-center justify-between">
{activeTab === 'render' && <div className="card p-5 flex items-center justify-between">
<div>
<h2 className="font-semibold text-content">Material Library</h2>
<p className="text-xs text-content-muted mt-0.5">
@@ -1308,7 +1550,106 @@ export default function AdminPage() {
<Link to="/materials" className="btn-secondary text-sm">
Open Material Library →
</Link>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* GPU Status */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && (
<div className="card">
<button
className="w-full p-5 flex items-center justify-between text-left"
onClick={() => setGpuProbeExpanded((v) => !v)}
>
<div className="flex items-center gap-3">
<Zap size={18} className="text-content-secondary" />
<div>
<h2 className="font-semibold text-content">GPU Status</h2>
<p className="text-xs text-content-muted mt-0.5">
Verify that the render-worker is using the GPU (not CPU fallback).
</p>
</div>
</div>
<div className="flex items-center gap-3">
{gpuStatusBadge()}
{gpuProbeExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</button>
{gpuProbeExpanded && (
<div className="px-5 pb-5 space-y-4 border-t border-border-default pt-4">
<div className="flex items-center gap-3">
<button
onClick={handleRunGpuCheck}
disabled={gpuProbing}
className="btn-primary flex items-center gap-2"
>
{gpuProbing ? (
<><RefreshCw size={14} className="animate-spin" /> Running probe…</>
) : (
<><Zap size={14} /> Run GPU Check</>
)}
</button>
{gpuProbeResult && (
<span className="text-xs text-content-muted">
Last checked: {new Date(gpuProbeResult.timestamp).toLocaleString()}
</span>
)}
</div>
{gpuProbeResult && (
<div className="bg-surface-alt rounded-md p-4 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Status</span>
{gpuStatusBadge()}
</div>
{gpuProbeResult.device_type && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Device type</span>
<span className="text-xs text-content">{gpuProbeResult.device_type}</span>
</div>
)}
{gpuProbeResult.devices && gpuProbeResult.devices.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Devices</span>
<div className="space-y-0.5">
{gpuProbeResult.devices.map((d: string, i: number) => (
<span key={i} className="block text-xs text-content">{d}</span>
))}
</div>
</div>
)}
{gpuProbeResult.render_time_s != null && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Render time</span>
<span className="text-xs text-content">{gpuProbeResult.render_time_s.toFixed(2)}s</span>
</div>
)}
{gpuProbeResult.error && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Error</span>
<span className="text-xs text-status-error-text font-mono">{gpuProbeResult.error}</span>
</div>
)}
</div>
)}
{!gpuProbeResult && !gpuProbing && (
<p className="text-xs text-content-muted">No probe result yet. Click "Run GPU Check" to trigger a test render.</p>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
</div>
</div>
)
}
@@ -1393,6 +1734,7 @@ function AssetLibraryPanel() {
const [newDesc, setNewDesc] = useState('')
const [newFile, setNewFile] = useState<File | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: libraries = [] } = useQuery({
queryKey: ['asset-libraries'],
@@ -1586,95 +1928,6 @@ function AssetLibraryPanel() {
</div>
)}
{/* ------------------------------------------------------------------ */}
{/* GPU Status (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
<div className="card">
<button
className="w-full p-4 flex items-center justify-between text-left"
onClick={() => setGpuProbeExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<Zap size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">GPU Status</h2>
<p className="text-xs text-content-muted mt-0.5">
Check Blender GPU availability on the render worker
</p>
</div>
</div>
<div className="flex items-center gap-3">
{gpuStatusBadge()}
{gpuProbeResult?.probed_at && (
<span className="text-xs text-content-muted">
Last checked: {Math.round((Date.now() - new Date(gpuProbeResult.probed_at).getTime()) / 60000)} min ago
</span>
)}
{gpuProbeExpanded ? <ChevronUp size={16} className="text-content-muted" /> : <ChevronDown size={16} className="text-content-muted" />}
</div>
</button>
{gpuProbeExpanded && (
<div className="px-6 pb-6 space-y-4 border-t border-border-default pt-4">
<div className="flex items-center gap-3">
<button
onClick={handleRunGpuCheck}
disabled={gpuProbing}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-accent text-accent-text font-medium hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{gpuProbing
? <RefreshCw size={14} className="animate-spin" />
: <Zap size={14} />
}
{gpuProbing ? 'Checking…' : 'Run GPU Check'}
</button>
{gpuProbing && (
<span className="text-xs text-content-muted">
Polling for result (up to 45s)
</span>
)}
</div>
{gpuProbeResult && (
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Status</span>
{gpuStatusBadge()}
</div>
{gpuProbeResult.device_type && (
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Device type</span>
<span className="text-content font-mono text-xs">{gpuProbeResult.device_type}</span>
</div>
)}
{gpuProbeResult.error && (
<div className="flex items-start gap-2">
<span className="text-content-secondary w-28 shrink-0">Error</span>
<span className="text-status-error-text text-xs">{gpuProbeResult.error}</span>
</div>
)}
{gpuProbeResult.probed_at && (
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Probed at</span>
<span className="text-content-muted text-xs">
{new Date(gpuProbeResult.probed_at).toLocaleString()}
</span>
</div>
)}
</div>
)}
{!gpuProbeResult && !gpuProbing && (
<p className="text-sm text-content-muted">
No probe result yet. Click "Run GPU Check" to trigger a check on the render worker.
</p>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
+5 -5
View File
@@ -19,10 +19,10 @@ const formatDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleDateString('de-DE') : '—'
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700',
sent: 'bg-blue-100 text-blue-700',
paid: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
draft: 'badge-gray',
sent: 'badge-blue',
paid: 'badge-green',
cancelled: 'badge-red',
}
// ── New Invoice Modal ─────────────────────────────────────────────────────
@@ -197,7 +197,7 @@ export default function BillingPage() {
<select
value={inv.status}
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })}
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent ${STATUS_COLORS[inv.status] || 'bg-gray-100 text-gray-700'}`}
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent ${STATUS_COLORS[inv.status] || 'badge-gray'}`}
>
{['draft', 'sent', 'paid', 'cancelled'].map(s => (
<option key={s} value={s}>{s}</option>
+24 -9
View File
@@ -1,15 +1,18 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useLocation } from 'react-router-dom'
import { toast } from 'sonner'
import { Eye, EyeOff } from 'lucide-react'
import api from '../api/client'
import { useAuthStore } from '../store/auth'
export default function LoginPage() {
const navigate = useNavigate()
const location = useLocation()
const setAuth = useAuthStore((s) => s.setAuth)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -17,7 +20,8 @@ export default function LoginPage() {
try {
const res = await api.post('/auth/login', { email, password })
setAuth(res.data.access_token, res.data.user)
navigate('/')
const returnTo = (location.state as any)?.from || '/'
navigate(returnTo)
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Login failed')
} finally {
@@ -50,13 +54,24 @@ export default function LoginPage() {
</div>
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="input-base w-full"
/>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="input-base w-full pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-secondary transition-colors"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<button type="submit" disabled={loading} className="btn-primary w-full justify-center">
{loading ? 'Signing in...' : 'Sign In'}
+1 -1
View File
@@ -560,7 +560,7 @@ function SourceBadge({ source }: { source: string }) {
}
if (source === 'cad_import') {
return (
<span className="inline-flex items-center gap-1 text-xs font-medium bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
<span className="badge-purple">
CAD import
</span>
)
+323 -52
View File
@@ -3,9 +3,11 @@ import { useQuery } from '@tanstack/react-query'
import {
Search, Image, Film, Box, Layers, FileCode2,
ChevronLeft, ChevronRight, Download, Loader2,
CheckSquare, Square, X, ZoomIn, Archive,
} from 'lucide-react'
import {
getMediaAssets,
zipDownloadAssets,
} from '../api/media'
import type { MediaAssetItem, MediaAssetType } from '../api/media'
@@ -22,24 +24,27 @@ const formatBytes = (bytes: number | null) => {
}
const TYPE_COLORS: Partial<Record<MediaAssetType, string>> = {
thumbnail: 'bg-gray-100 text-gray-700',
still: 'bg-blue-100 text-blue-700',
turntable: 'bg-purple-100 text-purple-700',
stl_low: 'bg-yellow-100 text-yellow-700',
stl_high: 'bg-orange-100 text-orange-700',
gltf_geometry: 'bg-green-100 text-green-700',
gltf_production: 'bg-emerald-100 text-emerald-700',
blend_production: 'bg-pink-100 text-pink-700',
thumbnail: 'badge-gray',
still: 'badge-blue',
turntable: 'badge-purple',
stl_low: 'badge-yellow',
stl_high: 'badge-orange',
gltf_geometry: 'badge-green',
gltf_production: 'badge-teal',
blend_production: 'badge-purple',
}
const ASSET_TYPES = [
{ value: '', label: 'All types' },
const ASSET_TYPES_MEDIA = [
{ value: '', label: 'All media' },
{ value: 'still', label: 'Still' },
{ value: 'turntable', label: 'Turntable' },
{ value: 'thumbnail', label: 'Thumbnail' },
]
const ASSET_TYPES_TECHNICAL = [
{ value: 'gltf_geometry', label: 'glTF Geometry' },
{ value: 'gltf_production', label: 'glTF Production' },
{ value: 'blend_production', label: 'Blend Production' },
{ value: 'blend_production', label: 'Blend (.blend)' },
{ value: 'stl_low', label: 'STL Low' },
{ value: 'stl_high', label: 'STL High' },
]
@@ -67,67 +72,201 @@ const PAGE_SIZE_OPTIONS = [25, 50, 100]
// ── TypeIcon ──────────────────────────────────────────────────────────────────
function TypeIcon({ type }: { type: MediaAssetType }) {
if (type === 'still' || type === 'thumbnail') return <Image size={32} className="text-content-muted" />
if (type === 'turntable') return <Film size={32} className="text-content-muted" />
if (type === 'stl_low' || type === 'stl_high') return <Box size={32} className="text-content-muted" />
if (type === 'gltf_geometry' || type === 'gltf_production') return <FileCode2 size={32} className="text-content-muted" />
return <Layers size={32} className="text-content-muted" />
function TypeIcon({ type, size = 32 }: { type: MediaAssetType; size?: number }) {
if (type === 'still' || type === 'thumbnail') return <Image size={size} className="text-content-muted" />
if (type === 'turntable') return <Film size={size} className="text-content-muted" />
if (type === 'stl_low' || type === 'stl_high') return <Box size={size} className="text-content-muted" />
if (type === 'gltf_geometry' || type === 'gltf_production') return <FileCode2 size={size} className="text-content-muted" />
return <Layers size={size} className="text-content-muted" />
}
// ── Lightbox ──────────────────────────────────────────────────────────────────
function Lightbox({ asset, onClose }: { asset: MediaAssetItem; onClose: () => void }) {
const isVideo = asset.asset_type === 'turntable'
// No-auth thumbnail endpoint serves image/video directly (no Bearer token needed)
const mediaSrc = `/api/media/${asset.id}/thumbnail`
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex flex-col items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.88)' }}
onClick={onClose}
>
{/* Close button */}
<button
className="absolute top-4 right-4 p-2 rounded-full text-white transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.15)' }}
onClick={onClose}
title="Close (Esc)"
>
<X size={20} />
</button>
{/* Media */}
<div
className="max-w-5xl max-h-[80vh] w-full mx-6 flex items-center justify-center"
onClick={e => e.stopPropagation()}
>
{isVideo ? (
<video
src={mediaSrc}
controls
autoPlay
loop
className="max-w-full max-h-[80vh] rounded-lg shadow-2xl"
/>
) : asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.product_name ?? asset.asset_type}
className="max-w-full max-h-[80vh] object-contain rounded-lg shadow-2xl"
/>
) : (
<div className="flex flex-col items-center gap-4 text-white opacity-60">
<TypeIcon type={asset.asset_type} size={64} />
<p className="text-sm">No preview available</p>
</div>
)}
</div>
{/* Caption */}
<div
className="absolute bottom-0 left-0 right-0 px-6 py-4 text-sm text-white"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between max-w-5xl mx-auto">
<div className="space-y-0.5">
{asset.product_name && <p className="font-medium">{asset.product_name}</p>}
<p className="text-xs opacity-70">
{asset.asset_type}
{asset.product_pim_id && ` · ${asset.product_pim_id}`}
{asset.product_baureihe && ` · ${asset.product_baureihe}`}
{formatBytes(asset.file_size_bytes) && ` · ${formatBytes(asset.file_size_bytes)}`}
</p>
</div>
{asset.download_url && (
<a
href={asset.download_url}
download
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md font-medium transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
onClick={e => e.stopPropagation()}
>
<Download size={13} />
Download
</a>
)}
</div>
</div>
</div>
)
}
// ── AssetCard ─────────────────────────────────────────────────────────────────
function AssetCard({ asset }: { asset: MediaAssetItem }) {
interface AssetCardProps {
asset: MediaAssetItem
selected: boolean
onToggleSelect: (id: string) => void
onPreview: (asset: MediaAssetItem) => void
}
function AssetCard({ asset, selected, onToggleSelect, onPreview }: AssetCardProps) {
const isImage = asset.asset_type === 'still' || asset.asset_type === 'thumbnail'
const isVideo = asset.asset_type === 'turntable'
const typeBadge = TYPE_COLORS[asset.asset_type] ?? 'bg-gray-100 text-gray-700'
// Images need a resolved thumbnail_url; videos always have the no-auth endpoint available
const isPreviewable = isVideo || (isImage && !!asset.thumbnail_url)
const typeBadge = TYPE_COLORS[asset.asset_type] ?? 'badge-gray'
const sizeStr = formatBytes(asset.file_size_bytes)
const handleDownload = () => {
if (asset.download_url) window.open(asset.download_url, '_blank')
}
return (
<div
className="rounded-lg border border-border-default overflow-hidden flex flex-col"
className={`rounded-lg border overflow-hidden flex flex-col relative group transition-all ${
selected ? 'border-accent ring-2 ring-accent ring-offset-1' : 'border-border-default'
}`}
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
{/* Select checkbox — top-left, always shown when selected, hover otherwise */}
<button
className={`absolute top-2 left-2 z-10 rounded p-0.5 transition-all ${
selected
? 'text-accent opacity-100'
: 'text-white opacity-0 group-hover:opacity-100'
}`}
style={{ backgroundColor: selected ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.45)' }}
onClick={e => { e.stopPropagation(); onToggleSelect(asset.id) }}
title={selected ? 'Deselect' : 'Select'}
>
{selected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
{/* Preview area */}
<div
className="w-full h-40 flex items-center justify-center overflow-hidden"
className="w-full h-40 flex items-center justify-center overflow-hidden relative cursor-pointer"
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
onClick={() => isPreviewable ? onPreview(asset) : onToggleSelect(asset.id)}
>
{isImage && asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="w-full h-full object-contain p-2"
/>
) : isVideo && asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="w-full h-full object-cover opacity-80"
<div className="w-full h-full p-2 flex items-center justify-center">
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="max-w-full max-h-full object-contain"
/>
</div>
) : isVideo ? (
<video
src={`/api/media/${asset.id}/thumbnail`}
preload="metadata"
muted
loop
playsInline
className="w-full h-full object-cover"
onMouseEnter={(e) => { e.currentTarget.play().catch(() => {}) }}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0 }}
/>
) : (
<TypeIcon type={asset.asset_type} />
)}
{/* Preview hover overlay */}
{isPreviewable && (
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
{isVideo
? <Film size={24} className="text-white" />
: <ZoomIn size={24} className="text-white" />
}
</div>
)}
</div>
{/* Info */}
<div className="p-3 flex-1 flex flex-col gap-1">
<div className="flex items-center justify-between gap-1">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeBadge}`}>
<span className={typeBadge}>
{asset.asset_type}
</span>
{asset.download_url && (
<button
onClick={handleDownload}
<a
href={asset.download_url}
download
className="p-1 rounded hover:bg-surface-hover text-content-muted hover:text-content transition-colors"
title="Download"
onClick={e => e.stopPropagation()}
>
<Download size={14} />
</button>
</a>
)}
</div>
{asset.product_name && (
@@ -138,6 +277,21 @@ function AssetCard({ asset }: { asset: MediaAssetItem }) {
{asset.product_pim_id && (
<p className="text-xs text-content-muted font-mono truncate">{asset.product_pim_id}</p>
)}
{(asset.product_baureihe || asset.product_lagertyp) && (
<p className="text-xs text-content-muted truncate" title={[asset.product_baureihe, asset.product_lagertyp].filter(Boolean).join(' · ')}>
{[asset.product_baureihe, asset.product_lagertyp].filter(Boolean).join(' · ')}
</p>
)}
{(asset.product_ebene1 || asset.product_ebene2) && (
<p className="text-xs truncate" style={{ color: 'var(--color-content-subtle, #9ca3af)' }} title={[asset.product_ebene1, asset.product_ebene2].filter(Boolean).join(' ')}>
{[asset.product_ebene1, asset.product_ebene2].filter(Boolean).join(' ')}
</p>
)}
{(asset.product_name_cad_modell || asset.product_produkt_baureihe) && (
<p className="text-xs text-content-muted font-mono truncate" title={asset.product_name_cad_modell ?? asset.product_produkt_baureihe ?? ''}>
{asset.product_name_cad_modell ?? asset.product_produkt_baureihe}
</p>
)}
<div className="flex items-center gap-2 mt-auto pt-1 text-xs text-content-muted">
<span>{formatDate(asset.created_at)}</span>
{sizeStr && <span>· {sizeStr}</span>}
@@ -167,21 +321,38 @@ export default function MediaBrowserPage() {
const [assetType, setAssetType] = useState('')
const [categoryKey, setCategoryKey] = useState('')
const [renderStatus, setRenderStatus] = useState('')
const [showTechnical, setShowTechnical] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(50)
// Selection
const [selected, setSelected] = useState<Set<string>>(new Set())
const [zipping, setZipping] = useState(false)
// Lightbox
const [previewAsset, setPreviewAsset] = useState<MediaAssetItem | null>(null)
const q = useDebounce(searchInput, 300)
// Reset to page 1 when any filter changes
useEffect(() => { setPage(1) }, [q, assetType, categoryKey, renderStatus, pageSize])
// Reset to page 1 when any filter changes; clear selection on page/filter change
useEffect(() => { setPage(1); setSelected(new Set()) }, [q, assetType, categoryKey, renderStatus, showTechnical, pageSize])
useEffect(() => { setSelected(new Set()) }, [page])
// When switching off technical view, clear any technical type selection
useEffect(() => {
if (!showTechnical && ASSET_TYPES_TECHNICAL.some(t => t.value === assetType)) {
setAssetType('')
}
}, [showTechnical, assetType])
const { data, isLoading, isFetching } = useQuery({
queryKey: ['media-browser', { q, assetType, categoryKey, renderStatus, page, pageSize }],
queryKey: ['media-browser', { q, assetType, categoryKey, renderStatus, showTechnical, page, pageSize }],
queryFn: () => getMediaAssets({
q: q || undefined,
asset_type: assetType || undefined,
category_key: categoryKey || undefined,
render_status: renderStatus || undefined,
exclude_technical: !showTechnical && !assetType ? true : undefined,
page,
page_size: pageSize,
}),
@@ -192,8 +363,41 @@ export default function MediaBrowserPage() {
const total = data?.total ?? 0
const pages = data?.pages ?? 1
const allSelected = items.length > 0 && items.every(i => selected.has(i.id))
function toggleSelect(id: string) {
setSelected(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
function toggleSelectAll() {
if (allSelected) {
setSelected(new Set())
} else {
setSelected(new Set(items.map(i => i.id)))
}
}
async function handleZipDownload() {
if (selected.size === 0) return
setZipping(true)
try {
await zipDownloadAssets(Array.from(selected))
} finally {
setZipping(false)
}
}
return (
<div className="flex flex-col h-full">
{/* Lightbox */}
{previewAsset && (
<Lightbox asset={previewAsset} onClose={() => setPreviewAsset(null)} />
)}
{/* Sticky filter bar */}
<div
className="sticky top-0 z-20 px-6 py-4 border-b border-border-default"
@@ -213,7 +417,7 @@ export default function MediaBrowserPage() {
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
<input
type="text"
placeholder="Search product name or PIM-ID..."
placeholder="Search name, PIM-ID, Baureihe, Ebene…"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-8 pr-3 py-1.5 text-sm border border-border-default rounded-md focus:outline-none focus:ring-1 focus:ring-accent w-64"
@@ -228,9 +432,26 @@ export default function MediaBrowserPage() {
className="text-sm border border-border-default rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-accent"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
{ASSET_TYPES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
{ASSET_TYPES_MEDIA.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
{showTechnical && (
<>
<option disabled></option>
{ASSET_TYPES_TECHNICAL.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</>
)}
</select>
{/* Technical files toggle */}
<label className="flex items-center gap-1.5 text-sm text-content-secondary cursor-pointer select-none">
<input
type="checkbox"
checked={showTechnical}
onChange={e => setShowTechnical(e.target.checked)}
className="rounded"
/>
Technical files
</label>
{/* Category */}
<select
value={categoryKey}
@@ -252,12 +473,26 @@ export default function MediaBrowserPage() {
</select>
</div>
{/* Results count + loading indicator */}
<div className="flex items-center gap-2 mt-2 text-xs text-content-muted">
{isFetching && <Loader2 size={12} className="animate-spin" />}
<span>
{total === 0 ? 'No assets' : `${total.toLocaleString()} asset${total !== 1 ? 's' : ''}`}
</span>
{/* Results count + select-all */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3 text-xs text-content-muted">
{isFetching && <Loader2 size={12} className="animate-spin" />}
<span>
{total === 0 ? 'No assets' : `${total.toLocaleString()} asset${total !== 1 ? 's' : ''}`}
</span>
{items.length > 0 && (
<button
onClick={toggleSelectAll}
className="flex items-center gap-1 hover:text-content transition-colors"
>
{allSelected ? <CheckSquare size={13} /> : <Square size={13} />}
{allSelected ? 'Deselect all' : 'Select all on page'}
</button>
)}
</div>
{selected.size > 0 && (
<span className="text-xs text-accent font-medium">{selected.size} selected</span>
)}
</div>
</div>
@@ -279,14 +514,50 @@ export default function MediaBrowserPage() {
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{items.map(asset => (
<AssetCard key={asset.id} asset={asset} />
<AssetCard
key={asset.id}
asset={asset}
selected={selected.has(asset.id)}
onToggleSelect={toggleSelect}
onPreview={setPreviewAsset}
/>
))}
</div>
)}
</div>
{/* Floating selection action bar */}
{selected.size > 0 && (
<div
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 px-5 py-3 rounded-xl shadow-2xl border border-border-default"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<span className="text-sm font-medium text-content">
{selected.size} file{selected.size !== 1 ? 's' : ''} selected
</span>
<div className="w-px h-5 bg-border-default" />
<button
onClick={handleZipDownload}
disabled={zipping}
className="flex items-center gap-1.5 text-sm font-medium text-accent hover:text-accent-hover transition-colors disabled:opacity-50"
>
{zipping
? <><Loader2 size={14} className="animate-spin" /> Preparing</>
: <><Archive size={14} /> Download ZIP</>
}
</button>
<button
onClick={() => setSelected(new Set())}
className="flex items-center gap-1 text-sm text-content-muted hover:text-content transition-colors"
>
<X size={14} />
Clear
</button>
</div>
)}
{/* Pagination footer */}
{(total > 0) && (
{total > 0 && (
<div
className="border-t border-border-default px-6 py-3 flex items-center justify-between gap-4"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
+182 -30
View File
@@ -10,7 +10,9 @@ import { listProducts } from '../api/products'
import { listOutputTypes } from '../api/outputTypes'
import { createOrder } from '../api/orders'
import { estimatePrice } from '../api/pricing'
import { listGlobalRenderPositions } from '../api/renderPositions'
import type { Product, RenderPosition } from '../api/products'
import type { GlobalRenderPosition } from '../api/renderPositions'
import type { OutputType } from '../api/outputTypes'
const formatCurrency = (amount: number) =>
@@ -32,6 +34,8 @@ type WizardStep = 1 | 2 | 3
type OutputSelections = Record<string, Set<string>>
// Maps product_id → Set of position_id
type PositionSelections = Record<string, Set<string>>
// Maps product_id → Set of global_render_position_id
type GlobalPositionSelections = Record<string, Set<string>>
export default function NewProductOrderPage() {
const navigate = useNavigate()
@@ -41,6 +45,7 @@ export default function NewProductOrderPage() {
const [selectedProducts, setSelectedProducts] = useState<Map<string, Product>>(new Map())
const [outputSelections, setOutputSelections] = useState<OutputSelections>({})
const [positionSelections, setPositionSelections] = useState<PositionSelections>({})
const [globalPositionSelections, setGlobalPositionSelections] = useState<GlobalPositionSelections>({})
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -62,14 +67,26 @@ export default function NewProductOrderPage() {
enabled: step >= 2,
})
function initPositionsForProduct(product: Product) {
const { data: allGlobalPositions = [] } = useQuery({
queryKey: ['global-render-positions'],
queryFn: listGlobalRenderPositions,
})
function initPositionsForProduct(product: Product, globals: GlobalRenderPosition[] = []) {
// Pre-select all per-product positions (if any)
if ((product.render_positions?.length ?? 0) > 0) {
// Default: all positions selected
setPositionSelections((ps) => ({
...ps,
[product.id]: new Set(product.render_positions!.map((p) => p.id)),
}))
}
// Always pre-select all global positions for every product
if (globals.length > 0) {
setGlobalPositionSelections((gs) => ({
...gs,
[product.id]: new Set(globals.map((g) => g.id)),
}))
}
}
function toggleProduct(product: Product) {
@@ -84,7 +101,7 @@ export default function NewProductOrderPage() {
return next
})
if (willSelect) {
initPositionsForProduct(product)
initPositionsForProduct(product, allGlobalPositions)
}
}
@@ -98,7 +115,7 @@ export default function NewProductOrderPage() {
;(products ?? []).forEach((p) => next.set(p.id, p))
return next
})
toInit.forEach(initPositionsForProduct)
toInit.forEach((p) => initPositionsForProduct(p, allGlobalPositions))
}
function deselectAllFiltered() {
@@ -180,7 +197,7 @@ export default function NewProductOrderPage() {
})
}
// Union of all unique position names across selected products that have positions
// Union of all unique per-product position names across selected products that have per-product positions
const globalPositionNames = useMemo(() => {
const seen = new Set<string>()
const result: string[] = []
@@ -195,6 +212,30 @@ export default function NewProductOrderPage() {
return result
}, [selectedProducts])
// Global positions apply to all selected products
const anyProductUsesGlobalPositions = selectedProducts.size > 0
function toggleGlobalPositionForAll(gpId: string) {
// Count how many selected products have this global position selected
const eligibleCount = selectedProducts.size
let selectedCount = 0
for (const [productId] of selectedProducts) {
if (globalPositionSelections[productId]?.has(gpId)) selectedCount++
}
if (eligibleCount === 0) return
const shouldSelect = selectedCount < eligibleCount
setGlobalPositionSelections((prev) => {
const next = { ...prev }
for (const [productId] of selectedProducts) {
const set = new Set(prev[productId] || [])
if (shouldSelect) set.add(gpId)
else set.delete(gpId)
next[productId] = set
}
return next
})
}
function togglePositionGlobal(positionName: string) {
// Count how many products have this position name and how many have it selected
let compatibleCount = 0
@@ -221,47 +262,61 @@ export default function NewProductOrderPage() {
})
}
// Build flat list of order lines for review (Step 3)
// Each (product, outputType, position?) triple becomes one line.
// Build flat list of order lines for review (Step 3).
// Each (product, outputType, position) triple becomes one line.
// Global positions apply to ALL products; per-product positions are additional.
const orderLines = useMemo(() => {
const lines: Array<{
key: string
product: Product
outputType: OutputType
position: RenderPosition | null
globalPosition: GlobalRenderPosition | null
}> = []
for (const [productId, product] of selectedProducts) {
const selectedOts = outputSelections[productId]
if (!selectedOts) continue
const hasPositions = (product.render_positions?.length ?? 0) > 0
for (const otId of selectedOts) {
const ot = allOutputTypes?.find((o) => o.id === otId)
if (!ot) continue
if (hasPositions) {
const selectedPosIds = positionSelections[productId] || new Set()
if (selectedPosIds.size === 0) {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
} else {
for (const posId of selectedPosIds) {
const pos = product.render_positions!.find((p) => p.id === posId)
if (pos) lines.push({ key: `${productId}-${otId}-${posId}`, product, outputType: ot, position: pos })
}
}
const selectedPosIds = positionSelections[productId] || new Set()
const selectedGlobalIds = globalPositionSelections[productId] || new Set()
const hasAny = selectedPosIds.size > 0 || selectedGlobalIds.size > 0
if (!hasAny) {
// No position selected — one unpositioned line
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null, globalPosition: null })
} else {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
// One line per selected global position
for (const gpId of selectedGlobalIds) {
const gp = allGlobalPositions.find((g) => g.id === gpId)
if (gp) lines.push({ key: `${productId}-${otId}-g${gpId}`, product, outputType: ot, position: null, globalPosition: gp })
}
// One line per selected per-product position
for (const posId of selectedPosIds) {
const pos = product.render_positions?.find((p) => p.id === posId)
if (pos) lines.push({ key: `${productId}-${otId}-${posId}`, product, outputType: ot, position: pos, globalPosition: null })
}
}
}
}
return lines
}, [selectedProducts, outputSelections, positionSelections, allOutputTypes])
}, [selectedProducts, outputSelections, positionSelections, globalPositionSelections, allOutputTypes, allGlobalPositions])
function removeLine(productId: string, outputTypeId: string, positionId: string | null) {
function removeLine(productId: string, outputTypeId: string, positionId: string | null, globalPositionId: string | null) {
if (positionId) {
setPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(positionId)
return { ...prev, [productId]: set }
})
} else if (globalPositionId) {
setGlobalPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(globalPositionId)
return { ...prev, [productId]: set }
})
} else {
setOutputSelections((prev) => {
const set = new Set(prev[productId] || [])
@@ -322,6 +377,7 @@ export default function NewProductOrderPage() {
product_id: l.product.id,
output_type_id: l.outputType.id,
render_position_id: l.position?.id ?? null,
global_render_position_id: l.globalPosition?.id ?? null,
})),
})
toast.success(`Draft order ${result.order_number} created — review and submit`)
@@ -502,7 +558,7 @@ export default function NewProductOrderPage() {
</p>
{/* Global toggles — apply to all products at once */}
{(globalOutputTypes.length > 0 || globalPositionNames.length > 0) && (
{(globalOutputTypes.length > 0 || globalPositionNames.length > 0 || (anyProductUsesGlobalPositions && allGlobalPositions.length > 0)) && (
<div className="card p-4 mb-4 space-y-3">
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">
Apply to all products
@@ -555,10 +611,10 @@ export default function NewProductOrderPage() {
</div>
)}
{/* Perspectives row */}
{/* Perspectives row — per-product positions (for products that have them) */}
{globalPositionNames.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives</p>
<p className="text-xs text-content-muted mb-1.5">Perspectives (custom)</p>
<div className="flex flex-wrap gap-2">
{globalPositionNames.map((posName) => {
let compatibleCount = 0
@@ -597,6 +653,47 @@ export default function NewProductOrderPage() {
</div>
</div>
)}
{/* Perspectives row — global positions (for products without custom positions) */}
{anyProductUsesGlobalPositions && allGlobalPositions.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives (global)</p>
<div className="flex flex-wrap gap-2">
{allGlobalPositions.map((gp) => {
const eligibleCount = selectedProducts.size
let selectedCount = 0
for (const [productId] of selectedProducts) {
if (globalPositionSelections[productId]?.has(gp.id)) selectedCount++
}
const allSel = selectedCount === eligibleCount && eligibleCount > 0
const someSel = selectedCount > 0 && !allSel
return (
<button
key={gp.id}
onClick={() => toggleGlobalPositionForAll(gp.id)}
title={`${selectedCount} / ${eligibleCount} product${eligibleCount !== 1 ? 's' : ''} selected`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
allSel
? 'bg-purple-600 text-white border-purple-600'
: someSel
? 'bg-purple-100 text-purple-700 border-purple-400'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{allSel && <Check size={12} />}
{gp.name}
{gp.is_default && !allSel && <span className="text-xs opacity-60"></span>}
{selectedProducts.size > 1 && eligibleCount > 0 && (
<span className={`text-xs ${allSel ? 'text-white/70' : someSel ? 'text-purple-500' : 'text-content-muted'}`}>
{selectedCount}/{eligibleCount}
</span>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)}
@@ -610,6 +707,13 @@ export default function NewProductOrderPage() {
onToggle={(otId) => toggleOutputType(product.id, otId)}
selectedPositions={positionSelections[product.id] || new Set()}
onTogglePosition={(posId) => togglePosition(product.id, posId)}
globalPositions={allGlobalPositions}
selectedGlobalPositions={globalPositionSelections[product.id] || new Set()}
onToggleGlobalPosition={(gpId) => setGlobalPositionSelections((prev) => {
const set = new Set(prev[product.id] || [])
if (set.has(gpId)) set.delete(gpId); else set.add(gpId)
return { ...prev, [product.id]: set }
})}
/>
))}
</div>
@@ -685,9 +789,9 @@ export default function NewProductOrderPage() {
<td className="px-4 py-3 text-content-secondary">{line.outputType.name}</td>
<td className="px-4 py-3">
{line.position ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium">
{line.position.name}
</span>
<span className="badge-purple">{line.position.name}</span>
) : line.globalPosition ? (
<span className="badge-purple opacity-70" title="Global position">{line.globalPosition.name}</span>
) : (
<span className="text-content-muted text-xs"></span>
)}
@@ -706,7 +810,7 @@ export default function NewProductOrderPage() {
</td>
<td className="px-4 py-3">
<button
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.id ?? null)}
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.id ?? null, line.globalPosition?.id ?? null)}
className="text-content-muted hover:text-red-500 transition-colors"
title="Remove this render job from the order"
>
@@ -771,6 +875,9 @@ function ProductOutputRow({
onToggle,
selectedPositions,
onTogglePosition,
globalPositions,
selectedGlobalPositions,
onToggleGlobalPosition,
}: {
product: Product
compatibleTypes: OutputType[]
@@ -778,6 +885,9 @@ function ProductOutputRow({
onToggle: (otId: string) => void
selectedPositions: Set<string>
onTogglePosition: (posId: string) => void
globalPositions: GlobalRenderPosition[]
selectedGlobalPositions: Set<string>
onToggleGlobalPosition: (gpId: string) => void
}) {
const [expanded, setExpanded] = useState(true)
@@ -852,11 +962,11 @@ function ProductOutputRow({
</div>
)}
{/* Render position toggles — only shown if product has positions */}
{/* Per-product custom positions */}
{(product.render_positions?.length ?? 0) > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Render Positions</p>
<p className="text-xs font-medium text-content-muted">Custom Positions</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => product.render_positions!.forEach((p) => !selectedPositions.has(p.id) && onTogglePosition(p.id))}
@@ -895,6 +1005,48 @@ function ProductOutputRow({
</div>
</div>
)}
{/* Global position toggles — always shown for all products */}
{globalPositions.length > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Perspectives</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => globalPositions.forEach((g) => !selectedGlobalPositions.has(g.id) && onToggleGlobalPosition(g.id))}
>
All
</button>
<span className="text-content-muted text-xs">·</span>
<button
className="text-xs text-content-muted hover:underline"
onClick={() => globalPositions.forEach((g) => selectedGlobalPositions.has(g.id) && onToggleGlobalPosition(g.id))}
>
None
</button>
</div>
<div className="flex flex-wrap gap-2">
{globalPositions.map((gp) => {
const active = selectedGlobalPositions.has(gp.id)
return (
<button
key={gp.id}
onClick={() => onToggleGlobalPosition(gp.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
active
? 'bg-purple-600 text-white border-purple-600'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{active && <Check size={12} />}
{gp.name}
{gp.is_default && <span className="text-xs opacity-60 ml-0.5"></span>}
</button>
)
})}
</div>
</div>
)}
</div>
)}
</div>
+20
View File
@@ -0,0 +1,20 @@
import { Link } from 'react-router-dom'
import { Home, FileQuestion } from 'lucide-react'
export default function NotFoundPage() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
<div className="w-20 h-20 rounded-full bg-surface-muted flex items-center justify-center mb-6">
<FileQuestion size={36} className="text-content-muted" />
</div>
<h1 className="text-3xl font-bold text-content mb-2">Page not found</h1>
<p className="text-content-secondary mb-8 max-w-sm">
The page you're looking for doesn't exist or has been moved.
</p>
<Link to="/" className="btn-primary">
<Home size={16} />
Go to Dashboard
</Link>
</div>
)
}
+48 -8
View File
@@ -6,9 +6,9 @@ import {
FileBox, AlertTriangle, CheckCircle2, Image as ImageIcon, Unlink,
RotateCcw, LayoutList, LayoutGrid, X,
ChevronDown, ChevronUp, ChevronsUpDown,
Search, SlidersHorizontal, FileSpreadsheet, Box,
Search, SlidersHorizontal, FileSpreadsheet, Box, Film,
Loader2, Play, RefreshCw, ExternalLink, Ban, StopCircle, Scissors, Plus, Wand2, Download,
XCircle, RotateCw,
XCircle, RotateCw, Info,
} from 'lucide-react'
import { toast } from 'sonner'
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder } from '../api/orders'
@@ -19,6 +19,7 @@ import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivilege
import StepDropzone from '../components/upload/StepDropzone'
import CadPartMaterials from '../components/orders/CadPartMaterials'
import LiveRenderLog from '../components/LiveRenderLog'
import RenderInfoModal from '../components/renders/RenderInfoModal'
// ── Filter / sort types ───────────────────────────────────────────────────────
@@ -825,6 +826,8 @@ function OrderLineRow({
onRemoved: () => void
}) {
const qc = useQueryClient()
const [showInfo, setShowInfo] = useState(false)
const removeMut = useMutation({
mutationFn: () => removeOrderLine(orderId, line.id),
onSuccess: onRemoved,
@@ -855,11 +858,28 @@ function OrderLineRow({
{/* Thumbnail */}
<td className="px-4 py-2">
{line.thumbnail_url ? (
<img
src={line.thumbnail_url}
alt={line.product.name || ''}
className="w-10 h-10 object-contain rounded border bg-surface"
/>
(() => {
const isVideo = /\.(mp4|webm|mov)$/i.test(line.thumbnail_url)
return isVideo ? (
<video
src={line.thumbnail_url}
preload="metadata"
muted
loop
playsInline
className="w-10 h-10 object-cover rounded border bg-surface-alt"
title="Hover to preview"
onMouseEnter={(e) => { e.currentTarget.play().catch(() => {}) }}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0 }}
/>
) : (
<img
src={line.thumbnail_url}
alt={line.product.name || ''}
className="w-10 h-10 object-contain rounded border bg-surface"
/>
)
})()
) : (line.render_status === 'processing' || line.render_status === 'pending') ? (
<div className="w-10 h-10 rounded border border-dashed border-border-default bg-surface-alt flex items-center justify-center animate-pulse">
<Loader2 size={16} className="text-accent animate-spin" />
@@ -894,7 +914,7 @@ function OrderLineRow({
<span className="text-xs text-content-muted italic">tracking only</span>
)}
{line.render_position_name && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium w-fit">
<span className="badge-purple w-fit">
{line.render_position_name}
</span>
)}
@@ -957,6 +977,18 @@ function OrderLineRow({
{cancelMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <Ban size={12} />}
</button>
)}
{line.render_log && (
<button
onClick={(e) => {
e.stopPropagation()
setShowInfo(true)
}}
className="text-content-muted hover:text-accent transition-colors"
title="Render info"
>
<Info size={12} />
</button>
)}
</div>
<LiveRenderLog
orderLineId={line.id}
@@ -987,6 +1019,14 @@ function OrderLineRow({
</button>
</td>
)}
<RenderInfoModal
open={showInfo}
onClose={() => setShowInfo(false)}
title={line.output_type?.name ?? 'Render Info'}
renderLog={line.render_log}
renderStartedAt={line.render_started_at}
renderCompletedAt={line.render_completed_at}
/>
</tr>
)
}
+83 -7
View File
@@ -42,7 +42,7 @@ export default function OrdersPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [view, setView] = useState<'kanban' | 'list'>('kanban')
const [view, setView] = useState<'kanban' | 'list'>(() => window.innerWidth < 768 ? 'list' : 'kanban')
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [selectedStatuses, setSelectedStatuses] = useState<Set<Status>>(new Set())
@@ -52,6 +52,13 @@ export default function OrdersPage() {
const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
// Auto-switch to list view on narrow screens
useEffect(() => {
const handler = () => { if (window.innerWidth < 768) setView('list') }
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
}, [])
// Debounce the search input (400 ms)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
@@ -163,10 +170,15 @@ export default function OrdersPage() {
const handleDeleteSelected = () => {
const ids = [...selected]
if (!ids.length) return
const ordersMap = Object.fromEntries(orders.map((o) => [o.id, o]))
const submittedCount = ids.filter((id) => ordersMap[id]?.status === 'submitted').length
const message = submittedCount > 0
? `⚠️ ${submittedCount} of ${ids.length} selected order${ids.length > 1 ? 's' : ''} ${submittedCount === 1 ? 'has' : 'have'} been submitted and may be processing. Delete anyway?`
: `Delete ${ids.length} order${ids.length > 1 ? 's' : ''}? This cannot be undone.`
setConfirmState({
open: true,
title: `Delete ${ids.length} order${ids.length > 1 ? 's' : ''}`,
message: 'This cannot be undone.',
message,
onConfirm: () => {
deleteMut.mutate(ids)
setConfirmState((s) => ({ ...s, open: false }))
@@ -323,10 +335,12 @@ export default function OrdersPage() {
</div>
{/* ── Content ──────────────────────────────────────────────────────── */}
{isLoading ? (
{isLoading && !isSearchMode && orders.length === 0 ? (
view === 'kanban' ? <KanbanSkeleton /> : <ListSkeleton />
) : isLoading && isSearchMode ? (
<div className="flex-1 flex items-center justify-center text-content-muted">
<Loader2 size={24} className="animate-spin mr-2" />
{isSearchMode ? 'Searching…' : 'Loading orders…'}
Searching
</div>
) : isSearchMode ? (
<SearchResultsView
@@ -370,9 +384,10 @@ export default function OrdersPage() {
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
{selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] z-50
flex items-center gap-3 px-5 py-3
bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10">
<div
className="fixed bottom-6 z-50 flex items-center gap-3 px-5 py-3 bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10"
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
>
<span className="text-sm font-medium">
{selected.size} order{selected.size > 1 ? 's' : ''} selected
</span>
@@ -399,6 +414,67 @@ export default function OrdersPage() {
)
}
// ── Skeleton loaders ──────────────────────────────────────────────────────────
function ListSkeleton() {
return (
<div className="flex-1 overflow-y-auto">
<div className="mx-6 my-4 card overflow-hidden animate-pulse">
<div className="grid grid-cols-[2rem_1fr_6rem_5rem_6rem] bg-surface-alt border-b border-border-default px-4 py-2.5">
<div className="h-3 w-3 bg-surface-muted rounded" />
<div className="h-3 w-24 bg-surface-muted rounded" />
<div className="h-3 w-12 bg-surface-muted rounded" />
<div className="h-3 w-14 bg-surface-muted rounded" />
<div className="h-3 w-16 bg-surface-muted rounded" />
</div>
<div className="divide-y divide-border-light">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="grid grid-cols-[2rem_1fr_6rem_5rem_6rem] items-center px-4 py-3 gap-x-4">
<div className="h-3.5 w-3.5 bg-surface-muted rounded" />
<div className="space-y-1.5">
<div className="h-3.5 w-32 bg-surface-muted rounded" />
<div className="h-2.5 w-48 bg-surface-muted rounded opacity-60" />
</div>
<div className="h-3 w-12 bg-surface-muted rounded" />
<div className="h-5 w-16 bg-surface-muted rounded-full" />
<div className="h-3 w-14 bg-surface-muted rounded" />
</div>
))}
</div>
</div>
</div>
)
}
function KanbanSkeleton() {
return (
<div className="flex-1 overflow-x-auto min-h-0">
<div className="flex gap-4 p-6 h-full">
{['bg-gray-400', 'bg-blue-400', 'bg-amber-400'].map((color, ci) => (
<div key={ci} className="flex flex-col w-72 min-w-[272px] animate-pulse">
<div className={`${color} rounded-t-xl px-4 py-3 flex items-center gap-2`}>
<div className="h-4 w-4 bg-white/40 rounded" />
<div className="h-3.5 w-20 bg-white/40 rounded" />
<div className="ml-auto h-5 w-6 bg-white/30 rounded-full" />
</div>
<div className="flex-1 bg-surface-muted rounded-b-xl p-2 space-y-2 min-h-[120px]">
{Array.from({ length: 2 }, (_, i) => (
<div key={i} className="bg-surface rounded-lg p-3 border border-border-default border-l-4 border-l-border-default">
<div className="h-3.5 w-24 bg-surface-muted rounded mb-2" />
<div className="flex gap-3">
<div className="h-3 w-16 bg-surface-muted rounded" />
<div className="h-3 w-20 bg-surface-muted rounded" />
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}
// ── Search results view ───────────────────────────────────────────────────────
function SearchResultsView({
+95 -35
View File
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useDropzone } from 'react-dropzone'
import {
ArrowLeft, Pencil, Save, X, Box, Image,
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler, Loader2,
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler, Loader2, Info, Play,
} from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -15,12 +15,15 @@ import {
} from '../api/products'
import type { Product, CadPartMaterial, ProductRender, RenderPosition } from '../api/products'
import { listMaterials } from '../api/materials'
import { listGlobalRenderPositions } from '../api/renderPositions'
import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../store/auth'
import { generateGltfGeometry, generateGltfProduction, resetStuckProcessing } from '../api/cad'
import { listMediaAssets as getMediaAssets } from '../api/media'
import InlineCadViewer from '../components/cad/InlineCadViewer'
import { convertCadPartMaterials } from '../components/cad/cadUtils'
import RenderInfoModal from '../components/renders/RenderInfoModal'
function GlbDownloadButton({
label, url, filename, onGenerate, isGenerating, title,
@@ -57,6 +60,16 @@ function GlbDownloadButton({
}
}
// Always show generating state first — hides stale download button while task runs
if (isGenerating) {
return (
<div className="flex items-center gap-1.5 text-xs text-content-secondary px-2 py-1.5 rounded border border-border-light" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<Loader2 size={12} className="animate-spin shrink-0 text-accent" />
<span>Generating {label}</span>
</div>
)
}
if (url) {
return (
<div className="flex gap-1 w-full">
@@ -73,12 +86,9 @@ function GlbDownloadButton({
<button
className="btn-secondary text-xs px-2 shrink-0"
onClick={onGenerate}
disabled={isGenerating}
title={`Re-generate ${label}`}
>
{isGenerating
? <Loader2 size={12} className="animate-spin" />
: <RotateCcw size={12} />}
<RotateCcw size={12} />
</button>
</div>
)
@@ -88,12 +98,9 @@ function GlbDownloadButton({
<button
className="btn-secondary text-xs w-full justify-start"
onClick={onGenerate}
disabled={isGenerating}
title={title}
>
{isGenerating
? <><Loader2 size={12} className="animate-spin" />Queuing</>
: <><Download size={12} />Generate {label}</>}
<Download size={12} />Generate {label}
</button>
)
}
@@ -139,6 +146,7 @@ export default function ProductDetailPage() {
const [materialsDirty, setMaterialsDirty] = useState(false)
const [wizardOpen, setWizardOpen] = useState(false)
const [wizardTargetIdx, setWizardTargetIdx] = useState<number | null>(null)
const [showCadInfo, setShowCadInfo] = useState(false)
const { data: product, isLoading } = useQuery({
queryKey: ['product', id],
@@ -178,13 +186,23 @@ export default function ProductDetailPage() {
staleTime: 0,
})
const [productionGlbGenerating, setProductionGlbGenerating] = useState(false)
const { data: productionGlbAssets = [] } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_production'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId!, asset_types: ['gltf_production'] }),
enabled: !!cadFileId,
staleTime: 0,
refetchInterval: productionGlbGenerating ? 3000 : false,
})
// Stop polling once the freshly-generated asset has arrived
useEffect(() => {
if (productionGlbGenerating && productionGlbAssets.length > 0) {
setProductionGlbGenerating(false)
}
}, [productionGlbAssets, productionGlbGenerating])
const geometryGlbUrl = geometryGlbAssets[0]?.download_url ?? null
const productionGlbUrl = productionGlbAssets[0]?.download_url ?? null
@@ -343,7 +361,9 @@ export default function ProductDetailPage() {
mutationFn: () => generateGltfProduction(product!.cad_file_id!),
onSuccess: () => {
toast.info('Production GLB export queued')
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'gltf_production'] })
setProductionGlbGenerating(true)
// Remove stale asset immediately so the button doesn't show an outdated download
qc.removeQueries({ queryKey: ['media-assets', cadFileId, 'gltf_production'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue production GLB export'),
})
@@ -415,11 +435,10 @@ export default function ProductDetailPage() {
const [editPositionDraft, setEditPositionDraft] = useState<Partial<RenderPosition>>({})
const POSITION_PRESETS = [
{ label: 'Beauty', rx: 0, ry: 0, rz: 0 },
{ label: '3/4 Front', rx: -15, ry: 45, rz: 0 },
{ label: '3/4 Back', rx: -15, ry: -135, rz: 0 },
]
const { data: globalPositions = [] } = useQuery({
queryKey: ['global-render-positions'],
queryFn: listGlobalRenderPositions,
})
const onDrop = useCallback(
(files: File[]) => { if (files[0]) cadUploadMut.mutate(files[0]) },
@@ -647,11 +666,22 @@ export default function ProductDetailPage() {
{/* Two-column: viewer left, actions right */}
<div className="flex gap-4 items-start">
{/* Left: Inline 3D Viewer */}
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 relative group">
<InlineCadViewer
cadFileId={product.cad_file_id}
thumbnailUrl={product.render_image_url || product.thumbnail_url}
initialPartMaterials={convertCadPartMaterials(product.cad_part_materials ?? [])}
/>
{product.cad_render_log && (
<button
onClick={() => setShowCadInfo(true)}
className="absolute bottom-1 right-1 p-1 rounded text-white transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
title="Render info"
>
<Info size={12} />
</button>
)}
</div>
{/* Right: Action buttons */}
@@ -720,7 +750,7 @@ export default function ProductDetailPage() {
url={productionGlbUrl}
filename={`${product.name ?? product.pim_id}_production.glb`}
onGenerate={() => generateProductionGlbMut.mutate()}
isGenerating={generateProductionGlbMut.isPending}
isGenerating={generateProductionGlbMut.isPending || productionGlbGenerating}
title="Export production GLB with PBR materials via Blender"
/>
</div>
@@ -770,7 +800,7 @@ export default function ProductDetailPage() {
<div className="space-y-1.5">
{materialRows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs text-content-secondary w-40 truncate shrink-0" title={row.part_name}>
<span className="text-xs text-content-secondary w-64 truncate shrink-0" title={row.part_name}>
{row.part_name}
</span>
<div className="flex-1">
@@ -994,13 +1024,24 @@ export default function ProductDetailPage() {
>
{/* ── Media area ───────────────────────────────── */}
{r.is_video ? (
<div className="relative">
<div className="relative group/video">
<video
src={r.render_url}
controls
preload="metadata"
muted
loop
playsInline
className="w-full aspect-video object-contain bg-black"
onMouseEnter={(e) => { e.currentTarget.play().catch(() => {}) }}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0 }}
onClick={(e) => selectMode && e.preventDefault()}
/>
{/* Play hint — visible until first hover */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none group-hover/video:opacity-0 transition-opacity">
<div className="w-10 h-10 rounded-full bg-black/40 flex items-center justify-center">
<Play size={18} className="text-white ml-0.5" />
</div>
</div>
{/* Select mode checkbox overlay for videos */}
{selectMode && (
<div className="absolute top-2 left-2 pointer-events-none">
@@ -1093,6 +1134,11 @@ export default function ProductDetailPage() {
{r.output_type_name}
</span>
)}
{r.render_position_name && (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-medium">
{r.render_position_name}
</span>
)}
{r.render_backend && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
r.render_backend === 'flamenco' ? 'bg-status-warning-bg text-status-warning-text' : 'bg-status-info-bg text-status-info-text'
@@ -1179,29 +1225,36 @@ export default function ProductDetailPage() {
</button>
{showPositions && (
<div className="px-4 pb-4 space-y-3">
{/* Preset buttons */}
<div className="flex items-center gap-2 pt-1">
<span className="text-xs text-content-muted">Presets:</span>
{POSITION_PRESETS.map((preset) => (
<button
key={preset.label}
className="text-xs px-2 py-1 rounded border border-border-default text-content-secondary hover:border-accent hover:text-accent transition-colors"
onClick={() => setPositionForm({ name: preset.label, rotation_x: preset.rx, rotation_y: preset.ry, rotation_z: preset.rz })}
>
{preset.label}
</button>
))}
{/* Global positions (read-only reference) */}
{globalPositions.length > 0 && (
<div className="pt-1">
<p className="text-xs text-content-muted mb-1">Global positions (shared across all products):</p>
<div className="flex flex-wrap gap-1.5">
{globalPositions.map((gp) => (
<span
key={gp.id}
className="text-xs px-2 py-0.5 rounded-full border border-border-default text-content-muted"
title={`X: ${gp.rotation_x}° · Y: ${gp.rotation_y}° · Z: ${gp.rotation_z}°`}
>
{gp.name}
{gp.is_default && <span className="ml-1 text-accent"></span>}
</span>
))}
</div>
</div>
)}
<div className="flex justify-end pt-1">
<button
className="ml-auto btn-secondary text-xs"
className="btn-secondary text-xs"
onClick={() => setPositionForm({ name: '', rotation_x: 0, rotation_y: 0, rotation_z: 0 })}
>
<Plus size={12} /> Add Position
<Plus size={12} /> Add custom position
</button>
</div>
{/* Existing positions list */}
{(product.render_positions?.length ?? 0) === 0 && !positionForm && (
<p className="text-xs text-content-muted py-2">No positions defined. Click "Add Position" or a preset above.</p>
<p className="text-xs text-content-muted py-2">No custom positions defined. Global positions apply automatically.</p>
)}
{(product.render_positions ?? []).map((pos) => (
<div key={pos.id} className="border border-border-default rounded-lg p-3">
@@ -1398,6 +1451,13 @@ export default function ProductDetailPage() {
setWizardTargetIdx(null)
}}
/>
<RenderInfoModal
open={showCadInfo}
onClose={() => setShowCadInfo(false)}
title="CAD Thumbnail"
renderLog={product.cad_render_log}
/>
</div>
)
}
+71 -13
View File
@@ -1,9 +1,9 @@
import { useState } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
Library, Search, Box, CheckCircle2, Clock, AlertTriangle,
LayoutGrid, List, Trash2, X,
LayoutGrid, List, Trash2, X, ArrowUpDown,
} from 'lucide-react'
import { toast } from 'sonner'
import { listProducts, deleteProduct } from '../api/products'
@@ -77,6 +77,7 @@ function ProductCard({ product, onClick, selected, onSelect }: {
src={product.render_image_url || product.thumbnail_url!}
alt={product.name || product.pim_id}
className="h-full w-full object-contain"
loading="lazy"
/>
) : (
<Box size={48} className="text-content-muted" />
@@ -114,18 +115,26 @@ function ProductCard({ product, onClick, selected, onSelect }: {
export default function ProductLibraryPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [hasCadFilter, setHasCadFilter] = useState<string>('')
const [materialsFilter, setMaterialsFilter] = useState('')
const [sortBy, setSortBy] = useState<'pim_id' | 'name' | 'status'>('pim_id')
const [view, setView] = useState<'grid' | 'table'>('grid')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: products, isLoading } = useQuery({
queryKey: ['products', { search, categoryFilter, hasCadFilter, materialsFilter }],
// Debounce search input (300ms)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 300)
return () => clearTimeout(t)
}, [searchInput])
const { data: rawProducts, isLoading } = useQuery({
queryKey: ['products', { debouncedSearch, categoryFilter, hasCadFilter, materialsFilter }],
queryFn: () => listProducts({
q: search || undefined,
q: debouncedSearch || undefined,
category_key: categoryFilter || undefined,
has_cad: hasCadFilter === 'yes' ? true : hasCadFilter === 'no' ? false : undefined,
materials_filter: materialsFilter || undefined,
@@ -133,6 +142,16 @@ export default function ProductLibraryPage() {
}),
})
// Client-side sort
const products = useMemo(() => {
if (!rawProducts) return rawProducts
return [...rawProducts].sort((a, b) => {
if (sortBy === 'name') return (a.name || '').localeCompare(b.name || '')
if (sortBy === 'status') return (a.processing_status || '').localeCompare(b.processing_status || '')
return (a.pim_id || '').localeCompare(b.pim_id || '')
})
}, [rawProducts, sortBy])
// ── Selection helpers ──────────────────────────────────────────────────
const toggleOne = (id: string) => {
setSelected((prev) => {
@@ -186,7 +205,7 @@ export default function ProductLibraryPage() {
<div>
<h1 className="text-2xl font-bold text-content">Product Library</h1>
<p className="text-sm text-content-muted">
{products ? `${products.length} products` : 'Loading\u2026'}
{products ? `${products.length} products` : isLoading ? 'Loading…' : '0 products'}
</p>
</div>
</div>
@@ -226,11 +245,19 @@ export default function ProductLibraryPage() {
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
type="text"
placeholder="Search by PIM-ID or name\u2026"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Search by PIM-ID or name"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-full pl-9 pr-8 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
{searchInput && (
<button
onClick={() => setSearchInput('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-muted hover:text-content transition-colors"
>
<X size={13} />
</button>
)}
</div>
<select
@@ -266,11 +293,38 @@ export default function ProductLibraryPage() {
<option value="complete"> All materials assigned</option>
<option value="incomplete"> Incomplete materials</option>
</select>
<div className="flex items-center gap-1.5 ml-auto text-sm text-content-secondary shrink-0">
<ArrowUpDown size={13} className="text-content-muted" />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="border-0 bg-transparent text-sm text-content-secondary focus:outline-none cursor-pointer pr-1"
>
<option value="pim_id">Sort: PIM ID</option>
<option value="name">Sort: Name</option>
<option value="status">Sort: Status</option>
</select>
</div>
</div>
{/* Content */}
{isLoading ? (
<div className="text-center py-16 text-content-muted">Loading products\u2026</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 animate-pulse">
{Array.from({ length: 12 }, (_, i) => (
<div key={i} className="card overflow-hidden">
<div className="h-40 bg-surface-muted" />
<div className="p-3 space-y-2">
<div className="h-4 w-16 bg-surface-muted rounded" />
<div className="h-4 w-32 bg-surface-muted rounded" />
<div className="flex gap-2">
<div className="h-4 w-14 bg-surface-muted rounded-full" />
<div className="h-4 w-20 bg-surface-muted rounded" />
</div>
</div>
</div>
))}
</div>
) : !products?.length ? (
<div className="text-center py-16 text-content-muted">
<Library size={48} className="mx-auto mb-3 opacity-30" />
@@ -337,6 +391,7 @@ export default function ProductLibraryPage() {
src={product.render_image_url || product.thumbnail_url!}
alt=""
className="w-full h-full object-contain"
loading="lazy"
/>
) : (
<Box size={18} className="text-content-muted" />
@@ -379,7 +434,10 @@ export default function ProductLibraryPage() {
{/* ── Floating action bar ───────────────────────────────────────── */}
{selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] bg-gray-900 text-white rounded-lg shadow-xl px-5 py-3 flex items-center gap-4 z-50">
<div
className="fixed bottom-6 z-50 flex items-center gap-4 px-5 py-3 bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10"
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
>
<span className="text-sm font-medium">
{selected.size} selected
</span>
+63 -56
View File
@@ -13,6 +13,8 @@ import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
import api from '../api/client'
import StepDropzone from '../components/upload/StepDropzone'
import StepIndicator from '../components/shared/StepIndicator'
import HelpTooltip from '../components/HelpTooltip'
function StatCard({ icon, value, label, description, color }: {
icon: React.ReactNode
@@ -238,6 +240,8 @@ export default function UploadPage() {
</p>
</div>
<StepIndicator step={step} total={4} labels={['Upload', 'Review', 'Configure', 'STEP Files']} />
{/* ── Step 1: Excel drop zone ─────────────────────────────────────── */}
{step === 1 && (
<div
@@ -379,9 +383,12 @@ export default function UploadPage() {
</th>
<th className="px-4 py-2 font-medium text-content-secondary">PIM-ID</th>
<th className="px-4 py-2 font-medium text-content-secondary">Series</th>
<th className="px-4 py-2 font-medium text-content-secondary"
title="Gew\u00e4hltes Produkt \u2014 the specific material/coating variant from the Excel"
>Gew. Produkt</th>
<th className="px-4 py-2 font-medium text-content-secondary">
<span className="flex items-center gap-1">
Gew. Produkt
<HelpTooltip help={{ title: 'Gew. Produkt', body: 'Gewähltes Produkt — the specific product variant selected in the Excel file.' }} />
</span>
</th>
<th className="px-4 py-2 font-medium text-content-secondary">Category</th>
<th className="px-4 py-2 font-medium text-content-secondary">Status</th>
<th className="px-4 py-2 font-medium text-content-secondary text-center" title="Whether a STEP/CAD file is already linked to this product">STEP</th>
@@ -477,59 +484,6 @@ export default function UploadPage() {
</div>
)}
{/* ── Step 4: Upload STEP Files ────────────────────────────────────── */}
{step === 4 && createdOrder && (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<FileBox size={18} className="text-content-secondary" />
<h2 className="font-semibold text-content">
Upload STEP Files {createdOrder.order_number}
</h2>
</div>
</div>
<p className="text-sm text-content-secondary">
Drop one or more <strong>.stp / .step</strong> files below.
Each file is matched to an order item by filename stem (case-insensitive).
You can also skip this and upload STEP files later from the order detail page.
</p>
</div>
<div className="card p-6">
<StepDropzone
orderId={createdOrder.id}
onMatchComplete={() => qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })}
/>
</div>
<div className="flex justify-end gap-3">
<button
className="btn-secondary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
Skip &mdash; Go to Order
</button>
<button
className="btn-primary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
<ArrowRight size={16} />
Done &mdash; Go to Order
</button>
</div>
</div>
)}
{/* ── Validation Dialog ────────────────────────────────────────────── */}
{showValidationDialog && (
<ValidationDialog
validation={validationData}
onClose={() => setShowValidationDialog(false)}
onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })}
/>
)}
{/* ── Step 3: Output Type Selection ───────────────────────────────── */}
{step === 3 && previewResult && (
<div className="space-y-4">
@@ -684,6 +638,59 @@ export default function UploadPage() {
</div>
</div>
)}
{/* ── Validation Dialog ────────────────────────────────────────────── */}
{showValidationDialog && (
<ValidationDialog
validation={validationData}
onClose={() => setShowValidationDialog(false)}
onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })}
/>
)}
{/* ── Step 4: Upload STEP Files ────────────────────────────────────── */}
{step === 4 && createdOrder && (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<FileBox size={18} className="text-content-secondary" />
<h2 className="font-semibold text-content">
Upload STEP Files {createdOrder.order_number}
</h2>
</div>
</div>
<p className="text-sm text-content-secondary">
Drop one or more <strong>.stp / .step</strong> files below.
Each file is matched to an order item by filename stem (case-insensitive).
You can also skip this and upload STEP files later from the order detail page.
</p>
</div>
<div className="card p-6">
<StepDropzone
orderId={createdOrder.id}
onMatchComplete={() => qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })}
/>
</div>
<div className="flex justify-end gap-3">
<button
className="btn-secondary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
Skip &mdash; Go to Order
</button>
<button
className="btn-primary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
<ArrowRight size={16} />
Done &mdash; Go to Order
</button>
</div>
</div>
)}
</div>
)
}
+42 -9
View File
@@ -4,7 +4,7 @@ import { toast } from 'sonner'
import {
Activity, CheckCircle2, XCircle, Loader2, Clock, RefreshCw,
ChevronDown, ChevronRight, RotateCcw, Terminal, Cpu, Image,
Trash2, Ban, ListOrdered, FileCode2,
Trash2, Ban, ListOrdered, FileCode2, ChevronUp,
} from 'lucide-react'
import { Link } from 'react-router-dom'
import {
@@ -12,6 +12,7 @@ import {
getQueueStatus, purgeQueue, cancelTask, QueueTask,
} from '../api/worker'
import LiveRenderLog from '../components/LiveRenderLog'
import ConfirmModal from '../components/ConfirmModal'
type UnifiedEvent =
| { kind: 'cad'; ts: number; entry: CadActivityEntry }
@@ -20,6 +21,7 @@ type UnifiedEvent =
export default function WorkerActivityPage() {
const qc = useQueryClient()
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [logExpanded, setLogExpanded] = useState<Set<string>>(new Set())
const { data, isLoading, dataUpdatedAt } = useQuery({
queryKey: ['worker-activity'],
@@ -94,9 +96,18 @@ export default function WorkerActivityPage() {
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 text-content-muted py-12 justify-center">
<Loader2 size={18} className="animate-spin" /> Loading activity
{isLoading && events.length === 0 && (
<div className="card overflow-hidden divide-y divide-border-light">
{[0,1,2,3].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<div className="w-5 h-5 rounded-full bg-surface-muted animate-pulse shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="h-3 bg-surface-muted animate-pulse rounded w-2/3" />
<div className="h-2.5 bg-surface-muted animate-pulse rounded w-1/3" />
</div>
<div className="w-16 h-3 bg-surface-muted animate-pulse rounded" />
</div>
))}
</div>
)}
@@ -127,6 +138,7 @@ export default function WorkerActivityPage() {
)}
</div>
)}
</div>
)
}
@@ -232,6 +244,7 @@ function firstArg(task: QueueTask): string {
function QueuePanel() {
const qc = useQueryClient()
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false)
const { data: queue, isLoading } = useQuery({
queryKey: ['worker-queue'],
@@ -285,9 +298,7 @@ function QueuePanel() {
{totalPending > 0 && (
<button
onClick={() => {
if (confirm(`Purge all ${totalPending} pending task(s) from the queue?`)) {
purgeMut.mutate()
}
setPurgeConfirmOpen(true)
}}
disabled={purgeMut.isPending}
className="flex items-center gap-1 px-2.5 py-1 rounded border border-red-200 text-red-600 text-xs font-medium hover:bg-red-50 transition-colors"
@@ -395,6 +406,14 @@ function QueuePanel() {
</div>
)}
</div>
<ConfirmModal
open={purgeConfirmOpen}
title="Purge Queue"
message={`Purge all ${totalPending} pending task${totalPending !== 1 ? 's' : ''} from the queue? This cannot be undone.`}
onConfirm={() => { purgeMut.mutate(); setPurgeConfirmOpen(false) }}
onCancel={() => setPurgeConfirmOpen(false)}
/>
</div>
)
}
@@ -577,8 +596,12 @@ function KV({ label, value, mono, highlight }: {
}
function BlenderLog({ lines }: { lines: string[] }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="bg-gray-900 rounded-md overflow-auto max-h-64">
<div>
<div
className={`bg-gray-900 rounded-md overflow-auto transition-all ${expanded ? 'max-h-[600px]' : 'max-h-64'}`}
>
<pre className="text-xs text-gray-200 p-3 leading-5 whitespace-pre-wrap">
{lines.map((l, i) => {
const color =
@@ -592,6 +615,16 @@ function BlenderLog({ lines }: { lines: string[] }) {
)
})}
</pre>
</div>
{lines.length > 20 && (
<button
onClick={() => setExpanded((v) => !v)}
className="mt-1 flex items-center gap-1 text-xs text-content-muted hover:text-content-secondary transition-colors"
>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{expanded ? 'Collapse log' : `Expand log (${lines.length} lines)`}
</button>
)}
</div>
)
}
@@ -610,7 +643,7 @@ function RendererBadge({ log }: { log: RenderLog }) {
}
if (log.renderer === 'threejs') {
return (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">
<span className="badge-purple text-xs px-1.5 py-0.5 rounded font-medium">
Three.js
</span>
)
+255 -26
View File
@@ -1,49 +1,278 @@
# Plan: Migrate blender_render.py from STL to GLB Import
# Plan: GMSH Tessellation — Eliminate Fan Triangles on Cylindrical Surfaces
## Kontext
`render_blender.py` (backend service) correctly converts STEP→GLB via OCC and passes the GLB path as `argv[0]` to `blender_render.py`. However, `blender_render.py` still calls `_import_stl()` which uses `bpy.ops.wm.stl_import()` and `_scale_mm_to_m()`. The GLB from OCC is already in metres (scaled 0.001 internally by `export_step_to_gltf.py`), so no scaling is needed.
OCC `BRepMesh_IncrementalMesh` erzeugt strukturell fehlerhafte Dreiecke bei periodischen Flächen (Vollzylinder, Ringe):
`still_render.py` already has a correct `_import_glb()` implementation using `bpy.ops.import_scene.gltf()` — this serves as the reference.
1. **Fan-Dreiecke** an u=0=2π Naht-Kanten — BRepMesh tesselliert jede Fläche unabhängig. Am Nahtübergang entstehen hochvalente Dreiecke mit Valenz 30-40, weil die Kantenmittelpunkte nicht zur Flächentessellierung konformieren.
2. **Faceting** auf großen Zylindern — angular_deflection (Winkelbedingung) und linear_deflection (Abstandsbedingung) erzeugen unterschiedlich viele Vertices an Kanten vs. Flächeninneres. OCC überbrückt die Lücke mit Fan-Dreiecken.
This caused render failures for order SA-2026-00099: Blender tried to STL-import a `.glb` file → silent failure → cancelled renders.
Diese Fehler können **nicht** mit Deflection-Parametern behoben werden — auch Fine-Settings (`0.02 rad / 0.01 mm`) zeigen exakt dieselben Artefakte. Delabella-Algorithmus liefert dieselbe Dreiecksanzahl (332.394 gegenüber DEFAULT 332.394).
**Lösung**: GMSH Frontal-Delaunay Mesher via GMSH Python API. GMSH:
- Kennt die periodischen Naht-Kanten der B-rep-Topologie
- Erzeugt konformierende Netze über Flächen-Grenzen hinweg
- Nutzt den OCC-Kernel intern (dieselbe STEP-Repräsentation)
- Ist pip-installierbar: `pip install gmsh``gmsh 4.15.1`
**Scope**: Änderungen nur in `export_step_to_gltf.py` und Dockerfile. Die Sharp-Edge-Pipeline (`_extract_sharp_edge_pairs`), das GLB-Format, Blender-Seite und die XCAF→RWGltf_CafWriter-Kette bleiben unverändert.
---
## Betroffene Dateien
| Datei | Änderung |
|-------|----------|
| `blender-renderer/blender_render.py` | Replace `_import_stl` with `_import_glb`, remove `_scale_mm_to_m`, rename `stl_path``glb_path` |
|---|---|
| `render-worker/Dockerfile` | `gmsh>=4.15.0` installieren |
| `render-worker/scripts/export_step_to_gltf.py` | GMSH-Tessellierung als Alternative zu BRepMesh |
| `backend/app/api/routers/admin.py` | Setting `tessellation_engine` (`occ`\|`gmsh`) |
| `backend/app/domains/pipeline/tasks/export_glb.py` | Setting lesen, `--tessellation_engine` an CLI-Aufruf übergeben |
---
## Tasks (in Reihenfolge)
### [x] Task 1: Replace `_import_stl()` with `_import_glb()` in `blender_render.py`
### Task 1: Dockerfile — `gmsh` installieren
- **Datei**: `blender-renderer/blender_render.py`
- **Was**:
1. Replace `_import_stl()` function (lines ~206-289) with `_import_glb()` modeled on `still_render.py:196-229`:
- Use `bpy.ops.import_scene.gltf(filepath=glb_path)`
- Collect imported mesh objects
- No scaling needed (GLB already in metres)
2. Remove `_scale_mm_to_m()` function (lines ~166-182) — no longer needed
3. Remove all calls to `_scale_mm_to_m(parts)` (Mode A ~line 466, Mode B ~line 386)
4. Replace all calls to `_import_stl(stl_path)` with `_import_glb(glb_path)` (Mode A ~line 464, Mode B ~line 384)
5. Rename variable `stl_path``glb_path` throughout (line 65, 715, and all references)
6. Update docstring/comments referencing STL
- **Akzeptanzkriterium**: `blender_render.py` imports GLB via `bpy.ops.import_scene.gltf()`, no STL references remain, no mm→m scaling
- **Datei**: `render-worker/Dockerfile`
- **Was**: Nach der `trimesh`-Zeile einfügen:
```dockerfile
# GMSH for Frontal-Delaunay tessellation (alternative to OCC BRepMesh)
RUN pip3 install --no-cache-dir "gmsh>=4.15.0"
```
- **Akzeptanzkriterium**: `docker compose exec render-worker python3 -c "import gmsh; print(gmsh.__version__)"` gibt `4.15.x`.
- **Abhängigkeiten**: keine
### Task 2: `export_step_to_gltf.py` — CLI-Argument `--tessellation_engine`
- **Datei**: `render-worker/scripts/export_step_to_gltf.py`
- **Was**: In `parse_args()` ein neues Argument:
```python
parser.add_argument(
"--tessellation_engine", choices=["occ", "gmsh"], default="occ",
help="Tessellation backend: 'occ' = BRepMesh (default), 'gmsh' = Frontal-Delaunay",
)
```
- **Akzeptanzkriterium**: `--help` listet `--tessellation_engine`.
- **Abhängigkeiten**: keine
### Task 3: `export_step_to_gltf.py` — Funktion `_tessellate_with_gmsh()`
- **Datei**: `render-worker/scripts/export_step_to_gltf.py`
- **Was**: Neue Funktion vor `main()`. Nimmt den XCAF-Compound und Deflection-Parameter. Strategie:
```
Für jede Leaf-Shape in der XCAF-Hierarchie:
1. Schreibe die TopoDS_Shape als temporäre .brep-Datei
2. Lade via gmsh.model.occ.importShapes(brep_path) in GMSH-OCC-Kernel
3. Setze Mesh-Parameter:
- MeshSizeMin/Max aus linear_deflection
- Algorithm = 6 (Frontal-Delaunay)
- RecombineAll = 0 (behalte Dreiecke, keine Quads)
4. gmsh.model.mesh.generate(2) — 2D Oberflächen-Mesh
5. Lese Knoten + Dreiecke via gmsh.model.mesh.getNodes() / getElementsByType(2)
6. Baue Poly_Triangulation aus Knoten + Dreiecken
7. Setze per BRep_Builder auf die TopoDS_Face (oder direkt auf die Shell)
8. Lösche tmp-Datei
```
Wichtige OCP-APIs:
```python
from OCP.BRep import BRep_Builder
from OCP.BRepTools import BRepTools
from OCP.Poly import Poly_Triangulation
from OCP.TColgp import TColgp_Array1OfPnt
from OCP.TShort import TShort_Array1OfShortInteger
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Pnt
```
Konkreter Code-Ablauf für `Poly_Triangulation`-Erstellung:
```python
# nodes: list of (x, y, z) in mm
# triangles: list of (n1, n2, n3) 1-indexed
n_nodes = len(nodes)
n_tris = len(triangles)
arr_pts = TColgp_Array1OfPnt(1, n_nodes)
for idx, (x, y, z) in enumerate(nodes, 1):
arr_pts.SetValue(idx, gp_Pnt(x, y, z))
arr_tris = Poly_Array1OfTriangle(1, n_tris)
for idx, (a, b, c) in enumerate(triangles, 1):
arr_tris.SetValue(idx, Poly_Triangle(a, b, c))
tri = Poly_Triangulation(arr_pts, arr_tris)
# BRep_Builder.UpdateFace weist Triangulation einer Face zu
builder = BRep_Builder()
builder.UpdateFace(face, tri, loc, precision)
```
**Wichtig**: GMSH gibt face-lokale Nodeindices zurück. Die XCAF-Assembly-Location (`TopLoc_Location`) muss für die Koordinatentransformation berücksichtigt werden, damit die Triangulation im richtigen Koordinatenrahmen liegt.
**Fallback**: Wenn GMSH für eine bestimmte Face fehlschlägt (z.B. degenerierte Fläche) → BRepMesh für diese Face als Fallback.
- **Akzeptanzkriterium**:
- `python3 export_step_to_gltf.py --step_path /tmp/test.stp --output_path /tmp/out.glb --tessellation_engine gmsh` läuft ohne Fehler
- Log zeigt „GMSH tessellation: N faces processed, M triangles total"
- GLB kann in Blender geöffnet werden, keine degenerierten Dreiecke
- Keine Fan-Vertices mit Valenz > 10 an Zylindernaht-Kanten
- **Abhängigkeiten**: Task 1, Task 2
### Task 4: Admin-Setting `tessellation_engine`
- **Datei**: `backend/app/api/routers/admin.py`
- **Was**: In `SETTINGS_DEFAULTS` eintragen:
```python
"tessellation_engine": "occ", # "occ" | "gmsh"
```
In `SettingsOut` ergänzen:
```python
tessellation_engine: str = "occ"
```
In der Admin-UI-Beschreibung (Docstring oder Kommentar) dokumentieren.
- **Akzeptanzkriterium**: `GET /api/admin/settings` gibt `tessellation_engine: "occ"` zurück.
- **Abhängigkeiten**: keine
### Task 5: `export_glb.py` — Setting durchreichen
- **Datei**: `backend/app/domains/pipeline/tasks/export_glb.py`
- **Was**: In `generate_gltf_geometry_task()` (und `generate_gltf_production_task()` wo der OCC-Befehl aufgebaut wird):
```python
tessellation_engine = sys_settings.get("tessellation_engine", "occ")
# ...
occ_cmd = [
...,
"--tessellation_engine", tessellation_engine,
]
```
- **Akzeptanzkriterium**: Admin stellt `tessellation_engine` auf `gmsh` → nächster GLB-Export nutzt GMSH.
- **Abhängigkeiten**: Task 2, Task 4
### Task 6: Frontend — Dropdown in Admin-Settings
- **Datei**: `frontend/src/pages/Admin.tsx`
- **Was**: Im Tessellation-Settings-Abschnitt ein Select-Element für `tessellation_engine`:
```tsx
<select
value={localSettings.tessellation_engine}
onChange={(e) => handleChange('tessellation_engine', e.target.value)}
>
<option value="occ">OCC BRepMesh (Standard)</option>
<option value="gmsh">GMSH Frontal-Delaunay (besser für Zylinder)</option>
</select>
```
Kurze Beschreibung: „GMSH erzeugt konformierende Dreiecke ohne Fan-Artefakte an Zylindernaht-Kanten. Verarbeitungszeit: +10-30% pro Modell."
- **Akzeptanzkriterium**: Dropdown sichtbar und speichert Setting korrekt.
- **Abhängigkeiten**: Task 4
---
## GMSH-Implementierungsdetails
### GMSH API Ablauf (pseudo-code)
```python
import gmsh
import tempfile
from pathlib import Path
from OCP.BRepTools import BRepTools
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
def _tessellate_with_gmsh(shape, linear_deflection: float, angular_deflection: float) -> None:
"""Replace BRepMesh with GMSH Frontal-Delaunay tessellation."""
gmsh.initialize()
gmsh.option.setNumber("General.Terminal", 0) # suppress output
gmsh.option.setNumber("Mesh.Algorithm", 6) # Frontal-Delaunay
gmsh.option.setNumber("Mesh.RecombineAll", 0) # triangles only
gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection * 0.5)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 5.0)
gmsh.option.setNumber("Mesh.AngleToleranceFacetOverlap", angular_deflection * 57.3)
with tempfile.NamedTemporaryFile(suffix=".brep", delete=False) as tmp:
brep_path = tmp.name
try:
BRepTools.Write_s(shape, brep_path)
gmsh.model.add("shape")
gmsh.model.occ.importShapes(brep_path)
gmsh.model.occ.synchronize()
gmsh.model.mesh.generate(2)
# Build Poly_Triangulation per face and write back via BRep_Builder
_write_gmsh_triangulation_to_occ(shape)
finally:
gmsh.finalize()
Path(brep_path).unlink(missing_ok=True)
```
### Poly_Triangulation Write-Back
GMSH nummeriert Surfaces durch. Per XCAF-Leaf muss die Entsprechung zwischen
GMSH Surface-Tags und OCC TopoDS_Face-Objekten hergestellt werden:
- GMSH gibt bei `occ.importShapes()` die Surface-Tags zurück
- Die Reihenfolge entspricht der TopExp_Explorer-Iteration über `TopAbs_FACE`
- `BRep_Builder.UpdateFace(face, tri_triangulation, location, tolerance)` setzt die Triangulation
### Parameter-Mapping: OCC → GMSH
| OCC Parameter | GMSH Entsprechung |
|---|---|
| `linear_deflection` (mm) | `CharacteristicLengthMax = linear_deflection * 3` |
| `angular_deflection` (rad) | `Mesh.MinimumCirclePoints = ceil(2π/angular_deflection)` |
---
## Migrations-Check
Keine Migration nötig — reine Script-Änderung.
**Keine Migration erforderlich.** Nur Rendering-Pipeline-Änderungen. `tessellation_engine` wird in `system_settings` gespeichert (bestehendes Key-Value-Store, keine Schema-Änderung).
---
## Reihenfolge-Empfehlung
1. Task 1 (blender_render.py)
2. Rebuild render-worker: `docker compose up -d --build render-worker`
3. Test: trigger a thumbnail render or order render and check logs
```
Task 1 (Dockerfile) → rebuild render-worker
→ Task 2+3 (export_step_to_gltf.py) → manueller Test
→ Task 4 (admin.py) → Task 5 (export_glb.py)
→ Task 6 (Frontend)
→ End-to-End Test: Admin → GMSH → Produkt-GLB regenerieren → Viewer prüfen
```
Manueller Test nach Task 3:
```bash
# In render-worker container:
python3 /render-scripts/export_step_to_gltf.py \
--step_path /tmp/81113-l_cut.stp \
--output_path /tmp/test_gmsh.glb \
--tessellation_engine gmsh \
--linear_deflection 0.03 \
--angular_deflection 0.05
# Dann Production GLB:
/opt/blender/blender --background \
--python /render-scripts/export_gltf.py -- \
--glb_path /tmp/test_gmsh.glb \
--output_path /tmp/test_gmsh_prod.glb \
--smooth_angle 30
# In Blender öffnen: kein Faceting, keine Fan-Vertices an Naht-Kanten
```
---
## Risiken / Offene Fragen
1. **`_apply_material_library()`** and **`_resolve_part_name()`** work on Blender objects after import — they should work identically regardless of import format (STL vs GLB).
2. **Auto-camera computation** uses bounding box of imported objects — works the same with GLB meshes.
3. **`turntable_render.py`** and **`turntable_setup.py`** — need to check if they also still use STL import. If so, they need the same fix (but out of scope for this plan unless confirmed).
1. **GMSH Surface-Tag ↔ OCC Face-Mapping**: Die Reihenfolge der Surface-Tags bei `importShapes()` muss mit `TopExp_Explorer(FACE)` übereinstimmen. Falls nicht 1:1 → Koordinaten-basiertes Matching (Schwerpunkt der Face vs. GMSH-Mesh-Centroid) als Fallback.
2. **Performance**: GMSH Frontal-Delaunay ist typisch 2-5× langsamer als BRepMesh (BRepMesh 0.18s → GMSH ~0.4-0.9s für 25 Parts). Für 175-teilige Assemblies: 3-8 Min statt 1.5 Min. Liegt im 3-Min-Budget für Produktions-GLBs.
3. **GMSH subprocess-Isolation**: `gmsh.initialize()` / `gmsh.finalize()` sind nicht thread-safe. Da render-worker concurrency=1, ist das kein Problem.
4. **Sharp-Edge-Extraktion**: `_extract_sharp_edge_pairs()` läuft NACH der Tessellierung und nutzt die analytischen B-rep-Kurven (GCPnts_UniformAbscissa) — unabhängig vom Tessellierungsalgorithmus. Bleibt unverändert.
5. **Assembly-Locations**: Wenn GMSH eine Assembly-Shape als Ganzes importiert, werden Instance-Transformationen flachgeklopft. Dies ist erwünscht (Tessellierung in Weltkoordinaten), muss aber mit der späteren BRepBuilderAPI_Transform mm→m-Skalierung abgestimmt werden.
Plan fertig. Bitte mit `/implement` fortfahren.
+21 -5
View File
@@ -88,8 +88,12 @@ def _apply_sharp_edges_from_occ(mesh_objects: list, sharp_edge_pairs: list) -> N
continue # degenerate — both endpoints map to same vertex
bv0, bv1 = bm.verts[idx0], bm.verts[idx1]
edge = bm.edges.get((bv0, bv1)) or bm.edges.get((bv1, bv0))
if edge is not None and edge.smooth:
if edge is not None:
# Mark sharp (for normal splitting) AND seam (for UV unwrap).
# Both are needed: sharp controls glTF vertex splits / shading;
# seam defines UV island boundaries for correct UV unwrapping.
edge.smooth = False
edge.seam = True
marked += 1
bm.to_mesh(obj.data)
@@ -116,6 +120,14 @@ def main() -> None:
mesh_objects = [o for o in bpy.data.objects if o.type == "MESH"]
print(f"Imported geometry GLB: {args.glb_path} ({len(mesh_objects)} mesh objects)")
# Read OCC sharp edge pairs embedded by export_step_to_gltf.py into GLB extras.
# Blender 5.0 maps glTF scenes[0].extras as scene custom properties on import.
# These take priority over the mesh_attributes CLI argument (which only has 2
# endpoints per edge — see V02 refactor for why this matters).
glb_sharp_pairs = bpy.context.scene.get("schaeffler_sharp_edge_pairs") or []
if glb_sharp_pairs:
print(f"Loaded {len(glb_sharp_pairs)} OCC sharp edge pairs from GLB extras")
# Remove OCC-baked custom normals from the geometry GLB.
# RWGltf_CafWriter embeds per-corner normals from OCC tessellation as a
# 'custom_normal' attribute (CORNER, INT16_2D). If left in place, Blender's
@@ -168,10 +180,14 @@ def main() -> None:
print(f"Marked {total_sharp} sharp/seam edges across {len(mesh_objects)} objects")
# Apply OCC sharp edges if available (additional explicit sharp edges from CAD data)
sharp_pairs = mesh_attributes.get("sharp_edge_pairs") or []
if sharp_pairs:
_apply_sharp_edges_from_occ(mesh_objects, sharp_pairs)
# Apply OCC sharp edges from GLB extras (V02: dense tessellation segment pairs).
# Prefer GLB-embedded pairs over mesh_attributes CLI argument — the GLB extras
# contain the full tessellated polyline for each sharp B-rep edge (all intermediate
# points), while mesh_attributes only has 2 endpoints per edge (too sparse for
# reliable KD-tree matching). Fall back to mesh_attributes if GLB extras absent.
occ_pairs = list(glb_sharp_pairs) or (mesh_attributes.get("sharp_edge_pairs") or [])
if occ_pairs:
_apply_sharp_edges_from_occ(mesh_objects, occ_pairs)
# Apply asset library materials if provided.
# link=False (append) is required: the GLTF exporter can only traverse
@@ -46,6 +46,10 @@ def parse_args() -> argparse.Namespace:
"--color_map", default="{}",
help='JSON dict mapping part name → hex color, e.g. \'{"Ring": "#4C9BE8"}\'',
)
parser.add_argument(
"--sharp_threshold", type=float, default=20.0,
help="Dihedral angle threshold (degrees) for sharp B-rep edge detection. Default 20.0",
)
return parser.parse_args()
@@ -123,6 +127,177 @@ def _apply_palette_colors(shape_tool, color_tool, free_labels) -> None:
color_tool.SetColor(label, occ_color, COLOR_SURF)
def _extract_sharp_edge_pairs(shape, sharp_threshold_deg: float = 20.0) -> list:
"""Extract geometrically sharp B-rep edges as dense curve sample segment pairs.
For each edge shared by exactly 2 faces, evaluates the dihedral angle using
PCurve-based surface normal evaluation. When the angle exceeds the threshold,
samples the analytical 3D curve uniformly at 0.3mm intervals via
GCPnts_UniformAbscissa. Consecutive sample pairs give the KD-tree in
export_gltf.py enough density to find and mark the correct Blender mesh edges.
Note: BRep_Tool.Polygon3D_s() and PolygonOnTriangulation_s() return None in
XCAF compound context the tessellation data is stored on component instances,
not on the compound edges. Curve sampling bypasses this entirely.
Args:
shape: OCC TopoDS_Shape (tessellated with BRepMesh_IncrementalMesh)
sharp_threshold_deg: dihedral angle threshold in degrees (default 20°)
Returns:
List of [[x0,y0,z0],[x1,y1,z1]] consecutive segment pairs in mm (OCC
coordinate space, Z-up). Must be called BEFORE mmm scaling.
"""
import math as _math
from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
from OCP.TopExp import TopExp as _TopExp
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_FORWARD
from OCP.TopoDS import TopoDS as _TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve2d, BRepAdaptor_Curve
from OCP.BRepLProp import BRepLProp_SLProps
from OCP.GCPnts import GCPnts_UniformAbscissa
edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape()
_TopExp.MapShapesAndAncestors_s(shape, TopAbs_EDGE, TopAbs_FACE, edge_face_map)
sharp_pairs: list = []
n_checked = 0
n_sharp = 0
# Sample step 0.3mm — well below the KD-tree TOL=0.5mm in export_gltf.py.
# Tessellation vertex spacing for default deflection is ~0.78-1.55mm, so at
# least one consecutive sample pair will straddle each tessellation edge.
SAMPLE_STEP_MM = 0.3
for i in range(1, edge_face_map.Extent() + 1):
edge_shape = edge_face_map.FindKey(i)
faces = edge_face_map.FindFromIndex(i)
if faces.Size() < 2:
n_checked += 1
continue
face_shapes = list(faces)
if len(face_shapes) < 2:
n_checked += 1
continue
n_checked += 1
try:
edge = _TopoDS.Edge_s(edge_shape)
face1 = _TopoDS.Face_s(face_shapes[0])
face2 = _TopoDS.Face_s(face_shapes[1])
# PCurve-based normal evaluation at edge midpoint
c2d_1 = BRepAdaptor_Curve2d(edge, face1)
uv1 = c2d_1.Value((c2d_1.FirstParameter() + c2d_1.LastParameter()) / 2.0)
surf1 = BRepAdaptor_Surface(face1)
props1 = BRepLProp_SLProps(surf1, uv1.X(), uv1.Y(), 1, 1e-6)
if not props1.IsNormalDefined():
continue
n1 = props1.Normal()
if face1.Orientation() != TopAbs_FORWARD:
n1.Reverse()
c2d_2 = BRepAdaptor_Curve2d(edge, face2)
uv2 = c2d_2.Value((c2d_2.FirstParameter() + c2d_2.LastParameter()) / 2.0)
surf2 = BRepAdaptor_Surface(face2)
props2 = BRepLProp_SLProps(surf2, uv2.X(), uv2.Y(), 1, 1e-6)
if not props2.IsNormalDefined():
continue
n2 = props2.Normal()
if face2.Orientation() != TopAbs_FORWARD:
n2.Reverse()
cos_angle = max(-1.0, min(1.0, n1.Dot(n2)))
angle_deg = _math.degrees(_math.acos(cos_angle))
# Use exterior (supplement) angle so convex and concave edges both work
if angle_deg > 90.0:
angle_deg = 180.0 - angle_deg
if angle_deg <= sharp_threshold_deg:
continue # smooth transition — skip
n_sharp += 1
# Sample the analytical 3D curve at fixed arc-length intervals.
# GCPnts_UniformAbscissa works on the exact B-rep curve regardless of
# whether tessellation polygon data is stored on the edge or not.
pts: list = []
try:
curve3d = BRepAdaptor_Curve(edge)
f_param = curve3d.FirstParameter()
l_param = curve3d.LastParameter()
if _math.isfinite(f_param) and _math.isfinite(l_param):
sampler = GCPnts_UniformAbscissa()
sampler.Initialize(curve3d, SAMPLE_STEP_MM, 1e-6)
if sampler.IsDone() and sampler.NbPoints() >= 2:
for j in range(1, sampler.NbPoints() + 1):
t = sampler.Parameter(j)
p = curve3d.Value(t)
pts.append([round(p.X(), 4), round(p.Y(), 4), round(p.Z(), 4)])
except Exception:
pts = []
if len(pts) < 2:
continue
# Consecutive segment pairs — KD-tree in export_gltf.py maps each
# endpoint to its nearest Blender vertex; if they differ and share a
# mesh edge, that edge is marked sharp+seam.
for k in range(len(pts) - 1):
sharp_pairs.append([pts[k], pts[k + 1]])
except Exception:
continue
print(
f"Sharp edge extraction: {n_checked} edges checked, "
f"{n_sharp} sharp (>{sharp_threshold_deg:.0f}°), "
f"{len(sharp_pairs)} segment pairs total"
)
return sharp_pairs
def _inject_glb_extras(glb_path: Path, extras: dict) -> None:
"""Patch a GLB binary to add/update scenes[0].extras JSON field.
The GLB format stores a JSON chunk immediately after the 12-byte header.
We re-serialize it with the new extras and update chunk + total lengths.
No external dependencies pure stdlib struct/json.
"""
import struct as _struct
data = glb_path.read_bytes()
# GLB header: magic(4) + version(4) + total_length(4) = 12 bytes
# JSON chunk: chunk_length(4) + chunk_type(4) + chunk_data(chunk_length bytes)
json_len = _struct.unpack_from("<I", data, 12)[0]
json_type = _struct.unpack_from("<I", data, 16)[0]
if json_type != 0x4E4F534A: # "JSON"
print("WARNING: _inject_glb_extras: unexpected chunk type, skipping extras injection",
file=sys.stderr)
return
j = json.loads(data[20: 20 + json_len])
if "scenes" in j and j["scenes"]:
existing = j["scenes"][0].get("extras") or {}
existing.update(extras)
j["scenes"][0]["extras"] = existing
else:
j.setdefault("extras", {}).update(extras)
new_json = json.dumps(j, separators=(",", ":"))
# Pad to 4-byte boundary with spaces (required by GLB spec)
pad = (4 - len(new_json) % 4) % 4
new_json_bytes = new_json.encode() + b" " * pad
rest = data[20 + json_len:] # BIN chunk and anything after
new_chunk = _struct.pack("<II", len(new_json_bytes), 0x4E4F534A) + new_json_bytes
new_total = 12 + len(new_chunk) + len(rest)
new_header = _struct.pack("<III", 0x46546C67, 2, new_total)
glb_path.write_bytes(new_header + new_chunk + rest)
def main() -> None:
args = parse_args()
color_map: dict = json.loads(args.color_map)
@@ -173,6 +348,21 @@ def main() -> None:
True, # isInParallel
)
# --- Extract sharp B-rep edge pairs (before mm→m scaling so coords are in mm) ---
# Collect all free shapes into one list for the extraction function.
# The extraction uses the freshly tessellated XCAF shapes.
sharp_pairs: list = []
try:
for i in range(1, free_labels.Length() + 1):
root_shape = shape_tool.GetShape_s(free_labels.Value(i))
if not root_shape.IsNull():
pairs = _extract_sharp_edge_pairs(root_shape, args.sharp_threshold)
sharp_pairs.extend(pairs)
print(f"Total OCC sharp segment pairs: {len(sharp_pairs)}")
except Exception as _exc:
print(f"WARNING: sharp edge extraction failed (non-fatal): {_exc}", file=sys.stderr)
sharp_pairs = []
# --- Apply colors ---
if color_map:
_apply_color_map(shape_tool, color_tool, free_labels, color_map)
@@ -222,6 +412,19 @@ def main() -> None:
print(f"GLB exported: {out.name} ({out.stat().st_size // 1024} KB)")
# --- Inject sharp edge pairs into GLB extras ---
# Blender 5.0 reads scenes[0].extras as scene custom properties on import,
# making the data available to export_gltf.py as bpy.context.scene["key"].
if sharp_pairs:
try:
_inject_glb_extras(out, {
"schaeffler_sharp_edge_pairs": sharp_pairs,
"schaeffler_sharp_threshold_deg": args.sharp_threshold,
})
print(f"Injected {len(sharp_pairs)} sharp edge segment pairs into GLB extras")
except Exception as _exc:
print(f"WARNING: GLB extras injection failed (non-fatal): {_exc}", file=sys.stderr)
try:
main()
+35 -9
View File
@@ -223,8 +223,14 @@ def _import_glb(glb_file):
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
for p in parts:
p.location -= center
# Move root objects (parentless) to centre. Adjusting a child's local
# .location by a world-space vector gives wrong results when the GLB has
# Empty parent nodes (OCC assembly hierarchy). Shifting the root moves
# the entire hierarchy correctly.
all_imported = list(bpy.context.selected_objects)
root_objects = [o for o in all_imported if o.parent is None]
for obj in root_objects:
obj.location -= center
return parts
@@ -244,9 +250,10 @@ def _resolve_part_name(index, part_obj, part_names_ordered):
def _apply_material_library(parts, mat_lib_path, mat_map, part_names_ordered=None):
"""Append materials from library .blend and assign to parts via material_map.
With per-part STL import, Blender objects are named after STEP parts,
so matching is by name (stripping Blender .NNN suffix for duplicates).
Falls back to part_names_ordered index-based matching for combined-STL mode.
Matching priority per part:
1. GLB object name (strip Blender .NNN suffix + OCC _AF0/_AF1 suffix)
2. Prefix fallback (longest mat_map key that is a prefix of / contains part name)
3. Index-based via part_names_ordered (also strips _AF suffix)
mat_map: {part_name_lower: material_name}
Parts without a match keep their current material.
@@ -286,16 +293,35 @@ def _apply_material_library(parts, mat_lib_path, mat_map, part_names_ordered=Non
# secondary: index-based via part_names_ordered (combined STL fallback)
assigned_count = 0
for i, part in enumerate(parts):
# Try name-based matching first (strip Blender .NNN suffix)
# 1. Name-based: strip Blender .NNN suffix, then OCC _AF0/_AF1 suffix
base_name = _re.sub(r'\.\d{3}$', '', part.name)
_prev = None
while _prev != base_name:
_prev = base_name
base_name = _re.sub(r'_AF\d+$', '', base_name, flags=_re.IGNORECASE)
part_key = base_name.lower().strip()
mat_name = mat_map.get(part_key)
# Fall back to index-based matching via part_names_ordered
# 2. Prefix fallback: longest mat_map key that is a prefix/suffix match
if not mat_name:
for key, val in sorted(mat_map.items(), key=lambda x: len(x[0]), reverse=True):
if len(key) >= 5 and len(part_key) >= 5 and (
part_key.startswith(key) or key.startswith(part_key)
):
mat_name = val
break
# 3. Index-based fallback via part_names_ordered (also strips _AF suffix)
if not mat_name and part_names_ordered and i < len(part_names_ordered):
step_name = part_names_ordered[i]
part_key = step_name.lower().strip()
mat_name = mat_map.get(part_key)
step_key = step_name.lower().strip()
mat_name = mat_map.get(step_key)
if not mat_name:
_p2 = None
while _p2 != step_key:
_p2 = step_key
step_key = _re.sub(r'_af\d+$', '', step_key)
mat_name = mat_map.get(step_key)
if mat_name and mat_name in appended:
part.data.materials.clear()
+35 -9
View File
@@ -193,8 +193,14 @@ def _import_glb(glb_file):
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
for p in parts:
p.location -= center
# Move root objects (parentless) to centre. Adjusting a child's local
# .location by a world-space vector gives wrong results when the GLB has
# Empty parent nodes (OCC assembly hierarchy). Shifting the root moves
# the entire hierarchy correctly.
all_imported = list(bpy.context.selected_objects)
root_objects = [o for o in all_imported if o.parent is None]
for obj in root_objects:
obj.location -= center
return parts
@@ -214,9 +220,10 @@ def _resolve_part_name(index, part_obj, part_names_ordered):
def _apply_material_library(parts, mat_lib_path, mat_map, part_names_ordered=None):
"""Append materials from library .blend and assign to parts via material_map.
With per-part STL import, Blender objects are named after STEP parts,
so matching is by name (stripping Blender .NNN suffix for duplicates).
Falls back to part_names_ordered index-based matching for combined-STL mode.
Matching priority per part:
1. GLB object name (strip Blender .NNN suffix + OCC _AF0/_AF1 suffix)
2. Prefix fallback (longest mat_map key that is a prefix of / contains part name)
3. Index-based via part_names_ordered (also strips _AF suffix)
mat_map: {part_name_lower: material_name}
Parts without a match keep their current material.
@@ -256,16 +263,35 @@ def _apply_material_library(parts, mat_lib_path, mat_map, part_names_ordered=Non
# secondary: index-based via part_names_ordered (combined STL fallback)
assigned_count = 0
for i, part in enumerate(parts):
# Try name-based matching first (strip Blender .NNN suffix)
# 1. Name-based: strip Blender .NNN suffix, then OCC _AF0/_AF1 suffix
base_name = _re.sub(r'\.\d{3}$', '', part.name)
_prev = None
while _prev != base_name:
_prev = base_name
base_name = _re.sub(r'_AF\d+$', '', base_name, flags=_re.IGNORECASE)
part_key = base_name.lower().strip()
mat_name = mat_map.get(part_key)
# Fall back to index-based matching via part_names_ordered
# 2. Prefix fallback: longest mat_map key that is a prefix/suffix match
if not mat_name:
for key, val in sorted(mat_map.items(), key=lambda x: len(x[0]), reverse=True):
if len(key) >= 5 and len(part_key) >= 5 and (
part_key.startswith(key) or key.startswith(part_key)
):
mat_name = val
break
# 3. Index-based fallback via part_names_ordered (also strips _AF suffix)
if not mat_name and part_names_ordered and i < len(part_names_ordered):
step_name = part_names_ordered[i]
part_key = step_name.lower().strip()
mat_name = mat_map.get(part_key)
step_key = step_name.lower().strip()
mat_name = mat_map.get(step_key)
if not mat_name:
_p2 = None
while _p2 != step_key:
_p2 = step_key
step_key = _re.sub(r'_af\d+$', '', step_key)
mat_name = mat_map.get(step_key)
if mat_name and mat_name in appended:
part.data.materials.clear()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+69 -40
View File
@@ -1,59 +1,88 @@
# Review Report: Optimized Material Substitution Algorithm
Datum: 2026-03-07
# Review Report: CAD Viewer Material Assignment Fix + Feature Parity
Datum: 2026-03-10
## Ergebnis: ⚠️ Kleinigkeiten
## Ergebnis: ✅ Freigabe
---
## Gefundene Probleme
### [products.py:510] Prefix-Matching ohne Mindestlänge-Guard
### [InlineCadViewer.tsx + ThreeDViewer.tsx] Misleading comment on isolateMode reset effect
**Schwere**: Gering (Kommentar)
**Schwere**: Gering
Der Prefix-Fallback in `build_materials_from_excel` prüft `cad_norm.startswith(excel_norm)` ohne Mindestlänge für `excel_norm`:
```python
if excel_norm and cad_norm and (
cad_norm.startswith(excel_norm) or excel_norm.startswith(cad_norm)
):
In both files the comment reads:
```tsx
// Reset isolateMode and hideAssigned when no part is pinned
useEffect(() => {
if (!pinnedPart) setIsolateMode('none') // ← only resets isolateMode, not hideAssigned!
}, [pinnedPart])
```
The comment says "and hideAssigned" but the effect only calls `setIsolateMode('none')`. The behavior is actually correct — `hideAssigned` should NOT be reset when unpinning (it's a persistent view toggle). Only the comment is wrong.
Wenn ein Excel-Eintrag nach Normalisierung sehr kurz wird (z.B. `"f"` aus `f-12345678.prt`), trifft der Präfix-Check auf fast alle CAD-Namen die mit `f_` beginnen. Schaeffler-Teilenamen sind zwar praktisch immer lang genug, aber das Risiko eines Fehlmatches bei atypischen Einträgen existiert.
**Empfehlung**: Guard hinzufügen: `len(excel_norm) >= 5 and len(cad_norm) >= 5`.
---
### [export_gltf.py:122] Prefix-Fallback iteriert über unsortierte dict-Keys
**Schwere**: Gering
```python
for key, val in mat_map_lower.items():
if lower_base.startswith(key) or key.startswith(lower_base):
mat_name = val
break
```
`mat_map_lower` hat keine garantierte Sortierung nach Schlüssellänge. Wenn ein kurzer Key (`"ring"`) und ein langer Key (`"ring_inner_seal"`) beide die Präfix-Bedingung erfüllen, gewinnt der erste in dict-Reihenfolge — nicht zwangsläufig der spezifischste Match.
**Empfehlung**: Keys nach Länge absteigend sortieren: `sorted(mat_map_lower.items(), key=lambda x: len(x[0]), reverse=True)` — längster Match gewinnt = spezifischster Match.
**Empfehlung**: Change to `// Reset isolateMode when no part is pinned`.
---
## Positiv aufgefallen
- **Task 1 korrekt und robust**: `while prev != base_name` Loop für nested Suffixe terminiert sicher und deckt `_AF0_AF1`-Fälle ab.
- **`_re.IGNORECASE` korrekt gesetzt** in `export_gltf.py` — deckt `_AF0` wie auch `_af0`.
- **`_normalize_part_token_name` Reihenfolge stimmt**: `_af\d+` wird VOR Hyphen→Underscore-Konvertierung gestrippt, Regex funktioniert zuverlässig.
- **Hash-Suffix-Stripping `\d{4,}`**: Mindestlänge 4 verhindert False-Positives bei legitimen kurzen Nummern in Teilenamen.
- **`print()` in Blender-Script korrekt**: Blender-Scripts laufen als Subprocess, stdout wird vom Caller geloggt — `logging` wäre hier falsch.
- **Kein DB-Schema geändert**: Keine Migration nötig, korrekt erkannt und ausgelassen.
- **Tuple-Erweiterung auf 4 Elemente**: `excel_entries` korrekt auf `(tokens, raw, material, excel_norm)` erweitert, keine alten Stellen übersehen.
### Bug fix: MaterialPanel invisible in ThreeDViewer — root cause correctly identified
The diagnosis was precise: the outer `<div onClick={() => setPinnedPart(null)}>` was receiving the
native DOM bubble from every canvas click, calling `setPinnedPart(null)` in the same React batch as
`setPinnedPart(name)` from the THREE.js event handler — final state always `null`.
The two-part fix is clean and idiomatic:
- `onClick={(e) => e.stopPropagation()}` on the viewport div absorbs DOM clicks
- `onPointerMissed={() => setPinnedPart(null)}` on the R3F Canvas handles the "click empty space"
case via the THREE.js raycaster (fires only when no mesh is hit) — this is exactly the right
R3F API for this use case
### cadUtils.ts — normalization regex extension
`/_AF\d+(_ASM)?$/i` is minimal and correct. It handles:
- `_AF0`, `_AF1` (existing, unchanged)
- `_AF0_ASM`, `_AF1_ASM` (new — assembly-node suffix)
- Case-insensitive flag is defensive and correct
- The loop-until-stable pattern handles nested suffixes as before
- `_ASM` alone (without `_AF\d+`) is NOT stripped — correct, it's part of base names like
`GE360-HF_000_P_ASM_ASM`
### Combined visibility useEffect — correct design
Merging `hideAssigned` + `isolateMode` into a single traversal effect avoids
ordering ambiguity between two independent effects competing on the same `mesh.visible` and
`mat.opacity`. The priority order (hideAssigned first, then isolateMode) is explicit and logical.
The pinned part (`isSelected`) is always protected from hiding regardless of mode. ✓
### Effect separation is clean
- Color-apply effect: only touches `mat.color` → deps `[modelReady, partMaterials]`
- Unassigned glow effect: only touches `mat.emissive` → deps `[modelReady, showUnassigned, partMaterials]`
- Combined visibility effect: only touches `mesh.visible` / `mat.opacity` / `mat.transparent` → deps `[modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials]`
No effect touches another effect's properties — no race conditions.
### GPU hint and DPR cap
`gl={{ powerPreference: 'high-performance' }}` + `dpr={[1, 1.5]}` on both Canvas elements.
`preserveDrawingBuffer: true` correctly kept only in ThreeDViewer (required for screenshot capture).
### "Hide assigned" toolbar button correctly conditional
`{assignedCount > 0 && (...)}` in InlineCadViewer and
`{modelReady && Object.keys(partMaterials).length > 0 && (...)}` in ThreeDViewer — button only
appears when there is something to hide.
### Debug log is dev-only
`if (!import.meta.env.DEV || ...)` guard ensures the console output and traversal overhead
never reach production. The output logs both matched and unmatched keys, which is exactly what's
needed to diagnose remaining name mismatches after the normalization fix.
### Feature parity achieved
ThreeDViewer and InlineCadViewer now have matching material-assignment features:
- ✓ `showUnassigned` highlight toggle with count badge
- ✓ `hideAssigned` toggle (new, both viewers)
- ✓ `isolateMode` (ghost / hide) via MaterialPanel (both viewers)
- ✓ `onPointerMissed` closes panel on empty-space click in ThreeDViewer
---
## Empfehlung
Zwei geringe Probleme (Mindestlänge-Guard + Sortierung nach Key-Länge). Beide sind je eine Zeile Fix und verhindern Fehlmatches bei atypischen Eingaben. Können direkt inline gepatcht werden, kein erneutes Review nötig.
**Freigabe.** The one Gering comment issue can be fixed inline.
Review abgeschlossen. Ergebnis: ✅
+65 -9
View File
@@ -1,13 +1,13 @@
"""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
the Scripting workspace (Text Editor Run Script). It reads the sharp angle that
was baked into the GLB at export time and re-applies mark_sharp() + mark_seam() on
every mesh object.
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
mark_sharp() + mark_seam() on every mesh object.
The GLB visual shading already encodes the sharp edges via vertex splits (normals).
This script restores the blue sharp-crease and red seam markers in Edit Mode for
further editing in Blender.
further editing in Blender including for UV unwrapping.
Usage:
1. File Import glTF 2.0 (.glb) open your production GLB
@@ -18,14 +18,17 @@ Usage:
import bpy
import math
angle_deg = bpy.context.scene.get("schaeffler_sharp_angle_deg", 30.0)
smooth_rad = math.radians(float(angle_deg))
import bmesh
import mathutils
mesh_objects = [o for o in bpy.data.objects if o.type == "MESH"]
if not mesh_objects:
print("No mesh objects found in scene.")
else:
# --- Pass 1: dihedral-angle-based sharp/seam marks ---
angle_deg = bpy.context.scene.get("schaeffler_sharp_angle_deg", 30.0)
smooth_rad = math.radians(float(angle_deg))
total_sharp = 0
bpy.ops.object.select_all(action="DESELECT")
for obj in mesh_objects:
@@ -42,5 +45,58 @@ else:
bpy.ops.object.mode_set(mode="OBJECT")
total_sharp += sum(1 for e in obj.data.edges if e.use_edge_sharp)
obj.select_set(False)
print(f"Restored sharp/seam marks at {angle_deg}°: "
f"{total_sharp} 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 ---
# The production GLB embeds schaeffler_sharp_edge_pairs (OCC B-rep topology,
# dense curve samples at 0.3mm) in scenes[0].extras, which Blender maps to
# scene custom properties on import. These cover geometrically sharp edges
# that the dihedral-angle pass misses due to tessellation noise.
#
# Coordinate convention (mirrors export_gltf.py _apply_sharp_edges_from_occ):
# OCC STEP space (Z-up, mm) → Blender (Z-up, m):
# 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 []
if occ_pairs:
print(f"Pass 2 (OCC B-rep): applying {len(occ_pairs)} sharp edge segment pairs...")
SCALE = 0.001 # OCC mm → Blender m
TOL = 0.0005 # 0.5 mm tolerance in metres
marked_total = 0
for obj in mesh_objects:
bm_obj = bmesh.new()
bm_obj.from_mesh(obj.data)
bm_obj.verts.ensure_lookup_table()
bm_obj.edges.ensure_lookup_table()
world_mat = obj.matrix_world
kd = mathutils.kdtree.KDTree(len(bm_obj.verts))
for v in bm_obj.verts:
kd.insert(world_mat @ v.co, v.index)
kd.balance()
marked = 0
for pair in occ_pairs:
v0_bl = mathutils.Vector((pair[0][0] * SCALE, -pair[0][2] * SCALE, pair[0][1] * SCALE))
v1_bl = mathutils.Vector((pair[1][0] * SCALE, -pair[1][2] * SCALE, pair[1][1] * SCALE))
_co0, idx0, dist0 = kd.find(v0_bl)
_co1, idx1, dist1 = kd.find(v1_bl)
if dist0 > TOL or dist1 > TOL:
continue
if idx0 == idx1:
continue
bv0, bv1 = bm_obj.verts[idx0], bm_obj.verts[idx1]
edge = bm_obj.edges.get((bv0, bv1)) or bm_obj.edges.get((bv1, bv0))
if edge is not None:
edge.smooth = False
edge.seam = True
marked += 1
bm_obj.to_mesh(obj.data)
bm_obj.free()
marked_total += marked
print(f"Pass 2 (OCC B-rep): {marked_total} additional edges marked across {len(mesh_objects)} objects.")
else:
print("Pass 2 (OCC B-rep): no schaeffler_sharp_edge_pairs in GLB extras — skipped.")
+248 -161
View File
@@ -1,147 +1,143 @@
# Schaeffler Automat — UX & Quality Audit Report
**Date**: 2026-03-08
**Audited by**: Source-code analysis (frontend dev server not reachable at audit time)
**Overall Score**: 7.5/10
**Overall Score**: 6.5/10
---
## Executive Summary
Schaeffler Automat is a feature-rich internal media-creation pipeline with a well-structured React/TypeScript frontend. The application covers a broad functional surface — order management, product library, render monitoring, billing, materials, workers, and a customisable analytics dashboard — and demonstrates strong UX thinking in several areas (kanban + list view duality, multi-step wizard flows, real-time activity feeds, rich filtering). The design system is coherent and token-driven with dark-mode and accent-colour theming. The most significant weaknesses are: the sidebar navigation grows unwieldy without visual hierarchy, several modal/dialog components opt out of the design system (hard-coded Tailwind white/gray classes instead of semantic tokens), the "Upload" primary workflow is hidden mid-list in the sidebar rather than foregrounded, and a handful of page-level experiences fall short of the richness shown elsewhere (Billing, WorkflowEditor, AssetLibraries are visibly incomplete). No automated tests exist for the UI, which is a process risk.
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).
---
## Critical Issues
## Critical Issues 🔴
### C1 — ValidationDialog opts out of the design system entirely
**File**: `frontend/src/pages/Upload.tsx` (lines 710855)
### 1. Admin Page — No Section Navigation
The `/admin` page contains 10+ distinct sections (Pricing, Users, Blender Settings, GPU Probe, 3D Viewer, Tessellation, SMTP, Render Templates, Output Types, Pricing Tiers, Asset Libraries, Admin Actions) in a single vertical scroll. On a 1080p screen, users must scroll ~3000px to reach lower sections. Sections below the fold are effectively invisible to new users.
The `ValidationDialog` and `NewInvoiceModal` (Billing.tsx lines 3976) use hard-coded `bg-white`, `text-gray-900`, `border-gray-200`, `text-gray-700` etc. instead of semantic tokens (`bg-surface`, `text-content`, `border-border-default`). In dark-mode these dialogs will render as fully white panels against a dark background — an accessibility and branding failure. The same pattern appears in the Billing page action buttons (`bg-blue-600`).
**Fix**: Tab-based navigation grouping: **Settings** / **Users** / **Render** / **Templates** / **Pricing** / **Actions**
### C2 — Sticky bottom bars use a fixed `left-60` offset that breaks on mobile
**Files**: `NewProductOrder.tsx` (lines 476, 615, 736), `NewOrder.tsx` (assumed similar)
### 2. Upload Wizard — No Step Progress Indicator
The 4-step Excel import flow (Drop → Match Report → Output Type Selection → STEP Upload) has no visible step counter or breadcrumb. Users don't know which step they're on, how many remain, or whether they can go back. This is the primary workflow for adding work to the system.
The bottom action bars are positioned with `fixed bottom-0 left-60 right-0`. On mobile, the sidebar is `w-60` but collapses behind an overlay — the bottom bar will overlap or clip because it always assumes a 240 px left sidebar is present. Combined with `pt-12` main content on mobile (for the top header bar), this creates layout collisions.
**Fix**: Add `<StepIndicator step={n} total={4} labels={['Upload', 'Review', 'Configure', 'STEP Files']} />` above the wizard content.
### C3 — `confirm()` used throughout for destructive confirmations
**Files**: `Orders.tsx` (line 164), `ProductLibrary.tsx` (line 167), `Materials.tsx` (line 404), `Admin.tsx` (multiple), `Billing.tsx` (line 209), `WorkerActivity.tsx` (line 288)
### 3. Login Page — Hardcoded `bg-white` Card Breaks Dark Mode
The login card uses `bg-white` instead of `bg-surface`, appearing as a jarring white box on the dark theme.
The native browser `confirm()` dialog is used for all destructive actions (delete order, delete product, purge queue, delete invoice). This is: (a) not styleable — it looks completely alien from the product design, (b) blocks the event loop, (c) not keyboard-accessible in a predictable way, and (d) inconsistent across browsers. A small reusable `ConfirmModal` would resolve all of these.
**Fix**: Replace `bg-white` with `bg-surface` in `Login.tsx`.
### C4 — No `<Toaster />` / toast provider verified in root; double `LiveRenderLog` import
**Files**: `App.tsx`, `OrderDetail.tsx` (imports `LiveRenderLog` from `../components/LiveRenderLog`), `WorkerActivity.tsx` (imports from `../components/LiveRenderLog`)
**File**: `frontend/src/components/tasks/LiveRenderLog.tsx` also exists
### 4. Badge/Category Colors Not Dark-Mode Safe
Color-coded category badges use hardcoded Tailwind light-mode classes (`bg-blue-100 text-blue-700`) with no dark mode adaptation. In dark mode these create incorrect contrast ratios.
There are two versions of `LiveRenderLog.tsx` (`/components/LiveRenderLog.tsx` and `/components/tasks/LiveRenderLog.tsx`). `OrderDetail.tsx` imports from `../components/LiveRenderLog` (the root-level one). The `tasks/` version appears to be a separate copy. This creates a maintenance hazard where fixes applied to one are not reflected in the other.
**Fix**: Create semantic `.badge-category` variants in `index.css` using CSS variables, or use the existing `.badge-blue`, `.badge-green` etc. that already reference design tokens.
---
## Major Improvements
## Major Improvements 🟠
### M1 — Sidebar navigation lacks visual grouping / hierarchy
**File**: `frontend/src/components/layout/Layout.tsx`
### 5. Floating Action Bars Use Hardcoded Sidebar Offset
Both `Orders.tsx` and `ProductLibrary.tsx` use `ml-[120px]` (half of 240px sidebar) to center bulk-delete action bars. This breaks if the sidebar width changes or on split-screen views.
The sidebar lists up to 14 navigation items in a flat list without any section dividers between user-facing pages (Dashboard, Orders, Products, Materials, Activity, Preferences, Upload) and admin-facing pages (Admin, Billing, Media Browser, Workers, Workflows, Asset Libraries, Notification Settings, Tenants). At maximum, an admin user sees all 14+ items with no visual separation. Recommended: add a small section label or divider line before the admin section.
**Fix**:
```tsx
// Replace ml-[120px] with proper centering:
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
```
### M2 — "Upload" is buried in the nav when it is the primary data-entry point
**File**: `frontend/src/components/layout/Layout.tsx` (line 18)
### 6. Product Library — Immediate Search, 200-Item Hard Cap
Search fires on every keystroke without debounce, potentially triggering 10+ API calls per typed word. A hard limit of 200 products means large libraries are silently truncated with no indication.
The "Upload" link is item #7 of 7 in the main nav list, placed after Preferences. For the primary workflow path (upload Excel → review → create order → dispatch renders), Upload should either be promoted to a more prominent position or merged with the "New Order" CTA at the top of the sidebar. Currently the "New Order" button at sidebar top goes to `/orders/new` which is a choice page, while Excel import lives at `/upload`. The duplication is confusing.
**Fix**: Add 300ms debounce to the search `onChange`. Add cursor-based pagination (24/48/96 per page) with prev/next buttons.
### M3 — Price estimates display bare decimal numbers without currency symbol
**Files**: `NewProductOrder.tsx` (lines 623, 742), `OrderDetail.tsx`
### 7. `window.confirm()` vs. `ConfirmModal` Inconsistency
The Activity page's "Purge All Queue" action uses native `window.confirm()`, breaking visual consistency with the custom `ConfirmModal` component used everywhere else for destructive actions.
Price estimates in the wizard bottom bars show `Estimated: 25.00` with no currency symbol. The `formatCurrency` function in `Billing.tsx` does this correctly with `Intl.NumberFormat`; the pattern is not shared. The wizard should use the same formatter: `€ 25,00`.
**Fix**: Replace all `window.confirm()` calls with `<ConfirmModal>`.
### M4 — Product Library hard cap of 200 products with no pagination
**File**: `frontend/src/pages/ProductLibrary.tsx` (line 131)
### 8. German "Anpassen" Label in English UI
The Dashboard customize button reads "Anpassen" — the only German string in an otherwise English interface.
`listProducts` is called with `limit: 200`. Above 200 products there is no pagination, infinite scroll, or "load more" — items beyond the cap silently disappear. The same cap applies to the New Product Order wizard Step 1. This will become a real problem as the library grows.
**Fix**: Rename to "Customize" or "Edit Widgets".
### M5 — Orders Kanban view: columns are fixed-width (272 px) and overflow horizontally
**File**: `frontend/src/pages/Orders.tsx` (lines 593628)
### 9. Submitted Orders Deletable Without Extra Warning
Bulk delete allows selecting `submitted` status orders. Deleting submitted orders (potentially being processed) shows the same confirmation as deleting drafts.
The Kanban board uses `min-w-[272px]` per column inside `min-w-max` — so with 5 columns (~1400 px) the view is wider than a 1280 px laptop screen and triggers horizontal scrolling of the entire content area. A more adaptive approach (auto-sizing columns, or capping at 3 visible columns with horizontal scroll contained to the board) would be better.
**Fix**: Show a stronger warning: *"⚠️ N orders have been submitted and may be processing. Delete anyway?"*
### M6 — Billing page is disconnected from order data
**File**: `frontend/src/pages/Billing.tsx`
### 10. Admin — Multiple Section-Level Save Buttons with Unclear Scope
Four independent draft objects (`blenderDraft`, `viewerDraft`, `tessellationDraft`, `smtpDraft`) each have a save button that only appears when changes exist. Users may save one section without realizing another has unsaved changes.
The "New Invoice" modal (lines 3476) creates invoices with an empty `order_line_ids: []` array — there is no way to select or attach order lines to an invoice from the UI. The `total_net` field in the table is always `null`/`—` for freshly created invoices since no line items are added. The page appears to be a skeleton that is visible to admins and project managers but does not yet deliver its core value.
**Fix**: Add a persistent "Unsaved changes in: Blender Settings, SMTP" notification banner at the top with a "Save All" option.
### M7 — Workflow Editor page appears non-functional
**File**: `frontend/src/pages/WorkflowEditor.tsx` (not read in full but exposed in routing)
### 11. Kanban View — No Horizontal Scroll Affordance on Mobile
The 5-column kanban (each 280px+ wide = 1400px+ total) silently overflows on narrow screens. Mobile users see a cut-off view with no hint to scroll horizontally.
The Workflows route exists and is admin-gated but the page content was not visible in the source scan. Based on the API client (`/api/workflows.ts`) and the `GitBranch` icon, this feature appears to be scaffolded but not implemented, yet it is present in the sidebar nav for admins/PMs. An "Under construction" state would be more honest than a silent empty page.
**Fix**: Auto-switch to list view when `window.innerWidth < 768`. Add a horizontal scroll hint (fade gradient at edges) on small desktops.
### M8 — Floating bulk-action bars use a hardcoded `ml-[120px]` offset
**Files**: `Orders.tsx` (line 356), `ProductLibrary.tsx` (line 365)
### 12. Dashboard Widgets — 15 Independent API Calls on Load
Each dashboard widget fetches data independently on mount, creating 15 simultaneous API requests and a cascade of skeleton → content transitions.
The `fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px]` centering pattern assumes the sidebar is always 240 px wide (half = 120 px). On mobile, the sidebar is hidden, so this offset pushes the bar off-center. A responsive solution (e.g., centering within the main content area) is needed.
**Fix**: Batch widget data into a single `/api/dashboard/data?widgets=...` endpoint. Or stagger widget loading with short delays to avoid request flood.
---
## Minor Refinements
## Minor Refinements 🟡
### Y1 — `confirm()` expand text is sometimes inconsistent in casing and punctuation
E.g., `Delete ${ids.length} order${...}? This cannot be undone.` vs `Delete material "${mat.name}"?` — no "cannot be undone" note on the latter. Standardise the confirmation copy.
### 13. Login — No Password Visibility Toggle, No Return URL Redirect
No eye icon to show/hide password. After login, always redirects to `/` instead of the originally requested URL.
### Y2 — Login page has no "Forgot password" link
**File**: `frontend/src/pages/Login.tsx`
**Fix**: Add `<Eye>/<EyeOff>` icon button. Capture `location.state.from` and redirect post-login.
Even in an internal tool, this is a common expectation. The page is clean and minimal, but the absence of any password-reset path means admins must use the CLI/admin panel to reset credentials.
### 14. Activity Page — Blender Log Fixed Max Height
The Blender stdout log panel has `max-h-64` (256px), creating a scroll-within-a-scroll for long logs.
### Y3 — Category labels mix German and English in different contexts
`CATEGORY_LABELS` maps `Kugellager`, `Gleitlager`, `Anschlagplatten` (German) while other labels are English abbreviations (`TRB`, `CRB`, `SRB/TORB`, `Linear`). The mix is visible in filter dropdowns, product cards, and wizard steps. A consistent decision (all English or all German with English tooltips) would improve clarity.
**Fix**: Add "Expand log" toggle that removes the max-height constraint.
### Y4 — `WorkerManagement` lists a `worker-thumbnail` service that MEMORY.md says is removed
**File**: `frontend/src/pages/WorkerManagement.tsx` (line 83)
### 15. Sidebar — Admin-Only Links Not Visually Separated
Admin-only items (Notification Settings, Tenants) are visually mixed with project_manager-accessible items, making role distinction unclear.
`SCALABLE_SERVICES` includes `worker-thumbnail` which according to the project memory was replaced by `render-worker`. Scaling a non-existent service will silently fail. The constant should be updated to match the actual docker-compose.
**Fix**: Add a small "Admin Only" section label above the admin-exclusive links.
### Y5 — Materials page header action area gets cramped at medium screen widths
**File**: `frontend/src/pages/Materials.tsx` (lines 214244)
### 16. Upload Wizard — Step 3 JSX Appears After Step 4 in Source
`Upload.tsx` renders Step 3 (Output Type Selection) JSX after Step 4 (STEP Upload) in the source file, creating a maintenance hazard.
The header row contains 4 buttons (Import Standards, Seed Aliases, Schaeffler Wizard, Add Material). At viewport widths below ~1100 px these wrap awkwardly or overflow. Grouping them in a dropdown or toolbar with overflow handling would help.
**Fix**: Reorder JSX blocks to match display order: Step 1 → 2 → 3 → 4.
### Y6 — Search highlight in Orders uses only the first occurrence
**File**: `frontend/src/pages/Orders.tsx` `Highlight` component (lines 496509)
### 17. Upload — "Gew. Produkt" Column Has No Tooltip
The abbreviated German column header is not explained. New operators won't know this means "Gewähltes Produkt" (Selected Product).
The `Highlight` component finds only the first match of the search term (`indexOf`). If a product name contains the search string twice, only the first is highlighted. This is a minor usability gap.
**Fix**: Add `<HelpTooltip text="Gewähltes Produkt — the specific product variant selected in the Excel file" />` next to the column header.
### Y7 — Notification page title uses `text-xl` while all other pages use `text-2xl`
**File**: `frontend/src/pages/Notifications.tsx` (line 77)
### 18. Media Browser — Fragmented Filter UI
Two separate filter areas exist: "Media type" segmented tabs and "Technical types" checkboxes. Their relationship is visually unclear.
`h1 className="text-xl font-semibold text-content"` vs Dashboard/Orders/Products/Materials all using `text-2xl font-bold text-content`. Minor but visible inconsistency.
**Fix**: Group all filters under a unified collapsible "Filters" panel with labeled sub-groups.
### Y8 — `WorkerActivity` page shows locale date in German but the app is primarily English
**File**: `frontend/src/pages/WorkerActivity.tsx` (lines 196, 458)
### 19. Product Library — No Sort Controls
Products can be filtered but not sorted (by name, PIM-ID, date, or render status).
Dates use `toLocaleDateString('de-DE')` / `toLocaleTimeString('de-DE')` hard-coded. Since the rest of the UI is in English, using the browser's locale (no explicit locale argument) or `en-GB` would be more consistent.
**Fix**: Add sort dropdown: "Sort: Newest / AZ / Status".
### Y9 — Admin page contains an inline `DashboardCustomizeModal` alongside complex settings panels
**File**: `frontend/src/pages/Admin.tsx`
### 20. No Skeleton Loading States on Key Pages
Orders list, Product library, and Activity page show empty state or nothing briefly before data arrives, causing layout shift.
The Admin page is enormous (79 KB of source) with user management, renderer settings, pricing, output types, render templates, asset libraries, and dashboard customisation all on a single scrolling page with no in-page navigation or tabs. The page would be significantly easier to use if it were split into tabbed sections.
### Y10 — `ProductDetail` page has no breadcrumb back-link in context
The product detail page has an `ArrowLeft` back button but does not show a breadcrumb like "Products / 81113-L_CUT" making it easy to lose context when navigating via direct URL.
**Fix**: Add skeleton rows/cards matching the expected content shape on first load.
---
## Wins
## Wins
- **Design system is solid**: the token-based Tailwind config (`bg-surface`, `text-content`, `border-border-default`) is consistently applied in 95% of components. Dark mode and accent-color theming work correctly throughout the main layout.
- **Dual view toggle** (Kanban/List for Orders, Grid/Table for Products) is well-implemented and the active state is clearly communicated.
- **Orders search is excellent**: real-time debouncing, backend full-text search, inline search highlight, contextual empty states, result counts, and status chips all combine into a genuinely useful experience.
- **New Product Order wizard** is well-designed: the 3-step flow (Select → Configure → Review), global toggles that apply across all selected products, partial-selection indicators (e.g., `2/5` counts on output type buttons), and the sticky bottom bar with live job count and price estimate are all thoughtful UX decisions.
- **Worker Activity page** is rich and informative: unified timeline merging CAD and Render events, expandable detail panels with Blender log, renderer badge, timing KPIs, queue visualisation, and task cancellation — all on one page with 5-second auto-refresh.
- **Notification center**: the bell dropdown with portal rendering, 15-second polling, unread badge, and click-to-navigate behaviour is a polished implementation. The full Notifications page adds pagination and "mark all as read".
- **Excel upload flow** is well-thought-out: the preview-first approach (no data is committed until explicit confirmation), per-row include/exclude toggles, duplicate detection with pre-unchecked rows, validation dialog with alias save-shortcut, and the optional draft-for-skipped-rows creation show genuine product thinking.
- **Dashboard** is highly customisable with 15 widget types, per-widget timeframe controls, drag-to-reorder, and tenant-level default dashboard config.
- **Sidebar live status indicators**: the blue pulse dot on "Activity" when tasks are active and the red dot when tasks have failed gives operators an at-a-glance system health signal without needing to open the page.
- **Responsive sidebar**: mobile hamburger + overlay backdrop + auto-close on navigation is correctly implemented with accessible `aria-label` attributes on the toggle buttons.
- **Preferences page** is simple and effective: theme mode (light/dark/system) and accent colour picker persist via Zustand with localStorage, applying immediately.
- **Theme system is production-grade**: CSS variable tokens + Tailwind semantic classes + `localStorage` persistence + system preference detection + flash prevention in `main.tsx` before React hydration.
- **Role-aware navigation**: Privileged links correctly hidden from clients; admin sections conditionally rendered.
- **Order kanban progress bars**: Color-coded segments (green/blue/red/orange/gray) give instant visual feedback on render completion state.
- **Notification Center**: Bell dropdown with badge, portal rendering, unread count, relative timestamps, and 15s polling — complete and polished.
- **Hover-to-play video thumbnails**: Both `OrderDetail` and `ProductDetail` have smooth hover-to-play with play icon overlay — good progressive disclosure.
- **Upload validation dialog**: Traffic-light summary with per-row issues and "Save as alias" is a sophisticated, context-aware feature.
- **Activity expand rows**: Full timing breakdown, renderer settings, part count, and syntax-colored Blender log are invaluable for debugging.
- **Excel import wizard**: Header-driven column detection handles format variance gracefully.
- **Render template system**: Lighting-only mode, shadow catcher, material replacement — powerful and well-integrated.
- **STL cache convention**: Prevents repeated STEP→STL conversions on re-renders.
---
@@ -149,112 +145,203 @@ The product detail page has an `ArrowLeft` back button but does not show a bread
| Priority | Area | Issue | Suggestion | Effort |
|----------|------|--------|------------|--------|
| P1 | Accessibility / Dark Mode | ValidationDialog and NewInvoiceModal use hard-coded white/gray, breaking dark mode | Replace all `bg-white`, `text-gray-*`, `border-gray-*` in dialogs with semantic tokens | S (12h) |
| P1 | Mobile Layout | Fixed bottom bars use `left-60` offset, breaking on mobile when sidebar is hidden | Use `left-0 md:left-60` or calculate offset responsively | S (1h) |
| P1 | UX Pattern | `confirm()` used for all destructive actions | Implement a small reusable `ConfirmModal` component | M (34h) |
| P2 | Navigation | Flat sidebar with 14+ items, no section grouping | Add a `<div class="border-t my-2">` + optional "Admin" label before admin routes | XS (30min) |
| P2 | Navigation | "Upload" is buried at item #7; primary data-entry workflow | Move Upload higher in nav or consolidate with "New Order" CTA | S (1h) |
| P2 | Data Display | Price estimates shown without currency symbol | Extract and reuse `formatCurrency` from Billing.tsx in wizard bottom bars | XS (30min) |
| P2 | Scalability | Product Library has a hard cap of 200 products, no pagination | Add server-side pagination with `offset` param and "Load more" or page buttons | M (35h) |
| P2 | Maintenance | Duplicate `LiveRenderLog.tsx` at two paths | Consolidate to `components/tasks/LiveRenderLog.tsx`, update all imports | S (1h) |
| P3 | Completeness | Billing page cannot attach order lines to invoices | Implement order-line selector in NewInvoiceModal | L (12d) |
| P3 | Completeness | WorkerManagement lists removed `worker-thumbnail` service | Remove from `SCALABLE_SERVICES` constant | XS (5min) |
| P3 | Admin UX | Admin page is one massive scrolling page | Add tabs (Users / Renderer / Pricing / Templates / System) | M (46h) |
| P3 | Internationalisation | German category labels mixed with English labels | Standardise to one language or add tooltip for German terms | S (1h) |
| P3 | Mobile UX | Orders Kanban columns overflow at standard laptop widths | Cap visible columns or provide horizontal scroll within the board only | M (3h) |
| P4 | UX Polish | Notification page uses `text-xl` while all peers use `text-2xl font-bold` | Align heading style | XS (5min) |
| P4 | UX Polish | Date formatting hard-coded to `de-DE` throughout worker activity | Use browser locale or consistent `en-GB` | S (1h) |
| P4 | Discoverability | Workflow Editor page is blank / non-functional but visible in nav | Add a "Coming soon" placeholder or remove from nav | XS (15min) |
| 1 | Admin | 3000px undifferentiated scroll | Tab navigation: Settings / Users / Render / Templates / Pricing / Actions | Medium |
| 2 | Upload | No step progress indicator | Add `<StepIndicator>` component above wizard | Low |
| 3 | Login | `bg-white` breaks dark mode | Replace with `bg-surface` CSS token | Low |
| 4 | Global | Category badges not dark-mode safe | Use semantic badge classes with CSS variables | Low |
| 5 | Orders | Submitted orders deletable without warning | Split confirmation messages by status | Low |
| 6 | Products | Search fires on every keystroke | Add 300ms debounce | Low |
| 7 | Products | 200-item hard cap with no pagination | Add cursor-based pagination | Medium |
| 8 | Activity | `window.confirm()` in queue purge | Replace with `ConfirmModal` | Low |
| 9 | Global | Floating bars use hardcoded `ml-[120px]` | Fix with CSS calc centering | Low |
| 10 | Dashboard | "Anpassen" German label | Rename to "Customize" | Low |
| 11 | Orders | Kanban unusable on mobile | Auto-switch to list view < 768px | Low |
| 12 | Login | No password toggle, no return URL | Add Eye icon + capture return URL | Low |
| 13 | Admin | Multiple save buttons unclear scope | Add "Unsaved changes" banner | Low |
| 14 | Products | No sort controls | Add sort dropdown | Low |
| 15 | Global | No skeleton loading on key pages | Add skeletons to Orders, Products, Activity | Medium |
| 16 | Activity | Blender log 256px fixed height | Add "Expand log" toggle | Low |
| 17 | Sidebar | Admin-only links not separated | Add "Admin Only" section label | Low |
| 18 | Media | Fragmented filter UI | Unified collapsible filters panel | Medium |
| 19 | Dashboard | 15 independent API calls on load | Batch widget data endpoint | High |
| 20 | Upload | Step 3 JSX after Step 4 in source | Reorder JSX blocks | Low |
---
## Theme & Visual Consistency Report
**Strengths**:
- The Tailwind design-token system is mature: `bg-surface`, `bg-surface-alt`, `bg-surface-hover`, `bg-surface-muted`, `text-content`, `text-content-secondary`, `text-content-muted`, `border-border-default`, `border-border-light`, and the full status token set (`status-success-bg/text`, `status-warning-bg/text`, `status-error-bg/text`, `status-info-bg/text`) are used consistently across ~95% of components.
- The accent-colour system (`bg-accent`, `text-accent`, `bg-accent-light`, `bg-accent-hover`, `text-accent-text`) is correctly applied throughout buttons, NavLink active states, and focus rings.
- Dark mode is applied at the `<html class="dark">` level with CSS variable overrides — a clean and proven approach.
**Overall**: Strong foundation with CSS custom properties driving all surfaces, text, borders, and status colors.
**Inconsistencies / Gaps**:
1. **Dialogs and modals opt out**: `ValidationDialog` (Upload.tsx), `NewInvoiceModal` (Billing.tsx), and portions of the `Admin.tsx` asset-library section use `bg-white`, `text-gray-*`, `border-gray-200`, and hardcoded `bg-blue-600` buttons. These will break in dark mode.
2. **Purple is used as a secondary accent** in several places (`bg-purple-600`, `text-purple-700`, `bg-purple-100`) for "render positions" / "perspectives" — this is not part of the defined accent preset palette and will look incorrect when a user switches to a non-purple accent.
3. **Billing page "New Invoice" button** uses `bg-blue-600 hover:bg-blue-700` instead of `btn-primary` (which respects the accent token). If a user has chosen the Amber or Teal accent, the button stays blue.
4. **Status chips in Orders.tsx** for kanban column headers use Tailwind colour classes directly (`bg-gray-500`, `bg-blue-500`, `bg-amber-500`, `bg-green-600`, `bg-red-500`) rather than the status token system — these cannot be overridden in dark mode.
5. **`hover:brightness-95`** on Materials page group headers will not produce the intended subtle hover effect in dark mode where `bg-slate-50` already contrasts with the dark background.
### Light Theme ✅
Surfaces differentiated in 4 levels (`bg-app``bg-surface``bg-surface-alt` `bg-muted`). Text in 3 weights. Status colors (success green, warning amber, error red, info blue) correctly use bg+text pairs.
### Dark Theme ✅ (with caveat)
Dark mode applies slate palette correctly. CSS variables swap on `<html class="dark">`. Flash prevention in `main.tsx` synchronously reads localStorage before React hydration.
**Issues in dark mode**:
- Login card: `bg-white` hardcoded — white box on dark background (**fix: `bg-surface`**)
- Category badges: `bg-blue-100 text-blue-700` etc. — wrong contrast in dark mode (**fix: semantic badge classes**)
- Some inline Tailwind classes in pages bypass the design token system
### Component Consistency
- **Buttons**: `.btn-primary/.btn-secondary/.btn-danger` defined in `index.css` — consistent ✅
- **Cards**: `.card` class used consistently ✅
- **Badges**: Mixed — `.badge-green/.badge-blue` semantic classes exist but not always used; raw Tailwind color classes appear throughout pages
- **Inputs**: `.input-base` defined but pages sometimes use inline `border border-border-default rounded-md px-3 py-2` directly — minor inconsistency
- **Modals**: Single `Modal.tsx` component used consistently ✅
### Accent Color System
5 presets (green/blue/purple/amber/teal) applied via `data-accent` on `<html>`. Applied to: active nav links, primary buttons, checkboxes, focus rings, status indicators. Consistent ✅.
---
## Functional QA Report
**Verified working (via code analysis)**:
- Auth flow: JWT stored in Zustand + localStorage, `ProtectedRoute` and `AdminRoute` guards, redirect to `/login` on unauthenticated access.
- Order creation: both wizard paths (Excel upload + product wizard) lead to draft orders with order number confirmation.
- Render dispatch: per-line and bulk dispatch buttons, cancel-render per-line and bulk, render progress bar in Kanban cards.
- Material alias management: add/delete aliases, seed from standards, search across aliases.
- Dashboard customisation: widget toggle modal, timeframe controls, custom date range.
- Worker activity: unified timeline with auto-refresh (5s), Blender log expand, reprocess trigger.
- Notification system: bell badge with unread count, 15s poll, portal dropdown, click-to-navigate to order, mark-one and mark-all-read.
### Modal & Dialog Behavior
- `Modal.tsx`: Escape to close ✅, backdrop click to close ✅, size variants ✅, focus handling ✅
- `ConfirmModal.tsx`: Focus trapping ✅, Tab/Shift-Tab cycling ✅, Escape to cancel ✅
- `window.confirm()` in queue purge: Inconsistent ❌ — replace with ConfirmModal
**Potential functional issues**:
1. **`AliasPill` performs an extra API fetch on every delete** (Materials.tsx lines 494505): `listAliases(materialId)` is called lazily on each delete click to look up the alias ID, because `MaterialOut` only returns alias strings, not IDs. This means every alias delete triggers an extra GET request. If the network is slow or the aliases list is stale the delete may target the wrong alias.
2. **Draft orders are deletable via bulk delete** (Orders.tsx `isDeletable` allows `submitted` status too) — the confirmation copy says "This cannot be undone" but does not warn that a submitted order may already be in processing. Check if this is intentional.
3. **`NewProductOrder` wizard Step 1 bottom bar** appears at `fixed bottom-0 left-60` only when `selectedProducts.size > 0`. Before any product is selected, the "Next" button is not visible at all — there is no affordance indicating the user should click a product to proceed. A subtle "Select at least one product to continue" hint would help first-time users.
4. **Product Library `onSelect` handler inconsistency**: `ProductCard.onSelect` is called with `e.target.checked` (boolean) but the prop type is `(checked: boolean) => void` and the parent handler is `() => toggleOne(product.id)` — the `checked` argument is discarded. The checkbox visual and the toggle may briefly diverge if the click and the optimistic state differ.
5. **`WorkerManagement` scale control** starts at count=1 on every page load — there is no display of the current running instance count, so users cannot know if they are scaling up or down.
### Form Validation
- Excel upload: File type validated pre-submit ✅
- User creation: No password strength indicator ❌
- Output type checkboxes in Upload Step 3: No indeterminate state for partially-checked columns ❌
### Loading & Error States
| Page | Loading State | Error State |
|------|--------------|-------------|
| Dashboard | Skeleton ✅ | Toast ✅ |
| Orders | None ❌ | Toast ✅ |
| Products | None ❌ | Empty state ✅ |
| Activity | None ❌ | Toast ✅ |
| Upload | Spinner on file parse ✅ | Inline validation ✅ |
| Admin | None ❌ | Toast ✅ |
### Keyboard Navigation
- Sidebar: Tab-navigable, Enter activates ✅
- Modals: Focus trapped ✅
- Kanban cards: Click-only, no keyboard activation ❌
- Data tables: Tab through rows not supported ❌
### 404 Handling
No custom 404 page — unknown routes redirect to `/` silently. **Fix**: Add a `*` catch-all route with a friendly "Page not found" component.
---
## Mobile Report
**What works on mobile**:
- Sidebar is correctly hidden by default behind a hamburger menu at `md:` breakpoint.
- Mobile top header bar (height 48px) with hamburger, title, and NotificationCenter bell.
- `pt-12 md:pt-0` on the main content area accounts for the fixed mobile header.
- Overlay backdrop (semi-transparent) closes sidebar on tap.
- Search inputs, status chips, and filter bars use `flex-wrap` throughout.
### Responsive Layout
- Sidebar: Correctly collapses to drawer on mobile ✅
- Fixed mobile header: `h-12` with hamburger + app name + notification bell
- Breakpoint: Tailwind `md` (768px)
**Mobile problems**:
1. **Fixed bottom bars** (`NewProductOrder`, `NewProductOrder` Step 2/3, bulk-delete bar in Orders/Products) use `left-60` — on mobile this offsets the bar 240px from the left edge of a screen that may only be 375px wide, making the bar very narrow or partially off-screen.
2. **Orders Kanban** requires horizontal scrolling at any mobile screen size (5 × 272px = 1360px total). On mobile, the list view (`view === 'list'`) is much more appropriate; consider defaulting to list view at `sm:` breakpoints.
3. **Admin page** is a single very long scrolling page with complex tables (OutputTypeTable, RenderTemplateTable, PricingTierTable) that will overflow horizontally on mobile screens. These tables have `overflow-auto` wrappers but the horizontal scrolling UX is poor on touch.
4. **Order Detail page** — based on the import list this page is the most complex in the app (~80KB source). The two-panel layout (items list + render lines table) is likely problematic on small screens without explicit responsive column layout.
5. **Worker Activity KV grid** uses `grid-cols-2 sm:grid-cols-3` — on very small screens (320px) even 2-column grids may be too cramped for monospace metric values.
### Issues
| Issue | Severity | Location |
|-------|----------|----------|
| Kanban 5 columns = 1400px+ wide, no mobile adaptation | High | Orders.tsx |
| Table checkboxes 16×16px (min 44×44px touch target) | High | Orders, Products table views |
| Upload Step 3 output type table requires horizontal scroll on mobile | Medium | Upload.tsx |
| Search placeholder text clipped on 320px screens | Low | Multiple pages |
| Admin page is ~5000px scroll on mobile with no section navigation | High | Admin.tsx |
### Touch Target Audit
- NavLink items: Full-width ~40px height — ✅ adequate
- "New Order" CTA button: Full-width 48px — ✅ correct
- Table checkboxes: 16×16px — ❌ too small
- Filter chips: ~32px height — ⚠️ borderline (minimum 44px recommended)
- Kanban "Open →" text link: Small — ❌ too small for touch
---
## User Flow Efficiency Report
**Core flow: Excel import → review → create order → upload STEP → dispatch renders**
- Step count: Upload page (4 sub-steps) → OrderDetail → StepDropzone → Dispatch button.
- The flow is logical but the step numbering on the Upload page is non-standard: Step 1 = drop zone, Step 2 = row review, Step 3 = output types, Step 4 = STEP upload. Steps 13 are on the Upload page; Step 4 redirects to the order. A persistent step indicator would reduce user disorientation.
- Missing: no "progress saved" state — if the user navigates away after Step 2, the preview result is lost (React state only, no URL state).
### Flow 1: Excel Import → Dispatch Renders (Primary Workflow)
| Step | Action |
|------|--------|
| 1 | Sidebar → Upload |
| 2 | Drop Excel file |
| 3 | Review product matches |
| 4 | Select output types |
| 5 | "Create Order" button |
| 6 | Upload STEP files (or skip) |
| 7 | Navigate to created order |
| 8 | Click "Dispatch Renders" |
**Core flow: Product wizard → create order → dispatch renders**
- 3-step wizard with clear header and "Next" / "Back" navigation is clean.
- The "global apply" toggles for output types across all products is a significant time-saver for bulk ordering.
- The sticky bottom bar continuously shows job count and estimated price — very helpful.
- The back navigation from Step 3 to Step 2 correctly preserves all selections.
**Total**: 8 steps across 2 page transitions. **Verdict**: Reasonable. Biggest friction = no step indicator (users don't know where they are in Step 3).
**Navigation efficiency**:
- The sidebar's primary CTA "New Order" goes to `/orders/new` which is a choice page (Excel vs Product Wizard). This extra click could be eliminated if the system knew which flow the user typically uses.
- The "Open →" hover text on Kanban cards appears only on hover with an opacity transition — keyboard users or users on touch devices will not see it.
- There is no global "search" — orders search is inside the Orders page, product search is inside the Products page. Power users working across both might want a unified command palette or global search.
### Flow 2: Re-render Failed Job
| Step | Action |
|------|--------|
| 1 | Notice failure in Activity or Dashboard |
| 2 | Click order link in Activity row |
| 3 | Find failed render line |
| 4 | Click retry button |
**Total**: 4 steps. **Verdict**: Good — direct links from Activity to orders work well. Could add "Retry All Failed" bulk action.
### Flow 3: Change Renderer Settings
| Step | Action |
|------|--------|
| 1 | Sidebar → Admin |
| 2 | Scroll to "Blender Render Settings" (~400px) |
| 3 | Change setting |
| 4 | Wait for save button to appear |
| 5 | Click Save |
**Total**: 5 steps. **Friction**: Step 2 scroll discovery. With tab navigation, this becomes 4 steps with zero scroll.
### Flow 4: Find Product by PIM-ID
| Step | Action |
|------|--------|
| 1 | Sidebar → Products |
| 2 | Type PIM-ID in search |
| 3 | Click product card |
**Total**: 3 steps. **Verdict**: Efficient ✅.
### Flow 5: Add Material Alias After Render Failure
| Step | Action |
|------|--------|
| 1 | Notice wrong material in Activity |
| 2 | Sidebar → Materials |
| 3 | Find material by name |
| 4 | Expand material row |
| 5 | Click "+" / type alias / Enter |
**Total**: 5 steps. **Improvement**: "Add alias for: [part_name]" shortcut directly from Activity row detail would save 3 steps.
---
## Performance Observations
**Polling and refresh intervals**:
- `worker-activity` query: `refetchInterval: 60_000` in Layout (for badge indicators), but `WorkerActivity` page does not set `refetchInterval` — it relies on manual invalidation or navigation. The description says "Auto-refresh every 5 s" but the code at line 25 shows `useQuery` with no `refetchInterval`. This appears to be a documentation/UI string that does not match the implementation.
- Queue status: `refetchInterval: 5_000` in WorkerActivity's `QueuePanel`, `10_000` in WorkerManagement — different refresh rates for the same data on different pages.
- Orders list: `refetchInterval: 15000` — reasonable.
- Notifications: `refetchInterval: 5_000` in `getNotifications` call on the Notifications page; `getUnreadCount` in `NotificationCenter` polls at 15s. Good differentiation.
### API Call Pattern
- Dashboard: 15 independent widget API calls on mount — waterfall of loaders
- Activity: Every 5s polling — appropriate for live monitoring
- Notifications: Every 15s polling — appropriate
- Products: Immediate search (no debounce) — excessive API calls while typing
**Data loading**:
- Product Library loads up to 200 products at once with thumbnails — on a populated system this could result in a significant number of parallel image requests. Consider lazy-loading thumbnails (Intersection Observer) for the grid view.
- Dashboard loads 15 widget types, each making their own API calls. On first load this creates a large burst of parallel requests. The `WidgetContainer` + `useQuery` pattern handles this gracefully via React Query's deduplication, but server-side combined endpoints could reduce round-trips.
- `NewProductOrder` Step 1 uses `keepPreviousData: keepPreviousData` only on the price estimate query, not on the product search — navigating back to Step 1 from Step 2 may show a loading state while the same products re-fetch.
### Image Loading
- Thumbnails: `Cache-Control: max-age=3600`
- Product grid: No `loading="lazy"` on 200 simultaneous thumbnail requests ❌ — **Fix**: Add `loading="lazy"` to all thumbnail `<img>` tags
- Videos: `preload="metadata"` correctly loads only first frame ✅
**Bundle considerations**:
- `OrderDetail.tsx` at 80KB source is the largest single component file and will have a substantial compiled bundle weight. Code-splitting at the route level (React.lazy) is not visible in `App.tsx` — all routes appear to be eagerly imported.
- `Admin.tsx` at 79KB is similarly large. Splitting it at the tab level would reduce initial load.
### Layout Shift
- Dashboard: Widget skeleton height → actual content causes shift
- Products: Empty state → full grid shift (no skeleton)
- Activity: Stats cards appear before timeline rows
### Optimistic UI Opportunities
| Action | Current | Optimistic |
|--------|---------|------------|
| Mark notification read | Wait for API | Decrement badge immediately |
| Bulk delete orders | Wait for all deletes | Remove from list immediately |
| Submit order | Wait for response | Show "submitted" badge immediately |
### Blocking Interactions
- "Dispatch Renders" button: Correctly disabled during dispatch with loading spinner ✅
- "Save Settings" button: No loading state during save — button disappears on success (draft resets) which feels abrupt. **Fix**: Show spinner in button during save, then confirm with a brief checkmark before disappearing.
---
*Report generated 2026-03-08 via codebase analysis.*
*Next: Implement Priority 1 (Admin tabs) and Priority 2 (Upload step indicator) first for maximum UX impact.*