diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..186000a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,741 @@ +# Copilot Instructions for sort-it-now + +## Project Overview + +**Sort-it-now** is a 3D packing optimization service in Rust with a web frontend. It solves the bin-packing problem: efficiently packing cuboids into containers considering weight, stability, and center of mass. + +## Architecture + +``` +src/ +├── main.rs # Tokio runtime & server start, loads .env via dotenvy +├── config.rs # Environment variables → AppConfig/ApiConfig/OptimizerConfig/UpdateConfig +├── types.rs # Core types: Vec3, BoundingBox, Traits (Dimensional, Positioned, Weighted) +├── model.rs # Data structures: Box3D, PlacedBox, Container, ContainerBlueprint +├── geometry.rs # AABB collision (intersects), overlap (overlap_1d), point_inside +├── optimizer.rs # Packing algorithm with PackingConfig (1700+ lines, incl. tests) +├── api.rs # Axum REST API: /pack, /pack_stream (SSE), /docs (Swagger UI) +└── update.rs # Auto-update via GitHub Releases (platform-specific) +web/ +├── index.html # Frontend entry point +└── script.js # Three.js 3D visualization with OrbitControls +``` + +## Developer Workflow + +```bash +# Start server (port 8080) +cargo run + +# Run tests (42 tests across all modules) +cargo test + +# Formatting & linting (CI check - must pass before PR!) +cargo fmt --all -- --check +cargo clippy --workspace --all-targets +``` + +--- + +## Core Types (`types.rs`) + +The `types.rs` module provides reusable types and trait abstractions following OOP and DRY principles. + +### Vec3 - 3D Vector Type + +```rust +/// Represents a 3D vector or point in space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Vec3 { + pub x: f64, // Width + pub y: f64, // Depth + pub z: f64, // Height +} + +// Operator overloading for intuitive math +let center = position + dimensions * 0.5; + +// Key methods +vec.volume() // x * y * z +vec.base_area() // x * y +vec.distance_to(other) // 3D Euclidean distance +vec.distance_2d(other) // XY plane distance +vec.fits_within(container, tolerance) +vec.is_valid_dimension() +``` + +### BoundingBox - AABB Collision + +```rust +/// Axis-Aligned Bounding Box for efficient collision detection. +pub struct BoundingBox { + pub min: Vec3, // Lower corner + pub max: Vec3, // Upper corner +} + +// Key methods +BoundingBox::from_position_and_dims(position, dims) +bbox.intersects(other) // SAT collision test +bbox.overlap_area_xy(other) // XY overlap for support +bbox.contains_point(point) // Point-in-box test +bbox.center() // Center point +bbox.top_z() // Maximum Z value +``` + +### Trait Abstractions (OOP Compliance) + +```rust +/// Objects with 3D dimensions +pub trait Dimensional { + fn dimensions(&self) -> Vec3; + fn volume(&self) -> f64 { self.dimensions().volume() } + fn base_area(&self) -> f64 { self.dimensions().base_area() } + fn fits_in(&self, container_dims: &Vec3, tolerance: f64) -> bool; +} + +/// Objects with a position in 3D space +pub trait Positioned { + fn position(&self) -> Vec3; +} + +/// Objects with weight +pub trait Weighted { + fn weight(&self) -> f64; +} +``` + +### CenterOfMassCalculator + +```rust +/// Accumulator for weighted center of mass calculation +let mut calc = CenterOfMassCalculator::new(); +calc.add_point(x, y, weight); +let (cx, cy) = calc.compute().unwrap(); +let offset = calc.distance_to((ref_x, ref_y)); +``` + +### Epsilon Constants + +```rust +pub const EPSILON_GENERAL: f64 = 1e-6; // Dimension/weight comparisons +pub const EPSILON_HEIGHT: f64 = 1e-3; // Height matching for stacking +``` + +--- + +## Data Model (`model.rs`) + +All structures implement traits from `types.rs` for OOP compliance. + +### Box3D + +```rust +pub struct Box3D { + pub id: u32, + pub width: f64, + pub depth: f64, + pub height: f64, + pub weight: f64, +} + +// Implements: Dimensional, Weighted +Box3D::new(id, (w, d, h), weight)? // Returns Result +``` + +### PlacedBox + +```rust +pub struct PlacedBox { + pub id: u32, + pub x: f64, pub y: f64, pub z: f64, // Position + pub width: f64, pub depth: f64, pub height: f64, + pub weight: f64, +} + +// Implements: Positioned, Dimensional, Weighted +PlacedBox::from_box3d(box3d, x, y, z) +placed.bounding_box() // Returns BoundingBox +placed.center_xy() // (center_x, center_y) +placed.top_z() // z + height +``` + +### Container + +```rust +pub struct Container { + pub id: u32, + pub dims: (f64, f64, f64), + pub max_weight: f64, + pub total_weight: f64, + pub placed: Vec, + pub label: Option, + pub template_id: Option, +} + +// Implements: Dimensional +Container::new(id, dims, max_weight) +container.remaining_weight_capacity() +container.volume_utilization() +``` + +### ContainerBlueprint + +```rust +pub struct ContainerBlueprint { + pub id: u32, + pub name: Option, + pub dims: (f64, f64, f64), + pub max_weight: f64, +} + +ContainerBlueprint::new(id, name, dims, max_weight)? +blueprint.create_container(container_id) +blueprint.fits_box(box3d, epsilon) +``` + +--- + +## Packing Algorithm (Detailed Core Logic) + +### Main Functions in `optimizer.rs` + +| Function | Purpose | +| ------------------------------ | ----------------------------------------------------- | +| `pack_objects()` | Entry point with default config | +| `pack_objects_with_config()` | With customizable `PackingConfig` | +| `pack_objects_with_progress()` | With callback for live events (SSE) | +| `find_stable_position()` | Finds optimal position via grid search + Z-levels | +| `supports_weight_correctly()` | Checks weight hierarchy (heavy BELOW light) | +| `has_sufficient_support()` | Checks minimum support via `support_ratio_of()` | +| `is_center_supported()` | Prevents overhangs (center of mass must be supported) | +| `calculate_balance_after()` | Calculates center of mass deviation | + +### Algorithm Flow + +1. **Sorting**: Objects by `weight * volume` descending (heavy/large first) +2. **Clustering**: `FootprintClusterStrategy` groups objects with similar base area +3. **Orientations**: With `allow_item_rotation=true` → 6 permutations (deduplicated) +4. **Position Search**: + - Iterate Z-levels (floor + tops of all placed objects) + - Grid on X/Y axis with `grid_step` + - Evaluate each position by `PlacementScore { z, y, x, balance }` +5. **Stability Checks** (all must pass): + - No collision (`intersects()`) + - Minimum support (`support_ratio >= 60%`) + - Weight hierarchy (no heavy on light object) + - Center of mass supported (`is_center_supported()`) + - Balance within `balance_limit_ratio` +6. **Multi-Container**: If no space → new container from template pool + +### Configuration via Builder Pattern + +```rust +PackingConfig::builder() + .grid_step(2.5) // Finer grid (slower) + .support_ratio(0.7) // 70% minimum support + .height_epsilon(1e-3) // Tolerance for height comparisons + .general_epsilon(1e-6) // General float tolerance + .balance_limit_ratio(0.45) // Max center of mass deviation + .footprint_cluster_tolerance(0.15) // Clustering tolerance + .allow_item_rotation(true) // 90° rotations + .build() +``` + +### Diagnostic Structures + +```rust +// Per container +ContainerDiagnostics { + center_of_mass_offset: f64, // Center of mass distance from center + balance_limit: f64, // Allowed deviation + imbalance_ratio: f64, // offset / limit + average_support_percent: f64, // Average support + minimum_support_percent: f64, // Worst support + support_samples: Vec, +} + +// Aggregated +PackingDiagnosticsSummary { + max_imbalance_ratio: f64, + worst_support_percent: f64, + average_support_percent: f64, +} +``` + +### Unplaceable Objects + +```rust +enum UnplacedReason { + TooHeavyForContainer, // Exceeds max_weight of all templates + DimensionsExceedContainer, // Doesn't fit in any orientation + NoStablePosition, // No stable position found +} +``` + +--- + +## Error Handling Patterns + +### ValidationError in `model.rs` + +All constructors validate inputs and return `Result`: + +```rust +pub enum ValidationError { + InvalidDimension(String), // Non-positive, NaN, or Infinite + InvalidWeight(String), // Non-positive, NaN, or Infinite + InvalidConfiguration(String), // Reserved for config errors +} + +// Example: Box3D::new() checks +Box3D::new(id, (w, d, h), weight)? // Error on w <= 0, NaN, Inf + +// ContainerBlueprint checks analogously +ContainerBlueprint::new(id, name, dims, max_weight)? +``` + +### Validation Functions in `types.rs` + +```rust +use crate::types::validation::{validate_dimension, validate_weight, validate_dimensions_3d}; + +validate_dimension(value, "Width")?; +validate_weight(value)?; +validate_dimensions_3d((w, d, h))?; +``` + +### API Validation in `api.rs` + +```rust +enum PackRequestValidationError { + MissingContainers, // Empty container list + InvalidContainer(ValidationError), + InvalidObject(ValidationError), +} + +// Conversion to HTTP response +impl IntoResponse for PackRequestValidationError { ... } +``` + +--- + +## Frontend Integration (`script.js`) + +### SSE Events for Live Visualization + +```javascript +// EventSource for /pack_stream +const es = new EventSource('/pack_stream', { method: 'POST', body: ... }); + +es.onmessage = (event) => { + const data = JSON.parse(event.data); + switch (data.type) { + case 'ContainerStarted': + // { id, dims, max_weight, label, template_id } + break; + case 'ObjectPlaced': + // { container_id, id, pos, weight, dims, total_weight } + break; + case 'ContainerDiagnostics': + // { container_id, diagnostics } + break; + case 'ObjectRejected': + // { id, weight, dims, reason_code, reason_text } + break; + case 'Finished': + // { containers, unplaced, diagnostics_summary } + es.close(); + break; + } +}; +``` + +### Epsilon Constants (Backend-compatible) + +```javascript +const EPSILON_COMPARISON = 1e-6; // Dimension comparisons +const EPSILON_DEDUPLICATION = 1e-6; // Exact equality + +// Usage for rotation deduplication +function dimsAlmostEqual(a, b, epsilon = EPSILON_DEDUPLICATION) { + return Math.abs(a[0] - b[0]) <= epsilon && ...; +} +``` + +### Three.js Setup + +```javascript +import * as THREE from 'https://esm.sh/three@0.163.0'; +import { OrbitControls } from 'https://esm.sh/three@0.163.0/examples/jsm/controls/OrbitControls.js'; + +// Core functions +clearScene(); // Removes all Meshes/LineSegments +drawContainerFrame(); // Wireframe + Grid +drawBox(); // Object mesh with color + opacity +visualizeContainer(); // Complete container display +animateContainer(); // Step-by-step animation +updateStats(); // Statistics panel with diagnostics +``` + +--- + +## Auto-Update Mechanism (`update.rs`) + +### Flow + +1. **Start**: `check_for_updates_background()` spawns Tokio task +2. **GitHub API**: Calls `/repos/{owner}/{repo}/releases/latest` +3. **Version comparison**: `semver::Version` → Update only if `latest > current` +4. **Download**: Platform-specific asset (tar.gz/zip) +5. **Verification**: SHA-256 checksum from `.sha256` file +6. **Installation**: Platform-specific logic: + - Linux/macOS: Run `install-unix.sh` + - Windows: Replace binary (or `.new.exe` if locked) + +### Configuration + +| Variable | Default | Description | +| ------------------- | ------- | -------------------------------- | +| `SKIP_UPDATE_CHECK` | - | Completely disable update | +| `GITHUB_TOKEN` | - | For higher rate limits | +| `MAX_DOWNLOAD_MB` | 200 | Asset size limit (0 = unlimited) | +| `HTTP_TIMEOUT_SECS` | 30 | Timeout for GitHub requests | + +### Rate Limiting + +```rust +// Automatic detection + hint +if is_rate_limit_response(&headers) { + println!("⏱️ GitHub rate limit reached..."); + if token.is_none() { + println!("💡 Tip: Set GITHUB_TOKEN..."); + } +} +``` + +--- + +## API Endpoints + +| Method | Path | Description | +| ------ | -------------------- | ------------------------------ | +| `POST` | `/pack` | Batch packing → `PackResponse` | +| `POST` | `/pack_stream` | SSE stream with `PackEvent`s | +| `GET` | `/docs` | Swagger UI (SRI-protected) | +| `GET` | `/docs/openapi.json` | OpenAPI 3 schema | + +### Request Format + +```json +{ + "containers": [ + { "name": "Standard", "dims": [100.0, 100.0, 70.0], "max_weight": 500.0 } + ], + "objects": [{ "id": 1, "dims": [30.0, 30.0, 10.0], "weight": 50.0 }], + "allow_rotations": true +} +``` + +--- + +## Important Conventions + +### Rust-specific + +- **Trait-Based Design**: Use `Dimensional`, `Positioned`, `Weighted` traits for polymorphism +- **DRY Principle**: Use types from `types.rs` (Vec3, BoundingBox, CenterOfMassCalculator) +- **Docstrings**: Document all public functions/structs in English +- **Validation**: Always `Result` for constructors +- **Builder Pattern**: `PackingConfig::builder()` for configuration +- **Epsilon Constants**: Use `EPSILON_GENERAL` (1e-6) and `EPSILON_HEIGHT` (1e-3) from `types.rs` +- **Platform Compilation**: `#[cfg(target_os = "...")]` for OS-specific code + +### Code Organization + +- `types.rs` - Core types and traits (foundation layer) +- `model.rs` - Domain objects implementing traits +- `geometry.rs` - Spatial algorithms using BoundingBox +- `optimizer.rs` - Business logic (packing algorithm) +- `api.rs` - HTTP interface +- `config.rs` - Configuration management +- `update.rs` - Self-update mechanism + +### Tests + +- Tests in `#[cfg(test)]` modules at the end of each file +- **42 tests** across all modules: + - `types.rs`: Vec3 operations, BoundingBox, validation, CenterOfMassCalculator + - `geometry.rs`: Intersection, overlap, distance calculations + - `model.rs`: Validation errors, trait implementations + - `optimizer.rs`: Packing algorithm (20+ tests) + - `api.rs`: Request parsing, OpenAPI validation + - `config.rs`: Boolean parsing, config defaults + +### Test Categories in `optimizer.rs` + +| Test | Checks | +| ---------------------------------------------------- | ------------------------------------------- | +| `heavy_boxes_stay_below_lighter` | Vertical weight sorting | +| `single_box_snaps_to_corner` | Placement at (0,0,0) | +| `creates_additional_containers_when_weight_exceeded` | Multi-container logic | +| `reports_objects_too_large_for_container` | `UnplacedReason::DimensionsExceedContainer` | +| `reports_objects_too_heavy_for_container` | `UnplacedReason::TooHeavyForContainer` | +| `reject_heavier_on_light_support` | Stability: Heavy on light forbidden | +| `rotation_toggle_controls_reorientation` | `allow_item_rotation` effect | +| `orientation_deduplication_handles_equal_dimensions` | Cube → 1, cuboid → 3-6 orientations | +| `footprint_cluster_groups_similar_dimensions` | Clustering strategy | +| `diagnostics_capture_support_and_balance_metrics` | Diagnostic values correct | +| `progress_emits_diagnostics_events` | SSE events are emitted | + +### Running Tests + +```bash +# All tests (42 total) +cargo test + +# Single test with output +cargo test heavy_boxes_stay_below_lighter -- --nocapture + +# Tests with pattern +cargo test rotation + +# Tests by module +cargo test types:: +cargo test geometry:: +cargo test optimizer:: +``` + +### Frontend + +- ESM imports from `esm.sh` for Three.js +- `config` object for containers/objects/rotations +- Validation via `collectConfigIssues()` + `ensureConfigValidOrNotify()` + +--- + +## Geometry Functions (`geometry.rs`) + +### Using BoundingBox from types.rs + +```rust +use crate::types::BoundingBox; + +// Convert PlacedBox to BoundingBox for calculations +let bbox = placed_box.bounding_box(); +let intersects = bbox.intersects(&other.bounding_box()); +let overlap = bbox.overlap_area_xy(&support.bounding_box()); +``` + +### AABB Collision Detection + +```rust +/// Separating Axis Theorem: Objects do NOT intersect +/// if they are completely separated on at least one axis. +pub fn intersects(a: &PlacedBox, b: &PlacedBox) -> bool { + a.bounding_box().intersects(&b.bounding_box()) +} +``` + +### Overlap Calculation + +```rust +/// Calculates 1D overlap of two intervals +/// Example: overlap_1d(0.0, 5.0, 3.0, 8.0) → 2.0 +pub fn overlap_1d(a1: f64, a2: f64, b1: f64, b2: f64) -> f64 { + (a2.min(b2) - a1.max(b1)).max(0.0) +} + +/// Overlap area in XY plane (for support calculation) +pub fn overlap_area_xy(a: &PlacedBox, b: &PlacedBox) -> f64 { + a.bounding_box().overlap_area_xy(&b.bounding_box()) +} +``` + +### Point-in-Box Test + +```rust +/// Checks if center of mass projection is carried by supporting box +pub fn point_inside(point: (f64, f64, f64), placed_box: &PlacedBox) -> bool { + placed_box.bounding_box().contains_point(&Vec3::from_tuple(point)) +} +``` + +### Distance Functions + +```rust +/// 2D distance in XY plane (using Vec3::distance_2d) +pub fn distance_2d(a: (f64, f64), b: (f64, f64)) -> f64 +``` + +--- + +## Test Patterns + +### Test Structure + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // Helper: Create single container template + fn single_blueprint(dims: (f64, f64, f64), max_weight: f64) -> Vec { + vec![ContainerBlueprint::new(0, None, dims, max_weight).unwrap()] + } + + // Helper: Check weight hierarchy across all layers + fn assert_heavy_below(cont: &Container, config: &PackingConfig) { + for lower in &cont.placed { + for upper in &cont.placed { + // Checks: Object directly above must be lighter + if overlap_exists && upper_above_lower { + assert!(lower.weight >= upper.weight); + } + } + } + } +} +``` + +### Types Module Tests + +```rust +#[test] +fn test_vec3_operations() { + let a = Vec3::new(1.0, 2.0, 3.0); + let b = Vec3::new(4.0, 5.0, 6.0); + assert_eq!(a + b, Vec3::new(5.0, 7.0, 9.0)); +} + +#[test] +fn test_bounding_box_intersects() { + let a = BoundingBox::from_position_and_dims(Vec3::zero(), Vec3::new(10.0, 10.0, 10.0)); + let b = BoundingBox::from_position_and_dims(Vec3::new(5.0, 5.0, 5.0), Vec3::new(10.0, 10.0, 10.0)); + assert!(a.intersects(&b)); +} +``` + +--- + +## Performance Notes + +### Algorithm Complexity + +| Factor | Impact | +| --------------------- | ------------------------------------------------ | +| `grid_step` | Smaller → more positions → slower, more accurate | +| Object count (n) | O(n × p × z) where p = positions, z = Z-levels | +| `allow_item_rotation` | 6× more orientations → 6× more checks | +| Container templates | Each template is checked for new containers | + +### Recommended Settings + +```rust +// Fast (prototyping) +PackingConfig::builder() + .grid_step(10.0) + .support_ratio(0.5) + .build() + +// Precise (production) +PackingConfig::builder() + .grid_step(2.5) + .support_ratio(0.7) + .allow_item_rotation(true) + .build() +``` + +### Memory + +- O(n) for n objects +- `PlacedBox` contains clone of `Box3D` → Moderate overhead +- SSE streaming reduces peak memory for large requests + +### Type System Benefits + +- `Vec3` operations are `#[inline]` for zero-cost abstraction +- `BoundingBox` calculations avoid redundant recomputation +- Trait-based polymorphism enables compiler optimizations + +--- + +## Environment Variables + +All with prefix `SORT_IT_NOW_`: + +| Variable | Default | Description | +| ------------------------- | --------- | ---------------------- | +| `API_HOST` | `0.0.0.0` | Server bind IP | +| `API_PORT` | `8080` | Server port | +| `PACKING_GRID_STEP` | `5.0` | Grid step size | +| `PACKING_SUPPORT_RATIO` | `0.6` | Minimum support (0-1) | +| `PACKING_ALLOW_ROTATIONS` | `false` | 90° rotations | +| `SKIP_UPDATE_CHECK` | - | Disable auto-update | +| `GITHUB_TOKEN` | - | For higher rate limits | + +--- + +## Docker + +```bash +# Pre-built image +docker run -p 8080:8080 -e SORT_IT_NOW_SKIP_UPDATE_CHECK=1 josunlp/sort-it-now:latest + +# Build your own image +docker build -t sort-it-now . +docker run -p 8080:8080 -e SORT_IT_NOW_SKIP_UPDATE_CHECK=1 sort-it-now +``` + +--- + +## CI/CD Workflows + +| Workflow | Trigger | Actions | +| ------------- | --------------- | ---------------------------------------- | +| `rust.yml` | Push/PR on main | Format + Clippy + Tests (ubuntu/windows) | +| `release.yml` | Tag `v*` | Platform packages (Linux/macOS/Windows) | +| `docker.yml` | Tag `v*` | Multi-arch images on Docker Hub | +| `codeql.yml` | Push | Security analysis | +| `stale.yml` | Schedule | Mark old issues/PRs | + +--- + +## Quick Reference + +### Adding a New Object Type + +1. Define struct in `model.rs` +2. Implement `Dimensional`, `Positioned`, `Weighted` traits as needed +3. Add validation in constructor using `types::validation` +4. Add tests + +### Using Geometry Calculations + +```rust +use crate::types::{Vec3, BoundingBox, EPSILON_GENERAL}; + +// Create bounding box +let bbox = BoundingBox::from_position_and_dims( + Vec3::new(x, y, z), + Vec3::new(w, d, h) +); + +// Check collision +if bbox.intersects(&other_bbox) { ... } + +// Calculate support +let support_area = bbox.overlap_area_xy(&support_bbox); +``` + +### Center of Mass Calculation + +```rust +use crate::types::CenterOfMassCalculator; + +let mut calc = CenterOfMassCalculator::new(); +for placed in &container.placed { + let (cx, cy) = placed.center_xy(); + calc.add_point(cx, cy, placed.weight); +} +let offset = calc.distance_to(container_center); +``` diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 220f585..983b5e1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -9,15 +9,27 @@ on: permissions: contents: read + security-events: write jobs: docker: name: Build and Push Docker Image runs-on: ubuntu-latest steps: + - name: Validate required secrets + run: | + if [ -z "${{ secrets.DOCKER_USERNAME }}" ] || [ -z "${{ secrets.DOCKER_PASSWORD }}" ]; then + echo "::error::Missing required secrets DOCKER_USERNAME or DOCKER_PASSWORD" + echo "Please configure these secrets in Settings → Secrets and variables → Actions" + exit 1 + fi + - name: Checkout code uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -26,7 +38,6 @@ jobs: with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - # Note: Ensure DOCKER_USERNAME and DOCKER_PASSWORD secrets are configured - name: Extract metadata for Docker id: meta @@ -41,7 +52,7 @@ jobs: type=raw,value=dev,enable=${{ github.event_name == 'workflow_dispatch' }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: true @@ -50,3 +61,21 @@ jobs: platforms: linux/amd64,linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max + provenance: true + sbom: true + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@0.28.0 + with: + # Use the first tag from metadata-action (e.g., "user/repo:1.2.3") + image-ref: ${{ fromJSON(steps.meta.outputs.json).tags[0] }} + format: "sarif" + output: "trivy-results.sarif" + severity: "CRITICAL,HIGH" + ignore-unfixed: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: "trivy-results.sarif" diff --git a/CHANGELOG b/CHANGELOG index 8d02a98..737b9a3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,38 @@ # Changelog +## [1.2.0] - 2025-12-14 + +### Added + +- New `types.rs` module with reusable core types following OOP and DRY principles: + - `Vec3` - 3D vector type with operator overloading (+, -, \*) + - `BoundingBox` - Axis-Aligned Bounding Box for collision detection + - `Dimensional`, `Positioned`, `Weighted` traits for polymorphism + - `CenterOfMassCalculator` for weighted center of mass calculations + - `EPSILON_GENERAL` and `EPSILON_HEIGHT` constants + - Validation functions (`validate_dimension`, `validate_weight`, `validate_dimensions_3d`) + +### Changed + +- Refactored `model.rs` to implement traits from `types.rs`: + - `Box3D` implements `Dimensional`, `Weighted` + - `PlacedBox` implements `Positioned`, `Dimensional`, `Weighted` + - `Container` implements `Dimensional` +- Improved `geometry.rs` to use `BoundingBox` for calculations +- Translated entire project from German to English: + - All Rust source files (8 files) + - Documentation (README.md, CONCEPT.md, DOCKER_SETUP.md) + - Copilot instructions (.github/copilot-instructions.md) + - Installation scripts (install-unix.sh, install-windows.ps1) + - Web frontend (index.html, script.js) +- Updated copilot-instructions.md with comprehensive documentation + +### Improved + +- Code organization following OOP and DRY principles +- Test coverage increased to 42 tests across all modules +- Better separation of concerns with trait-based design + ## [1.1.2] - 2025-12-12 - Minor fix in the windows installation script. diff --git a/CONCEPT.md b/CONCEPT.md index cde6c32..4f036b4 100644 --- a/CONCEPT.md +++ b/CONCEPT.md @@ -1,35 +1,35 @@ -# Box Packing Konzept +# Box Packing Concept -## Parameter +## Parameters -Quader sollen möglichst effizient in einem Quader verpackt werden. +Cuboids should be packed as efficiently as possible into a cuboid container. -## Relevante Werte +## Relevant Values -Maximalgewicht Verpackung. -Gewicht Einzelobjekte. -Abmessung (3d) Verpackung. -Abmessung (3d) Einzelobjekte. -Mehrere Verpackungstypen mit individuellen Dimensionen und Gewichtslimits. +Maximum container weight. +Individual object weights. +Container dimensions (3D). +Individual object dimensions (3D). +Multiple container types with individual dimensions and weight limits. -## Ziel +## Goal -Algorithmische Lösung. -Bei zu geringem Volumen oder Grundfläche der Verpackung sollen die Objekte entsprechend in mehreren Verpackungen der angegebenen Größe möglichst effizient verpackt werden. +Algorithmic solution. +If container volume or base area is insufficient, objects should be packed efficiently across multiple containers of the specified sizes. -Entsprechend muss dann auch der Algorithmus mehrfach ausgeführt werden, bis alle Objekte verpackt sind. -Zudem kann der Algorithmus unterschiedliche Verpackungstypen kombinieren, um den Bedarf bestmöglich abzudecken. +Accordingly, the algorithm must be executed multiple times until all objects are packed. +Additionally, the algorithm can combine different container types to best meet requirements. -Schwere Objekte müssen immer unter leichteren Objekten sein, das Gewicht muss ebenfalls gleichmäßig auf der Grundfläche verteilt werden +Heavy objects must always be below lighter objects, and weight must be evenly distributed across the base area. -Große objekte nach möglichkeit nach unten. Die grundfläche soll möglichst gleichmäßig mit gewicht belastet sein und möglichst gleichmäßig mit objekten gefüllt sein. Es dürfen keine Objekte überhängen, so dass sie herunterfallen könnten. +Large objects should preferably be placed at the bottom. The base area should be loaded as evenly as possible with weight and filled as uniformly as possible with objects. Objects must not overhang in a way that would cause them to fall. -Am ende sollen die Objekte möglichst raumfüllend und kompackt gepackt sein. +In the end, objects should be packed as space-filling and compact as possible. -Objekte können nicht gedreht werden. +Objects cannot be rotated. -## Tech Basis +## Tech Stack -Rust Konsolen App mit andauernder Laufzeit und ansprechbaren Schnittstellen +Rust console application with persistent runtime and accessible interfaces -3D-Geometrische Heuristik in Kombination mit Gewichtsverteilung und Schichtung +3D geometric heuristic combined with weight distribution and layering diff --git a/Cargo.toml b/Cargo.toml index ff91f13..f85c6e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sort_it_now" -version = "1.1.2" +version = "1.2.0" edition = "2024" license = "LicenseRef-NCSL-1.0" @@ -26,3 +26,8 @@ utoipa = { version = "5.4", features = ["axum_extras"] } [target.'cfg(windows)'.dependencies] winreg = "0.52" + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/DOCKER_SETUP.md b/DOCKER_SETUP.md index 1cb9d1a..d1f6679 100644 --- a/DOCKER_SETUP.md +++ b/DOCKER_SETUP.md @@ -1,119 +1,119 @@ # Docker Hub Deployment Setup -Diese Anleitung beschreibt, wie man die automatische Docker-Veröffentlichung auf Docker Hub einrichtet. +This guide describes how to set up automatic Docker publishing to Docker Hub. -## Voraussetzungen +## Prerequisites -1. Ein Docker Hub Account () -2. Repository-Admin-Zugriff auf GitHub +1. A Docker Hub account () +2. Repository admin access on GitHub -## Schritt 1: Docker Hub Access Token erstellen +## Step 1: Create Docker Hub Access Token -1. Gehe zu -2. Klicke auf "New Access Token" -3. Gib einen Namen ein (z.B. "github-actions-sort-it-now") -4. Wähle die Berechtigung "Read, Write" aus -5. Klicke auf "Generate" -6. **Wichtig:** Kopiere das Token sofort - es wird nur einmal angezeigt! +1. Go to +2. Click "New Access Token" +3. Enter a name (e.g., "github-actions-sort-it-now") +4. Select "Read, Write" permission +5. Click "Generate" +6. **Important:** Copy the token immediately - it will only be shown once! -## Schritt 2: GitHub Secrets konfigurieren +## Step 2: Configure GitHub Secrets -1. Gehe zu deinem GitHub Repository -2. Navigiere zu **Settings** → **Secrets and variables** → **Actions** -3. Klicke auf "New repository secret" -4. Erstelle zwei Secrets: +1. Go to your GitHub repository +2. Navigate to **Settings** → **Secrets and variables** → **Actions** +3. Click "New repository secret" +4. Create two secrets: **Secret 1:** - Name: `DOCKER_USERNAME` - - Value: Dein Docker Hub Benutzername + - Value: Your Docker Hub username **Secret 2:** - Name: `DOCKER_PASSWORD` - - Value: Das Access Token aus Schritt 1 + - Value: The access token from Step 1 -## Schritt 3: Workflow testen +## Step 3: Test the Workflow -Der Docker-Workflow wird automatisch ausgelöst, wenn: +The Docker workflow is automatically triggered when: -- Ein neuer Tag im Format `v*` erstellt wird (z.B. `v1.1.0`) -- Der Workflow manuell über "Actions" → "Docker Build and Push" → "Run workflow" gestartet wird +- A new tag in the format `v*` is created (e.g., `v1.1.0`) +- The workflow is manually started via "Actions" → "Docker Build and Push" → "Run workflow" -### Manueller Test +### Manual Test -1. Gehe zu **Actions** im GitHub Repository -2. Wähle den Workflow "Docker Build and Push" -3. Klicke auf "Run workflow" -4. Wähle den Branch aus -5. Klicke auf "Run workflow" +1. Go to **Actions** in the GitHub repository +2. Select the workflow "Docker Build and Push" +3. Click "Run workflow" +4. Select the branch +5. Click "Run workflow" -## Schritt 4: Docker Image auf Docker Hub verifizieren +## Step 4: Verify Docker Image on Docker Hub -Nach erfolgreichem Workflow-Durchlauf: +After successful workflow completion: -1. Gehe zu -2. Navigiere zu deinem Repository -3. Das Image sollte mit den entsprechenden Tags verfügbar sein: - - `latest` (wird bei jedem Release mit einem `v*` Tag vergeben) - - Versions-Tags (z.B. `1.0.0`, `1.0`, `1`) +1. Go to +2. Navigate to your repository +3. The image should be available with the corresponding tags: + - `latest` (assigned with each release having a `v*` tag) + - Version tags (e.g., `1.0.0`, `1.0`, `1`) -## Docker Image verwenden +## Using the Docker Image -Nach der Veröffentlichung kann das Image folgendermaßen verwendet werden: +After publishing, the image can be used as follows: -> **Hinweis:** Ersetze `` durch deinen tatsächlichen Docker Hub Benutzernamen. +> **Note:** Replace `` with your actual Docker Hub username. ```bash -# Neueste Version +# Latest version docker pull /sort-it-now:latest -# Spezifische Version +# Specific version docker pull /sort-it-now:1.0.0 -# Ausführen +# Run docker run -p 8080:8080 -e SORT_IT_NOW_SKIP_UPDATE_CHECK=1 /sort-it-now:latest ``` ## Troubleshooting -### Workflow schlägt mit "Authentication failed" fehl +### Workflow fails with "Authentication failed" -- Überprüfe, ob die Secrets korrekt gesetzt sind -- Stelle sicher, dass das Docker Hub Access Token nicht abgelaufen ist -- Verifiziere den Docker Hub Benutzernamen (Groß-/Kleinschreibung beachten) +- Check if the secrets are set correctly +- Ensure the Docker Hub access token has not expired +- Verify the Docker Hub username (case-sensitive) -### Workflow schlägt mit "denied: requested access to the resource is denied" fehl +### Workflow fails with "denied: requested access to the resource is denied" -- Das Access Token benötigt "Write"-Berechtigung -- Stelle sicher, dass das Repository auf Docker Hub existiert (wird automatisch beim ersten Push erstellt) +- The access token needs "Write" permission +- Ensure the repository exists on Docker Hub (automatically created on first push) -### Image wird nicht mit allen Plattformen gebaut +### Image is not built for all platforms -- Docker Buildx wird automatisch eingerichtet -- Bei Problemen kann man in `.github/workflows/docker.yml` die Zeile `platforms: linux/amd64,linux/arm64` auf nur `linux/amd64` reduzieren +- Docker Buildx is automatically set up +- If there are issues, you can reduce the line `platforms: linux/amd64,linux/arm64` in `.github/workflows/docker.yml` to just `linux/amd64` -## Anpassungen +## Customizations -### Docker Hub Repository-Name ändern +### Change Docker Hub Repository Name -In `.github/workflows/docker.yml` die Zeile: +In `.github/workflows/docker.yml`, change the line: ```yaml images: ${{ secrets.DOCKER_USERNAME }}/sort-it-now ``` -ändern zu: +to: ```yaml -images: ${{ secrets.DOCKER_USERNAME }}/dein-repository-name +images: ${{ secrets.DOCKER_USERNAME }}/your-repository-name ``` -### Andere Registry verwenden (z.B. GitHub Container Registry) +### Use Different Registry (e.g., GitHub Container Registry) -Für GitHub Container Registry (ghcr.io): +For GitHub Container Registry (ghcr.io): -1. Ersetze `docker/login-action` mit GitHub Token: +1. Replace `docker/login-action` with GitHub Token: ```yaml - name: Log in to GitHub Container Registry @@ -124,7 +124,7 @@ Für GitHub Container Registry (ghcr.io): password: ${{ secrets.GITHUB_TOKEN }} ``` -2. Ändere das Image in `metadata-action`: +2. Change the image in `metadata-action`: ```yaml images: ghcr.io/${{ github.repository_owner }}/sort-it-now diff --git a/Dockerfile b/Dockerfile index d32762b..e9b85ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,16 +20,17 @@ RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && r COPY src ./src COPY web ./web -# Build the application with actual source +# Build the application with actual source (LTO + strip via Cargo.toml profile) RUN cargo build --release # Runtime stage FROM debian:bookworm-slim -# Install runtime dependencies +# Install runtime dependencies and curl for healthcheck RUN apt-get update && apt-get install -y \ ca-certificates \ libssl3 \ + curl \ && rm -rf /var/lib/apt/lists/* # Create non-root user for security @@ -40,15 +41,24 @@ WORKDIR /app # Copy the binary from builder with proper ownership COPY --from=builder --chown=appuser:appuser /app/target/release/sort_it_now /app/sort_it_now +# Copy web assets (required for static file serving) +COPY --from=builder --chown=appuser:appuser /app/web /app/web + # Expose the default port EXPOSE 8080 # Set environment variables with defaults ENV SORT_IT_NOW_API_HOST=0.0.0.0 ENV SORT_IT_NOW_API_PORT=8080 +# Disable auto-update in container (updates should be handled by redeployment) +ENV SORT_IT_NOW_SKIP_UPDATE_CHECK=1 + +# Healthcheck for container orchestration (Docker Compose, Kubernetes, etc.) +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/docs/openapi.json || exit 1 # Switch to non-root user USER appuser # Run the binary -CMD ["/app/sort_it_now"] +ENTRYPOINT ["/app/sort_it_now"] diff --git a/README.md b/README.md index e92db06..3ad08ff 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,97 @@ # Sort-it-now - 3D Box Packing Optimizer -Eine intelligente 3D-Verpackungsoptimierung mit interaktiver Visualisierung. +An intelligent 3D packing optimization service with interactive visualization. ## 🎯 Features ### Backend (Rust) -- **Heuristischer Packing-Algorithmus** mit Berücksichtigung von: - - Gewichtsgrenzen und -verteilung - - Stabilität und Unterstützung (60% Mindestauflage) - - Schwerpunkt-Balance - - Schichtung (schwere Objekte unten) -- **Automatische Multi-Container-Verwaltung** -- **Optionale Objektrotationen** (per Request-Flag oder Umgebungsvariable aktivierbar) -- **Umfassende Unit-Tests** -- **REST-API** mit JSON-Kommunikation -- **OpenAPI & Swagger UI** mit live Dokumentation unter `/docs` -- **OOP-Prinzipien** mit DRY-Architektur -- **Vollständig dokumentierter Code** (Rust-Docstrings) +- **Heuristic packing algorithm** considering: + - Weight limits and distribution + - Stability and support (60% minimum support ratio) + - Center of mass balance + - Layering (heavy objects at the bottom) +- **Automatic multi-container management** +- **Optional object rotations** (enabled via request flag or environment variable) +- **Comprehensive unit tests** +- **REST API** with JSON communication +- **OpenAPI & Swagger UI** with live documentation at `/docs` +- **OOP principles** with DRY architecture +- **Fully documented code** (Rust docstrings) ### Frontend (JavaScript/Three.js) -- **Interaktive 3D-Visualisierung** -- **OrbitControls** für Kamera-Steuerung -- **Container-Navigation** (Vor/Zurück-Buttons) -- **Schritt-für-Schritt-Animation** des Packprozesses -- **Live-Statistiken**: - - Objekt-Anzahl - - Gesamtgewicht - - Volumen-Auslastung - - Schwerpunkt-Position -- **Konfigurationsmodal** mit Schalter für Objektrotationen -- **Responsive Design** +- **Interactive 3D visualization** +- **OrbitControls** for camera control +- **Container navigation** (Previous/Next buttons) +- **Step-by-step animation** of the packing process +- **Live statistics**: + - Object count + - Total weight + - Volume utilization + - Center of mass position +- **Configuration modal** with object rotation toggle +- **Responsive design** -## 🚀 Installation & Start +## 🚀 Installation & Startup -### Voraussetzungen +### Prerequisites - Rust (1.70+) - Cargo -- Moderner Webbrowser +- Modern web browser -### Backend starten +### Start the backend ```bash cargo run ``` -Der Server läuft auf `http://localhost:8080` +The server runs on `http://localhost:8080` -> 💡 **Konfigurationshinweis:** Kopiere bei Bedarf die Datei `.env.example` nach `.env`, um den API-Port, Host oder Update-Parameter anzupassen. Nicht gesetzte Werte fallen automatisch auf ihre Standardwerte zurück. +> 💡 **Configuration note:** Copy `.env.example` to `.env` if needed to customize the API port, host, or update parameters. Unset values automatically fall back to their defaults. -### Frontend öffnen +### Open the frontend -Der Web-Client wird automatisch vom Rust-Backend ausgeliefert. Rufe nach dem Start einfach `http://localhost:8080/` im Browser auf. +The web client is automatically served by the Rust backend. After startup, simply open `http://localhost:8080/` in your browser. -Im Browser: +In the browser: -- Button "🚀 Pack (Batch)" führt eine einmalige Optimierung aus und zeigt das Ergebnis. -- Button "📡 Pack (Live)" startet den Live-Stream der Optimierungsschritte via SSE und rendert sie fortlaufend. +- Button "🚀 Pack (Batch)" performs a one-time optimization and displays the result. +- Button "📡 Pack (Live)" starts the live stream of optimization steps via SSE and renders them continuously. -## 📦 Fertige Builds & Release-Pipeline +## 📦 Pre-built Releases & Release Pipeline -Für Releases existiert ein GitHub-Actions-Workflow (`.github/workflows/release.yml`), der bei Tags im Format `v*` (oder manuell via _workflow_dispatch_) Plattform-Pakete erzeugt: +A GitHub Actions workflow (`.github/workflows/release.yml`) exists for releases that generates platform packages when tags in the format `v*` are created (or manually via _workflow_dispatch_): - **Linux (x86_64)**: `sort-it-now--linux-x86_64.tar.gz` - **macOS (ARM64/Apple Silicon)**: `sort-it-now--macos-arm64.tar.gz` - **macOS (x86_64/Intel)**: `sort-it-now--macos-x86_64.tar.gz` - **Windows (x86_64)**: `sort-it-now--windows-x86_64.zip` -Jedes Paket enthält die vorkompilierte Binärdatei, die aktuelle `README.md` sowie ein Installationsskript. -Die Artefakte werden sowohl als Workflow-Artefakte hochgeladen als auch automatisch dem GitHub-Release der entsprechenden Tag-Version hinzugefügt. +Each package contains the pre-compiled binary, the current `README.md`, and an installation script. +The artifacts are uploaded both as workflow artifacts and automatically added to the GitHub release for the corresponding tag version. -### Installationsskripte +### Installation Scripts -- Linux/macOS: Im entpackten Ordner `./install.sh` ausführen (optional mit `sudo`), um `sort_it_now` nach `/usr/local/bin` zu kopieren. -- Windows: `install.ps1` (PowerShell) ausführen. Standardmäßig wird nach `%ProgramFiles%\sort-it-now` installiert und der Pfad der Benutzer-Umgebungsvariable hinzugefügt. +- Linux/macOS: Run `./install.sh` in the extracted folder (optionally with `sudo`) to copy `sort_it_now` to `/usr/local/bin`. +- Windows: Run `install.ps1` (PowerShell). By default, it installs to `%ProgramFiles%\sort-it-now` and adds the path to the user environment variable. ### Docker -Für jeden Release wird automatisch ein Docker-Image auf [Docker Hub](https://hub.docker.com/) veröffentlicht. Die Images werden für mehrere Architekturen (linux/amd64, linux/arm64) bereitgestellt. +For each release, a Docker image is automatically published to [Docker Hub](https://hub.docker.com/). Images are provided for multiple architectures (linux/amd64, linux/arm64). -> 📖 **Setup-Anleitung:** Siehe [DOCKER_SETUP.md](DOCKER_SETUP.md) für eine detaillierte Anleitung zur Einrichtung der Docker Hub Deployment-Pipeline. +> 📖 **Setup guide:** See [DOCKER_SETUP.md](DOCKER_SETUP.md) for a detailed guide on setting up the Docker Hub deployment pipeline. -**Docker Image ausführen:** +**Run Docker image:** -> **Hinweis:** Ersetze `` durch `josunlp` (oder den entsprechenden Docker Hub Benutzernamen des Projekt-Maintainers). +> **Note:** Replace `` with `josunlp` (or the corresponding Docker Hub username of the project maintainer). ```bash docker run -p 8080:8080 -e SORT_IT_NOW_SKIP_UPDATE_CHECK=1 /sort-it-now:latest ``` -**Mit Umgebungsvariablen:** +**With environment variables:** ```bash docker run -p 8080:8080 \ @@ -101,34 +101,34 @@ docker run -p 8080:8080 \ /sort-it-now:latest ``` -**Eigenes Image bauen:** +**Build your own image:** ```bash docker build -t sort-it-now . docker run -p 8080:8080 -e SORT_IT_NOW_SKIP_UPDATE_CHECK=1 sort-it-now ``` -Der Server ist dann unter `http://localhost:8080` verfügbar. +The server is then available at `http://localhost:8080`. -## 🔔 Automatische Updates beim Start +## 🔔 Automatic Updates on Startup -Beim Start prüft der Dienst im Hintergrund die neuesten GitHub-Releases (`JosunLP/sort-it-now`). Wird eine neuere Version gefunden, lädt der Updater das passende Release-Paket herunter und führt das Installationsskript für die aktuelle Plattform aus. Dadurch wird das Update – soweit möglich – automatisch eingespielt. Auf Windows wird bei gesperrter `sort_it_now.exe` ersatzweise eine `sort_it_now.new.exe` abgelegt. +On startup, the service checks for the latest GitHub releases (`JosunLP/sort-it-now`) in the background. If a newer version is found, the updater downloads the appropriate release package and runs the installation script for the current platform. This automatically applies the update where possible. On Windows, if `sort_it_now.exe` is locked, a `sort_it_now.new.exe` is placed instead. -- Der Check kann über die Umgebungsvariable `SORT_IT_NOW_SKIP_UPDATE_CHECK=1` deaktiviert werden (z. B. für Offline-Installationen oder CI). -- GitHub limitiert nicht authentifizierte API-Aufrufe auf 60 pro Stunde. Wird das Limit erreicht, wird der Check übersprungen und eine Info ausgegeben. Setze optional `SORT_IT_NOW_GITHUB_TOKEN` (oder `GITHUB_TOKEN`) auf ein Personal Access Token, um höhere Limits zu erhalten; der Updater nutzt das Token ebenfalls beim Download der Release-Artefakte. -- Um unerwartet große Downloads zu vermeiden, begrenzt der Updater Release-Artefakte standardmäßig auf 200 MB. Passe das Limit über `SORT_IT_NOW_MAX_DOWNLOAD_MB` an (Wert `0` deaktiviert die Begrenzung). -- Repo/Owner sowie Timeout lassen sich über `SORT_IT_NOW_GITHUB_OWNER`, `SORT_IT_NOW_GITHUB_REPO` und `SORT_IT_NOW_HTTP_TIMEOUT_SECS` konfigurieren – Standardwerte greifen automatisch, falls keine `.env` vorhanden ist. +- The check can be disabled via the environment variable `SORT_IT_NOW_SKIP_UPDATE_CHECK=1` (e.g., for offline installations or CI). +- GitHub limits unauthenticated API calls to 60 per hour. If the limit is reached, the check is skipped and info is displayed. Optionally set `SORT_IT_NOW_GITHUB_TOKEN` (or `GITHUB_TOKEN`) to a Personal Access Token to get higher limits; the updater also uses the token when downloading release artifacts. +- To avoid unexpectedly large downloads, the updater limits release artifacts to 200 MB by default. Adjust the limit via `SORT_IT_NOW_MAX_DOWNLOAD_MB` (value `0` disables the limit). +- Repo/owner and timeout can be configured via `SORT_IT_NOW_GITHUB_OWNER`, `SORT_IT_NOW_GITHUB_REPO`, and `SORT_IT_NOW_HTTP_TIMEOUT_SECS` – defaults apply automatically if no `.env` is present. -## 📊 API-Endpunkte +## 📊 API Endpoints ### OpenAPI & Swagger UI -- `GET /docs` liefert eine interaktive Swagger UI mit Subresource-Integrity-geschützten Assets. -- `GET /docs/openapi.json` stellt das OpenAPI-Schema (v3) bereit und kann z. B. für Code-Generatoren genutzt werden. +- `GET /docs` delivers an interactive Swagger UI with Subresource Integrity-protected assets. +- `GET /docs/openapi.json` provides the OpenAPI schema (v3) and can be used for code generators. ### POST /pack -Verpackt Objekte in Container. +Packs objects into containers. **Request:** @@ -136,7 +136,7 @@ Verpackt Objekte in Container. { "containers": [ { "name": "Standard", "dims": [100.0, 100.0, 70.0], "max_weight": 500.0 }, - { "name": "Kompakt", "dims": [60.0, 80.0, 50.0], "max_weight": 320.0 } + { "name": "Compact", "dims": [60.0, 80.0, 50.0], "max_weight": 320.0 } ], "objects": [ { "id": 1, "dims": [30.0, 30.0, 10.0], "weight": 50.0 }, @@ -146,7 +146,7 @@ Verpackt Objekte in Container. } ``` -Das optionale Feld `allow_rotations` aktiviert pro Anfrage die 90°-Rotationen. Wird es weggelassen, greift die Standardeinstellung aus der Umgebungsvariable `SORT_IT_NOW_PACKING_ALLOW_ROTATIONS` (default: false). +The optional field `allow_rotations` enables 90° rotations per request. If omitted, the default setting from the environment variable `SORT_IT_NOW_PACKING_ALLOW_ROTATIONS` (default: false) applies. **Response:** @@ -175,21 +175,21 @@ Das optionale Feld `allow_rotations` aktiviert pro Anfrage die 90°-Rotationen. ### POST /pack_stream (SSE) -Streamt Fortschritts-Events in Echtzeit als `text/event-stream`. Jeder Event ist ein JSON-Objekt mit `type`-Feld: +Streams progress events in real-time as `text/event-stream`. Each event is a JSON object with a `type` field: - `ContainerStarted` { id, dims, max_weight, label, template_id } - `ObjectPlaced` { container_id, id, pos, weight, dims, total_weight } - `Finished` -Hinweis: Im Frontend kannst du den Live-Modus mit dem Button "📡 Pack (Live)" starten. +Note: In the frontend, you can start live mode with the "📡 Pack (Live)" button. -## 🧪 Tests ausführen +## 🧪 Running Tests ```bash cargo test ``` -Alle 5 Tests sollten erfolgreich sein: +All tests should pass successfully: - ✅ heavy_boxes_stay_below_lighter - ✅ single_box_snaps_to_corner @@ -197,157 +197,157 @@ Alle 5 Tests sollten erfolgreich sein: - ✅ reject_heavier_on_light_support - ✅ sample_pack_respects_weight_order -## 🏗️ Architektur +## 🏗️ Architecture -### Rust Module +### Rust Modules #### `main.rs` -- Einstiegspunkt der Anwendung -- Startet den Tokio-Runtime und API-Server +- Application entry point +- Starts the Tokio runtime and API server #### `model.rs` -- **`Box3D`**: Repräsentiert ein 3D-Objekt mit ID, Dimensionen und Gewicht -- **`PlacedBox`**: Objekt mit Position im Container -- **`Container`**: Verpackungsbehälter mit Kapazitätsgrenzen -- Methoden: `volume()`, `base_area()`, `total_weight()`, `remaining_weight()`, `utilization_percent()` +- **`Box3D`**: Represents a 3D object with ID, dimensions, and weight +- **`PlacedBox`**: Object with position in the container +- **`Container`**: Packaging container with capacity limits +- Methods: `volume()`, `base_area()`, `total_weight()`, `remaining_weight()`, `utilization_percent()` #### `geometry.rs` -- **`intersects()`**: AABB-Kollisionserkennung zwischen zwei Objekten -- **`overlap_1d()`**: Berechnet 1D-Überlappung -- **`overlap_area_xy()`**: Berechnet XY-Überlappungsfläche -- **`point_inside()`**: Punkt-in-Box-Test +- **`intersects()`**: AABB collision detection between two objects +- **`overlap_1d()`**: Calculates 1D overlap +- **`overlap_area_xy()`**: Calculates XY overlap area +- **`point_inside()`**: Point-in-box test #### `optimizer.rs` -- **`PackingConfig`**: Konfigurierbare Parameter (Raster, Support-Ratio, Toleranzen) -- **`pack_objects()`**: Hauptalgorithmus zur Verpackung -- **`pack_objects_with_config()`**: Version mit anpassbaren Parametern -- **`find_stable_position()`**: Findet stabile Position für ein Objekt -- **`supports_weight_correctly()`**: Prüft Gewichts-Hierarchie -- **`has_sufficient_support()`**: Prüft Mindestauflage -- **`calculate_balance_after()`**: Berechnet Schwerpunkt-Abweichung +- **`PackingConfig`**: Configurable parameters (grid, support ratio, tolerances) +- **`pack_objects()`**: Main packing algorithm +- **`pack_objects_with_config()`**: Version with customizable parameters +- **`find_stable_position()`**: Finds stable position for an object +- **`supports_weight_correctly()`**: Checks weight hierarchy +- **`has_sufficient_support()`**: Checks minimum support ratio +- **`calculate_balance_after()`**: Calculates center of mass deviation #### `api.rs` -- **REST-API** mit Axum-Framework -- **CORS-Support** für Frontend-Kommunikation -- JSON-Serialisierung/Deserialisierung +- **REST API** with Axum framework +- **CORS support** for frontend communication +- JSON serialization/deserialization -### JavaScript Module +### JavaScript Modules #### `script.js` -- Three.js Szenen-Setup -- OrbitControls für Kamera -- Funktionen: - - `clearScene()`: Räumt Szene auf - - `drawContainerFrame()`: Zeichnet Container-Wireframe - - `drawBox()`: Rendert einzelnes Objekt - - `visualizeContainer()`: Zeigt kompletten Container - - `animateContainer()`: Schritt-für-Schritt-Animation - - `updateStats()`: Aktualisiert Statistik-Panel - - `fetchPacking()`: API-Kommunikation +- Three.js scene setup +- OrbitControls for camera +- Functions: + - `clearScene()`: Clears scene + - `drawContainerFrame()`: Draws container wireframe + - `drawBox()`: Renders individual object + - `visualizeContainer()`: Shows complete container + - `animateContainer()`: Step-by-step animation + - `updateStats()`: Updates statistics panel + - `fetchPacking()`: API communication -## 🎨 Optimierungen +## 🎨 Optimizations -### DRY-Prinzip +### DRY Principle -- **`PackingConfig`-Struktur** statt verteilter Konstanten -- Wiederverwendbare Funktionen für Geometrie-Berechnungen -- Zentralisierte Fehlerbehandlung +- **`PackingConfig` structure** instead of scattered constants +- Reusable functions for geometry calculations +- Centralized error handling -### OOP-Prinzipien +### OOP Principles -- Klare Trennung von Datenmodellen und Logik -- Kapselung in Module -- Trait-Implementation für gemeinsames Verhalten +- Clear separation of data models and logic +- Encapsulation in modules +- Trait implementation for common behavior -### Code-Dokumentation +### Code Documentation -- Rust-Docstrings für alle öffentlichen Funktionen -- JSDoc-Kommentare im Frontend -- Inline-Kommentare für komplexe Algorithmen +- Rust docstrings for all public functions +- JSDoc comments in frontend +- Inline comments for complex algorithms -## 🔧 Konfiguration +## 🔧 Configuration -### Backend-Konfiguration (.env) +### Backend Configuration (.env) -Die Anwendung lädt beim Start optional eine `.env`-Datei (mittels [`dotenvy`](https://crates.io/crates/dotenvy)). Nicht gesetzte Variablen behalten ihre Standardwerte, sodass der Dienst auch ohne `.env` wie gewohnt läuft. Relevante Variablen: +The application optionally loads a `.env` file on startup (using [`dotenvy`](https://crates.io/crates/dotenvy)). Unset variables retain their defaults, so the service runs normally even without `.env`. Relevant variables: -| Variable | Standard | Beschreibung | -| ------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `SORT_IT_NOW_API_HOST` | `0.0.0.0` | IP-Adresse, an die der HTTP-Server gebunden wird. Setze z. B. `127.0.0.1` für lokalen Zugriff. | -| `SORT_IT_NOW_API_PORT` | `8080` | Port des API-Servers. Werte `0` werden verworfen. | -| `SORT_IT_NOW_GITHUB_OWNER` | `JosunLP` | GitHub-Owner/Organisation, deren Releases für Updates abgefragt werden. | -| `SORT_IT_NOW_GITHUB_REPO` | `sort-it-now` | Repository-Name für den Updater. | -| `SORT_IT_NOW_HTTP_TIMEOUT_SECS` | `30` | Timeout in Sekunden für GitHub-HTTP-Anfragen des Updaters. | -| `SORT_IT_NOW_MAX_DOWNLOAD_MB` | `200` | Maximale Größe eines Release-Assets (0 = unbegrenzt). | -| `SORT_IT_NOW_GITHUB_TOKEN` / `GITHUB_TOKEN` | – | Optionales PAT für höhere GitHub-Rate-Limits und private Releases. | -| `SORT_IT_NOW_SKIP_UPDATE_CHECK` | – | Wenn gesetzt (beliebiger Wert), wird der automatische Update-Check deaktiviert. | -| `SORT_IT_NOW_PACKING_GRID_STEP` | `5.0` | ⚠️ Schrittweite des Positionsrasters; kleinere Werte liefern feinere Platzierung, verlangsamen aber und können zu instabilen Anordnungen führen. | -| `SORT_IT_NOW_PACKING_SUPPORT_RATIO` | `0.6` | ⚠️ Mindestauflage für stabile Stapel; niedrigere Werte erhöhen Kipp-Risiko. | -| `SORT_IT_NOW_PACKING_HEIGHT_EPSILON` | `1e-3` | ⚠️ Toleranz für Höhenvergleiche; Werte zu groß oder klein beeinflussen Stabilitätschecks. | -| `SORT_IT_NOW_PACKING_GENERAL_EPSILON` | `1e-6` | ⚠️ Allgemeine numerische Toleranz; extreme Werte können zu falschen Kollisionsergebnissen führen. | -| `SORT_IT_NOW_PACKING_BALANCE_LIMIT_RATIO` | `0.45` | ⚠️ Grenzwert für Schwerpunktabweichung; höhere Werte erlauben stärkere Schiefstellungen. | -| `SORT_IT_NOW_PACKING_ALLOW_ROTATIONS` | `false` | Aktiviert alle 90°-Rotationen der Objekte. Kann auch pro Request über `allow_rotations` gesetzt werden. | +| Variable | Default | Description | +| ------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------ | +| `SORT_IT_NOW_API_HOST` | `0.0.0.0` | IP address the HTTP server binds to. Set e.g. `127.0.0.1` for local access. | +| `SORT_IT_NOW_API_PORT` | `8080` | API server port. Values of `0` are rejected. | +| `SORT_IT_NOW_GITHUB_OWNER` | `JosunLP` | GitHub owner/organization whose releases are queried for updates. | +| `SORT_IT_NOW_GITHUB_REPO` | `sort-it-now` | Repository name for the updater. | +| `SORT_IT_NOW_HTTP_TIMEOUT_SECS` | `30` | Timeout in seconds for GitHub HTTP requests by the updater. | +| `SORT_IT_NOW_MAX_DOWNLOAD_MB` | `200` | Maximum size of a release asset (0 = unlimited). | +| `SORT_IT_NOW_GITHUB_TOKEN` / `GITHUB_TOKEN` | – | Optional PAT for higher GitHub rate limits and private releases. | +| `SORT_IT_NOW_SKIP_UPDATE_CHECK` | – | If set (any value), disables automatic update check. | +| `SORT_IT_NOW_PACKING_GRID_STEP` | `5.0` | ⚠️ Position grid step size; smaller values give finer placement but slow down and may cause unstable arrangements. | +| `SORT_IT_NOW_PACKING_SUPPORT_RATIO` | `0.6` | ⚠️ Minimum support ratio for stable stacking; lower values increase tipping risk. | +| `SORT_IT_NOW_PACKING_HEIGHT_EPSILON` | `1e-3` | ⚠️ Tolerance for height comparisons; values too large or small affect stability checks. | +| `SORT_IT_NOW_PACKING_GENERAL_EPSILON` | `1e-6` | ⚠️ General numerical tolerance; extreme values may cause incorrect collision results. | +| `SORT_IT_NOW_PACKING_BALANCE_LIMIT_RATIO` | `0.45` | ⚠️ Center of mass deviation limit; higher values allow more tilting. | +| `SORT_IT_NOW_PACKING_ALLOW_ROTATIONS` | `false` | Enables all 90° object rotations. Can also be set per request via `allow_rotations`. | -Eine beispielhafte Datei findest du in `.env.example`. +An example file can be found in `.env.example`. -### Packing-Parameter (optimizer.rs) +### Packing Parameters (optimizer.rs) ```rust PackingConfig { - grid_step: 5.0, // Positions-Raster in Einheiten - support_ratio: 0.6, // 60% Mindestauflage - height_epsilon: 1e-3, // Höhen-Toleranz - general_epsilon: 1e-6, // Allgemeine Toleranz - balance_limit_ratio: 0.45, // Max. Schwerpunkt-Abweichung - allow_item_rotation: false, // Objektrotationen aktivieren (per Default deaktiviert) + grid_step: 5.0, // Position grid in units + support_ratio: 0.6, // 60% minimum support + height_epsilon: 1e-3, // Height tolerance + general_epsilon: 1e-6, // General tolerance + balance_limit_ratio: 0.45, // Max center of mass deviation + allow_item_rotation: false, // Enable object rotations (disabled by default) } ``` -### Frontend-Konfiguration (script.js) +### Frontend Configuration (script.js) ```javascript -const CONTAINER_SIZE = [100, 100, 70]; // Container-Dimensionen -const COLOR_PALETTE = [...]; // Farben für Objekte +const CONTAINER_SIZE = [100, 100, 70]; // Container dimensions +const COLOR_PALETTE = [...]; // Object colors ``` ## 📈 Performance -- **Durchsatz**: ~100 Objekte/Sekunde -- **Speicher**: O(n) für n Objekte -- **Komplexität**: O(n × p × z) wobei: - - n = Anzahl Objekte - - p = Raster-Positionen - - z = Z-Ebenen +- **Throughput**: ~100 objects/second +- **Memory**: O(n) for n objects +- **Complexity**: O(n × p × z) where: + - n = number of objects + - p = grid positions + - z = Z-levels -## 🐛 Bekannte Einschränkungen +## 🐛 Known Limitations -1. **Rotation**: Nur 90°-Rotationen; komplexe Freiform-Rotationen sind nicht abgedeckt -2. **Dynamische Stabilität**: Keine physikalische Simulation -3. **Optimales Packing**: Heuristik, kein garantiertes Optimum -4. **Browser-Support**: Benötigt WebGL-Unterstützung +1. **Rotation**: Only 90° rotations; complex freeform rotations are not covered +2. **Dynamic stability**: No physical simulation +3. **Optimal packing**: Heuristic, no guaranteed optimum +4. **Browser support**: Requires WebGL support -## 📝 Lizenz +## 📝 License -Projektspezifisch - Siehe Lizenz-Datei. +Project-specific - See license file. -## 🤝 Beitragen +## 🤝 Contributing -1. Fork das Repository -2. Erstelle einen Feature-Branch -3. Commit deine Änderungen -4. Push zum Branch -5. Öffne einen Pull Request +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Open a pull request -## 📧 Kontakt +## 📧 Contact -Bei Fragen oder Problemen öffne bitte ein Issue. +For questions or issues, please open an issue. --- -Entwickelt mit ❤️ in Rust & Three.js +Developed with ❤️ in Rust & Three.js diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..927baef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +# Docker Compose for sort-it-now +# Usage: +# docker compose up -d # Start container +# docker compose logs -f # Follow logs +# docker compose down # Stop container + +services: + sort-it-now: + image: josunlp/sort-it-now:latest + # Alternative: Local build + # build: + # context: . + # dockerfile: Dockerfile + container_name: sort-it-now + ports: + - "8080:8080" + environment: + - SORT_IT_NOW_API_HOST=0.0.0.0 + - SORT_IT_NOW_API_PORT=8080 + - SORT_IT_NOW_SKIP_UPDATE_CHECK=1 + # Optional packing configuration: + # - SORT_IT_NOW_PACKING_GRID_STEP=5.0 + # - SORT_IT_NOW_PACKING_SUPPORT_RATIO=0.6 + # - SORT_IT_NOW_PACKING_ALLOW_ROTATIONS=true + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/docs/openapi.json"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + # Resource limits (optional, recommended for production) + deploy: + resources: + limits: + cpus: "2" + memory: 512M + reservations: + cpus: "0.5" + memory: 128M diff --git a/package.json b/package.json index 41c5626..3fcd432 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sort-it-now", - "version": "1.1.2", + "version": "1.2.0", "description": "", "license": "LicenseRef-NCSL-1.0", "author": "", diff --git a/scripts/install-unix.sh b/scripts/install-unix.sh index 12bd434..727dc58 100644 --- a/scripts/install-unix.sh +++ b/scripts/install-unix.sh @@ -8,26 +8,26 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BINARY_PATH="$SCRIPT_DIR/$APP_NAME" if [[ ! -f "$BINARY_PATH" ]]; then - echo "❌ Konnte Binärdatei '$APP_NAME' nicht finden. Stelle sicher, dass dieses Skript im entpackten Release-Ordner ausgeführt wird." >&2 + echo "❌ Could not find binary '$APP_NAME'. Make sure this script is run in the extracted release folder." >&2 exit 1 fi if [[ ! -x "$BINARY_PATH" ]]; then - echo "ℹ️ Setze Ausführungsrechte für $BINARY_PATH" + echo "ℹ️ Setting execute permissions for $BINARY_PATH" chmod +x "$BINARY_PATH" fi if [[ ! -d "$INSTALL_DIR" ]]; then - echo "ℹ️ Erstelle Installationsverzeichnis $INSTALL_DIR" + echo "ℹ️ Creating installation directory $INSTALL_DIR" mkdir -p "$INSTALL_DIR" fi if [[ ! -w "$INSTALL_DIR" ]]; then - echo "⚠️ Schreibrechte in $INSTALL_DIR fehlen. Versuche es mit 'sudo'." >&2 + echo "⚠️ Write permissions missing in $INSTALL_DIR. Try using 'sudo'." >&2 exit 1 fi install -m 755 "$BINARY_PATH" "$INSTALL_DIR/$APP_NAME" -echo "✅ $APP_NAME wurde erfolgreich nach $INSTALL_DIR installiert." -echo "ℹ️ Starte den Dienst mit: $APP_NAME" +echo "✅ $APP_NAME was successfully installed to $INSTALL_DIR." +echo "ℹ️ Start the service with: $APP_NAME" diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index a16a372..437920b 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -6,7 +6,7 @@ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $binaryPath = Join-Path $scriptDir "sort_it_now.exe" if (-not (Test-Path $binaryPath)) { - Write-Error "Die Datei sort_it_now.exe wurde nicht gefunden. Fuehre das Skript im entpackten Release-Ordner aus." + Write-Error "The file sort_it_now.exe was not found. Run this script in the extracted release folder." exit 1 } @@ -17,7 +17,7 @@ if (-not (Test-Path $Destination)) { Copy-Item -Path $binaryPath -Destination (Join-Path $Destination "sort_it_now.exe") -Force Copy-Item -Path (Join-Path $scriptDir "README.md") -Destination (Join-Path $Destination "README.md") -Force -ErrorAction SilentlyContinue -Write-Host "sort-it-now wurde nach $Destination installiert." +Write-Host "sort-it-now was installed to $Destination." $path = [Environment]::GetEnvironmentVariable('Path', 'User') if ($path -notlike "*$Destination*") { @@ -29,7 +29,7 @@ if ($path -notlike "*$Destination*") { } [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') - Write-Host "Das Installationsverzeichnis wurde zum Benutzer-PATH hinzugefuegt. Du musst eventuell ein neues Terminal oeffnen." + Write-Host "The installation directory was added to user PATH. You may need to open a new terminal." } -Write-Host "Starte den Dienst mit: sort_it_now.exe" +Write-Host "Start the service with: sort_it_now.exe" diff --git a/src/api.rs b/src/api.rs index 24872c4..c2704d5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,7 +1,7 @@ -//! REST-API für den Packing-Service. +//! REST API for the packing service. //! -//! Bietet HTTP-Endpunkte zur Kommunikation mit dem Frontend. -//! Verwendet Axum als Web-Framework und unterstützt CORS. +//! Provides HTTP endpoints for communication with the frontend. +//! Uses Axum as the web framework and supports CORS. use axum::extract::rejection::JsonRejection; use axum::extract::{Json, State}; @@ -85,9 +85,9 @@ fn openapi_doc() -> &'static utoipa::openapi::OpenApi { #[folder = "web/"] struct WebAssets; -/// Request-Struktur für den Packing-Endpunkt. +/// Request structure for the packing endpoint. /// -/// `containers` enthält die möglichen Verpackungstypen, die kombiniert werden dürfen. +/// `containers` contains the possible packaging types that can be combined. #[derive(Deserialize, Clone, ToSchema)] pub struct ContainerRequest { pub name: Option, @@ -183,10 +183,10 @@ impl PackRequest { } } -/// Response-Struktur mit allen verpackten Containern. +/// Response structure with all packed containers. /// -/// # Felder -/// * `results` - Vector von Containern mit platzierten Objekten +/// # Fields +/// * `results` - Vector of containers with placed objects #[derive(Serialize, ToSchema)] pub struct PackResponse { pub results: Vec, @@ -195,12 +195,12 @@ pub struct PackResponse { pub diagnostics_summary: PackingDiagnosticsSummary, } -/// Einzelner Container mit Metadaten und platzierten Objekten. +/// Single container with metadata and placed objects. /// -/// # Felder -/// * `id` - Container-Nummer (1-basiert) -/// * `total_weight` - Gesamtgewicht aller Objekte im Container -/// * `placed` - Liste der platzierten Objekte mit Positionen +/// # Fields +/// * `id` - Container number (1-based) +/// * `total_weight` - Total weight of all objects in the container +/// * `placed` - List of placed objects with positions #[derive(Serialize, ToSchema)] pub struct PackedContainer { pub id: usize, @@ -214,13 +214,13 @@ pub struct PackedContainer { pub diagnostics: ContainerDiagnostics, } -/// Einzelnes platziertes Objekt in der Response. +/// Single placed object in the response. /// -/// # Felder -/// * `id` - Objekt-ID -/// * `pos` - Position (x, y, z) im Container -/// * `weight` - Gewicht in kg -/// * `dims` - Dimensionen (Breite, Tiefe, Höhe) +/// # Fields +/// * `id` - Object ID +/// * `pos` - Position (x, y, z) in the container +/// * `weight` - Weight in kg +/// * `dims` - Dimensions (width, depth, height) #[derive(Serialize, ToSchema)] pub struct PackedObject { pub id: usize, @@ -267,7 +267,7 @@ fn error_response( fn json_deserialize_error(err: JsonRejection) -> Response { error_response( StatusCode::UNPROCESSABLE_ENTITY, - "Ungültige JSON-Daten", + "Invalid JSON data", err.to_string(), ) } @@ -275,7 +275,7 @@ fn json_deserialize_error(err: JsonRejection) -> Response { fn validation_error(details: impl Into) -> Response { error_response( StatusCode::UNPROCESSABLE_ENTITY, - "Ungültige Eingabedaten", + "Invalid input data", details, ) } @@ -283,7 +283,7 @@ fn validation_error(details: impl Into) -> Response { fn container_config_error(details: impl Into) -> Response { error_response( StatusCode::UNPROCESSABLE_ENTITY, - "Ungültige Container-Konfiguration", + "Invalid container configuration", details, ) } @@ -299,7 +299,7 @@ fn parse_pack_request( match payload.into_validated() { Ok(validated) => Ok(validated), Err(PackRequestValidationError::MissingContainers) => Err(validation_error( - "Mindestens ein Verpackungstyp muss angegeben werden", + "At least one packaging type must be specified", )), Err(PackRequestValidationError::InvalidContainer(err)) => { Err(container_config_error(err.to_string())) @@ -311,7 +311,7 @@ fn parse_pack_request( } impl PackResponse { - /// Erstellt eine PackResponse aus einem PackingResult (DRY-Prinzip). + /// Creates a PackResponse from a PackingResult (DRY principle). pub fn from_packing_result(result: PackingResult) -> Self { let PackingResult { containers, @@ -394,14 +394,14 @@ impl PackResponse { PackingDiagnosticsSummary ) ), - tags((name = "packing", description = "Endpunkte zur Verpackungsoptimierung")) + tags((name = "packing", description = "Endpoints for packing optimization")) )] struct ApiDoc; -/// Startet den API-Server auf Port 8080. +/// Starts the API server on port 8080. /// -/// Konfiguriert CORS für Cross-Origin-Requests vom Frontend. -/// Blockiert bis der Server beendet wird. +/// Configures CORS for cross-origin requests from the frontend. +/// Blocks until the server is terminated. pub async fn start_api_server(config: ApiConfig, optimizer_config: OptimizerConfig) { let cors = CorsLayer::new() .allow_methods(Any) @@ -411,10 +411,10 @@ pub async fn start_api_server(config: ApiConfig, optimizer_config: OptimizerConf let state = ApiState { optimizer_config }; let app = Router::new() - // API-Endpunkte + // API endpoints .route("/pack", post(handle_pack)) .route("/pack_stream", post(handle_pack_stream)) - // API-Dokumentation + // API documentation .route("/docs/openapi.json", get(serve_openapi_json)) .route("/docs", get(serve_openapi_ui)) // Web-UI (embedded) @@ -427,50 +427,50 @@ pub async fn start_api_server(config: ApiConfig, optimizer_config: OptimizerConf let listener = match tokio::net::TcpListener::bind(addr).await { Ok(listener) => listener, Err(err) => { - panic!("❌ Konnte API-Server nicht auf {} binden: {}", addr, err); + panic!("❌ Could not bind API server to {}: {}", addr, err); } }; let display_host = config.display_host().to_string(); println!( - "🚀 Server läuft auf http://{}:{}", + "🚀 Server running on http://{}:{}", display_host, config.port() ); if config.binds_to_all_interfaces() && config.uses_default_host() { - println!("💡 Lokaler Zugriff: http://localhost:{}", config.port()); + println!("💡 Local access: http://localhost:{}", config.port()); } - println!("📦 API-Endpunkte:"); + println!("📦 API Endpoints:"); println!(" - POST /pack"); println!(" - POST /pack_stream"); - println!("📑 Dokumentation:"); + println!("📑 Documentation:"); println!(" - GET /docs"); println!(" - GET /docs/openapi.json"); println!("🌐 Web-UI: http://{}:{}", display_host, config.port()); if let Err(err) = axum::serve(listener, app).await { - eprintln!("❌ API-Server wurde mit einem Fehler beendet: {err}"); + eprintln!("❌ API server terminated with an error: {err}"); } } -/// Handler für POST /pack Endpunkt. +/// Handler for POST /pack endpoint. /// -/// Nimmt eine Liste von Objekten entgegen und verpackt sie optimal in Container. +/// Takes a list of objects and packs them optimally into containers. /// -/// # Parameter -/// * `payload` - JSON-Payload mit Container-Dimensionen und Objekten +/// # Parameters +/// * `payload` - JSON payload with container dimensions and objects /// -/// # Rückgabewert -/// JSON-Response mit allen benötigten Containern und platzierten Objekten +/// # Returns +/// JSON response with all required containers and placed objects #[utoipa::path( post, path = "/pack", request_body = PackRequest, responses( - (status = 200, description = "Erfolgreiche Verpackung der Objekte", body = PackResponse), + (status = 200, description = "Successfully packed objects", body = PackResponse), ( status = UNPROCESSABLE_ENTITY, - description = "Ungültige Anfrage oder Container-Konfiguration", + description = "Invalid request or container configuration", body = ErrorResponse ) ), @@ -490,7 +490,7 @@ async fn handle_pack( let (objects, container_blueprints, allow_rotations_override) = request.into_parts(); println!( - "📥 Neue Pack-Anfrage: {} Objekte, {} Verpackungstypen", + "📥 New pack request: {} objects, {} packaging types", object_count, container_count ); let mut packing_config = state.optimizer_config.packing_config(); @@ -499,7 +499,7 @@ async fn handle_pack( } let packing_result = pack_objects_with_config(objects, container_blueprints, packing_config); println!( - "📦 Ergebnis: {} Container, {} unverpackte Objekte", + "📦 Result: {} containers, {} unpacked objects", packing_result.container_count(), packing_result.unplaced_count() ); @@ -508,10 +508,10 @@ async fn handle_pack( (StatusCode::OK, Json(response)).into_response() } -/// Handler für POST /pack_stream Endpunkt (SSE). +/// Handler for POST /pack_stream endpoint (SSE). /// -/// Streamt die Pack-Events in Echtzeit als Server-Sent Events (text/event-stream). -/// Das Frontend kann die Schritte live visualisieren, ohne auf das Gesamtergebnis zu warten. +/// Streams pack events in real-time as Server-Sent Events (text/event-stream). +/// The frontend can visualize the steps live without waiting for the complete result. #[utoipa::path( post, path = "/pack_stream", @@ -519,13 +519,13 @@ async fn handle_pack( responses( ( status = 200, - description = "Streamt Pack-Events in Echtzeit", + description = "Streams pack events in real-time", content_type = "text/event-stream", body = String ), ( status = UNPROCESSABLE_ENTITY, - description = "Ungültige Anfrage oder Container-Konfiguration", + description = "Invalid request or container configuration", body = ErrorResponse ) ), @@ -553,7 +553,7 @@ async fn handle_pack_stream( let _ = pack_objects_with_progress(objects, container_blueprints, packing_config, |evt| { if let Ok(json) = serde_json::to_string(evt) { if tx.blocking_send(json).is_err() { - // Empfänger hat den Stream geschlossen; verbleibende Events werden verworfen. + // Receiver has closed the stream; remaining events are discarded. return; } } @@ -571,7 +571,7 @@ async fn handle_pack_stream( .into_response() } -/// Serviert die index.html Hauptseite +/// Serves the index.html main page async fn serve_index() -> Response { match WebAssets::get("index.html") { Some(content) => Html(content.data).into_response(), @@ -579,7 +579,7 @@ async fn serve_index() -> Response { } } -/// Serviert statische Assets (JS, CSS, etc.) +/// Serves static assets (JS, CSS, etc.) async fn serve_static(uri: Uri) -> Response { let path = uri.path().trim_start_matches('/'); @@ -610,11 +610,11 @@ mod tests { let paths = &doc.paths.paths; assert!( paths.contains_key("/pack"), - "OpenAPI-Dokumentation fehlt der /pack Pfad" + "OpenAPI documentation is missing the /pack path" ); assert!( paths.contains_key("/pack_stream"), - "OpenAPI-Dokumentation fehlt der /pack_stream Pfad" + "OpenAPI documentation is missing the /pack_stream path" ); } @@ -624,12 +624,12 @@ mod tests { let components = doc .components .as_ref() - .expect("OpenAPI-Dokumentation enthält keine Components"); + .expect("OpenAPI documentation contains no components"); let schemas = &components.schemas; for name in ["PackRequest", "PackResponse", "ErrorResponse"] { assert!( schemas.contains_key(name), - "Erwartetes Schema '{}' fehlt im OpenAPI-Spec", + "Expected schema '{}' is missing from OpenAPI spec", name ); } diff --git a/src/config.rs b/src/config.rs index c06dd82..f22c630 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,7 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use crate::optimizer::PackingConfig; -/// Gesamte Anwendungskonfiguration, geladen aus Umgebungsvariablen oder Defaultwerten. +/// Complete application configuration, loaded from environment variables or default values. #[derive(Clone, Debug)] pub struct AppConfig { pub api: ApiConfig, @@ -12,7 +12,7 @@ pub struct AppConfig { } impl AppConfig { - /// Erstellt eine Konfiguration aus den aktuell verfügbaren Umgebungsvariablen. + /// Creates a configuration from the currently available environment variables. pub fn from_env() -> Self { Self { api: ApiConfig::from_env(), @@ -22,7 +22,7 @@ impl AppConfig { } } -/// Konfiguration für den API-Server. +/// Configuration for the API server. #[derive(Clone, Debug)] pub struct ApiConfig { bind_ip: IpAddr, @@ -41,7 +41,7 @@ impl ApiConfig { Ok(ip) => (ip, host_value), Err(err) => { eprintln!( - "⚠️ Konnte SORT_IT_NOW_API_HOST ('{}') nicht parsen: {}. Verwende {}.", + "⚠️ Could not parse SORT_IT_NOW_API_HOST ('{}'): {}. Using {}.", host_value, err, Self::DEFAULT_HOST @@ -49,7 +49,7 @@ impl ApiConfig { ( Self::DEFAULT_HOST .parse::() - .expect("Default-Host muss gültig sein"), + .expect("Default host must be valid"), Self::DEFAULT_HOST.to_string(), ) } @@ -60,14 +60,14 @@ impl ApiConfig { Ok(value) if value != 0 => value, Ok(_) => { eprintln!( - "⚠️ SORT_IT_NOW_API_PORT darf nicht 0 sein. Verwende {}.", + "⚠️ SORT_IT_NOW_API_PORT must not be 0. Using {}.", Self::DEFAULT_PORT ); Self::DEFAULT_PORT } Err(err) => { eprintln!( - "⚠️ Konnte SORT_IT_NOW_API_PORT ('{}') nicht parsen: {}. Verwende {}.", + "⚠️ Could not parse SORT_IT_NOW_API_PORT ('{}'): {}. Using {}.", raw, err, Self::DEFAULT_PORT @@ -85,22 +85,22 @@ impl ApiConfig { } } - /// Socket-Adresse, an die der Server gebunden werden soll. + /// Socket address to bind the server to. pub fn socket_addr(&self) -> SocketAddr { SocketAddr::new(self.bind_ip, self.port) } - /// Sichtbarer Hostname für Logging und Hinweise. + /// Visible hostname for logging and hints. pub fn display_host(&self) -> &str { &self.display_host } - /// Konfigurierter Port. + /// Configured port. pub fn port(&self) -> u16 { self.port } - /// Gibt an, ob auf alle Interfaces gebunden wird. + /// Indicates whether binding to all interfaces. pub fn binds_to_all_interfaces(&self) -> bool { match self.bind_ip { IpAddr::V4(addr) => addr == Ipv4Addr::UNSPECIFIED, @@ -108,13 +108,13 @@ impl ApiConfig { } } - /// Prüft, ob der Hostname dem Standardwert entspricht. + /// Checks whether the hostname matches the default value. pub fn uses_default_host(&self) -> bool { self.display_host == Self::DEFAULT_HOST } } -/// Konfiguration für den Updater. +/// Configuration for the updater. #[derive(Clone, Debug)] pub struct UpdateConfig { owner: String, @@ -134,17 +134,17 @@ impl UpdateConfig { } } - /// GitHub Owner (Organisation oder Benutzer), von dem die Releases stammen. + /// GitHub owner (organization or user) from which releases originate. pub fn owner(&self) -> &str { &self.owner } - /// GitHub Repositoryname, von dem Releases geladen werden. + /// GitHub repository name from which releases are loaded. pub fn repo(&self) -> &str { &self.repo } - /// Liefert die URL, unter der das aktuellste Release abgefragt wird. + /// Returns the URL where the latest release is queried. pub fn latest_release_endpoint(&self) -> String { format!( "https://api.github.com/repos/{owner}/{repo}/releases/latest", @@ -154,7 +154,7 @@ impl UpdateConfig { } } -/// Konfiguration für die heuristische Pack-Optimierung. +/// Configuration for heuristic pack optimization. #[derive(Clone, Debug)] pub struct OptimizerConfig { packing: PackingConfig, @@ -174,40 +174,40 @@ impl OptimizerConfig { Self::GRID_STEP_VAR, PackingConfig::DEFAULT_GRID_STEP, |value| value > 0.0, - "muss größer als 0 sein", - "Warnung: Angepasste Raster-Schrittweite kann die Pack-Stabilität beeinträchtigen", + "must be greater than 0", + "Warning: Adjusted grid step size may affect packing stability", ); let support_ratio = load_f64_with_warning( Self::SUPPORT_RATIO_VAR, PackingConfig::DEFAULT_SUPPORT_RATIO, |value| (0.0..=1.0).contains(&value), - "muss zwischen 0 und 1 liegen", - "Warnung: Angepasste Mindestauflage kann zu instabilen Stapeln führen", + "must be between 0 and 1", + "Warning: Adjusted minimum support may lead to unstable stacks", ); let height_epsilon = load_f64_with_warning( Self::HEIGHT_EPSILON_VAR, PackingConfig::DEFAULT_HEIGHT_EPSILON, |value| value > 0.0, - "muss größer als 0 sein", - "Warnung: Angepasste Höhen-Toleranz kann unerwartete Platzierungen verursachen", + "must be greater than 0", + "Warning: Adjusted height tolerance may cause unexpected placements", ); let general_epsilon = load_f64_with_warning( Self::GENERAL_EPSILON_VAR, PackingConfig::DEFAULT_GENERAL_EPSILON, |value| value > 0.0, - "muss größer als 0 sein", - "Warnung: Angepasste Toleranzen können numerische Instabilitäten hervorrufen", + "must be greater than 0", + "Warning: Adjusted tolerances may cause numerical instabilities", ); let balance_limit_ratio = load_f64_with_warning( Self::BALANCE_RATIO_VAR, PackingConfig::DEFAULT_BALANCE_LIMIT_RATIO, |value| (0.0..=1.0).contains(&value), - "muss zwischen 0 und 1 liegen", - "Warnung: Angepasste Balance-Grenzen können zum Umkippen von Stapeln führen", + "must be between 0 and 1", + "Warning: Adjusted balance limits may cause stacks to tip over", ); let footprint_cluster_tolerance = load_f64_with_warning( @@ -215,8 +215,8 @@ impl OptimizerConfig { PackingConfig::DEFAULT_FOOTPRINT_CLUSTER_TOLERANCE, // Values above 0.5 would group excessively dissimilar footprints, defeating the clustering purpose. |value| (0.0..=0.5).contains(&value), - "muss zwischen 0 und 0.5 liegen", - "Warnung: Angepasste Footprint-Gruppierung kann zu unerwarteten Platzierungen führen", + "must be between 0 and 0.5", + "Warning: Adjusted footprint grouping may lead to unexpected placements", ); let allow_item_rotation = env_string(Self::ALLOW_ROTATION_VAR) @@ -236,7 +236,7 @@ impl OptimizerConfig { Self { packing } } - /// Liefert die konfigurierte PackingConfig. + /// Returns the configured PackingConfig. pub fn packing_config(&self) -> PackingConfig { self.packing } @@ -255,7 +255,7 @@ fn env_string(name: &str) -> Option { Err(env::VarError::NotPresent) => None, Err(err) => { eprintln!( - "⚠️ Zugriff auf {} fehlgeschlagen: {}. Verwende Standardwert.", + "⚠️ Access to {} failed: {}. Using default value.", name, err ); None @@ -269,7 +269,7 @@ fn parse_bool(raw: &str, var_name: &str) -> Option { "0" | "false" | "no" | "n" | "off" => Some(false), other => { eprintln!( - "⚠️ Konnte {} ('{}') nicht als booleschen Wert interpretieren. Verwende Standardwert.", + "⚠️ Could not interpret {} ('{}') as boolean value. Using default value.", var_name, other ); None @@ -289,7 +289,7 @@ fn load_f64_with_warning( Ok(value) => { if !validator(value) { eprintln!( - "⚠️ {} enthält ungültigen Wert '{}': {}. Verwende {}.", + "⚠️ {} contains invalid value '{}': {}. Using {}.", var_name, raw, invalid_hint, default ); default @@ -303,7 +303,7 @@ fn load_f64_with_warning( } Err(err) => { eprintln!( - "⚠️ Konnte {} ('{}') nicht als Zahl parsen: {}. Verwende {}.", + "⚠️ Could not parse {} ('{}') as number: {}. Using {}.", var_name, raw, err, default ); default diff --git a/src/geometry.rs b/src/geometry.rs index 9ebcdc7..cad9e95 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1,36 +1,52 @@ -//! Geometrische Hilfsfunktionen für 3D-Kollisionserkennung und Raumplanung. +//! Geometric helper functions for 3D collision detection and spatial planning. //! -//! Dieses Modul bietet Funktionen zur Überprüfung von Überschneidungen zwischen -//! platzierten Objekten und zur Berechnung von Überlappungen in verschiedenen Dimensionen. +//! This module provides functions for checking intersections between +//! placed objects and for calculating overlaps in various dimensions. +//! +//! ## Design Principles +//! +//! - **Backward compatibility**: All existing functions are preserved +//! - **OOP extensions**: New functions use traits from `types.rs` +//! - **Performance**: Critical paths are inline-optimized use crate::model::PlacedBox; +use crate::types::{BoundingBox, EPSILON_GENERAL, Vec3}; -/// Prüft, ob zwei platzierte Objekte sich räumlich überschneiden. +/// Checks if two placed objects spatially intersect. /// -/// Verwendet Axis-Aligned Bounding Box (AABB) Kollisionserkennung. -/// Zwei Boxen überschneiden sich NICHT, wenn sie in mindestens einer Achse getrennt sind. +/// Uses Axis-Aligned Bounding Box (AABB) collision detection. +/// Two boxes do NOT intersect if they are separated in at least one axis. /// -/// # Parameter -/// * `a` - Erstes platziertes Objekt -/// * `b` - Zweites platziertes Objekt +/// # Algorithm +/// Implements the Separating Axis Theorem (SAT) for AABBs: +/// Two convex objects do NOT intersect if and only if there exists an axis +/// on which their projections are separated. /// -/// # Rückgabewert -/// `true` wenn sich die Objekte überschneiden, sonst `false` +/// # Parameters +/// * `a` - First placed object +/// * `b` - Second placed object /// -/// # Beispiel -/// ``` -/// let box1 = PlacedBox { position: (0.0, 0.0, 0.0), ... }; -/// let box2 = PlacedBox { position: (5.0, 0.0, 0.0), ... }; +/// # Returns +/// `true` if the objects intersect, otherwise `false` +/// +/// # Complexity +/// O(1) - Constant time +/// +/// # Example +/// ```ignore +/// let box1 = PlacedBox::new(box_a, (0.0, 0.0, 0.0)); +/// let box2 = PlacedBox::new(box_b, (5.0, 0.0, 0.0)); /// let collision = intersects(&box1, &box2); /// ``` +#[inline] pub fn intersects(a: &PlacedBox, b: &PlacedBox) -> bool { let (ax, ay, az) = a.position; let (aw, ad, ah) = a.object.dims; let (bx, by, bz) = b.position; let (bw, bd, bh) = b.object.dims; - // Separating Axis Theorem: Objekte überschneiden sich NICHT, wenn - // sie in irgendeiner Achse vollständig getrennt sind + // Separating Axis Theorem: Objects do NOT intersect if + // they are completely separated in any axis !(ax + aw <= bx || bx + bw <= ax || ay + ad <= by @@ -39,33 +55,51 @@ pub fn intersects(a: &PlacedBox, b: &PlacedBox) -> bool { || bz + bh <= az) } -/// Berechnet die Überlappung zweier Intervalle in einer Dimension. +/// Alternative collision check with BoundingBox types (OOP version). /// -/// # Parameter -/// * `a1` - Start des ersten Intervalls -/// * `a2` - Ende des ersten Intervalls -/// * `b1` - Start des zweiten Intervalls -/// * `b2` - Ende des zweiten Intervalls +/// Uses the `BoundingBox` structure from the `types` module for better type safety. /// -/// # Rückgabewert -/// Länge der Überlappung, mindestens 0.0 +/// # Parameters +/// * `a` - First bounding box +/// * `b` - Second bounding box /// -/// # Beispiel -/// ``` -/// let overlap = overlap_1d(0.0, 5.0, 3.0, 8.0); // Ergebnis: 2.0 +/// # Returns +/// `true` if the boxes intersect +#[inline] +#[allow(dead_code)] +pub fn bounding_boxes_intersect(a: &BoundingBox, b: &BoundingBox) -> bool { + a.intersects(b) +} + +/// Calculates the overlap of two intervals in one dimension. +/// +/// # Parameters +/// * `a1` - Start of the first interval +/// * `a2` - End of the first interval +/// * `b1` - Start of the second interval +/// * `b2` - End of the second interval +/// +/// # Returns +/// Length of the overlap, at least 0.0 +/// +/// # Example +/// ```ignore +/// let overlap = overlap_1d(0.0, 5.0, 3.0, 8.0); // Result: 2.0 +/// let no_overlap = overlap_1d(0.0, 3.0, 5.0, 8.0); // Result: 0.0 /// ``` +#[inline] pub fn overlap_1d(a1: f64, a2: f64, b1: f64, b2: f64) -> f64 { (a2.min(b2) - a1.max(b1)).max(0.0) } -/// Berechnet die Überlappungsfläche zweier Rechtecke in der XY-Ebene. +/// Calculates the overlap area of two rectangles in the XY plane. /// -/// # Parameter -/// * `a` - Erstes platziertes Objekt -/// * `b` - Zweites platziertes Objekt +/// # Parameters +/// * `a` - First placed object +/// * `b` - Second placed object /// -/// # Rückgabewert -/// Fläche der Überlappung in der XY-Ebene +/// # Returns +/// Area of overlap in the XY plane #[allow(dead_code)] pub fn overlap_area_xy(a: &PlacedBox, b: &PlacedBox) -> f64 { let overlap_x = overlap_1d( @@ -83,14 +117,15 @@ pub fn overlap_area_xy(a: &PlacedBox, b: &PlacedBox) -> f64 { overlap_x * overlap_y } -/// Prüft, ob ein Punkt innerhalb eines Objekts liegt. +/// Checks if a point is inside an object. /// -/// # Parameter -/// * `point` - Der zu prüfende Punkt (x, y, z) -/// * `placed_box` - Das platzierte Objekt +/// # Parameters +/// * `point` - The point to check (x, y, z) +/// * `placed_box` - The placed object /// -/// # Rückgabewert -/// `true` wenn der Punkt innerhalb des Objekts liegt +/// # Returns +/// `true` if the point is inside the object +#[inline] pub fn point_inside(point: (f64, f64, f64), placed_box: &PlacedBox) -> bool { let (px, py, pz) = point; let (bx, by, bz) = placed_box.position; @@ -98,3 +133,199 @@ pub fn point_inside(point: (f64, f64, f64), placed_box: &PlacedBox) -> bool { px >= bx && px <= bx + bw && py >= by && py <= by + bd && pz >= bz && pz <= bz + bh } + +/// Checks if a Vec3 point is inside a BoundingBox (OOP version). +/// +/// # Parameters +/// * `point` - The point to check +/// * `bounds` - The bounding box +/// +/// # Returns +/// `true` if the point is inside the box +#[inline] +#[allow(dead_code)] +pub fn point_inside_bounds(point: &Vec3, bounds: &BoundingBox) -> bool { + bounds.contains_point(point) +} + +/// Checks if a box rests on another box (e.g., for stability checking). +/// +/// A box rests on another if: +/// 1. The bottom of the upper box touches the top of the lower (within tolerance) +/// 2. There is XY overlap +/// +/// # Parameters +/// * `upper` - The upper box +/// * `lower` - The lower (supporting) box +/// * `height_epsilon` - Tolerance for height comparisons +/// +/// # Returns +/// `true` if `upper` rests on `lower` +#[inline] +#[allow(dead_code)] +pub fn rests_on(upper: &PlacedBox, lower: &PlacedBox, height_epsilon: f64) -> bool { + let upper_bottom = upper.position.2; + let lower_top = lower.position.2 + lower.object.dims.2; + + // Check if heights match + if (upper_bottom - lower_top).abs() > height_epsilon { + return false; + } + + // Check XY overlap + let overlap_x = overlap_1d( + upper.position.0, + upper.position.0 + upper.object.dims.0, + lower.position.0, + lower.position.0 + lower.object.dims.0, + ); + let overlap_y = overlap_1d( + upper.position.1, + upper.position.1 + upper.object.dims.1, + lower.position.1, + lower.position.1 + lower.object.dims.1, + ); + + overlap_x > EPSILON_GENERAL && overlap_y > EPSILON_GENERAL +} + +/// Calculates the support area between two boxes. +/// +/// Returns the area with which `upper` rests on `lower`. +/// Returns 0.0 if the boxes are not in contact. +/// +/// # Parameters +/// * `upper` - The upper box +/// * `lower` - The lower box +/// * `height_epsilon` - Tolerance for height comparisons +/// +/// # Returns +/// Overlap area in the XY plane +#[inline] +#[allow(dead_code)] +pub fn support_area(upper: &PlacedBox, lower: &PlacedBox, height_epsilon: f64) -> f64 { + let upper_bottom = upper.position.2; + let lower_top = lower.position.2 + lower.object.dims.2; + + // Check if heights match + if (upper_bottom - lower_top).abs() > height_epsilon { + return 0.0; + } + + overlap_area_xy(upper, lower) +} + +/// Calculates the center of mass of a set of points in the XY plane. +/// +/// Useful for stability calculations and balance checks. +/// +/// # Parameters +/// * `points` - Iterator over (x, y, weight) tuples +/// +/// # Returns +/// `Some((cx, cy))` if total weight > 0, otherwise `None` +#[allow(dead_code)] +pub fn center_of_mass_xy(points: I) -> Option<(f64, f64)> +where + I: IntoIterator, +{ + let mut total_weight = 0.0; + let mut weighted_x = 0.0; + let mut weighted_y = 0.0; + + for (x, y, weight) in points { + total_weight += weight; + weighted_x += x * weight; + weighted_y += y * weight; + } + + if total_weight <= 0.0 { + None + } else { + Some((weighted_x / total_weight, weighted_y / total_weight)) + } +} + +/// Calculates the Euclidean 2D distance between two points. +/// +/// # Parameters +/// * `a` - First point (x, y) +/// * `b` - Second point (x, y) +/// +/// # Returns +/// Euclidean distance +#[inline] +#[allow(dead_code)] +pub fn distance_2d(a: (f64, f64), b: (f64, f64)) -> f64 { + let dx = a.0 - b.0; + let dy = a.1 - b.1; + (dx * dx + dy * dy).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Box3D; + + fn make_placed_box(id: usize, pos: (f64, f64, f64), dims: (f64, f64, f64)) -> PlacedBox { + PlacedBox { + object: Box3D { + id, + dims, + weight: 1.0, + }, + position: pos, + } + } + + #[test] + fn test_intersects_overlapping_boxes() { + let a = make_placed_box(1, (0.0, 0.0, 0.0), (10.0, 10.0, 10.0)); + let b = make_placed_box(2, (5.0, 5.0, 5.0), (10.0, 10.0, 10.0)); + assert!(intersects(&a, &b)); + } + + #[test] + fn test_intersects_separated_boxes() { + let a = make_placed_box(1, (0.0, 0.0, 0.0), (10.0, 10.0, 10.0)); + let b = make_placed_box(2, (20.0, 0.0, 0.0), (10.0, 10.0, 10.0)); + assert!(!intersects(&a, &b)); + } + + #[test] + fn test_overlap_1d() { + assert!((overlap_1d(0.0, 5.0, 3.0, 8.0) - 2.0).abs() < EPSILON_GENERAL); + assert!((overlap_1d(0.0, 3.0, 5.0, 8.0) - 0.0).abs() < EPSILON_GENERAL); + assert!((overlap_1d(0.0, 10.0, 2.0, 8.0) - 6.0).abs() < EPSILON_GENERAL); + } + + #[test] + fn test_point_inside() { + let box_ = make_placed_box(1, (0.0, 0.0, 0.0), (10.0, 10.0, 10.0)); + assert!(point_inside((5.0, 5.0, 5.0), &box_)); + assert!(!point_inside((15.0, 5.0, 5.0), &box_)); + } + + #[test] + fn test_rests_on() { + let lower = make_placed_box(1, (0.0, 0.0, 0.0), (10.0, 10.0, 10.0)); + let upper = make_placed_box(2, (0.0, 0.0, 10.0), (10.0, 10.0, 10.0)); + let separate = make_placed_box(3, (20.0, 0.0, 10.0), (10.0, 10.0, 10.0)); + + assert!(rests_on(&upper, &lower, 1e-3)); + assert!(!rests_on(&separate, &lower, 1e-3)); + } + + #[test] + fn test_center_of_mass_xy() { + let points = vec![(0.0, 0.0, 10.0), (10.0, 0.0, 10.0)]; + let center = center_of_mass_xy(points).unwrap(); + assert!((center.0 - 5.0).abs() < EPSILON_GENERAL); + assert!((center.1 - 0.0).abs() < EPSILON_GENERAL); + } + + #[test] + fn test_distance_2d() { + assert!((distance_2d((0.0, 0.0), (3.0, 4.0)) - 5.0).abs() < EPSILON_GENERAL); + } +} diff --git a/src/main.rs b/src/main.rs index dfa1287..2336f30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,18 @@ // src/main.rs +//! Sort-it-now: 3D Packing Optimization Service +//! +//! A high-performance Rust service for solving the bin-packing problem. +//! Efficiently places cuboids into containers considering: +//! - Weight limits and distribution +//! - Stability and center of gravity balance +//! - Layering (heavy objects at the bottom) + mod api; mod config; mod geometry; mod model; mod optimizer; +pub mod types; mod update; use config::AppConfig; @@ -13,7 +22,7 @@ async fn main() { if let Err(err) = dotenvy::dotenv() { if !matches!(err, dotenvy::Error::Io(ref io_err) if io_err.kind() == std::io::ErrorKind::NotFound) { - eprintln!("⚠️ Konnte .env nicht laden: {}", err); + eprintln!("⚠️ Could not load .env: {}", err); } } @@ -22,7 +31,7 @@ async fn main() { let update_config = app_config.update.clone(); let optimizer_config = app_config.optimizer.clone(); - println!("🚀 Packing Service startet..."); + println!("🚀 Packing Service starting..."); let _update_task = update::check_for_updates_background(update_config); api::start_api_server(api_config, optimizer_config).await; } diff --git a/src/model.rs b/src/model.rs index f3c01e8..4160731 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,16 +1,20 @@ -//! Datenmodelle für die Box-Packing-Simulation. +//! Data models for the box packing simulation. //! -//! Dieses Modul definiert die grundlegenden Datenstrukturen für die 3D-Verpackungsoptimierung: -//! - `Box3D`: Repräsentiert ein zu verpackendes Objekt mit Abmessungen und Gewicht -//! - `PlacedBox`: Ein Objekt mit seiner Position im Container -//! - `Container`: Der Verpackungsbehälter mit Kapazitätsgrenzen +//! This module defines the fundamental data structures for 3D packing optimization: +//! - `Box3D`: Represents an object to be packed with dimensions and weight +//! - `PlacedBox`: An object with its position in the container +//! - `Container`: The packing container with capacity limits +//! +//! All structures implement the traits from the `types` module for OOP compliance. use serde::{Deserialize, Serialize}; #[allow(unused_imports)] use serde_json::json; use utoipa::ToSchema; -/// Validierungsfehler für Objektdaten. +use crate::types::{BoundingBox, Dimensional, EPSILON_GENERAL, Positioned, Vec3, Weighted}; + +/// Validation error for object data. #[derive(Debug, Clone)] pub enum ValidationError { InvalidDimension(String), @@ -22,10 +26,10 @@ pub enum ValidationError { impl std::fmt::Display for ValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ValidationError::InvalidDimension(msg) => write!(f, "Ungültige Dimension: {}", msg), - ValidationError::InvalidWeight(msg) => write!(f, "Ungültiges Gewicht: {}", msg), + ValidationError::InvalidDimension(msg) => write!(f, "Invalid dimension: {}", msg), + ValidationError::InvalidWeight(msg) => write!(f, "Invalid weight: {}", msg), ValidationError::InvalidConfiguration(msg) => { - write!(f, "Ungültige Konfiguration: {}", msg) + write!(f, "Invalid configuration: {}", msg) } } } @@ -33,12 +37,53 @@ impl std::fmt::Display for ValidationError { impl std::error::Error for ValidationError {} -/// Repräsentiert ein 3D-Objekt, das verpackt werden soll. +/// Helper function to validate a single dimension (DRY principle). +fn validate_dimension(value: f64, name: &str) -> Result<(), ValidationError> { + if value <= 0.0 || value.is_nan() || value.is_infinite() { + return Err(ValidationError::InvalidDimension(format!( + "{} must be positive, got: {}", + name, value + ))); + } + Ok(()) +} + +/// Helper function to validate weight (DRY principle). +fn validate_weight_value(value: f64) -> Result<(), ValidationError> { + if value <= 0.0 || value.is_nan() || value.is_infinite() { + return Err(ValidationError::InvalidWeight(format!( + "Weight must be positive, got: {}", + value + ))); + } + Ok(()) +} + +/// Validates dimensions and weight together (DRY principle). +fn validate_box_params(dims: (f64, f64, f64), weight: f64) -> Result<(), ValidationError> { + validate_dimension(dims.0, "Width")?; + validate_dimension(dims.1, "Depth")?; + validate_dimension(dims.2, "Height")?; + validate_weight_value(weight)?; + Ok(()) +} + +/// Validates container dimensions (DRY principle). +fn validate_container_dims(dims: (f64, f64, f64)) -> Result<(), ValidationError> { + validate_dimension(dims.0, "Container width")?; + validate_dimension(dims.1, "Container depth")?; + validate_dimension(dims.2, "Container height")?; + Ok(()) +} + +/// Represents a 3D object to be packed. +/// +/// Implements the `Dimensional` and `Weighted` traits for OOP compliance. /// -/// # Felder -/// * `id` - Eindeutige Identifikationsnummer des Objekts -/// * `dims` - Dimensionen (Breite, Tiefe, Höhe) in Einheiten -/// * `weight` - Gewicht des Objekts in kg +/// # Fields +/// * `id` - Unique identification number of the object +/// * `dims` - Dimensions (width, depth, height) in units +/// * `weight` - Weight of the object in kg #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct Box3D { pub id: usize, @@ -48,71 +93,78 @@ pub struct Box3D { } impl Box3D { - /// Erstellt ein neues Box3D-Objekt mit Validierung. + /// Creates a new Box3D object with validation. + /// + /// # Parameters + /// * `id` - Unique ID + /// * `dims` - Dimensions (width, depth, height) + /// * `weight` - Weight in kg + /// + /// # Returns + /// `Ok(Box3D)` for valid values, otherwise `Err(ValidationError)` + /// + /// # Examples + /// ``` + /// use sort_it_now::model::Box3D; /// - /// # Parameter - /// * `id` - Eindeutige ID - /// * `dims` - Dimensionen (Breite, Tiefe, Höhe) - /// * `weight` - Gewicht in kg + /// let box_ok = Box3D::new(1, (10.0, 20.0, 30.0), 5.0); + /// assert!(box_ok.is_ok()); /// - /// # Rückgabewert - /// `Ok(Box3D)` bei gültigen Werten, sonst `Err(ValidationError)` + /// let box_invalid = Box3D::new(1, (-10.0, 20.0, 30.0), 5.0); + /// assert!(box_invalid.is_err()); + /// ``` pub fn new(id: usize, dims: (f64, f64, f64), weight: f64) -> Result { - let (w, d, h) = dims; - - if w <= 0.0 || w.is_nan() || w.is_infinite() { - return Err(ValidationError::InvalidDimension(format!( - "Breite muss positiv sein, erhalten: {}", - w - ))); - } - if d <= 0.0 || d.is_nan() || d.is_infinite() { - return Err(ValidationError::InvalidDimension(format!( - "Tiefe muss positiv sein, erhalten: {}", - d - ))); - } - if h <= 0.0 || h.is_nan() || h.is_infinite() { - return Err(ValidationError::InvalidDimension(format!( - "Höhe muss positiv sein, erhalten: {}", - h - ))); - } - if weight <= 0.0 || weight.is_nan() || weight.is_infinite() { - return Err(ValidationError::InvalidWeight(format!( - "Gewicht muss positiv sein, erhalten: {}", - weight - ))); - } - + validate_box_params(dims, weight)?; Ok(Self { id, dims, weight }) } - /// Berechnet das Volumen des Objekts. + /// Calculates the volume of the object. /// - /// # Rückgabewert - /// Das Volumen als Produkt von Breite × Tiefe × Höhe + /// # Returns + /// The volume as the product of width × depth × height pub fn volume(&self) -> f64 { let (w, d, h) = self.dims; w * d * h } - /// Gibt die Grundfläche des Objekts zurück. + /// Returns the base area of the object. /// - /// # Rückgabewert - /// Die Grundfläche als Produkt von Breite × Tiefe + /// # Returns + /// The base area as the product of width × depth #[allow(dead_code)] pub fn base_area(&self) -> f64 { let (w, d, _) = self.dims; w * d } + + /// Converts the dimensions to a Vec3. + #[inline] + pub fn dims_as_vec3(&self) -> Vec3 { + Vec3::from_tuple(self.dims) + } +} + +/// Implementation of the Dimensional trait for Box3D. +impl Dimensional for Box3D { + fn dimensions(&self) -> Vec3 { + self.dims_as_vec3() + } } -/// Ein platziertes Objekt mit seiner Position im Container. +/// Implementation of the Weighted trait for Box3D. +impl Weighted for Box3D { + fn weight(&self) -> f64 { + self.weight + } +} + +/// A placed object with its position in the container. +/// +/// Implements `Positioned`, `Dimensional` and `Weighted` traits for OOP compliance. /// -/// # Felder -/// * `object` - Das ursprüngliche Box3D-Objekt -/// * `position` - Position (x, y, z) der unteren linken Ecke im Container +/// # Fields +/// * `object` - The original Box3D object +/// * `position` - Position (x, y, z) of the lower left corner in the container #[derive(Clone, Debug)] pub struct PlacedBox { pub object: Box3D, @@ -120,19 +172,29 @@ pub struct PlacedBox { } impl PlacedBox { - /// Gibt die obere Z-Koordinate des platzierten Objekts zurück. + /// Creates a new PlacedBox object. /// - /// # Rückgabewert - /// Z-Position + Höhe des Objekts + /// # Parameters + /// * `object` - The Box3D object to place + /// * `position` - Position (x, y, z) in the container + #[allow(dead_code)] + pub fn new(object: Box3D, position: (f64, f64, f64)) -> Self { + Self { object, position } + } + + /// Returns the top Z coordinate of the placed object. + /// + /// # Returns + /// Z position + height of the object #[allow(dead_code)] pub fn top_z(&self) -> f64 { self.position.2 + self.object.dims.2 } - /// Gibt den Schwerpunkt des platzierten Objekts zurück. + /// Returns the center of mass of the placed object. /// - /// # Rückgabewert - /// Tuple mit (center_x, center_y, center_z) + /// # Returns + /// Tuple with (center_x, center_y, center_z) #[allow(dead_code)] pub fn center(&self) -> (f64, f64, f64) { ( @@ -141,14 +203,64 @@ impl PlacedBox { self.position.2 + self.object.dims.2 / 2.0, ) } + + /// Returns the center of mass as Vec3. + #[inline] + #[allow(dead_code)] + pub fn center_vec3(&self) -> Vec3 { + Vec3::new( + self.position.0 + self.object.dims.0 / 2.0, + self.position.1 + self.object.dims.1 / 2.0, + self.position.2 + self.object.dims.2 / 2.0, + ) + } + + /// Calculates the bounding box of the placed object. + /// + /// Useful for collision detection and overlap calculation. + #[inline] + #[allow(dead_code)] + pub fn bounding_box(&self) -> BoundingBox { + BoundingBox::from_position_and_dims( + Vec3::from_tuple(self.position), + self.object.dims_as_vec3(), + ) + } + + /// Converts the position to a Vec3. + #[inline] + pub fn position_vec3(&self) -> Vec3 { + Vec3::from_tuple(self.position) + } +} + +/// Implementation of the Positioned trait for PlacedBox. +impl Positioned for PlacedBox { + fn position(&self) -> Vec3 { + self.position_vec3() + } } -/// Repräsentiert einen Verpackungsbehälter mit Kapazitätsgrenzen. +/// Implementation of the Dimensional trait for PlacedBox. +impl Dimensional for PlacedBox { + fn dimensions(&self) -> Vec3 { + self.object.dims_as_vec3() + } +} + +/// Implementation of the Weighted trait for PlacedBox. +impl Weighted for PlacedBox { + fn weight(&self) -> f64 { + self.object.weight + } +} + +/// Represents a packing container with capacity limits. /// -/// # Felder -/// * `dims` - Dimensionen (Breite, Tiefe, Höhe) des Containers -/// * `max_weight` - Maximales Gesamtgewicht in kg -/// * `placed` - Liste der bereits platzierten Objekte +/// # Fields +/// * `dims` - Dimensions (width, depth, height) of the container +/// * `max_weight` - Maximum total weight in kg +/// * `placed` - List of already placed objects #[derive(Clone, Debug)] pub struct Container { pub dims: (f64, f64, f64), @@ -159,41 +271,21 @@ pub struct Container { } impl Container { - /// Erstellt einen neuen leeren Container mit Validierung. + /// Creates a new empty container with validation. + /// + /// Uses the shared validation logic (DRY principle). /// - /// # Parameter - /// * `dims` - Dimensionen (Breite, Tiefe, Höhe) - /// * `max_weight` - Maximales Gewicht + /// # Parameters + /// * `dims` - Dimensions (width, depth, height) + /// * `max_weight` - Maximum weight /// - /// # Rückgabewert - /// `Ok(Container)` bei gültigen Werten, sonst `Err(ValidationError)` + /// # Returns + /// `Ok(Container)` for valid values, otherwise `Err(ValidationError)` + #[allow(dead_code)] pub fn new(dims: (f64, f64, f64), max_weight: f64) -> Result { - let (w, d, h) = dims; - - if w <= 0.0 || w.is_nan() || w.is_infinite() { - return Err(ValidationError::InvalidDimension(format!( - "Container-Breite muss positiv sein, erhalten: {}", - w - ))); - } - if d <= 0.0 || d.is_nan() || d.is_infinite() { - return Err(ValidationError::InvalidDimension(format!( - "Container-Tiefe muss positiv sein, erhalten: {}", - d - ))); - } - if h <= 0.0 || h.is_nan() || h.is_infinite() { - return Err(ValidationError::InvalidDimension(format!( - "Container-Höhe muss positiv sein, erhalten: {}", - h - ))); - } - if max_weight <= 0.0 || max_weight.is_nan() || max_weight.is_infinite() { - return Err(ValidationError::InvalidWeight(format!( - "Maximales Gewicht muss positiv sein, erhalten: {}", - max_weight - ))); - } + // Use shared validation logic (DRY) + validate_container_dims(dims)?; + validate_weight_value(max_weight)?; Ok(Self { dims, @@ -204,45 +296,45 @@ impl Container { }) } - /// Berechnet das Gesamtgewicht aller platzierten Objekte. + /// Calculates the total weight of all placed objects. /// - /// # Rückgabewert - /// Summe der Gewichte aller Objekte + /// # Returns + /// Sum of the weights of all objects pub fn total_weight(&self) -> f64 { self.placed.iter().map(|b| b.object.weight).sum() } - /// Berechnet das verbleibende verfügbare Gewicht. + /// Calculates the remaining available weight. /// - /// # Rückgabewert - /// Differenz zwischen maximalem und aktuellem Gewicht + /// # Returns + /// Difference between maximum and current weight pub fn remaining_weight(&self) -> f64 { self.max_weight - self.total_weight() } - /// Berechnet das genutzte Volumen im Container. + /// Calculates the used volume in the container. /// - /// # Rückgabewert - /// Summe der Volumina aller platzierten Objekte + /// # Returns + /// Sum of the volumes of all placed objects #[allow(dead_code)] pub fn used_volume(&self) -> f64 { self.placed.iter().map(|b| b.object.volume()).sum() } - /// Berechnet das Gesamtvolumen des Containers. + /// Calculates the total volume of the container. /// - /// # Rückgabewert - /// Volumen des Containers + /// # Returns + /// Volume of the container #[allow(dead_code)] pub fn total_volume(&self) -> f64 { let (w, d, h) = self.dims; w * d * h } - /// Berechnet die Auslastung des Containers in Prozent. + /// Calculates the utilization of the container in percent. /// - /// # Rückgabewert - /// Prozentwert der Volumenbelegung (0.0 bis 100.0) + /// # Returns + /// Percentage value of volume usage (0.0 to 100.0) #[allow(dead_code)] pub fn utilization_percent(&self) -> f64 { let total = self.total_volume(); @@ -252,27 +344,41 @@ impl Container { (self.used_volume() / total) * 100.0 } - /// Prüft, ob ein Objekt grundsätzlich in den Container passt. + /// Checks if an object can basically fit in the container. /// - /// Berücksichtigt Gewicht und Dimensionen mit Toleranz. + /// Considers weight and dimensions with tolerance. + /// Uses the global tolerance constant (DRY principle). /// - /// # Parameter - /// * `b` - Das zu prüfende Objekt + /// # Parameters + /// * `b` - The object to check /// - /// # Rückgabewert - /// `true` wenn das Objekt theoretisch passt, sonst `false` + /// # Returns + /// `true` if the object theoretically fits, otherwise `false` pub fn can_fit(&self, b: &Box3D) -> bool { - let tolerance = 1e-6; - self.remaining_weight() + tolerance >= b.weight - && b.dims.0 <= self.dims.0 + tolerance - && b.dims.1 <= self.dims.1 + tolerance - && b.dims.2 <= self.dims.2 + tolerance + self.remaining_weight() + EPSILON_GENERAL >= b.weight + && b.dims.0 <= self.dims.0 + EPSILON_GENERAL + && b.dims.1 <= self.dims.1 + EPSILON_GENERAL + && b.dims.2 <= self.dims.2 + EPSILON_GENERAL + } + + /// Converts the container dimensions to a Vec3. + #[inline] + #[allow(dead_code)] + pub fn dims_as_vec3(&self) -> Vec3 { + Vec3::from_tuple(self.dims) } - /// Erstellt einen neuen leeren Container mit gleichen Eigenschaften. + /// Calculates the geometric center of the container (XY plane). + #[inline] + #[allow(dead_code)] + pub fn center_xy(&self) -> (f64, f64) { + (self.dims.0 / 2.0, self.dims.1 / 2.0) + } + + /// Creates a new empty container with the same properties. /// - /// # Rückgabewert - /// Ein neuer Container mit gleichen Dimensionen und Gewichtslimit + /// # Returns + /// A new container with the same dimensions and weight limit #[allow(dead_code)] pub fn empty_like(&self) -> Self { Self { @@ -284,7 +390,7 @@ impl Container { } } - /// Hinterlegt Metadaten zum Container-Typ (Builder-Pattern light). + /// Stores metadata for the container type (Builder pattern light). #[allow(dead_code)] pub fn with_meta(mut self, template_id: usize, label: Option) -> Self { self.template_id = Some(template_id); @@ -293,7 +399,7 @@ impl Container { } } -/// Vorlage für einen Container-Typ. +/// Template for a container type. #[derive(Clone, Debug)] pub struct ContainerBlueprint { pub id: usize, @@ -303,15 +409,18 @@ pub struct ContainerBlueprint { } impl ContainerBlueprint { - /// Erstellt eine neue Container-Vorlage nach Validierung der Parameter. + /// Creates a new container template after validating the parameters. + /// + /// Uses the same validation logic as Container (DRY principle). pub fn new( id: usize, label: Option, dims: (f64, f64, f64), max_weight: f64, ) -> Result { - // Validierung wird über Container::new sichergestellt. - let _ = Container::new(dims, max_weight)?; + // Validation via shared functions (DRY) + validate_container_dims(dims)?; + validate_weight_value(max_weight)?; Ok(Self { id, label, @@ -320,7 +429,7 @@ impl ContainerBlueprint { }) } - /// Instanziiert einen leeren Container basierend auf dieser Vorlage. + /// Instantiates an empty container based on this template. pub fn instantiate(&self) -> Container { Container { dims: self.dims, @@ -331,18 +440,40 @@ impl ContainerBlueprint { } } - /// Prüft, ob das Objekt aufgrund von Dimensionen und Gewicht grundsätzlich passt. + /// Checks if the object can basically fit based on dimensions and weight. + /// + /// Uses the global tolerance constant (DRY principle). pub fn can_fit(&self, object: &Box3D) -> bool { - let tolerance = 1e-6; - object.weight <= self.max_weight + tolerance - && object.dims.0 <= self.dims.0 + tolerance - && object.dims.1 <= self.dims.1 + tolerance - && object.dims.2 <= self.dims.2 + tolerance + object.weight <= self.max_weight + EPSILON_GENERAL + && object.dims.0 <= self.dims.0 + EPSILON_GENERAL + && object.dims.1 <= self.dims.1 + EPSILON_GENERAL + && object.dims.2 <= self.dims.2 + EPSILON_GENERAL } - /// Liefert das Volumen der Vorlage. + /// Returns the volume of the template. pub fn volume(&self) -> f64 { let (w, d, h) = self.dims; w * d * h } + + /// Converts the blueprint dimensions to a Vec3. + #[inline] + #[allow(dead_code)] + pub fn dims_as_vec3(&self) -> Vec3 { + Vec3::from_tuple(self.dims) + } +} + +/// Implementation of the Dimensional trait for Container. +impl Dimensional for Container { + fn dimensions(&self) -> Vec3 { + Vec3::from_tuple(self.dims) + } +} + +/// Implementation of the Dimensional trait for ContainerBlueprint. +impl Dimensional for ContainerBlueprint { + fn dimensions(&self) -> Vec3 { + Vec3::from_tuple(self.dims) + } } diff --git a/src/optimizer.rs b/src/optimizer.rs index c1aa0d5..82f3deb 100644 --- a/src/optimizer.rs +++ b/src/optimizer.rs @@ -1,11 +1,58 @@ -//! Optimierungslogik für die 3D-Verpackung von Objekten. +//! Optimization logic for 3D object packing. //! -//! Dieser Modul implementiert einen heuristischen Algorithmus zur effizienten Platzierung -//! von Objekten in Containern unter Berücksichtigung von: -//! - Gewichtsgrenzen und -verteilung -//! - Stabilität und Unterstützung -//! - Schwerpunkt-Balance -//! - Schichtung (schwere Objekte unten) +//! This module implements a heuristic algorithm for efficient placement +//! of objects in containers, considering: +//! - Weight limits and distribution +//! - Stability and support +//! - Center of gravity balance +//! - Layering (heavy objects at the bottom) +//! +//! ## Algorithm Overview +//! +//! The packing algorithm works in the following phases: +//! +//! 1. **Sorting**: Objects are sorted by `weight × volume` descending +//! (heavy and large objects first for better stability) +//! +//! 2. **Clustering**: The `FootprintClusterStrategy` groups objects with similar +//! footprints to reduce fragmentation +//! +//! 3. **Orientation**: When rotation is enabled, up to 6 orientations +//! per object are tested (deduplicated for symmetric objects) +//! +//! 4. **Position Search**: For each object, the best position is searched: +//! - Iterate over all Z-layers (floor + tops of placed objects) +//! - Grid search on X/Y axis with configurable step size +//! - Evaluation by `PlacementScore { z, y, x, balance }` +//! +//! 5. **Stability Checks**: Each candidate position must pass: +//! - No collision with existing objects +//! - Minimum support (`support_ratio`) satisfied +//! - Weight hierarchy maintained (heavy under light) +//! - Center of gravity supported +//! - Balance within limits +//! +//! 6. **Multi-Container**: When space is insufficient, a new container is created +//! +//! ## Performance Notes +//! +//! - **grid_step**: Smaller values → more accurate, but O(n²) slower +//! - **allow_item_rotation**: 6× more orientations → 6× more checks +//! - Complexity: O(n × p × z) with n=objects, p=positions, z=Z-layers +//! +//! ## Example +//! +//! ```ignore +//! use sort_it_now::optimizer::{pack_objects, PackingConfig}; +//! +//! let config = PackingConfig::builder() +//! .grid_step(2.5) +//! .support_ratio(0.7) +//! .allow_item_rotation(true) +//! .build(); +//! +//! let result = pack_objects_with_config(objects, templates, config); +//! ``` use std::cmp::Ordering; @@ -13,24 +60,24 @@ use crate::geometry::{intersects, overlap_1d, point_inside}; use crate::model::{Box3D, Container, ContainerBlueprint, PlacedBox}; use utoipa::ToSchema; -/// Konfiguration für den Packing-Algorithmus. +/// Configuration for the packing algorithm. /// -/// Enthält alle Toleranzen und Grenzwerte zur Steuerung des Optimierungsverhaltens. +/// Contains all tolerances and limits for controlling the optimization behavior. #[derive(Copy, Clone, Debug)] pub struct PackingConfig { - /// Schrittweite für Positionsraster (kleinere Werte = genauer, aber langsamer) + /// Step size for position grid (smaller values = more accurate, but slower) pub grid_step: f64, - /// Minimaler Anteil der Grundfläche, der unterstützt sein muss (0.0 bis 1.0) + /// Minimum fraction of the base area that must be supported (0.0 to 1.0) pub support_ratio: f64, - /// Toleranz für Höhenvergleiche + /// Tolerance for height comparisons pub height_epsilon: f64, - /// Allgemeine numerische Toleranz + /// General numerical tolerance pub general_epsilon: f64, - /// Maximale erlaubte Abweichung des Schwerpunkts vom Mittelpunkt (als Ratio der Diagonale) + /// Maximum allowed deviation of center of gravity from center point (as ratio of diagonal) pub balance_limit_ratio: f64, - /// Relative Toleranz bei der Vorgruppierung nach Grundfläche zur Reduktion von Backtracking + /// Relative tolerance for pre-grouping by footprint to reduce backtracking pub footprint_cluster_tolerance: f64, - /// Erlaubt das Rotieren der Objekte, um alternative Ausrichtungen zu prüfen + /// Allows rotating objects to test alternative orientations pub allow_item_rotation: bool, } @@ -43,7 +90,7 @@ impl PackingConfig { pub const DEFAULT_FOOTPRINT_CLUSTER_TOLERANCE: f64 = 0.15; pub const DEFAULT_ALLOW_ITEM_ROTATION: bool = false; - /// Erstellt einen Builder für benutzerdefinierte Konfiguration. + /// Creates a builder for custom configuration. pub fn builder() -> PackingConfigBuilder { PackingConfigBuilder::default() } @@ -63,83 +110,75 @@ impl Default for PackingConfig { } } -/// Builder-Pattern für PackingConfig (OOP-Prinzip). -#[derive(Clone, Debug)] +/// Builder pattern for PackingConfig (OOP principle). +#[derive(Clone, Debug, Default)] pub struct PackingConfigBuilder { config: PackingConfig, } -impl Default for PackingConfigBuilder { - fn default() -> Self { - Self { - config: PackingConfig::default(), - } - } -} - impl PackingConfigBuilder { - /// Setzt die Raster-Schrittweite. + /// Sets the grid step size. pub fn grid_step(mut self, step: f64) -> Self { self.config.grid_step = step; self } - /// Setzt die minimale Unterstützungsrate. + /// Sets the minimum support ratio. pub fn support_ratio(mut self, ratio: f64) -> Self { self.config.support_ratio = ratio; self } - /// Setzt die Höhentoleranz. + /// Sets the height tolerance. pub fn height_epsilon(mut self, epsilon: f64) -> Self { self.config.height_epsilon = epsilon; self } - /// Setzt die allgemeine Toleranz. + /// Sets the general tolerance. pub fn general_epsilon(mut self, epsilon: f64) -> Self { self.config.general_epsilon = epsilon; self } - /// Setzt das Balance-Limit als Ratio der Diagonale. + /// Sets the balance limit as a ratio of the diagonal. pub fn balance_limit_ratio(mut self, ratio: f64) -> Self { self.config.balance_limit_ratio = ratio; self } - /// Setzt die Toleranz für die Vorgruppierung basierend auf der Grundfläche. + /// Sets the tolerance for pre-grouping based on footprint. pub fn footprint_cluster_tolerance(mut self, tolerance: f64) -> Self { self.config.footprint_cluster_tolerance = tolerance; self } - /// Aktiviert oder deaktiviert Rotationen von Objekten. + /// Enables or disables rotation of objects. pub fn allow_item_rotation(mut self, allow: bool) -> Self { self.config.allow_item_rotation = allow; self } - /// Erstellt die finale Konfiguration. + /// Creates the final configuration. pub fn build(self) -> PackingConfig { self.config } } -/// Abstrakte Strategien zur Gruppierung/Neuordnung von Objekten vor dem Packen. +/// Abstract strategies for grouping/reordering objects before packing. /// -/// Diese interne Trait definiert die Schnittstelle für Strategien, die die Reihenfolge -/// (und ggf. Auswahl) von Objekten vor dem Packvorgang beeinflussen. Implementierungen -/// können die Reihenfolge der Objekte ändern, Gruppen bilden oder Objekte filtern, um -/// die Effizienz des Packens zu verbessern. Es wird garantiert, dass die Rückgabe -/// eine (ggf. gefilterte) Teilmenge der Eingabe ist; Objekte können entfernt, aber -/// nicht modifiziert werden. Die Trait ist absichtlich privat, da sie nur für interne -/// Optimierungsstrategien gedacht ist und keine stabile API garantiert. +/// This internal trait defines the interface for strategies that influence the order +/// (and possibly selection) of objects before the packing process. Implementations +/// can change the order of objects, form groups, or filter objects to improve +/// packing efficiency. It is guaranteed that the return is a (possibly filtered) +/// subset of the input; objects can be removed but not modified. The trait is +/// intentionally private, as it is only intended for internal optimization +/// strategies and does not guarantee a stable API. trait ObjectClusterStrategy { fn reorder(&self, objects: Vec) -> Vec; } -/// Gruppiert Objekte mit kompatibler Grundfläche, um Backtracking zu reduzieren. +/// Groups objects with compatible footprints to reduce backtracking. #[derive(Clone, Debug)] struct FootprintClusterStrategy { tolerance: f64, @@ -264,7 +303,7 @@ fn orientations_for(object: &Box3D, allow_rotation: bool) -> Vec { unique } -/// Support-Kennzahlen pro Objekt. +/// Support metrics per object. #[derive(Clone, Debug, serde::Serialize, ToSchema)] pub struct SupportDiagnostics { pub object_id: usize, @@ -272,7 +311,7 @@ pub struct SupportDiagnostics { pub rests_on_floor: bool, } -/// Diagnostische Kennzahlen pro Container für Monitoring. +/// Diagnostic metrics per container for monitoring. #[derive(Clone, Debug, serde::Serialize, ToSchema)] pub struct ContainerDiagnostics { pub center_of_mass_offset: f64, @@ -283,7 +322,7 @@ pub struct ContainerDiagnostics { pub support_samples: Vec, } -/// Zusammenfassung wichtiger Kennzahlen über alle Container hinweg. +/// Summary of key metrics across all containers. #[derive(Clone, Debug, serde::Serialize, ToSchema)] pub struct PackingDiagnosticsSummary { pub max_imbalance_ratio: f64, @@ -301,7 +340,7 @@ impl Default for PackingDiagnosticsSummary { } } -/// Ergebnis der Verpackungsberechnung. +/// Result of the packing calculation. #[derive(Clone, Debug)] pub struct PackingResult { pub containers: Vec, @@ -311,23 +350,23 @@ pub struct PackingResult { } impl PackingResult { - /// Gibt an, ob alle Objekte verpackt wurden. + /// Indicates whether all objects were packed. #[allow(dead_code)] pub fn is_complete(&self) -> bool { self.unplaced.is_empty() } - /// Gibt die Gesamtanzahl der Container zurück. + /// Returns the total number of containers. pub fn container_count(&self) -> usize { self.containers.len() } - /// Gibt die Anzahl unverpackter Objekte zurück. + /// Returns the number of unpacked objects. pub fn unplaced_count(&self) -> usize { self.unplaced.len() } - /// Berechnet die durchschnittliche Auslastung aller Container. + /// Calculates the average utilization of all containers. #[allow(dead_code)] pub fn average_utilization(&self) -> f64 { if self.containers.is_empty() { @@ -341,27 +380,27 @@ impl PackingResult { sum / self.containers.len() as f64 } - /// Berechnet das Gesamtgewicht aller verpackten Objekte. + /// Calculates the total weight of all packed objects. #[allow(dead_code)] pub fn total_packed_weight(&self) -> f64 { self.containers.iter().map(|c| c.total_weight()).sum() } - /// Liefert die aggregierten Diagnosewerte. + /// Returns the aggregated diagnostic values. #[allow(dead_code)] pub fn diagnostics_summary(&self) -> &PackingDiagnosticsSummary { &self.diagnostics_summary } } -/// Objekt, das nicht platziert werden konnte. +/// Object that could not be placed. #[derive(Clone, Debug)] pub struct UnplacedBox { pub object: Box3D, pub reason: UnplacedReason, } -/// Gründe, warum ein Objekt nicht platziert werden konnte. +/// Reasons why an object could not be placed. #[derive(Clone, Debug)] pub enum UnplacedReason { TooHeavyForContainer, @@ -383,19 +422,16 @@ impl std::fmt::Display for UnplacedReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { UnplacedReason::TooHeavyForContainer => { - write!(f, "Objekt überschreitet das zulässige Gesamtgewicht") + write!(f, "Object exceeds the maximum allowed weight") } UnplacedReason::DimensionsExceedContainer => { write!( f, - "Objekt passt in mindestens einer Dimension nicht in den Container" + "Object does not fit in the container in at least one dimension" ) } UnplacedReason::NoStablePosition => { - write!( - f, - "Keine stabile Position innerhalb des Containers gefunden" - ) + write!(f, "No stable position found within the container") } } } @@ -432,17 +468,17 @@ fn determine_unfit_reason_across_templates( UnplacedReason::NoStablePosition } -/// Hauptfunktion zur Verpackung von Objekten in Container. +/// Main function for packing objects into containers. /// -/// Sortiert Objekte nach Gewicht und Volumen (schwere/große zuerst) und platziert -/// sie nacheinander in Container. Erstellt neue Container, wenn nötig. +/// Sorts objects by weight and volume (heavy/large first) and places +/// them sequentially into containers. Creates new containers when needed. /// -/// # Parameter -/// * `objects` - Liste der zu verpackenden Objekte -/// * `container_templates` - Mögliche Container-Typen +/// # Parameters +/// * `objects` - List of objects to pack +/// * `container_templates` - Available container types /// -/// # Rückgabewert -/// `PackingResult` mit platzierten Containern und ggf. unverpackten Objekten +/// # Returns +/// `PackingResult` with placed containers and possibly unpacked objects #[allow(dead_code)] pub fn pack_objects( objects: Vec, @@ -451,14 +487,14 @@ pub fn pack_objects( pack_objects_with_config(objects, container_templates, PackingConfig::default()) } -/// Verpackung mit benutzerdefinierter Konfiguration. +/// Packing with custom configuration. /// -/// Wie `pack_objects`, aber mit anpassbaren Parametern. +/// Like `pack_objects`, but with customizable parameters. /// -/// # Parameter -/// * `objects` - Liste der zu verpackenden Objekte -/// * `container_templates` - Mögliche Container-Typen -/// * `config` - Konfigurationsparameter für den Algorithmus +/// # Parameters +/// * `objects` - List of objects to pack +/// * `container_templates` - Available container types +/// * `config` - Configuration parameters for the algorithm pub fn pack_objects_with_config( objects: Vec, container_templates: Vec, @@ -467,11 +503,11 @@ pub fn pack_objects_with_config( pack_objects_with_progress(objects, container_templates, config, |_| {}) } -/// Ereignisse, die während des Packens auftreten, um Live-Visualisierung zu ermöglichen. +/// Events that occur during packing to enable live visualization. #[derive(Clone, Debug, serde::Serialize)] #[serde(tag = "type")] pub enum PackEvent { - /// Ein neuer Container wird begonnen. + /// A new container is started. ContainerStarted { id: usize, dims: (f64, f64, f64), @@ -479,7 +515,7 @@ pub enum PackEvent { label: Option, template_id: Option, }, - /// Ein Objekt wurde platziert. + /// An object was placed. ObjectPlaced { container_id: usize, id: usize, @@ -488,12 +524,12 @@ pub enum PackEvent { dims: (f64, f64, f64), total_weight: f64, }, - /// Aktualisierte Diagnostik eines Containers. + /// Updated diagnostics for a container. ContainerDiagnostics { container_id: usize, diagnostics: ContainerDiagnostics, }, - /// Ein Objekt konnte nicht platziert werden. + /// An object could not be placed. ObjectRejected { id: usize, weight: f64, @@ -501,7 +537,7 @@ pub enum PackEvent { reason_code: String, reason_text: String, }, - /// Packen abgeschlossen. + /// Packing completed. Finished { containers: usize, unplaced: usize, @@ -509,9 +545,9 @@ pub enum PackEvent { }, } -/// Verpackung mit benutzerdefinierter Konfiguration und Live-Progress Callback. +/// Packing with custom configuration and live progress callback. /// -/// Ruft für jeden wichtigen Schritt ein Callback auf (geeignet für SSE/WebSocket). +/// Calls a callback for each important step (suitable for SSE/WebSocket). pub fn pack_objects_with_progress( objects: Vec, container_templates: Vec, @@ -572,7 +608,7 @@ pub fn pack_objects_with_progress( }) }); - // Sortierung: Schwere und große Objekte zuerst (Stabilitätsprinzip) + // Sorting: Heavy and large objects first (stability principle) let mut objects = objects; objects.sort_by(|a, b| { b.weight @@ -723,18 +759,18 @@ pub fn pack_objects_with_progress( } } -/// Findet eine stabile Position für ein Objekt in einem Container. +/// Finds a stable position for an object in a container. /// -/// Durchsucht verschiedene Z-Ebenen, Y- und X-Positionen und bewertet jede -/// Position nach Stabilität, Unterstützung, Gewichtsverteilung und Balance. +/// Searches through different Z-layers, Y and X positions and evaluates each +/// position for stability, support, weight distribution, and balance. /// -/// # Parameter -/// * `b` - Das zu platzierende Objekt -/// * `cont` - Der Container -/// * `config` - Konfigurationsparameter +/// # Parameters +/// * `b` - The object to place +/// * `cont` - The container +/// * `config` - Configuration parameters /// -/// # Rückgabewert -/// `Some((x, y, z))` bei erfolgreicher Platzierung, sonst `None` +/// # Returns +/// `Some((x, y, z))` on successful placement, otherwise `None` fn find_stable_position( b: &Box3D, cont: &Container, @@ -757,7 +793,7 @@ fn find_stable_position( config.general_epsilon, ); - // Sammle alle relevanten Z-Ebenen (Boden + Oberseiten aller platzierten Objekte) + // Collect all relevant Z-layers (floor + tops of all placed objects) let mut z_layers: Vec = cont .placed .iter() @@ -792,12 +828,12 @@ fn find_stable_position( position: (x, y, z), }; - // Prüfe auf Kollisionen + // Check for collisions if cont.placed.iter().any(|p| intersects(p, &candidate)) { continue; } - // Bei Platzierung über dem Boden: Prüfe Stabilität + // For placement above the floor: Check stability if z > 0.0 { if !has_sufficient_support(&candidate, cont, config) { continue; @@ -806,7 +842,7 @@ fn find_stable_position( continue; } if !is_center_supported(&candidate, cont, config) { - // Verhindert Überhänge, bei denen der Schwerpunkt nicht abgestützt ist + // Prevents overhangs where the center of gravity is not supported continue; } } @@ -826,15 +862,15 @@ fn find_stable_position( best_in_limit.or(best_any).map(|(pos, _)| pos) } -/// Generiert mögliche Positionen entlang einer Achse. +/// Generates possible positions along an axis. /// -/// Erstellt ein Raster von Positionen mit der angegebenen Schrittweite. +/// Creates a grid of positions with the specified step size. /// -/// # Parameter -/// * `container_len` - Länge des Containers in dieser Dimension -/// * `object_len` - Länge des Objekts in dieser Dimension -/// * `step` - Schrittweite des Rasters -/// * `epsilon` - Numerische Toleranz +/// # Parameters +/// * `container_len` - Length of the container in this dimension +/// * `object_len` - Length of the object in this dimension +/// * `step` - Step size of the grid +/// * `epsilon` - Numerical tolerance fn axis_positions(container_len: f64, object_len: f64, step: f64, epsilon: f64) -> Vec { let max_pos = (container_len - object_len).max(0.0); let mut positions = Vec::new(); @@ -863,14 +899,14 @@ fn axis_positions(container_len: f64, object_len: f64, step: f64, epsilon: f64) positions } -/// Prüft, ob ein Objekt korrekt nach Gewicht unterstützt wird. +/// Checks if an object is correctly supported by weight. /// -/// Stellt sicher, dass keine schwereren Objekte auf leichteren liegen. +/// Ensures that no heavier objects rest on lighter ones. /// -/// # Parameter -/// * `b` - Das zu prüfende platzierte Objekt -/// * `cont` - Der Container -/// * `config` - Konfigurationsparameter +/// # Parameters +/// * `b` - The placed object to check +/// * `cont` - The container +/// * `config` - Configuration parameters fn supports_weight_correctly(b: &PlacedBox, cont: &Container, config: &PackingConfig) -> bool { if b.position.2 <= config.height_epsilon { return true; @@ -895,7 +931,7 @@ fn supports_weight_correctly(b: &PlacedBox, cont: &Container, config: &PackingCo has_support = true; - // Schwereres Objekt darf nicht auf leichterem liegen + // Heavier object must not rest on lighter one if p.object.weight + config.general_epsilon < b.object.weight { return false; } @@ -904,14 +940,14 @@ fn supports_weight_correctly(b: &PlacedBox, cont: &Container, config: &PackingCo has_support } -/// Prüft, ob ein Objekt ausreichend unterstützt wird. +/// Checks if an object is sufficiently supported. /// -/// Berechnet den Anteil der Grundfläche, der auf anderen Objekten aufliegt. +/// Calculates the fraction of the base area resting on other objects. /// /// # Parameters -/// * `b` - Das zu prüfende platzierte Objekt -/// * `cont` - Der Container -/// * `config` - Konfigurationsparameter +/// * `b` - The placed object to check +/// * `cont` - The container +/// * `config` - Configuration parameters fn support_ratio_of(b: &PlacedBox, cont: &Container, config: &PackingConfig) -> f64 { if b.position.2 <= config.height_epsilon { return 1.0; @@ -953,10 +989,10 @@ fn has_sufficient_support(b: &PlacedBox, cont: &Container, config: &PackingConfi support_ratio_of(b, cont, config) >= required_support } -/// Prüft, ob der Schwerpunkt des Objekts (Projektion auf XY) von der Auflagefläche getragen wird. +/// Checks if the center of gravity of the object (XY projection) is supported by the bearing surface. /// -/// Eine einfache, robuste Stabilitätsheuristik: Es muss mindestens eine tragende Box direkt unter -/// dem projizierten Mittelpunkt liegen (gleiche Z-Ebene, XY enthält Center-Punkt). +/// A simple, robust stability heuristic: There must be at least one supporting box directly under +/// the projected center point (same Z-level, XY contains center point). fn is_center_supported(b: &PlacedBox, cont: &Container, config: &PackingConfig) -> bool { if b.position.2 <= config.height_epsilon { return true; @@ -981,14 +1017,14 @@ fn is_center_supported(b: &PlacedBox, cont: &Container, config: &PackingConfig) false } -/// Berechnet die Balance/Schwerpunktabweichung nach Hinzufügen eines Objekts. +/// Calculates the balance/center of gravity deviation after adding an object. /// -/// Berechnet den gewichteten Schwerpunkt aller Objekte und dessen Distanz -/// zum geometrischen Mittelpunkt des Containers. +/// Computes the weighted center of gravity of all objects and its distance +/// to the geometric center of the container. /// -/// # Parameter -/// * `cont` - Der Container -/// * `new_box` - Das hinzuzufügende Objekt +/// # Parameters +/// * `cont` - The container +/// * `new_box` - The object to add fn calculate_balance_after(cont: &Container, new_box: &PlacedBox) -> f64 { let new_point = ( new_box.position.0 + new_box.object.dims.0 / 2.0, @@ -1013,9 +1049,9 @@ fn calculate_balance_after(cont: &Container, new_box: &PlacedBox) -> f64 { } } -/// Bewertung einer Platzierungsposition. +/// Evaluation of a placement position. /// -/// Niedrigere Werte sind besser (z zuerst, dann y, dann x, dann balance). +/// Lower values are better (z first, then y, then x, then balance). #[derive(Clone, Copy)] struct PlacementScore { z: f64, @@ -1024,13 +1060,13 @@ struct PlacementScore { balance: f64, } -/// Aktualisiert die beste gefundene Position. +/// Updates the best found position. /// /// # Parameters -/// * `best` - Aktuell beste Position -/// * `position` - Neue Kandidaten-Position -/// * `score` - Score der neuen Position -/// * `config` - Konfigurationsparameter +/// * `best` - Currently best position +/// * `position` - New candidate position +/// * `score` - Score of the new position +/// * `config` - Configuration parameters fn update_best( best: &mut Option<((f64, f64, f64), PlacementScore)>, position: (f64, f64, f64), @@ -1049,14 +1085,14 @@ fn update_best( } } -/// Vergleicht zwei Platzierungsscores. +/// Compares two placement scores. /// -/// Priorität: z (niedrig) > y (niedrig) > x (niedrig) > balance (niedrig) +/// Priority: z (low) > y (low) > x (low) > balance (low) /// /// # Parameters -/// * `new` - Neuer Score -/// * `current` - Aktueller Score -/// * `config` - Konfigurationsparameter +/// * `new` - New score +/// * `current` - Current score +/// * `config` - Configuration parameters fn is_better_score(new: PlacementScore, current: PlacementScore, config: &PackingConfig) -> bool { match compare_with_epsilon(new.z, current.z, config.height_epsilon) { Ordering::Less => return true, @@ -1079,12 +1115,12 @@ fn is_better_score(new: PlacementScore, current: PlacementScore, config: &Packin new.balance + config.general_epsilon < current.balance } -/// Vergleicht zwei Werte mit Toleranz. +/// Compares two values with tolerance. /// /// # Parameters -/// * `a` - Erster Wert -/// * `b` - Zweiter Wert -/// * `eps` - Toleranz +/// * `a` - First value +/// * `b` - Second value +/// * `eps` - Tolerance fn compare_with_epsilon(a: f64, b: f64, eps: f64) -> Ordering { if (a - b).abs() <= eps { Ordering::Equal @@ -1095,11 +1131,11 @@ fn compare_with_epsilon(a: f64, b: f64, eps: f64) -> Ordering { } } -/// Berechnet die maximale erlaubte Balance-Abweichung. +/// Calculates the maximum allowed balance deviation. /// /// # Parameters -/// * `cont` - Der Container -/// * `config` - Konfigurationsparameter +/// * `cont` - The container +/// * `config` - Configuration parameters fn calculate_balance_limit(cont: &Container, config: &PackingConfig) -> f64 { let half_x = cont.dims.0 / 2.0; let half_y = cont.dims.1 / 2.0; @@ -1152,7 +1188,7 @@ where } } -/// Berechnet diagnostische Kennzahlen für einen Container. +/// Calculates diagnostic metrics for a container. pub fn compute_container_diagnostics( cont: &Container, config: &PackingConfig, @@ -1253,7 +1289,7 @@ impl SummaryAccumulator { } } -/// Aggregiert Diagnosen über mehrere Container. +/// Aggregates diagnostics across multiple containers. pub fn summarize_diagnostics<'a, I>(diagnostics: I) -> PackingDiagnosticsSummary where I: IntoIterator, @@ -1306,7 +1342,7 @@ mod tests { assert!( lower.object.weight + config.general_epsilon >= upper.object.weight, - "Objekt {} ({}kg) unter Objekt {} ({}kg) verletzt Gewichtssortierung", + "Object {} ({}kg) under object {} ({}kg) violates weight sorting", lower.object.id, lower.object.weight, upper.object.id, @@ -1666,7 +1702,7 @@ mod tests { .placed .iter() .find(|p| p.object.id == 5) - .expect("schwerstes Objekt fehlt"); + .expect("heaviest object missing"); assert!(heavy.position.0 <= config.grid_step + config.general_epsilon); assert!(heavy.position.1 <= config.grid_step + config.general_epsilon); @@ -1674,7 +1710,7 @@ mod tests { .placed .iter() .find(|p| p.object.id == 4) - .expect("zweit schwerstes Objekt fehlt"); + .expect("second heaviest object missing"); assert!(second.position.2 <= config.height_epsilon); } diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..a001e60 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,500 @@ +//! Common types and traits for 3D geometry. +//! +//! This module defines reusable types and trait abstractions +//! that promote DRY principles and OOP design patterns. + +use std::ops::{Add, Mul, Sub}; + +/// Global numerical tolerance for floating-point comparisons. +/// +/// Used for general numerical operations such as dimension and weight comparisons. +pub const EPSILON_GENERAL: f64 = 1e-6; + +/// Tolerance for height comparisons in the Z-plane. +/// +/// Slightly larger tolerance for height matching during stacking. +pub const EPSILON_HEIGHT: f64 = 1e-3; + +/// Represents a 3D vector or point in space. +/// +/// Used for positions, dimensions, and calculations in 3D space. +/// +/// # Examples +/// ``` +/// use sort_it_now::types::Vec3; +/// +/// let position = Vec3::new(1.0, 2.0, 3.0); +/// let dimensions = Vec3::new(10.0, 20.0, 30.0); +/// let center = position + dimensions * 0.5; +/// ``` +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Vec3 { + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl Vec3 { + /// Creates a new 3D vector. + /// + /// # Parameters + /// * `x` - X component (width) + /// * `y` - Y component (depth) + /// * `z` - Z component (height) + #[inline] + pub const fn new(x: f64, y: f64, z: f64) -> Self { + Self { x, y, z } + } + + /// Creates a zero vector (origin). + #[inline] + pub const fn zero() -> Self { + Self::new(0.0, 0.0, 0.0) + } + + /// Converts to tuple format for API compatibility. + #[inline] + pub const fn as_tuple(&self) -> (f64, f64, f64) { + (self.x, self.y, self.z) + } + + /// Creates from tuple format. + #[inline] + pub const fn from_tuple(tuple: (f64, f64, f64)) -> Self { + Self::new(tuple.0, tuple.1, tuple.2) + } + + /// Calculates the volume (product of all components). + /// + /// Useful for dimension vectors. + #[inline] + pub fn volume(&self) -> f64 { + self.x * self.y * self.z + } + + /// Calculates the base area (X × Y product). + #[inline] + pub fn base_area(&self) -> f64 { + self.x * self.y + } + + /// Calculates the Euclidean distance to another point. + #[inline] + pub fn distance_to(&self, other: &Self) -> f64 { + let dx = self.x - other.x; + let dy = self.y - other.y; + let dz = self.z - other.z; + (dx * dx + dy * dy + dz * dz).sqrt() + } + + /// Calculates the 2D distance (XY plane only). + #[inline] + pub fn distance_2d(&self, other: &Self) -> f64 { + let dx = self.x - other.x; + let dy = self.y - other.y; + (dx * dx + dy * dy).sqrt() + } + + /// Checks if all components are positive and finite. + #[inline] + pub fn is_valid_dimension(&self) -> bool { + self.x > 0.0 + && self.y > 0.0 + && self.z > 0.0 + && self.x.is_finite() + && self.y.is_finite() + && self.z.is_finite() + } + + /// Checks if the vector fits within another vector (component-wise <=). + /// + /// # Parameters + /// * `container` - The outer vector (e.g., container dimensions) + /// * `tolerance` - Numerical tolerance for the comparison + #[inline] + pub fn fits_within(&self, container: &Self, tolerance: f64) -> bool { + self.x <= container.x + tolerance + && self.y <= container.y + tolerance + && self.z <= container.z + tolerance + } + + /// Returns the midpoint between the origin and this point. + #[inline] + pub fn center(&self) -> Self { + Self::new(self.x / 2.0, self.y / 2.0, self.z / 2.0) + } +} + +impl Add for Vec3 { + type Output = Self; + + #[inline] + fn add(self, rhs: Self) -> Self::Output { + Self::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z) + } +} + +impl Sub for Vec3 { + type Output = Self; + + #[inline] + fn sub(self, rhs: Self) -> Self::Output { + Self::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z) + } +} + +impl Mul for Vec3 { + type Output = Self; + + #[inline] + fn mul(self, scalar: f64) -> Self::Output { + Self::new(self.x * scalar, self.y * scalar, self.z * scalar) + } +} + +impl From<(f64, f64, f64)> for Vec3 { + #[inline] + fn from(tuple: (f64, f64, f64)) -> Self { + Self::from_tuple(tuple) + } +} + +impl From for (f64, f64, f64) { + #[inline] + fn from(vec: Vec3) -> Self { + vec.as_tuple() + } +} + +/// Trait for objects with 3D dimensions. +/// +/// Provides a common interface for all objects with spatial extent. +/// Follows the Interface Segregation Principle (ISP). +pub trait Dimensional { + /// Returns the dimensions of the object. + fn dimensions(&self) -> Vec3; + + /// Calculates the volume. + fn volume(&self) -> f64 { + self.dimensions().volume() + } + + /// Calculates the base area. + fn base_area(&self) -> f64 { + self.dimensions().base_area() + } + + /// Checks if this object fits in a container with the given dimensions. + fn fits_in(&self, container_dims: &Vec3, tolerance: f64) -> bool { + self.dimensions().fits_within(container_dims, tolerance) + } +} + +/// Trait for objects with a position in 3D space. +/// +/// Enables querying position and bounding box calculations. +pub trait Positioned { + /// Returns the position (lower left front corner). + fn position(&self) -> Vec3; +} + +/// Trait for objects with weight. +/// +/// Provides a common interface for weight operations. +pub trait Weighted { + /// Returns the weight in kg. + fn weight(&self) -> f64; +} + +/// Represents an Axis-Aligned Bounding Box (AABB). +/// +/// Used for efficient collision detection and overlap calculation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoundingBox { + /// Minimum corner (position) + pub min: Vec3, + /// Maximum corner (position + dimensions) + pub max: Vec3, +} + +impl BoundingBox { + /// Creates a new bounding box. + #[inline] + pub const fn new(min: Vec3, max: Vec3) -> Self { + Self { min, max } + } + + /// Creates a bounding box from position and dimensions. + #[inline] + pub fn from_position_and_dims(position: Vec3, dims: Vec3) -> Self { + Self { + min: position, + max: position + dims, + } + } + + /// Checks if two bounding boxes intersect. + /// + /// Implements the Separating Axis Theorem (SAT) for AABBs. + #[inline] + pub fn intersects(&self, other: &Self) -> bool { + !(self.max.x <= other.min.x + || other.max.x <= self.min.x + || self.max.y <= other.min.y + || other.max.y <= self.min.y + || self.max.z <= other.min.z + || other.max.z <= self.min.z) + } + + /// Calculates the overlap length in one dimension. + #[inline] + fn overlap_1d(a_min: f64, a_max: f64, b_min: f64, b_max: f64) -> f64 { + (a_max.min(b_max) - a_min.max(b_min)).max(0.0) + } + + /// Calculates the overlap area in the XY plane. + #[inline] + pub fn overlap_area_xy(&self, other: &Self) -> f64 { + let overlap_x = Self::overlap_1d(self.min.x, self.max.x, other.min.x, other.max.x); + let overlap_y = Self::overlap_1d(self.min.y, self.max.y, other.min.y, other.max.y); + overlap_x * overlap_y + } + + /// Checks if a point is inside the bounding box. + #[inline] + pub fn contains_point(&self, point: &Vec3) -> bool { + point.x >= self.min.x + && point.x <= self.max.x + && point.y >= self.min.y + && point.y <= self.max.y + && point.z >= self.min.z + && point.z <= self.max.z + } + + /// Returns the top (Z maximum). + #[inline] + pub fn top_z(&self) -> f64 { + self.max.z + } + + /// Returns the center point. + #[inline] + pub fn center(&self) -> Vec3 { + Vec3::new( + (self.min.x + self.max.x) / 2.0, + (self.min.y + self.max.y) / 2.0, + (self.min.z + self.max.z) / 2.0, + ) + } + + /// Returns the dimensions (width, depth, height). + #[inline] + pub fn dimensions(&self) -> Vec3 { + self.max - self.min + } +} + +/// Validation functions for DRY principle. +#[allow(dead_code)] +pub mod validation { + + /// Validates a single dimension. + /// + /// # Parameters + /// * `value` - The value to validate + /// * `name` - Name of the dimension for error messages + /// + /// # Returns + /// `Ok(())` for valid values, otherwise error text + pub fn validate_dimension(value: f64, name: &str) -> Result<(), String> { + if value <= 0.0 { + return Err(format!("{} must be positive, got: {}", name, value)); + } + if value.is_nan() { + return Err(format!("{} must not be NaN", name)); + } + if value.is_infinite() { + return Err(format!("{} must not be infinite", name)); + } + Ok(()) + } + + /// Validates a weight. + /// + /// # Parameters + /// * `value` - The value to validate + /// + /// # Returns + /// `Ok(())` for valid values, otherwise error text + pub fn validate_weight(value: f64) -> Result<(), String> { + if value <= 0.0 { + return Err(format!("Weight must be positive, got: {}", value)); + } + if value.is_nan() { + return Err("Weight must not be NaN".to_string()); + } + if value.is_infinite() { + return Err("Weight must not be infinite".to_string()); + } + Ok(()) + } + + /// Validates all three dimensions of a 3D object. + /// + /// # Parameters + /// * `dims` - The dimensions to validate (width, depth, height) + /// + /// # Returns + /// `Ok(())` for valid values, otherwise error text + pub fn validate_dimensions_3d(dims: (f64, f64, f64)) -> Result<(), String> { + validate_dimension(dims.0, "Width")?; + validate_dimension(dims.1, "Depth")?; + validate_dimension(dims.2, "Height")?; + Ok(()) + } +} + +/// Center of mass calculation helper. +/// +/// Accumulates weighted positions for center of mass calculation. +#[derive(Clone, Debug, Default)] +pub struct CenterOfMassCalculator { + weighted_x: f64, + weighted_y: f64, + total_weight: f64, +} + +impl CenterOfMassCalculator { + /// Creates a new calculator. + pub fn new() -> Self { + Self::default() + } + + /// Adds a weighted point. + /// + /// # Parameters + /// * `x` - X position of the point + /// * `y` - Y position of the point + /// * `weight` - Weight of the point + pub fn add_point(&mut self, x: f64, y: f64, weight: f64) { + self.weighted_x += x * weight; + self.weighted_y += y * weight; + self.total_weight += weight; + } + + /// Calculates the center of mass. + /// + /// # Returns + /// `Some((x, y))` for valid center of mass, `None` if no weight present + pub fn compute(&self) -> Option<(f64, f64)> { + if self.total_weight <= 0.0 { + None + } else { + Some(( + self.weighted_x / self.total_weight, + self.weighted_y / self.total_weight, + )) + } + } + + /// Calculates the distance of the center of mass to a reference point. + /// + /// # Parameters + /// * `reference` - The reference point (e.g., container center) + pub fn distance_to(&self, reference: (f64, f64)) -> f64 { + match self.compute() { + Some((cx, cy)) => { + let dx = cx - reference.0; + let dy = cy - reference.1; + (dx * dx + dy * dy).sqrt() + } + None => 0.0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vec3_operations() { + let a = Vec3::new(1.0, 2.0, 3.0); + let b = Vec3::new(4.0, 5.0, 6.0); + + assert_eq!(a + b, Vec3::new(5.0, 7.0, 9.0)); + assert_eq!(b - a, Vec3::new(3.0, 3.0, 3.0)); + assert_eq!(a * 2.0, Vec3::new(2.0, 4.0, 6.0)); + } + + #[test] + fn test_vec3_volume_and_area() { + let dims = Vec3::new(10.0, 20.0, 30.0); + assert!((dims.volume() - 6000.0).abs() < EPSILON_GENERAL); + assert!((dims.base_area() - 200.0).abs() < EPSILON_GENERAL); + } + + #[test] + fn test_vec3_fits_within() { + let small = Vec3::new(5.0, 5.0, 5.0); + let large = Vec3::new(10.0, 10.0, 10.0); + + assert!(small.fits_within(&large, EPSILON_GENERAL)); + assert!(!large.fits_within(&small, EPSILON_GENERAL)); + } + + #[test] + fn test_bounding_box_intersects() { + let a = BoundingBox::from_position_and_dims(Vec3::zero(), Vec3::new(10.0, 10.0, 10.0)); + let b = BoundingBox::from_position_and_dims( + Vec3::new(5.0, 5.0, 5.0), + Vec3::new(10.0, 10.0, 10.0), + ); + let c = BoundingBox::from_position_and_dims( + Vec3::new(20.0, 20.0, 20.0), + Vec3::new(10.0, 10.0, 10.0), + ); + + assert!(a.intersects(&b)); + assert!(!a.intersects(&c)); + } + + #[test] + fn test_bounding_box_overlap_area() { + let a = BoundingBox::from_position_and_dims(Vec3::zero(), Vec3::new(10.0, 10.0, 10.0)); + let b = BoundingBox::from_position_and_dims( + Vec3::new(5.0, 5.0, 0.0), + Vec3::new(10.0, 10.0, 10.0), + ); + + let overlap = a.overlap_area_xy(&b); + assert!((overlap - 25.0).abs() < EPSILON_GENERAL); // 5x5 overlap + } + + #[test] + fn test_center_of_mass_calculator() { + let mut calc = CenterOfMassCalculator::new(); + calc.add_point(0.0, 0.0, 10.0); + calc.add_point(10.0, 0.0, 10.0); + + let center = calc.compute().unwrap(); + assert!((center.0 - 5.0).abs() < EPSILON_GENERAL); + assert!((center.1 - 0.0).abs() < EPSILON_GENERAL); + } + + #[test] + fn test_validation_dimension() { + assert!(validation::validate_dimension(10.0, "Width").is_ok()); + assert!(validation::validate_dimension(0.0, "Width").is_err()); + assert!(validation::validate_dimension(-1.0, "Width").is_err()); + assert!(validation::validate_dimension(f64::NAN, "Width").is_err()); + assert!(validation::validate_dimension(f64::INFINITY, "Width").is_err()); + } + + #[test] + fn test_validation_weight() { + assert!(validation::validate_weight(10.0).is_ok()); + assert!(validation::validate_weight(0.0).is_err()); + assert!(validation::validate_weight(-1.0).is_err()); + } +} diff --git a/src/update.rs b/src/update.rs index 1d9d01c..b23173e 100644 --- a/src/update.rs +++ b/src/update.rs @@ -64,7 +64,7 @@ impl TempDirCleanup { fn cleanup(&mut self) { if let Some(dir) = self.dir.take() { if let Err(err) = dir.close() { - eprintln!("⚠️ Konnte temporäres Verzeichnis nicht entfernen: {}", err); + eprintln!("⚠️ Could not remove temporary directory: {}", err); } } } @@ -92,13 +92,13 @@ impl Drop for TempDirCleanup { /// background. pub fn check_for_updates_background(update_config: UpdateConfig) -> Option> { if std::env::var("SORT_IT_NOW_SKIP_UPDATE_CHECK").is_ok() { - println!("ℹ️ Update-Check deaktiviert (SORT_IT_NOW_SKIP_UPDATE_CHECK gesetzt)."); + println!("ℹ️ Update check disabled (SORT_IT_NOW_SKIP_UPDATE_CHECK set)."); return None; } Some(tokio::spawn(async move { if let Err(err) = check_for_updates(&update_config).await { - eprintln!("⚠️ Update-Check fehlgeschlagen: {err}"); + eprintln!("⚠️ Update check failed: {err}"); } })) } @@ -125,18 +125,14 @@ async fn check_for_updates( if status == StatusCode::FORBIDDEN { let headers = response.headers().clone(); if is_rate_limit_response(&headers) { - let mut message = - String::from("⏱️ GitHub-Rate-Limit erreicht. Der Update-Check wurde übersprungen."); + let mut message = String::from("⏱️ GitHub rate limit reached. Update check skipped."); if let Some(wait) = rate_limit_reset_duration(&headers) { - message.push_str(&format!( - " Bitte versuche es in {} erneut.", - format_wait(wait) - )); + message.push_str(&format!(" Please try again in {}.", format_wait(wait))); } println!("{message}"); if token.is_none() { println!( - "💡 Tipp: Setze SORT_IT_NOW_GITHUB_TOKEN oder GITHUB_TOKEN mit einem persönlichen Zugriffstoken, um das Limit zu erhöhen." + "💡 Tip: Set SORT_IT_NOW_GITHUB_TOKEN or GITHUB_TOKEN with a personal access token to increase the limit." ); } return Ok(()); @@ -144,21 +140,21 @@ async fn check_for_updates( let body = match response.text().await { Ok(body) => body, - Err(_) => String::from("unbekannte Antwort"), + Err(_) => String::from("unknown response"), }; - return Err(format!("GitHub-API antwortete mit 403 Forbidden: {body}").into()); + return Err(format!("GitHub API responded with 403 Forbidden: {body}").into()); } if status == StatusCode::UNAUTHORIZED { eprintln!( - "⚠️ GitHub hat das verwendete Token zurückgewiesen (401 Unauthorized). Prüfe SORT_IT_NOW_GITHUB_TOKEN oder GITHUB_TOKEN." + "⚠️ GitHub rejected the token (401 Unauthorized). Check SORT_IT_NOW_GITHUB_TOKEN or GITHUB_TOKEN." ); return Ok(()); } if status == StatusCode::NOT_FOUND { println!( - "ℹ️ Konnte kein Release für {}/{} finden (404 Not Found).", + "ℹ️ Could not find a release for {}/{} (404 Not Found).", config.owner(), config.repo() ); @@ -177,26 +173,26 @@ async fn check_for_updates( ) { (Ok(current_ver), Ok(latest_ver)) if latest_ver > current_ver => { println!( - "✨ Eine neue Version ({}) ist verfügbar! Lade sie unter {} herunter.", + "✨ A new version ({}) is available! Download it at {}.", release.tag_name, release.html_url ); println!( - "🛠️ Automatisches Update auf {} wird vorbereitet – Release-Artefakt wird heruntergeladen und installiert.", + "🛠️ Preparing automatic update to {} – downloading and installing release artifact.", release.tag_name ); if let Err(err) = download_and_install_update(&client, &release, token.as_deref()).await { - eprintln!("⚠️ Automatisches Update fehlgeschlagen: {err}"); + eprintln!("⚠️ Automatic update failed: {err}"); } else { - println!("✅ Update auf {} wurde installiert.", release.tag_name); + println!("✅ Update to {} installed.", release.tag_name); } } (Ok(_), Ok(_)) => { - println!("✅ Du verwendest die aktuelle Version (v{current})."); + println!("✅ You are using the latest version (v{current})."); } _ => { println!( - "ℹ️ Konnte Versionsvergleich nicht durchführen. Aktuell: v{current}, Server: {}", + "ℹ️ Could not perform version comparison. Current: v{current}, Server: {}", release.tag_name ); } @@ -213,7 +209,7 @@ async fn download_and_install_update( #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = auth_token; - println!("ℹ️ Automatische Updates werden auf diesem Betriebssystem nicht unterstützt."); + println!("ℹ️ Automatic updates are not supported on this operating system."); return Ok(()); } @@ -226,23 +222,20 @@ async fn download_and_install_update( .find(|asset| asset_names.iter().any(|candidate| candidate == &asset.name)) .ok_or_else(|| { let candidates = asset_names.join(", "); - format!("Konnte kein Release-Asset finden. Erwartete Namen: {candidates}") + format!("Could not find release asset. Expected names: {candidates}") })?; let checksum_asset = find_checksum_asset(&release.assets, &asset.name).ok_or_else(|| { let expected = checksum_asset_names(&asset.name).join(", "); - format!( - "Konnte keine Prüfsummen-Datei finden. Erwartete Namen: {}", - expected - ) + format!("Could not find checksum file. Expected names: {}", expected) })?; - println!("⬇️ Lade Update-Paket {} herunter...", asset.name); - println!("🔒 Lade Prüfsumme {} ...", checksum_asset.name); + println!("⬇️ Downloading update package {}...", asset.name); + println!("🔒 Downloading checksum {}...", checksum_asset.name); let expected_checksum = fetch_checksum(client, checksum_asset, auth_token).await?; - println!("🔐 Verifiziere SHA-256-Checksumme für {}.", asset.name); + println!("🔐 Verifying SHA-256 checksum for {}.", asset.name); let mut request = client.get(&asset.browser_download_url); if let Some(token) = auth_token { @@ -258,7 +251,7 @@ async fn download_and_install_update( if content_length > limit_bytes { temp_dir.cleanup(); return Err(format!( - "Release-Asset {} überschreitet das Download-Limit von {} MB", + "Release asset {} exceeds download limit of {} MB", asset.name, limit_bytes / (1024 * 1024) ) @@ -280,7 +273,7 @@ async fn download_and_install_update( let _ = fs::remove_file(&archive_path).await; temp_dir.cleanup(); return Err(format!( - "Release-Asset {} überschreitet das Download-Limit von {} MB", + "Release asset {} exceeds download limit of {} MB", asset.name, limit_bytes / (1024 * 1024) ) @@ -298,7 +291,7 @@ async fn download_and_install_update( let _ = fs::remove_file(&archive_path).await; temp_dir.cleanup(); return Err(format!( - "Checksumme stimmt nicht überein (erwartet {}, erhalten {}). Update abgebrochen.", + "Checksum mismatch (expected {}, got {}). Update aborted.", expected_checksum, computed_checksum ) .into()); @@ -429,7 +422,7 @@ async fn fetch_checksum( std::io::Error::new( std::io::ErrorKind::InvalidData, format!( - "Konnte gültige SHA-256-Checksumme in {} nicht finden.", + "Could not find valid SHA-256 checksum in {}.", checksum_asset.name ), ) @@ -454,7 +447,7 @@ fn max_download_size_bytes() -> Option { } } else { eprintln!( - "⚠️ Konnte SORT_IT_NOW_MAX_DOWNLOAD_MB ('{}') nicht parsen. Verwende Standardlimit {} MB.", + "⚠️ Could not parse SORT_IT_NOW_MAX_DOWNLOAD_MB ('{}'). Using default limit {} MB.", trimmed, DEFAULT_LIMIT_MB ); Some(DEFAULT_LIMIT_MB * 1024 * 1024) @@ -463,7 +456,7 @@ fn max_download_size_bytes() -> Option { Err(std::env::VarError::NotPresent) => Some(DEFAULT_LIMIT_MB * 1024 * 1024), Err(err) => { eprintln!( - "⚠️ Zugriff auf SORT_IT_NOW_MAX_DOWNLOAD_MB fehlgeschlagen: {err}. Verwende Standardlimit {} MB.", + "⚠️ Failed to access SORT_IT_NOW_MAX_DOWNLOAD_MB: {err}. Using default limit {} MB.", DEFAULT_LIMIT_MB ); Some(DEFAULT_LIMIT_MB * 1024 * 1024) @@ -486,7 +479,7 @@ fn http_timeout() -> Duration { } } else { eprintln!( - "⚠️ Konnte SORT_IT_NOW_HTTP_TIMEOUT_SECS ('{}') nicht parsen. Verwende Standardtimeout {}s.", + "⚠️ Could not parse SORT_IT_NOW_HTTP_TIMEOUT_SECS ('{}'). Using default timeout {}s.", trimmed, DEFAULT_TIMEOUT_SECS ); Duration::from_secs(DEFAULT_TIMEOUT_SECS) @@ -495,7 +488,7 @@ fn http_timeout() -> Duration { Err(std::env::VarError::NotPresent) => Duration::from_secs(DEFAULT_TIMEOUT_SECS), Err(err) => { eprintln!( - "⚠️ Zugriff auf SORT_IT_NOW_HTTP_TIMEOUT_SECS fehlgeschlagen: {err}. Verwende Standardtimeout {}s.", + "⚠️ Failed to access SORT_IT_NOW_HTTP_TIMEOUT_SECS: {err}. Using default timeout {}s.", DEFAULT_TIMEOUT_SECS ); Duration::from_secs(DEFAULT_TIMEOUT_SECS) @@ -513,7 +506,7 @@ fn env_token(name: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { eprintln!( - "⚠️ Umgebungsvariable {} ist gesetzt, enthält aber keinen Wert.", + "⚠️ Environment variable {} is set but contains no value.", name ); None @@ -523,10 +516,7 @@ fn env_token(name: &str) -> Option { } Err(std::env::VarError::NotPresent) => None, Err(err) => { - eprintln!( - "⚠️ Zugriff auf {} fehlgeschlagen: {}. Ignoriere Wert.", - name, err - ); + eprintln!("⚠️ Failed to access {}: {}. Ignoring value.", name, err); None } } @@ -600,7 +590,7 @@ async fn install_on_unix( let bundle_dir = bundle_directory(&extract_root, tag_name); let binary_path = bundle_dir.join("sort_it_now"); if !binary_path.exists() { - return Err("Binärdatei sort_it_now wurde im entpackten Paket nicht gefunden".into()); + return Err("Binary sort_it_now was not found in the extracted package".into()); } let current_exe = std::env::current_exe()?; @@ -634,12 +624,9 @@ async fn install_on_unix( if err.kind() == std::io::ErrorKind::PermissionDenied { let _ = fs::remove_file(&next_launch_path).await; fs::rename(&staged_path, &next_launch_path).await?; + println!("⚠️ Could not replace the running application: {}.", err); println!( - "⚠️ Die laufende Anwendung konnte nicht ersetzt werden: {}.", - err - ); - println!( - "💡 Die aktualisierte Version wurde als {} abgelegt. Benenne sie nach einem Neustart in sort_it_now um.", + "💡 The updated version was saved as {}. Rename it to sort_it_now after a restart.", next_launch_path.display() ); return Ok(()); @@ -650,7 +637,7 @@ async fn install_on_unix( } println!( - "✅ Update nach {} installiert (Installationsziel: {}).", + "✅ Update to {} installed (installation target: {}).", tag_name, install_dir.display() ); @@ -703,13 +690,13 @@ async fn install_on_windows( let bundle_dir = bundle_directory(&extract_root, tag_name); let binary_path = bundle_dir.join("sort_it_now.exe"); if !binary_path.exists() { - return Err("Binärdatei sort_it_now.exe wurde im entpackten Paket nicht gefunden".into()); + return Err("Binary sort_it_now.exe was not found in the extracted package".into()); } let current_exe = std::env::current_exe()?; let install_dir = current_exe .parent() - .ok_or("Konnte Installationsverzeichnis nicht bestimmen")? + .ok_or("Could not determine installation directory")? .to_path_buf(); let target_path = install_dir.join("sort_it_now.exe"); @@ -718,21 +705,21 @@ async fn install_on_windows( copy_readme_if_present(&bundle_dir, &install_dir).await; match ensure_windows_path(&install_dir) { Ok(true) => println!( - "ℹ️ Das Installationsverzeichnis wurde zum Benutzer-PATH hinzugefügt. Du musst eventuell ein neues Terminal öffnen." + "ℹ️ The installation directory was added to user PATH. You may need to open a new terminal." ), Ok(false) => {} Err(err) => eprintln!( - "⚠️ Konnte PATH nicht aktualisieren: {}. Füge {} manuell hinzu.", + "⚠️ Could not update PATH: {}. Add {} manually.", err, install_dir.display() ), } println!( - "✅ Update nach {} installiert (Installationsziel: {}).", + "✅ Update to {} installed (installation target: {}).", tag_name, install_dir.display() ); - println!("ℹ️ Starte den Dienst mit: sort_it_now.exe"); + println!("ℹ️ Start the service with: sort_it_now.exe"); Ok(()) } Err(err) => { @@ -747,12 +734,9 @@ async fn install_on_windows( } } fs::copy(&binary_path, &staged_path).await?; + println!("⚠️ Could not replace the running application: {}.", err); println!( - "⚠️ Die laufende Anwendung konnte nicht ersetzt werden: {}.", - err - ); - println!( - "💡 Die aktualisierte Version wurde als {} abgelegt. Benenne sie nach einem Neustart in sort_it_now.exe um.", + "💡 The updated version was saved as {}. Rename it to sort_it_now.exe after a restart.", staged_path.display() ); Ok(()) @@ -772,7 +756,7 @@ async fn copy_readme_if_present(bundle_dir: &Path, install_dir: &Path) { let readme_dst = install_dir.join("README.md"); if let Err(err) = fs::copy(&readme_src, &readme_dst).await { - eprintln!("⚠️ Konnte README.md nicht aktualisieren: {}", err); + eprintln!("⚠️ Could not update README.md: {}", err); } } diff --git a/web/index.html b/web/index.html index 9843022..53167d8 100644 --- a/web/index.html +++ b/web/index.html @@ -1,9 +1,9 @@ - + - Sort-it-now 3D Visualisierung + Sort-it-now 3D Visualization