fix(back-end): add extended support for 3MF conversion
Some checks failed
PR Checks / test-frontend (pull_request) Successful in 1m7s
Build and Deploy / test-backend (push) Successful in 35s
PR Checks / prettier-autofix (pull_request) Successful in 11s
Build and Deploy / test-frontend (push) Successful in 1m7s
PR Checks / security-sast (pull_request) Failing after 30s
PR Checks / test-backend (pull_request) Successful in 24s
Build and Deploy / build-and-push (push) Successful in 1m31s
Build and Deploy / deploy (push) Successful in 11s

This commit is contained in:
2026-03-03 18:13:15 +01:00
parent 548b23317f
commit 476dc5b2ce
2 changed files with 533 additions and 2 deletions

View File

@@ -15,6 +15,7 @@ FROM eclipse-temurin:21-jre-jammy
RUN apt-get update && apt-get install -y \
wget \
p7zip-full \
assimp-utils \
libgl1 \
libglib2.0-0 \
libgtk-3-0 \
@@ -32,6 +33,7 @@ RUN wget -q https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/Orc
ENV PATH="/opt/orcaslicer/usr/bin:${PATH}"
# Set Slicer Path env variable for Java app
ENV SLICER_PATH="/opt/orcaslicer/AppRun"
ENV ASSIMP_PATH="assimp"
WORKDIR /app
# Copy JAR from build stage

View File

@@ -6,21 +6,37 @@ import com.printcalculator.model.ModelDimensions;
import com.printcalculator.model.PrintStats;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
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;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
@Service
public class SlicerService {
@@ -31,16 +47,19 @@ public class SlicerService {
private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private final String trustedSlicerPath;
private final String trustedAssimpPath;
private final ProfileManager profileManager;
private final GCodeParser gCodeParser;
private final ObjectMapper mapper;
public SlicerService(
@Value("${slicer.path}") String slicerPath,
@Value("${assimp.path:assimp}") String assimpPath,
ProfileManager profileManager,
GCodeParser gCodeParser,
ObjectMapper mapper) {
this.trustedSlicerPath = normalizeExecutablePath(slicerPath);
this.trustedAssimpPath = normalizeExecutablePath(assimpPath);
this.profileManager = profileManager;
this.gCodeParser = gCodeParser;
this.mapper = mapper;
@@ -87,7 +106,8 @@ public class SlicerService {
String processProfilePath = requireSafeArgument(pFile.getAbsolutePath(), "process profile path");
String filamentProfilePath = requireSafeArgument(fFile.getAbsolutePath(), "filament profile path");
String outputDirPath = requireSafeArgument(tempDir.toAbsolutePath().toString(), "output directory path");
String inputStlPath = requireSafeArgument(inputStl.getAbsolutePath(), "input STL path");
String inputModelPath = requireSafeArgument(inputStl.getAbsolutePath(), "input model path");
List<String> slicerInputPaths = resolveSlicerInputPaths(inputStl, inputModelPath, tempDir);
// 3. Run slicer. Retry with arrange only for out-of-volume style failures.
for (boolean useArrange : new boolean[]{false, true}) {
@@ -110,7 +130,7 @@ public class SlicerService {
command.add("0");
command.add("--outputdir");
command.add(outputDirPath);
command.add(inputStlPath);
command.addAll(slicerInputPaths);
logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command));
@@ -274,6 +294,515 @@ public class SlicerService {
|| normalized.contains("calc_exclude_triangles");
}
private List<String> resolveSlicerInputPaths(File inputModel, String inputModelPath, Path tempDir)
throws IOException, InterruptedException {
if (!inputModel.getName().toLowerCase().endsWith(".3mf")) {
return List.of(inputModelPath);
}
List<String> convertedStlPaths = convert3mfToStlInputPaths(inputModel, tempDir);
logger.info("Converted 3MF to " + convertedStlPaths.size() + " STL file(s) for slicing.");
return convertedStlPaths;
}
private List<String> convert3mfToStlInputPaths(File input3mf, Path tempDir) throws IOException, InterruptedException {
Path conversionOutputDir = tempDir.resolve("converted-from-3mf");
Files.createDirectories(conversionOutputDir);
String conversionOutputStlPath = requireSafeArgument(
conversionOutputDir.resolve("converted.stl").toAbsolutePath().toString(),
"3MF conversion output STL path"
);
String conversionOutputObjPath = requireSafeArgument(
conversionOutputDir.resolve("converted.obj").toAbsolutePath().toString(),
"3MF conversion output OBJ path"
);
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());
}
logger.warning("Assimp STL conversion produced empty geometry. Retrying 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());
}
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());
}
throw new IOException("3MF conversion produced no renderable geometry (STL+OBJ). STL log: "
+ stlLog + " OBJ log: " + objLog);
}
private String runAssimpExport(String input3mfPath, String outputModelPath, Path conversionLogPath)
throws IOException, InterruptedException {
ProcessBuilder conversionPb = new ProcessBuilder();
List<String> conversionCommand = conversionPb.command();
conversionCommand.add(trustedAssimpPath);
conversionCommand.add("export");
conversionCommand.add(input3mfPath);
conversionCommand.add(outputModelPath);
logger.info("Converting 3MF with Assimp: " + String.join(" ", conversionCommand));
Files.deleteIfExists(conversionLogPath);
conversionPb.redirectErrorStream(true);
conversionPb.redirectOutput(conversionLogPath.toFile());
Process conversionProcess = conversionPb.start();
boolean conversionFinished = conversionProcess.waitFor(3, TimeUnit.MINUTES);
if (!conversionFinished) {
conversionProcess.destroyForcibly();
throw new IOException("3MF conversion timed out");
}
String conversionLog = Files.exists(conversionLogPath)
? Files.readString(conversionLogPath, StandardCharsets.UTF_8)
: "";
if (conversionProcess.exitValue() != 0) {
throw new IOException("3MF conversion failed with exit code "
+ conversionProcess.exitValue() + ": " + conversionLog);
}
return conversionLog;
}
private boolean hasRenderableGeometry(Path modelPath) throws IOException {
if (!Files.isRegularFile(modelPath) || Files.size(modelPath) == 0) {
return false;
}
String fileName = modelPath.getFileName().toString().toLowerCase();
if (fileName.endsWith(".obj")) {
try (var lines = Files.lines(modelPath)) {
return lines.map(String::trim).anyMatch(line -> line.startsWith("f "));
}
}
if (fileName.endsWith(".stl")) {
long size = Files.size(modelPath);
if (size <= 84) {
return false;
}
byte[] header = new byte[84];
try (InputStream is = Files.newInputStream(modelPath)) {
int read = is.read(header);
if (read < 84) {
return false;
}
}
long triangleCount = ((long) (header[80] & 0xff))
| (((long) (header[81] & 0xff)) << 8)
| (((long) (header[82] & 0xff)) << 16)
| (((long) (header[83] & 0xff)) << 24);
if (triangleCount > 0) {
return true;
}
try (var lines = Files.lines(modelPath)) {
return lines.limit(2000).anyMatch(line -> line.contains("facet normal"));
}
}
return true;
}
private long convert3mfArchiveToAsciiStl(Path input3mf, Path outputStl) throws IOException {
Map<String, ThreeMfModelDocument> modelCache = new HashMap<>();
long[] triangleCount = new long[]{0L};
try (ZipFile zipFile = new ZipFile(input3mf.toFile());
BufferedWriter writer = Files.newBufferedWriter(outputStl, StandardCharsets.UTF_8)) {
writer.write("solid converted\n");
ThreeMfModelDocument rootModel = loadThreeMfModel(zipFile, modelCache, "3D/3dmodel.model");
Element build = findFirstChildByLocalName(rootModel.rootElement(), "build");
if (build == null) {
throw new IOException("3MF build section not found in root model");
}
for (Element item : findChildrenByLocalName(build, "item")) {
if ("0".equals(getAttributeByLocalName(item, "printable"))) {
continue;
}
String objectId = getAttributeByLocalName(item, "objectid");
if (objectId == null || objectId.isBlank()) {
continue;
}
Transform itemTransform = parseTransform(getAttributeByLocalName(item, "transform"));
writeObjectTriangles(
zipFile,
modelCache,
rootModel.modelPath(),
objectId,
itemTransform,
writer,
triangleCount,
new HashSet<>(),
0
);
}
writer.write("endsolid converted\n");
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("3MF fallback conversion failed: " + e.getMessage(), e);
}
return triangleCount[0];
}
private void writeObjectTriangles(
ZipFile zipFile,
Map<String, ThreeMfModelDocument> modelCache,
String modelPath,
String objectId,
Transform transform,
BufferedWriter writer,
long[] triangleCount,
Set<String> recursionGuard,
int depth
) throws Exception {
if (depth > 64) {
throw new IOException("3MF component nesting too deep");
}
String guardKey = modelPath + "#" + objectId;
if (!recursionGuard.add(guardKey)) {
return;
}
try {
ThreeMfModelDocument modelDocument = loadThreeMfModel(zipFile, modelCache, modelPath);
Element objectElement = modelDocument.objectsById().get(objectId);
if (objectElement == null) {
return;
}
Element mesh = findFirstChildByLocalName(objectElement, "mesh");
if (mesh != null) {
writeMeshTriangles(mesh, transform, writer, triangleCount);
}
Element components = findFirstChildByLocalName(objectElement, "components");
if (components != null) {
for (Element component : findChildrenByLocalName(components, "component")) {
String childObjectId = getAttributeByLocalName(component, "objectid");
if (childObjectId == null || childObjectId.isBlank()) {
continue;
}
String componentPath = getAttributeByLocalName(component, "path");
String resolvedModelPath = (componentPath == null || componentPath.isBlank())
? modelDocument.modelPath()
: normalizeZipPath(componentPath);
Transform componentTransform = parseTransform(getAttributeByLocalName(component, "transform"));
Transform combinedTransform = transform.multiply(componentTransform);
writeObjectTriangles(
zipFile,
modelCache,
resolvedModelPath,
childObjectId,
combinedTransform,
writer,
triangleCount,
recursionGuard,
depth + 1
);
}
}
} finally {
recursionGuard.remove(guardKey);
}
}
private void writeMeshTriangles(
Element meshElement,
Transform transform,
BufferedWriter writer,
long[] triangleCount
) throws IOException {
Element verticesElement = findFirstChildByLocalName(meshElement, "vertices");
Element trianglesElement = findFirstChildByLocalName(meshElement, "triangles");
if (verticesElement == null || trianglesElement == null) {
return;
}
List<Vec3> vertices = new java.util.ArrayList<>();
for (Element vertex : findChildrenByLocalName(verticesElement, "vertex")) {
Double x = parseDoubleAttribute(vertex, "x");
Double y = parseDoubleAttribute(vertex, "y");
Double z = parseDoubleAttribute(vertex, "z");
if (x == null || y == null || z == null) {
continue;
}
vertices.add(new Vec3(x, y, z));
}
if (vertices.isEmpty()) {
return;
}
for (Element triangle : findChildrenByLocalName(trianglesElement, "triangle")) {
Integer v1 = parseIntAttribute(triangle, "v1");
Integer v2 = parseIntAttribute(triangle, "v2");
Integer v3 = parseIntAttribute(triangle, "v3");
if (v1 == null || v2 == null || v3 == null) {
continue;
}
if (v1 < 0 || v2 < 0 || v3 < 0 || v1 >= vertices.size() || v2 >= vertices.size() || v3 >= vertices.size()) {
continue;
}
Vec3 p1 = transform.apply(vertices.get(v1));
Vec3 p2 = transform.apply(vertices.get(v2));
Vec3 p3 = transform.apply(vertices.get(v3));
writeAsciiFacet(writer, p1, p2, p3);
triangleCount[0]++;
}
}
private void writeAsciiFacet(BufferedWriter writer, Vec3 p1, Vec3 p2, Vec3 p3) throws IOException {
Vec3 normal = computeNormal(p1, p2, p3);
writer.write("facet normal " + normal.x() + " " + normal.y() + " " + normal.z() + "\n");
writer.write(" outer loop\n");
writer.write(" vertex " + p1.x() + " " + p1.y() + " " + p1.z() + "\n");
writer.write(" vertex " + p2.x() + " " + p2.y() + " " + p2.z() + "\n");
writer.write(" vertex " + p3.x() + " " + p3.y() + " " + p3.z() + "\n");
writer.write(" endloop\n");
writer.write("endfacet\n");
}
private Vec3 computeNormal(Vec3 a, Vec3 b, Vec3 c) {
double ux = b.x() - a.x();
double uy = b.y() - a.y();
double uz = b.z() - a.z();
double vx = c.x() - a.x();
double vy = c.y() - a.y();
double vz = c.z() - a.z();
double nx = uy * vz - uz * vy;
double ny = uz * vx - ux * vz;
double nz = ux * vy - uy * vx;
double length = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (length <= 1e-12) {
return new Vec3(0.0, 0.0, 0.0);
}
return new Vec3(nx / length, ny / length, nz / length);
}
private ThreeMfModelDocument loadThreeMfModel(
ZipFile zipFile,
Map<String, ThreeMfModelDocument> modelCache,
String modelPath
) throws Exception {
String normalizedPath = normalizeZipPath(modelPath);
ThreeMfModelDocument cached = modelCache.get(normalizedPath);
if (cached != null) {
return cached;
}
ZipEntry entry = zipFile.getEntry(normalizedPath);
if (entry == null) {
throw new IOException("3MF model entry not found: " + normalizedPath);
}
Document document = parseXmlDocument(zipFile, entry);
Element root = document.getDocumentElement();
Map<String, Element> objectsById = new HashMap<>();
Element resources = findFirstChildByLocalName(root, "resources");
if (resources != null) {
for (Element objectElement : findChildrenByLocalName(resources, "object")) {
String id = getAttributeByLocalName(objectElement, "id");
if (id != null && !id.isBlank()) {
objectsById.put(id, objectElement);
}
}
}
ThreeMfModelDocument loaded = new ThreeMfModelDocument(normalizedPath, root, objectsById);
modelCache.put(normalizedPath, loaded);
return loaded;
}
private Document parseXmlDocument(ZipFile zipFile, ZipEntry entry) throws Exception {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
disableIfSupported(dbf, "http://apache.org/xml/features/disallow-doctype-decl");
disableIfSupported(dbf, "http://xml.org/sax/features/external-general-entities");
disableIfSupported(dbf, "http://xml.org/sax/features/external-parameter-entities");
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
try (InputStream is = zipFile.getInputStream(entry)) {
return dbf.newDocumentBuilder().parse(is);
}
}
private void disableIfSupported(DocumentBuilderFactory dbf, String feature) {
try {
dbf.setFeature(feature, false);
} catch (Exception ignored) {
// Best-effort hardening.
}
}
private String normalizeZipPath(String rawPath) throws IOException {
if (rawPath == null || rawPath.isBlank()) {
throw new IOException("Invalid empty 3MF model path");
}
String normalized = rawPath.trim().replace("\\", "/");
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
if (normalized.contains("..")) {
throw new IOException("Invalid 3MF model path: " + rawPath);
}
return normalized;
}
private List<Element> findChildrenByLocalName(Element parent, String localName) {
List<Element> result = new java.util.ArrayList<>();
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
String nodeLocalName = element.getLocalName() != null ? element.getLocalName() : element.getTagName();
if (localName.equals(nodeLocalName)) {
result.add(element);
}
}
}
return result;
}
private Element findFirstChildByLocalName(Element parent, String localName) {
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
String nodeLocalName = element.getLocalName() != null ? element.getLocalName() : element.getTagName();
if (localName.equals(nodeLocalName)) {
return element;
}
}
}
return null;
}
private String getAttributeByLocalName(Element element, String localName) {
if (element.hasAttribute(localName)) {
return element.getAttribute(localName);
}
NamedNodeMap attrs = element.getAttributes();
for (int i = 0; i < attrs.getLength(); i++) {
Node attr = attrs.item(i);
String attrLocal = attr.getLocalName() != null ? attr.getLocalName() : attr.getNodeName();
if (localName.equals(attrLocal)) {
return attr.getNodeValue();
}
}
return null;
}
private Double parseDoubleAttribute(Element element, String attributeName) {
String value = getAttributeByLocalName(element, attributeName);
if (value == null || value.isBlank()) {
return null;
}
try {
return Double.parseDouble(value);
} catch (NumberFormatException ignored) {
return null;
}
}
private Integer parseIntAttribute(Element element, String attributeName) {
String value = getAttributeByLocalName(element, attributeName);
if (value == null || value.isBlank()) {
return null;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException ignored) {
return null;
}
}
private Transform parseTransform(String rawTransform) throws IOException {
if (rawTransform == null || rawTransform.isBlank()) {
return Transform.identity();
}
String[] tokens = rawTransform.trim().split("\\s+");
if (tokens.length != 12) {
throw new IOException("Invalid 3MF transform format: " + rawTransform);
}
double[] v = new double[12];
for (int i = 0; i < 12; i++) {
try {
v[i] = Double.parseDouble(tokens[i]);
} catch (NumberFormatException e) {
throw new IOException("Invalid number in 3MF transform: " + rawTransform, e);
}
}
return new Transform(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11]);
}
private record ThreeMfModelDocument(String modelPath, Element rootElement, Map<String, Element> objectsById) {
}
private record Vec3(double x, double y, double z) {
}
private record Transform(
double m00, double m01, double m02,
double m10, double m11, double m12,
double m20, double m21, double m22,
double tx, double ty, double tz
) {
static Transform identity() {
return new Transform(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0);
}
Transform multiply(Transform other) {
return new Transform(
m00 * other.m00 + m01 * other.m10 + m02 * other.m20,
m00 * other.m01 + m01 * other.m11 + m02 * other.m21,
m00 * other.m02 + m01 * other.m12 + m02 * other.m22,
m10 * other.m00 + m11 * other.m10 + m12 * other.m20,
m10 * other.m01 + m11 * other.m11 + m12 * other.m21,
m10 * other.m02 + m11 * other.m12 + m12 * other.m22,
m20 * other.m00 + m21 * other.m10 + m22 * other.m20,
m20 * other.m01 + m21 * other.m11 + m22 * other.m21,
m20 * other.m02 + m21 * other.m12 + m22 * other.m22,
m00 * other.tx + m01 * other.ty + m02 * other.tz + tx,
m10 * other.tx + m11 * other.ty + m12 * other.tz + ty,
m20 * other.tx + m21 * other.ty + m22 * other.tz + tz
);
}
Vec3 apply(Vec3 v) {
return new Vec3(
m00 * v.x() + m01 * v.y() + m02 * v.z() + tx,
m10 * v.x() + m11 * v.y() + m12 * v.z() + ty,
m20 * v.x() + m21 * v.y() + m22 * v.z() + tz
);
}
}
private String normalizeExecutablePath(String configuredPath) {
if (configuredPath == null || configuredPath.isBlank()) {
throw new IllegalArgumentException("slicer.path is required");