diff --git a/.gitignore b/.gitignore index 2ccb45e..c1e0db4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,13 @@ # OS files .DS_Store + +**/venv +*/.pytest_cache + + +ReferenceSurfaceGenerator/data/job_queue +ReferenceSurfaceGenerator/data/job_metadata +ReferenceSurfaceGenerator/data/jobs_metadata +ReferenceSurfaceGenerator/data/outputs +ReferenceSurfaceGenerator/data/uploads diff --git a/ReferenceSurfaceGenerator/Dockerfile b/ReferenceSurfaceGenerator/Dockerfile index e68e2a5..3f0118e 100644 --- a/ReferenceSurfaceGenerator/Dockerfile +++ b/ReferenceSurfaceGenerator/Dockerfile @@ -6,19 +6,24 @@ COPY frontend/ . RUN npm run build -# Stage 2: Final application image +# Stage 2: Install backend dependencies +FROM python:3.11-slim AS backend-builder +WORKDIR /app +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Stage 3: Final application image FROM python:3.11-slim WORKDIR /app -# Copy backend requirements -COPY backend/requirements.txt . +# Copy installed Python packages from the backend-builder stage +COPY --from=backend-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages -# Install backend dependencies directly into the final image -RUN pip install --no-cache-dir -r requirements.txt - -# Copy backend application code +# Copy backend application code and start script COPY backend/app /app/app +COPY backend/start.sh /app/start.sh +RUN chmod +x /app/start.sh # Copy the built React app from the frontend-builder stage COPY --from=frontend-builder /app/frontend/build /app/static @@ -26,6 +31,5 @@ # Expose the port the app runs on EXPOSE 8000 -# Run the application as a module (most robust method) -CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] - +# Run the application using the start script +CMD ["sh", "start.sh"] \ No newline at end of file diff --git a/ReferenceSurfaceGenerator/README.md b/ReferenceSurfaceGenerator/README.md index 143fcc0..7bf1dad 100644 --- a/ReferenceSurfaceGenerator/README.md +++ b/ReferenceSurfaceGenerator/README.md @@ -1,51 +1,119 @@ +# 3D Mesh to Layered Curves Web App + +## Overview + +This project provides a modern, web-based interface for the mesh simplification tool. Users can upload a 3D model file (`.obj`, `.stl`, `.3mf`), and the application will generate a `.dxf` file containing a series of simplified, layered 2D profiles suitable for use in CAD/CAM software. + +The application features a modern UI with real-time progress updates, configurable processing parameters, and persistent job management. It is designed with an asynchronous worker architecture for robust background processing. + +## Architecture + +This project is a **single-container application** built with a multi-stage `Dockerfile` and consists of: + +- **Frontend**: A **React** single-page application built with `create-react-app`. It provides the user interface for uploading files, monitoring progress, and managing jobs. It communicates with the backend via HTTP requests and WebSockets. +- **Backend**: A **FastAPI** (Python) application. It handles: + - File uploads and processing parameters. + - Enqueuing processing jobs to a **file-based job queue**. + - Serving API endpoints for managing job status (list, retrieve, delete). + - Serving the static frontend files directly in production. +- **Worker**: A separate Python process (`worker.py`) that runs alongside the FastAPI app. It monitors the job queue, picks up jobs, processes the mesh, and updates job status and progress (which the frontend fetches via APIs/WebSockets). + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) (for containerized deployment) +- For local development: [Node.js and npm](https://nodejs.org/en/download/), [Python 3.10+](https://www.python.org/downloads/) + +## How to Run the Application + +### 1. Build the Docker Image + +From the root of the project, run the following command to build the application image: + +```sh +docker build -t mesh-app . +``` + +This may take a few minutes the first time as it downloads base images and installs all frontend and backend dependencies. + +### 2. Run the Container + +Once the image is built, run it with this command, making sure to use an **absolute path** to your data directory: + +```sh +docker run --rm -p 8000:8000 -v "/path/to/your/data/directory:/app/data" mesh-app +``` + +- `--rm`: Automatically removes the container when it is stopped. +- `-p 8000:8000`: Maps port 8000 on your local machine to port 8000 inside the container. +- `-v "/path/to/your/data/directory:/app/data"`: Mounts a local directory (e.g., `$(pwd)/data`) into the container at `/app/data`. This is where uploaded files, processed outputs, and job metadata will be stored. **Ensure this path exists and is absolute.** + +### 3. Access the Web Interface + +Once the container is running, open your web browser and navigate to: + +**[http://localhost:8000](http://localhost:8000)** + +### 4. Use the Application + +1. **Upload File**: Select a mesh file (`.obj`, `.stl`, `.3mf`). Optionally, adjust the `Number of Layers` and `Points per Layer` for processing. +2. **Start Processing**: Click "Start Processing". The upload progress bar will move. +3. **Monitor Jobs**: A new job entry will appear in the "Job List" with its status, real-time progress, and messages. You can close and reopen the browser, and your jobs will persist. +4. **Actions**: For `COMPLETE` jobs, you can download the generated DXF file. For any job, you can delete it. + +### 5. Stopping the Application + +To stop the application, press `Ctrl+C` in the terminal where the container is running. + --- -## Local Development (Without Docker) +## Local Development -For active development, it's often easier to run the frontend and backend servers directly on your local machine. +For active development, it's often easier to run the frontend, backend API, and worker processes directly on your local machine. This allows for live reloading of code changes. -### Prerequisites +### 1. Run the Development Script -- [Node.js and npm](https://nodejs.org/en/download/) (for the frontend) -- [Python 3.10+](https://www.python.org/downloads/) (for the backend) - -### 1. Run the Backend Server - -First, set up and run the FastAPI backend. +A helper script is provided to automate the entire setup process. From the root of the project, run: ```sh -# Navigate to the backend directory -cd backend +# Make the script executable (only needed once) +chmod +x start_local_dev.sh -# Create a virtual environment (recommended) -python3 -m venv venv -source venv/bin/activate - -# Install the required Python packages -pip install -r requirements.txt - -# Run the FastAPI server -# The --reload flag will automatically restart the server when you make code changes -uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +# Run the script +./start_local_dev.sh ``` -The backend API will now be running at `http://localhost:8000`. +This script will: +1. Set up a Python virtual environment for the backend. +2. Install all Python and `npm` dependencies for both services. +3. Start the backend API server at `http://localhost:8000`. +4. Start the asynchronous worker process. +5. Start the React frontend development server at `http://localhost:3000` (which proxies API requests to port 8000). -### 2. Run the Frontend Server +Your browser should automatically open to `http://localhost:3000`. You can now edit the code in `frontend/src` or `backend/app` to see live updates. -In a **separate terminal**, set up and run the React development server. +**To stop all servers, press `Ctrl+C` in the terminal where the script is running.** -```sh -# Navigate to the frontend directory -cd frontend +--- +## Project Structure -# Install the required npm packages -npm install - -# Start the React development server -npm start ``` - -This will automatically open a new browser tab with the application running at `http://localhost:3000`. The React development server will proxy API requests to the backend (running on port 8000) to avoid CORS issues. - -You can now edit the source code in the `frontend/src` or `backend/app` directories, and the servers will automatically reload to reflect your changes. \ No newline at end of file +. +├── backend/ +│ ├── app/ +│ │ ├── __init__.py +│ │ ├── main.py # FastAPI app (API endpoints) +│ │ ├── models.py # Pydantic models for job management +│ │ ├── processing.py # Core mesh logic (generator for progress) +│ │ └── worker.py # Asynchronous job processing worker +│ ├── tests/ # Unit tests for backend logic +│ ├── start.sh # Script to start backend/worker in Docker +│ └── requirements.txt +├── data/ +│ └── (contains original model, and will store uploads/outputs/job_metadata/job_queue) +├── frontend/ +│ ├── public/ # React public assets +│ ├── src/ # React source code (App.js, JobItem, JobWatcher etc.) +│ └── package.json +├── Dockerfile # Production Docker build (single image) +└── start_local_dev.sh # Local development setup and start script +``` \ No newline at end of file diff --git a/ReferenceSurfaceGenerator/backend/app/main.py b/ReferenceSurfaceGenerator/backend/app/main.py index 1c94888..d4a2325 100644 --- a/ReferenceSurfaceGenerator/backend/app/main.py +++ b/ReferenceSurfaceGenerator/backend/app/main.py @@ -1,16 +1,19 @@ import asyncio import os import uuid -from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect, Request +import json +import datetime +from typing import Dict, List, Optional + +from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect, HTTPException, status from fastapi.responses import FileResponse from starlette.websockets import WebSocketState from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -# Import the core processing logic +# Import the core processing logic and data models from .processing import create_layered_curves_dxf +from .models import Job, JobStatus app = FastAPI() @@ -23,80 +26,157 @@ allow_headers=["*"], ) -# Create a directory for uploads and outputs +# Create directories for uploads, outputs, and job metadata UPLOAD_DIR = "/app/data/uploads" OUTPUT_DIR = "/app/data/outputs" +JOBS_METADATA_DIR = "/app/data/jobs_metadata" +JOB_QUEUE_DIR = "/app/data/job_queue" os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(OUTPUT_DIR, exist_ok=True) +os.makedirs(JOBS_METADATA_DIR, exist_ok=True) +os.makedirs(JOB_QUEUE_DIR, exist_ok=True) -# Mount the static files directory for the React app +# --- Helper Functions for Job Metadata Persistence --- + +def _get_job_metadata_path(job_id: uuid.UUID) -> str: + """ + Returns the filesystem path for a job's metadata file. + """ + return os.path.join(JOBS_METADATA_DIR, f"{job_id}.json") + +def _save_job_metadata(job: Job): + """ + Saves a Job object's metadata to a JSON file. + """ + path = _get_job_metadata_path(job.id) + with open(path, "w") as f: + json.dump(job.model_dump(mode='json'), f, indent=4) # Using model_dump for Pydantic v2 + +def _load_job_metadata(job_id: uuid.UUID) -> Optional[Job]: + """ + Loads a Job object's metadata from a JSON file. + """ + path = _get_job_metadata_path(job_id) + if os.path.exists(path): + try: + with open(path, "r") as f: + data = json.load(f) + return Job(**data) + except json.JSONDecodeError: + print(f"Error: Corrupt job metadata file: {path}") + os.remove(path) # Clean up corrupt file + return None + return None + +def _load_all_job_metadata() -> List[Job]: + """ + Loads metadata for all jobs from the jobs_metadata directory. + """ + jobs = [] + for filename in os.listdir(JOBS_METADATA_DIR): + if filename.endswith(".json"): + job_id_str = filename.replace(".json", "") + try: + job_id = uuid.UUID(job_id_str) + job = _load_job_metadata(job_id) + if job: + jobs.append(job) + except ValueError: + # Skip invalid filenames + continue + # Sort by timestamp, newest first + jobs.sort(key=lambda j: j.timestamp, reverse=True) + return jobs + + @app.post("/upload/") -async def upload_mesh_file(file: UploadFile = File(...)): +async def upload_mesh_file(file: UploadFile = File(...), num_layers: int = 20, num_points_per_layer: int = 30): """ Accepts a file upload and saves it to a temporary location. - Returns a unique file ID to the client. + Creates a new job and returns its ID. """ - # Create a unique filename to avoid collisions - file_id = str(uuid.uuid4()) - input_path = os.path.join(UPLOAD_DIR, f"{file_id}_{file.filename}") - + job_id = uuid.uuid4() + input_path = os.path.join(UPLOAD_DIR, f"{job_id}_{file.filename}") + output_path = os.path.join(OUTPUT_DIR, f"{job_id}_curves.dxf") + + # Save the uploaded file with open(input_path, "wb") as buffer: buffer.write(await file.read()) + + # Create and save the initial job metadata + job = Job( + id=job_id, + filename=file.filename, + input_path=input_path, + output_path=output_path, + num_layers=num_layers, + num_points_per_layer=num_points_per_layer, + status=JobStatus.QUEUED, # Initial status is now QUEUED + message=f"File ''{file.filename}'' uploaded, job queued." + ) + _save_job_metadata(job) + + # Create a trigger file for the worker + with open(os.path.join(JOB_QUEUE_DIR, f"{job_id}.trigger"), "w") as f: + f.write(str(job_id)) + + return {"job_id": str(job.id), "filename": job.filename, "status": job.status.value} + + +async def track_job_progress(websocket: WebSocket, job_id: uuid.UUID): + """ + Monitors a job's metadata file and sends updates over a WebSocket. + """ + last_update_content = None + job_metadata_path = _get_job_metadata_path(job_id) + + while websocket.client_state == WebSocketState.CONNECTED: + job = _load_job_metadata(job_id) + if not job: + await websocket.send_json({"status": "error", "message": "Job disappeared or was deleted."}) + break + + update_content = job.model_dump(mode='json') + if update_content != last_update_content: + await websocket.send_json(update_content) + last_update_content = update_content - return {"file_id": file_id, "filename": file.filename} + # Stop tracking if the job is in a terminal state + if job.status in [JobStatus.COMPLETE, JobStatus.FAILED]: + break + + await asyncio.sleep(0.5) # Check for updates every 500ms -@app.websocket("/ws/{file_id}/{filename}") -async def websocket_endpoint(websocket: WebSocket, file_id: str, filename: str): +@app.websocket("/ws/{job_id}") +async def websocket_endpoint(websocket: WebSocket, job_id: uuid.UUID): """ Handles the WebSocket connection for processing the file and sending progress. """ await websocket.accept() - input_path = os.path.join(UPLOAD_DIR, f"{file_id}_{filename}") - output_filename = f"{file_id}_curves.dxf" - output_path = os.path.join(OUTPUT_DIR, output_filename) + job = _load_job_metadata(job_id) + if not job: + await websocket.send_json({"status": "error", "message": "Job not found."}) + await websocket.close() + return try: - # Call the actual processing function, which is now a generator - async for progress in async_generator_wrapper(create_layered_curves_dxf(input_path, output_path)): - if websocket.client_state != WebSocketState.DISCONNECTED: - await websocket.send_json(progress) - else: - print("WebSocket disconnected, stopping process.") - break - - if websocket.client_state != WebSocketState.DISCONNECTED: - await websocket.send_json({ - "status": "complete", - "progress": 100, - "message": "Processing complete!", - "download_url": f"/download/{output_filename}" - }) - - except Exception as e: - error_message = f"An error occurred: {str(e)}" - print(error_message) - if websocket.client_state != WebSocketState.DISCONNECTED: - await websocket.send_json({ - "status": "error", - "message": error_message - }) + # Initial status send + await websocket.send_json(job.model_dump(mode='json')) + # Start tracking and sending real-time updates + await track_job_progress(websocket, job_id) + except WebSocketDisconnect: + print(f"Client disconnected from job {job_id}") finally: - # Clean up the uploaded file - if os.path.exists(input_path): - os.remove(input_path) - if websocket.client_state != WebSocketState.DISCONNECTED: - await websocket.close() + # The connection is automatically closed by Starlette when the endpoint function returns. + # No need to call websocket.close() manually, as it can lead to race conditions + # where both client and server try to close the connection simultaneously. + print(f"WebSocket connection handler finished for job {job_id}") -async def async_generator_wrapper(generator): - """ - A wrapper to run the synchronous generator in a separate thread - to avoid blocking the asyncio event loop. - """ - loop = asyncio.get_event_loop() - for item in await loop.run_in_executor(None, list, generator): - yield item +# The async_generator_wrapper is no longer needed as processing is fully offloaded +# to the worker process. + @app.get("/download/{filename}") @@ -110,5 +190,40 @@ return {"error": "File not found"} -# This MUST be the last route -app.mount("/", StaticFiles(directory="static", html = True), name="static") +# --- New API Endpoints for Job Management --- + +@app.get("/api/jobs", response_model=List[Job]) +async def get_all_jobs(): + """ + Retrieves a list of all processing jobs. + """ + return _load_all_job_metadata() + +@app.get("/api/jobs/{job_id}", response_model=Job) +async def get_job_status(job_id: uuid.UUID): + """ + Retrieves the status and details for a specific job. + """ + job = _load_job_metadata(job_id) + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + return job + +@app.delete("/api/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_job(job_id: uuid.UUID): + """ + Deletes a specific job's metadata and associated output file. + """ + job = _load_job_metadata(job_id) + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + + # Delete input file if it exists + if job.input_path and os.path.exists(job.input_path): + os.remove(job.input_path) + # Delete output file if it exists + if job.output_path and os.path.exists(job.output_path): + os.remove(job.output_path) + # Delete metadata file + os.remove(_get_job_metadata_path(job_id)) + return # 204 No Content \ No newline at end of file diff --git a/ReferenceSurfaceGenerator/backend/app/models.py b/ReferenceSurfaceGenerator/backend/app/models.py new file mode 100644 index 0000000..ba8a272 --- /dev/null +++ b/ReferenceSurfaceGenerator/backend/app/models.py @@ -0,0 +1,33 @@ +import datetime +import uuid +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + +class JobStatus(str, Enum): + PENDING = "PENDING" + QUEUED = "QUEUED" + PROCESSING = "PROCESSING" + COMPLETE = "COMPLETE" + FAILED = "FAILED" + +class Job(BaseModel): + id: uuid.UUID + filename: str + status: JobStatus = JobStatus.PENDING + progress: int = 0 # Percentage from 0 to 100 + message: str = "Job created, awaiting processing." + + # Paths on the server filesystem + input_path: str + output_path: str + + # URL for downloading the output + download_url: Optional[str] = None + + # Processing parameters + num_layers: int = 20 + num_points_per_layer: int = 30 + + timestamp: datetime.datetime = datetime.datetime.now(datetime.timezone.utc) diff --git a/ReferenceSurfaceGenerator/backend/app/processing.py b/ReferenceSurfaceGenerator/backend/app/processing.py index bb27737..451cfd2 100644 --- a/ReferenceSurfaceGenerator/backend/app/processing.py +++ b/ReferenceSurfaceGenerator/backend/app/processing.py @@ -96,7 +96,4 @@ finally: # In a real app, you might clean up the input file here - pass - - -create_layered_curves_dxf('input_model.stl', 'output_profiles.dxf') \ No newline at end of file + pass \ No newline at end of file diff --git a/ReferenceSurfaceGenerator/backend/app/tests/test_processing.py b/ReferenceSurfaceGenerator/backend/app/tests/test_processing.py index 89cbd2a..5e4075a 100644 --- a/ReferenceSurfaceGenerator/backend/app/tests/test_processing.py +++ b/ReferenceSurfaceGenerator/backend/app/tests/test_processing.py @@ -42,4 +42,4 @@ # Check that the output file was created and is not empty assert os.path.exists(output_file), "Output file was not created." - assert os.path.getsize(output_file) > 0, "Output file is empty." + assert os.path.getsize(output_file) > 0, "Output file is empty." \ No newline at end of file diff --git a/ReferenceSurfaceGenerator/backend/app/worker.py b/ReferenceSurfaceGenerator/backend/app/worker.py new file mode 100644 index 0000000..30a2ce1 --- /dev/null +++ b/ReferenceSurfaceGenerator/backend/app/worker.py @@ -0,0 +1,122 @@ +import os +import time +import uuid +import json +from typing import Optional +import sys +import datetime + +# Add the app directory to the Python path to allow imports from .models and .processing +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.models import Job, JobStatus +from app.processing import create_layered_curves_dxf + +# Define paths - ensure these match main.py +UPLOAD_DIR = "/app/data/uploads" +OUTPUT_DIR = "/app/data/outputs" +JOBS_METADATA_DIR = "/app/data/jobs_metadata" +JOB_QUEUE_DIR = "/app/data/job_queue" + +# Helper to save job metadata (duplicate from main.py for worker self-sufficiency) +def _save_job_metadata(job: Job): + path = os.path.join(JOBS_METADATA_DIR, f"{job.id}.json") + with open(path, "w") as f: + # Use model_dump(mode='json') for Pydantic v2 to ensure correct serialization of types like UUID and enums + json.dump(job.model_dump(mode='json'), f, indent=4) + +# Helper to load job metadata (duplicate from main.py for worker self-sufficiency) +def _load_job_metadata(job_id: uuid.UUID) -> Optional[Job]: + path = os.path.join(JOBS_METADATA_DIR, f"{job_id}.json") + if os.path.exists(path): + try: + with open(path, "r") as f: + data = json.load(f) + return Job(**data) + except json.JSONDecodeError: + print(f"[WORKER] Error: Corrupt job metadata file: {path}") + os.remove(path) + return None + return None + +async def process_job(job_id: uuid.UUID): + job = _load_job_metadata(job_id) + if not job: + print(f"[WORKER] Job {job_id} not found in metadata, skipping.") + return + + print(f"[WORKER] Starting processing for job {job.id} (File: {job.filename})...") + + # Update job status to PROCESSING + job.status = JobStatus.PROCESSING + job.message = "Processing started by worker." + _save_job_metadata(job) + + try: + # Execute the processing function (which is a generator) + for progress_update in create_layered_curves_dxf( + job.input_path, + job.output_path, + num_layers=job.num_layers, + num_points_per_layer=job.num_points_per_layer + ): + job.status = JobStatus(progress_update["status"].upper()) + job.progress = progress_update["progress"] + job.message = progress_update["message"] + _save_job_metadata(job) + # In a real system, you might also push this update to a message queue for websockets + + # Final update on completion + job.status = JobStatus.COMPLETE + job.progress = 100 + job.message = "Processing complete! DXF generated." + job.download_url = f"/download/{os.path.basename(job.output_path)}" + _save_job_metadata(job) + print(f"[WORKER] Job {job.id} completed successfully.") + + except Exception as e: + error_message = f"An error occurred during job {job.id} processing: {str(e)}" + print(f"[WORKER] ERROR: {error_message}") + job.status = JobStatus.FAILED + job.message = error_message + _save_job_metadata(job) + + finally: + # Clean up the trigger file from the queue + trigger_file_path = os.path.join(JOB_QUEUE_DIR, f"{job.id}.trigger") + if os.path.exists(trigger_file_path): + os.remove(trigger_file_path) + print(f"[WORKER] Cleaned up trigger file for job {job.id}.") + + # Clean up the input file (uploaded mesh) + if os.path.exists(job.input_path): + os.remove(job.input_path) + print(f"[WORKER] Cleaned up input file for job {job.id}.") + +async def main(): + print(f"[WORKER] Worker started. Monitoring {JOB_QUEUE_DIR} for new jobs...") + while True: + for filename in os.listdir(JOB_QUEUE_DIR): + if filename.endswith(".trigger"): + job_id_str = filename.replace(".trigger", "") + try: + job_id = uuid.UUID(job_id_str) + print(f"[WORKER] Found new job trigger: {job_id}") + await process_job(job_id) + except ValueError: + print(f"[WORKER] Invalid trigger filename: {filename}, skipping.") + continue + + time.sleep(1) # Check for new jobs every second + +if __name__ == "__main__": + # Ensure directories exist (they should be created by main.py on startup) + os.makedirs(UPLOAD_DIR, exist_ok=True) + os.makedirs(OUTPUT_DIR, exist_ok=True) + os.makedirs(JOBS_METADATA_DIR, exist_ok=True) + os.makedirs(JOB_QUEUE_DIR, exist_ok=True) + + # Run the worker's main loop + # Need to use asyncio.run to run an async main function + import asyncio + asyncio.run(main()) diff --git a/ReferenceSurfaceGenerator/backend/requirements.txt b/ReferenceSurfaceGenerator/backend/requirements.txt index 947c7ff..b1e1c82 100644 --- a/ReferenceSurfaceGenerator/backend/requirements.txt +++ b/ReferenceSurfaceGenerator/backend/requirements.txt @@ -1,9 +1,2 @@ -jinja2 -uvicorn[standard] -fastapi -trimesh -ezdxf -scipy -python-multipart -networkx -lxml \ No newline at end of file + +pydantic \ No newline at end of file diff --git a/ReferenceSurfaceGenerator/backend/start.sh b/ReferenceSurfaceGenerator/backend/start.sh new file mode 100755 index 0000000..a8a7037 --- /dev/null +++ b/ReferenceSurfaceGenerator/backend/start.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Start the FastAPI application with Uvicorn +python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --static-dir static --static-url / --static-index index.html & +UVICORN_PID=$! + +# Start the worker process +python app/worker.py & +WORKER_PID=$! + +# Keep the script running in the foreground to keep containers alive +wait $UVICORN_PID $WORKER_PID diff --git a/ReferenceSurfaceGenerator/deploy.sh b/ReferenceSurfaceGenerator/deploy.sh new file mode 100644 index 0000000..5accefa --- /dev/null +++ b/ReferenceSurfaceGenerator/deploy.sh @@ -0,0 +1 @@ +sudo docker system prune -f && sudo docker build --no-cache -t docker.jerxie.com/mesh-app . && sudo docker run --rm -p 8082:8000 -v "$(pwd)/data:/app/data" docker.jerxie.com/mesh-app \ No newline at end of file diff --git a/ReferenceSurfaceGenerator/frontend/package-lock.json b/ReferenceSurfaceGenerator/frontend/package-lock.json index a3592c2..cf52cd1 100644 --- a/ReferenceSurfaceGenerator/frontend/package-lock.json +++ b/ReferenceSurfaceGenerator/frontend/package-lock.json @@ -3203,14 +3203,6 @@ "node": ">=18" } }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -3532,9 +3524,9 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "node_modules/@types/react": { - "version": "19.2.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", - "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "dependencies": { "csstype": "^3.2.2" } @@ -4238,14 +4230,11 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -4282,11 +4271,11 @@ } }, "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "engines": { - "node": ">= 0.4" + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dependencies": { + "dequal": "^2.0.3" } }, "node_modules/array-buffer-byte-length": { @@ -4561,30 +4550,15 @@ } }, "node_modules/axios": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz", - "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, - "node_modules/axios/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5189,6 +5163,20 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -6952,6 +6940,14 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -7765,15 +7761,15 @@ } }, "node_modules/form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", - "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "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" + "mime-types": "^2.1.12" }, "engines": { "node": ">= 6" @@ -9849,17 +9845,6 @@ "@types/yargs-parser": "*" } }, - "node_modules/jest-watch-typeahead/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-watch-typeahead/node_modules/emittery": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", @@ -10155,6 +10140,21 @@ } } }, + "node_modules/jsdom/node_modules/form-data": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "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" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -12503,17 +12503,6 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -16156,6 +16145,20 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/ReferenceSurfaceGenerator/frontend/package.json b/ReferenceSurfaceGenerator/frontend/package.json index 7da6a92..6d535c0 100644 --- a/ReferenceSurfaceGenerator/frontend/package.json +++ b/ReferenceSurfaceGenerator/frontend/package.json @@ -2,7 +2,6 @@ "name": "frontend", "version": "0.1.0", "private": true, - "proxy": "http://localhost:8000", "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/ReferenceSurfaceGenerator/frontend/src/App.js b/ReferenceSurfaceGenerator/frontend/src/App.js index bb40396..dacce84 100644 --- a/ReferenceSurfaceGenerator/frontend/src/App.js +++ b/ReferenceSurfaceGenerator/frontend/src/App.js @@ -1,85 +1,117 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import axios from 'axios'; -import { Container, Navbar, Card, ProgressBar, Alert, Button } from 'react-bootstrap'; +import { Container, Navbar, Card, ProgressBar, Alert, Button, Form, Row, Col, ListGroup, Badge } from 'react-bootstrap'; -// This will be our main component for handling uploads and progress -const UploadComponent = () => { +const API_URL = process.env.NODE_ENV === 'development' ? 'http://localhost:8000' : ''; +const WS_URL = process.env.NODE_ENV === 'development' + ? 'ws://localhost:8000' + : window.location.protocol.replace('http', 'ws') + '//' + window.location.host; + +// --- Individual Job Item Component --- +const JobItem = ({ job, API_URL, onJobDelete }) => { + const getVariant = (status) => { + switch (status) { + case 'PENDING': return 'info'; + case 'PROCESSING': return 'primary'; + case 'COMPLETE': return 'success'; + case 'FAILED': return 'danger'; + default: return 'secondary'; + } + }; + + const handleDelete = async () => { + if (window.confirm(`Are you sure you want to delete job ${job.filename} (${job.id})?`)) { + try { + await axios.delete(`${API_URL}/api/jobs/${job.id}`); + onJobDelete(job.id); + } catch (error) { + console.error("Error deleting job:", error); + alert("Failed to delete job."); + } + } + }; + + return ( + +
+ {job.filename} (ID: {job.id.substring(0, 8) + '...'}) +
+ Status: {job.status} + {job.status === 'PROCESSING' && } + {job.message && {job.message}} +
+
+ {job.status === 'COMPLETE' && job.download_url && ( + + )} + +
+
+ ); +}; + +// --- Upload Component (modified) --- +const UploadComponent = ({ setJobs }) => { const [file, setFile] = useState(null); - const [progress, setProgress] = useState(0); - const [statusMessage, setStatusMessage] = useState('Upload a file to begin.'); - const [downloadUrl, setDownloadUrl] = useState(null); + const [numLayers, setNumLayers] = useState(20); + const [numPoints, setNumPoints] = useState(30); + const [uploadProgress, setUploadProgress] = useState(0); // For file upload progress + const [uploadStatusMessage, setUploadStatusMessage] = useState('Select a file to begin.'); const [error, setError] = useState(null); const handleFileChange = (event) => { setFile(event.target.files[0]); - setStatusMessage(event.target.files[0] ? event.target.files[0].name : 'Upload a file to begin.'); - setProgress(0); - setDownloadUrl(null); + setUploadStatusMessage(event.target.files[0] ? event.target.files[0].name : 'Select a file to begin.'); + setUploadProgress(0); setError(null); }; const handleUpload = async () => { if (!file) return; - setProgress(0); - setDownloadUrl(null); + setUploadProgress(0); setError(null); - setStatusMessage('Uploading file...'); + setUploadStatusMessage('Uploading file...'); const formData = new FormData(); formData.append('file', file); + formData.append('num_layers', numLayers); + formData.append('num_points_per_layer', numPoints); try { - // Step 1: Upload the file to the backend (will be proxied by the dev server) - const uploadResponse = await axios.post('/upload/', formData, { + const uploadResponse = await axios.post(`${API_URL}/upload/`, formData, { headers: { 'Content-Type': 'multipart/form-data', }, + onUploadProgress: (progressEvent) => { + const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); + setUploadProgress(percentCompleted); + setUploadStatusMessage(`Uploading... ${percentCompleted}%`); + }, }); - const { file_id, filename } = uploadResponse.data; - setStatusMessage('File uploaded. Initializing processing...'); + const { job_id, filename, status } = uploadResponse.data; + // Add the new job to the global state immediately + setJobs(prevJobs => [{ + id: job_id, + filename: filename, + status: status, + progress: 0, + message: `File '${filename}' uploaded, awaiting processing.`, + num_layers: numLayers, + num_points_per_layer: numPoints, + timestamp: new Date().toISOString() // Use ISO string for consistency + }, ...prevJobs]); - // Step 2: Establish a WebSocket connection (proxied by the dev server) - const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsHost = window.location.hostname; - const wsPort = window.location.port ? `:${window.location.port}` : ''; - const ws = new WebSocket(`${wsProtocol}//${wsHost}${wsPort}/ws/${file_id}/${filename}`); + setUploadStatusMessage(`Job ${job_id.substring(0, 8)}... created. Waiting for WebSocket updates.`); + setUploadProgress(0); // Reset upload progress for next file + setFile(null); // Clear the file input - ws.onopen = () => { - setStatusMessage('Connection established. Starting processing...'); - }; - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - if (data.status === 'processing') { - setProgress(data.progress); - setStatusMessage(data.message); - } else if (data.status === 'complete') { - setProgress(data.progress); - setStatusMessage(data.message); - setDownloadUrl(data.download_url); // This will be a relative URL like /download/xyz.dxf - ws.close(); - } else if (data.status === 'error') { - setError(data.message); - setProgress(0); - ws.close(); - } - }; - - ws.onerror = (event) => { - console.error("WebSocket error observed:", event); - setError('A connection error occurred. Could not get progress updates.'); - }; - - ws.onclose = () => { - console.log('WebSocket connection closed.'); - // If the process isn't complete, show a message - if (!downloadUrl && !error) { - setStatusMessage('Connection closed.'); - } - }; + // No longer open WebSocket here. WebSocket will be opened by JobList for ongoing jobs. } catch (err) { console.error(err); @@ -88,48 +120,146 @@ }; return ( - + - Mesh Simplifier + Upload Mesh File Select a .obj, .stl, or .3mf file to process. The tool will generate a DXF file containing the layered profiles of your model. -
- -
- - {file && ( - - )} +
+ + Select Mesh File + + + + + + Number of Layers + setNumLayers(parseInt(e.target.value))} + min="2" + max="100" + /> + + + Points per Layer + setNumPoints(parseInt(e.target.value))} + min="3" + max="200" + /> + + + + {file && ( + + )} +

-
Processing Status
-

{statusMessage}

- +
Upload Status
+

{uploadStatusMessage}

+ {uploadProgress > 0 && uploadProgress < 100 && ( + + )} {error && ( {error} )} - - {downloadUrl && ( -
- -
- )}
); }; +// --- Main App Component (with WebSocket logic) --- function App() { + const [jobs, setJobs] = useState([]); + const [websocket, setWebsocket] = useState(null); + const [trackingJobId, setTrackingJobId] = useState(null); + + // Fetch all existing jobs on component mount + useEffect(() => { + const fetchJobs = async () => { + try { + const response = await axios.get(`${API_URL}/api/jobs`); + setJobs(response.data.map(job => ({ ...job, timestamp: new Date(job.timestamp) }))); + } catch (error) { + console.error("Error fetching jobs:", error); + } + }; + fetchJobs(); + }, []); + + // Effect to connect WebSocket for a single QUEUED/PROCESSING job + useEffect(() => { + const jobToTrack = jobs.find(j => j.status === 'QUEUED' || j.status === 'PROCESSING'); + + if (jobToTrack && (!websocket || trackingJobId !== jobToTrack.id)) { + if (websocket) { + websocket.close(); // Close previous connection if tracking a new job + } + + const ws = new WebSocket(`${WS_URL}/ws/${jobToTrack.id}`); + ws.onopen = () => { + console.log(`WebSocket connected for job ${jobToTrack.id}`); + setTrackingJobId(jobToTrack.id); + }; + + ws.onmessage = (event) => { + const updatedJob = JSON.parse(event.data); + setJobs(prevJobs => + prevJobs.map(job => + job.id === updatedJob.id ? { ...job, ...updatedJob, timestamp: new Date(updatedJob.timestamp) } : job + ) + ); + }; + + ws.onclose = () => { + console.log(`WebSocket disconnected for job ${jobToTrack.id}`); + setTrackingJobId(null); + // Fetch latest job status to ensure consistency after disconnect + axios.get(`${API_URL}/api/jobs/${jobToTrack.id}`).then(response => { + setJobs(prevJobs => + prevJobs.map(job => + job.id === response.data.id ? { ...job, ...response.data, timestamp: new Date(response.data.timestamp) } : job + ) + ); + }); + }; + + ws.onerror = (error) => { + console.error(`WebSocket error for job ${jobToTrack.id}:`, error); + }; + + setWebsocket(ws); + } + + // Cleanup on component unmount + return () => { + if (websocket) { + websocket.close(); + } + }; + }, [jobs]); // Rerun when jobs list changes + + const handleJobDelete = (jobId) => { + setJobs(prevJobs => prevJobs.filter(job => job.id !== jobId)); + }; + + // Separate lists for display + const processingJob = jobs.find(j => j.status === 'PROCESSING' || j.status === 'QUEUED'); + const otherJobs = jobs.filter(j => j.status !== 'PROCESSING' && j.status !== 'QUEUED'); + return (
@@ -138,11 +268,36 @@ - + + + {processingJob && ( + + + Currently Processing + + + + + + )} + + + + Job History + {otherJobs.length === 0 ? ( + No past jobs to show. + ) : ( + + {otherJobs.map(job => ( + + ))} + + )} + +
); } export default App; - diff --git a/ReferenceSurfaceGenerator/start_local_dev.sh b/ReferenceSurfaceGenerator/start_local_dev.sh new file mode 100755 index 0000000..f0784be --- /dev/null +++ b/ReferenceSurfaceGenerator/start_local_dev.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# This script automates the setup and execution of the local development environment. +# It starts the backend server in the background and the frontend server in the foreground. + +# Exit immediately if a command exits with a non-zero status. +set -e + +# Function to clean up background processes on exit +cleanup() { + echo "Shutting down servers..." + if [ -f backend/uvicorn.pid ]; then + kill $(cat backend/uvicorn.pid) + rm backend/uvicorn.pid + fi + if [ -f backend/worker.pid ]; then + kill $(cat backend/worker.pid) + rm backend/worker.pid + fi + echo "Cleanup complete." +} + +# Trap the EXIT signal to run the cleanup function when the script is terminated. +trap cleanup EXIT + +# --- Backend Setup --- +echo "[DEV_SCRIPT] Setting up and starting backend server..." +cd backend + +# Create a virtual environment if it doesn't exist +if [ ! -d "venv" ]; then + echo "[DEV_SCRIPT] Creating Python virtual environment..." + python3 -m venv venv +fi + +# Activate the virtual environment and install dependencies +source venv/bin/activate +pip install -r requirements.txt + +# Start the FastAPI server in the background +echo "[DEV_SCRIPT] Starting backend server on http://localhost:8000..." +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload & +echo $! > uvicorn.pid + +# Start the worker process in the background +echo "[DEV_SCRIPT] Starting worker process..." +python app/worker.py & +echo $! > worker.pid + +cd .. + +# --- Frontend Setup --- +echo "" +echo "[DEV_SCRIPT] Setting up and starting frontend server..." +cd frontend + +# Install npm dependencies +npm install + +# Start the React development server in the foreground +echo "[DEV_SCRIPT] Starting frontend server on http://localhost:3000..." +echo "[DEV_SCRIPT] Press Ctrl+C to stop both servers." +npm start