feat(web) improvments in calculation page
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 29s
Build, Test and Deploy / build-and-push (push) Successful in 37s
Build, Test and Deploy / deploy (push) Successful in 6s

This commit is contained in:
2026-02-05 09:44:54 +01:00
parent da8e476485
commit ab7b95a3d7
6 changed files with 179 additions and 42 deletions

View File

@@ -13,3 +13,7 @@ pricing.machine-cost-per-hour=${MACHINE_COST_PER_HOUR:2.0}
pricing.energy-cost-per-kwh=${ENERGY_COST_PER_KWH:0.30} pricing.energy-cost-per-kwh=${ENERGY_COST_PER_KWH:0.30}
pricing.printer-power-watts=${PRINTER_POWER_WATTS:150.0} pricing.printer-power-watts=${PRINTER_POWER_WATTS:150.0}
pricing.markup-percent=${MARKUP_PERCENT:20.0} pricing.markup-percent=${MARKUP_PERCENT:20.0}
# File Upload Limits
spring.servlet.multipart.max-file-size=200MB
spring.servlet.multipart.max-request-size=200MB

View File

@@ -22,21 +22,16 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
<!-- Left Column: Input --> <!-- Left Column: Input -->
<div class="col-input"> <div class="col-input">
<app-card> <app-card>
<div class="tabs-wrapper"> <div class="mode-selector">
<div class="sub-tabs"> <div class="mode-option"
<span
class="mode-switch"
[class.active]="mode() === 'easy'" [class.active]="mode() === 'easy'"
(click)="mode.set('easy')"> (click)="mode.set('easy')">
{{ 'CALC.MODE_EASY' | translate }} {{ 'CALC.MODE_EASY' | translate }}
</span> </div>
<span class="divider">/</span> <div class="mode-option"
<span
class="mode-switch"
[class.active]="mode() === 'advanced'" [class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')"> (click)="mode.set('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }} {{ 'CALC.MODE_ADVANCED' | translate }}
</span>
</div> </div>
</div> </div>
@@ -54,7 +49,13 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
<app-alert type="error">Si è verificato un errore durante il calcolo del preventivo.</app-alert> <app-alert type="error">Si è verificato un errore durante il calcolo del preventivo.</app-alert>
} }
@if (result()) { @if (loading()) {
<app-card class="loading-state">
<div class="spinner"></div>
<p>Analisi geometria e slicing in corso...</p>
<small class="text-muted">Potrebbe richiedere qualche secondo.</small>
</app-card>
} @else if (result()) {
<app-quote-result [result]="result()!"></app-quote-result> <app-quote-result [result]="result()!"></app-quote-result>
} @else { } @else {
<app-card> <app-card>
@@ -82,21 +83,61 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
} }
} }
.tabs-wrapper { /* Mode Selector (Segmented Control style) */
.mode-selector {
display: flex; display: flex;
justify-content: space-between; background-color: var(--color-neutral-100);
align-items: center; border-radius: var(--radius-md);
padding: 4px;
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
border-bottom: 1px solid var(--color-border); gap: 4px;
padding-bottom: var(--space-2); width: 100%;
} }
.sub-tabs { font-size: 0.875rem; color: var(--color-text-muted); } .mode-option {
.mode-switch { cursor: pointer; &:hover { color: var(--color-text); } } flex: 1;
.mode-switch.active { font-weight: 700; color: var(--color-brand); } text-align: center;
.divider { margin: 0 var(--space-2); } padding: 8px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
transition: all 0.2s ease;
user-select: none;
&:hover { color: var(--color-text); }
&.active {
background-color: var(--color-brand);
color: #000;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
}
.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; } .benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
.loading-state {
text-align: center;
padding: var(--space-8);
color: var(--color-text-muted);
.spinner {
border: 3px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--color-brand);
border-radius: 50%;
width: 32px;
height: 32px;
animation: spin 1s linear infinite;
margin: 0 auto var(--space-4);
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`] `]
}) })
export class CalculatorPageComponent { export class CalculatorPageComponent {

View File

@@ -24,7 +24,7 @@ import { QuoteResult } from '../../services/quote-estimator.service';
</app-summary-card> </app-summary-card>
<app-summary-card [label]="'CALC.TIME' | translate"> <app-summary-card [label]="'CALC.TIME' | translate">
{{ result().printTimeHours }}h {{ result().printTimeHours }}h {{ result().printTimeMinutes }}m
</app-summary-card> </app-summary-card>
<app-summary-card [label]="'CALC.MATERIAL' | translate"> <app-summary-card [label]="'CALC.MATERIAL' | translate">

View File

@@ -101,12 +101,25 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
></app-input> ></app-input>
} }
@if (loading()) {
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<p class="progress-text">Uploading & Analyzing...</p>
</div>
}
<div class="actions"> <div class="actions">
<app-button <app-button
type="submit" type="submit"
[disabled]="form.invalid || loading()" [disabled]="form.invalid || loading()"
[fullWidth]="true"> [fullWidth]="true">
{{ loading() ? '...' : ('CALC.CALCULATE' | translate) }} @if (loading()) {
Slicing in progress...
} @else {
{{ 'CALC.CALCULATE' | translate }}
}
</app-button> </app-button>
</div> </div>
</form> </form>
@@ -172,6 +185,36 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
cursor: pointer; cursor: pointer;
} }
} }
/* Progress Bar */
.progress-container {
margin-top: var(--space-4);
padding: var(--space-4);
background: var(--color-neutral-100);
border-radius: var(--radius-md);
text-align: center;
}
.progress-bar {
height: 6px;
background: var(--color-border);
border-radius: 3px;
overflow: hidden;
margin-bottom: var(--space-2);
position: relative;
}
.progress-fill {
height: 100%;
background: var(--color-brand);
width: 0%;
animation: progress 2s ease-in-out infinite;
}
.progress-text { font-size: 0.875rem; color: var(--color-text-muted); }
@keyframes progress {
0% { width: 0%; transform: translateX(-100%); }
50% { width: 100%; transform: translateX(0); }
100% { width: 100%; transform: translateX(100%); }
}
`] `]
}) })
export class UploadFormComponent { export class UploadFormComponent {
@@ -230,13 +273,27 @@ export class UploadFormComponent {
} }
onFilesDropped(newFiles: File[]) { onFilesDropped(newFiles: File[]) {
this.files.update(current => [...current, ...newFiles]); const MAX_SIZE = 200 * 1024 * 1024; // 200MB
const validFiles: File[] = [];
let hasError = false;
for (const file of newFiles) {
if (file.size > MAX_SIZE) {
hasError = true;
} else {
validFiles.push(file);
}
}
if (hasError) {
alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti.");
}
if (validFiles.length > 0) {
this.files.update(current => [...current, ...validFiles]);
this.form.patchValue({ files: this.files() }); this.form.patchValue({ files: this.files() });
this.form.get('files')?.markAsTouched(); this.form.get('files')?.markAsTouched();
this.selectedFile.set(validFiles[validFiles.length - 1]);
// Select the last added file by default if none selected
if (newFiles.length > 0) {
this.selectedFile.set(newFiles[newFiles.length - 1]);
} }
} }

View File

@@ -21,6 +21,7 @@ export interface QuoteResult {
price: number; price: number;
currency: string; currency: string;
printTimeHours: number; printTimeHours: number;
printTimeMinutes: number;
materialUsageGrams: number; materialUsageGrams: number;
setupCost: number; setupCost: number;
} }
@@ -104,10 +105,14 @@ export class QuoteEstimatorService {
// Total time usually parallel if we have multiple printers, but let's sum for now // Total time usually parallel if we have multiple printers, but let's sum for now
totalTime = totalTime * request.quantity; totalTime = totalTime * request.quantity;
const totalHours = Math.floor(totalTime / 3600);
const totalMinutes = Math.ceil((totalTime % 3600) / 60);
return { return {
price: Math.round(totalPrice * 100) / 100, price: Math.round(totalPrice * 100) / 100, // Keep 2 decimals
currency: 'CHF', currency: 'CHF',
printTimeHours: Math.ceil(totalTime / 3600), // Ceil hours printTimeHours: totalHours,
printTimeMinutes: totalMinutes,
materialUsageGrams: Math.ceil(totalWeight), materialUsageGrams: Math.ceil(totalWeight),
setupCost setupCost
}; };

View File

@@ -18,6 +18,11 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
<span>Loading 3D Model...</span> <span>Loading 3D Model...</span>
</div> </div>
} }
@if (file && !loading) {
<div class="dims-overlay">
{{ dimensions.x }} x {{ dimensions.y }} x {{ dimensions.z }} mm
</div>
}
</div> </div>
`, `,
styles: [` styles: [`
@@ -53,6 +58,18 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
.dims-overlay {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0,0,0,0.6);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-family: monospace;
pointer-events: none;
}
`] `]
}) })
export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
@@ -125,6 +142,8 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
resizeObserver.observe(this.rendererContainer.nativeElement); resizeObserver.observe(this.rendererContainer.nativeElement);
} }
dimensions = { x: 0, y: 0, z: 0 };
private loadFile(file: File) { private loadFile(file: File) {
this.loading = true; this.loading = true;
const reader = new FileReader(); const reader = new FileReader();
@@ -150,22 +169,33 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
geometry.computeBoundingBox(); geometry.computeBoundingBox();
geometry.center(); geometry.center();
// Get Dimensions
const boundingBox = geometry.boundingBox!;
const size = new THREE.Vector3();
boundingBox.getSize(size);
this.dimensions = {
x: Math.round(size.x * 10) / 10,
y: Math.round(size.y * 10) / 10,
z: Math.round(size.z * 10) / 10
};
// Rotate to stand upright (usually necessary for STLs) // Rotate to stand upright (usually necessary for STLs)
this.currentMesh.rotation.x = -Math.PI / 2; this.currentMesh.rotation.x = -Math.PI / 2;
this.scene.add(this.currentMesh); this.scene.add(this.currentMesh);
// Adjust camera to fit object // Adjust camera to fit object
const boundingBox = geometry.boundingBox!;
const size = new THREE.Vector3();
boundingBox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z); const maxDim = Math.max(size.x, size.y, size.z);
const fov = this.camera.fov * (Math.PI / 180); const fov = this.camera.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 * Math.tan(fov * 2)); // Basic fit
cameraZ *= 2.5; // Zoom out a bit // Calculate distance towards camera (z-axis)
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
cameraZ *= 1.5; // Tighter zoom (reduced from 2.5)
this.camera.position.z = cameraZ; this.camera.position.z = cameraZ;
this.camera.updateProjectionMatrix(); this.camera.updateProjectionMatrix();
this.controls.update();
} catch (err) { } catch (err) {
console.error('Error loading STL:', err); console.error('Error loading STL:', err);