From 685cd704e78d6464f2b3bdd8ca556a6c2cf5157c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 10:23:25 +0100 Subject: [PATCH] fix(back-end): 3mf preview --- .../controller/QuoteSessionController.java | 35 ++++++++++++-- .../calculator/calculator-page.component.ts | 46 +++++++++++++++---- .../upload-form/upload-form.component.html | 4 +- .../upload-form/upload-form.component.ts | 27 ++++++++++- .../services/quote-estimator.service.ts | 9 +++- 5 files changed, 102 insertions(+), 19 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index b3724fe..6785d25 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -426,6 +426,7 @@ public class QuoteSessionController { dto.put("colorCode", item.getColorCode()); dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null); dto.put("status", item.getStatus()); + dto.put("convertedStoredPath", extractConvertedStoredPath(item)); BigDecimal unitPrice = item.getUnitPriceChf(); if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) { @@ -486,7 +487,8 @@ public class QuoteSessionController { @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content") public ResponseEntity downloadLineItemContent( @PathVariable UUID sessionId, - @PathVariable UUID lineItemId + @PathVariable UUID lineItemId, + @RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview ) throws IOException { QuoteLineItem item = lineItemRepo.findById(lineItemId) .orElseThrow(() -> new RuntimeException("Item not found")); @@ -495,20 +497,32 @@ public class QuoteSessionController { return ResponseEntity.badRequest().build(); } - if (item.getStoredPath() == null) { + String targetStoredPath = item.getStoredPath(); + if (preview) { + String convertedPath = extractConvertedStoredPath(item); + if (convertedPath != null && !convertedPath.isBlank()) { + targetStoredPath = convertedPath; + } + } + + if (targetStoredPath == null) { return ResponseEntity.notFound().build(); } - Path path = resolveStoredQuotePath(item.getStoredPath(), sessionId); + Path path = resolveStoredQuotePath(targetStoredPath, sessionId); if (path == null || !Files.exists(path)) { return ResponseEntity.notFound().build(); } org.springframework.core.io.Resource resource = new UrlResource(path.toUri()); + String downloadName = item.getOriginalFilename(); + if (preview) { + downloadName = path.getFileName().toString(); + } return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) - .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + item.getOriginalFilename() + "\"") + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"") .body(resource); } @@ -549,4 +563,17 @@ public class QuoteSessionController { return null; } } + + private String extractConvertedStoredPath(QuoteLineItem item) { + Map breakdown = item.getPricingBreakdown(); + if (breakdown == null) { + return null; + } + Object converted = breakdown.get("convertedStoredPath"); + if (converted == null) { + return null; + } + String path = String.valueOf(converted).trim(); + return path.isEmpty() ? null : path; + } } diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 8619be5..3126476 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -8,8 +8,8 @@ import { } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; -import { forkJoin } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { forkJoin, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; @@ -128,15 +128,18 @@ export class CalculatorPageComponent implements OnInit { // Download all files const downloads = items.map((item) => - this.estimator.getLineItemContent(session.id, item.id).pipe( - map((blob: Blob) => { + forkJoin({ + originalBlob: this.estimator.getLineItemContent(session.id, item.id), + previewBlob: this.estimator + .getLineItemContent(session.id, item.id, true) + .pipe(catchError(() => of(null))), + }).pipe( + map(({ originalBlob, previewBlob }) => { return { - blob, + originalBlob, + previewBlob, fileName: item.originalFilename, - // We need to match the file object to the item so we can set colors ideally. - // UploadForm.setFiles takes File[]. - // We might need to handle matching but UploadForm just pushes them. - // If order is preserved, we are good. items from backend are list. + hasConvertedPreview: !!item.convertedStoredPath, }; }), ), @@ -146,13 +149,25 @@ export class CalculatorPageComponent implements OnInit { next: (results: any[]) => { const files = results.map( (res) => - new File([res.blob], res.fileName, { + new File([res.originalBlob], res.fileName, { type: 'application/octet-stream', }), ); if (this.uploadForm) { this.uploadForm.setFiles(files); + results.forEach((res, index) => { + if (!res.hasConvertedPreview || !res.previewBlob) { + return; + } + const previewName = res.fileName + .replace(/\.[^.]+$/, '') + .concat('.stl'); + const previewFile = new File([res.previewBlob], previewName, { + type: 'model/stl', + }); + this.uploadForm.setPreviewFileByIndex(index, previewFile); + }); this.uploadForm.patchSettings(session); // Also restore colors? @@ -231,6 +246,17 @@ export class CalculatorPageComponent implements OnInit { queryParamsHandling: 'merge', // merge with existing params like 'mode' if any replaceUrl: true, // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update" }); + this.estimator.getQuoteSession(res.sessionId).subscribe({ + next: (sessionData) => { + this.restoreFilesAndSettings( + sessionData.session, + sessionData.items || [], + ); + }, + error: (err) => { + console.warn('Failed to refresh files for preview', err); + }, + }); } } }, diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index 87d7b97..ead6d45 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -2,13 +2,13 @@
@if (selectedFile()) {
- @if (!isStepFile(selectedFile())) { + @if (!canPreviewSelectedFile()) {

{{ "CALC.STEP_WARNING" | translate }}

} @else { diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index b8a023c..19c880b 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -32,6 +32,7 @@ import { getColorHex } from '../../../../core/constants/colors.const'; interface FormItem { file: File; + previewFile?: File; quantity: number; color: string; filamentVariantId?: number; @@ -96,12 +97,24 @@ export class UploadFormComponent implements OnInit { acceptedFormats = '.stl,.3mf,.step,.stp'; - isStepFile(file: File | null): boolean { + isStlFile(file: File | null): boolean { if (!file) return false; const name = file.name.toLowerCase(); return name.endsWith('.stl'); } + canPreviewSelectedFile(): boolean { + return this.isStlFile(this.getSelectedPreviewFile()); + } + + getSelectedPreviewFile(): File | null { + const selected = this.selectedFile(); + if (!selected) return null; + const item = this.items().find((i) => i.file === selected); + if (!item) return null; + return item.previewFile ?? item.file; + } + constructor() { this.form = this.fb.group({ itemsTouched: [false], // Hack to track touched state for custom items list @@ -262,6 +275,7 @@ export class UploadFormComponent implements OnInit { const defaultSelection = this.getDefaultVariantSelection(); validItems.push({ file, + previewFile: this.isStlFile(file) ? file : undefined, quantity: 1, color: defaultSelection.colorName, filamentVariantId: defaultSelection.filamentVariantId, @@ -390,6 +404,7 @@ export class UploadFormComponent implements OnInit { for (const file of files) { validItems.push({ file, + previewFile: this.isStlFile(file) ? file : undefined, quantity: 1, color: defaultSelection.colorName, filamentVariantId: defaultSelection.filamentVariantId, @@ -404,6 +419,16 @@ export class UploadFormComponent implements OnInit { } } + setPreviewFileByIndex(index: number, previewFile: File) { + if (!Number.isInteger(index) || index < 0) return; + this.items.update((current) => { + if (index >= current.length) return current; + const updated = [...current]; + updated[index] = { ...updated[index], previewFile }; + return updated; + }); + } + private getDefaultVariantSelection(): { colorName: string; filamentVariantId?: number; diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 85d1d44..8c9d77d 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -416,10 +416,15 @@ export class QuoteEstimatorService { } // Session File Retrieval - getLineItemContent(sessionId: string, lineItemId: string): Observable { + getLineItemContent( + sessionId: string, + lineItemId: string, + preview = false, + ): Observable { const headers: any = {}; + const previewQuery = preview ? '?preview=true' : ''; return this.http.get( - `${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`, + `${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content${previewQuery}`, { headers, responseType: 'blob',