Merge pull request 'feat/calculator-options' (#23) from feat/calculator-options into dev
All checks were successful
Build and Deploy / build-and-push (push) Successful in 45s
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / deploy (push) Successful in 10s

Reviewed-on: #23
This commit was merged in pull request #23.
This commit is contained in:
2026-03-05 15:14:08 +01:00
41 changed files with 1184 additions and 101 deletions

View File

@@ -5,7 +5,7 @@ import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository; import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.service.ClamAVService; import com.printcalculator.service.storage.ClamAVService;
import com.printcalculator.service.email.EmailNotificationService; import com.printcalculator.service.email.EmailNotificationService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@@ -3,17 +3,16 @@ package com.printcalculator.controller;
import com.printcalculator.dto.OptionsResponse; import com.printcalculator.dto.OptionsResponse;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.LayerHeightOption;
import com.printcalculator.entity.MaterialOrcaProfileMap; import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.NozzleOption; import com.printcalculator.entity.NozzleOption;
import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile; import com.printcalculator.entity.PrinterMachineProfile;
import com.printcalculator.repository.FilamentMaterialTypeRepository; import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.LayerHeightOptionRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository; import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.OrcaProfileResolver; import com.printcalculator.service.OrcaProfileResolver;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -24,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -32,26 +32,26 @@ public class OptionsController {
private final FilamentMaterialTypeRepository materialRepo; private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo; private final FilamentVariantRepository variantRepo;
private final LayerHeightOptionRepository layerHeightRepo;
private final NozzleOptionRepository nozzleRepo; private final NozzleOptionRepository nozzleRepo;
private final PrinterMachineRepository printerMachineRepo; private final PrinterMachineRepository printerMachineRepo;
private final MaterialOrcaProfileMapRepository materialOrcaMapRepo; private final MaterialOrcaProfileMapRepository materialOrcaMapRepo;
private final OrcaProfileResolver orcaProfileResolver; private final OrcaProfileResolver orcaProfileResolver;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
public OptionsController(FilamentMaterialTypeRepository materialRepo, public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo, FilamentVariantRepository variantRepo,
LayerHeightOptionRepository layerHeightRepo,
NozzleOptionRepository nozzleRepo, NozzleOptionRepository nozzleRepo,
PrinterMachineRepository printerMachineRepo, PrinterMachineRepository printerMachineRepo,
MaterialOrcaProfileMapRepository materialOrcaMapRepo, MaterialOrcaProfileMapRepository materialOrcaMapRepo,
OrcaProfileResolver orcaProfileResolver) { OrcaProfileResolver orcaProfileResolver,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.materialRepo = materialRepo; this.materialRepo = materialRepo;
this.variantRepo = variantRepo; this.variantRepo = variantRepo;
this.layerHeightRepo = layerHeightRepo;
this.nozzleRepo = nozzleRepo; this.nozzleRepo = nozzleRepo;
this.printerMachineRepo = printerMachineRepo; this.printerMachineRepo = printerMachineRepo;
this.materialOrcaMapRepo = materialOrcaMapRepo; this.materialOrcaMapRepo = materialOrcaMapRepo;
this.orcaProfileResolver = orcaProfileResolver; this.orcaProfileResolver = orcaProfileResolver;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
} }
@GetMapping("/api/calculator/options") @GetMapping("/api/calculator/options")
@@ -116,15 +116,6 @@ public class OptionsController {
new OptionsResponse.InfillPatternOption("cubic", "Cubic") new OptionsResponse.InfillPatternOption("cubic", "Cubic")
); );
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
.filter(l -> Boolean.TRUE.equals(l.getIsActive()))
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
.map(l -> new OptionsResponse.LayerHeightOptionDTO(
l.getLayerHeightMm().doubleValue(),
String.format("%.2f mm", l.getLayerHeightMm())
))
.toList();
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream() List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
.filter(n -> Boolean.TRUE.equals(n.getIsActive())) .filter(n -> Boolean.TRUE.equals(n.getIsActive()))
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm)) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
@@ -137,7 +128,31 @@ public class OptionsController {
)) ))
.toList(); .toList();
return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles)); Map<BigDecimal, List<BigDecimal>> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle();
BigDecimal selectedNozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
);
List<OptionsResponse.LayerHeightOptionDTO> layers = toLayerDtos(rulesByNozzle.getOrDefault(selectedNozzle, List.of()));
if (layers.isEmpty()) {
layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of());
}
List<OptionsResponse.NozzleLayerHeightOptionsDTO> layerHeightsByNozzle = rulesByNozzle.entrySet().stream()
.map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO(
entry.getKey().doubleValue(),
toLayerDtos(entry.getValue())
))
.toList();
return ResponseEntity.ok(new OptionsResponse(
materialOptions,
qualities,
patterns,
layers,
nozzles,
layerHeightsByNozzle
));
} }
private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) {
@@ -152,9 +167,9 @@ public class OptionsController {
return Set.of(); return Set.of();
} }
BigDecimal nozzle = nozzleDiameter != null BigDecimal nozzle = nozzleLayerHeightPolicyService.resolveNozzle(
? BigDecimal.valueOf(nozzleDiameter) nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
: BigDecimal.valueOf(0.40); );
PrinterMachineProfile machineProfile = orcaProfileResolver PrinterMachineProfile machineProfile = orcaProfileResolver
.resolveMachineProfile(machine, nozzle) .resolveMachineProfile(machine, nozzle)
@@ -172,6 +187,16 @@ public class OptionsController {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
private List<OptionsResponse.LayerHeightOptionDTO> toLayerDtos(List<BigDecimal> layers) {
return layers.stream()
.sorted(Comparator.naturalOrder())
.map(layer -> new OptionsResponse.LayerHeightOptionDTO(
layer.doubleValue(),
String.format("%.2f mm", layer)
))
.toList();
}
private String resolveHexColor(FilamentVariant variant) { private String resolveHexColor(FilamentVariant variant) {
if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) { if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) {
return variant.getColorHex(); return variant.getColorHex();

View File

@@ -3,12 +3,12 @@ package com.printcalculator.controller;
import com.printcalculator.dto.*; import com.printcalculator.dto.*;
import com.printcalculator.entity.*; import com.printcalculator.entity.*;
import com.printcalculator.repository.*; import com.printcalculator.repository.*;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService; import com.printcalculator.service.OrderService;
import com.printcalculator.service.PaymentService; import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.TwintPaymentService; import com.printcalculator.service.payment.TwintPaymentService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;

View File

@@ -4,17 +4,23 @@ import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult; import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.storage.ClamAVService;
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;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
import java.util.HashMap;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
public class QuoteController { public class QuoteController {
@@ -22,17 +28,23 @@ public class QuoteController {
private final SlicerService slicerService; private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo; private final PrinterMachineRepository machineRepo;
private final com.printcalculator.service.ClamAVService clamAVService; private final ClamAVService clamAVService;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
// Defaults (using aliases defined in ProfileManager) // Defaults (using aliases defined in ProfileManager)
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, com.printcalculator.service.ClamAVService clamAVService) { public QuoteController(SlicerService slicerService,
QuoteCalculator quoteCalculator,
PrinterMachineRepository machineRepo,
ClamAVService clamAVService,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.slicerService = slicerService; this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
} }
@PostMapping("/api/quote") @PostMapping("/api/quote")
@@ -69,15 +81,27 @@ public class QuoteController {
if (infillPattern != null && !infillPattern.isEmpty()) { if (infillPattern != null && !infillPattern.isEmpty()) {
processOverrides.put("sparse_infill_pattern", infillPattern); processOverrides.put("sparse_infill_pattern", infillPattern);
} }
BigDecimal normalizedNozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
);
if (layerHeight != null) { if (layerHeight != null) {
processOverrides.put("layer_height", String.valueOf(layerHeight)); BigDecimal normalizedLayer = nozzleLayerHeightPolicyService.normalizeLayer(BigDecimal.valueOf(layerHeight));
if (!nozzleLayerHeightPolicyService.isAllowed(normalizedNozzle, normalizedLayer)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Layer height " + normalizedLayer.stripTrailingZeros().toPlainString()
+ " is not allowed for nozzle " + normalizedNozzle.stripTrailingZeros().toPlainString()
+ ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(normalizedNozzle)
);
}
processOverrides.put("layer_height", normalizedLayer.stripTrailingZeros().toPlainString());
} }
if (supportEnabled != null) { if (supportEnabled != null) {
processOverrides.put("enable_support", supportEnabled ? "1" : "0"); processOverrides.put("enable_support", supportEnabled ? "1" : "0");
} }
if (nozzleDiameter != null) { if (nozzleDiameter != null) {
machineOverrides.put("nozzle_diameter", String.valueOf(nozzleDiameter)); machineOverrides.put("nozzle_diameter", normalizedNozzle.stripTrailingZeros().toPlainString());
// Also need to ensure the printer profile is compatible or just override? // Also need to ensure the printer profile is compatible or just override?
// Usually nozzle diameter changes require a different printer profile or deep overrides. // Usually nozzle diameter changes require a different printer profile or deep overrides.
// For now, we trust the override key works on the base profile. // For now, we trust the override key works on the base profile.

View File

@@ -14,9 +14,11 @@ 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.OrcaProfileResolver; import com.printcalculator.service.OrcaProfileResolver;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.QuoteSessionTotalsService; import com.printcalculator.service.QuoteSessionTotalsService;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.storage.ClamAVService;
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;
@@ -61,8 +63,9 @@ public class QuoteSessionController {
private final FilamentMaterialTypeRepository materialRepo; private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo; private final FilamentVariantRepository variantRepo;
private final OrcaProfileResolver orcaProfileResolver; private final OrcaProfileResolver orcaProfileResolver;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
private final com.printcalculator.service.ClamAVService clamAVService; private final ClamAVService clamAVService;
private final QuoteSessionTotalsService quoteSessionTotalsService; private final QuoteSessionTotalsService quoteSessionTotalsService;
public QuoteSessionController(QuoteSessionRepository sessionRepo, public QuoteSessionController(QuoteSessionRepository sessionRepo,
@@ -73,8 +76,9 @@ public class QuoteSessionController {
FilamentMaterialTypeRepository materialRepo, FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo, FilamentVariantRepository variantRepo,
OrcaProfileResolver orcaProfileResolver, OrcaProfileResolver orcaProfileResolver,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService,
com.printcalculator.repository.PricingPolicyRepository pricingRepo, com.printcalculator.repository.PricingPolicyRepository pricingRepo,
com.printcalculator.service.ClamAVService clamAVService, ClamAVService clamAVService,
QuoteSessionTotalsService quoteSessionTotalsService) { QuoteSessionTotalsService quoteSessionTotalsService) {
this.sessionRepo = sessionRepo; this.sessionRepo = sessionRepo;
this.lineItemRepo = lineItemRepo; this.lineItemRepo = lineItemRepo;
@@ -84,6 +88,7 @@ public class QuoteSessionController {
this.materialRepo = materialRepo; this.materialRepo = materialRepo;
this.variantRepo = variantRepo; this.variantRepo = variantRepo;
this.orcaProfileResolver = orcaProfileResolver; this.orcaProfileResolver = orcaProfileResolver;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
this.pricingRepo = pricingRepo; this.pricingRepo = pricingRepo;
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
this.quoteSessionTotalsService = quoteSessionTotalsService; this.quoteSessionTotalsService = quoteSessionTotalsService;
@@ -167,7 +172,23 @@ public class QuoteSessionController {
applyPrintSettings(settings); applyPrintSettings(settings);
} }
BigDecimal nozzleDiameter = BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4); BigDecimal nozzleDiameter = nozzleLayerHeightPolicyService.resolveNozzle(
settings.getNozzleDiameter() != null ? BigDecimal.valueOf(settings.getNozzleDiameter()) : null
);
BigDecimal layerHeight = nozzleLayerHeightPolicyService.resolveLayer(
settings.getLayerHeight() != null ? BigDecimal.valueOf(settings.getLayerHeight()) : null,
nozzleDiameter
);
if (!nozzleLayerHeightPolicyService.isAllowed(nozzleDiameter, layerHeight)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Layer height " + layerHeight.stripTrailingZeros().toPlainString()
+ " is not allowed for nozzle " + nozzleDiameter.stripTrailingZeros().toPlainString()
+ ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(nozzleDiameter)
);
}
settings.setNozzleDiameter(nozzleDiameter.doubleValue());
settings.setLayerHeight(layerHeight.doubleValue());
// Pick machine (selected machine if provided, otherwise first active) // Pick machine (selected machine if provided, otherwise first active)
PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId()); PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId());
@@ -190,7 +211,7 @@ public class QuoteSessionController {
if (!cadSession) { if (!cadSession) {
session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode()); session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
session.setNozzleDiameterMm(nozzleDiameter); session.setNozzleDiameterMm(nozzleDiameter);
session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2)); session.setLayerHeightMm(layerHeight);
session.setInfillPattern(settings.getInfillPattern()); session.setInfillPattern(settings.getInfillPattern());
session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20); session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false); session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
@@ -209,7 +230,7 @@ public class QuoteSessionController {
// Build overrides map from settings // Build overrides map from settings
Map<String, String> processOverrides = new HashMap<>(); Map<String, String> processOverrides = new HashMap<>();
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight())); processOverrides.put("layer_height", layerHeight.stripTrailingZeros().toPlainString());
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());
@@ -289,6 +310,10 @@ public class QuoteSessionController {
} }
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) { private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
if (settings.getNozzleDiameter() == null) {
settings.setNozzleDiameter(0.40);
}
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) { if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
// Set defaults based on Quality // Set defaults based on Quality
String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard"; String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard";
@@ -313,7 +338,6 @@ public class QuoteSessionController {
} }
} else { } else {
// ADVANCED Mode: Use values from Frontend, set defaults if missing // ADVANCED Mode: Use values from Frontend, set defaults if missing
if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20);
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0); if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid"); if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
} }

View File

@@ -12,10 +12,10 @@ import com.printcalculator.event.OrderShippedEvent;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository; import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.PaymentService; import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.storage.StorageService;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition; import org.springframework.http.ContentDisposition;
@@ -34,7 +34,6 @@ import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.InvalidPathException; import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;

View File

@@ -7,7 +7,8 @@ public record OptionsResponse(
List<QualityOption> qualities, List<QualityOption> qualities,
List<InfillPatternOption> infillPatterns, List<InfillPatternOption> infillPatterns,
List<LayerHeightOptionDTO> layerHeights, List<LayerHeightOptionDTO> layerHeights,
List<NozzleOptionDTO> nozzleDiameters List<NozzleOptionDTO> nozzleDiameters,
List<NozzleLayerHeightOptionsDTO> layerHeightsByNozzle
) { ) {
public record MaterialOption(String code, String label, List<VariantOption> variants) {} public record MaterialOption(String code, String label, List<VariantOption> variants) {}
public record VariantOption( public record VariantOption(
@@ -24,4 +25,5 @@ public record OptionsResponse(
public record InfillPatternOption(String id, String label) {} public record InfillPatternOption(String id, String label) {}
public record LayerHeightOptionDTO(double value, String label) {} public record LayerHeightOptionDTO(double value, String label) {}
public record NozzleOptionDTO(double value, String label) {} public record NozzleOptionDTO(double value, String label) {}
public record NozzleLayerHeightOptionsDTO(double nozzleDiameter, List<LayerHeightOptionDTO> layerHeights) {}
} }

View File

@@ -0,0 +1,63 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(
name = "nozzle_layer_height_option",
uniqueConstraints = @UniqueConstraint(
name = "ux_nozzle_layer_height_option_nozzle_layer",
columnNames = {"nozzle_diameter_mm", "layer_height_mm"}
)
)
public class NozzleLayerHeightOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "nozzle_layer_height_option_id", nullable = false)
private Long id;
@Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal layerHeightMm;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -8,9 +8,9 @@ import com.printcalculator.event.OrderShippedEvent;
import com.printcalculator.event.PaymentConfirmedEvent; import com.printcalculator.event.PaymentConfirmedEvent;
import com.printcalculator.event.PaymentReportedEvent; import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.email.EmailNotificationService; import com.printcalculator.service.email.EmailNotificationService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;

View File

@@ -0,0 +1,10 @@
package com.printcalculator.repository;
import com.printcalculator.entity.NozzleLayerHeightOption;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface NozzleLayerHeightOptionRepository extends JpaRepository<NozzleLayerHeightOption, Long> {
List<NozzleLayerHeightOption> findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc();
}

View File

@@ -0,0 +1,143 @@
package com.printcalculator.service;
import com.printcalculator.entity.NozzleLayerHeightOption;
import com.printcalculator.repository.NozzleLayerHeightOptionRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Service
public class NozzleLayerHeightPolicyService {
private static final BigDecimal DEFAULT_NOZZLE = BigDecimal.valueOf(0.40).setScale(2, RoundingMode.HALF_UP);
private static final BigDecimal DEFAULT_LAYER = BigDecimal.valueOf(0.20).setScale(3, RoundingMode.HALF_UP);
private final NozzleLayerHeightOptionRepository ruleRepo;
public NozzleLayerHeightPolicyService(NozzleLayerHeightOptionRepository ruleRepo) {
this.ruleRepo = ruleRepo;
}
public Map<BigDecimal, List<BigDecimal>> getActiveRulesByNozzle() {
List<NozzleLayerHeightOption> rules = ruleRepo.findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc();
if (rules.isEmpty()) {
return fallbackRules();
}
Map<BigDecimal, List<BigDecimal>> byNozzle = new LinkedHashMap<>();
for (NozzleLayerHeightOption rule : rules) {
BigDecimal nozzle = normalizeNozzle(rule.getNozzleDiameterMm());
BigDecimal layer = normalizeLayer(rule.getLayerHeightMm());
if (nozzle == null || layer == null) {
continue;
}
byNozzle.computeIfAbsent(nozzle, ignored -> new ArrayList<>()).add(layer);
}
byNozzle.values().forEach(this::sortAndDeduplicate);
return byNozzle;
}
public BigDecimal normalizeNozzle(BigDecimal value) {
if (value == null) {
return null;
}
return value.setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal normalizeLayer(BigDecimal value) {
if (value == null) {
return null;
}
return value.setScale(3, RoundingMode.HALF_UP);
}
public BigDecimal resolveNozzle(BigDecimal requestedNozzle) {
return normalizeNozzle(requestedNozzle != null ? requestedNozzle : DEFAULT_NOZZLE);
}
public BigDecimal resolveLayer(BigDecimal requestedLayer, BigDecimal nozzleDiameter) {
if (requestedLayer != null) {
return normalizeLayer(requestedLayer);
}
return defaultLayerForNozzle(nozzleDiameter);
}
public List<BigDecimal> allowedLayersForNozzle(BigDecimal nozzleDiameter) {
BigDecimal nozzle = resolveNozzle(nozzleDiameter);
List<BigDecimal> allowed = getActiveRulesByNozzle().get(nozzle);
return allowed != null ? allowed : List.of();
}
public boolean isAllowed(BigDecimal nozzleDiameter, BigDecimal layerHeight) {
BigDecimal layer = normalizeLayer(layerHeight);
if (layer == null) {
return false;
}
return allowedLayersForNozzle(nozzleDiameter)
.stream()
.anyMatch(allowed -> allowed.compareTo(layer) == 0);
}
public BigDecimal defaultLayerForNozzle(BigDecimal nozzleDiameter) {
List<BigDecimal> allowed = allowedLayersForNozzle(nozzleDiameter);
if (allowed.isEmpty()) {
return DEFAULT_LAYER;
}
BigDecimal preferred = normalizeLayer(DEFAULT_LAYER);
for (BigDecimal candidate : allowed) {
if (candidate.compareTo(preferred) == 0) {
return candidate;
}
}
return allowed.get(0);
}
public String allowedLayersLabel(BigDecimal nozzleDiameter) {
List<BigDecimal> allowed = allowedLayersForNozzle(nozzleDiameter);
if (allowed.isEmpty()) {
return "none";
}
return allowed.stream()
.map(value -> String.format(Locale.ROOT, "%.2f", value))
.reduce((a, b) -> a + ", " + b)
.orElse("none");
}
private void sortAndDeduplicate(List<BigDecimal> values) {
values.sort(Comparator.naturalOrder());
for (int i = values.size() - 1; i > 0; i--) {
if (values.get(i).compareTo(values.get(i - 1)) == 0) {
values.remove(i);
}
}
}
private Map<BigDecimal, List<BigDecimal>> fallbackRules() {
Map<BigDecimal, List<BigDecimal>> fallback = new LinkedHashMap<>();
fallback.put(scaleNozzle(0.20), scaleLayers(0.04, 0.06, 0.08, 0.10, 0.12));
fallback.put(scaleNozzle(0.40), scaleLayers(0.08, 0.12, 0.16, 0.20, 0.24, 0.28));
fallback.put(scaleNozzle(0.60), scaleLayers(0.16, 0.20, 0.24, 0.30, 0.36));
fallback.put(scaleNozzle(0.80), scaleLayers(0.20, 0.28, 0.36, 0.40, 0.48, 0.56));
return fallback;
}
private BigDecimal scaleNozzle(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP);
}
private List<BigDecimal> scaleLayers(double... values) {
List<BigDecimal> scaled = new ArrayList<>();
for (double value : values) {
scaled.add(BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP));
}
return scaled;
}
}

View File

@@ -8,6 +8,10 @@ import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.event.OrderCreatedEvent; import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.payment;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import com.openhtmltopdf.svgsupport.BatikSVGDrawer; import com.openhtmltopdf.svgsupport.BatikSVGDrawer;

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.payment;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment; import com.printcalculator.entity.Payment;
@@ -65,7 +65,7 @@ public class PaymentService {
payment.setReportedAt(OffsetDateTime.now()); payment.setReportedAt(OffsetDateTime.now());
// We intentionally do not update the payment method here based on user input, // We intentionally do not update the payment method here based on user input,
// because the user cannot reliably determine the actual method without an integration. // because the system cannot reliably determine the actual method without an integration.
// It will be updated by the backoffice admin manually. // It will be updated by the backoffice admin manually.
payment = paymentRepo.save(payment); payment = paymentRepo.save(payment);

View File

@@ -1,13 +1,10 @@
package com.printcalculator.service; package com.printcalculator.service.payment;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import net.codecrete.qrbill.generator.Bill; import net.codecrete.qrbill.generator.Bill;
import net.codecrete.qrbill.generator.GraphicsFormat;
import net.codecrete.qrbill.generator.QRBill; import net.codecrete.qrbill.generator.QRBill;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service @Service
public class QrBillService { public class QrBillService {

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.payment;
import io.nayuki.qrcodegen.QrCode; import io.nayuki.qrcodegen.QrCode;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.storage;
import com.printcalculator.exception.VirusDetectedException; import com.printcalculator.exception.VirusDetectedException;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.storage;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
@@ -7,7 +7,6 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.printcalculator.exception.StorageException; import com.printcalculator.exception.StorageException;
import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.storage;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;

View File

@@ -8,12 +8,12 @@ import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository; import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService; import com.printcalculator.service.OrderService;
import com.printcalculator.service.PaymentService; import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.TwintPaymentService; import com.printcalculator.service.payment.TwintPaymentService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;

View File

@@ -6,10 +6,10 @@ import com.printcalculator.entity.Order;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository; import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.PaymentService; import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.storage.StorageService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;

View File

@@ -4,9 +4,9 @@ import com.printcalculator.entity.Customer;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import com.printcalculator.event.OrderCreatedEvent; import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.email.EmailNotificationService; import com.printcalculator.service.email.EmailNotificationService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -0,0 +1,21 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /admin/
Disallow: /*/admin
Disallow: /*/admin/
Disallow: /order/
Disallow: /*/order/
Disallow: /co/
Disallow: /*/co/
Disallow: /checkout
Disallow: /checkout/
Disallow: /*/checkout
Disallow: /*/checkout/
Disallow: /shop
Disallow: /shop/
Disallow: /*/shop
Disallow: /*/shop/
Sitemap: https://3d-fab.ch/sitemap.xml

144
frontend/public/sitemap.xml Normal file
View File

@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
>
<url>
<loc>https://3d-fab.ch/it</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/calculator/basic</loc>
<xhtml:link
rel="alternate"
hreflang="it"
href="https://3d-fab.ch/it/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="en"
href="https://3d-fab.ch/en/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://3d-fab.ch/de/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="fr"
href="https://3d-fab.ch/fr/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/calculator/basic"
/>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
<xhtml:link
rel="alternate"
hreflang="it"
href="https://3d-fab.ch/it/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="en"
href="https://3d-fab.ch/en/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://3d-fab.ch/de/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="fr"
href="https://3d-fab.ch/fr/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/calculator/advanced"
/>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/about"
/>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/contact</loc>
<xhtml:link
rel="alternate"
hreflang="it"
href="https://3d-fab.ch/it/contact"
/>
<xhtml:link
rel="alternate"
hreflang="en"
href="https://3d-fab.ch/en/contact"
/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://3d-fab.ch/de/contact"
/>
<xhtml:link
rel="alternate"
hreflang="fr"
href="https://3d-fab.ch/fr/contact"
/>
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/contact"
/>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/privacy"
/>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
</urlset>

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { SeoService } from './core/services/seo.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -8,4 +9,6 @@ import { RouterOutlet } from '@angular/router';
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
}) })
export class AppComponent {} export class AppComponent {
private readonly seoService = inject(SeoService);
}

View File

@@ -5,6 +5,11 @@ const appChildRoutes: Routes = [
path: '', path: '',
loadComponent: () => loadComponent: () =>
import('./features/home/home.component').then((m) => m.HomeComponent), import('./features/home/home.component').then((m) => m.HomeComponent),
data: {
seoTitle: '3D fab | Stampa 3D su misura',
seoDescription:
'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.',
},
}, },
{ {
path: 'calculator', path: 'calculator',
@@ -12,21 +17,42 @@ const appChildRoutes: Routes = [
import('./features/calculator/calculator.routes').then( import('./features/calculator/calculator.routes').then(
(m) => m.CALCULATOR_ROUTES, (m) => m.CALCULATOR_ROUTES,
), ),
data: {
seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab',
seoDescription:
'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.',
},
}, },
{ {
path: 'shop', path: 'shop',
loadChildren: () => loadChildren: () =>
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES), import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
data: {
seoTitle: 'Shop 3D fab',
seoDescription:
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: 'about', path: 'about',
loadChildren: () => loadChildren: () =>
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES), import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
data: {
seoTitle: 'Chi siamo | 3D fab',
seoDescription:
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
},
}, },
{ {
path: 'contact', path: 'contact',
loadChildren: () => loadChildren: () =>
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES), import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
data: {
seoTitle: 'Contatti | 3D fab',
seoDescription:
'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.',
},
}, },
{ {
path: 'checkout/cad', path: 'checkout/cad',
@@ -34,6 +60,10 @@ const appChildRoutes: Routes = [
import('./features/checkout/checkout.component').then( import('./features/checkout/checkout.component').then(
(m) => m.CheckoutComponent, (m) => m.CheckoutComponent,
), ),
data: {
seoTitle: 'Checkout | 3D fab',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: 'checkout', path: 'checkout',
@@ -41,16 +71,28 @@ const appChildRoutes: Routes = [
import('./features/checkout/checkout.component').then( import('./features/checkout/checkout.component').then(
(m) => m.CheckoutComponent, (m) => m.CheckoutComponent,
), ),
data: {
seoTitle: 'Checkout | 3D fab',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: 'order/:orderId', path: 'order/:orderId',
loadComponent: () => loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent), import('./features/order/order.component').then((m) => m.OrderComponent),
data: {
seoTitle: 'Ordine | 3D fab',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: 'co/:orderId', path: 'co/:orderId',
loadComponent: () => loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent), import('./features/order/order.component').then((m) => m.OrderComponent),
data: {
seoTitle: 'Ordine | 3D fab',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: '', path: '',
@@ -61,6 +103,10 @@ const appChildRoutes: Routes = [
path: 'admin', path: 'admin',
loadChildren: () => loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES), import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
data: {
seoTitle: 'Admin | 3D fab',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: '**', path: '**',

View File

@@ -0,0 +1,129 @@
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';
import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class SeoService {
private readonly defaultTitle = '3D fab | Stampa 3D su misura';
private readonly defaultDescription =
'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.';
private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']);
constructor(
private router: Router,
private titleService: Title,
private metaService: Meta,
@Inject(DOCUMENT) private document: Document,
) {
this.applyRouteSeo(this.router.routerState.snapshot.root);
this.router.events
.pipe(
filter(
(event): event is NavigationEnd => event instanceof NavigationEnd,
),
)
.subscribe(() => {
this.applyRouteSeo(this.router.routerState.snapshot.root);
});
}
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
const mergedData = this.getMergedRouteData(rootSnapshot);
const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle;
const description =
this.asString(mergedData['seoDescription']) ?? this.defaultDescription;
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
this.titleService.setTitle(title);
this.metaService.updateTag({ name: 'description', content: description });
this.metaService.updateTag({ name: 'robots', content: robots });
this.metaService.updateTag({ property: 'og:title', content: title });
this.metaService.updateTag({
property: 'og:description',
content: description,
});
this.metaService.updateTag({ property: 'og:type', content: 'website' });
this.metaService.updateTag({ name: 'twitter:card', content: 'summary' });
const cleanPath = this.getCleanPath(this.router.url);
const canonical = `${this.document.location.origin}${cleanPath}`;
this.metaService.updateTag({ property: 'og:url', content: canonical });
this.updateCanonicalTag(canonical);
this.updateLangAndAlternates(cleanPath);
}
private getMergedRouteData(
snapshot: ActivatedRouteSnapshot,
): Record<string, unknown> {
const merged: Record<string, unknown> = {};
let cursor: ActivatedRouteSnapshot | null = snapshot;
while (cursor) {
Object.assign(merged, cursor.data ?? {});
cursor = cursor.firstChild;
}
return merged;
}
private asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
private getCleanPath(url: string): string {
const path = (url || '/').split('?')[0].split('#')[0];
return path || '/';
}
private updateCanonicalTag(url: string): void {
let link = this.document.head.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
if (!link) {
link = this.document.createElement('link');
link.setAttribute('rel', 'canonical');
this.document.head.appendChild(link);
}
link.setAttribute('href', url);
}
private updateLangAndAlternates(path: string): void {
const segments = path.split('/').filter(Boolean);
const firstSegment = segments[0]?.toLowerCase();
const hasLang = Boolean(
firstSegment && this.supportedLangs.has(firstSegment),
);
const lang = hasLang ? firstSegment : 'it';
const suffixSegments = hasLang ? segments.slice(1) : segments;
const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
this.document.documentElement.lang = lang;
this.document.head
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
.forEach((node) => node.remove());
for (const alt of ['it', 'en', 'de', 'fr']) {
this.appendAlternateLink(
alt,
`${this.document.location.origin}/${alt}${suffix}`,
);
}
this.appendAlternateLink(
'x-default',
`${this.document.location.origin}/it${suffix}`,
);
}
private appendAlternateLink(hreflang: string, href: string): void {
const link = this.document.createElement('link');
link.setAttribute('rel', 'alternate');
link.setAttribute('hreflang', hreflang);
link.setAttribute('href', href);
link.setAttribute('data-seo-managed', 'true');
this.document.head.appendChild(link);
}
}

View File

@@ -2,5 +2,13 @@ import { Routes } from '@angular/router';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
export const ABOUT_ROUTES: Routes = [ export const ABOUT_ROUTES: Routes = [
{ path: '', component: AboutPageComponent }, {
path: '',
component: AboutPageComponent,
data: {
seoTitle: 'Chi siamo | 3D fab',
seoDescription:
'Siamo un laboratorio di stampa 3D orientato a prototipi, ricambi e produzioni su misura.',
},
},
]; ];

View File

@@ -24,7 +24,7 @@
class="mode-option" class="mode-option"
[class.active]="mode() === 'easy'" [class.active]="mode() === 'easy'"
[class.disabled]="cadSessionLocked()" [class.disabled]="cadSessionLocked()"
(click)="!cadSessionLocked() && mode.set('easy')" (click)="switchMode('easy')"
> >
{{ "CALC.MODE_EASY" | translate }} {{ "CALC.MODE_EASY" | translate }}
</div> </div>
@@ -32,7 +32,7 @@
class="mode-option" class="mode-option"
[class.active]="mode() === 'advanced'" [class.active]="mode() === 'advanced'"
[class.disabled]="cadSessionLocked()" [class.disabled]="cadSessionLocked()"
(click)="!cadSessionLocked() && mode.set('advanced')" (click)="switchMode('advanced')"
> >
{{ "CALC.MODE_ADVANCED" | translate }} {{ "CALC.MODE_ADVANCED" | translate }}
</div> </div>
@@ -45,6 +45,8 @@
[loading]="loading()" [loading]="loading()"
[uploadProgress]="uploadProgress()" [uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)" (submitRequest)="onCalculate($event)"
(itemQuantityChange)="onUploadItemQuantityChange($event)"
(printSettingsChange)="onUploadPrintSettingsChange($event)"
></app-upload-form> ></app-upload-form>
</app-card> </app-card>
</div> </div>
@@ -64,8 +66,10 @@
} @else if (result()) { } @else if (result()) {
<app-quote-result <app-quote-result
[result]="result()!" [result]="result()!"
[recalculationRequired]="requiresRecalculation()"
(consult)="onConsult()" (consult)="onConsult()"
(proceed)="onProceed()" (proceed)="onProceed()"
(itemQuantityPreviewChange)="onQuoteItemQuantityPreviewChange($event)"
(itemChange)="onItemChange($event)" (itemChange)="onItemChange($event)"
></app-quote-result> ></app-quote-result>
} @else if (isZeroQuoteError()) { } @else if (isZeroQuoteError()) {

View File

@@ -42,7 +42,7 @@ import { LanguageService } from '../../core/services/language.service';
styleUrl: './calculator-page.component.scss', styleUrl: './calculator-page.component.scss',
}) })
export class CalculatorPageComponent implements OnInit { export class CalculatorPageComponent implements OnInit {
mode = signal<any>('easy'); mode = signal<'easy' | 'advanced'>('easy');
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload'); step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
loading = signal(false); loading = signal(false);
@@ -56,6 +56,17 @@ export class CalculatorPageComponent implements OnInit {
); );
orderSuccess = signal(false); orderSuccess = signal(false);
requiresRecalculation = signal(false);
private baselinePrintSettings: {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
} | null = null;
@ViewChild('uploadForm') uploadForm!: UploadFormComponent; @ViewChild('uploadForm') uploadForm!: UploadFormComponent;
@ViewChild('resultCol') resultCol!: ElementRef; @ViewChild('resultCol') resultCol!: ElementRef;
@@ -101,6 +112,10 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(false); this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC'); this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(result); this.result.set(result);
this.baselinePrintSettings = this.toTrackedSettingsFromSession(
data.session,
);
this.requiresRecalculation.set(false);
const isCadSession = data?.session?.status === 'CAD_ACTIVE'; const isCadSession = data?.session?.status === 'CAD_ACTIVE';
this.cadSessionLocked.set(isCadSession); this.cadSessionLocked.set(isCadSession);
this.step.set('quote'); this.step.set('quote');
@@ -238,6 +253,8 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(false); this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC'); this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(res); this.result.set(res);
this.baselinePrintSettings = this.toTrackedSettingsFromRequest(req);
this.requiresRecalculation.set(false);
this.loading.set(false); this.loading.set(false);
this.uploadProgress.set(100); this.uploadProgress.set(100);
this.step.set('quote'); this.step.set('quote');
@@ -295,9 +312,10 @@ export class CalculatorPageComponent implements OnInit {
index: number; index: number;
fileName: string; fileName: string;
quantity: number; quantity: number;
source?: 'left' | 'right';
}) { }) {
// 1. Update local form for consistency (UI feedback) // 1. Update local form for consistency (UI feedback)
if (this.uploadForm) { if (event.source !== 'left' && this.uploadForm) {
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity); this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
} }
@@ -340,6 +358,33 @@ export class CalculatorPageComponent implements OnInit {
} }
} }
onUploadItemQuantityChange(event: {
index: number;
fileName: string;
quantity: number;
}) {
const resultItems = this.result()?.items || [];
const byIndex = resultItems[event.index];
const byName = resultItems.find((item) => item.fileName === event.fileName);
const id = byIndex?.id ?? byName?.id;
this.onItemChange({
...event,
id,
source: 'left',
});
}
onQuoteItemQuantityPreviewChange(event: {
index: number;
fileName: string;
quantity: number;
}) {
if (!this.uploadForm) return;
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
}
onSubmitOrder(orderData: any) { onSubmitOrder(orderData: any) {
console.log('Order Submitted:', orderData); console.log('Order Submitted:', orderData);
this.orderSuccess.set(true); this.orderSuccess.set(true);
@@ -349,15 +394,37 @@ export class CalculatorPageComponent implements OnInit {
onNewQuote() { onNewQuote() {
this.step.set('upload'); this.step.set('upload');
this.result.set(null); this.result.set(null);
this.requiresRecalculation.set(false);
this.baselinePrintSettings = null;
this.cadSessionLocked.set(false); this.cadSessionLocked.set(false);
this.orderSuccess.set(false); this.orderSuccess.set(false);
this.mode.set('easy'); // Reset to default this.switchMode('easy'); // Reset to default and sync URL
} }
private currentRequest: QuoteRequest | null = null; private currentRequest: QuoteRequest | null = null;
onUploadPrintSettingsChange(settings: {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
}) {
if (!this.result()) return;
if (!this.baselinePrintSettings) return;
this.requiresRecalculation.set(
!this.sameTrackedSettings(this.baselinePrintSettings, settings),
);
}
onConsult() { onConsult() {
if (!this.currentRequest) { const currentFormRequest = this.uploadForm?.getCurrentRequestDraft();
const req = currentFormRequest ?? this.currentRequest;
if (!req) {
this.router.navigate([ this.router.navigate([
'/', '/',
this.languageService.selectedLang(), this.languageService.selectedLang(),
@@ -366,7 +433,6 @@ export class CalculatorPageComponent implements OnInit {
return; return;
} }
const req = this.currentRequest;
let details = `Richiesta Preventivo:\n`; let details = `Richiesta Preventivo:\n`;
details += `- Materiale: ${req.material}\n`; details += `- Materiale: ${req.material}\n`;
details += `- Qualità: ${req.quality}\n`; details += `- Qualità: ${req.quality}\n`;
@@ -411,5 +477,126 @@ export class CalculatorPageComponent implements OnInit {
this.errorKey.set(key); this.errorKey.set(key);
this.error.set(true); this.error.set(true);
this.result.set(null); this.result.set(null);
this.requiresRecalculation.set(false);
this.baselinePrintSettings = null;
}
switchMode(nextMode: 'easy' | 'advanced'): void {
if (this.cadSessionLocked()) return;
const targetPath = nextMode === 'easy' ? 'basic' : 'advanced';
const currentPath = this.route.snapshot.routeConfig?.path;
this.mode.set(nextMode);
if (currentPath === targetPath) {
return;
}
this.router.navigate(['..', targetPath], {
relativeTo: this.route,
queryParamsHandling: 'preserve',
});
}
private toTrackedSettingsFromRequest(req: QuoteRequest): {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
} {
return {
mode: req.mode,
material: this.normalizeString(req.material || 'PLA'),
quality: this.normalizeString(req.quality || 'standard'),
nozzleDiameter: this.normalizeNumber(req.nozzleDiameter, 0.4, 2),
layerHeight: this.normalizeNumber(req.layerHeight, 0.2, 3),
infillDensity: this.normalizeNumber(req.infillDensity, 20, 2),
infillPattern: this.normalizeString(req.infillPattern || 'grid'),
supportEnabled: Boolean(req.supportEnabled),
};
}
private toTrackedSettingsFromSession(session: any): {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
} {
const layer = this.normalizeNumber(session?.layerHeightMm, 0.2, 3);
return {
mode: this.mode(),
material: this.normalizeString(session?.materialCode || 'PLA'),
quality:
layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard',
nozzleDiameter: this.normalizeNumber(session?.nozzleDiameterMm, 0.4, 2),
layerHeight: layer,
infillDensity: this.normalizeNumber(session?.infillPercent, 20, 2),
infillPattern: this.normalizeString(session?.infillPattern || 'grid'),
supportEnabled: Boolean(session?.supportsEnabled),
};
}
private sameTrackedSettings(
a: {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
},
b: {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
},
): boolean {
return (
a.mode === b.mode &&
a.material === this.normalizeString(b.material) &&
a.quality === this.normalizeString(b.quality) &&
Math.abs(
a.nozzleDiameter - this.normalizeNumber(b.nozzleDiameter, 0.4, 2),
) < 0.0001 &&
Math.abs(a.layerHeight - this.normalizeNumber(b.layerHeight, 0.2, 3)) <
0.0001 &&
Math.abs(a.infillDensity - this.normalizeNumber(b.infillDensity, 20, 2)) <
0.0001 &&
a.infillPattern === this.normalizeString(b.infillPattern) &&
a.supportEnabled === Boolean(b.supportEnabled)
);
}
private normalizeString(value: string): string {
return String(value || '')
.trim()
.toLowerCase();
}
private normalizeNumber(
value: unknown,
fallback: number,
decimals: number,
): number {
const numeric = Number(value);
const resolved = Number.isFinite(numeric) ? numeric : fallback;
const factor = 10 ** decimals;
return Math.round(resolved * factor) / factor;
} }
} }

View File

@@ -3,10 +3,24 @@ import { CalculatorPageComponent } from './calculator-page.component';
export const CALCULATOR_ROUTES: Routes = [ export const CALCULATOR_ROUTES: Routes = [
{ path: '', redirectTo: 'basic', pathMatch: 'full' }, { path: '', redirectTo: 'basic', pathMatch: 'full' },
{ path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } }, {
path: 'basic',
component: CalculatorPageComponent,
data: {
mode: 'easy',
seoTitle: 'Calcolatore stampa 3D base | 3D fab',
seoDescription:
'Calcola rapidamente il prezzo della tua stampa 3D in modalita base.',
},
},
{ {
path: 'advanced', path: 'advanced',
component: CalculatorPageComponent, component: CalculatorPageComponent,
data: { mode: 'advanced' }, data: {
mode: 'advanced',
seoTitle: 'Calcolatore stampa 3D avanzato | 3D fab',
seoDescription:
'Configura parametri avanzati e ottieni un preventivo preciso con slicing reale.',
},
}, },
]; ];

View File

@@ -45,6 +45,12 @@
<p>{{ result().notes }}</p> <p>{{ result().notes }}</p>
</div> </div>
} }
@if (recalculationRequired()) {
<div class="recalc-banner">
Hai modificato i parametri di stampa. Ricalcola il preventivo prima di
procedere con l'ordine.
</div>
}
<div class="divider"></div> <div class="divider"></div>
@@ -104,7 +110,10 @@
<div class="actions-right"> <div class="actions-right">
@if (!hasQuantityOverLimit()) { @if (!hasQuantityOverLimit()) {
<app-button (click)="proceed.emit()"> <app-button
[disabled]="recalculationRequired()"
(click)="proceed.emit()"
>
{{ "QUOTE.PROCEED_ORDER" | translate }} {{ "QUOTE.PROCEED_ORDER" | translate }}
</app-button> </app-button>
} @else { } @else {
@@ -112,6 +121,11 @@
"QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit } "QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit }
}}</small> }}</small>
} }
@if (recalculationRequired()) {
<small class="limit-note">
Ricalcola il preventivo per riattivare il checkout.
</small>
}
</div> </div>
</div> </div>
</app-card> </app-card>

View File

@@ -184,3 +184,14 @@
white-space: pre-wrap; /* Preserve line breaks */ white-space: pre-wrap; /* Preserve line breaks */
} }
} }
.recalc-banner {
margin-top: var(--space-4);
margin-bottom: var(--space-4);
padding: var(--space-3);
border: 1px solid #f0c95a;
background: #fff8e1;
border-radius: var(--radius-md);
color: #6f5b1a;
font-size: 0.9rem;
}

View File

@@ -35,6 +35,7 @@ export class QuoteResultComponent implements OnDestroy {
readonly quantityAutoRefreshMs = 2000; readonly quantityAutoRefreshMs = 2000;
result = input.required<QuoteResult>(); result = input.required<QuoteResult>();
recalculationRequired = input<boolean>(false);
consult = output<void>(); consult = output<void>();
proceed = output<void>(); proceed = output<void>();
itemChange = output<{ itemChange = output<{
@@ -43,6 +44,12 @@ export class QuoteResultComponent implements OnDestroy {
fileName: string; fileName: string;
quantity: number; quantity: number;
}>(); }>();
itemQuantityPreviewChange = output<{
id?: string;
index: number;
fileName: string;
quantity: number;
}>();
// Local mutable state for items to handle quantity changes // Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]); items = signal<QuoteItem[]>([]);
@@ -87,6 +94,13 @@ export class QuoteResultComponent implements OnDestroy {
return updated; return updated;
}); });
this.itemQuantityPreviewChange.emit({
id: item.id,
index,
fileName: item.fileName,
quantity: normalizedQty,
});
this.scheduleQuantityRefresh(index, key); this.scheduleQuantityRefresh(index, key);
} }

View File

@@ -62,6 +62,21 @@ export class UploadFormComponent implements OnInit {
loading = input<boolean>(false); loading = input<boolean>(false);
uploadProgress = input<number>(0); uploadProgress = input<number>(0);
submitRequest = output<QuoteRequest>(); submitRequest = output<QuoteRequest>();
itemQuantityChange = output<{
index: number;
fileName: string;
quantity: number;
}>();
printSettingsChange = output<{
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
}>();
private estimator = inject(QuoteEstimatorService); private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
@@ -81,6 +96,8 @@ export class UploadFormComponent implements OnInit {
// Store full material options to lookup variants/colors if needed later // Store full material options to lookup variants/colors if needed later
private fullMaterialOptions: MaterialOption[] = []; private fullMaterialOptions: MaterialOption[] = [];
private allLayerHeights: SimpleOption[] = [];
private layerHeightsByNozzle: Record<string, SimpleOption[]> = {};
private isPatchingSettings = false; private isPatchingSettings = false;
// Computed variants for valid material // Computed variants for valid material
@@ -141,6 +158,14 @@ export class UploadFormComponent implements OnInit {
if (this.mode() !== 'easy' || this.isPatchingSettings) return; if (this.mode() !== 'easy' || this.isPatchingSettings) return;
this.applyAdvancedPresetFromQuality(quality); this.applyAdvancedPresetFromQuality(quality);
}); });
this.form.get('nozzleDiameter')?.valueChanges.subscribe((nozzle) => {
if (this.isPatchingSettings) return;
this.updateLayerHeightOptionsForNozzle(nozzle, true);
});
this.form.valueChanges.subscribe(() => {
if (this.isPatchingSettings) return;
this.emitPrintSettingsChange();
});
effect(() => { effect(() => {
this.applySettingsLock(this.lockedSettings()); this.applySettingsLock(this.lockedSettings());
@@ -187,6 +212,7 @@ export class UploadFormComponent implements OnInit {
const preset = presets[normalized] || presets['standard']; const preset = presets[normalized] || presets['standard'];
this.form.patchValue(preset, { emitEvent: false }); this.form.patchValue(preset, { emitEvent: false });
this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true);
} }
ngOnInit() { ngOnInit() {
@@ -204,9 +230,19 @@ export class UploadFormComponent implements OnInit {
this.infillPatterns.set( this.infillPatterns.set(
options.infillPatterns.map((p) => ({ label: p.label, value: p.id })), options.infillPatterns.map((p) => ({ label: p.label, value: p.id })),
); );
this.layerHeights.set( this.allLayerHeights = options.layerHeights.map((l) => ({
options.layerHeights.map((l) => ({ label: l.label, value: l.value })), label: l.label,
); value: l.value,
}));
this.layerHeightsByNozzle = {};
(options.layerHeightsByNozzle || []).forEach((entry) => {
this.layerHeightsByNozzle[this.toNozzleKey(entry.nozzleDiameter)] =
entry.layerHeights.map((layer) => ({
label: layer.label,
value: layer.value,
}));
});
this.layerHeights.set(this.allLayerHeights);
this.nozzleDiameters.set( this.nozzleDiameters.set(
options.nozzleDiameters.map((n) => ({ options.nozzleDiameters.map((n) => ({
label: n.label, label: n.label,
@@ -231,6 +267,11 @@ export class UploadFormComponent implements OnInit {
value: 'standard', value: 'standard',
}, },
]); ]);
this.allLayerHeights = [{ label: '0.20 mm', value: 0.2 }];
this.layerHeightsByNozzle = {
[this.toNozzleKey(0.4)]: this.allLayerHeights,
};
this.layerHeights.set(this.allLayerHeights);
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]); this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
this.setDefaults(); this.setDefaults();
}, },
@@ -240,7 +281,16 @@ export class UploadFormComponent implements OnInit {
private setDefaults() { private setDefaults() {
// Set Defaults if available // Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) { if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value); const exactPla = this.materials().find(
(m) => typeof m.value === 'string' && m.value.toUpperCase() === 'PLA',
);
const anyPla = this.materials().find(
(m) =>
typeof m.value === 'string' &&
m.value.toUpperCase().startsWith('PLA'),
);
const preferredMaterial = exactPla ?? anyPla ?? this.materials()[0];
this.form.get('material')?.setValue(preferredMaterial.value);
} }
if (this.qualities().length > 0 && !this.form.get('quality')?.value) { if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first // Try to find 'standard' or use first
@@ -255,18 +305,20 @@ export class UploadFormComponent implements OnInit {
) { ) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4 this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
} }
if (
this.layerHeights().length > 0 && this.updateLayerHeightOptionsForNozzle(
!this.form.get('layerHeight')?.value this.form.get('nozzleDiameter')?.value,
) { true,
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2 );
}
if ( if (
this.infillPatterns().length > 0 && this.infillPatterns().length > 0 &&
!this.form.get('infillPattern')?.value !this.form.get('infillPattern')?.value
) { ) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value); this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
} }
this.emitPrintSettingsChange();
} }
onFilesDropped(newFiles: File[]) { onFilesDropped(newFiles: File[]) {
@@ -369,7 +421,15 @@ export class UploadFormComponent implements OnInit {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const parsed = parseInt(input.value, 10); const parsed = parseInt(input.value, 10);
const quantity = Number.isFinite(parsed) ? parsed : 1; const quantity = Number.isFinite(parsed) ? parsed : 1;
const currentItem = this.items()[index];
if (!currentItem) return;
const normalizedQty = this.normalizeQuantity(quantity);
this.updateItemQuantityByIndex(index, quantity); this.updateItemQuantityByIndex(index, quantity);
this.itemQuantityChange.emit({
index,
fileName: currentItem.file.name,
quantity: normalizedQty,
});
} }
updateItemColor( updateItemColor(
@@ -514,6 +574,11 @@ export class UploadFormComponent implements OnInit {
this.isPatchingSettings = true; this.isPatchingSettings = true;
this.form.patchValue(patch, { emitEvent: false }); this.form.patchValue(patch, { emitEvent: false });
this.isPatchingSettings = false; this.isPatchingSettings = false;
this.updateLayerHeightOptionsForNozzle(
this.form.get('nozzleDiameter')?.value,
true,
);
this.emitPrintSettingsChange();
} }
onSubmit() { onSubmit() {
@@ -561,6 +626,86 @@ export class UploadFormComponent implements OnInit {
return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? ''; return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
} }
private updateLayerHeightOptionsForNozzle(
nozzleValue: unknown,
preserveCurrent: boolean,
): void {
const key = this.toNozzleKey(nozzleValue);
const nozzleSpecific = this.layerHeightsByNozzle[key] || [];
const available =
nozzleSpecific.length > 0 ? nozzleSpecific : this.allLayerHeights;
this.layerHeights.set(available);
const control = this.form.get('layerHeight');
if (!control) return;
const currentValue = Number(control.value);
const currentAllowed = available.some(
(option) => Math.abs(Number(option.value) - currentValue) < 0.0001,
);
if (preserveCurrent && currentAllowed) {
return;
}
const preferred = available.find(
(option) => Math.abs(Number(option.value) - 0.2) < 0.0001,
);
const next = preferred ?? available[0];
if (next) {
control.setValue(next.value, { emitEvent: false });
}
}
private toNozzleKey(value: unknown): string {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '';
return numeric.toFixed(2);
}
getCurrentRequestDraft(): QuoteRequest | null {
if (this.items().length === 0) return null;
const raw = this.form.getRawValue();
return {
items: this.items(),
material: raw.material,
quality: raw.quality,
notes: raw.notes,
infillDensity: raw.infillDensity,
infillPattern: raw.infillPattern,
supportEnabled: raw.supportEnabled,
layerHeight: raw.layerHeight,
nozzleDiameter: raw.nozzleDiameter,
mode: this.mode(),
};
}
getCurrentPrintSettings(): {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
} {
const raw = this.form.getRawValue();
return {
mode: this.mode(),
material: String(raw.material || 'PLA'),
quality: String(raw.quality || 'standard'),
nozzleDiameter: Number(raw.nozzleDiameter ?? 0.4),
layerHeight: Number(raw.layerHeight ?? 0.2),
infillDensity: Number(raw.infillDensity ?? 20),
infillPattern: String(raw.infillPattern || 'grid'),
supportEnabled: Boolean(raw.supportEnabled),
};
}
private emitPrintSettingsChange(): void {
this.printSettingsChange.emit(this.getCurrentPrintSettings());
}
private applySettingsLock(locked: boolean): void { private applySettingsLock(locked: boolean): void {
const controlsToLock = [ const controlsToLock = [
'material', 'material',

View File

@@ -102,12 +102,18 @@ export interface NumericOption {
label: string; label: string;
} }
export interface NozzleLayerHeightsOption {
nozzleDiameter: number;
layerHeights: NumericOption[];
}
export interface OptionsResponse { export interface OptionsResponse {
materials: MaterialOption[]; materials: MaterialOption[];
qualities: QualityOption[]; qualities: QualityOption[];
infillPatterns: InfillOption[]; infillPatterns: InfillOption[];
layerHeights: NumericOption[]; layerHeights: NumericOption[];
nozzleDiameters: NumericOption[]; nozzleDiameters: NumericOption[];
layerHeightsByNozzle?: NozzleLayerHeightsOption[];
} }
// UI Option for Select Component // UI Option for Select Component

View File

@@ -5,5 +5,10 @@ export const CONTACT_ROUTES: Routes = [
path: '', path: '',
loadComponent: () => loadComponent: () =>
import('./contact-page.component').then((m) => m.ContactPageComponent), import('./contact-page.component').then((m) => m.ContactPageComponent),
data: {
seoTitle: 'Contatti | 3D fab',
seoDescription:
'Richiedi informazioni, preventivi personalizzati o supporto per progetti di stampa 3D.',
},
}, },
]; ];

View File

@@ -37,28 +37,40 @@
<div class="cap-cards"> <div class="cap-cards">
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/prototipi.jpg" alt="" /> <img
src="assets/images/home/prototipi.jpg"
[attr.alt]="'HOME.CAP_1_TITLE' | translate"
/>
</div> </div>
<h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3> <h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p> <p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/original-vs-3dprinted.jpg" alt="" /> <img
src="assets/images/home/original-vs-3dprinted.jpg"
[attr.alt]="'HOME.CAP_2_TITLE' | translate"
/>
</div> </div>
<h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3> <h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p> <p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/serie.jpg" alt="" /> <img
src="assets/images/home/serie.jpg"
[attr.alt]="'HOME.CAP_3_TITLE' | translate"
/>
</div> </div>
<h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3> <h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p> <p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/cad.jpg" alt="" /> <img
src="assets/images/home/cad.jpg"
[attr.alt]="'HOME.CAP_4_TITLE' | translate"
/>
</div> </div>
<h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3> <h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p> <p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p>

View File

@@ -5,10 +5,20 @@ export const LEGAL_ROUTES: Routes = [
path: 'privacy', path: 'privacy',
loadComponent: () => loadComponent: () =>
import('./privacy/privacy.component').then((m) => m.PrivacyComponent), import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
data: {
seoTitle: 'Privacy Policy | 3D fab',
seoDescription:
'Informativa privacy di 3D fab: trattamento dati, finalita e contatti.',
},
}, },
{ {
path: 'terms', path: 'terms',
loadComponent: () => loadComponent: () =>
import('./terms/terms.component').then((m) => m.TermsComponent), import('./terms/terms.component').then((m) => m.TermsComponent),
data: {
seoTitle: 'Termini e condizioni | 3D fab',
seoDescription:
'Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi.',
},
}, },
]; ];

View File

@@ -3,6 +3,22 @@ import { ShopPageComponent } from './shop-page.component';
import { ProductDetailComponent } from './product-detail.component'; import { ProductDetailComponent } from './product-detail.component';
export const SHOP_ROUTES: Routes = [ export const SHOP_ROUTES: Routes = [
{ path: '', component: ShopPageComponent }, {
{ path: ':id', component: ProductDetailComponent }, path: '',
component: ShopPageComponent,
data: {
seoTitle: 'Shop 3D fab',
seoDescription:
'Lo shop 3D fab e in allestimento. Intanto puoi usare il calcolatore per ottenere un preventivo.',
seoRobots: 'noindex, nofollow',
},
},
{
path: ':id',
component: ProductDetailComponent,
data: {
seoTitle: 'Prodotto | 3D fab',
seoRobots: 'noindex, nofollow',
},
},
]; ];

View File

@@ -2,8 +2,12 @@
<html lang="it"> <html lang="it">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="robots" content="noindex, nofollow" /> <title>3D fab | Stampa 3D su misura</title>
<title>3D fab</title> <meta
name="description"
content="Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi."
/>
<meta name="robots" content="index, follow" />
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="assets/images/Fav-icon.png" /> <link rel="icon" type="image/png" href="assets/images/Fav-icon.png" />