diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b6d03f..fe93620 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,45 @@ jobs: else VERSION="snapshot-${GITHUB_SHA::7}" fi + TRIMMED_VERSION="${VERSION#v}" + MAX_MSIX_COMPONENT=65535 + build_snapshot_msix_version() { + local max_component="$1" + local run_number="${GITHUB_RUN_NUMBER:-0}" + local year month_day seq + year="$(date -u +%Y)" + month_day="$(date -u +%m%d)" + # MSIX version components are limited to the range 0..=65535. + # If GITHUB_RUN_NUMBER is unavailable we fall back to 0 for reproducible snapshot metadata. + # `10#` forces base-10 parsing so zero-padded run numbers are not treated as octal. + # This yields 65,536 distinct values (0..=max_component) before wrapping on a later run, + # which is well beyond the expected number of snapshot packages emitted within one UTC bucket. + seq="$((10#${run_number} % (max_component + 1)))" + printf '%s\n' "${year}.${month_day}.${seq}.0" + } + if [[ "$TRIMMED_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + PACKAGE_VERSION="$TRIMMED_VERSION" + MSIX_MAJOR="${BASH_REMATCH[1]}" + MSIX_MINOR="${BASH_REMATCH[2]}" + MSIX_PATCH="${BASH_REMATCH[3]}" + # Direct semver→MSIX mapping is only valid while every component fits the + # Windows MSIX 16-bit component limit (0..=MAX_MSIX_COMPONENT). + if (( MSIX_MAJOR <= MAX_MSIX_COMPONENT && MSIX_MINOR <= MAX_MSIX_COMPONENT && MSIX_PATCH <= MAX_MSIX_COMPONENT )); then + MSIX_VERSION="${MSIX_MAJOR}.${MSIX_MINOR}.${MSIX_PATCH}.0" + else + echo "::warning::Release version '$VERSION' has at least one MSIX component above the limit (0..=${MAX_MSIX_COMPONENT}); using snapshot-style MSIX metadata." + MSIX_VERSION="$(build_snapshot_msix_version "$MAX_MSIX_COMPONENT")" + fi + else + echo "::warning::Release version '$VERSION' is not a plain semver tag; generating snapshot-style native package metadata." + DATE_STAMP="$(date -u +%Y%m%d)" + RUN_NUMBER="${GITHUB_RUN_NUMBER:-0}" + PACKAGE_VERSION="0.0.0+${DATE_STAMP}.${RUN_NUMBER}" + MSIX_VERSION="$(build_snapshot_msix_version "$MAX_MSIX_COMPONENT")" + fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT" + echo "msix_version=$MSIX_VERSION" >> "$GITHUB_OUTPUT" - name: Build release binary run: cargo build --release @@ -68,7 +106,9 @@ jobs: chmod +x "$BUNDLE_DIR/sort_it_now" cp README.md "$BUNDLE_DIR/" cp scripts/install-unix.sh "$BUNDLE_DIR/install.sh" + cp scripts/uninstall-unix.sh "$BUNDLE_DIR/uninstall.sh" chmod +x "$BUNDLE_DIR/install.sh" + chmod +x "$BUNDLE_DIR/uninstall.sh" tar -czf "$ARCHIVE_PATH" "$BUNDLE_DIR" if [[ "$RUNNER_OS" == "Linux" ]]; then sha256sum "$ARCHIVE_PATH" > "$ARCHIVE_PATH.sha256" @@ -76,10 +116,63 @@ jobs: shasum -a 256 "$ARCHIVE_PATH" | awk '{printf "%s %s\n", $1, $2}' > "$ARCHIVE_PATH.sha256" fi + - name: Package native installer (.deb) + if: matrix.os == 'ubuntu-latest' + env: + VERSION: ${{ steps.meta.outputs.version }} + PACKAGE_VERSION: ${{ steps.meta.outputs.package_version }} + SUFFIX: ${{ matrix.artifact_suffix }} + shell: bash + run: | + set -eux + PKG_ROOT="pkgroot" + mkdir -p "$PKG_ROOT/DEBIAN" "$PKG_ROOT/usr/bin" "$PKG_ROOT/usr/share/doc/sort-it-now" + cp target/release/sort_it_now "$PKG_ROOT/usr/bin/" + chmod 755 "$PKG_ROOT/usr/bin/sort_it_now" + cp README.md "$PKG_ROOT/usr/share/doc/sort-it-now/README.md" + cat > "$PKG_ROOT/DEBIAN/control" < + Description: 3D box packing optimizer with web UI + Sort-it-now optimizes cuboid placement with stability, weight, + balance and live visualization support. + EOF + DEB_PATH="sort-it-now-${VERSION}-${SUFFIX}.deb" + dpkg-deb --root-owner-group --build "$PKG_ROOT" "$DEB_PATH" + sha256sum "$DEB_PATH" > "$DEB_PATH.sha256" + + - name: Package native installer (.pkg) + if: startsWith(matrix.os, 'macos') + env: + VERSION: ${{ steps.meta.outputs.version }} + PACKAGE_VERSION: ${{ steps.meta.outputs.package_version }} + SUFFIX: ${{ matrix.artifact_suffix }} + shell: bash + run: | + set -eux + PKG_ROOT="pkgroot" + mkdir -p "$PKG_ROOT/usr/local/bin" "$PKG_ROOT/usr/local/share/doc/sort-it-now" + cp target/release/sort_it_now "$PKG_ROOT/usr/local/bin/" + chmod 755 "$PKG_ROOT/usr/local/bin/sort_it_now" + cp README.md "$PKG_ROOT/usr/local/share/doc/sort-it-now/README.md" + PKG_PATH="sort-it-now-${VERSION}-${SUFFIX}.pkg" + pkgbuild \ + --root "$PKG_ROOT" \ + --identifier "io.github.josunlp.sort-it-now" \ + --version "${PACKAGE_VERSION}" \ + --install-location "/" \ + "$PKG_PATH" + shasum -a 256 "$PKG_PATH" | awk '{printf "%s %s\n", $1, $2}' > "$PKG_PATH.sha256" + - name: Package artifact (Windows) if: matrix.os == 'windows-latest' env: VERSION: ${{ steps.meta.outputs.version }} + MSIX_VERSION: ${{ steps.meta.outputs.msix_version }} SUFFIX: ${{ matrix.artifact_suffix }} EXT: ${{ matrix.archive_extension }} shell: pwsh @@ -90,10 +183,109 @@ jobs: Copy-Item -Path "target\release\sort_it_now.exe" -Destination (Join-Path $bundleDir "sort_it_now.exe") Copy-Item -Path "README.md" -Destination (Join-Path $bundleDir "README.md") Copy-Item -Path "scripts\install-windows.ps1" -Destination (Join-Path $bundleDir "install.ps1") + Copy-Item -Path "scripts\uninstall-windows.ps1" -Destination (Join-Path $bundleDir "uninstall.ps1") Compress-Archive -Path $bundleDir -DestinationPath $archivePath -Force Get-FileHash -Path $archivePath -Algorithm SHA256 | Select-Object -ExpandProperty Hash | Out-File -FilePath "$archivePath.sha256" -Encoding ASCII - - name: Upload artifact + $msixDir = Join-Path $PWD "msix" + $assetsDir = Join-Path $msixDir "Assets" + New-Item -ItemType Directory -Force -Path $msixDir, $assetsDir | Out-Null + Copy-Item -Path "target\release\sort_it_now.exe" -Destination (Join-Path $msixDir "sort_it_now.exe") -Force + + Add-Type -AssemblyName System.Drawing + function New-SolidPng { + param( + [string]$Path, + [int]$Size + ) + + $bitmap = New-Object System.Drawing.Bitmap $Size, $Size + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.Clear([System.Drawing.Color]::FromArgb(255, 35, 117, 192)) + $brush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::White) + $font = New-Object System.Drawing.Font("Arial", [Math]::Max([int]($Size / 4), 10), [System.Drawing.FontStyle]::Bold) + $graphics.DrawString("SIN", $font, $brush, 4, [Math]::Max([int]($Size / 3), 4)) + $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + $graphics.Dispose() + $brush.Dispose() + $font.Dispose() + $bitmap.Dispose() + } + + New-SolidPng -Path (Join-Path $assetsDir "Square44x44Logo.png") -Size 44 + New-SolidPng -Path (Join-Path $assetsDir "Square150x150Logo.png") -Size 150 + New-SolidPng -Path (Join-Path $assetsDir "StoreLogo.png") -Size 50 + + @" + + + + + Sort It Now + JosunLP + 3D box packing optimizer + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + "@ | Out-File -FilePath (Join-Path $msixDir "AppxManifest.xml") -Encoding utf8 + + $kitBin = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64" -Directory | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $kitBin) { + throw "Could not locate Windows SDK tools for MSIX packaging." + } + + $makeAppx = Join-Path $kitBin.FullName "makeappx.exe" + $signtool = Join-Path $kitBin.FullName "signtool.exe" + $msixPath = "sort-it-now-$env:VERSION-$env:SUFFIX.msix" + & $makeAppx pack /d $msixDir /p $msixPath /o + + $cert = New-SelfSignedCertificate ` + -Type Custom ` + -Subject "CN=SortItNow" ` + -KeyUsage DigitalSignature ` + -FriendlyName "Sort It Now CI Signing" ` + -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3") ` + -CertStoreLocation "Cert:\CurrentUser\My" + $generatedPassword = [guid]::NewGuid().ToString("N") + $passwordSecure = ConvertTo-SecureString -String $generatedPassword -AsPlainText -Force + $pfxPath = Join-Path $PWD "sort-it-now-ci-signing.pfx" + $cerPath = "sort-it-now-$env:VERSION-$env:SUFFIX.cer" + Export-PfxCertificate -Cert $cert -FilePath $pfxPath -Password $passwordSecure | Out-Null + Export-Certificate -Cert $cert -FilePath $cerPath | Out-Null + & $signtool sign /fd SHA256 /f $pfxPath /p $generatedPassword $msixPath + Remove-Item -Path $pfxPath -Force + Get-FileHash -Path $msixPath -Algorithm SHA256 | Select-Object -ExpandProperty Hash | Out-File -FilePath "$msixPath.sha256" -Encoding ASCII + + - name: Upload archive artifact uses: actions/upload-artifact@v4 with: name: sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }} @@ -101,6 +293,34 @@ jobs: sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}.${{ matrix.archive_extension }} sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}.${{ matrix.archive_extension }}.sha256 + - name: Upload Linux installer artifact + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}-native + path: | + sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}.deb + sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}.deb.sha256 + + - name: Upload macOS installer artifact + if: startsWith(matrix.os, 'macos') + uses: actions/upload-artifact@v4 + with: + name: sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}-native + path: | + sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}.pkg + sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}.pkg.sha256 + + - name: Upload Windows installer artifact + if: matrix.os == 'windows-latest' + uses: actions/upload-artifact@v4 + with: + name: sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}-native + path: | + sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}.msix + sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}.msix.sha256 + sort-it-now-${{ steps.meta.outputs.version }}-${{ matrix.artifact_suffix }}.cer + publish: name: Publish GitHub Release runs-on: ubuntu-latest diff --git a/README.md b/README.md index 3ad08ff..dc95ebc 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,18 @@ An intelligent 3D packing optimization service with interactive visualization. - **OrbitControls** for camera control - **Container navigation** (Previous/Next buttons) - **Step-by-step animation** of the packing process +- **Highlighted live/animation focus** for the current placement step - **Live statistics**: - Object count - Total weight - Volume utilization - Center of mass position +- **Packing status panel** with progress and configuration readiness +- **Unplaced object panel** with rejection reasons - **Configuration modal** with object rotation toggle +- **Persistent configuration** via browser local storage +- **Keyboard shortcuts** for batch/live runs, animation, navigation, and configuration +- **Inline validation and toast notifications** for faster feedback - **Responsive design** ## 🚀 Installation & Startup @@ -40,6 +46,7 @@ An intelligent 3D packing optimization service with interactive visualization. - Rust (1.70+) - Cargo - Modern web browser +- Python 3 (only needed for the Unix one-command installer) ### Start the backend @@ -55,27 +62,94 @@ The server runs on `http://localhost:8080` The web client is automatically served by the Rust backend. After startup, simply open `http://localhost:8080/` in your browser. +> 🔗 **Same-origin note:** The frontend intentionally calls `/pack` and `/pack_stream` on the same origin that serves the UI. This matches the default local setup (`cargo run`) and the production deployment model where the Rust backend serves both API and web assets. + In the browser: - 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. +- Saved configurations are restored automatically after a page reload. +- The status and unplaced-object panels provide immediate feedback without blocking dialogs. +- Keyboard shortcuts: + - `B` = batch packing + - `L` = live packing + - `C` = open configuration + - `←` / `→` = switch containers + - `Space` = start/stop animation ## 📦 Pre-built Releases & Release Pipeline 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` +- **Linux native installer**: `sort-it-now--linux-x86_64.deb` - **macOS (ARM64/Apple Silicon)**: `sort-it-now--macos-arm64.tar.gz` - **macOS (x86_64/Intel)**: `sort-it-now--macos-x86_64.tar.gz` +- **macOS native installer**: `sort-it-now--macos-.pkg` - **Windows (x86_64)**: `sort-it-now--windows-x86_64.zip` +- **Windows native installer**: `sort-it-now--windows-x86_64.msix` -Each package contains the pre-compiled binary, the current `README.md`, and an installation script. +Each archive package contains the pre-compiled binary, the current `README.md`, and installation/uninstallation scripts. The artifacts are uploaded both as workflow artifacts and automatically added to the GitHub release for the corresponding tag version. -### Installation Scripts +### Single-command Installation / Uninstallation + +For reproducible installs, prefer a release tag (or commit SHA) instead of the mutable `main` branch. +Replace every `` placeholder below with the same release tag, including the `v` prefix (for example `v1.2.0`). +The examples download the script first so you can review it before executing. +You can additionally set `SORT_IT_NOW_VERSION=` to instruct the install scripts to download that specific release. + +- Linux / macOS install: + + ```bash + curl -fsSLo /tmp/sort-it-now-install-unix.sh \ + https://raw.githubusercontent.com/JosunLP/sort-it-now//scripts/install-unix.sh + chmod +x /tmp/sort-it-now-install-unix.sh + SORT_IT_NOW_VERSION= /tmp/sort-it-now-install-unix.sh + ``` + +- Linux / macOS uninstall: + + ```bash + curl -fsSLo /tmp/sort-it-now-uninstall-unix.sh \ + https://raw.githubusercontent.com/JosunLP/sort-it-now//scripts/uninstall-unix.sh + chmod +x /tmp/sort-it-now-uninstall-unix.sh + /tmp/sort-it-now-uninstall-unix.sh + ``` + +- Windows install (PowerShell): + + ```powershell + $version = "" + $script = Join-Path $env:TEMP "sort-it-now-install-windows.ps1" + irm "https://raw.githubusercontent.com/JosunLP/sort-it-now/$version/scripts/install-windows.ps1" -OutFile $script + $env:SORT_IT_NOW_VERSION = $version + & $script + ``` + +- Windows uninstall (PowerShell): + + ```powershell + $version = "" + $script = Join-Path $env:TEMP "sort-it-now-uninstall-windows.ps1" + irm "https://raw.githubusercontent.com/JosunLP/sort-it-now/$version/scripts/uninstall-windows.ps1" -OutFile $script + & $script + ``` + +Both installer scripts also continue to work locally from an extracted release bundle. Set `INSTALL_DIR` (Unix) or `-Destination` (PowerShell) to override the default target. + +### Archive Installation Scripts - Linux/macOS: Run `./install.sh` in the extracted folder (optionally with `sudo`) to copy `sort_it_now` to `/usr/local/bin`. +- Linux/macOS: Run `./uninstall.sh` in the extracted folder to remove a prior archive-based installation again. - Windows: Run `install.ps1` (PowerShell). By default, it installs to `%ProgramFiles%\sort-it-now` and adds the path to the user environment variable. +- Windows: Run `uninstall.ps1` to remove the installed binary and clean the user PATH entry again. + +### Native Installer Notes + +- **Linux (`.deb`)**: Install with `sudo dpkg -i sort-it-now--linux-x86_64.deb`, uninstall with `sudo dpkg -r sort-it-now`. +- **macOS (`.pkg`)**: Install with `sudo installer -pkg sort-it-now--macos-.pkg -target /`. Use the uninstall shell script afterwards if you want to remove the binary from `/usr/local/bin`. +- **Windows (`.msix`)**: Each release workflow run produces a signed MSIX together with a matching `.cer` certificate for that specific release. Import the certificate for the version you want to install into the trusted people store, then install the package with `Add-AppxPackage .\sort-it-now--windows-x86_64.msix`. Because the workflow currently signs with a repository-generated self-signed certificate, you may need to repeat the import step for a different release, and you should only trust a certificate when the release came from the official repository and the published checksums were verified. ### Docker @@ -112,7 +186,7 @@ The server is then available at `http://localhost:8080`. ## 🔔 Automatic Updates on Startup -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. +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 archive package matching the current platform and updates the installed binary in place. Native installers (`.deb`, `.pkg`, `.msix`) are published alongside the archive assets for manual installation flows. On Windows, if `sort_it_now.exe` is locked, a `sort_it_now.new.exe` is placed instead. - 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. diff --git a/scripts/install-unix.sh b/scripts/install-unix.sh index 727dc58..ed89094 100644 --- a/scripts/install-unix.sh +++ b/scripts/install-unix.sh @@ -4,30 +4,160 @@ set -euo pipefail APP_NAME="sort_it_now" DEFAULT_INSTALL_DIR="/usr/local/bin" INSTALL_DIR="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BINARY_PATH="$SCRIPT_DIR/$APP_NAME" +OWNER="${SORT_IT_NOW_GITHUB_OWNER:-JosunLP}" +REPO="${SORT_IT_NOW_GITHUB_REPO:-sort-it-now}" +REQUESTED_VERSION="${SORT_IT_NOW_VERSION:-latest}" +SCRIPT_SOURCE="${BASH_SOURCE[0]:-}" +SCRIPT_DIR="$PWD" +DOWNLOAD_TMP_DIR="" -if [[ ! -f "$BINARY_PATH" ]]; then - echo "❌ Could not find binary '$APP_NAME'. Make sure this script is run in the extracted release folder." >&2 - exit 1 +if [[ -n "$SCRIPT_SOURCE" && -e "$SCRIPT_SOURCE" ]]; then + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)" fi -if [[ ! -x "$BINARY_PATH" ]]; then - echo "ℹ️ Setting execute permissions for $BINARY_PATH" - chmod +x "$BINARY_PATH" -fi +install_local_binary() { + local binary_path="$1" -if [[ ! -d "$INSTALL_DIR" ]]; then - echo "ℹ️ Creating installation directory $INSTALL_DIR" - mkdir -p "$INSTALL_DIR" -fi + if [[ ! -x "$binary_path" ]]; then + echo "ℹ️ Setting execute permissions for $binary_path" + chmod +x "$binary_path" + fi -if [[ ! -w "$INSTALL_DIR" ]]; then - echo "⚠️ Write permissions missing in $INSTALL_DIR. Try using 'sudo'." >&2 - exit 1 -fi + if [[ ! -d "$INSTALL_DIR" ]]; then + echo "ℹ️ Creating installation directory $INSTALL_DIR" + mkdir -p "$INSTALL_DIR" + fi + + if [[ ! -w "$INSTALL_DIR" ]]; then + 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 was successfully installed to $INSTALL_DIR." + echo "ℹ️ Start the service with: $APP_NAME" +} + +detect_target_suffix() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + case "$os/$arch" in + Linux/x86_64|Linux/amd64) + printf '%s\n' "linux-x86_64" + ;; + Darwin/arm64|Darwin/aarch64) + printf '%s\n' "macos-arm64" + ;; + Darwin/x86_64) + printf '%s\n' "macos-x86_64" + ;; + *) + echo "❌ Unsupported platform for one-command install: $os/$arch" >&2 + exit 1 + ;; + esac +} + +parse_release_asset_urls() { + local suffix="$1" + if ! command -v python3 >/dev/null 2>&1; then + echo "❌ Python 3 is required for one-command installation metadata parsing." >&2 + exit 1 + fi + python3 -c ' +import json +import sys + +suffix = sys.argv[1] +release = json.load(sys.stdin) +archive = None +checksum = None + +for asset in release.get("assets", []): + name = asset.get("name", "") + url = asset.get("browser_download_url", "") + if name.endswith(f"{suffix}.tar.gz"): + archive = url + if name.endswith(f"{suffix}.tar.gz.sha256"): + checksum = url -install -m 755 "$BINARY_PATH" "$INSTALL_DIR/$APP_NAME" +if not archive: + raise SystemExit(1) -echo "✅ $APP_NAME was successfully installed to $INSTALL_DIR." -echo "ℹ️ Start the service with: $APP_NAME" +print(archive) +print(checksum or "") +' "$suffix" +} + +download_and_install_latest_release() { + local suffix api_url auth_header tmp_dir archive_path checksum_path release_json archive_url checksum_url bundle_dir expected_checksum computed_checksum + suffix="$(detect_target_suffix)" + if [[ "$REQUESTED_VERSION" == "latest" ]]; then + api_url="https://api.github.com/repos/$OWNER/$REPO/releases/latest" + else + api_url="https://api.github.com/repos/$OWNER/$REPO/releases/tags/$REQUESTED_VERSION" + fi + + auth_header=() + if [[ -n "${SORT_IT_NOW_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}" ]]; then + auth_header=(-H "Authorization: Bearer ${SORT_IT_NOW_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}") + fi + + if ! release_json="$(curl -fsSL "${auth_header[@]}" -H "Accept: application/vnd.github+json" "$api_url")"; then + echo "❌ Failed to fetch release metadata from $api_url." >&2 + exit 1 + fi + mapfile -t asset_urls < <(printf '%s' "$release_json" | parse_release_asset_urls "$suffix") + archive_url="${asset_urls[0]:-}" + checksum_url="${asset_urls[1]:-}" + if [[ -z "$archive_url" ]]; then + echo "❌ No matching release asset for $suffix was found in release '$REQUESTED_VERSION'." >&2 + exit 1 + fi + + tmp_dir="$(mktemp -d)" + DOWNLOAD_TMP_DIR="$tmp_dir" + trap 'rm -rf -- "$DOWNLOAD_TMP_DIR"' EXIT + archive_path="$tmp_dir/release.tar.gz" + checksum_path="$tmp_dir/release.tar.gz.sha256" + + echo "⬇️ Downloading sort-it-now release for $suffix..." + if ! curl -fsSL "${auth_header[@]}" -o "$archive_path" "$archive_url"; then + echo "❌ Failed to download release archive from $archive_url." >&2 + exit 1 + fi + if [[ -n "$checksum_url" ]]; then + if ! curl -fsSL "${auth_header[@]}" -o "$checksum_path" "$checksum_url"; then + echo "❌ Failed to download checksum file from $checksum_url." >&2 + exit 1 + fi + expected_checksum="$(awk '{print tolower($1)}' "$checksum_path" | head -n1)" + if command -v sha256sum >/dev/null 2>&1; then + computed_checksum="$(sha256sum "$archive_path" | awk '{print tolower($1)}')" + else + computed_checksum="$(shasum -a 256 "$archive_path" | awk '{print tolower($1)}')" + fi + if [[ "$expected_checksum" != "$computed_checksum" ]]; then + echo "❌ Checksum verification failed for downloaded archive." >&2 + exit 1 + fi + fi + + tar -xzf "$archive_path" -C "$tmp_dir" + bundle_dir="$(find "$tmp_dir" -maxdepth 1 -type d -name 'sort-it-now-*' | head -n1)" + if [[ -z "$bundle_dir" ]]; then + echo "❌ Could not locate extracted release bundle." >&2 + exit 1 + fi + + INSTALL_DIR="$INSTALL_DIR" bash "$bundle_dir/install.sh" +} + +BINARY_PATH="$SCRIPT_DIR/$APP_NAME" +if [[ -f "$BINARY_PATH" ]]; then + install_local_binary "$BINARY_PATH" +else + download_and_install_latest_release +fi diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index 437920b..66a6c27 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -2,34 +2,209 @@ param( [string]$Destination = "$env:ProgramFiles\sort-it-now" ) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ErrorActionPreference = "Stop" +$Owner = if ($env:SORT_IT_NOW_GITHUB_OWNER) { $env:SORT_IT_NOW_GITHUB_OWNER } else { "JosunLP" } +$Repo = if ($env:SORT_IT_NOW_GITHUB_REPO) { $env:SORT_IT_NOW_GITHUB_REPO } else { "sort-it-now" } +$RequestedVersion = if ($env:SORT_IT_NOW_VERSION) { $env:SORT_IT_NOW_VERSION } else { "latest" } +$scriptDir = if ($MyInvocation.MyCommand.Path) { Split-Path -Parent $MyInvocation.MyCommand.Path } else { (Get-Location).Path } $binaryPath = Join-Path $scriptDir "sort_it_now.exe" -if (-not (Test-Path $binaryPath)) { - Write-Error "The file sort_it_now.exe was not found. Run this script in the extracted release folder." - exit 1 +function Test-IsAdministrator { + $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($currentIdentity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } -if (-not (Test-Path $Destination)) { - New-Item -ItemType Directory -Path $Destination -Force | Out-Null +function Assert-DestinationWritable { + param([string]$TargetDirectory) + + $probeRoot = $TargetDirectory + while (-not (Test-Path $probeRoot)) { + $parent = Split-Path -Path $probeRoot -Parent + if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $probeRoot) { + break + } + $probeRoot = $parent + } + + if ([string]::IsNullOrWhiteSpace($probeRoot)) { + $probeRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($TargetDirectory)) + } + + try { + $probeFile = Join-Path $probeRoot ".sort-it-now-write-test-$([guid]::NewGuid())" + New-Item -ItemType File -Path $probeFile -Force | Out-Null + Remove-Item -Path $probeFile -Force + } + catch { + $fullDestination = [System.IO.Path]::GetFullPath($TargetDirectory) + $programFilesRoot = [System.IO.Path]::GetFullPath($env:ProgramFiles) + $suggestedDestination = Join-Path $env:LOCALAPPDATA "Programs\sort-it-now" + + if ($fullDestination.StartsWith($programFilesRoot, [System.StringComparison]::OrdinalIgnoreCase) -and -not (Test-IsAdministrator)) { + throw "Cannot write to $TargetDirectory. Re-run PowerShell as Administrator or pass -Destination `"$suggestedDestination`"." + } + + throw "Cannot write to $TargetDirectory. Choose a writable -Destination (for example `"$suggestedDestination`") or re-run PowerShell with sufficient permissions." + } } -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 +function Normalize-PathEntry { + param([string]$PathEntry) -Write-Host "sort-it-now was installed to $Destination." + if ([string]::IsNullOrWhiteSpace($PathEntry)) { + return $null + } -$path = [Environment]::GetEnvironmentVariable('Path', 'User') -if ($path -notlike "*$Destination*") { - if ([string]::IsNullOrWhiteSpace($path)) { - $newPath = $Destination + $normalized = $PathEntry.Trim() + try { + $normalized = [System.IO.Path]::GetFullPath($normalized) + if (Test-Path -LiteralPath $normalized) { + $normalized = (Get-Item -LiteralPath $normalized -ErrorAction Stop).FullName + } } - else { - $newPath = "$path;$Destination" + catch { + # Fall back to the original entry if the path cannot be resolved canonically. } - [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + $rootPath = [System.IO.Path]::GetPathRoot($normalized) + if ( + -not [string]::IsNullOrWhiteSpace($rootPath) -and + -not $rootPath.Equals($normalized, [System.StringComparison]::OrdinalIgnoreCase) + ) { + $normalized = $normalized.TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + } + + return $normalized +} + +function Add-DestinationToPath { + param([string]$PathEntry) + + $path = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @() + if (-not [string]::IsNullOrWhiteSpace($path)) { + $entries = $path -split ';' | Where-Object { $_ } + } + + $normalizedPathEntry = Normalize-PathEntry -PathEntry $PathEntry + $normalizedEntrySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($entry in $entries) { + $normalizedEntry = Normalize-PathEntry -PathEntry $entry + if (-not [string]::IsNullOrWhiteSpace($normalizedEntry)) { + [void]$normalizedEntrySet.Add($normalizedEntry) + } + } + + if ($normalizedEntrySet.Contains($normalizedPathEntry)) { + return + } + + $entries += $PathEntry + [Environment]::SetEnvironmentVariable('Path', ($entries -join ';'), 'User') Write-Host "The installation directory was added to user PATH. You may need to open a new terminal." } -Write-Host "Start the service with: sort_it_now.exe" +function Install-LocalBinary { + param( + [string]$BinaryPath, + [string]$TargetDirectory, + [string]$ReadmeSource + ) + + Assert-DestinationWritable -TargetDirectory $TargetDirectory + + if (-not (Test-Path $TargetDirectory)) { + New-Item -ItemType Directory -Path $TargetDirectory -Force | Out-Null + } + + Copy-Item -Path $BinaryPath -Destination (Join-Path $TargetDirectory "sort_it_now.exe") -Force + Copy-Item -Path $ReadmeSource -Destination (Join-Path $TargetDirectory "README.md") -Force -ErrorAction SilentlyContinue + Write-Host "sort-it-now was installed to $TargetDirectory." + Add-DestinationToPath -PathEntry $TargetDirectory + Write-Host "Start the service with: sort_it_now.exe" +} + +function Get-ReleaseAsset { + param( + [object]$Release, + [string]$Suffix + ) + + $archive = $Release.assets | Where-Object { $_.name -like "*$Suffix.zip" } | Select-Object -First 1 + $checksum = $Release.assets | Where-Object { $_.name -like "*$Suffix.zip.sha256" } | Select-Object -First 1 + if (-not $archive) { + throw "Could not find a release archive for $Suffix." + } + + return @{ + Archive = $archive + Checksum = $checksum + } +} + +function Install-FromRelease { + param([string]$TargetDirectory) + + $headers = @{ Accept = "application/vnd.github+json" } + if ($env:SORT_IT_NOW_GITHUB_TOKEN) { + $headers["Authorization"] = "Bearer $($env:SORT_IT_NOW_GITHUB_TOKEN)" + } + elseif ($env:GITHUB_TOKEN) { + $headers["Authorization"] = "Bearer $($env:GITHUB_TOKEN)" + } + + $releaseUrl = if ($RequestedVersion -eq "latest") { + "https://api.github.com/repos/$Owner/$Repo/releases/latest" + } + else { + "https://api.github.com/repos/$Owner/$Repo/releases/tags/$RequestedVersion" + } + + $release = Invoke-RestMethod -Uri $releaseUrl -Headers $headers + $assetSet = Get-ReleaseAsset -Release $release -Suffix "windows-x86_64" + + $tempDir = Join-Path $env:TEMP "sort-it-now-install-$([guid]::NewGuid())" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + try { + $archivePath = Join-Path $tempDir "release.zip" + $checksumPath = Join-Path $tempDir "release.zip.sha256" + + Write-Host "Downloading sort-it-now release..." + Invoke-WebRequest -Uri $assetSet.Archive.browser_download_url -Headers $headers -OutFile $archivePath + if ($assetSet.Checksum) { + Invoke-WebRequest -Uri $assetSet.Checksum.browser_download_url -Headers $headers -OutFile $checksumPath + $checksumTokens = (Get-Content -Path $checksumPath -Raw).Trim() -split '\s+' + if (-not $checksumTokens -or [string]::IsNullOrWhiteSpace($checksumTokens[0])) { + throw "Checksum file does not contain a valid hash." + } + $expectedHash = $checksumTokens[0].ToLowerInvariant() + $actualHash = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expectedHash -ne $actualHash) { + throw "Checksum verification failed for the downloaded archive." + } + } + + Expand-Archive -Path $archivePath -DestinationPath $tempDir -Force + $bundleDir = Get-ChildItem -Path $tempDir -Directory -Filter "sort-it-now-*" | Select-Object -First 1 + if (-not $bundleDir) { + throw "Could not find the extracted release bundle." + } + + & (Join-Path $bundleDir.FullName "install.ps1") -Destination $TargetDirectory + } + finally { + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if (Test-Path $binaryPath) { + Install-LocalBinary -BinaryPath $binaryPath -TargetDirectory $Destination -ReadmeSource (Join-Path $scriptDir "README.md") +} +else { + Install-FromRelease -TargetDirectory $Destination +} diff --git a/scripts/uninstall-unix.sh b/scripts/uninstall-unix.sh new file mode 100644 index 0000000..3af6c15 --- /dev/null +++ b/scripts/uninstall-unix.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="sort_it_now" +DEFAULT_INSTALL_DIR="/usr/local/bin" +INSTALL_DIR="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" +BINARY_PATH="$INSTALL_DIR/$APP_NAME" + +if [[ ! -e "$BINARY_PATH" ]]; then + echo "ℹ️ $APP_NAME is not installed in $INSTALL_DIR." + exit 0 +fi + +if [[ ! -w "$INSTALL_DIR" ]]; then + echo "⚠️ Write permissions missing in $INSTALL_DIR. Try using 'sudo'." >&2 + exit 1 +fi + +rm -f "$BINARY_PATH" +echo "✅ $APP_NAME was successfully removed from $INSTALL_DIR." diff --git a/scripts/uninstall-windows.ps1 b/scripts/uninstall-windows.ps1 new file mode 100644 index 0000000..ca911c5 --- /dev/null +++ b/scripts/uninstall-windows.ps1 @@ -0,0 +1,97 @@ +param( + [string]$Destination = "$env:ProgramFiles\sort-it-now" +) + +$ErrorActionPreference = "Stop" +$binaryPath = Join-Path $Destination "sort_it_now.exe" +$readmePath = Join-Path $Destination "README.md" + +function Test-IsAdministrator { + $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($currentIdentity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Assert-DestinationWritable { + param([string]$TargetDirectory) + + if (-not (Test-Path $TargetDirectory)) { + return + } + + try { + $probeFile = Join-Path $TargetDirectory ".sort-it-now-write-test-$([guid]::NewGuid())" + New-Item -ItemType File -Path $probeFile -Force | Out-Null + Remove-Item -Path $probeFile -Force + } + catch { + $fullDestination = [System.IO.Path]::GetFullPath($TargetDirectory) + $programFilesRoot = [System.IO.Path]::GetFullPath($env:ProgramFiles) + $suggestedDestination = Join-Path $env:LOCALAPPDATA "Programs\sort-it-now" + + if ($fullDestination.StartsWith($programFilesRoot, [System.StringComparison]::OrdinalIgnoreCase) -and -not (Test-IsAdministrator)) { + throw "Cannot remove files from $TargetDirectory. Re-run PowerShell as Administrator or pass -Destination `"$suggestedDestination`" if you installed it per-user." + } + + throw "Cannot remove files from $TargetDirectory. Re-run PowerShell with sufficient permissions or pass a writable -Destination." + } +} + +function Normalize-PathEntry { + param([string]$PathEntry) + + if ([string]::IsNullOrWhiteSpace($PathEntry)) { + return $null + } + + $normalized = $PathEntry.Trim() + try { + $normalized = [System.IO.Path]::GetFullPath($normalized) + if (Test-Path -LiteralPath $normalized) { + $normalized = (Get-Item -LiteralPath $normalized -ErrorAction Stop).FullName + } + } + catch { + # Fall back to the original entry if the path cannot be resolved canonically. + } + + if ($normalized.Length -gt 3) { + $normalized = $normalized.TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + } + + return $normalized +} + +$binaryPresent = Test-Path $binaryPath + +Assert-DestinationWritable -TargetDirectory $Destination + +if ($binaryPresent) { + Remove-Item -Path $binaryPath -Force +} +else { + Write-Host "sort-it-now executable was not found in $Destination. Continuing with PATH and directory cleanup." +} + +if (Test-Path $readmePath) { + Remove-Item -Path $readmePath -Force +} + +$pathEntries = ([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ }) +$normalizedDestination = Normalize-PathEntry -PathEntry $Destination +$remaining = $pathEntries | Where-Object { + (Normalize-PathEntry -PathEntry $_) -ne $normalizedDestination +} +[Environment]::SetEnvironmentVariable('Path', ($remaining -join ';'), 'User') + +if (Test-Path $Destination) { + $children = Get-ChildItem -Path $Destination -Force -ErrorAction SilentlyContinue + if (-not $children) { + Remove-Item -Path $Destination -Force -ErrorAction SilentlyContinue + } +} + +Write-Host "sort-it-now was successfully uninstalled." diff --git a/src/optimizer.rs b/src/optimizer.rs index 82f3deb..6261ec9 100644 --- a/src/optimizer.rs +++ b/src/optimizer.rs @@ -11,8 +11,8 @@ //! //! 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) +//! 1. **Sorting**: Objects are sorted by weight, occupied volume, and +//! physics-aware load indicators (heavy/large/high-pressure objects first) //! //! 2. **Clustering**: The `FootprintClusterStrategy` groups objects with similar //! footprints to reduce fragmentation @@ -23,7 +23,9 @@ //! 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 }` +//! - Evaluation by `PlacementScore { z, instability, support_ratio, +//! support_centroid_offset_ratio, support_contact_count, y, x, +//! balance_shift, balance }` //! //! 5. **Stability Checks**: Each candidate position must pass: //! - No collision with existing objects @@ -58,6 +60,7 @@ use std::cmp::Ordering; use crate::geometry::{intersects, overlap_1d, point_inside}; use crate::model::{Box3D, Container, ContainerBlueprint, PlacedBox}; +use crate::types::Dimensional; use utoipa::ToSchema; /// Configuration for the packing algorithm. @@ -94,6 +97,26 @@ impl PackingConfig { pub fn builder() -> PackingConfigBuilder { PackingConfigBuilder::default() } + + /// Normalizes numerically invalid runtime inputs for the packing pipeline. + /// + /// `PackingConfig` remains publicly constructible, so packing re-sanitizes the active + /// config before numeric thresholds are derived from user-provided values. + fn sanitized(mut self) -> Self { + self.grid_step = sanitize_positive_finite(self.grid_step, Self::DEFAULT_GRID_STEP); + self.support_ratio = sanitize_ratio(self.support_ratio, Self::DEFAULT_SUPPORT_RATIO); + self.height_epsilon = + sanitize_nonnegative_finite(self.height_epsilon, Self::DEFAULT_HEIGHT_EPSILON); + self.general_epsilon = + sanitize_nonnegative_finite(self.general_epsilon, Self::DEFAULT_GENERAL_EPSILON); + self.balance_limit_ratio = + sanitize_ratio(self.balance_limit_ratio, Self::DEFAULT_BALANCE_LIMIT_RATIO); + self.footprint_cluster_tolerance = sanitize_nonnegative_finite( + self.footprint_cluster_tolerance, + Self::DEFAULT_FOOTPRINT_CLUSTER_TOLERANCE, + ); + self + } } impl Default for PackingConfig { @@ -110,6 +133,34 @@ impl Default for PackingConfig { } } +fn sanitize_positive_finite(value: f64, fallback: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value + } else { + fallback + } +} + +fn sanitize_nonnegative_finite(value: f64, fallback: f64) -> f64 { + if value.is_finite() && value >= 0.0 { + value + } else { + fallback + } +} + +/// Accepts ratio-like values only inside the supported `[0.0, 1.0]` interval. +/// +/// Non-finite values fall back to the supplied default so downstream numeric thresholds remain +/// comparable. +fn sanitize_ratio(value: f64, fallback: f64) -> f64 { + if value.is_finite() && (0.0..=1.0).contains(&value) { + value + } else { + fallback + } +} + /// Builder pattern for PackingConfig (OOP principle). #[derive(Clone, Debug, Default)] pub struct PackingConfigBuilder { @@ -262,6 +313,71 @@ impl ObjectCluster { } } +#[derive(Clone, Copy, Debug)] +struct ObjectOrderingScore { + weight: f64, + volume: f64, + floor_load: f64, + density: f64, + slenderness: f64, +} + +fn object_ordering_score(object: &Box3D, config: &PackingConfig) -> ObjectOrderingScore { + let dims = object.dimensions(); + let (width, depth, height) = (dims.x, dims.y, dims.z); + let volume = object.volume(); + // `PackingConfig` is publicly constructible, so keep a minimal runtime floor even when + // callers bypass the builder/defaults and provide a zero or extremely small epsilon. + let epsilon = config.general_epsilon.max(f64::EPSILON); + let min_base_area = epsilon.powi(2); + let min_volume = epsilon.powi(3); + let base_area = object.base_area().max(min_base_area); + let min_base_edge = width.min(depth).max(epsilon); + + ObjectOrderingScore { + weight: object.weight, + volume, + floor_load: object.weight / base_area, + density: object.weight / volume.max(min_volume), + slenderness: height / min_base_edge, + } +} + +fn compare_objects_for_packing(a: &Box3D, b: &Box3D, config: &PackingConfig) -> Ordering { + let a_score = object_ordering_score(a, config); + let b_score = object_ordering_score(b, config); + + b_score + .weight + .partial_cmp(&a_score.weight) + .unwrap_or(Ordering::Equal) + .then_with(|| { + b_score + .volume + .partial_cmp(&a_score.volume) + .unwrap_or(Ordering::Equal) + }) + .then_with(|| { + b_score + .floor_load + .partial_cmp(&a_score.floor_load) + .unwrap_or(Ordering::Equal) + }) + .then_with(|| { + b_score + .density + .partial_cmp(&a_score.density) + .unwrap_or(Ordering::Equal) + }) + .then_with(|| { + b_score + .slenderness + .partial_cmp(&a_score.slenderness) + .unwrap_or(Ordering::Equal) + }) + .then_with(|| a.id.cmp(&b.id)) +} + fn orientations_for(object: &Box3D, allow_rotation: bool) -> Vec { if !allow_rotation { return vec![object.clone()]; @@ -596,6 +712,8 @@ pub fn pack_objects_with_progress( }; } + let config = config.sanitized(); + let mut templates = container_templates; templates.sort_by(|a, b| { a.volume() @@ -608,19 +726,10 @@ pub fn pack_objects_with_progress( }) }); - // Sorting: Heavy and large objects first (stability principle) + // Sorting: heavy and large objects first, then refine ties with + // pressure/density/slenderness to keep physically demanding items low. let mut objects = objects; - objects.sort_by(|a, b| { - b.weight - .partial_cmp(&a.weight) - .unwrap_or(Ordering::Equal) - .then_with(|| { - b.volume() - .partial_cmp(&a.volume()) - .unwrap_or(Ordering::Equal) - }) - .then_with(|| a.id.cmp(&b.id)) - }); + objects.sort_by(|a, b| compare_objects_for_packing(a, b, &config)); let cluster_strategy = FootprintClusterStrategy::new(config.footprint_cluster_tolerance); objects = cluster_strategy.reorder(objects); @@ -636,11 +745,11 @@ pub fn pack_objects_with_progress( for oriented in &orientations { // Versuche, in bestehenden Containern zu platzieren for idx in 0..containers.len() { - if !containers[idx].can_fit(&oriented) { + if !containers[idx].can_fit(oriented) { continue; } - if let Some(position) = find_stable_position(&oriented, &containers[idx], &config) { + if let Some(position) = find_stable_position(oriented, &containers[idx], &config) { containers[idx].placed.push(PlacedBox { object: oriented.clone(), position, @@ -680,12 +789,12 @@ pub fn pack_objects_with_progress( // Keine bestehenden Container geeignet, versuche neue Container for template in &templates { - if !template.can_fit(&oriented) { + if !template.can_fit(oriented) { continue; } let mut new_container = template.instantiate(); - if let Some(position) = find_stable_position(&oriented, &new_container, &config) { + if let Some(position) = find_stable_position(oriented, &new_container, &config) { let new_id = containers.len() + 1; let dims = new_container.dims; let max_weight = new_container.max_weight; @@ -804,6 +913,7 @@ fn find_stable_position( z_layers.dedup_by(|a, b| (*a - *b).abs() < config.height_epsilon); let balance_limit = calculate_balance_limit(cont, config); + let current_balance = calculate_current_balance_offset(cont); let mut best_in_limit: Option<((f64, f64, f64), PlacementScore)> = None; let mut best_any: Option<((f64, f64, f64), PlacementScore)> = None; @@ -834,21 +944,35 @@ fn find_stable_position( } // For placement above the floor: Check stability + let support_analysis = analyze_support_surface(&candidate, cont, config); if z > 0.0 { - if !has_sufficient_support(&candidate, cont, config) { + let required_support = (config.support_ratio - config.general_epsilon).max(0.0); + if support_analysis.support_ratio < required_support { continue; } - if !supports_weight_correctly(&candidate, cont, config) { + if !support_analysis.supports_weight { continue; } - if !is_center_supported(&candidate, cont, config) { + if !support_analysis.center_supported { // Prevents overhangs where the center of gravity is not supported continue; } } + let stability = + simulate_static_stability_from_analysis(&candidate, config, support_analysis); let balance = calculate_balance_after(cont, &candidate); - let score = PlacementScore { z, y, x, balance }; + let score = PlacementScore { + z, + instability: stability.instability_score, + support_ratio: stability.support_ratio, + support_centroid_offset_ratio: stability.support_centroid_offset_ratio, + support_contact_count: stability.support_contact_count, + y, + x, + balance_shift: (balance - current_balance).abs(), + balance, + }; update_best(&mut best_any, (x, y, z), score, config); @@ -899,69 +1023,97 @@ fn axis_positions(container_len: f64, object_len: f64, step: f64, epsilon: f64) positions } -/// Checks if an object is correctly supported by weight. -/// -/// Ensures that no heavier objects rest on lighter ones. +/// Calculates the ratio of an object's base area that is supported. /// /// # 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; - } - - let (bx, by, bz) = b.position; - let (bw, bd, _) = b.object.dims; - let mut has_support = false; - - for p in &cont.placed { - let top_z = p.position.2 + p.object.dims.2; - if (bz - top_z).abs() > config.height_epsilon { - continue; - } - - let over_x = overlap_1d(bx, bx + bw, p.position.0, p.position.0 + p.object.dims.0); - let over_y = overlap_1d(by, by + bd, p.position.1, p.position.1 + p.object.dims.1); - - if over_x <= 0.0 || over_y <= 0.0 { - continue; - } - - has_support = true; - - // Heavier object must not rest on lighter one - if p.object.weight + config.general_epsilon < b.object.weight { - return false; - } - } - - has_support +/// +/// # Returns +/// A value in the range `0.0..=1.0`, where `1.0` means the full base area is supported. +fn support_ratio_of(b: &PlacedBox, cont: &Container, config: &PackingConfig) -> f64 { + analyze_support_surface(b, cont, config).support_ratio } -/// Checks if an object is sufficiently supported. +/// Calculates the balance/center of gravity deviation after adding an object. /// -/// Calculates the fraction of the base area resting on other objects. +/// Computes the weighted center of gravity of all objects and its distance +/// to the geometric center of the container. /// /// # Parameters -/// * `b` - The placed object to check /// * `cont` - The container -/// * `config` - Configuration parameters -fn support_ratio_of(b: &PlacedBox, cont: &Container, config: &PackingConfig) -> f64 { +/// * `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, + new_box.position.1 + new_box.object.dims.1 / 2.0, + new_box.object.weight, + ); + + match compute_center_of_mass_xy( + cont.placed + .iter() + .map(|p| { + ( + p.position.0 + p.object.dims.0 / 2.0, + p.position.1 + p.object.dims.1 / 2.0, + p.object.weight, + ) + }) + .chain(std::iter::once(new_point)), + ) { + Some(cm) => distance_2d(cm, container_center_xy(cont)), + None => 0.0, + } +} + +#[derive(Clone, Copy, Debug)] +struct StaticStabilityMetrics { + support_ratio: f64, + support_contact_count: usize, + support_centroid_offset_ratio: f64, + instability_score: f64, +} + +#[derive(Clone, Copy, Debug)] +struct SupportAnalysis { + support_ratio: f64, + support_contact_count: usize, + support_centroid_offset_ratio: f64, + supports_weight: bool, + center_supported: bool, +} + +const SUPPORT_DEFICIT_WEIGHT: f64 = 4.0; +const SINGLE_SUPPORT_CONTACT_PENALTY: f64 = 0.15; + +fn analyze_support_surface( + b: &PlacedBox, + cont: &Container, + config: &PackingConfig, +) -> SupportAnalysis { if b.position.2 <= config.height_epsilon { - return 1.0; + return SupportAnalysis { + support_ratio: 1.0, + support_contact_count: 0, + support_centroid_offset_ratio: 0.0, + supports_weight: true, + center_supported: true, + }; } let (bx, by, bz) = b.position; let (bw, bd, _) = b.object.dims; - let base_area = bw * bd; - let min_support_area = config.general_epsilon * config.general_epsilon; - if base_area <= min_support_area { - return 0.0; - } - + let min_base_area = config.general_epsilon.max(f64::EPSILON).powi(2); + let base_area = b.base_area().max(min_base_area); + let center_xy = (bx + bw / 2.0, by + bd / 2.0, bz); let mut support_area = 0.0; + let mut support_center_x = 0.0; + let mut support_center_y = 0.0; + let mut support_contacts = 0usize; + let mut supports_weight = true; + let mut center_supported = false; for p in &cont.placed { let support_surface_z = p.position.2 + p.object.dims.2; @@ -971,92 +1123,114 @@ fn support_ratio_of(b: &PlacedBox, cont: &Container, config: &PackingConfig) -> let over_x = overlap_1d(bx, bx + bw, p.position.0, p.position.0 + p.object.dims.0); let over_y = overlap_1d(by, by + bd, p.position.1, p.position.1 + p.object.dims.1); + if over_x <= config.general_epsilon || over_y <= config.general_epsilon { + continue; + } + + let overlap_area = over_x * over_y; + let overlap_center_x = bx.max(p.position.0) + over_x / 2.0; + let overlap_center_y = by.max(p.position.1) + over_y / 2.0; - if over_x > 0.0 && over_y > 0.0 { - support_area += over_x * over_y; + support_area += overlap_area; + support_center_x += overlap_center_x * overlap_area; + support_center_y += overlap_center_y * overlap_area; + support_contacts += 1; + + if p.object.weight + config.general_epsilon < b.object.weight { + supports_weight = false; + } + + if point_inside(center_xy, p) { + center_supported = true; } } - (support_area / base_area).clamp(0.0, 1.0) -} + let support_ratio = (support_area / base_area).clamp(0.0, 1.0); + let min_base_edge = bw.min(bd).max(config.general_epsilon); + let support_centroid_offset_ratio = if support_area >= min_base_area { + let centroid = ( + support_center_x / support_area, + support_center_y / support_area, + ); + let base_radius = (min_base_edge / 2.0).max(config.general_epsilon); + distance_2d((center_xy.0, center_xy.1), centroid) / base_radius + } else { + 1.0 + }; -fn has_sufficient_support(b: &PlacedBox, cont: &Container, config: &PackingConfig) -> bool { - if b.position.2 <= config.height_epsilon { - return true; + SupportAnalysis { + support_ratio, + support_contact_count: support_contacts, + support_centroid_offset_ratio, + // Without any supporting contacts, the candidate is inherently unsupported for load transfer. + supports_weight: support_contacts > 0 && supports_weight, + center_supported, } - - let required_support = (config.support_ratio - config.general_epsilon).max(0.0); - support_ratio_of(b, cont, config) >= required_support } -/// Checks if the center of gravity of the object (XY projection) is supported by the bearing surface. -/// -/// 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 { +fn simulate_static_stability_from_analysis( + b: &PlacedBox, + config: &PackingConfig, + support: SupportAnalysis, +) -> StaticStabilityMetrics { if b.position.2 <= config.height_epsilon { - return true; + return StaticStabilityMetrics { + support_ratio: support.support_ratio, + support_contact_count: support.support_contact_count, + support_centroid_offset_ratio: support.support_centroid_offset_ratio, + instability_score: 0.0, + }; } - let center_xy = ( - b.position.0 + b.object.dims.0 / 2.0, - b.position.1 + b.object.dims.1 / 2.0, - b.position.2, - ); - - for p in &cont.placed { - let top_z = p.position.2 + p.object.dims.2; - if (b.position.2 - top_z).abs() > config.height_epsilon { - continue; - } + let (_, _, bh) = b.object.dims; + let min_base_edge = b + .object + .dims + .0 + .min(b.object.dims.1) + .max(config.general_epsilon); + let slenderness = bh / min_base_edge; + let contact_penalty = if support.support_contact_count <= 1 { + SINGLE_SUPPORT_CONTACT_PENALTY + } else { + 0.0 + }; + let instability_score = (1.0 - support.support_ratio) * SUPPORT_DEFICIT_WEIGHT + + support.support_centroid_offset_ratio * (1.0 + slenderness) + + contact_penalty; - if point_inside(center_xy, p) { - return true; - } + StaticStabilityMetrics { + support_ratio: support.support_ratio, + support_contact_count: support.support_contact_count, + support_centroid_offset_ratio: support.support_centroid_offset_ratio, + instability_score, } - false } -/// Calculates the balance/center of gravity deviation after adding an object. -/// -/// Computes the weighted center of gravity of all objects and its distance -/// to the geometric center of the container. -/// -/// # 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, - new_box.position.1 + new_box.object.dims.1 / 2.0, - new_box.object.weight, - ); - - match compute_center_of_mass_xy( - cont.placed - .iter() - .map(|p| { - ( - p.position.0 + p.object.dims.0 / 2.0, - p.position.1 + p.object.dims.1 / 2.0, - p.object.weight, - ) - }) - .chain(std::iter::once(new_point)), - ) { - Some(cm) => distance_2d(cm, container_center_xy(cont)), - None => 0.0, - } +// Kept as a convenience function for tests and future call sites that need static +// stability metrics without manually threading precomputed support analysis. +#[allow(dead_code)] +fn simulate_static_stability( + b: &PlacedBox, + cont: &Container, + config: &PackingConfig, +) -> StaticStabilityMetrics { + simulate_static_stability_from_analysis(b, config, analyze_support_surface(b, cont, config)) } /// Evaluation of a placement position. /// -/// Lower values are better (z first, then y, then x, then balance). +/// Lower values are better (z first, then local instability, then y/x, then balance). #[derive(Clone, Copy)] struct PlacementScore { z: f64, + instability: f64, + support_ratio: f64, + support_centroid_offset_ratio: f64, + support_contact_count: usize, y: f64, x: f64, + balance_shift: f64, balance: f64, } @@ -1087,7 +1261,9 @@ fn update_best( /// Compares two placement scores. /// -/// Priority: z (low) > y (low) > x (low) > balance (low) +/// Priority: z (low) > local instability (low) > support ratio (high) +/// > center-offset ratio (low) > support contacts (high) > y (low) +/// > x (low) > balance shift (low) > balance (low) /// /// # Parameters /// * `new` - New score @@ -1100,6 +1276,41 @@ fn is_better_score(new: PlacementScore, current: PlacementScore, config: &Packin Ordering::Equal => {} } + match compare_with_epsilon(new.instability, current.instability, config.general_epsilon) { + Ordering::Less => return true, + Ordering::Greater => return false, + Ordering::Equal => {} + } + + match compare_with_epsilon( + current.support_ratio, + new.support_ratio, + config.general_epsilon, + ) { + Ordering::Less => return true, + Ordering::Greater => return false, + Ordering::Equal => {} + } + + match compare_with_epsilon( + new.support_centroid_offset_ratio, + current.support_centroid_offset_ratio, + config.general_epsilon, + ) { + Ordering::Less => return true, + Ordering::Greater => return false, + Ordering::Equal => {} + } + + match current + .support_contact_count + .cmp(&new.support_contact_count) + { + Ordering::Less => return true, + Ordering::Greater => return false, + Ordering::Equal => {} + } + match compare_with_epsilon(new.y, current.y, config.general_epsilon) { Ordering::Less => return true, Ordering::Greater => return false, @@ -1112,6 +1323,16 @@ fn is_better_score(new: PlacementScore, current: PlacementScore, config: &Packin Ordering::Equal => {} } + match compare_with_epsilon( + new.balance_shift, + current.balance_shift, + config.general_epsilon, + ) { + Ordering::Less => return true, + Ordering::Greater => return false, + Ordering::Equal => {} + } + new.balance + config.general_epsilon < current.balance } @@ -1816,4 +2037,197 @@ mod tests { assert!(diagnostics_events >= 1); } + + #[test] + fn physics_aware_object_ordering_breaks_volume_ties_with_floor_load() { + let higher_floor_load = Box3D { + id: 1, + dims: (5.0, 5.0, 4.0), + weight: 10.0, + }; + let lower_floor_load = Box3D { + id: 2, + dims: (10.0, 10.0, 1.0), + weight: 10.0, + }; + + let config = PackingConfig::default(); + let ordering = compare_objects_for_packing(&higher_floor_load, &lower_floor_load, &config); + assert_eq!(ordering, Ordering::Less); + } + + #[test] + fn object_ordering_score_scales_epsilon_by_dimension_units() { + let config = PackingConfig::builder().general_epsilon(0.1).build(); + let object = Box3D { + id: 1, + dims: (0.2, 0.2, 0.2), + weight: 10.0, + }; + + let score = object_ordering_score(&object, &config); + + assert!((score.floor_load - 250.0).abs() <= 1e-9); + assert!((score.density - 1250.0).abs() <= 1e-9); + } + + #[test] + fn simulated_stability_penalizes_offset_support() { + let config = PackingConfig::default(); + let mut container = Container::new((20.0, 20.0, 30.0), 200.0).unwrap(); + + container.placed.push(PlacedBox { + object: Box3D { + id: 1, + dims: (10.0, 10.0, 10.0), + weight: 20.0, + }, + position: (0.0, 0.0, 0.0), + }); + + let centered = PlacedBox { + object: Box3D { + id: 2, + dims: (10.0, 10.0, 8.0), + weight: 8.0, + }, + position: (0.0, 0.0, 10.0), + }; + let offset = PlacedBox { + object: centered.object.clone(), + position: (4.0, 0.0, 10.0), + }; + + let centered_metrics = simulate_static_stability(¢ered, &container, &config); + let offset_metrics = simulate_static_stability(&offset, &container, &config); + + assert!(centered_metrics.support_ratio > offset_metrics.support_ratio); + assert_eq!(centered_metrics.support_contact_count, 1); + assert_eq!(offset_metrics.support_contact_count, 1); + assert!( + centered_metrics.support_centroid_offset_ratio + < offset_metrics.support_centroid_offset_ratio + ); + assert!(centered_metrics.instability_score < offset_metrics.instability_score); + } + + #[test] + fn support_analysis_uses_area_scaled_epsilon_floor() { + let config = PackingConfig::builder().general_epsilon(0.1).build(); + let mut container = Container::new((5.0, 5.0, 5.0), 100.0).unwrap(); + container.placed.push(PlacedBox { + object: Box3D { + id: 1, + dims: (0.2, 0.2, 0.2), + weight: 5.0, + }, + position: (0.0, 0.0, 0.0), + }); + + let supported = PlacedBox { + object: Box3D { + id: 2, + dims: (0.2, 0.2, 0.2), + weight: 4.0, + }, + position: (0.0, 0.0, 0.2), + }; + + let analysis = analyze_support_surface(&supported, &container, &config); + + assert!((analysis.support_ratio - 1.0).abs() <= 1e-9); + } + + #[test] + fn support_centroid_offset_ratio_uses_area_scaled_threshold() { + let config = PackingConfig::builder().general_epsilon(0.1).build(); + let mut container = Container::new((5.0, 5.0, 5.0), 100.0).unwrap(); + container.placed.push(PlacedBox { + object: Box3D { + id: 1, + dims: (0.2, 0.2, 0.2), + weight: 5.0, + }, + position: (0.0, 0.0, 0.0), + }); + + let offset = PlacedBox { + object: Box3D { + id: 2, + dims: (0.2, 0.2, 0.2), + weight: 4.0, + }, + position: (0.05, 0.0, 0.2), + }; + + let analysis = analyze_support_surface(&offset, &container, &config); + + assert!((analysis.support_ratio - 0.75).abs() <= 1e-9); + assert!(analysis.support_centroid_offset_ratio > 0.0); + assert!(analysis.support_centroid_offset_ratio < 1.0); + } + + #[test] + fn packing_config_sanitizes_invalid_numeric_values() { + let config = PackingConfig { + grid_step: 0.0, + support_ratio: -0.5, + height_epsilon: -1.0, + general_epsilon: f64::NAN, + balance_limit_ratio: 2.0, + footprint_cluster_tolerance: -0.5, + allow_item_rotation: true, + }; + + let sanitized = config.sanitized(); + + assert_eq!(sanitized.grid_step, PackingConfig::DEFAULT_GRID_STEP); + // Ratio-like fields fall back to safe defaults when callers provide out-of-range values. + assert_eq!( + sanitized.support_ratio, + PackingConfig::DEFAULT_SUPPORT_RATIO + ); + assert_eq!( + sanitized.height_epsilon, + PackingConfig::DEFAULT_HEIGHT_EPSILON + ); + assert_eq!( + sanitized.general_epsilon, + PackingConfig::DEFAULT_GENERAL_EPSILON + ); + // Balance is expressed as a ratio of the diagonal, so invalid values are reset as well. + assert_eq!( + sanitized.balance_limit_ratio, + PackingConfig::DEFAULT_BALANCE_LIMIT_RATIO + ); + assert_eq!( + sanitized.footprint_cluster_tolerance, + PackingConfig::DEFAULT_FOOTPRINT_CLUSTER_TOLERANCE + ); + assert!(sanitized.allow_item_rotation); + } + + #[test] + fn packing_uses_sanitized_support_ratio_thresholds() { + let config = PackingConfig { + support_ratio: f64::NAN, + ..PackingConfig::default() + }; + let objects = vec![ + Box3D::new(1, (5.0, 10.0, 5.0), 10.0).unwrap(), + Box3D::new(2, (10.0, 10.0, 5.0), 5.0).unwrap(), + ]; + + let result = + pack_objects_with_config(objects, single_blueprint((10.0, 10.0, 10.0), 100.0), config); + + // In the original container, the second box can only fit on top of the half-width base box. + // Requiring a second container therefore proves the sanitized support threshold rejected + // that otherwise collision-free but insufficiently supported stack. + assert_eq!(result.containers.len(), 2); + assert_eq!(result.containers[0].placed.len(), 1); + assert_eq!(result.containers[1].placed.len(), 1); + assert_eq!(result.containers[0].placed[0].object.id, 1); + assert_eq!(result.containers[1].placed[0].object.id, 2); + } } diff --git a/web/index.html b/web/index.html index 53167d8..7f55360 100644 --- a/web/index.html +++ b/web/index.html @@ -47,6 +47,14 @@ box-shadow: 0 4px 20px rgba(0, 170, 255, 0.3); } + .panel { + background: rgba(0, 0, 0, 0.8); + border: 2px solid #00aaff; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 170, 255, 0.3); + pointer-events: auto; + } + #stats h3 { color: #00aaff; margin-bottom: 15px; @@ -165,6 +173,222 @@ color: #ffcc00; } + #statusPanel { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + min-width: 340px; + max-width: min(92vw, 520px); + padding: 18px; + } + + #statusPanel h3, + #unplacedPanel h3 { + color: #00aaff; + margin-bottom: 12px; + font-size: 1.1em; + } + + #statusContent { + display: grid; + gap: 12px; + } + + .status-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + } + + .pill.info { + color: #7dd3fc; + } + + .pill.success { + color: #86efac; + } + + .pill.warning { + color: #fcd34d; + } + + .pill.error { + color: #fca5a5; + } + + .status-message { + line-height: 1.5; + color: #efefef; + } + + .status-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + } + + .metric-card { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(0, 170, 255, 0.2); + border-radius: 8px; + padding: 10px; + } + + .metric-card strong { + display: block; + margin-bottom: 4px; + color: #ffcc00; + font-size: 0.82rem; + } + + .progress-track { + width: 100%; + height: 10px; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.12); + } + + .progress-fill { + height: 100%; + width: 0; + border-radius: inherit; + background: linear-gradient(135deg, #00ccff, #55ff55); + transition: width 0.25s ease; + } + + .legend-list, + .issue-list, + .shortcut-list { + list-style: none; + display: grid; + gap: 8px; + padding: 0; + } + + .legend-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.88rem; + } + + .legend-swatch { + width: 14px; + height: 14px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.25); + flex-shrink: 0; + } + + .legend-swatch.frame { + background: rgba(0, 170, 255, 0.2); + border-color: #00aaff; + } + + .legend-swatch.placed { + background: linear-gradient(135deg, #7c3aed, #06b6d4); + } + + .legend-swatch.active { + background: linear-gradient(135deg, #ffcc00, #ff7a00); + } + + .legend-swatch.unplaced { + background: linear-gradient(135deg, #ef4444, #f97316); + } + + #unplacedPanel { + position: absolute; + right: 20px; + bottom: 120px; + width: min(360px, calc(100vw - 40px)); + padding: 16px; + max-height: 40vh; + overflow: auto; + } + + .empty-state { + color: #bdbdbd; + font-size: 0.9rem; + line-height: 1.5; + } + + .unplaced-item { + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + } + + .unplaced-item strong { + color: #ffcc00; + } + + .unplaced-meta { + margin-top: 4px; + color: #d5d5d5; + font-size: 0.85rem; + line-height: 1.45; + } + + #toastRegion { + position: absolute; + right: 20px; + top: 20px; + z-index: 1100; + width: min(360px, calc(100vw - 40px)); + display: grid; + gap: 10px; + pointer-events: none; + } + + .toast { + padding: 12px 14px; + border-radius: 10px; + color: #fff; + background: rgba(15, 23, 42, 0.94); + border-left: 4px solid #00aaff; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35); + animation: toast-in 0.25s ease; + } + + .toast.success { + border-left-color: #22c55e; + } + + .toast.warning { + border-left-color: #f59e0b; + } + + .toast.error { + border-left-color: #ef4444; + } + + @keyframes toast-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + @keyframes pulse { 0%, 100% { @@ -215,6 +439,11 @@ .close { color: #aaa; float: right; + background: transparent; + border: 0; + padding: 0; + border-radius: 0; + box-shadow: none; font-size: 28px; font-weight: bold; cursor: pointer; @@ -223,6 +452,13 @@ .close:hover { color: #fff; + background: transparent; + transform: none; + box-shadow: none; + } + + .close:active { + transform: none; } .form-group { @@ -325,22 +561,110 @@ #saveConfigBtn { width: 100%; } + + .validation-summary { + margin-bottom: 20px; + padding: 14px; + border-radius: 8px; + border: 1px solid rgba(0, 170, 255, 0.3); + background: rgba(0, 170, 255, 0.08); + } + + .validation-summary[data-state='warning'] { + border-color: rgba(245, 158, 11, 0.45); + background: rgba(245, 158, 11, 0.12); + } + + .validation-summary[data-state='ready'] { + border-color: rgba(34, 197, 94, 0.45); + background: rgba(34, 197, 94, 0.12); + } + + .validation-summary strong { + color: #ffcc00; + } + + .shortcut-list .kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + padding: 2px 6px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.14); + font-size: 0.82rem; + } + + @media (max-width: 960px) { + #stats, + #statusPanel, + #help, + #unplacedPanel { + position: static; + width: min(100%, 720px); + max-width: 100%; + margin: 12px auto 0; + transform: none; + } + + #ui-overlay { + overflow: auto; + padding: 12px; + } + + #controls { + position: static; + transform: none; + flex-wrap: wrap; + justify-content: center; + width: min(100%, 720px); + margin: 12px auto 24px; + } + + .separator { + display: none; + } + + .status-grid { + grid-template-columns: 1fr; + } + } + + @media (prefers-reduced-motion: reduce) { + * { + scroll-behavior: auto; + } + + button, + .progress-fill, + .toast, + .loading { + transition: none !important; + animation: none !important; + } + }
-
-
-

Statistics

-

Objects: 0 / 0

-

Weight: 0.00 kg

-

Utilization: 0%

-

Center of Mass: (0, 0, 0)

-
+
+
+

Statistics

+

Objects: 0 / 0

+

Weight: 0.00 kg

+

Utilization: 0%

+

Center of Mass: (0, 0, 0)

+
-
- +
+

📊 Packing Status

+
+
+ +
+
@@ -353,23 +677,69 @@

Statistics

-
-

🎮 Controls

-
    -
  • Left mouse button: Rotate camera
  • -
  • Right mouse button: Pan camera
  • -
  • Mouse wheel: Zoom
  • -
  • Navigation: Switch containers
  • -
  • Animation: Step-by-step placement
  • -
+
+

🎮 Controls

+
    +
  • Left mouse button: Rotate camera
  • +
  • Right mouse button: Pan camera
  • +
  • Mouse wheel: Zoom
  • +
  • Arrow keys: Switch containers
  • +
  • Space: Start/stop animation
  • +
  • B / L / C: Batch, Live, Configuration
  • +
+

🎨 Legend

+
    +
  • + + Container frame and floor grid +
  • +
  • + + Packed objects already placed +
  • +
  • + + Current animation/live focus +
  • +
  • + + Objects that could not be packed +
  • +
+
+ +
+

📦 Unplaced Objects

+
    +
    + +
    -
    -