Merge pull request 'dev' (#15) from dev into main
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
@@ -10,11 +10,12 @@ RUN ./gradlew bootJar -x test --no-daemon
|
|||||||
|
|
||||||
# Stage 2: Runtime Environment
|
# Stage 2: Runtime Environment
|
||||||
FROM eclipse-temurin:21-jre-jammy
|
FROM eclipse-temurin:21-jre-jammy
|
||||||
|
ARG ORCA_VERSION=2.3.1
|
||||||
|
ARG ORCA_DOWNLOAD_URL
|
||||||
|
|
||||||
# Install system dependencies for OrcaSlicer (same as before)
|
# Install system dependencies for OrcaSlicer (same as before)
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
wget \
|
wget \
|
||||||
p7zip-full \
|
|
||||||
assimp-utils \
|
assimp-utils \
|
||||||
libgl1 \
|
libgl1 \
|
||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
@@ -25,8 +26,35 @@ RUN apt-get update && apt-get install -y \
|
|||||||
|
|
||||||
# Install OrcaSlicer
|
# Install OrcaSlicer
|
||||||
WORKDIR /opt
|
WORKDIR /opt
|
||||||
RUN wget -q https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/OrcaSlicer_Linux_V2.2.0.AppImage -O OrcaSlicer.AppImage \
|
RUN set -eux; \
|
||||||
&& 7z x OrcaSlicer.AppImage -o/opt/orcaslicer \
|
ORCA_URL="${ORCA_DOWNLOAD_URL:-}"; \
|
||||||
|
if [ -n "${ORCA_URL}" ]; then \
|
||||||
|
wget -q "${ORCA_URL}" -O OrcaSlicer.AppImage; \
|
||||||
|
else \
|
||||||
|
CANDIDATES="\
|
||||||
|
https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \
|
||||||
|
https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \
|
||||||
|
https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/OrcaSlicer_Linux_V2.2.0.AppImage"; \
|
||||||
|
ok=0; \
|
||||||
|
for url in $CANDIDATES; do \
|
||||||
|
if wget -q --spider "$url"; then \
|
||||||
|
echo "Using OrcaSlicer URL: $url"; \
|
||||||
|
wget -q "$url" -O OrcaSlicer.AppImage; \
|
||||||
|
ok=1; \
|
||||||
|
break; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
if [ "$ok" -ne 1 ]; then \
|
||||||
|
echo "Failed to find OrcaSlicer AppImage for version ${ORCA_VERSION}" >&2; \
|
||||||
|
echo "Tried URLs:" >&2; \
|
||||||
|
for url in $CANDIDATES; do echo " - $url" >&2; done; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
fi \
|
||||||
|
&& chmod +x OrcaSlicer.AppImage \
|
||||||
|
&& rm -rf /opt/orcaslicer /opt/squashfs-root \
|
||||||
|
&& ./OrcaSlicer.AppImage --appimage-extract >/dev/null \
|
||||||
|
&& mv /opt/squashfs-root /opt/orcaslicer \
|
||||||
&& chmod -R +x /opt/orcaslicer \
|
&& chmod -R +x /opt/orcaslicer \
|
||||||
&& rm OrcaSlicer.AppImage
|
&& rm OrcaSlicer.AppImage
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||||
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
|
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||||
|
implementation platform('org.lwjgl:lwjgl-bom:3.3.4')
|
||||||
|
implementation 'org.lwjgl:lwjgl'
|
||||||
|
implementation 'org.lwjgl:lwjgl-assimp'
|
||||||
|
runtimeOnly 'org.lwjgl:lwjgl::natives-linux'
|
||||||
|
runtimeOnly 'org.lwjgl:lwjgl::natives-macos'
|
||||||
|
runtimeOnly 'org.lwjgl:lwjgl::natives-macos-arm64'
|
||||||
|
runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-linux'
|
||||||
|
runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-macos'
|
||||||
|
runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-macos-arm64'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ public class QuoteSessionController {
|
|||||||
|
|
||||||
// Helper to add item
|
// Helper to add item
|
||||||
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
|
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
|
||||||
if (file.isEmpty()) throw new IOException("File is empty");
|
if (file.isEmpty()) throw new IllegalArgumentException("File is empty");
|
||||||
|
|
||||||
// Scan for virus
|
// Scan for virus
|
||||||
clamAVService.scan(file.getInputStream());
|
clamAVService.scan(file.getInputStream());
|
||||||
@@ -146,6 +146,7 @@ public class QuoteSessionController {
|
|||||||
Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Path convertedPersistentPath = null;
|
||||||
try {
|
try {
|
||||||
// Apply Basic/Advanced Logic
|
// Apply Basic/Advanced Logic
|
||||||
applyPrintSettings(settings);
|
applyPrintSettings(settings);
|
||||||
@@ -182,10 +183,21 @@ public class QuoteSessionController {
|
|||||||
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight()));
|
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight()));
|
||||||
if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
|
if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
|
||||||
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
|
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
|
||||||
|
|
||||||
|
Path slicerInputPath = persistentPath;
|
||||||
|
if ("3mf".equals(ext)) {
|
||||||
|
String convertedFilename = UUID.randomUUID() + "-converted.stl";
|
||||||
|
convertedPersistentPath = sessionStorageDir.resolve(convertedFilename).normalize();
|
||||||
|
if (!convertedPersistentPath.startsWith(sessionStorageDir)) {
|
||||||
|
throw new IOException("Invalid converted STL storage path");
|
||||||
|
}
|
||||||
|
slicerService.convert3mfToPersistentStl(persistentPath.toFile(), convertedPersistentPath);
|
||||||
|
slicerInputPath = convertedPersistentPath;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Slice (Use persistent path)
|
// 3. Slice (Use persistent path)
|
||||||
PrintStats stats = slicerService.slice(
|
PrintStats stats = slicerService.slice(
|
||||||
persistentPath.toFile(),
|
slicerInputPath.toFile(),
|
||||||
machineProfile,
|
machineProfile,
|
||||||
filamentProfile,
|
filamentProfile,
|
||||||
processProfile,
|
processProfile,
|
||||||
@@ -193,7 +205,7 @@ public class QuoteSessionController {
|
|||||||
processOverrides
|
processOverrides
|
||||||
);
|
);
|
||||||
|
|
||||||
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(persistentPath.toFile());
|
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(slicerInputPath.toFile());
|
||||||
|
|
||||||
// 4. Calculate Quote
|
// 4. Calculate Quote
|
||||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
|
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
|
||||||
@@ -216,6 +228,9 @@ public class QuoteSessionController {
|
|||||||
Map<String, Object> breakdown = new HashMap<>();
|
Map<String, Object> breakdown = new HashMap<>();
|
||||||
breakdown.put("machine_cost", result.getTotalPrice()); // Excludes setup fee which is at session level
|
breakdown.put("machine_cost", result.getTotalPrice()); // Excludes setup fee which is at session level
|
||||||
breakdown.put("setup_fee", 0);
|
breakdown.put("setup_fee", 0);
|
||||||
|
if (convertedPersistentPath != null) {
|
||||||
|
breakdown.put("convertedStoredPath", QUOTE_STORAGE_ROOT.relativize(convertedPersistentPath).toString());
|
||||||
|
}
|
||||||
item.setPricingBreakdown(breakdown);
|
item.setPricingBreakdown(breakdown);
|
||||||
|
|
||||||
// Dimensions for shipping/package checks are computed server-side from the uploaded model.
|
// Dimensions for shipping/package checks are computed server-side from the uploaded model.
|
||||||
@@ -237,6 +252,9 @@ public class QuoteSessionController {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Cleanup if failed
|
// Cleanup if failed
|
||||||
Files.deleteIfExists(persistentPath);
|
Files.deleteIfExists(persistentPath);
|
||||||
|
if (convertedPersistentPath != null) {
|
||||||
|
Files.deleteIfExists(convertedPersistentPath);
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,6 +426,7 @@ public class QuoteSessionController {
|
|||||||
dto.put("colorCode", item.getColorCode());
|
dto.put("colorCode", item.getColorCode());
|
||||||
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
|
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
|
||||||
dto.put("status", item.getStatus());
|
dto.put("status", item.getStatus());
|
||||||
|
dto.put("convertedStoredPath", extractConvertedStoredPath(item));
|
||||||
|
|
||||||
BigDecimal unitPrice = item.getUnitPriceChf();
|
BigDecimal unitPrice = item.getUnitPriceChf();
|
||||||
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
|
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
|
||||||
@@ -468,7 +487,8 @@ public class QuoteSessionController {
|
|||||||
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
|
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
|
||||||
public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent(
|
public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent(
|
||||||
@PathVariable UUID sessionId,
|
@PathVariable UUID sessionId,
|
||||||
@PathVariable UUID lineItemId
|
@PathVariable UUID lineItemId,
|
||||||
|
@RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||||
.orElseThrow(() -> new RuntimeException("Item not found"));
|
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||||
@@ -477,20 +497,32 @@ public class QuoteSessionController {
|
|||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.getStoredPath() == null) {
|
String targetStoredPath = item.getStoredPath();
|
||||||
|
if (preview) {
|
||||||
|
String convertedPath = extractConvertedStoredPath(item);
|
||||||
|
if (convertedPath != null && !convertedPath.isBlank()) {
|
||||||
|
targetStoredPath = convertedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetStoredPath == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Path path = resolveStoredQuotePath(item.getStoredPath(), sessionId);
|
Path path = resolveStoredQuotePath(targetStoredPath, sessionId);
|
||||||
if (path == null || !Files.exists(path)) {
|
if (path == null || !Files.exists(path)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
org.springframework.core.io.Resource resource = new UrlResource(path.toUri());
|
org.springframework.core.io.Resource resource = new UrlResource(path.toUri());
|
||||||
|
String downloadName = item.getOriginalFilename();
|
||||||
|
if (preview) {
|
||||||
|
downloadName = path.getFileName().toString();
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + item.getOriginalFilename() + "\"")
|
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
|
||||||
.body(resource);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,4 +563,17 @@ public class QuoteSessionController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String extractConvertedStoredPath(QuoteLineItem item) {
|
||||||
|
Map<String, Object> breakdown = item.getPricingBreakdown();
|
||||||
|
if (breakdown == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object converted = breakdown.get("convertedStoredPath");
|
||||||
|
if (converted == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String path = String.valueOf(converted).trim();
|
||||||
|
return path.isEmpty() ? null : path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ import java.util.Map;
|
|||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
public class GlobalExceptionHandler {
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(ModelProcessingException.class)
|
||||||
|
public ResponseEntity<Object> handleModelProcessingException(
|
||||||
|
ModelProcessingException ex, WebRequest request) {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("timestamp", LocalDateTime.now());
|
||||||
|
body.put("status", HttpStatus.UNPROCESSABLE_ENTITY.value());
|
||||||
|
body.put("error", "Unprocessable Entity");
|
||||||
|
body.put("code", ex.getCode());
|
||||||
|
body.put("message", ex.getMessage());
|
||||||
|
body.put("path", extractPath(request));
|
||||||
|
|
||||||
|
return new ResponseEntity<>(body, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(VirusDetectedException.class)
|
@ExceptionHandler(VirusDetectedException.class)
|
||||||
public ResponseEntity<Object> handleVirusDetectedException(
|
public ResponseEntity<Object> handleVirusDetectedException(
|
||||||
VirusDetectedException ex, WebRequest request) {
|
VirusDetectedException ex, WebRequest request) {
|
||||||
@@ -58,4 +72,12 @@ public class GlobalExceptionHandler {
|
|||||||
|
|
||||||
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
|
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String extractPath(WebRequest request) {
|
||||||
|
String raw = request.getDescription(false);
|
||||||
|
if (raw == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return raw.startsWith("uri=") ? raw.substring(4) : raw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.printcalculator.exception;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class ModelProcessingException extends IOException {
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
public ModelProcessingException(String code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ModelProcessingException(String code, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,14 @@ package com.printcalculator.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.printcalculator.exception.ModelProcessingException;
|
||||||
import com.printcalculator.model.ModelDimensions;
|
import com.printcalculator.model.ModelDimensions;
|
||||||
import com.printcalculator.model.PrintStats;
|
import com.printcalculator.model.PrintStats;
|
||||||
|
import org.lwjgl.PointerBuffer;
|
||||||
|
import org.lwjgl.assimp.AIFace;
|
||||||
|
import org.lwjgl.assimp.AIMesh;
|
||||||
|
import org.lwjgl.assimp.AIScene;
|
||||||
|
import org.lwjgl.assimp.AIVector3D;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.w3c.dom.Document;
|
import org.w3c.dom.Document;
|
||||||
@@ -11,7 +17,6 @@ import org.w3c.dom.Element;
|
|||||||
import org.w3c.dom.NamedNodeMap;
|
import org.w3c.dom.NamedNodeMap;
|
||||||
import org.w3c.dom.Node;
|
import org.w3c.dom.Node;
|
||||||
import org.w3c.dom.NodeList;
|
import org.w3c.dom.NodeList;
|
||||||
import org.xml.sax.InputSource;
|
|
||||||
|
|
||||||
import javax.xml.XMLConstants;
|
import javax.xml.XMLConstants;
|
||||||
import javax.xml.parsers.DocumentBuilderFactory;
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
@@ -19,7 +24,7 @@ import java.io.BufferedWriter;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.StringReader;
|
import java.nio.IntBuffer;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.InvalidPathException;
|
import java.nio.file.InvalidPathException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -38,6 +43,14 @@ import java.util.regex.Pattern;
|
|||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipFile;
|
import java.util.zip.ZipFile;
|
||||||
|
|
||||||
|
import static org.lwjgl.assimp.Assimp.aiGetErrorString;
|
||||||
|
import static org.lwjgl.assimp.Assimp.aiImportFile;
|
||||||
|
import static org.lwjgl.assimp.Assimp.aiProcess_JoinIdenticalVertices;
|
||||||
|
import static org.lwjgl.assimp.Assimp.aiProcess_PreTransformVertices;
|
||||||
|
import static org.lwjgl.assimp.Assimp.aiProcess_SortByPType;
|
||||||
|
import static org.lwjgl.assimp.Assimp.aiProcess_Triangulate;
|
||||||
|
import static org.lwjgl.assimp.Assimp.aiReleaseImport;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SlicerService {
|
public class SlicerService {
|
||||||
|
|
||||||
@@ -144,7 +157,10 @@ public class SlicerService {
|
|||||||
|
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
process.destroyForcibly();
|
process.destroyForcibly();
|
||||||
throw new IOException("Slicer timed out");
|
throw new ModelProcessingException(
|
||||||
|
"SLICER_TIMEOUT",
|
||||||
|
"Model processing timed out. Try another format or contact us directly via Request Consultation."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.exitValue() != 0) {
|
if (process.exitValue() != 0) {
|
||||||
@@ -156,7 +172,11 @@ public class SlicerService {
|
|||||||
logger.warning("Slicer reported model out of printable area, retrying with arrange.");
|
logger.warning("Slicer reported model out of printable area, retrying with arrange.");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error);
|
logger.warning("Slicer failed with exit code " + process.exitValue() + ". Log: " + error);
|
||||||
|
throw new ModelProcessingException(
|
||||||
|
"SLICER_EXECUTION_FAILED",
|
||||||
|
"Unable to process this model. Try another format or contact us directly via Request Consultation."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
File gcodeFile = tempDir.resolve(basename + ".gcode").toFile();
|
File gcodeFile = tempDir.resolve(basename + ".gcode").toFile();
|
||||||
@@ -165,14 +185,20 @@ public class SlicerService {
|
|||||||
if (alt.exists()) {
|
if (alt.exists()) {
|
||||||
gcodeFile = alt;
|
gcodeFile = alt;
|
||||||
} else {
|
} else {
|
||||||
throw new IOException("GCode output not found in " + tempDir);
|
throw new ModelProcessingException(
|
||||||
|
"SLICER_OUTPUT_MISSING",
|
||||||
|
"Unable to generate slicing output for this model. Try another format or contact us directly via Request Consultation."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return gCodeParser.parse(gcodeFile);
|
return gCodeParser.parse(gcodeFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new IOException("Slicer failed after retry");
|
throw new ModelProcessingException(
|
||||||
|
"SLICER_FAILED_AFTER_RETRY",
|
||||||
|
"Unable to process this model. Try another format or contact us directly via Request Consultation."
|
||||||
|
);
|
||||||
|
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
@@ -305,6 +331,31 @@ public class SlicerService {
|
|||||||
return convertedStlPaths;
|
return convertedStlPaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Path convert3mfToPersistentStl(File input3mf, Path destinationStl) throws IOException {
|
||||||
|
Path tempDir = Files.createTempDirectory("slicer_convert_");
|
||||||
|
try {
|
||||||
|
List<String> convertedPaths = convert3mfToStlInputPaths(input3mf, tempDir);
|
||||||
|
if (convertedPaths.isEmpty()) {
|
||||||
|
throw new ModelProcessingException(
|
||||||
|
"MODEL_CONVERSION_FAILED",
|
||||||
|
"Unable to process this 3MF file. Try another format or contact us directly via Request Consultation."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Path source = Path.of(convertedPaths.get(0));
|
||||||
|
Path parent = destinationStl.toAbsolutePath().normalize().getParent();
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
Files.copy(source, destinationStl, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
return destinationStl;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("Interrupted during 3MF conversion", e);
|
||||||
|
} finally {
|
||||||
|
deleteRecursively(tempDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<String> convert3mfToStlInputPaths(File input3mf, Path tempDir) throws IOException, InterruptedException {
|
private List<String> convert3mfToStlInputPaths(File input3mf, Path tempDir) throws IOException, InterruptedException {
|
||||||
Path conversionOutputDir = tempDir.resolve("converted-from-3mf");
|
Path conversionOutputDir = tempDir.resolve("converted-from-3mf");
|
||||||
Files.createDirectories(conversionOutputDir);
|
Files.createDirectories(conversionOutputDir);
|
||||||
@@ -319,30 +370,145 @@ public class SlicerService {
|
|||||||
);
|
);
|
||||||
String input3mfPath = requireSafeArgument(input3mf.getAbsolutePath(), "input 3MF path");
|
String input3mfPath = requireSafeArgument(input3mf.getAbsolutePath(), "input 3MF path");
|
||||||
|
|
||||||
Path convertedStl = Path.of(conversionOutputStlPath);
|
String stlLog = "";
|
||||||
String stlLog = runAssimpExport(input3mfPath, conversionOutputStlPath, tempDir.resolve("assimp-convert-stl.log"));
|
String objLog = "";
|
||||||
if (hasRenderableGeometry(convertedStl)) {
|
|
||||||
return List.of(convertedStl.toString());
|
Path lwjglConvertedStl = conversionOutputDir.resolve("converted-lwjgl.stl");
|
||||||
|
try {
|
||||||
|
long lwjglTriangles = convert3mfToStlWithLwjglAssimp(input3mf.toPath(), lwjglConvertedStl);
|
||||||
|
if (lwjglTriangles > 0 && hasRenderableGeometry(lwjglConvertedStl)) {
|
||||||
|
logger.info("Converted 3MF to STL via LWJGL Assimp. Triangles: " + lwjglTriangles);
|
||||||
|
return List.of(lwjglConvertedStl.toString());
|
||||||
|
}
|
||||||
|
logger.warning("LWJGL Assimp conversion produced no renderable geometry.");
|
||||||
|
} catch (Exception | LinkageError e) {
|
||||||
|
logger.warning("LWJGL Assimp conversion failed, falling back to assimp CLI: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warning("Assimp STL conversion produced empty geometry. Retrying conversion to OBJ.");
|
Path convertedStl = Path.of(conversionOutputStlPath);
|
||||||
|
try {
|
||||||
|
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.");
|
||||||
|
} catch (IOException e) {
|
||||||
|
stlLog = e.getMessage() != null ? e.getMessage() : "";
|
||||||
|
logger.warning("Assimp STL conversion failed, trying alternate conversion paths: " + stlLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warning("Retrying 3MF conversion to OBJ.");
|
||||||
|
|
||||||
Path convertedObj = Path.of(conversionOutputObjPath);
|
Path convertedObj = Path.of(conversionOutputObjPath);
|
||||||
String objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log"));
|
try {
|
||||||
if (hasRenderableGeometry(convertedObj)) {
|
objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log"));
|
||||||
return List.of(convertedObj.toString());
|
if (hasRenderableGeometry(convertedObj)) {
|
||||||
|
Path stlFromObj = conversionOutputDir.resolve("converted-from-obj.stl");
|
||||||
|
runAssimpExport(
|
||||||
|
convertedObj.toString(),
|
||||||
|
stlFromObj.toString(),
|
||||||
|
tempDir.resolve("assimp-convert-obj-to-stl.log")
|
||||||
|
);
|
||||||
|
if (hasRenderableGeometry(stlFromObj)) {
|
||||||
|
return List.of(stlFromObj.toString());
|
||||||
|
}
|
||||||
|
logger.warning("Assimp OBJ->STL conversion produced empty geometry.");
|
||||||
|
}
|
||||||
|
logger.warning("Assimp OBJ conversion produced empty geometry.");
|
||||||
|
} catch (IOException e) {
|
||||||
|
objLog = e.getMessage() != null ? e.getMessage() : "";
|
||||||
|
logger.warning("Assimp OBJ conversion failed: " + objLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path fallbackStl = conversionOutputDir.resolve("converted-fallback.stl");
|
Path fallbackStl = conversionOutputDir.resolve("converted-fallback.stl");
|
||||||
long fallbackTriangles = convert3mfArchiveToAsciiStl(input3mf.toPath(), fallbackStl);
|
try {
|
||||||
if (fallbackTriangles > 0 && hasRenderableGeometry(fallbackStl)) {
|
long fallbackTriangles = convert3mfArchiveToAsciiStl(input3mf.toPath(), fallbackStl);
|
||||||
logger.warning("Assimp conversion produced empty geometry. Fallback 3MF XML extractor generated "
|
if (fallbackTriangles > 0 && hasRenderableGeometry(fallbackStl)) {
|
||||||
+ fallbackTriangles + " triangles.");
|
logger.warning("Assimp conversion produced empty geometry. Fallback 3MF XML extractor generated "
|
||||||
return List.of(fallbackStl.toString());
|
+ fallbackTriangles + " triangles.");
|
||||||
|
return List.of(fallbackStl.toString());
|
||||||
|
}
|
||||||
|
logger.warning("3MF XML fallback completed but produced no renderable triangles.");
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warning("3MF XML fallback conversion failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new IOException("3MF conversion produced no renderable geometry (STL+OBJ). STL log: "
|
throw new ModelProcessingException(
|
||||||
+ stlLog + " OBJ log: " + objLog);
|
"MODEL_CONVERSION_FAILED",
|
||||||
|
"Unable to process this 3MF file. Try another format or contact us directly via Request Consultation."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long convert3mfToStlWithLwjglAssimp(Path input3mf, Path outputStl) throws IOException {
|
||||||
|
int flags = aiProcess_Triangulate
|
||||||
|
| aiProcess_JoinIdenticalVertices
|
||||||
|
| aiProcess_PreTransformVertices
|
||||||
|
| aiProcess_SortByPType;
|
||||||
|
AIScene scene = aiImportFile(input3mf.toString(), flags);
|
||||||
|
if (scene == null) {
|
||||||
|
throw new IOException("LWJGL Assimp import failed: " + aiGetErrorString());
|
||||||
|
}
|
||||||
|
|
||||||
|
long triangleCount = 0L;
|
||||||
|
try (BufferedWriter writer = Files.newBufferedWriter(outputStl, StandardCharsets.UTF_8)) {
|
||||||
|
writer.write("solid converted\n");
|
||||||
|
|
||||||
|
int meshCount = scene.mNumMeshes();
|
||||||
|
PointerBuffer meshPointers = scene.mMeshes();
|
||||||
|
if (meshCount <= 0 || meshPointers == null) {
|
||||||
|
throw new IOException("LWJGL Assimp import contains no meshes");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int meshIndex = 0; meshIndex < meshCount; meshIndex++) {
|
||||||
|
long meshPtr = meshPointers.get(meshIndex);
|
||||||
|
if (meshPtr == 0L) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
AIMesh mesh = AIMesh.create(meshPtr);
|
||||||
|
AIVector3D.Buffer vertices = mesh.mVertices();
|
||||||
|
AIFace.Buffer faces = mesh.mFaces();
|
||||||
|
if (vertices == null || faces == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int vertexCount = mesh.mNumVertices();
|
||||||
|
int faceCount = mesh.mNumFaces();
|
||||||
|
for (int faceIndex = 0; faceIndex < faceCount; faceIndex++) {
|
||||||
|
AIFace face = faces.get(faceIndex);
|
||||||
|
if (face.mNumIndices() != 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
IntBuffer indices = face.mIndices();
|
||||||
|
if (indices == null || indices.remaining() < 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int i0 = indices.get(0);
|
||||||
|
int i1 = indices.get(1);
|
||||||
|
int i2 = indices.get(2);
|
||||||
|
if (i0 < 0 || i1 < 0 || i2 < 0
|
||||||
|
|| i0 >= vertexCount
|
||||||
|
|| i1 >= vertexCount
|
||||||
|
|| i2 >= vertexCount) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec3 p1 = toVec3(vertices.get(i0));
|
||||||
|
Vec3 p2 = toVec3(vertices.get(i1));
|
||||||
|
Vec3 p3 = toVec3(vertices.get(i2));
|
||||||
|
writeAsciiFacet(writer, p1, p2, p3);
|
||||||
|
triangleCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.write("endsolid converted\n");
|
||||||
|
} finally {
|
||||||
|
aiReleaseImport(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triangleCount <= 0) {
|
||||||
|
throw new IOException("LWJGL Assimp conversion produced no triangles");
|
||||||
|
}
|
||||||
|
return triangleCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String runAssimpExport(String input3mfPath, String outputModelPath, Path conversionLogPath)
|
private String runAssimpExport(String input3mfPath, String outputModelPath, Path conversionLogPath)
|
||||||
@@ -583,6 +749,10 @@ public class SlicerService {
|
|||||||
writer.write("endfacet\n");
|
writer.write("endfacet\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Vec3 toVec3(AIVector3D v) {
|
||||||
|
return new Vec3(v.x(), v.y(), v.z());
|
||||||
|
}
|
||||||
|
|
||||||
private Vec3 computeNormal(Vec3 a, Vec3 b, Vec3 c) {
|
private Vec3 computeNormal(Vec3 a, Vec3 b, Vec3 c) {
|
||||||
double ux = b.x() - a.x();
|
double ux = b.x() - a.x();
|
||||||
double uy = b.y() - a.y();
|
double uy = b.y() - a.y();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ server.port=8000
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc}
|
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc}
|
||||||
spring.datasource.username=${DB_USERNAME:printcalc}
|
spring.datasource.username=${DB_USERNAME:printcalc}
|
||||||
spring.datasource.password=${DB_PASSWORD:}
|
spring.datasource.password=${DB_PASSWORD:printcalc_secret}
|
||||||
spring.jpa.hibernate.ddl-auto=update
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||||
spring.jpa.open-in-view=false
|
spring.jpa.open-in-view=false
|
||||||
@@ -13,6 +13,7 @@ spring.jpa.open-in-view=false
|
|||||||
# Slicer Configuration
|
# Slicer Configuration
|
||||||
# Map SLICER_PATH env var if present (default to /opt/orcaslicer/AppRun or Mac path)
|
# 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}
|
slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer}
|
||||||
|
assimp.path=${ASSIMP_PATH:assimp}
|
||||||
profiles.root=${PROFILES_DIR:profiles}
|
profiles.root=${PROFILES_DIR:profiles}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,16 @@
|
|||||||
(proceed)="onProceed()"
|
(proceed)="onProceed()"
|
||||||
(itemChange)="onItemChange($event)"
|
(itemChange)="onItemChange($event)"
|
||||||
></app-quote-result>
|
></app-quote-result>
|
||||||
|
} @else if (isZeroQuoteError()) {
|
||||||
|
<app-card class="zero-result-card">
|
||||||
|
<h3>{{ "CALC.ZERO_RESULT_TITLE" | translate }}</h3>
|
||||||
|
<p>{{ "CALC.ZERO_RESULT_HELP" | translate }}</p>
|
||||||
|
<div class="zero-result-action">
|
||||||
|
<app-button variant="outline" (click)="onConsult()">
|
||||||
|
{{ "QUOTE.CONSULT" | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
} @else {
|
} @else {
|
||||||
<app-card>
|
<app-card>
|
||||||
<h3>{{ "CALC.BENEFITS_TITLE" | translate }}</h3>
|
<h3>{{ "CALC.BENEFITS_TITLE" | translate }}</h3>
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: calc(var(--space-4) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
.content-grid {
|
.content-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -82,6 +88,15 @@
|
|||||||
line-height: 2;
|
line-height: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zero-result-card p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zero-result-action {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
.loader-content {
|
.loader-content {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
signal,
|
signal,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
@@ -7,11 +8,12 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
|
|
||||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
||||||
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
||||||
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
|
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +33,7 @@ import { LanguageService } from '../../core/services/language.service';
|
|||||||
TranslateModule,
|
TranslateModule,
|
||||||
AppCardComponent,
|
AppCardComponent,
|
||||||
AppAlertComponent,
|
AppAlertComponent,
|
||||||
|
AppButtonComponent,
|
||||||
UploadFormComponent,
|
UploadFormComponent,
|
||||||
QuoteResultComponent,
|
QuoteResultComponent,
|
||||||
SuccessStateComponent,
|
SuccessStateComponent,
|
||||||
@@ -47,6 +50,9 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
result = signal<QuoteResult | null>(null);
|
result = signal<QuoteResult | null>(null);
|
||||||
error = signal<boolean>(false);
|
error = signal<boolean>(false);
|
||||||
errorKey = signal<string>('CALC.ERROR_GENERIC');
|
errorKey = signal<string>('CALC.ERROR_GENERIC');
|
||||||
|
isZeroQuoteError = computed(
|
||||||
|
() => this.error() && this.errorKey() === 'CALC.ERROR_ZERO_PRICE',
|
||||||
|
);
|
||||||
|
|
||||||
orderSuccess = signal(false);
|
orderSuccess = signal(false);
|
||||||
|
|
||||||
@@ -122,15 +128,18 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
|
|
||||||
// Download all files
|
// Download all files
|
||||||
const downloads = items.map((item) =>
|
const downloads = items.map((item) =>
|
||||||
this.estimator.getLineItemContent(session.id, item.id).pipe(
|
forkJoin({
|
||||||
map((blob: Blob) => {
|
originalBlob: this.estimator.getLineItemContent(session.id, item.id),
|
||||||
|
previewBlob: this.estimator
|
||||||
|
.getLineItemContent(session.id, item.id, true)
|
||||||
|
.pipe(catchError(() => of(null))),
|
||||||
|
}).pipe(
|
||||||
|
map(({ originalBlob, previewBlob }) => {
|
||||||
return {
|
return {
|
||||||
blob,
|
originalBlob,
|
||||||
|
previewBlob,
|
||||||
fileName: item.originalFilename,
|
fileName: item.originalFilename,
|
||||||
// We need to match the file object to the item so we can set colors ideally.
|
hasConvertedPreview: !!item.convertedStoredPath,
|
||||||
// UploadForm.setFiles takes File[].
|
|
||||||
// We might need to handle matching but UploadForm just pushes them.
|
|
||||||
// If order is preserved, we are good. items from backend are list.
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -140,13 +149,25 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
next: (results: any[]) => {
|
next: (results: any[]) => {
|
||||||
const files = results.map(
|
const files = results.map(
|
||||||
(res) =>
|
(res) =>
|
||||||
new File([res.blob], res.fileName, {
|
new File([res.originalBlob], res.fileName, {
|
||||||
type: 'application/octet-stream',
|
type: 'application/octet-stream',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.uploadForm) {
|
if (this.uploadForm) {
|
||||||
this.uploadForm.setFiles(files);
|
this.uploadForm.setFiles(files);
|
||||||
|
results.forEach((res, index) => {
|
||||||
|
if (!res.hasConvertedPreview || !res.previewBlob) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previewName = res.fileName
|
||||||
|
.replace(/\.[^.]+$/, '')
|
||||||
|
.concat('.stl');
|
||||||
|
const previewFile = new File([res.previewBlob], previewName, {
|
||||||
|
type: 'model/stl',
|
||||||
|
});
|
||||||
|
this.uploadForm.setPreviewFileByIndex(index, previewFile);
|
||||||
|
});
|
||||||
this.uploadForm.patchSettings(session);
|
this.uploadForm.patchSettings(session);
|
||||||
|
|
||||||
// Also restore colors?
|
// Also restore colors?
|
||||||
@@ -225,6 +246,17 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
|
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
|
||||||
replaceUrl: true, // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
|
replaceUrl: true, // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
|
||||||
});
|
});
|
||||||
|
this.estimator.getQuoteSession(res.sessionId).subscribe({
|
||||||
|
next: (sessionData) => {
|
||||||
|
this.restoreFilesAndSettings(
|
||||||
|
sessionData.session,
|
||||||
|
sessionData.items || [],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.warn('Failed to refresh files for preview', err);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -318,7 +350,14 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
private currentRequest: QuoteRequest | null = null;
|
private currentRequest: QuoteRequest | null = null;
|
||||||
|
|
||||||
onConsult() {
|
onConsult() {
|
||||||
if (!this.currentRequest) return;
|
if (!this.currentRequest) {
|
||||||
|
this.router.navigate([
|
||||||
|
'/',
|
||||||
|
this.languageService.selectedLang(),
|
||||||
|
'contact',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const req = this.currentRequest;
|
const req = this.currentRequest;
|
||||||
let details = `Richiesta Preventivo:\n`;
|
let details = `Richiesta Preventivo:\n`;
|
||||||
@@ -349,7 +388,16 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isInvalidQuote(result: QuoteResult): boolean {
|
private isInvalidQuote(result: QuoteResult): boolean {
|
||||||
return !Number.isFinite(result.totalPrice) || result.totalPrice <= 0;
|
const invalidPrice =
|
||||||
|
!Number.isFinite(result.totalPrice) || result.totalPrice <= 0;
|
||||||
|
const invalidWeight =
|
||||||
|
!Number.isFinite(result.totalWeight) || result.totalWeight <= 0;
|
||||||
|
const invalidTime =
|
||||||
|
!Number.isFinite(result.totalTimeHours) ||
|
||||||
|
!Number.isFinite(result.totalTimeMinutes) ||
|
||||||
|
(result.totalTimeHours <= 0 && result.totalTimeMinutes <= 0);
|
||||||
|
|
||||||
|
return invalidPrice || invalidWeight || invalidTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setQuoteError(key: string): void {
|
private setQuoteError(key: string): void {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
@if (selectedFile()) {
|
@if (selectedFile()) {
|
||||||
<div class="viewer-wrapper">
|
<div class="viewer-wrapper">
|
||||||
@if (!isStepFile(selectedFile())) {
|
@if (!canPreviewSelectedFile()) {
|
||||||
<div class="step-warning">
|
<div class="step-warning">
|
||||||
<p>{{ "CALC.STEP_WARNING" | translate }}</p>
|
<p>{{ "CALC.STEP_WARNING" | translate }}</p>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<app-stl-viewer
|
<app-stl-viewer
|
||||||
[file]="selectedFile()"
|
[file]="getSelectedPreviewFile()"
|
||||||
[color]="getSelectedFileColor()"
|
[color]="getSelectedFileColor()"
|
||||||
>
|
>
|
||||||
</app-stl-viewer>
|
</app-stl-viewer>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { getColorHex } from '../../../../core/constants/colors.const';
|
|||||||
|
|
||||||
interface FormItem {
|
interface FormItem {
|
||||||
file: File;
|
file: File;
|
||||||
|
previewFile?: File;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
color: string;
|
color: string;
|
||||||
filamentVariantId?: number;
|
filamentVariantId?: number;
|
||||||
@@ -96,12 +97,24 @@ export class UploadFormComponent implements OnInit {
|
|||||||
|
|
||||||
acceptedFormats = '.stl,.3mf,.step,.stp';
|
acceptedFormats = '.stl,.3mf,.step,.stp';
|
||||||
|
|
||||||
isStepFile(file: File | null): boolean {
|
isStlFile(file: File | null): boolean {
|
||||||
if (!file) return false;
|
if (!file) return false;
|
||||||
const name = file.name.toLowerCase();
|
const name = file.name.toLowerCase();
|
||||||
return name.endsWith('.stl');
|
return name.endsWith('.stl');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canPreviewSelectedFile(): boolean {
|
||||||
|
return this.isStlFile(this.getSelectedPreviewFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedPreviewFile(): File | null {
|
||||||
|
const selected = this.selectedFile();
|
||||||
|
if (!selected) return null;
|
||||||
|
const item = this.items().find((i) => i.file === selected);
|
||||||
|
if (!item) return null;
|
||||||
|
return item.previewFile ?? item.file;
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
itemsTouched: [false], // Hack to track touched state for custom items list
|
itemsTouched: [false], // Hack to track touched state for custom items list
|
||||||
@@ -262,6 +275,7 @@ export class UploadFormComponent implements OnInit {
|
|||||||
const defaultSelection = this.getDefaultVariantSelection();
|
const defaultSelection = this.getDefaultVariantSelection();
|
||||||
validItems.push({
|
validItems.push({
|
||||||
file,
|
file,
|
||||||
|
previewFile: this.isStlFile(file) ? file : undefined,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
color: defaultSelection.colorName,
|
color: defaultSelection.colorName,
|
||||||
filamentVariantId: defaultSelection.filamentVariantId,
|
filamentVariantId: defaultSelection.filamentVariantId,
|
||||||
@@ -390,6 +404,7 @@ export class UploadFormComponent implements OnInit {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
validItems.push({
|
validItems.push({
|
||||||
file,
|
file,
|
||||||
|
previewFile: this.isStlFile(file) ? file : undefined,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
color: defaultSelection.colorName,
|
color: defaultSelection.colorName,
|
||||||
filamentVariantId: defaultSelection.filamentVariantId,
|
filamentVariantId: defaultSelection.filamentVariantId,
|
||||||
@@ -404,6 +419,16 @@ export class UploadFormComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPreviewFileByIndex(index: number, previewFile: File) {
|
||||||
|
if (!Number.isInteger(index) || index < 0) return;
|
||||||
|
this.items.update((current) => {
|
||||||
|
if (index >= current.length) return current;
|
||||||
|
const updated = [...current];
|
||||||
|
updated[index] = { ...updated[index], previewFile };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getDefaultVariantSelection(): {
|
private getDefaultVariantSelection(): {
|
||||||
colorName: string;
|
colorName: string;
|
||||||
filamentVariantId?: number;
|
filamentVariantId?: number;
|
||||||
|
|||||||
@@ -416,10 +416,15 @@ export class QuoteEstimatorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Session File Retrieval
|
// Session File Retrieval
|
||||||
getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> {
|
getLineItemContent(
|
||||||
|
sessionId: string,
|
||||||
|
lineItemId: string,
|
||||||
|
preview = false,
|
||||||
|
): Observable<Blob> {
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
|
const previewQuery = preview ? '?preview=true' : '';
|
||||||
return this.http.get(
|
return this.http.get(
|
||||||
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`,
|
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content${previewQuery}`,
|
||||||
{
|
{
|
||||||
headers,
|
headers,
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@@ -91,7 +91,9 @@
|
|||||||
"NOTES_PLACEHOLDER": "Spezifische Anweisungen...",
|
"NOTES_PLACEHOLDER": "Spezifische Anweisungen...",
|
||||||
"SETUP_NOTE": "* Beinhaltet {{cost}} als Einrichtungskosten",
|
"SETUP_NOTE": "* Beinhaltet {{cost}} als Einrichtungskosten",
|
||||||
"SHIPPING_NOTE": "** Versandkosten ausgeschlossen, werden im nächsten Schritt berechnet",
|
"SHIPPING_NOTE": "** Versandkosten ausgeschlossen, werden im nächsten Schritt berechnet",
|
||||||
"ERROR_ZERO_PRICE": "Etwas ist schiefgelaufen. Versuche ein anderes Format oder kontaktiere uns."
|
"ERROR_ZERO_PRICE": "Bei der Berechnung ist etwas schiefgelaufen. Versuche ein anderes Format oder kontaktiere uns direkt über \"Beratung anfragen\".",
|
||||||
|
"ZERO_RESULT_TITLE": "Ungültiges Ergebnis",
|
||||||
|
"ZERO_RESULT_HELP": "Die Berechnung hat ungültige Werte (0) geliefert. Versuche ein anderes Dateiformat oder kontaktiere uns direkt über \"Beratung anfragen\"."
|
||||||
},
|
},
|
||||||
"SHOP": {
|
"SHOP": {
|
||||||
"TITLE": "Technische Lösungen",
|
"TITLE": "Technische Lösungen",
|
||||||
|
|||||||
@@ -91,7 +91,9 @@
|
|||||||
"NOTES_PLACEHOLDER": "Specific instructions...",
|
"NOTES_PLACEHOLDER": "Specific instructions...",
|
||||||
"SETUP_NOTE": "* Includes {{cost}} as setup cost",
|
"SETUP_NOTE": "* Includes {{cost}} as setup cost",
|
||||||
"SHIPPING_NOTE": "** Shipping costs excluded, calculated at the next step",
|
"SHIPPING_NOTE": "** Shipping costs excluded, calculated at the next step",
|
||||||
"ERROR_ZERO_PRICE": "Something went wrong. Try another format or contact us."
|
"ERROR_ZERO_PRICE": "Something went wrong during the calculation. Try another format or contact us directly via Request Consultation.",
|
||||||
|
"ZERO_RESULT_TITLE": "Invalid Result",
|
||||||
|
"ZERO_RESULT_HELP": "The calculation returned invalid zero values. Try another file format or contact us directly via Request Consultation."
|
||||||
},
|
},
|
||||||
"SHOP": {
|
"SHOP": {
|
||||||
"TITLE": "Technical solutions",
|
"TITLE": "Technical solutions",
|
||||||
|
|||||||
@@ -116,7 +116,9 @@
|
|||||||
"FALLBACK_MATERIAL": "PLA (fallback)",
|
"FALLBACK_MATERIAL": "PLA (fallback)",
|
||||||
"FALLBACK_QUALITY_STANDARD": "Standard",
|
"FALLBACK_QUALITY_STANDARD": "Standard",
|
||||||
"ERR_FILE_TOO_LARGE": "Certains fichiers dépassent la limite de 200MB et n'ont pas été ajoutés.",
|
"ERR_FILE_TOO_LARGE": "Certains fichiers dépassent la limite de 200MB et n'ont pas été ajoutés.",
|
||||||
"ERROR_ZERO_PRICE": "Quelque chose s'est mal passé. Essayez un autre format ou contactez-nous."
|
"ERROR_ZERO_PRICE": "Un problème est survenu pendant le calcul. Essayez un autre format ou contactez-nous directement via Demander une consultation.",
|
||||||
|
"ZERO_RESULT_TITLE": "Résultat invalide",
|
||||||
|
"ZERO_RESULT_HELP": "Le calcul a renvoyé des valeurs nulles invalides. Essayez un autre format de fichier ou contactez-nous directement via Demander une consultation."
|
||||||
},
|
},
|
||||||
"QUOTE": {
|
"QUOTE": {
|
||||||
"PROCEED_ORDER": "Procéder à la commande",
|
"PROCEED_ORDER": "Procéder à la commande",
|
||||||
|
|||||||
@@ -116,7 +116,9 @@
|
|||||||
"FALLBACK_MATERIAL": "PLA (fallback)",
|
"FALLBACK_MATERIAL": "PLA (fallback)",
|
||||||
"FALLBACK_QUALITY_STANDARD": "Standard",
|
"FALLBACK_QUALITY_STANDARD": "Standard",
|
||||||
"ERR_FILE_TOO_LARGE": "Alcuni file superano il limite di 200MB e non sono stati aggiunti.",
|
"ERR_FILE_TOO_LARGE": "Alcuni file superano il limite di 200MB e non sono stati aggiunti.",
|
||||||
"ERROR_ZERO_PRICE": "Qualcosa è andato storto. Prova con un altro formato oppure contattaci."
|
"ERROR_ZERO_PRICE": "Qualcosa è andato storto nel calcolo. Prova un altro formato o contattaci direttamente con Richiedi Consulenza.",
|
||||||
|
"ZERO_RESULT_TITLE": "Risultato non valido",
|
||||||
|
"ZERO_RESULT_HELP": "Il calcolo ha restituito valori non validi (0). Prova con un altro formato file oppure contattaci direttamente con Richiedi Consulenza."
|
||||||
},
|
},
|
||||||
"QUOTE": {
|
"QUOTE": {
|
||||||
"PROCEED_ORDER": "Procedi con l'ordine",
|
"PROCEED_ORDER": "Procedi con l'ordine",
|
||||||
|
|||||||
Reference in New Issue
Block a user