commit 7af9c323f65de8278058c3a073f538270f9bfc8e Author: likegears <30487484+likegears@users.noreply.github.com> Date: Fri Dec 12 14:00:17 2025 +0800 Initial commit: 3D Viewer application Features: - Vue 3 frontend with Three.js/Online3DViewer - Node.js API with PostgreSQL and Redis - Python worker for model conversion - Docker Compose for deployment - ViewCube navigation with drag rotation and 90° snap - Cross-section, exploded view, and render settings - Parts tree with visibility controls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae6eea3 --- /dev/null +++ b/.env.example @@ -0,0 +1,55 @@ +# 3D Model Viewer - Environment Variables +# Copy this file to .env and update values as needed + +# =================== +# API Server +# =================== +NODE_ENV=development +PORT=3000 +API_PREFIX=/api + +# =================== +# Database (PostgreSQL) +# =================== +DATABASE_URL=postgresql://viewer:viewer_password@localhost:5432/viewer_db +DATABASE_POOL_MIN=2 +DATABASE_POOL_MAX=10 + +# =================== +# Redis (BullMQ Queue) +# =================== +REDIS_URL=redis://localhost:6379 + +# =================== +# MinIO Object Storage +# =================== +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_USE_SSL=false +MINIO_BUCKET_RAW=raw-models +MINIO_BUCKET_CONVERTED=converted-models +MINIO_BUCKET_THUMBNAILS=thumbnails + +# =================== +# Security +# =================== +CORS_ORIGINS=http://localhost:5173 +PRESIGNED_URL_EXPIRY=3600 + +# =================== +# Logging +# =================== +LOG_LEVEL=debug + +# =================== +# Worker Settings +# =================== +WORKER_CONCURRENCY=2 +WORKER_MAX_RETRIES=3 + +# =================== +# Frontend (Vite) +# =================== +VITE_API_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db5b3f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Environment variables +.env +.env.local +.env.*.local + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# IDE and editor +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +.Python +*.egg-info/ + +# Claude Code +.claude/ + +# Docker volumes (local data) +postgres_data/ +redis_data/ +minio_data/ + +# Testing +coverage/ +.nyc_output/ + +# Misc +*.tmp +*.temp +.cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ec71da4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +- 每次更新都要确保到docker compose更新 +- always redeploy docker compose after change \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..b9ac78e --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,56 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install + +# Copy source code +COPY . . + +# Build TypeScript +RUN pnpm build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 expressjs + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install production dependencies only +RUN pnpm install --prod + +# Copy built application +COPY --from=builder /app/dist ./dist + +# Set ownership +RUN chown -R expressjs:nodejs /app + +# Switch to non-root user +USER expressjs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost:3000/api/health || exit 1 + +# Start application +CMD ["node", "dist/index.js"] diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..1ca4433 --- /dev/null +++ b/api/package.json @@ -0,0 +1,44 @@ +{ + "name": "viewer3d-api", + "version": "1.0.0", + "description": "3D Model Viewer API Server", + "main": "dist/index.js", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "bullmq": "^5.12.0", + "cors": "^2.8.5", + "dotenv": "^16.4.0", + "express": "^4.21.0", + "helmet": "^7.1.0", + "ioredis": "^5.4.0", + "minio": "^8.0.0", + "multer": "^2.0.2", + "pg": "^8.12.0", + "pino": "^9.0.0", + "uuid": "^10.0.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/multer": "^2.0.0", + "@types/node": "^20.14.0", + "@types/pg": "^8.11.6", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.57.0", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml new file mode 100644 index 0000000..212f0e1 --- /dev/null +++ b/api/pnpm-lock.yaml @@ -0,0 +1,2975 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + bullmq: + specifier: ^5.12.0 + version: 5.65.1 + cors: + specifier: ^2.8.5 + version: 2.8.5 + dotenv: + specifier: ^16.4.0 + version: 16.6.1 + express: + specifier: ^4.21.0 + version: 4.22.1 + helmet: + specifier: ^7.1.0 + version: 7.2.0 + ioredis: + specifier: ^5.4.0 + version: 5.8.2 + minio: + specifier: ^8.0.0 + version: 8.0.6 + multer: + specifier: ^2.0.2 + version: 2.0.2 + pg: + specifier: ^8.12.0 + version: 8.16.3 + pino: + specifier: ^9.0.0 + version: 9.14.0 + uuid: + specifier: ^10.0.0 + version: 10.0.0 + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/multer': + specifier: ^2.0.0 + version: 2.0.0 + '@types/node': + specifier: ^20.14.0 + version: 20.19.25 + '@types/pg': + specifier: ^8.11.6 + version: 8.15.6 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@typescript-eslint/eslint-plugin': + specifier: ^7.0.0 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^7.0.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.57.0 + version: 8.57.1 + tsx: + specifier: ^4.16.0 + version: 4.21.0 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.1': + resolution: {integrity: sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.1': + resolution: {integrity: sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.1': + resolution: {integrity: sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.1': + resolution: {integrity: sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.1': + resolution: {integrity: sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.1': + resolution: {integrity: sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.1': + resolution: {integrity: sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.1': + resolution: {integrity: sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.1': + resolution: {integrity: sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.1': + resolution: {integrity: sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.1': + resolution: {integrity: sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.1': + resolution: {integrity: sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.1': + resolution: {integrity: sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.1': + resolution: {integrity: sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.1': + resolution: {integrity: sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.1': + resolution: {integrity: sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.1': + resolution: {integrity: sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.1': + resolution: {integrity: sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.1': + resolution: {integrity: sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.1': + resolution: {integrity: sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.1': + resolution: {integrity: sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.1': + resolution: {integrity: sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.1': + resolution: {integrity: sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.1': + resolution: {integrity: sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.1': + resolution: {integrity: sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.1': + resolution: {integrity: sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@ioredis/commands@1.4.0': + resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/express-serve-static-core@4.19.7': + resolution: {integrity: sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/multer@2.0.0': + resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + + '@types/node@20.19.25': + resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@typescript-eslint/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@zxing/text-encoding@0.9.0': + resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + block-stream2@2.1.0: + resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} + + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-or-node@2.1.1: + resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bullmq@5.65.1: + resolution: {integrity: sha512-QgDAzX1G9L5IRy4Orva5CfQTXZT+5K+OfO/kbPrAqN+pmL9LJekCzxijXehlm/u2eXfWPfWvIdJJIqiuz3WJSg==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.1: + resolution: {integrity: sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-xml-parser@4.5.3: + resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} + hasBin: true + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + helmet@7.2.0: + resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} + engines: {node: '>=16.0.0'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ioredis@5.8.2: + resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + engines: {node: '>=12.22.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minio@8.0.6: + resolution: {integrity: sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ==} + engines: {node: ^16 || ^18 || >=20} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + + multer@2.0.2: + resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} + engines: {node: '>= 10.16.0'} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.4.3: + resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + send@0.19.1: + resolution: {integrity: sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + through2@4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + web-encoding@1.1.5: + resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.1': + optional: true + + '@esbuild/android-arm64@0.27.1': + optional: true + + '@esbuild/android-arm@0.27.1': + optional: true + + '@esbuild/android-x64@0.27.1': + optional: true + + '@esbuild/darwin-arm64@0.27.1': + optional: true + + '@esbuild/darwin-x64@0.27.1': + optional: true + + '@esbuild/freebsd-arm64@0.27.1': + optional: true + + '@esbuild/freebsd-x64@0.27.1': + optional: true + + '@esbuild/linux-arm64@0.27.1': + optional: true + + '@esbuild/linux-arm@0.27.1': + optional: true + + '@esbuild/linux-ia32@0.27.1': + optional: true + + '@esbuild/linux-loong64@0.27.1': + optional: true + + '@esbuild/linux-mips64el@0.27.1': + optional: true + + '@esbuild/linux-ppc64@0.27.1': + optional: true + + '@esbuild/linux-riscv64@0.27.1': + optional: true + + '@esbuild/linux-s390x@0.27.1': + optional: true + + '@esbuild/linux-x64@0.27.1': + optional: true + + '@esbuild/netbsd-arm64@0.27.1': + optional: true + + '@esbuild/netbsd-x64@0.27.1': + optional: true + + '@esbuild/openbsd-arm64@0.27.1': + optional: true + + '@esbuild/openbsd-x64@0.27.1': + optional: true + + '@esbuild/openharmony-arm64@0.27.1': + optional: true + + '@esbuild/sunos-x64@0.27.1': + optional: true + + '@esbuild/win32-arm64@0.27.1': + optional: true + + '@esbuild/win32-ia32@0.27.1': + optional: true + + '@esbuild/win32-x64@0.27.1': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@ioredis/commands@1.4.0': {} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@pinojs/redact@0.4.0': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.19.25 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.19.25 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 20.19.25 + + '@types/express-serve-static-core@4.19.7': + dependencies: + '@types/node': 20.19.25 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.7 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + + '@types/mime@1.3.5': {} + + '@types/multer@2.0.0': + dependencies: + '@types/express': 4.17.25 + + '@types/node@20.19.25': + dependencies: + undici-types: 6.21.0 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 20.19.25 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.19.25 + + '@types/send@1.2.1': + dependencies: + '@types/node': 20.19.25 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 20.19.25 + '@types/send': 0.17.6 + + '@types/uuid@10.0.0': {} + + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@7.18.0': {} + + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.3 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.0': {} + + '@zxing/text-encoding@0.9.0': + optional: true + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + append-field@1.0.0: {} + + argparse@2.0.1: {} + + array-flatten@1.1.1: {} + + array-union@2.1.0: {} + + async@3.2.6: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + balanced-match@1.0.2: {} + + block-stream2@2.1.0: + dependencies: + readable-stream: 3.6.2 + + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-or-node@2.1.1: {} + + buffer-crc32@1.0.0: {} + + buffer-from@1.1.2: {} + + bullmq@5.65.1: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.8.2 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.3 + tslib: 2.8.1 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + cluster-key-slot@1.1.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-uri-component@0.2.2: {} + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + denque@2.1.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: + optional: true + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.1 + '@esbuild/android-arm': 0.27.1 + '@esbuild/android-arm64': 0.27.1 + '@esbuild/android-x64': 0.27.1 + '@esbuild/darwin-arm64': 0.27.1 + '@esbuild/darwin-x64': 0.27.1 + '@esbuild/freebsd-arm64': 0.27.1 + '@esbuild/freebsd-x64': 0.27.1 + '@esbuild/linux-arm': 0.27.1 + '@esbuild/linux-arm64': 0.27.1 + '@esbuild/linux-ia32': 0.27.1 + '@esbuild/linux-loong64': 0.27.1 + '@esbuild/linux-mips64el': 0.27.1 + '@esbuild/linux-ppc64': 0.27.1 + '@esbuild/linux-riscv64': 0.27.1 + '@esbuild/linux-s390x': 0.27.1 + '@esbuild/linux-x64': 0.27.1 + '@esbuild/netbsd-arm64': 0.27.1 + '@esbuild/netbsd-x64': 0.27.1 + '@esbuild/openbsd-arm64': 0.27.1 + '@esbuild/openbsd-x64': 0.27.1 + '@esbuild/openharmony-arm64': 0.27.1 + '@esbuild/sunos-x64': 0.27.1 + '@esbuild/win32-arm64': 0.27.1 + '@esbuild/win32-ia32': 0.27.1 + '@esbuild/win32-x64': 0.27.1 + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventemitter3@5.0.1: {} + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.1 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-xml-parser@4.5.3: + dependencies: + strnum: 1.1.2 + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@1.1.0: {} + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generator-function@2.0.1: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + helmet@7.2.0: {} + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ioredis@5.8.2: + dependencies: + '@ioredis/commands': 1.4.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.3.0: {} + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-extglob@2.1.1: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + isexe@2.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + + lodash.merge@4.6.2: {} + + lodash@4.17.21: {} + + luxon@3.7.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minio@8.0.6: + dependencies: + async: 3.2.6 + block-stream2: 2.1.0 + browser-or-node: 2.1.1 + buffer-crc32: 1.0.0 + eventemitter3: 5.0.1 + fast-xml-parser: 4.5.3 + ipaddr.js: 2.3.0 + lodash: 4.17.21 + mime-types: 2.1.35 + query-string: 7.1.3 + stream-json: 1.9.1 + through2: 4.0.2 + web-encoding: 1.1.5 + xml2js: 0.6.2 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.0.0: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + + multer@2.0.2: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + node-abort-controller@3.1.1: {} + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-to-regexp@0.1.12: {} + + path-type@4.0.0: {} + + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picomatch@2.3.1: {} + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.0.0: {} + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + + possible-typed-array-names@1.1.0: {} + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + process-warning@5.0.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + real-require@0.2.0: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + sax@1.4.3: {} + + semver@7.7.3: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + send@0.19.1: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + slash@3.0.0: {} + + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + + split-on-first@1.1.0: {} + + split2@4.2.0: {} + + standard-as-callback@2.1.0: {} + + statuses@2.0.1: {} + + statuses@2.0.2: {} + + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + + streamsearch@1.1.0: {} + + strict-uri-encode@2.0.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + strnum@1.1.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + text-table@0.2.0: {} + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + through2@4.0.2: + dependencies: + readable-stream: 3.6.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.1 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray@0.0.6: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.19 + + utils-merge@1.0.1: {} + + uuid@10.0.0: {} + + uuid@11.1.0: {} + + vary@1.1.2: {} + + web-encoding@1.1.5: + dependencies: + util: 0.12.5 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + xml2js@0.6.2: + dependencies: + sax: 1.4.3 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xtend@4.0.2: {} + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} diff --git a/api/src/app.ts b/api/src/app.ts new file mode 100644 index 0000000..fa75c8a --- /dev/null +++ b/api/src/app.ts @@ -0,0 +1,55 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import { env } from './config/env.js'; +import logger from './utils/logger.js'; +import { errorHandler, notFoundHandler } from './middleware/error.middleware.js'; +import healthRoutes from './routes/health.routes.js'; +import uploadRoutes from './routes/upload.routes.js'; +import modelsRoutes from './routes/models.routes.js'; + +export function createApp(): express.Application { + const app = express(); + + // Security middleware + app.use(helmet({ + crossOriginResourcePolicy: { policy: 'cross-origin' }, + })); + + // CORS + app.use(cors({ + origin: env.CORS_ORIGINS, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'], + exposedHeaders: ['X-Request-ID'], + maxAge: 86400, + })); + + // Request logging middleware + app.use((req, res, next) => { + if (req.url !== '/api/health') { + logger.info({ method: req.method, url: req.url }, 'Request received'); + } + next(); + }); + + // Body parsing + app.use(express.json({ limit: '10mb' })); + app.use(express.urlencoded({ extended: true, limit: '10mb' })); + + // Routes + app.use('/api/health', healthRoutes); + app.use('/api/upload', uploadRoutes); + app.use('/api/models', modelsRoutes); + + // 404 handler + app.use(notFoundHandler); + + // Error handler (must be last) + app.use(errorHandler); + + return app; +} + +export default createApp; diff --git a/api/src/config/env.ts b/api/src/config/env.ts new file mode 100644 index 0000000..77f2dea --- /dev/null +++ b/api/src/config/env.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; +import dotenv from 'dotenv'; + +// Load environment variables +dotenv.config(); + +const envSchema = z.object({ + // Application + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.string().transform(Number).default('3000'), + API_PREFIX: z.string().default('/api'), + + // Database + DATABASE_URL: z.string().url(), + DATABASE_POOL_MIN: z.string().transform(Number).default('2'), + DATABASE_POOL_MAX: z.string().transform(Number).default('10'), + + // Redis + REDIS_URL: z.string().url(), + + // MinIO + MINIO_ENDPOINT: z.string(), + MINIO_PORT: z.string().transform(Number).default('9000'), + MINIO_PUBLIC_ENDPOINT: z.string().optional(), // External endpoint for browser access + MINIO_PUBLIC_PORT: z.string().transform(Number).optional(), + MINIO_ACCESS_KEY: z.string().min(1), + MINIO_SECRET_KEY: z.string().min(1), + MINIO_USE_SSL: z.string().transform((v) => v === 'true').default('false'), + MINIO_BUCKET_RAW: z.string().default('raw-models'), + MINIO_BUCKET_CONVERTED: z.string().default('converted-models'), + MINIO_BUCKET_THUMBNAILS: z.string().default('thumbnails'), + + // Security + CORS_ORIGINS: z.string().transform((s) => s.split(',')).default('http://localhost:5173'), + PRESIGNED_URL_EXPIRY: z.string().transform(Number).default('3600'), + + // Logging + LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'), +}); + +export type Env = z.infer; + +function validateEnv(): Env { + try { + return envSchema.parse(process.env); + } catch (error) { + if (error instanceof z.ZodError) { + const missing = error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('\n'); + console.error('Environment validation failed:\n', missing); + process.exit(1); + } + throw error; + } +} + +export const env = validateEnv(); diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..b068884 --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,94 @@ +import { createApp } from './app.js'; +import { env } from './config/env.js'; +import logger from './utils/logger.js'; +import { testConnection, closePool } from './services/database.service.js'; +import { initializeBuckets } from './services/storage.service.js'; +import { closeQueue, setupQueueEvents } from './services/queue.service.js'; +import * as modelsService from './services/models.service.js'; + +async function main(): Promise { + logger.info({ env: env.NODE_ENV }, 'Starting API server...'); + + // Test database connection + const dbConnected = await testConnection(); + if (!dbConnected) { + logger.fatal('Failed to connect to database'); + process.exit(1); + } + logger.info('Database connected'); + + // Initialize MinIO buckets + try { + await initializeBuckets(); + logger.info('MinIO buckets initialized'); + } catch (error) { + logger.error(error, 'Failed to initialize MinIO buckets'); + // Continue anyway - buckets might already exist + } + + // Setup queue event handlers + setupQueueEvents( + async (jobId, result) => { + // Update model on job completion + const data = result as { modelUrl?: string; thumbnailUrl?: string; metadata?: Record }; + await modelsService.updateModel(jobId, { + conversion_status: 'completed', + model_url: data.modelUrl, + thumbnail_url: data.thumbnailUrl, + metadata: data.metadata, + }); + }, + async (jobId, error) => { + // Update model on job failure + await modelsService.updateModel(jobId, { + conversion_status: 'failed', + conversion_error: error, + }); + } + ); + + // Create Express app + const app = createApp(); + + // Start server + const server = app.listen(env.PORT, () => { + logger.info({ port: env.PORT }, 'API server listening'); + }); + + // Graceful shutdown + const shutdown = async (signal: string): Promise => { + logger.info({ signal }, 'Shutdown signal received'); + + // Stop accepting new connections + server.close(() => { + logger.info('HTTP server closed'); + }); + + // Close queue connections + await closeQueue(); + + // Close database pool + await closePool(); + + logger.info('Graceful shutdown complete'); + process.exit(0); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + // Handle uncaught errors + process.on('uncaughtException', (error) => { + logger.fatal(error, 'Uncaught exception'); + process.exit(1); + }); + + process.on('unhandledRejection', (reason) => { + logger.error({ reason }, 'Unhandled rejection'); + }); +} + +main().catch((error) => { + logger.fatal(error, 'Failed to start server'); + process.exit(1); +}); diff --git a/api/src/middleware/error.middleware.ts b/api/src/middleware/error.middleware.ts new file mode 100644 index 0000000..5e1bf79 --- /dev/null +++ b/api/src/middleware/error.middleware.ts @@ -0,0 +1,119 @@ +import type { Request, Response, NextFunction } from 'express'; +import { ZodError } from 'zod'; +import logger from '../utils/logger.js'; + +export interface ApiError extends Error { + statusCode?: number; + code?: string; + details?: unknown; +} + +/** + * Custom error classes + */ +export class NotFoundError extends Error implements ApiError { + statusCode = 404; + code = 'NOT_FOUND'; + + constructor(message: string = 'Resource not found') { + super(message); + this.name = 'NotFoundError'; + } +} + +export class ValidationError extends Error implements ApiError { + statusCode = 400; + code = 'VALIDATION_ERROR'; + details?: unknown; + + constructor(message: string = 'Validation failed', details?: unknown) { + super(message); + this.name = 'ValidationError'; + this.details = details; + } +} + +export class ConflictError extends Error implements ApiError { + statusCode = 409; + code = 'CONFLICT'; + + constructor(message: string = 'Resource conflict') { + super(message); + this.name = 'ConflictError'; + } +} + +export class StorageError extends Error implements ApiError { + statusCode = 503; + code = 'STORAGE_ERROR'; + + constructor(message: string = 'Storage service error') { + super(message); + this.name = 'StorageError'; + } +} + +/** + * Global error handler middleware + */ +export function errorHandler( + err: ApiError, + req: Request, + res: Response, + _next: NextFunction +): void { + // Log the error + logger.error({ + err, + method: req.method, + url: req.url, + body: req.body, + }, 'Request error'); + + // Handle Zod validation errors + if (err instanceof ZodError) { + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Validation failed', + details: err.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + }, + }); + return; + } + + // Handle custom errors + const statusCode = err.statusCode || 500; + const code = err.code || 'INTERNAL_ERROR'; + const message = statusCode === 500 ? 'Internal server error' : err.message; + + const errorResponse: { code: string; message: string; details?: unknown } = { + code, + message, + }; + if (err.details) { + errorResponse.details = err.details; + } + + res.status(statusCode).json({ + success: false, + error: errorResponse, + }); +} + +/** + * 404 handler for unknown routes + */ +export function notFoundHandler(req: Request, res: Response): void { + res.status(404).json({ + success: false, + error: { + code: 'NOT_FOUND', + message: `Route ${req.method} ${req.path} not found`, + }, + }); +} diff --git a/api/src/middleware/validation.middleware.ts b/api/src/middleware/validation.middleware.ts new file mode 100644 index 0000000..5ec5dda --- /dev/null +++ b/api/src/middleware/validation.middleware.ts @@ -0,0 +1,75 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; + +/** + * Validation middleware factory + */ +export function validate( + schema: T, + source: 'body' | 'query' | 'params' = 'body' +) { + return (req: Request, res: Response, next: NextFunction): void => { + try { + const data = schema.parse(req[source]); + req[source] = data; + next(); + } catch (error) { + next(error); + } + }; +} + +// Common validation schemas +export const schemas = { + // UUID parameter + uuidParam: z.object({ + id: z.string().uuid('Invalid ID format'), + }), + + // Pagination query + pagination: z.object({ + limit: z.string().transform(Number).pipe(z.number().min(1).max(100)).optional(), + offset: z.string().transform(Number).pipe(z.number().min(0)).optional(), + }), + + // Model list query + modelListQuery: z.object({ + search: z.string().max(255).optional(), + status: z.enum(['pending', 'processing', 'completed', 'failed']).optional(), + format: z.string().max(10).optional(), + limit: z.string().transform(Number).pipe(z.number().min(1).max(100)).default('50'), + offset: z.string().transform(Number).pipe(z.number().min(0)).default('0'), + }), + + // Upload initialization + initUpload: z.object({ + filename: z + .string() + .min(1) + .max(255) + .refine( + (name) => !name.includes('..') && !name.includes('/'), + 'Invalid filename' + ) + .refine( + (name) => { + const ext = name.split('.').pop()?.toLowerCase(); + return ['step', 'stp', 'stl', 'obj', 'glb', 'gltf', '3ds', 'fbx', 'iges', 'igs'].includes(ext || ''); + }, + 'Unsupported file format' + ), + }), + + // Upload confirmation + confirmUpload: z.object({ + modelId: z.string().uuid(), + filename: z.string().min(1).max(255), + fileSize: z.number().positive().max(500 * 1024 * 1024), // Max 500MB + storageKey: z.string().min(1), + }), + + // Model update + updateModel: z.object({ + name: z.string().min(1).max(255).optional(), + }), +}; diff --git a/api/src/routes/health.routes.ts b/api/src/routes/health.routes.ts new file mode 100644 index 0000000..b765bdd --- /dev/null +++ b/api/src/routes/health.routes.ts @@ -0,0 +1,92 @@ +import { Router, type IRouter } from 'express'; +import { testConnection } from '../services/database.service.js'; +import { redis } from '../services/queue.service.js'; +import { minioClient } from '../services/storage.service.js'; +import logger from '../utils/logger.js'; + +const router: IRouter = Router(); + +interface HealthCheck { + status: 'up' | 'down'; + latency?: number; + error?: string; +} + +/** + * GET /health - Basic liveness check + */ +router.get('/', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +/** + * GET /health/ready - Full readiness check + */ +router.get('/ready', async (_req, res) => { + const checks: Record = {}; + let allHealthy = true; + + // Check database + const dbStart = Date.now(); + try { + const dbOk = await testConnection(); + checks.database = { + status: dbOk ? 'up' : 'down', + latency: Date.now() - dbStart, + }; + if (!dbOk) allHealthy = false; + } catch (error) { + checks.database = { + status: 'down', + error: error instanceof Error ? error.message : 'Unknown error', + }; + allHealthy = false; + } + + // Check Redis + const redisStart = Date.now(); + try { + await redis.ping(); + checks.redis = { + status: 'up', + latency: Date.now() - redisStart, + }; + } catch (error) { + checks.redis = { + status: 'down', + error: error instanceof Error ? error.message : 'Unknown error', + }; + allHealthy = false; + } + + // Check MinIO + const minioStart = Date.now(); + try { + await minioClient.listBuckets(); + checks.minio = { + status: 'up', + latency: Date.now() - minioStart, + }; + } catch (error) { + checks.minio = { + status: 'down', + error: error instanceof Error ? error.message : 'Unknown error', + }; + allHealthy = false; + } + + const response = { + status: allHealthy ? 'healthy' : 'unhealthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + checks, + }; + + if (!allHealthy) { + logger.warn(response, 'Health check failed'); + } + + res.status(allHealthy ? 200 : 503).json(response); +}); + +export default router; diff --git a/api/src/routes/models.routes.ts b/api/src/routes/models.routes.ts new file mode 100644 index 0000000..a65f7ad --- /dev/null +++ b/api/src/routes/models.routes.ts @@ -0,0 +1,303 @@ +import { Router, type IRouter } from 'express'; +import multer from 'multer'; +import { validate, schemas } from '../middleware/validation.middleware.js'; +import { NotFoundError } from '../middleware/error.middleware.js'; +import * as modelsService from '../services/models.service.js'; +import { addThumbnailJob } from '../services/queue.service.js'; +import logger from '../utils/logger.js'; + +const router: IRouter = Router(); + +// Configure multer for thumbnail uploads (memory storage for small images) +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 2 * 1024 * 1024, // 2MB max + }, + fileFilter: (req, file, cb) => { + if (file.mimetype === 'image/png' || file.mimetype === 'image/jpeg') { + cb(null, true); + } else { + cb(new Error('Only PNG and JPEG images are allowed')); + } + }, +}); + +/** + * GET /models - List all models + */ +router.get('/', validate(schemas.modelListQuery, 'query'), async (req, res, next) => { + try { + const query = req.query as unknown as { + search?: string; + status?: 'pending' | 'processing' | 'completed' | 'failed'; + format?: string; + limit: number; + offset: number; + }; + + const result = await modelsService.getModels({ + search: query.search, + status: query.status, + format: query.format, + limit: Number(query.limit) || 20, + offset: Number(query.offset) || 0, + }); + + res.json({ + success: true, + data: result.models, + meta: { + total: result.total, + limit: Number(query.limit) || 20, + offset: Number(query.offset) || 0, + }, + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /models/:id - Get a single model + */ +router.get('/:id', validate(schemas.uuidParam, 'params'), async (req, res, next) => { + try { + const { id } = req.params; + + const model = await modelsService.getModelById(id); + if (!model) { + throw new NotFoundError('Model not found'); + } + + res.json({ + success: true, + data: model, + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /models/:id/parts - Get model parts + */ +router.get('/:id/parts', validate(schemas.uuidParam, 'params'), async (req, res, next) => { + try { + const { id } = req.params; + + const model = await modelsService.getModelById(id); + if (!model) { + throw new NotFoundError('Model not found'); + } + + const parts = await modelsService.getModelParts(id); + + res.json({ + success: true, + data: parts, + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /models/:id/url - Get download URL for viewing + */ +router.get('/:id/url', validate(schemas.uuidParam, 'params'), async (req, res, next) => { + try { + const { id } = req.params; + + const url = await modelsService.getModelDownloadUrl(id); + if (!url) { + throw new NotFoundError('Model not ready or not found'); + } + + res.json({ + success: true, + data: { url }, + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /models/:id/lod - Get all LOD URLs for a model + */ +router.get('/:id/lod', validate(schemas.uuidParam, 'params'), async (req, res, next) => { + try { + const { id } = req.params; + + const lodUrls = await modelsService.getModelLodUrls(id); + if (!lodUrls) { + throw new NotFoundError('Model not ready or not found'); + } + + res.json({ + success: true, + data: lodUrls, + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /models/:id/lod/:level - Get URL for specific LOD level + */ +router.get('/:id/lod/:level', validate(schemas.uuidParam, 'params'), async (req, res, next) => { + try { + const { id, level } = req.params; + const lodLevel = parseInt(level, 10); + + if (isNaN(lodLevel) || lodLevel < 0 || lodLevel > 2) { + res.status(400).json({ + success: false, + error: 'Invalid LOD level. Must be 0, 1, or 2.', + }); + return; + } + + const url = await modelsService.getModelLodUrl(id, lodLevel); + if (!url) { + throw new NotFoundError('Model not ready or not found'); + } + + res.json({ + success: true, + data: { url, level: lodLevel }, + }); + } catch (error) { + next(error); + } +}); + +/** + * PATCH /models/:id - Update model metadata + */ +router.patch( + '/:id', + validate(schemas.uuidParam, 'params'), + validate(schemas.updateModel), + async (req, res, next) => { + try { + const { id } = req.params; + const updates = req.body; + + const model = await modelsService.updateModel(id, updates); + if (!model) { + throw new NotFoundError('Model not found'); + } + + logger.info({ modelId: id }, 'Model updated'); + + res.json({ + success: true, + data: model, + }); + } catch (error) { + next(error); + } + } +); + +/** + * DELETE /models/:id - Delete a model + */ +router.delete('/:id', validate(schemas.uuidParam, 'params'), async (req, res, next) => { + try { + const { id } = req.params; + + const deleted = await modelsService.deleteModel(id); + if (!deleted) { + throw new NotFoundError('Model not found'); + } + + logger.info({ modelId: id }, 'Model deleted'); + + res.json({ + success: true, + message: 'Model deleted successfully', + }); + } catch (error) { + next(error); + } +}); + +/** + * POST /models/:id/thumbnail - Upload a thumbnail for a model + */ +router.post( + '/:id/thumbnail', + validate(schemas.uuidParam, 'params'), + upload.single('thumbnail'), + async (req, res, next) => { + try { + const { id } = req.params; + + if (!req.file) { + res.status(400).json({ + success: false, + error: 'No thumbnail file provided', + }); + return; + } + + const model = await modelsService.uploadThumbnail(id, req.file.buffer); + if (!model) { + throw new NotFoundError('Model not found'); + } + + logger.info({ modelId: id }, 'Thumbnail uploaded'); + + res.json({ + success: true, + data: model, + thumbnail_url: model.thumbnail_url, + }); + } catch (error) { + next(error); + } + } +); + +/** + * POST /models/:id/regenerate-thumbnail - Regenerate thumbnail for a model + */ +router.post( + '/:id/regenerate-thumbnail', + validate(schemas.uuidParam, 'params'), + async (req, res, next) => { + try { + const { id } = req.params; + + const model = await modelsService.getModelById(id); + if (!model) { + throw new NotFoundError('Model not found'); + } + + if (!model.model_url) { + res.status(400).json({ + success: false, + error: 'Model not ready for thumbnail generation', + }); + return; + } + + await addThumbnailJob({ modelId: id, modelUrl: model.model_url }); + + logger.info({ modelId: id }, 'Thumbnail regeneration job queued'); + + res.json({ + success: true, + message: 'Thumbnail regeneration job queued', + }); + } catch (error) { + next(error); + } + } +); + +export default router; diff --git a/api/src/routes/upload.routes.ts b/api/src/routes/upload.routes.ts new file mode 100644 index 0000000..0ccacd3 --- /dev/null +++ b/api/src/routes/upload.routes.ts @@ -0,0 +1,52 @@ +import { Router, type IRouter } from 'express'; +import { validate, schemas } from '../middleware/validation.middleware.js'; +import * as modelsService from '../services/models.service.js'; +import logger from '../utils/logger.js'; + +const router: IRouter = Router(); + +/** + * POST /upload/presigned-url - Get a presigned URL for uploading + */ +router.post('/presigned-url', validate(schemas.initUpload), async (req, res, next) => { + try { + const { filename } = req.body; + + const result = await modelsService.initializeUpload(filename); + + logger.info({ modelId: result.modelId, filename }, 'Upload initialized'); + + res.json({ + success: true, + data: { + uploadUrl: result.uploadUrl, + modelId: result.modelId, + storageKey: result.storageKey, + }, + }); + } catch (error) { + next(error); + } +}); + +/** + * POST /upload/complete - Confirm upload and start conversion + */ +router.post('/complete', validate(schemas.confirmUpload), async (req, res, next) => { + try { + const { modelId, filename, fileSize, storageKey } = req.body; + + const model = await modelsService.confirmUpload(modelId, filename, fileSize, storageKey); + + logger.info({ modelId: model.id }, 'Upload confirmed, conversion queued'); + + res.status(201).json({ + success: true, + data: model, + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/api/src/services/database.service.ts b/api/src/services/database.service.ts new file mode 100644 index 0000000..1542693 --- /dev/null +++ b/api/src/services/database.service.ts @@ -0,0 +1,46 @@ +import pg from 'pg'; +import { env } from '../config/env.js'; +import logger from '../utils/logger.js'; + +const { Pool } = pg; + +// Create connection pool +export const pool = new Pool({ + connectionString: env.DATABASE_URL, + min: env.DATABASE_POOL_MIN, + max: env.DATABASE_POOL_MAX, +}); + +// Test connection on startup +pool.on('connect', () => { + logger.debug('New database connection established'); +}); + +pool.on('error', (err) => { + logger.error(err, 'Unexpected database pool error'); +}); + +/** + * Test database connection + */ +export async function testConnection(): Promise { + try { + const client = await pool.connect(); + await client.query('SELECT 1'); + client.release(); + return true; + } catch (error) { + logger.error(error, 'Database connection test failed'); + return false; + } +} + +/** + * Close database pool + */ +export async function closePool(): Promise { + await pool.end(); + logger.info('Database pool closed'); +} + +export default pool; diff --git a/api/src/services/models.service.ts b/api/src/services/models.service.ts new file mode 100644 index 0000000..b02c014 --- /dev/null +++ b/api/src/services/models.service.ts @@ -0,0 +1,328 @@ +import { v4 as uuidv4 } from 'uuid'; +import pool from './database.service.js'; +import { addConversionJob, addThumbnailJob } from './queue.service.js'; +import { BUCKETS, getPresignedUploadUrl, getPresignedDownloadUrl, deleteObjectsByPrefix, getPublicUrl, toPublicUrl, uploadBuffer } from './storage.service.js'; +import type { Model, ModelPart, CreateModelInput, UpdateModelInput, ConversionStatus } from '../types/model.js'; +import logger from '../utils/logger.js'; + +/** + * Transform model URLs to use public endpoint + */ +function transformModelUrls(model: Model): Model { + return { + ...model, + model_url: toPublicUrl(model.model_url), + thumbnail_url: toPublicUrl(model.thumbnail_url), + }; +} + +/** + * Get all models with optional filtering + */ +export async function getModels(options: { + search?: string; + status?: ConversionStatus; + format?: string; + limit?: number; + offset?: number; +} = {}): Promise<{ models: Model[]; total: number }> { + const { search, status, format, limit = 50, offset = 0 } = options; + + let whereClause = 'WHERE 1=1'; + const params: unknown[] = []; + let paramIndex = 1; + + if (search) { + whereClause += ` AND (name ILIKE $${paramIndex} OR original_filename ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + if (status) { + whereClause += ` AND conversion_status = $${paramIndex}`; + params.push(status); + paramIndex++; + } + + if (format) { + whereClause += ` AND original_format = $${paramIndex}`; + params.push(format); + paramIndex++; + } + + // Get total count + const countResult = await pool.query( + `SELECT COUNT(*) FROM models ${whereClause}`, + params + ); + const total = parseInt(countResult.rows[0].count); + + // Get models + const result = await pool.query( + `SELECT * FROM models ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...params, limit, offset] + ); + + return { + models: (result.rows as Model[]).map(transformModelUrls), + total, + }; +} + +/** + * Get a single model by ID + */ +export async function getModelById(id: string): Promise { + const result = await pool.query('SELECT * FROM models WHERE id = $1', [id]); + const model = result.rows[0] as Model | null; + return model ? transformModelUrls(model) : null; +} + +/** + * Create a new model record + */ +export async function createModel(input: CreateModelInput): Promise { + const { name, original_filename, original_format, file_size, raw_storage_key } = input; + + const result = await pool.query( + `INSERT INTO models (name, original_filename, original_format, file_size, raw_storage_key, conversion_status) + VALUES ($1, $2, $3, $4, $5, 'pending') + RETURNING *`, + [name, original_filename, original_format, file_size, raw_storage_key] + ); + + const model = result.rows[0] as Model; + logger.info({ modelId: model.id }, 'Model record created'); + + return model; +} + +/** + * Update a model + */ +export async function updateModel(id: string, input: UpdateModelInput): Promise { + const fields: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const [key, value] of Object.entries(input)) { + if (value !== undefined) { + fields.push(`${key} = $${paramIndex}`); + values.push(key === 'metadata' ? JSON.stringify(value) : value); + paramIndex++; + } + } + + if (fields.length === 0) { + return getModelById(id); + } + + values.push(id); + const result = await pool.query( + `UPDATE models SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, + values + ); + + return result.rows[0] as Model | null; +} + +/** + * Delete a model and its associated files + */ +export async function deleteModel(id: string): Promise { + const model = await getModelById(id); + if (!model) return false; + + // Delete from database (cascade will delete model_parts) + await pool.query('DELETE FROM models WHERE id = $1', [id]); + + // Delete files from MinIO + try { + await deleteObjectsByPrefix(BUCKETS.RAW, `${id}/`); + await deleteObjectsByPrefix(BUCKETS.CONVERTED, `${id}/`); + await deleteObjectsByPrefix(BUCKETS.THUMBNAILS, `${id}/`); + } catch (error) { + logger.error({ modelId: id, error }, 'Error deleting model files from storage'); + } + + logger.info({ modelId: id }, 'Model deleted'); + return true; +} + +/** + * Get model parts + */ +export async function getModelParts(modelId: string): Promise { + const result = await pool.query( + 'SELECT * FROM model_parts WHERE model_id = $1 ORDER BY name', + [modelId] + ); + return result.rows as ModelPart[]; +} + +/** + * Generate presigned upload URL for a new model + */ +export async function initializeUpload(filename: string): Promise<{ + uploadUrl: string; + modelId: string; + storageKey: string; +}> { + const modelId = uuidv4(); + const storageKey = `${modelId}/${filename}`; + const uploadUrl = await getPresignedUploadUrl(BUCKETS.RAW, storageKey); + + return { + uploadUrl, + modelId, + storageKey, + }; +} + +/** + * Confirm upload and start conversion + */ +export async function confirmUpload( + modelId: string, + filename: string, + fileSize: number, + storageKey: string +): Promise { + const format = filename.split('.').pop()?.toLowerCase() || 'unknown'; + const name = filename.replace(/\.[^/.]+$/, ''); // Remove extension + + // Create model record + const model = await createModel({ + name, + original_filename: filename, + original_format: format, + file_size: fileSize, + raw_storage_key: storageKey, + }); + + // Queue conversion job (unless already GLB) + if (format !== 'glb' && format !== 'gltf') { + await addConversionJob({ + modelId: model.id, + key: storageKey, + fileType: format, + }); + logger.info({ modelId: model.id }, 'Conversion job queued'); + } else { + // GLB/GLTF don't need conversion - file stays in raw bucket + const modelUrl = getPublicUrl(BUCKETS.RAW, storageKey); + await updateModel(model.id, { + conversion_status: 'completed', + // Don't set converted_storage_key - file is in raw bucket, not converted bucket + model_url: modelUrl, + }); + + // Queue thumbnail generation job for GLB/GLTF files + await addThumbnailJob({ + modelId: model.id, + modelUrl: modelUrl, + }); + logger.info({ modelId: model.id }, 'Thumbnail job queued for GLB/GLTF'); + } + + return model; +} + +/** + * Get download URL for a model + */ +export async function getModelDownloadUrl(id: string): Promise { + const model = await getModelById(id); + if (!model || model.conversion_status !== 'completed') return null; + + // If model_url is already set (GLB/GLTF files or converted models), return it directly + if (model.model_url) { + return model.model_url; + } + + // Otherwise generate presigned URL for files that need it + const key = model.converted_storage_key || model.raw_storage_key; + if (!key) return null; + + const bucket = model.converted_storage_key ? BUCKETS.CONVERTED : BUCKETS.RAW; + return getPresignedDownloadUrl(bucket, key); +} + +/** + * Get download URL for a specific LOD level + * @param id Model ID + * @param lodLevel LOD level (0, 1, or 2). Default is 0 (highest quality) + */ +export async function getModelLodUrl(id: string, lodLevel: number = 0): Promise { + const model = await getModelById(id); + if (!model || model.conversion_status !== 'completed') return null; + + // Check if LOD URLs are available in metadata + const metadata = model.metadata as Record | null; + const lodUrls = metadata?.lod_urls as Record | undefined; + + if (lodUrls) { + const lodKey = `lod${lodLevel}`; + if (lodUrls[lodKey]) { + return toPublicUrl(lodUrls[lodKey]); + } + // Fallback to LOD0 if requested level not available + if (lodUrls['lod0']) { + return toPublicUrl(lodUrls['lod0']); + } + } + + // Fallback to original model_url for backward compatibility + return model.model_url; +} + +/** + * Get all available LOD URLs for a model + */ +export async function getModelLodUrls(id: string): Promise | null> { + const model = await getModelById(id); + if (!model || model.conversion_status !== 'completed') return null; + + const metadata = model.metadata as Record | null; + const lodUrls = metadata?.lod_urls as Record | undefined; + + if (lodUrls) { + // Transform all URLs to public URLs + const publicLodUrls: Record = {}; + for (const [key, url] of Object.entries(lodUrls)) { + const publicUrl = toPublicUrl(url); + if (publicUrl) { + publicLodUrls[key] = publicUrl; + } + } + return Object.keys(publicLodUrls).length > 0 ? publicLodUrls : null; + } + + // Fallback: return model_url as lod0 for backward compatibility + if (model.model_url) { + return { lod0: model.model_url }; + } + + return null; +} + +/** + * Upload a thumbnail for a model + */ +export async function uploadThumbnail(id: string, buffer: Buffer): Promise { + const model = await getModelById(id); + if (!model) return null; + + // Upload to MinIO + const thumbnailKey = `${id}/preview.png`; + const thumbnailUrl = await uploadBuffer(BUCKETS.THUMBNAILS, thumbnailKey, buffer, 'image/png'); + + // Update database + const result = await pool.query( + `UPDATE models SET thumbnail_url = $1, thumbnail_storage_key = $2, updated_at = NOW() WHERE id = $3 RETURNING *`, + [thumbnailUrl, thumbnailKey, id] + ); + + logger.info({ modelId: id }, 'Thumbnail uploaded'); + return result.rows[0] ? transformModelUrls(result.rows[0] as Model) : null; +} diff --git a/api/src/services/queue.service.ts b/api/src/services/queue.service.ts new file mode 100644 index 0000000..dd7e053 --- /dev/null +++ b/api/src/services/queue.service.ts @@ -0,0 +1,129 @@ +import { Queue, QueueEvents } from 'bullmq'; +import { Redis } from 'ioredis'; +import { env } from '../config/env.js'; +import logger from '../utils/logger.js'; +import type { ConversionJobData, ThumbnailJobData, QueueJobData } from '../types/model.js'; + +// Parse Redis URL +const redisUrl = new URL(env.REDIS_URL); +const redisConnection = { + host: redisUrl.hostname, + port: parseInt(redisUrl.port || '6379'), + password: redisUrl.password || undefined, +}; + +// Create Redis client +export const redis = new Redis(env.REDIS_URL); + +// Queue name +const QUEUE_NAME = 'model-conversion'; + +// Default job options +const defaultJobOptions = { + attempts: 3, + backoff: { + type: 'exponential' as const, + delay: 5000, // 5s, 25s, 125s + }, + removeOnComplete: { + age: 3600, // Keep for 1 hour + count: 100, // Keep last 100 + }, + removeOnFail: { + age: 86400, // Keep failures for 24 hours + }, +}; + +// Create the queue (accepts both conversion and thumbnail jobs) +export const conversionQueue = new Queue(QUEUE_NAME, { + connection: redisConnection, + defaultJobOptions, +}); + +// Create queue events listener +export const queueEvents = new QueueEvents(QUEUE_NAME, { + connection: redisConnection, +}); + +/** + * Add a conversion job to the queue + */ +export async function addConversionJob(data: ConversionJobData): Promise { + const job = await conversionQueue.add('convert', data, { + jobId: data.modelId, // Prevent duplicate jobs for same model + }); + logger.info({ jobId: job.id, modelId: data.modelId }, 'Conversion job added to queue'); + return job.id!; +} + +/** + * Add a thumbnail-only job to the queue (for GLB/GLTF files that don't need conversion) + */ +export async function addThumbnailJob(data: { modelId: string; modelUrl: string }): Promise { + const thumbnailJobData: ThumbnailJobData = { + modelId: data.modelId, + modelUrl: data.modelUrl, + jobType: 'thumbnail', + }; + const job = await conversionQueue.add('thumbnail', thumbnailJobData, { + jobId: `thumbnail-${data.modelId}`, // Unique job ID for thumbnail + attempts: 2, + backoff: { + type: 'exponential' as const, + delay: 3000, + }, + }); + logger.info({ jobId: job.id, modelId: data.modelId }, 'Thumbnail job added to queue'); + return job.id!; +} + +/** + * Get job status + */ +export async function getJobStatus(jobId: string): Promise<{ + state: string; + progress: number; + error?: string; +} | null> { + const job = await conversionQueue.getJob(jobId); + if (!job) return null; + + const state = await job.getState(); + return { + state, + progress: job.progress as number || 0, + error: job.failedReason, + }; +} + +/** + * Setup queue event handlers + */ +export function setupQueueEvents( + onCompleted: (jobId: string, result: unknown) => Promise, + onFailed: (jobId: string, error: string) => Promise +): void { + queueEvents.on('completed', async ({ jobId, returnvalue }) => { + logger.info({ jobId }, 'Job completed'); + await onCompleted(jobId, returnvalue); + }); + + queueEvents.on('failed', async ({ jobId, failedReason }) => { + logger.error({ jobId, error: failedReason }, 'Job failed'); + await onFailed(jobId, failedReason); + }); + + queueEvents.on('progress', ({ jobId, data }) => { + logger.debug({ jobId, progress: data }, 'Job progress'); + }); +} + +/** + * Graceful shutdown + */ +export async function closeQueue(): Promise { + await queueEvents.close(); + await conversionQueue.close(); + await redis.quit(); + logger.info('Queue connections closed'); +} diff --git a/api/src/services/storage.service.ts b/api/src/services/storage.service.ts new file mode 100644 index 0000000..ba7b2e3 --- /dev/null +++ b/api/src/services/storage.service.ts @@ -0,0 +1,146 @@ +import { Client as MinioClient } from 'minio'; +import { env } from '../config/env.js'; +import logger from '../utils/logger.js'; + +// Internal client for server-to-server operations +const minioClient = new MinioClient({ + endPoint: env.MINIO_ENDPOINT, + port: env.MINIO_PORT, + useSSL: env.MINIO_USE_SSL, + accessKey: env.MINIO_ACCESS_KEY, + secretKey: env.MINIO_SECRET_KEY, + region: 'us-east-1', // Fixed region to avoid bucket region lookup +}); + +// Public client for generating presigned URLs (uses public endpoint but region is fixed) +const publicMinioClient = new MinioClient({ + endPoint: env.MINIO_PUBLIC_ENDPOINT || env.MINIO_ENDPOINT, + port: env.MINIO_PUBLIC_PORT || env.MINIO_PORT, + useSSL: env.MINIO_USE_SSL, + accessKey: env.MINIO_ACCESS_KEY, + secretKey: env.MINIO_SECRET_KEY, + region: 'us-east-1', // Fixed region to avoid bucket region lookup +}); + +// Bucket names +export const BUCKETS = { + RAW: env.MINIO_BUCKET_RAW, + CONVERTED: env.MINIO_BUCKET_CONVERTED, + THUMBNAILS: env.MINIO_BUCKET_THUMBNAILS, +} as const; + +/** + * Initialize MinIO buckets (ensure they exist) + */ +export async function initializeBuckets(): Promise { + for (const bucket of Object.values(BUCKETS)) { + const exists = await minioClient.bucketExists(bucket); + if (!exists) { + await minioClient.makeBucket(bucket); + logger.info(`Created bucket: ${bucket}`); + } + } +} + +/** + * Generate a presigned URL for uploading a file (uses public endpoint for browser access) + */ +export async function getPresignedUploadUrl( + bucket: string, + key: string, + expirySeconds: number = env.PRESIGNED_URL_EXPIRY +): Promise { + return publicMinioClient.presignedPutObject(bucket, key, expirySeconds); +} + +/** + * Generate a presigned URL for downloading a file (uses public endpoint for browser access) + */ +export async function getPresignedDownloadUrl( + bucket: string, + key: string, + expirySeconds: number = env.PRESIGNED_URL_EXPIRY +): Promise { + return publicMinioClient.presignedGetObject(bucket, key, expirySeconds); +} + +/** + * Delete an object from MinIO + */ +export async function deleteObject(bucket: string, key: string): Promise { + await minioClient.removeObject(bucket, key); +} + +/** + * Delete multiple objects with a prefix + */ +export async function deleteObjectsByPrefix(bucket: string, prefix: string): Promise { + const objects = minioClient.listObjects(bucket, prefix, true); + const objectsToDelete: string[] = []; + + for await (const obj of objects) { + objectsToDelete.push(obj.name); + } + + if (objectsToDelete.length > 0) { + await minioClient.removeObjects(bucket, objectsToDelete); + } +} + +/** + * Check if an object exists + */ +export async function objectExists(bucket: string, key: string): Promise { + try { + await minioClient.statObject(bucket, key); + return true; + } catch { + return false; + } +} + +/** + * Get public URL for an object (for publicly accessible buckets) + */ +export function getPublicUrl(bucket: string, key: string): string { + const protocol = env.MINIO_USE_SSL ? 'https' : 'http'; + const endpoint = env.MINIO_PUBLIC_ENDPOINT || env.MINIO_ENDPOINT; + const port = env.MINIO_PUBLIC_PORT || env.MINIO_PORT; + return `${protocol}://${endpoint}:${port}/${bucket}/${key}`; +} + +/** + * Transform internal MinIO URL to public URL + * Handles URLs that were stored with internal hostname or localhost + */ +export function toPublicUrl(url: string | null): string | null { + if (!url) return null; + + const publicEndpoint = env.MINIO_PUBLIC_ENDPOINT || env.MINIO_ENDPOINT; + const publicPort = env.MINIO_PUBLIC_PORT || env.MINIO_PORT; + + // Replace internal hostname patterns with public endpoint + // Also handle localhost URLs from legacy data + return url + .replace(/minio:9000/g, `${publicEndpoint}:${publicPort}`) + .replace(/localhost:9000/g, `${publicEndpoint}:${publicPort}`) + .replace(/127\.0\.0\.1:9000/g, `${publicEndpoint}:${publicPort}`) + .replace(new RegExp(`${env.MINIO_ENDPOINT}:${env.MINIO_PORT}`, 'g'), `${publicEndpoint}:${publicPort}`); +} + +/** + * Upload a buffer directly to MinIO + */ +export async function uploadBuffer( + bucket: string, + key: string, + buffer: Buffer, + contentType: string = 'application/octet-stream' +): Promise { + await minioClient.putObject(bucket, key, buffer, buffer.length, { + 'Content-Type': contentType, + }); + return getPublicUrl(bucket, key); +} + +export { minioClient }; diff --git a/api/src/types/model.ts b/api/src/types/model.ts new file mode 100644 index 0000000..157be6d --- /dev/null +++ b/api/src/types/model.ts @@ -0,0 +1,76 @@ +export interface Model { + id: string; + name: string; + original_filename: string; + original_format: string; + file_size: number; + raw_storage_key: string | null; + converted_storage_key: string | null; + thumbnail_storage_key: string | null; + model_url: string | null; + thumbnail_url: string | null; + conversion_status: ConversionStatus; + conversion_error: string | null; + metadata: ModelMetadata; + created_at: Date; + updated_at: Date; +} + +export type ConversionStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +export interface ModelMetadata { + vertices?: number; + faces?: number; + bounding_box?: BoundingBox; + parts_count?: number; + [key: string]: unknown; +} + +export interface BoundingBox { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; +} + +export interface ModelPart { + id: string; + model_id: string; + name: string | null; + mesh_index: number | null; + bounding_box: BoundingBox; + center_point: { x: number; y: number; z: number }; + parent_part_id: string | null; + created_at: Date; +} + +export interface CreateModelInput { + name: string; + original_filename: string; + original_format: string; + file_size: number; + raw_storage_key: string; +} + +export interface UpdateModelInput { + name?: string; + converted_storage_key?: string; + thumbnail_storage_key?: string; + model_url?: string; + thumbnail_url?: string; + conversion_status?: ConversionStatus; + conversion_error?: string; + metadata?: ModelMetadata; +} + +export interface ConversionJobData { + modelId: string; + key: string; + fileType: string; +} + +export interface ThumbnailJobData { + modelId: string; + modelUrl: string; + jobType: 'thumbnail'; +} + +export type QueueJobData = ConversionJobData | ThumbnailJobData; diff --git a/api/src/utils/logger.ts b/api/src/utils/logger.ts new file mode 100644 index 0000000..40399f4 --- /dev/null +++ b/api/src/utils/logger.ts @@ -0,0 +1,22 @@ +import pino from 'pino'; +import { env } from '../config/env.js'; + +export const logger = pino({ + level: env.LOG_LEVEL, + transport: + env.NODE_ENV === 'development' + ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + } + : undefined, + base: { + env: env.NODE_ENV, + }, +}); + +export default logger; diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..3381f86 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b61e673 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,168 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + platform: linux/amd64 + container_name: viewer3d-postgres + environment: + POSTGRES_USER: viewer + POSTGRES_PASSWORD: viewer_password + POSTGRES_DB: viewer_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./infrastructure/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U viewer -d viewer_db"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - viewer3d-network + + redis: + image: redis:7-alpine + platform: linux/amd64 + container_name: viewer3d-redis + command: redis-server --appendonly yes --maxmemory-policy noeviction + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - viewer3d-network + + minio: + image: minio/minio:latest + platform: linux/amd64 + container_name: viewer3d-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 30s + timeout: 20s + retries: 3 + networks: + - viewer3d-network + + minio-init: + image: minio/mc:latest + platform: linux/amd64 + container_name: viewer3d-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 minioadmin minioadmin; + mc mb --ignore-existing local/raw-models; + mc mb --ignore-existing local/converted-models; + mc mb --ignore-existing local/thumbnails; + mc anonymous set download local/raw-models; + mc anonymous set download local/converted-models; + mc anonymous set download local/thumbnails; + echo 'Buckets created successfully'; + exit 0; + " + networks: + - viewer3d-network + + api: + build: + context: ./api + dockerfile: Dockerfile + platform: linux/amd64 + container_name: viewer3d-api + ports: + - "4000:3000" + environment: + NODE_ENV: production + PORT: 3000 + DATABASE_URL: postgresql://viewer:viewer_password@postgres:5432/viewer_db + REDIS_URL: redis://redis:6379 + MINIO_ENDPOINT: minio + MINIO_PORT: 9000 + MINIO_PUBLIC_ENDPOINT: ${HOST_IP:-localhost} + MINIO_PUBLIC_PORT: 9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + MINIO_USE_SSL: "false" + CORS_ORIGINS: http://${HOST_IP:-localhost},http://${HOST_IP:-localhost}:80,http://localhost,http://localhost:80,http://localhost:5173 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - viewer3d-network + + worker: + build: + context: ./worker + dockerfile: Dockerfile + platform: linux/amd64 + environment: + REDIS_URL: redis://redis:6379 + DATABASE_URL: postgresql://viewer:viewer_password@postgres:5432/viewer_db + MINIO_ENDPOINT: minio:9000 + MINIO_PUBLIC_ENDPOINT: ${HOST_IP:-localhost}:9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + MINIO_USE_SSL: "false" + PYOPENGL_PLATFORM: osmesa + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio-init: + condition: service_completed_successfully + deploy: + replicas: 2 + networks: + - viewer3d-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + # VITE_API_URL no longer needed - using nginx proxy with relative path /api + platform: linux/amd64 + container_name: viewer3d-frontend + ports: + - "80:80" + depends_on: + - api + networks: + - viewer3d-network + +volumes: + postgres_data: + redis_data: + minio_data: + +networks: + viewer3d-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..bf368f7 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,41 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Accept build arg for API URL +ARG VITE_API_URL=http://localhost:3000 +ENV VITE_API_URL=$VITE_API_URL + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install + +# Copy source code +COPY . . + +# Build the application +RUN pnpm build + +# Production stage with nginx +FROM nginx:alpine AS runner + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built assets +COPY --from=builder /app/dist /usr/share/nginx/html + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..976832b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 3D 模型查看器 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..73bb80a --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,42 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied expired no-cache no-store private auth; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA routing - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy - forwards /api requests to the backend + location /api { + proxy_pass http://api:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # Increase timeouts for large file uploads + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1d35262 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "viewer3d-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@types/earcut": "^3.0.0", + "axios": "^1.6.0", + "earcut": "^3.0.2", + "online-3d-viewer": "^0.16.0", + "pinia": "^2.1.0", + "three": "^0.160.0", + "three-mesh-bvh": "^0.9.3", + "vue": "^3.4.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/three": "^0.160.0", + "@vitejs/plugin-vue": "^5.0.0", + "@vue/tsconfig": "^0.5.0", + "typescript": "~5.6.0", + "vite": "^5.0.0", + "vue-tsc": "^2.1.0" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..cca2966 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1213 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@types/earcut': + specifier: ^3.0.0 + version: 3.0.0 + axios: + specifier: ^1.6.0 + version: 1.13.2 + earcut: + specifier: ^3.0.2 + version: 3.0.2 + online-3d-viewer: + specifier: ^0.16.0 + version: 0.16.0 + pinia: + specifier: ^2.1.0 + version: 2.3.1(typescript@5.6.3)(vue@3.5.25(typescript@5.6.3)) + three: + specifier: ^0.160.0 + version: 0.160.1 + three-mesh-bvh: + specifier: ^0.9.3 + version: 0.9.3(three@0.160.1) + vue: + specifier: ^3.4.0 + version: 3.5.25(typescript@5.6.3) + devDependencies: + '@types/node': + specifier: ^20.14.0 + version: 20.19.25 + '@types/three': + specifier: ^0.160.0 + version: 0.160.0 + '@vitejs/plugin-vue': + specifier: ^5.0.0 + version: 5.2.4(vite@5.4.21(@types/node@20.19.25))(vue@3.5.25(typescript@5.6.3)) + '@vue/tsconfig': + specifier: ^0.5.0 + version: 0.5.1 + typescript: + specifier: ~5.6.0 + version: 5.6.3 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@20.19.25) + vue-tsc: + specifier: ^2.1.0 + version: 2.2.12(typescript@5.6.3) + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.53.3': + resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.3': + resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.3': + resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.3': + resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.3': + resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.3': + resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.53.3': + resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.53.3': + resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.53.3': + resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.53.3': + resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.53.3': + resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.53.3': + resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.3': + resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.3': + resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.3': + resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.3': + resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.3': + resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} + cpu: [x64] + os: [win32] + + '@simonwep/pickr@1.9.0': + resolution: {integrity: sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==} + + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@20.19.25': + resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.160.0': + resolution: {integrity: sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.25': + resolution: {integrity: sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==} + + '@vue/compiler-dom@3.5.25': + resolution: {integrity: sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==} + + '@vue/compiler-sfc@3.5.25': + resolution: {integrity: sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==} + + '@vue/compiler-ssr@3.5.25': + resolution: {integrity: sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.25': + resolution: {integrity: sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==} + + '@vue/runtime-core@3.5.25': + resolution: {integrity: sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==} + + '@vue/runtime-dom@3.5.25': + resolution: {integrity: sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==} + + '@vue/server-renderer@3.5.25': + resolution: {integrity: sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==} + peerDependencies: + vue: 3.5.25 + + '@vue/shared@3.5.25': + resolution: {integrity: sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==} + + '@vue/tsconfig@0.5.1': + resolution: {integrity: sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + core-js@3.32.2: + resolution: {integrity: sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fflate@0.6.10: + resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + meshoptimizer@0.18.1: + resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanopop@2.3.0: + resolution: {integrity: sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==} + + online-3d-viewer@0.16.0: + resolution: {integrity: sha512-Mcmo41TM3K+svlMDRH8ySKSY2e8s7Sssdb5U9LV3gkFKVWGGuS304Vk5gqxopAJbE72DpsC67Ve3YNtcAuROwQ==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + rollup@4.53.3: + resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + three-mesh-bvh@0.9.3: + resolution: {integrity: sha512-LaxfvQpF+At96fS90GnQxHpff9bu78UL5eooJNxYyBbyiWrOBpjRx+5yn/+Dj2lQVhz5A/jHqwpVchYYnz/hWQ==} + peerDependencies: + three: '>= 0.159.0' + + three@0.160.1: + resolution: {integrity: sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==} + + three@0.176.0: + resolution: {integrity: sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==} + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.25: + resolution: {integrity: sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.53.3': + optional: true + + '@rollup/rollup-android-arm64@4.53.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.3': + optional: true + + '@rollup/rollup-darwin-x64@4.53.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.53.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.53.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.3': + optional: true + + '@simonwep/pickr@1.9.0': + dependencies: + core-js: 3.32.2 + nanopop: 2.3.0 + + '@types/earcut@3.0.0': {} + + '@types/estree@1.0.8': {} + + '@types/node@20.19.25': + dependencies: + undici-types: 6.21.0 + + '@types/stats.js@0.17.4': {} + + '@types/three@0.160.0': + dependencies: + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.6.10 + meshoptimizer: 0.18.1 + + '@types/webxr@0.5.24': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.25))(vue@3.5.25(typescript@5.6.3))': + dependencies: + vite: 5.4.21(@types/node@20.19.25) + vue: 3.5.25(typescript@5.6.3) + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.25': + dependencies: + '@babel/parser': 7.28.5 + '@vue/shared': 3.5.25 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.25': + dependencies: + '@vue/compiler-core': 3.5.25 + '@vue/shared': 3.5.25 + + '@vue/compiler-sfc@3.5.25': + dependencies: + '@babel/parser': 7.28.5 + '@vue/compiler-core': 3.5.25 + '@vue/compiler-dom': 3.5.25 + '@vue/compiler-ssr': 3.5.25 + '@vue/shared': 3.5.25 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.25': + dependencies: + '@vue/compiler-dom': 3.5.25 + '@vue/shared': 3.5.25 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@2.2.12(typescript@5.6.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.25 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.25 + alien-signals: 1.0.13 + minimatch: 9.0.5 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.6.3 + + '@vue/reactivity@3.5.25': + dependencies: + '@vue/shared': 3.5.25 + + '@vue/runtime-core@3.5.25': + dependencies: + '@vue/reactivity': 3.5.25 + '@vue/shared': 3.5.25 + + '@vue/runtime-dom@3.5.25': + dependencies: + '@vue/reactivity': 3.5.25 + '@vue/runtime-core': 3.5.25 + '@vue/shared': 3.5.25 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.25(vue@3.5.25(typescript@5.6.3))': + dependencies: + '@vue/compiler-ssr': 3.5.25 + '@vue/shared': 3.5.25 + vue: 3.5.25(typescript@5.6.3) + + '@vue/shared@3.5.25': {} + + '@vue/tsconfig@0.5.1': {} + + alien-signals@1.0.13: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + core-js@3.32.2: {} + + csstype@3.2.3: {} + + de-indent@1.0.2: {} + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + earcut@3.0.2: {} + + entities@4.5.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@2.0.2: {} + + fflate@0.6.10: {} + + fflate@0.8.2: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + meshoptimizer@0.18.1: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + muggle-string@0.4.1: {} + + nanoid@3.3.11: {} + + nanopop@2.3.0: {} + + online-3d-viewer@0.16.0: + dependencies: + '@simonwep/pickr': 1.9.0 + fflate: 0.8.2 + three: 0.176.0 + + path-browserify@1.0.1: {} + + picocolors@1.1.1: {} + + pinia@2.3.1(typescript@5.6.3)(vue@3.5.25(typescript@5.6.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.25(typescript@5.6.3) + vue-demi: 0.14.10(vue@3.5.25(typescript@5.6.3)) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - '@vue/composition-api' + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + rollup@4.53.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.3 + '@rollup/rollup-android-arm64': 4.53.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-freebsd-arm64': 4.53.3 + '@rollup/rollup-freebsd-x64': 4.53.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 + '@rollup/rollup-linux-arm-musleabihf': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-arm64-musl': 4.53.3 + '@rollup/rollup-linux-loong64-gnu': 4.53.3 + '@rollup/rollup-linux-ppc64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-musl': 4.53.3 + '@rollup/rollup-linux-s390x-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-musl': 4.53.3 + '@rollup/rollup-openharmony-arm64': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.53.3 + '@rollup/rollup-win32-ia32-msvc': 4.53.3 + '@rollup/rollup-win32-x64-gnu': 4.53.3 + '@rollup/rollup-win32-x64-msvc': 4.53.3 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + three-mesh-bvh@0.9.3(three@0.160.1): + dependencies: + three: 0.160.1 + + three@0.160.1: {} + + three@0.176.0: {} + + typescript@5.6.3: {} + + undici-types@6.21.0: {} + + vite@5.4.21(@types/node@20.19.25): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.3 + optionalDependencies: + '@types/node': 20.19.25 + fsevents: 2.3.3 + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.25(typescript@5.6.3)): + dependencies: + vue: 3.5.25(typescript@5.6.3) + + vue-tsc@2.2.12(typescript@5.6.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.6.3) + typescript: 5.6.3 + + vue@3.5.25(typescript@5.6.3): + dependencies: + '@vue/compiler-dom': 3.5.25 + '@vue/compiler-sfc': 3.5.25 + '@vue/runtime-dom': 3.5.25 + '@vue/server-renderer': 3.5.25(vue@3.5.25(typescript@5.6.3)) + '@vue/shared': 3.5.25 + optionalDependencies: + typescript: 5.6.3 diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..3bff382 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..65def47 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,145 @@ +import axios from 'axios' +import type { Model, UploadInitResponse, ApiResponse } from '@/types/model' + +// Use relative path - requests will be proxied by nginx +const client = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Models API +export async function getModels(params?: { + search?: string + status?: string + format?: string + limit?: number + offset?: number +}): Promise<{ models: Model[]; total: number }> { + const response = await client.get>('/models', { params }) + return { + models: response.data.data || [], + total: response.data.meta?.total || 0, + } +} + +export async function getModel(id: string): Promise { + const response = await client.get>(`/models/${id}`) + if (!response.data.data) throw new Error('Model not found') + return response.data.data +} + +export async function getModelUrl(id: string): Promise { + const response = await client.get>(`/models/${id}/url`) + if (!response.data.data) throw new Error('Model URL not found') + return response.data.data.url +} + +/** + * Get all available LOD URLs for a model + */ +export async function getModelLodUrls(id: string): Promise> { + const response = await client.get>>(`/models/${id}/lod`) + if (!response.data.data) throw new Error('LOD URLs not found') + return response.data.data +} + +/** + * Get URL for a specific LOD level + * @param id Model ID + * @param level LOD level (0=highest quality, 1=medium, 2=lowest) + */ +export async function getModelLodUrl(id: string, level: number): Promise { + const response = await client.get>(`/models/${id}/lod/${level}`) + if (!response.data.data) throw new Error('LOD URL not found') + return response.data.data.url +} + +export async function deleteModel(id: string): Promise { + await client.delete(`/models/${id}`) +} + +export async function updateModel(id: string, data: { name?: string }): Promise { + const response = await client.patch>(`/models/${id}`, data) + if (!response.data.data) throw new Error('Update failed') + return response.data.data +} + +// Upload API +export async function initUpload(filename: string): Promise { + const response = await client.post>('/upload/presigned-url', { + filename, + }) + if (!response.data.data) throw new Error('Failed to initialize upload') + return response.data.data +} + +export async function confirmUpload(data: { + modelId: string + filename: string + fileSize: number + storageKey: string +}): Promise { + const response = await client.post>('/upload/complete', data) + if (!response.data.data) throw new Error('Failed to confirm upload') + return response.data.data +} + +// Direct upload to MinIO +export async function uploadToMinIO(url: string, file: File): Promise { + await axios.put(url, file, { + headers: { + 'Content-Type': file.type || 'application/octet-stream', + }, + }) +} + +/** + * Fetch a file with progress tracking using ReadableStream + * @param url - URL to fetch + * @param onProgress - Callback with progress 0-100 + * @param signal - AbortSignal for cancellation + * @returns Blob of the downloaded file + */ +export async function fetchWithProgress( + url: string, + onProgress?: (progress: number) => void, + signal?: AbortSignal +): Promise { + const response = await fetch(url, { signal }) + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`) + } + + const contentLength = response.headers.get('content-length') + const total = contentLength ? parseInt(contentLength, 10) : 0 + + // If no content-length header, fall back to simple fetch + if (!total || !response.body) { + return response.blob() + } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let received = 0 + + while (true) { + const { done, value } = await reader.read() + + if (done) break + + chunks.push(value) + received += value.length + + if (onProgress && total > 0) { + onProgress(Math.round((received / total) * 100)) + } + } + + // Combine chunks into a single blob + return new Blob(chunks) +} + +export default client diff --git a/frontend/src/components/common/ConfirmDialog.vue b/frontend/src/components/common/ConfirmDialog.vue new file mode 100644 index 0000000..965c10b --- /dev/null +++ b/frontend/src/components/common/ConfirmDialog.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend/src/components/common/RenameDialog.vue b/frontend/src/components/common/RenameDialog.vue new file mode 100644 index 0000000..ded73c2 --- /dev/null +++ b/frontend/src/components/common/RenameDialog.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/frontend/src/components/common/ThemeToggle.vue b/frontend/src/components/common/ThemeToggle.vue new file mode 100644 index 0000000..9a8ba59 --- /dev/null +++ b/frontend/src/components/common/ThemeToggle.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/components/layout/AppLayout.vue b/frontend/src/components/layout/AppLayout.vue new file mode 100644 index 0000000..700d51a --- /dev/null +++ b/frontend/src/components/layout/AppLayout.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/src/components/layout/SidebarPanel.vue b/frontend/src/components/layout/SidebarPanel.vue new file mode 100644 index 0000000..14fff6b --- /dev/null +++ b/frontend/src/components/layout/SidebarPanel.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/components/layout/ViewerPanel.vue b/frontend/src/components/layout/ViewerPanel.vue new file mode 100644 index 0000000..ffdaf7e --- /dev/null +++ b/frontend/src/components/layout/ViewerPanel.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/components/models/ModelCard.vue b/frontend/src/components/models/ModelCard.vue new file mode 100644 index 0000000..74ef838 --- /dev/null +++ b/frontend/src/components/models/ModelCard.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/frontend/src/components/models/ModelList.vue b/frontend/src/components/models/ModelList.vue new file mode 100644 index 0000000..4a8f053 --- /dev/null +++ b/frontend/src/components/models/ModelList.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/components/models/SearchFilter.vue b/frontend/src/components/models/SearchFilter.vue new file mode 100644 index 0000000..b304b6e --- /dev/null +++ b/frontend/src/components/models/SearchFilter.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/models/UploadButton.vue b/frontend/src/components/models/UploadButton.vue new file mode 100644 index 0000000..1e7ec75 --- /dev/null +++ b/frontend/src/components/models/UploadButton.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/components/partsTree/ColorPicker.vue b/frontend/src/components/partsTree/ColorPicker.vue new file mode 100644 index 0000000..5bca2d5 --- /dev/null +++ b/frontend/src/components/partsTree/ColorPicker.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/frontend/src/components/partsTree/PartsTreeNode.vue b/frontend/src/components/partsTree/PartsTreeNode.vue new file mode 100644 index 0000000..b9775c4 --- /dev/null +++ b/frontend/src/components/partsTree/PartsTreeNode.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/frontend/src/components/partsTree/PartsTreePanel.vue b/frontend/src/components/partsTree/PartsTreePanel.vue new file mode 100644 index 0000000..15d1903 --- /dev/null +++ b/frontend/src/components/partsTree/PartsTreePanel.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/frontend/src/components/viewer/ContextMenu.vue b/frontend/src/components/viewer/ContextMenu.vue new file mode 100644 index 0000000..bae242b --- /dev/null +++ b/frontend/src/components/viewer/ContextMenu.vue @@ -0,0 +1,391 @@ + + + + + diff --git a/frontend/src/components/viewer/CrossSection.vue b/frontend/src/components/viewer/CrossSection.vue new file mode 100644 index 0000000..f1d826a --- /dev/null +++ b/frontend/src/components/viewer/CrossSection.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/frontend/src/components/viewer/ExplodedView.vue b/frontend/src/components/viewer/ExplodedView.vue new file mode 100644 index 0000000..8e009b6 --- /dev/null +++ b/frontend/src/components/viewer/ExplodedView.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/frontend/src/components/viewer/FeaturePanel.vue b/frontend/src/components/viewer/FeaturePanel.vue new file mode 100644 index 0000000..65e3b8c --- /dev/null +++ b/frontend/src/components/viewer/FeaturePanel.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/viewer/ModelViewer.vue b/frontend/src/components/viewer/ModelViewer.vue new file mode 100644 index 0000000..a3a0f75 --- /dev/null +++ b/frontend/src/components/viewer/ModelViewer.vue @@ -0,0 +1,860 @@ + + + + + diff --git a/frontend/src/components/viewer/RenderSettings.vue b/frontend/src/components/viewer/RenderSettings.vue new file mode 100644 index 0000000..cee185f --- /dev/null +++ b/frontend/src/components/viewer/RenderSettings.vue @@ -0,0 +1,481 @@ + + + + + diff --git a/frontend/src/components/viewer/ThumbnailCapture.vue b/frontend/src/components/viewer/ThumbnailCapture.vue new file mode 100644 index 0000000..747279b --- /dev/null +++ b/frontend/src/components/viewer/ThumbnailCapture.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/frontend/src/components/viewer/ViewCube.vue b/frontend/src/components/viewer/ViewCube.vue new file mode 100644 index 0000000..8c9bdb6 --- /dev/null +++ b/frontend/src/components/viewer/ViewCube.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..c97cad3 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,16 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import './style.css' +import { useThemeStore } from '@/stores/theme' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) + +// Initialize theme before mounting to prevent flash +const themeStore = useThemeStore(pinia) +themeStore.initialize() + +app.mount('#app') diff --git a/frontend/src/services/clippingService.ts b/frontend/src/services/clippingService.ts new file mode 100644 index 0000000..df46709 --- /dev/null +++ b/frontend/src/services/clippingService.ts @@ -0,0 +1,1633 @@ +import * as THREE from 'three' +import earcut from 'earcut' +import { MeshBVH, acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' + +// Install BVH extension on Three.js prototypes (only once) +if (!THREE.BufferGeometry.prototype.computeBoundsTree) { + THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree + THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree + THREE.Mesh.prototype.raycast = acceleratedRaycast +} + +export type Axis = 'x' | 'y' | 'z' + +// Worker message types - simplified (no BVH in Worker) +interface WorkerInput { + segments: Float32Array // Packed segments: [x1,y1,z1, x2,y2,z2, ...] (6 floats per segment) + axis: Axis + requestId: number // Unique request ID to match responses +} + +interface WorkerOutput { + vertices: Float32Array + indices: Uint32Array + axis: Axis // Echo back axis + requestId: number // Echo back requestId +} + +// BVH cached mesh data for fast section cap generation +interface BVHMeshData { + geometry: THREE.BufferGeometry + matrixWorld: THREE.Matrix4 + bvh: MeshBVH + mesh: THREE.Mesh // Store mesh reference to check visibility +} + +export class ClippingService { + private planes: Record + private helpers: Record + private scene: THREE.Scene | null = null + private renderer: THREE.WebGLRenderer | null = null + private bounds: THREE.Box3 = new THREE.Box3() + private initialized: boolean = false + + // Plane visualization meshes and edge lines + private planeMeshes: Record = { x: null, y: null, z: null } + private planeEdgeLines: Record = { x: null, y: null, z: null } + private planeVisible: boolean = true + + // Track enabled state and flip state for each axis + private enabledAxes: Record = { x: false, y: false, z: false } + private flipped: boolean = false + + // Section cap related - geometry-based approach + private sectionCapMeshes: Record = { x: null, y: null, z: null } + + // Web Worker for section cap calculation + private sectionCapWorker: Worker | null = null + + // Drag interaction + private camera: THREE.Camera | null = null + private raycaster: THREE.Raycaster = new THREE.Raycaster() + private dragAxis: Axis | null = null + + // BVH cached mesh data for fast section cap generation + private bvhMeshCache: BVHMeshData[] = [] + + // Track pending section cap request (only one at a time to avoid listener conflicts) + private pendingCapRequest: { axis: Axis; resolve: () => void; requestId: number } | null = null + private capRequestId: number = 0 + + constructor() { + // Initialize planes with normals pointing inward (clip what's behind) + this.planes = { + x: new THREE.Plane(new THREE.Vector3(-1, 0, 0), 0), + y: new THREE.Plane(new THREE.Vector3(0, -1, 0), 0), + z: new THREE.Plane(new THREE.Vector3(0, 0, -1), 0), + } + this.helpers = { x: null, y: null, z: null } + } + + /** + * Initialize clipping with Three.js scene and renderer + */ + initialize(scene: THREE.Scene, renderer: THREE.WebGLRenderer): void { + this.scene = scene + this.renderer = renderer + + // Initialize Web Worker for section cap calculation + if (!this.sectionCapWorker) { + this.sectionCapWorker = new Worker( + new URL('../workers/sectionCapWorker.ts', import.meta.url), + { type: 'module' } + ) + + // Setup persistent message handler (avoids multiple listener conflicts) + this.sectionCapWorker.onmessage = (e: MessageEvent) => { + if (!this.pendingCapRequest) return // No pending request + + const { axis, resolve, requestId } = this.pendingCapRequest + const { vertices, indices, axis: resultAxis, requestId: resultId } = e.data + + // Discard stale responses (from previous requests that completed out of order) + if (resultId !== requestId || resultAxis !== axis) { + console.log(`ClippingService: Discarding stale Worker response (expected id=${requestId} axis=${axis}, got id=${resultId} axis=${resultAxis})`) + return + } + + this.pendingCapRequest = null + + if (vertices.length === 0 || indices.length === 0) { + resolve() + return + } + + // Remove any existing cap before creating new one (prevents orphaned caps) + this.removeSectionCap(axis) + + // Create geometry from worker result + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)) + geometry.setIndex(new THREE.Uint32BufferAttribute(indices, 1)) + geometry.computeVertexNormals() + + const otherClippingPlanes = this.getOtherActiveClippingPlanes(axis) + + const capMaterial = new THREE.MeshStandardMaterial({ + color: 0xaaaaaa, + side: THREE.DoubleSide, + clippingPlanes: otherClippingPlanes, + metalness: 0.3, + roughness: 0.5, + }) + + const capMesh = new THREE.Mesh(geometry, capMaterial) + capMesh.name = `__section_cap_${axis}__` + capMesh.renderOrder = 3 + + this.sectionCapMeshes[axis] = capMesh + this.scene?.add(capMesh) + + resolve() + } + } + + // Enable local clipping on renderer + renderer.localClippingEnabled = true + + // Calculate model bounds for plane positioning + this.bounds.setFromObject(scene) + this.initialized = !this.bounds.isEmpty() + + console.log('ClippingService: Initialized', this.bounds) + + // Build BVH cache after initialization (ensures cache is ready for async section cap) + this.updateMeshDataCache() + } + + /** + * Enable/disable clipping on a specific axis + */ + setAxisEnabled(axis: Axis, enabled: boolean): void { + if (!this.scene || !this.initialized) return + + this.enabledAxes[axis] = enabled + const plane = this.planes[axis] + + this.scene.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh && mesh.material) { + // Skip our helper meshes (plane visualization, section caps, stencil meshes) + if (mesh.name && mesh.name.startsWith('__')) return + + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material] + + materials.forEach((material) => { + if (!material.clippingPlanes) { + material.clippingPlanes = [] + } + + const planeIndex = material.clippingPlanes.indexOf(plane) + + if (enabled && planeIndex === -1) { + material.clippingPlanes.push(plane) + } else if (!enabled && planeIndex !== -1) { + material.clippingPlanes.splice(planeIndex, 1) + } + + material.clipShadows = true + material.needsUpdate = true + }) + } + }) + + // Manage plane visualization mesh + if (enabled) { + this.createAxisPlaneMesh(axis) + this.createSectionCap(axis) + // Refresh other active caps to include new clipping plane + this.refreshOtherActiveCaps(axis) + } else { + this.removeAxisPlaneMesh(axis) + this.removeSectionCap(axis) + // Refresh remaining active caps to remove this clipping plane + this.refreshOtherActiveCaps(axis) + } + } + + /** + * Refresh section caps for other active axes (when clipping planes change) + */ + private refreshOtherActiveCaps(excludeAxis: Axis): void { + const axes: Axis[] = ['x', 'y', 'z'] + axes.forEach(axis => { + if (axis !== excludeAxis && this.enabledAxes[axis]) { + this.removeSectionCap(axis) + this.createSectionCap(axis) + } + }) + } + + /** + * Set plane position along axis (0-100% of model bounds) + * @param isDragging - if true, skip expensive cap regeneration (for smooth dragging) + */ + setPlanePosition(axis: Axis, percent: number, isDragging: boolean = false): void { + if (!this.initialized) return + + const plane = this.planes[axis] + const min = this.bounds.min + const max = this.bounds.max + + // Convert percent to position within bounds + const normalizedPercent = Math.max(0, Math.min(100, percent)) / 100 + + let position: number + switch (axis) { + case 'x': + position = THREE.MathUtils.lerp(min.x, max.x, normalizedPercent) + // Plane constant depends on normal direction + plane.constant = this.flipped ? -position : position + break + case 'y': + position = THREE.MathUtils.lerp(min.y, max.y, normalizedPercent) + plane.constant = this.flipped ? -position : position + break + case 'z': + position = THREE.MathUtils.lerp(min.z, max.z, normalizedPercent) + plane.constant = this.flipped ? -position : position + break + } + + // Update plane mesh position and cap position + this.updatePlaneMeshPosition(axis) + this.updateCapPosition(axis, !isDragging) // Skip regeneration during drag + } + + /** + * Create view-dependent shader material for cutting planes + * Opacity varies based on angle between camera direction and plane normal + */ + private createPlaneShaderMaterial(): THREE.ShaderMaterial { + return new THREE.ShaderMaterial({ + uniforms: { + baseColor: { value: new THREE.Color(0xaaaaaa) }, + minOpacity: { value: 0.02 }, + maxOpacity: { value: 0.5 }, + }, + vertexShader: ` + varying vec3 vNormal; + varying vec3 vViewDir; + void main() { + vNormal = normalize(normalMatrix * normal); + vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); + vViewDir = normalize(-mvPosition.xyz); + gl_Position = projectionMatrix * mvPosition; + } + `, + fragmentShader: ` + uniform vec3 baseColor; + uniform float minOpacity; + uniform float maxOpacity; + varying vec3 vNormal; + varying vec3 vViewDir; + void main() { + float facing = abs(dot(vNormal, vViewDir)); + // facing=1 (parallel to camera) → minOpacity + // facing=0 (perpendicular) → maxOpacity + float opacity = mix(maxOpacity, minOpacity, facing); + gl_FragColor = vec4(baseColor, opacity); + } + `, + transparent: true, + side: THREE.DoubleSide, + depthWrite: true, + }) + } + + /** + * Create a visual mesh for axis clipping plane + */ + private createAxisPlaneMesh(axis: Axis): void { + if (!this.scene || this.planeMeshes[axis]) return + + const size = this.bounds.getSize(new THREE.Vector3()) + const margin = 1.1 // 10% margin beyond bounding box + + // Size plane to fit bounding box face for this axis + let width: number, height: number + switch (axis) { + case 'x': + width = size.z * margin + height = size.y * margin + break + case 'y': + width = size.x * margin + height = size.z * margin + break + case 'z': + width = size.x * margin + height = size.y * margin + break + } + + const geometry = new THREE.PlaneGeometry(width, height) + + // View-dependent shader material - opacity varies with viewing angle + const material = this.createPlaneShaderMaterial() + + const mesh = new THREE.Mesh(geometry, material) + mesh.name = `__clipping_plane_${axis}__` + mesh.renderOrder = 999 + + // Add visible edge outline + const edges = new THREE.EdgesGeometry(geometry) + const lineMaterial = new THREE.LineBasicMaterial({ + color: 0x888888, + transparent: true, + opacity: 0.5, + depthTest: true, // Enable depth testing for proper occlusion + }) + const edgeLines = new THREE.LineSegments(edges, lineMaterial) + edgeLines.name = `__clipping_plane_edge_${axis}__` + edgeLines.renderOrder = 1000 + mesh.add(edgeLines) + this.planeEdgeLines[axis] = edgeLines + + // Set rotation based on axis + switch (axis) { + case 'x': + mesh.rotation.y = Math.PI / 2 + break + case 'y': + mesh.rotation.x = -Math.PI / 2 + break + case 'z': + // No rotation needed for Z axis (facing forward) + break + } + + this.planeMeshes[axis] = mesh + + this.updatePlaneMeshPosition(axis) + mesh.visible = this.planeVisible + this.scene.add(mesh) + } + + /** + * Remove axis plane visualization mesh + */ + private removeAxisPlaneMesh(axis: Axis): void { + if (this.planeMeshes[axis] && this.scene) { + this.scene.remove(this.planeMeshes[axis]!) + this.planeMeshes[axis]!.geometry.dispose() + ;(this.planeMeshes[axis]!.material as THREE.Material).dispose() + this.planeMeshes[axis] = null + } + } + + /** + * Update plane mesh position to match clipping plane + */ + private updatePlaneMeshPosition(axis: Axis): void { + const mesh = this.planeMeshes[axis] + if (!mesh) return + + const center = new THREE.Vector3() + this.bounds.getCenter(center) + + const plane = this.planes[axis] + // Get position along axis from plane constant + const pos = this.flipped ? -plane.constant : plane.constant + + switch (axis) { + case 'x': + mesh.position.set(pos, center.y, center.z) + break + case 'y': + mesh.position.set(center.x, pos, center.z) + break + case 'z': + mesh.position.set(center.x, center.y, pos) + break + } + } + + /** + * Set visibility of all plane visualization meshes + */ + setPlaneVisible(visible: boolean): void { + this.planeVisible = visible + const axes: Axis[] = ['x', 'y', 'z'] + axes.forEach((axis) => { + if (this.planeMeshes[axis]) { + this.planeMeshes[axis]!.visible = visible + } + }) + } + + /** + * Update plane render order based on camera distance + * Closer planes get higher renderOrder (render later = appear on top) + * This ensures correct occlusion for transparent planes + */ + updatePlaneRenderOrder(): void { + if (!this.camera) return + + const enabledAxes = (['x', 'y', 'z'] as Axis[]).filter( + a => this.enabledAxes[a] && this.planeMeshes[a] + ) + + if (enabledAxes.length < 2) return // No need to sort single plane + + // Calculate distances and sort + const planeDistances = enabledAxes.map(axis => ({ + axis, + distance: this.camera!.position.distanceTo(this.planeMeshes[axis]!.position) + })) + + // Sort by distance (farthest first) + planeDistances.sort((a, b) => b.distance - a.distance) + + // Assign renderOrder: farthest = lowest (renders first), closest = highest (renders last, on top) + planeDistances.forEach((item, index) => { + const baseOrder = 999 + this.planeMeshes[item.axis]!.renderOrder = baseOrder + index + if (this.planeEdgeLines[item.axis]) { + this.planeEdgeLines[item.axis]!.renderOrder = baseOrder + index + 0.5 + } + }) + } + + /** + * Flip all enabled plane normals (show opposite region) + */ + flipAllPlaneNormals(): void { + this.flipped = !this.flipped + const axes: Axis[] = ['x', 'y', 'z'] + + axes.forEach((axis) => { + if (this.enabledAxes[axis]) { + const plane = this.planes[axis] + plane.negate() + this.updatePlaneMeshPosition(axis) + this.updateCapPosition(axis) + } + }) + } + + /** + * Get flip state + */ + isFlipped(): boolean { + return this.flipped + } + + /** + * Check if any axis is enabled + */ + hasActivePlane(): boolean { + return this.enabledAxes.x || this.enabledAxes.y || this.enabledAxes.z + } + + /** + * Get clipping planes for other active axes (excluding specified axis) + */ + private getOtherActiveClippingPlanes(excludeAxis: Axis): THREE.Plane[] { + const axes: Axis[] = ['x', 'y', 'z'] + return axes + .filter(a => a !== excludeAxis && this.enabledAxes[a]) + .map(a => this.planes[a]) + } + + /** + * Recalculate model bounds from current scene + * This is needed when the model changes + */ + private recalculateBounds(): void { + if (!this.scene) return + + // Create a temporary box to calculate bounds excluding helper objects + const tempBounds = new THREE.Box3() + this.scene.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh && mesh.geometry) { + // Skip helper meshes + if (mesh.name && mesh.name.startsWith('__')) return + + // Expand bounds to include this mesh + mesh.geometry.computeBoundingBox() + if (mesh.geometry.boundingBox) { + const meshBounds = mesh.geometry.boundingBox.clone() + meshBounds.applyMatrix4(mesh.matrixWorld) + tempBounds.union(meshBounds) + } + } + }) + + if (!tempBounds.isEmpty()) { + this.bounds.copy(tempBounds) + } + } + + /** + * Update bounds and refresh all active plane meshes/caps + * Called when model transforms change (e.g., explosion) + */ + updateBounds(): void { + if (!this.scene || !this.initialized) return + + this.recalculateBounds() + + // Update all active planes and caps with new bounds + const axes: Axis[] = ['x', 'y', 'z'] + axes.forEach(axis => { + if (this.enabledAxes[axis]) { + // Store current position percentage + const currentPercent = this.getPlanePositionPercent(axis) + + // Recreate plane mesh and cap with new bounds + this.removeAxisPlaneMesh(axis) + this.removeSectionCap(axis) + this.createAxisPlaneMesh(axis) + this.createSectionCap(axis) + + // Restore position percentage + this.setPlanePosition(axis, currentPercent) + } + }) + } + + /** + * Get current plane position as percentage (0-100) + */ + getPlanePositionPercent(axis: Axis): number { + const plane = this.planes[axis] + const min = this.bounds.min + const max = this.bounds.max + + let position: number + let minVal: number + let maxVal: number + + switch (axis) { + case 'x': + position = this.flipped ? -plane.constant : plane.constant + minVal = min.x + maxVal = max.x + break + case 'y': + position = this.flipped ? -plane.constant : plane.constant + minVal = min.y + maxVal = max.y + break + case 'z': + position = this.flipped ? -plane.constant : plane.constant + minVal = min.z + maxVal = max.z + break + } + + const range = maxVal - minVal + if (range === 0) return 50 + return ((position - minVal) / range) * 100 + } + + // ==================== Section Cap Methods (Geometry-Based) ==================== + + /** + * Create section cap for an axis by computing actual plane-mesh intersection geometry + * Uses BVH acceleration for fast computation (O(log n) instead of O(n)) + */ + private createSectionCap(axis: Axis): void { + if (!this.scene) return + + // Cancel any pending async request for this axis to prevent orphaned caps + if (this.pendingCapRequest && this.pendingCapRequest.axis === axis) { + this.pendingCapRequest.resolve() + this.pendingCapRequest = null + } + + // Remove existing if any + this.removeSectionCap(axis) + + // Generate section geometry using BVH acceleration + const capGeometry = this.generateCapWithBVH(axis) + if (!capGeometry) return + + const otherClippingPlanes = this.getOtherActiveClippingPlanes(axis) + + const capMaterial = new THREE.MeshStandardMaterial({ + color: 0xaaaaaa, // Gray for section cap + side: THREE.DoubleSide, + clippingPlanes: otherClippingPlanes, + metalness: 0.3, + roughness: 0.5, + }) + + const capMesh = new THREE.Mesh(capGeometry, capMaterial) + capMesh.name = `__section_cap_${axis}__` + capMesh.renderOrder = 3 + + this.sectionCapMeshes[axis] = capMesh + this.scene.add(capMesh) + } + + /** + * Update cached mesh data from scene (call after model loads) + * Builds BVH for each geometry and sends serialized data to Worker + */ + updateMeshDataCache(): void { + this.bvhMeshCache = [] + + if (!this.scene) return + + const startTime = performance.now() + + // Build BVH and collect mesh data + this.scene.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh || !mesh.geometry) return + if (mesh.name && mesh.name.startsWith('__')) return // Skip helper meshes + + const geometry = mesh.geometry as THREE.BufferGeometry + const position = geometry.attributes.position + if (!position) return + + // Build BVH for this geometry (one-time cost) + // Use MeshBVH constructor directly instead of prototype method for reliability + if (!geometry.boundsTree) { + geometry.boundsTree = new MeshBVH(geometry, { maxLeafTris: 10 }) + } + + if (geometry.boundsTree) { + mesh.updateMatrixWorld(true) + this.bvhMeshCache.push({ + geometry, + matrixWorld: mesh.matrixWorld.clone(), + bvh: geometry.boundsTree, + mesh // Store mesh reference to check visibility later + }) + } + }) + + const buildTime = performance.now() - startTime + console.log(`ClippingService: Built BVH for ${this.bvhMeshCache.length} meshes in ${buildTime.toFixed(1)}ms`) + } + + /** + * Find intersection segments using BVH acceleration (main thread) + * This is fast (O(log n) per mesh, typically 1-5ms total) so it runs on main thread + * Returns packed Float32Array for transfer to Worker + */ + private findIntersectionSegments(axis: Axis): Float32Array { + if (this.bvhMeshCache.length === 0) { + return new Float32Array(0) + } + + const plane = this.planes[axis] + const segments: Array<[THREE.Vector3, THREE.Vector3]> = [] + + // Reusable vectors for intersection calculation + const v0 = new THREE.Vector3() + const v1 = new THREE.Vector3() + const v2 = new THREE.Vector3() + + for (const meshData of this.bvhMeshCache) { + // Skip hidden meshes + if (!meshData.mesh.visible) continue + + const { geometry, matrixWorld, bvh } = meshData + + // Transform plane to local space for BVH shapecast + const inverseMatrix = matrixWorld.clone().invert() + const localPlane = plane.clone().applyMatrix4(inverseMatrix) + + const position = geometry.attributes.position + const index = geometry.index + + // BVH shapecast - O(log n) tree traversal + bvh.shapecast({ + intersectsBounds: (box: THREE.Box3) => { + return this.boxIntersectsPlane(box, localPlane) + }, + intersectsTriangle: (_tri: { a: THREE.Vector3; b: THREE.Vector3; c: THREE.Vector3 }, triIndex: number) => { + if (index) { + v0.fromBufferAttribute(position, index.getX(triIndex * 3)) + v1.fromBufferAttribute(position, index.getX(triIndex * 3 + 1)) + v2.fromBufferAttribute(position, index.getX(triIndex * 3 + 2)) + } else { + v0.fromBufferAttribute(position, triIndex * 3) + v1.fromBufferAttribute(position, triIndex * 3 + 1) + v2.fromBufferAttribute(position, triIndex * 3 + 2) + } + + v0.applyMatrix4(matrixWorld) + v1.applyMatrix4(matrixWorld) + v2.applyMatrix4(matrixWorld) + + const segment = this.planeTriangleIntersection(plane, v0.clone(), v1.clone(), v2.clone()) + if (segment) { + segments.push(segment) + } + + return false + } + }) + } + + // Pack segments into Float32Array for Worker transfer + const packedSegments = new Float32Array(segments.length * 6) + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + packedSegments[i * 6 + 0] = seg[0].x + packedSegments[i * 6 + 1] = seg[0].y + packedSegments[i * 6 + 2] = seg[0].z + packedSegments[i * 6 + 3] = seg[1].x + packedSegments[i * 6 + 4] = seg[1].y + packedSegments[i * 6 + 5] = seg[1].z + } + + return packedSegments + } + + /** + * Clear cached mesh data (call when model is unloaded) + */ + clearMeshDataCache(): void { + this.bvhMeshCache = [] + } + + /** + * Create section cap using Web Worker (async, non-blocking) + * BVH shapecast runs on main thread (fast), contour building runs in Worker + */ + private createSectionCapAsync(axis: Axis): Promise { + return new Promise((resolve) => { + if (!this.scene || !this.sectionCapWorker) { + resolve() + return + } + + // Check if BVH cache exists + if (this.bvhMeshCache.length === 0) { + console.warn('ClippingService: BVH not initialized, falling back to sync method') + this.createSectionCap(axis) + resolve() + return + } + + // Cancel any previous pending request + if (this.pendingCapRequest) { + this.pendingCapRequest.resolve() + } + + // Remove existing cap + this.removeSectionCap(axis) + + // Run BVH shapecast on main thread (fast, ~1-5ms) + const startTime = performance.now() + const segments = this.findIntersectionSegments(axis) + const shapecastTime = performance.now() - startTime + + if (segments.length === 0) { + console.log(`ClippingService: No intersections found (${shapecastTime.toFixed(1)}ms)`) + resolve() + return + } + + console.log(`ClippingService: Found ${segments.length / 6} segments in ${shapecastTime.toFixed(1)}ms, sending to Worker`) + + // Increment request ID for each new request + const requestId = ++this.capRequestId + + // Set pending request with requestId + this.pendingCapRequest = { axis, resolve, requestId } + + // Send segments to Worker for contour building + triangulation + const workerInput: WorkerInput = { segments, axis, requestId } + this.sectionCapWorker.postMessage(workerInput, [segments.buffer]) + }) + } + + /** + * Generate actual section geometry by computing plane-mesh intersections + */ + private generateSectionGeometry(axis: Axis): THREE.BufferGeometry | null { + if (!this.scene) return null + + const plane = this.planes[axis] + const segments: Array<[THREE.Vector3, THREE.Vector3]> = [] + + // Collect all intersection segments from model meshes + this.scene.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh || !mesh.geometry) return + if (mesh.name && mesh.name.startsWith('__')) return // Skip helper meshes + + const intersections = this.getPlaneTriangleIntersections(mesh, plane) + segments.push(...intersections) + }) + + if (segments.length === 0) return null + + // Build closed contours from segments + const contours = this.buildContours(segments) + if (contours.length === 0) return null + + // Project to 2D, triangulate, create geometry + return this.createCapGeometryFromContours(contours, axis) + } + + /** + * Get all plane-triangle intersection segments for a mesh + */ + private getPlaneTriangleIntersections( + mesh: THREE.Mesh, + plane: THREE.Plane + ): Array<[THREE.Vector3, THREE.Vector3]> { + const segments: Array<[THREE.Vector3, THREE.Vector3]> = [] + const geometry = mesh.geometry + const position = geometry.attributes.position + const index = geometry.index + + mesh.updateMatrixWorld(true) + + // Process each triangle + const triCount = index ? index.count / 3 : position.count / 3 + const v0 = new THREE.Vector3() + const v1 = new THREE.Vector3() + const v2 = new THREE.Vector3() + + for (let i = 0; i < triCount; i++) { + // Get triangle vertices + if (index) { + v0.fromBufferAttribute(position, index.getX(i * 3)) + v1.fromBufferAttribute(position, index.getX(i * 3 + 1)) + v2.fromBufferAttribute(position, index.getX(i * 3 + 2)) + } else { + v0.fromBufferAttribute(position, i * 3) + v1.fromBufferAttribute(position, i * 3 + 1) + v2.fromBufferAttribute(position, i * 3 + 2) + } + + // Transform to world space + v0.applyMatrix4(mesh.matrixWorld) + v1.applyMatrix4(mesh.matrixWorld) + v2.applyMatrix4(mesh.matrixWorld) + + // Get intersection segment + const segment = this.planeTriangleIntersection(plane, v0, v1, v2) + if (segment) segments.push(segment) + } + + return segments + } + + /** + * Compute intersection of a plane with a triangle + * Returns a line segment (2 points) or null if no intersection + */ + private planeTriangleIntersection( + plane: THREE.Plane, + v0: THREE.Vector3, + v1: THREE.Vector3, + v2: THREE.Vector3 + ): [THREE.Vector3, THREE.Vector3] | null { + const d0 = plane.distanceToPoint(v0) + const d1 = plane.distanceToPoint(v1) + const d2 = plane.distanceToPoint(v2) + + const points: THREE.Vector3[] = [] + const epsilon = 1e-6 + + // Check each edge for intersection + if (d0 * d1 < 0) { + points.push(this.edgePlaneIntersection(v0, v1, d0, d1)) + } + if (d1 * d2 < 0) { + points.push(this.edgePlaneIntersection(v1, v2, d1, d2)) + } + if (d2 * d0 < 0) { + points.push(this.edgePlaneIntersection(v2, v0, d2, d0)) + } + + // Handle vertex exactly on plane cases + if (Math.abs(d0) < epsilon && !points.some(p => p.distanceTo(v0) < epsilon)) { + points.push(v0.clone()) + } + if (Math.abs(d1) < epsilon && !points.some(p => p.distanceTo(v1) < epsilon)) { + points.push(v1.clone()) + } + if (Math.abs(d2) < epsilon && !points.some(p => p.distanceTo(v2) < epsilon)) { + points.push(v2.clone()) + } + + // Need exactly 2 intersection points for a valid segment + if (points.length >= 2) { + return [points[0], points[1]] + } + return null + } + + /** + * Compute intersection point of an edge with a plane + */ + private edgePlaneIntersection( + p0: THREE.Vector3, + p1: THREE.Vector3, + d0: number, + d1: number + ): THREE.Vector3 { + const t = d0 / (d0 - d1) + return new THREE.Vector3().lerpVectors(p0, p1, t) + } + + /** + * Build closed contours from line segments by connecting endpoints + */ + private buildContours(segments: Array<[THREE.Vector3, THREE.Vector3]>): THREE.Vector3[][] { + if (segments.length === 0) return [] + + const contours: THREE.Vector3[][] = [] + const used = new Set() + const epsilon = 1e-4 // Tolerance for connecting points + + // Helper to find closest unused segment endpoint + const findConnecting = (point: THREE.Vector3): { segIdx: number; startEnd: 0 | 1 } | null => { + let bestDist = epsilon + let best: { segIdx: number; startEnd: 0 | 1 } | null = null + + for (let i = 0; i < segments.length; i++) { + if (used.has(i)) continue + const seg = segments[i] + + const d0 = point.distanceTo(seg[0]) + const d1 = point.distanceTo(seg[1]) + + if (d0 < bestDist) { + bestDist = d0 + best = { segIdx: i, startEnd: 0 } + } + if (d1 < bestDist) { + bestDist = d1 + best = { segIdx: i, startEnd: 1 } + } + } + return best + } + + // Build contours by connecting segments + while (used.size < segments.length) { + // Find first unused segment + let startIdx = -1 + for (let i = 0; i < segments.length; i++) { + if (!used.has(i)) { + startIdx = i + break + } + } + if (startIdx === -1) break + + const contour: THREE.Vector3[] = [] + used.add(startIdx) + contour.push(segments[startIdx][0].clone()) + contour.push(segments[startIdx][1].clone()) + + // Extend contour by finding connecting segments + let extended = true + while (extended) { + extended = false + + // Try to extend from end + const endPoint = contour[contour.length - 1] + const nextEnd = findConnecting(endPoint) + if (nextEnd) { + used.add(nextEnd.segIdx) + const seg = segments[nextEnd.segIdx] + // Add the other end of the segment + const newPoint = nextEnd.startEnd === 0 ? seg[1] : seg[0] + contour.push(newPoint.clone()) + extended = true + } + + // Try to extend from start + const startPoint = contour[0] + const nextStart = findConnecting(startPoint) + if (nextStart) { + used.add(nextStart.segIdx) + const seg = segments[nextStart.segIdx] + // Add the other end at the beginning + const newPoint = nextStart.startEnd === 0 ? seg[1] : seg[0] + contour.unshift(newPoint.clone()) + extended = true + } + } + + if (contour.length >= 3) { + contours.push(contour) + } + } + + return contours + } + + /** + * Create cap geometry from contours by triangulating in 2D + */ + private createCapGeometryFromContours(contours: THREE.Vector3[][], axis: Axis): THREE.BufferGeometry | null { + if (contours.length === 0) return null + + const allVertices: number[] = [] + const allIndices: number[] = [] + let vertexOffset = 0 + + for (const contour of contours) { + if (contour.length < 3) continue + + // Project 3D points to 2D based on axis + const coords2D: number[] = [] + for (const p of contour) { + switch (axis) { + case 'x': + coords2D.push(p.z, p.y) + break + case 'y': + coords2D.push(p.x, p.z) + break + case 'z': + coords2D.push(p.x, p.y) + break + } + } + + // Triangulate the 2D polygon using earcut + const indices = earcut(coords2D) + + // Add vertices (3D) + for (const p of contour) { + allVertices.push(p.x, p.y, p.z) + } + + // Add indices with offset + for (const idx of indices) { + allIndices.push(idx + vertexOffset) + } + + vertexOffset += contour.length + } + + if (allVertices.length === 0 || allIndices.length === 0) return null + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(allVertices, 3)) + geometry.setIndex(allIndices) + geometry.computeVertexNormals() + + return geometry + } + + // ==================== BVH-Accelerated Section Cap Methods ==================== + + /** + * Generate section cap geometry using BVH acceleration + * O(log n + k) instead of O(n) where k is triangles actually intersecting the plane + */ + private generateCapWithBVH(axis: Axis): THREE.BufferGeometry | null { + if (this.bvhMeshCache.length === 0) { + // Fall back to non-BVH method if no cache + return this.generateSectionGeometry(axis) + } + + const startTime = performance.now() + const plane = this.planes[axis] + const segments: Array<[THREE.Vector3, THREE.Vector3]> = [] + + // Reusable vectors for intersection calculation + const v0 = new THREE.Vector3() + const v1 = new THREE.Vector3() + const v2 = new THREE.Vector3() + + for (const meshData of this.bvhMeshCache) { + // Skip hidden meshes + if (!meshData.mesh.visible) continue + + const { geometry, matrixWorld, bvh } = meshData + + // Transform plane to local space for BVH shapecast + const inverseMatrix = matrixWorld.clone().invert() + const localPlane = plane.clone().applyMatrix4(inverseMatrix) + + const position = geometry.attributes.position + const index = geometry.index + + // BVH shapecast - O(log n) tree traversal, only visits nodes intersecting plane + bvh.shapecast({ + intersectsBounds: (box: THREE.Box3) => { + // Check if bounding box intersects the plane + return this.boxIntersectsPlane(box, localPlane) + }, + intersectsTriangle: (_tri: { a: THREE.Vector3; b: THREE.Vector3; c: THREE.Vector3 }, triIndex: number) => { + // Get triangle vertices in local space + if (index) { + v0.fromBufferAttribute(position, index.getX(triIndex * 3)) + v1.fromBufferAttribute(position, index.getX(triIndex * 3 + 1)) + v2.fromBufferAttribute(position, index.getX(triIndex * 3 + 2)) + } else { + v0.fromBufferAttribute(position, triIndex * 3) + v1.fromBufferAttribute(position, triIndex * 3 + 1) + v2.fromBufferAttribute(position, triIndex * 3 + 2) + } + + // Transform to world space + v0.applyMatrix4(matrixWorld) + v1.applyMatrix4(matrixWorld) + v2.applyMatrix4(matrixWorld) + + // Get intersection segment in world space + const segment = this.planeTriangleIntersection(plane, v0.clone(), v1.clone(), v2.clone()) + if (segment) { + segments.push(segment) + } + + return false // Continue to find all intersections + } + }) + } + + const intersectTime = performance.now() - startTime + + if (segments.length === 0) return null + + // Build contours with spatial hash optimization (O(n) instead of O(n²)) + const contours = this.buildContoursWithSpatialHash(segments) + if (contours.length === 0) return null + + const totalTime = performance.now() - startTime + console.log(`ClippingService: BVH cap generation: ${segments.length} segments, ${contours.length} contours in ${totalTime.toFixed(1)}ms (intersect: ${intersectTime.toFixed(1)}ms)`) + + return this.createCapGeometryFromContours(contours, axis) + } + + /** + * Check if bounding box intersects plane + * Used by BVH shapecast to skip non-intersecting subtrees + */ + private boxIntersectsPlane(box: THREE.Box3, plane: THREE.Plane): boolean { + const center = new THREE.Vector3() + const extents = new THREE.Vector3() + + box.getCenter(center) + box.getSize(extents).multiplyScalar(0.5) + + // Compute projection radius of box onto plane normal + const r = Math.abs(plane.normal.x) * extents.x + + Math.abs(plane.normal.y) * extents.y + + Math.abs(plane.normal.z) * extents.z + + // Check if center distance to plane is within radius + return Math.abs(plane.distanceToPoint(center)) <= r + } + + /** + * Build closed contours from line segments using spatial hashing + * O(n) instead of O(n²) for finding connecting segments + */ + private buildContoursWithSpatialHash(segments: Array<[THREE.Vector3, THREE.Vector3]>): THREE.Vector3[][] { + if (segments.length === 0) return [] + + const epsilon = 1e-4 + const cellSize = epsilon * 10 + + // Spatial hash for O(1) endpoint lookup + interface EndpointEntry { + segIdx: number + pointIdx: 0 | 1 + point: THREE.Vector3 + } + const hash = new Map() + + const getKey = (p: THREE.Vector3): string => { + const x = Math.floor(p.x / cellSize) + const y = Math.floor(p.y / cellSize) + const z = Math.floor(p.z / cellSize) + return `${x},${y},${z}` + } + + // Index all segment endpoints + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + for (const pointIdx of [0, 1] as const) { + const point = seg[pointIdx] + const key = getKey(point) + if (!hash.has(key)) hash.set(key, []) + hash.get(key)!.push({ segIdx: i, pointIdx, point }) + } + } + + // O(1) average lookup for connecting point + const findConnecting = (point: THREE.Vector3, used: Set): { segIdx: number; startEnd: 0 | 1 } | null => { + const cx = Math.floor(point.x / cellSize) + const cy = Math.floor(point.y / cellSize) + const cz = Math.floor(point.z / cellSize) + + let bestDist = epsilon + let best: { segIdx: number; startEnd: 0 | 1 } | null = null + + // Check 3x3x3 neighborhood + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + for (let dz = -1; dz <= 1; dz++) { + const key = `${cx + dx},${cy + dy},${cz + dz}` + const entries = hash.get(key) + if (!entries) continue + + for (const entry of entries) { + if (used.has(entry.segIdx)) continue + const d = point.distanceTo(entry.point) + if (d < bestDist) { + bestDist = d + best = { segIdx: entry.segIdx, startEnd: entry.pointIdx } + } + } + } + } + } + return best + } + + // Build contours by connecting segments + const contours: THREE.Vector3[][] = [] + const used = new Set() + + while (used.size < segments.length) { + // Find first unused segment + let startIdx = -1 + for (let i = 0; i < segments.length; i++) { + if (!used.has(i)) { + startIdx = i + break + } + } + if (startIdx === -1) break + + const contour: THREE.Vector3[] = [] + used.add(startIdx) + contour.push(segments[startIdx][0].clone()) + contour.push(segments[startIdx][1].clone()) + + // Extend contour by finding connecting segments + let extended = true + while (extended) { + extended = false + + // Try to extend from end + const endPoint = contour[contour.length - 1] + const nextEnd = findConnecting(endPoint, used) + if (nextEnd) { + used.add(nextEnd.segIdx) + const seg = segments[nextEnd.segIdx] + const newPoint = nextEnd.startEnd === 0 ? seg[1] : seg[0] + contour.push(newPoint.clone()) + extended = true + } + + // Try to extend from start + const startPoint = contour[0] + const nextStart = findConnecting(startPoint, used) + if (nextStart) { + used.add(nextStart.segIdx) + const seg = segments[nextStart.segIdx] + const newPoint = nextStart.startEnd === 0 ? seg[1] : seg[0] + contour.unshift(newPoint.clone()) + extended = true + } + } + + if (contour.length >= 3) { + contours.push(contour) + } + } + + return contours + } + + /** + * Remove section cap for an axis + */ + private removeSectionCap(axis: Axis): void { + const mesh = this.sectionCapMeshes[axis] + if (mesh && this.scene) { + this.scene.remove(mesh) + mesh.geometry.dispose() + if (Array.isArray(mesh.material)) { + mesh.material.forEach(m => m.dispose()) + } else { + (mesh.material as THREE.Material).dispose() + } + this.sectionCapMeshes[axis] = null + } + } + + /** + * Update cap position + * @param regenerate - if true, regenerate geometry; if false, just hide cap (for smooth dragging) + */ + private updateCapPosition(axis: Axis, regenerate: boolean = true): void { + if (!this.enabledAxes[axis]) return + + if (regenerate) { + // Full regeneration (expensive) + this.removeSectionCap(axis) + this.createSectionCap(axis) + } else { + // During drag: remove the cap (will be recreated when drag ends) + this.removeSectionCap(axis) + } + } + + /** + * Finalize after drag ends - regenerate cap geometry asynchronously + * Uses Web Worker with BVH acceleration for non-blocking computation + */ + finalizeDrag(axis: Axis): void { + if (this.enabledAxes[axis]) { + // Use async Worker method - cap computes in background, doesn't block rotation + this.createSectionCapAsync(axis) + } + } + + /** + * Get model bounds + */ + getBounds(): THREE.Box3 { + return this.bounds + } + + /** + * Get scene + */ + getScene(): THREE.Scene | null { + return this.scene + } + + /** + * Get renderer + */ + getRenderer(): THREE.WebGLRenderer | null { + return this.renderer + } + + /** + * Reset all clipping planes + */ + reset(): void { + if (!this.scene) return + + // Remove planes from all materials + this.scene.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh && mesh.material) { + // Skip our helper meshes + if (mesh.name && mesh.name.startsWith('__')) return + + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material] + + materials.forEach((material) => { + material.clippingPlanes = [] + material.needsUpdate = true + }) + } + }) + + // Remove axis helpers + Object.values(this.helpers).forEach((helper) => { + if (helper && this.scene) { + this.scene.remove(helper) + } + }) + this.helpers = { x: null, y: null, z: null } + + // Remove all plane meshes and section caps + const axes: Axis[] = ['x', 'y', 'z'] + axes.forEach((axis) => { + this.removeAxisPlaneMesh(axis) + this.removeSectionCap(axis) + }) + + // Terminate worker + if (this.sectionCapWorker) { + this.sectionCapWorker.terminate() + this.sectionCapWorker = null + } + + // Reset state + this.enabledAxes = { x: false, y: false, z: false } + this.flipped = false + this.planeVisible = true + + // Clear BVH cache + this.bvhMeshCache = [] + } + + /** + * Add visual plane helper for debugging + */ + addPlaneHelper(axis: Axis, color: number = 0xff0000): void { + if (!this.scene || !this.initialized) return + + // Remove existing helper + if (this.helpers[axis]) { + this.scene.remove(this.helpers[axis]!) + } + + const plane = this.planes[axis] + const size = this.bounds.getSize(new THREE.Vector3()).length() * 0.5 + + const helper = new THREE.PlaneHelper(plane, size, color) + this.scene.add(helper) + this.helpers[axis] = helper + } + + /** + * Remove plane helper + */ + removePlaneHelper(axis: Axis): void { + if (this.helpers[axis] && this.scene) { + this.scene.remove(this.helpers[axis]!) + this.helpers[axis] = null + } + } + + /** + * Check if initialized + */ + isInitialized(): boolean { + return this.initialized + } + + /** + * Get all currently active clipping planes + * Used by other services (e.g., renderService for edge line clipping) + */ + getActiveClippingPlanes(): THREE.Plane[] { + const axes: Axis[] = ['x', 'y', 'z'] + return axes + .filter(a => this.enabledAxes[a]) + .map(a => this.planes[a]) + } + + /** + * Refresh all active section caps + * Call this when mesh visibility changes to update caps accordingly + */ + refreshSectionCaps(): void { + const axes: Axis[] = ['x', 'y', 'z'] + axes.forEach(axis => { + if (this.enabledAxes[axis]) { + this.removeSectionCap(axis) + this.createSectionCap(axis) + } + }) + } + + // ==================== Drag Interaction Methods ==================== + + /** + * Set camera reference for raycasting + */ + setCamera(camera: THREE.Camera): void { + this.camera = camera + } + + /** + * Check if mouse intersects any enabled plane mesh + * Returns the axis of the closest intersected plane or null + */ + checkPlaneIntersection(mouse: THREE.Vector2): Axis | null { + if (!this.camera || !this.initialized || !this.scene) return null + + this.raycaster.setFromCamera(mouse, this.camera) + + // Collect all plane mesh intersections + const planeHits: { axis: Axis; distance: number }[] = [] + + const axes: Axis[] = ['x', 'y', 'z'] + for (const axis of axes) { + if (!this.enabledAxes[axis]) continue + + const planeMesh = this.planeMeshes[axis] + if (!planeMesh || !planeMesh.visible) continue + + const intersects = this.raycaster.intersectObject(planeMesh, false) + if (intersects.length > 0) { + planeHits.push({ axis, distance: intersects[0].distance }) + } + } + + // Return closest plane to camera + if (planeHits.length > 0) { + planeHits.sort((a, b) => a.distance - b.distance) + return planeHits[0].axis + } + + return null + } + + /** + * Start dragging a plane + */ + startDrag(axis: Axis): void { + this.dragAxis = axis + } + + /** + * Process drag movement and return new percent value (0-100) + */ + drag(mouse: THREE.Vector2): number | null { + if (!this.dragAxis || !this.camera) return null + + this.raycaster.setFromCamera(mouse, this.camera) + const ray = this.raycaster.ray + + const axis = this.dragAxis + const min = this.bounds.min + const max = this.bounds.max + const center = this.bounds.getCenter(new THREE.Vector3()) + + // Use camera-facing plane for reliable intersection + // Then project the intersection point onto the drag axis + const cameraDir = this.camera.getWorldDirection(new THREE.Vector3()) + + // Create a plane facing the camera, passing through model center + const cameraPlane = new THREE.Plane().setFromNormalAndCoplanarPoint(cameraDir, center) + + // Find intersection point with camera plane + const intersection = new THREE.Vector3() + const hit = ray.intersectPlane(cameraPlane, intersection) + + if (!hit) return null + + // Extract position along the drag axis + let position: number + let minVal: number + let maxVal: number + + switch (axis) { + case 'x': + position = intersection.x + minVal = min.x + maxVal = max.x + break + case 'y': + position = intersection.y + minVal = min.y + maxVal = max.y + break + case 'z': + position = intersection.z + minVal = min.z + maxVal = max.z + break + } + + // Convert to 0-100 percent + const range = maxVal - minVal + if (range === 0) return 50 + + const percent = ((position - minVal) / range) * 100 + return Math.max(0, Math.min(100, percent)) + } + + /** + * End dragging + */ + endDrag(): void { + this.dragAxis = null + } + + /** + * Check if currently dragging + */ + isDragging(): boolean { + return this.dragAxis !== null + } + + /** + * Get the axis currently being dragged + */ + getDragAxis(): Axis | null { + return this.dragAxis + } +} + +// Singleton instance +let clippingService: ClippingService | null = null + +export function getClippingService(): ClippingService { + if (!clippingService) { + clippingService = new ClippingService() + } + return clippingService +} + +export function resetClippingService(): void { + if (clippingService) { + clippingService.reset() + } + clippingService = null +} diff --git a/frontend/src/services/explodeService.ts b/frontend/src/services/explodeService.ts new file mode 100644 index 0000000..3a6c707 --- /dev/null +++ b/frontend/src/services/explodeService.ts @@ -0,0 +1,658 @@ +import * as THREE from 'three' +import { getRenderService } from './renderService' +import { getClippingService } from './clippingService' +import { getPartsTreeService } from './partsTreeService' + +interface PartData { + mesh: THREE.Object3D + uuid: string + originalPosition: THREE.Vector3 + direction: THREE.Vector3 // Direction relative to parent (or model center for roots) + distance: number // Distance to parent center (or model center for roots) + isExploded: boolean // For per-part explosion tracking + parentUuid: string | null // Parent part UUID (null for root parts) + childrenUuids: string[] // Children part UUIDs + depth: number // Hierarchy depth (0 for roots) +} + +export class ExplodeService { + private parts: PartData[] = [] + private partsMap: Map = new Map() + private modelCenter: THREE.Vector3 = new THREE.Vector3() + private maxExplosionDistance: number = 1 + private initialized: boolean = false + private currentFactor: number = 0 + private animationId: number | null = null + private onRenderCallback: (() => void) | null = null + + /** + * Initialize explosion data from the Three.js scene + * Builds hierarchical parent-child relationships for recursive explosion + */ + initializeFromScene(scene: THREE.Scene, onRender?: () => void): void { + this.parts = [] + this.partsMap.clear() + this.initialized = false + this.currentFactor = 0 + this.onRenderCallback = onRender || null + + // Calculate overall bounding box for model center + const modelBox = new THREE.Box3().setFromObject(scene) + if (modelBox.isEmpty()) { + console.warn('Scene bounding box is empty') + return + } + + modelBox.getCenter(this.modelCenter) + + // Calculate max dimension for scaling explosion distance + const modelSize = new THREE.Vector3() + modelBox.getSize(modelSize) + this.maxExplosionDistance = Math.max(modelSize.x, modelSize.y, modelSize.z) * 0.5 + + // Pass 1: Collect all mesh parts + scene.traverse((object) => { + const isMesh = object.type === 'Mesh' || (object as unknown as { isMesh?: boolean }).isMesh === true + if (isMesh) { + const mesh = object as THREE.Mesh + if (!mesh.geometry) return + + const partData: PartData = { + mesh: object, + uuid: object.uuid, + originalPosition: object.position.clone(), + direction: new THREE.Vector3(), + distance: 0, + isExploded: false, + parentUuid: null, + childrenUuids: [], + depth: 0, + } + + this.parts.push(partData) + this.partsMap.set(object.uuid, partData) + } + }) + + // Pass 2: Build parent-child relationships (only mesh parents) + for (const part of this.parts) { + let parent = part.mesh.parent + while (parent && parent.type !== 'Scene') { + if (this.partsMap.has(parent.uuid)) { + part.parentUuid = parent.uuid + this.partsMap.get(parent.uuid)!.childrenUuids.push(part.uuid) + break + } + parent = parent.parent + } + } + + // Pass 3: Calculate depth and explosion directions relative to parent + for (const part of this.parts) { + // Calculate depth + part.depth = this.calculateDepth(part.uuid) + + // Get parent center for direction calculation + let parentCenter: THREE.Vector3 + if (part.parentUuid) { + // Has mesh parent - use parent's center + parentCenter = this.getPartCenter(part.parentUuid) + } else { + // Root part - try to find a Group ancestor for better explosion direction + const groupCenter = this.findGroupParentCenter(part.mesh) + parentCenter = groupCenter || this.modelCenter + } + + // Get this part's center + const partCenter = this.getPartCenter(part.uuid) + + // Calculate direction from parent center to part center + const direction = new THREE.Vector3().subVectors(partCenter, parentCenter) + const distance = direction.length() + + if (distance > 0.001) { + direction.normalize() + } else { + // For parts at same center as parent, use a pseudo-random direction + direction.set( + Math.sin(part.mesh.id * 0.1), + Math.cos(part.mesh.id * 0.2), + Math.sin(part.mesh.id * 0.3) + ).normalize() + } + + part.direction = direction + part.distance = distance + } + + this.initialized = this.parts.length > 0 + const rootCount = this.parts.filter(p => !p.parentUuid).length + const maxDepth = Math.max(...this.parts.map(p => p.depth), 0) + console.log(`ExplodeService: Initialized with ${this.parts.length} parts (${rootCount} roots, max depth: ${maxDepth})`) + } + + /** + * Calculate hierarchy depth for a part + */ + private calculateDepth(uuid: string): number { + const part = this.partsMap.get(uuid) + if (!part || !part.parentUuid) return 0 + return 1 + this.calculateDepth(part.parentUuid) + } + + /** + * Get the world-space center of a part + */ + private getPartCenter(uuid: string): THREE.Vector3 { + const part = this.partsMap.get(uuid) + if (!part) return new THREE.Vector3() + + const box = new THREE.Box3().setFromObject(part.mesh) + return box.getCenter(new THREE.Vector3()) + } + + /** + * Get the world-space center of any Object3D + */ + private getObjectCenter(object: THREE.Object3D): THREE.Vector3 { + const box = new THREE.Box3().setFromObject(object) + return box.getCenter(new THREE.Vector3()) + } + + /** + * Find the center of the nearest Group ancestor with multiple mesh descendants + * Used for calculating explosion direction for root parts + */ + private findGroupParentCenter(mesh: THREE.Object3D): THREE.Vector3 | null { + let parent = mesh.parent + while (parent && parent.type !== 'Scene') { + const meshCount = this.countMeshDescendants(parent) + if (meshCount > 1) { + return this.getObjectCenter(parent) + } + parent = parent.parent + } + return null + } + + /** + * Check if an object is a mesh + */ + private isMesh(object: THREE.Object3D): boolean { + return object.type === 'Mesh' || + (object as unknown as { isMesh?: boolean }).isMesh === true + } + + /** + * Count mesh descendants of an object (excluding the object itself) + */ + private countMeshDescendants(object: THREE.Object3D): number { + let count = 0 + object.traverse((child) => { + if (child !== object && this.isMesh(child)) { + count++ + } + }) + return count + } + + /** + * Convert a world-space direction vector to local-space for a given parent object + * This is needed because mesh.position is in local coordinates, but our explosion + * directions are calculated in world coordinates + */ + private worldToLocalDirection(parent: THREE.Object3D, worldDir: THREE.Vector3): THREE.Vector3 { + // Get the inverse of the parent's world matrix + const parentWorldMatrixInverse = new THREE.Matrix4() + parentWorldMatrixInverse.copy(parent.matrixWorld).invert() + + // Transform direction only (rotation/scale, not translation) + // Note: transformDirection normalizes the result, so we need to restore length + const length = worldDir.length() + const localDir = worldDir.clone() + localDir.transformDirection(parentWorldMatrixInverse) + localDir.setLength(length) + + return localDir + } + + /** + * Apply explosion with given factor (0 = collapsed, 100 = fully exploded) + * Uses hierarchical recursive explosion based on assembly tree structure + * @param factor - Explosion factor (0-100) + * @param isDragging - If true, skip expensive sync operations for better performance during slider drag + */ + applyExplosion(factor: number, isDragging: boolean = false): void { + if (!this.initialized) return + + this.currentFactor = factor + + if (factor === 0) { + // Reset all parts to original position + this.parts.forEach((part) => { + if (part.mesh && part.mesh.parent) { + part.mesh.position.copy(part.originalPosition) + } + }) + } else { + // Normalize factor from 0-100 to 0-1 + const normalizedFactor = Math.max(0, Math.min(1, factor / 100)) + + // Scale factor for visible effect + const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5 + + // Find root parts (no parent) and recursively apply explosion + const rootParts = this.parts.filter(p => !p.parentUuid) + + for (const root of rootParts) { + this.applyExplosionRecursive(root, explosionScale, new THREE.Vector3()) + } + } + + // Sync edge lines with new positions (now O(n) after optimization, fast enough for dragging) + getRenderService().syncEdgeTransforms() + + // Skip expensive operations during dragging for smooth slider interaction + if (!isDragging) { + // Update clipping bounds when explosion changes + getClippingService().updateBounds() + + // Sync selection overlay positions + getPartsTreeService().syncSelectionOverlays() + } + } + + /** + * Recursively apply explosion offset, accumulating parent offsets + * parentOffset is in world coordinates and gets converted to local coordinates for each part + */ + private applyExplosionRecursive( + part: PartData, + explosionScale: number, + parentWorldOffset: THREE.Vector3 // World-space accumulated offset + ): void { + // Check if mesh is still valid + if (!part.mesh || !part.mesh.parent) return + + // Calculate this part's explosion offset in world coordinates + // (direction is already in world coordinates from initialization) + const partWorldOffset = part.direction.clone().multiplyScalar( + part.distance > 0.001 ? explosionScale : explosionScale * 0.5 + ) + + // Total world offset = parent's accumulated world offset + this part's world offset + const totalWorldOffset = parentWorldOffset.clone().add(partWorldOffset) + + // Convert world-space offset to local-space for this mesh's parent + const localOffset = this.worldToLocalDirection(part.mesh.parent, totalWorldOffset) + + // Apply local offset to local position + part.mesh.position.copy(part.originalPosition).add(localOffset) + + // Recursively apply to children with accumulated world offset + for (const childUuid of part.childrenUuids) { + const childPart = this.partsMap.get(childUuid) + if (childPart) { + this.applyExplosionRecursive(childPart, explosionScale, totalWorldOffset) + } + } + } + + /** + * Animate explosion to target factor + */ + animateExplosion( + targetFactor: number, + duration: number = 500, + onComplete?: () => void + ): void { + if (!this.initialized) return + + // Cancel any existing animation + this.cancelAnimation() + + const startFactor = this.currentFactor + const startTime = performance.now() + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime + const progress = Math.min(elapsed / duration, 1) + + // Ease out cubic + const eased = 1 - Math.pow(1 - progress, 3) + const factor = startFactor + (targetFactor - startFactor) * eased + + this.applyExplosionDirect(factor) + + if (this.onRenderCallback) { + this.onRenderCallback() + } + + if (progress < 1) { + this.animationId = requestAnimationFrame(animate) + } else { + this.animationId = null + this.currentFactor = targetFactor + // Update clipping bounds when animation completes + getClippingService().updateBounds() + if (onComplete) onComplete() + } + } + + this.animationId = requestAnimationFrame(animate) + } + + /** + * Direct apply explosion (used by animation, bypasses per-part logic) + * Uses hierarchical recursive explosion + */ + private applyExplosionDirect(factor: number): void { + this.currentFactor = factor + + if (factor === 0) { + this.parts.forEach((part) => { + part.mesh.position.copy(part.originalPosition) + }) + } else { + const normalizedFactor = Math.max(0, Math.min(1, factor / 100)) + const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5 + + // Find root parts and recursively apply explosion + const rootParts = this.parts.filter(p => !p.parentUuid) + + for (const root of rootParts) { + this.applyExplosionRecursive(root, explosionScale, new THREE.Vector3()) + } + } + + // Sync edge lines with new positions + getRenderService().syncEdgeTransforms() + + // Sync selection overlay positions + getPartsTreeService().syncSelectionOverlays() + } + + /** + * Cancel any running animation + */ + cancelAnimation(): void { + if (this.animationId !== null) { + cancelAnimationFrame(this.animationId) + this.animationId = null + } + } + + /** + * Check if animation is running + */ + isAnimating(): boolean { + return this.animationId !== null + } + + /** + * Explode a single part by UUID (also moves children) + */ + explodePart(uuid: string, factor: number = 100): void { + const part = this.partsMap.get(uuid) + if (!part || !part.mesh.parent) return + + part.isExploded = true + + const normalizedFactor = Math.max(0, Math.min(1, factor / 100)) + const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5 + + // Calculate offset in world coordinates + const worldOffset = part.direction.clone().multiplyScalar( + part.distance > 0.001 ? explosionScale : explosionScale * 0.5 + ) + + // Convert to local coordinates for this part + const localOffset = this.worldToLocalDirection(part.mesh.parent, worldOffset) + + // Move this part + part.mesh.position.copy(part.originalPosition).add(localOffset) + + // Move all descendants by the same world offset (each converted to their local space) + this.moveDescendants(part, worldOffset) + + // Sync edge lines with new position + getRenderService().syncEdgeTransforms() + + // Sync selection overlay positions + getPartsTreeService().syncSelectionOverlays() + + if (this.onRenderCallback) { + this.onRenderCallback() + } + } + + /** + * Recursively move all descendants by a world-space offset + */ + private moveDescendants(part: PartData, worldOffset: THREE.Vector3): void { + for (const childUuid of part.childrenUuids) { + const child = this.partsMap.get(childUuid) + if (child && child.mesh.parent) { + // Convert world offset to child's local coordinate system + const localOffset = this.worldToLocalDirection(child.mesh.parent, worldOffset) + child.mesh.position.copy(child.originalPosition).add(localOffset) + this.moveDescendants(child, worldOffset) + } + } + } + + /** + * Animate explode a single part by UUID (also moves children) + */ + animateExplodePart( + uuid: string, + targetFactor: number = 100, + duration: number = 300 + ): void { + const part = this.partsMap.get(uuid) + if (!part || !part.mesh.parent) return + + // Collect start positions for part and all descendants + const startPositions = new Map() + startPositions.set(uuid, part.mesh.position.clone()) + this.collectDescendantPositions(part, startPositions) + + const normalizedFactor = Math.max(0, Math.min(1, targetFactor / 100)) + const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5 + + // Calculate world offset + const worldOffset = part.direction.clone().multiplyScalar( + part.distance > 0.001 ? explosionScale : explosionScale * 0.5 + ) + + // Convert to local offset for target position + const localOffset = this.worldToLocalDirection(part.mesh.parent, worldOffset) + const targetPosition = part.originalPosition.clone().add(localOffset) + + const startTime = performance.now() + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime + const progress = Math.min(elapsed / duration, 1) + const eased = 1 - Math.pow(1 - progress, 3) + + // Move main part + const startPos = startPositions.get(uuid)! + part.mesh.position.lerpVectors(startPos, targetPosition, eased) + + // Move all descendants by interpolated world offset (converted to local for each) + const currentWorldOffset = worldOffset.clone().multiplyScalar(eased) + this.animateDescendants(part, startPositions, currentWorldOffset) + + // Sync edge lines with new position + getRenderService().syncEdgeTransforms() + + // Sync selection overlay positions + getPartsTreeService().syncSelectionOverlays() + + if (this.onRenderCallback) { + this.onRenderCallback() + } + + if (progress < 1) { + requestAnimationFrame(animate) + } else { + part.isExploded = targetFactor > 0 + } + } + + requestAnimationFrame(animate) + } + + /** + * Collect start positions for all descendants + */ + private collectDescendantPositions(part: PartData, positions: Map): void { + for (const childUuid of part.childrenUuids) { + const child = this.partsMap.get(childUuid) + if (child) { + positions.set(childUuid, child.mesh.position.clone()) + this.collectDescendantPositions(child, positions) + } + } + } + + /** + * Animate descendants during single-part animation + * worldOffset is in world coordinates + */ + private animateDescendants( + part: PartData, + startPositions: Map, + worldOffset: THREE.Vector3 + ): void { + for (const childUuid of part.childrenUuids) { + const child = this.partsMap.get(childUuid) + if (child && child.mesh.parent) { + const startPos = startPositions.get(childUuid) + if (startPos) { + // Convert world offset to child's local coordinate system + const localOffset = this.worldToLocalDirection(child.mesh.parent, worldOffset) + child.mesh.position.copy(child.originalPosition).add(localOffset) + } + this.animateDescendants(child, startPositions, worldOffset) + } + } + } + + /** + * Reset a single part to original position (also resets children) + */ + resetPart(uuid: string): void { + const part = this.partsMap.get(uuid) + if (!part) return + + part.isExploded = false + part.mesh.position.copy(part.originalPosition) + + // Reset all descendants too + this.resetDescendants(part) + + // Sync edge lines with new position + getRenderService().syncEdgeTransforms() + + // Sync selection overlay positions + getPartsTreeService().syncSelectionOverlays() + + if (this.onRenderCallback) { + this.onRenderCallback() + } + } + + /** + * Recursively reset descendants to original positions + */ + private resetDescendants(part: PartData): void { + for (const childUuid of part.childrenUuids) { + const child = this.partsMap.get(childUuid) + if (child) { + child.isExploded = false + child.mesh.position.copy(child.originalPosition) + this.resetDescendants(child) + } + } + } + + /** + * Animate reset a single part + */ + animateResetPart(uuid: string, duration: number = 300): void { + this.animateExplodePart(uuid, 0, duration) + } + + /** + * Get current explosion factor + */ + getCurrentFactor(): number { + return this.currentFactor + } + + /** + * Reset to original positions + */ + reset(): void { + this.cancelAnimation() + this.parts.forEach((part) => { + part.mesh.position.copy(part.originalPosition) + part.isExploded = false + }) + this.currentFactor = 0 + + // Sync edge lines with reset positions + getRenderService().syncEdgeTransforms() + + // Update clipping bounds when reset + getClippingService().updateBounds() + } + + /** + * Check if service is initialized + */ + isInitialized(): boolean { + return this.initialized + } + + /** + * Get number of parts + */ + getPartsCount(): number { + return this.parts.length + } + + /** + * Check if a part is exploded + */ + isPartExploded(uuid: string): boolean { + const part = this.partsMap.get(uuid) + return part?.isExploded ?? false + } + + /** + * Get exploded parts UUIDs + */ + getExplodedParts(): string[] { + return this.parts.filter(p => p.isExploded).map(p => p.uuid) + } +} + +// Singleton instance +let explodeService: ExplodeService | null = null + +export function getExplodeService(): ExplodeService { + if (!explodeService) { + explodeService = new ExplodeService() + } + return explodeService +} + +export function resetExplodeService(): void { + if (explodeService) { + explodeService.reset() + } + explodeService = null +} diff --git a/frontend/src/services/partsTreeService.ts b/frontend/src/services/partsTreeService.ts new file mode 100644 index 0000000..d6da788 --- /dev/null +++ b/frontend/src/services/partsTreeService.ts @@ -0,0 +1,1067 @@ +import * as THREE from 'three' +import type { TreeNode, FlatTreeNode } from '@/types/partsTree' +import { getClippingService } from './clippingService' + +// Material type enum +export enum MaterialType { + Clay = 'clay', // Clay/white model for structure display (default) + Metal = 'metal', // Metallic PBR material + Paint = 'paint', // Industrial matte paint/powder coat +} + +// Material configuration interface +export interface MaterialConfig { + type: MaterialType + color?: number // Color (hex) +} + +export class PartsTreeService { + private originalMaterials: Map = new Map() + private highlightedObject: THREE.Object3D | null = null + private scene: THREE.Scene | null = null + + // Selection state (click to select with green highlight overlay) + private selectedObject: THREE.Object3D | null = null + private selectionOverlays: Map = new Map() + + // Truly original materials - stored once when model loads, never modified + private trulyOriginalMaterials: Map = new Map() + + // Auto-color state (decoupled from material type) + private autoColorEnabled: boolean = false + private autoColorPalette: Map = new Map() // uuid -> colorIndex in palette + + // Material system properties + private globalMaterialType: MaterialType = MaterialType.Clay + private partMaterialOverrides: Map = new Map() + + // CAD color palette - balanced industrial colors with good differentiation + // Main color (steel blue) for larger parts, varied muted colors for contrast + private readonly cadColorPalette = [ + 0x5B7C8A, // 主色:钢蓝 (大零件优先) + 0x8B9D83, // 橄榄灰绿 (对比色) + 0x7A8B99, // 蓝灰 + 0x9C8B7A, // 暖灰棕 (对比色) + 0x6B8B8B, // 青灰 + 0x8B7B8B, // 紫灰 (对比色) + 0x7B8B7B, // 灰绿 + 0x8B8B7B, // 黄灰 (对比色) + 0x6B7B8B, // 深蓝灰 + 0x9B8B8B, // 浅紫灰 + 0x7B9B8B, // 薄荷灰 + 0x8B7B7B, // 暖灰 + ] + + /** + * Set scene reference + */ + setScene(scene: THREE.Scene | null): void { + this.scene = scene + } + + /** + * Store truly original materials when model is loaded + * This should be called once after loading a model, before any material modifications + */ + storeOriginalMaterials(): void { + if (!this.scene) return + + // Clear previous originals (for new model) + this.trulyOriginalMaterials.clear() + + // Traverse entire scene to find all meshes (like RenderService does) + // This is more robust than using findModelRoot which only returns first non-system child + this.scene.traverse((object) => { + // Skip system objects + if (object.name.startsWith('__')) return + + if (this.isMesh(object) && object.material) { + // Store original material - only written once, never modified + this.trulyOriginalMaterials.set(object.uuid, object.material) + } + }) + + console.log(`PartsTreeService: Stored ${this.trulyOriginalMaterials.size} original materials`) + } + + /** + * Build a tree structure from the Three.js scene + */ + buildTree(scene: THREE.Scene): TreeNode | null { + this.scene = scene // Store scene reference + + // Find the actual model root (skip scene wrapper objects like lights, cameras) + const modelRoot = this.findModelRoot(scene) + if (!modelRoot) { + console.warn('PartsTreeService: No model root found in scene') + return null + } + + const tree = this.buildNode(modelRoot, 0) + console.log(`PartsTreeService: Built tree with ${this.countNodes(tree)} nodes`) + return tree + } + + /** + * Check if an object is a light (using type string to avoid instanceof issues with multiple THREE instances) + */ + private isLight(obj: THREE.Object3D): boolean { + return obj.type.includes('Light') || (obj as unknown as { isLight?: boolean }).isLight === true + } + + /** + * Check if an object is a camera + */ + private isCamera(obj: THREE.Object3D): boolean { + return obj.type.includes('Camera') || (obj as unknown as { isCamera?: boolean }).isCamera === true + } + + /** + * Check if an object is a helper + */ + private isHelper(obj: THREE.Object3D): boolean { + return obj.type === 'GridHelper' || obj.type === 'AxesHelper' || obj.type.includes('Helper') + } + + /** + * Decode part name to fix encoding issues (GBK/GB2312 misinterpreted as Latin-1) + * This fixes Chinese characters that appear as garbled text from STEP files + */ + private decodePartName(name: string): string { + if (!name) return name + + try { + // Check if the string contains characters in the Latin-1 extended range (0x80-0xFF) + // which indicates it might be GBK bytes misinterpreted as Latin-1 + const latin1Pattern = /[\x80-\xff]/ + if (latin1Pattern.test(name)) { + // Convert each character code to a byte and decode as GBK (common for Chinese CAD software) + const bytes = new Uint8Array([...name].map(c => c.charCodeAt(0))) + const decoded = new TextDecoder('gbk').decode(bytes) + // Check if decoding was successful (no replacement characters) + if (decoded && !decoded.includes('\ufffd')) { + return decoded + } + } + } catch { + // Decoding failed, return original name + } + return name + } + + /** + * Find the model root object in the scene + * Handles both hierarchical models (single root with children) and + * flat models (meshes as direct scene children) + */ + private findModelRoot(scene: THREE.Scene): THREE.Object3D | null { + // Collect all non-system children + const modelChildren: THREE.Object3D[] = [] + + for (const child of scene.children) { + // Skip lights, cameras, helpers using type checks (not instanceof) + if (this.isLight(child) || this.isCamera(child) || this.isHelper(child)) { + continue + } + // Skip our custom objects (edge lines, lights group, etc.) + if (child.name.startsWith('__')) { + continue + } + modelChildren.push(child) + } + + if (modelChildren.length === 0) return null + + // If only one child with its own children, use it as root (hierarchical model) + if (modelChildren.length === 1 && modelChildren[0].children.length > 0) { + return modelChildren[0] + } + + // For flat structures (all meshes at scene level) or empty first child, + // create a virtual root that contains all model objects + const virtualRoot = new THREE.Group() + virtualRoot.name = 'Model' + for (const child of modelChildren) { + virtualRoot.children.push(child) + } + + return virtualRoot + } + + /** + * Recursively build a tree node + */ + private buildNode(object: THREE.Object3D, depth: number): TreeNode { + const children = object.children + .filter(child => !this.isLight(child) && !this.isCamera(child)) + .map(child => this.buildNode(child, depth + 1)) + + const childCount = this.countDescendants(object) + + // Record original opacity for restore + let originalOpacity = 1 + if (this.isMesh(object)) { + const mesh = object as THREE.Mesh + const material = mesh.material as THREE.MeshStandardMaterial + if (material && material.opacity !== undefined) { + originalOpacity = material.opacity + } + } + + return { + id: object.uuid, + name: this.decodePartName(object.name) || `${object.type}_${object.id}`, + depth, + visible: object.visible, + originalOpacity, + isExpanded: depth < 2, // Auto-expand first 2 levels + childCount, + object, + children, + } + } + + /** + * Count total descendants of an object + */ + private countDescendants(object: THREE.Object3D): number { + let count = 0 + object.traverse((child) => { + if (child !== object) count++ + }) + return count + } + + /** + * Count total nodes in tree + */ + private countNodes(node: TreeNode | null): number { + if (!node) return 0 + return 1 + node.children.reduce((sum, child) => sum + this.countNodes(child), 0) + } + + /** + * Set mesh visibility for visibility control + * Sets mesh.visible directly on mesh objects (not parent groups) + * This allows group children to render independently + */ + private setMeshOpacity(object: THREE.Object3D, opacity: number): void { + const visible = opacity > 0 + let meshCount = 0 + object.traverse((child) => { + if (this.isMesh(child)) { + const mesh = child as THREE.Mesh + // Set mesh visible directly - works because meshes are leaf nodes + mesh.visible = visible + meshCount++ + } + }) + } + + /** + * Set visibility of a node (cascades to all children) + * Uses opacity control instead of visible property + */ + setVisible(node: TreeNode, visible: boolean): void { + // Update node state + node.visible = visible + + // Use opacity to control visibility (not object.visible) + const targetOpacity = visible ? node.originalOpacity : 0 + this.setMeshOpacity(node.object, targetOpacity) + + // Cascade to all children + this.cascadeVisibility(node, visible) + + // Refresh section caps to exclude/include hidden/shown meshes + getClippingService().refreshSectionCaps() + } + + /** + * Set visibility independently (only this node, no cascade to TreeNode children) + * Used when showing a child while parent is hidden + */ + setVisibleIndependent(node: TreeNode, visible: boolean): void { + node.visible = visible + const targetOpacity = visible ? node.originalOpacity : 0 + + // Set opacity for all meshes within this node's THREE.Object3D + // This does NOT cascade to TreeNode children (handled separately) + this.setMeshOpacity(node.object, targetOpacity) + + // Refresh section caps to exclude/include hidden/shown meshes + getClippingService().refreshSectionCaps() + } + + /** + * Recursively cascade visibility to children + */ + private cascadeVisibility(node: TreeNode, visible: boolean): void { + for (const child of node.children) { + child.visible = visible + const targetOpacity = visible ? child.originalOpacity : 0 + this.setMeshOpacity(child.object, targetOpacity) + this.cascadeVisibility(child, visible) + } + } + + /** + * Show all nodes + */ + showAll(node: TreeNode): void { + this.setVisible(node, true) + } + + /** + * Hide all nodes + */ + hideAll(node: TreeNode): void { + this.setVisible(node, false) + } + + /** + * Expand all nodes in the tree + */ + expandAll(node: TreeNode): void { + node.isExpanded = true + node.children.forEach(child => this.expandAll(child)) + } + + /** + * Collapse all nodes in the tree + */ + collapseAll(node: TreeNode): void { + node.isExpanded = false + node.children.forEach(child => this.collapseAll(child)) + } + + /** + * Toggle expansion of a node + */ + toggleExpanded(node: TreeNode): void { + node.isExpanded = !node.isExpanded + } + + /** + * Find a node by ID + */ + findNodeById(root: TreeNode, id: string): TreeNode | null { + if (root.id === id) return root + for (const child of root.children) { + const found = this.findNodeById(child, id) + if (found) return found + } + return null + } + + /** + * Filter tree by search query + * Returns array of nodes that match (including ancestors to maintain hierarchy) + */ + filterBySearch(root: TreeNode, query: string): Set { + const matchingIds = new Set() + const normalizedQuery = query.toLowerCase().trim() + + if (!normalizedQuery) return matchingIds + + const searchNode = (node: TreeNode, ancestorIds: string[]): boolean => { + const nameMatches = node.name.toLowerCase().includes(normalizedQuery) + let hasMatchingDescendant = false + + for (const child of node.children) { + if (searchNode(child, [...ancestorIds, node.id])) { + hasMatchingDescendant = true + } + } + + if (nameMatches || hasMatchingDescendant) { + // Add this node and all ancestors + matchingIds.add(node.id) + ancestorIds.forEach(id => matchingIds.add(id)) + return true + } + + return false + } + + searchNode(root, []) + return matchingIds + } + + /** + * Flatten tree for virtual scrolling + */ + flattenTree(root: TreeNode, expandedIds: Set): FlatTreeNode[] { + const result: FlatTreeNode[] = [] + + const flatten = (node: TreeNode) => { + result.push({ + id: node.id, + name: node.name, + depth: node.depth, + visible: node.visible, + originalOpacity: node.originalOpacity, + isExpanded: node.isExpanded, + hasChildren: node.children.length > 0, + childCount: node.childCount, + object: node.object, + }) + + if (node.isExpanded && expandedIds.has(node.id)) { + node.children.forEach(child => flatten(child)) + } + } + + flatten(root) + return result + } + + /** + * Highlight a part in the 3D view + */ + highlightPart(object: THREE.Object3D | null, renderer: THREE.WebGLRenderer | null): void { + // Restore previous highlighted object + if (this.highlightedObject) { + this.restoreOriginalMaterial(this.highlightedObject) + this.highlightedObject = null + } + + if (!object || !renderer) return + + // Store and apply highlight + this.highlightedObject = object + this.applyHighlightMaterial(object) + } + + /** + * Check if an object is a mesh + */ + private isMesh(obj: THREE.Object3D): obj is THREE.Mesh { + return obj.type === 'Mesh' || (obj as unknown as { isMesh?: boolean }).isMesh === true + } + + /** + * Apply highlight effect to an object + */ + private applyHighlightMaterial(object: THREE.Object3D): void { + object.traverse((child) => { + if (this.isMesh(child) && child.material) { + // Store original material + this.originalMaterials.set(child.uuid, child.material) + + // Clone and modify material for highlight + const materials = Array.isArray(child.material) ? child.material : [child.material] + const highlightedMaterials = materials.map(mat => { + const cloned = mat.clone() + // Copy clipping planes from original material (clone() doesn't copy these) + if (mat.clippingPlanes && mat.clippingPlanes.length > 0) { + cloned.clippingPlanes = mat.clippingPlanes + cloned.clipShadows = mat.clipShadows + } + // Check if material has emissive property (MeshStandardMaterial, MeshPhongMaterial, etc.) + const emissiveMat = cloned as THREE.MeshStandardMaterial + if (emissiveMat.emissive && typeof emissiveMat.emissive.setHex === 'function') { + emissiveMat.emissive.setHex(0x3b82f6) // Blue highlight + if (emissiveMat.emissiveIntensity !== undefined) { + emissiveMat.emissiveIntensity = 0.3 + } + } + return cloned + }) + + child.material = Array.isArray(child.material) + ? highlightedMaterials + : highlightedMaterials[0] + } + }) + } + + /** + * Restore original material of an object + */ + private restoreOriginalMaterial(object: THREE.Object3D): void { + object.traverse((child) => { + if (this.isMesh(child)) { + const original = this.originalMaterials.get(child.uuid) + if (original) { + // Dispose cloned highlight materials + const current = Array.isArray(child.material) ? child.material : [child.material] + current.forEach(mat => mat.dispose()) + + child.material = original + this.originalMaterials.delete(child.uuid) + } + } + }) + } + + /** + * Clear highlight + */ + clearHighlight(): void { + if (this.highlightedObject) { + this.restoreOriginalMaterial(this.highlightedObject) + this.highlightedObject = null + } + } + + /** + * Select a part with transparent green overlay + * Used for click-to-select interaction + * Does not modify original material - creates overlay mesh instead + */ + selectPart(object: THREE.Object3D | null): void { + // Clear previous selection first + this.clearSelection() + + if (!object || !this.scene) return + + this.selectedObject = object + + // Get active clipping planes from clipping service + const clippingPlanes = getClippingService().getActiveClippingPlanes() + + // Create transparent green overlay meshes (does not modify original material) + object.traverse((child) => { + if (this.isMesh(child) && child.geometry) { + // Create transparent green overlay material with clipping planes + const overlayMaterial = new THREE.MeshBasicMaterial({ + color: 0x00ff00, + transparent: true, + opacity: 0.5, + depthWrite: false, + side: THREE.DoubleSide, + polygonOffset: true, + polygonOffsetFactor: -1, + polygonOffsetUnits: -1, + }) + // Apply clipping planes if any are active + if (clippingPlanes.length > 0) { + overlayMaterial.clippingPlanes = clippingPlanes + } + + // Create overlay mesh using same geometry + const overlayMesh = new THREE.Mesh(child.geometry, overlayMaterial) + overlayMesh.name = '__selection_overlay' + + // Copy world transform + overlayMesh.matrixAutoUpdate = false + overlayMesh.matrix.copy(child.matrixWorld) + // Also set matrixWorld since renderer uses it and matrixAutoUpdate is false + overlayMesh.matrixWorld.copy(child.matrixWorld) + + // Add to scene and track + this.scene!.add(overlayMesh) + this.selectionOverlays.set(child.uuid, overlayMesh) + } + }) + } + + /** + * Clear current selection + */ + clearSelection(): void { + // Remove all overlay meshes + this.selectionOverlays.forEach((overlay) => { + // Dispose material only (geometry is shared with original mesh) + if (overlay.material) { + const materials = Array.isArray(overlay.material) ? overlay.material : [overlay.material] + materials.forEach(mat => mat.dispose()) + } + this.scene?.remove(overlay) + }) + this.selectionOverlays.clear() + this.selectedObject = null + } + + /** + * Get the currently selected object + */ + getSelectedObject(): THREE.Object3D | null { + return this.selectedObject + } + + /** + * Sync selection overlay positions with their source meshes + * Call this after transformations like explosion effect + */ + syncSelectionOverlays(): void { + if (!this.scene || this.selectionOverlays.size === 0) return + + this.selectionOverlays.forEach((overlay, meshUuid) => { + // Find original mesh by uuid using getObjectByProperty + const sourceMesh = this.scene!.getObjectByProperty('uuid', meshUuid) + + if (sourceMesh) { + sourceMesh.updateMatrixWorld(true) + overlay.matrix.copy(sourceMesh.matrixWorld) + // Also update matrixWorld since renderer uses matrixWorld for positioning + // and matrixAutoUpdate is false so Three.js won't compute it automatically + overlay.matrixWorld.copy(sourceMesh.matrixWorld) + } + }) + } + + /** + * Update clipping planes on all selection overlays + * Call this when cross-section settings change + */ + updateSelectionClipping(): void { + if (this.selectionOverlays.size === 0) return + + const clippingPlanes = getClippingService().getActiveClippingPlanes() + const planesValue = clippingPlanes.length > 0 ? clippingPlanes : [] + + this.selectionOverlays.forEach((overlay) => { + const material = overlay.material as THREE.MeshBasicMaterial + material.clippingPlanes = planesValue + material.needsUpdate = true + }) + } + + /** + * Find a TreeNode that contains the mesh with the given UUID + * Searches the parts tree to find the deepest node containing the clicked mesh + */ + findNodeByMeshUuid(meshUuid: string, root: TreeNode): TreeNode | null { + // Check if this node's object is the mesh itself + if (root.object.uuid === meshUuid) { + return root + } + + // Check if this node contains the mesh as a descendant + let containsMesh = false + root.object.traverse((child) => { + if (child.uuid === meshUuid) { + containsMesh = true + } + }) + + if (containsMesh) { + // Check children first - find the deepest matching node + for (const child of root.children) { + const found = this.findNodeByMeshUuid(meshUuid, child) + if (found) { + return found + } + } + // If no child contains it, this node is the deepest match + return root + } + + return null + } + + /** + * Apply auto-colors to all parts using CAD color palette + * Now decoupled from material type - only changes color, not material + */ + applyAutoColors(): void { + if (!this.scene) { + console.warn('PartsTreeService: No scene available for auto-coloring') + return + } + + // Store originals if not already stored + if (this.trulyOriginalMaterials.size === 0) { + this.storeOriginalMaterials() + } + + this.autoColorEnabled = true + this.refreshAllMaterials() + } + + /** + * Reset all parts to their original colors + * Now decoupled from material type - only resets color, keeps material type + */ + resetToOriginalColors(): void { + this.autoColorEnabled = false + this.autoColorPalette.clear() + this.refreshAllMaterials() + } + + /** + * Set transparency for a specific part + * @param node The tree node to make transparent + * @param opacity Opacity value (0 = fully transparent, 1 = fully opaque) + */ + setPartTransparency(node: TreeNode, opacity: number): void { + node.object.traverse((child) => { + if (this.isMesh(child)) { + const mesh = child as THREE.Mesh + if (Array.isArray(mesh.material)) { + mesh.material.forEach((mat) => { + if (mat) { + mat.transparent = opacity < 1 + mat.opacity = opacity + mat.needsUpdate = true + } + }) + } else if (mesh.material) { + mesh.material.transparent = opacity < 1 + mesh.material.opacity = opacity + mesh.material.needsUpdate = true + } + } + }) + } + + /** + * Reset transparency for a specific part to its original value + * @param node The tree node to reset + */ + resetPartTransparency(node: TreeNode): void { + this.setPartTransparency(node, node.originalOpacity) + } + + /** + * Reset all parts to their original opacity + * @param rootNode The root tree node + */ + resetAllOpacity(rootNode: TreeNode): void { + const resetNode = (node: TreeNode) => { + this.setPartTransparency(node, node.originalOpacity) + node.children.forEach(resetNode) + } + resetNode(rootNode) + } + + /** + * Check if auto-color is currently enabled + */ + isAutoColorEnabled(): boolean { + return this.autoColorEnabled + } + + /** + * Clear color maps without accessing meshes (safe for model switching) + * Note: autoColorEnabled is NOT reset - it's a global setting that persists across models + */ + clearColorMaps(): void { + this.autoColorPalette.clear() + this.trulyOriginalMaterials.clear() + this.scene = null // Prevent stale material operations during model switch + console.log('PartsTreeService: Color maps cleared') + } + + /** + * Set color for a specific part by UUID + * Handles both Mesh and Group nodes - applies color to all child meshes + */ + setPartColor(uuid: string, color: number): void { + if (!this.scene) return + + // Find object by UUID (could be Mesh or Group) + const targetObject = this.findObjectByUuid(uuid) + if (!targetObject) { + console.warn(`PartsTreeService: Object not found for UUID ${uuid}`) + return + } + + // Apply color to the object and all its mesh children + targetObject.traverse((child: THREE.Object3D) => { + if (this.isMesh(child)) { + const mesh = child as THREE.Mesh + // Store original material if not already stored + if (!this.trulyOriginalMaterials.has(mesh.uuid)) { + this.trulyOriginalMaterials.set(mesh.uuid, mesh.material) + } + + // Register color override so refreshAllMaterials respects it + this.partMaterialOverrides.set(mesh.uuid, { + type: this.globalMaterialType, + color: color, + }) + + // Create and apply material with the new color + const newMaterial = this.createMaterialByType(this.globalMaterialType, color) + + // Apply material directly - overlay selection doesn't modify original material + this.applyMaterialWithClipping(mesh, newMaterial) + } + }) + } + + /** + * Find an object by UUID (any type, not just Mesh) + */ + private findObjectByUuid(uuid: string): THREE.Object3D | null { + if (!this.scene) return null + + let found: THREE.Object3D | null = null + this.scene.traverse((object: THREE.Object3D) => { + if (object.uuid === uuid) { + found = object + } + }) + return found + } + + /** + * Find a mesh by UUID + */ + private findMeshByUuid(uuid: string): THREE.Mesh | null { + if (!this.scene) return null + + let foundMesh: THREE.Mesh | null = null + this.scene.traverse((object) => { + if (object.uuid === uuid && this.isMesh(object)) { + foundMesh = object + } + }) + return foundMesh + } + + + /** + * Get the CAD color palette + */ + getColorPalette(): number[] { + return [...this.cadColorPalette] + } + + // ==================== Material System ==================== + + /** + * Create a clay material - non-metallic, matte finish for structure visualization + */ + private createClayMaterial(color: number): THREE.MeshStandardMaterial { + return new THREE.MeshStandardMaterial({ + color: color, + metalness: 0.0, + roughness: 0.6, + }) + } + + /** + * Create a metal material - high metalness, low roughness for shiny metal look + */ + private createMetalMaterial(color: number): THREE.MeshStandardMaterial { + return new THREE.MeshStandardMaterial({ + color: color, + metalness: 0.8, + roughness: 0.25, + }) + } + + /** + * Create a paint material - industrial matte powder coat finish + */ + private createPaintMaterial(color: number): THREE.MeshStandardMaterial { + return new THREE.MeshStandardMaterial({ + color: color, + metalness: 0.0, + roughness: 0.5, + }) + } + + /** + * Create material based on type and configuration + */ + private createMaterialByType(type: MaterialType, color: number, _config?: MaterialConfig): THREE.Material { + switch (type) { + case MaterialType.Metal: + return this.createMetalMaterial(color) + case MaterialType.Paint: + return this.createPaintMaterial(color) + case MaterialType.Clay: + default: + return this.createClayMaterial(color) + } + } + + /** + * Unified material refresh method + * Applies transformations in order: original color → auto-color (if enabled) → material type + * This ensures auto-color and material type are fully decoupled + */ + private refreshAllMaterials(): void { + if (!this.scene) return + + let colorIndex = 0 + // Traverse entire scene to find all meshes (consistent with storeOriginalMaterials) + this.scene.traverse((object) => { + // Skip system objects + if (object.name.startsWith('__')) return + + if (this.isMesh(object)) { + const mesh = object + + // Check for per-part override - skip if has override + const override = this.partMaterialOverrides.get(mesh.uuid) + if (override) return + + // Get truly original material + const original = this.trulyOriginalMaterials.get(mesh.uuid) as THREE.MeshStandardMaterial + if (!original) return + + // Step 1: Determine color (auto-color or original) + let color: number + if (this.autoColorEnabled) { + // Assign palette index if not already assigned + if (!this.autoColorPalette.has(mesh.uuid)) { + this.autoColorPalette.set(mesh.uuid, colorIndex++) + } + const paletteIndex = this.autoColorPalette.get(mesh.uuid)! + color = this.cadColorPalette[paletteIndex % this.cadColorPalette.length] + } else { + // Use original color + color = original.color?.getHex() ?? 0xcccccc + } + + // Step 2: Create material based on current material type + const newMaterial = this.createMaterialByType(this.globalMaterialType, color) + + // Step 3: Apply material (preserving clipping planes) + this.applyMaterialWithClipping(mesh, newMaterial) + } + }) + + console.log(`PartsTreeService: Refreshed materials (autoColor=${this.autoColorEnabled}, type=${this.globalMaterialType})`) + } + + /** + * Get the current global material type + */ + getGlobalMaterialType(): MaterialType { + return this.globalMaterialType + } + + /** + * Set the global material type and apply to all meshes + * Decoupled from auto-color - respects current auto-color state + */ + setGlobalMaterial(type: MaterialType): void { + this.globalMaterialType = type + + if (!this.scene) return + + // Store originals if not already stored + if (this.trulyOriginalMaterials.size === 0) { + this.storeOriginalMaterials() + } + + this.refreshAllMaterials() + } + + /** + * Set material for a specific part (override global setting) + */ + setPartMaterial(uuid: string, config: MaterialConfig): void { + if (!this.scene) return + + const mesh = this.findMeshByUuid(uuid) + if (!mesh) { + console.warn(`PartsTreeService: Mesh not found for UUID ${uuid}`) + return + } + + // Store original if not already stored + if (!this.trulyOriginalMaterials.has(uuid)) { + this.trulyOriginalMaterials.set(uuid, mesh.material) + } + + // Store override config + this.partMaterialOverrides.set(uuid, config) + + // Get color: use config color, or original color, or fallback + let color = config.color + if (!color) { + const originalMat = this.trulyOriginalMaterials.get(uuid) as THREE.MeshStandardMaterial + color = originalMat?.color?.getHex() ?? 0xcccccc + } + + // Create and apply material + const newMaterial = this.createMaterialByType(config.type, color, config) + this.applyMaterialWithClipping(mesh, newMaterial) + } + + /** + * Reset a part to use global material + */ + resetPartMaterial(uuid: string): void { + this.partMaterialOverrides.delete(uuid) + + if (!this.scene) return + + const mesh = this.findMeshByUuid(uuid) + if (!mesh) return + + // Get current color + const currentMat = mesh.material as THREE.MeshStandardMaterial + const color = currentMat.color?.getHex() ?? 0xcccccc + + // Apply global material type + const newMaterial = this.createMaterialByType(this.globalMaterialType, color) + this.applyMaterialWithClipping(mesh, newMaterial) + } + + /** + * Get material override for a part + */ + getPartMaterialOverride(uuid: string): MaterialConfig | undefined { + return this.partMaterialOverrides.get(uuid) + } + + /** + * Apply material to mesh while preserving clipping planes + */ + private applyMaterialWithClipping(mesh: THREE.Mesh, material: THREE.Material): void { + // Get existing clipping planes if any + const currentMat = mesh.material as THREE.Material + const clippingPlanes = currentMat.clippingPlanes + + // Apply clipping planes to new material if they exist + if (clippingPlanes && clippingPlanes.length > 0) { + material.clippingPlanes = clippingPlanes + material.clipShadows = true + } + + // Dispose old material if it's not the original + const old = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + old.forEach(m => { + const original = this.trulyOriginalMaterials.get(mesh.uuid) + if (m !== original && (!Array.isArray(original) || !original.includes(m))) { + m.dispose() + } + }) + + mesh.material = material + } + + /** + * Reset service state + */ + reset(): void { + this.clearHighlight() + this.clearSelection() + // Reset auto-color state + this.autoColorEnabled = false + this.autoColorPalette.clear() + this.trulyOriginalMaterials.clear() + // Reset highlight materials + this.originalMaterials.clear() + // Reset material overrides + this.partMaterialOverrides.clear() + this.globalMaterialType = MaterialType.Clay + this.scene = null + } +} + +// Singleton instance +let partsTreeService: PartsTreeService | null = null + +export function getPartsTreeService(): PartsTreeService { + if (!partsTreeService) { + partsTreeService = new PartsTreeService() + } + return partsTreeService +} + +export function resetPartsTreeService(): void { + if (partsTreeService) { + partsTreeService.reset() + } + partsTreeService = null +} diff --git a/frontend/src/services/renderService.ts b/frontend/src/services/renderService.ts new file mode 100644 index 0000000..5b5b084 --- /dev/null +++ b/frontend/src/services/renderService.ts @@ -0,0 +1,823 @@ +import * as THREE from 'three' +import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2.js' +import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js' +import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js' +import { getClippingService } from './clippingService' + +// Render mode enum +export enum RenderMode { + Standard = 'standard', // Standard solid rendering (default) + HiddenLine = 'hiddenLine', // Hidden line removal mode + Wireframe = 'wireframe', // Wireframe mode (all edges visible) +} + +export class RenderService { + private scene: THREE.Scene | null = null + private renderer: THREE.WebGLRenderer | null = null + private edgesGroup: THREE.Group | null = null + private edgesEnabled: boolean = false + private edgeColor: THREE.Color = new THREE.Color(0x000000) + private edgeOpacity: number = 0.8 + private edgeLineWidth: number = 1 + private initialized: boolean = false + private lightsGroup: THREE.Group | null = null + private lightsInitialized: boolean = false + private cameraLight: THREE.DirectionalLight | null = null + private ambientLight: THREE.AmbientLight | null = null + private keyLight: THREE.DirectionalLight | null = null + + // Render mode properties + private renderMode: RenderMode = RenderMode.Standard + private renderModeMaterials: Map = new Map() + private hiddenMeshes: Set = new Set() + + /** + * Initialize with Three.js scene and renderer + */ + initialize(scene: THREE.Scene, renderer?: THREE.WebGLRenderer | null): void { + this.scene = scene + this.renderer = renderer || null + this.initialized = true + + // Setup lighting on initialization + this.setupLighting() + + // Setup environment - skipped due to PMREMGenerator conflicts + this.setupEnvironment() + + // Configure renderer for transparent materials + this.configureRendererForTransparency() + + console.log('RenderService: Initialized') + } + + /** + * Configure renderer settings for transparent/glass materials + */ + private configureRendererForTransparency(): void { + if (!this.renderer) return + + // Enable proper transparency sorting + this.renderer.sortObjects = true + + // Enable local clipping for cross-section features + this.renderer.localClippingEnabled = true + + // Enable tone mapping for exposure control + this.renderer.toneMapping = THREE.ACESFilmicToneMapping + this.renderer.toneMappingExposure = 1.0 + + console.log('RenderService: Renderer configured for transparency and tone mapping') + } + + /** + * Setup environment map for metallic reflections + * NOTE: PMREMGenerator conflicts with online-3d-viewer's THREE.js instance, + * causing "Cannot read properties of undefined (reading 'length')" errors + * in updateRenderTargetMipmap. Skip environment map - metalness/roughness + * still work with the scene lighting. + */ + private setupEnvironment(): void { + // PMREMGenerator causes render target conflicts with online-3d-viewer. + // Skip environment map generation - metalness/roughness will still work + // with the custom lighting setup. + console.log('RenderService: Environment map skipped (compatibility mode)') + } + + /** + * Setup lighting for the scene + */ + setupLighting(): void { + if (!this.scene || this.lightsInitialized) return + + // Create lights group + this.lightsGroup = new THREE.Group() + this.lightsGroup.name = '__custom_lights__' + + // Ambient light - overall illumination + this.ambientLight = new THREE.AmbientLight(0xffffff, 0.6) + this.ambientLight.name = '__ambient_light__' + this.lightsGroup.add(this.ambientLight) + + // Key light - main directional light from top-right + this.keyLight = new THREE.DirectionalLight(0xffffff, 0.8) + this.keyLight.name = '__key_light__' + this.keyLight.position.set(5, 10, 7) + this.lightsGroup.add(this.keyLight) + + // Fill light - softer light from opposite side + const fillLight = new THREE.DirectionalLight(0xffffff, 0.3) + fillLight.name = '__fill_light__' + fillLight.position.set(-5, 3, -5) + this.lightsGroup.add(fillLight) + + // Back light - rim lighting effect + const backLight = new THREE.DirectionalLight(0xffffff, 0.2) + backLight.name = '__back_light__' + backLight.position.set(0, 5, -10) + this.lightsGroup.add(backLight) + + // Camera light - follows camera direction for front-facing illumination + const cameraLight = new THREE.DirectionalLight(0xffffff, 0.5) + cameraLight.name = '__camera_light__' + this.cameraLight = cameraLight + this.lightsGroup.add(cameraLight) + // DirectionalLight target needs to be in scene for proper direction + this.scene.add(cameraLight.target) + + this.scene.add(this.lightsGroup) + this.lightsInitialized = true + + console.log('RenderService: Lighting setup complete') + } + + /** + * Update camera light to follow camera position and direction + * Call this in the render loop + */ + updateCameraLight(camera: THREE.Camera): void { + if (!this.cameraLight) return + + // Position light at camera position + this.cameraLight.position.copy(camera.position) + + // Make light point in camera's look direction + const target = new THREE.Vector3() + camera.getWorldDirection(target) + target.add(camera.position) + this.cameraLight.target.position.copy(target) + this.cameraLight.target.updateMatrixWorld() + } + + /** + * Enable or disable edge lines + * Uses visibility toggle for performance (avoids recreating geometry on each toggle) + */ + setEdgesEnabled(enabled: boolean): void { + if (!this.initialized || !this.scene) return + + this.edgesEnabled = enabled + + if (enabled) { + // If edges already exist, just show them + if (this.edgesGroup) { + this.edgesGroup.visible = true + } else { + // First time - create edges + this.createEdges() + } + } else { + // Hide edges instead of destroying them (much faster toggle) + if (this.edgesGroup) { + this.edgesGroup.visible = false + } + } + } + + /** + * Invalidate cached edges (call when model changes) + * This forces edge recreation on next enable + */ + invalidateEdges(): void { + this.removeEdges() + } + + /** + * Create edge lines for all meshes using LineSegments2 for variable line width support + */ + private createEdges(): void { + if (!this.scene || !this.renderer) return + + // Remove existing edges first + this.removeEdges() + + // Create edges group + this.edgesGroup = new THREE.Group() + this.edgesGroup.name = '__edge_lines__' + + // Get renderer size for LineMaterial resolution + const size = new THREE.Vector2() + this.renderer.getSize(size) + + // Traverse scene and create edges for each mesh + this.scene.traverse((object) => { + const isMesh = object.type === 'Mesh' || (object as unknown as { isMesh?: boolean }).isMesh === true + if (isMesh) { + const mesh = object as THREE.Mesh + if (!mesh.geometry) return + + // Skip hidden meshes + if (!mesh.visible) return + + // Skip our own edge lines + if (mesh.name.startsWith('__')) return + + try { + // Create edges geometry with angle threshold for CAD-style lines + const edgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 30) + const positions = edgesGeometry.attributes.position.array as Float32Array + + // Convert EdgesGeometry positions to LineSegmentsGeometry format + const lineGeometry = new LineSegmentsGeometry() + + // LineSegmentsGeometry expects pairs of points for each line segment + // EdgesGeometry provides pairs: [p0, p1, p2, p3, ...] where (p0,p1), (p2,p3) are separate segments + lineGeometry.setPositions(positions) + + // Get clipping planes from clipping service + const clippingPlanes = getClippingService().getActiveClippingPlanes() + + // Create LineMaterial with adjustable width + const lineMaterial = new LineMaterial({ + color: this.edgeColor.getHex(), + linewidth: this.edgeLineWidth, + transparent: true, + opacity: this.edgeOpacity, + resolution: size, + }) + // Set clipping planes (inherited from Material base class) + if (clippingPlanes.length > 0) { + lineMaterial.clippingPlanes = clippingPlanes + } + + // Create LineSegments2 with proper width support + const edges = new LineSegments2(lineGeometry, lineMaterial) + edges.name = `__edge_${mesh.uuid}__` + edges.computeLineDistances() + + // Store source mesh reference for fast transform sync (avoids O(n) scene search) + edges.userData.sourceMesh = mesh + + // Copy world transform from mesh + mesh.updateMatrixWorld(true) + edges.matrix.copy(mesh.matrixWorld) + edges.matrixAutoUpdate = false + + this.edgesGroup!.add(edges) + } catch (e) { + // Skip meshes with invalid geometry + console.warn('RenderService: Could not create edges for mesh', mesh.name) + } + } + }) + + this.scene.add(this.edgesGroup) + console.log(`RenderService: Created edges for ${this.edgesGroup.children.length} meshes`) + } + + /** + * Remove edge lines + */ + private removeEdges(): void { + if (this.edgesGroup && this.scene) { + // Dispose geometries and materials + this.edgesGroup.traverse((object) => { + // Handle LineSegments2 objects + if (object instanceof LineSegments2) { + object.geometry.dispose() + if (object.material instanceof LineMaterial) { + object.material.dispose() + } + } + // Handle legacy LineSegments for backwards compatibility + if (object instanceof THREE.LineSegments) { + object.geometry.dispose() + if (object.material instanceof THREE.Material) { + object.material.dispose() + } + } + }) + + this.scene.remove(this.edgesGroup) + this.edgesGroup = null + } + } + + /** + * Update edges when model changes (e.g., explosion) + */ + updateEdges(): void { + if (this.edgesEnabled) { + this.createEdges() + } + } + + /** + * Update clipping planes on all edge line materials + * Called when cross-section planes change + */ + updateEdgeClipping(): void { + if (!this.edgesGroup) return + + const clippingPlanes = getClippingService().getActiveClippingPlanes() + + this.edgesGroup.traverse((object) => { + if (object instanceof LineSegments2) { + const material = object.material as LineMaterial + material.clippingPlanes = clippingPlanes.length > 0 ? clippingPlanes : null + material.needsUpdate = true + } + }) + } + + /** + * Sync edge line transforms with their corresponding meshes + * More efficient than updateEdges() - only updates transforms, not geometry + * Optimized: Uses stored mesh reference (O(1)) instead of scene search (O(n)) + */ + syncEdgeTransforms(): void { + if (!this.edgesGroup || !this.scene || !this.edgesEnabled) return + + this.edgesGroup.traverse((edge) => { + // Handle LineSegments2 objects + if ((edge instanceof LineSegments2 || edge instanceof THREE.LineSegments) && edge.name.startsWith('__edge_')) { + // Use stored mesh reference for O(1) lookup instead of O(n) scene search + const mesh = edge.userData.sourceMesh as THREE.Object3D + if (mesh) { + mesh.updateMatrixWorld(true) + edge.matrix.copy(mesh.matrixWorld) + } + } + }) + } + + /** + * Sync edge line visibility for specific mesh UUIDs + */ + syncEdgeVisibility(meshUuids: string[], visible: boolean): void { + if (!this.edgesGroup) return + + for (const uuid of meshUuids) { + const edgeName = `__edge_${uuid}__` + const edge = this.edgesGroup.getObjectByName(edgeName) + if (edge) { + edge.visible = visible + } + } + } + + /** + * Sync all edge line visibilities with their mesh opacity + */ + syncAllEdgeVisibility(): void { + if (!this.edgesGroup || !this.scene) return + + this.edgesGroup.traverse((edge) => { + if ((edge instanceof LineSegments2 || edge instanceof THREE.LineSegments) && edge.name.startsWith('__edge_')) { + const uuid = edge.name.slice(7, -2) + const mesh = this.scene!.getObjectByProperty('uuid', uuid) + if (mesh) { + // Check mesh material opacity to determine visibility + const meshObj = mesh as THREE.Mesh + const material = meshObj.material as THREE.MeshStandardMaterial + edge.visible = material && material.opacity > 0 + } + } + }) + } + + /** + * Create a single edge line for a mesh with configurable options + */ + private createSingleEdgeLine( + mesh: THREE.Mesh, + options: { + color: number + opacity: number + depthTest: boolean + renderOrder: number + nameSuffix?: string + } + ): LineSegments2 | null { + if (!mesh.geometry || !this.renderer) return null + + try { + // Get renderer size for LineMaterial resolution + const size = new THREE.Vector2() + this.renderer.getSize(size) + + // Create edges geometry with angle threshold for CAD-style lines + const edgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 30) + const positions = edgesGeometry.attributes.position.array as Float32Array + + // Convert EdgesGeometry positions to LineSegmentsGeometry format + const lineGeometry = new LineSegmentsGeometry() + lineGeometry.setPositions(positions) + + // Get clipping planes from clipping service + const clippingPlanes = getClippingService().getActiveClippingPlanes() + + // Create LineMaterial with configurable options + const lineMaterial = new LineMaterial({ + color: options.color, + linewidth: this.edgeLineWidth, + transparent: true, + opacity: options.opacity, + resolution: size, + depthTest: options.depthTest, + }) + // Set clipping planes (inherited from Material base class) + if (clippingPlanes.length > 0) { + lineMaterial.clippingPlanes = clippingPlanes + } + + // Create LineSegments2 + const edges = new LineSegments2(lineGeometry, lineMaterial) + edges.name = `__edge_${mesh.uuid}${options.nameSuffix || ''}__` + edges.renderOrder = options.renderOrder + edges.computeLineDistances() + + // Store source mesh reference for fast transform sync (avoids O(n) scene search) + edges.userData.sourceMesh = mesh + + // Copy world transform from mesh + mesh.updateMatrixWorld(true) + edges.matrix.copy(mesh.matrixWorld) + edges.matrixAutoUpdate = false + + return edges + } catch (e) { + console.warn('RenderService: Could not create edge line for mesh', mesh.name) + return null + } + } + + /** + * Set edge color + */ + setEdgeColor(color: number): void { + this.edgeColor.setHex(color) + if (this.edgesEnabled && this.edgesGroup) { + this.edgesGroup.traverse((object) => { + // Handle LineSegments2 objects + if (object instanceof LineSegments2) { + const material = object.material as LineMaterial + material.color.setHex(color) + } + // Handle legacy LineSegments + if (object instanceof THREE.LineSegments) { + const material = object.material as THREE.LineBasicMaterial + material.color.copy(this.edgeColor) + } + }) + } + } + + /** + * Set edge line width + */ + setEdgeLineWidth(width: number): void { + this.edgeLineWidth = Math.max(0.5, Math.min(5, width)) + + if (this.edgesGroup) { + this.edgesGroup.traverse((object) => { + if (object instanceof LineSegments2) { + const material = object.material as LineMaterial + material.linewidth = this.edgeLineWidth + material.needsUpdate = true + } + }) + } + } + + /** + * Get current edge line width + */ + getEdgeLineWidth(): number { + return this.edgeLineWidth + } + + /** + * Check if edges are enabled + */ + isEdgesEnabled(): boolean { + return this.edgesEnabled + } + + // ==================== Lighting Control Methods ==================== + + /** + * Set tone mapping exposure (overall scene brightness) + * @param value Exposure value (0.1 - 3.0, default 1.0) + */ + setToneMappingExposure(value: number): void { + if (!this.renderer) return + this.renderer.toneMappingExposure = Math.max(0.1, Math.min(3.0, value)) + } + + /** + * Get current tone mapping exposure + */ + getToneMappingExposure(): number { + return this.renderer?.toneMappingExposure ?? 1.0 + } + + /** + * Set main (key) light intensity + * @param intensity Light intensity (0 - 2.0, default 0.8) + */ + setMainLightIntensity(intensity: number): void { + if (this.keyLight) { + this.keyLight.intensity = Math.max(0, Math.min(2.0, intensity)) + } + } + + /** + * Get current main light intensity + */ + getMainLightIntensity(): number { + return this.keyLight?.intensity ?? 0.8 + } + + /** + * Set ambient light intensity + * @param intensity Light intensity (0 - 2.0, default 0.6) + */ + setAmbientLightIntensity(intensity: number): void { + if (this.ambientLight) { + this.ambientLight.intensity = Math.max(0, Math.min(2.0, intensity)) + } + } + + /** + * Get current ambient light intensity + */ + getAmbientLightIntensity(): number { + return this.ambientLight?.intensity ?? 0.6 + } + + // ==================== Render Mode Methods ==================== + + /** + * Get current render mode + */ + getRenderMode(): RenderMode { + return this.renderMode + } + + /** + * Set render mode + */ + setRenderMode(mode: RenderMode): void { + if (!this.initialized || !this.scene) return + if (this.renderMode === mode) return + + // First restore to standard mode + this.restoreStandardMode() + + // Then apply new mode + this.renderMode = mode + + switch (mode) { + case RenderMode.HiddenLine: + this.applyHiddenLineMode() + break + case RenderMode.Wireframe: + this.applyWireframeMode() + break + case RenderMode.Standard: + default: + // Already restored, nothing more to do + break + } + + console.log(`RenderService: Render mode set to ${mode}`) + } + + /** + * Find the model root in the scene (skip lights, helpers, etc.) + */ + private findModelRoot(): THREE.Object3D | null { + if (!this.scene) return null + + for (const child of this.scene.children) { + // Skip lights + if (child.type.includes('Light') || (child as unknown as { isLight?: boolean }).isLight) continue + // Skip cameras + if (child.type.includes('Camera') || (child as unknown as { isCamera?: boolean }).isCamera) continue + // Skip helpers + if (child.type.includes('Helper')) continue + // Skip our custom objects + if (child.name.startsWith('__')) continue + + return child + } + return null + } + + /** + * Check if object is a mesh + */ + private isMesh(obj: THREE.Object3D): obj is THREE.Mesh { + return obj.type === 'Mesh' || (obj as unknown as { isMesh?: boolean }).isMesh === true + } + + /** + * Apply Hidden Line mode - white materials with two-layer edge lines (hidden + visible) + */ + private applyHiddenLineMode(): void { + const modelRoot = this.findModelRoot() + if (!modelRoot) return + + // Apply white materials to all meshes + modelRoot.traverse((object) => { + if (this.isMesh(object) && object.material) { + const mesh = object + + // Store original material + if (!this.renderModeMaterials.has(mesh.uuid)) { + this.renderModeMaterials.set(mesh.uuid, mesh.material) + } + + // Create white material with polygon offset to avoid z-fighting with edges + const whiteMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + polygonOffset: true, + polygonOffsetFactor: 1, + polygonOffsetUnits: 1, + }) + + mesh.material = whiteMaterial + } + }) + + // Create two-layer edge lines for hidden line mode + this.createHiddenLineEdges() + } + + /** + * Create two-layer edge lines for Hidden Line mode: + * 1. Hidden edges (no depth test, light color) - rendered first + * 2. Visible edges (with depth test, normal color) - rendered second + */ + private createHiddenLineEdges(): void { + if (!this.scene) return + + // Remove existing edges first + this.removeEdges() + + // Create edges group + this.edgesGroup = new THREE.Group() + this.edgesGroup.name = '__edge_lines__' + + const modelRoot = this.findModelRoot() + if (!modelRoot) return + + // Traverse scene and create two-layer edges for each mesh + modelRoot.traverse((object) => { + if (this.isMesh(object)) { + const mesh = object + if (!mesh.geometry) return + if (!mesh.visible) return + if (mesh.name.startsWith('__')) return + + // Layer 1: Hidden edges (light gray, no depth test, rendered first) + const hiddenEdges = this.createSingleEdgeLine(mesh, { + color: 0xaaaaaa, // Light gray + opacity: 0.35, // Lower opacity + depthTest: false, // No depth test - always visible + renderOrder: 0, // Rendered first + nameSuffix: '_hidden', + }) + + // Layer 2: Visible edges (black, with depth test, rendered second) + const visibleEdges = this.createSingleEdgeLine(mesh, { + color: 0x000000, // Black + opacity: 0.8, // Normal opacity + depthTest: true, // With depth test - occluded by surfaces + renderOrder: 1, // Rendered second, overlays hidden edges + nameSuffix: '_visible', + }) + + if (hiddenEdges) this.edgesGroup!.add(hiddenEdges) + if (visibleEdges) this.edgesGroup!.add(visibleEdges) + } + }) + + this.scene.add(this.edgesGroup) + this.edgesEnabled = true + console.log(`RenderService: Created hidden line edges for ${this.edgesGroup.children.length / 2} meshes`) + } + + /** + * Apply Wireframe mode - hide meshes, show only edges + */ + private applyWireframeMode(): void { + const modelRoot = this.findModelRoot() + if (!modelRoot) return + + // Hide all meshes but keep them for edge generation + modelRoot.traverse((object) => { + if (this.isMesh(object)) { + const mesh = object + + // Store original visibility state if visible + if (mesh.visible) { + this.hiddenMeshes.add(mesh.uuid) + } + } + }) + + // First create edges while meshes are visible + this.setEdgesEnabled(true) + + // Then hide meshes after edges are created + modelRoot.traverse((object) => { + if (this.isMesh(object)) { + const mesh = object + if (this.hiddenMeshes.has(mesh.uuid)) { + mesh.visible = false + } + } + }) + } + + /** + * Restore standard rendering mode + */ + private restoreStandardMode(): void { + const modelRoot = this.findModelRoot() + if (!modelRoot) return + + // Restore original materials + modelRoot.traverse((object) => { + if (this.isMesh(object)) { + const mesh = object + + // Restore original material + const originalMaterial = this.renderModeMaterials.get(mesh.uuid) + if (originalMaterial) { + // Dispose the temporary material + if (mesh.material !== originalMaterial) { + const currentMat = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + currentMat.forEach(m => m.dispose()) + } + mesh.material = originalMaterial + this.renderModeMaterials.delete(mesh.uuid) + } + + // Restore visibility + if (this.hiddenMeshes.has(mesh.uuid)) { + mesh.visible = true + this.hiddenMeshes.delete(mesh.uuid) + } + } + }) + + // Clear tracking sets + this.renderModeMaterials.clear() + this.hiddenMeshes.clear() + + // Disable edges if we were in a special mode + if (this.renderMode !== RenderMode.Standard) { + this.setEdgesEnabled(false) + } + } + + /** + * Reset service + */ + reset(): void { + // Restore standard mode first + if (this.renderMode !== RenderMode.Standard) { + this.restoreStandardMode() + } + this.renderMode = RenderMode.Standard + this.renderModeMaterials.clear() + this.hiddenMeshes.clear() + + this.removeEdges() + this.edgesEnabled = false + + // Remove lights + if (this.lightsGroup && this.scene) { + this.scene.remove(this.lightsGroup) + this.lightsGroup = null + } + this.lightsInitialized = false + } + + /** + * Check if initialized + */ + isInitialized(): boolean { + return this.initialized + } +} + +// Singleton instance +let renderService: RenderService | null = null + +export function getRenderService(): RenderService { + if (!renderService) { + renderService = new RenderService() + } + return renderService +} + +export function resetRenderService(): void { + if (renderService) { + renderService.reset() + } + renderService = null +} diff --git a/frontend/src/services/screenshotService.ts b/frontend/src/services/screenshotService.ts new file mode 100644 index 0000000..7c6dc62 --- /dev/null +++ b/frontend/src/services/screenshotService.ts @@ -0,0 +1,121 @@ +import * as THREE from 'three' + +const API_URL = import.meta.env.VITE_API_URL || '' + +/** + * Capture a full-resolution screenshot from the Three.js renderer + * Preserves the original viewport dimensions + * @param renderer - WebGL renderer + * @param scene - Scene to render + * @param camera - Camera for the view + * @returns PNG blob of the screenshot at original resolution + */ +export async function captureFullScreenshot( + renderer: THREE.WebGLRenderer, + scene: THREE.Scene, + camera: THREE.Camera +): Promise { + // Force render the current scene + renderer.render(scene, camera) + + // Get the canvas from the renderer + const canvas = renderer.domElement + + // Convert to blob directly (original size) + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) { + resolve(blob) + } else { + reject(new Error('Failed to create blob from canvas')) + } + }, + 'image/png', + 1.0 + ) + }) +} + +/** + * Capture a screenshot from the Three.js renderer using canvas approach + * This avoids WebGLRenderTarget issues with online-3d-viewer + * @param renderer - WebGL renderer + * @param scene - Scene to render + * @param camera - Camera for the view + * @param size - Output image size (default 512x512) + * @returns PNG blob of the screenshot + */ +export async function captureViewerScreenshot( + renderer: THREE.WebGLRenderer, + scene: THREE.Scene, + camera: THREE.Camera, + size: number = 512 +): Promise { + // Force render the current scene + renderer.render(scene, camera) + + // Get the canvas from the renderer + const sourceCanvas = renderer.domElement + + // Create a temporary canvas for the thumbnail + const thumbCanvas = document.createElement('canvas') + thumbCanvas.width = size + thumbCanvas.height = size + const ctx = thumbCanvas.getContext('2d')! + + // Calculate crop area for square thumbnail (center crop) + const srcWidth = sourceCanvas.width + const srcHeight = sourceCanvas.height + const minDim = Math.min(srcWidth, srcHeight) + const srcX = (srcWidth - minDim) / 2 + const srcY = (srcHeight - minDim) / 2 + + // Draw the center-cropped source onto the thumbnail canvas + ctx.drawImage( + sourceCanvas, + srcX, srcY, minDim, minDim, // Source rectangle (center crop) + 0, 0, size, size // Destination rectangle (full thumbnail) + ) + + // Convert to blob + return new Promise((resolve, reject) => { + thumbCanvas.toBlob( + (blob) => { + if (blob) { + resolve(blob) + } else { + reject(new Error('Failed to create blob from canvas')) + } + }, + 'image/png', + 1.0 + ) + }) +} + +/** + * Upload a thumbnail blob to the server + * @param modelId - Model ID + * @param blob - PNG blob + * @returns Updated thumbnail URL + */ +export async function uploadThumbnail( + modelId: string, + blob: Blob +): Promise { + const formData = new FormData() + formData.append('thumbnail', blob, 'thumbnail.png') + + const response = await fetch(`${API_URL}/api/models/${modelId}/thumbnail`, { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + throw new Error(`Failed to upload thumbnail: ${response.statusText}`) + } + + const data = await response.json() + return data.thumbnail_url +} diff --git a/frontend/src/services/viewCubeService.ts b/frontend/src/services/viewCubeService.ts new file mode 100644 index 0000000..0239fc6 --- /dev/null +++ b/frontend/src/services/viewCubeService.ts @@ -0,0 +1,463 @@ +import * as THREE from 'three' + +export type ViewDirection = 'front' | 'back' | 'left' | 'right' | 'top' | 'bottom' + +interface FaceConfig { + name: string + direction: ViewDirection + normal: THREE.Vector3 + color: string + // Three.js BoxGeometry face order: +X, -X, +Y, -Y, +Z, -Z + materialIndex: number +} + +type FaceClickCallback = (direction: ViewDirection) => void +type RotateCallback = (deltaX: number, deltaY: number) => void +type Rotate90Callback = (direction: ViewDirection) => void + +class ViewCubeService { + private scene: THREE.Scene | null = null + private camera: THREE.OrthographicCamera | null = null + private renderer: THREE.WebGLRenderer | null = null + private cube: THREE.Mesh | null = null + private raycaster: THREE.Raycaster = new THREE.Raycaster() + private mouse: THREE.Vector2 = new THREE.Vector2() + private container: HTMLElement | null = null + private onFaceClick: FaceClickCallback | null = null + private onRotate: RotateCallback | null = null + private onRotate90: Rotate90Callback | null = null + private hoveredFaceIndex: number = -1 + private materials: THREE.MeshBasicMaterial[] = [] + private animationFrameId: number | null = null + + // Drag rotation state + private isDragging: boolean = false + private hasMouseDown: boolean = false + private lastMouseX: number = 0 + private lastMouseY: number = 0 + private dragStartX: number = 0 + private dragStartY: number = 0 + + // Face configurations + // BoxGeometry face order: +X (right), -X (left), +Y (top), -Y (bottom), +Z (front), -Z (back) + private faces: FaceConfig[] = [ + { name: '右', direction: 'right', normal: new THREE.Vector3(1, 0, 0), color: '#5cb85c', materialIndex: 0 }, + { name: '左', direction: 'left', normal: new THREE.Vector3(-1, 0, 0), color: '#5cb85c', materialIndex: 1 }, + { name: '上', direction: 'top', normal: new THREE.Vector3(0, 1, 0), color: '#d9534f', materialIndex: 2 }, + { name: '下', direction: 'bottom', normal: new THREE.Vector3(0, -1, 0), color: '#d9534f', materialIndex: 3 }, + { name: '前', direction: 'front', normal: new THREE.Vector3(0, 0, 1), color: '#4a90d9', materialIndex: 4 }, + { name: '后', direction: 'back', normal: new THREE.Vector3(0, 0, -1), color: '#4a90d9', materialIndex: 5 }, + ] + + initialize( + container: HTMLElement, + onFaceClick: FaceClickCallback, + onRotate?: RotateCallback, + onRotate90?: Rotate90Callback + ): void { + this.container = container + this.onFaceClick = onFaceClick + this.onRotate = onRotate || null + this.onRotate90 = onRotate90 || null + + // Create scene + this.scene = new THREE.Scene() + + // Create orthographic camera for consistent cube size + const size = 2 + this.camera = new THREE.OrthographicCamera(-size, size, size, -size, 0.1, 100) + this.camera.position.set(3, 3, 3) + this.camera.lookAt(0, 0, 0) + + // Create renderer with transparent background + this.renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + }) + this.renderer.setSize(container.clientWidth, container.clientHeight) + this.renderer.setPixelRatio(window.devicePixelRatio) + this.renderer.setClearColor(0x000000, 0) + container.appendChild(this.renderer.domElement) + + // Create cube with face materials + this.createCube() + + // Add lighting + const ambientLight = new THREE.AmbientLight(0xffffff, 0.8) + this.scene.add(ambientLight) + + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4) + directionalLight.position.set(5, 5, 5) + this.scene.add(directionalLight) + + // Event listeners + container.addEventListener('mousemove', this.handleMouseMove) + container.addEventListener('click', this.handleClick) + container.addEventListener('mouseleave', this.handleMouseLeave) + container.addEventListener('mousedown', this.handleMouseDown) + // Listen on window to catch mouseup even when mouse leaves the container + window.addEventListener('mouseup', this.handleMouseUp) + + // Initial render + this.render() + } + + private createCube(): void { + if (!this.scene) return + + const geometry = new THREE.BoxGeometry(1.8, 1.8, 1.8) + + // Create materials for each face + this.materials = this.faces.map((face) => { + const texture = this.createFaceTexture(face.name, face.color) + return new THREE.MeshBasicMaterial({ + map: texture, + transparent: false, + }) + }) + + this.cube = new THREE.Mesh(geometry, this.materials) + this.scene.add(this.cube) + } + + private createFaceTexture(label: string, bgColor: string): THREE.CanvasTexture { + const canvas = document.createElement('canvas') + canvas.width = 128 + canvas.height = 128 + const ctx = canvas.getContext('2d')! + + // Background with rounded corners effect + ctx.fillStyle = bgColor + ctx.fillRect(0, 0, 128, 128) + + // Inner border + ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)' + ctx.lineWidth = 3 + ctx.strokeRect(4, 4, 120, 120) + + // Text shadow for depth + ctx.fillStyle = 'rgba(0, 0, 0, 0.3)' + ctx.font = 'bold 52px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, 66, 66) + + // Main text + ctx.fillStyle = '#ffffff' + ctx.fillText(label, 64, 64) + + const texture = new THREE.CanvasTexture(canvas) + texture.needsUpdate = true + return texture + } + + private createHighlightTexture(label: string, bgColor: string): THREE.CanvasTexture { + const canvas = document.createElement('canvas') + canvas.width = 128 + canvas.height = 128 + const ctx = canvas.getContext('2d')! + + // Brighter background for hover + ctx.fillStyle = this.lightenColor(bgColor, 30) + ctx.fillRect(0, 0, 128, 128) + + // Highlight border + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 5 + ctx.strokeRect(4, 4, 120, 120) + + // Text shadow + ctx.fillStyle = 'rgba(0, 0, 0, 0.3)' + ctx.font = 'bold 52px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, 66, 66) + + // Main text + ctx.fillStyle = '#ffffff' + ctx.fillText(label, 64, 64) + + const texture = new THREE.CanvasTexture(canvas) + texture.needsUpdate = true + return texture + } + + private lightenColor(hex: string, percent: number): string { + const num = parseInt(hex.replace('#', ''), 16) + const amt = Math.round(2.55 * percent) + const R = Math.min(255, (num >> 16) + amt) + const G = Math.min(255, ((num >> 8) & 0x00ff) + amt) + const B = Math.min(255, (num & 0x0000ff) + amt) + return `#${((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1)}` + } + + private handleMouseMove = (event: MouseEvent): void => { + if (!this.container || !this.camera || !this.cube) return + + // Handle drag rotation + if (this.isDragging && this.onRotate) { + const deltaX = event.clientX - this.lastMouseX + const deltaY = event.clientY - this.lastMouseY + this.lastMouseX = event.clientX + this.lastMouseY = event.clientY + + // Call rotation callback with sensitivity adjustment (higher = more responsive) + this.onRotate(deltaX * 1.5, deltaY * 1.5) + return // Skip hover handling during drag + } + + const rect = this.container.getBoundingClientRect() + this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 + + this.raycaster.setFromCamera(this.mouse, this.camera) + const intersects = this.raycaster.intersectObject(this.cube) + + if (intersects.length > 0) { + const faceIndex = Math.floor(intersects[0].faceIndex! / 2) + + if (faceIndex !== this.hoveredFaceIndex) { + // Reset previous hover + if (this.hoveredFaceIndex >= 0) { + const prevFace = this.faces[this.hoveredFaceIndex] + this.materials[this.hoveredFaceIndex].map = this.createFaceTexture( + prevFace.name, + prevFace.color + ) + this.materials[this.hoveredFaceIndex].needsUpdate = true + } + + // Set new hover + this.hoveredFaceIndex = faceIndex + const face = this.faces[faceIndex] + this.materials[faceIndex].map = this.createHighlightTexture(face.name, face.color) + this.materials[faceIndex].needsUpdate = true + + this.container.style.cursor = 'pointer' + this.render() + } + } else { + this.resetHover() + } + } + + private handleClick = (event: MouseEvent): void => { + if (!this.container || !this.camera || !this.cube || !this.onFaceClick) return + + // Only check drag distance if mousedown was triggered on this element + if (this.hasMouseDown) { + const dragDistance = Math.sqrt( + Math.pow(event.clientX - this.dragStartX, 2) + + Math.pow(event.clientY - this.dragStartY, 2) + ) + // If dragged more than 5 pixels, don't fire click + if (dragDistance > 5) return + } + + const rect = this.container.getBoundingClientRect() + this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 + + this.raycaster.setFromCamera(this.mouse, this.camera) + const intersects = this.raycaster.intersectObject(this.cube) + + if (intersects.length > 0 && intersects[0].face) { + // Transform face normal to world space (accounting for cube rotation) + const worldNormal = intersects[0].face.normal.clone() + worldNormal.applyQuaternion(this.cube.quaternion) + + // Determine direction from world-space normal + const direction = this.getDirectionFromNormal(worldNormal) + if (direction) { + // Check if already directly facing this direction (showing only one face) + if (this.isDirectlyFacing(direction) && this.onRotate90) { + // Already facing this face → rotate 90 degrees clockwise + this.onRotate90(direction) + } else { + // Not directly facing → animate to this face view + this.onFaceClick(direction) + } + } + } + } + + private handleMouseDown = (event: MouseEvent): void => { + if (!this.container) return + + this.isDragging = true + this.hasMouseDown = true + this.lastMouseX = event.clientX + this.lastMouseY = event.clientY + this.dragStartX = event.clientX + this.dragStartY = event.clientY + this.container.style.cursor = 'grabbing' + + // Prevent text selection during drag + event.preventDefault() + } + + private handleMouseUp = (): void => { + this.isDragging = false + // Reset hasMouseDown after a short delay to allow click event to fire first + setTimeout(() => { + this.hasMouseDown = false + }, 10) + if (this.container) { + this.container.style.cursor = 'default' + } + } + + private handleMouseLeave = (): void => { + this.resetHover() + } + + private resetHover(): void { + if (this.hoveredFaceIndex >= 0) { + const face = this.faces[this.hoveredFaceIndex] + this.materials[this.hoveredFaceIndex].map = this.createFaceTexture(face.name, face.color) + this.materials[this.hoveredFaceIndex].needsUpdate = true + this.hoveredFaceIndex = -1 + if (this.container) { + this.container.style.cursor = 'default' + } + this.render() + } + } + + /** + * Determine view direction from a world-space normal vector. + * Finds which axis the normal is most aligned with. + */ + private getDirectionFromNormal(normal: THREE.Vector3): ViewDirection | null { + const absX = Math.abs(normal.x) + const absY = Math.abs(normal.y) + const absZ = Math.abs(normal.z) + + if (absX > absY && absX > absZ) { + return normal.x > 0 ? 'right' : 'left' + } else if (absY > absX && absY > absZ) { + return normal.y > 0 ? 'top' : 'bottom' + } else { + return normal.z > 0 ? 'front' : 'back' + } + } + + /** + * Check if camera is directly facing a specific direction (showing only one face). + * Uses dot product to determine alignment - when facing directly, dot product ≈ 1. + */ + private isDirectlyFacing(direction: ViewDirection): boolean { + if (!this.camera) return false + + // Get normalized camera position (direction from origin to camera) + const cameraDir = this.camera.position.clone().normalize() + + // Threshold for "directly facing" - 0.99 means within ~8 degrees of perfect alignment + const threshold = 0.99 + + // Map directions to their corresponding camera position vectors + // When viewing "front", camera is at +Z looking toward origin + const axisMap: Record = { + front: new THREE.Vector3(0, 0, 1), + back: new THREE.Vector3(0, 0, -1), + right: new THREE.Vector3(1, 0, 0), + left: new THREE.Vector3(-1, 0, 0), + top: new THREE.Vector3(0, 1, 0), + bottom: new THREE.Vector3(0, -1, 0), + } + + // Check if camera direction aligns with the face's expected camera position + return Math.abs(cameraDir.dot(axisMap[direction])) > threshold + } + + syncWithMainCamera(mainCamera: THREE.Camera): void { + if (!this.cube || !this.camera) return + + // Keep cube at identity rotation (faces always aligned with world axes) + this.cube.quaternion.identity() + + // Move ViewCube camera to match main camera's view direction + // Get main camera's forward direction (looking at -Z in camera space) + const direction = new THREE.Vector3(0, 0, -1) + direction.applyQuaternion(mainCamera.quaternion) + + // Position ViewCube camera opposite to view direction + const distance = 5 + this.camera.position.copy(direction.clone().multiplyScalar(-distance)) + this.camera.lookAt(0, 0, 0) + + // Sync up vector to maintain proper orientation + const up = new THREE.Vector3(0, 1, 0) + up.applyQuaternion(mainCamera.quaternion) + this.camera.up.copy(up) + + this.render() + } + + render(): void { + if (!this.renderer || !this.scene || !this.camera) return + this.renderer.render(this.scene, this.camera) + } + + isInitialized(): boolean { + return this.renderer !== null + } + + dispose(): void { + // Cancel animation frame if running + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId) + } + + // Remove event listeners + if (this.container) { + this.container.removeEventListener('mousemove', this.handleMouseMove) + this.container.removeEventListener('click', this.handleClick) + this.container.removeEventListener('mouseleave', this.handleMouseLeave) + this.container.removeEventListener('mousedown', this.handleMouseDown) + } + window.removeEventListener('mouseup', this.handleMouseUp) + + // Dispose Three.js resources + if (this.cube) { + this.cube.geometry.dispose() + this.materials.forEach((mat) => { + if (mat.map) mat.map.dispose() + mat.dispose() + }) + } + + if (this.renderer) { + this.renderer.dispose() + if (this.container && this.renderer.domElement.parentNode === this.container) { + this.container.removeChild(this.renderer.domElement) + } + } + + this.scene = null + this.camera = null + this.renderer = null + this.cube = null + this.container = null + this.onFaceClick = null + this.onRotate = null + this.materials = [] + } +} + +// Singleton instance +let viewCubeServiceInstance: ViewCubeService | null = null + +export function getViewCubeService(): ViewCubeService { + if (!viewCubeServiceInstance) { + viewCubeServiceInstance = new ViewCubeService() + } + return viewCubeServiceInstance +} + +export function resetViewCubeService(): void { + if (viewCubeServiceInstance) { + viewCubeServiceInstance.dispose() + viewCubeServiceInstance = null + } +} diff --git a/frontend/src/stores/models.ts b/frontend/src/stores/models.ts new file mode 100644 index 0000000..b26517e --- /dev/null +++ b/frontend/src/stores/models.ts @@ -0,0 +1,173 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Model, ConversionStatus } from '@/types/model' +import * as api from '@/api/client' + +export const useModelsStore = defineStore('models', () => { + // State + const models = ref([]) + const selectedModelId = ref(null) + const searchQuery = ref('') + const isLoading = ref(false) + const error = ref(null) + const total = ref(0) + + // Getters + const selectedModel = computed(() => { + if (!selectedModelId.value) return null + return models.value.find(m => m.id === selectedModelId.value) || null + }) + + const filteredModels = computed(() => { + if (!searchQuery.value) return models.value + + const query = searchQuery.value.toLowerCase() + return models.value.filter(m => + m.name.toLowerCase().includes(query) || + m.original_filename.toLowerCase().includes(query) + ) + }) + + const readyModels = computed(() => + models.value.filter(m => m.conversion_status === 'completed') + ) + + // Actions + async function fetchModels(params?: { + search?: string + status?: ConversionStatus + format?: string + limit?: number + offset?: number + }) { + isLoading.value = true + error.value = null + + try { + const result = await api.getModels(params) + models.value = result.models + total.value = result.total + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to fetch models' + console.error('Failed to fetch models:', e) + } finally { + isLoading.value = false + } + } + + async function refreshModels() { + await fetchModels() + } + + function selectModel(id: string | null) { + selectedModelId.value = id + } + + async function uploadModel(file: File): Promise { + try { + // 1. Initialize upload + const { uploadUrl, modelId, storageKey } = await api.initUpload(file.name) + + // 2. Upload directly to MinIO + await api.uploadToMinIO(uploadUrl, file) + + // 3. Confirm upload + const model = await api.confirmUpload({ + modelId, + filename: file.name, + fileSize: file.size, + storageKey, + }) + + // Add to local state + models.value.unshift(model) + + return model + } catch (e) { + error.value = e instanceof Error ? e.message : 'Upload failed' + console.error('Upload failed:', e) + return null + } + } + + async function removeModel(id: string): Promise { + try { + await api.deleteModel(id) + models.value = models.value.filter(m => m.id !== id) + if (selectedModelId.value === id) { + selectedModelId.value = null + } + return true + } catch (e) { + error.value = e instanceof Error ? e.message : 'Delete failed' + console.error('Delete failed:', e) + return false + } + } + + async function renameModel(id: string, newName: string): Promise { + try { + const updated = await api.updateModel(id, { name: newName }) + updateModelInStore(updated) + return updated + } catch (e) { + error.value = e instanceof Error ? e.message : 'Rename failed' + console.error('Rename failed:', e) + return null + } + } + + function updateModelInStore(model: Model) { + const index = models.value.findIndex(m => m.id === model.id) + if (index !== -1) { + models.value[index] = model + } + } + + // Polling for status updates + let pollInterval: number | null = null + + function startPolling(interval = 5000) { + stopPolling() + pollInterval = window.setInterval(async () => { + // Only poll if there are pending/processing models + const hasPending = models.value.some( + m => m.conversion_status === 'pending' || m.conversion_status === 'processing' + ) + if (hasPending) { + await refreshModels() + } + }, interval) + } + + function stopPolling() { + if (pollInterval !== null) { + clearInterval(pollInterval) + pollInterval = null + } + } + + return { + // State + models, + selectedModelId, + searchQuery, + isLoading, + error, + total, + // Getters + selectedModel, + filteredModels, + readyModels, + // Actions + fetchModels, + refreshModels, + selectModel, + uploadModel, + removeModel, + renameModel, + updateModelInStore, + startPolling, + stopPolling, + } +}) diff --git a/frontend/src/stores/partsTree.ts b/frontend/src/stores/partsTree.ts new file mode 100644 index 0000000..ab05cd9 --- /dev/null +++ b/frontend/src/stores/partsTree.ts @@ -0,0 +1,407 @@ +import { defineStore } from 'pinia' +import { ref, shallowRef, computed } from 'vue' +import type { TreeNode, FlatTreeNode } from '@/types/partsTree' +import { getPartsTreeService, resetPartsTreeService } from '@/services/partsTreeService' +import { getRenderService } from '@/services/renderService' +import { getExplodeService } from '@/services/explodeService' +import { useViewerStore } from './viewer' + +export const usePartsTreeStore = defineStore('partsTree', () => { + const viewerStore = useViewerStore() + + // State + const tree = shallowRef(null) + const expandedIds = ref>(new Set()) + const searchQuery = ref('') + const matchingIds = ref>(new Set()) + const hoveredNodeId = ref(null) + const isPanelCollapsed = ref(false) + const panelWidth = ref(280) + + // Computed + const flattenedTree = computed(() => { + if (!tree.value) return [] + const service = getPartsTreeService() + return service.flattenTree(tree.value, expandedIds.value) + }) + + const filteredFlatTree = computed(() => { + if (!searchQuery.value.trim()) return flattenedTree.value + + // Only show nodes that match or have matching descendants + return flattenedTree.value.filter(node => matchingIds.value.has(node.id)) + }) + + const hasTree = computed(() => tree.value !== null) + + const nodeCount = computed(() => { + if (!tree.value) return 0 + let count = 0 + const countNodes = (node: TreeNode) => { + count++ + node.children.forEach(countNodes) + } + countNodes(tree.value) + return count + }) + + // Actions + function buildTree() { + const scene = viewerStore.scene + if (!scene) { + console.warn('PartsTreeStore: No scene available') + tree.value = null + return + } + + const service = getPartsTreeService() + tree.value = service.buildTree(scene) + + // Initialize expanded IDs from tree + if (tree.value) { + expandedIds.value = new Set() + initExpandedIds(tree.value) + } + } + + function initExpandedIds(node: TreeNode) { + if (node.isExpanded) { + expandedIds.value.add(node.id) + } + node.children.forEach(initExpandedIds) + } + + function toggleExpanded(nodeId: string) { + if (!tree.value) return + + const service = getPartsTreeService() + const node = service.findNodeById(tree.value, nodeId) + if (!node) return + + service.toggleExpanded(node) + + if (node.isExpanded) { + expandedIds.value.add(nodeId) + } else { + expandedIds.value.delete(nodeId) + } + // Trigger reactivity + expandedIds.value = new Set(expandedIds.value) + } + + /** + * Helper: Collect mesh UUIDs from a node and all its descendants + */ + function collectMeshUuids(node: TreeNode): string[] { + const meshUuids: string[] = [] + const collect = (n: TreeNode) => { + n.object.traverse((obj) => { + if (obj.type === 'Mesh' || (obj as unknown as { isMesh?: boolean }).isMesh) { + meshUuids.push(obj.uuid) + } + }) + n.children.forEach(collect) + } + collect(node) + return meshUuids + } + + /** + * Helper: Collect mesh UUIDs from a single node (not descendants) + */ + function collectNodeMeshUuids(node: TreeNode): string[] { + const meshUuids: string[] = [] + node.object.traverse((obj) => { + if (obj.type === 'Mesh' || (obj as unknown as { isMesh?: boolean }).isMesh) { + meshUuids.push(obj.uuid) + } + }) + return meshUuids + } + + /** + * Set visibility with cascade (for hiding parent) + */ + function setVisible(nodeId: string, visible: boolean) { + if (!tree.value) return + + const service = getPartsTreeService() + const node = service.findNodeById(tree.value, nodeId) + if (!node) return + + service.setVisible(node, visible) + + // Sync edge lines + const renderService = getRenderService() + if (renderService.isEdgesEnabled()) { + const meshUuids = collectMeshUuids(node) + renderService.syncEdgeVisibility(meshUuids, visible) + } + + // Force re-render + viewerStore.forceRender() + // Trigger tree reactivity by creating new reference + tree.value = { ...tree.value } + } + + /** + * Set visibility independently (only this node, no cascade) + * Used when showing a child while parent is hidden + */ + function setVisibleIndependent(nodeId: string, visible: boolean) { + if (!tree.value) return + + const service = getPartsTreeService() + const node = service.findNodeById(tree.value, nodeId) + if (!node) return + + service.setVisibleIndependent(node, visible) + + // Sync edge lines + const renderService = getRenderService() + if (renderService.isEdgesEnabled()) { + const meshUuids = collectNodeMeshUuids(node) + renderService.syncEdgeVisibility(meshUuids, visible) + } + + // Force re-render + viewerStore.forceRender() + // Trigger tree reactivity by creating new reference + tree.value = { ...tree.value } + } + + /** + * Toggle visibility: + * - Hiding: cascade to children + * - Showing: independent (only this node) + */ + function toggleVisible(nodeId: string) { + if (!tree.value) return + + const service = getPartsTreeService() + const node = service.findNodeById(tree.value, nodeId) + if (!node) return + + if (node.visible) { + // Hiding: use cascade + setVisible(nodeId, false) + } else { + // Showing: use independent + setVisibleIndependent(nodeId, true) + } + } + + function showAll() { + if (!tree.value) return + const service = getPartsTreeService() + service.showAll(tree.value) + + // Sync edge lines + const renderService = getRenderService() + if (renderService.isEdgesEnabled()) { + const meshUuids = collectMeshUuids(tree.value) + renderService.syncEdgeVisibility(meshUuids, true) + } + + viewerStore.forceRender() + tree.value = { ...tree.value } + } + + function hideAll() { + if (!tree.value) return + const service = getPartsTreeService() + service.hideAll(tree.value) + + // Sync edge lines + const renderService = getRenderService() + if (renderService.isEdgesEnabled()) { + const meshUuids = collectMeshUuids(tree.value) + renderService.syncEdgeVisibility(meshUuids, false) + } + + viewerStore.forceRender() + tree.value = { ...tree.value } + } + + /** + * Isolate a node: hide all others, show only this node and its children + */ + function isolate(nodeId: string) { + if (!tree.value) return + + const service = getPartsTreeService() + const node = service.findNodeById(tree.value, nodeId) + if (!node) return + + // 1. Hide all first + service.hideAll(tree.value) + + // 2. Show the selected node and its children (cascade) + service.setVisible(node, true) + + // Sync edge lines + const renderService = getRenderService() + if (renderService.isEdgesEnabled()) { + // Hide all edges first + const allMeshUuids = collectMeshUuids(tree.value) + renderService.syncEdgeVisibility(allMeshUuids, false) + // Show edges for isolated node + const nodeMeshUuids = collectMeshUuids(node) + renderService.syncEdgeVisibility(nodeMeshUuids, true) + } + + viewerStore.forceRender() + tree.value = { ...tree.value } + } + + /** + * Set a node to be transparent (semi-transparent) + */ + function setTransparent(nodeId: string, opacity: number = 0.3) { + if (!tree.value) return + + const service = getPartsTreeService() + const node = service.findNodeById(tree.value, nodeId) + if (!node) return + + service.setPartTransparency(node, opacity) + viewerStore.forceRender() + tree.value = { ...tree.value } + } + + /** + * Reset all to initial state: show all, reset colors, reset explode, reset opacity + */ + function resetAll() { + if (!tree.value) return + + const service = getPartsTreeService() + + // 0. Clear highlight state first + service.clearHighlight() + + // 1. Show all parts + showAll() + + // 2. Reset colors to original + service.resetToOriginalColors() + + // 3. Reset explode state + getExplodeService().reset() + + // 4. Reset all opacity to original + service.resetAllOpacity(tree.value) + + viewerStore.forceRender() + tree.value = { ...tree.value } + } + + function expandAll() { + if (!tree.value) return + const service = getPartsTreeService() + service.expandAll(tree.value) + + // Update expanded IDs + expandedIds.value = new Set() + const collectIds = (node: TreeNode) => { + expandedIds.value.add(node.id) + node.children.forEach(collectIds) + } + collectIds(tree.value) + expandedIds.value = new Set(expandedIds.value) + } + + function collapseAll() { + if (!tree.value) return + const service = getPartsTreeService() + service.collapseAll(tree.value) + expandedIds.value = new Set() + } + + function setSearchQuery(query: string) { + searchQuery.value = query + + if (!tree.value || !query.trim()) { + matchingIds.value = new Set() + return + } + + const service = getPartsTreeService() + matchingIds.value = service.filterBySearch(tree.value, query) + + // Auto-expand matching nodes' ancestors + matchingIds.value.forEach(id => { + expandedIds.value.add(id) + }) + expandedIds.value = new Set(expandedIds.value) + } + + function highlightNode(nodeId: string | null) { + hoveredNodeId.value = nodeId + + const service = getPartsTreeService() + + if (!nodeId || !tree.value) { + service.highlightPart(null, null) + viewerStore.forceRender() + return + } + + const node = service.findNodeById(tree.value, nodeId) + if (node) { + service.highlightPart(node.object, viewerStore.renderer) + viewerStore.forceRender() + } + } + + function setPanelCollapsed(collapsed: boolean) { + isPanelCollapsed.value = collapsed + } + + function setPanelWidth(width: number) { + panelWidth.value = Math.max(200, Math.min(500, width)) + } + + function reset() { + tree.value = null + expandedIds.value = new Set() + searchQuery.value = '' + matchingIds.value = new Set() + hoveredNodeId.value = null + resetPartsTreeService() + } + + return { + // State + tree, + expandedIds, + searchQuery, + matchingIds, + hoveredNodeId, + isPanelCollapsed, + panelWidth, + // Computed + flattenedTree, + filteredFlatTree, + hasTree, + nodeCount, + // Actions + buildTree, + toggleExpanded, + setVisible, + setVisibleIndependent, + toggleVisible, + showAll, + hideAll, + isolate, + setTransparent, + resetAll, + expandAll, + collapseAll, + setSearchQuery, + highlightNode, + setPanelCollapsed, + setPanelWidth, + reset, + } +}) diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts new file mode 100644 index 0000000..e387fb1 --- /dev/null +++ b/frontend/src/stores/theme.ts @@ -0,0 +1,125 @@ +import { defineStore } from 'pinia' +import { ref, computed, watch } from 'vue' + +export type Theme = 'light' | 'dark' | 'system' +export type ResolvedTheme = 'light' | 'dark' + +const STORAGE_KEY = 'viewer3d-theme' + +export const useThemeStore = defineStore('theme', () => { + // State + const preference = ref('system') + const resolvedTheme = ref('light') + const systemPreference = ref('light') + + // Getters + const isDark = computed(() => resolvedTheme.value === 'dark') + const isLight = computed(() => resolvedTheme.value === 'light') + const isSystem = computed(() => preference.value === 'system') + + /** + * Apply theme to DOM by setting data-theme attribute + */ + function applyTheme() { + document.documentElement.setAttribute('data-theme', resolvedTheme.value) + } + + /** + * Update the resolved theme based on preference + */ + function updateResolvedTheme() { + if (preference.value === 'system') { + resolvedTheme.value = systemPreference.value + } else { + resolvedTheme.value = preference.value + } + applyTheme() + } + + /** + * Initialize theme from localStorage and system preference + * Should be called before app mount to prevent flash + */ + function initialize() { + // 1. Read saved preference from localStorage + try { + const saved = localStorage.getItem(STORAGE_KEY) as Theme | null + if (saved && ['light', 'dark', 'system'].includes(saved)) { + preference.value = saved + } + } catch { + // localStorage might be unavailable (e.g., private browsing) + console.warn('Could not read theme preference from localStorage') + } + + // 2. Detect system preference + if (typeof window !== 'undefined' && window.matchMedia) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + systemPreference.value = mediaQuery.matches ? 'dark' : 'light' + + // 3. Listen for system preference changes + mediaQuery.addEventListener('change', (e) => { + systemPreference.value = e.matches ? 'dark' : 'light' + updateResolvedTheme() + }) + } + + // 4. Initial resolution + updateResolvedTheme() + } + + /** + * Set theme preference (light, dark, or system) + */ + function setTheme(theme: Theme) { + preference.value = theme + + // Persist to localStorage + try { + localStorage.setItem(STORAGE_KEY, theme) + } catch { + console.warn('Could not save theme preference to localStorage') + } + + updateResolvedTheme() + } + + /** + * Toggle between light and dark (sets explicit preference, not system) + */ + function toggle() { + const newTheme = resolvedTheme.value === 'light' ? 'dark' : 'light' + setTheme(newTheme) + } + + /** + * Cycle through themes: light -> dark -> system -> light + */ + function cycle() { + const order: Theme[] = ['light', 'dark', 'system'] + const currentIndex = order.indexOf(preference.value) + const nextIndex = (currentIndex + 1) % order.length + setTheme(order[nextIndex]) + } + + // Watch preference changes + watch(preference, updateResolvedTheme) + + return { + // State + preference, + resolvedTheme, + systemPreference, + + // Getters + isDark, + isLight, + isSystem, + + // Actions + initialize, + setTheme, + toggle, + cycle, + } +}) diff --git a/frontend/src/stores/viewer.ts b/frontend/src/stores/viewer.ts new file mode 100644 index 0000000..d937843 --- /dev/null +++ b/frontend/src/stores/viewer.ts @@ -0,0 +1,562 @@ +import { defineStore } from 'pinia' +import { ref, shallowRef } from 'vue' +import type * as OV from 'online-3d-viewer' +import * as THREE from 'three' +import type { ViewDirection } from '@/services/viewCubeService' + +export const useViewerStore = defineStore('viewer', () => { + // State - use shallowRef for complex objects that shouldn't be deeply reactive + const viewer = shallowRef(null) + const model = shallowRef(null) + const scene = shallowRef(null) + const renderer = shallowRef(null) + const camera = shallowRef(null) + const isLoading = ref(false) + const loadingProgress = ref(0) + const loadingStage = ref<'downloading' | 'parsing' | null>(null) + const error = ref(null) + const currentModelUrl = ref(null) + + // Exploded view state + const explosionFactor = ref(0) + const isExplodedViewEnabled = ref(false) + + // Cross-section state + const crossSection = ref({ + x: { enabled: false, position: 100 }, + y: { enabled: false, position: 100 }, + z: { enabled: false, position: 100 }, + planeVisible: true, // Show/hide cutting plane visualization + sectionFlipped: false, // Flip all plane normals to show opposite region + }) + + // Render settings state + const renderSettings = ref({ + renderMode: 'standard' as 'standard' | 'hiddenLine' | 'wireframe', // Render mode + edgesEnabled: true, // Edge lines toggle (default ON) + edgeLineWidth: 1, // Edge line width (0.5-5) + autoColorEnabled: true, // Auto-assign part colors toggle (default ON) + materialType: 'clay' as 'clay' | 'metal' | 'paint', // Global material type (clay default) + // Lighting settings + exposure: 1.0, // Tone mapping exposure / scene brightness (0.1 - 3.0) + mainLightIntensity: 0.8, // Main directional light intensity (0 - 2.0) + ambientLightIntensity: 0.6, // Ambient light intensity (0 - 2.0) + }) + + // Selection state + const selectedPartId = ref(null) + + // Context menu state + const contextMenu = ref({ + visible: false, + x: 0, + y: 0, + partId: null as string | null, + }) + + // Actions + function setViewer(v: OV.EmbeddedViewer | null) { + viewer.value = v + if (v) { + // Access Three.js internals + const threeViewer = v.GetViewer() + if (threeViewer) { + scene.value = (threeViewer as unknown as { scene: THREE.Scene }).scene + renderer.value = (threeViewer as unknown as { renderer: THREE.WebGLRenderer }).renderer + camera.value = (threeViewer as unknown as { camera: THREE.Camera }).camera + } + } else { + scene.value = null + renderer.value = null + camera.value = null + model.value = null + } + } + + function setModel(m: OV.Model | null) { + model.value = m + } + + function setLoading(loading: boolean) { + isLoading.value = loading + if (!loading) { + loadingProgress.value = 0 + loadingStage.value = null + } + } + + function setLoadingProgress(progress: number, stage?: 'downloading' | 'parsing') { + loadingProgress.value = Math.max(0, Math.min(100, progress)) + if (stage) { + loadingStage.value = stage + } + } + + function setError(err: string | null) { + error.value = err + } + + function setCurrentModelUrl(url: string | null) { + currentModelUrl.value = url + } + + function setExplosionFactor(factor: number) { + explosionFactor.value = Math.max(0, Math.min(100, factor)) + } + + function setExplodedViewEnabled(enabled: boolean) { + isExplodedViewEnabled.value = enabled + if (!enabled) { + explosionFactor.value = 0 + } + } + + function setCrossSectionAxis(axis: 'x' | 'y' | 'z', enabled: boolean) { + crossSection.value[axis].enabled = enabled + } + + function setCrossSectionPosition(axis: 'x' | 'y' | 'z', position: number) { + crossSection.value[axis].position = Math.max(0, Math.min(100, position)) + } + + function setCrossSectionPlaneVisible(visible: boolean) { + crossSection.value.planeVisible = visible + } + + function setCrossSectionFlipped(flipped: boolean) { + crossSection.value.sectionFlipped = flipped + } + + function setRenderMode(mode: 'standard' | 'hiddenLine' | 'wireframe') { + renderSettings.value.renderMode = mode + } + + function setEdgesEnabled(enabled: boolean) { + renderSettings.value.edgesEnabled = enabled + } + + function setEdgeLineWidth(width: number) { + renderSettings.value.edgeLineWidth = Math.max(0.5, Math.min(5, width)) + } + + function setAutoColorEnabled(enabled: boolean) { + renderSettings.value.autoColorEnabled = enabled + } + + function setMaterialType(type: 'clay' | 'metal' | 'paint') { + renderSettings.value.materialType = type + } + + function setExposure(value: number) { + renderSettings.value.exposure = Math.max(0.1, Math.min(3.0, value)) + } + + function setMainLightIntensity(value: number) { + renderSettings.value.mainLightIntensity = Math.max(0, Math.min(2.0, value)) + } + + function setAmbientLightIntensity(value: number) { + renderSettings.value.ambientLightIntensity = Math.max(0, Math.min(2.0, value)) + } + + function setSelectedPart(id: string | null) { + selectedPartId.value = id + } + + function showContextMenu(x: number, y: number, partId: string) { + contextMenu.value = { visible: true, x, y, partId } + } + + function hideContextMenu() { + contextMenu.value.visible = false + contextMenu.value.partId = null + } + + function resetFeatures() { + // Preserve global settings that should persist across model switches + const currentAutoColor = renderSettings.value.autoColorEnabled + const currentEdges = renderSettings.value.edgesEnabled + + explosionFactor.value = 0 + isExplodedViewEnabled.value = false + selectedPartId.value = null + contextMenu.value = { visible: false, x: 0, y: 0, partId: null } + crossSection.value = { + x: { enabled: false, position: 100 }, + y: { enabled: false, position: 100 }, + z: { enabled: false, position: 100 }, + planeVisible: true, + sectionFlipped: false, + } + renderSettings.value = { + renderMode: 'standard', + edgesEnabled: currentEdges, // Preserve edge setting + edgeLineWidth: 1, + autoColorEnabled: currentAutoColor, // Preserve auto-color setting + materialType: 'clay', // Default to clay material + exposure: 1.0, // Default exposure + mainLightIntensity: 0.8, // Default main light + ambientLightIntensity: 0.6, // Default ambient light + } + } + + function forceRender() { + if (viewer.value) { + const threeViewer = viewer.value.GetViewer() + if (threeViewer && typeof (threeViewer as unknown as { Render: () => void }).Render === 'function') { + (threeViewer as unknown as { Render: () => void }).Render() + } + } + } + + /** + * Fit the camera to show the entire model + */ + function fitToView() { + if (!viewer.value) return + + const threeViewer = viewer.value.GetViewer() + if (!threeViewer) return + + // Access navigation and bounding sphere from Online3DViewer + const nav = (threeViewer as unknown as { + navigation?: { + FitSphereToWindow: (sphere: unknown, animate: boolean) => void + } + }).navigation + + const getBoundingSphere = (threeViewer as unknown as { + GetBoundingSphere?: () => unknown + }).GetBoundingSphere + + if (nav && getBoundingSphere) { + const sphere = getBoundingSphere.call(threeViewer) + nav.FitSphereToWindow(sphere, true) // true = animate + } + } + + // Camera animation state + let cameraAnimationId: number | null = null + + /** + * Animate camera to a specific view direction (for ViewCube) + */ + function animateCameraToView(direction: ViewDirection, duration: number = 500): void { + if (!viewer.value || !camera.value) return + + const threeViewer = viewer.value.GetViewer() + if (!threeViewer) return + + // Cancel any ongoing animation + if (cameraAnimationId !== null) { + cancelAnimationFrame(cameraAnimationId) + } + + // Get bounding sphere for model center and distance calculation + // GetBoundingSphere takes a callback function to filter which meshes to include + // Pass a function that returns true for all meshes to include everything + const viewerWithSphere = threeViewer as unknown as { + GetBoundingSphere?: (needToProcess: (meshUserData: unknown) => boolean) => { center: { x: number; y: number; z: number }; radius: number } + } + + if (!viewerWithSphere.GetBoundingSphere) return + + // Call with a filter function that includes all meshes + const boundingSphere = viewerWithSphere.GetBoundingSphere(() => true) + if (!boundingSphere) return + + // Extract center and radius from bounding sphere + const sphereCenter = boundingSphere.center as { x: number; y: number; z: number } + if (!sphereCenter || typeof sphereCenter.x !== 'number') return + + // Create Vector3 from our THREE module to avoid cross-module issues + const center = new THREE.Vector3(sphereCenter.x, sphereCenter.y, sphereCenter.z) + const radius = boundingSphere.radius + if (typeof radius !== 'number' || radius <= 0) return + + const distance = radius * 2.5 + + // Calculate target position based on direction + const positions: Record = { + front: new THREE.Vector3(center.x, center.y, center.z + distance), + back: new THREE.Vector3(center.x, center.y, center.z - distance), + right: new THREE.Vector3(center.x + distance, center.y, center.z), + left: new THREE.Vector3(center.x - distance, center.y, center.z), + top: new THREE.Vector3(center.x, center.y + distance, center.z + 0.001), // Small offset to avoid gimbal lock + bottom: new THREE.Vector3(center.x, center.y - distance, center.z + 0.001), + } + + const targetPosition = positions[direction] + const currentCamera = camera.value as THREE.PerspectiveCamera | null + if (!currentCamera) return + + // Create startPosition using our THREE module to avoid cross-module Vector3 issues + const camPos = currentCamera.position + const startPosition = new THREE.Vector3(camPos.x, camPos.y, camPos.z) + const startTime = performance.now() + + // Get navigation to directly update its internal camera state + const nav = (threeViewer as unknown as { + navigation?: { + camera: { + eye: { x: number; y: number; z: number } + center: { x: number; y: number; z: number } + up: { x: number; y: number; z: number } + } + Update: () => void + } + }).navigation + + if (!nav || !nav.camera) { + console.error('Navigation camera not available') + return + } + + const animate = (currentTime: number) => { + try { + const elapsed = currentTime - startTime + const progress = Math.min(elapsed / duration, 1) + // Ease-out cubic for smooth deceleration + const eased = 1 - Math.pow(1 - progress, 3) + + // Interpolate position + const newX = startPosition.x + (targetPosition.x - startPosition.x) * eased + const newY = startPosition.y + (targetPosition.y - startPosition.y) * eased + const newZ = startPosition.z + (targetPosition.z - startPosition.z) * eased + + // Directly update navigation's internal camera state (plain objects, no Vector3) + nav.camera.eye.x = newX + nav.camera.eye.y = newY + nav.camera.eye.z = newZ + + // Keep center at model center + nav.camera.center.x = center.x + nav.camera.center.y = center.y + nav.camera.center.z = center.z + + // Update up vector for top/bottom views + if (direction === 'top') { + nav.camera.up.x = 0 + nav.camera.up.y = 0 + nav.camera.up.z = -1 + } else if (direction === 'bottom') { + nav.camera.up.x = 0 + nav.camera.up.y = 0 + nav.camera.up.z = 1 + } else { + nav.camera.up.x = 0 + nav.camera.up.y = 1 + nav.camera.up.z = 0 + } + + // Sync the navigation's internal state with the Three.js camera + nav.Update() + + // Force update camera matrix so quaternion is available for ViewCube sync + if (camera.value) { + camera.value.updateMatrixWorld(true) + } + + if (progress < 1) { + cameraAnimationId = requestAnimationFrame(animate) + } else { + cameraAnimationId = null + } + } catch (err) { + console.error('Animation error:', err instanceof Error ? err.message : err) + console.error('Stack:', err instanceof Error ? err.stack : 'no stack') + cameraAnimationId = null + } + } + + cameraAnimationId = requestAnimationFrame(animate) + } + + /** + * Rotate the camera by delta amounts (for ViewCube drag rotation) + */ + function rotateCamera(deltaX: number, deltaY: number): void { + if (!viewer.value) return + + const threeViewer = viewer.value.GetViewer() + if (!threeViewer) return + + const nav = (threeViewer as unknown as { + navigation?: { + camera: { + eye: { x: number; y: number; z: number } + center: { x: number; y: number; z: number } + up: { x: number; y: number; z: number } + } + Update: () => void + } + }).navigation + + if (!nav || !nav.camera) return + + // Calculate camera-to-center offset vector + const eye = new THREE.Vector3(nav.camera.eye.x, nav.camera.eye.y, nav.camera.eye.z) + const center = new THREE.Vector3(nav.camera.center.x, nav.camera.center.y, nav.camera.center.z) + + const offset = eye.clone().sub(center) + + // Convert delta to radians (adjust sensitivity) + const azimuthAngle = -deltaX * 0.01 // Horizontal rotation + const polarAngle = -deltaY * 0.01 // Vertical rotation + + // Use spherical coordinates for orbit rotation + const spherical = new THREE.Spherical().setFromVector3(offset) + spherical.theta += azimuthAngle + spherical.phi += polarAngle + + // Clamp polar angle to prevent camera flip + spherical.phi = Math.max(0.01, Math.min(Math.PI - 0.01, spherical.phi)) + + // Apply new position + offset.setFromSpherical(spherical) + const newEye = center.clone().add(offset) + + nav.camera.eye.x = newEye.x + nav.camera.eye.y = newEye.y + nav.camera.eye.z = newEye.z + + nav.Update() + } + + /** + * Rotate the camera 90 degrees clockwise around the current viewing axis + * (for ViewCube single-face click rotation) with smooth animation + */ + function rotateCameraAroundAxis(direction: ViewDirection, duration: number = 300): void { + if (!viewer.value) return + + const threeViewer = viewer.value.GetViewer() + if (!threeViewer) return + + // Cancel any ongoing animation + if (cameraAnimationId !== null) { + cancelAnimationFrame(cameraAnimationId) + } + + const nav = (threeViewer as unknown as { + navigation?: { + camera: { + eye: { x: number; y: number; z: number } + center: { x: number; y: number; z: number } + up: { x: number; y: number; z: number } + } + Update: () => void + } + }).navigation + + if (!nav || !nav.camera) return + + // Get current up vector as start point + const startUp = new THREE.Vector3(nav.camera.up.x, nav.camera.up.y, nav.camera.up.z) + + // Rotation axis is the viewing direction (from camera eye toward center) + // When viewing "front", the axis is (0, 0, -1) + const axisMap: Record = { + front: new THREE.Vector3(0, 0, -1), + back: new THREE.Vector3(0, 0, 1), + right: new THREE.Vector3(-1, 0, 0), + left: new THREE.Vector3(1, 0, 0), + top: new THREE.Vector3(0, -1, 0), + bottom: new THREE.Vector3(0, 1, 0), + } + + // Calculate target up vector (rotated 90 degrees clockwise) + const targetUp = startUp.clone() + const quaternion = new THREE.Quaternion() + quaternion.setFromAxisAngle(axisMap[direction], -Math.PI / 2) + targetUp.applyQuaternion(quaternion) + + const startTime = performance.now() + + const animate = (currentTime: number) => { + try { + const elapsed = currentTime - startTime + const progress = Math.min(elapsed / duration, 1) + // Ease-out cubic for smooth deceleration + const eased = 1 - Math.pow(1 - progress, 3) + + // Interpolate up vector + const currentUp = new THREE.Vector3().lerpVectors(startUp, targetUp, eased) + currentUp.normalize() + + // Update navigation's up vector + nav.camera.up.x = currentUp.x + nav.camera.up.y = currentUp.y + nav.camera.up.z = currentUp.z + + nav.Update() + + // Force update camera matrix for ViewCube sync + if (camera.value) { + camera.value.updateMatrixWorld(true) + } + + if (progress < 1) { + cameraAnimationId = requestAnimationFrame(animate) + } else { + cameraAnimationId = null + } + } catch (err) { + console.error('Rotation animation error:', err) + cameraAnimationId = null + } + } + + cameraAnimationId = requestAnimationFrame(animate) + } + + return { + // State + viewer, + model, + scene, + renderer, + camera, + isLoading, + loadingProgress, + loadingStage, + error, + currentModelUrl, + explosionFactor, + isExplodedViewEnabled, + crossSection, + renderSettings, + selectedPartId, + contextMenu, + // Actions + setViewer, + setModel, + setLoading, + setLoadingProgress, + setError, + setCurrentModelUrl, + setExplosionFactor, + setExplodedViewEnabled, + setCrossSectionAxis, + setCrossSectionPosition, + setCrossSectionPlaneVisible, + setCrossSectionFlipped, + setRenderMode, + setEdgesEnabled, + setEdgeLineWidth, + setAutoColorEnabled, + setMaterialType, + setExposure, + setMainLightIntensity, + setAmbientLightIntensity, + setSelectedPart, + showContextMenu, + hideContextMenu, + resetFeatures, + forceRender, + fitToView, + animateCameraToView, + rotateCamera, + rotateCameraAroundAxis, + } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..871cdea --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,1248 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap'); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* ============================================ + DESIGN SYSTEM: "Technical Precision × Soft Craft" + ============================================ */ + +:root { + /* Typography */ + --font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: 'JetBrains Mono', 'SF Mono', monospace; + + /* Layout */ + --sidebar-width: 19rem; + --header-height: 3.5rem; + + /* Primary brand - Vibrant Blue with depth */ + --primary-color: #2563eb; + --primary-hover: #1d4ed8; + --primary-light: #dbeafe; + --primary-subtle: rgba(37, 99, 235, 0.08); + --primary-glow: rgba(37, 99, 235, 0.25); + + /* Semantic colors - Refined palette */ + --success-color: #059669; + --success-light: #d1fae5; + --success-subtle: rgba(5, 150, 105, 0.1); + --warning-color: #d97706; + --warning-light: #fef3c7; + --warning-subtle: rgba(217, 119, 6, 0.1); + --danger-color: #dc2626; + --danger-light: #fee2e2; + --danger-subtle: rgba(220, 38, 38, 0.1); + + /* Background layers (light theme) - Warmer tones */ + --bg-primary: #f8fafc; + --bg-secondary: #ffffff; + --bg-tertiary: #f1f5f9; + --bg-elevated: #ffffff; + --bg-sunken: #e2e8f0; + + /* Text colors - Higher contrast */ + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #94a3b8; + --text-inverted: #ffffff; + + /* Border colors */ + --border-color: #e2e8f0; + --border-subtle: #f1f5f9; + --border-focus: var(--primary-color); + + /* Shadows - Color-tinted for depth */ + --shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.04); + --shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04); + --shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.08), 0 2px 4px -2px rgba(15, 23, 42, 0.04); + --shadow-lg: 0 10px 15px -3px rgba(15, 23, 42, 0.1), 0 4px 6px -4px rgba(15, 23, 42, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(15, 23, 42, 0.1), 0 8px 10px -6px rgba(15, 23, 42, 0.05); + --shadow-float: 0 25px 50px -12px rgba(15, 23, 42, 0.2); + --shadow-glow: 0 0 20px var(--primary-glow); + --shadow-inner: inset 0 2px 4px rgba(15, 23, 42, 0.04); + + /* Border radius - Refined scale */ + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 14px; + --radius-2xl: 18px; + --radius-full: 9999px; + + /* Spacing - 4px base */ + --space-0: 0; + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + + /* Animation timing - Snappy defaults */ + --duration-instant: 75ms; + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-slow: 300ms; + --duration-slower: 400ms; + + /* Easing functions - Physics-based */ + --ease-default: cubic-bezier(0.2, 0, 0, 1); + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-bounce: cubic-bezier(0.68, -0.6, 0.32, 1.6); + --ease-elastic: cubic-bezier(0.5, 1.5, 0.5, 1); + + /* Viewer specific */ + --viewer-bg: #f1f5f9; + --panel-overlay-bg: rgba(255, 255, 255, 0.85); + --panel-backdrop-blur: 16px; + + /* Axis colors - Vivid */ + --axis-x: #ef4444; + --axis-y: #22c55e; + --axis-z: #3b82f6; + + /* Gradients */ + --gradient-primary: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + --gradient-surface: linear-gradient(180deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.4) 100%); +} + +/* ============================================ + DARK THEME + ============================================ */ + +[data-theme="dark"] { + /* Primary brand - Luminous in dark */ + --primary-color: #60a5fa; + --primary-hover: #93c5fd; + --primary-light: #1e3a5f; + --primary-subtle: rgba(96, 165, 250, 0.12); + --primary-glow: rgba(96, 165, 250, 0.3); + + /* Semantic colors - Vibrant for dark */ + --success-color: #34d399; + --success-light: #064e3b; + --success-subtle: rgba(52, 211, 153, 0.15); + --warning-color: #fbbf24; + --warning-light: #78350f; + --warning-subtle: rgba(251, 191, 36, 0.15); + --danger-color: #f87171; + --danger-light: #7f1d1d; + --danger-subtle: rgba(248, 113, 113, 0.15); + + /* Background layers - Deep, layered */ + --bg-primary: #0c0d12; + --bg-secondary: #151820; + --bg-tertiary: #1e2330; + --bg-elevated: #1a1f2c; + --bg-sunken: #0a0b0f; + + /* Text colors - High contrast */ + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --text-tertiary: #64748b; + --text-inverted: #0f172a; + + /* Border colors */ + --border-color: #2d3a50; + --border-subtle: #1e2738; + --border-focus: var(--primary-color); + + /* Shadows - Deeper with glow potential */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -4px rgba(0, 0, 0, 0.4); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 8px 10px -6px rgba(0, 0, 0, 0.4); + --shadow-float: 0 25px 50px -12px rgba(0, 0, 0, 0.8); + --shadow-glow: 0 0 24px var(--primary-glow); + --shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3); + + /* Viewer specific */ + --viewer-bg: #0f1218; + --panel-overlay-bg: rgba(21, 24, 32, 0.9); + + /* Gradients */ + --gradient-primary: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%); + --gradient-surface: linear-gradient(180deg, rgba(30,35,48,0.9) 0%, rgba(21,24,32,0.8) 100%); +} + +/* ============================================ + BASE STYLES + ============================================ */ + +html { + transition: background-color var(--duration-slow) var(--ease-default), + color var(--duration-slow) var(--ease-default); +} + +html, body, #app { + width: 100%; + height: 100%; + font-family: var(--font-sans); + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 15px; + line-height: 1.5; + letter-spacing: -0.011em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; +} + +/* Selection styling */ +::selection { + background: var(--primary-subtle); + color: var(--primary-color); +} + +/* ============================================ + LAYOUT + ============================================ */ + +.app-layout { + display: flex; + width: 100%; + height: 100%; +} + +/* Tree panel wrapper - must not shrink so viewer panel shrinks instead */ +.tree-panel-wrapper { + flex-shrink: 0; + transition: width var(--duration-slow) var(--ease-out), + min-width var(--duration-slow) var(--ease-out); +} + +/* ============================================ + SIDEBAR + ============================================ */ + +.sidebar { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + height: 100%; + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + overflow: hidden; + transition: background-color var(--duration-slow) var(--ease-default), + border-color var(--duration-slow) var(--ease-default); +} + +.sidebar-header { + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-height: var(--header-height); +} + +.sidebar-header h1 { + font-size: 1.0625rem; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--text-primary); + display: flex; + align-items: center; + gap: var(--space-2); +} + +/* Logo/brand icon */ +.sidebar-header h1::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + background: var(--gradient-primary); + border-radius: 2px; + transform: rotate(45deg); +} + +.header-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.sidebar-content { + flex: 1; + overflow-y: auto; + padding: var(--space-4); + scroll-behavior: smooth; +} + +/* Custom scrollbar - Refined */ +.sidebar-content::-webkit-scrollbar { + width: 8px; +} + +.sidebar-content::-webkit-scrollbar-track { + background: transparent; + margin: var(--space-2) 0; +} + +.sidebar-content::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: var(--radius-full); + border: 2px solid var(--bg-secondary); + transition: background var(--duration-fast) var(--ease-default); +} + +.sidebar-content::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +/* ============================================ + VIEWER PANEL + ============================================ */ + +.viewer-panel { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + position: relative; + background: var(--bg-primary); + transition: background-color var(--duration-normal) var(--ease-default); +} + +.viewer-container { + flex: 1; + position: relative; +} + +/* ============================================ + MODEL LIST + ============================================ */ + +.model-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* ============================================ + MODEL CARD + ============================================ */ + +.model-card { + position: relative; + background: var(--bg-secondary); + border-radius: var(--radius-lg); + padding: var(--space-3); + cursor: pointer; + border: 1px solid var(--border-subtle); + box-shadow: var(--shadow-xs); + transition: + transform var(--duration-normal) var(--ease-spring), + box-shadow var(--duration-normal) var(--ease-default), + border-color var(--duration-fast) var(--ease-default), + background-color var(--duration-fast) var(--ease-default); +} + +.model-card::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + opacity: 0; + background: var(--gradient-primary); + transition: opacity var(--duration-normal) var(--ease-default); + pointer-events: none; + z-index: -1; +} + +.model-card:hover { + border-color: var(--border-color); + box-shadow: var(--shadow-md); + transform: translateY(-3px); +} + +.model-card:hover::before { + opacity: 0.03; +} + +.model-card:active { + transform: translateY(-1px) scale(0.99); + transition-duration: var(--duration-instant); +} + +.model-card.selected { + border-color: var(--primary-color); + background: var(--primary-subtle); + box-shadow: + 0 0 0 1px var(--primary-color), + var(--shadow-glow); +} + +.model-card.selected::before { + opacity: 0.05; +} + +.model-card.has-menu-open { + z-index: 100; +} + +.model-card-content { + display: flex; + gap: var(--space-3); + align-items: flex-start; +} + +.model-card-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.model-card .name { + font-weight: 550; + font-size: 0.875rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); + transition: color var(--duration-fast) var(--ease-default); +} + +.model-card:hover .name { + color: var(--primary-color); +} + +.model-card .meta { + font-size: 0.75rem; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +/* Status badges - Modern pill design */ +.model-card .status { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: var(--radius-full); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + transition: all var(--duration-fast) var(--ease-default); +} + +/* Status dot indicator */ +.model-card .status::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.8; +} + +.model-card .status.pending { + background: var(--warning-subtle); + color: var(--warning-color); +} + +.model-card .status.processing { + background: var(--primary-subtle); + color: var(--primary-color); +} + +/* Pulsing animation for processing */ +.model-card .status.processing::before { + animation: pulse-dot 1.5s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 0.4; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.2); } +} + +.model-card .status.completed { + background: var(--success-subtle); + color: var(--success-color); +} + +.model-card .status.failed { + background: var(--danger-subtle); + color: var(--danger-color); +} + +/* ============================================ + THUMBNAIL + ============================================ */ + +.thumbnail { + width: 52px; + height: 52px; + border-radius: var(--radius-md); + object-fit: cover; + background: var(--bg-tertiary); + flex-shrink: 0; + border: 1px solid var(--border-subtle); + transition: + transform var(--duration-normal) var(--ease-spring), + border-color var(--duration-fast) var(--ease-default); +} + +.model-card:hover .thumbnail { + transform: scale(1.04); + border-color: var(--border-color); +} + +.thumbnail.placeholder { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-weight: 600; + font-size: 11px; + font-family: var(--font-mono); + letter-spacing: 0.05em; + background: + linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-sunken) 100%); +} + +/* ============================================ + BUTTONS + ============================================ */ + +.btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-md); + font-family: var(--font-sans); + font-size: 0.875rem; + font-weight: 550; + letter-spacing: -0.01em; + cursor: pointer; + border: none; + outline: none; + user-select: none; + -webkit-tap-highlight-color: transparent; + transition: + transform var(--duration-normal) var(--ease-spring), + box-shadow var(--duration-fast) var(--ease-default), + background-color var(--duration-fast) var(--ease-default), + border-color var(--duration-fast) var(--ease-default); +} + +.btn:hover:not(:disabled) { + transform: translateY(-2px); +} + +.btn:active:not(:disabled) { + transform: translateY(0) scale(0.97); + transition-duration: var(--duration-instant); +} + +.btn:focus-visible { + box-shadow: + 0 0 0 2px var(--bg-secondary), + 0 0 0 4px var(--primary-color); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + pointer-events: none; +} + +/* Primary button - Gradient with glow */ +.btn-primary { + background: var(--gradient-primary); + color: var(--text-inverted); + box-shadow: var(--shadow-sm); +} + +.btn-primary:hover:not(:disabled) { + box-shadow: var(--shadow-md), var(--shadow-glow); +} + +.btn-primary:active:not(:disabled) { + box-shadow: var(--shadow-xs); +} + +/* Secondary button */ +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-xs); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--bg-tertiary); + border-color: var(--text-tertiary); + box-shadow: var(--shadow-sm); +} + +/* Ghost button */ +.btn-ghost { + background: transparent; + color: var(--text-secondary); +} + +.btn-ghost:hover:not(:disabled) { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +/* Button sizes */ +.btn-sm { + padding: 6px var(--space-3); + font-size: 0.8125rem; + border-radius: var(--radius-sm); +} + +.btn-lg { + padding: var(--space-3) var(--space-6); + font-size: 0.9375rem; + border-radius: var(--radius-lg); +} + +.btn-icon { + width: 2.25rem; + height: 2.25rem; + padding: 0; + border-radius: var(--radius-md); +} + +.btn-icon svg { + width: 18px; + height: 18px; + transition: transform var(--duration-fast) var(--ease-spring); +} + +.btn-icon:hover:not(:disabled) svg { + transform: scale(1.1); +} + +/* ============================================ + INPUTS + ============================================ */ + +.input { + width: 100%; + padding: 10px var(--space-3); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.875rem; + line-height: 1.4; + transition: + border-color var(--duration-fast) var(--ease-default), + box-shadow var(--duration-normal) var(--ease-spring), + background-color var(--duration-fast) var(--ease-default); +} + +.input:hover:not(:focus) { + border-color: var(--text-tertiary); +} + +.input:focus { + outline: none; + border-color: var(--primary-color); + background: var(--bg-secondary); + box-shadow: + 0 0 0 3px var(--primary-subtle), + var(--shadow-sm); +} + +.input::placeholder { + color: var(--text-tertiary); + opacity: 1; +} + +/* Search bar with icon */ +.search-bar { + position: relative; + margin-bottom: var(--space-3); +} + +.search-bar .input { + padding-left: var(--space-10); +} + +.search-bar::before { + content: ''; + position: absolute; + left: var(--space-3); + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + pointer-events: none; + transition: opacity var(--duration-fast) var(--ease-default); +} + +.search-bar:focus-within::before { + opacity: 0.6; +} + +/* ============================================ + FEATURE PANEL + ============================================ */ + +.feature-panel { + position: absolute; + bottom: var(--space-5); + right: var(--space-5); + background: var(--panel-overlay-bg); + backdrop-filter: blur(var(--panel-backdrop-blur)); + -webkit-backdrop-filter: blur(var(--panel-backdrop-blur)); + border-radius: var(--radius-xl); + padding: var(--space-5); + min-width: 290px; + max-width: 340px; + border: 1px solid var(--border-color); + box-shadow: var(--shadow-xl); + transition: + background-color var(--duration-slow) var(--ease-default), + border-color var(--duration-slow) var(--ease-default), + transform var(--duration-normal) var(--ease-spring), + opacity var(--duration-normal) var(--ease-default); +} + +/* Entry animation */ +.feature-panel[data-animate] { + animation: panel-enter var(--duration-slow) var(--ease-spring) forwards; +} + +@keyframes panel-enter { + from { + opacity: 0; + transform: translateY(16px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.feature-panel h3 { + font-size: 0.6875rem; + font-weight: 600; + margin-bottom: var(--space-4); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.08em; + display: flex; + align-items: center; + gap: var(--space-2); +} + +.feature-panel h3::after { + content: ''; + flex: 1; + height: 1px; + background: linear-gradient(90deg, var(--border-color) 0%, transparent 100%); + margin-left: var(--space-2); +} + +.feature-section { + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--border-subtle); + margin-bottom: var(--space-4); +} + +.feature-section:last-child { + padding-bottom: 0; + border-bottom: none; + margin-bottom: 0; +} + +.feature-section h4 { + font-size: 0.8125rem; + font-weight: 550; + margin-bottom: var(--space-3); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; +} + +/* ============================================ + SLIDER + ============================================ */ + +.slider-container { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.slider { + --slider-progress: 0%; + flex: 1; + -webkit-appearance: none; + height: 5px; + border-radius: var(--radius-full); + background: + linear-gradient(to right, + var(--primary-color) 0%, + var(--primary-color) var(--slider-progress), + var(--bg-tertiary) var(--slider-progress), + var(--bg-tertiary) 100% + ); + cursor: pointer; + transition: background var(--duration-fast) var(--ease-default); +} + +.slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--bg-secondary); + border: 2px solid var(--primary-color); + cursor: grab; + box-shadow: var(--shadow-sm); + transition: + transform var(--duration-fast) var(--ease-spring), + box-shadow var(--duration-fast) var(--ease-default), + border-color var(--duration-fast) var(--ease-default); +} + +.slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: var(--shadow-md), 0 0 0 4px var(--primary-subtle); +} + +.slider::-webkit-slider-thumb:active { + cursor: grabbing; + transform: scale(1.1); + border-color: var(--primary-hover); +} + +.slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--bg-secondary); + border: 2px solid var(--primary-color); + cursor: grab; + box-shadow: var(--shadow-sm); +} + +.slider::-moz-range-thumb:hover { + transform: scale(1.15); +} + +.slider::-moz-range-progress { + background: var(--primary-color); + border-radius: var(--radius-full); + height: 5px; +} + +.slider-value { + min-width: 40px; + text-align: right; + font-size: 0.75rem; + font-family: var(--font-mono); + font-weight: 500; + color: var(--text-secondary); + padding: 2px 6px; + background: var(--bg-tertiary); + border-radius: var(--radius-sm); +} + +/* ============================================ + AXIS TOGGLES + ============================================ */ + +.axis-toggles { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-3); + padding: 3px; + background: var(--bg-tertiary); + border-radius: var(--radius-lg); +} + +.axis-btn { + position: relative; + flex: 1; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + font-size: 0.75rem; + font-weight: 600; + font-family: var(--font-mono); + letter-spacing: 0.02em; + background: transparent; + color: var(--text-secondary); + border: none; + cursor: pointer; + z-index: 1; + transition: + color var(--duration-fast) var(--ease-default), + transform var(--duration-fast) var(--ease-spring); +} + +.axis-btn::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + opacity: 0; + transition: opacity var(--duration-fast) var(--ease-default); + z-index: -1; +} + +.axis-btn:hover:not(.active) { + color: var(--axis-color); +} + +.axis-btn:hover:not(.active)::before { + background: var(--axis-color); + opacity: 0.1; +} + +.axis-btn:active { + transform: scale(0.95); +} + +.axis-btn.x { --axis-color: var(--axis-x); } +.axis-btn.y { --axis-color: var(--axis-y); } +.axis-btn.z { --axis-color: var(--axis-z); } + +.axis-btn.active { + color: var(--text-inverted); +} + +.axis-btn.active::before { + background: var(--axis-color); + opacity: 1; + box-shadow: 0 2px 8px color-mix(in srgb, var(--axis-color) 40%, transparent); +} + +/* ============================================ + TOGGLE SWITCH + ============================================ */ + +.toggle-label { + display: flex; + align-items: center; + gap: var(--space-3); + cursor: pointer; + user-select: none; + padding: var(--space-1) 0; +} + +.toggle-switch { + position: relative; + width: 40px; + height: 22px; + background: var(--bg-sunken); + border-radius: var(--radius-full); + transition: + background-color var(--duration-normal) var(--ease-default), + box-shadow var(--duration-fast) var(--ease-default); + flex-shrink: 0; +} + +.toggle-switch::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + background: var(--bg-secondary); + border-radius: 50%; + box-shadow: var(--shadow-sm); + transition: + transform var(--duration-normal) var(--ease-spring), + width var(--duration-fast) var(--ease-default); +} + +.toggle-checkbox { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.toggle-checkbox:checked + .toggle-switch { + background: var(--gradient-primary); + box-shadow: 0 0 12px var(--primary-glow); +} + +.toggle-checkbox:checked + .toggle-switch::after { + transform: translateX(18px); +} + +.toggle-label:hover .toggle-switch { + box-shadow: 0 0 0 3px var(--primary-subtle); +} + +.toggle-label:active .toggle-switch::after { + width: 20px; +} + +.toggle-checkbox:checked + .toggle-switch + .toggle-text { + color: var(--text-primary); +} + +.toggle-text { + font-size: 0.875rem; + color: var(--text-secondary); + transition: color var(--duration-fast) var(--ease-default); +} + +.toggle-label:hover .toggle-text { + color: var(--text-primary); +} + +/* ============================================ + UPLOAD AREA + ============================================ */ + +.upload-area { + position: relative; + border: 2px dashed var(--border-color); + border-radius: var(--radius-xl); + padding: var(--space-8); + text-align: center; + cursor: pointer; + transition: + border-color var(--duration-fast) var(--ease-default), + background-color var(--duration-fast) var(--ease-default), + transform var(--duration-normal) var(--ease-spring); + margin-bottom: var(--space-3); + background: var(--bg-primary); + overflow: hidden; +} + +.upload-area::before { + content: ''; + position: absolute; + inset: 0; + background: var(--gradient-primary); + opacity: 0; + transition: opacity var(--duration-normal) var(--ease-default); +} + +.upload-area:hover { + border-color: var(--primary-color); + transform: scale(1.01); +} + +.upload-area:hover::before { + opacity: 0.04; +} + +.upload-area.dragover { + border-color: var(--primary-color); + border-style: solid; + transform: scale(1.02); +} + +.upload-area.dragover::before { + opacity: 0.08; +} + +.upload-area p { + position: relative; + color: var(--text-secondary); + font-size: 0.9375rem; + font-weight: 500; + z-index: 1; +} + +/* ============================================ + EMPTY STATE + ============================================ */ + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + gap: var(--space-2); +} + +.empty-state p { + font-size: 1rem; +} + +.empty-state .text-sm { + font-size: 0.875rem; + color: var(--text-tertiary); +} + +/* ============================================ + LOADING + ============================================ */ + +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.spinner { + width: 2.5rem; + height: 2.5rem; + border: 3px solid var(--bg-tertiary); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Skeleton loader */ +.skeleton { + background: linear-gradient( + 90deg, + var(--bg-tertiary) 25%, + var(--bg-secondary) 50%, + var(--bg-tertiary) 75% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s infinite; + border-radius: var(--radius-sm); +} + +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ============================================ + CONTEXT MENU + ============================================ */ + +.context-menu { + position: fixed; + z-index: 1000; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: var(--space-1); + min-width: 180px; + box-shadow: var(--shadow-xl); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + animation: context-menu-enter var(--duration-fast) var(--ease-spring) forwards; + transform-origin: top left; +} + +@keyframes context-menu-enter { + from { + opacity: 0; + transform: scale(0.9) translateY(-4px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.context-menu-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 450; + color: var(--text-primary); + cursor: pointer; + transition: + background-color var(--duration-fast) var(--ease-default), + color var(--duration-fast) var(--ease-default), + transform var(--duration-fast) var(--ease-spring); +} + +.context-menu-item:hover { + background: var(--bg-tertiary); + color: var(--primary-color); +} + +.context-menu-item:active { + transform: scale(0.98); +} + +.context-menu-item svg { + width: 16px; + height: 16px; + color: var(--text-secondary); + transition: color var(--duration-fast) var(--ease-default); +} + +.context-menu-item:hover svg { + color: var(--primary-color); +} + +/* ============================================ + ACCESSIBILITY - REDUCED MOTION + ============================================ */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ============================================ + UTILITY CLASSES + ============================================ */ + +.text-xs { font-size: 0.6875rem; } +.text-sm { font-size: 0.8125rem; } +.text-base { font-size: 0.875rem; } + +.text-primary { color: var(--text-primary); } +.text-secondary { color: var(--text-secondary); } +.text-tertiary { color: var(--text-tertiary); } + +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } + +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.gap-1 { gap: var(--space-1); } +.gap-2 { gap: var(--space-2); } +.gap-3 { gap: var(--space-3); } diff --git a/frontend/src/types/model.ts b/frontend/src/types/model.ts new file mode 100644 index 0000000..88979eb --- /dev/null +++ b/frontend/src/types/model.ts @@ -0,0 +1,71 @@ +export interface Model { + id: string + name: string + original_filename: string + original_format: string + file_size: number + raw_storage_key: string | null + converted_storage_key: string | null + thumbnail_storage_key: string | null + model_url: string | null + thumbnail_url: string | null + conversion_status: ConversionStatus + conversion_error: string | null + metadata: ModelMetadata + created_at: string + updated_at: string +} + +export type ConversionStatus = 'pending' | 'processing' | 'completed' | 'failed' + +export interface ModelMetadata { + vertices?: number + faces?: number + parts_count?: number + bounding_box?: BoundingBox + parts?: ModelPartMeta[] + [key: string]: unknown +} + +export interface BoundingBox { + min: { x: number; y: number; z: number } + max: { x: number; y: number; z: number } +} + +export interface ModelPartMeta { + name: string + bounding_box?: BoundingBox + center_point?: { x: number; y: number; z: number } +} + +export interface ModelPart { + id: string + model_id: string + name: string | null + mesh_index: number | null + bounding_box: BoundingBox + center_point: { x: number; y: number; z: number } + parent_part_id: string | null + created_at: string +} + +export interface UploadInitResponse { + uploadUrl: string + modelId: string + storageKey: string +} + +export interface ApiResponse { + success: boolean + data?: T + error?: { + code: string + message: string + details?: unknown + } + meta?: { + total?: number + limit?: number + offset?: number + } +} diff --git a/frontend/src/types/partsTree.ts b/frontend/src/types/partsTree.ts new file mode 100644 index 0000000..701c66b --- /dev/null +++ b/frontend/src/types/partsTree.ts @@ -0,0 +1,40 @@ +import type * as THREE from 'three' + +/** + * Represents a node in the parts tree hierarchy + */ +export interface TreeNode { + /** Unique identifier (THREE.Object3D.uuid) */ + id: string + /** Display name (object.name or fallback) */ + name: string + /** Nesting level for indentation */ + depth: number + /** Own visibility state */ + visible: boolean + /** Original material opacity (for restore) */ + originalOpacity: number + /** UI expansion state */ + isExpanded: boolean + /** Total number of descendants */ + childCount: number + /** Reference to Three.js object */ + object: THREE.Object3D + /** Child nodes */ + children: TreeNode[] +} + +/** + * Flattened tree node for virtual scrolling + */ +export interface FlatTreeNode { + id: string + name: string + depth: number + visible: boolean + originalOpacity: number + isExpanded: boolean + hasChildren: boolean + childCount: number + object: THREE.Object3D +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..b54b4c9 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/src/workers/sectionCapWorker.ts b/frontend/src/workers/sectionCapWorker.ts new file mode 100644 index 0000000..e191cd2 --- /dev/null +++ b/frontend/src/workers/sectionCapWorker.ts @@ -0,0 +1,260 @@ +/** + * Web Worker for section cap geometry calculation + * Receives pre-computed intersection segments from main thread + * Performs contour building and triangulation off the main thread + * + * No Three.js or three-mesh-bvh dependencies - pure math only + */ + +/* eslint-disable no-restricted-globals */ +import earcut from 'earcut' + +type Axis = 'x' | 'y' | 'z' +type Vec3 = [number, number, number] + +interface WorkerInput { + segments: Float32Array // Packed: [x1,y1,z1, x2,y2,z2, ...] (6 floats per segment) + axis: Axis + requestId: number // Unique request ID to match responses +} + +interface GeometryResult { + vertices: Float32Array + indices: Uint32Array +} + +interface WorkerOutput extends GeometryResult { + axis: Axis // Echo back axis + requestId: number // Echo back requestId +} + +// Parse packed Float32Array into segment array +function parseSegments(packed: Float32Array): Array<[Vec3, Vec3]> { + const segments: Array<[Vec3, Vec3]> = [] + const count = packed.length / 6 + + for (let i = 0; i < count; i++) { + const offset = i * 6 + segments.push([ + [packed[offset], packed[offset + 1], packed[offset + 2]], + [packed[offset + 3], packed[offset + 4], packed[offset + 5]] + ]) + } + + return segments +} + +function vec3Distance(a: Vec3, b: Vec3): number { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + const dz = a[2] - b[2] + return Math.sqrt(dx * dx + dy * dy + dz * dz) +} + +function vec3Clone(v: Vec3): Vec3 { + return [v[0], v[1], v[2]] +} + +/** + * Build closed contours from line segments using spatial hashing + * O(n) instead of O(n²) for finding connecting segments + */ +function buildContoursWithSpatialHash(segments: Array<[Vec3, Vec3]>): Vec3[][] { + if (segments.length === 0) return [] + + const epsilon = 1e-4 + const cellSize = epsilon * 10 + + interface EndpointEntry { + segIdx: number + pointIdx: 0 | 1 + point: Vec3 + } + const hash = new Map() + + const getKey = (p: Vec3): string => { + const x = Math.floor(p[0] / cellSize) + const y = Math.floor(p[1] / cellSize) + const z = Math.floor(p[2] / cellSize) + return `${x},${y},${z}` + } + + // Index all segment endpoints + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + for (const pointIdx of [0, 1] as const) { + const point = seg[pointIdx] + const key = getKey(point) + if (!hash.has(key)) hash.set(key, []) + hash.get(key)!.push({ segIdx: i, pointIdx, point }) + } + } + + // O(1) average lookup for connecting point + const findConnecting = (point: Vec3, used: Set): { segIdx: number; startEnd: 0 | 1 } | null => { + const cx = Math.floor(point[0] / cellSize) + const cy = Math.floor(point[1] / cellSize) + const cz = Math.floor(point[2] / cellSize) + + let bestDist = epsilon + let best: { segIdx: number; startEnd: 0 | 1 } | null = null + + // Check 3x3x3 neighborhood + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + for (let dz = -1; dz <= 1; dz++) { + const key = `${cx + dx},${cy + dy},${cz + dz}` + const entries = hash.get(key) + if (!entries) continue + + for (const entry of entries) { + if (used.has(entry.segIdx)) continue + const d = vec3Distance(point, entry.point) + if (d < bestDist) { + bestDist = d + best = { segIdx: entry.segIdx, startEnd: entry.pointIdx } + } + } + } + } + } + return best + } + + // Build contours by connecting segments + const contours: Vec3[][] = [] + const used = new Set() + + while (used.size < segments.length) { + let startIdx = -1 + for (let i = 0; i < segments.length; i++) { + if (!used.has(i)) { + startIdx = i + break + } + } + if (startIdx === -1) break + + const contour: Vec3[] = [] + used.add(startIdx) + contour.push(vec3Clone(segments[startIdx][0])) + contour.push(vec3Clone(segments[startIdx][1])) + + let extended = true + while (extended) { + extended = false + + const endPoint = contour[contour.length - 1] + const nextEnd = findConnecting(endPoint, used) + if (nextEnd) { + used.add(nextEnd.segIdx) + const seg = segments[nextEnd.segIdx] + const newPoint = nextEnd.startEnd === 0 ? seg[1] : seg[0] + contour.push(vec3Clone(newPoint)) + extended = true + } + + const startPoint = contour[0] + const nextStart = findConnecting(startPoint, used) + if (nextStart) { + used.add(nextStart.segIdx) + const seg = segments[nextStart.segIdx] + const newPoint = nextStart.startEnd === 0 ? seg[1] : seg[0] + contour.unshift(vec3Clone(newPoint)) + extended = true + } + } + + if (contour.length >= 3) { + contours.push(contour) + } + } + + return contours +} + +/** + * Create cap geometry from contours by triangulating in 2D + */ +function createCapGeometryFromContours( + contours: Vec3[][], + axis: Axis +): GeometryResult { + if (contours.length === 0) { + return { + vertices: new Float32Array(0), + indices: new Uint32Array(0) + } + } + + const allVertices: number[] = [] + const allIndices: number[] = [] + let vertexOffset = 0 + + for (const contour of contours) { + if (contour.length < 3) continue + + // Project 3D points to 2D based on axis + const coords2D: number[] = [] + for (const p of contour) { + switch (axis) { + case 'x': + coords2D.push(p[2], p[1]) + break + case 'y': + coords2D.push(p[0], p[2]) + break + case 'z': + coords2D.push(p[0], p[1]) + break + } + } + + // Triangulate the 2D polygon + const indices = earcut(coords2D) + + // Add vertices (3D) + for (const p of contour) { + allVertices.push(p[0], p[1], p[2]) + } + + // Add indices with offset + for (const idx of indices) { + allIndices.push(idx + vertexOffset) + } + + vertexOffset += contour.length + } + + return { + vertices: new Float32Array(allVertices), + indices: new Uint32Array(allIndices) + } +} + +// Worker message handler +const workerSelf = self as unknown as { + onmessage: ((e: MessageEvent) => void) | null + postMessage: (message: WorkerOutput, transfer?: Transferable[]) => void +} + +workerSelf.onmessage = (e: MessageEvent) => { + const { segments, axis, requestId } = e.data + const startTime = performance.now() + + // Parse packed segments + const segmentArray = parseSegments(segments) + + // Build contours with spatial hash (O(n)) + const contours = buildContoursWithSpatialHash(segmentArray) + + // Triangulate + const result = createCapGeometryFromContours(contours, axis) + + const elapsed = performance.now() - startTime + console.log(`[Worker] Contour + triangulate: ${segmentArray.length} segments → ${contours.length} contours in ${elapsed.toFixed(1)}ms`) + + // Include axis and requestId in response to match with original request + const response: WorkerOutput = { ...result, axis, requestId } + workerSelf.postMessage(response, [result.vertices.buffer, result.indices.buffer]) +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..964ccad --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..1a555ac --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.d.ts b/frontend/vite.config.d.ts new file mode 100644 index 0000000..340562a --- /dev/null +++ b/frontend/vite.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vite").UserConfig; +export default _default; diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..e5ffd24 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +var __dirname = dirname(fileURLToPath(import.meta.url)); +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, + build: { + target: 'esnext', + }, +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..cd8027c --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, + build: { + target: 'esnext', + }, +}) diff --git a/infrastructure/postgres/init.sql b/infrastructure/postgres/init.sql new file mode 100644 index 0000000..7823960 --- /dev/null +++ b/infrastructure/postgres/init.sql @@ -0,0 +1,94 @@ +-- 3D Model Viewer Database Schema +-- PostgreSQL 16 + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Models table: stores metadata about uploaded 3D models +CREATE TABLE models ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + original_filename VARCHAR(255) NOT NULL, + original_format VARCHAR(10) NOT NULL, + file_size BIGINT NOT NULL, + raw_storage_key TEXT, + converted_storage_key TEXT, + thumbnail_storage_key TEXT, + model_url TEXT, + thumbnail_url TEXT, + conversion_status VARCHAR(20) DEFAULT 'pending' + CHECK (conversion_status IN ('pending', 'processing', 'completed', 'failed')), + conversion_error TEXT, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Model parts table: stores information about individual parts of a model +-- Used for exploded view feature +CREATE TABLE model_parts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, + name VARCHAR(255), + mesh_index INTEGER, + bounding_box JSONB NOT NULL, -- {min: {x,y,z}, max: {x,y,z}} + center_point JSONB NOT NULL, -- {x, y, z} + parent_part_id UUID REFERENCES model_parts(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for common queries +CREATE INDEX idx_models_status ON models(conversion_status); +CREATE INDEX idx_models_created ON models(created_at DESC); +CREATE INDEX idx_models_name ON models(name); +CREATE INDEX idx_models_format ON models(original_format); +CREATE INDEX idx_model_parts_model ON model_parts(model_id); +CREATE INDEX idx_model_parts_parent ON model_parts(parent_part_id); + +-- Full-text search index on model name +CREATE INDEX idx_models_name_search ON models USING gin(to_tsvector('english', name)); + +-- Updated_at trigger function +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Apply updated_at trigger to models table +CREATE TRIGGER models_updated_at + BEFORE UPDATE ON models + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Helper function to search models by name +CREATE OR REPLACE FUNCTION search_models(search_query TEXT) +RETURNS SETOF models AS $$ +BEGIN + RETURN QUERY + SELECT * + FROM models + WHERE to_tsvector('english', name) @@ plainto_tsquery('english', search_query) + OR name ILIKE '%' || search_query || '%' + ORDER BY created_at DESC; +END; +$$ LANGUAGE plpgsql; + +-- View for models with part counts +CREATE VIEW models_with_parts AS +SELECT + m.*, + COALESCE(p.part_count, 0) AS part_count +FROM models m +LEFT JOIN ( + SELECT model_id, COUNT(*) AS part_count + FROM model_parts + GROUP BY model_id +) p ON m.id = p.model_id; + +-- Grant permissions (for production, you might want more restricted permissions) +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO viewer; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO viewer; +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO viewer; diff --git a/milestones.csv b/milestones.csv new file mode 100644 index 0000000..d115d7e --- /dev/null +++ b/milestones.csv @@ -0,0 +1,4 @@ +里程碑ID,里程碑名称,目标日期,交付物,状态,完成百分比 +M1,基础架构完成,2024-12-06,Docker环境 + API框架 + 前端框架,已完成,100% +M2,核心功能可用,2024-12-13,上传→转换→查看 完整流程,进行中,20% +M3,MVP 发布,2024-12-20,完整功能 + 测试通过 + 文档,待开始,0% diff --git a/project-management.csv b/project-management.csv new file mode 100644 index 0000000..91c653d --- /dev/null +++ b/project-management.csv @@ -0,0 +1,27 @@ +任务ID,任务名称,负责人,阶段,优先级,状态,预计工时(天),开始日期,截止日期,依赖项,备注 +1.1,Docker 环境搭建,后端,第一周,P0,已完成,1,2024-12-02,2024-12-02,-,PostgreSQL/Redis/MinIO +1.2,数据库 Schema 设计,后端,第一周,P0,已完成,0.5,2024-12-02,2024-12-02,1.1,models/model_parts表 +1.3,API 服务框架,后端,第一周,P0,已完成,1.5,2024-12-02,2024-12-03,1.1,Express + TypeScript +1.4,MinIO 存储服务,后端,第一周,P0,已完成,1,2024-12-03,2024-12-04,1.1,预签名URL上传 +1.5,BullMQ 队列服务,后端,第一周,P0,已完成,0.5,2024-12-04,2024-12-04,1.1,任务队列 +1.6,Python Worker 框架,后端,第一周,P0,已完成,1,2024-12-04,2024-12-05,1.5,模型转换基础 +1.7,Vue 前端框架,前端,第一周,P0,已完成,1,2024-12-02,2024-12-03,-,Vue3 + Vite + Pinia +1.8,3D Viewer 组件,前端,第一周,P0,已完成,1.5,2024-12-03,2024-12-05,1.7,Online3DViewer集成 +1.9,爆炸图服务,前端,第一周,P1,已完成,1,2024-12-05,2024-12-06,1.8,ExplodeService +1.10,剖面服务,前端,第一周,P1,已完成,1,2024-12-05,2024-12-06,1.8,ClippingService +2.1,STEP 文件转换,后端,第二周,P0,进行中,2,2024-12-09,2024-12-10,1.6,cascadio → GLB +2.2,STL/OBJ 转换,后端,第二周,P0,待开始,1,2024-12-10,2024-12-11,1.6,trimesh → GLB +2.3,缩略图生成,后端,第二周,P1,待开始,1.5,2024-12-11,2024-12-12,2.1,pyrender + OSMesa +2.4,模型上传流程,前端,第二周,P0,待开始,1,2024-12-09,2024-12-10,1.4,拖拽上传 + 进度条 +2.5,模型列表页,前端,第二周,P0,待开始,1.5,2024-12-10,2024-12-11,2.4,缩略图 + 搜索 + 筛选 +2.6,模型详情页,前端,第二周,P0,待开始,1,2024-12-11,2024-12-12,2.5,查看 + 编辑 + 删除 +2.7,爆炸图 UI 完善,前端,第二周,P1,待开始,1,2024-12-12,2024-12-13,1.9,滑块 + 动画效果 +2.8,剖面 UI 完善,前端,第二周,P1,待开始,1,2024-12-12,2024-12-13,1.10,三轴控制 + 翻转 +3.1,错误处理完善,全栈,第三周,P0,待开始,1,2024-12-16,2024-12-16,2.*,统一错误格式 +3.2,加载状态优化,前端,第三周,P1,待开始,0.5,2024-12-16,2024-12-16,2.5,Skeleton + Loading +3.3,大模型性能优化,前端,第三周,P1,待开始,1.5,2024-12-17,2024-12-18,1.8,LOD + 懒加载 +3.4,Worker 并发优化,后端,第三周,P2,待开始,1,2024-12-17,2024-12-18,1.6,资源限制 +3.5,UI 样式美化,前端,第三周,P2,待开始,1,2024-12-18,2024-12-19,2.*,响应式 + 动效 +3.6,E2E 测试,QA,第三周,P1,待开始,1,2024-12-19,2024-12-20,3.*,Playwright +3.7,文档编写,全栈,第三周,P2,待开始,0.5,2024-12-20,2024-12-20,全部,README + API文档 +3.8,部署脚本,DevOps,第三周,P1,待开始,0.5,2024-12-20,2024-12-20,全部,CI/CD配置 diff --git a/risks.csv b/risks.csv new file mode 100644 index 0000000..772e5e6 --- /dev/null +++ b/risks.csv @@ -0,0 +1,5 @@ +风险ID,风险描述,发生概率,影响程度,风险等级,缓解措施,负责人,状态 +R1,STEP 转换失败率高,中,高,高,多引擎回退 (cascadio → OpenCASCADE),后端,监控中 +R2,大文件上传超时,中,中,中,分片上传 + 断点续传,全栈,待处理 +R3,3D渲染性能问题,低,中,低,WebGL降级 + LOD,前端,待处理 +R4,依赖包安全漏洞,低,高,中,定期 npm audit / pip check,DevOps,监控中 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..c173048 --- /dev/null +++ b/start.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# 3D Viewer Startup Script +# Automatically detects the host IP address for network access + +set -e + +# Detect the host IP address +detect_ip() { + local ip="" + + # macOS + if [[ "$OSTYPE" == "darwin"* ]]; then + # Try en0 (Wi-Fi) first, then en1 (Ethernet) + ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "") + # Linux + else + # Get the first non-localhost IP + ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "") + fi + + # Fallback to localhost if no IP found + if [[ -z "$ip" ]]; then + ip="localhost" + fi + + echo "$ip" +} + +# Main +export HOST_IP=$(detect_ip) + +echo "========================================" +echo " 3D Viewer Startup" +echo "========================================" +echo "" +echo "Detected HOST_IP: $HOST_IP" +echo "" + +# Check if we need to rebuild frontend (first time or --rebuild flag) +if [[ "$1" == "--rebuild" ]] || [[ "$1" == "-r" ]]; then + echo "Rebuilding frontend..." + docker compose build --no-cache frontend +fi + +# Stop existing containers +echo "Stopping existing containers..." +docker compose down + +# Start all services +echo "Starting services..." +docker compose up -d + +echo "" +echo "========================================" +echo " 3D Viewer is running!" +echo "========================================" +echo "" +echo " Local access: http://localhost" +echo " Network access: http://$HOST_IP" +echo "" +echo " MinIO Console: http://$HOST_IP:9001" +echo " API Health: http://$HOST_IP:4000/api/health" +echo "" +echo "To view logs: docker compose logs -f" +echo "To stop: docker compose down" +echo "" diff --git a/worker/Dockerfile b/worker/Dockerfile new file mode 100644 index 0000000..fa50b56 --- /dev/null +++ b/worker/Dockerfile @@ -0,0 +1,54 @@ +# Python Worker for 3D Model Conversion +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies for 3D processing and headless rendering +RUN apt-get update && apt-get install -y --no-install-recommends \ + # OpenGL/OSMesa for headless rendering (pyrender) + libosmesa6-dev \ + libgl1 \ + libglu1-mesa \ + # Build tools for some Python packages + build-essential \ + # OpenCASCADE runtime libraries for cascadio (STEP conversion) + libocct-data-exchange-7.8 \ + libocct-draw-7.8 \ + libocct-foundation-7.8 \ + libocct-modeling-algorithms-7.8 \ + libocct-modeling-data-7.8 \ + libocct-ocaf-7.8 \ + libocct-visualization-7.8 \ + # Cleanup + && rm -rf /var/lib/apt/lists/* + +# Set environment for headless rendering +ENV PYOPENGL_PLATFORM=osmesa + +# Install uv for fast package management +RUN pip install --no-cache-dir uv + +# Copy project files +COPY pyproject.toml . +COPY src/ src/ + +# Create __init__.py files if they don't exist +RUN touch src/__init__.py src/processors/__init__.py src/services/__init__.py + +# Install dependencies using uv +RUN uv pip install --system -e . + +# Create non-root user +RUN groupadd --system --gid 1001 worker && \ + useradd --system --uid 1001 --gid worker worker && \ + mkdir -p /tmp/conversions && \ + chown -R worker:worker /app /tmp/conversions + +# Switch to non-root user +USER worker + +# Set temp directory +ENV TEMP_DIR=/tmp/conversions + +# Run the worker +CMD ["python", "-m", "src.main"] diff --git a/worker/pyproject.toml b/worker/pyproject.toml new file mode 100644 index 0000000..a075780 --- /dev/null +++ b/worker/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "viewer3d-worker" +version = "1.0.0" +description = "3D Model Conversion Worker Service" +requires-python = ">=3.11" +dependencies = [ + "redis>=5.0.0", + "minio>=7.2.0", + "trimesh>=4.4.0", + "cascadio>=0.0.13", + "pillow>=10.0.0", + "pyrender>=0.1.45", + "pydantic-settings>=2.0.0", + "psycopg[binary]>=3.1.0", + "numpy>=1.24.0", + "fast-simplification>=0.1.7", +] + +[project.scripts] +worker = "src.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] diff --git a/worker/src/__init__.py b/worker/src/__init__.py new file mode 100644 index 0000000..e4c10e4 --- /dev/null +++ b/worker/src/__init__.py @@ -0,0 +1 @@ +# 3D Model Conversion Worker diff --git a/worker/src/config.py b/worker/src/config.py new file mode 100644 index 0000000..9ffd8f0 --- /dev/null +++ b/worker/src/config.py @@ -0,0 +1,49 @@ +"""Configuration management using Pydantic Settings.""" + +import os +from functools import lru_cache +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # Redis + redis_url: str = "redis://localhost:6379" + redis_stream: str = "bull:model-conversion:wait" + redis_consumer_group: str = "conversion-workers" + redis_consumer_name: str = f"worker-{os.getpid()}" + + # Database + database_url: str = "postgresql://viewer:viewer_password@localhost:5432/viewer_db" + + # MinIO + minio_endpoint: str = "localhost:9000" + minio_public_endpoint: str = "localhost:9000" # For public URLs (browser access) + minio_access_key: str = "minioadmin" + minio_secret_key: str = "minioadmin" + minio_use_ssl: bool = False + minio_bucket_raw: str = "raw-models" + minio_bucket_converted: str = "converted-models" + minio_bucket_thumbnails: str = "thumbnails" + + # Processing + temp_dir: str = "/tmp/conversions" + max_file_size_mb: int = 500 + thumbnail_size: tuple[int, int] = (256, 256) + + # Logging + log_level: str = "INFO" + + class Config: + env_file = ".env" + case_sensitive = False + + +@lru_cache +def get_settings() -> Settings: + """Get cached settings instance.""" + return Settings() + + +settings = get_settings() diff --git a/worker/src/main.py b/worker/src/main.py new file mode 100644 index 0000000..b221631 --- /dev/null +++ b/worker/src/main.py @@ -0,0 +1,463 @@ +""" +3D Model Conversion Worker + +Listens to BullMQ queue in Redis and processes model conversion jobs. +""" + +import json +import logging +import os +import signal +import sys +import time +from pathlib import Path +from typing import Any + +import psycopg +import redis + +from .config import settings +from .processors.converter import convert_to_glb, convert_to_glb_with_lod +from .services.storage import download_file, upload_file +from .services.thumbnail import generate_thumbnail + +# Configure logging +logging.basicConfig( + level=getattr(logging, settings.log_level), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger(__name__) + +# Graceful shutdown flag +shutdown_requested = False + + +def signal_handler(signum, frame): + """Handle shutdown signals.""" + global shutdown_requested + logger.info(f"Received signal {signum}, initiating graceful shutdown...") + shutdown_requested = True + + +def get_redis_client() -> redis.Redis: + """Create Redis client.""" + return redis.from_url(settings.redis_url, decode_responses=True) + + +def get_db_connection() -> psycopg.Connection: + """Create database connection.""" + return psycopg.connect(settings.database_url) + + +def update_thumbnail_only( + model_id: str, + thumbnail_url: str, +) -> None: + """Update only the thumbnail URL in the database (for thumbnail-only jobs).""" + with get_db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE models + SET thumbnail_url = %s, + thumbnail_storage_key = %s, + updated_at = NOW() + WHERE id = %s + """, + ( + thumbnail_url, + f"{model_id}/preview.png", + model_id, + ), + ) + conn.commit() + + logger.info(f"Updated thumbnail for model {model_id}") + + +def update_model_status( + model_id: str, + status: str, + model_url: str = None, + thumbnail_url: str = None, + metadata: dict = None, + error: str = None, + lod_urls: dict = None, +) -> None: + """Update model status in the database.""" + with get_db_connection() as conn: + with conn.cursor() as cur: + if status == 'completed': + # Include LOD URLs in metadata + full_metadata = metadata or {} + if lod_urls: + full_metadata['lod_urls'] = lod_urls + + cur.execute( + """ + UPDATE models + SET conversion_status = %s, + model_url = %s, + thumbnail_url = %s, + converted_storage_key = %s, + thumbnail_storage_key = %s, + metadata = %s, + updated_at = NOW() + WHERE id = %s + """, + ( + status, + model_url, + thumbnail_url, + f"{model_id}/model.glb", + f"{model_id}/preview.png", + json.dumps(full_metadata), + model_id, + ), + ) + elif status == 'failed': + cur.execute( + """ + UPDATE models + SET conversion_status = %s, + conversion_error = %s, + updated_at = NOW() + WHERE id = %s + """, + (status, error, model_id), + ) + else: + cur.execute( + """ + UPDATE models + SET conversion_status = %s, + updated_at = NOW() + WHERE id = %s + """, + (status, model_id), + ) + conn.commit() + + logger.info(f"Updated model {model_id} status to {status}") + + +def save_model_parts(model_id: str, parts: list[dict]) -> None: + """Save model parts to the database.""" + if not parts: + return + + with get_db_connection() as conn: + with conn.cursor() as cur: + for part in parts: + cur.execute( + """ + INSERT INTO model_parts (model_id, name, bounding_box, center_point) + VALUES (%s, %s, %s, %s) + """, + ( + model_id, + part.get('name', 'unnamed'), + json.dumps(part.get('bounding_box', {})), + json.dumps(part.get('center_point', {})), + ), + ) + conn.commit() + + logger.info(f"Saved {len(parts)} parts for model {model_id}") + + +def process_thumbnail_job(job_data: dict[str, Any]) -> dict[str, Any]: + """ + Process a thumbnail-only job for GLB/GLTF files. + + 1. Download GLB file from model URL + 2. Generate thumbnail + 3. Upload thumbnail to MinIO + 4. Update database with thumbnail URL + """ + model_id = job_data['modelId'] + model_url = job_data['modelUrl'] + + logger.info(f"Processing thumbnail job for model {model_id}") + + # Create temp directory for this job + temp_dir = Path(settings.temp_dir) / f"thumb_{model_id}" + temp_dir.mkdir(parents=True, exist_ok=True) + + try: + # 1. Download GLB file from model URL + import urllib.request + input_path = temp_dir / "input.glb" + + # model_url might be internal MinIO URL, convert to accessible URL + download_url = model_url + # If it's an internal URL, we need to use MinIO client instead + if 'minio:9000' in model_url or 'localhost:9000' in model_url: + # Extract bucket and key from URL + # URL format: http://minio:9000/bucket/key + from urllib.parse import urlparse + parsed = urlparse(model_url) + path_parts = parsed.path.lstrip('/').split('/', 1) + if len(path_parts) == 2: + bucket, key = path_parts + download_file(bucket, key, input_path) + else: + raise ValueError(f"Invalid model URL format: {model_url}") + else: + # External URL, download directly + urllib.request.urlretrieve(download_url, input_path) + + logger.info(f"Downloaded GLB file to {input_path}") + + # 2. Generate thumbnail + thumbnail_path = temp_dir / "preview.png" + generate_thumbnail(input_path, thumbnail_path) + logger.info(f"Generated thumbnail: {thumbnail_path}") + + # 3. Upload thumbnail to MinIO + thumbnail_key = f"{model_id}/preview.png" + thumbnail_url = upload_file( + thumbnail_path, + settings.minio_bucket_thumbnails, + thumbnail_key, + content_type="image/png", + ) + logger.info(f"Uploaded thumbnail: {thumbnail_key}") + + # 4. Update database with thumbnail URL + update_thumbnail_only(model_id, thumbnail_url) + + return { + 'modelId': model_id, + 'thumbnailUrl': thumbnail_url, + } + + except Exception as e: + logger.error(f"Thumbnail job failed for model {model_id}: {e}", exc_info=True) + raise + + finally: + # Cleanup temp files + import shutil + if temp_dir.exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + logger.debug(f"Cleaned up temp directory: {temp_dir}") + + +def process_job(job_data: dict[str, Any]) -> dict[str, Any]: + """ + Process a single conversion job with LOD support. + + 1. Download original file from MinIO + 2. Convert to GLB with multiple LOD levels + 3. Generate thumbnail + 4. Upload all LOD files to MinIO + 5. Update database + """ + # Check if this is a thumbnail-only job + job_type = job_data.get('jobType', 'conversion') + if job_type == 'thumbnail': + return process_thumbnail_job(job_data) + + model_id = job_data['modelId'] + storage_key = job_data['key'] + file_type = job_data['fileType'] + + logger.info(f"Processing job for model {model_id}, type: {file_type}") + + # Update status to processing + update_model_status(model_id, 'processing') + + # Create temp directory for this job + temp_dir = Path(settings.temp_dir) / model_id + temp_dir.mkdir(parents=True, exist_ok=True) + + try: + # 1. Download original file + input_path = temp_dir / f"input.{file_type}" + download_file(settings.minio_bucket_raw, storage_key, input_path) + logger.info(f"Downloaded input file to {input_path}") + + # 2. Convert to GLB with LOD levels + output_dir = temp_dir / "lod" + metadata = convert_to_glb_with_lod(input_path, output_dir, file_type, model_id) + logger.info(f"Converted to GLB with LOD: {output_dir}") + + # Get LOD files info + lod_files = metadata.get('lod_files', {'lod0': f'{model_id}_lod0.glb'}) + + # 3. Generate thumbnail from LOD0 (highest quality) + lod0_path = output_dir / lod_files['lod0'] + thumbnail_path = temp_dir / "preview.png" + generate_thumbnail(lod0_path, thumbnail_path) + logger.info(f"Generated thumbnail: {thumbnail_path}") + + # 4. Upload all LOD files to MinIO + lod_urls = {} + for lod_level, lod_filename in lod_files.items(): + lod_path = output_dir / lod_filename + if lod_path.exists(): + lod_key = f"{model_id}/{lod_filename}" + lod_url = upload_file( + lod_path, + settings.minio_bucket_converted, + lod_key, + content_type="model/gltf-binary", + ) + lod_urls[lod_level] = lod_url + logger.info(f"Uploaded {lod_level}: {lod_key}") + + # Also upload LOD0 as model.glb for backward compatibility + model_key = f"{model_id}/model.glb" + model_url = upload_file( + lod0_path, + settings.minio_bucket_converted, + model_key, + content_type="model/gltf-binary", + ) + + # Upload thumbnail + thumbnail_key = f"{model_id}/preview.png" + thumbnail_url = upload_file( + thumbnail_path, + settings.minio_bucket_thumbnails, + thumbnail_key, + content_type="image/png", + ) + + # 5. Save model parts if available + parts = metadata.get('parts', []) + if parts: + save_model_parts(model_id, parts) + + # 6. Update database with success (includes LOD URLs in metadata) + update_model_status( + model_id, + 'completed', + model_url=model_url, + thumbnail_url=thumbnail_url, + metadata=metadata, + lod_urls=lod_urls, + ) + + return { + 'modelId': model_id, + 'modelUrl': model_url, + 'thumbnailUrl': thumbnail_url, + 'lodUrls': lod_urls, + 'metadata': metadata, + } + + except Exception as e: + logger.error(f"Job failed for model {model_id}: {e}", exc_info=True) + update_model_status(model_id, 'failed', error=str(e)) + raise + + finally: + # Cleanup temp files + import shutil + if temp_dir.exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + logger.debug(f"Cleaned up temp directory: {temp_dir}") + + +def poll_jobs(redis_client: redis.Redis) -> None: + """ + Poll for jobs from the BullMQ queue. + + BullMQ stores jobs in Redis with a specific structure. + We use BRPOPLPUSH to atomically move jobs from wait to active. + """ + wait_key = "bull:model-conversion:wait" + active_key = "bull:model-conversion:active" + completed_key = "bull:model-conversion:completed" + + while not shutdown_requested: + try: + # Try to get a job (blocking with 5 second timeout) + job_id = redis_client.brpoplpush(wait_key, active_key, timeout=5) + + if job_id is None: + continue + + logger.info(f"Received job: {job_id}") + + # Get job data + job_key = f"bull:model-conversion:{job_id}" + job_json = redis_client.hget(job_key, "data") + + if not job_json: + logger.warning(f"No data found for job {job_id}") + redis_client.lrem(active_key, 1, job_id) + continue + + job_data = json.loads(job_json) + + # Process the job + try: + result = process_job(job_data) + + # Mark job as completed + redis_client.hset(job_key, "returnvalue", json.dumps(result)) + redis_client.hset(job_key, "finishedOn", str(int(time.time() * 1000))) + redis_client.lrem(active_key, 1, job_id) + redis_client.lpush(completed_key, job_id) + + logger.info(f"Job {job_id} completed successfully") + + except Exception as e: + # Mark job as failed + redis_client.hset(job_key, "failedReason", str(e)) + redis_client.hset(job_key, "finishedOn", str(int(time.time() * 1000))) + redis_client.lrem(active_key, 1, job_id) + + # Move to failed queue + failed_key = "bull:model-conversion:failed" + redis_client.lpush(failed_key, job_id) + + logger.error(f"Job {job_id} failed: {e}") + + except redis.exceptions.ConnectionError as e: + logger.error(f"Redis connection error: {e}") + time.sleep(5) + + except Exception as e: + logger.error(f"Unexpected error in job polling: {e}", exc_info=True) + time.sleep(1) + + +def main(): + """Main entry point for the worker.""" + logger.info("Starting 3D Model Conversion Worker") + logger.info(f"Redis URL: {settings.redis_url}") + logger.info(f"MinIO endpoint: {settings.minio_endpoint}") + + # Setup signal handlers + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + # Create temp directory + temp_dir = Path(settings.temp_dir) + temp_dir.mkdir(parents=True, exist_ok=True) + + # Connect to Redis + redis_client = get_redis_client() + + # Test connection + try: + redis_client.ping() + logger.info("Connected to Redis") + except redis.exceptions.ConnectionError as e: + logger.fatal(f"Failed to connect to Redis: {e}") + sys.exit(1) + + # Start polling for jobs + logger.info("Worker ready, polling for jobs...") + poll_jobs(redis_client) + + logger.info("Worker shutdown complete") + + +if __name__ == "__main__": + main() diff --git a/worker/src/processors/__init__.py b/worker/src/processors/__init__.py new file mode 100644 index 0000000..3e86415 --- /dev/null +++ b/worker/src/processors/__init__.py @@ -0,0 +1 @@ +# Model processors diff --git a/worker/src/processors/converter.py b/worker/src/processors/converter.py new file mode 100644 index 0000000..c36ba6e --- /dev/null +++ b/worker/src/processors/converter.py @@ -0,0 +1,391 @@ +"""3D model conversion processor with LOD support.""" + +import logging +from pathlib import Path +from typing import Any + +import numpy as np +import trimesh + +from ..config import settings + +logger = logging.getLogger(__name__) + +# LOD configuration: level -> face ratio (for non-STEP files) +LOD_LEVELS = { + 0: 1.0, # LOD0: 100% faces (original) + 1: 0.5, # LOD1: 50% faces + 2: 0.25, # LOD2: 25% faces +} + +# LOD tessellation parameters for STEP files (cascadio) +# Higher values = coarser mesh = fewer triangles +LOD_TESSELLATION = { + 0: {'tol_linear': 0.01, 'tol_angular': 0.5}, # High quality (default) + 1: {'tol_linear': 0.1, 'tol_angular': 1.0}, # Medium quality + 2: {'tol_linear': 0.5, 'tol_angular': 2.0}, # Low quality (for preview) +} + + +def convert_to_glb(input_path: Path, output_path: Path, file_type: str) -> dict[str, Any]: + """ + Convert a 3D model to GLB format with LOD support. + + Supports: STEP, STL, OBJ, and other formats via trimesh/cascadio. + + Returns metadata about the converted model including LOD file paths. + """ + file_type = file_type.lower() + + # Ensure output directory exists + output_path.parent.mkdir(parents=True, exist_ok=True) + + if file_type in ('step', 'stp'): + return _convert_step(input_path, output_path) + else: + return _convert_with_trimesh(input_path, output_path, file_type) + + +def convert_to_glb_with_lod(input_path: Path, output_dir: Path, file_type: str, model_id: str) -> dict[str, Any]: + """ + Convert a 3D model to GLB format with multiple LOD levels. + + For STEP files: Generate each LOD directly from source with different tessellation precision. + For other files: Generate LOD0 then simplify for other levels. + + Args: + input_path: Path to input file + output_dir: Directory to save LOD files + file_type: File extension (step, stl, obj, etc.) + model_id: Unique model identifier for file naming + + Returns: + Metadata including LOD file paths and statistics + """ + file_type = file_type.lower() + output_dir.mkdir(parents=True, exist_ok=True) + + lod_files = {} + + # STEP files: Generate each LOD with different tessellation precision + if file_type in ('step', 'stp'): + return _convert_step_with_lod(input_path, output_dir, model_id) + + # Non-STEP files: Use post-processing simplification + return _convert_other_with_lod(input_path, output_dir, file_type, model_id) + + +def _convert_step_with_lod(input_path: Path, output_dir: Path, model_id: str) -> dict[str, Any]: + """ + Convert STEP file to GLB with multiple LOD levels using different tessellation precision. + + This is more effective than post-processing simplification because it controls + mesh generation at the source. + """ + lod_files = {} + metadata = None + + for level, params in LOD_TESSELLATION.items(): + lod_path = output_dir / f"{model_id}_lod{level}.glb" + + try: + level_metadata = _convert_step( + input_path, + lod_path, + tol_linear=params['tol_linear'], + tol_angular=params['tol_angular'], + ) + + lod_files[f'lod{level}'] = str(lod_path.name) + faces = level_metadata.get('faces', 0) + logger.info(f"Generated LOD{level} with {faces:,} faces (tol_linear={params['tol_linear']})") + + # Use LOD0 metadata as the primary metadata + if level == 0: + metadata = level_metadata + + except Exception as e: + logger.error(f"Failed to generate LOD{level}: {e}") + # Fall back to LOD0 if available + if 'lod0' in lod_files: + lod_files[f'lod{level}'] = lod_files['lod0'] + + # If LOD0 failed, raise error + if metadata is None: + raise RuntimeError("Failed to convert STEP file") + + # Add LOD info to metadata + metadata['lod_files'] = lod_files + metadata['lod_levels'] = len(set(lod_files.values())) + + return metadata + + +def _convert_other_with_lod(input_path: Path, output_dir: Path, file_type: str, model_id: str) -> dict[str, Any]: + """ + Convert non-STEP files to GLB with LOD using post-processing simplification. + """ + # LOD0 path (original quality) + lod0_path = output_dir / f"{model_id}_lod0.glb" + + # Convert to LOD0 + metadata = _convert_with_trimesh(input_path, lod0_path, file_type) + + lod_files = { + 'lod0': str(lod0_path.name), + } + + # Get face count for LOD generation decision + total_faces = metadata.get('faces', 0) + + # Only generate LODs if model has enough faces + if total_faces > 1000: + try: + # Generate LOD1 and LOD2 using mesh simplification + for level in [1, 2]: + lod_path = output_dir / f"{model_id}_lod{level}.glb" + ratio = LOD_LEVELS[level] + + # Reload mesh fresh for each LOD level + mesh = trimesh.load(str(lod0_path)) + + simplified = _simplify_mesh(mesh, ratio) + if simplified is not None: + simplified.export(str(lod_path), file_type='glb') + lod_files[f'lod{level}'] = str(lod_path.name) + logger.info(f"Generated LOD{level} with {ratio*100:.0f}% faces: {lod_path.name}") + else: + logger.warning(f"Failed to generate LOD{level}, using LOD0") + lod_files[f'lod{level}'] = lod_files['lod0'] + + except Exception as e: + logger.warning(f"LOD generation failed: {e}, using LOD0 for all levels") + lod_files['lod1'] = lod_files['lod0'] + lod_files['lod2'] = lod_files['lod0'] + else: + # Small model, use LOD0 for all levels + logger.info(f"Model has {total_faces} faces, skipping LOD generation") + lod_files['lod1'] = lod_files['lod0'] + lod_files['lod2'] = lod_files['lod0'] + + # Add LOD info to metadata + metadata['lod_files'] = lod_files + metadata['lod_levels'] = len(set(lod_files.values())) + + return metadata + + +def _simplify_mesh(mesh: trimesh.Trimesh | trimesh.Scene, ratio: float) -> trimesh.Trimesh | trimesh.Scene | None: + """ + Simplify a mesh or scene to the target face ratio. + + Args: + mesh: Trimesh mesh or scene + ratio: Target ratio of faces (0.0 - 1.0) + + Returns: + Simplified mesh/scene or None if failed + """ + # Minimum reduction required (at least 10% reduction for fast_simplification to work) + MIN_REDUCTION_RATIO = 0.9 + + try: + if isinstance(mesh, trimesh.Scene): + # Simplify each geometry in the scene + simplified_geometries = {} + for name, geom in mesh.geometry.items(): + # Skip small geometries and non-mesh objects + if not hasattr(geom, 'faces') or len(geom.faces) < 100: + simplified_geometries[name] = geom + continue + + original_faces = len(geom.faces) + target_faces = max(int(original_faces * ratio), 4) + + # Only simplify if we're reducing by at least 10% + # (fast_simplification requires reduction > 0) + if target_faces < original_faces * MIN_REDUCTION_RATIO: + try: + simplified = geom.simplify_quadric_decimation(target_faces) + simplified_geometries[name] = simplified + except Exception as e: + logger.warning(f"Failed to simplify geometry {name}: {e}") + simplified_geometries[name] = geom + else: + # Reduction too small, skip simplification + simplified_geometries[name] = geom + + # Create new scene with simplified geometries + new_scene = trimesh.Scene() + for name, geom in simplified_geometries.items(): + try: + # Get original transform if exists + node_name = None + if hasattr(mesh.graph, 'nodes_geometry'): + for item in mesh.graph.nodes_geometry: + # Handle both tuple formats: (node, geom_name) or (node, geom_name, ...) + if len(item) >= 2 and item[1] == name: + node_name = item[0] + break + + if node_name: + transform = mesh.graph.get(node_name)[0] + new_scene.add_geometry(geom, node_name=node_name, geom_name=name, transform=transform) + else: + new_scene.add_geometry(geom, geom_name=name) + except Exception as e: + # If transform lookup fails, just add geometry without transform + logger.debug(f"Could not get transform for {name}: {e}") + new_scene.add_geometry(geom, geom_name=name) + + return new_scene + + elif hasattr(mesh, 'faces') and len(mesh.faces) >= 100: + # Single mesh simplification + original_faces = len(mesh.faces) + target_faces = max(int(original_faces * ratio), 4) + + # Only simplify if we're reducing by at least 10% + if target_faces < original_faces * MIN_REDUCTION_RATIO: + return mesh.simplify_quadric_decimation(target_faces) + + return mesh + + except Exception as e: + logger.error(f"Mesh simplification failed: {e}") + return None + + +def _convert_step( + input_path: Path, + output_path: Path, + tol_linear: float = 0.01, + tol_angular: float = 0.5, +) -> dict[str, Any]: + """Convert STEP file using cascadio with configurable tessellation precision. + + Args: + input_path: Path to STEP file + output_path: Path to save GLB file + tol_linear: Linear deflection tolerance (higher = coarser mesh) + tol_angular: Angular deflection tolerance in radians (higher = coarser mesh) + + Returns: + Metadata about the converted model + """ + try: + import cascadio + + logger.info(f"Converting STEP file with cascadio: {input_path}") + logger.info(f"Tessellation params: tol_linear={tol_linear}, tol_angular={tol_angular}") + + cascadio.step_to_glb( + str(input_path), + str(output_path), + tol_linear=tol_linear, + tol_angular=tol_angular, + ) + + # Load the result to get metadata + mesh = trimesh.load(str(output_path)) + return _extract_metadata(mesh) + + except ImportError: + logger.error("cascadio not installed, cannot convert STEP files") + raise RuntimeError("STEP conversion requires cascadio package") + except Exception as e: + logger.error(f"STEP conversion failed: {e}") + raise + + +def _convert_with_trimesh(input_path: Path, output_path: Path, file_type: str) -> dict[str, Any]: + """Convert STL, OBJ, and other formats using trimesh.""" + logger.info(f"Converting {file_type.upper()} file with trimesh: {input_path}") + + try: + # Load the mesh + mesh = trimesh.load(str(input_path)) + + # Export to GLB + mesh.export(str(output_path), file_type='glb') + + return _extract_metadata(mesh) + + except Exception as e: + logger.error(f"Trimesh conversion failed: {e}") + raise + + +def _extract_metadata(mesh: trimesh.Trimesh | trimesh.Scene) -> dict[str, Any]: + """Extract metadata from a trimesh object.""" + metadata: dict[str, Any] = {} + + try: + if isinstance(mesh, trimesh.Scene): + # Scene with multiple meshes + metadata['type'] = 'scene' + metadata['parts_count'] = len(mesh.geometry) + + # Aggregate stats + total_vertices = 0 + total_faces = 0 + + for name, geom in mesh.geometry.items(): + if hasattr(geom, 'vertices'): + total_vertices += len(geom.vertices) + if hasattr(geom, 'faces'): + total_faces += len(geom.faces) + + metadata['vertices'] = total_vertices + metadata['faces'] = total_faces + + # Bounding box + if hasattr(mesh, 'bounds') and mesh.bounds is not None: + bounds = mesh.bounds + metadata['bounding_box'] = { + 'min': {'x': float(bounds[0][0]), 'y': float(bounds[0][1]), 'z': float(bounds[0][2])}, + 'max': {'x': float(bounds[1][0]), 'y': float(bounds[1][1]), 'z': float(bounds[1][2])}, + } + + # Parts info + parts = [] + for name, geom in mesh.geometry.items(): + part_info = {'name': name} + + if hasattr(geom, 'bounds') and geom.bounds is not None: + part_bounds = geom.bounds + part_info['bounding_box'] = { + 'min': {'x': float(part_bounds[0][0]), 'y': float(part_bounds[0][1]), 'z': float(part_bounds[0][2])}, + 'max': {'x': float(part_bounds[1][0]), 'y': float(part_bounds[1][1]), 'z': float(part_bounds[1][2])}, + } + part_info['center_point'] = { + 'x': float((part_bounds[0][0] + part_bounds[1][0]) / 2), + 'y': float((part_bounds[0][1] + part_bounds[1][1]) / 2), + 'z': float((part_bounds[0][2] + part_bounds[1][2]) / 2), + } + + parts.append(part_info) + + metadata['parts'] = parts + + else: + # Single mesh + metadata['type'] = 'mesh' + metadata['parts_count'] = 1 + + if hasattr(mesh, 'vertices'): + metadata['vertices'] = len(mesh.vertices) + if hasattr(mesh, 'faces'): + metadata['faces'] = len(mesh.faces) + + if hasattr(mesh, 'bounds') and mesh.bounds is not None: + bounds = mesh.bounds + metadata['bounding_box'] = { + 'min': {'x': float(bounds[0][0]), 'y': float(bounds[0][1]), 'z': float(bounds[0][2])}, + 'max': {'x': float(bounds[1][0]), 'y': float(bounds[1][1]), 'z': float(bounds[1][2])}, + } + + except Exception as e: + logger.warning(f"Error extracting metadata: {e}") + + return metadata diff --git a/worker/src/services/__init__.py b/worker/src/services/__init__.py new file mode 100644 index 0000000..8c4fae6 --- /dev/null +++ b/worker/src/services/__init__.py @@ -0,0 +1 @@ +# Worker services diff --git a/worker/src/services/storage.py b/worker/src/services/storage.py new file mode 100644 index 0000000..f588a8e --- /dev/null +++ b/worker/src/services/storage.py @@ -0,0 +1,61 @@ +"""MinIO storage service for the worker.""" + +import logging +from pathlib import Path +from minio import Minio +from minio.error import S3Error + +from ..config import settings + +logger = logging.getLogger(__name__) + + +def get_minio_client() -> Minio: + """Create MinIO client.""" + return Minio( + endpoint=settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_use_ssl, + ) + + +def download_file(bucket: str, key: str, local_path: Path) -> None: + """Download a file from MinIO.""" + client = get_minio_client() + + # Ensure parent directory exists + local_path.parent.mkdir(parents=True, exist_ok=True) + + logger.info(f"Downloading {bucket}/{key} to {local_path}") + client.fget_object(bucket_name=bucket, object_name=key, file_path=str(local_path)) + + +def upload_file(local_path: Path, bucket: str, key: str, content_type: str = None) -> str: + """Upload a file to MinIO and return the URL.""" + client = get_minio_client() + + logger.info(f"Uploading {local_path} to {bucket}/{key}") + + client.fput_object( + bucket_name=bucket, + object_name=key, + file_path=str(local_path), + content_type=content_type or "application/octet-stream", + ) + + # Return the public URL (using public endpoint for browser access) + protocol = "https" if settings.minio_use_ssl else "http" + public_endpoint = settings.minio_public_endpoint or settings.minio_endpoint + return f"{protocol}://{public_endpoint}/{bucket}/{key}" + + +def delete_file(bucket: str, key: str) -> None: + """Delete a file from MinIO.""" + client = get_minio_client() + + try: + client.remove_object(bucket_name=bucket, object_name=key) + logger.info(f"Deleted {bucket}/{key}") + except S3Error as e: + logger.warning(f"Failed to delete {bucket}/{key}: {e}") diff --git a/worker/src/services/thumbnail.py b/worker/src/services/thumbnail.py new file mode 100644 index 0000000..fbaa7b4 --- /dev/null +++ b/worker/src/services/thumbnail.py @@ -0,0 +1,277 @@ +"""Thumbnail generation service using trimesh and legacy OSMesa.""" + +import logging +from pathlib import Path + +import numpy as np +import trimesh +from PIL import Image + +from ..config import settings + +logger = logging.getLogger(__name__) + +# Maximum faces to render for thumbnail (performance limit for immediate mode) +MAX_FACES_FOR_RENDER = 50000 + + +def generate_thumbnail(glb_path: Path, output_path: Path) -> bool: + """ + Generate a thumbnail image from a GLB file. + + Uses legacy OSMesa for off-screen rendering. + Falls back to a simple placeholder if rendering fails. + """ + try: + return _generate_with_osmesa(glb_path, output_path) + except ImportError as e: + logger.warning(f"OSMesa not available: {e}, using simple thumbnail") + return _generate_simple_thumbnail(glb_path, output_path) + except Exception as e: + logger.error(f"Failed to generate thumbnail with OSMesa: {e}", exc_info=True) + return _generate_simple_thumbnail(glb_path, output_path) + + +def _generate_with_osmesa(glb_path: Path, output_path: Path) -> bool: + """Generate thumbnail using legacy OSMesa context and OpenGL.""" + from OpenGL import osmesa + from OpenGL.GL import ( + GL_COLOR_BUFFER_BIT, + GL_DEPTH_BUFFER_BIT, + GL_DEPTH_TEST, + GL_LESS, + GL_LIGHT0, + GL_LIGHT1, + GL_LIGHTING, + GL_MODELVIEW, + GL_NORMALIZE, + GL_POSITION, + GL_PROJECTION, + GL_SMOOTH, + GL_TRIANGLES, + GL_UNSIGNED_BYTE, + glBegin, + glClear, + glClearColor, + glColor3f, + glDepthFunc, + glEnable, + glEnd, + glFinish, + glLightfv, + glLoadIdentity, + glMatrixMode, + glNormal3fv, + glShadeModel, + glVertex3fv, + glViewport, + ) + from OpenGL.GLU import gluLookAt, gluPerspective + + # Load the mesh + mesh = trimesh.load(str(glb_path)) + logger.info(f"Loaded mesh from {glb_path}") + + # Get combined geometry if it's a scene + if isinstance(mesh, trimesh.Scene): + # Combine all geometries into a single mesh + meshes = [] + for name, geom in mesh.geometry.items(): + if isinstance(geom, trimesh.Trimesh): + meshes.append(geom) + if meshes: + mesh = trimesh.util.concatenate(meshes) + else: + logger.warning("No valid meshes found in scene") + return _generate_simple_thumbnail(glb_path, output_path) + + if not isinstance(mesh, trimesh.Trimesh): + logger.warning(f"Unsupported mesh type: {type(mesh)}") + return _generate_simple_thumbnail(glb_path, output_path) + + # Simplify mesh if too large for immediate mode rendering + num_faces = len(mesh.faces) + if num_faces > MAX_FACES_FOR_RENDER: + logger.info(f"Simplifying mesh from {num_faces} to ~{MAX_FACES_FOR_RENDER} faces for thumbnail") + try: + # Try fast-simplification library first + import fast_simplification + simplified_vertices, simplified_faces = fast_simplification.simplify( + mesh.vertices, + mesh.faces, + target_reduction=1 - (MAX_FACES_FOR_RENDER / num_faces) + ) + mesh = trimesh.Trimesh(vertices=simplified_vertices, faces=simplified_faces) + logger.info(f"Simplified to {len(mesh.faces)} faces using fast-simplification") + except Exception as e: + logger.warning(f"Simplification failed: {e}, will render subset") + # Limit to first N faces if simplification fails + if num_faces > MAX_FACES_FOR_RENDER: + mesh.faces = mesh.faces[:MAX_FACES_FOR_RENDER] + + # Get mesh data + vertices = mesh.vertices + faces = mesh.faces + face_normals = mesh.face_normals if hasattr(mesh, 'face_normals') else None + + # Get vertex colors if available + vertex_colors = None + if hasattr(mesh, 'visual') and hasattr(mesh.visual, 'vertex_colors'): + try: + vc = mesh.visual.vertex_colors + if vc is not None and len(vc) > 0: + vertex_colors = vc[:, :3].astype(np.float32) / 255.0 + except Exception: + pass + + # Calculate bounding box and camera position + bounds = mesh.bounds + center = (bounds[0] + bounds[1]) / 2 + size = np.max(bounds[1] - bounds[0]) + + if size == 0 or np.isnan(size): + size = 1.0 + + # Camera setup - position for isometric-like view + camera_distance = size * 2.5 + camera_pos = center + np.array([ + camera_distance * 0.7, + camera_distance * 0.5, + camera_distance * 0.7, + ]) + + # Create OSMesa context using legacy function + width, height = settings.thumbnail_size + ctx = osmesa.OSMesaCreateContextExt(osmesa.OSMESA_RGBA, 24, 0, 0, None) + + if not ctx: + raise RuntimeError("Failed to create OSMesa context") + + try: + # Create buffer for rendering + buffer = np.zeros((height, width, 4), dtype=np.uint8) + + # Make context current + result = osmesa.OSMesaMakeCurrent(ctx, buffer, GL_UNSIGNED_BYTE, width, height) + if not result: + raise RuntimeError("Failed to make OSMesa context current") + + # Set up viewport + glViewport(0, 0, width, height) + + # Set up projection matrix + glMatrixMode(GL_PROJECTION) + glLoadIdentity() + aspect = width / height + gluPerspective(45.0, aspect, size * 0.01, size * 100) + + # Set up modelview matrix + glMatrixMode(GL_MODELVIEW) + glLoadIdentity() + gluLookAt( + float(camera_pos[0]), float(camera_pos[1]), float(camera_pos[2]), + float(center[0]), float(center[1]), float(center[2]), + 0, 1, 0 + ) + + # Enable depth testing + glEnable(GL_DEPTH_TEST) + glDepthFunc(GL_LESS) + + # Enable lighting + glEnable(GL_LIGHTING) + glEnable(GL_LIGHT0) + glEnable(GL_LIGHT1) + glEnable(GL_NORMALIZE) + glShadeModel(GL_SMOOTH) + + # Enable color material so vertex colors work with lighting + from OpenGL.GL import GL_COLOR_MATERIAL, GL_AMBIENT_AND_DIFFUSE, glColorMaterial + glEnable(GL_COLOR_MATERIAL) + glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE) + + # Set up light 0 (main light from camera direction) + from OpenGL.GL import GL_AMBIENT, GL_DIFFUSE, GL_SPECULAR + light0_pos = [float(camera_pos[0]), float(camera_pos[1]), float(camera_pos[2]), 0.0] # Directional light + glLightfv(GL_LIGHT0, GL_POSITION, light0_pos) + glLightfv(GL_LIGHT0, GL_AMBIENT, [0.3, 0.3, 0.3, 1.0]) + glLightfv(GL_LIGHT0, GL_DIFFUSE, [0.8, 0.8, 0.8, 1.0]) + + # Set up light 1 (fill light from opposite side) + light1_pos = [float(-camera_pos[0]), float(camera_pos[1]), float(-camera_pos[2]), 0.0] + glLightfv(GL_LIGHT1, GL_POSITION, light1_pos) + glLightfv(GL_LIGHT1, GL_AMBIENT, [0.2, 0.2, 0.2, 1.0]) + glLightfv(GL_LIGHT1, GL_DIFFUSE, [0.5, 0.5, 0.5, 1.0]) + + # Clear buffers + glClearColor(0.15, 0.15, 0.18, 1.0) + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + + # Render the mesh using immediate mode + glBegin(GL_TRIANGLES) + + for i, face in enumerate(faces): + # Set face normal for lighting + if face_normals is not None: + n = face_normals[i] + glNormal3fv([float(n[0]), float(n[1]), float(n[2])]) + + for vertex_idx in face: + # Set vertex color (default to light gray if no colors) + if vertex_colors is not None and vertex_idx < len(vertex_colors): + c = vertex_colors[vertex_idx] + glColor3f(float(c[0]), float(c[1]), float(c[2])) + else: + glColor3f(0.75, 0.75, 0.78) + + # Draw vertex + v = vertices[vertex_idx] + glVertex3fv([float(v[0]), float(v[1]), float(v[2])]) + + glEnd() + glFinish() + + # Flip the image vertically (OpenGL origin is bottom-left) + image_data = np.flipud(buffer) + + # Save the image + output_path.parent.mkdir(parents=True, exist_ok=True) + Image.fromarray(image_data).save(str(output_path)) + logger.info(f"Thumbnail generated with OSMesa: {output_path} ({len(faces)} faces rendered)") + return True + + finally: + osmesa.OSMesaDestroyContext(ctx) + + +def _generate_simple_thumbnail(glb_path: Path, output_path: Path) -> bool: + """ + Generate a simple placeholder thumbnail when OSMesa is not available. + Creates a solid color image with a gradient pattern. + """ + width, height = settings.thumbnail_size + + # Create a simple gradient background + img = Image.new('RGB', (width, height), color=(64, 64, 64)) + + # Add a simple icon/pattern to indicate 3D model + pixels = img.load() + center_x, center_y = width // 2, height // 2 + + # Draw a simple cube-like shape + for y in range(height): + for x in range(width): + # Create a gradient + dist = ((x - center_x) ** 2 + (y - center_y) ** 2) ** 0.5 + max_dist = (width ** 2 + height ** 2) ** 0.5 / 2 + factor = 1 - (dist / max_dist) * 0.5 + + r = int(80 * factor) + g = int(120 * factor) + b = int(160 * factor) + pixels[x, y] = (r, g, b) + + output_path.parent.mkdir(parents=True, exist_ok=True) + img.save(str(output_path)) + logger.info(f"Simple thumbnail generated: {output_path}") + return True