Initial commit - BraceIQMed platform with frontend, API, and brace generator

This commit is contained in:
2026-01-29 14:34:05 -08:00
commit 745f9f827f
187 changed files with 534688 additions and 0 deletions

80
frontend/.gitignore vendored Normal file
View 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)

View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET"],
"AllowedOrigins": ["*"],
"ExposeHeaders": [],
"MaxAgeSeconds": 3000
}
]

23
frontend/eslint.config.js Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View 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
View 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/*"
}
]
}

View 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>

View 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;
}

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

File diff suppressed because one or more lines are too long

View 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 ***!
\*****************************************/

File diff suppressed because one or more lines are too long

View 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
View 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
View 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
View 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)}`)} />;
}

View 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}` : ""}`);
}

View 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
View 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;
},
};

View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
</>
);
}

View 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 &amp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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)
}

View 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';

View 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
View 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
View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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

File diff suppressed because it is too large Load Diff

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})