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
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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user