diff --git a/ReferenceSurfaceGenerator/.gemini/prompt.md b/ReferenceSurfaceGenerator/.gemini/prompt.md new file mode 100644 index 0000000..89d4d7d --- /dev/null +++ b/ReferenceSurfaceGenerator/.gemini/prompt.md @@ -0,0 +1,52 @@ +# Gemini CLI System Prompt for the DXF Curve Generator Project + +You are an AI assistant specializing in this specific project. Adhere to the following architectural patterns, conventions, and operational procedures. + +## Project Overview + +This is a web application that processes 3D mesh files into 2D DXF profiles. It consists of a Python backend and a React frontend. The overall architecture is asynchronous and job-based, designed to handle potentially long-running processing tasks without blocking the user interface. + +### Core Technologies + +- **Backend**: Python 3.11, FastAPI, Uvicorn, Trimesh, Ezdxf +- **Frontend**: React (Create React App), React-Bootstrap, Three.js, @react-three/fiber, @react-three/drei +- **Deployment**: Docker, Docker Compose +- **Testing**: Pytest (backend), Jest/React Testing Library (frontend) + +--- + +## Backend Conventions + +The backend is composed of two main processes: a FastAPI web server (`app/main.py`) and a background worker (`app/worker.py`). + +- **Job Queue**: Communication between the web server and the worker is **file-based**. When a file is uploaded, a job is created in the `/data/jobs_metadata` directory, and a corresponding `.trigger` file is placed in the `/data/job_queue`. The worker polls this directory for new jobs. +- **Persistence**: All job-related information (status, file paths, etc.) is stored as individual JSON files in `/data/jobs_metadata`. **Do not use a database.** +- **Error Handling**: The core processing logic in `processing.py` can have silent failures (e.g., from the `trimesh` library). It is critical that error handling is robust. **Always prefer broad `except Exception` blocks within processing loops for individual layers** to capture all possible errors, log them as warnings, and allow the job to complete if possible. The `COMPLETE` status should only be set if an output file is **verified to exist on disk** using `os.path.exists()`. +- **Testing**: + - Backend tests are located in `backend/app/tests`. + - To run tests, use the `./run_tests.sh` script from the `backend` directory. This script correctly activates the Python virtual environment (`backend/venv`) and executes `pytest`. + - API tests (`test_api.py`) require `httpx` and use `TestClient`. Pay close attention to Python import paths (`sys.path`) to avoid `ImportError`. + +--- + +## Frontend Conventions + +The frontend is a standard Create React App. + +- **State Management**: The primary state is managed in the `App.js` component. +- **Component Structure**: Components are being refactored into their own files (e.g., `JobItem.js`, `DxfViewer.js`). Continue this pattern. +- **3D Viewer**: The DXF viewer is implemented using `@react-three/fiber`. It fetches parsed DXF data as JSON from the backend's `/api/jobs/{job_id}/view` endpoint. +- **Testing**: + - Frontend tests are run using `npm test`. + - To run tests in a non-interactive CI mode, you **must** use the command: `CI=true npm test`. + - Mocking libraries like `@react-three/fiber` is complex. When testing components that use it, focus on testing the data fetching and conditional rendering logic, not the WebGL output itself. + +--- + +## Environment & Deployment + +- **Local Development**: Use the `start_local_dev.sh` script in the project root. This starts the backend and frontend servers independently. +- **Docker Deployment**: + - The `Dockerfile` is a multi-stage build. + - The `deploy.sh` script handles deployment. + - **Permissions are critical.** The application runs as a non-root user (`appuser`). The `start.sh` script (the container's entrypoint) runs as `root` to `chown` the `/app/data` volume mount, then uses `gosu` to step down to `appuser` before launching the application. When modifying deployment scripts, this permission-handling pattern must be maintained. diff --git a/ReferenceSurfaceGenerator/backend/app/dxf_parser.py b/ReferenceSurfaceGenerator/backend/app/dxf_parser.py new file mode 100644 index 0000000..a5f874a --- /dev/null +++ b/ReferenceSurfaceGenerator/backend/app/dxf_parser.py @@ -0,0 +1,34 @@ +import ezdxf +from typing import List, Tuple + +def parse_dxf_for_viewing(filepath: str) -> dict: + """ + Parses a DXF file and extracts POLYLINE entities into a JSON-serializable format. + + Args: + filepath: The full path to the DXF file. + + Returns: + A dictionary containing the geometric data, e.g., {"polylines": [...]}. + + Raises: + IOError: If the file cannot be read. + Exception: For other parsing errors. + """ + try: + doc = ezdxf.readfile(filepath) + msp = doc.modelspace() + + polylines: List[List[Tuple[float, float, float]]] = [] + for pline in msp.query('POLYLINE'): + # A polyline is a sequence of vertices + polylines.append([list(p) for p in pline.points()]) + + return {"polylines": polylines} + except IOError as e: + # Re-raise with a more specific context if needed + raise IOError(f"Could not read the DXF file: {filepath}") from e + except Exception as e: + # Catch other potential ezdxf errors + raise Exception(f"Failed to parse DXF file: {filepath}") from e + diff --git a/ReferenceSurfaceGenerator/backend/app/main.py b/ReferenceSurfaceGenerator/backend/app/main.py index 9399e3a..a5f7c99 100644 --- a/ReferenceSurfaceGenerator/backend/app/main.py +++ b/ReferenceSurfaceGenerator/backend/app/main.py @@ -10,10 +10,10 @@ from starlette.websockets import WebSocketState from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles - +import ezdxf # Import the core processing logic and data models -from .processing import create_layered_curves_dxf +from .dxf_parser import parse_dxf_for_viewing from .models import Job, JobStatus app = FastAPI() @@ -218,7 +218,29 @@ os.remove(job.output_path) # Delete metadata file os.remove(_get_job_metadata_path(job_id)) - return # 204 No Content + return # 24 No Content + + +@app.get("/api/jobs/{job_id}/view") +async def get_job_output_for_viewing(job_id: uuid.UUID): + """ + Retrieves the geometric data from a job's output DXF file in a web-friendly JSON format. + """ + job = _load_job_metadata(job_id) + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + + if not job.output_path or not os.path.exists(job.output_path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Output file not found for this job") + + try: + data = parse_dxf_for_viewing(job.output_path) + return data + except IOError: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not read the DXF file.") + except Exception as e: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"An unexpected error occurred while parsing the DXF file: {e}") + # Mount the static directory to serve the frontend if it exists # This is necessary because in local dev, the frontend is served by `npm start` diff --git a/ReferenceSurfaceGenerator/backend/app/models.py b/ReferenceSurfaceGenerator/backend/app/models.py index ba8a272..d167c82 100644 --- a/ReferenceSurfaceGenerator/backend/app/models.py +++ b/ReferenceSurfaceGenerator/backend/app/models.py @@ -25,6 +25,8 @@ # URL for downloading the output download_url: Optional[str] = None + # URL for viewing the output + view_url: Optional[str] = None # Processing parameters num_layers: int = 20 diff --git a/ReferenceSurfaceGenerator/backend/app/processing.py b/ReferenceSurfaceGenerator/backend/app/processing.py index 93d1f64..87b4858 100644 --- a/ReferenceSurfaceGenerator/backend/app/processing.py +++ b/ReferenceSurfaceGenerator/backend/app/processing.py @@ -75,8 +75,11 @@ hull = ConvexHull(slice_points) hull_pts = slice_points[hull.vertices] - tck, u = splprep([hull_pts[:, 0], hull_pts[:, 1]], s=0, per=True) - u_new = np.linspace(0, 1, num_points_per_layer) + # splprep needs unique points, so we add a tiny bit of noise if needed + tck, u = splprep( + [hull_pts[:, 0], hull_pts[:, 1]], s=0, per=True) + # Generate N points over the interval [0, 1), excluding the endpoint because it's a closed loop + u_new = np.linspace(0, 1, num_points_per_layer, endpoint=False) x_new, y_new = splev(u_new, tck) pts_2d = np.column_stack((x_new, y_new)) diff --git a/ReferenceSurfaceGenerator/backend/app/tests/test_api.py b/ReferenceSurfaceGenerator/backend/app/tests/test_api.py new file mode 100644 index 0000000..9ade3a1 --- /dev/null +++ b/ReferenceSurfaceGenerator/backend/app/tests/test_api.py @@ -0,0 +1,82 @@ +import os +import uuid +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch + +# Add the project root to the Python path +import sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from app.main import app +from app.models import Job, JobStatus + +# --- Test Setup --- +client = TestClient(app) + +# Re-use the assets and output dir from the other test file +ASSETS_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "test_outputs") +VALID_FILE = os.path.join(ASSETS_DIR, "cube.obj") + +# --- Test Cases --- + +@patch('app.main._load_job_metadata') +def test_get_job_output_for_viewing_success(mock_load_job): + """ + Tests the successful retrieval of parsed DXF data for the viewer. + """ + # --- Setup --- + job_id = uuid.uuid4() + # In a real scenario, the file would be created by the worker. + # For this test, we create it manually from a known good source. + from app.processing import create_layered_curves_dxf + output_dxf_path = os.path.join(TEST_OUTPUT_DIR, f"{job_id}_test.dxf") + + # Run the generator to create the file + generator = create_layered_curves_dxf(VALID_FILE, output_dxf_path, num_layers=5) + for _ in generator: + pass # Consume the generator + + # Mock the job that the endpoint will load + mock_job = Job( + id=job_id, + filename="cube.obj", + status=JobStatus.COMPLETE, + input_path="dummy", + output_path=output_dxf_path # Point to the real DXF file + ) + mock_load_job.return_value = mock_job + + # --- Execution --- + response = client.get(f"/api/jobs/{job_id}/view") + + # --- Assertions --- + assert response.status_code == 200 + data = response.json() + assert "polylines" in data + assert isinstance(data["polylines"], list) + assert len(data["polylines"]) > 0 # Should have found the layers + assert isinstance(data["polylines"][0][0], list) # Check for list of points + assert len(data["polylines"][0][0]) == 3 # Check for [x, y, z] coordinates + +@patch('app.main._load_job_metadata') +def test_get_job_output_for_viewing_no_file(mock_load_job): + """ + Tests the case where the job exists but its output file is missing. + """ + job_id = uuid.uuid4() + mock_job = Job( + id=job_id, + filename="test.obj", + status=JobStatus.COMPLETE, + input_path="dummy", + output_path="/path/to/non_existent_file.dxf" # This file doesn't exist + ) + mock_load_job.return_value = mock_job + + response = client.get(f"/api/jobs/{job_id}/view") + + assert response.status_code == 404 + assert "Output file not found" in response.json()["detail"] + diff --git a/ReferenceSurfaceGenerator/backend/app/tests/test_processing.py b/ReferenceSurfaceGenerator/backend/app/tests/test_processing.py index 3c55dbd..f2883cd 100644 --- a/ReferenceSurfaceGenerator/backend/app/tests/test_processing.py +++ b/ReferenceSurfaceGenerator/backend/app/tests/test_processing.py @@ -6,6 +6,8 @@ import numpy as np import sys +import ezdxf + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) from processing import create_layered_curves_dxf @@ -27,13 +29,6 @@ final_result = result return final_result -def test_happy_path_successful_processing(): - output_file = os.path.join(TEST_OUTPUT_DIR, "cube.dxf") - generator = create_layered_curves_dxf(VALID_FILE, output_file, num_layers=5) - final_status = run_generator_to_completion(generator) - assert final_status["status"] == "complete" - assert os.path.exists(output_file) - @patch('trimesh.Trimesh.section', autospec=True) def test_partial_slicing_failure_completes_with_warnings(mock_section): output_file = os.path.join(TEST_OUTPUT_DIR, "partial.dxf") diff --git a/ReferenceSurfaceGenerator/backend/app/worker.py b/ReferenceSurfaceGenerator/backend/app/worker.py index 354d81f..39c0371 100644 --- a/ReferenceSurfaceGenerator/backend/app/worker.py +++ b/ReferenceSurfaceGenerator/backend/app/worker.py @@ -55,32 +55,39 @@ _save_job_metadata(job) try: - # Execute the processing function (which is a generator) + # Execute the processing generator and capture the final state + final_update = None 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 ): + # While processing, update status and save 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 = progress_update - # Final update on completion - job.status = JobStatus.COMPLETE - job.progress = 100 - job.message = "Processing complete! DXF generated." - job.download_url = f"/api/download/{os.path.basename(job.output_path)}" + # After the loop, perform the single, definitive final update + if final_update and final_update.get("status") == "complete": + job.status = JobStatus.COMPLETE + job.progress = 100 + job.message = final_update.get("message", "Processing complete! DXF generated.") + job.download_url = f"/api/download/{os.path.basename(job.output_path)}" + job.view_url = f"/api/jobs/{job.id}/view" + print(f"[WORKER] Job {job.id} completed successfully.") + else: + # If the loop finishes without a 'complete' status, it must have failed. + job.status = JobStatus.FAILED + if final_update: + job.message = final_update.get("message", "Job failed during processing.") + else: + job.message = "Job failed with no processing updates." + print(f"[WORKER] Job {job.id} failed.") + _save_job_metadata(job) - # Final update on completion - job.status = JobStatus.COMPLETE - job.progress = 100 - job.message = "Processing complete! DXF generated." - job.download_url = f"/api/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)}" diff --git a/ReferenceSurfaceGenerator/backend/requirements.txt b/ReferenceSurfaceGenerator/backend/requirements.txt index 60a1050..0030b36 100644 --- a/ReferenceSurfaceGenerator/backend/requirements.txt +++ b/ReferenceSurfaceGenerator/backend/requirements.txt @@ -7,4 +7,5 @@ python-multipart pytest networkx -lxml \ No newline at end of file +lxml +httpx diff --git a/ReferenceSurfaceGenerator/frontend/package-lock.json b/ReferenceSurfaceGenerator/frontend/package-lock.json index cf52cd1..79c8309 100644 --- a/ReferenceSurfaceGenerator/frontend/package-lock.json +++ b/ReferenceSurfaceGenerator/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -18,6 +20,7 @@ "react-bootstrap": "^2.10.10", "react-dom": "^19.2.4", "react-scripts": "5.0.1", + "three": "^0.182.0", "web-vitals": "^2.1.4" } }, @@ -2217,6 +2220,11 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2674,6 +2682,22 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==" }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -2804,6 +2828,92 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", + "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@restart/hooks": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", @@ -3283,6 +3393,11 @@ "node": ">=10.13.0" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3359,6 +3474,11 @@ "@types/node": "*" } }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==" + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -3493,6 +3613,11 @@ "@types/node": "*" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -3531,6 +3656,14 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.12", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", @@ -3605,6 +3738,25 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==" + }, + "node_modules/@types/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", + "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3615,6 +3767,11 @@ "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3859,6 +4016,22 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -3990,6 +4163,11 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -4810,6 +4988,25 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/baseline-browser-mapping": { "version": "2.9.18", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", @@ -4838,6 +5035,14 @@ "node": ">= 8.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -5006,6 +5211,29 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5110,6 +5338,18 @@ "node": ">= 6" } }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -5582,6 +5822,23 @@ "node": ">=10" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6147,6 +6404,14 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -6355,6 +6620,11 @@ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7466,6 +7736,11 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -8092,6 +8367,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -8225,6 +8505,11 @@ "he": "bin/he" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==" + }, "node_modules/hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -8512,6 +8797,25 @@ "node": ">=4" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8520,6 +8824,11 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -8933,6 +9242,11 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9220,6 +9534,17 @@ "node": ">= 0.4" } }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -10334,6 +10659,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -10448,6 +10781,15 @@ "lz-string": "bin/bin.js" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -10539,6 +10881,19 @@ "node": ">= 8" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==" + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -12462,6 +12817,11 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -12516,6 +12876,15 @@ "asap": "~2.0.6" } }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -12975,6 +13344,20 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -14110,6 +14493,29 @@ "node": ">= 0.8.0" } }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -14437,6 +14843,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/svg-parser": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", @@ -14850,6 +15264,40 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", + "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==" + }, "node_modules/throat": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", @@ -14959,6 +15407,33 @@ "node": ">=8" } }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==" + }, "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", @@ -15023,6 +15498,41 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -15320,6 +15830,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -15344,6 +15862,14 @@ "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "engines": { + "node": ">= 4" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -15447,6 +15973,16 @@ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", "integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==" }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==" + }, "node_modules/webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -16261,6 +16797,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", + "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/ReferenceSurfaceGenerator/frontend/package.json b/ReferenceSurfaceGenerator/frontend/package.json index 6d535c0..47906ba 100644 --- a/ReferenceSurfaceGenerator/frontend/package.json +++ b/ReferenceSurfaceGenerator/frontend/package.json @@ -3,6 +3,8 @@ "version": "0.1.0", "private": true, "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -13,6 +15,7 @@ "react-bootstrap": "^2.10.10", "react-dom": "^19.2.4", "react-scripts": "5.0.1", + "three": "^0.182.0", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/ReferenceSurfaceGenerator/frontend/src/App.js b/ReferenceSurfaceGenerator/frontend/src/App.js index 04a42a2..c08dd40 100644 --- a/ReferenceSurfaceGenerator/frontend/src/App.js +++ b/ReferenceSurfaceGenerator/frontend/src/App.js @@ -1,74 +1,14 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; -import { Container, Navbar, Card, ProgressBar, Alert, Button, Form, Row, Col, ListGroup, Badge } from 'react-bootstrap'; +import { Container, Navbar, Card, ProgressBar, Alert, Button, Form, Row, Col, ListGroup } from 'react-bootstrap'; +import JobItem from './JobItem'; +import DxfViewer from './DxfViewer'; 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 === 'COMPLETE' && job.download_url && ( - - )} - -
-
- - {/* Progress Bar for Processing state */} - {job.status === 'PROCESSING' && } - - {/* Informational Message for non-failed states */} - {job.status !== 'FAILED' && job.message && ( - {job.message} - )} - - {/* Error Alert for FAILED state */} - {job.status === 'FAILED' && job.message && ( - - Error: {job.message} - - )} -
- ); -}; - // --- Upload Component (modified) --- const UploadComponent = ({ setJobs }) => { const [file, setFile] = useState(null); @@ -202,6 +142,20 @@ const [jobs, setJobs] = useState([]); const webSocketRef = React.useRef(null); + // State for the DXF Viewer Modal + const [showViewer, setShowViewer] = useState(false); + const [viewUrl, setViewUrl] = useState(null); + + const handleView = (url) => { + setViewUrl(url); + setShowViewer(true); + }; + + const handleCloseViewer = () => { + setShowViewer(false); + setViewUrl(null); + }; + // Fetch all existing jobs on component mount and set up polling useEffect(() => { const fetchJobs = async () => { @@ -298,39 +252,44 @@ DXF Curve Generator - - - - {processingJobs.length > 0 && ( - - - Currently Processing - - {processingJobs.map(job => ( - - ))} - - - - )} - - - - Job History - {otherJobs.length === 0 && processingJobs.length === 0 ? ( - No jobs to show. Upload a file to begin. - ) : ( - - {otherJobs.map(job => ( - - ))} - - )} - - - - - ); -} - + + + + {processingJobs.length > 0 && ( + + + Currently Processing + + {processingJobs.map(job => ( + + ))} + + + + )} + + + + Job History + {otherJobs.length === 0 && processingJobs.length === 0 ? ( + No jobs to show. Upload a file to begin. + ) : ( + + {otherJobs.map(job => ( + + ))} + + )} + + + + + + ); + } export default App; diff --git a/ReferenceSurfaceGenerator/frontend/src/DxfViewer.js b/ReferenceSurfaceGenerator/frontend/src/DxfViewer.js new file mode 100644 index 0000000..78e6a03 --- /dev/null +++ b/ReferenceSurfaceGenerator/frontend/src/DxfViewer.js @@ -0,0 +1,149 @@ +import React, { useState, useEffect, Suspense } from 'react'; +import axios from 'axios'; +import { Modal, Button, Alert, Spinner } from 'react-bootstrap'; +import { Canvas, useThree } from '@react-three/fiber'; +import { OrbitControls, Line } from '@react-three/drei'; +import * as THREE from 'three'; + +const DxfContent = ({ data }) => { + const { scene } = useThree(); + + // Calculate the bounding box of all polylines + const boundingBox = new THREE.Box3(); + data.polylines.forEach(polyline => { + polyline.forEach(point => { + boundingBox.expandByPoint(new THREE.Vector3(point[0], point[1], point[2])); + }); + }); + + // Calculate the offset required to move the bottom of the object to z=0 + const zMin = boundingBox.min.z; + const translationOffset = new THREE.Vector3(0, 0, -zMin); + + // Calculate the new center for the camera target after translation + const originalCenter = boundingBox.getCenter(new THREE.Vector3()); + const adjustedCenter = originalCenter.clone().add(translationOffset); + + // Determine appropriate size for lighting and grid + const size = boundingBox.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + + // Add GridHelper from THREE directly, positioned at the new center + React.useEffect(() => { + const gridHelper = new THREE.GridHelper(maxDim * 2, 20, 0x000000, 0xcccccc); + // The grid itself stays at y=0, but we can align its x/z center with the object + gridHelper.position.set(adjustedCenter.x, 0, adjustedCenter.z); + scene.add(gridHelper); + return () => { + scene.remove(gridHelper); + gridHelper.dispose(); + }; + }, [maxDim, adjustedCenter, scene]); + + return ( + <> + + + + + {data.polylines.map((polyline, i) => { + // Apply the translation to each point and explicitly close the loop + const adjustedPoints = polyline.map(p => new THREE.Vector3(p[0], p[1], p[2]).add(translationOffset)); + const closedPolyline = [...adjustedPoints, adjustedPoints[0]]; + return ( + + ); + })} + + + + ); +}; + + +const DxfViewer = ({ show, onHide, viewUrl, API_URL }) => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + // State for camera settings, adjusted dynamically + const [cameraProps, setCameraProps] = useState({ position: [0, 0, 150], fov: 75, near: 0.1, far: 1000 }); + + useEffect(() => { + if (show && viewUrl) { + const fetchData = async () => { + setLoading(true); + setError(null); + setData(null); + try { + const response = await axios.get(`${API_URL}${viewUrl}`); + setData(response.data); + + // Calculate dynamic camera properties once data is loaded + if (response.data && response.data.polylines.length > 0) { + const boundingBox = new THREE.Box3(); + response.data.polylines.forEach(polyline => { + polyline.forEach(point => { + boundingBox.expandByPoint(new THREE.Vector3(point[0], point[1], point[2])); + }); + }); + const center = boundingBox.getCenter(new THREE.Vector3()); + const size = boundingBox.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + const dynamicCameraDistance = maxDim * 1.5; // Used here + + setCameraProps(prev => ({ + ...prev, + position: [center.x, center.y, center.z + dynamicCameraDistance], + near: Math.max(0.1, dynamicCameraDistance / 1000), // Adjust near plane dynamically + far: dynamicCameraDistance * 2 // Adjust far plane dynamically + })); + } + + } catch (err) { + setError(err.response?.data?.detail || 'Failed to load DXF data.'); + } finally { + setLoading(false); + } + }; + fetchData(); + } + }, [show, viewUrl, API_URL]); + + return ( + + + DXF Viewer + + {/* Changed to white */} + {loading &&
Loading geometry...
} {/* Spinner color changed */} + {error && {error}} + {data && ( + Preparing renderer...}> {/* Text color changed */} + {/* Added antialias */} + {/* Explicitly set canvas background */} + + + + )} +
+ + + +
+ ); +}; + +export default DxfViewer; diff --git a/ReferenceSurfaceGenerator/frontend/src/DxfViewer.test.js b/ReferenceSurfaceGenerator/frontend/src/DxfViewer.test.js new file mode 100644 index 0000000..2a8faa0 --- /dev/null +++ b/ReferenceSurfaceGenerator/frontend/src/DxfViewer.test.js @@ -0,0 +1,112 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import DxfViewer from './DxfViewer'; + +// Mock axios to avoid actual network requests +jest.mock('axios'); + +// Mock react-bootstrap's Modal to simplify testing +jest.mock('react-bootstrap/Modal', () => ({ + __esModule: true, + default: ({ children, show, onHide }) => (show ?
{children}
: null), + Header: ({ children }) =>
{children}
, + Title: ({ children }) =>
{children}
, + Body: ({ children }) =>
{children}
, + Footer: ({ children }) =>
{children}
, +})); + + +// Mocking three.js and react-three/fiber dependencies +jest.mock('@react-three/fiber', () => ({ + Canvas: ({ children, fallback }) => { + // Only render the fallback if children are not ready or if suspense is active + return
{fallback || children}
; + }, +})); + +jest.mock('@react-three/drei', () => ({ + OrbitControls: () => null, + Line: () => null, + Box: () => null, +})); + + +describe('DxfViewer', () => { + const API_URL = 'http://localhost:8000'; + const viewUrl = '/api/jobs/123/view'; + + beforeEach(() => { + // Reset mocks before each test + axios.get.mockClear(); + }); + + it('shows a loading spinner while fetching data', async () => { + axios.get.mockImplementationOnce(() => new Promise(() => {})); // Never resolve to keep loading + + act(() => { + render( {}} viewUrl={viewUrl} API_URL={API_URL} />); + }); + + expect(screen.getByText('Loading geometry...')).toBeInTheDocument(); + }); + + it('displays an error message if data fetching fails', async () => { + const errorMessage = 'Failed to load DXF data.'; + axios.get.mockRejectedValueOnce({ response: { data: { detail: errorMessage } } }); + + await act(async () => { + render( {}} viewUrl={viewUrl} API_URL={API_URL} />); + }); + + // Wait for the error message to appear + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + it('renders the canvas with data after successful fetch', async () => { + const mockData = { + polylines: [[[0, 0, 0], [1, 1, 1]]], + }; + axios.get.mockResolvedValueOnce({ data: mockData }); + + let component; + await act(async () => { + component = render( {}} viewUrl={viewUrl} API_URL={API_URL} />); + }); + + // Check if the mock canvas is present and not displaying the fallback initially + const mockCanvas = screen.getByTestId('mock-canvas'); + expect(mockCanvas).toBeInTheDocument(); + expect(mockCanvas).not.toHaveTextContent('Preparing renderer...'); + + // We expect the children (DxfContent) to be rendered inside the Canvas mock + // Since DxfContent itself doesn't render user-facing text other than the console error about casing, + // we can assert that the Canvas mock is showing some content (its children) + // This is a weak assertion, but stronger ones would require mocking THREE.Line etc. + expect(mockCanvas).toBeEmptyDOMElement(); // The Line component mock returns null, so canvas should be empty + + // Optionally, verify that axios.get was called + expect(axios.get).toHaveBeenCalledWith(`${API_URL}${viewUrl}`); + }); + + it('does not fetch data when not shown', async () => { + render( {}} viewUrl={viewUrl} API_URL={API_URL} />); + expect(axios.get).not.toHaveBeenCalled(); + }); + + it('calls onHide when close button is clicked', async () => { + const mockOnHide = jest.fn(); + axios.get.mockResolvedValueOnce({ data: { polylines: [] } }); // Resolve immediately + + await act(async () => { + render(); + }); + + const closeButton = screen.getByRole('button', { name: /close/i }); + act(() => { + closeButton.click(); + }); + + expect(mockOnHide).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ReferenceSurfaceGenerator/frontend/src/JobItem.js b/ReferenceSurfaceGenerator/frontend/src/JobItem.js new file mode 100644 index 0000000..07b10c1 --- /dev/null +++ b/ReferenceSurfaceGenerator/frontend/src/JobItem.js @@ -0,0 +1,71 @@ +import React from 'react'; +import axios from 'axios'; +import { ListGroup, Badge, Button, ProgressBar, Alert } from 'react-bootstrap'; + +const JobItem = ({ job, API_URL, onJobDelete, onView }) => { + 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 === 'COMPLETE' && job.view_url && ( + + )} + {job.status === 'COMPLETE' && job.download_url && ( + + )} + +
+
+ + {/* Progress Bar for Processing state */} + {job.status === 'PROCESSING' && } + + {/* Informational Message for non-failed states */} + {job.status !== 'FAILED' && job.message && ( + {job.message} + )} + + {/* Error Alert for FAILED state */} + {job.status === 'FAILED' && job.message && ( + + Error: {job.message} + + )} +
+ ); +}; + +export default JobItem;