From 8bd4ea54b230fdfe47186311d168eecf92dba039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 3 Mar 2026 18:48:59 +0100 Subject: [PATCH 1/9] fix(back-end): fix 3mf calculator --- backend/Dockerfile | 9 +++++++- .../calculator/calculator-page.component.html | 17 ++++++++++++++ .../calculator/calculator-page.component.scss | 15 +++++++++++++ .../calculator/calculator-page.component.ts | 22 +++++++++++++++++-- frontend/src/assets/i18n/de.json | 4 +++- frontend/src/assets/i18n/en.json | 4 +++- frontend/src/assets/i18n/fr.json | 4 +++- frontend/src/assets/i18n/it.json | 4 +++- 8 files changed, 72 insertions(+), 7 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 2239f80..6be68ae 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,6 +10,8 @@ RUN ./gradlew bootJar -x test --no-daemon # Stage 2: Runtime Environment FROM eclipse-temurin:21-jre-jammy +ARG ORCA_VERSION=2.3.1 +ARG ORCA_DOWNLOAD_URL # Install system dependencies for OrcaSlicer (same as before) RUN apt-get update && apt-get install -y \ @@ -25,7 +27,12 @@ RUN apt-get update && apt-get install -y \ # Install OrcaSlicer WORKDIR /opt -RUN wget -q https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/OrcaSlicer_Linux_V2.2.0.AppImage -O OrcaSlicer.AppImage \ +RUN set -eux; \ + ORCA_URL="${ORCA_DOWNLOAD_URL:-}"; \ + if [ -z "${ORCA_URL}" ]; then \ + ORCA_URL="https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_V${ORCA_VERSION}.AppImage"; \ + fi; \ + wget -q "${ORCA_URL}" -O OrcaSlicer.AppImage \ && 7z x OrcaSlicer.AppImage -o/opt/orcaslicer \ && chmod -R +x /opt/orcaslicer \ && rm OrcaSlicer.AppImage diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index ab7ae3a..03c86d5 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -4,6 +4,13 @@ @if (error()) { {{ errorKey() | translate }} + @if (isZeroQuoteError()) { +
+ + {{ "QUOTE.CONSULT" | translate }} + +
+ } } @@ -65,6 +72,16 @@ (proceed)="onProceed()" (itemChange)="onItemChange($event)" > + } @else if (isZeroQuoteError()) { + +

{{ "CALC.ZERO_RESULT_TITLE" | translate }}

+

{{ "CALC.ZERO_RESULT_HELP" | translate }}

+
+ + {{ "QUOTE.CONSULT" | translate }} + +
+
} @else {

{{ "CALC.BENEFITS_TITLE" | translate }}

diff --git a/frontend/src/app/features/calculator/calculator-page.component.scss b/frontend/src/app/features/calculator/calculator-page.component.scss index 1eb6b2e..1b878cd 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.scss +++ b/frontend/src/app/features/calculator/calculator-page.component.scss @@ -9,6 +9,12 @@ margin: 0 auto; } +.error-action { + display: flex; + justify-content: center; + margin-top: calc(var(--space-4) * -1); +} + .content-grid { display: grid; grid-template-columns: 1fr; @@ -82,6 +88,15 @@ line-height: 2; } +.zero-result-card p { + color: var(--color-text-muted); + line-height: 1.6; +} + +.zero-result-action { + margin-top: var(--space-4); +} + .loader-content { text-align: center; max-width: 300px; diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 14e7a12..8619be5 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -1,5 +1,6 @@ import { Component, + computed, signal, ViewChild, ElementRef, @@ -12,6 +13,7 @@ import { map } from 'rxjs/operators'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; +import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { UploadFormComponent } from './components/upload-form/upload-form.component'; import { QuoteResultComponent } from './components/quote-result/quote-result.component'; import { @@ -31,6 +33,7 @@ import { LanguageService } from '../../core/services/language.service'; TranslateModule, AppCardComponent, AppAlertComponent, + AppButtonComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent, @@ -47,6 +50,9 @@ export class CalculatorPageComponent implements OnInit { result = signal(null); error = signal(false); errorKey = signal('CALC.ERROR_GENERIC'); + isZeroQuoteError = computed( + () => this.error() && this.errorKey() === 'CALC.ERROR_ZERO_PRICE', + ); orderSuccess = signal(false); @@ -318,7 +324,10 @@ export class CalculatorPageComponent implements OnInit { private currentRequest: QuoteRequest | null = null; onConsult() { - if (!this.currentRequest) return; + if (!this.currentRequest) { + this.router.navigate(['/', this.languageService.selectedLang(), 'contact']); + return; + } const req = this.currentRequest; let details = `Richiesta Preventivo:\n`; @@ -349,7 +358,16 @@ export class CalculatorPageComponent implements OnInit { } private isInvalidQuote(result: QuoteResult): boolean { - return !Number.isFinite(result.totalPrice) || result.totalPrice <= 0; + const invalidPrice = + !Number.isFinite(result.totalPrice) || result.totalPrice <= 0; + const invalidWeight = + !Number.isFinite(result.totalWeight) || result.totalWeight <= 0; + const invalidTime = + !Number.isFinite(result.totalTimeHours) || + !Number.isFinite(result.totalTimeMinutes) || + (result.totalTimeHours <= 0 && result.totalTimeMinutes <= 0); + + return invalidPrice || invalidWeight || invalidTime; } private setQuoteError(key: string): void { diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index 49b29ed..f2bb3d1 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -91,7 +91,9 @@ "NOTES_PLACEHOLDER": "Spezifische Anweisungen...", "SETUP_NOTE": "* Beinhaltet {{cost}} als Einrichtungskosten", "SHIPPING_NOTE": "** Versandkosten ausgeschlossen, werden im nächsten Schritt berechnet", - "ERROR_ZERO_PRICE": "Etwas ist schiefgelaufen. Versuche ein anderes Format oder kontaktiere uns." + "ERROR_ZERO_PRICE": "Bei der Berechnung ist etwas schiefgelaufen. Versuche ein anderes Format oder kontaktiere uns direkt über \"Beratung anfragen\".", + "ZERO_RESULT_TITLE": "Ungültiges Ergebnis", + "ZERO_RESULT_HELP": "Die Berechnung hat ungültige Werte (0) geliefert. Versuche ein anderes Dateiformat oder kontaktiere uns direkt über \"Beratung anfragen\"." }, "SHOP": { "TITLE": "Technische Lösungen", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index a61b7d3..e65a8ce 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -91,7 +91,9 @@ "NOTES_PLACEHOLDER": "Specific instructions...", "SETUP_NOTE": "* Includes {{cost}} as setup cost", "SHIPPING_NOTE": "** Shipping costs excluded, calculated at the next step", - "ERROR_ZERO_PRICE": "Something went wrong. Try another format or contact us." + "ERROR_ZERO_PRICE": "Something went wrong during the calculation. Try another format or contact us directly via Request Consultation.", + "ZERO_RESULT_TITLE": "Invalid Result", + "ZERO_RESULT_HELP": "The calculation returned invalid zero values. Try another file format or contact us directly via Request Consultation." }, "SHOP": { "TITLE": "Technical solutions", diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index e9f3ad4..70f2bde 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -116,7 +116,9 @@ "FALLBACK_MATERIAL": "PLA (fallback)", "FALLBACK_QUALITY_STANDARD": "Standard", "ERR_FILE_TOO_LARGE": "Certains fichiers dépassent la limite de 200MB et n'ont pas été ajoutés.", - "ERROR_ZERO_PRICE": "Quelque chose s'est mal passé. Essayez un autre format ou contactez-nous." + "ERROR_ZERO_PRICE": "Un problème est survenu pendant le calcul. Essayez un autre format ou contactez-nous directement via Demander une consultation.", + "ZERO_RESULT_TITLE": "Résultat invalide", + "ZERO_RESULT_HELP": "Le calcul a renvoyé des valeurs nulles invalides. Essayez un autre format de fichier ou contactez-nous directement via Demander une consultation." }, "QUOTE": { "PROCEED_ORDER": "Procéder à la commande", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 15437f2..af78b60 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -116,7 +116,9 @@ "FALLBACK_MATERIAL": "PLA (fallback)", "FALLBACK_QUALITY_STANDARD": "Standard", "ERR_FILE_TOO_LARGE": "Alcuni file superano il limite di 200MB e non sono stati aggiunti.", - "ERROR_ZERO_PRICE": "Qualcosa è andato storto. Prova con un altro formato oppure contattaci." + "ERROR_ZERO_PRICE": "Qualcosa è andato storto nel calcolo. Prova un altro formato o contattaci direttamente con Richiedi Consulenza.", + "ZERO_RESULT_TITLE": "Risultato non valido", + "ZERO_RESULT_HELP": "Il calcolo ha restituito valori non validi (0). Prova con un altro formato file oppure contattaci direttamente con Richiedi Consulenza." }, "QUOTE": { "PROCEED_ORDER": "Procedi con l'ordine", -- 2.49.1 From 6eb0629136a69d087777282a6c0c76c85e81a8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 09:21:07 +0100 Subject: [PATCH 2/9] fix(back-end): fix 3mf calculator --- backend/build.gradle | 9 + .../controller/QuoteSessionController.java | 2 +- .../exception/GlobalExceptionHandler.java | 22 +++ .../exception/ModelProcessingException.java | 21 +++ .../service/SlicerService.java | 178 +++++++++++++++--- .../src/main/resources/application.properties | 3 +- 6 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/exception/ModelProcessingException.java diff --git a/backend/build.gradle b/backend/build.gradle index 627b2cc..d54f8b3 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -42,6 +42,15 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation platform('org.lwjgl:lwjgl-bom:3.3.4') + implementation 'org.lwjgl:lwjgl' + implementation 'org.lwjgl:lwjgl-assimp' + runtimeOnly 'org.lwjgl:lwjgl::natives-linux' + runtimeOnly 'org.lwjgl:lwjgl::natives-macos' + runtimeOnly 'org.lwjgl:lwjgl::natives-macos-arm64' + runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-linux' + runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-macos' + runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-macos-arm64' diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 58ccb50..5ab36a9 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -120,7 +120,7 @@ public class QuoteSessionController { // Helper to add item private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { - if (file.isEmpty()) throw new IOException("File is empty"); + if (file.isEmpty()) throw new IllegalArgumentException("File is empty"); // Scan for virus clamAVService.scan(file.getInputStream()); diff --git a/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java index dc49fbd..e45936d 100644 --- a/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java @@ -17,6 +17,20 @@ import java.util.Map; @ControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(ModelProcessingException.class) + public ResponseEntity handleModelProcessingException( + ModelProcessingException ex, WebRequest request) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.UNPROCESSABLE_ENTITY.value()); + body.put("error", "Unprocessable Entity"); + body.put("code", ex.getCode()); + body.put("message", ex.getMessage()); + body.put("path", extractPath(request)); + + return new ResponseEntity<>(body, HttpStatus.UNPROCESSABLE_ENTITY); + } + @ExceptionHandler(VirusDetectedException.class) public ResponseEntity handleVirusDetectedException( VirusDetectedException ex, WebRequest request) { @@ -58,4 +72,12 @@ public class GlobalExceptionHandler { return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); } + + private String extractPath(WebRequest request) { + String raw = request.getDescription(false); + if (raw == null) { + return ""; + } + return raw.startsWith("uri=") ? raw.substring(4) : raw; + } } diff --git a/backend/src/main/java/com/printcalculator/exception/ModelProcessingException.java b/backend/src/main/java/com/printcalculator/exception/ModelProcessingException.java new file mode 100644 index 0000000..9199128 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/exception/ModelProcessingException.java @@ -0,0 +1,21 @@ +package com.printcalculator.exception; + +import java.io.IOException; + +public class ModelProcessingException extends IOException { + private final String code; + + public ModelProcessingException(String code, String message) { + super(message); + this.code = code; + } + + public ModelProcessingException(String code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + public String getCode() { + return code; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index e489db7..ed4e1c1 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -2,8 +2,14 @@ package com.printcalculator.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.printcalculator.exception.ModelProcessingException; import com.printcalculator.model.ModelDimensions; import com.printcalculator.model.PrintStats; +import org.lwjgl.PointerBuffer; +import org.lwjgl.assimp.AIFace; +import org.lwjgl.assimp.AIMesh; +import org.lwjgl.assimp.AIScene; +import org.lwjgl.assimp.AIVector3D; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.w3c.dom.Document; @@ -11,7 +17,6 @@ import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; -import org.xml.sax.InputSource; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilderFactory; @@ -19,7 +24,7 @@ import java.io.BufferedWriter; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.io.StringReader; +import java.nio.IntBuffer; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -38,6 +43,14 @@ import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; +import static org.lwjgl.assimp.Assimp.aiGetErrorString; +import static org.lwjgl.assimp.Assimp.aiImportFile; +import static org.lwjgl.assimp.Assimp.aiProcess_JoinIdenticalVertices; +import static org.lwjgl.assimp.Assimp.aiProcess_PreTransformVertices; +import static org.lwjgl.assimp.Assimp.aiProcess_SortByPType; +import static org.lwjgl.assimp.Assimp.aiProcess_Triangulate; +import static org.lwjgl.assimp.Assimp.aiReleaseImport; + @Service public class SlicerService { @@ -144,7 +157,10 @@ public class SlicerService { if (!finished) { process.destroyForcibly(); - throw new IOException("Slicer timed out"); + throw new ModelProcessingException( + "SLICER_TIMEOUT", + "Model processing timed out. Try another format or contact us directly via Request Consultation." + ); } if (process.exitValue() != 0) { @@ -156,7 +172,11 @@ public class SlicerService { logger.warning("Slicer reported model out of printable area, retrying with arrange."); continue; } - throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error); + logger.warning("Slicer failed with exit code " + process.exitValue() + ". Log: " + error); + throw new ModelProcessingException( + "SLICER_EXECUTION_FAILED", + "Unable to process this model. Try another format or contact us directly via Request Consultation." + ); } File gcodeFile = tempDir.resolve(basename + ".gcode").toFile(); @@ -165,14 +185,20 @@ public class SlicerService { if (alt.exists()) { gcodeFile = alt; } else { - throw new IOException("GCode output not found in " + tempDir); + throw new ModelProcessingException( + "SLICER_OUTPUT_MISSING", + "Unable to generate slicing output for this model. Try another format or contact us directly via Request Consultation." + ); } } return gCodeParser.parse(gcodeFile); } - throw new IOException("Slicer failed after retry"); + throw new ModelProcessingException( + "SLICER_FAILED_AFTER_RETRY", + "Unable to process this model. Try another format or contact us directly via Request Consultation." + ); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -319,30 +345,136 @@ public class SlicerService { ); String input3mfPath = requireSafeArgument(input3mf.getAbsolutePath(), "input 3MF path"); - Path convertedStl = Path.of(conversionOutputStlPath); - String stlLog = runAssimpExport(input3mfPath, conversionOutputStlPath, tempDir.resolve("assimp-convert-stl.log")); - if (hasRenderableGeometry(convertedStl)) { - return List.of(convertedStl.toString()); + String stlLog = ""; + String objLog = ""; + + Path lwjglConvertedStl = conversionOutputDir.resolve("converted-lwjgl.stl"); + try { + long lwjglTriangles = convert3mfToStlWithLwjglAssimp(input3mf.toPath(), lwjglConvertedStl); + if (lwjglTriangles > 0 && hasRenderableGeometry(lwjglConvertedStl)) { + logger.info("Converted 3MF to STL via LWJGL Assimp. Triangles: " + lwjglTriangles); + return List.of(lwjglConvertedStl.toString()); + } + logger.warning("LWJGL Assimp conversion produced no renderable geometry."); + } catch (Exception | LinkageError e) { + logger.warning("LWJGL Assimp conversion failed, falling back to assimp CLI: " + e.getMessage()); } - logger.warning("Assimp STL conversion produced empty geometry. Retrying conversion to OBJ."); + Path convertedStl = Path.of(conversionOutputStlPath); + try { + stlLog = runAssimpExport(input3mfPath, conversionOutputStlPath, tempDir.resolve("assimp-convert-stl.log")); + if (hasRenderableGeometry(convertedStl)) { + return List.of(convertedStl.toString()); + } + logger.warning("Assimp STL conversion produced empty geometry."); + } catch (IOException e) { + stlLog = e.getMessage() != null ? e.getMessage() : ""; + logger.warning("Assimp STL conversion failed, trying alternate conversion paths: " + stlLog); + } + + logger.warning("Retrying 3MF conversion to OBJ."); Path convertedObj = Path.of(conversionOutputObjPath); - String objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log")); - if (hasRenderableGeometry(convertedObj)) { - return List.of(convertedObj.toString()); + try { + objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log")); + if (hasRenderableGeometry(convertedObj)) { + return List.of(convertedObj.toString()); + } + logger.warning("Assimp OBJ conversion produced empty geometry."); + } catch (IOException e) { + objLog = e.getMessage() != null ? e.getMessage() : ""; + logger.warning("Assimp OBJ conversion failed: " + objLog); } Path fallbackStl = conversionOutputDir.resolve("converted-fallback.stl"); - long fallbackTriangles = convert3mfArchiveToAsciiStl(input3mf.toPath(), fallbackStl); - if (fallbackTriangles > 0 && hasRenderableGeometry(fallbackStl)) { - logger.warning("Assimp conversion produced empty geometry. Fallback 3MF XML extractor generated " - + fallbackTriangles + " triangles."); - return List.of(fallbackStl.toString()); + try { + long fallbackTriangles = convert3mfArchiveToAsciiStl(input3mf.toPath(), fallbackStl); + if (fallbackTriangles > 0 && hasRenderableGeometry(fallbackStl)) { + logger.warning("Assimp conversion produced empty geometry. Fallback 3MF XML extractor generated " + + fallbackTriangles + " triangles."); + return List.of(fallbackStl.toString()); + } + logger.warning("3MF XML fallback completed but produced no renderable triangles."); + } catch (IOException e) { + logger.warning("3MF XML fallback conversion failed: " + e.getMessage()); } - throw new IOException("3MF conversion produced no renderable geometry (STL+OBJ). STL log: " - + stlLog + " OBJ log: " + objLog); + throw new ModelProcessingException( + "MODEL_CONVERSION_FAILED", + "Unable to process this 3MF file. Try another format or contact us directly via Request Consultation." + ); + } + + private long convert3mfToStlWithLwjglAssimp(Path input3mf, Path outputStl) throws IOException { + int flags = aiProcess_Triangulate + | aiProcess_JoinIdenticalVertices + | aiProcess_PreTransformVertices + | aiProcess_SortByPType; + AIScene scene = aiImportFile(input3mf.toString(), flags); + if (scene == null) { + throw new IOException("LWJGL Assimp import failed: " + aiGetErrorString()); + } + + long triangleCount = 0L; + try (BufferedWriter writer = Files.newBufferedWriter(outputStl, StandardCharsets.UTF_8)) { + writer.write("solid converted\n"); + + int meshCount = scene.mNumMeshes(); + PointerBuffer meshPointers = scene.mMeshes(); + if (meshCount <= 0 || meshPointers == null) { + throw new IOException("LWJGL Assimp import contains no meshes"); + } + + for (int meshIndex = 0; meshIndex < meshCount; meshIndex++) { + long meshPtr = meshPointers.get(meshIndex); + if (meshPtr == 0L) { + continue; + } + AIMesh mesh = AIMesh.create(meshPtr); + AIVector3D.Buffer vertices = mesh.mVertices(); + AIFace.Buffer faces = mesh.mFaces(); + if (vertices == null || faces == null) { + continue; + } + + int vertexCount = mesh.mNumVertices(); + int faceCount = mesh.mNumFaces(); + for (int faceIndex = 0; faceIndex < faceCount; faceIndex++) { + AIFace face = faces.get(faceIndex); + if (face.mNumIndices() != 3) { + continue; + } + IntBuffer indices = face.mIndices(); + if (indices == null || indices.remaining() < 3) { + continue; + } + int i0 = indices.get(0); + int i1 = indices.get(1); + int i2 = indices.get(2); + if (i0 < 0 || i1 < 0 || i2 < 0 + || i0 >= vertexCount + || i1 >= vertexCount + || i2 >= vertexCount) { + continue; + } + + Vec3 p1 = toVec3(vertices.get(i0)); + Vec3 p2 = toVec3(vertices.get(i1)); + Vec3 p3 = toVec3(vertices.get(i2)); + writeAsciiFacet(writer, p1, p2, p3); + triangleCount++; + } + } + + writer.write("endsolid converted\n"); + } finally { + aiReleaseImport(scene); + } + + if (triangleCount <= 0) { + throw new IOException("LWJGL Assimp conversion produced no triangles"); + } + return triangleCount; } private String runAssimpExport(String input3mfPath, String outputModelPath, Path conversionLogPath) @@ -583,6 +715,10 @@ public class SlicerService { writer.write("endfacet\n"); } + private Vec3 toVec3(AIVector3D v) { + return new Vec3(v.x(), v.y(), v.z()); + } + private Vec3 computeNormal(Vec3 a, Vec3 b, Vec3 c) { double ux = b.x() - a.x(); double uy = b.y() - a.y(); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 37dab8d..ad6e2a0 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -4,7 +4,7 @@ server.port=8000 # Database Configuration spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc} spring.datasource.username=${DB_USERNAME:printcalc} -spring.datasource.password=${DB_PASSWORD:} +spring.datasource.password=${DB_PASSWORD:printcalc_secret} spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.open-in-view=false @@ -13,6 +13,7 @@ spring.jpa.open-in-view=false # Slicer Configuration # Map SLICER_PATH env var if present (default to /opt/orcaslicer/AppRun or Mac path) slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer} +assimp.path=${ASSIMP_PATH:assimp} profiles.root=${PROFILES_DIR:profiles} -- 2.49.1 From db748fb6497955e862f355d52cb1911b7b9bd958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 09:24:21 +0100 Subject: [PATCH 3/9] fix(back-end): fix 3mf calculator --- .../controller/QuoteSessionController.java | 22 ++++++++++-- .../service/SlicerService.java | 36 ++++++++++++++++++- 2 files changed, 55 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 5ab36a9..b3724fe 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -146,6 +146,7 @@ public class QuoteSessionController { Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING); } + Path convertedPersistentPath = null; try { // Apply Basic/Advanced Logic applyPrintSettings(settings); @@ -182,10 +183,21 @@ public class QuoteSessionController { if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight())); if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%"); if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern()); + + Path slicerInputPath = persistentPath; + if ("3mf".equals(ext)) { + String convertedFilename = UUID.randomUUID() + "-converted.stl"; + convertedPersistentPath = sessionStorageDir.resolve(convertedFilename).normalize(); + if (!convertedPersistentPath.startsWith(sessionStorageDir)) { + throw new IOException("Invalid converted STL storage path"); + } + slicerService.convert3mfToPersistentStl(persistentPath.toFile(), convertedPersistentPath); + slicerInputPath = convertedPersistentPath; + } // 3. Slice (Use persistent path) PrintStats stats = slicerService.slice( - persistentPath.toFile(), + slicerInputPath.toFile(), machineProfile, filamentProfile, processProfile, @@ -193,7 +205,7 @@ public class QuoteSessionController { processOverrides ); - Optional modelDimensions = slicerService.inspectModelDimensions(persistentPath.toFile()); + Optional modelDimensions = slicerService.inspectModelDimensions(slicerInputPath.toFile()); // 4. Calculate Quote QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant); @@ -216,6 +228,9 @@ public class QuoteSessionController { Map breakdown = new HashMap<>(); breakdown.put("machine_cost", result.getTotalPrice()); // Excludes setup fee which is at session level breakdown.put("setup_fee", 0); + if (convertedPersistentPath != null) { + breakdown.put("convertedStoredPath", QUOTE_STORAGE_ROOT.relativize(convertedPersistentPath).toString()); + } item.setPricingBreakdown(breakdown); // Dimensions for shipping/package checks are computed server-side from the uploaded model. @@ -237,6 +252,9 @@ public class QuoteSessionController { } catch (Exception e) { // Cleanup if failed Files.deleteIfExists(persistentPath); + if (convertedPersistentPath != null) { + Files.deleteIfExists(convertedPersistentPath); + } throw e; } } diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index ed4e1c1..844adad 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -331,6 +331,31 @@ public class SlicerService { return convertedStlPaths; } + public Path convert3mfToPersistentStl(File input3mf, Path destinationStl) throws IOException { + Path tempDir = Files.createTempDirectory("slicer_convert_"); + try { + List convertedPaths = convert3mfToStlInputPaths(input3mf, tempDir); + if (convertedPaths.isEmpty()) { + throw new ModelProcessingException( + "MODEL_CONVERSION_FAILED", + "Unable to process this 3MF file. Try another format or contact us directly via Request Consultation." + ); + } + Path source = Path.of(convertedPaths.get(0)); + Path parent = destinationStl.toAbsolutePath().normalize().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(source, destinationStl, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + return destinationStl; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted during 3MF conversion", e); + } finally { + deleteRecursively(tempDir); + } + } + private List convert3mfToStlInputPaths(File input3mf, Path tempDir) throws IOException, InterruptedException { Path conversionOutputDir = tempDir.resolve("converted-from-3mf"); Files.createDirectories(conversionOutputDir); @@ -378,7 +403,16 @@ public class SlicerService { try { objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log")); if (hasRenderableGeometry(convertedObj)) { - return List.of(convertedObj.toString()); + Path stlFromObj = conversionOutputDir.resolve("converted-from-obj.stl"); + runAssimpExport( + convertedObj.toString(), + stlFromObj.toString(), + tempDir.resolve("assimp-convert-obj-to-stl.log") + ); + if (hasRenderableGeometry(stlFromObj)) { + return List.of(stlFromObj.toString()); + } + logger.warning("Assimp OBJ->STL conversion produced empty geometry."); } logger.warning("Assimp OBJ conversion produced empty geometry."); } catch (IOException e) { -- 2.49.1 From 0f57034b525d8d551a2685676ed218b296ff741d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 09:49:03 +0100 Subject: [PATCH 4/9] fix(back-end): fix 3mf calculator --- backend/Dockerfile | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 6be68ae..fb61590 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -29,10 +29,33 @@ RUN apt-get update && apt-get install -y \ WORKDIR /opt RUN set -eux; \ ORCA_URL="${ORCA_DOWNLOAD_URL:-}"; \ - if [ -z "${ORCA_URL}" ]; then \ - ORCA_URL="https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_V${ORCA_VERSION}.AppImage"; \ - fi; \ - wget -q "${ORCA_URL}" -O OrcaSlicer.AppImage \ + if [ -n "${ORCA_URL}" ]; then \ + wget -q "${ORCA_URL}" -O OrcaSlicer.AppImage; \ + else \ + CANDIDATES="\ +https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2404_V${ORCA_VERSION}.AppImage \ +https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \ +https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_V${ORCA_VERSION}.AppImage \ +https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_V${ORCA_VERSION}.AppImage \ +https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2404_V${ORCA_VERSION}.AppImage \ +https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \ +https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_V${ORCA_VERSION}.AppImage \ +https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_V${ORCA_VERSION}.AppImage"; \ + ok=0; \ + for url in $CANDIDATES; do \ + if wget -q --spider "$url"; then \ + wget -q "$url" -O OrcaSlicer.AppImage; \ + ok=1; \ + break; \ + fi; \ + done; \ + if [ "$ok" -ne 1 ]; then \ + echo "Failed to find OrcaSlicer AppImage for version ${ORCA_VERSION}" >&2; \ + echo "Tried URLs:" >&2; \ + for url in $CANDIDATES; do echo " - $url" >&2; done; \ + exit 1; \ + fi; \ + fi \ && 7z x OrcaSlicer.AppImage -o/opt/orcaslicer \ && chmod -R +x /opt/orcaslicer \ && rm OrcaSlicer.AppImage -- 2.49.1 From 27d0399263309a9768d2e53112cc956ad452bfdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 09:52:09 +0100 Subject: [PATCH 5/9] fix(back-end): fix 3mf calculator --- backend/Dockerfile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index fb61590..e730ac4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,7 +16,6 @@ ARG ORCA_DOWNLOAD_URL # Install system dependencies for OrcaSlicer (same as before) RUN apt-get update && apt-get install -y \ wget \ - p7zip-full \ assimp-utils \ libgl1 \ libglib2.0-0 \ @@ -33,13 +32,13 @@ RUN set -eux; \ wget -q "${ORCA_URL}" -O OrcaSlicer.AppImage; \ else \ CANDIDATES="\ -https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2404_V${ORCA_VERSION}.AppImage \ https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \ https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_V${ORCA_VERSION}.AppImage \ +https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2404_V${ORCA_VERSION}.AppImage \ https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_V${ORCA_VERSION}.AppImage \ -https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2404_V${ORCA_VERSION}.AppImage \ https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \ https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_V${ORCA_VERSION}.AppImage \ +https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2404_V${ORCA_VERSION}.AppImage \ https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_V${ORCA_VERSION}.AppImage"; \ ok=0; \ for url in $CANDIDATES; do \ @@ -56,7 +55,10 @@ https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaS exit 1; \ fi; \ fi \ - && 7z x OrcaSlicer.AppImage -o/opt/orcaslicer \ + && chmod +x OrcaSlicer.AppImage \ + && rm -rf /opt/orcaslicer /opt/squashfs-root \ + && ./OrcaSlicer.AppImage --appimage-extract >/dev/null \ + && mv /opt/squashfs-root /opt/orcaslicer \ && chmod -R +x /opt/orcaslicer \ && rm OrcaSlicer.AppImage -- 2.49.1 From 09179ce825d806681af240683ad4e3d3408d133e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 09:59:25 +0100 Subject: [PATCH 6/9] fix(back-end): fix 3mf calculator --- backend/Dockerfile | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index e730ac4..6067fdf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -33,16 +33,12 @@ RUN set -eux; \ else \ CANDIDATES="\ https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \ -https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_V${ORCA_VERSION}.AppImage \ -https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2404_V${ORCA_VERSION}.AppImage \ -https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_V${ORCA_VERSION}.AppImage \ https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \ -https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_V${ORCA_VERSION}.AppImage \ -https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2404_V${ORCA_VERSION}.AppImage \ -https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_V${ORCA_VERSION}.AppImage"; \ +https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/OrcaSlicer_Linux_V2.2.0.AppImage"; \ ok=0; \ for url in $CANDIDATES; do \ if wget -q --spider "$url"; then \ + echo "Using OrcaSlicer URL: $url"; \ wget -q "$url" -O OrcaSlicer.AppImage; \ ok=1; \ break; \ -- 2.49.1 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 7/9] 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', -- 2.49.1 From 0f2f2bc7a93a8d8688a01f35708385db4eb66416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 10:26:40 +0100 Subject: [PATCH 8/9] fix(back-end): 3mf preview --- .../app/features/calculator/calculator-page.component.html | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 03c86d5..404a48b 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -4,13 +4,6 @@ @if (error()) { {{ errorKey() | translate }} - @if (isZeroQuoteError()) { -
- - {{ "QUOTE.CONSULT" | translate }} - -
- } }
-- 2.49.1 From 412f3ae71b02cc2e207d4d10da23bdd20c84f004 Mon Sep 17 00:00:00 2001 From: printcalc-ci Date: Wed, 4 Mar 2026 09:59:05 +0000 Subject: [PATCH 9/9] style: apply prettier formatting --- .../app/features/calculator/calculator-page.component.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 3126476..c91308c 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -351,7 +351,11 @@ export class CalculatorPageComponent implements OnInit { onConsult() { if (!this.currentRequest) { - this.router.navigate(['/', this.languageService.selectedLang(), 'contact']); + this.router.navigate([ + '/', + this.languageService.selectedLang(), + 'contact', + ]); return; } -- 2.49.1