feat(back-end and front-end) 3d visualization for cad #21
@@ -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;
|
||||
|
||||
@@ -434,6 +434,17 @@ 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 || [];
|
||||
|
||||
@@ -255,6 +255,39 @@
|
||||
{{ 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 +335,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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user