diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index 6ac62b6..2122ac8 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -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; } } diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 74c12de..c677efa 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -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); @@ -136,6 +140,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(); @@ -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); diff --git a/backend/src/main/java/com/printcalculator/model/StlShiftResult.java b/backend/src/main/java/com/printcalculator/model/StlShiftResult.java new file mode 100644 index 0000000..5c21256 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/model/StlShiftResult.java @@ -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) { +} diff --git a/backend/src/main/java/com/printcalculator/service/StlService.java b/backend/src/main/java/com/printcalculator/service/StlService.java index db39326..8a95b79 100644 --- a/backend/src/main/java/com/printcalculator/service/StlService.java +++ b/backend/src/main/java/com/printcalculator/service/StlService.java @@ -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; diff --git a/backend/src/test/java/com/printcalculator/ManualSessionPersistenceTest.java b/backend/src/test/java/com/printcalculator/ManualSessionPersistenceTest.java index efe1732..adc2200 100644 --- a/backend/src/test/java/com/printcalculator/ManualSessionPersistenceTest.java +++ b/backend/src/test/java/com/printcalculator/ManualSessionPersistenceTest.java @@ -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"); }