Initial commit - BraceIQMed platform with frontend, API, and brace generator
80
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
# Node deps
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
.vite/
|
||||
|
||||
# Environment files (sensitive)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
*.log
|
||||
|
||||
# VCS and tooling
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor / IDE history and temp
|
||||
*.swp
|
||||
*~
|
||||
.history/
|
||||
|
||||
# Test & coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
jest.cache
|
||||
|
||||
# Caches
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
.pnp.*
|
||||
.eslintcache
|
||||
|
||||
# Serverless / local dev artifacts
|
||||
.serverless/
|
||||
.firebase/
|
||||
|
||||
# Packages / artifacts
|
||||
*.tgz
|
||||
|
||||
# Optional: lockfiles should be committed (do NOT ignore package-lock.json / yarn.lock)
|
||||
382
frontend/BRACE_GENERATOR_INTEGRATION.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# Brace Generator Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes how the GPU-based brace generator is integrated into the Braceflow frontend via AWS Lambda functions.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Frontend (React)
|
||||
│
|
||||
├── Upload X-ray
|
||||
│ └── POST /cases → Create case
|
||||
│ └── POST /cases/{caseId}/upload-url → Get presigned URL
|
||||
│ └── PUT to S3 → Upload file directly
|
||||
│
|
||||
└── Generate Brace
|
||||
└── POST /cases/{caseId}/generate-brace
|
||||
└── Lambda: braceflow_invoke_brace_generator
|
||||
└── Download image from S3
|
||||
└── Call EC2 GPU server /analyze/upload
|
||||
└── Download outputs from GPU server
|
||||
└── Upload outputs to S3
|
||||
└── Update database with results
|
||||
└── Return results with presigned URLs
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. Create Case
|
||||
```
|
||||
POST /cases
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"caseId": "case-20260125-abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Get Upload URL
|
||||
```
|
||||
POST /cases/{caseId}/upload-url
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"filename": "ap.jpg",
|
||||
"contentType": "image/jpeg"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"url": "https://braceflow-uploads-xxx.s3.amazonaws.com/...",
|
||||
"s3Key": "cases/case-xxx/input/ap.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Generate Brace
|
||||
```
|
||||
POST /cases/{caseId}/generate-brace
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"experiment": "experiment_3",
|
||||
"config": {
|
||||
"brace_height_mm": 400,
|
||||
"torso_width_mm": 280,
|
||||
"torso_depth_mm": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"caseId": "case-20260125-abc123",
|
||||
"status": "brace_generated",
|
||||
"experiment": "experiment_3",
|
||||
"model": "ScolioVis",
|
||||
"vertebrae_detected": 17,
|
||||
"cobb_angles": {
|
||||
"PT": 12.5,
|
||||
"MT": 28.3,
|
||||
"TL": 15.2
|
||||
},
|
||||
"curve_type": "S-shaped",
|
||||
"rigo_classification": {
|
||||
"type": "A3",
|
||||
"description": "Major thoracic with compensatory lumbar"
|
||||
},
|
||||
"mesh": {
|
||||
"vertices": 6204,
|
||||
"faces": 12404
|
||||
},
|
||||
"outputs": {
|
||||
"stl": { "s3Key": "cases/.../brace.stl", "url": "https://..." },
|
||||
"ply": { "s3Key": "cases/.../brace.ply", "url": "https://..." },
|
||||
"visualization": { "s3Key": "cases/.../viz.png", "url": "https://..." },
|
||||
"landmarks": { "s3Key": "cases/.../landmarks.json", "url": "https://..." }
|
||||
},
|
||||
"processing_time_ms": 3250
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Get Brace Outputs
|
||||
```
|
||||
GET /cases/{caseId}/brace-outputs
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"caseId": "case-20260125-abc123",
|
||||
"status": "brace_generated",
|
||||
"analysis": {
|
||||
"experiment": "experiment_3",
|
||||
"model": "ScolioVis",
|
||||
"cobb_angles": { "PT": 12.5, "MT": 28.3, "TL": 15.2 },
|
||||
"curve_type": "S-shaped",
|
||||
"rigo_classification": { "type": "A3", "description": "..." }
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"filename": "brace.stl",
|
||||
"type": "stl",
|
||||
"s3Key": "cases/.../brace.stl",
|
||||
"size": 1234567,
|
||||
"url": "https://...",
|
||||
"expiresIn": 3600
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### API Client (`src/api/braceflowApi.ts`)
|
||||
|
||||
The API client includes these functions:
|
||||
|
||||
```typescript
|
||||
// Create a new case
|
||||
export async function createCase(body?: { notes?: string }): Promise<{ caseId: string }>;
|
||||
|
||||
// Get presigned URL for S3 upload
|
||||
export async function getUploadUrl(caseId: string, filename: string, contentType: string):
|
||||
Promise<{ url: string; s3Key: string }>;
|
||||
|
||||
// Upload file directly to S3
|
||||
export async function uploadToS3(presignedUrl: string, file: File): Promise<void>;
|
||||
|
||||
// Invoke brace generator Lambda
|
||||
export async function generateBrace(caseId: string, options?: {
|
||||
experiment?: string;
|
||||
config?: Record<string, unknown>
|
||||
}): Promise<GenerateBraceResponse>;
|
||||
|
||||
// Get brace outputs with presigned URLs
|
||||
export async function getBraceOutputs(caseId: string): Promise<BraceOutputsResponse>;
|
||||
|
||||
// Full workflow helper
|
||||
export async function analyzeXray(file: File, options?: {
|
||||
experiment?: string;
|
||||
config?: Record<string, unknown>
|
||||
}): Promise<{ caseId: string; result: GenerateBraceResponse }>;
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
export type CobbAngles = {
|
||||
PT?: number;
|
||||
MT?: number;
|
||||
TL?: number;
|
||||
};
|
||||
|
||||
export type RigoClassification = {
|
||||
type: string;
|
||||
description: string;
|
||||
curve_pattern?: string;
|
||||
};
|
||||
|
||||
export type AnalysisResult = {
|
||||
experiment?: string;
|
||||
model?: string;
|
||||
vertebrae_detected?: number;
|
||||
cobb_angles?: CobbAngles;
|
||||
curve_type?: string;
|
||||
rigo_classification?: RigoClassification;
|
||||
mesh_info?: { vertices?: number; faces?: number };
|
||||
outputs?: Record<string, { s3Key: string; url: string }>;
|
||||
processing_time_ms?: number;
|
||||
};
|
||||
|
||||
export type BraceOutput = {
|
||||
filename: string;
|
||||
type: "stl" | "ply" | "obj" | "image" | "json" | "other";
|
||||
s3Key: string;
|
||||
size: number;
|
||||
url: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Page | Description |
|
||||
|-------|------|-------------|
|
||||
| `/analyze` | BraceAnalysisPage | New analysis with X-ray upload |
|
||||
| `/cases/:caseId/analysis` | BraceAnalysisPage | View existing case analysis |
|
||||
| `/generate` | ShellGenerationPage | Direct brace generation (legacy) |
|
||||
|
||||
---
|
||||
|
||||
## Page: BraceAnalysisPage
|
||||
|
||||
Located at `src/pages/BraceAnalysisPage.tsx`
|
||||
|
||||
### Features
|
||||
|
||||
1. **Upload Panel** - Drag-and-drop X-ray upload
|
||||
2. **3D Viewer** - Interactive brace model preview
|
||||
3. **Analysis Results** - Displays:
|
||||
- Overall severity assessment
|
||||
- Curve type classification
|
||||
- Cobb angles (PT, MT, TL)
|
||||
- Rigo-Chêneau classification
|
||||
- Mesh information
|
||||
4. **Downloads** - All generated files with presigned S3 URLs
|
||||
|
||||
### Layout
|
||||
|
||||
Three-column layout:
|
||||
- Left: Upload panel with case ID display
|
||||
- Center: 3D brace viewer with processing info
|
||||
- Right: Analysis results and download links
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Reusable Components
|
||||
|
||||
| Component | Location | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `UploadPanel` | `src/components/rigo/UploadPanel.tsx` | Drag-and-drop file upload |
|
||||
| `BraceViewer` | `src/components/rigo/BraceViewer.tsx` | 3D model viewer (React Three Fiber) |
|
||||
| `AnalysisResults` | `src/components/rigo/AnalysisResults.tsx` | Analysis display component |
|
||||
|
||||
---
|
||||
|
||||
## Lambda Functions
|
||||
|
||||
### braceflow_invoke_brace_generator
|
||||
|
||||
Located at: `braceflow_lambda/braceflow_invoke_brace_generator/index.mjs`
|
||||
|
||||
**Process:**
|
||||
1. Validate environment and request
|
||||
2. Get case from database
|
||||
3. Update status to `processing_brace`
|
||||
4. Download X-ray from S3
|
||||
5. Call GPU server `/analyze/upload`
|
||||
6. Download outputs from GPU server `/download/{caseId}/{filename}`
|
||||
7. Upload outputs to S3
|
||||
8. Update database with analysis results
|
||||
9. Return results with presigned URLs
|
||||
|
||||
**Environment Variables:**
|
||||
- `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`
|
||||
- `BRACE_GENERATOR_URL` - EC2 GPU server URL
|
||||
- `BUCKET_NAME` - S3 bucket name
|
||||
|
||||
### braceflow_get_brace_outputs
|
||||
|
||||
Located at: `braceflow_lambda/braceflow_get_brace_outputs/index.mjs`
|
||||
|
||||
**Process:**
|
||||
1. Get case from database
|
||||
2. List files in S3 `cases/{caseId}/outputs/`
|
||||
3. Generate presigned URLs for each file
|
||||
4. Return files list with analysis data
|
||||
|
||||
---
|
||||
|
||||
## S3 Structure
|
||||
|
||||
```
|
||||
braceflow-uploads-{date}/
|
||||
├── cases/
|
||||
│ └── {caseId}/
|
||||
│ ├── input/
|
||||
│ │ └── ap.jpg # Original X-ray
|
||||
│ └── outputs/
|
||||
│ ├── brace_{caseId}.stl # 3D printable model
|
||||
│ ├── brace_{caseId}_adaptive.ply # Adaptive mesh
|
||||
│ ├── brace_{caseId}.png # Visualization
|
||||
│ └── brace_{caseId}.json # Landmarks data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE brace_cases (
|
||||
case_id VARCHAR(64) PRIMARY KEY,
|
||||
status ENUM('created', 'processing_brace', 'brace_generated', 'brace_failed'),
|
||||
current_step VARCHAR(64),
|
||||
analysis_result JSON,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deploy Lambda Functions
|
||||
|
||||
```powershell
|
||||
cd braceflow_lambda/deploy
|
||||
.\deploy-brace-generator-lambdas.ps1 -BraceGeneratorUrl "http://YOUR_EC2_IP:8000"
|
||||
```
|
||||
|
||||
### Add API Gateway Routes
|
||||
|
||||
```
|
||||
POST /cases/{caseId}/generate-brace → braceflow_invoke_brace_generator
|
||||
GET /cases/{caseId}/brace-outputs → braceflow_get_brace_outputs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd braceflow
|
||||
npm install
|
||||
npm run dev
|
||||
# Open http://localhost:5173
|
||||
```
|
||||
|
||||
Navigate to `/analyze` to use the new brace analysis page.
|
||||
|
||||
### Testing
|
||||
|
||||
1. Go to http://localhost:5173/analyze
|
||||
2. Upload an X-ray image
|
||||
3. Wait for analysis to complete
|
||||
4. View results and download files
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **CORS errors**: Ensure API Gateway has CORS configured
|
||||
2. **Timeout errors**: Lambda timeout is 120s, may need increase for large images
|
||||
3. **S3 access denied**: Check Lambda role has S3 permissions
|
||||
4. **GPU server unreachable**: Check EC2 security group allows port 8000
|
||||
|
||||
### Checking Lambda Logs
|
||||
|
||||
```bash
|
||||
aws logs tail /aws/lambda/braceflow_invoke_brace_generator --follow
|
||||
```
|
||||
37
frontend/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
# ============================================
|
||||
# BraceIQMed Frontend - React + Vite + nginx
|
||||
# ============================================
|
||||
|
||||
# Stage 1: Build the React app
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the app (uses relative API URLs)
|
||||
ENV VITE_API_URL=""
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Create log directory
|
||||
RUN mkdir -p /var/log/nginx
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
9
frontend/cors.json
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"AllowedHeaders": ["*"],
|
||||
"AllowedMethods": ["GET"],
|
||||
"AllowedOrigins": ["*"],
|
||||
"ExposeHeaders": [],
|
||||
"MaxAgeSeconds": 3000
|
||||
}
|
||||
]
|
||||
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BraceiQ</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
61
frontend/nginx.conf
Normal file
@@ -0,0 +1,61 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
gzip_min_length 1000;
|
||||
|
||||
# Increase max body size for file uploads
|
||||
client_max_body_size 100M;
|
||||
|
||||
# Proxy API requests to the API container
|
||||
location /api/ {
|
||||
proxy_pass http://api:3002/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
|
||||
# Proxy file requests to the API container
|
||||
location /files/ {
|
||||
proxy_pass http://api:3002/files/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Serve static assets with caching
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback - serve index.html for all other routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 'OK';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
3689
frontend/package-lock.json
generated
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "braceflow-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.0.0",
|
||||
"@react-three/fiber": "^9.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"three": "^0.170.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.170.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
}
|
||||
}
|
||||
12
frontend/policy.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PublicReadGetObject",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::braceflow-ui-www/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
frontend/public/sculptgl/authSuccess.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
This window will close in 2 seconds. You can close it manually if it doesn't.
|
||||
|
||||
<script type="text/javascript">
|
||||
setTimeout(function(){
|
||||
window.close();
|
||||
}, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
507
frontend/public/sculptgl/css/yagui.css
Normal file
@@ -0,0 +1,507 @@
|
||||
* {
|
||||
font: 13px 'Open Sans', sans-serif;
|
||||
color: #bbb;
|
||||
font-weight: 400;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#tablet-plugin {
|
||||
position: absolute;
|
||||
top: -1000px;
|
||||
}
|
||||
|
||||
#viewport {
|
||||
position: absolute;
|
||||
left: 310px; /* Add left margin to account for sidebar on the left */
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
/****** SIDE BAR ******/
|
||||
|
||||
.gui-sidebar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
padding-bottom: 20px;
|
||||
width: 310px;
|
||||
background: #3c3c3c;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
border-right: double;
|
||||
border-width: 4px;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.gui-sidebar::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.gui-sidebar::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.gui-sidebar::-webkit-scrollbar-corner {
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gui-resize {
|
||||
cursor: ew-resize;
|
||||
position: absolute;
|
||||
left: 310px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
margin-left: -3px;
|
||||
margin-right: -3px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
/****** folder ******/
|
||||
|
||||
.gui-sidebar > ul > label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
position: relative;
|
||||
display: block;
|
||||
line-height: 30px;
|
||||
margin: 5px 0 5px 0;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.gui-sidebar > ul[opened=true] > label:before {
|
||||
content: '▼';
|
||||
text-indent: 1em;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.gui-sidebar > ul[opened=false] > label:before {
|
||||
content: '►';
|
||||
text-indent: 1em;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.gui-sidebar > ul {
|
||||
display: block;
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
-webkit-transition: max-height 0.3s ease;
|
||||
-moz-transition: max-height 0.3s ease;
|
||||
-ms-transition: max-height 0.3s ease;
|
||||
-o-transition: max-height 0.3s ease;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.gui-sidebar > ul[opened=true] {
|
||||
max-height: 700px;
|
||||
}
|
||||
|
||||
.gui-sidebar > ul[opened=false] {
|
||||
height: 35px;
|
||||
max-height: 35px;
|
||||
}
|
||||
|
||||
.gui-sidebar > ul > li {
|
||||
height: 22px;
|
||||
margin: 4px 5px 4px 5px;
|
||||
}
|
||||
|
||||
.gui-glowOnHover:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.gui-pointerOnHover:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/****** label ******/
|
||||
|
||||
.gui-label-side {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
width: 36%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/****** checkbox ******/
|
||||
|
||||
.gui-input-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gui-input-checkbox + label {
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
margin-top: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.gui-input-checkbox + label::before {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 5px;
|
||||
height: 14px;
|
||||
width: 6px;
|
||||
border-right: 2px solid;
|
||||
border-bottom: 2px solid;
|
||||
-webkit-transform: rotate(60deg) skew(25deg, 0);
|
||||
-ms-transform: rotate(60deg) skew(25deg, 0);
|
||||
transform: rotate(60deg) skew(25deg, 0);
|
||||
}
|
||||
|
||||
.gui-input-checkbox:checked + label::before {
|
||||
content: '';
|
||||
}
|
||||
|
||||
|
||||
/****** input number ******/
|
||||
|
||||
.gui-input-number {
|
||||
-moz-appearance: textfield;
|
||||
float: right;
|
||||
position: relative;
|
||||
width: 10%;
|
||||
height: 100%;
|
||||
margin-left: 2%;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
font-size: 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.gui-widget-color > input::-webkit-inner-spin-button,
|
||||
.gui-widget-color > input::-webkit-outer-spin-button,
|
||||
.gui-input-number::-webkit-inner-spin-button,
|
||||
.gui-input-number::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
/****** input on hover ******/
|
||||
|
||||
.gui-slider:hover,
|
||||
.gui-input-number:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
|
||||
/****** slider ******/
|
||||
|
||||
.gui-slider {
|
||||
-webkit-appearance: none;
|
||||
cursor: ew-resize;
|
||||
float: right;
|
||||
width: 52%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.gui-slider > div {
|
||||
height: 100%;
|
||||
background: #525f63;
|
||||
}
|
||||
|
||||
|
||||
/****** button ******/
|
||||
|
||||
.gui-button {
|
||||
-webkit-appearance: none;
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
background: #525f63;
|
||||
}
|
||||
|
||||
.gui-button::-moz-focus-inner {
|
||||
padding: 0 !important;
|
||||
border: 0 none !important;
|
||||
}
|
||||
|
||||
.gui-button:enabled:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gui-button:active {
|
||||
box-shadow: 0 1px 0 hsla(0, 0%, 100%, .1), inset 0 1px 4px hsla(0, 0%, 0%, .8);
|
||||
}
|
||||
|
||||
.gui-button:disabled {
|
||||
background: #444;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
|
||||
/****** widget color ******/
|
||||
|
||||
.gui-widget-color {
|
||||
float: right;
|
||||
display: block;
|
||||
width: 64%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gui-widget-color > input {
|
||||
-moz-appearance: textfield;
|
||||
position: relative;
|
||||
display: inline;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background: #f00;
|
||||
}
|
||||
|
||||
.gui-widget-color > input + div:hover,
|
||||
.gui-widget-color > input:hover + div {
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gui-widget-color > input + div {
|
||||
display: none;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
padding: 3px;
|
||||
width: 125px;
|
||||
height: 105px;
|
||||
z-index: 2;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
|
||||
/* saturation */
|
||||
|
||||
.gui-color-saturation {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-right: 3px;
|
||||
border: 1px solid #555;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gui-color-saturation > div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.gui-knob-saturation {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
z-index: 4;
|
||||
border: #fff;
|
||||
border-radius: 10px;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
|
||||
/* hue*/
|
||||
|
||||
.gui-color-hue {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 100px;
|
||||
border: 1px solid #555;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.gui-knob-hue {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 15px;
|
||||
height: 2px;
|
||||
border-right: 4px solid #fff;
|
||||
}
|
||||
|
||||
|
||||
/* alpha */
|
||||
|
||||
.gui-color-alpha {
|
||||
display: inline-block;
|
||||
margin-left: 3px;
|
||||
height: 100px;
|
||||
width: 15px;
|
||||
border: 1px solid #555;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.gui-knob-alpha {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 15px;
|
||||
height: 2px;
|
||||
border-right: 4px solid #fff;
|
||||
}
|
||||
|
||||
|
||||
/****** select ******/
|
||||
|
||||
.gui-select {
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 64%;
|
||||
height: 100%;
|
||||
padding-left: 1%;
|
||||
outline: none;
|
||||
background: #525f63;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gui-select:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
/****** TOP BAR ******/
|
||||
|
||||
.gui-topbar {
|
||||
position: absolute;
|
||||
background: #20211d;
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
padding-left: 10px;
|
||||
z-index: 1;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.gui-topbar ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gui-topbar ul > li {
|
||||
float: left;
|
||||
line-height: 40px;
|
||||
padding: 0 15px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gui-topbar ul > li.gui-logo {
|
||||
padding: 0 12px 0 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gui-topbar ul > li.gui-logo:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.gui-topbar ul > li.gui-logo img {
|
||||
display: block;
|
||||
height: 28px;
|
||||
margin-top: 6px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.gui-topbar ul > li .shortcut {
|
||||
float: right;
|
||||
font-style: oblique;
|
||||
margin-right: 11px;
|
||||
}
|
||||
|
||||
.gui-topbar ul > li:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gui-topbar ul > li:hover > ul {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
top: 30px;
|
||||
}
|
||||
|
||||
.gui-topbar ul > li > ul {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 10px;
|
||||
background: #222;
|
||||
width: 220px;
|
||||
padding: 8px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
-webkit-transition: 0.15s all ease;
|
||||
-ms-transition: 0.15s all ease;
|
||||
-moz-transition: 0.15s all ease;
|
||||
-o-transition: 0.15s all ease;
|
||||
transition: 0.15s all ease;
|
||||
}
|
||||
|
||||
.gui-topbar ul > li > ul > li {
|
||||
float: none;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
margin: 6px 0 6px 0;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #999 !important;
|
||||
cursor: default !important;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #444444;
|
||||
padding-bottom: 5px;
|
||||
margin: 10px 0 10px 0;
|
||||
}
|
||||
47
frontend/public/sculptgl/index.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang='en'>
|
||||
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<meta name='description' content='SculptGL is a small sculpting application powered by JavaScript and webGL.'>
|
||||
<meta name='author' content='stéphane GINIER'>
|
||||
<meta name='mobile-web-app-capable' content='yes'>
|
||||
<meta name='apple-mobile-web-app-capable' content='yes'>
|
||||
|
||||
<title> BRACE iQ </title>
|
||||
|
||||
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'>
|
||||
<link rel='stylesheet' href='css/yagui.css' type='text/css' />
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
window.sketchfabOAuth2Config = {
|
||||
hostname: 'sketchfab.com',
|
||||
client_id: 'OWoAmrd1QCS9wB54Ly17rMl2i5AHGvDNfmN4pEUH',
|
||||
redirect_uri: 'https://stephaneginier.com/sculptgl/authSuccess.html'
|
||||
};
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
var app = new window.SculptGL();
|
||||
app.start();
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body oncontextmenu='return false;'>
|
||||
<input type='file' id='fileopen' multiple style='display: none' />
|
||||
<input type='file' id='backgroundopen' style='display: none' />
|
||||
<input type='file' id='alphaopen' style='display: none' />
|
||||
<input type='file' id='textureopen' style='display: none' />
|
||||
<input type='file' id='matcapopen' style='display: none' />
|
||||
|
||||
<div id='viewport'>
|
||||
<canvas id='canvas'></canvas>
|
||||
</div>
|
||||
|
||||
<script src='sculptgl.js'></script>
|
||||
<!-- <script src='//cdn.webglstats.com/stat.js' defer='defer' async='async'></script> -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
BIN
frontend/public/sculptgl/resources/alpha/skin.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
frontend/public/sculptgl/resources/alpha/square.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/sculptgl/resources/dropper.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 817 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 862 KiB |
|
After Width: | Height: | Size: 881 KiB |
|
After Width: | Height: | Size: 705 KiB |
BIN
frontend/public/sculptgl/resources/logo.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/clay.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/green.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/matcapFV.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/pearl.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/redClay.jpg
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/skin.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/skinHazardousarts.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/white.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/sculptgl/resources/uv.png
Normal file
|
After Width: | Height: | Size: 408 KiB |
2
frontend/public/sculptgl/sculptgl.js
Normal file
77
frontend/public/sculptgl/sculptgl.js.LICENSE.txt
Normal file
@@ -0,0 +1,77 @@
|
||||
/*! Hammer.JS - v2.0.7 - 2016-04-22
|
||||
* http://hammerjs.github.io/
|
||||
*
|
||||
* Copyright (c) 2016 Jorik Tangelder;
|
||||
* Licensed under the MIT license */
|
||||
|
||||
/*! exports provided: default */
|
||||
|
||||
/*! no static exports found */
|
||||
|
||||
/*!**********************!*\
|
||||
!*** ./src/yagui.js ***!
|
||||
\**********************/
|
||||
|
||||
/*!************************!*\
|
||||
!*** ./src/GuiMain.js ***!
|
||||
\************************/
|
||||
|
||||
/*!******************************!*\
|
||||
!*** ./src/widgets/Color.js ***!
|
||||
\******************************/
|
||||
|
||||
/*!******************************!*\
|
||||
!*** ./src/widgets/Title.js ***!
|
||||
\******************************/
|
||||
|
||||
/*!*******************************!*\
|
||||
!*** ./src/utils/GuiUtils.js ***!
|
||||
\*******************************/
|
||||
|
||||
/*!*******************************!*\
|
||||
!*** ./src/widgets/Button.js ***!
|
||||
\*******************************/
|
||||
|
||||
/*!*******************************!*\
|
||||
!*** ./src/widgets/Slider.js ***!
|
||||
\*******************************/
|
||||
|
||||
/*!********************************!*\
|
||||
!*** ./src/containers/Menu.js ***!
|
||||
\********************************/
|
||||
|
||||
/*!********************************!*\
|
||||
!*** ./src/utils/EditStyle.js ***!
|
||||
\********************************/
|
||||
|
||||
/*!*********************************!*\
|
||||
!*** ./src/widgets/Checkbox.js ***!
|
||||
\*********************************/
|
||||
|
||||
/*!*********************************!*\
|
||||
!*** ./src/widgets/Combobox.js ***!
|
||||
\*********************************/
|
||||
|
||||
/*!**********************************!*\
|
||||
!*** ./src/containers/Folder.js ***!
|
||||
\**********************************/
|
||||
|
||||
/*!**********************************!*\
|
||||
!*** ./src/containers/Topbar.js ***!
|
||||
\**********************************/
|
||||
|
||||
/*!***********************************!*\
|
||||
!*** ./src/containers/Sidebar.js ***!
|
||||
\***********************************/
|
||||
|
||||
/*!***********************************!*\
|
||||
!*** ./src/widgets/BaseWidget.js ***!
|
||||
\***********************************/
|
||||
|
||||
/*!***********************************!*\
|
||||
!*** ./src/widgets/MenuButton.js ***!
|
||||
\***********************************/
|
||||
|
||||
/*!*****************************************!*\
|
||||
!*** ./src/containers/BaseContainer.js ***!
|
||||
\*****************************************/
|
||||
1
frontend/public/sculptgl/worker/deflate.js
Normal file
147
frontend/public/sculptgl/worker/z-worker.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/* jshint worker:true */
|
||||
(function main(global) {
|
||||
"use strict";
|
||||
|
||||
if (global.zWorkerInitialized)
|
||||
throw new Error('z-worker.js should be run only once');
|
||||
global.zWorkerInitialized = true;
|
||||
|
||||
addEventListener("message", function(event) {
|
||||
var message = event.data, type = message.type, sn = message.sn;
|
||||
var handler = handlers[type];
|
||||
if (handler) {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (e) {
|
||||
onError(type, sn, e);
|
||||
}
|
||||
}
|
||||
//for debug
|
||||
//postMessage({type: 'echo', originalType: type, sn: sn});
|
||||
});
|
||||
|
||||
var handlers = {
|
||||
importScripts: doImportScripts,
|
||||
newTask: newTask,
|
||||
append: processData,
|
||||
flush: processData,
|
||||
};
|
||||
|
||||
// deflater/inflater tasks indexed by serial numbers
|
||||
var tasks = {};
|
||||
|
||||
function doImportScripts(msg) {
|
||||
if (msg.scripts && msg.scripts.length > 0)
|
||||
importScripts.apply(undefined, msg.scripts);
|
||||
postMessage({type: 'importScripts'});
|
||||
}
|
||||
|
||||
function newTask(msg) {
|
||||
var CodecClass = global[msg.codecClass];
|
||||
var sn = msg.sn;
|
||||
if (tasks[sn])
|
||||
throw Error('duplicated sn');
|
||||
tasks[sn] = {
|
||||
codec: new CodecClass(msg.options),
|
||||
crcInput: msg.crcType === 'input',
|
||||
crcOutput: msg.crcType === 'output',
|
||||
crc: new Crc32(),
|
||||
};
|
||||
postMessage({type: 'newTask', sn: sn});
|
||||
}
|
||||
|
||||
// performance may not be supported
|
||||
var now = global.performance ? global.performance.now.bind(global.performance) : Date.now;
|
||||
|
||||
function processData(msg) {
|
||||
var sn = msg.sn, type = msg.type, input = msg.data;
|
||||
var task = tasks[sn];
|
||||
// allow creating codec on first append
|
||||
if (!task && msg.codecClass) {
|
||||
newTask(msg);
|
||||
task = tasks[sn];
|
||||
}
|
||||
var isAppend = type === 'append';
|
||||
var start = now();
|
||||
var output;
|
||||
if (isAppend) {
|
||||
try {
|
||||
output = task.codec.append(input, function onprogress(loaded) {
|
||||
postMessage({type: 'progress', sn: sn, loaded: loaded});
|
||||
});
|
||||
} catch (e) {
|
||||
delete tasks[sn];
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
delete tasks[sn];
|
||||
output = task.codec.flush();
|
||||
}
|
||||
var codecTime = now() - start;
|
||||
|
||||
start = now();
|
||||
if (input && task.crcInput)
|
||||
task.crc.append(input);
|
||||
if (output && task.crcOutput)
|
||||
task.crc.append(output);
|
||||
var crcTime = now() - start;
|
||||
|
||||
var rmsg = {type: type, sn: sn, codecTime: codecTime, crcTime: crcTime};
|
||||
var transferables = [];
|
||||
if (output) {
|
||||
rmsg.data = output;
|
||||
transferables.push(output.buffer);
|
||||
}
|
||||
if (!isAppend && (task.crcInput || task.crcOutput))
|
||||
rmsg.crc = task.crc.get();
|
||||
postMessage(rmsg, transferables);
|
||||
}
|
||||
|
||||
function onError(type, sn, e) {
|
||||
var msg = {
|
||||
type: type,
|
||||
sn: sn,
|
||||
error: formatError(e)
|
||||
};
|
||||
postMessage(msg);
|
||||
}
|
||||
|
||||
function formatError(e) {
|
||||
return { message: e.message, stack: e.stack };
|
||||
}
|
||||
|
||||
// Crc32 code copied from file zip.js
|
||||
function Crc32() {
|
||||
this.crc = -1;
|
||||
}
|
||||
Crc32.prototype.append = function append(data) {
|
||||
var crc = this.crc | 0, table = this.table;
|
||||
for (var offset = 0, len = data.length | 0; offset < len; offset++)
|
||||
crc = (crc >>> 8) ^ table[(crc ^ data[offset]) & 0xFF];
|
||||
this.crc = crc;
|
||||
};
|
||||
Crc32.prototype.get = function get() {
|
||||
return ~this.crc;
|
||||
};
|
||||
Crc32.prototype.table = (function() {
|
||||
var i, j, t, table = []; // Uint32Array is actually slower than []
|
||||
for (i = 0; i < 256; i++) {
|
||||
t = i;
|
||||
for (j = 0; j < 8; j++)
|
||||
if (t & 1)
|
||||
t = (t >>> 1) ^ 0xEDB88320;
|
||||
else
|
||||
t = t >>> 1;
|
||||
table[i] = t;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
// "no-op" codec
|
||||
function NOOP() {}
|
||||
global.NOOP = NOOP;
|
||||
NOOP.prototype.append = function append(bytes, onprogress) {
|
||||
return bytes;
|
||||
};
|
||||
NOOP.prototype.flush = function flush() {};
|
||||
})(this);
|
||||
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
838
frontend/src/App.css
Normal file
@@ -0,0 +1,838 @@
|
||||
/* #root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Typography */
|
||||
font-family: Inter, system-ui, Arial;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
|
||||
/* Core background */
|
||||
--bg-main:
|
||||
radial-gradient(
|
||||
900px 520px at 86% 20%,
|
||||
rgba(209, 118, 69, 0.12) 0%,
|
||||
rgba(23, 27, 34, 0.35) 55%,
|
||||
rgba(12, 15, 20, 0.0) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 560px at 12% 86%,
|
||||
rgba(96, 122, 155, 0.14) 0%,
|
||||
rgba(18, 23, 30, 0.35) 55%,
|
||||
rgba(12, 15, 20, 0.0) 100%
|
||||
),
|
||||
linear-gradient(180deg, #151a22 0%, #10141a 100%);
|
||||
|
||||
--bg-surface: rgba(255, 255, 255, 0.14);
|
||||
--bg-surface-hover: rgba(255, 255, 255, 0.2);
|
||||
--bg-surface-active: rgba(255, 255, 255, 0.26);
|
||||
|
||||
/* Text */
|
||||
--text-main: #f1f5f9;
|
||||
--text-muted: #9aa4b2;
|
||||
|
||||
/* Accents */
|
||||
--accent-primary: #dd8250; /* copper (slightly brighter) */
|
||||
--accent-secondary: #f3b886; /* warm copper (slightly brighter) */
|
||||
--accent-success: #43c59e;
|
||||
--accent-danger: #f37f7f;
|
||||
|
||||
/* Borders */
|
||||
--border-soft: rgba(255, 255, 255, 0.11);
|
||||
--border-strong: rgba(255, 255, 255, 0.24);
|
||||
|
||||
background: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
/* =========================================================
|
||||
BraceFlow AppShell (isolated styles) — uses bf-* classes
|
||||
========================================================= */
|
||||
|
||||
.bf-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bf-header {
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
flex-wrap: wrap;
|
||||
align-content: center;
|
||||
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(249, 115, 22, 0.18),
|
||||
rgba(15, 23, 42, 0.65)
|
||||
);
|
||||
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
0 14px 40px rgba(8, 12, 18, 0.55),
|
||||
0 0 48px rgba(210, 225, 255, 0.08);
|
||||
}
|
||||
|
||||
.bf-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0; /* allows nav to shrink properly */
|
||||
}
|
||||
|
||||
.bf-brand {
|
||||
font-weight: 800;
|
||||
font-size: 19px;
|
||||
letter-spacing: 0.2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-main);
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.bf-brand-accent {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bf-brand:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.bf-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bf-nav-item {
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
|
||||
}
|
||||
|
||||
.bf-nav-item:hover:not(:disabled) {
|
||||
color: var(--text-main);
|
||||
background: rgba(209, 118, 69, 0.10);
|
||||
border-color: rgba(209, 118, 69, 0.3);
|
||||
}
|
||||
|
||||
.bf-nav-item:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.bf-nav-item.is-active {
|
||||
color: var(--text-main);
|
||||
background: rgba(249, 115, 22, 0.16);
|
||||
border-color: rgba(249, 115, 22, 0.45);
|
||||
}
|
||||
|
||||
.bf-nav-item:disabled,
|
||||
.bf-nav-item.is-disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bf-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bf-case-context {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.bf-case-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
max-width: 240px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bf-case-badge.is-empty {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bf-case-label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.35px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.bf-case-id {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bf-case-input {
|
||||
height: 32px;
|
||||
width: 150px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
|
||||
border: 1px solid var(--border-soft);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bf-case-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.bf-case-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.25);
|
||||
}
|
||||
|
||||
.bf-go {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--bg-surface);
|
||||
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.bf-go:hover:not(:disabled) {
|
||||
background: rgba(209, 118, 69, 0.14);
|
||||
border-color: rgba(209, 118, 69, 0.35);
|
||||
}
|
||||
|
||||
.bf-go:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bf-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
margin: 0 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bf-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.bf-step:hover:not(:disabled) {
|
||||
color: var(--text-main);
|
||||
background: rgba(209, 118, 69, 0.10);
|
||||
border-color: rgba(209, 118, 69, 0.3);
|
||||
}
|
||||
|
||||
.bf-step.is-active {
|
||||
color: var(--text-main);
|
||||
background: rgba(249, 115, 22, 0.16);
|
||||
border-color: rgba(249, 115, 22, 0.45);
|
||||
}
|
||||
|
||||
.bf-step.is-complete {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.bf-step.is-disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bf-step-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-main);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bf-step.is-active .bf-step-dot {
|
||||
background: var(--accent-primary);
|
||||
border-color: transparent;
|
||||
color: #0b1020;
|
||||
box-shadow: 0 8px 18px rgba(249, 115, 22, 0.35);
|
||||
}
|
||||
|
||||
.bf-step.is-complete .bf-step-dot {
|
||||
background: var(--accent-primary);
|
||||
border-color: transparent;
|
||||
color: #0b1020;
|
||||
}
|
||||
|
||||
.bf-step-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bf-step-connector {
|
||||
width: 26px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bf-step-connector.is-complete {
|
||||
background: rgba(249, 115, 22, 0.65);
|
||||
}
|
||||
|
||||
.bf-content {
|
||||
flex: 1;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bf-content--landing {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bf-content--fade-in {
|
||||
animation: bf-content-fade-in 560ms ease both;
|
||||
}
|
||||
|
||||
@keyframes bf-content-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Editor page needs less padding to maximize space */
|
||||
.bf-content:has(.shell-editor-page) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* Keep the Rigo shell page aligned with the standard page spacing */
|
||||
.bf-content:has(.rigo-shell-page) {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
/* Optional: on small screens, keep header usable */
|
||||
@media (max-width: 720px) {
|
||||
.bf-case-input {
|
||||
width: 150px;
|
||||
}
|
||||
.bf-nav {
|
||||
gap: 6px;
|
||||
}
|
||||
.bf-nav-item {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.bf-left {
|
||||
flex: 1 1 100%;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.bf-right {
|
||||
flex: 1 1 100%;
|
||||
order: 2;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bf-stepper {
|
||||
flex: 1 1 100%;
|
||||
order: 3;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
overflow-x: visible;
|
||||
padding: 6px 0 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bf-step-connector {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app { min-height: 100vh; }
|
||||
.header { padding: 18px 24px; border-bottom: 1px solid rgba(255,255,255,0.08); display:flex; gap:12px; align-items: baseline; }
|
||||
.brand { font-weight: 700; letter-spacing: 0.3px; }
|
||||
.subtitle { opacity: 0.7; }
|
||||
.container { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
box-shadow:
|
||||
0 10px 30px rgba(10, 15, 35, 0.35),
|
||||
0 0 32px rgba(210, 225, 255, 0.08);
|
||||
}
|
||||
.row { display:flex; align-items:center; }
|
||||
.row.space { justify-content: space-between; }
|
||||
.row.right { justify-content: flex-end; }
|
||||
.row.gap { gap: 10px; }
|
||||
|
||||
.input {
|
||||
flex:1;
|
||||
padding: 12px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: rgba(0,0,0,0.25);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
background: rgba(0,0,0,0.32);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: rgba(255,255,255,0.10);
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: rgba(209, 118, 69, 0.14);
|
||||
border-color: rgba(209, 118, 69, 0.35);
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent-primary);
|
||||
border-color: transparent;
|
||||
color: #0b1020;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: var(--accent-secondary);
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn.secondary { background: transparent; }
|
||||
|
||||
.muted { opacity: 0.7; margin-top: 8px; }
|
||||
.error { margin-top: 12px; padding: 10px; border-radius: 10px; background: rgba(255,0,0,0.12); border: 1px solid rgba(255,0,0,0.25); }
|
||||
.notice { margin-top: 12px; padding: 10px; border-radius: 10px; background: rgba(0,170,255,0.12); border: 1px solid rgba(0,170,255,0.25); }
|
||||
|
||||
.landmark-layout { display:grid; grid-template-columns: 320px 1fr; gap: 14px; margin-top: 14px; }
|
||||
.panel { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 14px; }
|
||||
.list { margin: 10px 0 0; padding-left: 18px; }
|
||||
.list li { margin: 10px 0; }
|
||||
.list li.active .label { font-weight: 700; }
|
||||
.label { margin-bottom: 4px; }
|
||||
.meta { opacity: 0.7; font-size: 12px; }
|
||||
.pill { opacity: 0.8; font-size: 12px; padding: 6px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.12); }
|
||||
|
||||
.canvasWrap { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 12px; }
|
||||
.imgWrap { position: relative; display: inline-block; cursor: crosshair; }
|
||||
.xray { max-width: 100%; border-radius: 12px; display:block; }
|
||||
.overlay { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; }
|
||||
.hint { margin-top: 8px; opacity: 0.7; font-size: 12px; }
|
||||
|
||||
.table { width:100%; border-collapse: collapse; margin-top: 14px; }
|
||||
.table th, .table td { text-align:left; padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.summary { display:flex; gap: 20px; margin-top: 12px; flex-wrap: wrap; }
|
||||
|
||||
.tag { padding: 4px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.10); font-size: 12px; }
|
||||
.tag.pending { opacity: 0.8; }
|
||||
.tag.running { border-color: rgba(0,170,255,0.35); background: rgba(0,170,255,0.10); }
|
||||
.tag.done { border-color: rgba(0,255,140,0.35); background: rgba(0,255,140,0.10); }
|
||||
.tag.waiting_for_landmarks { border-color: rgba(255,200,0,0.35); background: rgba(255,200,0,0.10); }
|
||||
|
||||
|
||||
|
||||
/* =========================================================
|
||||
BraceFlow - Slide-in Drawer (Artifacts)
|
||||
========================================================= */
|
||||
|
||||
.bf-drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 160ms ease;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.bf-drawer-backdrop.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.bf-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
width: min(560px, 92vw);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
border-left: 1px solid var(--border-soft);
|
||||
backdrop-filter: blur(16px);
|
||||
transform: translateX(100%);
|
||||
transition: transform 180ms ease;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bf-drawer.is-open { transform: translateX(0); }
|
||||
|
||||
.bf-drawer-header {
|
||||
padding: 14px 14px 10px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.bf-drawer-title { font-weight: 900; letter-spacing: 0.2px; }
|
||||
.bf-drawer-subtitle { opacity: 0.7; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.bf-tabs {
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.bf-tab {
|
||||
height: 32px;
|
||||
min-width: 38px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.92);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.bf-tab:hover { background: rgba(209, 118, 69, 0.12); border-color: rgba(209, 118, 69, 0.32); }
|
||||
.bf-tab.is-active { background: rgba(255,255,255,0.16); border-color: rgba(255,255,255,0.35); }
|
||||
|
||||
.bf-drawer-body { flex: 1; padding: 14px; overflow: auto; }
|
||||
|
||||
|
||||
/* =========================================================
|
||||
Landmark Capture – Thumbnail + Fixed Canvas
|
||||
========================================================= */
|
||||
|
||||
.lc-imageRow {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lc-thumbCol {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.lc-thumbBox {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lc-thumbBox img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.lc-landmarkBox {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(0,0,0,0.22);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Force LandmarkCanvas to respect size */
|
||||
.fixed-250 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.fixed-250 img,
|
||||
.fixed-250 svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.imgWrap.fixed-250 {
|
||||
position: relative;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.imgWrap.fixed-250 img {
|
||||
display: block;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.imgWrap.fixed-250 .overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/* =========================================================
|
||||
Landmark Capture page – Thumbnail top, Workspace below
|
||||
========================================================= */
|
||||
|
||||
.lc-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Thumbnail row (top) */
|
||||
.lc-thumbRow {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.lc-thumbCol {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.lc-thumbBox {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(0,0,0,0.22);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lc-thumbBox img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lc-thumbActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Workspace (below thumbnail) */
|
||||
.lc-workspace {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.lc-workspace-title {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.lc-workspace-body {
|
||||
/* just spacing wrapper */
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
IMPORTANT: Fix the "ruined" layout by letting LandmarkCanvas
|
||||
keep its own grid, but constrain ONLY the image holder.
|
||||
LandmarkCanvas uses:
|
||||
.landmark-layout (grid)
|
||||
.panel (left)
|
||||
.canvasWrap (right)
|
||||
.imgWrap (image container)
|
||||
--------------------------------------------------------- */
|
||||
|
||||
.lc-workspace .landmark-layout {
|
||||
grid-template-columns: 340px 1fr; /* nicer proportion */
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.lc-workspace .landmark-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Constrain ONLY the DCM/image holder (not the whole component) */
|
||||
.lc-workspace .canvasWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lc-workspace .imgWrap {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(0,0,0,0.18);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Make image + overlay fill that 250x250 cleanly */
|
||||
.lc-workspace .xray {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain; /* keeps anatomy proportions */
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.lc-workspace .overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Make the landmarks panel feel aligned with the 250 box */
|
||||
.lc-workspace .panel {
|
||||
border-radius: 16px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
175
frontend/src/App.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Routes, Route, useNavigate, Navigate } from "react-router-dom";
|
||||
import { AppShell } from "./components/AppShell";
|
||||
import { AuthProvider, useAuth } from "./context/AuthContext";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import CaseDetailPage from "./pages/CaseDetail";
|
||||
import PipelineCaseDetail from "./pages/PipelineCaseDetail";
|
||||
import ShellEditorPage from "./pages/ShellEditorPage";
|
||||
|
||||
// Admin pages
|
||||
import AdminDashboard from "./pages/admin/AdminDashboard";
|
||||
import AdminUsers from "./pages/admin/AdminUsers";
|
||||
import AdminCases from "./pages/admin/AdminCases";
|
||||
import AdminActivity from "./pages/admin/AdminActivity";
|
||||
|
||||
// Import pipeline styles
|
||||
import "./components/pipeline/pipeline.css";
|
||||
|
||||
// Protected route wrapper - redirects to login if not authenticated
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bf-loading-screen">
|
||||
<div className="bf-loading-spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Admin route wrapper - requires admin role
|
||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isAdmin, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bf-loading-screen">
|
||||
<div className="bf-loading-spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/cases" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes - wrapped in AppShell */}
|
||||
<Route
|
||||
path="/cases"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<DashboardWrapper />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cases/:caseId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<PipelineCaseDetail />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cases-legacy/:caseId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<CaseDetailPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/editor"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<ShellEditorPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AppShell>
|
||||
<AdminDashboard />
|
||||
</AppShell>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AppShell>
|
||||
<AdminUsers />
|
||||
</AppShell>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/cases"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AppShell>
|
||||
<AdminCases />
|
||||
</AppShell>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/activity"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AppShell>
|
||||
<AdminActivity />
|
||||
</AppShell>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Legacy redirects */}
|
||||
<Route path="/dashboard" element={<Navigate to="/cases" replace />} />
|
||||
|
||||
{/* Catch-all redirect */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardWrapper() {
|
||||
const nav = useNavigate();
|
||||
return <Dashboard onView={(id: string) => nav(`/cases/${encodeURIComponent(id)}`)} />;
|
||||
}
|
||||
236
frontend/src/api/adminApi.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Admin API Client
|
||||
* API functions for admin dashboard features
|
||||
*/
|
||||
|
||||
import { getAuthHeaders } from "../context/AuthContext";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api";
|
||||
|
||||
async function adminFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
const error = text ? JSON.parse(text) : { message: "Request failed" };
|
||||
throw new Error(error.message || `Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return text ? JSON.parse(text) : ({} as T);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// USER MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
export type AdminUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
full_name: string | null;
|
||||
role: "admin" | "user" | "viewer";
|
||||
is_active: number;
|
||||
last_login: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export async function listUsers(): Promise<{ users: AdminUser[] }> {
|
||||
return adminFetch("/admin/users");
|
||||
}
|
||||
|
||||
export async function getUser(userId: number): Promise<{ user: AdminUser }> {
|
||||
return adminFetch(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function createUser(data: {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
fullName?: string;
|
||||
role?: "admin" | "user" | "viewer";
|
||||
}): Promise<{ user: AdminUser }> {
|
||||
return adminFetch("/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
userId: number,
|
||||
data: {
|
||||
email?: string;
|
||||
fullName?: string;
|
||||
role?: "admin" | "user" | "viewer";
|
||||
isActive?: boolean;
|
||||
password?: string;
|
||||
}
|
||||
): Promise<{ user: AdminUser }> {
|
||||
return adminFetch(`/admin/users/${userId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: number): Promise<{ message: string }> {
|
||||
return adminFetch(`/admin/users/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CASES WITH FILTERS
|
||||
// ============================================
|
||||
|
||||
export type AdminCase = {
|
||||
caseId: string;
|
||||
case_type: string;
|
||||
status: string;
|
||||
current_step: string | null;
|
||||
notes: string | null;
|
||||
analysis_result: any;
|
||||
landmarks_data: any;
|
||||
body_scan_path: string | null;
|
||||
created_by: number | null;
|
||||
created_by_username: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ListCasesResponse = {
|
||||
cases: AdminCase[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export async function listCasesAdmin(params?: {
|
||||
status?: string;
|
||||
createdBy?: number;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}): Promise<ListCasesResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.createdBy) searchParams.set("createdBy", params.createdBy.toString());
|
||||
if (params?.search) searchParams.set("search", params.search);
|
||||
if (params?.limit) searchParams.set("limit", params.limit.toString());
|
||||
if (params?.offset) searchParams.set("offset", params.offset.toString());
|
||||
if (params?.sortBy) searchParams.set("sortBy", params.sortBy);
|
||||
if (params?.sortOrder) searchParams.set("sortOrder", params.sortOrder);
|
||||
|
||||
const query = searchParams.toString();
|
||||
return adminFetch(`/admin/cases${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ANALYTICS
|
||||
// ============================================
|
||||
|
||||
export type CaseStats = {
|
||||
total: number;
|
||||
byStatus: Record<string, number>;
|
||||
last7Days: { date: string; count: number }[];
|
||||
last30Days: { date: string; count: number }[];
|
||||
};
|
||||
|
||||
export type UserStats = {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
byRole: Record<string, number>;
|
||||
recentLogins: number;
|
||||
};
|
||||
|
||||
export type CobbAngleStats = {
|
||||
PT: { min: number; max: number; avg: number; count: number };
|
||||
MT: { min: number; max: number; avg: number; count: number };
|
||||
TL: { min: number; max: number; avg: number; count: number };
|
||||
totalCasesWithAngles: number;
|
||||
};
|
||||
|
||||
export type ProcessingTimeStats = {
|
||||
min: number;
|
||||
max: number;
|
||||
avg: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type BodyScanStats = {
|
||||
total: number;
|
||||
withBodyScan: number;
|
||||
withoutBodyScan: number;
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
export type DashboardAnalytics = {
|
||||
cases: CaseStats;
|
||||
users: UserStats;
|
||||
rigoDistribution: Record<string, number>;
|
||||
cobbAngles: CobbAngleStats;
|
||||
processingTime: ProcessingTimeStats;
|
||||
bodyScan: BodyScanStats;
|
||||
};
|
||||
|
||||
export async function getDashboardAnalytics(): Promise<DashboardAnalytics> {
|
||||
return adminFetch("/admin/analytics/dashboard");
|
||||
}
|
||||
|
||||
export async function getRigoDistribution(): Promise<{ distribution: Record<string, number> }> {
|
||||
return adminFetch("/admin/analytics/rigo");
|
||||
}
|
||||
|
||||
export async function getCobbAngleStats(): Promise<{ stats: CobbAngleStats }> {
|
||||
return adminFetch("/admin/analytics/cobb-angles");
|
||||
}
|
||||
|
||||
export async function getProcessingTimeStats(): Promise<{ stats: ProcessingTimeStats }> {
|
||||
return adminFetch("/admin/analytics/processing-time");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUDIT LOG
|
||||
// ============================================
|
||||
|
||||
export type AuditLogEntry = {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
username: string | null;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
details: string | null;
|
||||
ip_address: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export async function getAuditLog(params?: {
|
||||
userId?: number;
|
||||
action?: string;
|
||||
entityType?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ entries: AuditLogEntry[] }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.userId) searchParams.set("userId", params.userId.toString());
|
||||
if (params?.action) searchParams.set("action", params.action);
|
||||
if (params?.entityType) searchParams.set("entityType", params.entityType);
|
||||
if (params?.limit) searchParams.set("limit", params.limit.toString());
|
||||
if (params?.offset) searchParams.set("offset", params.offset.toString());
|
||||
|
||||
const query = searchParams.toString();
|
||||
return adminFetch(`/admin/audit-log${query ? `?${query}` : ""}`);
|
||||
}
|
||||
884
frontend/src/api/braceflowApi.ts
Normal file
@@ -0,0 +1,884 @@
|
||||
export type CaseRecord = {
|
||||
caseId: string;
|
||||
status: string;
|
||||
current_step: string | null;
|
||||
created_at: string;
|
||||
analysis_result?: AnalysisResult | null;
|
||||
landmarks_data?: LandmarksResult | null;
|
||||
analysis_data?: RecalculationResult | null;
|
||||
body_scan_path?: string | null;
|
||||
body_scan_url?: string | null;
|
||||
body_scan_metadata?: BodyScanMetadata | null;
|
||||
};
|
||||
|
||||
export type BodyScanMetadata = {
|
||||
total_height_mm?: number;
|
||||
shoulder_width_mm?: number;
|
||||
chest_width_mm?: number;
|
||||
chest_depth_mm?: number;
|
||||
waist_width_mm?: number;
|
||||
waist_depth_mm?: number;
|
||||
hip_width_mm?: number;
|
||||
brace_coverage_height_mm?: number;
|
||||
file_format?: string;
|
||||
vertex_count?: number;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export type CobbAngles = {
|
||||
PT?: number;
|
||||
MT?: number;
|
||||
TL?: number;
|
||||
};
|
||||
|
||||
export type RigoClassification = {
|
||||
type: string;
|
||||
description: string;
|
||||
curve_pattern?: string;
|
||||
};
|
||||
|
||||
export type DeformationZone = {
|
||||
zone: string;
|
||||
patch: [number, number];
|
||||
deform_mm: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type DeformationReport = {
|
||||
patch_grid: string;
|
||||
deformations: number[][];
|
||||
zones: DeformationZone[];
|
||||
};
|
||||
|
||||
export type AnalysisResult = {
|
||||
experiment?: string;
|
||||
model?: string;
|
||||
vertebrae_detected?: number;
|
||||
cobb_angles?: CobbAngles;
|
||||
curve_type?: string;
|
||||
rigo_classification?: RigoClassification;
|
||||
mesh_info?: {
|
||||
vertices?: number;
|
||||
faces?: number;
|
||||
};
|
||||
deformation_report?: DeformationReport;
|
||||
outputs?: Record<string, { s3Key: string; url: string }>;
|
||||
processing_time_ms?: number;
|
||||
};
|
||||
|
||||
export type BraceOutput = {
|
||||
filename: string;
|
||||
type: "stl" | "ply" | "obj" | "image" | "json" | "other";
|
||||
s3Key: string;
|
||||
size: number;
|
||||
url: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
|
||||
export type BraceOutputsResponse = {
|
||||
caseId: string;
|
||||
status: string;
|
||||
analysis: AnalysisResult | null;
|
||||
outputs: BraceOutput[];
|
||||
};
|
||||
|
||||
export type GenerateBraceResponse = {
|
||||
caseId: string;
|
||||
status: string;
|
||||
experiment: string;
|
||||
model: string;
|
||||
vertebrae_detected: number;
|
||||
cobb_angles: CobbAngles;
|
||||
curve_type: string;
|
||||
rigo_classification: RigoClassification;
|
||||
mesh: {
|
||||
vertices: number;
|
||||
faces: number;
|
||||
};
|
||||
outputs: Record<string, { s3Key: string; url: string }>;
|
||||
processing_time_ms: number;
|
||||
deformation_report?: DeformationReport;
|
||||
};
|
||||
|
||||
// API Base URL
|
||||
// - In production (Docker): empty string uses relative URLs with /api prefix
|
||||
// - In development: set VITE_API_BASE=http://localhost:8001 in .env.local
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
|
||||
|
||||
// API prefix for relative URLs (when API_BASE is empty or doesn't include /api)
|
||||
const API_PREFIX = "/api";
|
||||
|
||||
// File server base URL (same as API base for dev server)
|
||||
const FILE_BASE = API_BASE.replace(/\/api\/?$/, '');
|
||||
|
||||
/**
|
||||
* Convert relative file URLs to absolute URLs
|
||||
* e.g., "/files/outputs/..." -> "http://localhost:8001/files/outputs/..."
|
||||
*/
|
||||
export function toAbsoluteFileUrl(relativeUrl: string | undefined | null): string | undefined {
|
||||
if (!relativeUrl) return undefined;
|
||||
if (relativeUrl.startsWith('http://') || relativeUrl.startsWith('https://')) {
|
||||
return relativeUrl; // Already absolute
|
||||
}
|
||||
return `${FILE_BASE}${relativeUrl.startsWith('/') ? '' : '/'}${relativeUrl}`;
|
||||
}
|
||||
|
||||
async function safeFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : "";
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
// If no base URL, use API_PREFIX for relative URLs (production with nginx proxy)
|
||||
const prefix = base ? "" : API_PREFIX;
|
||||
const url = `${base}${prefix}${normalizedPath}`;
|
||||
|
||||
const res = await fetch(url, init);
|
||||
const text = await res.text().catch(() => "");
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed: ${res.status} ${res.statusText}${text ? ` :: ${text}` : ""}`);
|
||||
}
|
||||
|
||||
return text ? (JSON.parse(text) as T) : ({} as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supports common backend shapes:
|
||||
* - Array: [ {caseId...}, ... ]
|
||||
* - Object: { cases: [...], nextToken: "..." }
|
||||
* - Object: { items: [...], nextToken: "..." }
|
||||
*/
|
||||
function normalizeCasesResponse(
|
||||
json: any
|
||||
): { cases: CaseRecord[]; nextToken?: string | null } {
|
||||
if (Array.isArray(json)) return { cases: json, nextToken: null };
|
||||
|
||||
const cases = (json?.cases ?? json?.items ?? []) as CaseRecord[];
|
||||
const nextToken = (json?.nextToken ?? json?.next_token ?? json?.nextCursor ?? json?.next_cursor ?? null) as
|
||||
| string
|
||||
| null;
|
||||
|
||||
return { cases: Array.isArray(cases) ? cases : [], nextToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ALL cases, following nextToken if backend paginates.
|
||||
* Uses query param `?nextToken=` (common pattern).
|
||||
*/
|
||||
export async function fetchCases(): Promise<CaseRecord[]> {
|
||||
const all: CaseRecord[] = [];
|
||||
let nextToken: string | null = null;
|
||||
|
||||
// Safety guard to prevent infinite loops if backend misbehaves
|
||||
const MAX_PAGES = 50;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const path = nextToken ? `/cases?nextToken=${encodeURIComponent(nextToken)}` : `/cases`;
|
||||
const json = await safeFetch<any>(path);
|
||||
|
||||
const normalized = normalizeCasesResponse(json);
|
||||
all.push(...normalized.cases);
|
||||
|
||||
if (!normalized.nextToken) break;
|
||||
nextToken = normalized.nextToken;
|
||||
}
|
||||
|
||||
// Sort newest-first
|
||||
all.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function fetchCase(caseId: string): Promise<CaseRecord | null> {
|
||||
try {
|
||||
const result = await safeFetch<CaseRecord>(`/cases/${encodeURIComponent(caseId)}`);
|
||||
|
||||
// Convert relative URLs to absolute URLs for braces data
|
||||
if (result?.analysis_result) {
|
||||
const ar = result.analysis_result as any;
|
||||
// Convert both braces format URLs
|
||||
if (ar.braces) {
|
||||
if (ar.braces.regular?.outputs) {
|
||||
ar.braces.regular.outputs = {
|
||||
glb: toAbsoluteFileUrl(ar.braces.regular.outputs.glb),
|
||||
stl: toAbsoluteFileUrl(ar.braces.regular.outputs.stl),
|
||||
json: toAbsoluteFileUrl(ar.braces.regular.outputs.json),
|
||||
};
|
||||
}
|
||||
if (ar.braces.vase?.outputs) {
|
||||
ar.braces.vase.outputs = {
|
||||
glb: toAbsoluteFileUrl(ar.braces.vase.outputs.glb),
|
||||
stl: toAbsoluteFileUrl(ar.braces.vase.outputs.stl),
|
||||
json: toAbsoluteFileUrl(ar.braces.vase.outputs.json),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Convert single brace format URLs
|
||||
if (ar.brace?.outputs) {
|
||||
ar.brace.outputs = {
|
||||
stl: toAbsoluteFileUrl(ar.brace.outputs.stl),
|
||||
ply: toAbsoluteFileUrl(ar.brace.outputs.ply),
|
||||
visualization: toAbsoluteFileUrl(ar.brace.outputs.visualization),
|
||||
landmarks: toAbsoluteFileUrl(ar.brace.outputs.landmarks),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCase(body: { notes?: string } = {}): Promise<{ caseId: string }> {
|
||||
return await safeFetch<{ caseId: string }>(`/cases`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for uploading an X-ray image to S3
|
||||
*/
|
||||
export async function getUploadUrl(
|
||||
caseId: string,
|
||||
filename: string,
|
||||
contentType: string
|
||||
): Promise<{ url: string; s3Key: string }> {
|
||||
return await safeFetch<{ url: string; s3Key: string }>(
|
||||
`/cases/${encodeURIComponent(caseId)}/upload-url`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, contentType }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file directly to S3 using a presigned URL
|
||||
*/
|
||||
export async function uploadToS3(presignedUrl: string, file: File): Promise<void> {
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a case and upload an X-ray without starting processing.
|
||||
*/
|
||||
export async function createCaseAndUploadXray(
|
||||
file: File,
|
||||
options?: { notes?: string }
|
||||
): Promise<{ caseId: string }> {
|
||||
const { caseId } = await createCase({
|
||||
notes: options?.notes ?? `X-ray upload: ${file.name}`,
|
||||
});
|
||||
|
||||
const { url: uploadUrl } = await getUploadUrl(caseId, "ap.jpg", file.type);
|
||||
await uploadToS3(uploadUrl, file);
|
||||
|
||||
return { caseId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the brace generator Lambda for a case
|
||||
*/
|
||||
export async function generateBrace(
|
||||
caseId: string,
|
||||
options?: { experiment?: string; config?: Record<string, unknown> }
|
||||
): Promise<GenerateBraceResponse> {
|
||||
return await safeFetch<GenerateBraceResponse>(
|
||||
`/cases/${encodeURIComponent(caseId)}/generate-brace`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
caseId,
|
||||
experiment: options?.experiment ?? "experiment_3",
|
||||
config: options?.config,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brace generation outputs for a case (with presigned URLs)
|
||||
*/
|
||||
export async function getBraceOutputs(caseId: string): Promise<BraceOutputsResponse> {
|
||||
return await safeFetch<BraceOutputsResponse>(
|
||||
`/cases/${encodeURIComponent(caseId)}/brace-outputs`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned download URL for a case asset (e.g., X-ray)
|
||||
*/
|
||||
export async function getDownloadUrl(
|
||||
caseId: string,
|
||||
assetType: "xray" | "landmarks" | "measurements"
|
||||
): Promise<{ url: string }> {
|
||||
return await safeFetch<{ url: string }>(
|
||||
`/cases/${encodeURIComponent(caseId)}/download-url?type=${assetType}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full workflow: Create case -> Upload X-ray -> Generate brace
|
||||
*/
|
||||
export async function analyzeXray(
|
||||
file: File,
|
||||
options?: { experiment?: string; config?: Record<string, unknown> }
|
||||
): Promise<{ caseId: string; result: GenerateBraceResponse }> {
|
||||
// Step 1: Create a new case
|
||||
const { caseId } = await createCase({ notes: `X-ray analysis: ${file.name}` });
|
||||
console.log(`Created case: ${caseId}`);
|
||||
|
||||
// Step 2: Get presigned URL for upload
|
||||
const { url: uploadUrl } = await getUploadUrl(caseId, "ap.jpg", file.type);
|
||||
console.log(`Got upload URL for case: ${caseId}`);
|
||||
|
||||
// Step 3: Upload file to S3
|
||||
await uploadToS3(uploadUrl, file);
|
||||
console.log(`Uploaded X-ray to S3 for case: ${caseId}`);
|
||||
|
||||
// Step 4: Generate brace
|
||||
const result = await generateBrace(caseId, options);
|
||||
console.log(`Brace generation complete for case: ${caseId}`);
|
||||
|
||||
return { caseId, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a case and all associated files
|
||||
*/
|
||||
export async function deleteCase(caseId: string): Promise<{ message: string }> {
|
||||
return await safeFetch<{ message: string }>(
|
||||
`/cases/${encodeURIComponent(caseId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PIPELINE DEV API - New Stage-based endpoints
|
||||
// ============================================
|
||||
|
||||
export type VertebraData = {
|
||||
level: string;
|
||||
detected: boolean;
|
||||
scoliovis_data: {
|
||||
centroid_px: [number, number] | null;
|
||||
corners_px: [number, number][] | null;
|
||||
orientation_deg: number | null;
|
||||
confidence: number;
|
||||
};
|
||||
manual_override: {
|
||||
enabled: boolean;
|
||||
centroid_px: [number, number] | null;
|
||||
corners_px: [number, number][] | null;
|
||||
orientation_deg: number | null;
|
||||
confidence: number | null;
|
||||
notes: string | null;
|
||||
};
|
||||
final_values: {
|
||||
centroid_px: [number, number] | null;
|
||||
corners_px: [number, number][] | null;
|
||||
orientation_deg: number | null;
|
||||
confidence: number;
|
||||
source: 'scoliovis' | 'manual' | 'undetected';
|
||||
};
|
||||
};
|
||||
|
||||
export type VertebraeStructure = {
|
||||
all_levels: string[];
|
||||
detected_count: number;
|
||||
total_count: number;
|
||||
vertebrae: VertebraData[];
|
||||
manual_edit_instructions: {
|
||||
to_override: string;
|
||||
final_values_rule: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type LandmarksResult = {
|
||||
case_id: string;
|
||||
status: string;
|
||||
input: {
|
||||
image_dimensions: { width: number; height: number };
|
||||
pixel_spacing_mm: number | null;
|
||||
};
|
||||
detection_quality: {
|
||||
vertebrae_count: number;
|
||||
average_confidence: number;
|
||||
};
|
||||
cobb_angles: {
|
||||
PT: number;
|
||||
MT: number;
|
||||
TL: number;
|
||||
max: number;
|
||||
PT_severity: string;
|
||||
MT_severity: string;
|
||||
TL_severity: string;
|
||||
};
|
||||
rigo_classification: RigoClassification;
|
||||
curve_type: string;
|
||||
vertebrae_structure: VertebraeStructure;
|
||||
visualization_path?: string;
|
||||
visualization_url?: string;
|
||||
json_path?: string;
|
||||
json_url?: string;
|
||||
processing_time_ms: number;
|
||||
};
|
||||
|
||||
export type RecalculationResult = {
|
||||
case_id: string;
|
||||
status: string;
|
||||
cobb_angles: {
|
||||
PT: number;
|
||||
MT: number;
|
||||
TL: number;
|
||||
max: number;
|
||||
PT_severity: string;
|
||||
MT_severity: string;
|
||||
TL_severity: string;
|
||||
};
|
||||
rigo_classification: RigoClassification;
|
||||
curve_type: string;
|
||||
apex_indices: number[];
|
||||
vertebrae_used: number;
|
||||
processing_time_ms: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stage 1: Upload X-ray and detect landmarks
|
||||
*/
|
||||
export async function uploadXrayForCase(caseId: string, file: File): Promise<{ filename: string; path: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// Use API_BASE if set, otherwise use /api prefix for production
|
||||
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : API_PREFIX;
|
||||
const res = await fetch(`${base}/cases/${encodeURIComponent(caseId)}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Upload failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 1: Detect landmarks (no brace generation)
|
||||
*/
|
||||
export async function detectLandmarks(caseId: string): Promise<LandmarksResult> {
|
||||
return await safeFetch<LandmarksResult>(
|
||||
`/cases/${encodeURIComponent(caseId)}/detect-landmarks`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 1: Update landmarks (manual edits)
|
||||
*/
|
||||
export async function updateLandmarks(
|
||||
caseId: string,
|
||||
landmarksData: VertebraeStructure
|
||||
): Promise<{ caseId: string; status: string }> {
|
||||
return await safeFetch<{ caseId: string; status: string }>(
|
||||
`/cases/${encodeURIComponent(caseId)}/landmarks`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ landmarks_data: landmarksData }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 1->2: Approve landmarks and move to analysis
|
||||
*/
|
||||
export async function approveLandmarks(
|
||||
caseId: string,
|
||||
updatedLandmarks?: VertebraeStructure
|
||||
): Promise<{ caseId: string; status: string; next_step: string }> {
|
||||
return await safeFetch<{ caseId: string; status: string; next_step: string }>(
|
||||
`/cases/${encodeURIComponent(caseId)}/approve-landmarks`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ updated_landmarks: updatedLandmarks }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 2: Recalculate Cobb angles and Rigo from landmarks
|
||||
*/
|
||||
export async function recalculateAnalysis(caseId: string): Promise<RecalculationResult> {
|
||||
return await safeFetch<RecalculationResult>(
|
||||
`/cases/${encodeURIComponent(caseId)}/recalculate`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 3: Generate brace from approved landmarks
|
||||
*/
|
||||
export async function generateBraceFromLandmarks(
|
||||
caseId: string,
|
||||
options?: { experiment?: string }
|
||||
): Promise<GenerateBraceResponse> {
|
||||
return await safeFetch<GenerateBraceResponse>(
|
||||
`/cases/${encodeURIComponent(caseId)}/generate-brace`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
experiment: options?.experiment ?? 'experiment_9',
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 3: Update brace markers (manual edits)
|
||||
*/
|
||||
export async function updateMarkers(
|
||||
caseId: string,
|
||||
markersData: Record<string, unknown>
|
||||
): Promise<{ caseId: string; status: string }> {
|
||||
return await safeFetch<{ caseId: string; status: string }>(
|
||||
`/cases/${encodeURIComponent(caseId)}/markers`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ markers_data: markersData }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate GLB brace with markers
|
||||
*/
|
||||
export async function generateGlbBrace(
|
||||
caseId: string,
|
||||
options?: {
|
||||
rigoType?: string;
|
||||
templateType?: 'regular' | 'vase';
|
||||
}
|
||||
): Promise<{
|
||||
caseId: string;
|
||||
rigoType: string;
|
||||
templateType: string;
|
||||
outputs: { glb?: string; stl?: string; json?: string };
|
||||
markers: Record<string, number[]>;
|
||||
pressureZones: Array<{
|
||||
name: string;
|
||||
marker_name: string;
|
||||
position: number[];
|
||||
zone_type: string;
|
||||
direction: string;
|
||||
depth_mm: number;
|
||||
}>;
|
||||
meshStats: { vertices: number; faces: number };
|
||||
}> {
|
||||
const formData = new FormData();
|
||||
if (options?.rigoType) formData.append('rigo_type', options.rigoType);
|
||||
if (options?.templateType) formData.append('template_type', options.templateType);
|
||||
formData.append('case_id', caseId);
|
||||
|
||||
return await safeFetch(
|
||||
`/cases/${encodeURIComponent(caseId)}/generate-glb`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate both brace types (regular + vase) for comparison
|
||||
*/
|
||||
export type BothBracesResponse = {
|
||||
caseId: string;
|
||||
rigoType: string;
|
||||
cobbAngles: { PT: number; MT: number; TL: number };
|
||||
bodyScanUsed: boolean;
|
||||
braces: {
|
||||
regular?: {
|
||||
outputs: { glb?: string; stl?: string; json?: string };
|
||||
markers: Record<string, number[]>;
|
||||
pressureZones: Array<{
|
||||
name: string;
|
||||
marker_name: string;
|
||||
position: number[];
|
||||
zone_type: string;
|
||||
direction: string;
|
||||
depth_mm: number;
|
||||
}>;
|
||||
meshStats: { vertices: number; faces: number };
|
||||
};
|
||||
vase?: {
|
||||
outputs: { glb?: string; stl?: string; json?: string };
|
||||
markers: Record<string, number[]>;
|
||||
pressureZones: Array<{
|
||||
name: string;
|
||||
marker_name: string;
|
||||
position: number[];
|
||||
zone_type: string;
|
||||
direction: string;
|
||||
depth_mm: number;
|
||||
}>;
|
||||
meshStats: { vertices: number; faces: number };
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export async function generateBothBraces(
|
||||
caseId: string,
|
||||
options?: { rigoType?: string }
|
||||
): Promise<BothBracesResponse> {
|
||||
const formData = new FormData();
|
||||
if (options?.rigoType) formData.append('rigo_type', options.rigoType);
|
||||
formData.append('case_id', caseId);
|
||||
|
||||
const result = await safeFetch<BothBracesResponse>(
|
||||
`/cases/${encodeURIComponent(caseId)}/generate-both-braces`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
// Convert relative URLs to absolute URLs for 3D loaders
|
||||
if (result.braces?.regular?.outputs) {
|
||||
result.braces.regular.outputs = {
|
||||
glb: toAbsoluteFileUrl(result.braces.regular.outputs.glb),
|
||||
stl: toAbsoluteFileUrl(result.braces.regular.outputs.stl),
|
||||
json: toAbsoluteFileUrl(result.braces.regular.outputs.json),
|
||||
};
|
||||
}
|
||||
if (result.braces?.vase?.outputs) {
|
||||
result.braces.vase.outputs = {
|
||||
glb: toAbsoluteFileUrl(result.braces.vase.outputs.glb),
|
||||
stl: toAbsoluteFileUrl(result.braces.vase.outputs.stl),
|
||||
json: toAbsoluteFileUrl(result.braces.vase.outputs.json),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get case assets (uploaded files and outputs)
|
||||
*/
|
||||
export async function getCaseAssets(caseId: string): Promise<{
|
||||
caseId: string;
|
||||
assets: {
|
||||
uploads: { filename: string; url: string }[];
|
||||
outputs: { filename: string; url: string }[];
|
||||
};
|
||||
}> {
|
||||
const result = await safeFetch<{
|
||||
caseId: string;
|
||||
assets: {
|
||||
uploads: { filename: string; url: string }[];
|
||||
outputs: { filename: string; url: string }[];
|
||||
};
|
||||
}>(`/cases/${encodeURIComponent(caseId)}/assets`);
|
||||
|
||||
// Convert relative URLs to absolute
|
||||
if (result.assets?.uploads) {
|
||||
result.assets.uploads = result.assets.uploads.map(f => ({
|
||||
...f,
|
||||
url: toAbsoluteFileUrl(f.url) || f.url
|
||||
}));
|
||||
}
|
||||
if (result.assets?.outputs) {
|
||||
result.assets.outputs = result.assets.outputs.map(f => ({
|
||||
...f,
|
||||
url: toAbsoluteFileUrl(f.url) || f.url
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// BODY SCAN API (Stage 3)
|
||||
// ==============================================
|
||||
|
||||
export type BodyScanResponse = {
|
||||
caseId: string;
|
||||
has_body_scan: boolean;
|
||||
body_scan: {
|
||||
path: string;
|
||||
url: string;
|
||||
metadata: BodyScanMetadata;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type BodyScanUploadResponse = {
|
||||
caseId: string;
|
||||
status: string;
|
||||
body_scan: {
|
||||
path: string;
|
||||
url: string;
|
||||
metadata: BodyScanMetadata;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload body scan (STL/OBJ/PLY)
|
||||
*/
|
||||
export async function uploadBodyScan(
|
||||
caseId: string,
|
||||
file: File
|
||||
): Promise<BodyScanUploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// Use API_BASE if set, otherwise use /api prefix for production
|
||||
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : API_PREFIX;
|
||||
const response = await fetch(`${base}/cases/${encodeURIComponent(caseId)}/body-scan`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ message: 'Upload failed' }));
|
||||
throw new Error(err.message || 'Failed to upload body scan');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get body scan info
|
||||
*/
|
||||
export async function getBodyScan(caseId: string): Promise<BodyScanResponse> {
|
||||
return await safeFetch(`/cases/${encodeURIComponent(caseId)}/body-scan`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete body scan
|
||||
*/
|
||||
export async function deleteBodyScan(
|
||||
caseId: string
|
||||
): Promise<{ caseId: string; status: string; message: string }> {
|
||||
return await safeFetch(
|
||||
`/cases/${encodeURIComponent(caseId)}/body-scan`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip body scan stage and proceed to brace generation
|
||||
*/
|
||||
export async function skipBodyScan(
|
||||
caseId: string
|
||||
): Promise<{ caseId: string; status: string; message: string }> {
|
||||
return await safeFetch(
|
||||
`/cases/${encodeURIComponent(caseId)}/skip-body-scan`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a modified brace file to the case outputs
|
||||
* Used when the user modifies a brace in the inline editor
|
||||
*/
|
||||
export async function uploadModifiedBrace(
|
||||
caseId: string,
|
||||
braceType: 'regular' | 'vase',
|
||||
fileType: 'stl' | 'glb',
|
||||
blob: Blob,
|
||||
transformParams?: Record<string, unknown>
|
||||
): Promise<{
|
||||
caseId: string;
|
||||
status: string;
|
||||
output: {
|
||||
filename: string;
|
||||
url: string;
|
||||
s3Key: string;
|
||||
};
|
||||
}> {
|
||||
const filename = `${braceType}_modified.${fileType}`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, filename);
|
||||
formData.append('brace_type', braceType);
|
||||
formData.append('file_type', fileType);
|
||||
if (transformParams) {
|
||||
formData.append('transform_params', JSON.stringify(transformParams));
|
||||
}
|
||||
|
||||
// Use API_BASE if set, otherwise use /api prefix for production
|
||||
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : API_PREFIX;
|
||||
const response = await fetch(
|
||||
`${base}/cases/${encodeURIComponent(caseId)}/upload-modified-brace`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ message: 'Upload failed' }));
|
||||
throw new Error(err.message || 'Failed to upload modified brace');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get presigned URL for uploading modified brace to case storage
|
||||
*/
|
||||
export async function getModifiedBraceUploadUrl(
|
||||
caseId: string,
|
||||
braceType: 'regular' | 'vase',
|
||||
fileType: 'stl' | 'glb'
|
||||
): Promise<{ url: string; s3Key: string; contentType: string }> {
|
||||
const filename = `${braceType}_modified.${fileType}`;
|
||||
return await safeFetch<{ url: string; s3Key: string; contentType: string }>(
|
||||
`/cases/${encodeURIComponent(caseId)}/upload-url`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
uploadType: 'modified_brace',
|
||||
braceType,
|
||||
fileType,
|
||||
filename,
|
||||
contentType: fileType === 'stl' ? 'application/octet-stream' : 'model/gltf-binary',
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload blob to S3 using presigned URL
|
||||
*/
|
||||
export async function uploadBlobToS3(presignedUrl: string, blob: Blob, contentType: string): Promise<void> {
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
101
frontend/src/api/rigoApi.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Rigo Backend API Client
|
||||
// Backend URL: http://3.129.218.141:8000
|
||||
|
||||
const RIGO_API_BASE = "http://3.129.218.141:8000";
|
||||
|
||||
export interface AnalysisResult {
|
||||
pattern: string;
|
||||
apex: string;
|
||||
cobb_angle: number;
|
||||
thoracic_convexity?: string;
|
||||
lumbar_cobb_deg?: number;
|
||||
l4_tilt_deg?: number;
|
||||
l5_tilt_deg?: number;
|
||||
pelvic_tilt: string;
|
||||
}
|
||||
|
||||
export interface BraceParameters {
|
||||
pressure_pad_level: string;
|
||||
pressure_pad_depth: string;
|
||||
expansion_window_side: string;
|
||||
lumbar_support: boolean;
|
||||
include_shell: boolean;
|
||||
}
|
||||
|
||||
export interface AnalysisResponse {
|
||||
success: boolean;
|
||||
analysis: AnalysisResult;
|
||||
brace_params: BraceParameters;
|
||||
model_url: string | null;
|
||||
}
|
||||
|
||||
export interface RegenerateRequest {
|
||||
pressure_pad_level: string;
|
||||
pressure_pad_depth: string;
|
||||
expansion_window_side: string;
|
||||
lumbar_support: boolean;
|
||||
include_shell: boolean;
|
||||
}
|
||||
|
||||
export const rigoApi = {
|
||||
/**
|
||||
* Check backend health
|
||||
*/
|
||||
health: async (): Promise<{ status: string; service: string }> => {
|
||||
const res = await fetch(`${RIGO_API_BASE}/api/health`);
|
||||
if (!res.ok) throw new Error(`Health check failed: ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Analyze X-ray image and generate brace model
|
||||
*/
|
||||
analyze: async (imageFile: File): Promise<AnalysisResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", imageFile);
|
||||
|
||||
const res = await fetch(`${RIGO_API_BASE}/api/analyze`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Analysis failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Regenerate brace with different parameters
|
||||
*/
|
||||
regenerate: async (params: RegenerateRequest): Promise<{ success: boolean; model_url: string }> => {
|
||||
const res = await fetch(`${RIGO_API_BASE}/api/regenerate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Unknown error" }));
|
||||
throw new Error(error.detail || "Regeneration failed");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get full URL for a model file
|
||||
*/
|
||||
getModelUrl: (filename: string): string => {
|
||||
return `${RIGO_API_BASE}/api/models/${filename}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the base URL (for constructing model URLs from relative paths)
|
||||
*/
|
||||
getBaseUrl: (): string => {
|
||||
return RIGO_API_BASE;
|
||||
},
|
||||
};
|
||||
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
109
frontend/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
type NavItemProps = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
function NavItem({ label, onClick, disabled, active }: NavItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"bf-nav-item",
|
||||
active ? "is-active" : "",
|
||||
disabled ? "is-disabled" : "",
|
||||
].join(" ")}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const [shouldFadeIn, setShouldFadeIn] = useState(false);
|
||||
const prevPathRef = useRef(location.pathname);
|
||||
|
||||
const isCases = location.pathname === "/cases" || location.pathname.startsWith("/cases/");
|
||||
const isEditShell = location.pathname.startsWith("/editor");
|
||||
const isAdmin = location.pathname.startsWith("/admin");
|
||||
const isLanding = location.pathname === "/landing";
|
||||
const userIsAdmin = user?.role === "admin";
|
||||
|
||||
useEffect(() => {
|
||||
const prevPath = prevPathRef.current;
|
||||
prevPathRef.current = location.pathname;
|
||||
|
||||
if (prevPath === "/landing" && location.pathname !== "/landing") {
|
||||
setShouldFadeIn(true);
|
||||
const t = window.setTimeout(() => setShouldFadeIn(false), 560);
|
||||
return () => window.clearTimeout(t);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
nav("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bf-shell">
|
||||
{!isLanding && (
|
||||
<header className="bf-header">
|
||||
<div className="bf-left">
|
||||
<div
|
||||
className="bf-brand"
|
||||
onClick={() => nav("/")}
|
||||
onKeyDown={(e) => e.key === "Enter" && nav("/")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
Brace<span className="bf-brand-accent">iQ</span>
|
||||
</div>
|
||||
|
||||
<nav className="bf-nav" aria-label="Primary navigation">
|
||||
<NavItem label="Cases" active={isCases} onClick={() => nav("/cases")} />
|
||||
<NavItem label="Editor" active={isEditShell} onClick={() => nav("/editor")} />
|
||||
{userIsAdmin && (
|
||||
<NavItem label="Admin" active={isAdmin} onClick={() => nav("/admin")} />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="bf-right">
|
||||
{user && (
|
||||
<div className="bf-user-menu">
|
||||
<span className="bf-user-name">{user.fullName || user.username}</span>
|
||||
<button className="bf-logout-btn" onClick={handleLogout}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<main
|
||||
className={[
|
||||
"bf-content",
|
||||
isLanding ? "bf-content--landing" : "",
|
||||
!isLanding && shouldFadeIn ? "bf-content--fade-in" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/CaseTimeline.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
|
||||
export default function CaseTimeline({ }: { caseId?: string }) {
|
||||
// Placeholder timeline. Real implementation would fetch step records.
|
||||
const steps = [
|
||||
'XrayIngestNormalize',
|
||||
'BiomechMeasurementExtractor',
|
||||
'RigoRuleClassifier',
|
||||
'BraceTemplateSelector',
|
||||
'BraceParametricDeformer',
|
||||
'MeshFinalizerExporter',
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{steps.map((s) => (
|
||||
<div key={s} style={{ padding: 8, border: '1px solid #ddd', borderRadius: 4 }}>
|
||||
{s}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/LandmarkCanvas.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
export type Point = { x: number; y: number };
|
||||
export type LandmarkKey =
|
||||
| "pelvis_mid"
|
||||
| "t1_center"
|
||||
| "tp_point"
|
||||
| "csl_p1"
|
||||
| "csl_p2";
|
||||
|
||||
const LANDMARK_ORDER: Array<{ key: LandmarkKey; label: string }> = [
|
||||
{ key: "pelvis_mid", label: "Pelvis Mid" },
|
||||
{ key: "t1_center", label: "T1 Center" },
|
||||
{ key: "tp_point", label: "Thoracic Prominence (TP)" },
|
||||
{ key: "csl_p1", label: "CSL Point 1 (top)" },
|
||||
{ key: "csl_p2", label: "CSL Point 2 (bottom)" },
|
||||
];
|
||||
|
||||
export function LandmarkCanvas({
|
||||
imageUrl,
|
||||
onChange,
|
||||
initialLandmarks,
|
||||
}: {
|
||||
imageUrl?: string;
|
||||
onChange: (landmarks: Record<string, Point>, completed: boolean) => void;
|
||||
initialLandmarks?: Record<string, Point>;
|
||||
}) {
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const [landmarks, setLandmarks] = useState<Record<string, Point>>({});
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const active = LANDMARK_ORDER[activeIndex];
|
||||
|
||||
const completed = useMemo(
|
||||
() => LANDMARK_ORDER.every((l) => Boolean(landmarks[l.key])),
|
||||
[landmarks]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(landmarks, completed);
|
||||
}, [landmarks, completed, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLandmarks && Object.keys(initialLandmarks).length) {
|
||||
setLandmarks(initialLandmarks);
|
||||
}
|
||||
}, [initialLandmarks]);
|
||||
|
||||
function reset() {
|
||||
setLandmarks({});
|
||||
setActiveIndex(0);
|
||||
}
|
||||
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
if (!imgRef.current) return;
|
||||
|
||||
const rect = imgRef.current.getBoundingClientRect();
|
||||
const x = Math.round(e.clientX - rect.left);
|
||||
const y = Math.round(e.clientY - rect.top);
|
||||
|
||||
const next = { ...landmarks, [active.key]: { x, y } };
|
||||
setLandmarks(next);
|
||||
|
||||
if (activeIndex < LANDMARK_ORDER.length - 1) {
|
||||
setActiveIndex(activeIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="landmark-layout">
|
||||
<div className="panel">
|
||||
<h3>Landmarks</h3>
|
||||
|
||||
<ol className="list">
|
||||
{LANDMARK_ORDER.map((l, idx) => (
|
||||
<li key={l.key} className={idx === activeIndex ? "active" : ""}>
|
||||
<div className="label">{l.label}</div>
|
||||
<div className="meta">
|
||||
{landmarks[l.key]
|
||||
? `x=${landmarks[l.key].x}, y=${landmarks[l.key].y}`
|
||||
: "pending"}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="row gap">
|
||||
<button className="btn secondary" onClick={reset}>
|
||||
Reset
|
||||
</button>
|
||||
<div className="pill">
|
||||
{completed ? "Ready to submit" : `Next: ${active.label}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="canvasWrap">
|
||||
<div className="imgWrap fixed-250" onClick={handleClick}>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageUrl}
|
||||
className="xray"
|
||||
alt="AP X-ray"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
<svg className="overlay">
|
||||
{landmarks["csl_p1"] && landmarks["csl_p2"] && (
|
||||
<line
|
||||
x1={landmarks["csl_p1"].x}
|
||||
y1={landmarks["csl_p1"].y}
|
||||
x2={landmarks["csl_p2"].x}
|
||||
y2={landmarks["csl_p2"].y}
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="6 6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{Object.entries(landmarks).map(([k, p]) => (
|
||||
<g key={k}>
|
||||
<circle cx={p.x} cy={p.y} r="6" fill="white" />
|
||||
<circle cx={p.x} cy={p.y} r="3" fill="black" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="hint">Click to place the active landmark.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
const normalized = (status || "").toLowerCase();
|
||||
|
||||
const badgeClass = [
|
||||
"bf-status-badge",
|
||||
normalized === "created" ? "is-created" : "",
|
||||
normalized === "processing" ? "is-processing" : "",
|
||||
normalized === "failed" ? "is-failed" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return <span className={badgeClass}>{status}</span>;
|
||||
}
|
||||
90
frontend/src/components/XrayUploader.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE;
|
||||
|
||||
type Props = {
|
||||
caseId?: string;
|
||||
onUploaded?: () => void; // optional callback to refresh assets/status
|
||||
};
|
||||
|
||||
export default function XrayUploader({ caseId, onUploaded }: Props) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleUpload() {
|
||||
if (!file) return;
|
||||
|
||||
if (!caseId) {
|
||||
setError('No caseId provided. Create or load a case first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1️⃣ Ask backend for pre-signed upload URL
|
||||
const res = await fetch(
|
||||
`${API_BASE}/cases/${caseId}/upload-url`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ view: "ap" }) // AP view for MVP
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to get upload URL");
|
||||
}
|
||||
|
||||
const { uploadUrl } = await res.json();
|
||||
|
||||
// 2️⃣ Upload file directly to S3
|
||||
const uploadRes = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: file
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error("Upload to S3 failed");
|
||||
}
|
||||
|
||||
// 3️⃣ Notify parent to refresh assets / status
|
||||
onUploaded?.();
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message || "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: "1px dashed #ccc", padding: 16 }}>
|
||||
<h3>X-ray Upload (AP View)</h3>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".dcm,.jpg,.png"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || loading}
|
||||
>
|
||||
{loading ? "Uploading..." : "Upload X-ray"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: "red", marginTop: 8 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
282
frontend/src/components/pipeline/BodyScanUploadStage.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Stage 3: Body Scan Upload
|
||||
* Allows uploading a 3D body scan (STL/OBJ/PLY) for patient-specific fitting
|
||||
*/
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import type { BodyScanMetadata, BodyScanResponse } from '../../api/braceflowApi';
|
||||
import BodyScanViewer from '../three/BodyScanViewer';
|
||||
|
||||
type Props = {
|
||||
caseId: string;
|
||||
bodyScanData: BodyScanResponse | null;
|
||||
isLoading: boolean;
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
onSkip: () => Promise<void>;
|
||||
onContinue: () => void;
|
||||
onDelete: () => Promise<void>;
|
||||
};
|
||||
|
||||
export default function BodyScanUploadStage({
|
||||
caseId,
|
||||
bodyScanData,
|
||||
isLoading,
|
||||
onUpload,
|
||||
onSkip,
|
||||
onContinue,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const viewerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasBodyScan = bodyScanData?.has_body_scan && bodyScanData.body_scan;
|
||||
const metadata = bodyScanData?.body_scan?.metadata;
|
||||
|
||||
// Handle drag events
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle file drop
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
await handleFile(files[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
await handleFile(files[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Process uploaded file
|
||||
const handleFile = async (file: File) => {
|
||||
// Validate file type
|
||||
const allowedTypes = ['.stl', '.obj', '.ply', '.glb', '.gltf'];
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
|
||||
|
||||
if (!allowedTypes.includes(ext)) {
|
||||
setError(`Invalid file type. Allowed: ${allowedTypes.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setUploadProgress('Uploading...');
|
||||
|
||||
try {
|
||||
await onUpload(file);
|
||||
setUploadProgress(null);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Upload failed');
|
||||
setUploadProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Format measurement
|
||||
const formatMeasurement = (value: number | undefined, unit = 'mm') => {
|
||||
if (value === undefined || value === null) return 'N/A';
|
||||
return `${value.toFixed(1)} ${unit}`;
|
||||
};
|
||||
|
||||
// Render loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pipeline-stage body-scan-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 3: Body Scan Upload</h2>
|
||||
<div className="stage-status">
|
||||
<span className="status-badge status-processing">Processing...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stage-content">
|
||||
<div className="body-scan-loading">
|
||||
<div className="spinner large"></div>
|
||||
<p>Processing body scan...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pipeline-stage body-scan-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 3: Body Scan Upload</h2>
|
||||
<div className="stage-status">
|
||||
{hasBodyScan ? (
|
||||
<span className="status-badge status-complete">Uploaded</span>
|
||||
) : (
|
||||
<span className="status-badge status-pending">Optional</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-content body-scan-content">
|
||||
{/* Upload Area / Preview */}
|
||||
<div className="body-scan-main">
|
||||
{!hasBodyScan ? (
|
||||
<div
|
||||
className={`upload-dropzone ${dragActive ? 'drag-active' : ''}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".stl,.obj,.ply,.glb,.gltf"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div className="dropzone-content">
|
||||
<div className="dropzone-icon">📦</div>
|
||||
<h3>Upload 3D Body Scan</h3>
|
||||
<p>Drag and drop or click to select</p>
|
||||
<p className="file-types">STL, OBJ, PLY, GLB supported</p>
|
||||
{uploadProgress && <p className="upload-progress">{uploadProgress}</p>}
|
||||
{error && <p className="upload-error">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="body-scan-preview body-scan-preview-3d" ref={viewerRef}>
|
||||
{/* 3D Spinning Preview - fills container, slow lazy susan rotation */}
|
||||
<BodyScanViewer
|
||||
scanUrl={bodyScanData?.body_scan?.url || null}
|
||||
autoRotate={true}
|
||||
rotationSpeed={0.005}
|
||||
/>
|
||||
|
||||
{/* File info overlay */}
|
||||
<div className="preview-info-overlay">
|
||||
<span className="filename">{metadata?.filename || 'body_scan.stl'}</span>
|
||||
{metadata?.vertex_count && (
|
||||
<span className="vertex-count">{metadata.vertex_count.toLocaleString()} vertices</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn-remove" onClick={onDelete}>
|
||||
Remove Scan
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Panel */}
|
||||
<div className="body-scan-sidebar">
|
||||
<div className="info-panel">
|
||||
<h3>Why Upload a Body Scan?</h3>
|
||||
<p>
|
||||
A 3D body scan allows us to generate a perfectly fitted brace
|
||||
that matches your exact body measurements.
|
||||
</p>
|
||||
<ul className="benefits-list">
|
||||
<li>Precise fit based on body shape</li>
|
||||
<li>Automatic clearance calculation</li>
|
||||
<li>Better pressure zone placement</li>
|
||||
<li>3D printable shell output</li>
|
||||
</ul>
|
||||
<p className="optional-note">
|
||||
<strong>Optional:</strong> You can skip this step to generate
|
||||
a standard-sized brace based on X-ray analysis only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body Measurements */}
|
||||
{hasBodyScan && metadata && (
|
||||
<div className="measurements-panel">
|
||||
<h3>Body Measurements</h3>
|
||||
<div className="measurements-grid">
|
||||
{metadata.total_height_mm !== undefined && (
|
||||
<div className="measurement-item">
|
||||
<span className="label">Total Height</span>
|
||||
<span className="value">{formatMeasurement(metadata.total_height_mm)}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.shoulder_width_mm !== undefined && (
|
||||
<div className="measurement-item">
|
||||
<span className="label">Shoulder Width</span>
|
||||
<span className="value">{formatMeasurement(metadata.shoulder_width_mm)}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.chest_width_mm !== undefined && (
|
||||
<div className="measurement-item">
|
||||
<span className="label">Chest Width</span>
|
||||
<span className="value">{formatMeasurement(metadata.chest_width_mm)}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.chest_depth_mm !== undefined && (
|
||||
<div className="measurement-item">
|
||||
<span className="label">Chest Depth</span>
|
||||
<span className="value">{formatMeasurement(metadata.chest_depth_mm)}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.waist_width_mm !== undefined && (
|
||||
<div className="measurement-item">
|
||||
<span className="label">Waist Width</span>
|
||||
<span className="value">{formatMeasurement(metadata.waist_width_mm)}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.hip_width_mm !== undefined && (
|
||||
<div className="measurement-item">
|
||||
<span className="label">Hip Width</span>
|
||||
<span className="value">{formatMeasurement(metadata.hip_width_mm)}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.total_height_mm !== undefined && (
|
||||
<div className="measurement-item highlight">
|
||||
<span className="label">Brace Coverage (65%)</span>
|
||||
<span className="value">{formatMeasurement(metadata.total_height_mm * 0.65)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="stage-actions">
|
||||
{!hasBodyScan ? (
|
||||
<>
|
||||
<button className="btn secondary" onClick={onSkip}>
|
||||
Skip (Use X-ray Only)
|
||||
</button>
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Upload Body Scan
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn secondary" onClick={onDelete}>
|
||||
Remove & Skip
|
||||
</button>
|
||||
<button className="btn primary" onClick={onContinue}>
|
||||
Continue to Brace Generation
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
270
frontend/src/components/pipeline/BraceEditorStage.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Stage 5: Brace Editor
|
||||
* 3D visualization and deformation controls for the generated brace
|
||||
*
|
||||
* Based on EXPERIMENT_6's brace-transform-playground-v2
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import BraceViewer from '../three/BraceViewer';
|
||||
import type { GenerateBraceResponse } from '../../api/braceflowApi';
|
||||
|
||||
type MarkerInfo = {
|
||||
name: string;
|
||||
position: [number, number, number];
|
||||
color: string;
|
||||
};
|
||||
|
||||
type DeformationParams = {
|
||||
thoracicPadDepth: number;
|
||||
lumbarPadDepth: number;
|
||||
trunkShift: number;
|
||||
rotationCorrection: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
caseId: string;
|
||||
braceData: GenerateBraceResponse | null;
|
||||
onRegenerate: (params: DeformationParams) => Promise<void>;
|
||||
onExportSTL: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_PARAMS: DeformationParams = {
|
||||
thoracicPadDepth: 15,
|
||||
lumbarPadDepth: 10,
|
||||
trunkShift: 0,
|
||||
rotationCorrection: 0,
|
||||
};
|
||||
|
||||
export default function BraceEditorStage({
|
||||
caseId,
|
||||
braceData,
|
||||
onRegenerate,
|
||||
onExportSTL,
|
||||
}: Props) {
|
||||
const [params, setParams] = useState<DeformationParams>(DEFAULT_PARAMS);
|
||||
const [markers, setMarkers] = useState<MarkerInfo[]>([]);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [showMarkers, setShowMarkers] = useState(true);
|
||||
|
||||
// Get GLB URL from brace data
|
||||
const glbUrl = braceData?.outputs?.glb?.url || braceData?.outputs?.shell_glb?.url || null;
|
||||
const stlUrl = braceData?.outputs?.stl?.url || braceData?.outputs?.shell_stl?.url || null;
|
||||
|
||||
// Handle parameter change
|
||||
const handleParamChange = useCallback((key: keyof DeformationParams, value: number) => {
|
||||
setParams(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// Handle regenerate
|
||||
const handleRegenerate = useCallback(async () => {
|
||||
setIsRegenerating(true);
|
||||
try {
|
||||
await onRegenerate(params);
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
}, [params, onRegenerate]);
|
||||
|
||||
// Handle markers loaded
|
||||
const handleMarkersLoaded = useCallback((loadedMarkers: MarkerInfo[]) => {
|
||||
setMarkers(loadedMarkers);
|
||||
}, []);
|
||||
|
||||
// Reset parameters
|
||||
const handleReset = useCallback(() => {
|
||||
setParams(DEFAULT_PARAMS);
|
||||
}, []);
|
||||
|
||||
if (!braceData) {
|
||||
return (
|
||||
<div className="pipeline-stage brace-editor-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 5: Brace Editor</h2>
|
||||
<div className="stage-status">
|
||||
<span className="status-badge status-pending">Waiting for Brace</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stage-content">
|
||||
<div className="editor-empty">
|
||||
<p>Generate a brace first to use the 3D editor.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pipeline-stage brace-editor-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 5: Brace Editor</h2>
|
||||
<div className="stage-status">
|
||||
<span className="status-badge status-complete">Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-content">
|
||||
{/* 3D Viewer */}
|
||||
<div className="brace-editor-main">
|
||||
<BraceViewer
|
||||
glbUrl={glbUrl}
|
||||
width={700}
|
||||
height={550}
|
||||
showMarkers={showMarkers}
|
||||
onMarkersLoaded={handleMarkersLoaded}
|
||||
deformationParams={{
|
||||
thoracicPadDepth: params.thoracicPadDepth,
|
||||
lumbarPadDepth: params.lumbarPadDepth,
|
||||
trunkShift: params.trunkShift,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* View Controls */}
|
||||
<div className="viewer-controls">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMarkers}
|
||||
onChange={(e) => setShowMarkers(e.target.checked)}
|
||||
/>
|
||||
Show Markers
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls Sidebar */}
|
||||
<div className="brace-editor-sidebar">
|
||||
{/* Deformation Controls */}
|
||||
<div className="deformation-controls">
|
||||
<h3>Deformation Parameters</h3>
|
||||
|
||||
<div className="control-group">
|
||||
<label>Thoracic Pad Depth (mm)</label>
|
||||
<div className="control-slider">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="30"
|
||||
step="1"
|
||||
value={params.thoracicPadDepth}
|
||||
onChange={(e) => handleParamChange('thoracicPadDepth', Number(e.target.value))}
|
||||
/>
|
||||
<span className="value">{params.thoracicPadDepth}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>Lumbar Pad Depth (mm)</label>
|
||||
<div className="control-slider">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="25"
|
||||
step="1"
|
||||
value={params.lumbarPadDepth}
|
||||
onChange={(e) => handleParamChange('lumbarPadDepth', Number(e.target.value))}
|
||||
/>
|
||||
<span className="value">{params.lumbarPadDepth}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>Trunk Shift (mm)</label>
|
||||
<div className="control-slider">
|
||||
<input
|
||||
type="range"
|
||||
min="-20"
|
||||
max="20"
|
||||
step="1"
|
||||
value={params.trunkShift}
|
||||
onChange={(e) => handleParamChange('trunkShift', Number(e.target.value))}
|
||||
/>
|
||||
<span className="value">{params.trunkShift}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>Rotation Correction (°)</label>
|
||||
<div className="control-slider">
|
||||
<input
|
||||
type="range"
|
||||
min="-15"
|
||||
max="15"
|
||||
step="1"
|
||||
value={params.rotationCorrection}
|
||||
onChange={(e) => handleParamChange('rotationCorrection', Number(e.target.value))}
|
||||
/>
|
||||
<span className="value">{params.rotationCorrection}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="control-actions">
|
||||
<button className="btn secondary small" onClick={handleReset}>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
className="btn primary small"
|
||||
onClick={handleRegenerate}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
{isRegenerating ? 'Regenerating...' : 'Apply Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markers Panel */}
|
||||
{markers.length > 0 && (
|
||||
<div className="markers-panel">
|
||||
<h3>Markers ({markers.length})</h3>
|
||||
<div className="markers-list">
|
||||
{markers.map((marker, idx) => (
|
||||
<div key={idx} className="marker-item">
|
||||
<span
|
||||
className="marker-color"
|
||||
style={{ backgroundColor: marker.color }}
|
||||
/>
|
||||
<span className="marker-name">{marker.name.replace('LM_', '')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export Panel */}
|
||||
<div className="export-panel">
|
||||
<h3>Export</h3>
|
||||
<div className="export-buttons">
|
||||
{stlUrl && (
|
||||
<a href={stlUrl} download={`brace_${caseId}.stl`} className="btn secondary">
|
||||
Download STL
|
||||
</a>
|
||||
)}
|
||||
{glbUrl && (
|
||||
<a href={glbUrl} download={`brace_${caseId}.glb`} className="btn secondary">
|
||||
Download GLB
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Panel */}
|
||||
<div className="info-panel">
|
||||
<h3>About the Editor</h3>
|
||||
<p>
|
||||
Adjust the deformation parameters to customize the brace fit.
|
||||
Changes are previewed in real-time.
|
||||
</p>
|
||||
<ul className="tips-list">
|
||||
<li><strong>Thoracic Pad:</strong> Pressure on thoracic curve convexity</li>
|
||||
<li><strong>Lumbar Pad:</strong> Counter-pressure on lumbar region</li>
|
||||
<li><strong>Trunk Shift:</strong> Lateral correction force</li>
|
||||
<li><strong>Rotation:</strong> De-rotation effect</li>
|
||||
</ul>
|
||||
<p className="hint">
|
||||
Use mouse to orbit, scroll to zoom, right-click to pan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
621
frontend/src/components/pipeline/BraceFittingStage.tsx
Normal file
@@ -0,0 +1,621 @@
|
||||
/**
|
||||
* Stage 5: Brace Fitting Inspection
|
||||
* Shows both braces overlaid on the body scan to visualize fit
|
||||
* LEFT panel: Position, Rotation, Scale controls
|
||||
* RIGHT panel: Deformation sliders (Cobb angle, apex, etc.) from Stage 4
|
||||
*/
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import BraceInlineEditor, {
|
||||
type BraceTransformParams,
|
||||
DEFAULT_TRANSFORM_PARAMS
|
||||
} from './BraceInlineEditor';
|
||||
|
||||
// Three.js is loaded dynamically
|
||||
let THREE: any = null;
|
||||
let STLLoader: any = null;
|
||||
let GLTFLoader: any = null;
|
||||
let OrbitControls: any = null;
|
||||
|
||||
type BraceFittingStageProps = {
|
||||
caseId: string;
|
||||
bodyScanUrl: string | null;
|
||||
regularBraceUrl: string | null;
|
||||
vaseBraceUrl: string | null;
|
||||
braceData: any;
|
||||
};
|
||||
|
||||
export default function BraceFittingStage({
|
||||
caseId,
|
||||
bodyScanUrl,
|
||||
regularBraceUrl,
|
||||
vaseBraceUrl,
|
||||
braceData,
|
||||
}: BraceFittingStageProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<any>(null);
|
||||
const sceneRef = useRef<any>(null);
|
||||
const cameraRef = useRef<any>(null);
|
||||
const controlsRef = useRef<any>(null);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
|
||||
// Mesh references
|
||||
const bodyMeshRef = useRef<any>(null);
|
||||
const regularBraceMeshRef = useRef<any>(null);
|
||||
const vaseBraceMeshRef = useRef<any>(null);
|
||||
const regularBaseGeomRef = useRef<any>(null);
|
||||
const vaseBaseGeomRef = useRef<any>(null);
|
||||
|
||||
// Store base transforms for relative positioning
|
||||
const baseScaleRef = useRef<number>(1);
|
||||
|
||||
const [threeLoaded, setThreeLoaded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Visibility controls
|
||||
const [showBody, setShowBody] = useState(true);
|
||||
const [showRegularBrace, setShowRegularBrace] = useState(true);
|
||||
const [showVaseBrace, setShowVaseBrace] = useState(false);
|
||||
const [bodyOpacity, setBodyOpacity] = useState(0.3);
|
||||
const [autoRotate, setAutoRotate] = useState(false);
|
||||
|
||||
// Position/Rotation/Scale controls (for moving meshes)
|
||||
const [bracePositionX, setBracePositionX] = useState(0);
|
||||
const [bracePositionY, setBracePositionY] = useState(0);
|
||||
const [bracePositionZ, setBracePositionZ] = useState(0);
|
||||
const [braceRotationX, setBraceRotationX] = useState(0);
|
||||
const [braceRotationY, setBraceRotationY] = useState(0);
|
||||
const [braceRotationZ, setBraceRotationZ] = useState(0);
|
||||
const [braceScaleX, setBraceScaleX] = useState(1.0);
|
||||
const [braceScaleY, setBraceScaleY] = useState(1.0);
|
||||
const [braceScaleZ, setBraceScaleZ] = useState(1.0);
|
||||
|
||||
// Body position/rotation/scale
|
||||
const [bodyPositionX, setBodyPositionX] = useState(0);
|
||||
const [bodyPositionY, setBodyPositionY] = useState(0);
|
||||
const [bodyPositionZ, setBodyPositionZ] = useState(0);
|
||||
const [bodyRotationX, setBodyRotationX] = useState(0);
|
||||
const [bodyRotationY, setBodyRotationY] = useState(0);
|
||||
const [bodyRotationZ, setBodyRotationZ] = useState(0);
|
||||
const [bodyScale, setBodyScale] = useState(1.0);
|
||||
|
||||
// Which brace is being edited
|
||||
const [activeBrace, setActiveBrace] = useState<'regular' | 'vase'>('regular');
|
||||
|
||||
// Transform params for each brace (deformation sliders)
|
||||
const [regularParams, setRegularParams] = useState<BraceTransformParams>(() => ({
|
||||
...DEFAULT_TRANSFORM_PARAMS,
|
||||
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || 25,
|
||||
}));
|
||||
const [vaseParams, setVaseParams] = useState<BraceTransformParams>(() => ({
|
||||
...DEFAULT_TRANSFORM_PARAMS,
|
||||
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || 25,
|
||||
}));
|
||||
|
||||
// Colors
|
||||
const BODY_COLOR = 0xf5d0c5;
|
||||
const REGULAR_BRACE_COLOR = 0x4a90d9;
|
||||
const VASE_BRACE_COLOR = 0x50c878;
|
||||
|
||||
// Load Three.js
|
||||
useEffect(() => {
|
||||
const loadThree = async () => {
|
||||
try {
|
||||
const threeModule = await import('three');
|
||||
THREE = threeModule;
|
||||
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
|
||||
STLLoader = STL;
|
||||
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||
GLTFLoader = GLTF;
|
||||
const { OrbitControls: Controls } = await import('three/examples/jsm/controls/OrbitControls.js');
|
||||
OrbitControls = Controls;
|
||||
setThreeLoaded(true);
|
||||
} catch (e) {
|
||||
console.error('Failed to load Three.js:', e);
|
||||
setError('Failed to load 3D viewer');
|
||||
}
|
||||
};
|
||||
loadThree();
|
||||
return () => {
|
||||
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
||||
if (rendererRef.current) rendererRef.current.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize scene
|
||||
useEffect(() => {
|
||||
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a2e);
|
||||
sceneRef.current = scene;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
|
||||
// For Z-up meshes (medical/scanner convention), view from front (Y axis)
|
||||
// Camera at Y=-800 looking at torso level (Z~300)
|
||||
camera.position.set(0, -800, 300);
|
||||
camera.lookAt(0, 0, 300);
|
||||
camera.up.set(0, 0, 1); // Z is up
|
||||
cameraRef.current = camera;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.sortObjects = true;
|
||||
container.appendChild(renderer.domElement);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.target.set(0, 0, 300); // Look at torso level
|
||||
controlsRef.current = controls;
|
||||
|
||||
// Lighting (adjusted for Z-up)
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
||||
const keyLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
keyLight.position.set(200, -300, 500); // Front-top-right
|
||||
scene.add(keyLight);
|
||||
const fillLight = new THREE.DirectionalLight(0x88ccff, 0.5);
|
||||
fillLight.position.set(-200, -100, 400); // Front-left
|
||||
scene.add(fillLight);
|
||||
const backLight = new THREE.DirectionalLight(0xffffcc, 0.4);
|
||||
backLight.position.set(0, 300, 300); // Back
|
||||
scene.add(backLight);
|
||||
|
||||
// Grid on XY plane (floor for Z-up world)
|
||||
const gridHelper = new THREE.GridHelper(400, 20, 0x444444, 0x333333);
|
||||
gridHelper.rotation.x = Math.PI / 2; // Rotate to XY plane
|
||||
gridHelper.position.z = 0; // At Z=0 (floor level)
|
||||
scene.add(gridHelper);
|
||||
|
||||
const animate = () => {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
const handleResize = () => {
|
||||
if (!container || !renderer || !camera) return;
|
||||
camera.aspect = container.clientWidth / container.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(container);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [threeLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (controlsRef.current) controlsRef.current.autoRotate = autoRotate;
|
||||
}, [autoRotate]);
|
||||
|
||||
// Deformation algorithm
|
||||
const applyDeformation = useCallback((geometry: any, params: BraceTransformParams) => {
|
||||
if (!THREE || !geometry) return geometry;
|
||||
const deformed = geometry.clone();
|
||||
const positions = deformed.getAttribute('position');
|
||||
if (!positions) return deformed;
|
||||
|
||||
deformed.computeBoundingBox();
|
||||
const bbox = deformed.boundingBox;
|
||||
// For Z-up convention (medical/scanner), Z is vertical (height)
|
||||
const minZ = bbox?.min?.z || 0, maxZ = bbox?.max?.z || 1;
|
||||
const minX = bbox?.min?.x || -0.5, maxX = bbox?.max?.x || 0.5;
|
||||
const minY = bbox?.min?.y || -0.5, maxY = bbox?.max?.y || 0.5;
|
||||
const centerX = (minX + maxX) / 2, centerY = (minY + maxY) / 2;
|
||||
const bboxHeight = maxZ - minZ, bboxWidth = maxX - minX, bboxDepth = maxY - minY;
|
||||
|
||||
const pelvis = { x: centerX, y: centerY, z: minZ };
|
||||
const braceHeight = bboxHeight || 1;
|
||||
const unitsPerMm = braceHeight / Math.max(1e-6, params.expectedBraceHeightMm);
|
||||
const sev = Math.max(0, Math.min(1, (params.cobbDeg - 15) / 40));
|
||||
const padDepthMm = (8 + 12 * sev) * params.strengthMult;
|
||||
const bayClearMm = padDepthMm * 1.2;
|
||||
const padDepth = padDepthMm * unitsPerMm;
|
||||
const bayClear = bayClearMm * unitsPerMm;
|
||||
const sizeScale = 0.9 + 0.5 * sev;
|
||||
|
||||
// For Z-up convention: Z is height, Y is front-back depth, X is left-right width
|
||||
const features: Array<{ center: {x:number,y:number,z:number}; radii: {x:number,y:number,z:number}; depth: number; direction: 1|-1; falloffPower: number }> = [];
|
||||
|
||||
// Thoracic pad & bay (z is now height, y is depth)
|
||||
features.push({ center: { x: centerX + bboxWidth * 0.35, y: centerY - bboxDepth * 0.1, z: minZ + bboxHeight * params.apexNorm }, radii: { x: 45*unitsPerMm*sizeScale, y: 35*unitsPerMm*sizeScale, z: 90*unitsPerMm*sizeScale }, depth: padDepth, direction: -1, falloffPower: 2.0 });
|
||||
features.push({ center: { x: centerX - bboxWidth * 0.35, y: centerY - bboxDepth * 0.1, z: minZ + bboxHeight * params.apexNorm }, radii: { x: 60*unitsPerMm*sizeScale, y: 55*unitsPerMm*sizeScale, z: 110*unitsPerMm*sizeScale }, depth: bayClear, direction: 1, falloffPower: 1.6 });
|
||||
// Lumbar pad & bay
|
||||
features.push({ center: { x: centerX - bboxWidth * 0.3, y: centerY, z: minZ + bboxHeight * params.lumbarApexNorm }, radii: { x: 50*unitsPerMm*sizeScale, y: 40*unitsPerMm*sizeScale, z: 80*unitsPerMm*sizeScale }, depth: padDepth*0.9, direction: -1, falloffPower: 2.0 });
|
||||
features.push({ center: { x: centerX + bboxWidth * 0.3, y: centerY, z: minZ + bboxHeight * params.lumbarApexNorm }, radii: { x: 65*unitsPerMm*sizeScale, y: 55*unitsPerMm*sizeScale, z: 95*unitsPerMm*sizeScale }, depth: bayClear*0.9, direction: 1, falloffPower: 1.6 });
|
||||
// Hip anchors
|
||||
const hipDepth = params.hipAnchorStrengthMm * unitsPerMm * params.strengthMult;
|
||||
if (hipDepth > 0) {
|
||||
features.push({ center: { x: centerX - bboxWidth * 0.4, y: centerY, z: minZ + bboxHeight * 0.1 }, radii: { x: 35*unitsPerMm, y: 35*unitsPerMm, z: 55*unitsPerMm }, depth: hipDepth, direction: -1, falloffPower: 2.2 });
|
||||
features.push({ center: { x: centerX + bboxWidth * 0.4, y: centerY, z: minZ + bboxHeight * 0.1 }, radii: { x: 35*unitsPerMm, y: 35*unitsPerMm, z: 55*unitsPerMm }, depth: hipDepth, direction: -1, falloffPower: 2.2 });
|
||||
}
|
||||
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
let x = positions.getX(i), y = positions.getY(i), z = positions.getZ(i);
|
||||
if (params.mirrorX) x = -x;
|
||||
// For Z-up: height is along Z axis
|
||||
const heightNorm = Math.max(0, Math.min(1, (z - pelvis.z) / braceHeight));
|
||||
x += params.trunkShiftMm * unitsPerMm * heightNorm * 0.8;
|
||||
for (const f of features) {
|
||||
const dx = (x - f.center.x) / f.radii.x, dy = (y - f.center.y) / f.radii.y, dz = (z - f.center.z) / f.radii.z;
|
||||
const d2 = dx*dx + dy*dy + dz*dz;
|
||||
if (d2 >= 1) continue;
|
||||
const t = Math.pow(1 - d2, f.falloffPower);
|
||||
const disp = f.depth * t * f.direction;
|
||||
// For Z-up: radial direction is in XY plane
|
||||
const axisX = x - pelvis.x, axisY = y - pelvis.y;
|
||||
const len = Math.sqrt(axisX*axisX + axisY*axisY) || 1;
|
||||
x += (axisX/len) * disp;
|
||||
y += (axisY/len) * disp;
|
||||
}
|
||||
positions.setXYZ(i, x, y, z);
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
deformed.computeVertexNormals();
|
||||
return deformed;
|
||||
}, []);
|
||||
|
||||
// Load mesh
|
||||
const loadMesh = useCallback(async (url: string, color: number, opacity: number, isBody: boolean = false): Promise<{ mesh: any; baseGeometry: any }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ext = url.toLowerCase().split('.').pop() || '';
|
||||
const createMaterial = () => new THREE.MeshStandardMaterial({
|
||||
color, roughness: 0.6, metalness: 0.1, transparent: true, opacity,
|
||||
side: THREE.DoubleSide, depthWrite: !isBody, depthTest: true,
|
||||
});
|
||||
|
||||
if (ext === 'stl') {
|
||||
new STLLoader().load(url, (geometry: any) => {
|
||||
geometry.center();
|
||||
geometry.computeVertexNormals();
|
||||
const baseGeometry = geometry.clone();
|
||||
const mesh = new THREE.Mesh(geometry, createMaterial());
|
||||
// Don't apply fixed rotation - let user adjust with controls
|
||||
// mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.renderOrder = isBody ? 10 : 1;
|
||||
resolve({ mesh, baseGeometry });
|
||||
}, undefined, reject);
|
||||
} else if (ext === 'glb' || ext === 'gltf') {
|
||||
new GLTFLoader().load(url, (gltf: any) => {
|
||||
const mesh = gltf.scene;
|
||||
let baseGeometry: any = null;
|
||||
mesh.traverse((child: any) => {
|
||||
if (child.isMesh) {
|
||||
child.material = createMaterial();
|
||||
child.renderOrder = isBody ? 10 : 1;
|
||||
if (!baseGeometry) baseGeometry = child.geometry.clone();
|
||||
}
|
||||
});
|
||||
resolve({ mesh, baseGeometry });
|
||||
}, undefined, reject);
|
||||
} else reject(new Error(`Unsupported: ${ext}`));
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load all meshes
|
||||
useEffect(() => {
|
||||
if (!threeLoaded || !sceneRef.current) return;
|
||||
const scene = sceneRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
[bodyMeshRef, regularBraceMeshRef, vaseBraceMeshRef].forEach(ref => {
|
||||
if (ref.current) { scene.remove(ref.current); ref.current = null; }
|
||||
});
|
||||
regularBaseGeomRef.current = null;
|
||||
vaseBaseGeomRef.current = null;
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const meshes: any[] = [];
|
||||
|
||||
if (regularBraceUrl) {
|
||||
const { mesh, baseGeometry } = await loadMesh(regularBraceUrl, REGULAR_BRACE_COLOR, 0.9, false);
|
||||
regularBraceMeshRef.current = mesh;
|
||||
regularBaseGeomRef.current = baseGeometry;
|
||||
scene.add(mesh);
|
||||
meshes.push(mesh);
|
||||
}
|
||||
if (vaseBraceUrl) {
|
||||
const { mesh, baseGeometry } = await loadMesh(vaseBraceUrl, VASE_BRACE_COLOR, 0.9, false);
|
||||
mesh.visible = showVaseBrace;
|
||||
vaseBraceMeshRef.current = mesh;
|
||||
vaseBaseGeomRef.current = baseGeometry;
|
||||
scene.add(mesh);
|
||||
meshes.push(mesh);
|
||||
}
|
||||
if (bodyScanUrl) {
|
||||
const { mesh } = await loadMesh(bodyScanUrl, BODY_COLOR, bodyOpacity, true);
|
||||
bodyMeshRef.current = mesh;
|
||||
scene.add(mesh);
|
||||
meshes.push(mesh);
|
||||
}
|
||||
|
||||
if (meshes.length > 0) {
|
||||
const combinedBox = new THREE.Box3();
|
||||
meshes.forEach(m => combinedBox.union(new THREE.Box3().setFromObject(m)));
|
||||
const size = combinedBox.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 350 / maxDim;
|
||||
baseScaleRef.current = scale;
|
||||
meshes.forEach(m => m.scale.multiplyScalar(scale));
|
||||
|
||||
const newBox = new THREE.Box3();
|
||||
meshes.forEach(m => newBox.union(new THREE.Box3().setFromObject(m)));
|
||||
const center = newBox.getCenter(new THREE.Vector3());
|
||||
meshes.forEach(m => m.position.sub(center));
|
||||
|
||||
cameraRef.current.position.set(0, 50, 500);
|
||||
cameraRef.current.lookAt(0, 0, 0);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load:', err);
|
||||
setError('Failed to load 3D models');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadAll();
|
||||
}, [threeLoaded, bodyScanUrl, regularBraceUrl, vaseBraceUrl, loadMesh, bodyOpacity, showVaseBrace]);
|
||||
|
||||
// Visibility updates
|
||||
useEffect(() => { if (bodyMeshRef.current) bodyMeshRef.current.visible = showBody; }, [showBody]);
|
||||
useEffect(() => { if (regularBraceMeshRef.current) regularBraceMeshRef.current.visible = showRegularBrace; }, [showRegularBrace]);
|
||||
useEffect(() => { if (vaseBraceMeshRef.current) vaseBraceMeshRef.current.visible = showVaseBrace; }, [showVaseBrace]);
|
||||
|
||||
// Body opacity
|
||||
useEffect(() => {
|
||||
if (!bodyMeshRef.current) return;
|
||||
const update = (mat: any) => { mat.opacity = bodyOpacity; mat.needsUpdate = true; };
|
||||
bodyMeshRef.current.traverse((c: any) => { if (c.isMesh && c.material) update(c.material); });
|
||||
if (bodyMeshRef.current.material) update(bodyMeshRef.current.material);
|
||||
}, [bodyOpacity]);
|
||||
|
||||
// Apply position/rotation/scale to braces
|
||||
useEffect(() => {
|
||||
const applyToBrace = (mesh: any) => {
|
||||
if (!mesh) return;
|
||||
mesh.position.x = bracePositionX;
|
||||
mesh.position.y = bracePositionY;
|
||||
mesh.position.z = bracePositionZ;
|
||||
// Convert degrees to radians, no fixed offset
|
||||
mesh.rotation.x = braceRotationX * Math.PI / 180;
|
||||
mesh.rotation.y = braceRotationY * Math.PI / 180;
|
||||
mesh.rotation.z = braceRotationZ * Math.PI / 180;
|
||||
const base = baseScaleRef.current;
|
||||
mesh.scale.set(base * braceScaleX, base * braceScaleY, base * braceScaleZ);
|
||||
};
|
||||
applyToBrace(regularBraceMeshRef.current);
|
||||
applyToBrace(vaseBraceMeshRef.current);
|
||||
}, [bracePositionX, bracePositionY, bracePositionZ, braceRotationX, braceRotationY, braceRotationZ, braceScaleX, braceScaleY, braceScaleZ]);
|
||||
|
||||
// Apply position/rotation/scale to body
|
||||
useEffect(() => {
|
||||
if (!bodyMeshRef.current) return;
|
||||
bodyMeshRef.current.position.x = bodyPositionX;
|
||||
bodyMeshRef.current.position.y = bodyPositionY;
|
||||
bodyMeshRef.current.position.z = bodyPositionZ;
|
||||
// Convert degrees to radians, no fixed offset
|
||||
bodyMeshRef.current.rotation.x = bodyRotationX * Math.PI / 180;
|
||||
bodyMeshRef.current.rotation.y = bodyRotationY * Math.PI / 180;
|
||||
bodyMeshRef.current.rotation.z = bodyRotationZ * Math.PI / 180;
|
||||
const base = baseScaleRef.current;
|
||||
bodyMeshRef.current.scale.set(base * bodyScale, base * bodyScale, base * bodyScale);
|
||||
}, [bodyPositionX, bodyPositionY, bodyPositionZ, bodyRotationX, bodyRotationY, bodyRotationZ, bodyScale]);
|
||||
|
||||
// Apply deformation to braces
|
||||
useEffect(() => {
|
||||
if (!regularBraceMeshRef.current || !regularBaseGeomRef.current) return;
|
||||
const deformed = applyDeformation(regularBaseGeomRef.current.clone(), regularParams);
|
||||
regularBraceMeshRef.current.traverse((c: any) => { if (c.isMesh) { c.geometry.dispose(); c.geometry = deformed; }});
|
||||
if (regularBraceMeshRef.current.geometry) { regularBraceMeshRef.current.geometry.dispose(); regularBraceMeshRef.current.geometry = deformed; }
|
||||
}, [regularParams, applyDeformation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!vaseBraceMeshRef.current || !vaseBaseGeomRef.current) return;
|
||||
const deformed = applyDeformation(vaseBaseGeomRef.current.clone(), vaseParams);
|
||||
vaseBraceMeshRef.current.traverse((c: any) => { if (c.isMesh) { c.geometry.dispose(); c.geometry = deformed; }});
|
||||
if (vaseBraceMeshRef.current.geometry) { vaseBraceMeshRef.current.geometry.dispose(); vaseBraceMeshRef.current.geometry = deformed; }
|
||||
}, [vaseParams, applyDeformation]);
|
||||
|
||||
// Handlers
|
||||
const handleParamsChange = useCallback((params: BraceTransformParams) => {
|
||||
if (activeBrace === 'regular') setRegularParams(params);
|
||||
else setVaseParams(params);
|
||||
}, [activeBrace]);
|
||||
|
||||
const handleSave = useCallback(async () => { console.log('Save not implemented'); }, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const reset = { ...DEFAULT_TRANSFORM_PARAMS, cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || 25 };
|
||||
if (activeBrace === 'regular') setRegularParams(reset);
|
||||
else setVaseParams(reset);
|
||||
}, [activeBrace, braceData?.cobb_angles]);
|
||||
|
||||
const handleResetTransforms = () => {
|
||||
setBracePositionX(0); setBracePositionY(0); setBracePositionZ(0);
|
||||
setBraceRotationX(0); setBraceRotationY(0); setBraceRotationZ(0);
|
||||
setBraceScaleX(1); setBraceScaleY(1); setBraceScaleZ(1);
|
||||
setBodyPositionX(0); setBodyPositionY(0); setBodyPositionZ(0);
|
||||
setBodyRotationX(0); setBodyRotationY(0); setBodyRotationZ(0);
|
||||
setBodyScale(1);
|
||||
};
|
||||
|
||||
const hasBodyScan = !!bodyScanUrl;
|
||||
const hasBraces = regularBraceUrl || vaseBraceUrl;
|
||||
|
||||
if (!hasBodyScan && !hasBraces) {
|
||||
return (
|
||||
<div className="pipeline-stage fitting-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 5: Brace Fitting Inspection</h2>
|
||||
<div className="stage-status"><span className="status-badge status-pending">Pending</span></div>
|
||||
</div>
|
||||
<div className="stage-content">
|
||||
<div className="fitting-empty">
|
||||
<p>Body scan and braces are required to inspect fitting.</p>
|
||||
<p className="hint">Complete Stage 3 (Body Scan) and Stage 4 (Brace Generation) first.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pipeline-stage fitting-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 5: Brace Fitting Inspection</h2>
|
||||
<div className="stage-status"><span className="status-badge status-complete">Ready</span></div>
|
||||
</div>
|
||||
|
||||
<div className="fitting-layout-3col">
|
||||
{/* LEFT PANEL: Position, Rotation, Scale */}
|
||||
<div className="fitting-panel left-panel">
|
||||
<h3>Transform Controls</h3>
|
||||
|
||||
{/* Visibility */}
|
||||
<div className="panel-section">
|
||||
<h4>Visibility</h4>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={showBody} onChange={e => setShowBody(e.target.checked)} />
|
||||
<span className="color-dot" style={{background:'#f5d0c5'}} />
|
||||
<span>Body</span>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={showRegularBrace} onChange={e => setShowRegularBrace(e.target.checked)} disabled={!regularBraceUrl} />
|
||||
<span className="color-dot" style={{background:'#4a90d9'}} />
|
||||
<span>Regular Brace</span>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={showVaseBrace} onChange={e => setShowVaseBrace(e.target.checked)} disabled={!vaseBraceUrl} />
|
||||
<span className="color-dot" style={{background:'#50c878'}} />
|
||||
<span>Vase Brace</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Body Opacity */}
|
||||
<div className="panel-section">
|
||||
<h4>Body Opacity: {Math.round(bodyOpacity*100)}%</h4>
|
||||
<input type="range" min="0" max="100" value={bodyOpacity*100} onChange={e => setBodyOpacity(Number(e.target.value)/100)} />
|
||||
</div>
|
||||
|
||||
{/* Brace Position */}
|
||||
<div className="panel-section">
|
||||
<h4>Brace Position</h4>
|
||||
<div className="slider-compact"><span>X</span><input type="range" min="-100" max="100" value={bracePositionX} onChange={e => setBracePositionX(Number(e.target.value))} /><span>{bracePositionX}</span></div>
|
||||
<div className="slider-compact"><span>Y</span><input type="range" min="-100" max="100" value={bracePositionY} onChange={e => setBracePositionY(Number(e.target.value))} /><span>{bracePositionY}</span></div>
|
||||
<div className="slider-compact"><span>Z</span><input type="range" min="-100" max="100" value={bracePositionZ} onChange={e => setBracePositionZ(Number(e.target.value))} /><span>{bracePositionZ}</span></div>
|
||||
</div>
|
||||
|
||||
{/* Brace Rotation */}
|
||||
<div className="panel-section">
|
||||
<h4>Brace Rotation</h4>
|
||||
<div className="slider-compact"><span>X</span><input type="range" min="-180" max="180" value={braceRotationX} onChange={e => setBraceRotationX(Number(e.target.value))} /><span>{braceRotationX}°</span></div>
|
||||
<div className="slider-compact"><span>Y</span><input type="range" min="-180" max="180" value={braceRotationY} onChange={e => setBraceRotationY(Number(e.target.value))} /><span>{braceRotationY}°</span></div>
|
||||
<div className="slider-compact"><span>Z</span><input type="range" min="-180" max="180" value={braceRotationZ} onChange={e => setBraceRotationZ(Number(e.target.value))} /><span>{braceRotationZ}°</span></div>
|
||||
</div>
|
||||
|
||||
{/* Brace Scale */}
|
||||
<div className="panel-section">
|
||||
<h4>Brace Scale</h4>
|
||||
<div className="slider-compact"><span>X</span><input type="range" min="50" max="150" value={braceScaleX*100} onChange={e => setBraceScaleX(Number(e.target.value)/100)} /><span>{braceScaleX.toFixed(2)}</span></div>
|
||||
<div className="slider-compact"><span>Y</span><input type="range" min="50" max="150" value={braceScaleY*100} onChange={e => setBraceScaleY(Number(e.target.value)/100)} /><span>{braceScaleY.toFixed(2)}</span></div>
|
||||
<div className="slider-compact"><span>Z</span><input type="range" min="50" max="150" value={braceScaleZ*100} onChange={e => setBraceScaleZ(Number(e.target.value)/100)} /><span>{braceScaleZ.toFixed(2)}</span></div>
|
||||
</div>
|
||||
|
||||
{/* Body Transform */}
|
||||
<div className="panel-section">
|
||||
<h4>Body Position</h4>
|
||||
<div className="slider-compact"><span>X</span><input type="range" min="-100" max="100" value={bodyPositionX} onChange={e => setBodyPositionX(Number(e.target.value))} /><span>{bodyPositionX}</span></div>
|
||||
<div className="slider-compact"><span>Y</span><input type="range" min="-100" max="100" value={bodyPositionY} onChange={e => setBodyPositionY(Number(e.target.value))} /><span>{bodyPositionY}</span></div>
|
||||
<div className="slider-compact"><span>Z</span><input type="range" min="-100" max="100" value={bodyPositionZ} onChange={e => setBodyPositionZ(Number(e.target.value))} /><span>{bodyPositionZ}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
<h4>Body Rotation</h4>
|
||||
<div className="slider-compact"><span>X</span><input type="range" min="-180" max="180" value={bodyRotationX} onChange={e => setBodyRotationX(Number(e.target.value))} /><span>{bodyRotationX}°</span></div>
|
||||
<div className="slider-compact"><span>Y</span><input type="range" min="-180" max="180" value={bodyRotationY} onChange={e => setBodyRotationY(Number(e.target.value))} /><span>{bodyRotationY}°</span></div>
|
||||
<div className="slider-compact"><span>Z</span><input type="range" min="-180" max="180" value={bodyRotationZ} onChange={e => setBodyRotationZ(Number(e.target.value))} /><span>{bodyRotationZ}°</span></div>
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
<h4>Body Scale: {bodyScale.toFixed(2)}</h4>
|
||||
<input type="range" min="50" max="150" value={bodyScale*100} onChange={e => setBodyScale(Number(e.target.value)/100)} />
|
||||
</div>
|
||||
|
||||
<button className="btn-reset-all" onClick={handleResetTransforms}>Reset All Transforms</button>
|
||||
|
||||
{/* View Options */}
|
||||
<div className="panel-section">
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={autoRotate} onChange={e => setAutoRotate(e.target.checked)} />
|
||||
<span>Auto Rotate</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CENTER: 3D Viewer */}
|
||||
<div className="fitting-viewer-container">
|
||||
{!threeLoaded ? (
|
||||
<div className="fitting-viewer-loading"><div className="spinner"></div><p>Loading 3D viewer...</p></div>
|
||||
) : (
|
||||
<>
|
||||
<div ref={containerRef} className="fitting-viewer-canvas" />
|
||||
{loading && <div className="fitting-viewer-overlay"><div className="spinner"></div><p>Loading models...</p></div>}
|
||||
{error && <div className="fitting-viewer-overlay error"><p>{error}</p></div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL: Deformation Sliders (Stage 4 Editor) */}
|
||||
<div className="fitting-panel right-panel">
|
||||
<h3>Deformation Controls</h3>
|
||||
|
||||
{/* Brace Selector */}
|
||||
<div className="panel-section">
|
||||
<h4>Edit Brace</h4>
|
||||
<div className="brace-selector">
|
||||
<button className={`brace-select-btn ${activeBrace === 'regular' ? 'active' : ''}`} onClick={() => setActiveBrace('regular')} disabled={!regularBraceUrl}>
|
||||
Regular (Blue)
|
||||
</button>
|
||||
<button className={`brace-select-btn ${activeBrace === 'vase' ? 'active' : ''}`} onClick={() => setActiveBrace('vase')} disabled={!vaseBraceUrl}>
|
||||
Vase (Green)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The Stage 4 Editor */}
|
||||
<BraceInlineEditor
|
||||
braceType={activeBrace}
|
||||
initialParams={activeBrace === 'regular' ? regularParams : vaseParams}
|
||||
cobbAngles={braceData?.cobb_angles}
|
||||
onParamsChange={handleParamsChange}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
isModified={false}
|
||||
className="fitting-inline-editor"
|
||||
/>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="panel-section tips">
|
||||
<h4>Tips</h4>
|
||||
<ul>
|
||||
<li>Blue areas = brace pushing INTO body</li>
|
||||
<li>Increase Cobb angle for more pressure</li>
|
||||
<li>Adjust apex to move correction zone</li>
|
||||
<li>Use transforms on left to align meshes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
587
frontend/src/components/pipeline/BraceGenerationStage.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Stage 4: Brace Generation
|
||||
* Shows generated brace with 3D viewer and marker editing
|
||||
* Displays both Regular and Vase brace types side-by-side
|
||||
* Includes inline editors for real-time brace transformation
|
||||
*/
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import type { GenerateBraceResponse, DeformationZone } from '../../api/braceflowApi';
|
||||
import { getModifiedBraceUploadUrl, uploadBlobToS3 } from '../../api/braceflowApi';
|
||||
import BraceTransformViewer from '../three/BraceTransformViewer';
|
||||
import type { BraceTransformViewerRef } from '../three/BraceTransformViewer';
|
||||
import BraceInlineEditor, {
|
||||
type BraceTransformParams,
|
||||
DEFAULT_TRANSFORM_PARAMS
|
||||
} from './BraceInlineEditor';
|
||||
|
||||
type Props = {
|
||||
caseId: string;
|
||||
braceData: GenerateBraceResponse | null;
|
||||
isLoading: boolean;
|
||||
onGenerate: () => Promise<void>;
|
||||
onUpdateMarkers: (markers: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function BraceGenerationStage({
|
||||
caseId,
|
||||
braceData,
|
||||
isLoading,
|
||||
onGenerate,
|
||||
onUpdateMarkers,
|
||||
}: Props) {
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [showInlineEditors, setShowInlineEditors] = useState(true);
|
||||
const regularViewerRef = useRef<BraceTransformViewerRef>(null);
|
||||
const vaseViewerRef = useRef<BraceTransformViewerRef>(null);
|
||||
|
||||
// Transform params state for each brace type
|
||||
const [regularParams, setRegularParams] = useState<BraceTransformParams>(() => ({
|
||||
...DEFAULT_TRANSFORM_PARAMS,
|
||||
}));
|
||||
const [vaseParams, setVaseParams] = useState<BraceTransformParams>(() => ({
|
||||
...DEFAULT_TRANSFORM_PARAMS,
|
||||
}));
|
||||
|
||||
// Track if braces have been modified
|
||||
const [regularModified, setRegularModified] = useState(false);
|
||||
const [vaseModified, setVaseModified] = useState(false);
|
||||
|
||||
// Handle transform params changes
|
||||
const handleRegularParamsChange = useCallback((params: BraceTransformParams) => {
|
||||
setRegularParams(params);
|
||||
setRegularModified(true);
|
||||
}, []);
|
||||
|
||||
const handleVaseParamsChange = useCallback((params: BraceTransformParams) => {
|
||||
setVaseParams(params);
|
||||
setVaseModified(true);
|
||||
}, []);
|
||||
|
||||
// Track save status
|
||||
const [saveStatus, setSaveStatus] = useState<string | null>(null);
|
||||
|
||||
// Handle save/upload for modified braces
|
||||
const handleSaveRegular = useCallback(async (params: BraceTransformParams) => {
|
||||
if (!regularViewerRef.current) return;
|
||||
|
||||
setSaveStatus('Exporting regular brace...');
|
||||
|
||||
try {
|
||||
let savedCount = 0;
|
||||
|
||||
// Export STL
|
||||
const stlBlob = await regularViewerRef.current.exportSTL();
|
||||
if (stlBlob) {
|
||||
setSaveStatus('Uploading regular STL...');
|
||||
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'regular', 'stl');
|
||||
await uploadBlobToS3(uploadUrl, stlBlob, contentType || 'application/octet-stream');
|
||||
console.log('Regular STL uploaded to case storage');
|
||||
savedCount++;
|
||||
}
|
||||
|
||||
// Export GLB
|
||||
const glbBlob = await regularViewerRef.current.exportGLB();
|
||||
if (glbBlob) {
|
||||
setSaveStatus('Uploading regular GLB...');
|
||||
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'regular', 'glb');
|
||||
await uploadBlobToS3(uploadUrl, glbBlob, contentType || 'model/gltf-binary');
|
||||
console.log('Regular GLB uploaded to case storage');
|
||||
savedCount++;
|
||||
}
|
||||
|
||||
setSaveStatus(`Regular brace saved! (${savedCount} files)`);
|
||||
setRegularModified(false);
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
} catch (err) {
|
||||
console.error('Failed to save regular brace:', err);
|
||||
setSaveStatus(`Error: ${err instanceof Error ? err.message : 'Failed to save'}`);
|
||||
setTimeout(() => setSaveStatus(null), 5000);
|
||||
}
|
||||
}, [caseId]);
|
||||
|
||||
const handleSaveVase = useCallback(async (params: BraceTransformParams) => {
|
||||
if (!vaseViewerRef.current) return;
|
||||
|
||||
setSaveStatus('Exporting vase brace...');
|
||||
|
||||
try {
|
||||
let savedCount = 0;
|
||||
|
||||
// Export STL
|
||||
const stlBlob = await vaseViewerRef.current.exportSTL();
|
||||
if (stlBlob) {
|
||||
setSaveStatus('Uploading vase STL...');
|
||||
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'vase', 'stl');
|
||||
await uploadBlobToS3(uploadUrl, stlBlob, contentType || 'application/octet-stream');
|
||||
console.log('Vase STL uploaded to case storage');
|
||||
savedCount++;
|
||||
}
|
||||
|
||||
// Export GLB
|
||||
const glbBlob = await vaseViewerRef.current.exportGLB();
|
||||
if (glbBlob) {
|
||||
setSaveStatus('Uploading vase GLB...');
|
||||
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'vase', 'glb');
|
||||
await uploadBlobToS3(uploadUrl, glbBlob, contentType || 'model/gltf-binary');
|
||||
console.log('Vase GLB uploaded to case storage');
|
||||
savedCount++;
|
||||
}
|
||||
|
||||
setSaveStatus(`Vase brace saved! (${savedCount} files)`);
|
||||
setVaseModified(false);
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
} catch (err) {
|
||||
console.error('Failed to save vase brace:', err);
|
||||
setSaveStatus(`Error: ${err instanceof Error ? err.message : 'Failed to save'}`);
|
||||
setTimeout(() => setSaveStatus(null), 5000);
|
||||
}
|
||||
}, [caseId]);
|
||||
|
||||
// Handle reset
|
||||
const handleResetRegular = useCallback(() => {
|
||||
setRegularParams({
|
||||
...DEFAULT_TRANSFORM_PARAMS,
|
||||
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
|
||||
});
|
||||
setRegularModified(false);
|
||||
}, [braceData?.cobb_angles]);
|
||||
|
||||
const handleResetVase = useCallback(() => {
|
||||
setVaseParams({
|
||||
...DEFAULT_TRANSFORM_PARAMS,
|
||||
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
|
||||
});
|
||||
setVaseModified(false);
|
||||
}, [braceData?.cobb_angles]);
|
||||
|
||||
// Get output URLs for regular brace
|
||||
const outputs = braceData?.outputs || {};
|
||||
const stlUrl = outputs.stl?.url || (outputs as any).stl;
|
||||
const glbUrl = outputs.glb?.url || (outputs as any).glb;
|
||||
const vizUrl = outputs.visualization?.url || (outputs as any).visualization;
|
||||
const jsonUrl = outputs.landmarks?.url || (outputs as any).landmarks;
|
||||
|
||||
// Get output URLs for vase brace (if available)
|
||||
const braces = (braceData as any)?.braces || {};
|
||||
const regularBrace = braces.regular || { outputs: { stl: stlUrl, glb: glbUrl } };
|
||||
const vaseBrace = braces.vase || {};
|
||||
|
||||
// Helper function to format file size
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
if (!bytes) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
if (!braceData && !isLoading) {
|
||||
return (
|
||||
<div className="pipeline-stage brace-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 4: Brace Generation</h2>
|
||||
<div className="stage-status">
|
||||
<span className="status-badge status-pending">Pending</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stage-content">
|
||||
<div className="brace-empty">
|
||||
<p>Ready to generate custom brace based on approved landmarks and analysis.</p>
|
||||
<button className="btn primary btn-large" onClick={onGenerate}>
|
||||
Generate Brace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pipeline-stage brace-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 4: Brace Generation</h2>
|
||||
<div className="stage-status">
|
||||
<span className="status-badge status-processing">Generating...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stage-content">
|
||||
<div className="brace-loading">
|
||||
<div className="spinner large"></div>
|
||||
<p>Generating custom braces...</p>
|
||||
<p className="loading-hint">
|
||||
Generating both Regular and Vase brace designs for comparison.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pipeline-stage brace-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 4: Brace Generation</h2>
|
||||
<div className="stage-status">
|
||||
<span className="status-badge status-complete">Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Panels - Right under header */}
|
||||
<div className="brace-summary-row">
|
||||
{/* Generation Summary */}
|
||||
<div className="brace-panel summary-panel">
|
||||
<h3>Generation Summary</h3>
|
||||
<div className="summary-grid horizontal">
|
||||
{braceData?.rigo_classification && (
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">Rigo Type</span>
|
||||
<span className="summary-value badge">
|
||||
{braceData.rigo_classification.type}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{braceData?.curve_type && (
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">Curve</span>
|
||||
<span className="summary-value">{braceData.curve_type}-Curve</span>
|
||||
</div>
|
||||
)}
|
||||
{braceData?.processing_time_ms && (
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">Time</span>
|
||||
<span className="summary-value">
|
||||
{(braceData.processing_time_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cobb Angles Used */}
|
||||
{braceData?.cobb_angles && (
|
||||
<div className="brace-panel cobb-panel">
|
||||
<h3>Cobb Angles Used</h3>
|
||||
<div className="cobb-mini-grid horizontal">
|
||||
<div className="cobb-mini">
|
||||
<span className="cobb-label">PT</span>
|
||||
<span className="cobb-value">{braceData.cobb_angles.PT?.toFixed(1)}°</span>
|
||||
</div>
|
||||
<div className="cobb-mini">
|
||||
<span className="cobb-label">MT</span>
|
||||
<span className="cobb-value">{braceData.cobb_angles.MT?.toFixed(1)}°</span>
|
||||
</div>
|
||||
<div className="cobb-mini">
|
||||
<span className="cobb-label">TL</span>
|
||||
<span className="cobb-value">{braceData.cobb_angles.TL?.toFixed(1)}°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle Editor Button + Save Status */}
|
||||
<div className="editor-toggle-row">
|
||||
{saveStatus && (
|
||||
<span className={`save-status ${saveStatus.includes('Error') ? 'error' : saveStatus.includes('saved') ? 'success' : ''}`}>
|
||||
{saveStatus}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className={`btn-toggle-editor ${showInlineEditors ? 'active' : ''}`}
|
||||
onClick={() => setShowInlineEditors(!showInlineEditors)}
|
||||
>
|
||||
{showInlineEditors ? 'Hide Editors' : 'Show Editors'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dual Brace Viewers with Inline Editors */}
|
||||
<div className={`dual-brace-viewers ${showInlineEditors ? 'with-editors' : ''}`}>
|
||||
{/* Regular Brace Viewer + Editor */}
|
||||
<div className="brace-viewer-with-editor">
|
||||
<div className="brace-viewer-container">
|
||||
<div className="viewer-header">
|
||||
<h3>Regular Brace</h3>
|
||||
<span className="viewer-subtitle">Fitted design for precise correction</span>
|
||||
{regularModified && <span className="modified-indicator">Modified</span>}
|
||||
</div>
|
||||
<div className="brace-viewer brace-viewer-3d">
|
||||
<BraceTransformViewer
|
||||
ref={regularViewerRef}
|
||||
stlUrl={regularBrace.outputs?.stl || stlUrl}
|
||||
glbUrl={regularBrace.outputs?.glb || glbUrl}
|
||||
transformParams={regularParams}
|
||||
autoRotate={!showInlineEditors}
|
||||
rotationSpeed={0.005}
|
||||
showMarkers={true}
|
||||
showGrid={showInlineEditors}
|
||||
/>
|
||||
</div>
|
||||
{regularBrace.meshStats && (
|
||||
<div className="viewer-stats">
|
||||
<span>{regularBrace.meshStats.vertices?.toLocaleString()} vertices</span>
|
||||
<span>{regularBrace.meshStats.faces?.toLocaleString()} faces</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline Editor for Regular Brace */}
|
||||
{showInlineEditors && (
|
||||
<BraceInlineEditor
|
||||
braceType="regular"
|
||||
initialParams={regularParams}
|
||||
cobbAngles={braceData?.cobb_angles}
|
||||
onParamsChange={handleRegularParamsChange}
|
||||
onSave={handleSaveRegular}
|
||||
onReset={handleResetRegular}
|
||||
isModified={regularModified}
|
||||
className="viewer-inline-editor"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vase Brace Viewer + Editor */}
|
||||
<div className="brace-viewer-with-editor">
|
||||
<div className="brace-viewer-container">
|
||||
<div className="viewer-header">
|
||||
<h3>Vase Brace</h3>
|
||||
<span className="viewer-subtitle">Smooth contoured design</span>
|
||||
{vaseModified && <span className="modified-indicator">Modified</span>}
|
||||
</div>
|
||||
<div className="brace-viewer brace-viewer-3d">
|
||||
{vaseBrace.outputs?.stl || vaseBrace.outputs?.glb ? (
|
||||
<BraceTransformViewer
|
||||
ref={vaseViewerRef}
|
||||
stlUrl={vaseBrace.outputs?.stl}
|
||||
glbUrl={vaseBrace.outputs?.glb}
|
||||
transformParams={vaseParams}
|
||||
autoRotate={!showInlineEditors}
|
||||
rotationSpeed={0.005}
|
||||
showMarkers={true}
|
||||
showGrid={showInlineEditors}
|
||||
/>
|
||||
) : (
|
||||
<div className="viewer-placeholder">
|
||||
<div className="placeholder-icon">🏺</div>
|
||||
<p>Vase brace not generated</p>
|
||||
<p className="hint">Click "Generate Both" to create vase design</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{vaseBrace.meshStats && (
|
||||
<div className="viewer-stats">
|
||||
<span>{vaseBrace.meshStats.vertices?.toLocaleString()} vertices</span>
|
||||
<span>{vaseBrace.meshStats.faces?.toLocaleString()} faces</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline Editor for Vase Brace */}
|
||||
{showInlineEditors && (vaseBrace.outputs?.stl || vaseBrace.outputs?.glb) && (
|
||||
<BraceInlineEditor
|
||||
braceType="vase"
|
||||
initialParams={vaseParams}
|
||||
cobbAngles={braceData?.cobb_angles}
|
||||
onParamsChange={handleVaseParamsChange}
|
||||
onSave={handleSaveVase}
|
||||
onReset={handleResetVase}
|
||||
isModified={vaseModified}
|
||||
className="viewer-inline-editor"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brace Pressure Zones - Full Width Section */}
|
||||
{braceData?.deformation_report?.zones && braceData.deformation_report.zones.length > 0 && (
|
||||
<div className="pressure-zones-section">
|
||||
<h3>Brace Pressure Zones</h3>
|
||||
<p className="zones-desc">
|
||||
Based on the Cobb angles and Rigo classification, the following pressure modifications were applied to the brace:
|
||||
</p>
|
||||
<div className="pressure-zones-grid">
|
||||
{braceData.deformation_report.zones.map((zone: DeformationZone, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`zone-card ${zone.deform_mm < 0 ? 'zone-pressure' : 'zone-relief'}`}
|
||||
>
|
||||
<div className="zone-header">
|
||||
<span className="zone-name">{zone.zone}</span>
|
||||
<span className={`zone-value ${zone.deform_mm < 0 ? 'pressure' : 'relief'}`}>
|
||||
{zone.deform_mm > 0 ? '+' : ''}{zone.deform_mm.toFixed(1)} mm
|
||||
</span>
|
||||
</div>
|
||||
<span className="zone-reason">{zone.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{braceData.deformation_report.patch_grid && (
|
||||
<p className="patch-grid-info">
|
||||
Patch Grid: {braceData.deformation_report.patch_grid}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Files - Bottom Section */}
|
||||
<div className="downloads-section">
|
||||
<h3>Download Files</h3>
|
||||
<div className="downloads-columns">
|
||||
{/* Regular Brace Downloads */}
|
||||
<div className="download-column">
|
||||
<h4>Regular Brace</h4>
|
||||
<div className="downloads-list">
|
||||
{(regularBrace.outputs?.stl || stlUrl) && (
|
||||
<a
|
||||
href={regularBrace.outputs?.stl || stlUrl}
|
||||
className="download-item"
|
||||
download={`brace_${caseId}_regular.stl`}
|
||||
>
|
||||
<span className="download-icon">📦</span>
|
||||
<span className="download-info">
|
||||
<span className="download-name">regular.stl</span>
|
||||
<span className="download-desc">For 3D printing</span>
|
||||
</span>
|
||||
<span className="download-action">↓</span>
|
||||
</a>
|
||||
)}
|
||||
{(regularBrace.outputs?.glb || glbUrl) && (
|
||||
<a
|
||||
href={regularBrace.outputs?.glb || glbUrl}
|
||||
className="download-item"
|
||||
download={`brace_${caseId}_regular.glb`}
|
||||
>
|
||||
<span className="download-icon">🎮</span>
|
||||
<span className="download-info">
|
||||
<span className="download-name">regular.glb</span>
|
||||
<span className="download-desc">For web/AR</span>
|
||||
</span>
|
||||
<span className="download-action">↓</span>
|
||||
</a>
|
||||
)}
|
||||
{(regularBrace.outputs?.json || jsonUrl) && (
|
||||
<a
|
||||
href={regularBrace.outputs?.json || jsonUrl}
|
||||
className="download-item"
|
||||
download={`brace_${caseId}_regular_markers.json`}
|
||||
>
|
||||
<span className="download-icon">📄</span>
|
||||
<span className="download-info">
|
||||
<span className="download-name">markers.json</span>
|
||||
<span className="download-desc">With markers</span>
|
||||
</span>
|
||||
<span className="download-action">↓</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vase Brace Downloads */}
|
||||
<div className="download-column">
|
||||
<h4>Vase Brace</h4>
|
||||
<div className="downloads-list">
|
||||
{vaseBrace.outputs?.stl ? (
|
||||
<>
|
||||
<a
|
||||
href={vaseBrace.outputs.stl}
|
||||
className="download-item"
|
||||
download={`brace_${caseId}_vase.stl`}
|
||||
>
|
||||
<span className="download-icon">📦</span>
|
||||
<span className="download-info">
|
||||
<span className="download-name">vase.stl</span>
|
||||
<span className="download-desc">For 3D printing</span>
|
||||
</span>
|
||||
<span className="download-action">↓</span>
|
||||
</a>
|
||||
{vaseBrace.outputs?.glb && (
|
||||
<a
|
||||
href={vaseBrace.outputs.glb}
|
||||
className="download-item"
|
||||
download={`brace_${caseId}_vase.glb`}
|
||||
>
|
||||
<span className="download-icon">🎮</span>
|
||||
<span className="download-info">
|
||||
<span className="download-name">vase.glb</span>
|
||||
<span className="download-desc">For web/AR</span>
|
||||
</span>
|
||||
<span className="download-action">↓</span>
|
||||
</a>
|
||||
)}
|
||||
{vaseBrace.outputs?.json && (
|
||||
<a
|
||||
href={vaseBrace.outputs.json}
|
||||
className="download-item"
|
||||
download={`brace_${caseId}_vase_markers.json`}
|
||||
>
|
||||
<span className="download-icon">📄</span>
|
||||
<span className="download-info">
|
||||
<span className="download-name">markers.json</span>
|
||||
<span className="download-desc">With markers</span>
|
||||
</span>
|
||||
<span className="download-action">↓</span>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="download-placeholder">
|
||||
<span>Not generated</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="stage-actions">
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={() => setShowEditor(!showEditor)}
|
||||
>
|
||||
{showEditor ? 'Hide Editor' : 'Edit Brace Markers'}
|
||||
</button>
|
||||
<button className="btn primary" onClick={onGenerate}>
|
||||
Regenerate Brace
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Marker Editor Modal (placeholder) */}
|
||||
{showEditor && (
|
||||
<div className="marker-editor-overlay">
|
||||
<div className="marker-editor-modal">
|
||||
<div className="modal-header">
|
||||
<h3>Brace Marker Editor</h3>
|
||||
<button className="close-btn" onClick={() => setShowEditor(false)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-content">
|
||||
<p>
|
||||
The 3D marker editor integration is coming soon. This will allow you to:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Drag and reposition pressure pad markers</li>
|
||||
<li>Adjust deformation depths</li>
|
||||
<li>Preview changes in real-time</li>
|
||||
<li>Regenerate the brace with modified parameters</li>
|
||||
</ul>
|
||||
<p className="hint">
|
||||
For now, you can download the JSON file to manually edit marker positions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn secondary" onClick={() => setShowEditor(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
frontend/src/components/pipeline/BraceInlineEditor.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* BraceInlineEditor - Inline slider controls for brace transformation
|
||||
* Based on EXPERIMENT_6's brace-transform-playground-v2
|
||||
*
|
||||
* Provides real-time deformation controls that can be embedded
|
||||
* directly within the BraceGenerationStage component.
|
||||
*/
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
export type BraceTransformParams = {
|
||||
// Analysis parameters
|
||||
cobbDeg: number;
|
||||
apexNorm: number; // 0..1 along brace height
|
||||
lumbarApexNorm: number; // 0..1
|
||||
rotationScore: number; // 0..3
|
||||
trunkShiftMm: number;
|
||||
|
||||
// Calibration
|
||||
expectedBraceHeightMm: number;
|
||||
strengthMult: number;
|
||||
|
||||
// Direction strategy
|
||||
pushMode: 'normal' | 'radial' | 'lateral';
|
||||
|
||||
// Pelvis/anchors
|
||||
hipAnchorStrengthMm: number;
|
||||
|
||||
// Debug
|
||||
mirrorX: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_TRANSFORM_PARAMS: BraceTransformParams = {
|
||||
cobbDeg: 25,
|
||||
apexNorm: 0.62,
|
||||
lumbarApexNorm: 0.40,
|
||||
rotationScore: 0,
|
||||
trunkShiftMm: 0,
|
||||
expectedBraceHeightMm: 400,
|
||||
strengthMult: 1.0,
|
||||
pushMode: 'radial',
|
||||
hipAnchorStrengthMm: 4,
|
||||
mirrorX: false,
|
||||
};
|
||||
|
||||
type SliderConfig = {
|
||||
key: keyof BraceTransformParams;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit?: string;
|
||||
};
|
||||
|
||||
const SLIDER_CONFIGS: SliderConfig[] = [
|
||||
{ key: 'cobbDeg', label: 'Cobb Angle', min: 0, max: 80, step: 1, unit: '°' },
|
||||
{ key: 'apexNorm', label: 'Thoracic Apex Height', min: 0, max: 1, step: 0.01 },
|
||||
{ key: 'lumbarApexNorm', label: 'Lumbar Apex Height', min: 0, max: 1, step: 0.01 },
|
||||
{ key: 'rotationScore', label: 'Rotation', min: 0, max: 3, step: 0.1 },
|
||||
{ key: 'trunkShiftMm', label: 'Trunk Shift', min: -40, max: 40, step: 1, unit: 'mm' },
|
||||
{ key: 'strengthMult', label: 'Strength', min: 0.2, max: 2.0, step: 0.05, unit: 'x' },
|
||||
{ key: 'hipAnchorStrengthMm', label: 'Hip Anchor', min: 0, max: 12, step: 1, unit: 'mm' },
|
||||
];
|
||||
|
||||
const ADVANCED_SLIDER_CONFIGS: SliderConfig[] = [
|
||||
{ key: 'expectedBraceHeightMm', label: 'Expected Height', min: 250, max: 650, step: 10, unit: 'mm' },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
braceType: 'regular' | 'vase';
|
||||
initialParams?: Partial<BraceTransformParams>;
|
||||
cobbAngles?: { PT?: number; MT?: number; TL?: number };
|
||||
onParamsChange: (params: BraceTransformParams) => void;
|
||||
onSave: (params: BraceTransformParams) => void;
|
||||
onReset: () => void;
|
||||
isModified?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function BraceInlineEditor({
|
||||
braceType,
|
||||
initialParams,
|
||||
cobbAngles,
|
||||
onParamsChange,
|
||||
onSave,
|
||||
onReset,
|
||||
isModified = false,
|
||||
className = '',
|
||||
}: Props) {
|
||||
const [params, setParams] = useState<BraceTransformParams>(() => ({
|
||||
...DEFAULT_TRANSFORM_PARAMS,
|
||||
...initialParams,
|
||||
// Auto-set Cobb angle from analysis if available
|
||||
cobbDeg: cobbAngles?.MT || cobbAngles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
|
||||
}));
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Update params when initial params change
|
||||
useEffect(() => {
|
||||
if (initialParams) {
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
...initialParams,
|
||||
}));
|
||||
}
|
||||
}, [initialParams]);
|
||||
|
||||
// Notify parent of changes
|
||||
const handleParamChange = useCallback((key: keyof BraceTransformParams, value: number | string | boolean) => {
|
||||
setParams(prev => {
|
||||
const updated = { ...prev, [key]: value };
|
||||
onParamsChange(updated);
|
||||
return updated;
|
||||
});
|
||||
}, [onParamsChange]);
|
||||
|
||||
// Handle save
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(params);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [params, onSave]);
|
||||
|
||||
// Handle reset
|
||||
const handleReset = useCallback(() => {
|
||||
const resetParams = {
|
||||
...DEFAULT_TRANSFORM_PARAMS,
|
||||
...initialParams,
|
||||
cobbDeg: cobbAngles?.MT || cobbAngles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
|
||||
};
|
||||
setParams(resetParams);
|
||||
onReset();
|
||||
}, [initialParams, cobbAngles, onReset]);
|
||||
|
||||
const formatValue = (value: number, step: number, unit?: string): string => {
|
||||
const formatted = step < 1 ? value.toFixed(2) : value.toString();
|
||||
return unit ? `${formatted}${unit}` : formatted;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`brace-inline-editor ${className}`}>
|
||||
<div className="editor-header">
|
||||
<h4>{braceType === 'regular' ? 'Regular' : 'Vase'} Brace Editor</h4>
|
||||
{isModified && <span className="modified-badge">Modified</span>}
|
||||
</div>
|
||||
|
||||
<div className="editor-sliders">
|
||||
{SLIDER_CONFIGS.map(config => (
|
||||
<div key={config.key} className="slider-row">
|
||||
<div className="slider-label">
|
||||
<span>{config.label}</span>
|
||||
<span className="slider-value">
|
||||
{formatValue(params[config.key] as number, config.step, config.unit)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={config.min}
|
||||
max={config.max}
|
||||
step={config.step}
|
||||
value={params[config.key] as number}
|
||||
onChange={(e) => handleParamChange(config.key, Number(e.target.value))}
|
||||
className="slider-input"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Push Mode Toggle */}
|
||||
<div className="editor-mode-toggle">
|
||||
<span className="toggle-label">Push Mode:</span>
|
||||
<div className="toggle-buttons">
|
||||
{(['normal', 'radial', 'lateral'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`toggle-btn ${params.pushMode === mode ? 'active' : ''}`}
|
||||
onClick={() => handleParamChange('pushMode', mode)}
|
||||
>
|
||||
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mirror Toggle */}
|
||||
<div className="editor-checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.mirrorX}
|
||||
onChange={(e) => handleParamChange('mirrorX', e.target.checked)}
|
||||
/>
|
||||
<span>Mirror X (flip left/right)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Advanced Toggle */}
|
||||
<button
|
||||
className="advanced-toggle"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{showAdvanced ? '▼ Hide Advanced' : '▶ Show Advanced'}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="editor-sliders advanced">
|
||||
{ADVANCED_SLIDER_CONFIGS.map(config => (
|
||||
<div key={config.key} className="slider-row">
|
||||
<div className="slider-label">
|
||||
<span>{config.label}</span>
|
||||
<span className="slider-value">
|
||||
{formatValue(params[config.key] as number, config.step, config.unit)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={config.min}
|
||||
max={config.max}
|
||||
step={config.step}
|
||||
value={params[config.key] as number}
|
||||
onChange={(e) => handleParamChange(config.key, Number(e.target.value))}
|
||||
className="slider-input"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="editor-actions">
|
||||
<button
|
||||
className="btn-editor reset"
|
||||
onClick={handleReset}
|
||||
title="Reset to default values"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
className="btn-editor save"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
title="Save modified brace to case storage"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
703
frontend/src/components/pipeline/LandmarkDetectionStage.tsx
Normal file
@@ -0,0 +1,703 @@
|
||||
/**
|
||||
* Stage 1: Landmark Detection
|
||||
* Interactive canvas - always editable, draw landmarks from JSON
|
||||
* Supports green quadrilateral boxes around vertebrae with corner editing
|
||||
*/
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import type { LandmarksResult, VertebraeStructure, VertebraData } from '../../api/braceflowApi';
|
||||
|
||||
type Props = {
|
||||
caseId: string;
|
||||
landmarksData: LandmarksResult | null;
|
||||
xrayUrl: string | null;
|
||||
visualizationUrl: string | null;
|
||||
isLoading: boolean;
|
||||
onDetect: () => Promise<void>;
|
||||
onApprove: (updatedLandmarks?: VertebraeStructure) => Promise<void>;
|
||||
onUpdateLandmarks: (landmarks: VertebraeStructure) => Promise<void>;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
type: 'centroid' | 'corner';
|
||||
level: string;
|
||||
cornerIdx?: number; // 0-3 for corner drag
|
||||
startX: number;
|
||||
startY: number;
|
||||
originalCorners: [number, number][] | null;
|
||||
originalCentroid: [number, number];
|
||||
};
|
||||
|
||||
type CornerHit = {
|
||||
level: string;
|
||||
cornerIdx: number;
|
||||
};
|
||||
|
||||
export default function LandmarkDetectionStage({
|
||||
caseId,
|
||||
landmarksData,
|
||||
xrayUrl,
|
||||
visualizationUrl,
|
||||
isLoading,
|
||||
onDetect,
|
||||
onApprove,
|
||||
onUpdateLandmarks,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [structure, setStructure] = useState<VertebraeStructure | null>(null);
|
||||
const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
|
||||
const [hoveredLevel, setHoveredLevel] = useState<string | null>(null);
|
||||
const [hoveredCorner, setHoveredCorner] = useState<CornerHit | null>(null);
|
||||
const [dragState, setDragState] = useState<DragState | null>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
|
||||
// Initialize structure from landmarks data
|
||||
useEffect(() => {
|
||||
if (landmarksData?.vertebrae_structure) {
|
||||
setStructure(JSON.parse(JSON.stringify(landmarksData.vertebrae_structure)));
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [landmarksData]);
|
||||
|
||||
// Load X-ray image
|
||||
useEffect(() => {
|
||||
if (!xrayUrl) {
|
||||
setImage(null);
|
||||
setImageError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setImageError(null);
|
||||
console.log('Loading X-ray from URL:', xrayUrl);
|
||||
const img = new Image();
|
||||
// Note: crossOrigin removed for local dev - add back for production with proper CORS
|
||||
// img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = () => {
|
||||
console.log('X-ray loaded successfully:', img.naturalWidth, 'x', img.naturalHeight);
|
||||
setImage(img);
|
||||
setImageError(null);
|
||||
// Calculate scale to fit container
|
||||
if (containerRef.current) {
|
||||
const maxWidth = containerRef.current.clientWidth - 40;
|
||||
const maxHeight = containerRef.current.clientHeight - 40;
|
||||
const scaleX = maxWidth / img.naturalWidth;
|
||||
const scaleY = maxHeight / img.naturalHeight;
|
||||
setScale(Math.min(scaleX, scaleY, 1));
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = (e) => {
|
||||
setImage(null);
|
||||
setImageError('Failed to load X-ray image. Please try refreshing the page.');
|
||||
console.error('Failed to load X-ray image:', xrayUrl);
|
||||
console.error('Error event:', e);
|
||||
};
|
||||
|
||||
img.src = xrayUrl;
|
||||
}, [xrayUrl]);
|
||||
|
||||
// Get vertebra by level
|
||||
const getVertebra = useCallback((level: string): VertebraData | undefined => {
|
||||
return structure?.vertebrae.find(v => v.level === level);
|
||||
}, [structure]);
|
||||
|
||||
// Calculate centroid from corners
|
||||
const calculateCentroid = (corners: [number, number][]): [number, number] => {
|
||||
const sumX = corners.reduce((acc, c) => acc + c[0], 0);
|
||||
const sumY = corners.reduce((acc, c) => acc + c[1], 0);
|
||||
return [sumX / corners.length, sumY / corners.length];
|
||||
};
|
||||
|
||||
// Update vertebra with new corners and recalculate centroid
|
||||
const updateVertebraCorners = useCallback((level: string, newCorners: [number, number][]) => {
|
||||
const newCentroid = calculateCentroid(newCorners);
|
||||
|
||||
setStructure(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
vertebrae: prev.vertebrae.map(v => {
|
||||
if (v.level !== level) return v;
|
||||
return {
|
||||
...v,
|
||||
manual_override: {
|
||||
...v.manual_override,
|
||||
enabled: true,
|
||||
centroid_px: newCentroid,
|
||||
corners_px: newCorners,
|
||||
},
|
||||
final_values: {
|
||||
...v.final_values,
|
||||
centroid_px: newCentroid,
|
||||
corners_px: newCorners,
|
||||
source: 'manual' as const,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
setHasChanges(true);
|
||||
}, []);
|
||||
|
||||
// Update vertebra position (move centroid and all corners together)
|
||||
const updateVertebraPosition = useCallback((level: string, newCentroid: [number, number], originalCorners: [number, number][] | null, originalCentroid: [number, number]) => {
|
||||
// Calculate delta from original centroid
|
||||
const dx = newCentroid[0] - originalCentroid[0];
|
||||
const dy = newCentroid[1] - originalCentroid[1];
|
||||
|
||||
// Move all corners by same delta
|
||||
let newCorners: [number, number][] | null = null;
|
||||
if (originalCorners) {
|
||||
newCorners = originalCorners.map(c => [c[0] + dx, c[1] + dy] as [number, number]);
|
||||
}
|
||||
|
||||
setStructure(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
vertebrae: prev.vertebrae.map(v => {
|
||||
if (v.level !== level) return v;
|
||||
return {
|
||||
...v,
|
||||
manual_override: {
|
||||
...v.manual_override,
|
||||
enabled: true,
|
||||
centroid_px: newCentroid,
|
||||
corners_px: newCorners,
|
||||
},
|
||||
final_values: {
|
||||
...v.final_values,
|
||||
centroid_px: newCentroid,
|
||||
corners_px: newCorners,
|
||||
source: 'manual' as const,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
setHasChanges(true);
|
||||
}, []);
|
||||
|
||||
// Reset vertebra to original
|
||||
const resetVertebra = useCallback((level: string) => {
|
||||
const original = landmarksData?.vertebrae_structure.vertebrae.find(v => v.level === level);
|
||||
if (!original) return;
|
||||
|
||||
setStructure(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
vertebrae: prev.vertebrae.map(v => {
|
||||
if (v.level !== level) return v;
|
||||
return {
|
||||
...v,
|
||||
manual_override: {
|
||||
enabled: false,
|
||||
centroid_px: null,
|
||||
corners_px: null,
|
||||
orientation_deg: null,
|
||||
confidence: null,
|
||||
notes: null,
|
||||
},
|
||||
final_values: {
|
||||
...original.scoliovis_data,
|
||||
source: original.detected ? 'scoliovis' as const : 'undetected' as const,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
setHasChanges(true);
|
||||
}, [landmarksData]);
|
||||
|
||||
// Draw canvas with green boxes and red centroids
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx || !image) return;
|
||||
|
||||
// Set canvas size
|
||||
canvas.width = image.naturalWidth * scale;
|
||||
canvas.height = image.naturalHeight * scale;
|
||||
|
||||
// Clear and draw image
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(scale, scale);
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
// Draw vertebrae if we have structure
|
||||
if (structure) {
|
||||
structure.vertebrae.forEach(v => {
|
||||
const centroid = v.final_values?.centroid_px;
|
||||
if (!centroid) return;
|
||||
|
||||
const [x, y] = centroid;
|
||||
const isSelected = selectedLevel === v.level;
|
||||
const isHovered = hoveredLevel === v.level;
|
||||
const isManual = v.manual_override?.enabled;
|
||||
|
||||
// Draw green X-shape vertebra marker if corners exist
|
||||
// Corner order: [top_left, top_right, bottom_left, bottom_right]
|
||||
// Drawing 0→1→2→3→0 creates the X pattern that shows endplate orientations
|
||||
const corners = v.final_values?.corners_px;
|
||||
if (corners && corners.length === 4) {
|
||||
// Set line style based on state
|
||||
if (isSelected) {
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 2;
|
||||
} else if (isManual) {
|
||||
ctx.strokeStyle = '#00cc00';
|
||||
ctx.lineWidth = 1.5;
|
||||
} else {
|
||||
ctx.strokeStyle = '#22aa22';
|
||||
ctx.lineWidth = 1;
|
||||
}
|
||||
|
||||
// Draw the X-shape: 0→1, 1→2, 2→3, 3→0
|
||||
// This creates: top edge, diagonal, bottom edge, diagonal (X pattern)
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const j = (i + 1) % 4;
|
||||
ctx.moveTo(corners[i][0], corners[i][1]);
|
||||
ctx.lineTo(corners[j][0], corners[j][1]);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Draw corner handles for selected vertebra
|
||||
if (isSelected) {
|
||||
corners.forEach((corner, idx) => {
|
||||
const isCornerHovered = hoveredCorner?.level === v.level && hoveredCorner?.cornerIdx === idx;
|
||||
ctx.beginPath();
|
||||
ctx.arc(corner[0], corner[1], isCornerHovered ? 6 : 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isCornerHovered ? '#ffff00' : '#00ff00';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Determine centroid color
|
||||
let fillColor = '#ff3333'; // Detected (red)
|
||||
if (isManual) fillColor = '#ff6600'; // Manual (orange-red to distinguish)
|
||||
else if (!v.detected) fillColor = '#888888'; // Undetected (gray)
|
||||
|
||||
// Draw centroid circle
|
||||
const radius = isSelected || isHovered ? 8 : 5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
|
||||
// Highlight ring for centroid
|
||||
if (isSelected) {
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
} else if (isHovered) {
|
||||
ctx.strokeStyle = '#ffff00';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw label
|
||||
ctx.font = 'bold 11px Arial';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeText(v.level, x + 12, y + 4);
|
||||
ctx.fillText(v.level, x + 12, y + 4);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}, [image, scale, structure, selectedLevel, hoveredLevel, hoveredCorner]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
// Convert screen coords to image coords
|
||||
const screenToImage = useCallback((clientX: number, clientY: number): [number, number] => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return [0, 0];
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (clientX - rect.left) / scale;
|
||||
const y = (clientY - rect.top) / scale;
|
||||
return [x, y];
|
||||
}, [scale]);
|
||||
|
||||
// Find corner point at position
|
||||
const findCornerAt = useCallback((x: number, y: number): CornerHit | null => {
|
||||
if (!structure) return null;
|
||||
const threshold = 15 / scale;
|
||||
|
||||
for (const v of structure.vertebrae) {
|
||||
const corners = v.final_values?.corners_px;
|
||||
if (!corners) continue;
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const dist = Math.sqrt((x - corners[i][0]) ** 2 + (y - corners[i][1]) ** 2);
|
||||
if (dist < threshold) {
|
||||
return { level: v.level, cornerIdx: i };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [structure, scale]);
|
||||
|
||||
// Find vertebra centroid at position
|
||||
const findVertebraAt = useCallback((x: number, y: number): string | null => {
|
||||
if (!structure) return null;
|
||||
const threshold = 20 / scale;
|
||||
let closest: string | null = null;
|
||||
let minDist = threshold;
|
||||
|
||||
structure.vertebrae.forEach(v => {
|
||||
const centroid = v.final_values?.centroid_px;
|
||||
if (!centroid) return;
|
||||
const dist = Math.sqrt((x - centroid[0]) ** 2 + (y - centroid[1]) ** 2);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closest = v.level;
|
||||
}
|
||||
});
|
||||
|
||||
return closest;
|
||||
}, [structure, scale]);
|
||||
|
||||
// Mouse handlers
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!structure) return;
|
||||
const [x, y] = screenToImage(e.clientX, e.clientY);
|
||||
|
||||
// Check for corner hit first (only on selected vertebra for precision)
|
||||
if (selectedLevel) {
|
||||
const cornerHit = findCornerAt(x, y);
|
||||
if (cornerHit && cornerHit.level === selectedLevel) {
|
||||
const v = getVertebra(cornerHit.level);
|
||||
if (v?.final_values?.corners_px && v?.final_values?.centroid_px) {
|
||||
setDragState({
|
||||
type: 'corner',
|
||||
level: cornerHit.level,
|
||||
cornerIdx: cornerHit.cornerIdx,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
originalCorners: v.final_values.corners_px.map(c => [...c] as [number, number]),
|
||||
originalCentroid: [...v.final_values.centroid_px] as [number, number],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for centroid hit
|
||||
const level = findVertebraAt(x, y);
|
||||
if (level) {
|
||||
setSelectedLevel(level);
|
||||
const v = getVertebra(level);
|
||||
if (v?.final_values?.centroid_px) {
|
||||
setDragState({
|
||||
type: 'centroid',
|
||||
level,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
originalCorners: v.final_values.corners_px
|
||||
? v.final_values.corners_px.map(c => [...c] as [number, number])
|
||||
: null,
|
||||
originalCentroid: [...v.final_values.centroid_px] as [number, number],
|
||||
});
|
||||
}
|
||||
} else if (selectedLevel) {
|
||||
// Place selected (missing) vertebra at click position
|
||||
const v = getVertebra(selectedLevel);
|
||||
if (v && (!v.final_values?.centroid_px || !v.detected)) {
|
||||
// Create default corners around click position
|
||||
const defaultHalfWidth = 15;
|
||||
const defaultHalfHeight = 12;
|
||||
const defaultCorners: [number, number][] = [
|
||||
[x - defaultHalfWidth, y - defaultHalfHeight], // top_left
|
||||
[x + defaultHalfWidth, y - defaultHalfHeight], // top_right
|
||||
[x - defaultHalfWidth, y + defaultHalfHeight], // bottom_left
|
||||
[x + defaultHalfWidth, y + defaultHalfHeight], // bottom_right
|
||||
];
|
||||
updateVertebraCorners(selectedLevel, defaultCorners);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
const [x, y] = screenToImage(e.clientX, e.clientY);
|
||||
|
||||
if (dragState) {
|
||||
if (dragState.type === 'corner' && dragState.cornerIdx !== undefined && dragState.originalCorners) {
|
||||
// Dragging a corner
|
||||
const dx = (e.clientX - dragState.startX) / scale;
|
||||
const dy = (e.clientY - dragState.startY) / scale;
|
||||
|
||||
// Update just the dragged corner
|
||||
const newCorners = dragState.originalCorners.map((c, i) => {
|
||||
if (i === dragState.cornerIdx) {
|
||||
return [c[0] + dx, c[1] + dy] as [number, number];
|
||||
}
|
||||
return [...c] as [number, number];
|
||||
});
|
||||
|
||||
updateVertebraCorners(dragState.level, newCorners);
|
||||
} else if (dragState.type === 'centroid') {
|
||||
// Dragging the centroid (moves entire vertebra)
|
||||
const dx = (e.clientX - dragState.startX) / scale;
|
||||
const dy = (e.clientY - dragState.startY) / scale;
|
||||
const newCentroid: [number, number] = [
|
||||
dragState.originalCentroid[0] + dx,
|
||||
dragState.originalCentroid[1] + dy,
|
||||
];
|
||||
updateVertebraPosition(dragState.level, newCentroid, dragState.originalCorners, dragState.originalCentroid);
|
||||
}
|
||||
} else {
|
||||
// Hovering - check for corner first if a vertebra is selected
|
||||
if (selectedLevel) {
|
||||
const cornerHit = findCornerAt(x, y);
|
||||
if (cornerHit && cornerHit.level === selectedLevel) {
|
||||
setHoveredCorner(cornerHit);
|
||||
setHoveredLevel(cornerHit.level);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setHoveredCorner(null);
|
||||
setHoveredLevel(findVertebraAt(x, y));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragState(null);
|
||||
};
|
||||
|
||||
// Determine cursor style
|
||||
const getCursor = () => {
|
||||
if (dragState) return 'grabbing';
|
||||
if (hoveredCorner) return 'move';
|
||||
if (hoveredLevel) return 'grab';
|
||||
return 'crosshair';
|
||||
};
|
||||
|
||||
// Handle approve
|
||||
const handleApprove = async () => {
|
||||
if (structure && hasChanges) {
|
||||
await onUpdateLandmarks(structure);
|
||||
}
|
||||
await onApprove(structure || undefined);
|
||||
};
|
||||
|
||||
// Stats
|
||||
const detectedCount = structure?.vertebrae.filter(v => v.detected).length || 0;
|
||||
const manualCount = structure?.vertebrae.filter(v => v.manual_override?.enabled).length || 0;
|
||||
const placedCount = structure?.vertebrae.filter(v => v.final_values?.centroid_px).length || 0;
|
||||
|
||||
return (
|
||||
<div className="pipeline-stage landmark-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 1: Vertebrae Detection</h2>
|
||||
<div className="stage-status">
|
||||
{isLoading ? (
|
||||
<span className="status-badge status-processing">Processing...</span>
|
||||
) : landmarksData ? (
|
||||
<span className="status-badge status-complete">
|
||||
{hasChanges ? 'Modified' : 'Detected'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="status-badge status-pending">Pending</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-content landmark-interactive">
|
||||
{/* Canvas area */}
|
||||
<div className="landmark-canvas-area" ref={containerRef}>
|
||||
{!landmarksData && !isLoading && (
|
||||
<div className="landmark-empty">
|
||||
{xrayUrl ? (
|
||||
<>
|
||||
<img src={xrayUrl} alt="X-ray" className="xray-preview" />
|
||||
<p>X-ray uploaded. Click to detect landmarks.</p>
|
||||
<button className="btn primary" onClick={onDetect}>
|
||||
Detect Landmarks
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p>Please upload an X-ray image first.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="landmark-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Detecting vertebrae landmarks...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state when image fails to load */}
|
||||
{imageError && (
|
||||
<div className="landmark-error">
|
||||
<p>{imageError}</p>
|
||||
{xrayUrl && (
|
||||
<button className="btn secondary" onClick={() => {
|
||||
setImageError(null);
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => setImage(img);
|
||||
img.onerror = () => setImageError('Failed to load image');
|
||||
img.src = xrayUrl;
|
||||
}}>
|
||||
Retry Loading
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state while image is being fetched */}
|
||||
{landmarksData && !image && !imageError && xrayUrl && (
|
||||
<div className="landmark-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading X-ray image...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{landmarksData && image && (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="landmark-canvas-interactive"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{ cursor: getCursor() }}
|
||||
/>
|
||||
<div className="canvas-hint">
|
||||
{selectedLevel && !getVertebra(selectedLevel)?.final_values?.centroid_px ? (
|
||||
<span>Click on image to place <strong>{selectedLevel}</strong></span>
|
||||
) : selectedLevel ? (
|
||||
<span>Drag corners to adjust box shape • Drag center to move • Click another to select</span>
|
||||
) : (
|
||||
<span>Click a vertebra to select and edit • Drag center to move</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vertebrae panel */}
|
||||
{landmarksData && structure && (
|
||||
<div className="vertebrae-panel-inline">
|
||||
<div className="panel-header">
|
||||
<h3>Vertebrae</h3>
|
||||
<div className="panel-stats">
|
||||
<span className="stat-pill detected">{detectedCount} detected</span>
|
||||
{manualCount > 0 && <span className="stat-pill manual">{manualCount} edited</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vertebrae-scroll">
|
||||
{structure.vertebrae.map(v => {
|
||||
const hasCentroid = !!v.final_values?.centroid_px;
|
||||
const hasCorners = !!v.final_values?.corners_px;
|
||||
const isSelected = selectedLevel === v.level;
|
||||
const isManual = v.manual_override?.enabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.level}
|
||||
className={`vert-row ${isSelected ? 'selected' : ''} ${isManual ? 'manual' : ''} ${!hasCentroid ? 'missing' : ''}`}
|
||||
onClick={() => setSelectedLevel(v.level)}
|
||||
>
|
||||
<span className="vert-level">{v.level}</span>
|
||||
<span className="vert-info">
|
||||
{isManual ? (
|
||||
<span className="info-manual">Manual</span>
|
||||
) : v.detected ? (
|
||||
<span className="info-conf">
|
||||
{((v.scoliovis_data?.confidence || 0) * 100).toFixed(0)}%
|
||||
{hasCorners && <span className="has-corners" title="Has box corners"> ◻</span>}
|
||||
</span>
|
||||
) : hasCentroid ? (
|
||||
<span className="info-placed">Placed</span>
|
||||
) : (
|
||||
<span className="info-missing">Click to place</span>
|
||||
)}
|
||||
</span>
|
||||
{isManual && (
|
||||
<button
|
||||
className="vert-reset"
|
||||
onClick={(e) => { e.stopPropagation(); resetVertebra(v.level); }}
|
||||
title="Reset to original"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="panel-legend">
|
||||
<span><i className="dot red"></i>Detected</span>
|
||||
<span><i className="dot green"></i>Manual</span>
|
||||
<span><i className="dot gray"></i>Missing</span>
|
||||
</div>
|
||||
|
||||
{/* Quick analysis preview */}
|
||||
{landmarksData.cobb_angles && (
|
||||
<div className="quick-analysis">
|
||||
<div className="qa-row">
|
||||
<span>PT</span>
|
||||
<span>{(landmarksData.cobb_angles.PT || 0).toFixed(1)}°</span>
|
||||
</div>
|
||||
<div className="qa-row">
|
||||
<span>MT</span>
|
||||
<span>{(landmarksData.cobb_angles.MT || 0).toFixed(1)}°</span>
|
||||
</div>
|
||||
<div className="qa-row">
|
||||
<span>TL</span>
|
||||
<span>{(landmarksData.cobb_angles.TL || 0).toFixed(1)}°</span>
|
||||
</div>
|
||||
<div className="qa-rigo">
|
||||
<span className="rigo-tag">{landmarksData.rigo_classification?.type || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{landmarksData && (
|
||||
<div className="stage-actions">
|
||||
{hasChanges && (
|
||||
<span className="changes-indicator">Unsaved changes</span>
|
||||
)}
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{hasChanges ? 'Save & Continue' : 'Approve & Continue'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/pipeline/PipelineSteps.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Pipeline Step Indicator
|
||||
* Shows the current stage in the 5-stage pipeline
|
||||
*/
|
||||
|
||||
export type PipelineStage = 'upload' | 'landmarks' | 'analysis' | 'bodyscan' | 'brace' | 'fitting';
|
||||
|
||||
type Props = {
|
||||
currentStage: PipelineStage;
|
||||
landmarksApproved: boolean;
|
||||
analysisComplete: boolean;
|
||||
bodyScanComplete: boolean;
|
||||
braceGenerated: boolean;
|
||||
onStageClick?: (stage: PipelineStage) => void;
|
||||
};
|
||||
|
||||
const stages = [
|
||||
{ id: 'landmarks' as const, label: 'Landmark Detection', step: 1 },
|
||||
{ id: 'analysis' as const, label: 'Spine Analysis', step: 2 },
|
||||
{ id: 'bodyscan' as const, label: 'Body Scan', step: 3 },
|
||||
{ id: 'brace' as const, label: 'Brace Generation', step: 4 },
|
||||
{ id: 'fitting' as const, label: 'Fitting Inspection', step: 5 },
|
||||
];
|
||||
|
||||
export default function PipelineSteps({
|
||||
currentStage,
|
||||
landmarksApproved,
|
||||
analysisComplete,
|
||||
bodyScanComplete,
|
||||
braceGenerated,
|
||||
onStageClick
|
||||
}: Props) {
|
||||
const getStageStatus = (stageId: typeof stages[number]['id']) => {
|
||||
if (stageId === 'landmarks') {
|
||||
if (landmarksApproved) return 'complete';
|
||||
if (currentStage === 'landmarks') return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
if (stageId === 'analysis') {
|
||||
if (analysisComplete) return 'complete';
|
||||
if (currentStage === 'analysis') return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
if (stageId === 'bodyscan') {
|
||||
if (bodyScanComplete) return 'complete';
|
||||
if (currentStage === 'bodyscan') return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
if (stageId === 'brace') {
|
||||
if (braceGenerated) return 'complete';
|
||||
if (currentStage === 'brace') return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
if (stageId === 'fitting') {
|
||||
if (currentStage === 'fitting') return 'active';
|
||||
if (braceGenerated) return 'available'; // Can navigate to fitting after brace is generated
|
||||
return 'pending';
|
||||
}
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
const canNavigateToStage = (stageId: typeof stages[number]['id']) => {
|
||||
// Can always go back to completed stages
|
||||
const status = getStageStatus(stageId);
|
||||
if (status === 'complete') return true;
|
||||
if (status === 'active') return true;
|
||||
if (status === 'available') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pipeline-steps">
|
||||
{stages.map((stage, idx) => {
|
||||
const status = getStageStatus(stage.id);
|
||||
const canNavigate = canNavigateToStage(stage.id);
|
||||
return (
|
||||
<div
|
||||
key={stage.id}
|
||||
className={`pipeline-step pipeline-step--${status} ${canNavigate ? 'clickable' : ''}`}
|
||||
onClick={() => canNavigate && onStageClick?.(stage.id)}
|
||||
style={{ cursor: canNavigate ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="pipeline-step-number">
|
||||
{status === 'complete' ? '✓' : stage.step}
|
||||
</div>
|
||||
<div className="pipeline-step-label">{stage.label}</div>
|
||||
{idx < stages.length - 1 && <div className="pipeline-step-connector" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
239
frontend/src/components/pipeline/SpineAnalysisStage.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Stage 2: Spine Analysis
|
||||
* Shows Cobb angles, Rigo classification, and curve analysis
|
||||
*/
|
||||
import type { LandmarksResult, RecalculationResult } from '../../api/braceflowApi';
|
||||
|
||||
type Props = {
|
||||
landmarksData: LandmarksResult | null;
|
||||
analysisData: RecalculationResult | null;
|
||||
isLoading: boolean;
|
||||
onRecalculate: () => Promise<void>;
|
||||
onContinue: () => void;
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function getSeverityClass(severity: string): string {
|
||||
switch (severity?.toLowerCase()) {
|
||||
case 'normal':
|
||||
return 'severity-normal';
|
||||
case 'mild':
|
||||
return 'severity-mild';
|
||||
case 'moderate':
|
||||
return 'severity-moderate';
|
||||
case 'severe':
|
||||
case 'very severe':
|
||||
return 'severity-severe';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getRigoDescription(type: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
A1: 'Three-curve pattern with lumbar modifier - Main thoracic curve with compensatory lumbar',
|
||||
A2: 'Three-curve pattern with thoracolumbar modifier - Thoracolumbar prominence',
|
||||
A3: 'Three-curve pattern balanced - Balanced thoracic and lumbar curves',
|
||||
B1: 'Four-curve pattern with lumbar modifier - Double thoracic with lumbar',
|
||||
B2: 'Four-curve pattern with double thoracic - Primary double thoracic',
|
||||
C1: 'Non-3 non-4 with thoracolumbar curve - Single thoracolumbar focus',
|
||||
C2: 'Non-3 non-4 with lumbar curve - Single lumbar focus',
|
||||
E1: 'Single thoracic curve - Primary thoracic scoliosis',
|
||||
E2: 'Single thoracolumbar curve - Primary thoracolumbar scoliosis',
|
||||
};
|
||||
return descriptions[type] || `Rigo-Chêneau classification type ${type}`;
|
||||
}
|
||||
|
||||
function getTreatmentRecommendation(maxCobb: number): {
|
||||
recommendation: string;
|
||||
urgency: string;
|
||||
} {
|
||||
if (maxCobb < 10) {
|
||||
return {
|
||||
recommendation: 'Observation only - no treatment required',
|
||||
urgency: 'routine',
|
||||
};
|
||||
} else if (maxCobb < 25) {
|
||||
return {
|
||||
recommendation: 'Physical therapy and observation recommended',
|
||||
urgency: 'standard',
|
||||
};
|
||||
} else if (maxCobb < 40) {
|
||||
return {
|
||||
recommendation: 'Brace treatment indicated - custom brace recommended',
|
||||
urgency: 'priority',
|
||||
};
|
||||
} else if (maxCobb < 50) {
|
||||
return {
|
||||
recommendation: 'Aggressive bracing required - consider surgical consultation',
|
||||
urgency: 'high',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
recommendation: 'Surgical consultation recommended',
|
||||
urgency: 'urgent',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function SpineAnalysisStage({
|
||||
landmarksData,
|
||||
analysisData,
|
||||
isLoading,
|
||||
onRecalculate,
|
||||
onContinue,
|
||||
}: Props) {
|
||||
// Use recalculated data if available, otherwise use initial detection data
|
||||
const cobbAngles = analysisData?.cobb_angles || landmarksData?.cobb_angles;
|
||||
const rigoClass = analysisData?.rigo_classification || landmarksData?.rigo_classification;
|
||||
const curveType = analysisData?.curve_type || landmarksData?.curve_type;
|
||||
|
||||
if (!landmarksData) {
|
||||
return (
|
||||
<div className="pipeline-stage analysis-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 2: Spine Analysis</h2>
|
||||
<div className="stage-status">
|
||||
<span className="status-badge status-pending">Pending</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stage-content">
|
||||
<div className="stage-empty">
|
||||
<p>Complete Stage 1 (Landmark Detection) first.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxCobb = cobbAngles ? Math.max(cobbAngles.PT, cobbAngles.MT, cobbAngles.TL) : 0;
|
||||
const treatment = getTreatmentRecommendation(maxCobb);
|
||||
|
||||
return (
|
||||
<div className="pipeline-stage analysis-stage">
|
||||
<div className="stage-header">
|
||||
<h2>Stage 2: Spine Analysis</h2>
|
||||
<div className="stage-status">
|
||||
{isLoading ? (
|
||||
<span className="status-badge status-processing">Recalculating...</span>
|
||||
) : (
|
||||
<span className="status-badge status-complete">Analyzed</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-content">
|
||||
{/* Cobb Angles Panel */}
|
||||
<div className="analysis-panel cobb-panel">
|
||||
<h3>Cobb Angle Measurements</h3>
|
||||
{cobbAngles && (
|
||||
<div className="cobb-grid">
|
||||
<div className={`cobb-card ${getSeverityClass(cobbAngles.PT_severity)}`}>
|
||||
<div className="cobb-region">PT</div>
|
||||
<div className="cobb-label">Proximal Thoracic</div>
|
||||
<div className="cobb-value">{cobbAngles.PT.toFixed(1)}°</div>
|
||||
<div className="cobb-severity">{cobbAngles.PT_severity}</div>
|
||||
</div>
|
||||
<div className={`cobb-card ${getSeverityClass(cobbAngles.MT_severity)}`}>
|
||||
<div className="cobb-region">MT</div>
|
||||
<div className="cobb-label">Main Thoracic</div>
|
||||
<div className="cobb-value">{cobbAngles.MT.toFixed(1)}°</div>
|
||||
<div className="cobb-severity">{cobbAngles.MT_severity}</div>
|
||||
</div>
|
||||
<div className={`cobb-card ${getSeverityClass(cobbAngles.TL_severity)}`}>
|
||||
<div className="cobb-region">TL</div>
|
||||
<div className="cobb-label">Thoracolumbar/Lumbar</div>
|
||||
<div className="cobb-value">{cobbAngles.TL.toFixed(1)}°</div>
|
||||
<div className="cobb-severity">{cobbAngles.TL_severity}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Classification Panel */}
|
||||
<div className="analysis-panel classification-panel">
|
||||
<h3>Classification</h3>
|
||||
<div className="classification-grid">
|
||||
{rigoClass && (
|
||||
<div className="classification-card rigo-card">
|
||||
<div className="classification-type">Rigo-Chêneau</div>
|
||||
<div className="classification-badge">{rigoClass.type}</div>
|
||||
<div className="classification-desc">
|
||||
{rigoClass.description || getRigoDescription(rigoClass.type)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{curveType && (
|
||||
<div className="classification-card curve-card">
|
||||
<div className="classification-type">Curve Pattern</div>
|
||||
<div className="classification-badge">{curveType}-Curve</div>
|
||||
<div className="classification-desc">
|
||||
{curveType === 'S'
|
||||
? 'Double curve pattern with thoracic and lumbar components'
|
||||
: curveType === 'C'
|
||||
? 'Single curve pattern'
|
||||
: 'Curve pattern identified'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Treatment Recommendation */}
|
||||
<div className={`analysis-panel treatment-panel treatment-${treatment.urgency}`}>
|
||||
<h3>Clinical Recommendation</h3>
|
||||
<div className="treatment-content">
|
||||
<div className="treatment-summary">
|
||||
<span className="max-cobb">
|
||||
Maximum Cobb Angle: <strong>{maxCobb.toFixed(1)}°</strong>
|
||||
</span>
|
||||
<span className={`urgency-badge urgency-${treatment.urgency}`}>
|
||||
{treatment.urgency}
|
||||
</span>
|
||||
</div>
|
||||
<p className="treatment-recommendation">{treatment.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis Metadata */}
|
||||
<div className="analysis-panel metadata-panel">
|
||||
<h3>Analysis Details</h3>
|
||||
<div className="metadata-grid">
|
||||
<div className="metadata-item">
|
||||
<span className="metadata-label">Vertebrae Analyzed</span>
|
||||
<span className="metadata-value">
|
||||
{analysisData?.vertebrae_used || landmarksData?.vertebrae_structure.detected_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="metadata-item">
|
||||
<span className="metadata-label">Processing Time</span>
|
||||
<span className="metadata-value">
|
||||
{((analysisData?.processing_time_ms || landmarksData?.processing_time_ms || 0) / 1000).toFixed(2)}s
|
||||
</span>
|
||||
</div>
|
||||
{analysisData && (
|
||||
<div className="metadata-item">
|
||||
<span className="metadata-label">Source</span>
|
||||
<span className="metadata-value">Recalculated</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="stage-actions">
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={onRecalculate}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Recalculating...' : 'Recalculate'}
|
||||
</button>
|
||||
<button className="btn primary" onClick={onContinue}>
|
||||
Continue to Brace Generation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/pipeline/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { default as PipelineSteps } from './PipelineSteps';
|
||||
export { default as LandmarkDetectionStage } from './LandmarkDetectionStage';
|
||||
export { default as SpineAnalysisStage } from './SpineAnalysisStage';
|
||||
export { default as BodyScanUploadStage } from './BodyScanUploadStage';
|
||||
export { default as BraceGenerationStage } from './BraceGenerationStage';
|
||||
export { default as BraceEditorStage } from './BraceEditorStage';
|
||||
export { default as BraceInlineEditor } from './BraceInlineEditor';
|
||||
export { default as BraceFittingStage } from './BraceFittingStage';
|
||||
|
||||
export type { PipelineStage } from './PipelineSteps';
|
||||
export type { BraceTransformParams } from './BraceInlineEditor';
|
||||
3707
frontend/src/components/pipeline/pipeline.css
Normal file
301
frontend/src/components/rigo/AnalysisResults.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useState } from "react";
|
||||
import { rigoApi, type AnalysisResult } from "../../api/rigoApi";
|
||||
|
||||
interface AnalysisResultsProps {
|
||||
data: AnalysisResult | null;
|
||||
modelUrl: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onModelUpdate: (url: string) => void;
|
||||
}
|
||||
|
||||
export default function AnalysisResults({ data, modelUrl, isLoading, error, onModelUpdate }: AnalysisResultsProps) {
|
||||
const [showPadsOnly, setShowPadsOnly] = useState(true);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const handleTogglePadsOnly = async () => {
|
||||
if (!data || !onModelUpdate) return;
|
||||
|
||||
setIsRegenerating(true);
|
||||
const newShowPadsOnly = !showPadsOnly;
|
||||
|
||||
try {
|
||||
const result = await rigoApi.regenerate({
|
||||
pressure_pad_level: data.apex,
|
||||
pressure_pad_depth: data.cobb_angle > 40 ? "aggressive" : data.cobb_angle > 20 ? "moderate" : "standard",
|
||||
expansion_window_side: data.pelvic_tilt === "Left" ? "right" : "left",
|
||||
lumbar_support: true,
|
||||
include_shell: !newShowPadsOnly,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Construct full URL from relative path
|
||||
const fullUrl = result.model_url.startsWith("http")
|
||||
? result.model_url
|
||||
: `${rigoApi.getBaseUrl()}${result.model_url}`;
|
||||
onModelUpdate(fullUrl);
|
||||
setShowPadsOnly(newShowPadsOnly);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
alert(`Failed to regenerate: ${message}`);
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadGLB = () => {
|
||||
if (modelUrl) {
|
||||
const link = document.createElement("a");
|
||||
link.href = modelUrl;
|
||||
link.download = `brace_${data?.apex || "custom"}.glb`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else {
|
||||
alert("3D model generation requires Blender. The placeholder model is for visualization only.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSTL = () => {
|
||||
if (modelUrl) {
|
||||
const stlUrl = modelUrl.replace(".glb", ".stl");
|
||||
const link = document.createElement("a");
|
||||
link.href = stlUrl;
|
||||
link.download = `brace_${data?.apex || "custom"}.stl`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else {
|
||||
alert("3D model generation requires Blender. The placeholder model is for visualization only.");
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rigo-analysis-error">
|
||||
<div className="rigo-analysis-card" style={{ borderColor: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }}>
|
||||
<div className="rigo-analysis-label" style={{ color: "#f87171" }}>
|
||||
Error
|
||||
</div>
|
||||
<div className="rigo-analysis-value" style={{ color: "#f87171", fontSize: "1rem" }}>
|
||||
{error}
|
||||
</div>
|
||||
<div className="rigo-analysis-description">Please try uploading a clearer X-ray image.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rigo-analysis-loading">
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-loading-skeleton" style={{ height: "20px", marginBottom: "8px", width: "40%" }}></div>
|
||||
<div className="rigo-loading-skeleton" style={{ height: "36px", width: "60%" }}></div>
|
||||
</div>
|
||||
<div className="rigo-analysis-grid">
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-loading-skeleton" style={{ height: "16px", marginBottom: "8px", width: "50%" }}></div>
|
||||
<div className="rigo-loading-skeleton" style={{ height: "28px", width: "40%" }}></div>
|
||||
</div>
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-loading-skeleton" style={{ height: "16px", marginBottom: "8px", width: "50%" }}></div>
|
||||
<div className="rigo-loading-skeleton" style={{ height: "28px", width: "40%" }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ textAlign: "center", color: "#64748b", marginTop: "24px", fontSize: "0.875rem" }}>
|
||||
<span className="rigo-spinner" style={{ display: "inline-block", marginRight: "8px", verticalAlign: "middle" }}></span>
|
||||
Analyzing X-ray with Claude Vision...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="rigo-analysis-empty">
|
||||
<div style={{ textAlign: "center", padding: "32px", color: "#64748b" }}>
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
style={{ margin: "0 auto 16px", opacity: 0.3 }}
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<p>Upload an EOS X-ray to see the analysis results here.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine severity based on Cobb angle
|
||||
const getSeverity = (angle: number) => {
|
||||
if (angle < 20) return { level: "Mild", class: "success" };
|
||||
if (angle < 40) return { level: "Moderate", class: "highlight" };
|
||||
return { level: "Severe", class: "warning" };
|
||||
};
|
||||
|
||||
const severity = getSeverity(data.cobb_angle);
|
||||
|
||||
return (
|
||||
<div className="rigo-analysis-results">
|
||||
{/* Diagnosis */}
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-analysis-label">Diagnosis</div>
|
||||
<div className="rigo-analysis-value highlight">
|
||||
{data.pattern === "Type_3C" ? "Right Thoracic Scoliosis" : data.pattern}
|
||||
</div>
|
||||
<div className="rigo-analysis-description">
|
||||
Rigo-Cheneau Classification: <strong>Type 3C</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thoracic Measurements */}
|
||||
<div className="rigo-analysis-grid">
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-analysis-label">Thoracic Cobb</div>
|
||||
<div className={`rigo-analysis-value ${severity.class}`}>{data.cobb_angle}°</div>
|
||||
<div className="rigo-analysis-description">{severity.level} curve</div>
|
||||
</div>
|
||||
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-analysis-label">Apex Vertebra</div>
|
||||
<div className="rigo-analysis-value">{data.apex}</div>
|
||||
<div className="rigo-analysis-description">Curve apex location</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thoracic Convexity & Lumbar Cobb */}
|
||||
<div className="rigo-analysis-grid">
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-analysis-label">Thoracic Convexity</div>
|
||||
<div className="rigo-analysis-value">{data.thoracic_convexity || "Right"}</div>
|
||||
<div className="rigo-analysis-description">Curve direction</div>
|
||||
</div>
|
||||
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-analysis-label">Lumbar Cobb</div>
|
||||
<div className="rigo-analysis-value">{data.lumbar_cobb_deg != null ? `${data.lumbar_cobb_deg}°` : "—"}</div>
|
||||
<div className="rigo-analysis-description">Compensatory curve</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* L4/L5 Tilt */}
|
||||
<div className="rigo-analysis-grid">
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-analysis-label">L4 Tilt</div>
|
||||
<div className="rigo-analysis-value">{data.l4_tilt_deg != null ? `${data.l4_tilt_deg.toFixed(1)}°` : "—"}</div>
|
||||
<div className="rigo-analysis-description">Vertebra angle</div>
|
||||
</div>
|
||||
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-analysis-label">L5 Tilt</div>
|
||||
<div className="rigo-analysis-value">{data.l5_tilt_deg != null ? `${data.l5_tilt_deg.toFixed(1)}°` : "—"}</div>
|
||||
<div className="rigo-analysis-description">Vertebra angle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pelvic Tilt */}
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-analysis-label">Pelvic Tilt</div>
|
||||
<div className="rigo-analysis-value">{data.pelvic_tilt} Side</div>
|
||||
<div className="rigo-analysis-description">Compensatory pelvic position</div>
|
||||
</div>
|
||||
|
||||
{/* Brace Parameters */}
|
||||
<div
|
||||
className="rigo-analysis-card"
|
||||
style={{ marginTop: "16px", background: "rgba(59, 130, 246, 0.1)", borderColor: "#2563eb" }}
|
||||
>
|
||||
<div className="rigo-analysis-label" style={{ color: "#60a5fa" }}>
|
||||
Generated Brace Parameters
|
||||
</div>
|
||||
<div style={{ fontSize: "0.875rem", color: "#94a3b8", marginTop: "8px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "4px" }}>
|
||||
<span>Pressure Pad Position:</span>
|
||||
<strong style={{ color: "#f1f5f9" }}>{data.apex} Level</strong>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "4px" }}>
|
||||
<span>Pad Depth:</span>
|
||||
<strong style={{ color: "#f1f5f9" }}>{data.cobb_angle > 30 ? "Aggressive" : "Standard"}</strong>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span>Expansion Window:</span>
|
||||
<strong style={{ color: "#f1f5f9" }}>{data.pelvic_tilt === "Left" ? "Right" : "Left"} Side</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
{modelUrl && (
|
||||
<div style={{ marginTop: "16px" }}>
|
||||
<button
|
||||
className={`rigo-btn ${showPadsOnly ? "rigo-btn-primary" : "rigo-btn-secondary"} rigo-btn-block`}
|
||||
onClick={handleTogglePadsOnly}
|
||||
disabled={isRegenerating}
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
>
|
||||
{isRegenerating ? (
|
||||
<>
|
||||
<span className="rigo-spinner" style={{ width: "16px", height: "16px", marginRight: "8px" }}></span>
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
{showPadsOnly ? (
|
||||
<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 9h6v6H9z" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 5v2M12 17v2M5 12h2M17 12h2" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{showPadsOnly ? "Show Full Brace" : "Show Pads Only"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Buttons */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", marginTop: "16px" }}>
|
||||
<button
|
||||
className={`rigo-btn ${modelUrl ? "rigo-btn-primary" : "rigo-btn-secondary"} rigo-btn-block`}
|
||||
onClick={handleDownloadSTL}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
{modelUrl ? "Download STL (3D Print)" : "Download (Blender Required)"}
|
||||
</button>
|
||||
|
||||
{modelUrl && (
|
||||
<button className="rigo-btn rigo-btn-secondary rigo-btn-block" onClick={handleDownloadGLB}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
Download GLB (Web Viewer)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
frontend/src/components/rigo/BraceViewer.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Suspense, useState, useRef } from "react";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { OrbitControls, Environment, ContactShadows, useGLTF, Center, Grid } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
|
||||
// Placeholder brace model when no model is loaded
|
||||
function PlaceholderBrace({ opacity = 1 }: { opacity?: number }) {
|
||||
const meshRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (meshRef.current) {
|
||||
meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.3) * 0.1;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={meshRef}>
|
||||
{/* Main brace body - simplified torso shape */}
|
||||
<mesh position={[0, 0, 0]}>
|
||||
<cylinderGeometry args={[0.8, 1, 2.5, 32, 1, true]} />
|
||||
<meshStandardMaterial
|
||||
color="#4a90d9"
|
||||
transparent
|
||||
opacity={opacity * 0.8}
|
||||
side={THREE.DoubleSide}
|
||||
metalness={0.1}
|
||||
roughness={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Right pressure pad (thoracic) */}
|
||||
<mesh position={[0.85, 0.3, 0]} rotation={[0, 0, Math.PI / 6]}>
|
||||
<boxGeometry args={[0.15, 0.8, 0.6]} />
|
||||
<meshStandardMaterial color="#2563eb" transparent opacity={opacity} metalness={0.2} roughness={0.4} />
|
||||
</mesh>
|
||||
|
||||
{/* Left expansion window cutout visual */}
|
||||
<mesh position={[-0.75, 0.3, 0.2]}>
|
||||
<boxGeometry args={[0.1, 0.6, 0.5]} />
|
||||
<meshStandardMaterial color="#0f172a" transparent opacity={opacity * 0.9} />
|
||||
</mesh>
|
||||
|
||||
{/* Lumbar support */}
|
||||
<mesh position={[0, -0.8, 0.5]} rotation={[0.3, 0, 0]}>
|
||||
<boxGeometry args={[0.8, 0.4, 0.2]} />
|
||||
<meshStandardMaterial color="#3b82f6" transparent opacity={opacity} metalness={0.2} roughness={0.4} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Load actual GLB model
|
||||
function BraceModel({ url, opacity = 1 }: { url: string; opacity?: number }) {
|
||||
const { scene } = useGLTF(url);
|
||||
const meshRef = useRef<THREE.Object3D>(null);
|
||||
|
||||
// Apply materials to all meshes
|
||||
scene.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
(child as THREE.Mesh).material = new THREE.MeshStandardMaterial({
|
||||
color: "#4a90d9",
|
||||
transparent: true,
|
||||
opacity: opacity * 0.8,
|
||||
metalness: 0.1,
|
||||
roughness: 0.6,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<primitive ref={meshRef} object={scene} scale={1} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading indicator
|
||||
function LoadingIndicator() {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (meshRef.current) {
|
||||
meshRef.current.rotation.y = state.clock.elapsedTime * 2;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef}>
|
||||
<torusGeometry args={[0.5, 0.1, 16, 32]} />
|
||||
<meshStandardMaterial color="#3b82f6" emissive="#3b82f6" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
interface BraceViewerProps {
|
||||
modelUrl: string | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function BraceViewer({ modelUrl, isLoading }: BraceViewerProps) {
|
||||
const [transparency, setTransparency] = useState(false);
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
|
||||
const opacity = transparency ? 0.4 : 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Canvas
|
||||
className="rigo-viewer-canvas"
|
||||
camera={{ position: [3, 2, 3], fov: 45 }}
|
||||
gl={{ antialias: true, alpha: true }}
|
||||
style={{ width: "100%", height: "100%", minHeight: "400px" }}
|
||||
>
|
||||
<color attach="background" args={["#111827"]} />
|
||||
|
||||
{/* Lighting */}
|
||||
<ambientLight intensity={0.4} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={1} castShadow shadow-mapSize={[2048, 2048]} />
|
||||
<directionalLight position={[-5, 3, -5]} intensity={0.3} />
|
||||
<pointLight position={[0, 3, 0]} intensity={0.5} color="#60a5fa" />
|
||||
|
||||
{/* Environment for reflections */}
|
||||
<Environment preset="city" />
|
||||
|
||||
{/* Grid */}
|
||||
{showGrid && (
|
||||
<Grid
|
||||
args={[10, 10]}
|
||||
cellSize={0.5}
|
||||
cellThickness={0.5}
|
||||
cellColor="#334155"
|
||||
sectionSize={2}
|
||||
sectionThickness={1}
|
||||
sectionColor="#475569"
|
||||
fadeDistance={10}
|
||||
fadeStrength={1}
|
||||
position={[0, -1.5, 0]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Contact shadows */}
|
||||
<ContactShadows position={[0, -1.5, 0]} opacity={0.4} scale={5} blur={2.5} />
|
||||
|
||||
{/* Model */}
|
||||
<Suspense fallback={<LoadingIndicator />}>
|
||||
{isLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : modelUrl ? (
|
||||
<BraceModel url={modelUrl} opacity={opacity} />
|
||||
) : (
|
||||
<PlaceholderBrace opacity={opacity} />
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
{/* Controls */}
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
enableDamping
|
||||
dampingFactor={0.05}
|
||||
minDistance={2}
|
||||
maxDistance={10}
|
||||
minPolarAngle={Math.PI / 6}
|
||||
maxPolarAngle={Math.PI / 1.5}
|
||||
/>
|
||||
</Canvas>
|
||||
|
||||
{/* Viewer Controls */}
|
||||
<div className="rigo-viewer-controls">
|
||||
<button
|
||||
className={`rigo-viewer-control-btn ${transparency ? "active" : ""}`}
|
||||
onClick={() => setTransparency(!transparency)}
|
||||
title="Toggle Transparency"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" strokeDasharray="4 2" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`rigo-viewer-control-btn ${showGrid ? "active" : ""}`}
|
||||
onClick={() => setShowGrid(!showGrid)}
|
||||
title="Toggle Grid"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="3" y1="15" x2="21" y2="15" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
192
frontend/src/components/rigo/UploadPanel.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface UploadPanelProps {
|
||||
onUpload: (file: File) => void;
|
||||
isAnalyzing: boolean;
|
||||
onReset: () => void;
|
||||
hasResults: boolean;
|
||||
}
|
||||
|
||||
export default function UploadPanel({ onUpload, isAnalyzing, onReset, hasResults }: UploadPanelProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState("");
|
||||
|
||||
const handleFile = useCallback((file: File | null) => {
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ["image/jpeg", "image/png", "image/webp", "image/bmp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert("Please upload a valid image file (JPEG, PNG, WebP, or BMP)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setPreview(e.target?.result as string);
|
||||
setFileName(file.name);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
handleFile(file);
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
handleFile(file);
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleAnalyze = useCallback(() => {
|
||||
if (!preview) return;
|
||||
|
||||
// Convert base64 to file for upload
|
||||
fetch(preview)
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const file = new File([blob], fileName, { type: blob.type });
|
||||
onUpload(file);
|
||||
});
|
||||
}, [preview, fileName, onUpload]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setPreview(null);
|
||||
setFileName("");
|
||||
onReset();
|
||||
}, [onReset]);
|
||||
|
||||
return (
|
||||
<div className="rigo-upload-panel">
|
||||
{!preview ? (
|
||||
<label
|
||||
id="rigo-upload-zone"
|
||||
className={`rigo-upload-zone ${isDragging ? "dragging" : ""}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleInputChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<div className="rigo-upload-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="rigo-upload-text">
|
||||
<strong>Drop your EOS X-ray here</strong>
|
||||
<br />
|
||||
or click to browse
|
||||
</p>
|
||||
<p className="rigo-upload-hint">Supports JPEG, PNG, WebP, BMP</p>
|
||||
</label>
|
||||
) : (
|
||||
<div className="rigo-upload-preview-container">
|
||||
<div className="rigo-upload-preview">
|
||||
<img src={preview} alt="X-ray preview" />
|
||||
<div className="rigo-upload-preview-overlay">
|
||||
<span style={{ color: "white", fontSize: "0.875rem" }}>{fileName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
<button
|
||||
className="rigo-btn rigo-btn-primary rigo-btn-block rigo-btn-lg"
|
||||
onClick={handleAnalyze}
|
||||
disabled={isAnalyzing}
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<span className="rigo-spinner"></span>
|
||||
Generating...
|
||||
</>
|
||||
) : hasResults ? (
|
||||
<>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
Complete
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
Analyze
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rigo-btn rigo-btn-secondary rigo-btn-block"
|
||||
onClick={handleClear}
|
||||
disabled={isAnalyzing}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
Clear & Upload New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="rigo-progress-steps" style={{ marginTop: "32px" }}>
|
||||
<div className={`rigo-progress-step ${preview ? "completed" : "active"}`}>
|
||||
<div className="rigo-progress-step-indicator">1</div>
|
||||
<div className="rigo-progress-step-content">
|
||||
<div className="rigo-progress-step-title">Upload X-Ray</div>
|
||||
<div className="rigo-progress-step-description">EOS or standard spinal radiograph</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`rigo-progress-step ${isAnalyzing ? "active" : hasResults ? "completed" : ""}`}>
|
||||
<div className="rigo-progress-step-indicator">2</div>
|
||||
<div className="rigo-progress-step-content">
|
||||
<div className="rigo-progress-step-title">Generate Brace</div>
|
||||
<div className="rigo-progress-step-description">3D model with corrective parameters</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`rigo-progress-step ${hasResults ? "completed" : ""}`}>
|
||||
<div className="rigo-progress-step-indicator">3</div>
|
||||
<div className="rigo-progress-step-content">
|
||||
<div className="rigo-progress-step-title">Download STL</div>
|
||||
<div className="rigo-progress-step-description">Ready for 3D printing or editing</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
303
frontend/src/components/three/BodyScanViewer.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 3D Body Scan Viewer with Auto-Rotation
|
||||
* Displays STL/OBJ/GLB body scans with spinning animation
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Three.js is loaded dynamically
|
||||
let THREE: any = null;
|
||||
let STLLoader: any = null;
|
||||
let OBJLoader: any = null;
|
||||
let GLTFLoader: any = null;
|
||||
|
||||
type BodyScanViewerProps = {
|
||||
scanUrl: string | null;
|
||||
autoRotate?: boolean;
|
||||
rotationSpeed?: number;
|
||||
};
|
||||
|
||||
export default function BodyScanViewer({
|
||||
scanUrl,
|
||||
autoRotate = true,
|
||||
rotationSpeed = 0.01,
|
||||
}: BodyScanViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<any>(null);
|
||||
const sceneRef = useRef<any>(null);
|
||||
const cameraRef = useRef<any>(null);
|
||||
const meshRef = useRef<any>(null);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const autoRotateRef = useRef(autoRotate);
|
||||
const rotationSpeedRef = useRef(rotationSpeed);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [threeLoaded, setThreeLoaded] = useState(false);
|
||||
|
||||
// Keep refs in sync with props
|
||||
useEffect(() => {
|
||||
autoRotateRef.current = autoRotate;
|
||||
rotationSpeedRef.current = rotationSpeed;
|
||||
}, [autoRotate, rotationSpeed]);
|
||||
|
||||
// Load Three.js dynamically
|
||||
useEffect(() => {
|
||||
const loadThree = async () => {
|
||||
try {
|
||||
const threeModule = await import('three');
|
||||
THREE = threeModule;
|
||||
|
||||
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
|
||||
STLLoader = STL;
|
||||
|
||||
const { OBJLoader: OBJ } = await import('three/examples/jsm/loaders/OBJLoader.js');
|
||||
OBJLoader = OBJ;
|
||||
|
||||
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||
GLTFLoader = GLTF;
|
||||
|
||||
setThreeLoaded(true);
|
||||
} catch (e) {
|
||||
console.error('Failed to load Three.js:', e);
|
||||
setError('Failed to load 3D viewer');
|
||||
}
|
||||
};
|
||||
|
||||
loadThree();
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize scene when Three.js is loaded
|
||||
useEffect(() => {
|
||||
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
// Scene
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a2e);
|
||||
sceneRef.current = scene;
|
||||
|
||||
// Camera
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
|
||||
camera.position.set(0, 50, 500);
|
||||
camera.lookAt(0, 0, 0);
|
||||
cameraRef.current = camera;
|
||||
|
||||
// Renderer - fills container
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
container.appendChild(renderer.domElement);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
// Lighting - multiple lights for good coverage during rotation
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const frontLight = new THREE.DirectionalLight(0xffffff, 0.7);
|
||||
frontLight.position.set(0, 100, 400);
|
||||
scene.add(frontLight);
|
||||
|
||||
const backLight = new THREE.DirectionalLight(0x88ccff, 0.5);
|
||||
backLight.position.set(0, 100, -400);
|
||||
scene.add(backLight);
|
||||
|
||||
const topLight = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
topLight.position.set(0, 400, 0);
|
||||
scene.add(topLight);
|
||||
|
||||
const sideLight1 = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||
sideLight1.position.set(400, 100, 0);
|
||||
scene.add(sideLight1);
|
||||
|
||||
const sideLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||
sideLight2.position.set(-400, 100, 0);
|
||||
scene.add(sideLight2);
|
||||
|
||||
// Animation loop - rotate the mesh around its own Y-axis (self-rotation)
|
||||
const animate = () => {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
// Self-rotation: rotate around Z axis (the original vertical axis of STL files)
|
||||
if (meshRef.current && autoRotateRef.current) {
|
||||
meshRef.current.rotation.z += rotationSpeedRef.current;
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
if (!container || !renderer || !camera) return;
|
||||
const newWidth = container.clientWidth;
|
||||
const newHeight = container.clientHeight;
|
||||
camera.aspect = newWidth / newHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(newWidth, newHeight);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [threeLoaded]);
|
||||
|
||||
// Load mesh when URL changes
|
||||
useEffect(() => {
|
||||
if (!threeLoaded || !scanUrl || !sceneRef.current) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Clear previous mesh
|
||||
if (meshRef.current) {
|
||||
sceneRef.current.remove(meshRef.current);
|
||||
meshRef.current = null;
|
||||
}
|
||||
|
||||
// Determine loader based on file extension
|
||||
const ext = scanUrl.toLowerCase().split('.').pop() || '';
|
||||
|
||||
const onLoad = (result: any) => {
|
||||
let mesh: any;
|
||||
|
||||
if (ext === 'stl') {
|
||||
// STL returns geometry directly - center the geometry itself
|
||||
const geometry = result;
|
||||
geometry.center(); // This centers the geometry at origin
|
||||
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0xccaa88,
|
||||
specular: 0x222222,
|
||||
shininess: 50,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
mesh = new THREE.Mesh(geometry, material);
|
||||
|
||||
// STL files are typically Z-up, rotate to Y-up
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
|
||||
} else if (ext === 'obj') {
|
||||
mesh = result;
|
||||
mesh.traverse((child: any) => {
|
||||
if (child.isMesh) {
|
||||
child.material = new THREE.MeshPhongMaterial({
|
||||
color: 0xccaa88,
|
||||
specular: 0x222222,
|
||||
shininess: 50,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (ext === 'glb' || ext === 'gltf') {
|
||||
mesh = result.scene;
|
||||
}
|
||||
|
||||
if (mesh) {
|
||||
// Get bounding box to scale appropriately
|
||||
const box = new THREE.Box3().setFromObject(mesh);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
|
||||
// For non-STL, center the mesh position
|
||||
if (ext !== 'stl') {
|
||||
mesh.position.sub(center);
|
||||
}
|
||||
|
||||
// Scale to fit in view (smaller = further away appearance)
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const targetSize = 250;
|
||||
const scale = targetSize / maxDim;
|
||||
mesh.scale.multiplyScalar(scale);
|
||||
|
||||
// Position at scene center
|
||||
mesh.position.set(0, 0, 0);
|
||||
|
||||
sceneRef.current.add(mesh);
|
||||
meshRef.current = mesh;
|
||||
|
||||
// Position camera to see the whole model
|
||||
cameraRef.current.position.set(0, 80, 400);
|
||||
cameraRef.current.lookAt(0, 0, 0);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onError = (err: any) => {
|
||||
console.error('Failed to load mesh:', err);
|
||||
setError('Failed to load 3D model');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Load based on extension
|
||||
if (ext === 'stl') {
|
||||
const loader = new STLLoader();
|
||||
loader.load(scanUrl, onLoad, undefined, onError);
|
||||
} else if (ext === 'obj') {
|
||||
const loader = new OBJLoader();
|
||||
loader.load(scanUrl, onLoad, undefined, onError);
|
||||
} else if (ext === 'glb' || ext === 'gltf') {
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(scanUrl, onLoad, undefined, onError);
|
||||
} else if (ext === 'ply') {
|
||||
// PLY not directly supported, show message
|
||||
setError('PLY format preview not supported');
|
||||
setLoading(false);
|
||||
} else {
|
||||
setError(`Unsupported format: ${ext}`);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [threeLoaded, scanUrl]);
|
||||
|
||||
if (!threeLoaded) {
|
||||
return (
|
||||
<div className="body-scan-viewer-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading 3D viewer...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="body-scan-viewer-container">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="body-scan-viewer-canvas"
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<div className="body-scan-viewer-overlay">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading model...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="body-scan-viewer-overlay error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!scanUrl && !loading && (
|
||||
<div className="body-scan-viewer-overlay placeholder">
|
||||
<p>No scan loaded</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
frontend/src/components/three/BraceModelViewer.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 3D Brace Model Viewer with Auto-Rotation
|
||||
* Displays STL/GLB brace models with spinning animation
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Three.js is loaded dynamically
|
||||
let THREE: any = null;
|
||||
let STLLoader: any = null;
|
||||
let GLTFLoader: any = null;
|
||||
|
||||
type BraceModelViewerProps = {
|
||||
stlUrl?: string | null;
|
||||
glbUrl?: string | null;
|
||||
autoRotate?: boolean;
|
||||
rotationSpeed?: number;
|
||||
};
|
||||
|
||||
export default function BraceModelViewer({
|
||||
stlUrl,
|
||||
glbUrl,
|
||||
autoRotate = true,
|
||||
rotationSpeed = 0.005,
|
||||
}: BraceModelViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<any>(null);
|
||||
const sceneRef = useRef<any>(null);
|
||||
const cameraRef = useRef<any>(null);
|
||||
const meshRef = useRef<any>(null);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const autoRotateRef = useRef(autoRotate);
|
||||
const rotationSpeedRef = useRef(rotationSpeed);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [threeLoaded, setThreeLoaded] = useState(false);
|
||||
|
||||
// Keep refs in sync with props
|
||||
useEffect(() => {
|
||||
autoRotateRef.current = autoRotate;
|
||||
rotationSpeedRef.current = rotationSpeed;
|
||||
}, [autoRotate, rotationSpeed]);
|
||||
|
||||
// Determine which URL to use (prefer GLB over STL)
|
||||
const modelUrl = glbUrl || stlUrl;
|
||||
const modelType = glbUrl ? 'glb' : (stlUrl ? 'stl' : null);
|
||||
|
||||
// Load Three.js dynamically
|
||||
useEffect(() => {
|
||||
const loadThree = async () => {
|
||||
try {
|
||||
const threeModule = await import('three');
|
||||
THREE = threeModule;
|
||||
|
||||
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
|
||||
STLLoader = STL;
|
||||
|
||||
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||
GLTFLoader = GLTF;
|
||||
|
||||
setThreeLoaded(true);
|
||||
} catch (e) {
|
||||
console.error('Failed to load Three.js:', e);
|
||||
setError('Failed to load 3D viewer');
|
||||
}
|
||||
};
|
||||
|
||||
loadThree();
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize scene when Three.js is loaded
|
||||
useEffect(() => {
|
||||
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
// Scene with gradient background
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1e1e2e);
|
||||
sceneRef.current = scene;
|
||||
|
||||
// Camera - positioned to view upright brace from front
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
|
||||
camera.position.set(0, 0, 400);
|
||||
camera.lookAt(0, 0, 0);
|
||||
cameraRef.current = camera;
|
||||
|
||||
// Renderer
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
container.appendChild(renderer.domElement);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
// Improved lighting for skin-like appearance with shadows
|
||||
// Soft ambient for base illumination
|
||||
scene.add(new THREE.AmbientLight(0xffeedd, 0.4));
|
||||
|
||||
// Hemisphere light for natural sky/ground coloring
|
||||
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x8d7c6d, 0.5);
|
||||
hemiLight.position.set(0, 200, 0);
|
||||
scene.add(hemiLight);
|
||||
|
||||
// Key light (main light with shadows)
|
||||
const keyLight = new THREE.DirectionalLight(0xfff5e8, 1.0);
|
||||
keyLight.position.set(150, 200, 300);
|
||||
keyLight.castShadow = true;
|
||||
keyLight.shadow.mapSize.width = 1024;
|
||||
keyLight.shadow.mapSize.height = 1024;
|
||||
keyLight.shadow.camera.near = 50;
|
||||
keyLight.shadow.camera.far = 1000;
|
||||
keyLight.shadow.camera.left = -200;
|
||||
keyLight.shadow.camera.right = 200;
|
||||
keyLight.shadow.camera.top = 200;
|
||||
keyLight.shadow.camera.bottom = -200;
|
||||
keyLight.shadow.bias = -0.0005;
|
||||
scene.add(keyLight);
|
||||
|
||||
// Fill light (softer, opposite side)
|
||||
const fillLight = new THREE.DirectionalLight(0xe8f0ff, 0.5);
|
||||
fillLight.position.set(-200, 100, -100);
|
||||
scene.add(fillLight);
|
||||
|
||||
// Rim light (back light for edge definition)
|
||||
const rimLight = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
rimLight.position.set(0, 50, -300);
|
||||
scene.add(rimLight);
|
||||
|
||||
// Bottom fill (subtle, prevents too dark shadows underneath)
|
||||
const bottomFill = new THREE.DirectionalLight(0xffe8d0, 0.2);
|
||||
bottomFill.position.set(0, -200, 100);
|
||||
scene.add(bottomFill);
|
||||
|
||||
// Animation loop - rotate around Y axis (lazy susan for Y-up brace)
|
||||
const animate = () => {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
if (meshRef.current && autoRotateRef.current) {
|
||||
meshRef.current.rotation.y += rotationSpeedRef.current;
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
if (!container || !renderer || !camera) return;
|
||||
const newWidth = container.clientWidth;
|
||||
const newHeight = container.clientHeight;
|
||||
camera.aspect = newWidth / newHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(newWidth, newHeight);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [threeLoaded]);
|
||||
|
||||
// Load model when URL changes
|
||||
useEffect(() => {
|
||||
if (!threeLoaded || !modelUrl || !sceneRef.current) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Clear previous mesh
|
||||
if (meshRef.current) {
|
||||
sceneRef.current.remove(meshRef.current);
|
||||
meshRef.current = null;
|
||||
}
|
||||
|
||||
const onLoadSTL = (geometry: any) => {
|
||||
// Center the geometry
|
||||
geometry.center();
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
// Skin-like material for natural appearance
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
|
||||
roughness: 0.7, // Slightly smooth for skin-like appearance
|
||||
metalness: 0.0,
|
||||
side: THREE.DoubleSide,
|
||||
flatShading: false,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
|
||||
// Brace template is Y-up, no rotation needed
|
||||
|
||||
// Scale to fit
|
||||
const box = new THREE.Box3().setFromObject(mesh);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 200 / maxDim;
|
||||
mesh.scale.multiplyScalar(scale);
|
||||
|
||||
mesh.position.set(0, 0, 0);
|
||||
|
||||
sceneRef.current.add(mesh);
|
||||
meshRef.current = mesh;
|
||||
|
||||
// Position camera - view from front at torso level
|
||||
cameraRef.current.position.set(0, 0, 350);
|
||||
cameraRef.current.lookAt(0, 0, 0);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onLoadGLB = (gltf: any) => {
|
||||
const mesh = gltf.scene;
|
||||
|
||||
// Apply skin-like material to all meshes
|
||||
mesh.traverse((child: any) => {
|
||||
if (child.isMesh) {
|
||||
child.material = new THREE.MeshStandardMaterial({
|
||||
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
|
||||
roughness: 0.7, // Slightly smooth for skin-like appearance
|
||||
metalness: 0.0,
|
||||
side: THREE.DoubleSide,
|
||||
flatShading: false,
|
||||
});
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Center and scale
|
||||
const box = new THREE.Box3().setFromObject(mesh);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
mesh.position.sub(center);
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 200 / maxDim;
|
||||
mesh.scale.multiplyScalar(scale);
|
||||
|
||||
mesh.position.set(0, 0, 0);
|
||||
|
||||
sceneRef.current.add(mesh);
|
||||
meshRef.current = mesh;
|
||||
|
||||
// Position camera - view from front at torso level
|
||||
cameraRef.current.position.set(0, 0, 350);
|
||||
cameraRef.current.lookAt(0, 0, 0);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onError = (err: any) => {
|
||||
console.error('Failed to load model:', err);
|
||||
setError('Failed to load 3D model');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (modelType === 'stl') {
|
||||
const loader = new STLLoader();
|
||||
loader.load(modelUrl, onLoadSTL, undefined, onError);
|
||||
} else if (modelType === 'glb') {
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(modelUrl, onLoadGLB, undefined, onError);
|
||||
}
|
||||
}, [threeLoaded, modelUrl, modelType]);
|
||||
|
||||
if (!threeLoaded) {
|
||||
return (
|
||||
<div className="brace-model-viewer-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading 3D viewer...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="brace-model-viewer-container">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="brace-model-viewer-canvas"
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<div className="brace-model-viewer-overlay">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading brace model...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="brace-model-viewer-overlay error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!modelUrl && !loading && (
|
||||
<div className="brace-model-viewer-overlay placeholder">
|
||||
<div className="placeholder-icon">🦾</div>
|
||||
<p>Generate a brace to see 3D preview</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
980
frontend/src/components/three/BraceTransformViewer.tsx
Normal file
@@ -0,0 +1,980 @@
|
||||
/**
|
||||
* BraceTransformViewer - 3D Brace Viewer with Real-time Deformation
|
||||
* Based on EXPERIMENT_6's brace-transform-playground-v2
|
||||
*
|
||||
* Extends BraceModelViewer with the ability to apply ellipsoid deformations
|
||||
* in real-time based on transformation parameters.
|
||||
*/
|
||||
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { BraceTransformParams } from '../pipeline/BraceInlineEditor';
|
||||
|
||||
// Three.js is loaded dynamically
|
||||
let THREE: any = null;
|
||||
let STLLoader: any = null;
|
||||
let GLTFLoader: any = null;
|
||||
let STLExporter: any = null;
|
||||
let GLTFExporter: any = null;
|
||||
let OrbitControls: any = null;
|
||||
|
||||
type MarkerInfo = {
|
||||
name: string;
|
||||
position: [number, number, number];
|
||||
};
|
||||
|
||||
type MarkerMap = Record<string, { x: number; y: number; z: number }>;
|
||||
|
||||
export type BraceTransformViewerRef = {
|
||||
exportSTL: () => Promise<Blob | null>;
|
||||
exportGLB: () => Promise<Blob | null>;
|
||||
getModifiedGeometry: () => any;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
glbUrl?: string | null;
|
||||
stlUrl?: string | null;
|
||||
transformParams?: BraceTransformParams;
|
||||
autoRotate?: boolean;
|
||||
rotationSpeed?: number;
|
||||
showMarkers?: boolean;
|
||||
showGrid?: boolean;
|
||||
onMarkersLoaded?: (markers: MarkerInfo[]) => void;
|
||||
onGeometryUpdated?: () => void;
|
||||
};
|
||||
|
||||
const BraceTransformViewer = forwardRef<BraceTransformViewerRef, Props>(({
|
||||
glbUrl,
|
||||
stlUrl,
|
||||
transformParams,
|
||||
autoRotate = true,
|
||||
rotationSpeed = 0.005,
|
||||
showMarkers = false,
|
||||
showGrid = false,
|
||||
onMarkersLoaded,
|
||||
onGeometryUpdated,
|
||||
}, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<any>(null);
|
||||
const sceneRef = useRef<any>(null);
|
||||
const cameraRef = useRef<any>(null);
|
||||
const controlsRef = useRef<any>(null);
|
||||
const meshRef = useRef<any>(null);
|
||||
const baseGeometryRef = useRef<any>(null);
|
||||
const gridGroupRef = useRef<any>(null);
|
||||
const realWorldScaleRef = useRef<number>(1); // units per cm
|
||||
const markersRef = useRef<MarkerMap>({});
|
||||
const modelGroupRef = useRef<any>(null);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const autoRotateRef = useRef(autoRotate);
|
||||
const rotationSpeedRef = useRef(rotationSpeed);
|
||||
const paramsRef = useRef<BraceTransformParams | undefined>(transformParams);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [threeLoaded, setThreeLoaded] = useState(false);
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number; depth: number } | null>(null);
|
||||
|
||||
// Keep refs in sync with props
|
||||
useEffect(() => {
|
||||
autoRotateRef.current = autoRotate;
|
||||
rotationSpeedRef.current = rotationSpeed;
|
||||
if (controlsRef.current) {
|
||||
controlsRef.current.autoRotate = autoRotate;
|
||||
controlsRef.current.autoRotateSpeed = rotationSpeed * 100;
|
||||
}
|
||||
}, [autoRotate, rotationSpeed]);
|
||||
|
||||
// Create measurement grid with labels
|
||||
const createMeasurementGrid = useCallback((scene: any, unitsPerCm: number, modelHeight: number) => {
|
||||
if (!THREE) return;
|
||||
|
||||
// Remove existing grid
|
||||
if (gridGroupRef.current) {
|
||||
scene.remove(gridGroupRef.current);
|
||||
gridGroupRef.current = null;
|
||||
}
|
||||
|
||||
const gridGroup = new THREE.Group();
|
||||
gridGroup.name = 'measurementGrid';
|
||||
|
||||
// Calculate grid size based on model (add some padding)
|
||||
const gridSizeCm = Math.ceil(modelHeight / unitsPerCm / 10) * 10 + 20; // Round up to nearest 10cm + padding
|
||||
const gridSizeUnits = gridSizeCm * unitsPerCm;
|
||||
const divisionsPerCm = 1;
|
||||
const totalDivisions = gridSizeCm * divisionsPerCm;
|
||||
|
||||
// Create XZ grid (floor)
|
||||
const gridXZ = new THREE.GridHelper(gridSizeUnits, totalDivisions, 0x666688, 0x444466);
|
||||
gridXZ.position.y = -modelHeight / 2 - 10; // Below the model
|
||||
gridGroup.add(gridXZ);
|
||||
|
||||
// Create XY grid (back wall) - vertical
|
||||
const gridXY = new THREE.GridHelper(gridSizeUnits, totalDivisions, 0x668866, 0x446644);
|
||||
gridXY.rotation.x = Math.PI / 2;
|
||||
gridXY.position.z = -gridSizeUnits / 4;
|
||||
gridGroup.add(gridXY);
|
||||
|
||||
// Add axis lines (thicker)
|
||||
const axisMaterial = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
|
||||
|
||||
// X-axis (red)
|
||||
const xAxisGeom = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-gridSizeUnits / 2, -modelHeight / 2 - 10, 0),
|
||||
new THREE.Vector3(gridSizeUnits / 2, -modelHeight / 2 - 10, 0)
|
||||
]);
|
||||
const xAxis = new THREE.Line(xAxisGeom, new THREE.LineBasicMaterial({ color: 0xff4444 }));
|
||||
gridGroup.add(xAxis);
|
||||
|
||||
// Y-axis (green)
|
||||
const yAxisGeom = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, -modelHeight / 2 - 10, 0),
|
||||
new THREE.Vector3(0, modelHeight / 2 + 50, 0)
|
||||
]);
|
||||
const yAxis = new THREE.Line(yAxisGeom, new THREE.LineBasicMaterial({ color: 0x44ff44 }));
|
||||
gridGroup.add(yAxis);
|
||||
|
||||
// Z-axis (blue)
|
||||
const zAxisGeom = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, -modelHeight / 2 - 10, -gridSizeUnits / 2),
|
||||
new THREE.Vector3(0, -modelHeight / 2 - 10, gridSizeUnits / 2)
|
||||
]);
|
||||
const zAxis = new THREE.Line(zAxisGeom, new THREE.LineBasicMaterial({ color: 0x4444ff }));
|
||||
gridGroup.add(zAxis);
|
||||
|
||||
// Add measurement tick marks and labels every 10cm on Y-axis
|
||||
const tickSize = 5;
|
||||
const tickMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
|
||||
|
||||
for (let cm = 0; cm <= gridSizeCm; cm += 10) {
|
||||
const y = -modelHeight / 2 - 10 + cm * unitsPerCm;
|
||||
if (y > modelHeight / 2 + 50) break;
|
||||
|
||||
// Tick mark
|
||||
const tickGeom = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-tickSize, y, 0),
|
||||
new THREE.Vector3(tickSize, y, 0)
|
||||
]);
|
||||
const tick = new THREE.Line(tickGeom, tickMaterial);
|
||||
gridGroup.add(tick);
|
||||
|
||||
// Create text sprite for label
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 32;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 20px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(`${cm}`, 32, 16);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
||||
const sprite = new THREE.Sprite(spriteMaterial);
|
||||
sprite.position.set(-tickSize - 15, y, 0);
|
||||
sprite.scale.set(20, 10, 1);
|
||||
gridGroup.add(sprite);
|
||||
}
|
||||
}
|
||||
|
||||
// Add "cm" label at top
|
||||
const cmCanvas = document.createElement('canvas');
|
||||
cmCanvas.width = 64;
|
||||
cmCanvas.height = 32;
|
||||
const cmCtx = cmCanvas.getContext('2d');
|
||||
if (cmCtx) {
|
||||
cmCtx.fillStyle = '#88ff88';
|
||||
cmCtx.font = 'bold 18px Arial';
|
||||
cmCtx.textAlign = 'center';
|
||||
cmCtx.textBaseline = 'middle';
|
||||
cmCtx.fillText('cm', 32, 16);
|
||||
|
||||
const cmTexture = new THREE.CanvasTexture(cmCanvas);
|
||||
const cmSpriteMaterial = new THREE.SpriteMaterial({ map: cmTexture, transparent: true });
|
||||
const cmSprite = new THREE.Sprite(cmSpriteMaterial);
|
||||
cmSprite.position.set(-25, modelHeight / 2 + 30, 0);
|
||||
cmSprite.scale.set(25, 12, 1);
|
||||
gridGroup.add(cmSprite);
|
||||
}
|
||||
|
||||
gridGroupRef.current = gridGroup;
|
||||
scene.add(gridGroup);
|
||||
}, []);
|
||||
|
||||
// Keep params ref in sync
|
||||
useEffect(() => {
|
||||
paramsRef.current = transformParams;
|
||||
}, [transformParams]);
|
||||
|
||||
const modelUrl = glbUrl || stlUrl;
|
||||
const modelType = glbUrl ? 'glb' : (stlUrl ? 'stl' : null);
|
||||
|
||||
// Load Three.js dynamically
|
||||
useEffect(() => {
|
||||
const loadThree = async () => {
|
||||
try {
|
||||
const threeModule = await import('three');
|
||||
THREE = threeModule;
|
||||
|
||||
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
|
||||
STLLoader = STL;
|
||||
|
||||
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||
GLTFLoader = GLTF;
|
||||
|
||||
const { STLExporter: STLE } = await import('three/examples/jsm/exporters/STLExporter.js');
|
||||
STLExporter = STLE;
|
||||
|
||||
const { GLTFExporter: GLTFE } = await import('three/examples/jsm/exporters/GLTFExporter.js');
|
||||
GLTFExporter = GLTFE;
|
||||
|
||||
const { OrbitControls: OC } = await import('three/examples/jsm/controls/OrbitControls.js');
|
||||
OrbitControls = OC;
|
||||
|
||||
setThreeLoaded(true);
|
||||
} catch (e) {
|
||||
console.error('Failed to load Three.js:', e);
|
||||
setError('Failed to load 3D viewer');
|
||||
}
|
||||
};
|
||||
|
||||
loadThree();
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize scene
|
||||
useEffect(() => {
|
||||
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1e1e2e);
|
||||
sceneRef.current = scene;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
|
||||
camera.position.set(0, 0, 400);
|
||||
camera.lookAt(0, 0, 0);
|
||||
cameraRef.current = camera;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
container.appendChild(renderer.domElement);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
// OrbitControls for manual rotation
|
||||
if (OrbitControls) {
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.autoRotate = autoRotateRef.current;
|
||||
controls.autoRotateSpeed = rotationSpeedRef.current * 100;
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.minDistance = 100;
|
||||
controls.maxDistance = 800;
|
||||
controls.target.set(0, 0, 0);
|
||||
controlsRef.current = controls;
|
||||
}
|
||||
|
||||
// Improved lighting for skin-like appearance with shadows
|
||||
// Soft ambient for base illumination
|
||||
scene.add(new THREE.AmbientLight(0xffeedd, 0.4));
|
||||
|
||||
// Hemisphere light for natural sky/ground coloring
|
||||
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x8d7c6d, 0.5);
|
||||
hemiLight.position.set(0, 200, 0);
|
||||
scene.add(hemiLight);
|
||||
|
||||
// Key light (main light with shadows)
|
||||
const keyLight = new THREE.DirectionalLight(0xfff5e8, 1.0);
|
||||
keyLight.position.set(150, 200, 300);
|
||||
keyLight.castShadow = true;
|
||||
keyLight.shadow.mapSize.width = 1024;
|
||||
keyLight.shadow.mapSize.height = 1024;
|
||||
keyLight.shadow.camera.near = 50;
|
||||
keyLight.shadow.camera.far = 1000;
|
||||
keyLight.shadow.camera.left = -200;
|
||||
keyLight.shadow.camera.right = 200;
|
||||
keyLight.shadow.camera.top = 200;
|
||||
keyLight.shadow.camera.bottom = -200;
|
||||
keyLight.shadow.bias = -0.0005;
|
||||
scene.add(keyLight);
|
||||
|
||||
// Fill light (softer, opposite side)
|
||||
const fillLight = new THREE.DirectionalLight(0xe8f0ff, 0.5);
|
||||
fillLight.position.set(-200, 100, -100);
|
||||
scene.add(fillLight);
|
||||
|
||||
// Rim light (back light for edge definition)
|
||||
const rimLight = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
rimLight.position.set(0, 50, -300);
|
||||
scene.add(rimLight);
|
||||
|
||||
// Bottom fill (subtle, prevents too dark shadows underneath)
|
||||
const bottomFill = new THREE.DirectionalLight(0xffe8d0, 0.2);
|
||||
bottomFill.position.set(0, -200, 100);
|
||||
scene.add(bottomFill);
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
// Update orbit controls
|
||||
if (controlsRef.current) {
|
||||
controlsRef.current.update();
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
if (!container || !renderer || !camera) return;
|
||||
const newWidth = container.clientWidth;
|
||||
const newHeight = container.clientHeight;
|
||||
camera.aspect = newWidth / newHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(newWidth, newHeight);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [threeLoaded]);
|
||||
|
||||
// Extract markers from GLB
|
||||
const extractMarkers = useCallback((root: any): MarkerMap => {
|
||||
const markers: MarkerMap = {};
|
||||
|
||||
root.traverse((obj: any) => {
|
||||
if (obj.name && obj.name.startsWith('LM_')) {
|
||||
const worldPos = new THREE.Vector3();
|
||||
obj.getWorldPosition(worldPos);
|
||||
markers[obj.name] = { x: worldPos.x, y: worldPos.y, z: worldPos.z };
|
||||
}
|
||||
});
|
||||
|
||||
return markers;
|
||||
}, []);
|
||||
|
||||
// Apply ellipsoid deformation to geometry
|
||||
const applyDeformation = useCallback((geometry: any, params: BraceTransformParams, markers: MarkerMap) => {
|
||||
if (!THREE || !geometry) return geometry;
|
||||
|
||||
// Clone geometry to avoid mutating original
|
||||
const deformed = geometry.clone();
|
||||
const positions = deformed.getAttribute('position');
|
||||
|
||||
if (!positions) return deformed;
|
||||
|
||||
// Compute bounding box for fallback positioning
|
||||
deformed.computeBoundingBox();
|
||||
const bbox = deformed.boundingBox;
|
||||
const minY = bbox?.min?.y || 0;
|
||||
const maxY = bbox?.max?.y || 1;
|
||||
const minX = bbox?.min?.x || -0.5;
|
||||
const maxX = bbox?.max?.x || 0.5;
|
||||
const minZ = bbox?.min?.z || -0.5;
|
||||
const maxZ = bbox?.max?.z || 0.5;
|
||||
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerZ = (minZ + maxZ) / 2;
|
||||
const bboxHeight = maxY - minY;
|
||||
const bboxWidth = maxX - minX;
|
||||
const bboxDepth = maxZ - minZ;
|
||||
|
||||
// Get brace basis from markers OR fallback to bbox
|
||||
const pelvis = markers['LM_PELVIS_CENTER'] || { x: centerX, y: minY, z: centerZ };
|
||||
const top = markers['LM_TOP_CENTER'] || { x: centerX, y: maxY, z: centerZ };
|
||||
|
||||
const braceHeight = Math.sqrt(
|
||||
Math.pow(top.x - pelvis.x, 2) +
|
||||
Math.pow(top.y - pelvis.y, 2) +
|
||||
Math.pow(top.z - pelvis.z, 2)
|
||||
) || bboxHeight || 1;
|
||||
|
||||
const unitsPerMm = braceHeight / Math.max(1e-6, params.expectedBraceHeightMm);
|
||||
|
||||
// Get pad/bay markers - with fallback positions based on bounding box
|
||||
// Thoracic pad: right side, upper region
|
||||
const thPad = markers['LM_PAD_TH'] || {
|
||||
x: centerX + bboxWidth * 0.35,
|
||||
y: minY + bboxHeight * params.apexNorm,
|
||||
z: centerZ - bboxDepth * 0.1
|
||||
};
|
||||
// Thoracic bay: left side, upper region (opposite side of pad)
|
||||
const thBay = markers['LM_BAY_TH'] || {
|
||||
x: centerX - bboxWidth * 0.35,
|
||||
y: minY + bboxHeight * params.apexNorm,
|
||||
z: centerZ - bboxDepth * 0.1
|
||||
};
|
||||
// Lumbar pad: left side, lower region
|
||||
const lumPad = markers['LM_PAD_LUM'] || {
|
||||
x: centerX - bboxWidth * 0.3,
|
||||
y: minY + bboxHeight * params.lumbarApexNorm,
|
||||
z: centerZ
|
||||
};
|
||||
// Lumbar bay: right side, lower region
|
||||
const lumBay = markers['LM_BAY_LUM'] || {
|
||||
x: centerX + bboxWidth * 0.3,
|
||||
y: minY + bboxHeight * params.lumbarApexNorm,
|
||||
z: centerZ
|
||||
};
|
||||
|
||||
// Severity mapping
|
||||
const sev = Math.max(0, Math.min(1, (params.cobbDeg - 15) / 40));
|
||||
|
||||
// Pad depth based on severity
|
||||
const padDepthMm = (8 + 12 * sev) * params.strengthMult;
|
||||
const bayClearMm = padDepthMm * 1.2;
|
||||
|
||||
const padDepth = padDepthMm * unitsPerMm;
|
||||
const bayClear = bayClearMm * unitsPerMm;
|
||||
|
||||
// Size scale based on severity
|
||||
const sizeScale = 0.9 + 0.5 * sev;
|
||||
|
||||
// Define features (pads and bays) - always create them with fallback positions
|
||||
const features: Array<{
|
||||
center: { x: number; y: number; z: number };
|
||||
radii: { x: number; y: number; z: number };
|
||||
depth: number;
|
||||
direction: 1 | -1;
|
||||
falloffPower: number;
|
||||
}> = [];
|
||||
|
||||
// Add thoracic pad (push inward on convex side)
|
||||
features.push({
|
||||
center: {
|
||||
x: thPad.x,
|
||||
y: pelvis.y + params.apexNorm * braceHeight,
|
||||
z: thPad.z,
|
||||
},
|
||||
radii: {
|
||||
x: 45 * unitsPerMm * sizeScale,
|
||||
y: 90 * unitsPerMm * sizeScale,
|
||||
z: 35 * unitsPerMm * sizeScale
|
||||
},
|
||||
depth: padDepth,
|
||||
direction: -1,
|
||||
falloffPower: 2.0,
|
||||
});
|
||||
|
||||
// Add thoracic bay (relief on concave side)
|
||||
features.push({
|
||||
center: {
|
||||
x: thBay.x,
|
||||
y: pelvis.y + params.apexNorm * braceHeight,
|
||||
z: thBay.z,
|
||||
},
|
||||
radii: {
|
||||
x: 60 * unitsPerMm * sizeScale,
|
||||
y: 110 * unitsPerMm * sizeScale,
|
||||
z: 55 * unitsPerMm * sizeScale
|
||||
},
|
||||
depth: bayClear,
|
||||
direction: 1,
|
||||
falloffPower: 1.6,
|
||||
});
|
||||
|
||||
// Add lumbar pad
|
||||
features.push({
|
||||
center: {
|
||||
x: lumPad.x,
|
||||
y: pelvis.y + params.lumbarApexNorm * braceHeight,
|
||||
z: lumPad.z,
|
||||
},
|
||||
radii: {
|
||||
x: 50 * unitsPerMm * sizeScale,
|
||||
y: 80 * unitsPerMm * sizeScale,
|
||||
z: 40 * unitsPerMm * sizeScale
|
||||
},
|
||||
depth: padDepth * 0.9,
|
||||
direction: -1,
|
||||
falloffPower: 2.0,
|
||||
});
|
||||
|
||||
// Add lumbar bay
|
||||
features.push({
|
||||
center: {
|
||||
x: lumBay.x,
|
||||
y: pelvis.y + params.lumbarApexNorm * braceHeight,
|
||||
z: lumBay.z,
|
||||
},
|
||||
radii: {
|
||||
x: 65 * unitsPerMm * sizeScale,
|
||||
y: 95 * unitsPerMm * sizeScale,
|
||||
z: 55 * unitsPerMm * sizeScale
|
||||
},
|
||||
depth: bayClear * 0.9,
|
||||
direction: 1,
|
||||
falloffPower: 1.6,
|
||||
});
|
||||
|
||||
// Add hip anchors - with fallback positions
|
||||
const hipL = markers['LM_ANCHOR_HIP_L'] || {
|
||||
x: centerX - bboxWidth * 0.4,
|
||||
y: minY + bboxHeight * 0.1,
|
||||
z: centerZ,
|
||||
};
|
||||
const hipR = markers['LM_ANCHOR_HIP_R'] || {
|
||||
x: centerX + bboxWidth * 0.4,
|
||||
y: minY + bboxHeight * 0.1,
|
||||
z: centerZ,
|
||||
};
|
||||
const hipDepth = params.hipAnchorStrengthMm * unitsPerMm * params.strengthMult;
|
||||
|
||||
if (hipDepth > 0) {
|
||||
features.push({
|
||||
center: { x: hipL.x, y: hipL.y, z: hipL.z },
|
||||
radii: {
|
||||
x: 35 * unitsPerMm,
|
||||
y: 55 * unitsPerMm,
|
||||
z: 35 * unitsPerMm
|
||||
},
|
||||
depth: hipDepth,
|
||||
direction: -1,
|
||||
falloffPower: 2.2,
|
||||
});
|
||||
|
||||
features.push({
|
||||
center: { x: hipR.x, y: hipR.y, z: hipR.z },
|
||||
radii: {
|
||||
x: 35 * unitsPerMm,
|
||||
y: 55 * unitsPerMm,
|
||||
z: 35 * unitsPerMm
|
||||
},
|
||||
depth: hipDepth,
|
||||
direction: -1,
|
||||
falloffPower: 2.2,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply deformations
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
let x = positions.getX(i);
|
||||
let y = positions.getY(i);
|
||||
let z = positions.getZ(i);
|
||||
|
||||
// Mirror X if enabled
|
||||
if (params.mirrorX) {
|
||||
x = -x;
|
||||
}
|
||||
|
||||
// Apply trunk shift
|
||||
const heightNorm = Math.max(0, Math.min(1, (y - pelvis.y) / braceHeight));
|
||||
x += params.trunkShiftMm * unitsPerMm * heightNorm * 0.8;
|
||||
|
||||
// Apply ellipsoid deformations
|
||||
for (const feature of features) {
|
||||
const dx = (x - feature.center.x) / feature.radii.x;
|
||||
const dy = (y - feature.center.y) / feature.radii.y;
|
||||
const dz = (z - feature.center.z) / feature.radii.z;
|
||||
|
||||
const d2 = dx * dx + dy * dy + dz * dz;
|
||||
if (d2 >= 1) continue;
|
||||
|
||||
// Smooth falloff
|
||||
const t = Math.pow(1 - d2, feature.falloffPower);
|
||||
const displacement = feature.depth * t * feature.direction;
|
||||
|
||||
// Apply based on push mode
|
||||
if (params.pushMode === 'radial') {
|
||||
// Radial: push away from/toward brace axis
|
||||
const axisPoint = { x: pelvis.x, y: y, z: pelvis.z };
|
||||
const radialX = x - axisPoint.x;
|
||||
const radialZ = z - axisPoint.z;
|
||||
const radialLen = Math.sqrt(radialX * radialX + radialZ * radialZ) || 1;
|
||||
|
||||
x += (radialX / radialLen) * displacement;
|
||||
z += (radialZ / radialLen) * displacement;
|
||||
} else if (params.pushMode === 'lateral') {
|
||||
// Lateral: purely left/right
|
||||
const side = Math.sign(x - pelvis.x) || 1;
|
||||
x += side * displacement;
|
||||
} else {
|
||||
// Normal: would require vertex normals, approximate with radial
|
||||
const axisPoint = { x: pelvis.x, y: y, z: pelvis.z };
|
||||
const radialX = x - axisPoint.x;
|
||||
const radialZ = z - axisPoint.z;
|
||||
const radialLen = Math.sqrt(radialX * radialX + radialZ * radialZ) || 1;
|
||||
|
||||
x += (radialX / radialLen) * displacement;
|
||||
z += (radialZ / radialLen) * displacement;
|
||||
}
|
||||
}
|
||||
|
||||
positions.setXYZ(i, x, y, z);
|
||||
}
|
||||
|
||||
positions.needsUpdate = true;
|
||||
deformed.computeVertexNormals();
|
||||
|
||||
return deformed;
|
||||
}, []);
|
||||
|
||||
// Load model
|
||||
useEffect(() => {
|
||||
if (!threeLoaded || !modelUrl || !sceneRef.current) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Clear previous model
|
||||
if (modelGroupRef.current) {
|
||||
sceneRef.current.remove(modelGroupRef.current);
|
||||
modelGroupRef.current = null;
|
||||
}
|
||||
meshRef.current = null;
|
||||
baseGeometryRef.current = null;
|
||||
markersRef.current = {};
|
||||
|
||||
const group = new THREE.Group();
|
||||
modelGroupRef.current = group;
|
||||
|
||||
const onLoadGLB = (gltf: any) => {
|
||||
// Extract markers
|
||||
markersRef.current = extractMarkers(gltf.scene);
|
||||
|
||||
// Notify parent of markers
|
||||
if (onMarkersLoaded) {
|
||||
const markerList: MarkerInfo[] = Object.entries(markersRef.current).map(([name, pos]) => ({
|
||||
name,
|
||||
position: [pos.x, pos.y, pos.z],
|
||||
}));
|
||||
onMarkersLoaded(markerList);
|
||||
}
|
||||
|
||||
// Find the main mesh
|
||||
let mainMesh: any = null;
|
||||
let maxVertices = 0;
|
||||
|
||||
gltf.scene.traverse((child: any) => {
|
||||
if (child.isMesh && !child.name.startsWith('LM_')) {
|
||||
const count = child.geometry.getAttribute('position')?.count || 0;
|
||||
if (count > maxVertices) {
|
||||
maxVertices = count;
|
||||
mainMesh = child;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!mainMesh) {
|
||||
setError('No mesh found in model');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store base geometry
|
||||
baseGeometryRef.current = mainMesh.geometry.clone();
|
||||
|
||||
// Create mesh with skin-like material
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
|
||||
roughness: 0.7, // Slightly smooth for skin-like appearance
|
||||
metalness: 0.0,
|
||||
side: THREE.DoubleSide,
|
||||
flatShading: false,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(baseGeometryRef.current.clone(), material);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
meshRef.current = mesh;
|
||||
|
||||
// Center and scale
|
||||
const box = new THREE.Box3().setFromObject(mesh);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
mesh.position.sub(center);
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 200 / maxDim;
|
||||
mesh.scale.multiplyScalar(scale);
|
||||
|
||||
// Calculate real-world dimensions (assuming model units are mm)
|
||||
// Typical brace height is 350-450mm, width 250-350mm
|
||||
const scaledSize = {
|
||||
x: size.x * scale,
|
||||
y: size.y * scale,
|
||||
z: size.z * scale,
|
||||
};
|
||||
|
||||
// Store units per cm for grid (model is in mm, so 10mm = 1cm)
|
||||
const unitsPerCm = scale * 10; // scale * 10mm
|
||||
realWorldScaleRef.current = unitsPerCm;
|
||||
|
||||
// Store dimensions in cm
|
||||
setDimensions({
|
||||
width: Math.round(size.x / 10 * 10) / 10, // X dimension in cm
|
||||
height: Math.round(size.y / 10 * 10) / 10, // Y dimension in cm
|
||||
depth: Math.round(size.z / 10 * 10) / 10, // Z dimension in cm
|
||||
});
|
||||
|
||||
// Create measurement grid if enabled
|
||||
if (showGrid && sceneRef.current) {
|
||||
createMeasurementGrid(sceneRef.current, unitsPerCm, scaledSize.y);
|
||||
}
|
||||
|
||||
// Scale markers to match
|
||||
const scaledMarkers: MarkerMap = {};
|
||||
for (const [name, pos] of Object.entries(markersRef.current)) {
|
||||
scaledMarkers[name] = {
|
||||
x: (pos.x - center.x) * scale,
|
||||
y: (pos.y - center.y) * scale,
|
||||
z: (pos.z - center.z) * scale,
|
||||
};
|
||||
}
|
||||
markersRef.current = scaledMarkers;
|
||||
|
||||
// Add marker spheres if enabled
|
||||
if (showMarkers) {
|
||||
Object.entries(scaledMarkers).forEach(([name, pos]) => {
|
||||
const sphereGeom = new THREE.SphereGeometry(3, 16, 16);
|
||||
const sphereMat = new THREE.MeshBasicMaterial({
|
||||
color: name.includes('PAD') ? 0x00ff00 :
|
||||
name.includes('BAY') ? 0x0088ff :
|
||||
name.includes('HIP') ? 0xff8800 : 0xff0000,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
});
|
||||
const sphere = new THREE.Mesh(sphereGeom, sphereMat);
|
||||
sphere.position.set(pos.x, pos.y, pos.z);
|
||||
sphere.name = `marker_${name}`;
|
||||
group.add(sphere);
|
||||
});
|
||||
}
|
||||
|
||||
group.add(mesh);
|
||||
group.position.set(0, 0, 0);
|
||||
sceneRef.current.add(group);
|
||||
|
||||
// Apply initial deformation if params provided
|
||||
if (transformParams) {
|
||||
applyTransformToMesh(transformParams);
|
||||
}
|
||||
|
||||
cameraRef.current.position.set(0, 0, 350);
|
||||
cameraRef.current.lookAt(0, 0, 0);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onLoadSTL = (geometry: any) => {
|
||||
geometry.center();
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
// Store base geometry
|
||||
baseGeometryRef.current = geometry.clone();
|
||||
|
||||
// Skin-like material matching GLB loader
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
|
||||
roughness: 0.7, // Slightly smooth for skin-like appearance
|
||||
metalness: 0.0,
|
||||
side: THREE.DoubleSide,
|
||||
flatShading: false,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
meshRef.current = mesh;
|
||||
|
||||
// Scale to fit
|
||||
const box = new THREE.Box3().setFromObject(mesh);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 200 / maxDim;
|
||||
mesh.scale.multiplyScalar(scale);
|
||||
|
||||
// Calculate real-world dimensions (assuming model units are mm)
|
||||
const scaledSize = {
|
||||
x: size.x * scale,
|
||||
y: size.y * scale,
|
||||
z: size.z * scale,
|
||||
};
|
||||
|
||||
// Store units per cm for grid (model is in mm, so 10mm = 1cm)
|
||||
const unitsPerCm = scale * 10;
|
||||
realWorldScaleRef.current = unitsPerCm;
|
||||
|
||||
// Store dimensions in cm
|
||||
setDimensions({
|
||||
width: Math.round(size.x / 10 * 10) / 10,
|
||||
height: Math.round(size.y / 10 * 10) / 10,
|
||||
depth: Math.round(size.z / 10 * 10) / 10,
|
||||
});
|
||||
|
||||
// Create measurement grid if enabled
|
||||
if (showGrid && sceneRef.current) {
|
||||
createMeasurementGrid(sceneRef.current, unitsPerCm, scaledSize.y);
|
||||
}
|
||||
|
||||
group.add(mesh);
|
||||
group.position.set(0, 0, 0);
|
||||
sceneRef.current.add(group);
|
||||
|
||||
cameraRef.current.position.set(0, 0, 350);
|
||||
cameraRef.current.lookAt(0, 0, 0);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onError = (err: any) => {
|
||||
console.error('Failed to load model:', err);
|
||||
setError('Failed to load 3D model');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (modelType === 'stl') {
|
||||
const loader = new STLLoader();
|
||||
loader.load(modelUrl, onLoadSTL, undefined, onError);
|
||||
} else if (modelType === 'glb') {
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(modelUrl, onLoadGLB, undefined, onError);
|
||||
}
|
||||
}, [threeLoaded, modelUrl, modelType, showMarkers, showGrid, extractMarkers, onMarkersLoaded, createMeasurementGrid]);
|
||||
|
||||
// Apply transform when params change
|
||||
const applyTransformToMesh = useCallback((params: BraceTransformParams) => {
|
||||
if (!meshRef.current || !baseGeometryRef.current || !THREE) return;
|
||||
|
||||
const deformedGeometry = applyDeformation(
|
||||
baseGeometryRef.current.clone(),
|
||||
params,
|
||||
markersRef.current
|
||||
);
|
||||
|
||||
meshRef.current.geometry.dispose();
|
||||
meshRef.current.geometry = deformedGeometry;
|
||||
|
||||
if (onGeometryUpdated) {
|
||||
onGeometryUpdated();
|
||||
}
|
||||
}, [applyDeformation, onGeometryUpdated]);
|
||||
|
||||
// Watch for transform params changes
|
||||
useEffect(() => {
|
||||
if (transformParams && meshRef.current && baseGeometryRef.current) {
|
||||
applyTransformToMesh(transformParams);
|
||||
}
|
||||
}, [transformParams, applyTransformToMesh]);
|
||||
|
||||
// Export functions
|
||||
useImperativeHandle(ref, () => ({
|
||||
exportSTL: async () => {
|
||||
if (!meshRef.current || !STLExporter) return null;
|
||||
|
||||
const exporter = new STLExporter();
|
||||
const stlString = exporter.parse(meshRef.current, { binary: true });
|
||||
return new Blob([stlString], { type: 'application/octet-stream' });
|
||||
},
|
||||
|
||||
exportGLB: async () => {
|
||||
if (!meshRef.current || !GLTFExporter) return null;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const exporter = new GLTFExporter();
|
||||
exporter.parse(
|
||||
meshRef.current,
|
||||
(result: any) => {
|
||||
const blob = new Blob([result], { type: 'application/octet-stream' });
|
||||
resolve(blob);
|
||||
},
|
||||
(error: any) => {
|
||||
console.error('GLB export error:', error);
|
||||
resolve(null);
|
||||
},
|
||||
{ binary: true }
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
getModifiedGeometry: () => {
|
||||
return meshRef.current?.geometry || null;
|
||||
},
|
||||
}), []);
|
||||
|
||||
if (!threeLoaded) {
|
||||
return (
|
||||
<div className="brace-transform-viewer-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading 3D viewer...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="brace-transform-viewer-container">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="brace-transform-viewer-canvas"
|
||||
/>
|
||||
|
||||
{/* Dimensions overlay */}
|
||||
{showGrid && dimensions && !loading && (
|
||||
<div className="brace-dimensions-overlay">
|
||||
<div className="dimensions-title">Dimensions (cm)</div>
|
||||
<div className="dimension-row">
|
||||
<span className="dim-label">Width:</span>
|
||||
<span className="dim-value">{dimensions.width.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="dimension-row">
|
||||
<span className="dim-label">Height:</span>
|
||||
<span className="dim-value">{dimensions.height.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="dimension-row">
|
||||
<span className="dim-label">Depth:</span>
|
||||
<span className="dim-value">{dimensions.depth.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="brace-transform-viewer-overlay">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading brace model...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="brace-transform-viewer-overlay error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!modelUrl && !loading && (
|
||||
<div className="brace-transform-viewer-overlay placeholder">
|
||||
<div className="placeholder-icon">🦾</div>
|
||||
<p>Generate a brace to see 3D preview</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
BraceTransformViewer.displayName = 'BraceTransformViewer';
|
||||
|
||||
export default BraceTransformViewer;
|
||||
303
frontend/src/components/three/BraceViewer.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 3D Brace Viewer Component
|
||||
* Uses Three.js to display GLB brace models with markers
|
||||
*
|
||||
* Based on EXPERIMENT_6's brace-transform-playground-v2
|
||||
*/
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
// Three.js is loaded dynamically to avoid SSR issues
|
||||
let THREE: any = null;
|
||||
let GLTFLoader: any = null;
|
||||
let OrbitControls: any = null;
|
||||
|
||||
type MarkerInfo = {
|
||||
name: string;
|
||||
position: [number, number, number];
|
||||
color: string;
|
||||
};
|
||||
|
||||
type BraceViewerProps = {
|
||||
glbUrl: string | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
showMarkers?: boolean;
|
||||
onMarkersLoaded?: (markers: MarkerInfo[]) => void;
|
||||
deformationParams?: {
|
||||
thoracicPadDepth?: number;
|
||||
lumbarPadDepth?: number;
|
||||
trunkShift?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default function BraceViewer({
|
||||
glbUrl,
|
||||
width = 600,
|
||||
height = 500,
|
||||
showMarkers = true,
|
||||
onMarkersLoaded,
|
||||
deformationParams,
|
||||
}: BraceViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<any>(null);
|
||||
const sceneRef = useRef<any>(null);
|
||||
const cameraRef = useRef<any>(null);
|
||||
const controlsRef = useRef<any>(null);
|
||||
const braceMeshRef = useRef<any>(null);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [threeLoaded, setThreeLoaded] = useState(false);
|
||||
|
||||
// Load Three.js dynamically
|
||||
useEffect(() => {
|
||||
const loadThree = async () => {
|
||||
try {
|
||||
const threeModule = await import('three');
|
||||
THREE = threeModule;
|
||||
|
||||
const { GLTFLoader: Loader } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||
GLTFLoader = Loader;
|
||||
|
||||
const { OrbitControls: Controls } = await import('three/examples/jsm/controls/OrbitControls.js');
|
||||
OrbitControls = Controls;
|
||||
|
||||
setThreeLoaded(true);
|
||||
} catch (e) {
|
||||
console.error('Failed to load Three.js:', e);
|
||||
setError('Failed to load 3D viewer');
|
||||
}
|
||||
};
|
||||
|
||||
loadThree();
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize scene
|
||||
const initScene = useCallback(() => {
|
||||
if (!THREE || !containerRef.current) return;
|
||||
|
||||
// Scene
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a1a);
|
||||
sceneRef.current = scene;
|
||||
|
||||
// Camera
|
||||
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 10000);
|
||||
camera.position.set(300, 200, 400);
|
||||
cameraRef.current = camera;
|
||||
|
||||
// Renderer
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
containerRef.current.appendChild(renderer.domElement);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
// Controls
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.target.set(0, 100, 0);
|
||||
controls.update();
|
||||
controlsRef.current = controls;
|
||||
|
||||
// Lighting
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.4);
|
||||
scene.add(hemisphereLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
directionalLight.position.set(200, 300, 200);
|
||||
scene.add(directionalLight);
|
||||
|
||||
// Grid helper (optional)
|
||||
const gridHelper = new THREE.GridHelper(500, 20, 0x444444, 0x333333);
|
||||
scene.add(gridHelper);
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
}, [width, height]);
|
||||
|
||||
// Initialize scene when Three.js is loaded
|
||||
useEffect(() => {
|
||||
if (threeLoaded && containerRef.current && !rendererRef.current) {
|
||||
initScene();
|
||||
}
|
||||
}, [threeLoaded, initScene]);
|
||||
|
||||
// Load GLB when URL changes
|
||||
useEffect(() => {
|
||||
if (!threeLoaded || !glbUrl || !sceneRef.current) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
|
||||
loader.load(
|
||||
glbUrl,
|
||||
(gltf: any) => {
|
||||
// Clear previous mesh
|
||||
if (braceMeshRef.current) {
|
||||
sceneRef.current.remove(braceMeshRef.current);
|
||||
braceMeshRef.current = null;
|
||||
}
|
||||
|
||||
// Process loaded model
|
||||
const scene = gltf.scene;
|
||||
const markers: MarkerInfo[] = [];
|
||||
let mainMesh: any = null;
|
||||
|
||||
// Find main mesh and markers
|
||||
scene.traverse((child: any) => {
|
||||
if (child.isMesh) {
|
||||
// Check if it's a marker
|
||||
if (child.name.startsWith('LM_')) {
|
||||
markers.push({
|
||||
name: child.name,
|
||||
position: child.position.toArray() as [number, number, number],
|
||||
color: getMarkerColor(child.name),
|
||||
});
|
||||
|
||||
// Style marker for visibility
|
||||
if (showMarkers) {
|
||||
child.material = new THREE.MeshBasicMaterial({
|
||||
color: getMarkerColor(child.name),
|
||||
depthTest: false,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
});
|
||||
child.renderOrder = 999;
|
||||
} else {
|
||||
child.visible = false;
|
||||
}
|
||||
} else {
|
||||
// Main brace mesh
|
||||
if (!mainMesh || child.geometry.attributes.position.count > mainMesh.geometry.attributes.position.count) {
|
||||
mainMesh = child;
|
||||
}
|
||||
|
||||
// Apply standard material
|
||||
child.material = new THREE.MeshStandardMaterial({
|
||||
color: 0xcccccc,
|
||||
metalness: 0.2,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add to scene
|
||||
sceneRef.current.add(scene);
|
||||
braceMeshRef.current = scene;
|
||||
|
||||
// Center camera on mesh
|
||||
if (mainMesh) {
|
||||
const box = new THREE.Box3().setFromObject(scene);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
controlsRef.current.target.copy(center);
|
||||
cameraRef.current.position.set(
|
||||
center.x + size.x,
|
||||
center.y + size.y * 0.5,
|
||||
center.z + size.z
|
||||
);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
|
||||
// Notify about markers
|
||||
if (onMarkersLoaded && markers.length > 0) {
|
||||
onMarkersLoaded(markers);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
},
|
||||
undefined,
|
||||
(err: any) => {
|
||||
console.error('Failed to load GLB:', err);
|
||||
setError('Failed to load 3D model');
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
}, [threeLoaded, glbUrl, showMarkers, onMarkersLoaded]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
if (rendererRef.current && cameraRef.current) {
|
||||
rendererRef.current.setSize(width, height);
|
||||
cameraRef.current.aspect = width / height;
|
||||
cameraRef.current.updateProjectionMatrix();
|
||||
}
|
||||
}, [width, height]);
|
||||
|
||||
// Loading state
|
||||
if (!threeLoaded) {
|
||||
return (
|
||||
<div className="brace-viewer-loading" style={{ width, height }}>
|
||||
<div className="spinner"></div>
|
||||
<p>Loading 3D viewer...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="brace-viewer-container" style={{ width, height }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="brace-viewer-canvas"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<div className="brace-viewer-overlay">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading model...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="brace-viewer-overlay error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!glbUrl && !loading && (
|
||||
<div className="brace-viewer-overlay placeholder">
|
||||
<p>No 3D model loaded</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to get marker color based on name
|
||||
function getMarkerColor(name: string): string {
|
||||
if (name.includes('PELVIS')) return '#ff0000'; // Red
|
||||
if (name.includes('TOP')) return '#00ff00'; // Green
|
||||
if (name.includes('PAD_TH')) return '#ff00ff'; // Magenta (thoracic pad)
|
||||
if (name.includes('BAY_TH')) return '#00ffff'; // Cyan (thoracic bay)
|
||||
if (name.includes('PAD_LUM')) return '#ffff00'; // Yellow (lumbar pad)
|
||||
if (name.includes('BAY_LUM')) return '#ff8800'; // Orange (lumbar bay)
|
||||
if (name.includes('ANCHOR')) return '#8800ff'; // Purple (anchors)
|
||||
return '#ffffff'; // White (default)
|
||||
}
|
||||
5
frontend/src/components/three/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as BraceViewer } from './BraceViewer';
|
||||
export { default as BodyScanViewer } from './BodyScanViewer';
|
||||
export { default as BraceModelViewer } from './BraceModelViewer';
|
||||
export { default as BraceTransformViewer } from './BraceTransformViewer';
|
||||
export type { BraceTransformViewerRef } from './BraceTransformViewer';
|
||||
174
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
fullName: string | null;
|
||||
role: "admin" | "user" | "viewer";
|
||||
};
|
||||
|
||||
type AuthContextType = {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
error: string | null;
|
||||
clearError: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const AUTH_STORAGE_KEY = "braceflow_auth";
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api";
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check for existing session on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.user && parsed.token && parsed.expiresAt > Date.now()) {
|
||||
setUser(parsed.user);
|
||||
setToken(parsed.token);
|
||||
} else {
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || "Invalid username or password");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const userData: User = {
|
||||
id: data.user.id,
|
||||
username: data.user.username,
|
||||
fullName: data.user.full_name,
|
||||
role: data.user.role,
|
||||
};
|
||||
|
||||
// Store auth data
|
||||
const authData = {
|
||||
user: userData,
|
||||
token: data.token,
|
||||
expiresAt: new Date(data.expiresAt).getTime(),
|
||||
};
|
||||
|
||||
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(authData));
|
||||
setUser(userData);
|
||||
setToken(data.token);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Login failed. Please try again.");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
// Call logout endpoint if we have a token
|
||||
if (token) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Ignore logout API errors
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
}, [token]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
isAdmin: user?.role === "admin",
|
||||
login,
|
||||
logout,
|
||||
error,
|
||||
clearError,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get auth headers for API calls
|
||||
*/
|
||||
export function getAuthHeaders(): Record<string, string> {
|
||||
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.token) {
|
||||
return { "Authorization": `Bearer ${parsed.token}` };
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the auth token
|
||||
*/
|
||||
export function getAuthToken(): string | null {
|
||||
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.token || null;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
68
frontend/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
107
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
const API_BASE = 'https://cfx9z50wj2.execute-api.ca-central-1.amazonaws.com/prod';
|
||||
|
||||
async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const base = API_BASE ? API_BASE.replace(/\/+$/, '') : '';
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = `${base}${normalizedPath}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export type CaseStatus = {
|
||||
case: {
|
||||
case_id: string;
|
||||
status: string;
|
||||
current_step: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
steps: Array<{
|
||||
step_name: string;
|
||||
step_order: number;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
error_message: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CaseAssets = {
|
||||
caseId: string;
|
||||
apImageUrl: string;
|
||||
bucket?: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export type SubmitLandmarksRequest = {
|
||||
// Backend Lambda requires caseId in body (even though it's also in the URL)
|
||||
caseId?: string;
|
||||
view: "ap";
|
||||
landmarks: Record<string, { x: number; y: number }>;
|
||||
};
|
||||
|
||||
export type SubmitLandmarksResponse = {
|
||||
ok: boolean;
|
||||
caseId: string;
|
||||
resumedPipeline: boolean;
|
||||
values: {
|
||||
pelvis_offset_px: number;
|
||||
t1_offset_px: number;
|
||||
tp_offset_px: number;
|
||||
dominant_curve: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UploadUrlResponse = {
|
||||
uploadUrl: string;
|
||||
key?: string;
|
||||
s3Key?: string;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
createCase: (body: { notes?: string } = {}) =>
|
||||
http<{ caseId: string }>(`/cases`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
startCase: (caseId: string) =>
|
||||
http<{ caseId: string; executionArn?: string; status?: string }>(
|
||||
`/cases/${encodeURIComponent(caseId)}/start`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
),
|
||||
|
||||
getUploadUrl: (caseId: string, body: { view: string; contentType?: string; filename?: string }) =>
|
||||
http<UploadUrlResponse>(`/cases/${encodeURIComponent(caseId)}/upload-url`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
getCaseStatus: (caseId: string) => http<CaseStatus>(`/cases/${encodeURIComponent(caseId)}`),
|
||||
getCaseAssets: (caseId: string) => http<CaseAssets>(`/cases/${encodeURIComponent(caseId)}/assets`),
|
||||
|
||||
// FIX: include caseId in JSON body to satisfy backend Lambda contract
|
||||
submitLandmarks: (caseId: string, body: SubmitLandmarksRequest) =>
|
||||
http<SubmitLandmarksResponse>(`/cases/${encodeURIComponent(caseId)}/landmarks`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
14
frontend/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
import "./App.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
472
frontend/src/pages/BraceAnalysisPage.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import UploadPanel from "../components/rigo/UploadPanel";
|
||||
import BraceViewer from "../components/rigo/BraceViewer";
|
||||
import {
|
||||
analyzeXray,
|
||||
getBraceOutputs,
|
||||
type GenerateBraceResponse,
|
||||
type BraceOutput,
|
||||
type CobbAngles,
|
||||
type RigoClassification,
|
||||
} from "../api/braceflowApi";
|
||||
|
||||
// Helper to format file size
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// Helper to get severity level
|
||||
function getSeverity(cobbAngles: CobbAngles | undefined): { level: string; class: string; angle: number } {
|
||||
const maxAngle = Math.max(cobbAngles?.PT ?? 0, cobbAngles?.MT ?? 0, cobbAngles?.TL ?? 0);
|
||||
if (maxAngle < 10) return { level: "Normal", class: "success", angle: maxAngle };
|
||||
if (maxAngle < 25) return { level: "Mild", class: "success", angle: maxAngle };
|
||||
if (maxAngle < 40) return { level: "Moderate", class: "highlight", angle: maxAngle };
|
||||
return { level: "Severe", class: "warning", angle: maxAngle };
|
||||
}
|
||||
|
||||
// Metric Card component
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
highlight?: "success" | "highlight" | "warning";
|
||||
}) {
|
||||
return (
|
||||
<div className="rigo-analysis-card">
|
||||
<div className="rigo-analysis-label">{label}</div>
|
||||
<div className={`rigo-analysis-value ${highlight || ""}`}>{value}</div>
|
||||
{description && <div className="rigo-analysis-description">{description}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Download button for outputs
|
||||
function DownloadButton({ output }: { output: BraceOutput }) {
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "stl":
|
||||
case "ply":
|
||||
case "obj":
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
);
|
||||
case "json":
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={output.url}
|
||||
download={output.filename}
|
||||
className="rigo-btn rigo-btn-secondary"
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, textDecoration: "none" }}
|
||||
>
|
||||
{getIcon(output.type)}
|
||||
<span style={{ flex: 1, textAlign: "left" }}>
|
||||
{output.filename}
|
||||
<span style={{ color: "#64748b", fontSize: "0.75rem", marginLeft: 8 }}>
|
||||
({formatBytes(output.size)})
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Cobb Angles Display
|
||||
function CobbAnglesDisplay({ angles }: { angles: CobbAngles | undefined }) {
|
||||
if (!angles) return null;
|
||||
|
||||
const entries = [
|
||||
{ label: "Proximal Thoracic (PT)", value: angles.PT },
|
||||
{ label: "Main Thoracic (MT)", value: angles.MT },
|
||||
{ label: "Thoracolumbar (TL)", value: angles.TL },
|
||||
].filter((e) => e.value !== undefined && e.value !== null);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rigo-analysis-grid" style={{ gridTemplateColumns: `repeat(${entries.length}, 1fr)` }}>
|
||||
{entries.map((entry) => (
|
||||
<MetricCard
|
||||
key={entry.label}
|
||||
label={entry.label}
|
||||
value={`${entry.value?.toFixed(1)}°`}
|
||||
highlight={entry.value && entry.value > 25 ? (entry.value > 40 ? "warning" : "highlight") : "success"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Rigo Classification Display
|
||||
function RigoDisplay({ classification }: { classification: RigoClassification | undefined }) {
|
||||
if (!classification) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rigo-analysis-card"
|
||||
style={{ background: "rgba(59, 130, 246, 0.1)", borderColor: "#2563eb" }}
|
||||
>
|
||||
<div className="rigo-analysis-label" style={{ color: "#60a5fa" }}>
|
||||
Rigo-Chêneau Classification
|
||||
</div>
|
||||
<div className="rigo-analysis-value highlight">{classification.type}</div>
|
||||
<div className="rigo-analysis-description">{classification.description}</div>
|
||||
{classification.curve_pattern && (
|
||||
<div style={{ marginTop: 8, fontSize: "0.875rem", color: "#94a3b8" }}>
|
||||
Curve Pattern: <strong style={{ color: "#f1f5f9" }}>{classification.curve_pattern}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BraceAnalysisPage() {
|
||||
const { caseId: routeCaseId } = useParams<{ caseId?: string }>();
|
||||
|
||||
const [caseId, setCaseId] = useState<string | null>(routeCaseId || null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<GenerateBraceResponse | null>(null);
|
||||
const [outputs, setOutputs] = useState<BraceOutput[]>([]);
|
||||
const [modelUrl, setModelUrl] = useState<string | null>(null);
|
||||
|
||||
// Handle file upload and analysis
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setOutputs([]);
|
||||
setModelUrl(null);
|
||||
|
||||
try {
|
||||
// Run the full workflow
|
||||
const { caseId: newCaseId, result: analysisResult } = await analyzeXray(file);
|
||||
setCaseId(newCaseId);
|
||||
setResult(analysisResult);
|
||||
|
||||
// Find the GLB or STL model URL for 3D viewer
|
||||
const modelOutput =
|
||||
analysisResult.outputs?.["glb"] ||
|
||||
analysisResult.outputs?.["ply"] ||
|
||||
analysisResult.outputs?.["stl"];
|
||||
|
||||
if (modelOutput?.url) {
|
||||
setModelUrl(modelOutput.url);
|
||||
}
|
||||
|
||||
// Get all outputs with presigned URLs
|
||||
const outputsResponse = await getBraceOutputs(newCaseId);
|
||||
setOutputs(outputsResponse.outputs);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Analysis failed. Please try again.";
|
||||
setError(message);
|
||||
console.error("Analysis error:", err);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reset state
|
||||
const handleReset = useCallback(() => {
|
||||
setCaseId(null);
|
||||
setResult(null);
|
||||
setOutputs([]);
|
||||
setModelUrl(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const severity = getSeverity(result?.cobb_angles);
|
||||
|
||||
return (
|
||||
<div className="bf-page bf-page--wide">
|
||||
{/* Header */}
|
||||
<div className="bf-page-header">
|
||||
<div>
|
||||
<h1 className="bf-page-title">Brace Analysis</h1>
|
||||
<p className="bf-page-subtitle">
|
||||
Upload an X-ray image to analyze spinal curvature and generate a custom brace design.
|
||||
</p>
|
||||
</div>
|
||||
{caseId && (
|
||||
<Link to={`/cases/${caseId}/status`} className="rigo-btn rigo-btn-secondary">
|
||||
View Case Details
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content - Three Column Layout */}
|
||||
<div className="rigo-shell-page" style={{ gridTemplateColumns: "320px 1fr 380px", gap: 24 }}>
|
||||
{/* Left Panel - Upload */}
|
||||
<aside className="rigo-panel">
|
||||
<div className="rigo-panel-header">
|
||||
<h2 className="rigo-panel-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Upload X-Ray
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rigo-panel-content">
|
||||
<UploadPanel
|
||||
onUpload={handleUpload}
|
||||
isAnalyzing={isAnalyzing}
|
||||
onReset={handleReset}
|
||||
hasResults={!!result}
|
||||
/>
|
||||
|
||||
{caseId && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "rgba(59, 130, 246, 0.1)",
|
||||
borderRadius: 8,
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#60a5fa", marginBottom: 4 }}>Case ID</div>
|
||||
<code style={{ color: "#f1f5f9", fontSize: "0.75rem" }}>{caseId}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="rigo-error-message"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "rgba(255,0,0,0.1)",
|
||||
borderRadius: 8,
|
||||
color: "#f87171",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Center - 3D Viewer */}
|
||||
<main className="rigo-viewer-container">
|
||||
<BraceViewer modelUrl={modelUrl} isLoading={isAnalyzing} />
|
||||
|
||||
{/* Processing Info */}
|
||||
{result && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
background: "rgba(0,0,0,0.7)",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
fontSize: "0.75rem",
|
||||
color: "#94a3b8",
|
||||
}}
|
||||
>
|
||||
<span>Model: {result.model}</span>
|
||||
<span>Experiment: {result.experiment}</span>
|
||||
<span>Processing: {result.processing_time_ms}ms</span>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - Analysis Results */}
|
||||
<aside className="rigo-panel" style={{ overflow: "auto", maxHeight: "calc(100vh - 200px)" }}>
|
||||
<div className="rigo-panel-header">
|
||||
<h2 className="rigo-panel-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
Analysis Results
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="rigo-panel-content">
|
||||
{isAnalyzing ? (
|
||||
<div className="rigo-analysis-loading">
|
||||
<div className="rigo-analysis-card">
|
||||
<div
|
||||
className="rigo-loading-skeleton"
|
||||
style={{ height: "20px", marginBottom: "8px", width: "40%" }}
|
||||
></div>
|
||||
<div className="rigo-loading-skeleton" style={{ height: "36px", width: "60%" }}></div>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
textAlign: "center",
|
||||
color: "#64748b",
|
||||
marginTop: "24px",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="rigo-spinner"
|
||||
style={{ display: "inline-block", marginRight: "8px", verticalAlign: "middle" }}
|
||||
></span>
|
||||
Analyzing X-ray...
|
||||
</p>
|
||||
</div>
|
||||
) : result ? (
|
||||
<div className="rigo-analysis-results">
|
||||
{/* Severity Summary */}
|
||||
<MetricCard
|
||||
label="Overall Assessment"
|
||||
value={`${severity.level} Scoliosis`}
|
||||
description={`Max Cobb angle: ${severity.angle.toFixed(1)}°`}
|
||||
highlight={severity.class as "success" | "highlight" | "warning"}
|
||||
/>
|
||||
|
||||
{/* Curve Type */}
|
||||
<div className="rigo-analysis-grid">
|
||||
<MetricCard label="Curve Type" value={result.curve_type || "—"} />
|
||||
<MetricCard label="Vertebrae Detected" value={result.vertebrae_detected || "—"} />
|
||||
</div>
|
||||
|
||||
{/* Cobb Angles */}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
color: "#64748b",
|
||||
marginBottom: 8,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
Cobb Angles
|
||||
</h3>
|
||||
<CobbAnglesDisplay angles={result.cobb_angles} />
|
||||
</div>
|
||||
|
||||
{/* Rigo Classification */}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<RigoDisplay classification={result.rigo_classification} />
|
||||
</div>
|
||||
|
||||
{/* Mesh Info */}
|
||||
{result.mesh && (
|
||||
<div className="rigo-analysis-grid" style={{ marginTop: 16 }}>
|
||||
<MetricCard
|
||||
label="Mesh Vertices"
|
||||
value={result.mesh.vertices?.toLocaleString() || "—"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Mesh Faces"
|
||||
value={result.mesh.faces?.toLocaleString() || "—"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Section */}
|
||||
{outputs.length > 0 && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
color: "#64748b",
|
||||
marginBottom: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
Downloads
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{outputs
|
||||
.sort((a, b) => {
|
||||
// Sort: STL first, then PLY, then images, then JSON
|
||||
const order: Record<string, number> = {
|
||||
stl: 0,
|
||||
ply: 1,
|
||||
obj: 2,
|
||||
image: 3,
|
||||
json: 4,
|
||||
other: 5,
|
||||
};
|
||||
return (order[a.type] ?? 5) - (order[b.type] ?? 5);
|
||||
})
|
||||
.map((output) => (
|
||||
<DownloadButton key={output.s3Key} output={output} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rigo-analysis-empty">
|
||||
<div style={{ textAlign: "center", padding: "32px", color: "#64748b" }}>
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
style={{ margin: "0 auto 16px", opacity: 0.3 }}
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
<p>Upload an X-ray to see analysis results.</p>
|
||||
<p style={{ fontSize: "0.75rem", marginTop: 8 }}>
|
||||
Supported formats: JPEG, PNG, WebP, BMP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
frontend/src/pages/CaseDetail.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { fetchCase, getBraceOutputs, getDownloadUrl, generateBrace } from "../api/braceflowApi";
|
||||
import type { CaseRecord, BraceOutputsResponse } from "../api/braceflowApi";
|
||||
|
||||
// Helper function to determine curve severity from Cobb angle
|
||||
function getCurveSeverity(angle: number): string {
|
||||
if (angle < 10) return "Normal";
|
||||
if (angle < 25) return "Mild";
|
||||
if (angle < 40) return "Moderate";
|
||||
if (angle < 50) return "Severe";
|
||||
return "Very Severe";
|
||||
}
|
||||
|
||||
function getCurveSeverityClass(angle: number): string {
|
||||
if (angle < 10) return "severity-normal";
|
||||
if (angle < 25) return "severity-mild";
|
||||
if (angle < 40) return "severity-moderate";
|
||||
return "severity-severe";
|
||||
}
|
||||
|
||||
// Helper function to get Rigo type description
|
||||
function getRigoDescription(rigoType: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
'A1': 'Three-curve pattern with lumbar modifier',
|
||||
'A2': 'Three-curve pattern with thoracolumbar modifier',
|
||||
'A3': 'Three-curve pattern balanced',
|
||||
'B1': 'Four-curve pattern with lumbar modifier',
|
||||
'B2': 'Four-curve pattern with double thoracic',
|
||||
'C1': 'Non-3 non-4 with thoracolumbar curve',
|
||||
'C2': 'Non-3 non-4 with lumbar curve',
|
||||
'E1': 'Single thoracic curve',
|
||||
'E2': 'Single thoracolumbar curve',
|
||||
};
|
||||
return descriptions[rigoType] || `Rigo type ${rigoType}`;
|
||||
}
|
||||
|
||||
// Helper function to format file size
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export default function CaseDetailPage() {
|
||||
const { caseId } = useParams<{ caseId: string }>();
|
||||
const nav = useNavigate();
|
||||
|
||||
const [caseData, setCaseData] = useState<CaseRecord | null>(null);
|
||||
const [outputs, setOutputs] = useState<BraceOutputsResponse | null>(null);
|
||||
const [xrayUrl, setXrayUrl] = useState<string | null>(null);
|
||||
const [xrayError, setXrayError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [genError, setGenError] = useState<string | null>(null);
|
||||
|
||||
const loadCaseData = useCallback(async () => {
|
||||
if (!caseId) return;
|
||||
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
setXrayError(false);
|
||||
try {
|
||||
// Fetch case data first
|
||||
const caseResult = await fetchCase(caseId);
|
||||
setCaseData(caseResult);
|
||||
|
||||
// Fetch outputs and X-ray URL in parallel (don't fail if they error)
|
||||
const [outputsResult, xrayResult] = await Promise.all([
|
||||
getBraceOutputs(caseId).catch(() => null),
|
||||
getDownloadUrl(caseId, "xray").catch(() => null)
|
||||
]);
|
||||
|
||||
setOutputs(outputsResult);
|
||||
setXrayUrl(xrayResult?.url || null);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to load case");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [caseId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCaseData();
|
||||
}, [loadCaseData]);
|
||||
|
||||
// Handle generate brace button
|
||||
const handleGenerateBrace = async () => {
|
||||
if (!caseId) return;
|
||||
setGenerating(true);
|
||||
setGenError(null);
|
||||
try {
|
||||
await generateBrace(caseId, { experiment: "experiment_3" });
|
||||
// Reload case data after generation
|
||||
await loadCaseData();
|
||||
} catch (e: any) {
|
||||
setGenError(e?.message || "Failed to generate brace");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Find the visualization PNG from outputs
|
||||
const vizUrl = outputs?.outputs?.find(o =>
|
||||
o.filename.endsWith('.png') && !o.filename.includes('ap')
|
||||
)?.url;
|
||||
|
||||
// Check if brace has been generated
|
||||
const hasBrace = caseData?.status === "brace_generated" ||
|
||||
caseData?.status === "completed" ||
|
||||
caseData?.analysis_result?.cobb_angles;
|
||||
|
||||
const isProcessing = caseData?.status === "processing_brace" || generating;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bf-page">
|
||||
<div className="muted">Loading case...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (err || !caseData) {
|
||||
return (
|
||||
<div className="bf-page">
|
||||
<div className="error">{err || "Case not found"}</div>
|
||||
<button className="btn secondary" onClick={() => nav("/")}>Back to Cases</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bf-page bf-page--wide">
|
||||
{/* Header with case ID */}
|
||||
<div className="bf-case-header">
|
||||
<div className="bf-case-header-left">
|
||||
<button className="bf-back-btn" onClick={() => nav("/")}>
|
||||
← Back
|
||||
</button>
|
||||
<h1 className="bf-case-title">{caseId}</h1>
|
||||
<span className={`bf-case-status bf-case-status--${caseData.status}`}>
|
||||
{caseData.status?.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* X-ray Image Section */}
|
||||
<div className="bf-case-content">
|
||||
<div className="bf-case-xray-section">
|
||||
<h2 className="bf-section-title">Original X-ray</h2>
|
||||
<div className="bf-xray-container">
|
||||
{xrayUrl && !xrayError ? (
|
||||
<img
|
||||
src={xrayUrl}
|
||||
alt="X-ray"
|
||||
className="bf-xray-image"
|
||||
onError={() => setXrayError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="bf-xray-placeholder">
|
||||
<span>{xrayError ? "Failed to load X-ray" : "X-ray not available yet"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Processing State */}
|
||||
{isProcessing && (
|
||||
<div className="bf-processing-indicator">
|
||||
<div className="bf-processing-spinner"></div>
|
||||
<span>Processing... Generating brace from X-ray analysis</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate Button - show if X-ray exists but brace not generated */}
|
||||
{xrayUrl && !hasBrace && !isProcessing && (
|
||||
<div className="bf-generate-section">
|
||||
<button
|
||||
className="btn primary bf-generate-btn"
|
||||
onClick={handleGenerateBrace}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? "Generating..." : "Generate Brace"}
|
||||
</button>
|
||||
{genError && <div className="error">{genError}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visualization Section */}
|
||||
{vizUrl && (
|
||||
<div className="bf-case-viz-section">
|
||||
<h2 className="bf-section-title">Spine Analysis Visualization</h2>
|
||||
<div className="bf-viz-container">
|
||||
<img src={vizUrl} alt="Analysis visualization" className="bf-viz-image" />
|
||||
</div>
|
||||
|
||||
{/* Detailed Analysis Under Visualization */}
|
||||
{caseData.analysis_result && (
|
||||
<div className="bf-detailed-analysis">
|
||||
{/* Cobb Angles with Severity */}
|
||||
{caseData.analysis_result.cobb_angles && (
|
||||
<div className="bf-analysis-block">
|
||||
<h3>Cobb Angle Measurements</h3>
|
||||
<div className="bf-cobb-detailed">
|
||||
{caseData.analysis_result.cobb_angles.PT !== undefined && (
|
||||
<div className="bf-cobb-row">
|
||||
<span className="bf-cobb-name">PT (Proximal Thoracic)</span>
|
||||
<span className={`bf-cobb-value ${getCurveSeverityClass(caseData.analysis_result.cobb_angles.PT)}`}>
|
||||
{caseData.analysis_result.cobb_angles.PT.toFixed(1)}°
|
||||
</span>
|
||||
<span className="bf-cobb-severity">{getCurveSeverity(caseData.analysis_result.cobb_angles.PT)}</span>
|
||||
</div>
|
||||
)}
|
||||
{caseData.analysis_result.cobb_angles.MT !== undefined && (
|
||||
<div className="bf-cobb-row">
|
||||
<span className="bf-cobb-name">MT (Main Thoracic)</span>
|
||||
<span className={`bf-cobb-value ${getCurveSeverityClass(caseData.analysis_result.cobb_angles.MT)}`}>
|
||||
{caseData.analysis_result.cobb_angles.MT.toFixed(1)}°
|
||||
</span>
|
||||
<span className="bf-cobb-severity">{getCurveSeverity(caseData.analysis_result.cobb_angles.MT)}</span>
|
||||
</div>
|
||||
)}
|
||||
{caseData.analysis_result.cobb_angles.TL !== undefined && (
|
||||
<div className="bf-cobb-row">
|
||||
<span className="bf-cobb-name">TL (Thoracolumbar/Lumbar)</span>
|
||||
<span className={`bf-cobb-value ${getCurveSeverityClass(caseData.analysis_result.cobb_angles.TL)}`}>
|
||||
{caseData.analysis_result.cobb_angles.TL.toFixed(1)}°
|
||||
</span>
|
||||
<span className="bf-cobb-severity">{getCurveSeverity(caseData.analysis_result.cobb_angles.TL)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classification Summary */}
|
||||
<div className="bf-analysis-block">
|
||||
<h3>Classification</h3>
|
||||
<div className="bf-classification-grid">
|
||||
{caseData.analysis_result.curve_type && (
|
||||
<div className="bf-classification-item">
|
||||
<span className="bf-classification-label">Curve Pattern</span>
|
||||
<span className="bf-classification-value bf-curve-badge">
|
||||
{caseData.analysis_result.curve_type}-Curve
|
||||
</span>
|
||||
<span className="bf-classification-desc">
|
||||
{caseData.analysis_result.curve_type === 'S' ? 'Double curve (thoracic + lumbar)' :
|
||||
caseData.analysis_result.curve_type === 'C' ? 'Single curve pattern' :
|
||||
'Curve pattern identified'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{caseData.analysis_result.rigo_classification && (
|
||||
<div className="bf-classification-item">
|
||||
<span className="bf-classification-label">Rigo-Chêneau Type</span>
|
||||
<span className="bf-classification-value bf-rigo-badge">
|
||||
{caseData.analysis_result.rigo_classification.type}
|
||||
</span>
|
||||
<span className="bf-classification-desc">
|
||||
{caseData.analysis_result.rigo_classification.description || getRigoDescription(caseData.analysis_result.rigo_classification.type)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brace Generation Details */}
|
||||
<div className="bf-analysis-block">
|
||||
<h3>Brace Generation Details</h3>
|
||||
<div className="bf-brace-details-grid">
|
||||
{caseData.analysis_result.vertebrae_detected && (
|
||||
<div className="bf-detail-item">
|
||||
<span className="bf-detail-label">Vertebrae Detected</span>
|
||||
<span className="bf-detail-value">{caseData.analysis_result.vertebrae_detected}</span>
|
||||
</div>
|
||||
)}
|
||||
{caseData.analysis_result.mesh_info && (
|
||||
<>
|
||||
<div className="bf-detail-item">
|
||||
<span className="bf-detail-label">Mesh Vertices</span>
|
||||
<span className="bf-detail-value">{caseData.analysis_result.mesh_info.vertices?.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="bf-detail-item">
|
||||
<span className="bf-detail-label">Mesh Faces</span>
|
||||
<span className="bf-detail-value">{caseData.analysis_result.mesh_info.faces?.toLocaleString()}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{caseData.analysis_result.processing_time_ms && (
|
||||
<div className="bf-detail-item">
|
||||
<span className="bf-detail-label">Processing Time</span>
|
||||
<span className="bf-detail-value">{(caseData.analysis_result.processing_time_ms / 1000).toFixed(2)}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deformation/Pressure Zones */}
|
||||
{caseData.analysis_result.deformation_report?.zones && caseData.analysis_result.deformation_report.zones.length > 0 && (
|
||||
<div className="bf-analysis-block">
|
||||
<h3>Brace Pressure Zones</h3>
|
||||
<p className="bf-block-desc">
|
||||
Based on the Cobb angles and Rigo classification, the following pressure modifications were applied to the brace:
|
||||
</p>
|
||||
<div className="bf-pressure-zones">
|
||||
{caseData.analysis_result.deformation_report.zones.map((zone, idx) => (
|
||||
<div key={idx} className={`bf-zone-item ${zone.deform_mm < 0 ? 'bf-zone-pressure' : 'bf-zone-relief'}`}>
|
||||
<div className="bf-zone-header">
|
||||
<span className="bf-zone-name">{zone.zone}</span>
|
||||
<span className={`bf-zone-value ${zone.deform_mm < 0 ? 'bf-pressure' : 'bf-relief'}`}>
|
||||
{zone.deform_mm > 0 ? '+' : ''}{zone.deform_mm.toFixed(1)} mm
|
||||
</span>
|
||||
</div>
|
||||
<span className="bf-zone-reason">{zone.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{caseData.analysis_result.deformation_report.patch_grid && (
|
||||
<p className="bf-patch-info">
|
||||
Patch Grid: {caseData.analysis_result.deformation_report.patch_grid}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downloads */}
|
||||
{outputs?.outputs && outputs.outputs.length > 0 && (
|
||||
<div className="bf-case-downloads">
|
||||
<h2 className="bf-section-title">Generated Brace Files</h2>
|
||||
<div className="bf-downloads-grid">
|
||||
{outputs.outputs
|
||||
.filter(o => o.type === 'stl' || o.type === 'obj')
|
||||
.map(o => (
|
||||
<div key={o.filename} className="bf-download-card">
|
||||
<div className="bf-download-card-icon">
|
||||
{o.type === 'stl' ? '🧊' : '📦'}
|
||||
</div>
|
||||
<div className="bf-download-card-info">
|
||||
<span className="bf-download-card-name">{o.filename}</span>
|
||||
<span className="bf-download-card-size">{formatFileSize(o.size)}</span>
|
||||
</div>
|
||||
<div className="bf-download-card-actions">
|
||||
<a
|
||||
href={o.url}
|
||||
className="bf-action-btn bf-action-download"
|
||||
download={o.filename}
|
||||
title="Download file"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="bf-download-hint">STL files can be 3D printed or opened in any 3D modeling software.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
frontend/src/pages/CaseLoaderPage.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export function CaseLoaderPage() {
|
||||
const [caseId, setCaseId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const nav = useNavigate();
|
||||
|
||||
const onLoad = async () => {
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.getCaseStatus(caseId.trim());
|
||||
nav(`/cases/${encodeURIComponent(caseId.trim())}/status`);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to load case");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bf-page">
|
||||
<div className="bf-page-header">
|
||||
<div>
|
||||
<h1 className="bf-page-title">Load A Case</h1>
|
||||
<p className="bf-page-subtitle">
|
||||
Enter a case ID to view status or resume landmark capture.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bf-spacer" />
|
||||
<div className="bf-toolbar">
|
||||
<button className="btn primary" disabled={!caseId.trim() || loading} onClick={onLoad}>
|
||||
{loading ? "Loading..." : "Load Case"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<p className="muted muted--tight">
|
||||
To create a new case and upload an X-ray, use "Start A Case" in the header.
|
||||
</p>
|
||||
|
||||
<div className="row gap">
|
||||
<input
|
||||
value={caseId}
|
||||
onChange={(e) => setCaseId(e.target.value)}
|
||||
placeholder="case-20260122-..."
|
||||
className="input"
|
||||
/>
|
||||
<button className="btn secondary" disabled={!caseId.trim() || loading} onClick={onLoad}>
|
||||
{loading ? "Loading..." : "Load Case"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="error">{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/pages/CaseStatusPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { api } from "../lib/api";
|
||||
import type { CaseStatus } from "../lib/api";
|
||||
|
||||
export function CaseStatusPage() {
|
||||
const { caseId } = useParams();
|
||||
const id = caseId || "";
|
||||
const nav = useNavigate();
|
||||
|
||||
const [data, setData] = useState<CaseStatus | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await api.getCaseStatus(id);
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to load status");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 4000);
|
||||
return () => clearInterval(t);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div className="bf-page bf-page--wide">
|
||||
<div className="bf-page-header">
|
||||
<div>
|
||||
<h1 className="bf-page-title">Case Status</h1>
|
||||
<p className="bf-page-subtitle">Track pipeline progress and jump to the next step.</p>
|
||||
</div>
|
||||
<div className="bf-spacer" />
|
||||
<div className="bf-toolbar">
|
||||
<button className="btn secondary" onClick={load}>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => nav(`/cases/${encodeURIComponent(id)}/landmarks`)}
|
||||
>
|
||||
Landmark Tool
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="muted muted--tight">
|
||||
Case: <strong>{id}</strong>
|
||||
</div>
|
||||
|
||||
{err && <div className="error">{err}</div>}
|
||||
{!data ? (
|
||||
<div className="muted">Loading status...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bf-summary-grid">
|
||||
<div className="bf-summary-item">
|
||||
<span className="bf-summary-label">Status</span>
|
||||
<span className="bf-summary-value">{data.case.status}</span>
|
||||
</div>
|
||||
<div className="bf-summary-item">
|
||||
<span className="bf-summary-label">Current Step</span>
|
||||
<span className="bf-summary-value">{data.case.current_step || "-"}</span>
|
||||
</div>
|
||||
<div className="bf-summary-item">
|
||||
<span className="bf-summary-label">Last Updated</span>
|
||||
<span className="bf-summary-value">
|
||||
{new Date(data.case.updated_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Step</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Finished</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.steps.map((s) => (
|
||||
<tr key={s.step_name}>
|
||||
<td>{s.step_order}</td>
|
||||
<td>{s.step_name}</td>
|
||||
<td>
|
||||
<span className={`tag ${s.status}`}>{s.status}</span>
|
||||
</td>
|
||||
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : "-"}</td>
|
||||
<td>{s.finished_at ? new Date(s.finished_at).toLocaleString() : "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="muted">
|
||||
Step3 output (classification.json) display can be added next (optional for first demo).
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchCases, createCaseAndUploadXray, getDownloadUrl, deleteCase } from "../api/braceflowApi";
|
||||
import type { CaseRecord } from "../api/braceflowApi";
|
||||
|
||||
export default function Dashboard({ onView }: { onView?: (id: string) => void }) {
|
||||
const nav = useNavigate();
|
||||
|
||||
const [cases, setCases] = useState<CaseRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// Upload state
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState("");
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Thumbnail URLs for each case
|
||||
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
|
||||
|
||||
// Dropdown menu state
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const c = await fetchCases();
|
||||
setCases(c);
|
||||
// Load thumbnails for each case
|
||||
loadThumbnails(c);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to load cases");
|
||||
setCases([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load X-ray thumbnails for cases
|
||||
async function loadThumbnails(caseList: CaseRecord[]) {
|
||||
const newThumbnails: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
caseList.map(async (c) => {
|
||||
try {
|
||||
const result = await getDownloadUrl(c.caseId, "xray");
|
||||
newThumbnails[c.caseId] = result.url;
|
||||
} catch {
|
||||
// No thumbnail available
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setThumbnails(prev => ({ ...prev, ...newThumbnails }));
|
||||
}
|
||||
|
||||
// Handle delete case
|
||||
async function handleDelete(caseId: string, e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!confirm(`Are you sure you want to delete case "${caseId}"?\n\nThis will permanently remove the case and all associated files.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(caseId);
|
||||
setOpenMenu(null);
|
||||
|
||||
try {
|
||||
await deleteCase(caseId);
|
||||
setCases(prev => prev.filter(c => c.caseId !== caseId));
|
||||
setThumbnails(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[caseId];
|
||||
return updated;
|
||||
});
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to delete case");
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside() {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const c = await fetchCases();
|
||||
if (mounted) {
|
||||
setCases(c);
|
||||
loadThumbnails(c);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (mounted) setErr(e?.message || "Failed to load cases");
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function viewCase(caseId: string) {
|
||||
if (onView) {
|
||||
onView(caseId);
|
||||
return;
|
||||
}
|
||||
nav(`/cases/${encodeURIComponent(caseId)}/analysis`);
|
||||
}
|
||||
|
||||
const handleFileUpload = useCallback(async (file: File) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setErr("Please upload an image file (JPEG, PNG, etc.)");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setErr(null);
|
||||
setUploadProgress("Creating case...");
|
||||
|
||||
try {
|
||||
setUploadProgress("Uploading X-ray...");
|
||||
const { caseId } = await createCaseAndUploadXray(file);
|
||||
setUploadProgress("Complete!");
|
||||
|
||||
// Refresh the case list and navigate to the new case
|
||||
await load();
|
||||
viewCase(caseId);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
}, [handleFileUpload]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
}, []);
|
||||
|
||||
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
}, [handleFileUpload]);
|
||||
|
||||
const hasCases = cases.length > 0;
|
||||
|
||||
return (
|
||||
<div className="bf-page bf-page--wide">
|
||||
<div className="bf-page-header">
|
||||
<div>
|
||||
<h1 className="bf-page-title">Cases</h1>
|
||||
<p className="bf-page-subtitle">Upload an X-ray to create a new case, or select an existing one.</p>
|
||||
</div>
|
||||
<div className="bf-spacer" />
|
||||
<div className="bf-toolbar">
|
||||
<button className="btn secondary bf-btn-fixed" onClick={load} disabled={loading || uploading}>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div
|
||||
className={`bf-upload-zone ${dragActive ? "bf-upload-zone--active" : ""} ${uploading ? "bf-upload-zone--uploading" : ""}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileInputChange}
|
||||
style={{ display: "none" }}
|
||||
disabled={uploading}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="bf-upload-content">
|
||||
<div className="bf-upload-spinner"></div>
|
||||
<p className="bf-upload-text">{uploadProgress}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bf-upload-content">
|
||||
<div className="bf-upload-icon">+</div>
|
||||
<p className="bf-upload-text">
|
||||
<strong>Click to upload</strong> or drag and drop an X-ray image
|
||||
</p>
|
||||
<p className="bf-upload-hint">JPEG, PNG, WebP supported</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginTop: "1rem" }}>{err}</div>}
|
||||
|
||||
{/* Cases List */}
|
||||
<div className="card" style={{ marginTop: "1.5rem" }}>
|
||||
<h2 className="bf-section-title">Recent Cases</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="muted">Loading cases...</div>
|
||||
) : !hasCases ? (
|
||||
<div className="bf-empty">No cases yet. Upload an X-ray above to create your first case.</div>
|
||||
) : (
|
||||
<div className="bf-cases-list">
|
||||
{cases.map((c) => {
|
||||
const date = new Date(c.created_at);
|
||||
const isValidDate = !isNaN(date.getTime());
|
||||
const thumbUrl = thumbnails[c.caseId];
|
||||
const isDeleting = deleting === c.caseId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={c.caseId}
|
||||
className={`bf-case-row ${isDeleting ? "bf-case-row--deleting" : ""}`}
|
||||
onClick={() => !isDeleting && viewCase(c.caseId)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="bf-case-thumb">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt="X-ray" className="bf-case-thumb-img" />
|
||||
) : (
|
||||
<div className="bf-case-thumb-placeholder">X</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Case Info */}
|
||||
<div className="bf-case-row-info">
|
||||
<span className="bf-case-row-id">{c.caseId}</span>
|
||||
{isValidDate && (
|
||||
<span className="bf-case-row-date">
|
||||
{date.toLocaleDateString()} {date.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu Button */}
|
||||
<div className="bf-case-menu-container">
|
||||
{isDeleting ? (
|
||||
<div className="bf-case-menu-spinner"></div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="bf-case-menu-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenMenu(openMenu === c.caseId ? null : c.caseId);
|
||||
}}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
{openMenu === c.caseId && (
|
||||
<div className="bf-case-dropdown">
|
||||
<button
|
||||
className="bf-case-dropdown-item bf-case-dropdown-item--danger"
|
||||
onClick={(e) => handleDelete(c.caseId, e)}
|
||||
>
|
||||
Delete Case
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useNavigate, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// If authenticated, redirect directly to cases
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bf-loading-screen">
|
||||
<div className="bf-loading-spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/cases" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bf-home-page">
|
||||
{/* Hero Section */}
|
||||
<section className="bf-hero">
|
||||
<div className="bf-hero-content">
|
||||
<h1 className="bf-hero-title">
|
||||
Intelligent Scoliosis
|
||||
<br />
|
||||
<span className="bf-hero-accent">Brace Design</span>
|
||||
</h1>
|
||||
<p className="bf-hero-subtitle">
|
||||
Advanced AI-powered analysis and custom brace generation for scoliosis treatment.
|
||||
Upload an X-ray, get precise Cobb angle measurements and Rigo classification,
|
||||
and generate patient-specific 3D-printable braces.
|
||||
</p>
|
||||
<div className="bf-hero-actions">
|
||||
<button
|
||||
className="bf-hero-btn bf-hero-btn--primary"
|
||||
onClick={() => navigate("/login")}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Visual - Spine illustration */}
|
||||
<div className="bf-hero-visual">
|
||||
<svg
|
||||
className="bf-hero-svg"
|
||||
viewBox="0 0 200 300"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Spine curve */}
|
||||
<path
|
||||
d="M100 20 C 80 60 120 100 100 140 C 80 180 120 220 100 260"
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
className="bf-hero-spine"
|
||||
/>
|
||||
{/* Vertebrae */}
|
||||
{[40, 80, 120, 160, 200, 240].map((y, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={100 + (i % 2 === 0 ? -10 : 10) * Math.sin((i * Math.PI) / 3)}
|
||||
cy={y}
|
||||
r="12"
|
||||
fill="rgba(221, 130, 80, 0.15)"
|
||||
stroke="var(--accent-primary)"
|
||||
strokeWidth="2"
|
||||
className="bf-hero-vertebra"
|
||||
style={{ animationDelay: `${i * 0.15}s` }}
|
||||
/>
|
||||
))}
|
||||
{/* Brace outline */}
|
||||
<path
|
||||
d="M60 60 Q 40 150 60 240 L 140 240 Q 160 150 140 60 Z"
|
||||
stroke="var(--accent-primary)"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="8 4"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
className="bf-hero-brace"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="bf-features">
|
||||
<div className="bf-features-grid">
|
||||
<div className="bf-feature-card">
|
||||
<div className="bf-feature-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<circle cx="12" cy="15" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="bf-feature-title">X-ray Analysis</h3>
|
||||
<p className="bf-feature-desc">
|
||||
Upload spinal X-rays for automatic vertebrae detection and landmark identification
|
||||
using advanced computer vision.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bf-feature-card">
|
||||
<div className="bf-feature-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="bf-feature-title">Cobb Angle Measurement</h3>
|
||||
<p className="bf-feature-desc">
|
||||
Precise calculation of Cobb angles (PT, MT, TL) with severity classification
|
||||
and Rigo-Chêneau type determination.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bf-feature-card">
|
||||
<div className="bf-feature-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="7.5 4.21 12 6.81 16.5 4.21" />
|
||||
<polyline points="7.5 19.79 7.5 14.6 3 12" />
|
||||
<polyline points="21 12 16.5 14.6 16.5 19.79" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="bf-feature-title">3D Brace Generation</h3>
|
||||
<p className="bf-feature-desc">
|
||||
Generate custom 3D-printable braces with patient-specific pressure zones
|
||||
and relief windows based on curve analysis.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Workflow Section */}
|
||||
<section className="bf-workflow">
|
||||
<h2 className="bf-section-heading">How It Works</h2>
|
||||
<div className="bf-workflow-steps">
|
||||
<div className="bf-workflow-step">
|
||||
<div className="bf-workflow-number">1</div>
|
||||
<div className="bf-workflow-content">
|
||||
<h4>Upload X-ray</h4>
|
||||
<p>Upload a spinal PA/AP X-ray image</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bf-workflow-connector" />
|
||||
<div className="bf-workflow-step">
|
||||
<div className="bf-workflow-number">2</div>
|
||||
<div className="bf-workflow-content">
|
||||
<h4>Review Analysis</h4>
|
||||
<p>Verify landmarks and measurements</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bf-workflow-connector" />
|
||||
<div className="bf-workflow-step">
|
||||
<div className="bf-workflow-number">3</div>
|
||||
<div className="bf-workflow-content">
|
||||
<h4>Generate Brace</h4>
|
||||
<p>Create custom 3D brace design</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bf-workflow-connector" />
|
||||
<div className="bf-workflow-step">
|
||||
<div className="bf-workflow-number">4</div>
|
||||
<div className="bf-workflow-content">
|
||||
<h4>Download & Print</h4>
|
||||
<p>Export STL files for 3D printing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bf-home-footer">
|
||||
<p>BraceIQ Development Environment</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const ENTER_DURATION_MS = 2300;
|
||||
const EXIT_DURATION_MS = 650;
|
||||
|
||||
export default function LandingPage() {
|
||||
const nav = useNavigate();
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const exitTimer = window.setTimeout(() => setIsExiting(true), ENTER_DURATION_MS);
|
||||
const navTimer = window.setTimeout(
|
||||
() => nav("/dashboard", { replace: true }),
|
||||
ENTER_DURATION_MS + EXIT_DURATION_MS
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(exitTimer);
|
||||
window.clearTimeout(navTimer);
|
||||
};
|
||||
}, [nav]);
|
||||
|
||||
return (
|
||||
<div className={`bf-landing ${isExiting ? "is-exiting" : ""}`} role="status" aria-live="polite">
|
||||
<div className="bf-landing-inner">
|
||||
<div className="bf-landing-visual" aria-hidden="true">
|
||||
<svg className="bf-landing-svg" viewBox="0 0 200 200" role="presentation">
|
||||
<defs>
|
||||
<linearGradient id="bfBraceGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#efb07a" stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor="#d17645" stopOpacity="0.95" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path
|
||||
className="bf-landing-spine"
|
||||
d="M100 34 C 92 60, 108 86, 100 112 C 92 138, 108 164, 100 188"
|
||||
/>
|
||||
|
||||
<circle className="bf-landing-vertebra" cx="100" cy="48" r="6.2" />
|
||||
<circle className="bf-landing-vertebra" cx="100" cy="74" r="5.8" />
|
||||
<circle className="bf-landing-vertebra" cx="100" cy="100" r="5.8" />
|
||||
<circle className="bf-landing-vertebra" cx="100" cy="126" r="5.8" />
|
||||
<circle className="bf-landing-vertebra" cx="100" cy="152" r="6.2" />
|
||||
|
||||
<path
|
||||
className="bf-landing-brace bf-landing-brace--left"
|
||||
d="M58 62 C 44 82, 44 118, 58 138"
|
||||
stroke="url(#bfBraceGrad)"
|
||||
/>
|
||||
<path
|
||||
className="bf-landing-brace bf-landing-brace--right"
|
||||
d="M142 62 C 156 82, 156 118, 142 138"
|
||||
stroke="url(#bfBraceGrad)"
|
||||
/>
|
||||
|
||||
<path
|
||||
className="bf-landing-pad"
|
||||
d="M70 86 C 64 96, 64 104, 70 114"
|
||||
stroke="url(#bfBraceGrad)"
|
||||
/>
|
||||
<path
|
||||
className="bf-landing-pad"
|
||||
d="M130 86 C 136 96, 136 104, 130 114"
|
||||
stroke="url(#bfBraceGrad)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="bf-landing-mark">
|
||||
<div className="bf-landing-wordmark">
|
||||
Brace<span className="bf-brand-accent">iQ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="bf-landing-slogan">
|
||||
Guided support design, from imaging to fabrication.
|
||||
</p>
|
||||
|
||||
<div className="bf-landing-progress" aria-hidden="true">
|
||||
<span className="bf-landing-progress-bar" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
584
frontend/src/pages/LandmarkCapturePage.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { api } from "../lib/api";
|
||||
import type { SubmitLandmarksRequest } from "../lib/api";
|
||||
import { LandmarkCanvas } from "../components/LandmarkCanvas";
|
||||
import type { Point } from "../components/LandmarkCanvas";
|
||||
|
||||
const API_BASE = "https://cfx9z50wj2.execute-api.ca-central-1.amazonaws.com/prod";
|
||||
|
||||
type AnyObj = Record<string, any>;
|
||||
|
||||
async function httpJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const base = API_BASE.replace(/\/+$/, "");
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const url = `${base}${normalizedPath}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function buildPublicS3Url(bucket: string, key: string) {
|
||||
return `https://${bucket}.s3.amazonaws.com/${key}`;
|
||||
}
|
||||
|
||||
async function tryResolveApUrlFromApi(caseId: string): Promise<string | null> {
|
||||
const candidates: Array<{
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
body?: any;
|
||||
pickUrl: (json: AnyObj) => string | null;
|
||||
}> = [
|
||||
{
|
||||
method: "GET",
|
||||
path: `/cases/${encodeURIComponent(caseId)}/xray-url?view=ap`,
|
||||
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: `/cases/${encodeURIComponent(caseId)}/xray-preview?view=ap`,
|
||||
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: `/cases/${encodeURIComponent(caseId)}/download-url?type=xray&view=ap`,
|
||||
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: `/cases/${encodeURIComponent(caseId)}/download-url`,
|
||||
body: { type: "xray", view: "ap" },
|
||||
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: `/cases/${encodeURIComponent(caseId)}/file-url`,
|
||||
body: { kind: "xray", view: "ap" },
|
||||
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const json =
|
||||
c.method === "GET"
|
||||
? await httpJson<AnyObj>(c.path)
|
||||
: await httpJson<AnyObj>(c.path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(c.body ?? {}),
|
||||
});
|
||||
|
||||
const url = c.pickUrl(json);
|
||||
if (url && typeof url === "string") return url;
|
||||
} catch {
|
||||
// ignore and continue
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function assetHasAp(assets: AnyObj | null): boolean {
|
||||
if (!assets) return false;
|
||||
const xr = assets.xrays ?? assets.assets?.xrays;
|
||||
|
||||
if (Array.isArray(xr)) return xr.includes("ap");
|
||||
if (xr && typeof xr === "object") return !!xr.ap;
|
||||
if (typeof assets.apImageUrl === "string" && assets.apImageUrl) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function pickApUrlFromAssets(assets: AnyObj | null): string | null {
|
||||
if (!assets) return null;
|
||||
const a = assets.assets ?? assets;
|
||||
|
||||
if (typeof a.apImageUrl === "string" && a.apImageUrl) return a.apImageUrl;
|
||||
if (typeof a.xrays?.ap === "string" && a.xrays.ap) return a.xrays.ap;
|
||||
|
||||
const apObj = a?.ap || a?.xrays?.ap || a?.assets?.xrays?.ap;
|
||||
if (apObj && apObj.bucket && apObj.key) return buildPublicS3Url(apObj.bucket, apObj.key);
|
||||
|
||||
const apUrl = a?.xrays?.ap?.url || a?.xrays?.ap?.downloadUrl || a?.xrays?.ap?.imageUrl;
|
||||
if (typeof apUrl === "string" && apUrl) return apUrl;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function jsonPretty(v: any) {
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
type ArtifactTab = { n: number; label: string; path: string };
|
||||
type ArtifactState = { loading: boolean; error: string | null; json: any | null; lastLoadedAt?: number };
|
||||
|
||||
function buildArtifactUrl(caseId: string, path: string) {
|
||||
return `https://braceflow-uploads-20260125.s3.ca-central-1.amazonaws.com/cases/${encodeURIComponent(caseId)}/${path}`;
|
||||
}
|
||||
|
||||
export function LandmarkCapturePage() {
|
||||
const { caseId } = useParams();
|
||||
const nav = useNavigate();
|
||||
const id = (caseId || "").trim();
|
||||
|
||||
const [assets, setAssets] = useState<AnyObj | null>(null);
|
||||
const [assetsLoaded, setAssetsLoaded] = useState(false);
|
||||
|
||||
const [imageUrl, setImageUrl] = useState<string>("");
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
|
||||
const [manualUrl, setManualUrl] = useState<string>("");
|
||||
|
||||
// ✅ FIX: was Point[]; must be Record<string, Point> to match LandmarkCanvas + SubmitLandmarksRequest
|
||||
const [landmarks, setLandmarks] = useState<Record<string, Point>>({});
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
// --- Artifacts slide panel ---
|
||||
const [artifactsOpen, setArtifactsOpen] = useState(false);
|
||||
const [activeArtifactIdx, setActiveArtifactIdx] = useState(0);
|
||||
const [artifactStateByIdx, setArtifactStateByIdx] = useState<Record<number, ArtifactState>>({});
|
||||
|
||||
const artifactTabs: ArtifactTab[] = useMemo(
|
||||
() => [
|
||||
{ n: 1, label: "1", path: "step1_normalized/meta.json" },
|
||||
{ n: 2, label: "2", path: "step2_measurements/landmarks.json" },
|
||||
{ n: 3, label: "3", path: "step2_measurements/measurements.json" },
|
||||
{ n: 4, label: "4", path: "step3_rigo/classification.json" },
|
||||
{ n: 5, label: "5", path: "step4_template/template.json" },
|
||||
{ n: 6, label: "6", path: "step5_deformation/brace_spec.json" },
|
||||
{ n: 7, label: "7", path: "step6_export/print_manifest.json" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const apExists = useMemo(() => assetHasAp(assets), [assets]);
|
||||
|
||||
const codeBoxStyle: React.CSSProperties = {
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
background: "rgba(0,0,0,0.20)",
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
};
|
||||
|
||||
const linkStyle: React.CSSProperties = {
|
||||
textDecoration: "underline",
|
||||
fontWeight: 700,
|
||||
opacity: 0.95,
|
||||
};
|
||||
|
||||
async function loadAssetsAndResolveImage() {
|
||||
if (!id) {
|
||||
setMsg("No case id in route.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetsLoaded(false);
|
||||
setMsg(null);
|
||||
setImageError(null);
|
||||
|
||||
try {
|
||||
const a = await httpJson<AnyObj>(`/cases/${encodeURIComponent(id)}/assets`);
|
||||
setAssets(a);
|
||||
|
||||
const direct = pickApUrlFromAssets(a);
|
||||
if (direct) {
|
||||
setImageLoading(true);
|
||||
setImageUrl(direct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetHasAp(a)) {
|
||||
const resolved = await tryResolveApUrlFromApi(id);
|
||||
if (resolved) {
|
||||
setImageLoading(true);
|
||||
setImageUrl(resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
setImageUrl("");
|
||||
setImageError(
|
||||
"AP x-ray exists (assets.xrays includes 'ap') but no viewable image URL was returned. " +
|
||||
"Backend likely needs a presigned GET/preview endpoint (e.g., /cases/{caseId}/xray-url?view=ap). " +
|
||||
"Use the manual URL field below as a temporary workaround."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setImageUrl("");
|
||||
setImageError(null);
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Failed to load assets");
|
||||
} finally {
|
||||
setAssetsLoaded(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArtifactAt(idx: number) {
|
||||
if (!id) {
|
||||
setArtifactStateByIdx((p) => ({
|
||||
...p,
|
||||
[idx]: { loading: false, error: "Missing caseId in route.", json: null },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = artifactTabs[idx];
|
||||
if (!tab) return;
|
||||
|
||||
setArtifactStateByIdx((p) => ({
|
||||
...p,
|
||||
[idx]: { ...(p[idx] ?? { json: null, error: null }), loading: true, error: null },
|
||||
}));
|
||||
|
||||
const url = buildArtifactUrl(id, tab.path);
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
setArtifactStateByIdx((p) => ({
|
||||
...p,
|
||||
[idx]: { loading: false, error: null, json, lastLoadedAt: Date.now() },
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setArtifactStateByIdx((p) => ({
|
||||
...p,
|
||||
[idx]: { loading: false, error: e?.message || "Failed to load JSON", json: null },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAssetsAndResolveImage();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artifactsOpen) return;
|
||||
const st = artifactStateByIdx[activeArtifactIdx];
|
||||
if (!st || (!st.loading && st.json == null && st.error == null)) {
|
||||
loadArtifactAt(activeArtifactIdx);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artifactsOpen, activeArtifactIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artifactsOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setArtifactsOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [artifactsOpen]);
|
||||
|
||||
async function onSubmit() {
|
||||
if (!id) {
|
||||
setMsg("No case id");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const body: SubmitLandmarksRequest = { caseId: id, view: "ap", landmarks };
|
||||
const res = await api.submitLandmarks(id, body);
|
||||
|
||||
if (res?.ok) {
|
||||
setMsg("Landmarks submitted. Pipeline should resume from Step2.");
|
||||
nav(`/cases/${encodeURIComponent(id)}/status`);
|
||||
} else {
|
||||
setMsg("Submission completed but response did not include ok=true.");
|
||||
}
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Failed to submit landmarks");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const activeTab = artifactTabs[activeArtifactIdx];
|
||||
const activeUrl = activeTab && id ? buildArtifactUrl(id, activeTab.path) : "";
|
||||
const activeState = artifactStateByIdx[activeArtifactIdx] ?? { loading: false, error: null, json: null };
|
||||
const canSubmit = completed && !submitting && !!imageUrl;
|
||||
|
||||
return (
|
||||
<div className="bf-page bf-page--wide">
|
||||
<div className="bf-page-header">
|
||||
<div>
|
||||
<h1 className="bf-page-title">Landmark Capture</h1>
|
||||
<p className="bf-page-subtitle">
|
||||
Place landmarks on the AP X-ray, then submit to resume the pipeline.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bf-spacer" />
|
||||
<div className="bf-toolbar">
|
||||
<button className="btn secondary" onClick={() => setArtifactsOpen(true)}>
|
||||
Artifacts
|
||||
</button>
|
||||
<button className="btn secondary" onClick={() => nav(`/cases/${encodeURIComponent(id)}/status`)}>
|
||||
View Status
|
||||
</button>
|
||||
<button className="btn secondary" onClick={() => loadAssetsAndResolveImage()}>
|
||||
Reload Assets
|
||||
</button>
|
||||
<button className="btn primary" disabled={!canSubmit} onClick={onSubmit}>
|
||||
{submitting ? "Submitting..." : "Submit Landmarks"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="muted muted--tight">
|
||||
Case: <strong>{id || "(missing)"}</strong>
|
||||
</div>
|
||||
|
||||
{msg && <div className="notice">{msg}</div>}
|
||||
|
||||
{!assetsLoaded ? (
|
||||
<div className="muted">Loading assets...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bf-row-gap-16">
|
||||
<div className="bf-flex-1">
|
||||
{imageUrl ? (
|
||||
<div>
|
||||
<div className="bf-mb-8">
|
||||
{imageLoading && <div className="muted">Loading image…</div>}
|
||||
{imageError && <div className="error">{imageError}</div>}
|
||||
</div>
|
||||
|
||||
{/* ============================
|
||||
Thumbnail on top + Workspace below
|
||||
============================ */}
|
||||
<div className="lc-stack">
|
||||
{/* Thumbnail */}
|
||||
<div className="lc-thumbRow">
|
||||
<div className="lc-thumbCol">
|
||||
<div className="lc-thumbBox">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="AP x-ray thumbnail"
|
||||
onLoad={() => {
|
||||
setImageLoading(false);
|
||||
setImageError(null);
|
||||
}}
|
||||
onError={() => {
|
||||
setImageLoading(false);
|
||||
setImageError(
|
||||
"Image failed to load. Most common causes: (1) URL is not public/presigned, (2) S3 CORS blocks browser, (3) URL points to a DICOM (not browser-renderable)."
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lc-thumbActions">
|
||||
<button className="btn secondary" onClick={() => window.open(imageUrl, "_blank")}>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setImageLoading(true);
|
||||
setImageError(null);
|
||||
setImageUrl(imageUrl);
|
||||
}}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{imageLoading && <div className="muted">Loading image…</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
<div className="lc-workspace">
|
||||
<div className="lc-workspace-title muted">Landmark capture</div>
|
||||
|
||||
{/* IMPORTANT: do NOT wrap LandmarkCanvas in a 250x250 box.
|
||||
We will constrain ONLY the image holder via CSS. */}
|
||||
<div className="lc-workspace-body">
|
||||
<LandmarkCanvas
|
||||
imageUrl={imageUrl}
|
||||
initialLandmarks={landmarks}
|
||||
onChange={(lm, done) => {
|
||||
setLandmarks(lm);
|
||||
setCompleted(done);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* ============================ */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bf-dashed-panel">
|
||||
<div className="muted">
|
||||
{apExists
|
||||
? "AP x-ray exists for this case, but no viewable URL is available yet."
|
||||
: "AP image not available for this case."}
|
||||
</div>
|
||||
|
||||
<div className="bf-mt-12">
|
||||
<div className="muted bf-mb-6">
|
||||
Temporary workaround: paste a viewable image URL (presigned GET to a JPG/PNG).
|
||||
</div>
|
||||
<div className="row gap">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="https://... (presigned GET to preview image)"
|
||||
value={manualUrl}
|
||||
onChange={(e) => setManualUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
if (!manualUrl.trim()) return;
|
||||
setImageLoading(true);
|
||||
setImageError(null);
|
||||
setImageUrl(manualUrl.trim());
|
||||
}}
|
||||
>
|
||||
Use URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bf-mt-12">
|
||||
<button className="btn" onClick={() => nav(`/cases/${encodeURIComponent(id)}/xray`)}>
|
||||
Upload AP X-ray
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{imageError && (
|
||||
<div className="error bf-mt-12">
|
||||
{imageError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ==========================
|
||||
Slide-in Artifacts Drawer
|
||||
========================== */}
|
||||
<div
|
||||
className={`bf-drawer-backdrop ${artifactsOpen ? "is-open" : ""}`}
|
||||
onClick={() => setArtifactsOpen(false)}
|
||||
role="presentation"
|
||||
/>
|
||||
|
||||
<aside className={`bf-drawer ${artifactsOpen ? "is-open" : ""}`} aria-hidden={!artifactsOpen}>
|
||||
<div className="bf-drawer-header">
|
||||
<div className="bf-col-tight">
|
||||
<div className="bf-drawer-title">Artifacts</div>
|
||||
<div className="bf-drawer-subtitle">Case: {id || "(missing)"}</div>
|
||||
</div>
|
||||
<button className="btn secondary" onClick={() => setArtifactsOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bf-tabs">
|
||||
{artifactTabs.map((t, idx) => (
|
||||
<button
|
||||
key={t.n}
|
||||
className={`bf-tab ${idx === activeArtifactIdx ? "is-active" : ""}`}
|
||||
onClick={() => setActiveArtifactIdx(idx)}
|
||||
title={t.path}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bf-drawer-body">
|
||||
<div className="row space center">
|
||||
<div className="bf-col-tight">
|
||||
<div className="bf-strong">Artifact {activeTab?.label}</div>
|
||||
<div className="muted muted--small bf-ellipsis">
|
||||
{activeTab?.path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bf-row-end-wrap">
|
||||
<button className="btn secondary" onClick={() => window.open(activeUrl, "_blank")} disabled={!activeUrl}>
|
||||
Open JSON
|
||||
</button>
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={() => loadArtifactAt(activeArtifactIdx)}
|
||||
disabled={!id || activeState.loading}
|
||||
>
|
||||
{activeState.loading ? "Loading…" : "Reload"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeState.error && (
|
||||
<div className="error bf-mt-10">
|
||||
{activeState.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bf-mt-12">
|
||||
<pre style={codeBoxStyle}>
|
||||
{activeState.loading ? "Loading…" : activeState.json ? jsonPretty(activeState.json) : "(not available yet)"}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="bf-mt-14">
|
||||
<div className="muted muted--small bf-mb-6">
|
||||
Assets (debug)
|
||||
</div>
|
||||
<pre style={codeBoxStyle}>{jsonPretty(assets ?? {})}</pre>
|
||||
<div className="bf-mt-10">
|
||||
<div className="muted">AP exists: {String(apExists)}</div>
|
||||
<div className="muted">Image URL resolved: {imageUrl ? "yes" : "no"}</div>
|
||||
{activeUrl && (
|
||||
<div className="muted">
|
||||
URL:{" "}
|
||||
<a style={linkStyle} href={activeUrl} target="_blank" rel="noreferrer">
|
||||
{activeUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
121
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { login, isAuthenticated, error, clearError } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Get the redirect path from state, or default to "/"
|
||||
const from = (location.state as any)?.from?.pathname || "/";
|
||||
|
||||
// Redirect if already logged in
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate, from]);
|
||||
|
||||
// Clear errors when inputs change
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
clearError();
|
||||
}
|
||||
}, [username, password]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!username.trim() || !password.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate(from, { replace: true });
|
||||
} catch {
|
||||
// Error is handled by AuthContext
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bf-login-page">
|
||||
<div className="bf-login-container">
|
||||
{/* Logo/Brand */}
|
||||
<div className="bf-login-header">
|
||||
<h1 className="bf-login-brand">
|
||||
Brace<span className="bf-brand-accent">iQ</span>
|
||||
</h1>
|
||||
<p className="bf-login-subtitle">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form className="bf-login-form" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bf-login-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bf-form-group">
|
||||
<label htmlFor="username" className="bf-form-label">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
className="bf-form-input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bf-form-group">
|
||||
<label htmlFor="password" className="bf-form-label">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="bf-form-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
autoComplete="current-password"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bf-login-btn"
|
||||
disabled={isSubmitting || !username.trim() || !password.trim()}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="bf-login-spinner"></span>
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
552
frontend/src/pages/PipelineCaseDetail.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* Pipeline Case Detail Page
|
||||
* 3-stage pipeline: Landmarks → Analysis → Brace
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
fetchCase,
|
||||
uploadXrayForCase,
|
||||
detectLandmarks,
|
||||
updateLandmarks,
|
||||
approveLandmarks,
|
||||
recalculateAnalysis,
|
||||
generateBraceFromLandmarks,
|
||||
generateBothBraces,
|
||||
updateMarkers,
|
||||
getCaseAssets,
|
||||
uploadBodyScan,
|
||||
getBodyScan,
|
||||
deleteBodyScan,
|
||||
skipBodyScan,
|
||||
} from '../api/braceflowApi';
|
||||
import type {
|
||||
CaseRecord,
|
||||
LandmarksResult,
|
||||
RecalculationResult,
|
||||
GenerateBraceResponse,
|
||||
VertebraeStructure,
|
||||
BodyScanResponse,
|
||||
} from '../api/braceflowApi';
|
||||
|
||||
import PipelineSteps, { type PipelineStage } from '../components/pipeline/PipelineSteps';
|
||||
import LandmarkDetectionStage from '../components/pipeline/LandmarkDetectionStage';
|
||||
import SpineAnalysisStage from '../components/pipeline/SpineAnalysisStage';
|
||||
import BodyScanUploadStage from '../components/pipeline/BodyScanUploadStage';
|
||||
import BraceGenerationStage from '../components/pipeline/BraceGenerationStage';
|
||||
import BraceFittingStage from '../components/pipeline/BraceFittingStage';
|
||||
|
||||
export default function PipelineCaseDetail() {
|
||||
const { caseId } = useParams<{ caseId: string }>();
|
||||
const nav = useNavigate();
|
||||
|
||||
// Case data
|
||||
const [caseData, setCaseData] = useState<CaseRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stage data
|
||||
const [xrayUrl, setXrayUrl] = useState<string | null>(null);
|
||||
const [landmarksData, setLandmarksData] = useState<LandmarksResult | null>(null);
|
||||
const [analysisData, setAnalysisData] = useState<RecalculationResult | null>(null);
|
||||
const [bodyScanData, setBodyScanData] = useState<BodyScanResponse | null>(null);
|
||||
const [braceData, setBraceData] = useState<GenerateBraceResponse | null>(null);
|
||||
|
||||
// Current stage and loading states
|
||||
const [currentStage, setCurrentStage] = useState<PipelineStage>('upload');
|
||||
const [detectingLandmarks, setDetectingLandmarks] = useState(false);
|
||||
const [recalculating, setRecalculating] = useState(false);
|
||||
const [uploadingBodyScan, setUploadingBodyScan] = useState(false);
|
||||
const [generatingBrace, setGeneratingBrace] = useState(false);
|
||||
|
||||
// Load case data
|
||||
const loadCase = useCallback(async () => {
|
||||
if (!caseId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchCase(caseId);
|
||||
setCaseData(data);
|
||||
|
||||
// Load assets
|
||||
const assets = await getCaseAssets(caseId).catch((err) => {
|
||||
console.error('Failed to get case assets:', err);
|
||||
return null;
|
||||
});
|
||||
console.log('Case assets:', assets);
|
||||
const xray = assets?.assets?.uploads?.find((f) =>
|
||||
f.filename.match(/\.(jpg|jpeg|png)$/i)
|
||||
);
|
||||
console.log('Found xray:', xray);
|
||||
if (xray) {
|
||||
console.log('Setting xrayUrl to:', xray.url);
|
||||
setXrayUrl(xray.url);
|
||||
}
|
||||
|
||||
// Check existing state from case data
|
||||
if (data?.landmarks_data) {
|
||||
// Handle both formats - full LandmarksResult or just VertebraeStructure
|
||||
const ld = data.landmarks_data as any;
|
||||
if (ld.vertebrae_structure) {
|
||||
// Full LandmarksResult format
|
||||
setLandmarksData(ld as LandmarksResult);
|
||||
} else if (ld.vertebrae || ld.all_levels) {
|
||||
// Just VertebraeStructure - wrap it
|
||||
setLandmarksData({
|
||||
case_id: caseId || '',
|
||||
status: 'landmarks_detected',
|
||||
input: { image_dimensions: { width: 0, height: 0 }, pixel_spacing_mm: null },
|
||||
detection_quality: {
|
||||
vertebrae_count: ld.detected_count || ld.vertebrae?.filter((v: any) => v.detected).length || 0,
|
||||
average_confidence: 0
|
||||
},
|
||||
cobb_angles: ld.cobb_angles || { PT: 0, MT: 0, TL: 0, max: 0, PT_severity: 'N/A', MT_severity: 'N/A', TL_severity: 'N/A' },
|
||||
rigo_classification: ld.rigo_classification || { type: 'Unknown', description: '' },
|
||||
curve_type: ld.curve_type || 'Unknown',
|
||||
vertebrae_structure: ld as VertebraeStructure,
|
||||
processing_time_ms: 0
|
||||
} as LandmarksResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved analysis data if available (from previous recalculate)
|
||||
if (data?.analysis_data) {
|
||||
setAnalysisData(data.analysis_data as RecalculationResult);
|
||||
}
|
||||
|
||||
// Load body scan data if available
|
||||
if (data?.body_scan_path || data?.body_scan_url) {
|
||||
setBodyScanData({
|
||||
caseId: caseId || '',
|
||||
has_body_scan: true,
|
||||
body_scan: {
|
||||
path: data.body_scan_path || '',
|
||||
url: data.body_scan_url || '',
|
||||
metadata: data.body_scan_metadata || {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load brace data if available
|
||||
let hasBraceData = false;
|
||||
if (data?.analysis_result) {
|
||||
const analysisResult = data.analysis_result as any;
|
||||
// Check for both braces format (regular + vase)
|
||||
if (analysisResult.braces) {
|
||||
setBraceData({
|
||||
rigo_classification: { type: analysisResult.rigoType || 'A1' },
|
||||
cobb_angles: analysisResult.cobbAngles,
|
||||
braces: analysisResult.braces,
|
||||
} as any);
|
||||
hasBraceData = true;
|
||||
}
|
||||
// Check for single brace format (legacy)
|
||||
else if (analysisResult.brace) {
|
||||
setBraceData(analysisResult.brace as GenerateBraceResponse);
|
||||
hasBraceData = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine current stage - prioritize actual data over status
|
||||
// This ensures the UI reflects reality even if status is out of sync
|
||||
if (hasBraceData) {
|
||||
// Brace exists - go straight to brace stage
|
||||
setCurrentStage('brace');
|
||||
} else {
|
||||
// Determine stage from status
|
||||
switch (data?.status) {
|
||||
case 'created':
|
||||
setCurrentStage(xray ? 'landmarks' : 'upload');
|
||||
break;
|
||||
case 'landmarks_detected':
|
||||
// Landmarks detected but not approved - stay on landmarks stage
|
||||
setCurrentStage('landmarks');
|
||||
break;
|
||||
case 'landmarks_approved':
|
||||
// Landmarks approved - move to analysis stage
|
||||
setCurrentStage('analysis');
|
||||
break;
|
||||
case 'analysis_complete':
|
||||
// Analysis complete - move to body scan stage
|
||||
setCurrentStage('bodyscan');
|
||||
break;
|
||||
case 'body_scan_uploaded':
|
||||
// Body scan uploaded - still on body scan stage (need to continue)
|
||||
setCurrentStage('bodyscan');
|
||||
break;
|
||||
case 'processing_brace':
|
||||
// Currently generating brace
|
||||
setCurrentStage('brace');
|
||||
break;
|
||||
case 'brace_generated':
|
||||
case 'completed':
|
||||
// Brace already generated - show brace stage
|
||||
setCurrentStage('brace');
|
||||
break;
|
||||
default:
|
||||
setCurrentStage(xray ? 'landmarks' : 'upload');
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load case');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [caseId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCase();
|
||||
}, [loadCase]);
|
||||
|
||||
// Handle file upload
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await uploadXrayForCase(caseId, file);
|
||||
// Reload to get new X-ray URL
|
||||
await loadCase();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Upload failed');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 1: Detect landmarks
|
||||
const handleDetectLandmarks = async () => {
|
||||
if (!caseId) return;
|
||||
setDetectingLandmarks(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await detectLandmarks(caseId);
|
||||
setLandmarksData(result);
|
||||
setCurrentStage('landmarks');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Landmark detection failed');
|
||||
} finally {
|
||||
setDetectingLandmarks(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 1: Update landmarks
|
||||
const handleUpdateLandmarks = async (landmarks: VertebraeStructure) => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await updateLandmarks(caseId, landmarks);
|
||||
// Update local state
|
||||
if (landmarksData) {
|
||||
setLandmarksData({
|
||||
...landmarksData,
|
||||
vertebrae_structure: landmarks,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save landmarks');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 1 -> 2: Approve landmarks
|
||||
const handleApproveLandmarks = async (updatedLandmarks?: VertebraeStructure) => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await approveLandmarks(caseId, updatedLandmarks);
|
||||
setCurrentStage('analysis');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to approve landmarks');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 2: Recalculate analysis
|
||||
const handleRecalculate = async () => {
|
||||
if (!caseId) return;
|
||||
setRecalculating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await recalculateAnalysis(caseId);
|
||||
setAnalysisData(result);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Recalculation failed');
|
||||
} finally {
|
||||
setRecalculating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 2 -> 3: Continue to body scan
|
||||
const handleContinueToBodyScan = () => {
|
||||
setCurrentStage('bodyscan');
|
||||
};
|
||||
|
||||
// Stage 3: Upload body scan
|
||||
const handleUploadBodyScan = async (file: File) => {
|
||||
if (!caseId) return;
|
||||
setUploadingBodyScan(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await uploadBodyScan(caseId, file);
|
||||
setBodyScanData({
|
||||
caseId,
|
||||
has_body_scan: true,
|
||||
body_scan: result.body_scan
|
||||
});
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Body scan upload failed');
|
||||
} finally {
|
||||
setUploadingBodyScan(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3: Skip body scan
|
||||
const handleSkipBodyScan = async () => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await skipBodyScan(caseId);
|
||||
setBodyScanData({ caseId, has_body_scan: false, body_scan: null });
|
||||
setCurrentStage('brace');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to skip body scan');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3: Delete body scan
|
||||
const handleDeleteBodyScan = async () => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await deleteBodyScan(caseId);
|
||||
setBodyScanData({ caseId, has_body_scan: false, body_scan: null });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to delete body scan');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3 -> 4: Continue to brace generation
|
||||
const handleContinueToBrace = () => {
|
||||
setCurrentStage('brace');
|
||||
};
|
||||
|
||||
// Stage 4 -> 5: Continue to fitting inspection
|
||||
const handleContinueToFitting = () => {
|
||||
setCurrentStage('fitting');
|
||||
};
|
||||
|
||||
// Handle clicking on pipeline step to navigate
|
||||
const handleStageClick = (stage: PipelineStage) => {
|
||||
setCurrentStage(stage);
|
||||
};
|
||||
|
||||
// Stage 3: Generate brace (both regular and vase types)
|
||||
const handleGenerateBrace = async () => {
|
||||
if (!caseId) return;
|
||||
setGeneratingBrace(true);
|
||||
setError(null);
|
||||
|
||||
let standardResult: any = null;
|
||||
|
||||
try {
|
||||
// First try to generate the standard brace (for backward compatibility)
|
||||
try {
|
||||
standardResult = await generateBraceFromLandmarks(caseId, {
|
||||
experiment: 'experiment_9',
|
||||
});
|
||||
} catch (stdErr) {
|
||||
console.warn('Standard brace generation failed, trying both braces:', stdErr);
|
||||
}
|
||||
|
||||
// Generate both brace types (regular + vase) with markers
|
||||
const bothBraces = await generateBothBraces(caseId, {
|
||||
rigoType: standardResult?.rigo_classification?.type,
|
||||
});
|
||||
|
||||
// Merge both braces data into the result
|
||||
setBraceData({
|
||||
...(standardResult || {}),
|
||||
rigo_classification: standardResult?.rigo_classification || { type: bothBraces.rigoType },
|
||||
cobb_angles: standardResult?.cobb_angles || bothBraces.cobbAngles,
|
||||
braces: bothBraces.braces,
|
||||
} as any);
|
||||
|
||||
} catch (e: any) {
|
||||
// If both fail, show error
|
||||
if (standardResult) {
|
||||
// At least we have the standard result
|
||||
setBraceData(standardResult);
|
||||
} else {
|
||||
setError(e?.message || 'Brace generation failed');
|
||||
}
|
||||
} finally {
|
||||
setGeneratingBrace(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3: Update markers
|
||||
const handleUpdateMarkers = async (markers: Record<string, unknown>) => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await updateMarkers(caseId, markers);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save markers');
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pipeline-page">
|
||||
<div className="pipeline-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading case...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error && !caseData) {
|
||||
return (
|
||||
<div className="pipeline-page">
|
||||
<div className="pipeline-error">
|
||||
<p className="error">{error}</p>
|
||||
<button className="btn secondary" onClick={() => nav('/')}>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visualizationUrl =
|
||||
landmarksData?.visualization_url ||
|
||||
(landmarksData as any)?.visualization_path?.replace(/^.*[\\\/]/, '/files/outputs/' + caseId + '/');
|
||||
|
||||
return (
|
||||
<div className="pipeline-page">
|
||||
{/* Header */}
|
||||
<header className="pipeline-header">
|
||||
<div className="header-left">
|
||||
<button className="back-btn" onClick={() => nav('/')}>
|
||||
← Back
|
||||
</button>
|
||||
<h1 className="case-title">{caseId}</h1>
|
||||
<span className={`status-badge status-${caseData?.status || 'created'}`}>
|
||||
{caseData?.status?.replace(/_/g, ' ') || 'Created'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Pipeline Steps Indicator */}
|
||||
<PipelineSteps
|
||||
currentStage={currentStage}
|
||||
landmarksApproved={
|
||||
caseData?.status === 'landmarks_approved' ||
|
||||
caseData?.status === 'analysis_complete' ||
|
||||
caseData?.status === 'body_scan_uploaded' ||
|
||||
caseData?.status === 'brace_generated'
|
||||
}
|
||||
analysisComplete={
|
||||
caseData?.status === 'analysis_complete' ||
|
||||
caseData?.status === 'body_scan_uploaded' ||
|
||||
caseData?.status === 'brace_generated'
|
||||
}
|
||||
bodyScanComplete={
|
||||
caseData?.status === 'body_scan_uploaded' ||
|
||||
caseData?.status === 'brace_generated'
|
||||
}
|
||||
braceGenerated={caseData?.status === 'brace_generated'}
|
||||
onStageClick={handleStageClick}
|
||||
/>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Section (if no X-ray) */}
|
||||
{!xrayUrl && currentStage === 'upload' && (
|
||||
<div className="upload-section">
|
||||
<div className="upload-box">
|
||||
<h2>Upload X-ray Image</h2>
|
||||
<p>Upload a frontal (AP) X-ray image to begin analysis.</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage Components */}
|
||||
<div className="pipeline-stages">
|
||||
{(currentStage === 'landmarks' || xrayUrl) && (
|
||||
<LandmarkDetectionStage
|
||||
caseId={caseId || ''}
|
||||
landmarksData={landmarksData}
|
||||
xrayUrl={xrayUrl}
|
||||
visualizationUrl={visualizationUrl}
|
||||
isLoading={detectingLandmarks}
|
||||
onDetect={handleDetectLandmarks}
|
||||
onApprove={handleApproveLandmarks}
|
||||
onUpdateLandmarks={handleUpdateLandmarks}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentStage === 'analysis' || currentStage === 'bodyscan' || currentStage === 'brace') && (
|
||||
<SpineAnalysisStage
|
||||
landmarksData={landmarksData}
|
||||
analysisData={analysisData}
|
||||
isLoading={recalculating}
|
||||
onRecalculate={handleRecalculate}
|
||||
onContinue={handleContinueToBodyScan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentStage === 'bodyscan' || currentStage === 'brace') && (
|
||||
<BodyScanUploadStage
|
||||
caseId={caseId || ''}
|
||||
bodyScanData={bodyScanData}
|
||||
isLoading={uploadingBodyScan}
|
||||
onUpload={handleUploadBodyScan}
|
||||
onSkip={handleSkipBodyScan}
|
||||
onContinue={handleContinueToBrace}
|
||||
onDelete={handleDeleteBodyScan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStage === 'brace' && (
|
||||
<BraceGenerationStage
|
||||
caseId={caseId || ''}
|
||||
braceData={braceData}
|
||||
isLoading={generatingBrace}
|
||||
onGenerate={handleGenerateBrace}
|
||||
onUpdateMarkers={handleUpdateMarkers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stage 5: Brace Fitting - shows when brace is generated AND body scan exists */}
|
||||
{(currentStage === 'brace' || currentStage === 'fitting') && braceData && bodyScanData?.has_body_scan && (
|
||||
<BraceFittingStage
|
||||
caseId={caseId || ''}
|
||||
bodyScanUrl={bodyScanData?.body_scan?.url || null}
|
||||
regularBraceUrl={
|
||||
(braceData as any)?.braces?.regular?.outputs?.stl ||
|
||||
(braceData as any)?.braces?.regular?.outputs?.glb ||
|
||||
braceData?.outputs?.stl ||
|
||||
null
|
||||
}
|
||||
vaseBraceUrl={
|
||||
(braceData as any)?.braces?.vase?.outputs?.stl ||
|
||||
(braceData as any)?.braces?.vase?.outputs?.glb ||
|
||||
null
|
||||
}
|
||||
braceData={braceData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/pages/ShellEditorPage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export default function ShellEditorPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const modelUrl = searchParams.get("model");
|
||||
|
||||
// Build the SculptGL URL with optional model parameter
|
||||
// Always use nosphere=true to start with empty canvas
|
||||
const sculptglUrl = modelUrl
|
||||
? `/sculptgl/index.html?nosphere=true&model=${encodeURIComponent(modelUrl)}`
|
||||
: "/sculptgl/index.html?nosphere=true";
|
||||
|
||||
return (
|
||||
<div className="bf-page bf-page--full bf-page--compact">
|
||||
<div className="bf-page-header bf-page-header--center">
|
||||
<div>
|
||||
<h1 className="bf-page-title">Edit Shell</h1>
|
||||
<p className="bf-page-subtitle">Use SculptGL to refine the generated brace shell.</p>
|
||||
</div>
|
||||
<div className="bf-spacer" />
|
||||
<div className="bf-toolbar">
|
||||
<a
|
||||
href={sculptglUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn subtle small"
|
||||
>
|
||||
Open In New Tab
|
||||
</a>
|
||||
<a
|
||||
href="/sculptgl/index.html?nosphere=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn subtle small"
|
||||
>
|
||||
Open Empty Editor
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shell-editor-page">
|
||||
<div className="shell-editor-container">
|
||||
<iframe
|
||||
src={sculptglUrl}
|
||||
title="SculptGL Editor"
|
||||
className="shell-editor-iframe"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
</div>
|
||||
<div className="shell-editor-tips">
|
||||
<strong>Quick Tips:</strong>
|
||||
<span>Left-click + drag to sculpt</span>
|
||||
<span>Right-click + drag to rotate</span>
|
||||
<span>Scroll to zoom</span>
|
||||
<span>File -{"\u003e"} Import to load STL/OBJ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
frontend/src/pages/ShellGenerationPage.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import UploadPanel from "../components/rigo/UploadPanel";
|
||||
import BraceViewer from "../components/rigo/BraceViewer";
|
||||
import { rigoApi } from "../api/rigoApi";
|
||||
|
||||
// Helper to trigger file download
|
||||
function downloadFile(url: string, filename: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
export default function ShellGenerationPage() {
|
||||
const [modelUrl, setModelUrl] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Step 1: Analyze the X-ray
|
||||
const response = await rigoApi.analyze(file);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error("Generation failed");
|
||||
}
|
||||
|
||||
const analysis = response.analysis;
|
||||
|
||||
// Step 2: Generate full brace with shell
|
||||
const regenResult = await rigoApi.regenerate({
|
||||
pressure_pad_level: analysis.apex,
|
||||
pressure_pad_depth: analysis.cobb_angle > 40 ? "aggressive" : analysis.cobb_angle > 20 ? "moderate" : "standard",
|
||||
expansion_window_side: analysis.pelvic_tilt === "Left" ? "right" : "left",
|
||||
lumbar_support: true,
|
||||
include_shell: true,
|
||||
});
|
||||
|
||||
if (regenResult.success && regenResult.model_url) {
|
||||
const fullUrl = regenResult.model_url.startsWith("http")
|
||||
? regenResult.model_url
|
||||
: `${rigoApi.getBaseUrl()}${regenResult.model_url}`;
|
||||
|
||||
setModelUrl(fullUrl);
|
||||
|
||||
// Auto-download the STL file
|
||||
const stlUrl = fullUrl.replace(".glb", ".stl");
|
||||
const filename = `brace_${analysis.apex}_${analysis.cobb_angle}deg.stl`;
|
||||
|
||||
setTimeout(() => {
|
||||
downloadFile(stlUrl, filename);
|
||||
}, 1000);
|
||||
} else if (response.model_url) {
|
||||
const fullUrl = response.model_url.startsWith("http")
|
||||
? response.model_url
|
||||
: `${rigoApi.getBaseUrl()}${response.model_url}`;
|
||||
setModelUrl(fullUrl);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Generation failed. Please try again.";
|
||||
setError(message);
|
||||
console.error("Generation error:", err);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setModelUrl(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bf-page bf-page--wide">
|
||||
<div className="bf-page-header">
|
||||
<div>
|
||||
<h1 className="bf-page-title">Generate Shell</h1>
|
||||
<p className="bf-page-subtitle">
|
||||
Upload an X-ray, generate a brace shell, and preview it in 3D.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rigo-shell-page rigo-shell-page--two-col">
|
||||
{/* Left Panel - Upload */}
|
||||
<aside className="rigo-panel">
|
||||
<div className="rigo-panel-header">
|
||||
<h2 className="rigo-panel-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Upload X-Ray
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rigo-panel-content">
|
||||
<UploadPanel
|
||||
onUpload={handleUpload}
|
||||
isAnalyzing={isGenerating}
|
||||
onReset={handleReset}
|
||||
hasResults={!!modelUrl}
|
||||
/>
|
||||
{error && (
|
||||
<div
|
||||
className="rigo-error-message"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "rgba(255,0,0,0.1)",
|
||||
borderRadius: 8,
|
||||
color: "#f87171",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main - 3D Viewer */}
|
||||
<main className="rigo-viewer-container">
|
||||
<BraceViewer modelUrl={modelUrl} isLoading={isGenerating} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
frontend/src/pages/XrayUploadPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
/**
|
||||
* UI flow:
|
||||
* 1) If no caseId: create a new case
|
||||
* 2) Request presigned upload URL for AP view
|
||||
* 3) PUT file to S3 using uploadUrl
|
||||
* 4) Upload only; generation happens on demand
|
||||
*
|
||||
* Notes:
|
||||
* - Generation is triggered manually from the case page
|
||||
* - For PoC we upload only "ap". You can add "lat" later.
|
||||
*/
|
||||
export default function XrayUploadPage({
|
||||
caseId: propCaseId,
|
||||
onCaseCreated,
|
||||
onUploaded,
|
||||
}: {
|
||||
caseId?: string;
|
||||
onCaseCreated?: (caseId: string) => void;
|
||||
onUploaded?: (info: { caseId: string; s3Key: string }) => void;
|
||||
}) {
|
||||
// Support both prop and URL param for caseId
|
||||
const { caseId: paramCaseId } = useParams();
|
||||
const caseId = propCaseId || paramCaseId;
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const canUpload = useMemo(() => !!file && !busy, [file, busy]);
|
||||
|
||||
async function ensureCase(): Promise<string> {
|
||||
if (caseId?.trim()) return caseId.trim();
|
||||
|
||||
// Create case
|
||||
const createdAny: any = await api.createCase({ notes: "UI-created case" });
|
||||
const newId = createdAny.caseId || createdAny.case?.case_id || createdAny.id; // tolerate different shapes
|
||||
if (!newId) throw new Error("createCase response did not include caseId");
|
||||
|
||||
onCaseCreated?.(newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
async function onUpload() {
|
||||
if (!file) return;
|
||||
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
|
||||
try {
|
||||
const id = await ensureCase();
|
||||
|
||||
// 1) Presigned PUT URL
|
||||
const presign = await api.getUploadUrl(id, {
|
||||
view: "ap",
|
||||
// optional but helpful to pass through
|
||||
contentType: file.type || "application/octet-stream",
|
||||
filename: file.name,
|
||||
} as any);
|
||||
|
||||
const uploadUrl: string = presign.uploadUrl;
|
||||
const s3Key: string | undefined = (presign as any).s3Key || (presign as any).key;
|
||||
|
||||
if (!uploadUrl || !s3Key) throw new Error("getUploadUrl response missing uploadUrl/key");
|
||||
|
||||
// 2) Upload file bytes to S3
|
||||
const putRes = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
// Important: if your presign expects a content-type, keep it consistent.
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!putRes.ok) {
|
||||
const text = await putRes.text().catch(() => "");
|
||||
throw new Error(`S3 upload failed (${putRes.status}). ${text}`);
|
||||
}
|
||||
|
||||
setMsg("Uploaded OK. Open the case and click Generate Brace to run the pipeline.");
|
||||
|
||||
onUploaded?.({ caseId: id, s3Key });
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Upload failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bf-page bf-page--wide">
|
||||
<div className="bf-page-header">
|
||||
<div>
|
||||
<h1 className="bf-page-title">Start A Case</h1>
|
||||
<p className="bf-page-subtitle">
|
||||
Upload an AP X-ray. We will create a case automatically if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="muted muted--tight">
|
||||
Case: <strong>{caseId ?? "New (created on upload)"}</strong>
|
||||
</div>
|
||||
|
||||
<div className="row gap bf-mt-12">
|
||||
<input
|
||||
type="file"
|
||||
accept=".dcm,image/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button className="btn secondary" disabled={!canUpload} onClick={onUpload}>
|
||||
{busy ? "Uploading..." : "Upload X-ray"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className="notice bf-mt-12">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
frontend/src/pages/admin/AdminActivity.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAuditLog, type AuditLogEntry } from "../../api/adminApi";
|
||||
|
||||
export default function AdminActivity() {
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [actionFilter, setActionFilter] = useState("");
|
||||
const [limit, setLimit] = useState(50);
|
||||
|
||||
useEffect(() => {
|
||||
loadAuditLog();
|
||||
}, [actionFilter, limit]);
|
||||
|
||||
async function loadAuditLog() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getAuditLog({
|
||||
action: actionFilter || undefined,
|
||||
limit,
|
||||
});
|
||||
setEntries(data.entries);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load audit log");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function formatAction(action: string): string {
|
||||
return action.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function getActionColor(action: string): string {
|
||||
if (action.includes("delete")) return "bf-action-danger";
|
||||
if (action.includes("create")) return "bf-action-success";
|
||||
if (action.includes("update")) return "bf-action-warning";
|
||||
if (action.includes("login")) return "bf-action-info";
|
||||
return "bf-action-default";
|
||||
}
|
||||
|
||||
function parseDetails(details: string | null): Record<string, any> | null {
|
||||
if (!details) return null;
|
||||
try {
|
||||
return JSON.parse(details);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueActions = [...new Set(entries.map((e) => e.action))].sort();
|
||||
|
||||
return (
|
||||
<div className="bf-admin-page">
|
||||
<div className="bf-admin-header">
|
||||
<div>
|
||||
<h1>Activity Log</h1>
|
||||
<p className="bf-admin-subtitle">Audit trail of system actions</p>
|
||||
</div>
|
||||
<button className="btn" onClick={loadAuditLog} disabled={loading}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bf-admin-filters">
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
className="bf-admin-filter-select"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
{uniqueActions.map((action) => (
|
||||
<option key={action} value={action}>{formatAction(action)}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(parseInt(e.target.value))}
|
||||
className="bf-admin-filter-select"
|
||||
>
|
||||
<option value="25">Last 25</option>
|
||||
<option value="50">Last 50</option>
|
||||
<option value="100">Last 100</option>
|
||||
<option value="200">Last 200</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="bf-admin-error">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="bf-admin-loading">Loading activity log...</div>
|
||||
) : entries.length > 0 ? (
|
||||
<div className="bf-admin-card">
|
||||
<div className="bf-admin-activity-list">
|
||||
{entries.map((entry) => {
|
||||
const details = parseDetails(entry.details);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="bf-admin-activity-item">
|
||||
<div className="bf-admin-activity-icon">
|
||||
<span className={`bf-admin-activity-dot ${getActionColor(entry.action)}`} />
|
||||
</div>
|
||||
<div className="bf-admin-activity-content">
|
||||
<div className="bf-admin-activity-header">
|
||||
<span className={`bf-admin-activity-action ${getActionColor(entry.action)}`}>
|
||||
{formatAction(entry.action)}
|
||||
</span>
|
||||
<span className="bf-admin-activity-entity">
|
||||
{entry.entity_type}
|
||||
{entry.entity_id && `: ${entry.entity_id}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bf-admin-activity-meta">
|
||||
<span className="bf-admin-activity-user">
|
||||
{entry.username || "System"}
|
||||
</span>
|
||||
<span className="bf-admin-activity-time">
|
||||
{new Date(entry.created_at).toLocaleString()}
|
||||
</span>
|
||||
{entry.ip_address && (
|
||||
<span className="bf-admin-activity-ip">IP: {entry.ip_address}</span>
|
||||
)}
|
||||
</div>
|
||||
{details && Object.keys(details).length > 0 && (
|
||||
<div className="bf-admin-activity-details">
|
||||
{Object.entries(details).map(([key, value]) => (
|
||||
<span key={key} className="bf-admin-activity-detail">
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bf-admin-empty-state">
|
||||
<p>No activity recorded yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
frontend/src/pages/admin/AdminCases.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { listCasesAdmin, type AdminCase, type ListCasesResponse } from "../../api/adminApi";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "", label: "All Statuses" },
|
||||
{ value: "created", label: "Created" },
|
||||
{ value: "landmarks_detected", label: "Landmarks Detected" },
|
||||
{ value: "landmarks_approved", label: "Landmarks Approved" },
|
||||
{ value: "analysis_complete", label: "Analysis Complete" },
|
||||
{ value: "processing_brace", label: "Processing Brace" },
|
||||
{ value: "brace_generated", label: "Brace Generated" },
|
||||
{ value: "brace_failed", label: "Brace Failed" },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function AdminCases() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [data, setData] = useState<ListCasesResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortBy, setSortBy] = useState("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const loadCases = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await listCasesAdmin({
|
||||
status: statusFilter || undefined,
|
||||
search: searchQuery || undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: PAGE_SIZE,
|
||||
offset: currentPage * PAGE_SIZE,
|
||||
});
|
||||
setData(result);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load cases");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter, searchQuery, sortBy, sortOrder, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCases();
|
||||
}, [loadCases]);
|
||||
|
||||
function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCurrentPage(0);
|
||||
loadCases();
|
||||
}
|
||||
|
||||
function handleSort(column: string) {
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "ASC" ? "DESC" : "ASC");
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder("DESC");
|
||||
}
|
||||
setCurrentPage(0);
|
||||
}
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 0;
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "brace_generated": return "bf-status-success";
|
||||
case "processing_brace": return "bf-status-warning";
|
||||
case "brace_failed": return "bf-status-error";
|
||||
case "landmarks_detected":
|
||||
case "landmarks_approved":
|
||||
case "analysis_complete": return "bf-status-info";
|
||||
default: return "bf-status-default";
|
||||
}
|
||||
}
|
||||
|
||||
function extractRigoType(analysisResult: any): string | null {
|
||||
if (!analysisResult) return null;
|
||||
try {
|
||||
const result = typeof analysisResult === "string"
|
||||
? JSON.parse(analysisResult)
|
||||
: analysisResult;
|
||||
return result.rigoType || result.rigo_classification?.type || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCobbAngles(analysisResult: any, landmarksData: any): { PT?: number; MT?: number; TL?: number } | null {
|
||||
try {
|
||||
if (analysisResult) {
|
||||
const result = typeof analysisResult === "string"
|
||||
? JSON.parse(analysisResult)
|
||||
: analysisResult;
|
||||
if (result.cobbAngles || result.cobb_angles) {
|
||||
return result.cobbAngles || result.cobb_angles;
|
||||
}
|
||||
}
|
||||
if (landmarksData) {
|
||||
const landmarks = typeof landmarksData === "string"
|
||||
? JSON.parse(landmarksData)
|
||||
: landmarksData;
|
||||
return landmarks.cobb_angles || null;
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bf-admin-page">
|
||||
<div className="bf-admin-header">
|
||||
<div>
|
||||
<h1>All Cases</h1>
|
||||
<p className="bf-admin-subtitle">
|
||||
{data ? `${data.total} total cases` : "Loading..."}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn" onClick={loadCases} disabled={loading}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bf-admin-filters">
|
||||
<form onSubmit={handleSearch} className="bf-admin-search-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Case ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="bf-admin-search-input"
|
||||
/>
|
||||
<button type="submit" className="btn">Search</button>
|
||||
</form>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setCurrentPage(0);
|
||||
}}
|
||||
className="bf-admin-filter-select"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="bf-admin-error">{error}</div>}
|
||||
|
||||
{loading && !data ? (
|
||||
<div className="bf-admin-loading">Loading cases...</div>
|
||||
) : data && data.cases.length > 0 ? (
|
||||
<>
|
||||
<div className="bf-admin-card bf-admin-table-container">
|
||||
<table className="bf-admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
className="bf-admin-sortable"
|
||||
onClick={() => handleSort("case_id")}
|
||||
>
|
||||
Case ID {sortBy === "case_id" && (sortOrder === "ASC" ? "↑" : "↓")}
|
||||
</th>
|
||||
<th
|
||||
className="bf-admin-sortable"
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
Status {sortBy === "status" && (sortOrder === "ASC" ? "↑" : "↓")}
|
||||
</th>
|
||||
<th>Rigo Type</th>
|
||||
<th>Cobb Angles</th>
|
||||
<th>Body Scan</th>
|
||||
<th
|
||||
className="bf-admin-sortable"
|
||||
onClick={() => handleSort("created_at")}
|
||||
>
|
||||
Created {sortBy === "created_at" && (sortOrder === "ASC" ? "↑" : "↓")}
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.cases.map((c) => {
|
||||
const rigoType = extractRigoType(c.analysis_result);
|
||||
const cobbAngles = extractCobbAngles(c.analysis_result, c.landmarks_data);
|
||||
|
||||
return (
|
||||
<tr key={c.caseId}>
|
||||
<td className="bf-admin-cell-caseid">
|
||||
<code>{c.caseId}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`bf-admin-case-status ${getStatusColor(c.status)}`}>
|
||||
{c.status.replace(/_/g, " ")}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{rigoType ? (
|
||||
<span className="bf-admin-rigo-badge">{rigoType}</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className="bf-admin-cell-cobb">
|
||||
{cobbAngles ? (
|
||||
<span className="bf-admin-cobb-inline">
|
||||
{cobbAngles.PT !== undefined && <span>PT: {cobbAngles.PT}°</span>}
|
||||
{cobbAngles.MT !== undefined && <span>MT: {cobbAngles.MT}°</span>}
|
||||
{cobbAngles.TL !== undefined && <span>TL: {cobbAngles.TL}°</span>}
|
||||
</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
{c.body_scan_path ? (
|
||||
<span className="bf-admin-badge-yes">Yes</span>
|
||||
) : (
|
||||
<span className="bf-admin-badge-no">No</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="bf-admin-cell-date">
|
||||
{new Date(c.created_at).toLocaleDateString()}
|
||||
<br />
|
||||
<span className="bf-admin-cell-time">
|
||||
{new Date(c.created_at).toLocaleTimeString()}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="bf-admin-action-btn"
|
||||
onClick={() => navigate(`/cases/${c.caseId}`)}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bf-admin-pagination">
|
||||
<button
|
||||
className="btn small"
|
||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="bf-admin-page-info">
|
||||
Page {currentPage + 1} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn small"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="bf-admin-empty-state">
|
||||
<p>No cases found</p>
|
||||
{(statusFilter || searchQuery) && (
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setStatusFilter("");
|
||||
setSearchQuery("");
|
||||
setCurrentPage(0);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
frontend/src/pages/admin/AdminDashboard.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getDashboardAnalytics, type DashboardAnalytics } from "../../api/adminApi";
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalytics();
|
||||
}, []);
|
||||
|
||||
async function loadAnalytics() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDashboardAnalytics();
|
||||
setAnalytics(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load analytics");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bf-admin-page">
|
||||
<div className="bf-admin-loading">Loading analytics...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bf-admin-page">
|
||||
<div className="bf-admin-error">{error}</div>
|
||||
<button className="btn" onClick={loadAnalytics}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!analytics) return null;
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
brace_generated: "#4ade80",
|
||||
processing_brace: "#fbbf24",
|
||||
brace_failed: "#f87171",
|
||||
created: "#60a5fa",
|
||||
landmarks_detected: "#a78bfa",
|
||||
landmarks_approved: "#34d399",
|
||||
analysis_complete: "#22d3ee",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bf-admin-page">
|
||||
<div className="bf-admin-header">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<p className="bf-admin-subtitle">Analytics and system overview</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="bf-admin-stats-grid">
|
||||
<div className="bf-admin-stat-card">
|
||||
<div className="bf-admin-stat-value">{analytics.cases.total}</div>
|
||||
<div className="bf-admin-stat-label">Total Cases</div>
|
||||
</div>
|
||||
<div className="bf-admin-stat-card">
|
||||
<div className="bf-admin-stat-value">{analytics.cases.byStatus.brace_generated || 0}</div>
|
||||
<div className="bf-admin-stat-label">Braces Generated</div>
|
||||
</div>
|
||||
<div className="bf-admin-stat-card">
|
||||
<div className="bf-admin-stat-value">{analytics.users.total}</div>
|
||||
<div className="bf-admin-stat-label">Total Users</div>
|
||||
</div>
|
||||
<div className="bf-admin-stat-card">
|
||||
<div className="bf-admin-stat-value">{analytics.users.recentLogins}</div>
|
||||
<div className="bf-admin-stat-label">Active (7 days)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="bf-admin-charts-row">
|
||||
{/* Case Status Distribution */}
|
||||
<div className="bf-admin-card">
|
||||
<h3>Case Status Distribution</h3>
|
||||
<div className="bf-admin-status-list">
|
||||
{Object.entries(analytics.cases.byStatus).map(([status, count]) => (
|
||||
<div key={status} className="bf-admin-status-item">
|
||||
<div className="bf-admin-status-bar-container">
|
||||
<div
|
||||
className="bf-admin-status-bar"
|
||||
style={{
|
||||
width: `${(count / analytics.cases.total) * 100}%`,
|
||||
backgroundColor: statusColors[status] || "#64748b"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="bf-admin-status-label">
|
||||
<span className="bf-admin-status-name">{status.replace(/_/g, " ")}</span>
|
||||
<span className="bf-admin-status-count">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rigo Classification Distribution */}
|
||||
<div className="bf-admin-card">
|
||||
<h3>Rigo Classification</h3>
|
||||
{Object.keys(analytics.rigoDistribution).length > 0 ? (
|
||||
<div className="bf-admin-rigo-grid">
|
||||
{Object.entries(analytics.rigoDistribution)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, count]) => (
|
||||
<div key={type} className="bf-admin-rigo-item">
|
||||
<div className="bf-admin-rigo-type">{type}</div>
|
||||
<div className="bf-admin-rigo-count">{count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bf-admin-empty">No brace data yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cobb Angle Statistics */}
|
||||
<div className="bf-admin-card">
|
||||
<h3>Cobb Angle Statistics</h3>
|
||||
<p className="bf-admin-card-subtitle">
|
||||
Based on {analytics.cobbAngles.totalCasesWithAngles} cases with angle data
|
||||
</p>
|
||||
<div className="bf-admin-cobb-grid">
|
||||
{(["PT", "MT", "TL"] as const).map((angle) => (
|
||||
<div key={angle} className="bf-admin-cobb-card">
|
||||
<div className="bf-admin-cobb-header">{angle}</div>
|
||||
<div className="bf-admin-cobb-stats">
|
||||
<div className="bf-admin-cobb-stat">
|
||||
<span className="bf-admin-cobb-label">Avg</span>
|
||||
<span className="bf-admin-cobb-value">{analytics.cobbAngles[angle].avg}°</span>
|
||||
</div>
|
||||
<div className="bf-admin-cobb-stat">
|
||||
<span className="bf-admin-cobb-label">Min</span>
|
||||
<span className="bf-admin-cobb-value">{analytics.cobbAngles[angle].min}°</span>
|
||||
</div>
|
||||
<div className="bf-admin-cobb-stat">
|
||||
<span className="bf-admin-cobb-label">Max</span>
|
||||
<span className="bf-admin-cobb-value">{analytics.cobbAngles[angle].max}°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processing & Body Scan Stats */}
|
||||
<div className="bf-admin-charts-row">
|
||||
<div className="bf-admin-card">
|
||||
<h3>Processing Time</h3>
|
||||
{analytics.processingTime.count > 0 ? (
|
||||
<div className="bf-admin-processing-stats">
|
||||
<div className="bf-admin-processing-stat">
|
||||
<span className="bf-admin-processing-label">Average</span>
|
||||
<span className="bf-admin-processing-value">
|
||||
{(analytics.processingTime.avg / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="bf-admin-processing-stat">
|
||||
<span className="bf-admin-processing-label">Fastest</span>
|
||||
<span className="bf-admin-processing-value">
|
||||
{(analytics.processingTime.min / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="bf-admin-processing-stat">
|
||||
<span className="bf-admin-processing-label">Slowest</span>
|
||||
<span className="bf-admin-processing-value">
|
||||
{(analytics.processingTime.max / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bf-admin-empty">No processing data yet</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bf-admin-card">
|
||||
<h3>Body Scan Usage</h3>
|
||||
<div className="bf-admin-bodyscan-stats">
|
||||
<div className="bf-admin-bodyscan-chart">
|
||||
<div
|
||||
className="bf-admin-bodyscan-fill"
|
||||
style={{ height: `${analytics.bodyScan.percentage}%` }}
|
||||
/>
|
||||
<div className="bf-admin-bodyscan-percentage">
|
||||
{analytics.bodyScan.percentage}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bf-admin-bodyscan-legend">
|
||||
<div className="bf-admin-bodyscan-item">
|
||||
<span className="bf-admin-bodyscan-dot bf-admin-bodyscan-dot--with" />
|
||||
<span>With Body Scan: {analytics.bodyScan.withBodyScan}</span>
|
||||
</div>
|
||||
<div className="bf-admin-bodyscan-item">
|
||||
<span className="bf-admin-bodyscan-dot bf-admin-bodyscan-dot--without" />
|
||||
<span>X-ray Only: {analytics.bodyScan.withoutBodyScan}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="bf-admin-quick-links">
|
||||
<button className="bf-admin-link-btn" onClick={() => navigate("/admin/users")}>
|
||||
Manage Users →
|
||||
</button>
|
||||
<button className="bf-admin-link-btn" onClick={() => navigate("/admin/cases")}>
|
||||
View All Cases →
|
||||
</button>
|
||||
<button className="bf-admin-link-btn" onClick={() => navigate("/admin/activity")}>
|
||||
Activity Log →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
302
frontend/src/pages/admin/AdminUsers.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
listUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
type AdminUser
|
||||
} from "../../api/adminApi";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
|
||||
type UserFormData = {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: "admin" | "user" | "viewer";
|
||||
};
|
||||
|
||||
const emptyForm: UserFormData = {
|
||||
username: "",
|
||||
password: "",
|
||||
email: "",
|
||||
fullName: "",
|
||||
role: "user",
|
||||
};
|
||||
|
||||
export default function AdminUsers() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Modal state
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [formData, setFormData] = useState<UserFormData>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
async function loadUsers() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listUsers();
|
||||
setUsers(data.users);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load users");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingUser(null);
|
||||
setFormData(emptyForm);
|
||||
setFormError(null);
|
||||
setShowModal(true);
|
||||
}
|
||||
|
||||
function openEditModal(user: AdminUser) {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
username: user.username,
|
||||
password: "",
|
||||
email: user.email || "",
|
||||
fullName: user.full_name || "",
|
||||
role: user.role,
|
||||
});
|
||||
setFormError(null);
|
||||
setShowModal(true);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
|
||||
try {
|
||||
if (editingUser) {
|
||||
// Update existing user
|
||||
await updateUser(editingUser.id, {
|
||||
email: formData.email || undefined,
|
||||
fullName: formData.fullName || undefined,
|
||||
role: formData.role,
|
||||
password: formData.password || undefined,
|
||||
});
|
||||
} else {
|
||||
// Create new user
|
||||
if (!formData.username || !formData.password) {
|
||||
throw new Error("Username and password are required");
|
||||
}
|
||||
await createUser({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email || undefined,
|
||||
fullName: formData.fullName || undefined,
|
||||
role: formData.role,
|
||||
});
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
loadUsers();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || "Failed to save user");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(user: AdminUser) {
|
||||
if (user.id === currentUser?.id) {
|
||||
alert("You cannot disable your own account");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUser(user.id, { isActive: !user.is_active });
|
||||
loadUsers();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to update user");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(user: AdminUser) {
|
||||
if (user.id === currentUser?.id) {
|
||||
alert("You cannot delete your own account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete user "${user.username}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteUser(user.id);
|
||||
loadUsers();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to delete user");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bf-admin-page">
|
||||
<div className="bf-admin-header">
|
||||
<div>
|
||||
<h1>User Management</h1>
|
||||
<p className="bf-admin-subtitle">Manage system users and roles</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={openCreateModal}>
|
||||
+ Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="bf-admin-error">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="bf-admin-loading">Loading users...</div>
|
||||
) : (
|
||||
<div className="bf-admin-card">
|
||||
<table className="bf-admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className={!user.is_active ? "bf-admin-row-disabled" : ""}>
|
||||
<td className="bf-admin-cell-username">{user.username}</td>
|
||||
<td>{user.full_name || "—"}</td>
|
||||
<td>{user.email || "—"}</td>
|
||||
<td>
|
||||
<span className={`bf-admin-role-badge bf-admin-role-${user.role}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`bf-admin-status-badge ${user.is_active ? "bf-admin-status-active" : "bf-admin-status-inactive"}`}>
|
||||
{user.is_active ? "Active" : "Disabled"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="bf-admin-cell-date">
|
||||
{user.last_login
|
||||
? new Date(user.last_login).toLocaleDateString()
|
||||
: "Never"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="bf-admin-actions">
|
||||
<button
|
||||
className="bf-admin-action-btn"
|
||||
onClick={() => openEditModal(user)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="bf-admin-action-btn"
|
||||
onClick={() => handleToggleActive(user)}
|
||||
disabled={user.id === currentUser?.id}
|
||||
>
|
||||
{user.is_active ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button
|
||||
className="bf-admin-action-btn bf-admin-action-danger"
|
||||
onClick={() => handleDelete(user)}
|
||||
disabled={user.id === currentUser?.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Modal */}
|
||||
{showModal && (
|
||||
<div className="bf-admin-modal-backdrop" onClick={() => setShowModal(false)}>
|
||||
<div className="bf-admin-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>{editingUser ? "Edit User" : "Create User"}</h2>
|
||||
|
||||
{formError && <div className="bf-admin-form-error">{formError}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bf-admin-form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
disabled={!!editingUser}
|
||||
required={!editingUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bf-admin-form-group">
|
||||
<label>{editingUser ? "New Password (leave blank to keep)" : "Password"}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required={!editingUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bf-admin-form-group">
|
||||
<label>Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.fullName}
|
||||
onChange={(e) => setFormData({ ...formData, fullName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bf-admin-form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bf-admin-form-group">
|
||||
<label>Role</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bf-admin-form-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn primary" disabled={saving}>
|
||||
{saving ? "Saving..." : (editingUser ? "Update" : "Create")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3589
frontend/src/styles.css
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||