fix(back-end): fix 3mf calculator
Some checks failed
Build and Deploy / test-backend (push) Successful in 55s
Build and Deploy / test-frontend (push) Successful in 1m10s
Build and Deploy / build-and-push (push) Failing after 14s
Build and Deploy / deploy (push) Has been skipped

This commit is contained in:
2026-03-04 09:21:07 +01:00
parent 8bd4ea54b2
commit 6eb0629136
6 changed files with 212 additions and 23 deletions

View File

@@ -42,6 +42,15 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0' implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
implementation 'org.springframework.boot:spring-boot-starter-mail' 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'

View File

@@ -120,7 +120,7 @@ public class QuoteSessionController {
// Helper to add item // Helper to add item
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { 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 // Scan for virus
clamAVService.scan(file.getInputStream()); clamAVService.scan(file.getInputStream());

View File

@@ -17,6 +17,20 @@ import java.util.Map;
@ControllerAdvice @ControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@ExceptionHandler(ModelProcessingException.class)
public ResponseEntity<Object> handleModelProcessingException(
ModelProcessingException ex, WebRequest request) {
Map<String, Object> 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) @ExceptionHandler(VirusDetectedException.class)
public ResponseEntity<Object> handleVirusDetectedException( public ResponseEntity<Object> handleVirusDetectedException(
VirusDetectedException ex, WebRequest request) { VirusDetectedException ex, WebRequest request) {
@@ -58,4 +72,12 @@ public class GlobalExceptionHandler {
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); 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;
}
} }

View File

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

View File

@@ -2,8 +2,14 @@ package com.printcalculator.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.exception.ModelProcessingException;
import com.printcalculator.model.ModelDimensions; import com.printcalculator.model.ModelDimensions;
import com.printcalculator.model.PrintStats; 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.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.w3c.dom.Document; import org.w3c.dom.Document;
@@ -11,7 +17,6 @@ import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap; import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node; import org.w3c.dom.Node;
import org.w3c.dom.NodeList; import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.xml.XMLConstants; import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.DocumentBuilderFactory;
@@ -19,7 +24,7 @@ import java.io.BufferedWriter;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.StringReader; import java.nio.IntBuffer;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException; import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
@@ -38,6 +43,14 @@ import java.util.regex.Pattern;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipFile; 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 @Service
public class SlicerService { public class SlicerService {
@@ -144,7 +157,10 @@ public class SlicerService {
if (!finished) { if (!finished) {
process.destroyForcibly(); 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) { if (process.exitValue() != 0) {
@@ -156,7 +172,11 @@ public class SlicerService {
logger.warning("Slicer reported model out of printable area, retrying with arrange."); logger.warning("Slicer reported model out of printable area, retrying with arrange.");
continue; 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(); File gcodeFile = tempDir.resolve(basename + ".gcode").toFile();
@@ -165,14 +185,20 @@ public class SlicerService {
if (alt.exists()) { if (alt.exists()) {
gcodeFile = alt; gcodeFile = alt;
} else { } 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); 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) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
@@ -319,30 +345,136 @@ public class SlicerService {
); );
String input3mfPath = requireSafeArgument(input3mf.getAbsolutePath(), "input 3MF path"); String input3mfPath = requireSafeArgument(input3mf.getAbsolutePath(), "input 3MF path");
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());
}
Path convertedStl = Path.of(conversionOutputStlPath); Path convertedStl = Path.of(conversionOutputStlPath);
String stlLog = runAssimpExport(input3mfPath, conversionOutputStlPath, tempDir.resolve("assimp-convert-stl.log")); try {
stlLog = runAssimpExport(input3mfPath, conversionOutputStlPath, tempDir.resolve("assimp-convert-stl.log"));
if (hasRenderableGeometry(convertedStl)) { if (hasRenderableGeometry(convertedStl)) {
return List.of(convertedStl.toString()); 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("Assimp STL conversion produced empty geometry. Retrying conversion to OBJ."); logger.warning("Retrying 3MF conversion to OBJ.");
Path convertedObj = Path.of(conversionOutputObjPath); Path convertedObj = Path.of(conversionOutputObjPath);
String objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log")); try {
objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log"));
if (hasRenderableGeometry(convertedObj)) { if (hasRenderableGeometry(convertedObj)) {
return List.of(convertedObj.toString()); 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"); Path fallbackStl = conversionOutputDir.resolve("converted-fallback.stl");
try {
long fallbackTriangles = convert3mfArchiveToAsciiStl(input3mf.toPath(), fallbackStl); long fallbackTriangles = convert3mfArchiveToAsciiStl(input3mf.toPath(), fallbackStl);
if (fallbackTriangles > 0 && hasRenderableGeometry(fallbackStl)) { if (fallbackTriangles > 0 && hasRenderableGeometry(fallbackStl)) {
logger.warning("Assimp conversion produced empty geometry. Fallback 3MF XML extractor generated " logger.warning("Assimp conversion produced empty geometry. Fallback 3MF XML extractor generated "
+ fallbackTriangles + " triangles."); + fallbackTriangles + " triangles.");
return List.of(fallbackStl.toString()); 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: " throw new ModelProcessingException(
+ stlLog + " OBJ log: " + objLog); "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) private String runAssimpExport(String input3mfPath, String outputModelPath, Path conversionLogPath)
@@ -583,6 +715,10 @@ public class SlicerService {
writer.write("endfacet\n"); 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) { private Vec3 computeNormal(Vec3 a, Vec3 b, Vec3 c) {
double ux = b.x() - a.x(); double ux = b.x() - a.x();
double uy = b.y() - a.y(); double uy = b.y() - a.y();

View File

@@ -4,7 +4,7 @@ server.port=8000
# Database Configuration # Database Configuration
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc} spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc}
spring.datasource.username=${DB_USERNAME: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.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.open-in-view=false spring.jpa.open-in-view=false
@@ -13,6 +13,7 @@ spring.jpa.open-in-view=false
# Slicer Configuration # Slicer Configuration
# Map SLICER_PATH env var if present (default to /opt/orcaslicer/AppRun or Mac path) # 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} slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer}
assimp.path=${ASSIMP_PATH:assimp}
profiles.root=${PROFILES_DIR:profiles} profiles.root=${PROFILES_DIR:profiles}