From 0ef97eeb9b1f7e843a65cd4e083869efafec8a62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 16:49:18 +0100 Subject: [PATCH 1/2] feat(back-end and front-end) 3d visualization for cad --- .../controller/QuoteSessionController.java | 41 ++++++ .../services/quote-estimator.service.ts | 11 ++ .../features/checkout/checkout.component.html | 60 +++++++++ .../features/checkout/checkout.component.scss | 118 ++++++++++++++++++ .../features/checkout/checkout.component.ts | 98 +++++++++++++++ .../stl-viewer/stl-viewer.component.html | 7 +- .../stl-viewer/stl-viewer.component.ts | 7 +- frontend/src/assets/i18n/de.json | 5 + frontend/src/assets/i18n/en.json | 5 + frontend/src/assets/i18n/fr.json | 5 + frontend/src/assets/i18n/it.json | 5 + 11 files changed, 359 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 86d741c..70689ac 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -552,6 +552,47 @@ public class QuoteSessionController { .body(resource); } + // 7. Download STL preview for checkout (only when original file is STL) + @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/stl-preview") + public ResponseEntity downloadLineItemStlPreview( + @PathVariable UUID sessionId, + @PathVariable UUID lineItemId + ) throws IOException { + QuoteLineItem item = lineItemRepo.findById(lineItemId) + .orElseThrow(() -> new RuntimeException("Item not found")); + + if (!item.getQuoteSession().getId().equals(sessionId)) { + return ResponseEntity.badRequest().build(); + } + + // Only expose preview for native STL uploads. + if (!"stl".equals(getSafeExtension(item.getOriginalFilename(), ""))) { + return ResponseEntity.notFound().build(); + } + + String targetStoredPath = item.getStoredPath(); + if (targetStoredPath == null || targetStoredPath.isBlank()) { + return ResponseEntity.notFound().build(); + } + + Path path = resolveStoredQuotePath(targetStoredPath, sessionId); + if (path == null || !Files.exists(path)) { + return ResponseEntity.notFound().build(); + } + + if (!"stl".equals(getSafeExtension(path.getFileName().toString(), ""))) { + return ResponseEntity.notFound().build(); + } + + Resource resource = new UrlResource(path.toUri()); + String downloadName = path.getFileName().toString(); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("model/stl")) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + downloadName + "\"") + .body(resource); + } + private String getSafeExtension(String filename, String fallback) { if (filename == null) { return fallback; 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 96b25f5..591ccf4 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -434,6 +434,17 @@ export class QuoteEstimatorService { ); } + getLineItemStlPreview(sessionId: string, lineItemId: string): Observable { + const headers: any = {}; + return this.http.get( + `${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/stl-preview`, + { + headers, + responseType: 'blob', + }, + ); + } + mapSessionToQuoteResult(sessionData: any): QuoteResult { const session = sessionData.session; const items = sessionData.items || []; diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index 3642be8..7522786 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -255,6 +255,39 @@ {{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h | {{ item.materialGrams | number: "1.0-0" }}g +
+ + + + +
+ {{ "CHECKOUT.PREVIEW_LOADING" | translate }} +
+
+ {{ "CHECKOUT.PREVIEW_UNAVAILABLE" | translate }} +
+
+
@@ -302,3 +335,30 @@
+ +
+
+
+

{{ selectedPreviewName() }}

+ +
+ +
+
diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss index 86442ed..081022b 100644 --- a/frontend/src/app/features/checkout/checkout.component.scss +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -244,6 +244,77 @@ app-toggle-selector.user-type-selector-compact { color: var(--color-text-muted); margin-top: 2px; } + + .item-preview { + margin-top: var(--space-3); + + .preview-trigger { + display: block; + width: 100%; + padding: 0; + margin: 0; + border: 0; + background: transparent; + cursor: pointer; + text-align: center; + + .preview-surface { + position: relative; + width: min(320px, 100%); + margin-inline: auto; + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease; + } + + .preview-pill { + position: absolute; + top: 8px; + right: 8px; + background: rgba(17, 24, 39, 0.84); + color: #fff; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; + padding: 4px 10px; + pointer-events: none; + } + + &:hover .preview-surface, + &:focus-visible .preview-surface { + border-color: var(--color-brand); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14); + transform: translateY(-1px); + } + + &:focus-visible { + outline: none; + } + } + + .preview-state { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-neutral-50); + color: var(--color-text-muted); + font-size: 0.8rem; + min-height: 74px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: var(--space-2); + } + + .preview-state-error { + color: var(--color-danger-600, #dc2626); + } + } } .item-price { @@ -316,6 +387,53 @@ app-toggle-selector.user-type-selector-compact { font-weight: 500; } +.preview-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(12, 16, 22, 0.72); + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); +} + +.preview-modal { + width: min(820px, 96vw); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + background: var(--color-bg-card); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + padding: var(--space-4); +} + +.preview-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-3); + + h4 { + margin: 0; + font-size: 1rem; + line-height: 1.2; + word-break: break-word; + } +} + +.preview-modal-close { + border: 1px solid var(--color-border); + background: var(--color-neutral-50); + color: var(--color-text); + width: 32px; + height: 32px; + border-radius: 999px; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; +} + .mb-6 { margin-bottom: var(--space-6); } diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index 8c7d8d3..ad95985 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -17,6 +17,7 @@ import { ToggleOption, } from '../../shared/components/app-toggle-selector/app-toggle-selector.component'; import { LanguageService } from '../../core/services/language.service'; +import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component'; @Component({ selector: 'app-checkout', @@ -29,6 +30,7 @@ import { LanguageService } from '../../core/services/language.service'; AppButtonComponent, AppCardComponent, AppToggleSelectorComponent, + StlViewerComponent, ], templateUrl: './checkout.component.html', styleUrls: ['./checkout.component.scss'], @@ -46,6 +48,13 @@ export class CheckoutComponent implements OnInit { error: string | null = null; isSubmitting = signal(false); // Add signal for submit state quoteSession = signal(null); // Add signal for session details + previewFiles = signal>({}); + previewLoading = signal>({}); + previewErrors = signal>({}); + previewModalOpen = signal(false); + selectedPreviewFile = signal(null); + selectedPreviewName = signal(''); + selectedPreviewColor = signal('#c9ced6'); userTypeOptions: ToggleOption[] = [ { label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' }, @@ -153,6 +162,7 @@ export class CheckoutComponent implements OnInit { this.quoteService.getQuoteSession(this.sessionId).subscribe({ next: (session) => { this.quoteSession.set(session); + this.loadStlPreviews(session); console.log('Loaded session:', session); }, error: (err) => { @@ -178,6 +188,94 @@ export class CheckoutComponent implements OnInit { return this.quoteSession()?.cadTotalChf ?? 0; } + isStlItem(item: any): boolean { + const name = String(item?.originalFilename ?? '').toLowerCase(); + return name.endsWith('.stl'); + } + + previewFile(item: any): File | null { + const id = String(item?.id ?? ''); + if (!id) { + return null; + } + return this.previewFiles()[id] ?? null; + } + + previewColor(item: any): string { + const raw = String(item?.colorCode ?? '').trim(); + return raw || '#c9ced6'; + } + + isPreviewLoading(item: any): boolean { + const id = String(item?.id ?? ''); + if (!id) { + return false; + } + return !!this.previewLoading()[id]; + } + + hasPreviewError(item: any): boolean { + const id = String(item?.id ?? ''); + if (!id) { + return false; + } + return !!this.previewErrors()[id]; + } + + openPreview(item: any): void { + const file = this.previewFile(item); + if (!file) { + return; + } + this.selectedPreviewFile.set(file); + this.selectedPreviewName.set(String(item?.originalFilename ?? file.name)); + this.selectedPreviewColor.set(this.previewColor(item)); + this.previewModalOpen.set(true); + } + + closePreview(): void { + this.previewModalOpen.set(false); + this.selectedPreviewFile.set(null); + this.selectedPreviewName.set(''); + this.selectedPreviewColor.set('#c9ced6'); + } + + private loadStlPreviews(session: any): void { + if (!this.sessionId || !Array.isArray(session?.items)) { + return; + } + + for (const item of session.items) { + if (!this.isStlItem(item)) { + continue; + } + + const id = String(item?.id ?? ''); + if (!id || this.previewFiles()[id] || this.previewLoading()[id]) { + continue; + } + + this.previewLoading.update((prev) => ({ ...prev, [id]: true })); + this.previewErrors.update((prev) => ({ ...prev, [id]: false })); + + this.quoteService.getLineItemStlPreview(this.sessionId, id).subscribe({ + next: (blob) => { + const originalName = String(item?.originalFilename ?? `${id}.stl`); + const stlName = originalName.toLowerCase().endsWith('.stl') + ? originalName + : `${originalName}.stl`; + const previewFile = new File([blob], stlName, { type: 'model/stl' }); + this.previewFiles.update((prev) => ({ ...prev, [id]: previewFile })); + this.previewLoading.update((prev) => ({ ...prev, [id]: false })); + }, + error: () => { + this.previewErrors.update((prev) => ({ ...prev, [id]: true })); + this.previewLoading.update((prev) => ({ ...prev, [id]: false })); + }, + }); + } + } + onSubmit() { if (this.checkoutForm.invalid) { return; diff --git a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html index bdce4b0..bfa18b7 100644 --- a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html +++ b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html @@ -1,4 +1,9 @@ -
+
@if (loading) {
diff --git a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts index f3e5196..23628ad 100644 --- a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts +++ b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts @@ -26,6 +26,8 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { @Input() file: File | null = null; @Input() color: string = '#facf0a'; // Default Brand Color + @Input() height = 300; + @Input() borderRadius = 'var(--radius-lg)'; @ViewChild('rendererContainer', { static: true }) rendererContainer!: ElementRef; @@ -176,7 +178,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { this.scene.add(this.currentMesh); - // Adjust camera to fit object + // Adjust camera to fit object and keep it visually centered const maxDim = Math.max(size.x, size.y, size.z); const fov = this.camera.fov * (Math.PI / 180); @@ -184,7 +186,8 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); cameraZ *= 1.72; - this.camera.position.set(cameraZ * 0.65, cameraZ * 0.95, cameraZ * 1.1); + this.camera.position.set(cameraZ * 0.68, cameraZ * 0.62, cameraZ * 1.08); + this.controls.target.set(0, 0, 0); this.camera.lookAt(0, 0, 0); this.camera.updateProjectionMatrix(); this.controls.update(); diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index a3b0cf5..ca1275b 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -404,6 +404,11 @@ "QTY": "Menge", "PER_PIECE": "pro Stück", "SHIPPING": "Versand (CH)", + "PREVIEW_LOADING": "3D-Vorschau wird geladen...", + "PREVIEW_UNAVAILABLE": "Vorschau nicht verfügbar", + "PREVIEW_OPEN": "3D öffnen", + "PREVIEW_ENLARGE": "Klicken Sie auf die Vorschau, um das 3D-Modell zu öffnen", + "PREVIEW_CLOSE": "Vorschau schließen", "ERR_NO_SESSION_START": "Keine aktive Sitzung gefunden. Bitte starten Sie ein neues Angebot.", "ERR_LOAD_SESSION": "Sitzungsdetails konnten nicht geladen werden. Bitte erneut versuchen.", "ERR_NO_SESSION_CREATE_ORDER": "Keine aktive Sitzung gefunden. Bestellung kann nicht erstellt werden.", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 54cfde0..e464d13 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -404,6 +404,11 @@ "QTY": "Qty", "PER_PIECE": "per piece", "SHIPPING": "Shipping", + "PREVIEW_LOADING": "Loading 3D preview...", + "PREVIEW_UNAVAILABLE": "Preview not available", + "PREVIEW_OPEN": "Open 3D", + "PREVIEW_ENLARGE": "Click the preview to open the 3D model", + "PREVIEW_CLOSE": "Close preview", "ERR_NO_SESSION_START": "No active session found. Please start a new quote.", "ERR_LOAD_SESSION": "Failed to load session details. Please try again.", "ERR_NO_SESSION_CREATE_ORDER": "No active session found. Cannot create order.", diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index 58ff6de..02ebd0c 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -461,6 +461,11 @@ "QTY": "Qté", "PER_PIECE": "par pièce", "SHIPPING": "Expédition (CH)", + "PREVIEW_LOADING": "Chargement de l'aperçu 3D...", + "PREVIEW_UNAVAILABLE": "Aperçu non disponible", + "PREVIEW_OPEN": "Ouvrir 3D", + "PREVIEW_ENLARGE": "Cliquez sur l'aperçu pour ouvrir le modèle 3D", + "PREVIEW_CLOSE": "Fermer l'aperçu", "INVALID_EMAIL": "E-mail invalide", "COMPANY_OPTIONAL": "Nom de l'entreprise (Optionnel)", "REF_PERSON_OPTIONAL": "Personne de référence (Optionnel)", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 3ed21d0..9444cb8 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -461,6 +461,11 @@ "QTY": "Qtà", "PER_PIECE": "al pezzo", "SHIPPING": "Spedizione (CH)", + "PREVIEW_LOADING": "Caricamento anteprima 3D...", + "PREVIEW_UNAVAILABLE": "Anteprima non disponibile", + "PREVIEW_OPEN": "Apri 3D", + "PREVIEW_ENLARGE": "Clicca sull'anteprima per aprire il modello 3D", + "PREVIEW_CLOSE": "Chiudi anteprima", "INVALID_EMAIL": "Email non valida", "COMPANY_OPTIONAL": "Nome Azienda (Opzionale)", "REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)", -- 2.49.1 From 5e5a3949d4c1bda5b902e339505b3cf51f3c6a74 Mon Sep 17 00:00:00 2001 From: printcalc-ci Date: Wed, 4 Mar 2026 15:53:21 +0000 Subject: [PATCH 2/2] style: apply prettier formatting --- .../features/calculator/services/quote-estimator.service.ts | 5 ++++- frontend/src/app/features/checkout/checkout.component.html | 4 +++- .../shared/components/stl-viewer/stl-viewer.component.ts | 6 +++++- 3 files changed, 12 insertions(+), 3 deletions(-) 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 591ccf4..7c72852 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -434,7 +434,10 @@ export class QuoteEstimatorService { ); } - getLineItemStlPreview(sessionId: string, lineItemId: string): Observable { + getLineItemStlPreview( + sessionId: string, + lineItemId: string, + ): Observable { const headers: any = {}; return this.http.get( `${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/stl-preview`, diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index 7522786..123df04 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -256,7 +256,9 @@ {{ item.materialGrams | number: "1.0-0" }}g
- +