diff --git a/.gitignore b/.gitignore index a72732a..b933650 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ celerybeat.pid *.xlsx *.blend1 +backend/core diff --git a/LEARNINGS.md b/LEARNINGS.md index 59b2ab4..a2d9a4a 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -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 diff --git a/backend/alembic/versions/055_global_render_positions.py b/backend/alembic/versions/055_global_render_positions.py new file mode 100644 index 0000000..3882013 --- /dev/null +++ b/backend/alembic/versions/055_global_render_positions.py @@ -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") diff --git a/backend/alembic/versions/056_order_lines_global_render_position.py b/backend/alembic/versions/056_order_lines_global_render_position.py new file mode 100644 index 0000000..4aa5199 --- /dev/null +++ b/backend/alembic/versions/056_order_lines_global_render_position.py @@ -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") diff --git a/backend/alembic/versions/057_seed_global_render_positions.py b/backend/alembic/versions/057_seed_global_render_positions.py new file mode 100644 index 0000000..3007e3e --- /dev/null +++ b/backend/alembic/versions/057_seed_global_render_positions.py @@ -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')") + ) diff --git a/backend/alembic/versions/058_seed_additional_global_render_positions.py b/backend/alembic/versions/058_seed_additional_global_render_positions.py new file mode 100644 index 0000000..d0dcc99 --- /dev/null +++ b/backend/alembic/versions/058_seed_additional_global_render_positions.py @@ -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)')") + ) diff --git a/backend/alembic/versions/059_seed_top_global_position_and_clear_product_positions.py b/backend/alembic/versions/059_seed_top_global_position_and_clear_product_positions.py new file mode 100644 index 0000000..21a0d20 --- /dev/null +++ b/backend/alembic/versions/059_seed_top_global_position_and_clear_product_positions.py @@ -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) diff --git a/backend/alembic/versions/ce21c8a67543_add_part_materials_json_column_to_cad_.py b/backend/alembic/versions/ce21c8a67543_add_part_materials_json_column_to_cad_.py new file mode 100644 index 0000000..9589de7 --- /dev/null +++ b/backend/alembic/versions/ce21c8a67543_add_part_materials_json_column_to_cad_.py @@ -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 ### diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index 336dd59..f22d712 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -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), ) diff --git a/backend/app/api/routers/cad.py b/backend/app/api/routers/cad.py index 3736b8b..2dbe71c 100644 --- a/backend/app/api/routers/cad.py +++ b/backend/app/api/routers/cad.py @@ -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, + ) diff --git a/backend/app/api/routers/global_render_positions.py b/backend/app/api/routers/global_render_positions.py new file mode 100644 index 0000000..fcc988e --- /dev/null +++ b/backend/app/api/routers/global_render_positions.py @@ -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() diff --git a/backend/app/api/routers/orders.py b/backend/app/api/routers/orders.py index 9beb4f7..5adc1cd 100644 --- a/backend/app/api/routers/orders.py +++ b/backend/app/api/routers/orders.py @@ -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), diff --git a/backend/app/api/routers/products.py b/backend/app/api/routers/products.py index ef85322..bc7776c 100644 --- a/backend/app/api/routers/products.py +++ b/backend/app/api/routers/products.py @@ -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 diff --git a/backend/app/domains/media/router.py b/backend/app/domains/media/router.py index 8bdf5b5..00b6749 100644 --- a/backend/app/domains/media/router.py +++ b/backend/app/domains/media/router.py @@ -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, + } diff --git a/backend/app/domains/media/schemas.py b/backend/app/domains/media/schemas.py index 1dcec7e..45510b1 100644 --- a/backend/app/domains/media/schemas.py +++ b/backend/app/domains/media/schemas.py @@ -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 diff --git a/backend/app/domains/media/service.py b/backend/app/domains/media/service.py index 3bdc20c..579111e 100644 --- a/backend/app/domains/media/service.py +++ b/backend/app/domains/media/service.py @@ -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 diff --git a/backend/app/domains/orders/models.py b/backend/app/domains/orders/models.py index ec21ac6..39f0722 100644 --- a/backend/app/domains/orders/models.py +++ b/backend/app/domains/orders/models.py @@ -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" + ) diff --git a/backend/app/domains/orders/schemas.py b/backend/app/domains/orders/schemas.py index cc125a6..d25ef17 100644 --- a/backend/app/domains/orders/schemas.py +++ b/backend/app/domains/orders/schemas.py @@ -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 diff --git a/backend/app/domains/pipeline/tasks/export_glb.py b/backend/app/domains/pipeline/tasks/export_glb.py index cf6b0e2..519faeb 100644 --- a/backend/app/domains/pipeline/tasks/export_glb.py +++ b/backend/app/domains/pipeline/tasks/export_glb.py @@ -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, diff --git a/backend/app/domains/pipeline/tasks/render_order_line.py b/backend/app/domains/pipeline/tasks/render_order_line.py index 68cad50..11375f4 100644 --- a/backend/app/domains/pipeline/tasks/render_order_line.py +++ b/backend/app/domains/pipeline/tasks/render_order_line.py @@ -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() diff --git a/backend/app/domains/pipeline/tasks/render_thumbnail.py b/backend/app/domains/pipeline/tasks/render_thumbnail.py index 4cc1bbf..2a6ee43 100644 --- a/backend/app/domains/pipeline/tasks/render_thumbnail.py +++ b/backend/app/domains/pipeline/tasks/render_thumbnail.py @@ -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 diff --git a/backend/app/domains/products/models.py b/backend/app/domains/products/models.py index 758c8f3..0ed5c82 100644 --- a/backend/app/domains/products/models.py +++ b/backend/app/domains/products/models.py @@ -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 diff --git a/backend/app/domains/products/schemas.py b/backend/app/domains/products/schemas.py index e8f9c3d..5fd5580 100644 --- a/backend/app/domains/products/schemas.py +++ b/backend/app/domains/products/schemas.py @@ -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 diff --git a/backend/app/domains/rendering/models.py b/backend/app/domains/rendering/models.py index 4c7c3d5..252a72b 100644 --- a/backend/app/domains/rendering/models.py +++ b/backend/app/domains/rendering/models.py @@ -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" diff --git a/backend/app/domains/rendering/router.py b/backend/app/domains/rendering/router.py index 4fa39e8..1a2c02b 100644 --- a/backend/app/domains/rendering/router.py +++ b/backend/app/domains/rendering/router.py @@ -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"] diff --git a/backend/app/domains/rendering/schemas.py b/backend/app/domains/rendering/schemas.py index d4229b3..c014502 100644 --- a/backend/app/domains/rendering/schemas.py +++ b/backend/app/domains/rendering/schemas.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index e1b7fb3..366c867 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cda2b12..7aa5eda 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/services/step_processor.py b/backend/app/services/step_processor.py index b9d514a..740191d 100644 --- a/backend/app/services/step_processor.py +++ b/backend/app/services/step_processor.py @@ -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 diff --git a/docs/rfcs/0001-step-to-usd-workflow.md b/docs/rfcs/0001-step-to-usd-workflow.md new file mode 100644 index 0000000..4ed88a0 --- /dev/null +++ b/docs/rfcs/0001-step-to-usd-workflow.md @@ -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 today’s 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 today’s 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/ +/Root/Assembly// +/Root/Assembly///Mesh +/Root/Looks +/Root/Looks/ +``` + +### 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 system’s 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 today’s 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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c8b916a..67a64bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 + const location = useLocation() + if (!token) return return <>{children} } function AdminRoute({ children }: { children: React.ReactNode }) { const { token, user } = useAuthStore() if (!token) return - if (user?.role !== 'admin' && user?.role !== 'project_manager') return + if (!checkIsPrivileged(user)) return return <>{children} } @@ -123,6 +125,7 @@ export default function App() { } /> + } /> diff --git a/frontend/src/api/cad.ts b/frontend/src/api/cad.ts index 797f6a8..be92ea0 100644 --- a/frontend/src/api/cad.ts +++ b/frontend/src/api/cad.ts @@ -98,8 +98,56 @@ export async function generateGltfProduction(cadFileId: string): Promise { + const res = await api.get(`/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 + +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 { + const res = await api.get(`/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 { + const res = await api.put(`/cad/${cadFileId}/part-materials`, map) + return res.data.part_materials ?? {} +} diff --git a/frontend/src/api/media.ts b/frontend/src/api/media.ts index e3f0f2e..529aa3d 100644 --- a/frontend/src/api/media.ts +++ b/frontend/src/api/media.ts @@ -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 r.data) diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index abefa20..69076bd 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -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 } diff --git a/frontend/src/api/products.ts b/frontend/src/api/products.ts index 099cd73..e56906e 100644 --- a/frontend/src/api/products.ts +++ b/frontend/src/api/products.ts @@ -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 { diff --git a/frontend/src/api/renderPositions.ts b/frontend/src/api/renderPositions.ts new file mode 100644 index 0000000..fe7ae88 --- /dev/null +++ b/frontend/src/api/renderPositions.ts @@ -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 { + const res = await api.get('/render-positions/global') + return res.data +} + +export async function createGlobalRenderPosition(body: GlobalRenderPositionCreate): Promise { + const res = await api.post('/render-positions/global', body) + return res.data +} + +export async function updateGlobalRenderPosition(id: string, body: GlobalRenderPositionPatch): Promise { + const res = await api.patch(`/render-positions/global/${id}`, body) + return res.data +} + +export async function deleteGlobalRenderPosition(id: string): Promise { + await api.delete(`/render-positions/global/${id}`) +} diff --git a/frontend/src/components/admin/GlobalRenderPositionsPanel.tsx b/frontend/src/components/admin/GlobalRenderPositionsPanel.tsx new file mode 100644 index 0000000..6fefbda --- /dev/null +++ b/frontend/src/components/admin/GlobalRenderPositionsPanel.tsx @@ -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(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 }) => + 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) { + if (!editing) return null + return ( +
+ + setEditing({ ...editing, [field]: parseFloat(e.target.value) || 0 })} + /> +
+ ) + } + + if (isLoading) return

Loading…

+ + return ( +
+
+

+ Global camera rotation presets applied to all products. Per-product positions take priority. +

+ +
+ + + + + + + + + + + + + + {positions.map((pos) => { + const isEditingThis = editing && editing.id === pos.id + return ( + + {isEditingThis ? ( + <> + + + + + + + + + ) : ( + <> + + + + + + + + + )} + + ) + })} + + {/* New row */} + {adding && editing && ( + + + + + + + + + + )} + +
NameRot X°Rot Y°Rot Z°DefaultOrder +
+ setEditing({ ...editing!, name: e.target.value })} + /> + {rotField('', 'rotation_x')}{rotField('', 'rotation_y')}{rotField('', 'rotation_z')} + setEditing({ ...editing!, is_default: e.target.checked })} + /> + + setEditing({ ...editing!, sort_order: parseInt(e.target.value) || 0 })} + /> + + + + {pos.name}{pos.rotation_x}{pos.rotation_y}{pos.rotation_z} + {pos.is_default && } + {pos.sort_order} + + +
+ setEditing({ ...editing, name: e.target.value })} + /> + {rotField('', 'rotation_x')}{rotField('', 'rotation_y')}{rotField('', 'rotation_z')} + setEditing({ ...editing, is_default: e.target.checked })} + /> + + setEditing({ ...editing, sort_order: parseInt(e.target.value) || 0 })} + /> + + + +
+
+ ) +} diff --git a/frontend/src/components/cad/InlineCadViewer.tsx b/frontend/src/components/cad/InlineCadViewer.tsx index ec658e6..bc96d99 100644 --- a/frontend/src/components/cad/InlineCadViewer.tsx +++ b/frontend/src/components/cad/InlineCadViewer.tsx @@ -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 + controlsRef: React.RefObject + 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 + onReady: () => void + onPointerOver?: (e: any) => void + onPointerOut?: () => void + onClick?: (e: any) => void +}) { const { scene } = useGLTF(url) const cloned = useRef(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 + useEffect(() => { + sceneRef.current = cloned.current + onReady() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + ) } -const HEIGHT = 420 +const HEIGHT = 560 function ToolbarBtn({ active, onClick, children, title, @@ -70,7 +154,7 @@ function ToolbarBtn({ + + +
+ {/* Isolation toggles — ghost or hide all other parts */} + {onIsolateModeChange && ( +
+ + +
+ )} + + {/* Type tabs */} +
+ {(['library', 'hex'] as const).map((t) => ( + + ))} +
+ + {assignType === 'library' ? ( +
+ + +
+ ) : ( +
+ +
+ setHexValue(e.target.value)} + className="w-10 h-8 rounded border border-gray-600 bg-gray-800 cursor-pointer p-0.5" + /> + 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" + /> +
+
+ )} + + {/* Preview swatch */} +
+
+ Viewport preview color +
+ + {/* Current assignment */} + {currentEntry && ( +
+
+ Current: {currentEntry.value} +
+ )} + + {/* Actions */} +
+ + {currentEntry && ( + + )} +
+
+
+ ) +} diff --git a/frontend/src/components/cad/ThreeDViewer.tsx b/frontend/src/components/cad/ThreeDViewer.tsx index 61a7a10..723f562 100644 --- a/frontend/src/components/cad/ThreeDViewer.tsx +++ b/frontend/src/components/cad/ThreeDViewer.tsx @@ -4,18 +4,34 @@ import { useCallback, useState, useEffect, + useMemo, Component, type ErrorInfo, type ReactNode, } from 'react' import { useQuery } from '@tanstack/react-query' import { Canvas, useThree, useFrame } from '@react-three/fiber' -import { OrbitControls, useGLTF, Environment } from '@react-three/drei' +import { + OrbitControls, + useGLTF, + Environment, + PerspectiveCamera, + OrthographicCamera, + ContactShadows, + GizmoHelper, + GizmoViewport, +} from '@react-three/drei' +import * as THREE from 'three' import { toast } from 'sonner' import { X, Camera, Loader2, AlertTriangle, Box, Cpu, Download, ChevronDown, + Maximize2, Grid3X3, Sun, AlertCircle, EyeOff, } from 'lucide-react' import api from '../../api/client' +import { getParsedObjects, 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' // --------------------------------------------------------------------------- // Types @@ -28,57 +44,194 @@ export interface ThreeDViewerProps { geometryGltfUrl?: string /** URL for the production-quality GLB (Blender + PBR materials) */ productionGltfUrl?: string - /** Whether a geometry GLB exists (for hint display) */ hasGeometryGlb?: boolean - /** Whether a production GLB exists (for hint display) */ hasProductionGlb?: boolean - /** Called when the user clicks "Generate Geometry GLB" from the hint banner */ onGenerateGeometry?: () => void - /** Whether a geometry GLB generation is in progress */ isGeneratingGeometry?: boolean - /** Download URLs for assets */ - downloadUrls?: { - glb?: string - production?: string - blend?: string - } + downloadUrls?: { glb?: string; production?: string; blend?: string } + /** Pre-loaded material assignments from Product.cad_part_materials (Excel-driven) */ + initialPartMaterials?: PartMaterialMap } type ViewMode = 'geometry' | 'production' -const ENV_PRESETS = ['city', 'sunset', 'dawn', 'night', 'warehouse', 'forest', 'apartment', 'studio', 'park', 'lobby'] as const +const ENV_PRESETS = [ + 'city', 'sunset', 'dawn', 'night', 'warehouse', + 'forest', 'apartment', 'studio', 'park', 'lobby', +] as const type EnvPreset = typeof ENV_PRESETS[number] +const BG_COLORS = [ + { label: 'Black', value: '#0d1117' }, + { label: 'Dark', value: '#111827' }, + { label: 'Slate', value: '#1e293b' }, + { label: 'Light', value: '#e2e8f0' }, +] + +interface SceneInfo { + center: THREE.Vector3 + maxDim: number + groundY: number +} + // --------------------------------------------------------------------------- -// Inner model loader – separated so Suspense can catch it +// CameraFit — runs inside Canvas; repositions camera to fit the loaded model // --------------------------------------------------------------------------- -function GltfModel({ url, wireframe }: { url: string; wireframe: boolean }) { +function CameraFit({ + sceneRef, + controlsRef, + fitTrigger, + isOrtho, + onFitted, +}: { + sceneRef: React.MutableRefObject + controlsRef: React.RefObject + fitTrigger: number + isOrtho: boolean + onFitted: (info: SceneInfo) => void +}) { + const { camera, size } = useThree() + + useEffect(() => { + if (fitTrigger === 0 || !sceneRef.current) return + + // Compute bbox from meshes only (ignore lights / empty groups) + 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 groundY = box.min.y + + if (isOrtho) { + const oc = camera as THREE.OrthographicCamera + const aspect = size.width / size.height + const halfH = maxDim * 0.8 + oc.left = -halfH * aspect + oc.right = halfH * aspect + oc.top = halfH + oc.bottom = -halfH + oc.near = -maxDim * 20 + oc.far = maxDim * 20 + oc.zoom = 1 + oc.updateProjectionMatrix() + camera.position.set(center.x, center.y, center.z + maxDim * 5) + } else { + 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.1, center.y + maxDim * 0.3, 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) + if (!isOrtho) { + controlsRef.current.minDistance = maxDim * 0.05 + controlsRef.current.maxDistance = maxDim * 20 + } + controlsRef.current.update() + } + + onFitted({ center, maxDim, groundY }) + }, [fitTrigger]) // eslint-disable-line react-hooks/exhaustive-deps + + return null +} + +// --------------------------------------------------------------------------- +// SceneBackground — syncs background colour into the Three.js scene +// --------------------------------------------------------------------------- + +function SceneBackground({ color }: { color: string }) { + const { scene } = useThree() + useEffect(() => { + scene.background = new THREE.Color(color) + return () => { scene.background = null } + }, [color, scene]) + return null +} + +// --------------------------------------------------------------------------- +// CameraPositionTracker — keeps an up-to-date ref of camera position +// --------------------------------------------------------------------------- + +function CameraPositionTracker({ posRef }: { posRef: React.MutableRefObject<[number, number, number]> }) { + const { camera } = useThree() + useFrame(() => { + posRef.current = [camera.position.x, camera.position.y, camera.position.z] + }) + return null +} + +// --------------------------------------------------------------------------- +// ModelWithReady — loads GLB; exposes scene ref + pointer events +// --------------------------------------------------------------------------- + +interface ModelWithReadyProps { + url: string + wireframe: boolean + onReady: () => void + sceneRef: React.MutableRefObject + onPointerOver: (e: any) => void + onPointerOut: () => void + onClick: (e: any) => void +} + +function ModelWithReady({ url, wireframe, onReady, sceneRef, onPointerOver, onPointerOut, onClick }: ModelWithReadyProps) { const { scene } = useGLTF(url) + // Clone materials once on scene load so color assignments don't share references useEffect(() => { scene.traverse((child: any) => { if (child.isMesh) { - child.material = child.material.clone() - child.material.wireframe = wireframe + child.material = Array.isArray(child.material) + ? child.material.map((m: any) => m.clone()) + : child.material.clone() + } + }) + sceneRef.current = scene + onReady() + }, [scene]) // eslint-disable-line react-hooks/exhaustive-deps + + // Wireframe toggle: set property only — never replace the material object + useEffect(() => { + scene.traverse((child: any) => { + if (child.isMesh) { + const mats: any[] = Array.isArray(child.material) ? child.material : [child.material] + mats.forEach((m) => { m.wireframe = wireframe; m.needsUpdate = true }) } }) }, [scene, wireframe]) - return + return ( + + ) } // --------------------------------------------------------------------------- -// Screenshot helper – lives inside Canvas so it can access gl / useThree +// ScreenshotCapture // --------------------------------------------------------------------------- -interface ScreenshotCaptureProps { - enabled: boolean - cadFileId: string - onDone: () => void -} - -function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProps) { +function ScreenshotCapture({ enabled, cadFileId, onDone }: { + enabled: boolean; cadFileId: string; onDone: () => void +}) { const { gl } = useThree() const didCapture = useRef(false) @@ -87,35 +240,28 @@ function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProp didCapture.current = true const dataUrl = gl.domElement.toDataURL('image/png') - const [header, base64Data] = dataUrl.split(',') - const mimeMatch = header.match(/:(.*?);/) - const mimeType = mimeMatch ? mimeMatch[1] : 'image/png' - const byteCharacters = atob(base64Data) - const byteArray = new Uint8Array(byteCharacters.length) - for (let i = 0; i < byteCharacters.length; i++) { - byteArray[i] = byteCharacters.charCodeAt(i) - } - const blob = new Blob([byteArray], { type: mimeType }) - const formData = new FormData() - formData.append('thumbnail', blob, 'thumbnail.png') + const [header, b64] = dataUrl.split(',') + const mime = (header.match(/:(.*?);/) || [])[1] || 'image/png' + const bytes = atob(b64) + const arr = new Uint8Array(bytes.length) + for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i) + const blob = new Blob([arr], { type: mime }) + const fd = new FormData() + fd.append('thumbnail', blob, 'thumbnail.png') - api - .post(`/cad/${cadFileId}/regenerate-thumbnail`, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }) + api.post(`/cad/${cadFileId}/regenerate-thumbnail`, fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) .then(() => toast.success('Thumbnail captured and saved')) .catch(() => toast.error('Failed to save thumbnail')) - .finally(() => { - didCapture.current = false - onDone() - }) + .finally(() => { didCapture.current = false; onDone() }) }) return null } // --------------------------------------------------------------------------- -// Error boundary +// GltfErrorBoundary // --------------------------------------------------------------------------- class GltfErrorBoundary extends Component< @@ -126,9 +272,7 @@ class GltfErrorBoundary extends Component< super(props) this.state = { hasError: false } } - static getDerivedStateFromError(): { hasError: boolean } { - return { hasError: true } - } + static getDerivedStateFromError(): { hasError: boolean } { return { hasError: true } } componentDidCatch(error: Error, _info: ErrorInfo): void { this.props.onError(error.message || 'Failed to parse GLTF') } @@ -139,7 +283,7 @@ class GltfErrorBoundary extends Component< } // --------------------------------------------------------------------------- -// Loading overlay +// LoadingOverlay // --------------------------------------------------------------------------- function LoadingOverlay() { @@ -152,61 +296,26 @@ function LoadingOverlay() { } // --------------------------------------------------------------------------- -// Model loader with ready tracking +// EnvDropdown // --------------------------------------------------------------------------- -interface ModelWithReadyProps { - url: string - wireframe: boolean - onReady: () => void -} - -function ModelWithReady({ url, wireframe, onReady }: ModelWithReadyProps) { - const { scene } = useGLTF(url) - - useEffect(() => { - scene.traverse((child: any) => { - if (child.isMesh) { - child.material = child.material.clone() - child.material.wireframe = wireframe - } - }) - }, [scene, wireframe]) - - useEffect(() => { onReady() }, [onReady]) - return -} - -// --------------------------------------------------------------------------- -// Env preset dropdown -// --------------------------------------------------------------------------- - -function EnvDropdown({ - value, - onChange, -}: { - value: EnvPreset - onChange: (v: EnvPreset) => void -}) { +function EnvDropdown({ value, onChange }: { value: EnvPreset; onChange: (v: EnvPreset) => void }) { const [open, setOpen] = useState(false) return (
{open && ( -
- {ENV_PRESETS.map((p) => ( +
+ {ENV_PRESETS.map(p => ( @@ -218,7 +327,31 @@ function EnvDropdown({ } // --------------------------------------------------------------------------- -// Main exported component +// TBtn — small toolbar toggle button +// --------------------------------------------------------------------------- + +function TBtn({ active, onClick, title, children, disabled }: { + active?: boolean; onClick: () => void; title?: string + children: ReactNode; disabled?: boolean +}) { + return ( + + ) +} + +// --------------------------------------------------------------------------- +// Main ThreeDViewer component // --------------------------------------------------------------------------- export default function ThreeDViewer({ @@ -231,136 +364,469 @@ export default function ThreeDViewer({ onGenerateGeometry, isGeneratingGeometry, downloadUrls, + initialPartMaterials, }: ThreeDViewerProps) { - // Default to production mode if only production GLB is available const initialMode: ViewMode = productionGltfUrl && !geometryGltfUrl ? 'production' : 'geometry' - const [mode, setMode] = useState(initialMode) + const token = useAuthStore((s) => s.token) + + // View state + const [mode, setMode] = useState(initialMode) const [wireframe, setWireframe] = useState(false) const [envPreset, setEnvPreset] = useState('city') const [capturing, setCapturing] = useState(false) const [loadError, setLoadError] = useState(null) const [modelReady, setModelReady] = useState(false) + const [blobUrl, setBlobUrl] = useState(null) - const { data: settings3d } = useQuery({ - queryKey: ['admin-settings'], - queryFn: () => api.get('/admin/settings').then(r => r.data), - staleTime: 60_000, + // Tier 1 — fit + const [fitTrigger, setFitTrigger] = useState(0) + const [sceneInfo, setSceneInfo] = useState(null) + + // Tier 2 — scene options + const [isOrtho, setIsOrtho] = useState(false) + const [showGrid, setShowGrid] = useState(false) + const [showShadows, setShowShadows] = useState(false) + const [bgColor, setBgColor] = useState('#111827') + const [hoverInfo, setHoverInfo] = useState<{ name: string; x: number; y: number } | null>(null) + + // Task 5 — hovered mesh ref for emissive highlight + const hoveredMeshRef = useRef(null) + + // Task 7 — clicked (pinned) part for material panel + const [pinnedPart, setPinnedPart] = useState(null) + + // Task 8 — show unassigned toggle + const [showUnassigned, setShowUnassigned] = useState(false) + + // Hide assigned toggle — hides all parts that already have a material + const [hideAssigned, setHideAssigned] = useState(false) + + // Isolation mode — ghost/hide other parts while a part is pinned + const [isolateMode, setIsolateMode] = useState('none') + + // Refs + const sceneRef = useRef(null) + const controlsRef = useRef(null) + const camPosRef = useRef<[number, number, number]>([0, 0.1, 0.3]) + + // Dimension data from parsed_objects + const { data: parsedData } = useQuery({ + queryKey: ['cad-parsed-objects', cadFileId], + queryFn: () => getParsedObjects(cadFileId), + staleTime: Infinity, + retry: false, + }) + const dims = parsedData?.parsed_objects?.dimensions_mm + + // Total unique normalized mesh count (set once when model is ready) + const [totalMeshCount, setTotalMeshCount] = useState(0) + const [glbMeshNames, setGlbMeshNames] = useState>(new Set()) + + // Task 6 — load saved part-material assignments (manual overrides from the viewer) + const { data: savedPartMaterials = {} } = useQuery({ + queryKey: ['part-materials', cadFileId], + queryFn: () => getPartMaterials(cadFileId), + staleTime: 30_000, + retry: false, }) - // Resolve the active model URL: prefer selected mode, fall back to whichever URL exists - const activeUrl = - mode === 'production' && productionGltfUrl - ? productionGltfUrl - : geometryGltfUrl ?? productionGltfUrl + // Merge: initialPartMaterials (Product Excel data) as base; savedPartMaterials overrides + const partMaterials = useMemo( + () => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap), + [initialPartMaterials, savedPartMaterials], + ) - const handleModelReady = useCallback(() => setModelReady(true), []) - const handleError = useCallback((msg: string) => setLoadError(msg), []) + // Count how many unique GLB mesh types have a resolved material assignment + const assignedCount = useMemo( + () => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, partMaterials)).length, + [glbMeshNames, partMaterials], + ) + + // Raw URL selected by mode (used as stable key before blob fetch) + const rawActiveUrl = mode === 'production' && productionGltfUrl + ? productionGltfUrl + : geometryGltfUrl ?? productionGltfUrl + + // Resolved blob URL used in useGLTF (requires auth header) + const activeUrl = blobUrl + + const handleModelReady = useCallback(() => setModelReady(true), []) + const handleError = useCallback((msg: string) => setLoadError(msg), []) const handleCaptureDone = useCallback(() => setCapturing(false), []) + const handleFitted = useCallback((info: SceneInfo) => setSceneInfo(info), []) - // Reset ready state when URL changes + // Reset + fetch blob URL when selected model changes useEffect(() => { setModelReady(false) setLoadError(null) - }, [activeUrl]) + setSceneInfo(null) + setFitTrigger(0) + setBlobUrl(null) + + if (!rawActiveUrl || !token) return + + let objUrl = '' + fetch(rawActiveUrl, { headers: { Authorization: `Bearer ${token}` } }) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.blob() + }) + .then((blob) => { + objUrl = URL.createObjectURL(blob) + setBlobUrl(objUrl) + }) + .catch(() => setLoadError('Failed to load 3D model')) + + return () => { if (objUrl) URL.revokeObjectURL(objUrl) } + }, [rawActiveUrl, token]) // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-fit when model finishes loading + useEffect(() => { + if (modelReady) setFitTrigger(t => t + 1) + }, [modelReady]) + + // Compute unique normalized mesh names once (used in toolbar badge + assignedCount) + useEffect(() => { + if (!modelReady || !sceneRef.current) return + const names = new Set() + 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)) + }, [modelReady]) + + // Re-fit when switching projection mode + useEffect(() => { + if (modelReady) setFitTrigger(t => t + 1) + }, [isOrtho]) // eslint-disable-line react-hooks/exhaustive-deps + + // Task 6 — apply saved material colors after model loads or when partMaterials changes + 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) + 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]) + + // Apply/remove unassigned highlight — only glows when ≥1 assignment exists (for meaningful contrast) + 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((mat) => { + const m = mat as THREE.MeshStandardMaterial + if (!m || !('emissive' in m)) return + if (showUnassigned && hasAnyAssignment) { + const hasAssignment = !!resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials) + m.emissive.set(hasAssignment ? 0x000000 : 0xff4400) + m.emissiveIntensity = hasAssignment ? 0 : 0.8 + } else { + m.emissive.set(0x000000) + m.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 + 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]) + + // Keyboard shortcuts + useEffect(() => { + const down = (e: KeyboardEvent) => { + // Ignore when typing in an input/textarea + if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA') return + if (e.key === 'f' || e.key === 'F') setFitTrigger(t => t + 1) + if (e.key === 'w' || e.key === 'W') setWireframe(v => !v) + if (e.key === 'g' || e.key === 'G') setShowGrid(v => !v) + if (e.key === 's' || e.key === 'S') setShowShadows(v => !v) + if (e.key === 'Escape') { + if (pinnedPart) { setPinnedPart(null); return } + onClose() + } + } + window.addEventListener('keydown', down) + return () => window.removeEventListener('keydown', down) + }, [onClose, pinnedPart]) function handleDownload(url: string, filename: string) { const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) + a.href = url; a.download = filename + document.body.appendChild(a); a.click(); document.body.removeChild(a) } const hasBothModes = !!(geometryGltfUrl && productionGltfUrl) - return ( -
- {/* Toolbar */} -
- 3D Viewer + // Task 5 — hover: highlight mesh with emissive, restore on out + const handlePointerOver = useCallback((e: any) => { + e.stopPropagation() + const mesh = e.object as THREE.Mesh + const raw = (mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || '' + const name = normalizeMeshName(raw) || 'Part' + setHoverInfo({ name, x: e.nativeEvent.clientX, y: e.nativeEvent.clientY }) -
- {/* Mode toggle */} + // Restore previous hovered mesh (array-safe) + if (hoveredMeshRef.current && hoveredMeshRef.current !== mesh) { + const prev = hoveredMeshRef.current + if (!showUnassigned) { + const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material] + prevMats.forEach((m) => { + const mat = m as THREE.MeshStandardMaterial + if (mat && 'emissive' in mat) { mat.emissive.set(0x000000); mat.emissiveIntensity = 0 } + }) + } + } + + // Highlight new mesh + hoveredMeshRef.current = mesh + const mats = mesh?.material + ? (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]) + + const handlePointerOut = useCallback(() => { + setHoverInfo(null) + 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)) { + mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8 + } else { + mat.emissive.set(0x000000); mat.emissiveIntensity = 0 + } + }) + hoveredMeshRef.current = null + } + }, [showUnassigned, partMaterials]) + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + setHoverInfo(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : null) + }, []) + + // Task 7 — click to pin material panel + const handleClick = useCallback((e: any) => { + e.stopPropagation() + const mesh = e.object as THREE.Mesh + const name = normalizeMeshName((mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || '') + if (name) setPinnedPart(name) + }, []) + + return ( +
setPinnedPart(null)}> + + {/* ── Toolbar ────────────────────────────────────────────────────────── */} +
e.stopPropagation()} + > + 3D Viewer + +
+ + {/* Mode toggle: Geometry / Production */} {hasBothModes && (
- - + {(['geometry', 'production'] as const).map(m => ( + + ))}
)} - {/* Wireframe toggle */} - + {/* Wireframe */} + setWireframe(v => !v)} title="Wireframe (W)">Wire - {/* Environment preset */} + {/* Projection: Perspective / Ortho */} +
+ {(['Persp', 'Ortho'] as const).map(label => { + const ortho = label === 'Ortho' + return ( + + ) + })} +
+ + {/* Grid */} + setShowGrid(v => !v)} title="Grid (G)"> + + + + {/* Contact shadows */} + setShowShadows(v => !v)} title="Contact shadows (S)"> + + + + {/* Fit view */} + setFitTrigger(t => t + 1)} + title="Fit to model (F)" + disabled={!modelReady} + > + + + + {/* Show unassigned toggle — with assignment count badge */} + {modelReady && ( + setShowUnassigned(v => !v)} + title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`} + > + + {assignedCount}/{totalMeshCount} + + )} + + {/* Hide assigned toggle */} + {modelReady && Object.keys(partMaterials).length > 0 && ( + setHideAssigned(v => !v)} + title="Hide parts that already have a material assigned" + > + + Hide assigned + + )} + + {/* Environment */} - {/* Download buttons */} + {/* Background colour swatches */} +
+ {BG_COLORS.map(c => ( +
+ + {/* Downloads */} {downloadUrls?.glb && ( )} {downloadUrls?.production && ( )} {downloadUrls?.blend && ( )} - {/* Capture button */} + {/* Capture thumbnail */} {/* Close */} @@ -369,91 +835,177 @@ export default function ThreeDViewer({ className="p-1.5 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors" aria-label="Close viewer" > - +
- {/* Hint banners */} + {/* ── Hint banners ───────────────────────────────────────────────────── */} {!hasProductionGlb && (
- - No Production GLB yet. Go to the product page and click "Generate Production GLB" to create a high-quality version with PBR materials and proper mesh smoothing. - + No Production GLB yet. Generate a high-quality version with PBR materials from the product page.
)} {!hasGeometryGlb && hasProductionGlb && onGenerateGeometry && (
- - Showing Production GLB. Generate a Geometry GLB to enable the mode toggle and compare geometry vs. production quality. - - {isGeneratingGeometry ? ( - - - Generating… - - ) : ( - - )} + Showing Production GLB. Generate a Geometry GLB to enable mode toggle. + {isGeneratingGeometry + ? Generating… + : + }
)} - {/* Viewport */} -
+ {/* ── Viewport ───────────────────────────────────────────────────────── */} + {/* onClick stops propagation so mesh-clicks don't bubble to the outer setPinnedPart(null) */} +
e.stopPropagation()}> + + {/* Error state */} {loadError && (

Failed to load 3D model

{loadError}

- +
)} + {/* Loading overlay */} {!modelReady && !loadError && } + {/* Dimension badge — bottom-left */} + {modelReady && dims && ( +
+
+ {dims.x.toFixed(1)} × {dims.y.toFixed(1)} × {dims.z.toFixed(1)} mm +
+
+ )} + + {/* Part-name tooltip */} + {hoverInfo && !pinnedPart && ( +
+ {hoverInfo.name} +
+ )} + + {/* Task 7 — Material assignment panel (pinned) */} + {pinnedPart && ( + setPinnedPart(null)} + isolateMode={isolateMode} + onIsolateModeChange={setIsolateMode} + /> + )} + + {/* Keyboard hint — bottom-right */} +
+ F fit · W wire · G grid · S shadow · click part to assign · Esc close +
+ setPinnedPart(null)} > + {/* Background colour */} + + + {/* Camera — switch between perspective and orthographic */} + {isOrtho + ? + : + } + + {/* Track camera position for smooth camera type switching */} + + + {/* Lights */} - + + {/* Model */} {activeUrl && ( - + )} + {/* Ground grid */} + {showGrid && sceneInfo && ( + + )} + + {/* Contact shadows */} + {showShadows && sceneInfo && ( + + )} + + {/* Orbit controls */} + + {/* Environment lighting */} + {/* Orientation gizmo (always visible, bottom-right) */} + + + + + {/* Camera auto-fit */} + + + {/* Screenshot capture */} {capturing && ( , +): 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 +} diff --git a/frontend/src/components/dashboard/DashboardGrid.tsx b/frontend/src/components/dashboard/DashboardGrid.tsx index 178c0d6..694d730 100644 --- a/frontend/src/components/dashboard/DashboardGrid.tsx +++ b/frontend/src/components/dashboard/DashboardGrid.tsx @@ -158,7 +158,7 @@ function DashboardGridInner() { className="btn-secondary text-sm flex items-center gap-1.5 ml-auto" > - Anpassen + Customize
@@ -171,7 +171,7 @@ function DashboardGridInner() {
) : (widgets ?? []).length === 0 ? (
- No widgets configured. Click Anpassen to add widgets. + No widgets configured. Click Customize to add widgets.
) : (
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', - ) - } - > - - Admin - - )} - {(checkIsPrivileged(user)) && ( - 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', - ) - } - > - - Billing - - )} - {(checkIsPrivileged(user)) && ( - 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', - ) - } - > - - Media Browser - - )} - {(checkIsPrivileged(user)) && ( - 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', - ) - } - > - - Workers - - )} - {(checkIsPrivileged(user)) && ( - 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', - ) - } - > - - Workflows - - )} - {(checkIsPrivileged(user)) && ( - 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', - ) - } - > - - Asset Libraries - + {checkIsPrivileged(user) && ( + <> +
+ + Management + +
+ {privilegedNav.map(({ to, icon: Icon, label }) => ( + 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', + ) + } + > + + {label} + + ))} + )} + {checkIsAdmin(user) && ( - 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', - ) - } - > - - Notification Settings - - )} - {checkIsAdmin(user) && ( - 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', - ) - } - > - - Tenants - + <> +
+ + Admin Only + +
+ {adminOnlyNav.map(({ to, icon: Icon, label }) => ( + 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', + ) + } + > + + {label} + + ))} + )} diff --git a/frontend/src/components/renders/RenderInfoModal.tsx b/frontend/src/components/renders/RenderInfoModal.tsx new file mode 100644 index 0000000..ed08c9e --- /dev/null +++ b/frontend/src/components/renders/RenderInfoModal.tsx @@ -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 ( +

+ {children} +

+ ) +} + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value ?? '—'} +
+ ) +} + +function BoolPill({ value, trueLabel = 'Yes', falseLabel = 'No' }: { value: boolean | undefined; trueLabel?: string; falseLabel?: string }) { + if (value == null) return + return ( + + {value ? trueLabel : falseLabel} + + ) +} + +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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

{title} — Render Info

+ +
+ + {/* Body */} +
+ {/* Error */} + {hasError && ( +
+

Render Error

+
{rl!.error}
+
+ )} + + {/* Render Settings */} + {rl && ( +
+ Render Settings +
+ {rl.renderer && } + + {device && ( + + {isGpu ? : } + {device} + + } + /> + )} + {rl.samples != null && } + {rl.compute_type && } + {rl.gpu_fallback != null && ( + } /> + )} + {rl.format && } + {rl.parts_count != null && } + {rl.stl_quality && } + {rl.smooth_angle != null && } +
+
+ )} + + {/* Timing */} + {rl && (rl.total_duration_s != null || rl.stl_duration_s != null || rl.render_duration_s != null) && ( +
+ Timing +
+ + {rl.stl_duration_s != null && } + {rl.render_duration_s != null && } + {isAnimation && rl.ffmpeg_duration_s != null && } + {isAnimation && rl.frame_count != null && } + {isAnimation && rl.fps != null && } +
+
+ )} + + {/* Files */} + {rl && (rl.output_size_bytes != null || rl.stl_size_bytes != null) && ( +
+ Files +
+ {rl.output_size_bytes != null && } + {rl.stl_size_bytes != null && } +
+
+ )} + + {/* Template */} + {hasTemplate && rl && ( +
+ Template +
+ {rl.template}} /> + {rl.lighting_only != null && } />} + {rl.shadow_catcher != null && } />} + {rl.material_replace != null && } />} +
+
+ )} + + {/* Timestamps */} + {hasTimestamps && ( +
+ Timestamps +
+ {renderStartedAt && } + {renderCompletedAt && } +
+
+ )} + + {/* Blender Log */} + {hasLog && rl && ( +
+ + {logExpanded && ( +
+                  {rl.log_lines!.join('\n')}
+                
+ )} + {!logExpanded && ( +

{rl.log_lines!.length} lines — click to expand

+ )} +
+ )} + + {!rl && ( +

No render metadata available.

+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/shared/StepIndicator.tsx b/frontend/src/components/shared/StepIndicator.tsx new file mode 100644 index 0000000..d7f3ded --- /dev/null +++ b/frontend/src/components/shared/StepIndicator.tsx @@ -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 */} +
+ + Step {step} of {total} + {labels[step - 1] ? ` — ${labels[step - 1]}` : ''} + +
+ + {/* Desktop: full step bar */} +
+ {Array.from({ length: total }, (_, i) => { + const num = i + 1 + const isCompleted = num < step + const isActive = num === step + const isFuture = num > step + + return ( +
+ {/* Step circle + label */} +
+
+ {isCompleted ? : num} +
+ + {labels[i] ?? `Step ${num}`} + +
+ + {/* Connector line (not after last step) */} + {num < total && ( +
+ )} +
+ ) + })} +
+ + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 1e92395..f7a538a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 78022cd..25fe57a 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -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(null) + const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true }) const [editingTemplateId, setEditingTemplateId] = useState(null) const [priorityNewEntry, setPriorityNewEntry] = useState('') @@ -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('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 ( +
+ {/* Tab header */} +
+
+

Admin

+ {hasUnsavedChanges && ( +
+ + Unsaved changes +
+ )} +
+
+ {TABS.map((tab) => ( + + ))} +
+
+
-

Admin

{/* ------------------------------------------------------------------ */} {/* Pricing Summary */} {/* ------------------------------------------------------------------ */} - + {activeTab === 'overview' && } {/* ------------------------------------------------------------------ */} {/* Users (admin only) */} {/* ------------------------------------------------------------------ */} - {isAdmin &&
+ {activeTab === 'users' && isAdmin &&

Users

+ {users?.map((u) => ( +
+ {editingUserId === u.id ? ( +
+
+
+ + setEditUserDraft((d) => ({ ...d, full_name: e.target.value }))} + className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full" + /> +
+
+ + +
+
+
+ +
+ + +
+
+
+ ) : ( +
+
+

{u.full_name}

+

{u.email}

+
+ + {u.role} + + + {u.is_active ? 'active' : 'inactive'} + + + +
+ )}
))}
@@ -366,7 +510,7 @@ export default function AdminPage() { {/* ------------------------------------------------------------------ */} {/* Blender Render Settings (admin only) */} {/* ------------------------------------------------------------------ */} - {isAdmin &&
+ {activeTab === 'render' && isAdmin &&
@@ -788,6 +932,34 @@ export default function AdminPage() {

Registers existing renders & CAD thumbnails in the Media Browser.

+
+ +

Removes DB records for renders whose files no longer exist on disk.

+
+
+ +

Removes STEP files, thumbnails, and DB records not linked to any product.

+
} + {/* ------------------------------------------------------------------ */} + {/* Global Render Positions (admin only) */} + {/* ------------------------------------------------------------------ */} + {activeTab === 'render' && isAdmin &&
+
+ +
+

Global Render Positions

+

+ Camera rotation presets available to all products. Per-product positions override these. +

+
+
+
+ +
+
} + {/* ------------------------------------------------------------------ */} {/* Render Templates (admin/PM) */} {/* ------------------------------------------------------------------ */} -
+ {activeTab === 'render' &&
@@ -836,17 +1026,17 @@ export default function AdminPage() {
-
+
} {/* ------------------------------------------------------------------ */} {/* Asset Libraries */} {/* ------------------------------------------------------------------ */} - + {activeTab === 'libraries' && } {/* ------------------------------------------------------------------ */} {/* Output Types */} {/* ------------------------------------------------------------------ */} -
+ {activeTab === 'pricing' &&
@@ -857,12 +1047,12 @@ export default function AdminPage() {
-
+
} {/* ------------------------------------------------------------------ */} {/* Pricing Tiers */} {/* ------------------------------------------------------------------ */} -
+ {activeTab === 'pricing' &&
@@ -873,12 +1063,12 @@ export default function AdminPage() {
-
+
} {/* ------------------------------------------------------------------ */} {/* E-Mail / SMTP Settings */} {/* ------------------------------------------------------------------ */} - {isAdmin && ( + {activeTab === 'config' && isAdmin && (

E-Mail Notifications (SMTP)

@@ -966,7 +1156,7 @@ export default function AdminPage() { {/* ------------------------------------------------------------------ */} {/* Templates */} {/* ------------------------------------------------------------------ */} -
+ {activeTab === 'libraries' &&

Templates

@@ -1017,12 +1207,12 @@ export default function AdminPage() { ) })}

-
+
} {/* ------------------------------------------------------------------ */} {/* Dashboard Widget Configuration (admin only) */} {/* ------------------------------------------------------------------ */} - {isAdmin && ( + {activeTab === 'config' && isAdmin && (
@@ -1066,7 +1256,7 @@ export default function AdminPage() { {/* ------------------------------------------------------------------ */} {/* 3D Viewer & GLB Export Settings */} {/* ------------------------------------------------------------------ */} -
+ {activeTab === 'render' &&

3D Viewer & GLB Export

@@ -1205,12 +1395,12 @@ export default function AdminPage() { )}

-
+
} {/* ------------------------------------------------------------------ */} {/* Tessellation Quality */} {/* ------------------------------------------------------------------ */} -
+ {activeTab === 'render' &&

Tessellation Quality

@@ -1218,6 +1408,58 @@ export default function AdminPage() {

+ {/* 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 ( +
+

Presets

+
+ {PRESETS.map(preset => ( + + ))} +
+
+ ) + })()} + + {/* Manual inputs */}

Preview (Geometry GLB)

@@ -1238,10 +1480,10 @@ export default function AdminPage() { 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() { 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() { )}
-
+
} {/* ------------------------------------------------------------------ */} {/* Material Library link */} {/* ------------------------------------------------------------------ */} -
+ {activeTab === 'render' &&

Material Library

@@ -1308,7 +1550,106 @@ export default function AdminPage() { Open Material Library → -

+
} + + {/* ------------------------------------------------------------------ */} + {/* GPU Status */} + {/* ------------------------------------------------------------------ */} + {activeTab === 'render' && isAdmin && ( +
+ + + {gpuProbeExpanded && ( +
+
+ + {gpuProbeResult && ( + + Last checked: {new Date(gpuProbeResult.timestamp).toLocaleString()} + + )} +
+ + {gpuProbeResult && ( +
+
+ Status + {gpuStatusBadge()} +
+ {gpuProbeResult.device_type && ( +
+ Device type + {gpuProbeResult.device_type} +
+ )} + {gpuProbeResult.devices && gpuProbeResult.devices.length > 0 && ( +
+ Devices +
+ {gpuProbeResult.devices.map((d: string, i: number) => ( + {d} + ))} +
+
+ )} + {gpuProbeResult.render_time_s != null && ( +
+ Render time + {gpuProbeResult.render_time_s.toFixed(2)}s +
+ )} + {gpuProbeResult.error && ( +
+ Error + {gpuProbeResult.error} +
+ )} +
+ )} + + {!gpuProbeResult && !gpuProbing && ( +

No probe result yet. Click "Run GPU Check" to trigger a test render.

+ )} +
+ )} +
+ )} + + setConfirmState((s) => ({ ...s, open: false }))} + /> +
) } @@ -1393,6 +1734,7 @@ function AssetLibraryPanel() { const [newDesc, setNewDesc] = useState('') const [newFile, setNewFile] = useState(null) const [expanded, setExpanded] = useState>(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() {
)} - {/* ------------------------------------------------------------------ */} - {/* GPU Status (admin only) */} - {/* ------------------------------------------------------------------ */} - {isAdmin && ( -
- - - {gpuProbeExpanded && ( -
-
- - {gpuProbing && ( - - Polling for result (up to 45s)… - - )} -
- - {gpuProbeResult && ( -
-
- Status - {gpuStatusBadge()} -
- {gpuProbeResult.device_type && ( -
- Device type - {gpuProbeResult.device_type} -
- )} - {gpuProbeResult.error && ( -
- Error - {gpuProbeResult.error} -
- )} - {gpuProbeResult.probed_at && ( -
- Probed at - - {new Date(gpuProbeResult.probed_at).toLocaleString()} - -
- )} -
- )} - - {!gpuProbeResult && !gpuProbing && ( -

- No probe result yet. Click "Run GPU Check" to trigger a check on the render worker. -

- )} -
- )} -
- )} - iso ? new Date(iso).toLocaleDateString('de-DE') : '—' const STATUS_COLORS: Record = { - 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() { setPassword(e.target.value)} - required - className="input-base w-full" - /> +
+ setPassword(e.target.value)} + required + className="input-base w-full pr-10" + /> + +
+ + {/* Media */} +
e.stopPropagation()} + > + {isVideo ? ( +
+ + {/* Caption */} +
e.stopPropagation()} + > +
+
+ {asset.product_name &&

{asset.product_name}

} +

+ {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)}`} +

+
+ {asset.download_url && ( + e.stopPropagation()} + > + + Download + + )} +
+
+
+ ) } // ── 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 (
+ {/* Select checkbox — top-left, always shown when selected, hover otherwise */} + + {/* Preview area */}
isPreviewable ? onPreview(asset) : onToggleSelect(asset.id)} > {isImage && asset.thumbnail_url ? ( - {asset.asset_type} - ) : isVideo && asset.thumbnail_url ? ( - {asset.asset_type} + {asset.asset_type} +
+ ) : isVideo ? ( +
{/* Info */}
- + {asset.asset_type} {asset.download_url && ( - + )}
{asset.product_name && ( @@ -138,6 +277,21 @@ function AssetCard({ asset }: { asset: MediaAssetItem }) { {asset.product_pim_id && (

{asset.product_pim_id}

)} + {(asset.product_baureihe || asset.product_lagertyp) && ( +

+ {[asset.product_baureihe, asset.product_lagertyp].filter(Boolean).join(' · ')} +

+ )} + {(asset.product_ebene1 || asset.product_ebene2) && ( +

+ {[asset.product_ebene1, asset.product_ebene2].filter(Boolean).join(' › ')} +

+ )} + {(asset.product_name_cad_modell || asset.product_produkt_baureihe) && ( +

+ {asset.product_name_cad_modell ?? asset.product_produkt_baureihe} +

+ )}
{formatDate(asset.created_at)} {sizeStr && · {sizeStr}} @@ -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>(new Set()) + const [zipping, setZipping] = useState(false) + + // Lightbox + const [previewAsset, setPreviewAsset] = useState(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 (
+ {/* Lightbox */} + {previewAsset && ( + setPreviewAsset(null)} /> + )} + {/* Sticky filter bar */}
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 => )} + {ASSET_TYPES_MEDIA.map(o => )} + {showTechnical && ( + <> + + {ASSET_TYPES_TECHNICAL.map(o => )} + + )} + {/* Technical files toggle */} + + {/* Category */} 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 && ( + + )}
+ +
+ + +
{/* Content */} {isLoading ? ( -
Loading products\u2026
+
+ {Array.from({ length: 12 }, (_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
) : !products?.length ? (
@@ -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" /> ) : ( @@ -379,7 +434,10 @@ export default function ProductLibraryPage() { {/* ── Floating action bar ───────────────────────────────────────── */} {selected.size > 0 && ( -
+
{selected.size} selected diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx index 7374e13..49167d5 100644 --- a/frontend/src/pages/Upload.tsx +++ b/frontend/src/pages/Upload.tsx @@ -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() {

+ + {/* ── Step 1: Excel drop zone ─────────────────────────────────────── */} {step === 1 && (
PIM-ID Series - Gew. Produkt + + + Gew. Produkt + + + Category Status STEP @@ -477,59 +484,6 @@ export default function UploadPage() {
)} - {/* ── Step 4: Upload STEP Files ────────────────────────────────────── */} - {step === 4 && createdOrder && ( -
-
-
-
- -

- Upload STEP Files — {createdOrder.order_number} -

-
-
-

- Drop one or more .stp / .step 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. -

-
- -
- qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })} - /> -
- -
- - -
-
- )} - - {/* ── Validation Dialog ────────────────────────────────────────────── */} - {showValidationDialog && ( - setShowValidationDialog(false)} - onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })} - /> - )} - {/* ── Step 3: Output Type Selection ───────────────────────────────── */} {step === 3 && previewResult && (
@@ -684,6 +638,59 @@ export default function UploadPage() {
)} + + {/* ── Validation Dialog ────────────────────────────────────────────── */} + {showValidationDialog && ( + setShowValidationDialog(false)} + onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })} + /> + )} + + {/* ── Step 4: Upload STEP Files ────────────────────────────────────── */} + {step === 4 && createdOrder && ( +
+
+
+
+ +

+ Upload STEP Files — {createdOrder.order_number} +

+
+
+

+ Drop one or more .stp / .step 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. +

+
+ +
+ qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })} + /> +
+ +
+ + +
+
+ )}
) } diff --git a/frontend/src/pages/WorkerActivity.tsx b/frontend/src/pages/WorkerActivity.tsx index d779d79..5deb9a9 100644 --- a/frontend/src/pages/WorkerActivity.tsx +++ b/frontend/src/pages/WorkerActivity.tsx @@ -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>(new Set()) + const [logExpanded, setLogExpanded] = useState>(new Set()) const { data, isLoading, dataUpdatedAt } = useQuery({ queryKey: ['worker-activity'], @@ -94,9 +96,18 @@ export default function WorkerActivityPage() {
)} - {isLoading && ( -
- Loading activity… + {isLoading && events.length === 0 && ( +
+ {[0,1,2,3].map((i) => ( +
+
+
+
+
+
+
+
+ ))}
)} @@ -127,6 +138,7 @@ export default function WorkerActivityPage() { )}
)} +
) } @@ -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 && (
)}
+ + { purgeMut.mutate(); setPurgeConfirmOpen(false) }} + onCancel={() => setPurgeConfirmOpen(false)} + />
) } @@ -577,8 +596,12 @@ function KV({ label, value, mono, highlight }: { } function BlenderLog({ lines }: { lines: string[] }) { + const [expanded, setExpanded] = useState(false) return ( -
+
+
         {lines.map((l, i) => {
           const color =
@@ -592,6 +615,16 @@ function BlenderLog({ lines }: { lines: string[] }) {
           )
         })}
       
+
+ {lines.length > 20 && ( + + )}
) } @@ -610,7 +643,7 @@ function RendererBadge({ log }: { log: RenderLog }) { } if (log.renderer === 'threejs') { return ( - + Three.js ) diff --git a/plan.md b/plan.md index 7ebb25b..ceb5c40 100644 --- a/plan.md +++ b/plan.md @@ -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 + + ``` + 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. diff --git a/render-worker/scripts/export_gltf.py b/render-worker/scripts/export_gltf.py index 33ab485..16ead80 100644 --- a/render-worker/scripts/export_gltf.py +++ b/render-worker/scripts/export_gltf.py @@ -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 diff --git a/render-worker/scripts/export_step_to_gltf.py b/render-worker/scripts/export_step_to_gltf.py index 592d3c8..edd42da 100644 --- a/render-worker/scripts/export_step_to_gltf.py +++ b/render-worker/scripts/export_step_to_gltf.py @@ -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 mm→m 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(" 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() diff --git a/render-worker/scripts/still_render.py b/render-worker/scripts/still_render.py index 0627fd7..f8668fe 100644 --- a/render-worker/scripts/still_render.py +++ b/render-worker/scripts/still_render.py @@ -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() diff --git a/render-worker/scripts/turntable_render.py b/render-worker/scripts/turntable_render.py index 2502e24..e036788 100644 --- a/render-worker/scripts/turntable_render.py +++ b/render-worker/scripts/turntable_render.py @@ -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() diff --git a/renderproblems_tmp/tesselation_probllem2.png b/renderproblems_tmp/tesselation_probllem2.png new file mode 100644 index 0000000..1a8e811 Binary files /dev/null and b/renderproblems_tmp/tesselation_probllem2.png differ diff --git a/renderproblems_tmp/tesselation_probllem3.png b/renderproblems_tmp/tesselation_probllem3.png new file mode 100644 index 0000000..0ac7388 Binary files /dev/null and b/renderproblems_tmp/tesselation_probllem3.png differ diff --git a/renderproblems_tmp/tesselation_probllempng.png b/renderproblems_tmp/tesselation_probllempng.png new file mode 100644 index 0000000..eb0ff2e Binary files /dev/null and b/renderproblems_tmp/tesselation_probllempng.png differ diff --git a/review-report.md b/review-report.md index 96a59e4..59b77a0 100644 --- a/review-report.md +++ b/review-report.md @@ -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 `
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: ✅ diff --git a/tools/restore_sharp_marks.py b/tools/restore_sharp_marks.py index 635daf2..b758d2e 100644 --- a/tools/restore_sharp_marks.py +++ b/tools/restore_sharp_marks.py @@ -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.") diff --git a/visual-audit-report.md b/visual-audit-report.md index 95162bd..24de323 100644 --- a/visual-audit-report.md +++ b/visual-audit-report.md @@ -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 710–855) +### 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 39–76) 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 `` 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 `` / 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 ``. -### 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 593–628) +### 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 34–76) 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 `/` 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 214–244) +### 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 496–509) +### 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 `` 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 / A–Z / 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 (1–2h) | -| 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 (3–4h) | -| P2 | Navigation | Flat sidebar with 14+ items, no section grouping | Add a `
` + 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 (3–5h) | -| 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 (1–2d) | -| 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 (4–6h) | -| 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 `` 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 `` 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 ``. 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 ``. 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 494–505): `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 1–3 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 `` 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.*