fix(back-end): fix process and new feature
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
import com.printcalculator.entity.PrinterMachine;
|
import com.printcalculator.entity.PrinterMachine;
|
||||||
|
import com.printcalculator.exception.ModelTooLargeException;
|
||||||
import com.printcalculator.model.PrintStats;
|
import com.printcalculator.model.PrintStats;
|
||||||
import com.printcalculator.model.QuoteResult;
|
import com.printcalculator.model.QuoteResult;
|
||||||
|
import com.printcalculator.model.StlBounds;
|
||||||
import com.printcalculator.repository.PrinterMachineRepository;
|
import com.printcalculator.repository.PrinterMachineRepository;
|
||||||
import com.printcalculator.service.QuoteCalculator;
|
import com.printcalculator.service.QuoteCalculator;
|
||||||
import com.printcalculator.service.SlicerService;
|
import com.printcalculator.service.SlicerService;
|
||||||
|
import com.printcalculator.service.StlService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -20,6 +23,7 @@ import java.nio.file.Path;
|
|||||||
public class QuoteController {
|
public class QuoteController {
|
||||||
|
|
||||||
private final SlicerService slicerService;
|
private final SlicerService slicerService;
|
||||||
|
private final StlService stlService;
|
||||||
private final QuoteCalculator quoteCalculator;
|
private final QuoteCalculator quoteCalculator;
|
||||||
private final PrinterMachineRepository machineRepo;
|
private final PrinterMachineRepository machineRepo;
|
||||||
|
|
||||||
@@ -27,8 +31,9 @@ public class QuoteController {
|
|||||||
private static final String DEFAULT_FILAMENT = "pla_basic";
|
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||||
private static final String DEFAULT_PROCESS = "standard";
|
private static final String DEFAULT_PROCESS = "standard";
|
||||||
|
|
||||||
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) {
|
public QuoteController(SlicerService slicerService, StlService stlService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) {
|
||||||
this.slicerService = slicerService;
|
this.slicerService = slicerService;
|
||||||
|
this.stlService = stlService;
|
||||||
this.quoteCalculator = quoteCalculator;
|
this.quoteCalculator = quoteCalculator;
|
||||||
this.machineRepo = machineRepo;
|
this.machineRepo = machineRepo;
|
||||||
}
|
}
|
||||||
@@ -117,18 +122,36 @@ public class QuoteController {
|
|||||||
slicerMachineProfile = "bambu_a1";
|
slicerMachineProfile = "bambu_a1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate model size against machine volume
|
||||||
|
validateModelSize(tempInput.toFile(), machine);
|
||||||
|
|
||||||
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
|
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
|
||||||
|
|
||||||
// Calculate Quote (Pass machine display name for pricing lookup)
|
// Calculate Quote (Pass machine display name for pricing lookup)
|
||||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
|
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
|
||||||
|
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
return ResponseEntity.internalServerError().build();
|
|
||||||
} finally {
|
} finally {
|
||||||
Files.deleteIfExists(tempInput);
|
Files.deleteIfExists(tempInput);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
|
||||||
|
StlBounds bounds = stlService.readBounds(stlFile);
|
||||||
|
double x = bounds.sizeX();
|
||||||
|
double y = bounds.sizeY();
|
||||||
|
double z = bounds.sizeZ();
|
||||||
|
|
||||||
|
int bx = machine.getBuildVolumeXMm();
|
||||||
|
int by = machine.getBuildVolumeYMm();
|
||||||
|
int bz = machine.getBuildVolumeZMm();
|
||||||
|
|
||||||
|
double eps = 0.01;
|
||||||
|
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|
||||||
|
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
|
||||||
|
|
||||||
|
if (!fits) {
|
||||||
|
throw new ModelTooLargeException(x, y, z, bx, by, bz);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ package com.printcalculator.controller;
|
|||||||
import com.printcalculator.entity.PrinterMachine;
|
import com.printcalculator.entity.PrinterMachine;
|
||||||
import com.printcalculator.entity.QuoteLineItem;
|
import com.printcalculator.entity.QuoteLineItem;
|
||||||
import com.printcalculator.entity.QuoteSession;
|
import com.printcalculator.entity.QuoteSession;
|
||||||
|
import com.printcalculator.exception.ModelTooLargeException;
|
||||||
import com.printcalculator.model.PrintStats;
|
import com.printcalculator.model.PrintStats;
|
||||||
import com.printcalculator.model.QuoteResult;
|
import com.printcalculator.model.QuoteResult;
|
||||||
|
import com.printcalculator.model.StlBounds;
|
||||||
import com.printcalculator.repository.PrinterMachineRepository;
|
import com.printcalculator.repository.PrinterMachineRepository;
|
||||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||||
import com.printcalculator.repository.QuoteSessionRepository;
|
import com.printcalculator.repository.QuoteSessionRepository;
|
||||||
import com.printcalculator.service.QuoteCalculator;
|
import com.printcalculator.service.QuoteCalculator;
|
||||||
import com.printcalculator.service.SlicerService;
|
import com.printcalculator.service.SlicerService;
|
||||||
|
import com.printcalculator.service.StlService;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -37,6 +40,7 @@ public class QuoteSessionController {
|
|||||||
private final QuoteSessionRepository sessionRepo;
|
private final QuoteSessionRepository sessionRepo;
|
||||||
private final QuoteLineItemRepository lineItemRepo;
|
private final QuoteLineItemRepository lineItemRepo;
|
||||||
private final SlicerService slicerService;
|
private final SlicerService slicerService;
|
||||||
|
private final StlService stlService;
|
||||||
private final QuoteCalculator quoteCalculator;
|
private final QuoteCalculator quoteCalculator;
|
||||||
private final PrinterMachineRepository machineRepo;
|
private final PrinterMachineRepository machineRepo;
|
||||||
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
||||||
@@ -49,6 +53,7 @@ public class QuoteSessionController {
|
|||||||
public QuoteSessionController(QuoteSessionRepository sessionRepo,
|
public QuoteSessionController(QuoteSessionRepository sessionRepo,
|
||||||
QuoteLineItemRepository lineItemRepo,
|
QuoteLineItemRepository lineItemRepo,
|
||||||
SlicerService slicerService,
|
SlicerService slicerService,
|
||||||
|
StlService stlService,
|
||||||
QuoteCalculator quoteCalculator,
|
QuoteCalculator quoteCalculator,
|
||||||
PrinterMachineRepository machineRepo,
|
PrinterMachineRepository machineRepo,
|
||||||
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
|
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
|
||||||
@@ -56,6 +61,7 @@ public class QuoteSessionController {
|
|||||||
this.sessionRepo = sessionRepo;
|
this.sessionRepo = sessionRepo;
|
||||||
this.lineItemRepo = lineItemRepo;
|
this.lineItemRepo = lineItemRepo;
|
||||||
this.slicerService = slicerService;
|
this.slicerService = slicerService;
|
||||||
|
this.stlService = stlService;
|
||||||
this.quoteCalculator = quoteCalculator;
|
this.quoteCalculator = quoteCalculator;
|
||||||
this.machineRepo = machineRepo;
|
this.machineRepo = machineRepo;
|
||||||
this.pricingRepo = pricingRepo;
|
this.pricingRepo = pricingRepo;
|
||||||
@@ -127,8 +133,11 @@ public class QuoteSessionController {
|
|||||||
// 1. Pick Machine (default to first active or specific)
|
// 1. Pick Machine (default to first active or specific)
|
||||||
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
||||||
.orElseThrow(() -> new RuntimeException("No active printer found"));
|
.orElseThrow(() -> new RuntimeException("No active printer found"));
|
||||||
|
|
||||||
|
// 2. Validate model size against machine volume
|
||||||
|
StlBounds bounds = validateModelSize(persistentPath.toFile(), machine);
|
||||||
|
|
||||||
// 2. Pick Profiles
|
// 3. Pick Profiles
|
||||||
String machineProfile = machine.getSlicerMachineProfile();
|
String machineProfile = machine.getSlicerMachineProfile();
|
||||||
if (machineProfile == null || machineProfile.isBlank()) {
|
if (machineProfile == null || machineProfile.isBlank()) {
|
||||||
machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
|
machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
|
||||||
@@ -194,7 +203,7 @@ public class QuoteSessionController {
|
|||||||
machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter()));
|
machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Slice (Use persistent path)
|
// 4. Slice (Use persistent path)
|
||||||
PrintStats stats = slicerService.slice(
|
PrintStats stats = slicerService.slice(
|
||||||
persistentPath.toFile(),
|
persistentPath.toFile(),
|
||||||
machineProfile,
|
machineProfile,
|
||||||
@@ -204,10 +213,10 @@ public class QuoteSessionController {
|
|||||||
processOverrides
|
processOverrides
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. Calculate Quote
|
// 5. Calculate Quote
|
||||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
|
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
|
||||||
|
|
||||||
// 5. Create Line Item
|
// 6. Create Line Item
|
||||||
QuoteLineItem item = new QuoteLineItem();
|
QuoteLineItem item = new QuoteLineItem();
|
||||||
item.setQuoteSession(session);
|
item.setQuoteSession(session);
|
||||||
item.setOriginalFilename(file.getOriginalFilename());
|
item.setOriginalFilename(file.getOriginalFilename());
|
||||||
@@ -227,14 +236,10 @@ public class QuoteSessionController {
|
|||||||
breakdown.put("setup_fee", result.getSetupCost());
|
breakdown.put("setup_fee", result.getSetupCost());
|
||||||
item.setPricingBreakdown(breakdown);
|
item.setPricingBreakdown(breakdown);
|
||||||
|
|
||||||
// Dimensions
|
// Dimensions from STL
|
||||||
// Cannot get bb from GCodeParser yet?
|
item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
|
||||||
// If GCodeParser doesn't return size, we might defaults or 0.
|
item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
|
||||||
// Stats has filament used.
|
item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
|
||||||
// Let's set dummy for now or upgrade parser later.
|
|
||||||
item.setBoundingBoxXMm(BigDecimal.ZERO);
|
|
||||||
item.setBoundingBoxYMm(BigDecimal.ZERO);
|
|
||||||
item.setBoundingBoxZMm(BigDecimal.ZERO);
|
|
||||||
|
|
||||||
item.setCreatedAt(OffsetDateTime.now());
|
item.setCreatedAt(OffsetDateTime.now());
|
||||||
item.setUpdatedAt(OffsetDateTime.now());
|
item.setUpdatedAt(OffsetDateTime.now());
|
||||||
@@ -250,6 +255,26 @@ public class QuoteSessionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
|
||||||
|
StlBounds bounds = stlService.readBounds(stlFile);
|
||||||
|
double x = bounds.sizeX();
|
||||||
|
double y = bounds.sizeY();
|
||||||
|
double z = bounds.sizeZ();
|
||||||
|
|
||||||
|
int bx = machine.getBuildVolumeXMm();
|
||||||
|
int by = machine.getBuildVolumeYMm();
|
||||||
|
int bz = machine.getBuildVolumeZMm();
|
||||||
|
|
||||||
|
double eps = 0.01;
|
||||||
|
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|
||||||
|
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
|
||||||
|
|
||||||
|
if (!fits) {
|
||||||
|
throw new ModelTooLargeException(x, y, z, bx, by, bz);
|
||||||
|
}
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
|
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
|
||||||
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
|
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
|
||||||
// Set defaults based on Quality
|
// Set defaults based on Quality
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
|||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
|
||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -43,4 +45,27 @@ public class GlobalExceptionHandler {
|
|||||||
response.put("message", "The uploaded file exceeds the maximum allowed size.");
|
response.put("message", "The uploaded file exceeds the maximum allowed size.");
|
||||||
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
|
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ModelTooLargeException.class)
|
||||||
|
public ResponseEntity<?> handleModelTooLarge(ModelTooLargeException exc) {
|
||||||
|
Map<String, String> response = new HashMap<>();
|
||||||
|
response.put("error", "Model too large");
|
||||||
|
response.put("code", "MODEL_TOO_LARGE");
|
||||||
|
response.put("message", String.format(
|
||||||
|
"Model size %.2fx%.2fx%.2f mm exceeds build volume %dx%dx%d mm.",
|
||||||
|
exc.getModelX(), exc.getModelY(), exc.getModelZ(),
|
||||||
|
exc.getBuildX(), exc.getBuildY(), exc.getBuildZ()
|
||||||
|
));
|
||||||
|
response.put("model_x_mm", formatMm(exc.getModelX()));
|
||||||
|
response.put("model_y_mm", formatMm(exc.getModelY()));
|
||||||
|
response.put("model_z_mm", formatMm(exc.getModelZ()));
|
||||||
|
response.put("build_x_mm", String.valueOf(exc.getBuildX()));
|
||||||
|
response.put("build_y_mm", String.valueOf(exc.getBuildY()));
|
||||||
|
response.put("build_z_mm", String.valueOf(exc.getBuildZ()));
|
||||||
|
return ResponseEntity.unprocessableEntity().body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatMm(double value) {
|
||||||
|
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.printcalculator.exception;
|
||||||
|
|
||||||
|
public class ModelTooLargeException extends RuntimeException {
|
||||||
|
private final double modelX;
|
||||||
|
private final double modelY;
|
||||||
|
private final double modelZ;
|
||||||
|
private final int buildX;
|
||||||
|
private final int buildY;
|
||||||
|
private final int buildZ;
|
||||||
|
|
||||||
|
public ModelTooLargeException(double modelX, double modelY, double modelZ,
|
||||||
|
int buildX, int buildY, int buildZ) {
|
||||||
|
super("Model size exceeds build volume");
|
||||||
|
this.modelX = modelX;
|
||||||
|
this.modelY = modelY;
|
||||||
|
this.modelZ = modelZ;
|
||||||
|
this.buildX = buildX;
|
||||||
|
this.buildY = buildY;
|
||||||
|
this.buildZ = buildZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getModelX() {
|
||||||
|
return modelX;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getModelY() {
|
||||||
|
return modelY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getModelZ() {
|
||||||
|
return modelZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getBuildX() {
|
||||||
|
return buildX;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getBuildY() {
|
||||||
|
return buildY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getBuildZ() {
|
||||||
|
return buildZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.printcalculator.model;
|
||||||
|
|
||||||
|
public record StlBounds(double minX, double minY, double minZ,
|
||||||
|
double maxX, double maxY, double maxZ) {
|
||||||
|
public double sizeX() {
|
||||||
|
return maxX - minX;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double sizeY() {
|
||||||
|
return maxY - minY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double sizeZ() {
|
||||||
|
return maxZ - minZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,17 +50,9 @@ public class SlicerService {
|
|||||||
if (machineOverrides != null) machineOverrides.forEach(machineProfile::put);
|
if (machineOverrides != null) machineOverrides.forEach(machineProfile::put);
|
||||||
if (processOverrides != null) processOverrides.forEach(processProfile::put);
|
if (processOverrides != null) processOverrides.forEach(processProfile::put);
|
||||||
|
|
||||||
// MANTENIAMO L'IDENTITÀ BAMBU LAB A1
|
// Evitiamo solo asset grafici opzionali che potrebbero richiedere file esterni
|
||||||
// Ma puliamo solo i riferimenti a mesh esterne che causano il crash grafico (Unable to create exclude triangles)
|
if (machineProfile.has("bed_custom_model")) machineProfile.put("bed_custom_model", "");
|
||||||
machineProfile.put("printer_model", "Bambu Lab A1");
|
if (machineProfile.has("bed_custom_texture")) machineProfile.put("bed_custom_texture", "");
|
||||||
|
|
||||||
// Impostiamo aree di esclusione vuote esplicitamente per evitare che lo slicer tenti di caricarle dai suoi interni
|
|
||||||
machineProfile.putArray("bed_exclude_area");
|
|
||||||
machineProfile.putArray("head_wrap_detect_zone");
|
|
||||||
|
|
||||||
// Rimuoviamo i modelli 3D del piatto che richiedono caricamento di mesh STL/OBJ interne
|
|
||||||
machineProfile.remove("bed_custom_model");
|
|
||||||
machineProfile.remove("bed_custom_texture");
|
|
||||||
machineProfile.remove("thumbnail");
|
machineProfile.remove("thumbnail");
|
||||||
|
|
||||||
Path baseTempPath = Paths.get("/app/temp");
|
Path baseTempPath = Paths.get("/app/temp");
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.printcalculator.model.StlBounds;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.RandomAccessFile;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class StlService {
|
||||||
|
|
||||||
|
public StlBounds readBounds(File stlFile) throws IOException {
|
||||||
|
long size = stlFile.length();
|
||||||
|
if (size >= 84 && isBinaryStl(stlFile, size)) {
|
||||||
|
return readBinaryBounds(stlFile);
|
||||||
|
}
|
||||||
|
return readAsciiBounds(stlFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBinaryStl(File stlFile, long size) throws IOException {
|
||||||
|
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
|
||||||
|
raf.seek(80);
|
||||||
|
long triangleCount = readLEUInt32(raf);
|
||||||
|
long expected = 84L + triangleCount * 50L;
|
||||||
|
return expected == size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StlBounds readBinaryBounds(File stlFile) throws IOException {
|
||||||
|
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
|
||||||
|
raf.seek(80);
|
||||||
|
long triangleCount = readLEUInt32(raf);
|
||||||
|
raf.seek(84);
|
||||||
|
|
||||||
|
BoundsAccumulator acc = new BoundsAccumulator();
|
||||||
|
for (long i = 0; i < triangleCount; i++) {
|
||||||
|
// skip normal
|
||||||
|
readLEFloat(raf);
|
||||||
|
readLEFloat(raf);
|
||||||
|
readLEFloat(raf);
|
||||||
|
// 3 vertices
|
||||||
|
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
|
||||||
|
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
|
||||||
|
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
|
||||||
|
// skip attribute byte count
|
||||||
|
raf.skipBytes(2);
|
||||||
|
}
|
||||||
|
return acc.toBounds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StlBounds readAsciiBounds(File stlFile) throws IOException {
|
||||||
|
BoundsAccumulator acc = new BoundsAccumulator();
|
||||||
|
try (BufferedReader reader = Files.newBufferedReader(stlFile.toPath(), StandardCharsets.US_ASCII)) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line.startsWith("vertex")) continue;
|
||||||
|
String[] parts = line.split("\\s+");
|
||||||
|
if (parts.length < 4) continue;
|
||||||
|
double x = Double.parseDouble(parts[1]);
|
||||||
|
double y = Double.parseDouble(parts[2]);
|
||||||
|
double z = Double.parseDouble(parts[3]);
|
||||||
|
acc.accept(x, y, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc.toBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long readLEUInt32(RandomAccessFile raf) throws IOException {
|
||||||
|
int b1 = raf.read();
|
||||||
|
int b2 = raf.read();
|
||||||
|
int b3 = raf.read();
|
||||||
|
int b4 = raf.read();
|
||||||
|
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
|
||||||
|
return ((long) b1 & 0xFF)
|
||||||
|
| (((long) b2 & 0xFF) << 8)
|
||||||
|
| (((long) b3 & 0xFF) << 16)
|
||||||
|
| (((long) b4 & 0xFF) << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int readLEInt(RandomAccessFile raf) throws IOException {
|
||||||
|
int b1 = raf.read();
|
||||||
|
int b2 = raf.read();
|
||||||
|
int b3 = raf.read();
|
||||||
|
int b4 = raf.read();
|
||||||
|
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
|
||||||
|
return (b1 & 0xFF)
|
||||||
|
| ((b2 & 0xFF) << 8)
|
||||||
|
| ((b3 & 0xFF) << 16)
|
||||||
|
| ((b4 & 0xFF) << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float readLEFloat(RandomAccessFile raf) throws IOException {
|
||||||
|
return Float.intBitsToFloat(readLEInt(raf));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class BoundsAccumulator {
|
||||||
|
private boolean hasPoint = false;
|
||||||
|
private double minX;
|
||||||
|
private double minY;
|
||||||
|
private double minZ;
|
||||||
|
private double maxX;
|
||||||
|
private double maxY;
|
||||||
|
private double maxZ;
|
||||||
|
|
||||||
|
void accept(double x, double y, double z) {
|
||||||
|
if (!hasPoint) {
|
||||||
|
minX = maxX = x;
|
||||||
|
minY = maxY = y;
|
||||||
|
minZ = maxZ = z;
|
||||||
|
hasPoint = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (z < minZ) minZ = z;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
if (z > maxZ) maxZ = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
StlBounds toBounds() throws IOException {
|
||||||
|
if (!hasPoint) {
|
||||||
|
throw new IOException("STL appears to contain no vertices");
|
||||||
|
}
|
||||||
|
return new StlBounds(minX, minY, minZ, maxX, maxY, maxZ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,8 @@
|
|||||||
<!-- Right Column: Result or Info -->
|
<!-- Right Column: Result or Info -->
|
||||||
<div class="col-result" #resultCol>
|
<div class="col-result" #resultCol>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading() && !result()) {
|
||||||
|
<!-- Initial Loading State (before first result) -->
|
||||||
<app-card class="loading-state">
|
<app-card class="loading-state">
|
||||||
<div class="loader-content">
|
<div class="loader-content">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
@@ -54,6 +55,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
} @else if (result()) {
|
} @else if (result()) {
|
||||||
|
<!-- Result State (Active or Finished) -->
|
||||||
|
@if (loading()) {
|
||||||
|
<!-- Small loader indicator when refining results -->
|
||||||
|
<div class="analyzing-bar">
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
<span>Analisi in corso... ({{ uploadProgress() }}%)</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<app-quote-result
|
<app-quote-result
|
||||||
[result]="result()!"
|
[result]="result()!"
|
||||||
(consult)="onConsult()"
|
(consult)="onConsult()"
|
||||||
|
|||||||
@@ -161,12 +161,14 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
if (typeof event === 'number') {
|
if (typeof event === 'number') {
|
||||||
this.uploadProgress.set(event);
|
this.uploadProgress.set(event);
|
||||||
} else {
|
} else {
|
||||||
// It's the result
|
// It's the result (partial or final)
|
||||||
const res = event as QuoteResult;
|
const res = event as QuoteResult;
|
||||||
this.result.set(res);
|
this.result.set(res);
|
||||||
this.loading.set(false);
|
|
||||||
this.uploadProgress.set(100);
|
// Show result immediately if not already showing
|
||||||
this.step.set('quote');
|
if (this.step() !== 'quote') {
|
||||||
|
this.step.set('quote');
|
||||||
|
}
|
||||||
|
|
||||||
// Sync IDs back to upload form for future updates
|
// Sync IDs back to upload form for future updates
|
||||||
if (this.uploadForm) {
|
if (this.uploadForm) {
|
||||||
@@ -175,15 +177,23 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
|
|
||||||
// Update URL with session ID without reloading
|
// Update URL with session ID without reloading
|
||||||
if (res.sessionId) {
|
if (res.sessionId) {
|
||||||
this.router.navigate([], {
|
// Check if we need to update URL to avoid redundant navigations
|
||||||
relativeTo: this.route,
|
const currentSession = this.route.snapshot.queryParamMap.get('session');
|
||||||
queryParams: { session: res.sessionId },
|
if (currentSession !== res.sessionId) {
|
||||||
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
|
this.router.navigate([], {
|
||||||
replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
|
relativeTo: this.route,
|
||||||
});
|
queryParams: { session: res.sessionId },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.uploadProgress.set(100);
|
||||||
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
if (typeof err === 'string') {
|
if (typeof err === 'string') {
|
||||||
this.error.set(err);
|
this.error.set(err);
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
<span class="file-name">{{ item.fileName }}</span>
|
<span class="file-name">{{ item.fileName }}</span>
|
||||||
@if (item.error) {
|
@if (item.error) {
|
||||||
<span class="file-error">{{ 'CALC.ERROR_' + item.error | translate }}</span>
|
<span class="file-error">{{ 'CALC.ERROR_' + item.error | translate }}</span>
|
||||||
|
} @else if (item.status === 'pending') {
|
||||||
|
<span class="file-details pending">
|
||||||
|
<div class="spinner-mini"></div> Analisi...
|
||||||
|
</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="file-details">
|
<span class="file-details">
|
||||||
<span class="color-badge" [title]="item.color" [style.background-color]="getColorHex(item.color!)"></span>
|
<span class="color-badge" [title]="item.color" [style.background-color]="getColorHex(item.color!)"></span>
|
||||||
@@ -63,6 +67,10 @@
|
|||||||
<div class="item-price">
|
<div class="item-price">
|
||||||
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (item.status === 'pending') {
|
||||||
|
<div class="item-price pending">
|
||||||
|
<div class="spinner-mini"></div>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="item-price error">-</div>
|
<div class="item-price error">-</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,11 @@ export class QuoteResultComponent {
|
|||||||
let weight = 0;
|
let weight = 0;
|
||||||
|
|
||||||
currentItems.forEach(i => {
|
currentItems.forEach(i => {
|
||||||
price += i.unitPrice * i.quantity;
|
if (i.status === 'done' && !i.error) {
|
||||||
time += i.unitTime * i.quantity;
|
price += i.unitPrice * i.quantity;
|
||||||
weight += i.unitWeight * i.quantity;
|
time += i.unitTime * i.quantity;
|
||||||
|
weight += i.unitWeight * i.quantity;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const hours = Math.floor(time / 3600);
|
const hours = Math.floor(time / 3600);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface QuoteItem {
|
|||||||
material?: string;
|
material?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
status: 'pending' | 'done' | 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuoteResult {
|
export interface QuoteResult {
|
||||||
@@ -188,18 +189,74 @@ export class QuoteEstimatorService {
|
|||||||
const sessionId = sessionRes.id;
|
const sessionId = sessionRes.id;
|
||||||
const sessionSetupCost = sessionRes.setupCostChf || 0;
|
const sessionSetupCost = sessionRes.setupCostChf || 0;
|
||||||
|
|
||||||
|
// Initialize items in pending state
|
||||||
|
const currentItems: QuoteItem[] = request.items.map(item => ({
|
||||||
|
fileName: item.file.name,
|
||||||
|
unitPrice: 0,
|
||||||
|
unitTime: 0,
|
||||||
|
unitWeight: 0,
|
||||||
|
quantity: item.quantity,
|
||||||
|
status: 'pending',
|
||||||
|
color: item.color || 'White' // Default color for UI
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Emit initial state
|
||||||
|
const initialResult: QuoteResult = {
|
||||||
|
sessionId: sessionId,
|
||||||
|
items: [...currentItems],
|
||||||
|
setupCost: sessionSetupCost,
|
||||||
|
currency: 'CHF',
|
||||||
|
totalPrice: 0, // Will be calculated dynamically
|
||||||
|
totalTimeHours: 0,
|
||||||
|
totalTimeMinutes: 0,
|
||||||
|
totalWeight: 0,
|
||||||
|
notes: request.notes
|
||||||
|
};
|
||||||
|
observer.next(initialResult);
|
||||||
|
|
||||||
// 2. Upload files to this session
|
// 2. Upload files to this session
|
||||||
const totalItems = request.items.length;
|
const totalItems = request.items.length;
|
||||||
const allProgress: number[] = new Array(totalItems).fill(0);
|
const allProgress: number[] = new Array(totalItems).fill(0);
|
||||||
const finalResponses: any[] = [];
|
|
||||||
let completedRequests = 0;
|
let completedRequests = 0;
|
||||||
|
|
||||||
const checkCompletion = () => {
|
const emitUpdate = () => {
|
||||||
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
||||||
observer.next(avg);
|
observer.next(avg);
|
||||||
|
|
||||||
|
// Helper to calculate totals for current items
|
||||||
|
let grandTotal = 0;
|
||||||
|
let totalTime = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
let validCount = 0;
|
||||||
|
|
||||||
|
currentItems.forEach(item => {
|
||||||
|
if (item.status === 'done') {
|
||||||
|
grandTotal += item.unitPrice * item.quantity;
|
||||||
|
totalTime += item.unitTime * item.quantity;
|
||||||
|
totalWeight += item.unitWeight * item.quantity;
|
||||||
|
validCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validCount > 0) {
|
||||||
|
grandTotal += sessionSetupCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: QuoteResult = {
|
||||||
|
sessionId: sessionId,
|
||||||
|
items: [...currentItems], // Create copy to trigger change detection
|
||||||
|
setupCost: sessionSetupCost,
|
||||||
|
currency: 'CHF',
|
||||||
|
totalPrice: Math.round(grandTotal * 100) / 100,
|
||||||
|
totalTimeHours: Math.floor(totalTime / 3600),
|
||||||
|
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
||||||
|
totalWeight: Math.ceil(totalWeight),
|
||||||
|
notes: request.notes
|
||||||
|
};
|
||||||
|
observer.next(result);
|
||||||
|
|
||||||
if (completedRequests === totalItems) {
|
if (completedRequests === totalItems) {
|
||||||
finalize(finalResponses, sessionSetupCost, sessionId);
|
observer.complete();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,21 +286,42 @@ export class QuoteEstimatorService {
|
|||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (event) => {
|
next: (event) => {
|
||||||
if (event.type === HttpEventType.UploadProgress && event.total) {
|
if (event.type === HttpEventType.UploadProgress && event.total) {
|
||||||
allProgress[index] = Math.round((100 * event.loaded) / event.total);
|
allProgress[index] = Math.round((70 * event.loaded) / event.total); // Upload is 70% of "progress" for user perception
|
||||||
checkCompletion();
|
emitUpdate();
|
||||||
} else if (event.type === HttpEventType.Response) {
|
} else if (event.type === HttpEventType.Response) {
|
||||||
allProgress[index] = 100;
|
allProgress[index] = 100;
|
||||||
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item };
|
const resBody = event.body as any;
|
||||||
|
|
||||||
|
// Update item in list
|
||||||
|
currentItems[index] = {
|
||||||
|
id: resBody.id,
|
||||||
|
fileName: resBody.originalFilename, // use returned filename
|
||||||
|
unitPrice: resBody.unitPriceChf || 0,
|
||||||
|
unitTime: resBody.printTimeSeconds || 0,
|
||||||
|
unitWeight: resBody.materialGrams || 0,
|
||||||
|
quantity: item.quantity, // Keep original quantity
|
||||||
|
material: request.material,
|
||||||
|
color: item.color || 'White',
|
||||||
|
status: 'done'
|
||||||
|
};
|
||||||
|
|
||||||
completedRequests++;
|
completedRequests++;
|
||||||
checkCompletion();
|
emitUpdate();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Item upload failed', err);
|
console.error('Item upload failed', err);
|
||||||
const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED';
|
const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED';
|
||||||
finalResponses[index] = { success: false, fileName: item.file.name, error: errorMsg };
|
|
||||||
|
currentItems[index] = {
|
||||||
|
...currentItems[index],
|
||||||
|
status: 'error',
|
||||||
|
error: errorMsg
|
||||||
|
};
|
||||||
|
|
||||||
|
allProgress[index] = 100; // Mark as done despite error
|
||||||
completedRequests++;
|
completedRequests++;
|
||||||
checkCompletion();
|
emitUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -253,77 +331,6 @@ export class QuoteEstimatorService {
|
|||||||
observer.error('Could not initialize quote session');
|
observer.error('Could not initialize quote session');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
|
|
||||||
observer.next(100);
|
|
||||||
const items: QuoteItem[] = [];
|
|
||||||
let grandTotal = 0;
|
|
||||||
let totalTime = 0;
|
|
||||||
let totalWeight = 0;
|
|
||||||
let validCount = 0;
|
|
||||||
|
|
||||||
responses.forEach((res, idx) => {
|
|
||||||
const quantity = res?.originalQty || request.items[idx].quantity || 1;
|
|
||||||
|
|
||||||
if (!res || !res.success) {
|
|
||||||
items.push({
|
|
||||||
fileName: request.items[idx].file.name,
|
|
||||||
unitPrice: 0,
|
|
||||||
unitTime: 0,
|
|
||||||
unitWeight: 0,
|
|
||||||
quantity: quantity,
|
|
||||||
error: res?.error || 'UPLOAD_FAILED'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
validCount++;
|
|
||||||
const unitPrice = res.unitPriceChf || 0;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
id: res.id,
|
|
||||||
fileName: res.fileName,
|
|
||||||
unitPrice: unitPrice,
|
|
||||||
unitTime: res.printTimeSeconds || 0,
|
|
||||||
unitWeight: res.materialGrams || 0,
|
|
||||||
quantity: quantity,
|
|
||||||
material: request.material,
|
|
||||||
color: res.originalItem.color || 'Default'
|
|
||||||
});
|
|
||||||
|
|
||||||
grandTotal += unitPrice * quantity;
|
|
||||||
totalTime += (res.printTimeSeconds || 0) * quantity;
|
|
||||||
totalWeight += (res.materialGrams || 0) * quantity;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (validCount === 0) {
|
|
||||||
// Check if any failed due to virus
|
|
||||||
const virusError = responses.find(r => r.error === 'VIRUS_DETECTED');
|
|
||||||
if (virusError) {
|
|
||||||
observer.error('VIRUS_DETECTED');
|
|
||||||
} else {
|
|
||||||
observer.error('All calculations failed.');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
grandTotal += setupCost;
|
|
||||||
|
|
||||||
const result: QuoteResult = {
|
|
||||||
sessionId: sessionId,
|
|
||||||
items,
|
|
||||||
setupCost: setupCost,
|
|
||||||
currency: 'CHF',
|
|
||||||
totalPrice: Math.round(grandTotal * 100) / 100,
|
|
||||||
totalTimeHours: Math.floor(totalTime / 3600),
|
|
||||||
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
|
||||||
totalWeight: Math.ceil(totalWeight),
|
|
||||||
notes: request.notes
|
|
||||||
};
|
|
||||||
|
|
||||||
observer.next(result);
|
|
||||||
observer.complete();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +384,8 @@ export class QuoteEstimatorService {
|
|||||||
material: session.materialCode, // Assumption: session has one material for all? or items have it?
|
material: session.materialCode, // Assumption: session has one material for all? or items have it?
|
||||||
// Backend model QuoteSession has materialCode.
|
// Backend model QuoteSession has materialCode.
|
||||||
// But line items might have different colors.
|
// But line items might have different colors.
|
||||||
color: item.colorCode
|
color: item.colorCode,
|
||||||
|
status: 'done'
|
||||||
})),
|
})),
|
||||||
setupCost: session.setupCostChf,
|
setupCost: session.setupCostChf,
|
||||||
currency: 'CHF', // Fixed for now
|
currency: 'CHF', // Fixed for now
|
||||||
|
|||||||
Reference in New Issue
Block a user