fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 6s

This commit is contained in:
2026-02-16 17:32:29 +01:00
parent b337db03c4
commit 66de93a315
5 changed files with 201 additions and 4 deletions

View File

@@ -18,10 +18,13 @@ import java.util.HashMap;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Logger;
@RestController
public class QuoteController {
private static final Logger logger = Logger.getLogger(QuoteController.class.getName());
private final SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator;
@@ -113,6 +116,7 @@ public class QuoteController {
// Save uploaded file temporarily
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
com.printcalculator.model.StlShiftResult shift = null;
try {
file.transferTo(tempInput.toFile());
@@ -123,9 +127,23 @@ public class QuoteController {
}
// Validate model size against machine volume
validateModelSize(tempInput.toFile(), machine);
StlBounds bounds = validateModelSize(tempInput.toFile(), machine);
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
// Auto-center if needed
shift = stlService.shiftToFitIfNeeded(
tempInput.toFile(),
bounds,
machine.getBuildVolumeXMm(),
machine.getBuildVolumeYMm(),
machine.getBuildVolumeZMm()
);
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : tempInput.toFile();
if (shift.shifted()) {
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
}
PrintStats stats = slicerService.slice(sliceInput, slicerMachineProfile, filament, process, machineOverrides, processOverrides);
// Calculate Quote (Pass machine display name for pricing lookup)
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
@@ -133,10 +151,15 @@ public class QuoteController {
return ResponseEntity.ok(result);
} finally {
Files.deleteIfExists(tempInput);
if (shift != null && shift.shifted()) {
try {
Files.deleteIfExists(shift.shiftedPath());
} catch (Exception ignored) {}
}
}
}
private void validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
StlBounds bounds = stlService.readBounds(stlFile);
double x = bounds.sizeX();
double y = bounds.sizeY();
@@ -146,6 +169,13 @@ public class QuoteController {
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
logger.info(String.format(
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
bounds.minX(), bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
x, y, z, bx, by, bz
));
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
@@ -153,5 +183,6 @@ public class QuoteController {
if (!fits) {
throw new ModelTooLargeException(x, y, z, bx, by, bz);
}
return bounds;
}
}

View File

@@ -31,12 +31,15 @@ import java.util.Map;
import java.util.UUID;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import java.util.logging.Logger;
@RestController
@RequestMapping("/api/quote-sessions")
public class QuoteSessionController {
private static final Logger logger = Logger.getLogger(QuoteSessionController.class.getName());
private final QuoteSessionRepository sessionRepo;
private final QuoteLineItemRepository lineItemRepo;
private final SlicerService slicerService;
@@ -125,6 +128,7 @@ public class QuoteSessionController {
// Resolve absolute path for slicing and storage usage
Path persistentPath = storageService.loadAsResource(relativePath).getFile().toPath();
com.printcalculator.model.StlShiftResult shift = null;
try {
// Apply Basic/Advanced Logic
applyPrintSettings(settings);
@@ -137,6 +141,20 @@ public class QuoteSessionController {
// 2. Validate model size against machine volume
StlBounds bounds = validateModelSize(persistentPath.toFile(), machine);
// 2b. Auto-center if needed (keeps the stored STL unchanged)
shift = stlService.shiftToFitIfNeeded(
persistentPath.toFile(),
bounds,
machine.getBuildVolumeXMm(),
machine.getBuildVolumeYMm(),
machine.getBuildVolumeZMm()
);
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : persistentPath.toFile();
if (shift.shifted()) {
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
}
// 3. Pick Profiles
String machineProfile = machine.getSlicerMachineProfile();
if (machineProfile == null || machineProfile.isBlank()) {
@@ -205,7 +223,7 @@ public class QuoteSessionController {
// 4. Slice (Use persistent path)
PrintStats stats = slicerService.slice(
persistentPath.toFile(),
sliceInput,
machineProfile,
filamentProfile,
processProfile,
@@ -252,6 +270,12 @@ public class QuoteSessionController {
storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename));
} catch (Exception ignored) {}
throw e;
} finally {
if (shift != null && shift.shifted()) {
try {
Files.deleteIfExists(shift.shiftedPath());
} catch (Exception ignored) {}
}
}
}
@@ -265,6 +289,13 @@ public class QuoteSessionController {
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
logger.info(String.format(
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
bounds.minX(), bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
x, y, z, bx, by, bz
));
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);

View File

@@ -0,0 +1,10 @@
package com.printcalculator.model;
import java.nio.file.Path;
public record StlShiftResult(Path shiftedPath,
double offsetX,
double offsetY,
double offsetZ,
boolean shifted) {
}

View File

@@ -1,14 +1,20 @@
package com.printcalculator.service;
import com.printcalculator.model.StlBounds;
import com.printcalculator.model.StlShiftResult;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
@Service
public class StlService {
@@ -21,6 +27,30 @@ public class StlService {
return readAsciiBounds(stlFile);
}
public StlShiftResult shiftToFitIfNeeded(File stlFile, StlBounds bounds,
int bedX, int bedY, int bedZ) throws IOException {
double sizeX = bounds.sizeX();
double sizeY = bounds.sizeY();
double sizeZ = bounds.sizeZ();
double targetMinX = (bedX - sizeX) / 2.0;
double targetMinY = (bedY - sizeY) / 2.0;
double targetMinZ = 0.0;
double offsetX = targetMinX - bounds.minX();
double offsetY = targetMinY - bounds.minY();
double offsetZ = targetMinZ - bounds.minZ();
boolean needsShift = Math.abs(offsetX) > 1e-6 || Math.abs(offsetY) > 1e-6 || Math.abs(offsetZ) > 1e-6;
if (!needsShift) {
return new StlShiftResult(null, offsetX, offsetY, offsetZ, false);
}
Path shiftedPath = Files.createTempFile("stl_shifted_", ".stl");
writeShifted(stlFile, shiftedPath.toFile(), offsetX, offsetY, offsetZ);
return new StlShiftResult(shiftedPath, offsetX, offsetY, offsetZ, true);
}
private boolean isBinaryStl(File stlFile, long size) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
raf.seek(80);
@@ -71,6 +101,82 @@ public class StlService {
return acc.toBounds();
}
private void writeShifted(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
long size = input.length();
if (size >= 84 && isBinaryStl(input, size)) {
writeShiftedBinary(input, output, offsetX, offsetY, offsetZ);
} else {
writeShiftedAscii(input, output, offsetX, offsetY, offsetZ);
}
}
private void writeShiftedAscii(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(input.toPath(), StandardCharsets.US_ASCII);
BufferedWriter writer = Files.newBufferedWriter(output.toPath(), StandardCharsets.US_ASCII)) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (!trimmed.startsWith("vertex")) {
writer.write(line);
writer.newLine();
continue;
}
String[] parts = trimmed.split("\\s+");
if (parts.length < 4) {
writer.write(line);
writer.newLine();
continue;
}
double x = Double.parseDouble(parts[1]) + offsetX;
double y = Double.parseDouble(parts[2]) + offsetY;
double z = Double.parseDouble(parts[3]) + offsetZ;
int idx = line.indexOf("vertex");
String indent = idx > 0 ? line.substring(0, idx) : "";
writer.write(indent + String.format(Locale.US, "vertex %.6f %.6f %.6f", x, y, z));
writer.newLine();
}
}
}
private void writeShiftedBinary(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(input, "r");
OutputStream out = new FileOutputStream(output)) {
byte[] header = new byte[80];
raf.readFully(header);
out.write(header);
long triangleCount = readLEUInt32(raf);
writeLEUInt32(out, triangleCount);
for (long i = 0; i < triangleCount; i++) {
// normal
writeLEFloat(out, readLEFloat(raf));
writeLEFloat(out, readLEFloat(raf));
writeLEFloat(out, readLEFloat(raf));
// vertices
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
// attribute byte count
int b1 = raf.read();
int b2 = raf.read();
if ((b1 | b2) < 0) throw new IOException("Unexpected EOF while reading STL");
out.write(b1);
out.write(b2);
}
}
}
private long readLEUInt32(RandomAccessFile raf) throws IOException {
int b1 = raf.read();
int b2 = raf.read();
@@ -99,6 +205,21 @@ public class StlService {
return Float.intBitsToFloat(readLEInt(raf));
}
private void writeLEUInt32(OutputStream out, long value) throws IOException {
out.write((int) (value & 0xFF));
out.write((int) ((value >> 8) & 0xFF));
out.write((int) ((value >> 16) & 0xFF));
out.write((int) ((value >> 24) & 0xFF));
}
private void writeLEFloat(OutputStream out, float value) throws IOException {
int bits = Float.floatToIntBits(value);
out.write(bits & 0xFF);
out.write((bits >> 8) & 0xFF);
out.write((bits >> 16) & 0xFF);
out.write((bits >> 24) & 0xFF);
}
private static class BoundsAccumulator {
private boolean hasPoint = false;
private double minX;

View File

@@ -15,6 +15,7 @@ import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.StlBounds;
import com.printcalculator.model.StlShiftResult;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -33,6 +34,7 @@ import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import static org.junit.jupiter.api.Assertions.*;
@@ -109,6 +111,8 @@ public class ManualSessionPersistenceTest {
new QuoteResult(10.0, "CHF", new PrintStats(100, "1m", 10.0, 100), 0.0)
);
when(stlService.readBounds(any())).thenReturn(new StlBounds(0, 0, 0, 10, 10, 10));
when(stlService.shiftToFitIfNeeded(any(), any(), anyInt(), anyInt(), anyInt()))
.thenReturn(new StlShiftResult(null, 0, 0, 0, false));
when(storageService.loadAsResource(any())).thenReturn(new org.springframework.core.io.ByteArrayResource("dummy".getBytes()){
@Override
public File getFile() { return new File("dummy"); }