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
+146 -6
View File
@@ -60,7 +60,7 @@ async def _resolve_thumbnails_bulk(db: AsyncSession, assets: list) -> None:
for a in needs:
pid = str(a.product_id)
if pid in best_still:
a.thumbnail_url = f"/api/media/{best_still[pid]}/download"
a.thumbnail_url = f"/api/media/{best_still[pid]}/thumbnail"
elif pid in product_cad:
a.thumbnail_url = f"/api/cad/{product_cad[pid]}/thumbnail"
@@ -105,6 +105,7 @@ async def browse_media_assets(
category_key: str | None = None,
render_status: str | None = None,
q: str | None = None,
exclude_technical: bool = Query(True, description="Exclude GLB/STL/Blend technical assets"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
_user: User = Depends(get_current_user),
@@ -125,6 +126,12 @@ async def browse_media_assets(
Product.pim_id.label("product_pim_id"),
Product.category_key.label("category_key"),
OrderLine.render_status.label("render_status"),
Product.ebene1.label("product_ebene1"),
Product.ebene2.label("product_ebene2"),
Product.baureihe.label("product_baureihe"),
Product.produkt_baureihe.label("product_produkt_baureihe"),
Product.lagertyp.label("product_lagertyp"),
Product.name_cad_modell.label("product_name_cad_modell"),
)
.outerjoin(Product, MediaAsset.product_id == Product.id)
.outerjoin(OrderLine, MediaAsset.order_line_id == OrderLine.id)
@@ -133,12 +140,21 @@ async def browse_media_assets(
)
# Apply filters
_TECHNICAL_TYPES = (
MediaAssetType.gltf_geometry,
MediaAssetType.gltf_production,
MediaAssetType.blend_production,
MediaAssetType.stl_low,
MediaAssetType.stl_high,
)
if asset_type:
try:
at_enum = MediaAssetType(asset_type)
stmt = stmt.where(MediaAsset.asset_type == at_enum)
except ValueError:
pass # invalid type → ignore filter
elif exclude_technical:
stmt = stmt.where(MediaAsset.asset_type.notin_(_TECHNICAL_TYPES))
if category_key:
stmt = stmt.where(Product.category_key == category_key)
@@ -153,6 +169,12 @@ async def browse_media_assets(
or_(
Product.name.ilike(pattern),
Product.pim_id.ilike(pattern),
Product.ebene1.ilike(pattern),
Product.ebene2.ilike(pattern),
Product.baureihe.ilike(pattern),
Product.produkt_baureihe.ilike(pattern),
Product.lagertyp.ilike(pattern),
Product.name_cad_modell.ilike(pattern),
)
)
@@ -165,15 +187,30 @@ async def browse_media_assets(
offset = (page - 1) * page_size
stmt = stmt.offset(offset).limit(page_size)
rows = await db.execute(stmt)
all_rows = (await db.execute(stmt)).all()
# Pre-assign thumbnail_url so _resolve_thumbnails_bulk can check it
raw_assets = [row[0] for row in all_rows]
for a in raw_assets:
a.thumbnail_url = service.get_thumbnail_url(a)
# Resolve fallback thumbnails for non-image assets via product→cad lookup
await _resolve_thumbnails_bulk(db, raw_assets)
items: list[MediaAssetBrowseItem] = []
for row in rows.all():
for row in all_rows:
asset: MediaAsset = row[0]
product_name: str | None = row[1]
product_pim_id: str | None = row[2]
cat_key: str | None = row[3]
r_status: str | None = row[4]
ebene1: str | None = row[5]
ebene2: str | None = row[6]
baureihe: str | None = row[7]
produkt_baureihe: str | None = row[8]
lagertyp: str | None = row[9]
name_cad_modell: str | None = row[10]
thumb = asset.thumbnail_url
item = MediaAssetBrowseItem(
id=asset.id,
asset_type=asset.asset_type,
@@ -187,8 +224,14 @@ async def browse_media_assets(
product_pim_id=product_pim_id,
category_key=cat_key,
render_status=r_status,
product_ebene1=ebene1,
product_ebene2=ebene2,
product_baureihe=baureihe,
product_produkt_baureihe=produkt_baureihe,
product_lagertyp=lagertyp,
product_name_cad_modell=name_cad_modell,
download_url=f"/api/media/{asset.id}/download",
thumbnail_url=service.get_thumbnail_url(asset),
thumbnail_url=thumb,
)
items.append(item)
@@ -213,6 +256,48 @@ async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
return asset
@router.get("/{asset_id}/thumbnail")
async def thumbnail_asset(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
):
"""Serve asset as an inline image — no auth required (UUID is opaque enough).
Only serves image/video MIME types; returns 404 for binary files.
"""
from fastapi.responses import FileResponse, Response
from pathlib import Path
asset = await service.get_media_asset(db, asset_id)
if not asset:
raise HTTPException(404, "Asset not found")
mime = asset.mime_type or ""
if not (mime.startswith("image/") or mime.startswith("video/")):
raise HTTPException(404, "Not a previewable asset")
key = asset.storage_key
from app.config import settings
candidate = Path(key) if Path(key).is_absolute() else Path(settings.upload_dir) / key
if not candidate.exists() and "/shared/renders/" in key:
parts = key.split("/")
if len(parts) >= 2:
remapped = Path(settings.upload_dir) / "renders" / parts[-2] / parts[-1]
if remapped.exists():
candidate = remapped
if candidate.exists():
return FileResponse(
str(candidate), media_type=mime,
headers={"Cache-Control": "max-age=86400, public"},
)
try:
from app.core.storage import get_storage
data = get_storage().download_bytes(key)
return Response(content=data, media_type=mime,
headers={"Cache-Control": "max-age=86400, public"})
except Exception:
raise HTTPException(404, "File not available")
@router.api_route("/{asset_id}/download", methods=["GET", "HEAD"])
async def download_asset(
asset_id: uuid.UUID,
@@ -250,7 +335,7 @@ async def download_asset(
fname = f"{asset.asset_type.value}_{asset_id}.{ext or 'bin'}"
return FileResponse(
str(candidate), media_type=mime, filename=fname,
headers={"Cache-Control": "max-age=3600, public"},
headers={"Cache-Control": "no-cache"},
)
# Fall back to MinIO
@@ -264,7 +349,7 @@ async def download_asset(
media_type=mime,
headers={
"Content-Disposition": f"attachment; filename={fname}",
"Cache-Control": "max-age=3600, public",
"Cache-Control": "no-cache",
},
)
except Exception:
@@ -346,3 +431,58 @@ async def delete_asset_permanent(asset_id: uuid.UUID, db: AsyncSession = Depends
if not deleted:
raise HTTPException(404, "Asset not found")
return {"ok": True}
@router.post("/cleanup-orphaned")
async def cleanup_orphaned_assets(
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Delete all MediaAsset DB records whose backing file doesn't exist on disk or in MinIO.
Returns counts of checked/deleted records. Admin only.
"""
import logging
from pathlib import Path
from app.config import settings
from app.core.storage import get_storage
logger = logging.getLogger(__name__)
storage = get_storage()
def _file_exists(key: str) -> bool:
candidate = Path(key) if Path(key).is_absolute() else Path(settings.upload_dir) / key
if candidate.exists():
return True
# Legacy path remapping
if "/shared/renders/" in key:
parts = key.split("/")
if len(parts) >= 2:
remapped = Path(settings.upload_dir) / "renders" / parts[-2] / parts[-1]
if remapped.exists():
return True
# Check MinIO
try:
storage.download_bytes(key)
return True
except Exception:
return False
result = await db.execute(select(MediaAsset).where(MediaAsset.is_archived == False)) # noqa: E712
all_assets = result.scalars().all()
deleted_ids = []
for asset in all_assets:
if not _file_exists(asset.storage_key):
logger.info("Cleanup: deleting orphaned asset %s (%s)", asset.id, asset.storage_key)
await db.delete(asset)
deleted_ids.append(str(asset.id))
if deleted_ids:
await db.commit()
return {
"checked": len(all_assets),
"deleted": len(deleted_ids),
"deleted_ids": deleted_ids,
}
+7
View File
@@ -41,6 +41,13 @@ class MediaAssetBrowseItem(BaseModel):
product_pim_id: str | None
category_key: str | None
render_status: str | None
# Extended product metadata fields
product_ebene1: str | None = None
product_ebene2: str | None = None
product_baureihe: str | None = None
product_produkt_baureihe: str | None = None
product_lagertyp: str | None = None
product_name_cad_modell: str | None = None
download_url: str | None = None
thumbnail_url: str | None = None
+17 -3
View File
@@ -77,12 +77,26 @@ async def delete_media_asset(db: AsyncSession, asset_id: uuid.UUID) -> bool:
def get_download_url(asset: MediaAsset) -> str | None:
"""Return a backend proxy URL so the browser can always download the file."""
return f"/api/media/{asset.id}/download"
"""Return a backend proxy URL so the browser can always download the file.
Appends ?v={file_size_bytes} as a cache-buster: when a file is regenerated
in-place (same asset UUID, new content), the size changes and the URL changes,
which triggers a fresh fetch in InlineCadViewer's useEffect.
"""
v = asset.file_size_bytes or 0
return f"/api/media/{asset.id}/download?v={v}"
def get_thumbnail_url(asset: MediaAsset) -> str | None:
"""Return CAD thumbnail URL if asset has a cad_file_id."""
"""Return a no-auth preview URL for the asset.
Priority:
1. For image-type assets (still, thumbnail): the no-auth /thumbnail endpoint.
2. For any asset with a cad_file_id: the CAD thumbnail (also no-auth).
3. Otherwise None (caller may use _resolve_thumbnails_bulk for fallback).
"""
if asset.asset_type in (MediaAssetType.still, MediaAssetType.thumbnail):
return f"/api/media/{asset.id}/thumbnail"
if asset.cad_file_id:
return f"/api/cad/{asset.cad_file_id}/thumbnail"
return None
+8
View File
@@ -145,6 +145,11 @@ class OrderLine(Base):
ForeignKey("product_render_positions.id", ondelete="SET NULL"),
nullable=True,
)
global_render_position_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("global_render_positions.id", ondelete="SET NULL"),
nullable=True,
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
@@ -160,3 +165,6 @@ class OrderLine(Base):
render_position: Mapped["ProductRenderPosition | None"] = relationship(
"ProductRenderPosition", back_populates="order_lines"
)
global_render_position: Mapped["GlobalRenderPosition | None"] = relationship(
"GlobalRenderPosition", back_populates="order_lines"
)
+4
View File
@@ -64,6 +64,7 @@ class OrderLineCreate(BaseModel):
product_id: uuid.UUID
output_type_id: uuid.UUID | None = None
render_position_id: uuid.UUID | None = None
global_render_position_id: uuid.UUID | None = None
gewuenschte_bildnummer: str | None = None
notes: str | None = None
@@ -87,6 +88,9 @@ class OrderLineOut(BaseModel):
unit_price: float | None = None
render_position_id: uuid.UUID | None = None
render_position_name: str | None = None
render_log: dict | None = None
render_started_at: datetime | None = None
render_completed_at: datetime | None = None
notes: str | None
created_at: datetime
updated_at: datetime
@@ -69,7 +69,7 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
eng.dispose()
linear_deflection = float(sys_settings.get("gltf_preview_linear_deflection", "0.1"))
angular_deflection = float(sys_settings.get("gltf_preview_angular_deflection", "0.5"))
angular_deflection = float(sys_settings.get("gltf_preview_angular_deflection", "0.1"))
step = _Path(step_path_str)
@@ -230,7 +230,7 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
smooth_angle = float(sys_settings.get("blender_smooth_angle", "30"))
prod_linear = float(sys_settings.get("gltf_production_linear_deflection", "0.03"))
prod_angular = float(sys_settings.get("gltf_production_angular_deflection", "0.2"))
prod_angular = float(sys_settings.get("gltf_production_angular_deflection", "0.05"))
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
occ_script = scripts_dir / "export_step_to_gltf.py"
@@ -239,12 +239,14 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
prod_geom_glb = step_path.parent / f"{step_path.stem}_production_geom.glb"
python_bin = _sys.executable
sharp_threshold = float(sys_settings.get("sharp_edge_threshold", "20.0"))
occ_cmd = [
python_bin, str(occ_script),
"--step_path", str(step_path),
"--output_path", str(prod_geom_glb),
"--linear_deflection", str(prod_linear),
"--angular_deflection", str(prod_angular),
"--sharp_threshold", str(sharp_threshold),
]
log_task_event(
self.request.id,
@@ -130,7 +130,7 @@ def render_order_line_task(self, order_line_id: str):
logger.info(f"No render template for category_key={category_key!r}, output_type_id={ot_id!r}")
cad_name = cad_file.original_name if cad_file else "?"
# Load render_position for rotation values
# Load render_position for rotation values (per-product takes priority, falls back to global)
rotation_x = rotation_y = rotation_z = 0.0
if line.render_position_id:
from app.models.render_position import ProductRenderPosition
@@ -138,6 +138,12 @@ def render_order_line_task(self, order_line_id: str):
if rp:
rotation_x, rotation_y, rotation_z = rp.rotation_x, rp.rotation_y, rp.rotation_z
emit(order_line_id, f"Render position: '{rp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)")
elif line.global_render_position_id:
from app.models import GlobalRenderPosition
grp = session.get(GlobalRenderPosition, line.global_render_position_id)
if grp:
rotation_x, rotation_y, rotation_z = grp.rotation_x, grp.rotation_y, grp.rotation_z
emit(order_line_id, f"Global render position: '{grp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)")
emit(order_line_id, f"Starting render for {cad_name} ({len(part_colors)} coloured parts)")
@@ -345,6 +351,7 @@ def render_order_line_task(self, order_line_id: str):
if success:
# Create MediaAsset so the render appears in the Media Browser
try:
import os as _os
from app.domains.media.models import MediaAsset, MediaAssetType as MAT
from app.config import settings as _cfg2
_ext = str(output_path).rsplit(".", 1)[-1].lower() if "." in str(output_path) else "bin"
@@ -360,6 +367,33 @@ def render_order_line_task(self, order_line_id: str):
select(MediaAsset.id).where(MediaAsset.storage_key == _norm_key).limit(1)
).scalar_one_or_none()
if not _existing:
# Probe output file for metadata
_file_size = None
_width = None
_height = None
if _os.path.exists(output_path):
try:
_file_size = _os.path.getsize(output_path)
except OSError:
pass
if _ext in ("png", "jpg", "jpeg"):
try:
from PIL import Image as _PILImage
with _PILImage.open(output_path) as _im:
_width, _height = _im.size
except Exception:
pass
# Snapshot key render settings into render_config
_render_config = None
if isinstance(render_log, dict):
_render_config = {
k: render_log[k]
for k in (
"renderer", "engine_used", "engine", "samples",
"device_used", "compute_type", "total_duration_s",
)
if k in render_log
}
_asset = MediaAsset(
tenant_id=_tenant_id,
order_line_id=line.id,
@@ -367,6 +401,10 @@ def render_order_line_task(self, order_line_id: str):
asset_type=_at,
storage_key=_norm_key,
mime_type=_mime,
file_size_bytes=_file_size,
width=_width,
height=_height,
render_config=_render_config,
)
session.add(_asset)
session.commit()
@@ -95,6 +95,38 @@ def render_step_thumbnail(self, cad_file_id: str):
except Exception:
logger.exception(f"bbox extraction failed for {cad_file_id} (non-fatal)")
# Extract sharp edge topology (PCurve-based) if not already present.
# This runs on render-worker which has OCP (cadquery's OCC fork).
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.config import settings as _cfg3
from app.models.cad_file import CadFile as _CadFile3
from app.services.step_processor import extract_mesh_edge_data
_sync_url3 = _cfg3.database_url.replace("+asyncpg", "")
_eng3 = create_engine(_sync_url3)
with Session(_eng3) as _sess3:
_cad3 = _sess3.get(_CadFile3, cad_file_id)
_attrs = _cad3.mesh_attributes or {} if _cad3 else {}
_step_path3 = _cad3.stored_path if _cad3 else None
_eng3.dispose()
if _step_path3 and "sharp_edge_pairs" not in _attrs:
edge_data = extract_mesh_edge_data(_step_path3)
if edge_data:
_eng3 = create_engine(_sync_url3)
with Session(_eng3) as _sess3:
_cad3 = _sess3.get(_CadFile3, cad_file_id)
if _cad3:
_cad3.mesh_attributes = {**(_cad3.mesh_attributes or {}), **edge_data}
_sess3.commit()
n_pairs = len(edge_data.get("sharp_edge_pairs", []))
logger.info(f"Sharp edge data extracted for {cad_file_id}: {n_pairs} sharp edges")
_eng3.dispose()
except Exception:
logger.exception(f"Sharp edge extraction failed for {cad_file_id} (non-fatal)")
# Auto-populate materials now that parsed_objects are available
try:
from app.domains.pipeline.tasks.extract_metadata import _auto_populate_materials_for_cad
+1
View File
@@ -31,6 +31,7 @@ class CadFile(Base):
error_message: Mapped[str] = mapped_column(String(2000), nullable=True)
render_log: Mapped[dict] = mapped_column(JSONB, nullable=True)
mesh_attributes: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
part_materials: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
step_file_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
+1
View File
@@ -62,6 +62,7 @@ class ProductOut(BaseModel):
cad_parsed_objects: list[str] | None = None
cad_mesh_attributes: dict | None = None
arbeitspaket: str | None = None
cad_render_log: dict | None = None
notes: str | None
is_active: bool
source_excel: str | None
+18
View File
@@ -103,6 +103,24 @@ class ProductRenderPosition(Base):
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="render_position")
class GlobalRenderPosition(Base):
__tablename__ = "global_render_positions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
rotation_x: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_y: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="global_render_position")
class WorkflowDefinition(Base):
__tablename__ = "workflow_definitions"
+2 -1
View File
@@ -1,5 +1,6 @@
# Re-export from original routers.
from app.api.routers.render_templates import router as render_templates_router
from app.api.routers.output_types import router as output_types_router
from app.api.routers.global_render_positions import router as global_render_positions_router
__all__ = ["render_templates_router", "output_types_router"]
__all__ = ["render_templates_router", "output_types_router", "global_render_positions_router"]
+32
View File
@@ -94,6 +94,38 @@ class RenderPositionOut(BaseModel):
model_config = {"from_attributes": True}
class GlobalRenderPositionCreate(BaseModel):
name: str
rotation_x: float = 0.0
rotation_y: float = 0.0
rotation_z: float = 0.0
is_default: bool = False
sort_order: int = 0
class GlobalRenderPositionPatch(BaseModel):
name: str | None = None
rotation_x: float | None = None
rotation_y: float | None = None
rotation_z: float | None = None
is_default: bool | None = None
sort_order: int | None = None
class GlobalRenderPositionOut(BaseModel):
id: uuid.UUID
name: str
rotation_x: float
rotation_y: float
rotation_z: float
is_default: bool
sort_order: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class WorkflowDefinitionCreate(BaseModel):
name: str
output_type_id: uuid.UUID | None = None