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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:40:36 +01:00
parent 202b06a026
commit ca62319688
70 changed files with 6551 additions and 1130 deletions
@@ -0,0 +1,32 @@
"""Add global_render_positions table.
Revision ID: 055
Revises: 054
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "055"
down_revision = "ce21c8a67543"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"global_render_positions",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("rotation_x", sa.Float, nullable=False, server_default="0.0"),
sa.Column("rotation_y", sa.Float, nullable=False, server_default="0.0"),
sa.Column("rotation_z", sa.Float, nullable=False, server_default="0.0"),
sa.Column("is_default", sa.Boolean, nullable=False, server_default="false"),
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("now()")),
)
def downgrade() -> None:
op.drop_table("global_render_positions")
@@ -0,0 +1,29 @@
"""Add global_render_position_id FK to order_lines.
Revision ID: 056
Revises: 055
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "056"
down_revision = "055"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"order_lines",
sa.Column(
"global_render_position_id",
UUID(as_uuid=True),
sa.ForeignKey("global_render_positions.id", ondelete="SET NULL"),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("order_lines", "global_render_position_id")
@@ -0,0 +1,38 @@
"""Seed default global render positions (Beauty, 3/4 Front, 3/4 Back).
Revision ID: 057
Revises: 056
"""
from alembic import op
import sqlalchemy as sa
revision = "057"
down_revision = "056"
branch_labels = None
depends_on = None
_DEFAULT_POSITIONS = [
{"name": "Beauty", "rotation_x": 0.0, "rotation_y": 0.0, "rotation_z": 0.0, "is_default": True, "sort_order": 0},
{"name": "3/4 Front", "rotation_x": -15.0, "rotation_y": 45.0, "rotation_z": 0.0, "is_default": False, "sort_order": 1},
{"name": "3/4 Back", "rotation_x": -15.0, "rotation_y": -135.0, "rotation_z": 0.0, "is_default": False, "sort_order": 2},
]
def upgrade() -> None:
conn = op.get_bind()
for pos in _DEFAULT_POSITIONS:
conn.execute(
sa.text(
"INSERT INTO global_render_positions (id, name, rotation_x, rotation_y, rotation_z, is_default, sort_order, created_at, updated_at) "
"VALUES (gen_random_uuid(), :name, :rx, :ry, :rz, :is_default, :sort_order, now(), now())"
),
{"name": pos["name"], "rx": pos["rotation_x"], "ry": pos["rotation_y"], "rz": pos["rotation_z"],
"is_default": pos["is_default"], "sort_order": pos["sort_order"]},
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text("DELETE FROM global_render_positions WHERE name IN ('Beauty', '3/4 Front', '3/4 Back')")
)
@@ -0,0 +1,37 @@
"""Seed additional global render positions (3/4 Front mirrored, 3/4 Back mirrored).
Revision ID: 058
Revises: 057
"""
from alembic import op
import sqlalchemy as sa
revision = "058"
down_revision = "057"
branch_labels = None
depends_on = None
_ADDITIONAL_POSITIONS = [
{"name": "3/4 Front (mirrored)", "rotation_x": -15.0, "rotation_y": -45.0, "rotation_z": 0.0, "is_default": False, "sort_order": 3},
{"name": "3/4 Back (mirrored)", "rotation_x": -15.0, "rotation_y": 135.0, "rotation_z": 0.0, "is_default": False, "sort_order": 4},
]
def upgrade() -> None:
conn = op.get_bind()
for pos in _ADDITIONAL_POSITIONS:
conn.execute(
sa.text(
"INSERT INTO global_render_positions (id, name, rotation_x, rotation_y, rotation_z, is_default, sort_order, created_at, updated_at) "
"VALUES (gen_random_uuid(), :name, :rx, :ry, :rz, :is_default, :sort_order, now(), now())"
),
{"name": pos["name"], "rx": pos["rotation_x"], "ry": pos["rotation_y"], "rz": pos["rotation_z"],
"is_default": pos["is_default"], "sort_order": pos["sort_order"]},
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text("DELETE FROM global_render_positions WHERE name IN ('3/4 Front (mirrored)', '3/4 Back (mirrored)')")
)
@@ -0,0 +1,31 @@
"""Add Top global render position and clear all per-product positions.
Revision ID: 059
Revises: 058
"""
from alembic import op
import sqlalchemy as sa
revision = "059"
down_revision = "058"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Add Top perspective
conn.execute(
sa.text(
"INSERT INTO global_render_positions (id, name, rotation_x, rotation_y, rotation_z, is_default, sort_order, created_at, updated_at) "
"VALUES (gen_random_uuid(), 'Top', -90.0, 0.0, 0.0, false, 5, now(), now())"
)
)
# Remove all per-product render positions (now redundant with global ones)
conn.execute(sa.text("DELETE FROM product_render_positions"))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DELETE FROM global_render_positions WHERE name = 'Top'"))
# Per-product positions are not restored on downgrade (data was intentionally cleared)
@@ -0,0 +1,186 @@
"""add part_materials json column to cad_files
Revision ID: ce21c8a67543
Revises: 054
Create Date: 2026-03-09 20:11:52.201187
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'ce21c8a67543'
down_revision: Union[str, None] = '054'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('app_config')
op.drop_index(op.f('ix_audit_log_channel'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_notification_ts'), table_name='audit_log', postgresql_where='(notification = true)')
op.drop_index(op.f('ix_audit_log_target_notification'), table_name='audit_log')
op.add_column('cad_files', sa.Column('part_materials', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
op.alter_column('cad_files', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_constraint(op.f('cad_files_file_hash_key'), 'cad_files', type_='unique')
op.drop_index(op.f('ix_cad_files_file_hash'), table_name='cad_files')
op.create_index(op.f('ix_cad_files_file_hash'), 'cad_files', ['file_hash'], unique=True)
op.drop_index(op.f('uq_dashboard_config_tenant_default'), table_name='dashboard_configs', postgresql_where='(is_tenant_default = true)')
op.drop_index(op.f('uq_dashboard_config_user'), table_name='dashboard_configs', postgresql_where='(user_id IS NOT NULL)')
op.create_index(op.f('ix_dashboard_configs_tenant_id'), 'dashboard_configs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_dashboard_configs_user_id'), 'dashboard_configs', ['user_id'], unique=False)
op.drop_index(op.f('ix_import_validations_status'), table_name='import_validations')
op.drop_index(op.f('ix_import_validations_tenant'), table_name='import_validations')
op.create_index(op.f('ix_import_validations_tenant_id'), 'import_validations', ['tenant_id'], unique=False)
op.drop_index(op.f('ix_invoice_lines_invoice'), table_name='invoice_lines')
op.drop_index(op.f('ix_invoices_status'), table_name='invoices')
op.drop_index(op.f('ix_invoices_tenant'), table_name='invoices')
op.create_index(op.f('ix_invoices_tenant_id'), 'invoices', ['tenant_id'], unique=False)
op.drop_constraint(op.f('invoices_tenant_id_fkey'), 'invoices', type_='foreignkey')
op.create_foreign_key(None, 'invoices', 'tenants', ['tenant_id'], ['id'])
op.drop_index(op.f('ix_material_aliases_material_id'), table_name='material_aliases')
op.drop_index(op.f('uq_material_aliases_alias_lower'), table_name='material_aliases')
op.drop_index(op.f('ix_media_assets_asset_type'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_asset_type_created'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_order_line'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_product'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_tenant'), table_name='media_assets')
op.create_index(op.f('ix_media_assets_order_line_id'), 'media_assets', ['order_line_id'], unique=False)
op.create_index(op.f('ix_media_assets_product_id'), 'media_assets', ['product_id'], unique=False)
op.create_index(op.f('ix_media_assets_tenant_id'), 'media_assets', ['tenant_id'], unique=False)
op.drop_index(op.f('ix_notification_configs_user'), table_name='notification_configs')
op.drop_constraint(op.f('uq_notification_config_user_event_channel'), 'notification_configs', type_='unique')
op.create_index(op.f('ix_notification_configs_user_id'), 'notification_configs', ['user_id'], unique=False)
op.alter_column('order_items', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.alter_column('order_lines', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_index(op.f('uq_order_lines_render'), table_name='order_lines', postgresql_where='(output_type_id IS NOT NULL)')
op.drop_index(op.f('uq_order_lines_tracking'), table_name='order_lines', postgresql_where='(output_type_id IS NULL)')
op.alter_column('orders', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_constraint(op.f('orders_order_number_key'), 'orders', type_='unique')
op.drop_index(op.f('ix_orders_order_number'), table_name='orders')
op.create_index(op.f('ix_orders_order_number'), 'orders', ['order_number'], unique=True)
op.drop_index(op.f('ix_render_positions_product_id'), table_name='product_render_positions')
op.drop_index(op.f('uq_render_positions_product_name'), table_name='product_render_positions')
op.create_index(op.f('ix_product_render_positions_product_id'), 'product_render_positions', ['product_id'], unique=False)
op.alter_column('products', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_index(op.f('ix_products_category_lagertyp'), table_name='products')
op.drop_index(op.f('uq_products_produkt_baureihe'), table_name='products', postgresql_where='((produkt_baureihe IS NOT NULL) AND (is_active = true))')
op.drop_index(op.f('ix_render_templates_active_unique'), table_name='render_templates', postgresql_where='(is_active = true)')
op.drop_constraint(op.f('templates_category_key_key'), 'templates', type_='unique')
op.drop_index(op.f('ix_templates_category_key'), table_name='templates')
op.create_index(op.f('ix_templates_category_key'), 'templates', ['category_key'], unique=True)
op.alter_column('users', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_constraint(op.f('users_email_key'), 'users', type_='unique')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.drop_index(op.f('ix_workflow_node_results_run'), table_name='workflow_node_results')
op.create_index(op.f('ix_workflow_node_results_run_id'), 'workflow_node_results', ['run_id'], unique=False)
op.drop_index(op.f('ix_workflow_runs_order_line'), table_name='workflow_runs')
op.drop_index(op.f('ix_workflow_runs_status'), table_name='workflow_runs')
op.create_index(op.f('ix_workflow_runs_order_line_id'), 'workflow_runs', ['order_line_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_workflow_runs_order_line_id'), table_name='workflow_runs')
op.create_index(op.f('ix_workflow_runs_status'), 'workflow_runs', ['status'], unique=False)
op.create_index(op.f('ix_workflow_runs_order_line'), 'workflow_runs', ['order_line_id'], unique=False)
op.drop_index(op.f('ix_workflow_node_results_run_id'), table_name='workflow_node_results')
op.create_index(op.f('ix_workflow_node_results_run'), 'workflow_node_results', ['run_id'], unique=False)
op.drop_index(op.f('ix_users_email'), table_name='users')
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=False)
op.create_unique_constraint(op.f('users_email_key'), 'users', ['email'], postgresql_nulls_not_distinct=False)
op.alter_column('users', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_index(op.f('ix_templates_category_key'), table_name='templates')
op.create_index(op.f('ix_templates_category_key'), 'templates', ['category_key'], unique=False)
op.create_unique_constraint(op.f('templates_category_key_key'), 'templates', ['category_key'], postgresql_nulls_not_distinct=False)
op.create_index(op.f('ix_render_templates_active_unique'), 'render_templates', ['category_key', 'output_type_id'], unique=True, postgresql_where='(is_active = true)')
op.create_index(op.f('uq_products_produkt_baureihe'), 'products', [sa.literal_column('lower(produkt_baureihe::text)')], unique=True, postgresql_where='((produkt_baureihe IS NOT NULL) AND (is_active = true))')
op.create_index(op.f('ix_products_category_lagertyp'), 'products', ['category_key', 'lagertyp'], unique=False)
op.alter_column('products', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_index(op.f('ix_product_render_positions_product_id'), table_name='product_render_positions')
op.create_index(op.f('uq_render_positions_product_name'), 'product_render_positions', ['product_id', sa.literal_column('lower(name::text)')], unique=True)
op.create_index(op.f('ix_render_positions_product_id'), 'product_render_positions', ['product_id'], unique=False)
op.drop_index(op.f('ix_orders_order_number'), table_name='orders')
op.create_index(op.f('ix_orders_order_number'), 'orders', ['order_number'], unique=False)
op.create_unique_constraint(op.f('orders_order_number_key'), 'orders', ['order_number'], postgresql_nulls_not_distinct=False)
op.alter_column('orders', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.create_index(op.f('uq_order_lines_tracking'), 'order_lines', ['order_id', 'product_id', sa.literal_column("COALESCE(render_position_id, '00000000-0000-0000-0000-000000000000'::uuid)")], unique=True, postgresql_where='(output_type_id IS NULL)')
op.create_index(op.f('uq_order_lines_render'), 'order_lines', ['order_id', 'product_id', 'output_type_id', sa.literal_column("COALESCE(render_position_id, '00000000-0000-0000-0000-000000000000'::uuid)")], unique=True, postgresql_where='(output_type_id IS NOT NULL)')
op.alter_column('order_lines', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.alter_column('order_items', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_index(op.f('ix_notification_configs_user_id'), table_name='notification_configs')
op.create_unique_constraint(op.f('uq_notification_config_user_event_channel'), 'notification_configs', ['user_id', 'event_type', 'channel'], postgresql_nulls_not_distinct=False)
op.create_index(op.f('ix_notification_configs_user'), 'notification_configs', ['user_id'], unique=False)
op.drop_index(op.f('ix_media_assets_tenant_id'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_product_id'), table_name='media_assets')
op.drop_index(op.f('ix_media_assets_order_line_id'), table_name='media_assets')
op.create_index(op.f('ix_media_assets_tenant'), 'media_assets', ['tenant_id'], unique=False)
op.create_index(op.f('ix_media_assets_product'), 'media_assets', ['product_id'], unique=False)
op.create_index(op.f('ix_media_assets_order_line'), 'media_assets', ['order_line_id'], unique=False)
op.create_index(op.f('ix_media_assets_asset_type_created'), 'media_assets', ['asset_type', 'created_at'], unique=False)
op.create_index(op.f('ix_media_assets_asset_type'), 'media_assets', ['asset_type'], unique=False)
op.create_index(op.f('uq_material_aliases_alias_lower'), 'material_aliases', [sa.literal_column('lower(alias::text)')], unique=True)
op.create_index(op.f('ix_material_aliases_material_id'), 'material_aliases', ['material_id'], unique=False)
op.drop_constraint(None, 'invoices', type_='foreignkey')
op.create_foreign_key(op.f('invoices_tenant_id_fkey'), 'invoices', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.drop_index(op.f('ix_invoices_tenant_id'), table_name='invoices')
op.create_index(op.f('ix_invoices_tenant'), 'invoices', ['tenant_id'], unique=False)
op.create_index(op.f('ix_invoices_status'), 'invoices', ['status'], unique=False)
op.create_index(op.f('ix_invoice_lines_invoice'), 'invoice_lines', ['invoice_id'], unique=False)
op.drop_index(op.f('ix_import_validations_tenant_id'), table_name='import_validations')
op.create_index(op.f('ix_import_validations_tenant'), 'import_validations', ['tenant_id'], unique=False)
op.create_index(op.f('ix_import_validations_status'), 'import_validations', ['status'], unique=False)
op.drop_index(op.f('ix_dashboard_configs_user_id'), table_name='dashboard_configs')
op.drop_index(op.f('ix_dashboard_configs_tenant_id'), table_name='dashboard_configs')
op.create_index(op.f('uq_dashboard_config_user'), 'dashboard_configs', ['user_id'], unique=True, postgresql_where='(user_id IS NOT NULL)')
op.create_index(op.f('uq_dashboard_config_tenant_default'), 'dashboard_configs', ['tenant_id'], unique=True, postgresql_where='(is_tenant_default = true)')
op.drop_index(op.f('ix_cad_files_file_hash'), table_name='cad_files')
op.create_index(op.f('ix_cad_files_file_hash'), 'cad_files', ['file_hash'], unique=False)
op.create_unique_constraint(op.f('cad_files_file_hash_key'), 'cad_files', ['file_hash'], postgresql_nulls_not_distinct=False)
op.alter_column('cad_files', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_column('cad_files', 'part_materials')
op.create_index(op.f('ix_audit_log_target_notification'), 'audit_log', ['target_user_id', 'notification', 'read_at'], unique=False)
op.create_index(op.f('ix_audit_log_notification_ts'), 'audit_log', ['notification', 'timestamp'], unique=False, postgresql_where='(notification = true)')
op.create_index(op.f('ix_audit_log_channel'), 'audit_log', ['channel'], unique=False)
op.create_table('app_config',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('version', sa.INTEGER(), server_default=sa.text('1'), autoincrement=False, nullable=False),
sa.Column('render', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=False),
sa.Column('storage', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=False),
sa.Column('notifications', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=False),
sa.Column('worker', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=False),
sa.Column('billing', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('app_config_pkey'))
)
# ### end Alembic commands ###