Files
HartOMat/backend/app/domains/products/models.py
T
Hartmut 409fb92899 feat(P2): USD Foundation — canonical part identity + material overrides
M1 — USD exporter:
- render-worker/scripts/export_step_to_usd.py (631 lines)
  Full XCAF traversal, one UsdGeom.Mesh per leaf part,
  schaeffler:partKey on every prim, index-space sharpEdgeVertexPairs
- render-worker/Dockerfile: usd-core>=24.11 installed (USD 0.26.3)

M2 — usd_master MediaAsset + pipeline auto-chain:
- migrations 060 (usd_master enum), 061 (3 JSONB columns),
  062 (rename tessellation settings keys)
- generate_usd_master_task: runs export_step_to_usd.py, upserts
  usd_master MediaAsset, writes resolved_material_assignments to CadFile
- Auto-chained from generate_gltf_geometry_task after every GLB export
- step_tasks.py shim re-exports generate_usd_master_task

M3 — scene-manifest API:
- part_key_service.py: build_scene_manifest(), generate_part_key(),
  four-layer material priority resolution with provenance
- SceneManifest / PartEntry Pydantic models in products/schemas.py
- GET /api/cad/{id}/scene-manifest endpoint (graceful fallback to
  parsed_objects when USD not yet generated)
- POST /api/cad/{id}/generate-usd-master endpoint
- frontend/src/api/sceneManifest.ts: fetchSceneManifest(),
  triggerUsdMasterGeneration()

M4 — manual-material-overrides API:
- GET/PUT /api/cad/{id}/manual-material-overrides endpoints
- CadFile.manual_material_overrides JSONB column (migration 061)
- getManualOverrides() / saveManualOverrides() in cad.ts

M5 — ThreeDViewer partKey integration:
- export_step_to_gltf.py injects partKeyMap into GLB extras
- ThreeDViewer: partKeyMap extraction, resolvePartKey(), effectiveMaterials
  merges legacy partMaterials + new manualOverrides (server-side persistence)
- MaterialPanel: dual-path save (partKey vs legacy), provenance badge,
  reconciliation panel for unmatched/unassigned parts

Also:
- Admin.tsx: generate-missing-usd-masters + canonical scenes bulk actions
- ProductDetail.tsx: usd_master row in asset table
- vite-env.d.ts: fix ImportMeta.env TypeScript error
- GPUProbeResult: add timestamp/devices/render_time_s fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 13:11:09 +01:00

111 lines
5.8 KiB
Python

import uuid
import enum
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey, BigInteger, Enum as SAEnum, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class ProcessingStatus(str, enum.Enum):
pending = "pending"
processing = "processing"
completed = "completed"
failed = "failed"
class CadFile(Base):
__tablename__ = "cad_files"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
original_name: Mapped[str] = mapped_column(String(500), nullable=False)
stored_path: Mapped[str] = mapped_column(String(1000), nullable=False)
file_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=True)
parsed_objects: Mapped[dict] = mapped_column(JSONB, nullable=True)
thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True)
gltf_path: Mapped[str] = mapped_column(String(1000), nullable=True)
processing_status: Mapped[ProcessingStatus] = mapped_column(
SAEnum(ProcessingStatus), default=ProcessingStatus.pending, nullable=False
)
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)
source_material_assignments: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
resolved_material_assignments: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
manual_material_overrides: 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
)
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_items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="cad_file")
products: Mapped[list["Product"]] = relationship("Product", back_populates="cad_file")
class Product(Base):
__tablename__ = "products"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
pim_id: Mapped[str] = mapped_column(String(500), nullable=False)
name: Mapped[str | None] = mapped_column(String(500), nullable=True)
category_key: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
ebene1: Mapped[str | None] = mapped_column(String(500), nullable=True)
ebene2: Mapped[str | None] = mapped_column(String(500), nullable=True)
baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True)
produkt_baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True)
lagertyp: Mapped[str | None] = mapped_column(String(500), nullable=True)
name_cad_modell: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True)
medias_rendering: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
cad_file_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("cad_files.id", ondelete="SET NULL"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
arbeitspaket: Mapped[str | None] = mapped_column(String(500), nullable=True)
source_excel: Mapped[str | None] = mapped_column(String(1000), nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
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
)
cad_file: Mapped["CadFile | None"] = relationship("CadFile", back_populates="products")
tenant: Mapped["Tenant | None"] = relationship("Tenant", back_populates="products", lazy="noload")
order_lines: Mapped[list["OrderLine"]] = relationship(
"OrderLine", back_populates="product", cascade="all, delete-orphan"
)
render_positions: Mapped[list["ProductRenderPosition"]] = relationship(
"ProductRenderPosition", back_populates="product",
cascade="all, delete-orphan", order_by="ProductRenderPosition.sort_order"
)
@property
def thumbnail_url(self) -> str | None:
if self.cad_file and self.cad_file.thumbnail_path:
from pathlib import Path
return f"/thumbnails/{Path(self.cad_file.thumbnail_path).name}"
return None
@property
def processing_status(self) -> str | None:
if self.cad_file:
return self.cad_file.processing_status.value if hasattr(
self.cad_file.processing_status, 'value'
) else str(self.cad_file.processing_status)
return None
@property
def cad_parsed_objects(self) -> list[str] | None:
if self.cad_file and self.cad_file.parsed_objects:
return self.cad_file.parsed_objects.get("objects") or []
return None