Parcourir la source

Added source 3mf (sliver saved 3mf) file upload to archive card

Martin Ziegler il y a 5 mois
Parent
commit
597c1e2817

+ 227 - 0
backend/app/api/routes/archives.py

@@ -60,6 +60,7 @@ def archive_to_response(
         "content_hash": archive.content_hash,
         "thumbnail_path": archive.thumbnail_path,
         "timelapse_path": archive.timelapse_path,
+        "source_3mf_path": archive.source_3mf_path,
         "duplicates": duplicates,
         "duplicate_count": duplicate_count if duplicates is None else len(duplicates),
         "print_name": archive.print_name,
@@ -1390,3 +1391,229 @@ async def get_project_image(
         media_type=content_type,
         headers={"Cache-Control": "max-age=3600"},
     )
+
+
+# =============================================================================
+# Source 3MF API (Original Project Files)
+# =============================================================================
+
+@router.post("/{archive_id}/source")
+async def upload_source_3mf(
+    archive_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload the original source 3MF project file for an archive."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not file.filename or not file.filename.endswith(".3mf"):
+        raise HTTPException(400, "File must be a .3mf file")
+
+    # Get archive directory and create source subdirectory
+    file_path = settings.base_dir / archive.file_path
+    archive_dir = file_path.parent
+    source_dir = archive_dir / "source"
+    source_dir.mkdir(exist_ok=True)
+
+    # Delete old source file if exists
+    if archive.source_3mf_path:
+        old_source_path = settings.base_dir / archive.source_3mf_path
+        if old_source_path.exists():
+            old_source_path.unlink()
+
+    # Save the source 3MF file - preserve original filename
+    source_filename = file.filename
+    source_path = source_dir / source_filename
+
+    content = await file.read()
+    source_path.write_bytes(content)
+
+    # Update archive with source path (relative to base_dir)
+    archive.source_3mf_path = str(source_path.relative_to(settings.base_dir))
+
+    await db.commit()
+    await db.refresh(archive)
+
+    return {
+        "status": "uploaded",
+        "source_3mf_path": archive.source_3mf_path,
+        "filename": source_filename,
+    }
+
+
+@router.get("/{archive_id}/source")
+async def download_source_3mf(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download the source 3MF project file."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    source_path = settings.base_dir / archive.source_3mf_path
+    if not source_path.exists():
+        raise HTTPException(404, "Source 3MF file not found on disk")
+
+    # Use the actual filename from the path
+    filename = source_path.name
+
+    return FileResponse(
+        path=source_path,
+        filename=filename,
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.get("/{archive_id}/source/{filename}")
+async def download_source_3mf_for_slicer(
+    archive_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    source_path = settings.base_dir / archive.source_3mf_path
+    if not source_path.exists():
+        raise HTTPException(404, "Source 3MF file not found on disk")
+
+    return FileResponse(
+        path=source_path,
+        filename=filename if filename.endswith(".3mf") else f"{filename}.3mf",
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.post("/upload-source")
+async def upload_source_3mf_by_name(
+    file: UploadFile = File(...),
+    print_name: str = Query(None, description="Match archive by print name"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload source 3MF and match to archive by print name.
+
+    This endpoint is designed for slicer post-processing scripts.
+    It finds the most recent archive matching the print name and attaches the source.
+    """
+    if not file.filename or not file.filename.endswith(".3mf"):
+        raise HTTPException(400, "File must be a .3mf file")
+
+    # Derive print name from filename if not provided
+    if not print_name:
+        # Remove .3mf extension and common suffixes
+        print_name = file.filename.rsplit('.3mf', 1)[0]
+        # Remove _source suffix if present
+        if print_name.endswith('_source'):
+            print_name = print_name[:-7]
+
+    # Find matching archive - try exact match first, then fuzzy
+    result = await db.execute(
+        select(PrintArchive)
+        .where(PrintArchive.print_name == print_name)
+        .order_by(PrintArchive.created_at.desc())
+        .limit(1)
+    )
+    archive = result.scalar_one_or_none()
+
+    if not archive:
+        # Try matching filename without .gcode.3mf
+        result = await db.execute(
+            select(PrintArchive)
+            .where(PrintArchive.filename.like(f"{print_name}%"))
+            .order_by(PrintArchive.created_at.desc())
+            .limit(1)
+        )
+        archive = result.scalar_one_or_none()
+
+    if not archive:
+        # Try case-insensitive partial match on print_name
+        result = await db.execute(
+            select(PrintArchive)
+            .where(PrintArchive.print_name.ilike(f"%{print_name}%"))
+            .order_by(PrintArchive.created_at.desc())
+            .limit(1)
+        )
+        archive = result.scalar_one_or_none()
+
+    if not archive:
+        raise HTTPException(404, f"No archive found matching '{print_name}'")
+
+    # Get archive directory and create source subdirectory
+    file_path = settings.base_dir / archive.file_path
+    archive_dir = file_path.parent
+    source_dir = archive_dir / "source"
+    source_dir.mkdir(exist_ok=True)
+
+    # Delete old source file if exists
+    if archive.source_3mf_path:
+        old_source_path = settings.base_dir / archive.source_3mf_path
+        if old_source_path.exists():
+            old_source_path.unlink()
+
+    # Save the source 3MF file - preserve original filename
+    source_filename = file.filename
+    source_path = source_dir / source_filename
+
+    content = await file.read()
+    source_path.write_bytes(content)
+
+    # Update archive with source path
+    archive.source_3mf_path = str(source_path.relative_to(settings.base_dir))
+    await db.commit()
+    await db.refresh(archive)
+
+    return {
+        "status": "uploaded",
+        "archive_id": archive.id,
+        "archive_name": archive.print_name or archive.filename,
+        "source_3mf_path": archive.source_3mf_path,
+        "filename": source_filename,
+    }
+
+
+@router.delete("/{archive_id}/source")
+async def delete_source_3mf(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete the source 3MF project file from an archive."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    # Delete the file
+    source_path = settings.base_dir / archive.source_3mf_path
+    if source_path.exists():
+        source_path.unlink()
+
+    # Clear the path in database
+    archive.source_3mf_path = None
+    await db.commit()
+
+    return {"status": "deleted"}

+ 9 - 0
backend/app/core/database.py

@@ -82,3 +82,12 @@ async def run_migrations(conn):
     except Exception:
         # Column already exists
         pass
+
+    # Migration: Add source_3mf_path column to print_archives
+    try:
+        await conn.execute(text(
+            "ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"
+        ))
+    except Exception:
+        # Column already exists
+        pass

+ 1 - 0
backend/app/models/archive.py

@@ -18,6 +18,7 @@ class PrintArchive(Base):
     content_hash: Mapped[str | None] = mapped_column(String(64))  # SHA256 hash for duplicate detection
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
     timelapse_path: Mapped[str | None] = mapped_column(String(500))
+    source_3mf_path: Mapped[str | None] = mapped_column(String(500))  # Original project 3MF from slicer
 
     # Print details from 3MF / printer
     print_name: Mapped[str | None] = mapped_column(String(255))

+ 1 - 0
backend/app/schemas/archive.py

@@ -32,6 +32,7 @@ class ArchiveResponse(BaseModel):
     content_hash: str | None
     thumbnail_path: str | None
     timelapse_path: str | None
+    source_3mf_path: str | None = None  # Original project 3MF from slicer
 
     # Duplicate detection
     duplicates: list[ArchiveDuplicate] | None = None

+ 24 - 0
frontend/src/api/client.ts

@@ -90,6 +90,7 @@ export interface Archive {
   content_hash: string | null;
   thumbnail_path: string | null;
   timelapse_path: string | null;
+  source_3mf_path: string | null;
   duplicates: ArchiveDuplicate[] | null;
   duplicate_count: number;
   print_name: string | null;
@@ -626,6 +627,29 @@ export const api = {
     request<{ status: string; photos: string[] | null }>(`/archives/${archiveId}/photos/${encodeURIComponent(filename)}`, {
       method: 'DELETE',
     }),
+  // Source 3MF (original slicer project file)
+  getSource3mfDownloadUrl: (archiveId: number) =>
+    `${API_BASE}/archives/${archiveId}/source`,
+  getSource3mfForSlicer: (archiveId: number, filename: string) =>
+    `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  deleteSource3mf: (archiveId: number) =>
+    request<{ status: string }>(`/archives/${archiveId}/source`, {
+      method: 'DELETE',
+    }),
+
   // QR Code
   getArchiveQRCodeUrl: (archiveId: number, size = 200) =>
     `${API_BASE}/archives/${archiveId}/qrcode?size=${size}`,

+ 98 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -34,6 +34,7 @@ import {
   QrCode,
   Camera,
   FileText,
+  FileCode,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { Archive } from '../api/client';
@@ -103,7 +104,31 @@ function ArchiveCard({
   const [showPhotos, setShowPhotos] = useState(false);
   const [showProjectPage, setShowProjectPage] = useState(false);
   const [showSchedule, setShowSchedule] = useState(false);
+  const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+  const source3mfInputRef = useRef<HTMLInputElement>(null);
+
+  const source3mfUploadMutation = useMutation({
+    mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Source 3MF attached: ${data.filename}`);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to upload source 3MF', 'error');
+    },
+  });
+
+  const source3mfDeleteMutation = useMutation({
+    mutationFn: () => api.deleteSource3mf(archive.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast('Source 3MF removed');
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to remove source 3MF', 'error');
+    },
+  });
 
   const timelapseScanMutation = useMutation({
     mutationFn: () => api.scanArchiveTimelapse(archive.id),
@@ -207,6 +232,33 @@ function ArchiveCard({
       onClick: () => timelapseScanMutation.mutate(),
       disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending,
     },
+    { label: '', divider: true, onClick: () => {} },
+    {
+      label: archive.source_3mf_path ? 'Download Source 3MF' : 'Upload Source 3MF',
+      icon: <FileCode className="w-4 h-4" />,
+      onClick: () => {
+        if (archive.source_3mf_path) {
+          const link = document.createElement('a');
+          link.href = api.getSource3mfDownloadUrl(archive.id);
+          link.download = `${archive.print_name || archive.filename}_source.3mf`;
+          link.click();
+        } else {
+          source3mfInputRef.current?.click();
+        }
+      },
+    },
+    ...(archive.source_3mf_path ? [{
+      label: 'Replace Source 3MF',
+      icon: <Upload className="w-4 h-4" />,
+      onClick: () => source3mfInputRef.current?.click(),
+    },
+    {
+      label: 'Remove Source 3MF',
+      icon: <Trash2 className="w-4 h-4" />,
+      onClick: () => setShowDeleteSource3mfConfirm(true),
+      danger: true,
+    }] : []),
+    { label: '', divider: true, onClick: () => {} },
     {
       label: 'Download',
       icon: <Download className="w-4 h-4" />,
@@ -331,6 +383,22 @@ function ArchiveCard({
             duplicate
           </div>
         )}
+        {/* Source 3MF badge */}
+        {archive.source_3mf_path && (
+          <button
+            className="absolute bottom-2 left-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
+            onClick={(e) => {
+              e.stopPropagation();
+              // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
+              const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
+              const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`;
+              window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+            }}
+            title="Open source 3MF in Bambu Studio (right-click for more options)"
+          >
+            <FileCode className="w-4 h-4 text-orange-400" />
+          </button>
+        )}
         {/* Timelapse badge */}
         {archive.timelapse_path && (
           <button
@@ -583,6 +651,21 @@ function ArchiveCard({
         />
       )}
 
+      {/* Delete Source 3MF Confirmation */}
+      {showDeleteSource3mfConfirm && (
+        <ConfirmModal
+          title="Remove Source 3MF"
+          message={`Are you sure you want to remove the source 3MF file from "${archive.print_name || archive.filename}"? This will delete the original slicer project file.`}
+          confirmText="Remove"
+          variant="danger"
+          onConfirm={() => {
+            source3mfDeleteMutation.mutate();
+            setShowDeleteSource3mfConfirm(false);
+          }}
+          onCancel={() => setShowDeleteSource3mfConfirm(false)}
+        />
+      )}
+
       {/* Context Menu */}
       {contextMenu && (
         <ContextMenu
@@ -703,6 +786,21 @@ function ArchiveCard({
           onClose={() => setShowSchedule(false)}
         />
       )}
+
+      {/* Hidden file input for source 3MF upload */}
+      <input
+        ref={source3mfInputRef}
+        type="file"
+        accept=".3mf"
+        className="hidden"
+        onChange={(e) => {
+          const file = e.target.files?.[0];
+          if (file) {
+            source3mfUploadMutation.mutate(file);
+          }
+          e.target.value = '';
+        }}
+      />
     </Card>
   );
 }

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-CP_GXmpo.js


+ 1 - 1
static/index.html

@@ -7,7 +7,7 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-Bjcj9yTo.js"></script>
+    <script type="module" crossorigin src="/assets/index-CP_GXmpo.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DJNRCg8M.css">
   </head>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff