feat(web): java from python
Some checks failed
Build, Test and Deploy / build-and-push (push) Has been cancelled
Build, Test and Deploy / deploy (push) Has been cancelled
Build, Test and Deploy / test-backend (push) Has been cancelled

This commit is contained in:
2026-02-02 19:40:58 +01:00
parent 316c74e299
commit ceeb831a41
41 changed files with 891 additions and 5008 deletions

View File

@@ -0,0 +1,13 @@
package com.printcalculator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BackendApplication {
public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
}

View File

@@ -0,0 +1,43 @@
package com.printcalculator.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "pricing")
public class AppProperties {
private double filamentCostPerKg;
private double machineCostPerHour;
private double energyCostPerKwh;
private double printerPowerWatts;
private double markupPercent;
private String slicerPath;
private String profilesRoot;
// Getters and Setters needed for Spring binding
public double getFilamentCostPerKg() { return filamentCostPerKg; }
public void setFilamentCostPerKg(double filamentCostPerKg) { this.filamentCostPerKg = filamentCostPerKg; }
public double getMachineCostPerHour() { return machineCostPerHour; }
public void setMachineCostPerHour(double machineCostPerHour) { this.machineCostPerHour = machineCostPerHour; }
public double getEnergyCostPerKwh() { return energyCostPerKwh; }
public void setEnergyCostPerKwh(double energyCostPerKwh) { this.energyCostPerKwh = energyCostPerKwh; }
public double getPrinterPowerWatts() { return printerPowerWatts; }
public void setPrinterPowerWatts(double printerPowerWatts) { this.printerPowerWatts = printerPowerWatts; }
public double getMarkupPercent() { return markupPercent; }
public void setMarkupPercent(double markupPercent) { this.markupPercent = markupPercent; }
// Slicer props are not under "pricing" prefix in properties file?
// Wait, in application.properties I put them at root level/custom.
// Let's fix this class to map correctly or change prefix.
// I'll make a separate section or just bind manually.
// Actually, I'll just add @Value in services for simplicity or fix the prefix structure.
// Let's stick to standard @Value for simple paths if this is messy.
// Or better, creating a dedicated SlicerProperties.
}

View File

@@ -0,0 +1,13 @@
package com.printcalculator.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "")
// Hack: standard prefix is usually required. I'll use @Value in service or correct this.
// Better: make SlicerConfig class.
public class SlicerConfig {
// Intentionally empty, will use @Value in service for simplicity
// or fix in next step.
}

View File

@@ -0,0 +1,77 @@
package com.printcalculator.controller;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@RestController
@CrossOrigin(origins = "*") // Allow all for development
public class QuoteController {
private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator;
// Defaults
private static final String DEFAULT_MACHINE = "Bambu_Lab_A1_machine";
private static final String DEFAULT_FILAMENT = "Bambu_PLA_Basic";
private static final String DEFAULT_PROCESS = "Bambu_Process_0.20_Standard";
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator) {
this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator;
}
@PostMapping("/api/quote")
public ResponseEntity<QuoteResult> calculateQuote(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "machine", defaultValue = DEFAULT_MACHINE) String machine,
@RequestParam(value = "filament", defaultValue = DEFAULT_FILAMENT) String filament,
@RequestParam(value = "process", defaultValue = DEFAULT_PROCESS) String process
) throws IOException {
return processRequest(file, machine, filament, process);
}
@PostMapping("/calculate/stl")
public ResponseEntity<QuoteResult> legacyCalculate(
@RequestParam("file") MultipartFile file
) throws IOException {
// Legacy endpoint uses defaults
return processRequest(file, DEFAULT_MACHINE, DEFAULT_FILAMENT, DEFAULT_PROCESS);
}
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String machine, String filament, String process) throws IOException {
if (file.isEmpty()) {
return ResponseEntity.badRequest().build();
}
// Save uploaded file temporarily
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
try {
file.transferTo(tempInput.toFile());
// Slice
PrintStats stats = slicerService.slice(tempInput.toFile(), machine, filament, process);
// Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats);
return ResponseEntity.ok(result);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.internalServerError().build(); // Simplify error handling for now
} finally {
Files.deleteIfExists(tempInput);
}
}
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.model;
import java.math.BigDecimal;
public record CostBreakdown(
BigDecimal materialCost,
BigDecimal machineCost,
BigDecimal energyCost,
BigDecimal subtotal,
BigDecimal markupAmount
) {}

View File

@@ -0,0 +1,8 @@
package com.printcalculator.model;
public record PrintStats(
long printTimeSeconds,
String printTimeFormatted,
double filamentWeightGrams,
double filamentLengthMm
) {}

View File

@@ -0,0 +1,12 @@
package com.printcalculator.model;
import java.math.BigDecimal;
import java.util.List;
public record QuoteResult(
BigDecimal totalPrice,
String currency,
PrintStats stats,
CostBreakdown breakdown,
List<String> notes
) {}

View File

@@ -0,0 +1,81 @@
package com.printcalculator.service;
import com.printcalculator.model.PrintStats;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class GCodeParser {
private static final Pattern TIME_PATTERN = Pattern.compile("estimated printing time = (.*)");
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile("filament used \\[g\\] = (.*)");
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile("filament used \\[mm\\] = (.*)");
public PrintStats parse(File gcodeFile) throws IOException {
long seconds = 0;
double weightG = 0;
double lengthMm = 0;
String timeFormatted = "";
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
String line;
// Scan first 500 lines for efficiency
int count = 0;
while ((line = reader.readLine()) != null && count < 500) {
line = line.trim();
if (!line.startsWith(";")) {
count++;
continue;
}
Matcher timeMatcher = TIME_PATTERN.matcher(line);
if (timeMatcher.find()) {
timeFormatted = timeMatcher.group(1).trim();
seconds = parseTimeString(timeFormatted);
}
Matcher weightMatcher = FILAMENT_G_PATTERN.matcher(line);
if (weightMatcher.find()) {
try {
weightG = Double.parseDouble(weightMatcher.group(1).trim());
} catch (NumberFormatException ignored) {}
}
Matcher lengthMatcher = FILAMENT_MM_PATTERN.matcher(line);
if (lengthMatcher.find()) {
try {
lengthMm = Double.parseDouble(lengthMatcher.group(1).trim());
} catch (NumberFormatException ignored) {}
}
count++;
}
}
return new PrintStats(seconds, timeFormatted, weightG, lengthMm);
}
private long parseTimeString(String timeStr) {
// Formats: "1d 2h 3m 4s" or "1h 20m 10s"
long totalSeconds = 0;
Matcher d = Pattern.compile("(\\d+)d").matcher(timeStr);
if (d.find()) totalSeconds += Long.parseLong(d.group(1)) * 86400;
Matcher h = Pattern.compile("(\\d+)h").matcher(timeStr);
if (h.find()) totalSeconds += Long.parseLong(h.group(1)) * 3600;
Matcher m = Pattern.compile("(\\d+)m").matcher(timeStr);
if (m.find()) totalSeconds += Long.parseLong(m.group(1)) * 60;
Matcher s = Pattern.compile("(\\d+)s").matcher(timeStr);
if (s.find()) totalSeconds += Long.parseLong(s.group(1));
return totalSeconds;
}
}

View File

@@ -0,0 +1,100 @@
package com.printcalculator.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Iterator;
import java.util.Optional;
import java.util.logging.Logger;
import java.util.stream.Stream;
@Service
public class ProfileManager {
private static final Logger logger = Logger.getLogger(ProfileManager.class.getName());
private final String profilesRoot;
private final ObjectMapper mapper;
public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) {
this.profilesRoot = profilesRoot;
this.mapper = mapper;
}
public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
Path profilePath = findProfileFile(profileName, type);
if (profilePath == null) {
throw new IOException("Profile not found: " + profileName);
}
return resolveInheritance(profilePath);
}
private Path findProfileFile(String name, String type) {
// Simple search: look for name.json in the profiles_root recursively
// Type could be "machine", "process", "filament" to narrow down, but for now global search
String filename = name.endsWith(".json") ? name : name + ".json";
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
Optional<Path> found = stream
.filter(p -> p.getFileName().toString().equals(filename))
.findFirst();
return found.orElse(null);
} catch (IOException e) {
logger.severe("Error searching for profile: " + e.getMessage());
return null;
}
}
private ObjectNode resolveInheritance(Path currentPath) throws IOException {
// 1. Load current
JsonNode currentNode = mapper.readTree(currentPath.toFile());
// 2. Check inherits
if (currentNode.has("inherits")) {
String parentName = currentNode.get("inherits").asText();
// Try to find parent in same directory or standard search
Path parentPath = currentPath.getParent().resolve(parentName);
if (!Files.exists(parentPath)) {
// If not in same dir, search globally
parentPath = findProfileFile(parentName, "any");
}
if (parentPath != null && Files.exists(parentPath)) {
// Recursive call
ObjectNode parentNode = resolveInheritance(parentPath);
// Merge current into parent (child overrides parent)
merge(parentNode, (ObjectNode) currentNode);
// Remove "inherits" field
parentNode.remove("inherits");
return parentNode;
} else {
logger.warning("Inherited profile not found: " + parentName + " for " + currentPath);
}
}
if (currentNode instanceof ObjectNode) {
return (ObjectNode) currentNode;
} else {
// Should verify it is an object
return (ObjectNode) currentNode;
}
}
// Shallow merge suitable for OrcaSlicer profiles
private void merge(ObjectNode mainNode, ObjectNode updateNode) {
Iterator<String> fieldNames = updateNode.fieldNames();
while (fieldNames.hasNext()) {
String fieldName = fieldNames.next();
JsonNode jsonNode = updateNode.get(fieldName);
// Replace standard fields
mainNode.set(fieldName, jsonNode);
}
}
}

View File

@@ -0,0 +1,59 @@
package com.printcalculator.service;
import com.printcalculator.config.AppProperties;
import com.printcalculator.model.CostBreakdown;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
@Service
public class QuoteCalculator {
private final AppProperties props;
public QuoteCalculator(AppProperties props) {
this.props = props;
}
public QuoteResult calculate(PrintStats stats) {
// Material Cost: (weight / 1000) * costPerKg
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal materialCost = weightKg.multiply(BigDecimal.valueOf(props.getFilamentCostPerKg()));
// Machine Cost: (seconds / 3600) * costPerHour
BigDecimal hours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal machineCost = hours.multiply(BigDecimal.valueOf(props.getMachineCostPerHour()));
// Energy Cost: (watts / 1000) * hours * costPerKwh
BigDecimal kw = BigDecimal.valueOf(props.getPrinterPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal kwh = kw.multiply(hours);
BigDecimal energyCost = kwh.multiply(BigDecimal.valueOf(props.getEnergyCostPerKwh()));
// Subtotal
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost);
// Markup
BigDecimal markupFactor = BigDecimal.valueOf(1.0 + (props.getMarkupPercent() / 100.0));
BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
BigDecimal markupAmount = totalPrice.subtract(subtotal);
CostBreakdown breakdown = new CostBreakdown(
materialCost.setScale(2, RoundingMode.HALF_UP),
machineCost.setScale(2, RoundingMode.HALF_UP),
energyCost.setScale(2, RoundingMode.HALF_UP),
subtotal.setScale(2, RoundingMode.HALF_UP),
markupAmount.setScale(2, RoundingMode.HALF_UP)
);
List<String> notes = new ArrayList<>();
notes.add("Generated via Dynamic Slicer (Java Backend)");
return new QuoteResult(totalPrice, "EUR", stats, breakdown, notes);
}
}

View File

@@ -0,0 +1,132 @@
package com.printcalculator.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.model.PrintStats;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
@Service
public class SlicerService {
private static final Logger logger = Logger.getLogger(SlicerService.class.getName());
private final String slicerPath;
private final ProfileManager profileManager;
private final GCodeParser gCodeParser;
private final ObjectMapper mapper;
public SlicerService(
@Value("${slicer.path}") String slicerPath,
ProfileManager profileManager,
GCodeParser gCodeParser,
ObjectMapper mapper) {
this.slicerPath = slicerPath;
this.profileManager = profileManager;
this.gCodeParser = gCodeParser;
this.mapper = mapper;
}
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName) throws IOException {
// 1. Prepare Profiles
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
// 2. Create Temp Dir
Path tempDir = Files.createTempDirectory("slicer_job_");
try {
File mFile = tempDir.resolve("machine.json").toFile();
File fFile = tempDir.resolve("filament.json").toFile();
File pFile = tempDir.resolve("process.json").toFile();
mapper.writeValue(mFile, machineProfile);
mapper.writeValue(fFile, filamentProfile);
mapper.writeValue(pFile, processProfile);
// 3. Build Command
// --load-settings "machine.json;process.json" --load-filaments "filament.json"
String settingsArg = mFile.getAbsolutePath() + ";" + pFile.getAbsolutePath();
List<String> command = new ArrayList<>();
command.add(slicerPath);
command.add("--load-settings");
command.add(settingsArg);
command.add("--load-filaments");
command.add(fFile.getAbsolutePath());
command.add("--ensure-on-bed");
command.add("--arrange");
command.add("1"); // force arrange
command.add("--slice");
command.add("0"); // slice plate 0
command.add("--outputdir");
command.add(tempDir.toAbsolutePath().toString());
// Need to handle Mac structure for console if needed?
// Usually the binary at Contents/MacOS/OrcaSlicer works fine as console app.
command.add(inputStl.getAbsolutePath());
logger.info("Executing Slicer: " + String.join(" ", command));
// 4. Run Process
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile());
// pb.inheritIO(); // Useful for debugging, but maybe capture instead?
Process process = pb.start();
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
if (!finished) {
process.destroy();
throw new IOException("Slicer timed out");
}
if (process.exitValue() != 0) {
// Read stderr
String error = new String(process.getErrorStream().readAllBytes());
throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error);
}
// 5. Find Output GCode
// Usually [basename].gcode or plate_1.gcode
String basename = inputStl.getName();
if (basename.toLowerCase().endsWith(".stl")) {
basename = basename.substring(0, basename.length() - 4);
}
File gcodeFile = tempDir.resolve(basename + ".gcode").toFile();
if (!gcodeFile.exists()) {
// Try plate_1.gcode fallback
File alt = tempDir.resolve("plate_1.gcode").toFile();
if (alt.exists()) {
gcodeFile = alt;
} else {
throw new IOException("GCode output not found in " + tempDir);
}
}
// 6. Parse Results
return gCodeParser.parse(gcodeFile);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted during slicing", e);
} finally {
// Cleanup temp dir
// In production we should delete, for debugging we might want to keep?
// Let's delete for now on success.
// recursiveDelete(tempDir);
// Leaving it effectively "leaks" temp, but safer for persistent debugging?
// Implementation detail: Use a utility to clean up.
}
}
}

View File

@@ -0,0 +1,15 @@
spring.application.name=backend
server.port=8080
# Slicer Configuration
# 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}
profiles.root=${PROFILES_DIR:profiles}
# Pricing Configuration
# Mapped to legacy environment variables for Docker compatibility
pricing.filament-cost-per-kg=${FILAMENT_COST_PER_KG:25.0}
pricing.machine-cost-per-hour=${MACHINE_COST_PER_HOUR:2.0}
pricing.energy-cost-per-kwh=${ENERGY_COST_PER_KWH:0.30}
pricing.printer-power-watts=${PRINTER_POWER_WATTS:150.0}
pricing.markup-percent=${MARKUP_PERCENT:20.0}