feat(front-end and back-end): new nozle option, also fix quantity reload and reorganized service in back-end

This commit is contained in:
2026-03-05 15:01:40 +01:00
parent de9e473cca
commit 1a36808d9f
26 changed files with 712 additions and 87 deletions

View File

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

View File

@@ -42,7 +42,7 @@ import { LanguageService } from '../../core/services/language.service';
styleUrl: './calculator-page.component.scss',
})
export class CalculatorPageComponent implements OnInit {
mode = signal<any>('easy');
mode = signal<'easy' | 'advanced'>('easy');
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
loading = signal(false);
@@ -56,6 +56,17 @@ export class CalculatorPageComponent implements OnInit {
);
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('resultCol') resultCol!: ElementRef;
@@ -101,6 +112,10 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(result);
this.baselinePrintSettings = this.toTrackedSettingsFromSession(
data.session,
);
this.requiresRecalculation.set(false);
const isCadSession = data?.session?.status === 'CAD_ACTIVE';
this.cadSessionLocked.set(isCadSession);
this.step.set('quote');
@@ -238,6 +253,8 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(res);
this.baselinePrintSettings = this.toTrackedSettingsFromRequest(req);
this.requiresRecalculation.set(false);
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
@@ -295,9 +312,10 @@ export class CalculatorPageComponent implements OnInit {
index: number;
fileName: string;
quantity: number;
source?: 'left' | 'right';
}) {
// 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.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) {
console.log('Order Submitted:', orderData);
this.orderSuccess.set(true);
@@ -349,15 +394,37 @@ export class CalculatorPageComponent implements OnInit {
onNewQuote() {
this.step.set('upload');
this.result.set(null);
this.requiresRecalculation.set(false);
this.baselinePrintSettings = null;
this.cadSessionLocked.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;
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() {
if (!this.currentRequest) {
const currentFormRequest = this.uploadForm?.getCurrentRequestDraft();
const req = currentFormRequest ?? this.currentRequest;
if (!req) {
this.router.navigate([
'/',
this.languageService.selectedLang(),
@@ -366,7 +433,6 @@ export class CalculatorPageComponent implements OnInit {
return;
}
const req = this.currentRequest;
let details = `Richiesta Preventivo:\n`;
details += `- Materiale: ${req.material}\n`;
details += `- Qualità: ${req.quality}\n`;
@@ -411,5 +477,120 @@ export class CalculatorPageComponent implements OnInit {
this.errorKey.set(key);
this.error.set(true);
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

@@ -62,6 +62,21 @@ export class UploadFormComponent implements OnInit {
loading = input<boolean>(false);
uploadProgress = input<number>(0);
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 fb = inject(FormBuilder);
@@ -81,6 +96,8 @@ export class UploadFormComponent implements OnInit {
// Store full material options to lookup variants/colors if needed later
private fullMaterialOptions: MaterialOption[] = [];
private allLayerHeights: SimpleOption[] = [];
private layerHeightsByNozzle: Record<string, SimpleOption[]> = {};
private isPatchingSettings = false;
// Computed variants for valid material
@@ -141,6 +158,14 @@ export class UploadFormComponent implements OnInit {
if (this.mode() !== 'easy' || this.isPatchingSettings) return;
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(() => {
this.applySettingsLock(this.lockedSettings());
@@ -187,6 +212,7 @@ export class UploadFormComponent implements OnInit {
const preset = presets[normalized] || presets['standard'];
this.form.patchValue(preset, { emitEvent: false });
this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true);
}
ngOnInit() {
@@ -204,9 +230,19 @@ export class UploadFormComponent implements OnInit {
this.infillPatterns.set(
options.infillPatterns.map((p) => ({ label: p.label, value: p.id })),
);
this.layerHeights.set(
options.layerHeights.map((l) => ({ label: l.label, value: l.value })),
);
this.allLayerHeights = options.layerHeights.map((l) => ({
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(
options.nozzleDiameters.map((n) => ({
label: n.label,
@@ -231,6 +267,11 @@ export class UploadFormComponent implements OnInit {
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.setDefaults();
},
@@ -240,7 +281,15 @@ export class UploadFormComponent implements OnInit {
private setDefaults() {
// Set Defaults if available
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) {
// Try to find 'standard' or use first
@@ -255,18 +304,20 @@ export class UploadFormComponent implements OnInit {
) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
}
if (
this.layerHeights().length > 0 &&
!this.form.get('layerHeight')?.value
) {
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
}
this.updateLayerHeightOptionsForNozzle(
this.form.get('nozzleDiameter')?.value,
true,
);
if (
this.infillPatterns().length > 0 &&
!this.form.get('infillPattern')?.value
) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
}
this.emitPrintSettingsChange();
}
onFilesDropped(newFiles: File[]) {
@@ -369,7 +420,15 @@ export class UploadFormComponent implements OnInit {
const input = event.target as HTMLInputElement;
const parsed = parseInt(input.value, 10);
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.itemQuantityChange.emit({
index,
fileName: currentItem.file.name,
quantity: normalizedQty,
});
}
updateItemColor(
@@ -514,6 +573,11 @@ export class UploadFormComponent implements OnInit {
this.isPatchingSettings = true;
this.form.patchValue(patch, { emitEvent: false });
this.isPatchingSettings = false;
this.updateLayerHeightOptionsForNozzle(
this.form.get('nozzleDiameter')?.value,
true,
);
this.emitPrintSettingsChange();
}
onSubmit() {
@@ -561,6 +625,86 @@ export class UploadFormComponent implements OnInit {
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 {
const controlsToLock = [
'material',

View File

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