dev #8
@@ -3,6 +3,7 @@ package com.printcalculator.controller;
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.entity.QuoteLineItem;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.model.ModelDimensions;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import com.printcalculator.model.QuoteResult;
|
||||
import com.printcalculator.repository.PrinterMachineRepository;
|
||||
@@ -28,6 +29,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.Optional;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
|
||||
@@ -183,6 +185,8 @@ public class QuoteSessionController {
|
||||
null, // machine overrides
|
||||
processOverrides
|
||||
);
|
||||
|
||||
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(persistentPath.toFile());
|
||||
|
||||
// 4. Calculate Quote
|
||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
|
||||
@@ -206,14 +210,16 @@ public class QuoteSessionController {
|
||||
breakdown.put("setup_fee", 0);
|
||||
item.setPricingBreakdown(breakdown);
|
||||
|
||||
// Dimensions
|
||||
// Cannot get bb from GCodeParser yet?
|
||||
// If GCodeParser doesn't return size, we might defaults or 0.
|
||||
// Stats has filament used.
|
||||
// Let's set dummy for now or upgrade parser later.
|
||||
item.setBoundingBoxXMm(settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO);
|
||||
item.setBoundingBoxYMm(settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO);
|
||||
item.setBoundingBoxZMm(settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO);
|
||||
// Dimensions for shipping/package checks are computed server-side from the uploaded model.
|
||||
item.setBoundingBoxXMm(modelDimensions
|
||||
.map(dim -> BigDecimal.valueOf(dim.xMm()))
|
||||
.orElseGet(() -> settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO));
|
||||
item.setBoundingBoxYMm(modelDimensions
|
||||
.map(dim -> BigDecimal.valueOf(dim.yMm()))
|
||||
.orElseGet(() -> settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO));
|
||||
item.setBoundingBoxZMm(modelDimensions
|
||||
.map(dim -> BigDecimal.valueOf(dim.zMm()))
|
||||
.orElseGet(() -> settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO));
|
||||
|
||||
item.setCreatedAt(OffsetDateTime.now());
|
||||
item.setUpdatedAt(OffsetDateTime.now());
|
||||
@@ -371,7 +377,16 @@ public class QuoteSessionController {
|
||||
break;
|
||||
}
|
||||
}
|
||||
BigDecimal shippingCostChf = exceedsBaseSize ? BigDecimal.valueOf(4.00) : BigDecimal.valueOf(2.00);
|
||||
int totalQuantity = items.stream()
|
||||
.mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1)
|
||||
.sum();
|
||||
|
||||
BigDecimal shippingCostChf;
|
||||
if (exceedsBaseSize) {
|
||||
shippingCostChf = totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00);
|
||||
} else {
|
||||
shippingCostChf = BigDecimal.valueOf(2.00);
|
||||
}
|
||||
|
||||
BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCostChf);
|
||||
|
||||
|
||||
@@ -176,7 +176,15 @@ public class OrderService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
order.setShippingCostChf(exceedsBaseSize ? BigDecimal.valueOf(4.00) : BigDecimal.valueOf(2.00));
|
||||
int totalQuantity = quoteItems.stream()
|
||||
.mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1)
|
||||
.sum();
|
||||
|
||||
if (exceedsBaseSize) {
|
||||
order.setShippingCostChf(totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00));
|
||||
} else {
|
||||
order.setShippingCostChf(BigDecimal.valueOf(2.00));
|
||||
}
|
||||
|
||||
order = orderRepo.save(order);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.printcalculator.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.printcalculator.model.ModelDimensions;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -15,13 +16,19 @@ import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class SlicerService {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(SlicerService.class.getName());
|
||||
private static final Pattern SIZE_X_PATTERN = Pattern.compile("(?m)^\\s*size_x\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
|
||||
private static final Pattern SIZE_Y_PATTERN = Pattern.compile("(?m)^\\s*size_y\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
|
||||
private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
|
||||
|
||||
private final String slicerPath;
|
||||
private final ProfileManager profileManager;
|
||||
@@ -149,6 +156,89 @@ public class SlicerService {
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<ModelDimensions> inspectModelDimensions(File inputModel) {
|
||||
Path tempDir = null;
|
||||
try {
|
||||
tempDir = Files.createTempDirectory("slicer_info_");
|
||||
Path infoLogPath = tempDir.resolve("orcaslicer-info.log");
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(slicerPath);
|
||||
command.add("--info");
|
||||
command.add(inputModel.getAbsolutePath());
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(tempDir.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
pb.redirectOutput(infoLogPath.toFile());
|
||||
|
||||
Process process = pb.start();
|
||||
boolean finished = process.waitFor(2, TimeUnit.MINUTES);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
logger.warning("Model info extraction timed out for " + inputModel.getName());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String output = Files.exists(infoLogPath)
|
||||
? Files.readString(infoLogPath, StandardCharsets.UTF_8)
|
||||
: "";
|
||||
|
||||
if (process.exitValue() != 0) {
|
||||
logger.warning("OrcaSlicer --info failed (exit " + process.exitValue() + ") for "
|
||||
+ inputModel.getName() + ": " + output);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<ModelDimensions> parsed = parseModelDimensionsFromInfoOutput(output);
|
||||
if (parsed.isEmpty()) {
|
||||
logger.warning("Could not parse size_x/size_y/size_z from OrcaSlicer --info output for "
|
||||
+ inputModel.getName() + ": " + output);
|
||||
}
|
||||
return parsed;
|
||||
} catch (Exception e) {
|
||||
logger.warning("Failed to inspect model dimensions for " + inputModel.getName() + ": " + e.getMessage());
|
||||
return Optional.empty();
|
||||
} finally {
|
||||
if (tempDir != null) {
|
||||
deleteRecursively(tempDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Optional<ModelDimensions> parseModelDimensionsFromInfoOutput(String output) {
|
||||
if (output == null || output.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Double x = extractDouble(SIZE_X_PATTERN, output);
|
||||
Double y = extractDouble(SIZE_Y_PATTERN, output);
|
||||
Double z = extractDouble(SIZE_Z_PATTERN, output);
|
||||
|
||||
if (x == null || y == null || z == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (x <= 0 || y <= 0 || z <= 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(new ModelDimensions(x, y, z));
|
||||
}
|
||||
|
||||
private static Double extractDouble(Pattern pattern, String text) {
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
if (!matcher.find()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Double.parseDouble(matcher.group(1));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteRecursively(Path path) {
|
||||
if (path == null || !Files.exists(path)) {
|
||||
return;
|
||||
|
||||
@@ -10,15 +10,11 @@ import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl
|
||||
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
|
||||
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
|
||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||
import * as THREE from 'three';
|
||||
// @ts-ignore
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||
|
||||
interface FormItem {
|
||||
file: File;
|
||||
quantity: number;
|
||||
color: string;
|
||||
dimensions?: {x: number, y: number, z: number};
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -74,35 +70,6 @@ export class UploadFormComponent implements OnInit {
|
||||
return name.endsWith('.stl');
|
||||
}
|
||||
|
||||
private async getStlDimensions(file: File): Promise<{x: number, y: number, z: number} | null> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const loader = new STLLoader();
|
||||
const geometry = loader.parse(e.target?.result as ArrayBuffer);
|
||||
geometry.computeBoundingBox();
|
||||
if (geometry.boundingBox) {
|
||||
const size = new THREE.Vector3();
|
||||
geometry.boundingBox.getSize(size);
|
||||
resolve({
|
||||
x: Math.round(size.x * 10) / 10,
|
||||
y: Math.round(size.y * 10) / 10,
|
||||
z: Math.round(size.z * 10) / 10
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve(null);
|
||||
} catch (err) {
|
||||
console.error("Error parsing STL for dimensions:", err);
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.form = this.fb.group({
|
||||
itemsTouched: [false], // Hack to track touched state for custom items list
|
||||
@@ -189,7 +156,7 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
async onFilesDropped(newFiles: File[]) {
|
||||
onFilesDropped(newFiles: File[]) {
|
||||
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
||||
const validItems: FormItem[] = [];
|
||||
let hasError = false;
|
||||
@@ -198,13 +165,8 @@ export class UploadFormComponent implements OnInit {
|
||||
if (file.size > MAX_SIZE) {
|
||||
hasError = true;
|
||||
} else {
|
||||
let dimensions = undefined;
|
||||
if (file.name.toLowerCase().endsWith('.stl')) {
|
||||
const dims = await this.getStlDimensions(file);
|
||||
if (dims) dimensions = dims;
|
||||
}
|
||||
// Default color is Black
|
||||
validItems.push({ file, quantity: 1, color: 'Black', dimensions });
|
||||
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,16 +258,11 @@ export class UploadFormComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
async setFiles(files: File[]) {
|
||||
setFiles(files: File[]) {
|
||||
const validItems: FormItem[] = [];
|
||||
for (const file of files) {
|
||||
let dimensions = undefined;
|
||||
if (file.name.toLowerCase().endsWith('.stl')) {
|
||||
const dims = await this.getStlDimensions(file);
|
||||
if (dims) dimensions = dims;
|
||||
}
|
||||
// Default color is Black or derive from somewhere if possible, but here we just init
|
||||
validItems.push({ file, quantity: 1, color: 'Black', dimensions });
|
||||
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||
}
|
||||
|
||||
if (validItems.length > 0) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { map, catchError, tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
export interface QuoteRequest {
|
||||
items: { file: File, quantity: number, color?: string, dimensions?: {x: number, y: number, z: number} }[];
|
||||
items: { file: File, quantity: number, color?: string }[];
|
||||
material: string;
|
||||
quality: string;
|
||||
notes?: string;
|
||||
@@ -273,9 +273,6 @@ export class QuoteEstimatorService {
|
||||
quality: easyPreset ? easyPreset.quality : request.quality,
|
||||
supportsEnabled: request.supportEnabled,
|
||||
color: item.color || '#FFFFFF',
|
||||
boundingBoxX: item.dimensions?.x,
|
||||
boundingBoxY: item.dimensions?.y,
|
||||
boundingBoxZ: item.dimensions?.z,
|
||||
layerHeight: easyPreset ? easyPreset.layerHeight : request.layerHeight,
|
||||
infillDensity: easyPreset ? easyPreset.infillDensity : request.infillDensity,
|
||||
infillPattern: easyPreset ? easyPreset.infillPattern : request.infillPattern,
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
|
||||
</div>
|
||||
<div formGroupName="billingAddress">
|
||||
<!-- User Type Selector -->
|
||||
<app-toggle-selector class="mb-4 user-type-selector-compact"
|
||||
[options]="userTypeOptions"
|
||||
[selectedValue]="checkoutForm.get('customerType')?.value"
|
||||
(selectionChange)="setCustomerType($event)">
|
||||
</app-toggle-selector>
|
||||
|
||||
<!-- Private Person Fields -->
|
||||
<div *ngIf="!isCompany" class="form-row">
|
||||
@@ -44,13 +50,6 @@
|
||||
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
|
||||
<app-input formControlName="referencePerson" [label]="'CONTACT.REF_PERSON' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
||||
</div>
|
||||
|
||||
<!-- User Type Selector -->
|
||||
<app-toggle-selector
|
||||
[options]="userTypeOptions"
|
||||
[selectedValue]="checkoutForm.get('customerType')?.value"
|
||||
(selectionChange)="setCustomerType($event)">
|
||||
</app-toggle-selector>
|
||||
|
||||
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
|
||||
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
|
||||
|
||||
@@ -65,6 +65,11 @@
|
||||
}
|
||||
|
||||
/* User Type Selector CSS has been extracted to app-toggle-selector component */
|
||||
app-toggle-selector.user-type-selector-compact {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.company-fields {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user