fix(back-end): 3mf preview
This commit is contained in:
@@ -426,6 +426,7 @@ public class QuoteSessionController {
|
|||||||
dto.put("colorCode", item.getColorCode());
|
dto.put("colorCode", item.getColorCode());
|
||||||
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
|
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
|
||||||
dto.put("status", item.getStatus());
|
dto.put("status", item.getStatus());
|
||||||
|
dto.put("convertedStoredPath", extractConvertedStoredPath(item));
|
||||||
|
|
||||||
BigDecimal unitPrice = item.getUnitPriceChf();
|
BigDecimal unitPrice = item.getUnitPriceChf();
|
||||||
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
|
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
|
||||||
@@ -486,7 +487,8 @@ public class QuoteSessionController {
|
|||||||
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
|
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
|
||||||
public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent(
|
public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent(
|
||||||
@PathVariable UUID sessionId,
|
@PathVariable UUID sessionId,
|
||||||
@PathVariable UUID lineItemId
|
@PathVariable UUID lineItemId,
|
||||||
|
@RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||||
.orElseThrow(() -> new RuntimeException("Item not found"));
|
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||||
@@ -495,20 +497,32 @@ public class QuoteSessionController {
|
|||||||
return ResponseEntity.badRequest().build();
|
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();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Path path = resolveStoredQuotePath(item.getStoredPath(), sessionId);
|
Path path = resolveStoredQuotePath(targetStoredPath, sessionId);
|
||||||
if (path == null || !Files.exists(path)) {
|
if (path == null || !Files.exists(path)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
org.springframework.core.io.Resource resource = new UrlResource(path.toUri());
|
org.springframework.core.io.Resource resource = new UrlResource(path.toUri());
|
||||||
|
String downloadName = item.getOriginalFilename();
|
||||||
|
if (preview) {
|
||||||
|
downloadName = path.getFileName().toString();
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
.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);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,4 +563,17 @@ public class QuoteSessionController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String extractConvertedStoredPath(QuoteLineItem item) {
|
||||||
|
Map<String, Object> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
|
|
||||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
||||||
@@ -128,15 +128,18 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
|
|
||||||
// Download all files
|
// Download all files
|
||||||
const downloads = items.map((item) =>
|
const downloads = items.map((item) =>
|
||||||
this.estimator.getLineItemContent(session.id, item.id).pipe(
|
forkJoin({
|
||||||
map((blob: Blob) => {
|
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 {
|
return {
|
||||||
blob,
|
originalBlob,
|
||||||
|
previewBlob,
|
||||||
fileName: item.originalFilename,
|
fileName: item.originalFilename,
|
||||||
// We need to match the file object to the item so we can set colors ideally.
|
hasConvertedPreview: !!item.convertedStoredPath,
|
||||||
// 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.
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -146,13 +149,25 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
next: (results: any[]) => {
|
next: (results: any[]) => {
|
||||||
const files = results.map(
|
const files = results.map(
|
||||||
(res) =>
|
(res) =>
|
||||||
new File([res.blob], res.fileName, {
|
new File([res.originalBlob], res.fileName, {
|
||||||
type: 'application/octet-stream',
|
type: 'application/octet-stream',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.uploadForm) {
|
if (this.uploadForm) {
|
||||||
this.uploadForm.setFiles(files);
|
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);
|
this.uploadForm.patchSettings(session);
|
||||||
|
|
||||||
// Also restore colors?
|
// Also restore colors?
|
||||||
@@ -231,6 +246,17 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
|
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"
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
@if (selectedFile()) {
|
@if (selectedFile()) {
|
||||||
<div class="viewer-wrapper">
|
<div class="viewer-wrapper">
|
||||||
@if (!isStepFile(selectedFile())) {
|
@if (!canPreviewSelectedFile()) {
|
||||||
<div class="step-warning">
|
<div class="step-warning">
|
||||||
<p>{{ "CALC.STEP_WARNING" | translate }}</p>
|
<p>{{ "CALC.STEP_WARNING" | translate }}</p>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<app-stl-viewer
|
<app-stl-viewer
|
||||||
[file]="selectedFile()"
|
[file]="getSelectedPreviewFile()"
|
||||||
[color]="getSelectedFileColor()"
|
[color]="getSelectedFileColor()"
|
||||||
>
|
>
|
||||||
</app-stl-viewer>
|
</app-stl-viewer>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { getColorHex } from '../../../../core/constants/colors.const';
|
|||||||
|
|
||||||
interface FormItem {
|
interface FormItem {
|
||||||
file: File;
|
file: File;
|
||||||
|
previewFile?: File;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
color: string;
|
color: string;
|
||||||
filamentVariantId?: number;
|
filamentVariantId?: number;
|
||||||
@@ -96,12 +97,24 @@ export class UploadFormComponent implements OnInit {
|
|||||||
|
|
||||||
acceptedFormats = '.stl,.3mf,.step,.stp';
|
acceptedFormats = '.stl,.3mf,.step,.stp';
|
||||||
|
|
||||||
isStepFile(file: File | null): boolean {
|
isStlFile(file: File | null): boolean {
|
||||||
if (!file) return false;
|
if (!file) return false;
|
||||||
const name = file.name.toLowerCase();
|
const name = file.name.toLowerCase();
|
||||||
return name.endsWith('.stl');
|
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() {
|
constructor() {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
itemsTouched: [false], // Hack to track touched state for custom items list
|
itemsTouched: [false], // Hack to track touched state for custom items list
|
||||||
@@ -262,6 +275,7 @@ export class UploadFormComponent implements OnInit {
|
|||||||
const defaultSelection = this.getDefaultVariantSelection();
|
const defaultSelection = this.getDefaultVariantSelection();
|
||||||
validItems.push({
|
validItems.push({
|
||||||
file,
|
file,
|
||||||
|
previewFile: this.isStlFile(file) ? file : undefined,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
color: defaultSelection.colorName,
|
color: defaultSelection.colorName,
|
||||||
filamentVariantId: defaultSelection.filamentVariantId,
|
filamentVariantId: defaultSelection.filamentVariantId,
|
||||||
@@ -390,6 +404,7 @@ export class UploadFormComponent implements OnInit {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
validItems.push({
|
validItems.push({
|
||||||
file,
|
file,
|
||||||
|
previewFile: this.isStlFile(file) ? file : undefined,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
color: defaultSelection.colorName,
|
color: defaultSelection.colorName,
|
||||||
filamentVariantId: defaultSelection.filamentVariantId,
|
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(): {
|
private getDefaultVariantSelection(): {
|
||||||
colorName: string;
|
colorName: string;
|
||||||
filamentVariantId?: number;
|
filamentVariantId?: number;
|
||||||
|
|||||||
@@ -416,10 +416,15 @@ export class QuoteEstimatorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Session File Retrieval
|
// Session File Retrieval
|
||||||
getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> {
|
getLineItemContent(
|
||||||
|
sessionId: string,
|
||||||
|
lineItemId: string,
|
||||||
|
preview = false,
|
||||||
|
): Observable<Blob> {
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
|
const previewQuery = preview ? '?preview=true' : '';
|
||||||
return this.http.get(
|
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,
|
headers,
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
Reference in New Issue
Block a user