fix(back-end): fix 3mf calculator
This commit is contained in:
@@ -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'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
Path convertedStl = Path.of(conversionOutputStlPath);
|
String stlLog = "";
|
||||||
String stlLog = runAssimpExport(input3mfPath, conversionOutputStlPath, tempDir.resolve("assimp-convert-stl.log"));
|
String objLog = "";
|
||||||
if (hasRenderableGeometry(convertedStl)) {
|
|
||||||
return List.of(convertedStl.toString());
|
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);
|
Path convertedObj = Path.of(conversionOutputObjPath);
|
||||||
String objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log"));
|
try {
|
||||||
if (hasRenderableGeometry(convertedObj)) {
|
objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log"));
|
||||||
return List.of(convertedObj.toString());
|
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");
|
Path fallbackStl = conversionOutputDir.resolve("converted-fallback.stl");
|
||||||
long fallbackTriangles = convert3mfArchiveToAsciiStl(input3mf.toPath(), fallbackStl);
|
try {
|
||||||
if (fallbackTriangles > 0 && hasRenderableGeometry(fallbackStl)) {
|
long fallbackTriangles = convert3mfArchiveToAsciiStl(input3mf.toPath(), fallbackStl);
|
||||||
logger.warning("Assimp conversion produced empty geometry. Fallback 3MF XML extractor generated "
|
if (fallbackTriangles > 0 && hasRenderableGeometry(fallbackStl)) {
|
||||||
+ fallbackTriangles + " triangles.");
|
logger.warning("Assimp conversion produced empty geometry. Fallback 3MF XML extractor generated "
|
||||||
return List.of(fallbackStl.toString());
|
+ 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: "
|
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();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user