Merge pull request 'feat(back-end and front-end) 3d visualization for cad' (#21) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 8s

Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-03-04 16:57:49 +01:00
11 changed files with 368 additions and 3 deletions

View File

@@ -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<Resource> 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;

View File

@@ -434,6 +434,20 @@ export class QuoteEstimatorService {
);
}
getLineItemStlPreview(
sessionId: string,
lineItemId: string,
): Observable<Blob> {
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 || [];

View File

@@ -255,6 +255,41 @@
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
{{ item.materialGrams | number: "1.0-0" }}g
</div>
<div class="item-preview" *ngIf="isStlItem(item)">
<ng-container
*ngIf="previewFile(item) as itemPreview; else previewState"
>
<button
type="button"
class="preview-trigger"
(click)="openPreview(item)"
[attr.aria-label]="'CHECKOUT.PREVIEW_OPEN' | translate"
>
<div class="preview-surface">
<app-stl-viewer
[file]="itemPreview"
[height]="116"
[color]="previewColor(item)"
[borderRadius]="'var(--radius-lg)'"
></app-stl-viewer>
<span class="preview-pill">{{
"CHECKOUT.PREVIEW_OPEN" | translate
}}</span>
</div>
</button>
</ng-container>
<ng-template #previewState>
<div class="preview-state" *ngIf="isPreviewLoading(item)">
{{ "CHECKOUT.PREVIEW_LOADING" | translate }}
</div>
<div
class="preview-state preview-state-error"
*ngIf="!isPreviewLoading(item) && hasPreviewError(item)"
>
{{ "CHECKOUT.PREVIEW_UNAVAILABLE" | translate }}
</div>
</ng-template>
</div>
</div>
<div class="item-price">
<span class="item-total-price">
@@ -302,3 +337,30 @@
</div>
</div>
</div>
<div
class="preview-modal-backdrop"
*ngIf="previewModalOpen()"
(click)="closePreview()"
>
<div class="preview-modal" (click)="$event.stopPropagation()">
<div class="preview-modal-header">
<h4>{{ selectedPreviewName() }}</h4>
<button
type="button"
class="preview-modal-close"
(click)="closePreview()"
[attr.aria-label]="'CHECKOUT.PREVIEW_CLOSE' | translate"
>
×
</button>
</div>
<app-stl-viewer
*ngIf="selectedPreviewFile() as preview"
[file]="preview"
[height]="460"
[color]="selectedPreviewColor()"
[borderRadius]="'var(--radius-lg)'"
></app-stl-viewer>
</div>
</div>

View File

@@ -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);
}

View File

@@ -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<any>(null); // Add signal for session details
previewFiles = signal<Record<string, File>>({});
previewLoading = signal<Record<string, boolean>>({});
previewErrors = signal<Record<string, boolean>>({});
previewModalOpen = signal(false);
selectedPreviewFile = signal<File | null>(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;

View File

@@ -1,4 +1,9 @@
<div class="viewer-container" #rendererContainer>
<div
class="viewer-container"
#rendererContainer
[style.height.px]="height"
[style.border-radius]="borderRadius"
>
@if (loading) {
<div class="loading-overlay">
<div class="spinner"></div>

View File

@@ -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,12 @@ 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();

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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)",

View File

@@ -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)",