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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user