Initial commit - BraceIQMed platform with frontend, API, and brace generator
21
.env.example
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# ============================================
|
||||||
|
# BraceIQMed - Environment Variables
|
||||||
|
# Copy this to .env and fill in values
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Node environment
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
API_PORT=3002
|
||||||
|
BRACE_GENERATOR_URL=http://brace-generator:8002
|
||||||
|
|
||||||
|
# Brace Generator Configuration
|
||||||
|
DEVICE=cpu
|
||||||
|
MODEL=scoliovis
|
||||||
|
|
||||||
|
# Optional: Deta credentials for model download
|
||||||
|
# DETA_ID=your_deta_id
|
||||||
|
|
||||||
|
# Optional: Custom domain
|
||||||
|
# DOMAIN=braceiqmed.com
|
||||||
68
.gitignore
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ============================================
|
||||||
|
# BraceIQMed - Git Ignore Configuration
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Environment files (keep .env.example)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.pytest_cache/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Model weights (downloaded at runtime)
|
||||||
|
models/*.pt
|
||||||
|
models/*.pth
|
||||||
|
scoliovis-api/models/*.pt
|
||||||
|
|
||||||
|
# User uploads and outputs (mounted as volumes)
|
||||||
|
api/data/
|
||||||
|
data/uploads/
|
||||||
|
data/outputs/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Docker volumes (local development)
|
||||||
|
docker-data/
|
||||||
76
README.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# BraceIQMed
|
||||||
|
|
||||||
|
Medical scoliosis brace generation platform.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
braceiqmed/
|
||||||
|
├── frontend/ # React + Vite + Three.js
|
||||||
|
├── api/ # Node.js Express API server
|
||||||
|
├── brace-generator/ # FastAPI + PyTorch brace generation
|
||||||
|
├── templates/ # Brace template STL/GLB files
|
||||||
|
├── scoliovis-api/ # ScolioVis ML model for spine detection
|
||||||
|
└── scripts/ # Deployment scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start (Local Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build all containers
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Access at: http://localhost
|
||||||
|
|
||||||
|
## Deployment (EC2)
|
||||||
|
|
||||||
|
### First-time setup on server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repo
|
||||||
|
git clone https://github.com/YOUR_USERNAME/braceiqmed.git ~/braceiqmed
|
||||||
|
cd ~/braceiqmed
|
||||||
|
|
||||||
|
# Build and start
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/braceiqmed
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the deploy script:
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Internal Port | Description |
|
||||||
|
|---------|---------------|-------------|
|
||||||
|
| Frontend | 80 | React SPA + nginx proxy |
|
||||||
|
| API | 3002 | Express.js REST API |
|
||||||
|
| Brace Generator | 8002 | FastAPI + PyTorch |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.example` for available configuration options.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Proprietary - All rights reserved.
|
||||||
36
api/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# ============================================
|
||||||
|
# BraceIQMed API - Node.js Express Server
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Install build dependencies for better-sqlite3
|
||||||
|
RUN apk add --no-cache python3 make g++ sqlite
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create data directories
|
||||||
|
RUN mkdir -p /app/data/uploads /app/data/outputs
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3002
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
ENV DB_PATH=/app/data/braceflow.db
|
||||||
|
|
||||||
|
EXPOSE 3002
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
902
api/db/sqlite.js
Normal file
@@ -0,0 +1,902 @@
|
|||||||
|
/**
|
||||||
|
* SQLite Database Wrapper for BraceFlow DEV
|
||||||
|
* Mirrors the MySQL schema but uses SQLite for local development
|
||||||
|
*/
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
// Use DB_PATH from env (Docker) or local path (dev)
|
||||||
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'braceflow_DEV.db');
|
||||||
|
|
||||||
|
console.log(`Database path: ${DB_PATH}`);
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
db.exec(`
|
||||||
|
-- Main cases table
|
||||||
|
CREATE TABLE IF NOT EXISTS brace_cases (
|
||||||
|
case_id TEXT PRIMARY KEY,
|
||||||
|
case_type TEXT NOT NULL DEFAULT 'braceflow',
|
||||||
|
status TEXT NOT NULL DEFAULT 'created' CHECK(status IN (
|
||||||
|
'created', 'running', 'completed', 'failed', 'cancelled',
|
||||||
|
'processing_brace', 'brace_generated', 'brace_failed',
|
||||||
|
'landmarks_detected', 'landmarks_approved', 'analysis_complete',
|
||||||
|
'body_scan_uploaded'
|
||||||
|
)),
|
||||||
|
current_step TEXT DEFAULT NULL,
|
||||||
|
execution_arn TEXT DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
analysis_result TEXT DEFAULT NULL,
|
||||||
|
landmarks_data TEXT DEFAULT NULL,
|
||||||
|
analysis_data TEXT DEFAULT NULL,
|
||||||
|
markers_data TEXT DEFAULT NULL,
|
||||||
|
body_scan_path TEXT DEFAULT NULL,
|
||||||
|
body_scan_url TEXT DEFAULT NULL,
|
||||||
|
body_scan_metadata TEXT DEFAULT NULL,
|
||||||
|
created_by INTEGER DEFAULT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Case steps table
|
||||||
|
CREATE TABLE IF NOT EXISTS brace_case_steps (
|
||||||
|
case_id TEXT NOT NULL,
|
||||||
|
step_name TEXT NOT NULL,
|
||||||
|
step_order INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN (
|
||||||
|
'pending', 'running', 'done', 'failed', 'waiting_for_landmarks'
|
||||||
|
)),
|
||||||
|
error_message TEXT DEFAULT NULL,
|
||||||
|
started_at TEXT DEFAULT NULL,
|
||||||
|
finished_at TEXT DEFAULT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (case_id, step_name),
|
||||||
|
FOREIGN KEY (case_id) REFERENCES brace_cases(case_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Task tokens table (for pipeline state)
|
||||||
|
CREATE TABLE IF NOT EXISTS brace_case_task_tokens (
|
||||||
|
case_id TEXT NOT NULL,
|
||||||
|
step_name TEXT NOT NULL,
|
||||||
|
task_token TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'consumed', 'expired')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (case_id, step_name),
|
||||||
|
FOREIGN KEY (case_id) REFERENCES brace_cases(case_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users table (for authentication)
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
email TEXT DEFAULT NULL,
|
||||||
|
full_name TEXT DEFAULT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user', 'viewer')),
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_login TEXT DEFAULT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User sessions table (for token management)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
session_token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Audit log table (for tracking admin actions)
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER DEFAULT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id TEXT DEFAULT NULL,
|
||||||
|
details TEXT DEFAULT NULL,
|
||||||
|
ip_address TEXT DEFAULT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cases_status ON brace_cases(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cases_created ON brace_cases(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_steps_case_id ON brace_case_steps(case_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(session_token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migration: Add new columns to existing tables
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE brace_cases ADD COLUMN analysis_data TEXT DEFAULT NULL`);
|
||||||
|
} catch (e) { /* Column already exists */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE brace_cases ADD COLUMN body_scan_path TEXT DEFAULT NULL`);
|
||||||
|
} catch (e) { /* Column already exists */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE brace_cases ADD COLUMN body_scan_url TEXT DEFAULT NULL`);
|
||||||
|
} catch (e) { /* Column already exists */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE brace_cases ADD COLUMN body_scan_metadata TEXT DEFAULT NULL`);
|
||||||
|
} catch (e) { /* Column already exists */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE brace_cases ADD COLUMN created_by INTEGER DEFAULT NULL`);
|
||||||
|
} catch (e) { /* Column already exists */ }
|
||||||
|
|
||||||
|
// Insert default admin user if not exists (password: admin123)
|
||||||
|
// Note: In production, use proper bcrypt hashing. This is a simple hash for dev.
|
||||||
|
try {
|
||||||
|
const existingAdmin = db.prepare(`SELECT id FROM users WHERE username = ?`).get('admin');
|
||||||
|
if (!existingAdmin) {
|
||||||
|
// Simple hash for dev - in production use bcrypt
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO users (username, password_hash, full_name, role, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run('admin', 'admin123', 'Administrator', 'admin', 1);
|
||||||
|
console.log('Created default admin user (admin/admin123)');
|
||||||
|
}
|
||||||
|
} catch (e) { /* User already exists or table not ready */ }
|
||||||
|
|
||||||
|
// Step names for the pipeline
|
||||||
|
const STEP_NAMES = [
|
||||||
|
'LandmarkDetection',
|
||||||
|
'LandmarkApproval',
|
||||||
|
'SpineAnalysis',
|
||||||
|
'BodyScanUpload',
|
||||||
|
'BraceGeneration',
|
||||||
|
'BraceApproval'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new case
|
||||||
|
*/
|
||||||
|
export function createCase(caseId, caseType = 'braceflow', notes = null) {
|
||||||
|
const insertCase = db.prepare(`
|
||||||
|
INSERT INTO brace_cases (case_id, case_type, status, notes, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'created', ?, datetime('now'), datetime('now'))
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertStep = db.prepare(`
|
||||||
|
INSERT INTO brace_case_steps (case_id, step_name, step_order, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'pending', datetime('now'), datetime('now'))
|
||||||
|
`);
|
||||||
|
|
||||||
|
const transaction = db.transaction(() => {
|
||||||
|
insertCase.run(caseId, caseType, notes);
|
||||||
|
STEP_NAMES.forEach((stepName, idx) => {
|
||||||
|
insertStep.run(caseId, stepName, idx + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction();
|
||||||
|
return { caseId, status: 'created', steps: STEP_NAMES };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all cases
|
||||||
|
*/
|
||||||
|
export function listCases() {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT case_id as caseId, case_type, status, current_step, notes,
|
||||||
|
analysis_result, landmarks_data, created_at, updated_at
|
||||||
|
FROM brace_cases
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
|
return stmt.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get case by ID with steps
|
||||||
|
*/
|
||||||
|
export function getCase(caseId) {
|
||||||
|
const caseStmt = db.prepare(`
|
||||||
|
SELECT case_id, case_type, status, current_step, notes,
|
||||||
|
analysis_result, landmarks_data, analysis_data, markers_data,
|
||||||
|
body_scan_path, body_scan_url, body_scan_metadata,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM brace_cases
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const stepsStmt = db.prepare(`
|
||||||
|
SELECT step_name, step_order, status, error_message, started_at, finished_at
|
||||||
|
FROM brace_case_steps
|
||||||
|
WHERE case_id = ?
|
||||||
|
ORDER BY step_order ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const caseData = caseStmt.get(caseId);
|
||||||
|
if (!caseData) return null;
|
||||||
|
|
||||||
|
const steps = stepsStmt.all(caseId);
|
||||||
|
|
||||||
|
// Parse JSON fields
|
||||||
|
let analysisResult = null;
|
||||||
|
let landmarksData = null;
|
||||||
|
let analysisData = null;
|
||||||
|
let markersData = null;
|
||||||
|
let bodyScanMetadata = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (caseData.analysis_result) {
|
||||||
|
analysisResult = JSON.parse(caseData.analysis_result);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (caseData.landmarks_data) {
|
||||||
|
landmarksData = JSON.parse(caseData.landmarks_data);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (caseData.analysis_data) {
|
||||||
|
analysisData = JSON.parse(caseData.analysis_data);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (caseData.markers_data) {
|
||||||
|
markersData = JSON.parse(caseData.markers_data);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (caseData.body_scan_metadata) {
|
||||||
|
bodyScanMetadata = JSON.parse(caseData.body_scan_metadata);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
return {
|
||||||
|
caseId: caseData.case_id,
|
||||||
|
case_type: caseData.case_type,
|
||||||
|
status: caseData.status,
|
||||||
|
current_step: caseData.current_step,
|
||||||
|
notes: caseData.notes,
|
||||||
|
analysis_result: analysisResult,
|
||||||
|
landmarks_data: landmarksData,
|
||||||
|
analysis_data: analysisData,
|
||||||
|
markers_data: markersData,
|
||||||
|
body_scan_path: caseData.body_scan_path,
|
||||||
|
body_scan_url: caseData.body_scan_url,
|
||||||
|
body_scan_metadata: bodyScanMetadata,
|
||||||
|
created_at: caseData.created_at,
|
||||||
|
updated_at: caseData.updated_at,
|
||||||
|
steps
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update case status
|
||||||
|
*/
|
||||||
|
export function updateCaseStatus(caseId, status, currentStep = null) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_cases
|
||||||
|
SET status = ?, current_step = ?, updated_at = datetime('now')
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
return stmt.run(status, currentStep, caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save landmarks data
|
||||||
|
*/
|
||||||
|
export function saveLandmarks(caseId, landmarksData) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_cases
|
||||||
|
SET landmarks_data = ?, status = 'landmarks_detected',
|
||||||
|
current_step = 'LandmarkApproval', updated_at = datetime('now')
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
return stmt.run(JSON.stringify(landmarksData), caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve landmarks
|
||||||
|
*/
|
||||||
|
export function approveLandmarks(caseId, updatedLandmarks = null) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_cases
|
||||||
|
SET landmarks_data = COALESCE(?, landmarks_data),
|
||||||
|
status = 'landmarks_approved',
|
||||||
|
current_step = 'SpineAnalysis',
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
const data = updatedLandmarks ? JSON.stringify(updatedLandmarks) : null;
|
||||||
|
return stmt.run(data, caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save analysis result
|
||||||
|
*/
|
||||||
|
export function saveAnalysisResult(caseId, analysisResult) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_cases
|
||||||
|
SET analysis_result = ?, status = 'analysis_complete',
|
||||||
|
current_step = 'BraceGeneration', updated_at = datetime('now')
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
return stmt.run(JSON.stringify(analysisResult), caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save brace generation result
|
||||||
|
*/
|
||||||
|
export function saveBraceResult(caseId, braceResult) {
|
||||||
|
const currentData = getCase(caseId);
|
||||||
|
const updatedAnalysis = {
|
||||||
|
...(currentData?.analysis_result || {}),
|
||||||
|
brace: braceResult
|
||||||
|
};
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_cases
|
||||||
|
SET analysis_result = ?, status = 'brace_generated',
|
||||||
|
current_step = 'BraceApproval', updated_at = datetime('now')
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
return stmt.run(JSON.stringify(updatedAnalysis), caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save both braces generation result (regular + vase)
|
||||||
|
*/
|
||||||
|
export function saveBothBracesResult(caseId, bracesData) {
|
||||||
|
const currentData = getCase(caseId);
|
||||||
|
const updatedAnalysis = {
|
||||||
|
...(currentData?.analysis_result || {}),
|
||||||
|
braces: bracesData.braces,
|
||||||
|
rigoType: bracesData.rigoType,
|
||||||
|
cobbAngles: bracesData.cobbAngles,
|
||||||
|
bodyScanUsed: bracesData.bodyScanUsed
|
||||||
|
};
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_cases
|
||||||
|
SET analysis_result = ?, status = 'brace_generated',
|
||||||
|
current_step = 'BraceApproval', updated_at = datetime('now')
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
return stmt.run(JSON.stringify(updatedAnalysis), caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save markers data (for brace editor)
|
||||||
|
*/
|
||||||
|
export function saveMarkers(caseId, markersData) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_cases
|
||||||
|
SET markers_data = ?, updated_at = datetime('now')
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
return stmt.run(JSON.stringify(markersData), caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save analysis data (from recalculate - separate from brace result)
|
||||||
|
*/
|
||||||
|
export function saveAnalysisData(caseId, analysisData) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_cases
|
||||||
|
SET analysis_data = ?, updated_at = datetime('now')
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
return stmt.run(JSON.stringify(analysisData), caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save body scan info
|
||||||
|
* Note: We use 'analysis_complete' status for compatibility with existing databases
|
||||||
|
* that may not have 'body_scan_uploaded' in their CHECK constraint
|
||||||
|
*/
|
||||||
|
export function saveBodyScan(caseId, scanPath, scanUrl, metadata = null) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_cases
|
||||||
|
SET body_scan_path = ?, body_scan_url = ?, body_scan_metadata = ?,
|
||||||
|
status = 'analysis_complete', current_step = 'BraceGeneration',
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
const metadataJson = metadata ? JSON.stringify(metadata) : null;
|
||||||
|
return stmt.run(scanPath, scanUrl, metadataJson, caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear body scan (user wants to skip or remove)
|
||||||
|
*/
|
||||||
|
export function clearBodyScan(caseId) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_cases
|
||||||
|
SET body_scan_path = NULL, body_scan_url = NULL, body_scan_metadata = NULL,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE case_id = ?
|
||||||
|
`);
|
||||||
|
return stmt.run(caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete case
|
||||||
|
*/
|
||||||
|
export function deleteCase(caseId) {
|
||||||
|
const stmt = db.prepare(`DELETE FROM brace_cases WHERE case_id = ?`);
|
||||||
|
return stmt.run(caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update step status
|
||||||
|
*/
|
||||||
|
export function updateStepStatus(caseId, stepName, status, errorMessage = null) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE brace_case_steps
|
||||||
|
SET status = ?, error_message = ?,
|
||||||
|
started_at = CASE WHEN ? = 'running' THEN datetime('now') ELSE started_at END,
|
||||||
|
finished_at = CASE WHEN ? IN ('done', 'failed') THEN datetime('now') ELSE finished_at END,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE case_id = ? AND step_name = ?
|
||||||
|
`);
|
||||||
|
return stmt.run(status, errorMessage, status, status, caseId, stepName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USER MANAGEMENT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by username (for login)
|
||||||
|
*/
|
||||||
|
export function getUserByUsername(username) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT id, username, password_hash, email, full_name, role, is_active, last_login, created_at
|
||||||
|
FROM users WHERE username = ?
|
||||||
|
`);
|
||||||
|
return stmt.get(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by ID
|
||||||
|
*/
|
||||||
|
export function getUserById(userId) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT id, username, email, full_name, role, is_active, last_login, created_at, updated_at
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
`);
|
||||||
|
return stmt.get(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all users
|
||||||
|
*/
|
||||||
|
export function listUsers() {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT id, username, email, full_name, role, is_active, last_login, created_at, updated_at
|
||||||
|
FROM users ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
|
return stmt.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create user
|
||||||
|
*/
|
||||||
|
export function createUser(username, passwordHash, email = null, fullName = null, role = 'user') {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO users (username, password_hash, email, full_name, role, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1, datetime('now'), datetime('now'))
|
||||||
|
`);
|
||||||
|
const result = stmt.run(username, passwordHash, email, fullName, role);
|
||||||
|
return { id: result.lastInsertRowid, username, email, fullName, role };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user
|
||||||
|
*/
|
||||||
|
export function updateUser(userId, updates) {
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
if (updates.email !== undefined) { fields.push('email = ?'); values.push(updates.email); }
|
||||||
|
if (updates.fullName !== undefined) { fields.push('full_name = ?'); values.push(updates.fullName); }
|
||||||
|
if (updates.role !== undefined) { fields.push('role = ?'); values.push(updates.role); }
|
||||||
|
if (updates.isActive !== undefined) { fields.push('is_active = ?'); values.push(updates.isActive ? 1 : 0); }
|
||||||
|
if (updates.passwordHash !== undefined) { fields.push('password_hash = ?'); values.push(updates.passwordHash); }
|
||||||
|
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
|
||||||
|
fields.push('updated_at = datetime(\'now\')');
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
const stmt = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`);
|
||||||
|
return stmt.run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last login
|
||||||
|
*/
|
||||||
|
export function updateLastLogin(userId) {
|
||||||
|
const stmt = db.prepare(`UPDATE users SET last_login = datetime('now'), updated_at = datetime('now') WHERE id = ?`);
|
||||||
|
return stmt.run(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete user
|
||||||
|
*/
|
||||||
|
export function deleteUser(userId) {
|
||||||
|
const stmt = db.prepare(`DELETE FROM users WHERE id = ?`);
|
||||||
|
return stmt.run(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SESSION MANAGEMENT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create session
|
||||||
|
*/
|
||||||
|
export function createSession(userId, token, expiresAt) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO user_sessions (user_id, session_token, expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
`);
|
||||||
|
return stmt.run(userId, token, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session by token
|
||||||
|
*/
|
||||||
|
export function getSessionByToken(token) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT s.*, u.username, u.role, u.full_name, u.is_active
|
||||||
|
FROM user_sessions s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.session_token = ? AND s.expires_at > datetime('now')
|
||||||
|
`);
|
||||||
|
return stmt.get(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete session
|
||||||
|
*/
|
||||||
|
export function deleteSession(token) {
|
||||||
|
const stmt = db.prepare(`DELETE FROM user_sessions WHERE session_token = ?`);
|
||||||
|
return stmt.run(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete expired sessions
|
||||||
|
*/
|
||||||
|
export function cleanupExpiredSessions() {
|
||||||
|
const stmt = db.prepare(`DELETE FROM user_sessions WHERE expires_at < datetime('now')`);
|
||||||
|
return stmt.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AUDIT LOGGING
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an action
|
||||||
|
*/
|
||||||
|
export function logAudit(userId, action, entityType, entityId = null, details = null, ipAddress = null) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
`);
|
||||||
|
return stmt.run(userId, action, entityType, entityId, details ? JSON.stringify(details) : null, ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit log entries
|
||||||
|
*/
|
||||||
|
export function getAuditLog(options = {}) {
|
||||||
|
const { userId, action, entityType, limit = 100, offset = 0 } = options;
|
||||||
|
|
||||||
|
let where = [];
|
||||||
|
let values = [];
|
||||||
|
|
||||||
|
if (userId) { where.push('a.user_id = ?'); values.push(userId); }
|
||||||
|
if (action) { where.push('a.action = ?'); values.push(action); }
|
||||||
|
if (entityType) { where.push('a.entity_type = ?'); values.push(entityType); }
|
||||||
|
|
||||||
|
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT a.*, u.username
|
||||||
|
FROM audit_log a
|
||||||
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
return stmt.all(...values, limit, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ANALYTICS QUERIES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get case statistics
|
||||||
|
*/
|
||||||
|
export function getCaseStats() {
|
||||||
|
const totalCases = db.prepare(`SELECT COUNT(*) as count FROM brace_cases`).get();
|
||||||
|
const byStatus = db.prepare(`
|
||||||
|
SELECT status, COUNT(*) as count
|
||||||
|
FROM brace_cases
|
||||||
|
GROUP BY status
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const last7Days = db.prepare(`
|
||||||
|
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||||
|
FROM brace_cases
|
||||||
|
WHERE created_at >= datetime('now', '-7 days')
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date ASC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const last30Days = db.prepare(`
|
||||||
|
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||||
|
FROM brace_cases
|
||||||
|
WHERE created_at >= datetime('now', '-30 days')
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date ASC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: totalCases.count,
|
||||||
|
byStatus: byStatus.reduce((acc, row) => { acc[row.status] = row.count; return acc; }, {}),
|
||||||
|
last7Days,
|
||||||
|
last30Days
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Rigo classification distribution (from analysis_result JSON)
|
||||||
|
*/
|
||||||
|
export function getRigoDistribution() {
|
||||||
|
const cases = db.prepare(`
|
||||||
|
SELECT analysis_result FROM brace_cases
|
||||||
|
WHERE analysis_result IS NOT NULL AND status = 'brace_generated'
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const distribution = {};
|
||||||
|
for (const c of cases) {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(c.analysis_result);
|
||||||
|
const rigoType = result.rigoType || result.rigo_classification?.type || result.brace?.rigo_classification?.type;
|
||||||
|
if (rigoType) {
|
||||||
|
distribution[rigoType] = (distribution[rigoType] || 0) + 1;
|
||||||
|
}
|
||||||
|
} catch (e) { /* skip invalid JSON */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return distribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Cobb angle statistics
|
||||||
|
*/
|
||||||
|
export function getCobbAngleStats() {
|
||||||
|
const cases = db.prepare(`
|
||||||
|
SELECT analysis_result, landmarks_data FROM brace_cases
|
||||||
|
WHERE (analysis_result IS NOT NULL OR landmarks_data IS NOT NULL)
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const angles = { PT: [], MT: [], TL: [] };
|
||||||
|
|
||||||
|
for (const c of cases) {
|
||||||
|
try {
|
||||||
|
let cobb = null;
|
||||||
|
|
||||||
|
// Try analysis_result first
|
||||||
|
if (c.analysis_result) {
|
||||||
|
const result = JSON.parse(c.analysis_result);
|
||||||
|
cobb = result.cobbAngles || result.cobb_angles || result.brace?.cobb_angles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to landmarks_data
|
||||||
|
if (!cobb && c.landmarks_data) {
|
||||||
|
const landmarks = JSON.parse(c.landmarks_data);
|
||||||
|
cobb = landmarks.cobb_angles;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cobb) {
|
||||||
|
if (cobb.PT !== undefined) angles.PT.push(cobb.PT);
|
||||||
|
if (cobb.MT !== undefined) angles.MT.push(cobb.MT);
|
||||||
|
if (cobb.TL !== undefined) angles.TL.push(cobb.TL);
|
||||||
|
}
|
||||||
|
} catch (e) { /* skip invalid JSON */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const calcStats = (arr) => {
|
||||||
|
if (arr.length === 0) return { min: 0, max: 0, avg: 0, count: 0 };
|
||||||
|
const sum = arr.reduce((a, b) => a + b, 0);
|
||||||
|
return {
|
||||||
|
min: Math.min(...arr),
|
||||||
|
max: Math.max(...arr),
|
||||||
|
avg: Math.round((sum / arr.length) * 10) / 10,
|
||||||
|
count: arr.length
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
PT: calcStats(angles.PT),
|
||||||
|
MT: calcStats(angles.MT),
|
||||||
|
TL: calcStats(angles.TL),
|
||||||
|
totalCasesWithAngles: Math.max(angles.PT.length, angles.MT.length, angles.TL.length)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get processing time statistics
|
||||||
|
*/
|
||||||
|
export function getProcessingTimeStats() {
|
||||||
|
const cases = db.prepare(`
|
||||||
|
SELECT analysis_result FROM brace_cases
|
||||||
|
WHERE analysis_result IS NOT NULL AND status = 'brace_generated'
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const times = [];
|
||||||
|
|
||||||
|
for (const c of cases) {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(c.analysis_result);
|
||||||
|
const time = result.processing_time_ms || result.brace?.processing_time_ms;
|
||||||
|
if (time) times.push(time);
|
||||||
|
} catch (e) { /* skip invalid JSON */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (times.length === 0) {
|
||||||
|
return { min: 0, max: 0, avg: 0, count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = times.reduce((a, b) => a + b, 0);
|
||||||
|
return {
|
||||||
|
min: Math.min(...times),
|
||||||
|
max: Math.max(...times),
|
||||||
|
avg: Math.round(sum / times.length),
|
||||||
|
count: times.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get body scan usage stats
|
||||||
|
*/
|
||||||
|
export function getBodyScanStats() {
|
||||||
|
const total = db.prepare(`SELECT COUNT(*) as count FROM brace_cases WHERE status = 'brace_generated'`).get();
|
||||||
|
const withScan = db.prepare(`SELECT COUNT(*) as count FROM brace_cases WHERE status = 'brace_generated' AND body_scan_path IS NOT NULL`).get();
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: total.count,
|
||||||
|
withBodyScan: withScan.count,
|
||||||
|
withoutBodyScan: total.count - withScan.count,
|
||||||
|
percentage: total.count > 0 ? Math.round((withScan.count / total.count) * 100) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user statistics
|
||||||
|
*/
|
||||||
|
export function getUserStats() {
|
||||||
|
const total = db.prepare(`SELECT COUNT(*) as count FROM users`).get();
|
||||||
|
const byRole = db.prepare(`SELECT role, COUNT(*) as count FROM users GROUP BY role`).all();
|
||||||
|
const active = db.prepare(`SELECT COUNT(*) as count FROM users WHERE is_active = 1`).get();
|
||||||
|
const recentLogins = db.prepare(`
|
||||||
|
SELECT COUNT(*) as count FROM users
|
||||||
|
WHERE last_login >= datetime('now', '-7 days')
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: total.count,
|
||||||
|
active: active.count,
|
||||||
|
inactive: total.count - active.count,
|
||||||
|
byRole: byRole.reduce((acc, row) => { acc[row.role] = row.count; return acc; }, {}),
|
||||||
|
recentLogins: recentLogins.count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List cases with filters (for admin)
|
||||||
|
*/
|
||||||
|
export function listCasesFiltered(options = {}) {
|
||||||
|
const { status, createdBy, search, limit = 50, offset = 0, sortBy = 'created_at', sortOrder = 'DESC' } = options;
|
||||||
|
|
||||||
|
let where = [];
|
||||||
|
let values = [];
|
||||||
|
|
||||||
|
if (status) { where.push('c.status = ?'); values.push(status); }
|
||||||
|
if (createdBy) { where.push('c.created_by = ?'); values.push(createdBy); }
|
||||||
|
if (search) { where.push('c.case_id LIKE ?'); values.push(`%${search}%`); }
|
||||||
|
|
||||||
|
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
const validSortColumns = ['created_at', 'updated_at', 'status', 'case_id'];
|
||||||
|
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : 'created_at';
|
||||||
|
const order = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
const countStmt = db.prepare(`SELECT COUNT(*) as count FROM brace_cases c ${whereClause}`);
|
||||||
|
const totalCount = countStmt.get(...values).count;
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT c.case_id as caseId, c.case_type, c.status, c.current_step, c.notes,
|
||||||
|
c.analysis_result, c.landmarks_data, c.body_scan_path,
|
||||||
|
c.created_by, c.created_at, c.updated_at,
|
||||||
|
u.username as created_by_username
|
||||||
|
FROM brace_cases c
|
||||||
|
LEFT JOIN users u ON c.created_by = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY c.${sortColumn} ${order}
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cases = stmt.all(...values, limit, offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cases,
|
||||||
|
total: totalCount,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
createCase,
|
||||||
|
listCases,
|
||||||
|
listCasesFiltered,
|
||||||
|
getCase,
|
||||||
|
updateCaseStatus,
|
||||||
|
saveLandmarks,
|
||||||
|
approveLandmarks,
|
||||||
|
saveAnalysisResult,
|
||||||
|
saveAnalysisData,
|
||||||
|
saveBraceResult,
|
||||||
|
saveBothBracesResult,
|
||||||
|
saveMarkers,
|
||||||
|
saveBodyScan,
|
||||||
|
clearBodyScan,
|
||||||
|
deleteCase,
|
||||||
|
updateStepStatus,
|
||||||
|
STEP_NAMES,
|
||||||
|
// User management
|
||||||
|
getUserByUsername,
|
||||||
|
getUserById,
|
||||||
|
listUsers,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
updateLastLogin,
|
||||||
|
deleteUser,
|
||||||
|
// Session management
|
||||||
|
createSession,
|
||||||
|
getSessionByToken,
|
||||||
|
deleteSession,
|
||||||
|
cleanupExpiredSessions,
|
||||||
|
// Audit logging
|
||||||
|
logAudit,
|
||||||
|
getAuditLog,
|
||||||
|
// Analytics
|
||||||
|
getCaseStats,
|
||||||
|
getRigoDistribution,
|
||||||
|
getCobbAngleStats,
|
||||||
|
getProcessingTimeStats,
|
||||||
|
getBodyScanStats,
|
||||||
|
getUserStats
|
||||||
|
};
|
||||||
1619
api/package-lock.json
generated
Normal file
21
api/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "braceflow-api-dev",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "DEV API server for BraceFlow (Express + SQLite)",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"undici": "^7.19.2",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
2065
api/server.js
Normal file
68
brace-generator/Dockerfile
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ============================================
|
||||||
|
# BraceIQMed Brace Generator - FastAPI + PyTorch (CPU)
|
||||||
|
# Build context: repo root (braceiqmed/)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
# Prevent interactive prompts
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libsm6 \
|
||||||
|
libxext6 \
|
||||||
|
libxrender-dev \
|
||||||
|
wget \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install PyTorch CPU version (smaller, no CUDA)
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
|
# Copy and install requirements (from brace-generator folder)
|
||||||
|
COPY brace-generator/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy scoliovis-api requirements and install
|
||||||
|
COPY scoliovis-api/requirements.txt /app/requirements-scoliovis.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements-scoliovis.txt || true
|
||||||
|
|
||||||
|
# Copy brace-generator code
|
||||||
|
COPY brace-generator/ /app/brace_generator/server_DEV/
|
||||||
|
|
||||||
|
# Copy scoliovis-api
|
||||||
|
COPY scoliovis-api/ /app/scoliovis-api/
|
||||||
|
|
||||||
|
# Copy templates
|
||||||
|
COPY templates/ /app/templates/
|
||||||
|
|
||||||
|
# Set Python path
|
||||||
|
ENV PYTHONPATH=/app:/app/brace_generator/server_DEV:/app/scoliovis-api
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=8002
|
||||||
|
ENV DEVICE=cpu
|
||||||
|
ENV MODEL=scoliovis
|
||||||
|
ENV TEMP_DIR=/tmp/brace_generator
|
||||||
|
ENV CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
RUN mkdir -p /tmp/brace_generator /app/data/uploads /app/data/outputs
|
||||||
|
|
||||||
|
EXPOSE 8002
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8002/health || exit 1
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
CMD ["python", "-m", "uvicorn", "brace_generator.server_DEV.app:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||||
8
brace-generator/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Brace Generator Server Package.
|
||||||
|
"""
|
||||||
|
from .app import app
|
||||||
|
from .config import config
|
||||||
|
from .services import BraceService
|
||||||
|
|
||||||
|
__all__ = ["app", "config", "BraceService"]
|
||||||
137
brace-generator/app.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
FastAPI server for Brace Generator.
|
||||||
|
|
||||||
|
Provides REST API for:
|
||||||
|
- X-ray analysis and landmark detection
|
||||||
|
- Cobb angle measurement
|
||||||
|
- Rigo-Chêneau classification
|
||||||
|
- Adaptive brace generation (STL/PLY)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uvicorn server.app:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
Or with Docker:
|
||||||
|
docker run -p 8000:8000 --gpus all brace-generator
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
# Add parent directories to path for imports
|
||||||
|
server_dir = Path(__file__).parent
|
||||||
|
brace_generator_dir = server_dir.parent
|
||||||
|
spine_dir = brace_generator_dir.parent
|
||||||
|
|
||||||
|
sys.path.insert(0, str(spine_dir))
|
||||||
|
sys.path.insert(0, str(brace_generator_dir))
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .routes import router
|
||||||
|
from .services import BraceService
|
||||||
|
|
||||||
|
|
||||||
|
# Global service instance (initialized on startup)
|
||||||
|
brace_service: BraceService = None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Initialize and cleanup resources."""
|
||||||
|
global brace_service
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Brace Generator Server Starting")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
config.ensure_dirs()
|
||||||
|
|
||||||
|
# Initialize service with model
|
||||||
|
device = config.get_device()
|
||||||
|
print(f"Device: {device}")
|
||||||
|
|
||||||
|
if device == "cuda":
|
||||||
|
print(f"GPU: {torch.cuda.get_device_name(0)}")
|
||||||
|
print(f"GPU Memory: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB")
|
||||||
|
|
||||||
|
print(f"Loading model: {config.MODEL}")
|
||||||
|
brace_service = BraceService(device=device, model=config.MODEL)
|
||||||
|
print("Model loaded successfully!")
|
||||||
|
|
||||||
|
# Make service available to routes
|
||||||
|
app.state.brace_service = brace_service
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Server ready at http://{config.HOST}:{config.PORT}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
print("Shutting down...")
|
||||||
|
del brace_service
|
||||||
|
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title="Brace Generator API",
|
||||||
|
description="""
|
||||||
|
API for generating scoliosis braces from X-ray images.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Vertebrae landmark detection (ScolioVis model)
|
||||||
|
- Cobb angle measurement (PT, MT, TL)
|
||||||
|
- Rigo-Chêneau classification
|
||||||
|
- Adaptive brace generation with research-based deformations
|
||||||
|
|
||||||
|
## Experiments
|
||||||
|
- **standard**: Original template-based pipeline
|
||||||
|
- **experiment_3**: Research-based adaptive deformation (Guy et al. 2024)
|
||||||
|
""",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=config.CORS_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include routes
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
|
||||||
|
# Exception handlers
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(request, exc):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"error": exc.detail}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def general_exception_handler(request, exc):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "Internal server error", "detail": str(exc)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"server.app:app",
|
||||||
|
host=config.HOST,
|
||||||
|
port=config.PORT,
|
||||||
|
reload=config.DEBUG
|
||||||
|
)
|
||||||
411
brace-generator/body_integration.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
"""
|
||||||
|
Body scan integration for patient-specific brace fitting.
|
||||||
|
|
||||||
|
Based on EXPERIMENT_10's approach:
|
||||||
|
1. Extract body measurements from 3D scan
|
||||||
|
2. Compute body basis (coordinate frame)
|
||||||
|
3. Select template based on Rigo classification
|
||||||
|
4. Fit shell to body using basis alignment
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, Optional, Tuple
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
|
||||||
|
try:
|
||||||
|
import trimesh
|
||||||
|
HAS_TRIMESH = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_TRIMESH = False
|
||||||
|
|
||||||
|
# Add EXPERIMENT_10 to path for imports
|
||||||
|
EXPERIMENTS_DIR = Path(__file__).parent.parent / "EXPERIMENTS"
|
||||||
|
EXP10_DIR = EXPERIMENTS_DIR / "EXPERIMENT_10"
|
||||||
|
if str(EXP10_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(EXP10_DIR))
|
||||||
|
|
||||||
|
# Import EXPERIMENT_10 modules
|
||||||
|
try:
|
||||||
|
from body_measurements import extract_body_measurements, measurements_to_dict, BodyMeasurements
|
||||||
|
from body_basis import compute_body_basis, body_basis_to_dict, BodyBasis
|
||||||
|
from shell_fitter_v2 import (
|
||||||
|
fit_shell_to_body_v2,
|
||||||
|
compute_brace_basis_from_geometry,
|
||||||
|
brace_basis_to_dict,
|
||||||
|
RIGO_TO_VASE,
|
||||||
|
FittingFeedback
|
||||||
|
)
|
||||||
|
HAS_EXP10 = True
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"Warning: Could not import EXPERIMENT_10 modules: {e}")
|
||||||
|
HAS_EXP10 = False
|
||||||
|
|
||||||
|
# Vase templates directory
|
||||||
|
VASES_DIR = Path(__file__).parent.parent.parent / "_vase" / "_vase"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_measurements_from_scan(scan_path: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract body measurements from a 3D body scan.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_path: Path to STL/OBJ/PLY body scan file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with measurements suitable for API response
|
||||||
|
"""
|
||||||
|
if not HAS_TRIMESH:
|
||||||
|
raise ImportError("trimesh is required for body scan processing")
|
||||||
|
|
||||||
|
# Try EXPERIMENT_10 first
|
||||||
|
if HAS_EXP10:
|
||||||
|
try:
|
||||||
|
measurements = extract_body_measurements(scan_path)
|
||||||
|
result = measurements_to_dict(measurements)
|
||||||
|
|
||||||
|
# Flatten for API-friendly format
|
||||||
|
flat = {
|
||||||
|
"total_height_mm": result["overall_dimensions"]["total_height_mm"],
|
||||||
|
"shoulder_width_mm": result["widths_mm"]["shoulder_width"],
|
||||||
|
"chest_width_mm": result["widths_mm"]["chest_width"],
|
||||||
|
"chest_depth_mm": result["depths_mm"]["chest_depth"],
|
||||||
|
"waist_width_mm": result["widths_mm"]["waist_width"],
|
||||||
|
"waist_depth_mm": result["depths_mm"]["waist_depth"],
|
||||||
|
"hip_width_mm": result["widths_mm"]["hip_width"],
|
||||||
|
"hip_depth_mm": result["depths_mm"]["hip_depth"],
|
||||||
|
"brace_coverage_height_mm": result["brace_coverage_region"]["coverage_height_mm"],
|
||||||
|
"chest_circumference_mm": result["circumferences_mm"]["chest"],
|
||||||
|
"waist_circumference_mm": result["circumferences_mm"]["waist"],
|
||||||
|
"hip_circumference_mm": result["circumferences_mm"]["hip"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Also include full detailed result
|
||||||
|
flat["detailed"] = result
|
||||||
|
return flat
|
||||||
|
except Exception as e:
|
||||||
|
print(f"EXPERIMENT_10 measurement extraction failed: {e}, using fallback")
|
||||||
|
|
||||||
|
# Fallback: Simple trimesh-based measurements
|
||||||
|
return _extract_measurements_trimesh_fallback(scan_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_measurements_trimesh_fallback(scan_path: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Simple fallback for body measurements using trimesh bounding box analysis.
|
||||||
|
Less accurate than EXPERIMENT_10 but provides basic measurements.
|
||||||
|
"""
|
||||||
|
mesh = trimesh.load(scan_path)
|
||||||
|
|
||||||
|
# Get bounding box
|
||||||
|
bounds = mesh.bounds
|
||||||
|
min_pt, max_pt = bounds[0], bounds[1]
|
||||||
|
|
||||||
|
# Assuming Y is up (typical human scan orientation)
|
||||||
|
# Try to auto-detect orientation
|
||||||
|
extents = max_pt - min_pt
|
||||||
|
height_axis = np.argmax(extents) # Longest axis is usually height
|
||||||
|
|
||||||
|
if height_axis == 1: # Y-up
|
||||||
|
total_height = extents[1]
|
||||||
|
width_axis, depth_axis = 0, 2
|
||||||
|
elif height_axis == 2: # Z-up
|
||||||
|
total_height = extents[2]
|
||||||
|
width_axis, depth_axis = 0, 1
|
||||||
|
else: # X-up (unusual)
|
||||||
|
total_height = extents[0]
|
||||||
|
width_axis, depth_axis = 1, 2
|
||||||
|
|
||||||
|
width = extents[width_axis]
|
||||||
|
depth = extents[depth_axis]
|
||||||
|
|
||||||
|
# Estimate body segments using height percentages
|
||||||
|
# These are approximate ratios for human body
|
||||||
|
chest_height_ratio = 0.75 # Chest at 75% of height from bottom
|
||||||
|
waist_height_ratio = 0.60 # Waist at 60% of height
|
||||||
|
hip_height_ratio = 0.50 # Hips at 50% of height
|
||||||
|
shoulder_height_ratio = 0.82 # Shoulders at 82%
|
||||||
|
|
||||||
|
# Get cross-sections at different heights to estimate widths
|
||||||
|
def get_width_at_height(height_ratio):
|
||||||
|
if height_axis == 1:
|
||||||
|
h = min_pt[1] + total_height * height_ratio
|
||||||
|
mask = (mesh.vertices[:, 1] > h - total_height * 0.05) & \
|
||||||
|
(mesh.vertices[:, 1] < h + total_height * 0.05)
|
||||||
|
elif height_axis == 2:
|
||||||
|
h = min_pt[2] + total_height * height_ratio
|
||||||
|
mask = (mesh.vertices[:, 2] > h - total_height * 0.05) & \
|
||||||
|
(mesh.vertices[:, 2] < h + total_height * 0.05)
|
||||||
|
else:
|
||||||
|
h = min_pt[0] + total_height * height_ratio
|
||||||
|
mask = (mesh.vertices[:, 0] > h - total_height * 0.05) & \
|
||||||
|
(mesh.vertices[:, 0] < h + total_height * 0.05)
|
||||||
|
|
||||||
|
if not np.any(mask):
|
||||||
|
return width, depth
|
||||||
|
|
||||||
|
slice_verts = mesh.vertices[mask]
|
||||||
|
slice_width = np.ptp(slice_verts[:, width_axis])
|
||||||
|
slice_depth = np.ptp(slice_verts[:, depth_axis])
|
||||||
|
return slice_width, slice_depth
|
||||||
|
|
||||||
|
shoulder_w, shoulder_d = get_width_at_height(shoulder_height_ratio)
|
||||||
|
chest_w, chest_d = get_width_at_height(chest_height_ratio)
|
||||||
|
waist_w, waist_d = get_width_at_height(waist_height_ratio)
|
||||||
|
hip_w, hip_d = get_width_at_height(hip_height_ratio)
|
||||||
|
|
||||||
|
# Estimate circumferences using ellipse approximation
|
||||||
|
def estimate_circumference(w, d):
|
||||||
|
a, b = w / 2, d / 2
|
||||||
|
# Ramanujan's approximation for ellipse circumference
|
||||||
|
h = ((a - b) ** 2) / ((a + b) ** 2)
|
||||||
|
return np.pi * (a + b) * (1 + 3 * h / (10 + np.sqrt(4 - 3 * h)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_height_mm": float(total_height),
|
||||||
|
"shoulder_width_mm": float(shoulder_w),
|
||||||
|
"chest_width_mm": float(chest_w),
|
||||||
|
"chest_depth_mm": float(chest_d),
|
||||||
|
"waist_width_mm": float(waist_w),
|
||||||
|
"waist_depth_mm": float(waist_d),
|
||||||
|
"hip_width_mm": float(hip_w),
|
||||||
|
"hip_depth_mm": float(hip_d),
|
||||||
|
"brace_coverage_height_mm": float(total_height * 0.55), # 55% coverage
|
||||||
|
"chest_circumference_mm": float(estimate_circumference(chest_w, chest_d)),
|
||||||
|
"waist_circumference_mm": float(estimate_circumference(waist_w, waist_d)),
|
||||||
|
"hip_circumference_mm": float(estimate_circumference(hip_w, hip_d)),
|
||||||
|
"measurement_source": "trimesh_fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_fitted_brace(
|
||||||
|
body_scan_path: str,
|
||||||
|
rigo_type: str,
|
||||||
|
output_dir: str,
|
||||||
|
case_id: str,
|
||||||
|
clearance_mm: float = 8.0,
|
||||||
|
wall_thickness_mm: float = 2.4
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate a patient-specific brace fitted to body scan.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body_scan_path: Path to 3D body scan (STL/OBJ/PLY)
|
||||||
|
rigo_type: Rigo classification (A1, A2, B1, etc.)
|
||||||
|
output_dir: Directory to save output files
|
||||||
|
case_id: Case identifier for naming files
|
||||||
|
clearance_mm: Clearance between body and shell (default 8mm)
|
||||||
|
wall_thickness_mm: Shell wall thickness (default 2.4mm for 3D printing)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with output file paths and fitting info
|
||||||
|
"""
|
||||||
|
if not HAS_TRIMESH:
|
||||||
|
raise ImportError("trimesh is required for brace fitting")
|
||||||
|
|
||||||
|
if not HAS_EXP10:
|
||||||
|
raise ImportError("EXPERIMENT_10 modules not available")
|
||||||
|
|
||||||
|
output_path = Path(output_dir)
|
||||||
|
output_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Select template based on Rigo type
|
||||||
|
template_file = RIGO_TO_VASE.get(rigo_type, "A1_vase.OBJ")
|
||||||
|
template_path = VASES_DIR / template_file
|
||||||
|
|
||||||
|
if not template_path.exists():
|
||||||
|
# Try alternative paths
|
||||||
|
alt_paths = [
|
||||||
|
EXPERIMENTS_DIR / "EXPERIMENT_10" / "_vase" / template_file,
|
||||||
|
Path(__file__).parent.parent.parent / "_vase" / template_file,
|
||||||
|
]
|
||||||
|
for alt in alt_paths:
|
||||||
|
if alt.exists():
|
||||||
|
template_path = alt
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError(f"Template not found: {template_file}")
|
||||||
|
|
||||||
|
# Fit shell to body
|
||||||
|
# Returns: (shell_mesh, body_mesh, combined_mesh, feedback)
|
||||||
|
fitted_mesh, body_mesh, combined_mesh, feedback = fit_shell_to_body_v2(
|
||||||
|
body_scan_path=body_scan_path,
|
||||||
|
template_path=str(template_path),
|
||||||
|
clearance_mm=clearance_mm
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate output files
|
||||||
|
outputs = {}
|
||||||
|
|
||||||
|
# Shell STL (for 3D printing)
|
||||||
|
shell_stl = output_path / f"{case_id}_shell.stl"
|
||||||
|
fitted_mesh.export(str(shell_stl))
|
||||||
|
outputs["shell_stl"] = str(shell_stl)
|
||||||
|
|
||||||
|
# Shell GLB (for web viewing)
|
||||||
|
shell_glb = output_path / f"{case_id}_shell.glb"
|
||||||
|
fitted_mesh.export(str(shell_glb))
|
||||||
|
outputs["shell_glb"] = str(shell_glb)
|
||||||
|
|
||||||
|
# Combined body + shell STL (for visualization)
|
||||||
|
# combined_mesh is already returned from fit_shell_to_body_v2
|
||||||
|
combined_stl = output_path / f"{case_id}_body_with_shell.stl"
|
||||||
|
combined_mesh.export(str(combined_stl))
|
||||||
|
outputs["combined_stl"] = str(combined_stl)
|
||||||
|
|
||||||
|
# Feedback JSON
|
||||||
|
feedback_json = output_path / f"{case_id}_feedback.json"
|
||||||
|
with open(feedback_json, "w") as f:
|
||||||
|
json.dump(asdict(feedback), f, indent=2, default=_json_serializer)
|
||||||
|
outputs["feedback_json"] = str(feedback_json)
|
||||||
|
|
||||||
|
# Create visualization
|
||||||
|
try:
|
||||||
|
viz_path = output_path / f"{case_id}_visualization.png"
|
||||||
|
create_fitting_visualization(body_mesh, fitted_mesh, feedback, str(viz_path))
|
||||||
|
outputs["visualization"] = str(viz_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not create visualization: {e}")
|
||||||
|
|
||||||
|
# Return result
|
||||||
|
return {
|
||||||
|
"template_used": template_file,
|
||||||
|
"rigo_type": rigo_type,
|
||||||
|
"clearance_mm": clearance_mm,
|
||||||
|
"fitting": {
|
||||||
|
"scale_right": feedback.scale_right,
|
||||||
|
"scale_up": feedback.scale_up,
|
||||||
|
"scale_forward": feedback.scale_forward,
|
||||||
|
"pelvis_distance_mm": feedback.pelvis_distance_mm,
|
||||||
|
"up_alignment_dot": feedback.up_alignment_dot,
|
||||||
|
"warnings": feedback.warnings,
|
||||||
|
},
|
||||||
|
"body_measurements": {
|
||||||
|
"max_width_mm": feedback.max_body_width_mm,
|
||||||
|
"max_depth_mm": feedback.max_body_depth_mm,
|
||||||
|
},
|
||||||
|
"shell_dimensions": {
|
||||||
|
"width_mm": feedback.target_shell_width_mm,
|
||||||
|
"depth_mm": feedback.target_shell_depth_mm,
|
||||||
|
"bounds_min": feedback.final_bounds_min,
|
||||||
|
"bounds_max": feedback.final_bounds_max,
|
||||||
|
},
|
||||||
|
"mesh_stats": {
|
||||||
|
"vertices": len(fitted_mesh.vertices),
|
||||||
|
"faces": len(fitted_mesh.faces),
|
||||||
|
},
|
||||||
|
"outputs": outputs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_fitting_visualization(
|
||||||
|
body_mesh: 'trimesh.Trimesh',
|
||||||
|
shell_mesh: 'trimesh.Trimesh',
|
||||||
|
feedback: 'FittingFeedback',
|
||||||
|
output_path: str
|
||||||
|
):
|
||||||
|
"""Create a multi-panel visualization of the fitted brace."""
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from mpl_toolkits.mplot3d import Axes3D
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
fig = plt.figure(figsize=(16, 10))
|
||||||
|
|
||||||
|
# Panel 1: Front view
|
||||||
|
ax1 = fig.add_subplot(2, 3, 1, projection='3d')
|
||||||
|
plot_mesh_silhouette(ax1, body_mesh, 'gray', alpha=0.3)
|
||||||
|
plot_mesh_silhouette(ax1, shell_mesh, 'blue', alpha=0.6)
|
||||||
|
ax1.set_title('Front View')
|
||||||
|
ax1.view_init(elev=0, azim=0)
|
||||||
|
|
||||||
|
# Panel 2: Side view
|
||||||
|
ax2 = fig.add_subplot(2, 3, 2, projection='3d')
|
||||||
|
plot_mesh_silhouette(ax2, body_mesh, 'gray', alpha=0.3)
|
||||||
|
plot_mesh_silhouette(ax2, shell_mesh, 'blue', alpha=0.6)
|
||||||
|
ax2.set_title('Side View')
|
||||||
|
ax2.view_init(elev=0, azim=90)
|
||||||
|
|
||||||
|
# Panel 3: Top view
|
||||||
|
ax3 = fig.add_subplot(2, 3, 3, projection='3d')
|
||||||
|
plot_mesh_silhouette(ax3, body_mesh, 'gray', alpha=0.3)
|
||||||
|
plot_mesh_silhouette(ax3, shell_mesh, 'blue', alpha=0.6)
|
||||||
|
ax3.set_title('Top View')
|
||||||
|
ax3.view_init(elev=90, azim=0)
|
||||||
|
|
||||||
|
# Panel 4: Fitting info
|
||||||
|
ax4 = fig.add_subplot(2, 3, 4)
|
||||||
|
ax4.axis('off')
|
||||||
|
info_text = f"""
|
||||||
|
Fitting Information
|
||||||
|
-------------------
|
||||||
|
Template: {feedback.template_name}
|
||||||
|
Clearance: {feedback.clearance_mm} mm
|
||||||
|
|
||||||
|
Scale Factors:
|
||||||
|
Right: {feedback.scale_right:.3f}
|
||||||
|
Up: {feedback.scale_up:.3f}
|
||||||
|
Forward: {feedback.scale_forward:.3f}
|
||||||
|
|
||||||
|
Alignment:
|
||||||
|
Pelvis Distance: {feedback.pelvis_distance_mm:.2f} mm
|
||||||
|
Up Alignment: {feedback.up_alignment_dot:.4f}
|
||||||
|
|
||||||
|
Shell vs Body:
|
||||||
|
Width Margin: {feedback.shell_minus_body_width_mm:.1f} mm
|
||||||
|
Depth Margin: {feedback.shell_minus_body_depth_mm:.1f} mm
|
||||||
|
"""
|
||||||
|
ax4.text(0.1, 0.9, info_text, transform=ax4.transAxes, fontsize=10,
|
||||||
|
verticalalignment='top', fontfamily='monospace')
|
||||||
|
|
||||||
|
# Panel 5: Warnings
|
||||||
|
ax5 = fig.add_subplot(2, 3, 5)
|
||||||
|
ax5.axis('off')
|
||||||
|
warnings_text = "Warnings:\n" + ("\n".join(feedback.warnings) if feedback.warnings else "None")
|
||||||
|
ax5.text(0.1, 0.9, warnings_text, transform=ax5.transAxes, fontsize=10,
|
||||||
|
verticalalignment='top', color='orange' if feedback.warnings else 'green')
|
||||||
|
|
||||||
|
# Panel 6: Isometric view
|
||||||
|
ax6 = fig.add_subplot(2, 3, 6, projection='3d')
|
||||||
|
plot_mesh_silhouette(ax6, body_mesh, 'gray', alpha=0.3)
|
||||||
|
plot_mesh_silhouette(ax6, shell_mesh, 'blue', alpha=0.6)
|
||||||
|
ax6.set_title('Isometric View')
|
||||||
|
ax6.view_init(elev=20, azim=45)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
|
||||||
|
def plot_mesh_silhouette(ax, mesh, color, alpha=0.5):
|
||||||
|
"""Plot a simplified mesh representation."""
|
||||||
|
# Sample vertices for plotting
|
||||||
|
verts = mesh.vertices
|
||||||
|
if len(verts) > 5000:
|
||||||
|
indices = np.random.choice(len(verts), 5000, replace=False)
|
||||||
|
verts = verts[indices]
|
||||||
|
|
||||||
|
ax.scatter(verts[:, 0], verts[:, 1], verts[:, 2],
|
||||||
|
c=color, alpha=alpha, s=1)
|
||||||
|
|
||||||
|
# Set equal aspect ratio
|
||||||
|
max_range = np.max(mesh.extents) / 2
|
||||||
|
mid = mesh.centroid
|
||||||
|
ax.set_xlim(mid[0] - max_range, mid[0] + max_range)
|
||||||
|
ax.set_ylim(mid[1] - max_range, mid[1] + max_range)
|
||||||
|
ax.set_zlim(mid[2] - max_range, mid[2] + max_range)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_serializer(obj):
|
||||||
|
"""JSON serializer for numpy types."""
|
||||||
|
if isinstance(obj, np.ndarray):
|
||||||
|
return obj.tolist()
|
||||||
|
if isinstance(obj, (np.float32, np.float64)):
|
||||||
|
return float(obj)
|
||||||
|
if isinstance(obj, (np.int32, np.int64)):
|
||||||
|
return int(obj)
|
||||||
|
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
|
||||||
58
brace-generator/config.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Server configuration for Brace Generator API.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Server configuration loaded from environment variables."""
|
||||||
|
|
||||||
|
# Server settings (DEV uses port 8001)
|
||||||
|
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||||
|
PORT: int = int(os.getenv("PORT", "8001"))
|
||||||
|
DEBUG: bool = os.getenv("DEBUG", "true").lower() == "true"
|
||||||
|
|
||||||
|
# Model settings
|
||||||
|
DEVICE: str = os.getenv("DEVICE", "cuda") # 'cuda' or 'cpu'
|
||||||
|
MODEL: str = os.getenv("MODEL", "scoliovis") # 'scoliovis' or 'vertebra-landmark'
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
BASE_DIR: Path = Path(__file__).parent.parent
|
||||||
|
TEMPLATES_DIR: Path = BASE_DIR / "rigoBrace(2)"
|
||||||
|
WEIGHTS_DIR: Path = BASE_DIR.parent / "scoliovis-api" / "models"
|
||||||
|
|
||||||
|
# TEMP_DIR: Use system temp on Windows, /tmp on Linux
|
||||||
|
@staticmethod
|
||||||
|
def _get_temp_dir() -> Path:
|
||||||
|
env_temp = os.getenv("TEMP_DIR")
|
||||||
|
if env_temp:
|
||||||
|
return Path(env_temp)
|
||||||
|
# Use system temp directory (works on both Windows and Linux)
|
||||||
|
import tempfile
|
||||||
|
return Path(tempfile.gettempdir()) / "brace_generator"
|
||||||
|
|
||||||
|
TEMP_DIR: Path = _get_temp_dir()
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS: list = os.getenv("CORS_ORIGINS", "*").split(",")
|
||||||
|
|
||||||
|
# Request limits
|
||||||
|
MAX_IMAGE_SIZE_MB: int = int(os.getenv("MAX_IMAGE_SIZE_MB", "50"))
|
||||||
|
REQUEST_TIMEOUT_SECONDS: int = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "120"))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ensure_dirs(cls):
|
||||||
|
"""Create necessary directories."""
|
||||||
|
cls.TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_device(cls) -> str:
|
||||||
|
"""Get device, falling back to CPU if CUDA unavailable."""
|
||||||
|
import torch
|
||||||
|
if cls.DEVICE == "cuda" and torch.cuda.is_available():
|
||||||
|
return "cuda"
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
config = Config()
|
||||||
906
brace-generator/glb_generator.py
Normal file
@@ -0,0 +1,906 @@
|
|||||||
|
"""
|
||||||
|
GLB Brace Generator with Markers
|
||||||
|
|
||||||
|
This module generates GLB brace files with embedded markers for editing.
|
||||||
|
Supports both regular (fitted) and vase-shaped templates.
|
||||||
|
|
||||||
|
PRESSURE ZONES EXPLANATION:
|
||||||
|
===========================
|
||||||
|
|
||||||
|
The brace has 4 main pressure/expansion zones that correct spinal curvature:
|
||||||
|
|
||||||
|
1. THORACIC PAD (LM_PAD_TH) - PUSH ZONE
|
||||||
|
- Location: On the CONVEX side of the thoracic curve (the side that bulges out)
|
||||||
|
- Function: Pushes INWARD to correct the thoracic curvature
|
||||||
|
- For right thoracic curves: pad is on the RIGHT back
|
||||||
|
- Depth: 8-25mm depending on Cobb angle severity
|
||||||
|
|
||||||
|
2. THORACIC BAY (LM_BAY_TH) - EXPANSION ZONE
|
||||||
|
- Location: OPPOSITE the thoracic pad (concave side)
|
||||||
|
- Function: Creates SPACE for the body to move INTO during correction
|
||||||
|
- The ribs/body shift into this space as the pad pushes
|
||||||
|
- Clearance: 10-35mm
|
||||||
|
|
||||||
|
3. LUMBAR PAD (LM_PAD_LUM) - PUSH ZONE
|
||||||
|
- Location: On the CONVEX side of the lumbar curve
|
||||||
|
- Function: Pushes INWARD to correct lumbar curvature
|
||||||
|
- Usually on the opposite side of thoracic pad (for S-curves)
|
||||||
|
- Depth: 6-20mm
|
||||||
|
|
||||||
|
4. LUMBAR BAY (LM_BAY_LUM) - EXPANSION ZONE
|
||||||
|
- Location: OPPOSITE the lumbar pad
|
||||||
|
- Function: Creates SPACE for lumbar correction
|
||||||
|
- Clearance: 8-25mm
|
||||||
|
|
||||||
|
5. HIP ANCHORS (LM_ANCHOR_HIP_L/R) - STABILITY ZONES
|
||||||
|
- Location: Around the hip/pelvis area on both sides
|
||||||
|
- Function: Grip the pelvis to prevent brace from riding up
|
||||||
|
- Slight inward pressure to anchor the brace
|
||||||
|
|
||||||
|
The Rigo classification determines which zones are primary:
|
||||||
|
- A types (3-curve): Strong thoracic pad, minor lumbar
|
||||||
|
- B types (4-curve): Both thoracic and lumbar pads are primary
|
||||||
|
- C types (non-3-non-4): Balanced thoracic, neutral pelvis
|
||||||
|
- E types (single lumbar/TL): Strong lumbar/TL pad, counter-thoracic
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import numpy as np
|
||||||
|
import trimesh
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, Optional, Tuple, Literal
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
|
||||||
|
# Paths to template directories
|
||||||
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
BRACES_DIR = BASE_DIR / "braces"
|
||||||
|
REGULAR_TEMPLATES_DIR = BRACES_DIR / "brace_templates"
|
||||||
|
VASE_TEMPLATES_DIR = BRACES_DIR / "vase_brace_templates"
|
||||||
|
|
||||||
|
# Template types
|
||||||
|
TemplateType = Literal["regular", "vase"]
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MarkerPositions:
|
||||||
|
"""Marker positions for a brace template."""
|
||||||
|
LM_PELVIS_CENTER: Tuple[float, float, float]
|
||||||
|
LM_TOP_CENTER: Tuple[float, float, float]
|
||||||
|
LM_PAD_TH: Tuple[float, float, float]
|
||||||
|
LM_BAY_TH: Tuple[float, float, float]
|
||||||
|
LM_PAD_LUM: Tuple[float, float, float]
|
||||||
|
LM_BAY_LUM: Tuple[float, float, float]
|
||||||
|
LM_ANCHOR_HIP_L: Tuple[float, float, float]
|
||||||
|
LM_ANCHOR_HIP_R: Tuple[float, float, float]
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PressureZone:
|
||||||
|
"""Describes a pressure or expansion zone on the brace."""
|
||||||
|
name: str
|
||||||
|
marker_name: str
|
||||||
|
position: Tuple[float, float, float]
|
||||||
|
zone_type: Literal["pad", "bay", "anchor"]
|
||||||
|
function: str
|
||||||
|
direction: Literal["inward", "outward", "grip"]
|
||||||
|
depth_mm: float = 0.0
|
||||||
|
radius_mm: Tuple[float, float, float] = (50.0, 80.0, 40.0)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BraceGenerationResult:
|
||||||
|
"""Result of brace generation with markers."""
|
||||||
|
glb_path: str
|
||||||
|
stl_path: str
|
||||||
|
json_path: str
|
||||||
|
template_type: str
|
||||||
|
rigo_type: str
|
||||||
|
markers: Dict[str, Tuple[float, float, float]]
|
||||||
|
basis: Dict[str, Any]
|
||||||
|
pressure_zones: list
|
||||||
|
mesh_stats: Dict[str, int]
|
||||||
|
transform_applied: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_template_paths(rigo_type: str, template_type: TemplateType) -> Tuple[Path, Path]:
|
||||||
|
"""
|
||||||
|
Get paths to GLB template and markers JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rigo_type: Rigo classification (A1, A2, A3, B1, B2, C1, C2, E1, E2)
|
||||||
|
template_type: "regular" or "vase"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (glb_path, markers_json_path)
|
||||||
|
"""
|
||||||
|
if template_type == "regular":
|
||||||
|
glb_path = REGULAR_TEMPLATES_DIR / f"{rigo_type}_marked_v3.glb"
|
||||||
|
json_path = REGULAR_TEMPLATES_DIR / f"{rigo_type}_marked_v3.markers.json"
|
||||||
|
else: # vase
|
||||||
|
glb_path = VASE_TEMPLATES_DIR / "glb" / f"{rigo_type}_vase_marked.glb"
|
||||||
|
json_path = VASE_TEMPLATES_DIR / "markers_json" / f"{rigo_type}_vase_marked.markers.json"
|
||||||
|
|
||||||
|
return glb_path, json_path
|
||||||
|
|
||||||
|
|
||||||
|
def load_template_markers(rigo_type: str, template_type: TemplateType) -> Dict[str, Any]:
|
||||||
|
"""Load markers from JSON file for a template."""
|
||||||
|
_, json_path = get_template_paths(rigo_type, template_type)
|
||||||
|
|
||||||
|
if not json_path.exists():
|
||||||
|
raise FileNotFoundError(f"Markers JSON not found: {json_path}")
|
||||||
|
|
||||||
|
with open(json_path, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_glb_template(rigo_type: str, template_type: TemplateType) -> trimesh.Trimesh:
|
||||||
|
"""Load GLB template as trimesh."""
|
||||||
|
glb_path, _ = get_template_paths(rigo_type, template_type)
|
||||||
|
|
||||||
|
if not glb_path.exists():
|
||||||
|
raise FileNotFoundError(f"GLB template not found: {glb_path}")
|
||||||
|
|
||||||
|
scene = trimesh.load(str(glb_path))
|
||||||
|
|
||||||
|
# If it's a scene, concatenate all meshes
|
||||||
|
if isinstance(scene, trimesh.Scene):
|
||||||
|
meshes = [g for g in scene.geometry.values() if isinstance(g, trimesh.Trimesh)]
|
||||||
|
if meshes:
|
||||||
|
mesh = trimesh.util.concatenate(meshes)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"No valid meshes found in GLB: {glb_path}")
|
||||||
|
else:
|
||||||
|
mesh = scene
|
||||||
|
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_pressure_zones(
|
||||||
|
markers: Dict[str, Any],
|
||||||
|
rigo_type: str,
|
||||||
|
cobb_angles: Dict[str, float]
|
||||||
|
) -> list:
|
||||||
|
"""
|
||||||
|
Calculate pressure zone parameters based on markers and analysis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
markers: Marker positions from template
|
||||||
|
rigo_type: Rigo classification
|
||||||
|
cobb_angles: Dict with PT, MT, TL Cobb angles
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of PressureZone objects
|
||||||
|
"""
|
||||||
|
marker_pos = markers.get("markers", markers)
|
||||||
|
|
||||||
|
# Calculate severity from Cobb angles
|
||||||
|
mt_angle = cobb_angles.get("MT", 0)
|
||||||
|
tl_angle = cobb_angles.get("TL", 0)
|
||||||
|
|
||||||
|
# Severity mapping: Cobb -> depth
|
||||||
|
def cobb_to_depth(angle: float, min_depth: float = 6.0, max_depth: float = 22.0) -> float:
|
||||||
|
severity = min(max((angle - 10) / 40, 0), 1) # 0-1 range
|
||||||
|
return min_depth + severity * (max_depth - min_depth)
|
||||||
|
|
||||||
|
th_depth = cobb_to_depth(mt_angle, 8.0, 22.0)
|
||||||
|
lum_depth = cobb_to_depth(tl_angle, 6.0, 18.0)
|
||||||
|
|
||||||
|
# Bay clearance is typically 1.2-1.5x pad depth
|
||||||
|
th_clearance = th_depth * 1.3 + 5
|
||||||
|
lum_clearance = lum_depth * 1.3 + 4
|
||||||
|
|
||||||
|
zones = [
|
||||||
|
PressureZone(
|
||||||
|
name="Thoracic Pad",
|
||||||
|
marker_name="LM_PAD_TH",
|
||||||
|
position=tuple(marker_pos.get("LM_PAD_TH", [0, 0, 0])),
|
||||||
|
zone_type="pad",
|
||||||
|
function="Pushes INWARD on thoracic curve convex side to correct curvature",
|
||||||
|
direction="inward",
|
||||||
|
depth_mm=th_depth,
|
||||||
|
radius_mm=(50.0, 90.0, 40.0)
|
||||||
|
),
|
||||||
|
PressureZone(
|
||||||
|
name="Thoracic Bay",
|
||||||
|
marker_name="LM_BAY_TH",
|
||||||
|
position=tuple(marker_pos.get("LM_BAY_TH", [0, 0, 0])),
|
||||||
|
zone_type="bay",
|
||||||
|
function="Creates SPACE on thoracic concave side for body to shift into",
|
||||||
|
direction="outward",
|
||||||
|
depth_mm=th_clearance,
|
||||||
|
radius_mm=(65.0, 110.0, 55.0)
|
||||||
|
),
|
||||||
|
PressureZone(
|
||||||
|
name="Lumbar Pad",
|
||||||
|
marker_name="LM_PAD_LUM",
|
||||||
|
position=tuple(marker_pos.get("LM_PAD_LUM", [0, 0, 0])),
|
||||||
|
zone_type="pad",
|
||||||
|
function="Pushes INWARD on lumbar curve convex side to correct curvature",
|
||||||
|
direction="inward",
|
||||||
|
depth_mm=lum_depth,
|
||||||
|
radius_mm=(55.0, 85.0, 45.0)
|
||||||
|
),
|
||||||
|
PressureZone(
|
||||||
|
name="Lumbar Bay",
|
||||||
|
marker_name="LM_BAY_LUM",
|
||||||
|
position=tuple(marker_pos.get("LM_BAY_LUM", [0, 0, 0])),
|
||||||
|
zone_type="bay",
|
||||||
|
function="Creates SPACE on lumbar concave side for body to shift into",
|
||||||
|
direction="outward",
|
||||||
|
depth_mm=lum_clearance,
|
||||||
|
radius_mm=(70.0, 100.0, 60.0)
|
||||||
|
),
|
||||||
|
PressureZone(
|
||||||
|
name="Left Hip Anchor",
|
||||||
|
marker_name="LM_ANCHOR_HIP_L",
|
||||||
|
position=tuple(marker_pos.get("LM_ANCHOR_HIP_L", [0, 0, 0])),
|
||||||
|
zone_type="anchor",
|
||||||
|
function="Grips left hip/pelvis to stabilize brace and prevent riding up",
|
||||||
|
direction="grip",
|
||||||
|
depth_mm=4.0,
|
||||||
|
radius_mm=(40.0, 60.0, 40.0)
|
||||||
|
),
|
||||||
|
PressureZone(
|
||||||
|
name="Right Hip Anchor",
|
||||||
|
marker_name="LM_ANCHOR_HIP_R",
|
||||||
|
position=tuple(marker_pos.get("LM_ANCHOR_HIP_R", [0, 0, 0])),
|
||||||
|
zone_type="anchor",
|
||||||
|
function="Grips right hip/pelvis to stabilize brace and prevent riding up",
|
||||||
|
direction="grip",
|
||||||
|
depth_mm=4.0,
|
||||||
|
radius_mm=(40.0, 60.0, 40.0)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return zones
|
||||||
|
|
||||||
|
|
||||||
|
def transform_markers(
|
||||||
|
markers: Dict[str, list],
|
||||||
|
transform_matrix: np.ndarray
|
||||||
|
) -> Dict[str, Tuple[float, float, float]]:
|
||||||
|
"""Apply transformation matrix to all marker positions."""
|
||||||
|
transformed = {}
|
||||||
|
|
||||||
|
for name, pos in markers.items():
|
||||||
|
if isinstance(pos, (list, tuple)) and len(pos) == 3:
|
||||||
|
# Convert to homogeneous coordinates
|
||||||
|
pos_h = np.array([pos[0], pos[1], pos[2], 1.0])
|
||||||
|
# Apply transform
|
||||||
|
new_pos = transform_matrix @ pos_h
|
||||||
|
transformed[name] = (float(new_pos[0]), float(new_pos[1]), float(new_pos[2]))
|
||||||
|
|
||||||
|
return transformed
|
||||||
|
|
||||||
|
|
||||||
|
def generate_glb_brace(
|
||||||
|
rigo_type: str,
|
||||||
|
template_type: TemplateType,
|
||||||
|
output_dir: Path,
|
||||||
|
case_id: str,
|
||||||
|
cobb_angles: Dict[str, float],
|
||||||
|
body_scan_path: Optional[str] = None,
|
||||||
|
clearance_mm: float = 8.0
|
||||||
|
) -> BraceGenerationResult:
|
||||||
|
"""
|
||||||
|
Generate a GLB brace with markers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rigo_type: Rigo classification (A1, A2, etc.)
|
||||||
|
template_type: "regular" or "vase"
|
||||||
|
output_dir: Directory for output files
|
||||||
|
case_id: Case identifier
|
||||||
|
cobb_angles: Dict with PT, MT, TL angles
|
||||||
|
body_scan_path: Optional path to body scan STL for fitting
|
||||||
|
clearance_mm: Clearance between body and brace
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BraceGenerationResult with paths and marker info
|
||||||
|
"""
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Load template
|
||||||
|
mesh = load_glb_template(rigo_type, template_type)
|
||||||
|
marker_data = load_template_markers(rigo_type, template_type)
|
||||||
|
|
||||||
|
markers = marker_data.get("markers", {})
|
||||||
|
basis = marker_data.get("basis", {})
|
||||||
|
|
||||||
|
transform_matrix = np.eye(4)
|
||||||
|
transform_info = None
|
||||||
|
|
||||||
|
# If body scan provided, fit to body
|
||||||
|
if body_scan_path and Path(body_scan_path).exists():
|
||||||
|
mesh, transform_matrix, transform_info = fit_brace_to_body(
|
||||||
|
mesh, body_scan_path, clearance_mm, brace_basis=basis, template_type=template_type,
|
||||||
|
markers=markers # Pass markers for zone-aware ironing
|
||||||
|
)
|
||||||
|
# Transform markers to match the body-fitted mesh
|
||||||
|
markers = transform_markers(markers, transform_matrix)
|
||||||
|
|
||||||
|
# Calculate pressure zones
|
||||||
|
pressure_zones = calculate_pressure_zones(markers, rigo_type, cobb_angles)
|
||||||
|
|
||||||
|
# Output file names
|
||||||
|
type_suffix = "vase" if template_type == "vase" else "regular"
|
||||||
|
glb_filename = f"{case_id}_{rigo_type}_{type_suffix}.glb"
|
||||||
|
stl_filename = f"{case_id}_{rigo_type}_{type_suffix}.stl"
|
||||||
|
json_filename = f"{case_id}_{rigo_type}_{type_suffix}_markers.json"
|
||||||
|
|
||||||
|
glb_path = output_dir / glb_filename
|
||||||
|
stl_path = output_dir / stl_filename
|
||||||
|
json_path = output_dir / json_filename
|
||||||
|
|
||||||
|
# Export GLB
|
||||||
|
mesh.export(str(glb_path))
|
||||||
|
|
||||||
|
# Export STL
|
||||||
|
mesh.export(str(stl_path))
|
||||||
|
|
||||||
|
# Build output JSON with markers and zones
|
||||||
|
output_data = {
|
||||||
|
"case_id": case_id,
|
||||||
|
"rigo_type": rigo_type,
|
||||||
|
"template_type": template_type,
|
||||||
|
"cobb_angles": cobb_angles,
|
||||||
|
"markers": {k: list(v) if isinstance(v, tuple) else v for k, v in markers.items()},
|
||||||
|
"basis": basis,
|
||||||
|
"pressure_zones": [
|
||||||
|
{
|
||||||
|
"name": z.name,
|
||||||
|
"marker_name": z.marker_name,
|
||||||
|
"position": list(z.position),
|
||||||
|
"zone_type": z.zone_type,
|
||||||
|
"function": z.function,
|
||||||
|
"direction": z.direction,
|
||||||
|
"depth_mm": z.depth_mm,
|
||||||
|
"radius_mm": list(z.radius_mm)
|
||||||
|
}
|
||||||
|
for z in pressure_zones
|
||||||
|
],
|
||||||
|
"mesh_stats": {
|
||||||
|
"vertices": len(mesh.vertices),
|
||||||
|
"faces": len(mesh.faces)
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"glb": str(glb_path),
|
||||||
|
"stl": str(stl_path),
|
||||||
|
"json": str(json_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if transform_info:
|
||||||
|
output_data["body_fitting"] = transform_info
|
||||||
|
|
||||||
|
# Save JSON
|
||||||
|
with open(json_path, "w") as f:
|
||||||
|
json.dump(output_data, f, indent=2)
|
||||||
|
|
||||||
|
return BraceGenerationResult(
|
||||||
|
glb_path=str(glb_path),
|
||||||
|
stl_path=str(stl_path),
|
||||||
|
json_path=str(json_path),
|
||||||
|
template_type=template_type,
|
||||||
|
rigo_type=rigo_type,
|
||||||
|
markers=markers,
|
||||||
|
basis=basis,
|
||||||
|
pressure_zones=[asdict(z) for z in pressure_zones],
|
||||||
|
mesh_stats=output_data["mesh_stats"],
|
||||||
|
transform_applied=transform_info
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def iron_brace_to_body(
|
||||||
|
brace_mesh: trimesh.Trimesh,
|
||||||
|
body_mesh: trimesh.Trimesh,
|
||||||
|
min_clearance_mm: float = 3.0,
|
||||||
|
max_clearance_mm: float = 15.0,
|
||||||
|
smoothing_iterations: int = 2,
|
||||||
|
up_axis: int = 2,
|
||||||
|
markers: Optional[Dict[str, Any]] = None
|
||||||
|
) -> trimesh.Trimesh:
|
||||||
|
"""
|
||||||
|
Iron the brace surface to conform to the body scan surface.
|
||||||
|
|
||||||
|
This ensures the brace follows the body contour without excessive gaps.
|
||||||
|
Uses zone-aware ironing:
|
||||||
|
- FRONT (belly) and BACK: Aggressive ironing for tight fit
|
||||||
|
- SIDES (where pads/bays are): Preserve correction zones, moderate ironing
|
||||||
|
|
||||||
|
Args:
|
||||||
|
brace_mesh: The brace mesh to iron
|
||||||
|
body_mesh: The body scan mesh to conform to
|
||||||
|
min_clearance_mm: Minimum distance from body surface
|
||||||
|
max_clearance_mm: Maximum distance from body surface (trigger ironing)
|
||||||
|
smoothing_iterations: Number of Laplacian smoothing passes after ironing
|
||||||
|
up_axis: Which axis is "up" (0=X, 1=Y, 2=Z)
|
||||||
|
markers: Optional dict of marker positions to preserve pressure zones
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ironed brace mesh
|
||||||
|
"""
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
import math
|
||||||
|
|
||||||
|
print(f"Ironing brace to body surface (clearance: {min_clearance_mm}-{max_clearance_mm}mm)")
|
||||||
|
|
||||||
|
# Create a copy to modify
|
||||||
|
ironed_mesh = brace_mesh.copy()
|
||||||
|
vertices = ironed_mesh.vertices.copy()
|
||||||
|
|
||||||
|
# Get body center and bounds
|
||||||
|
body_center = body_mesh.centroid
|
||||||
|
body_bounds = body_mesh.bounds
|
||||||
|
|
||||||
|
# Determine the torso region (process middle 80% of body height)
|
||||||
|
body_height = body_bounds[1, up_axis] - body_bounds[0, up_axis]
|
||||||
|
torso_bottom = body_bounds[0, up_axis] + body_height * 0.10
|
||||||
|
torso_top = body_bounds[0, up_axis] + body_height * 0.90
|
||||||
|
|
||||||
|
# Build KD-tree from body mesh vertices for fast nearest neighbor queries
|
||||||
|
body_tree = cKDTree(body_mesh.vertices)
|
||||||
|
|
||||||
|
# Find closest points on body for ALL brace vertices at once
|
||||||
|
distances, closest_indices = body_tree.query(vertices, k=1)
|
||||||
|
closest_points = body_mesh.vertices[closest_indices]
|
||||||
|
|
||||||
|
# Determine horizontal axes (perpendicular to up axis)
|
||||||
|
horiz_axes = [i for i in range(3) if i != up_axis]
|
||||||
|
|
||||||
|
# Calculate brace center for angle computation
|
||||||
|
brace_center = np.mean(vertices, axis=0)
|
||||||
|
|
||||||
|
# Identify marker exclusion zones (preserve correction areas)
|
||||||
|
exclusion_zones = []
|
||||||
|
if markers:
|
||||||
|
# Pad and bay markers need preservation
|
||||||
|
for marker_name in ['LM_PAD_TH', 'LM_PAD_LUM', 'LM_BAY_TH', 'LM_BAY_LUM']:
|
||||||
|
if marker_name in markers:
|
||||||
|
pos = markers[marker_name]
|
||||||
|
if isinstance(pos, (list, tuple)) and len(pos) >= 3:
|
||||||
|
exclusion_zones.append({
|
||||||
|
'center': np.array(pos),
|
||||||
|
'radius': 60.0, # 60mm exclusion radius around markers
|
||||||
|
'name': marker_name
|
||||||
|
})
|
||||||
|
|
||||||
|
# Process each brace vertex
|
||||||
|
adjusted_count = 0
|
||||||
|
pulled_in_count = 0
|
||||||
|
pushed_out_count = 0
|
||||||
|
skipped_zone_count = 0
|
||||||
|
|
||||||
|
# Height normalization
|
||||||
|
brace_min_z = vertices[:, up_axis].min()
|
||||||
|
brace_max_z = vertices[:, up_axis].max()
|
||||||
|
brace_height_range = max(brace_max_z - brace_min_z, 1.0)
|
||||||
|
|
||||||
|
for i in range(len(vertices)):
|
||||||
|
vertex = vertices[i]
|
||||||
|
closest_pt = closest_points[i]
|
||||||
|
dist = distances[i]
|
||||||
|
|
||||||
|
# Only process vertices in the torso region
|
||||||
|
if vertex[up_axis] < torso_bottom or vertex[up_axis] > torso_top:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if vertex is in an exclusion zone (near pad/bay markers)
|
||||||
|
in_exclusion = False
|
||||||
|
for zone in exclusion_zones:
|
||||||
|
zone_dist = np.linalg.norm(vertex - zone['center'])
|
||||||
|
if zone_dist < zone['radius']:
|
||||||
|
in_exclusion = True
|
||||||
|
skipped_zone_count += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
if in_exclusion:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate angular position around body center (horizontal plane)
|
||||||
|
# 0° = front (belly), 90° = right side, 180° = back, 270° = left side
|
||||||
|
rel_pos = vertex - body_center
|
||||||
|
angle = math.atan2(rel_pos[horiz_axes[1]], rel_pos[horiz_axes[0]])
|
||||||
|
angle_deg = math.degrees(angle) % 360
|
||||||
|
|
||||||
|
# Determine zone based on angle:
|
||||||
|
# FRONT (belly): 315-45° - aggressive ironing
|
||||||
|
# BACK: 135-225° - aggressive ironing
|
||||||
|
# SIDES: 45-135° and 225-315° - moderate ironing (correction zones)
|
||||||
|
is_front_back = (angle_deg < 45 or angle_deg > 315) or (135 < angle_deg < 225)
|
||||||
|
|
||||||
|
# Height-based clearance adjustment
|
||||||
|
height_norm = (vertex[up_axis] - brace_min_z) / brace_height_range
|
||||||
|
|
||||||
|
# Set clearances based on zone
|
||||||
|
if is_front_back:
|
||||||
|
# FRONT/BACK: Aggressive ironing - very tight fit
|
||||||
|
local_min = min_clearance_mm * 0.5 # Allow closer to body
|
||||||
|
local_max = max_clearance_mm * 0.6 # Trigger ironing earlier
|
||||||
|
local_target = min_clearance_mm + 2.0 # Target just above minimum
|
||||||
|
else:
|
||||||
|
# SIDES: More conservative - preserve room for correction
|
||||||
|
local_min = min_clearance_mm
|
||||||
|
local_max = max_clearance_mm * 1.2 # Allow slightly more gap
|
||||||
|
local_target = (min_clearance_mm + max_clearance_mm) / 2
|
||||||
|
|
||||||
|
# Height adjustments (tighter at hips and chest)
|
||||||
|
if height_norm < 0.25 or height_norm > 0.75:
|
||||||
|
local_max *= 0.8 # Tighter at extremes
|
||||||
|
local_target *= 0.85
|
||||||
|
|
||||||
|
# Direction from body surface to brace vertex
|
||||||
|
direction = vertex - closest_pt
|
||||||
|
dir_length = np.linalg.norm(direction)
|
||||||
|
|
||||||
|
if dir_length < 1e-6:
|
||||||
|
direction = vertex - body_center
|
||||||
|
direction[up_axis] = 0
|
||||||
|
dir_length = np.linalg.norm(direction)
|
||||||
|
if dir_length < 1e-6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
direction = direction / dir_length
|
||||||
|
|
||||||
|
# Determine signed distance
|
||||||
|
vertex_dist_to_center = np.linalg.norm(vertex[:2] - body_center[:2])
|
||||||
|
closest_dist_to_center = np.linalg.norm(closest_pt[:2] - body_center[:2])
|
||||||
|
|
||||||
|
if vertex_dist_to_center >= closest_dist_to_center:
|
||||||
|
signed_distance = dist
|
||||||
|
else:
|
||||||
|
signed_distance = -dist
|
||||||
|
|
||||||
|
# Determine if adjustment is needed
|
||||||
|
needs_adjustment = False
|
||||||
|
new_position = vertex.copy()
|
||||||
|
|
||||||
|
if signed_distance > local_max:
|
||||||
|
# Gap too large - pull vertex closer to body
|
||||||
|
new_position = closest_pt + direction * local_target
|
||||||
|
new_position[up_axis] = vertex[up_axis] # Preserve height
|
||||||
|
needs_adjustment = True
|
||||||
|
pulled_in_count += 1
|
||||||
|
|
||||||
|
elif signed_distance < local_min:
|
||||||
|
# Too close or inside body - push outward
|
||||||
|
offset = local_min + 1.0
|
||||||
|
outward_dir = closest_pt - body_center
|
||||||
|
outward_dir[up_axis] = 0
|
||||||
|
outward_length = np.linalg.norm(outward_dir)
|
||||||
|
if outward_length > 1e-6:
|
||||||
|
outward_dir = outward_dir / outward_length
|
||||||
|
new_position = closest_pt + outward_dir * offset
|
||||||
|
new_position[up_axis] = vertex[up_axis]
|
||||||
|
needs_adjustment = True
|
||||||
|
pushed_out_count += 1
|
||||||
|
|
||||||
|
if needs_adjustment:
|
||||||
|
vertices[i] = new_position
|
||||||
|
adjusted_count += 1
|
||||||
|
|
||||||
|
print(f"Ironing adjusted {adjusted_count} vertices (pulled in: {pulled_in_count}, pushed out: {pushed_out_count}, skipped zones: {skipped_zone_count})")
|
||||||
|
|
||||||
|
# Apply modified vertices
|
||||||
|
ironed_mesh.vertices = vertices
|
||||||
|
|
||||||
|
# Apply Laplacian smoothing to blend changes and remove artifacts
|
||||||
|
if smoothing_iterations > 0 and adjusted_count > 0:
|
||||||
|
print(f"Applying {smoothing_iterations} smoothing iterations")
|
||||||
|
try:
|
||||||
|
ironed_mesh = trimesh.smoothing.filter_laplacian(
|
||||||
|
ironed_mesh,
|
||||||
|
lamb=0.3, # Gentler smoothing to preserve shape
|
||||||
|
iterations=smoothing_iterations,
|
||||||
|
implicit_time_integration=False
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Smoothing failed (non-critical): {e}")
|
||||||
|
|
||||||
|
# Ensure mesh is valid
|
||||||
|
ironed_mesh.fix_normals()
|
||||||
|
|
||||||
|
return ironed_mesh
|
||||||
|
|
||||||
|
|
||||||
|
def fit_brace_to_body(
|
||||||
|
brace_mesh: trimesh.Trimesh,
|
||||||
|
body_scan_path: str,
|
||||||
|
clearance_mm: float = 8.0,
|
||||||
|
brace_basis: Optional[Dict[str, Any]] = None,
|
||||||
|
template_type: str = "regular",
|
||||||
|
enable_ironing: bool = True,
|
||||||
|
markers: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Tuple[trimesh.Trimesh, np.ndarray, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fit brace to body scan using basis alignment.
|
||||||
|
|
||||||
|
The brace needs to be:
|
||||||
|
1. Rotated so its UP axis aligns with body's UP axis (typically Z for body scans)
|
||||||
|
2. Scaled to fit around the body with proper clearance
|
||||||
|
3. Positioned at the torso level
|
||||||
|
4. Ironed to conform to body surface (respecting correction zones)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (transformed_mesh, transform_matrix, fitting_info)
|
||||||
|
"""
|
||||||
|
# Load body scan
|
||||||
|
body_mesh = trimesh.load(body_scan_path, force='mesh')
|
||||||
|
|
||||||
|
# Get body dimensions
|
||||||
|
body_bounds = body_mesh.bounds
|
||||||
|
body_extents = body_mesh.extents
|
||||||
|
body_center = body_mesh.centroid
|
||||||
|
|
||||||
|
# Determine body up axis (typically the longest dimension = height)
|
||||||
|
# For human body scans, this is usually Z (from 3D scanners) or Y
|
||||||
|
body_up_axis_idx = np.argmax(body_extents)
|
||||||
|
print(f"Body up axis: {['X', 'Y', 'Z'][body_up_axis_idx]}, extents: {body_extents}")
|
||||||
|
|
||||||
|
# Get brace dimensions
|
||||||
|
brace_bounds = brace_mesh.bounds
|
||||||
|
brace_extents = brace_mesh.extents
|
||||||
|
brace_center = brace_mesh.centroid
|
||||||
|
|
||||||
|
print(f"Brace original extents: {brace_extents}, template_type: {template_type}")
|
||||||
|
|
||||||
|
# Start building transformation
|
||||||
|
transformed_mesh = brace_mesh.copy()
|
||||||
|
transform = np.eye(4)
|
||||||
|
|
||||||
|
# Step 1: Center brace at origin
|
||||||
|
T_center = np.eye(4)
|
||||||
|
T_center[:3, 3] = -brace_center
|
||||||
|
transformed_mesh.apply_transform(T_center)
|
||||||
|
transform = T_center @ transform
|
||||||
|
|
||||||
|
# Step 2: Apply rotations based on template type and body orientation
|
||||||
|
# Regular templates have: negative Y is up (inverted), need to flip
|
||||||
|
# Vase templates have: positive Y is up
|
||||||
|
# Body scan is Z-up
|
||||||
|
|
||||||
|
if body_up_axis_idx == 2: # Body is Z-up (standard for 3D scanners)
|
||||||
|
if template_type == "regular":
|
||||||
|
# Regular brace: -Y is up (inverted)
|
||||||
|
# 1. Rotate -90° around X to bring Y-up to Z-up
|
||||||
|
R1 = trimesh.transformations.rotation_matrix(-np.pi/2, [1, 0, 0])
|
||||||
|
transformed_mesh.apply_transform(R1)
|
||||||
|
transform = R1 @ transform
|
||||||
|
|
||||||
|
# 2. The brace is now Z-up but inverted (pelvis at top, shoulders at bottom)
|
||||||
|
# Flip 180° around X to correct (this keeps Z as up axis)
|
||||||
|
R2 = trimesh.transformations.rotation_matrix(np.pi, [1, 0, 0])
|
||||||
|
transformed_mesh.apply_transform(R2)
|
||||||
|
transform = R2 @ transform
|
||||||
|
|
||||||
|
# 3. Rotate around Z to face forward correctly
|
||||||
|
R3 = trimesh.transformations.rotation_matrix(-np.pi/2, [0, 0, 1])
|
||||||
|
transformed_mesh.apply_transform(R3)
|
||||||
|
transform = R3 @ transform
|
||||||
|
|
||||||
|
print(f"Applied regular brace rotations: X-90°, X+180° (flip), Z-90°")
|
||||||
|
|
||||||
|
else: # vase
|
||||||
|
# Vase brace: positive Y is up
|
||||||
|
# 1. Rotate -90° around X to bring Y-up to Z-up
|
||||||
|
R1 = trimesh.transformations.rotation_matrix(-np.pi/2, [1, 0, 0])
|
||||||
|
transformed_mesh.apply_transform(R1)
|
||||||
|
transform = R1 @ transform
|
||||||
|
|
||||||
|
# 2. Flip 180° around Y to correct orientation (right-side up)
|
||||||
|
R2 = trimesh.transformations.rotation_matrix(np.pi, [0, 1, 0])
|
||||||
|
transformed_mesh.apply_transform(R2)
|
||||||
|
transform = R2 @ transform
|
||||||
|
|
||||||
|
print(f"Applied vase brace rotations: X-90°, Y+180° (flip)")
|
||||||
|
|
||||||
|
# Step 3: Get new brace dimensions after rotation
|
||||||
|
new_brace_extents = transformed_mesh.extents
|
||||||
|
new_brace_center = transformed_mesh.centroid
|
||||||
|
print(f"Brace extents after rotation: {new_brace_extents}")
|
||||||
|
|
||||||
|
# Step 4: Calculate NON-UNIFORM scaling based on body dimensions
|
||||||
|
# The brace should cover the TORSO region (~50% of body height)
|
||||||
|
# AND wrap around the body with proper girth
|
||||||
|
|
||||||
|
body_height = body_extents[body_up_axis_idx]
|
||||||
|
brace_height = new_brace_extents[body_up_axis_idx] # After rotation, this is the height
|
||||||
|
|
||||||
|
# Body horizontal dimensions (girth at torso level)
|
||||||
|
horizontal_axes = [i for i in range(3) if i != body_up_axis_idx]
|
||||||
|
body_width = body_extents[horizontal_axes[0]] # X width
|
||||||
|
body_depth = body_extents[horizontal_axes[1]] # Y depth
|
||||||
|
|
||||||
|
# Brace horizontal dimensions
|
||||||
|
brace_width = new_brace_extents[horizontal_axes[0]]
|
||||||
|
brace_depth = new_brace_extents[horizontal_axes[1]]
|
||||||
|
|
||||||
|
# Target: brace height should cover ~65% of body height (full torso coverage)
|
||||||
|
target_height = body_height * 0.65
|
||||||
|
height_scale = target_height / brace_height if brace_height > 0 else 1.0
|
||||||
|
|
||||||
|
# Target: brace width/depth should be LARGER than body to wrap AROUND it
|
||||||
|
# The brace sits OUTSIDE the body, only pressure points push inward
|
||||||
|
# Add ~25% extra + clearance so brace externals are visible outside body
|
||||||
|
target_width = body_width * 1.25 + clearance_mm * 2
|
||||||
|
target_depth = body_depth * 1.25 + clearance_mm * 2
|
||||||
|
|
||||||
|
width_scale = target_width / brace_width if brace_width > 0 else 1.0
|
||||||
|
depth_scale = target_depth / brace_depth if brace_depth > 0 else 1.0
|
||||||
|
|
||||||
|
# Apply non-uniform scaling
|
||||||
|
# Determine which axis is which after rotation
|
||||||
|
S = np.eye(4)
|
||||||
|
if body_up_axis_idx == 2: # Z is up
|
||||||
|
S[0, 0] = width_scale # X scale
|
||||||
|
S[1, 1] = depth_scale # Y scale
|
||||||
|
S[2, 2] = height_scale # Z scale (height)
|
||||||
|
elif body_up_axis_idx == 1: # Y is up
|
||||||
|
S[0, 0] = width_scale # X scale
|
||||||
|
S[1, 1] = height_scale # Y scale (height)
|
||||||
|
S[2, 2] = depth_scale # Z scale
|
||||||
|
else: # X is up (unusual)
|
||||||
|
S[0, 0] = height_scale # X scale (height)
|
||||||
|
S[1, 1] = width_scale # Y scale
|
||||||
|
S[2, 2] = depth_scale # Z scale
|
||||||
|
|
||||||
|
# Limit scales to reasonable range
|
||||||
|
S[0, 0] = max(0.5, min(S[0, 0], 50.0))
|
||||||
|
S[1, 1] = max(0.5, min(S[1, 1], 50.0))
|
||||||
|
S[2, 2] = max(0.5, min(S[2, 2], 50.0))
|
||||||
|
|
||||||
|
transformed_mesh.apply_transform(S)
|
||||||
|
transform = S @ transform
|
||||||
|
|
||||||
|
print(f"Applied non-uniform scale: width={S[0,0]:.2f}, depth={S[1,1]:.2f}, height={S[2,2]:.2f}")
|
||||||
|
print(f"Target dimensions: width={target_width:.1f}, depth={target_depth:.1f}, height={target_height:.1f}")
|
||||||
|
|
||||||
|
# For fitting_info, use average scale
|
||||||
|
scale = (S[0, 0] + S[1, 1] + S[2, 2]) / 3
|
||||||
|
|
||||||
|
# Step 6: Position brace at torso level
|
||||||
|
# Calculate where the torso is (middle portion of body height)
|
||||||
|
body_height = body_extents[body_up_axis_idx]
|
||||||
|
body_bottom = body_bounds[0, body_up_axis_idx]
|
||||||
|
body_top = body_bounds[1, body_up_axis_idx]
|
||||||
|
|
||||||
|
# Torso is roughly the middle 40% of body height (from ~30% to ~70%)
|
||||||
|
torso_center_ratio = 0.5 # Middle of body
|
||||||
|
torso_center_height = body_bottom + body_height * torso_center_ratio
|
||||||
|
|
||||||
|
# Target position: center horizontally on body, at torso height vertically
|
||||||
|
target_center = body_center.copy()
|
||||||
|
target_center[body_up_axis_idx] = torso_center_height
|
||||||
|
|
||||||
|
# Current brace center after transformations
|
||||||
|
current_center = transformed_mesh.centroid
|
||||||
|
|
||||||
|
T_position = np.eye(4)
|
||||||
|
T_position[:3, 3] = target_center - current_center
|
||||||
|
transformed_mesh.apply_transform(T_position)
|
||||||
|
transform = T_position @ transform
|
||||||
|
|
||||||
|
# Step 7: Iron brace to conform to body surface (eliminate gaps and humps)
|
||||||
|
# Transform markers so we can exclude correction zones from ironing
|
||||||
|
transformed_markers = None
|
||||||
|
if markers:
|
||||||
|
transformed_markers = transform_markers(markers, transform)
|
||||||
|
|
||||||
|
ironing_info = {}
|
||||||
|
if enable_ironing:
|
||||||
|
try:
|
||||||
|
print(f"Starting brace ironing to body surface...")
|
||||||
|
pre_iron_extents = transformed_mesh.extents.copy()
|
||||||
|
|
||||||
|
transformed_mesh = iron_brace_to_body(
|
||||||
|
brace_mesh=transformed_mesh,
|
||||||
|
body_mesh=body_mesh,
|
||||||
|
min_clearance_mm=clearance_mm * 0.4, # Allow closer for tight fit
|
||||||
|
max_clearance_mm=clearance_mm * 1.5, # Iron areas with gaps > 1.5x clearance
|
||||||
|
smoothing_iterations=3,
|
||||||
|
up_axis=body_up_axis_idx,
|
||||||
|
markers=transformed_markers
|
||||||
|
)
|
||||||
|
|
||||||
|
post_iron_extents = transformed_mesh.extents
|
||||||
|
ironing_info = {
|
||||||
|
"enabled": True,
|
||||||
|
"pre_iron_extents": pre_iron_extents.tolist(),
|
||||||
|
"post_iron_extents": post_iron_extents.tolist(),
|
||||||
|
"min_clearance_mm": clearance_mm * 0.5,
|
||||||
|
"max_clearance_mm": clearance_mm * 2.0,
|
||||||
|
}
|
||||||
|
print(f"Ironing complete. Extents changed from {pre_iron_extents} to {post_iron_extents}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ironing failed (non-critical): {e}")
|
||||||
|
ironing_info = {"enabled": False, "error": str(e)}
|
||||||
|
else:
|
||||||
|
ironing_info = {"enabled": False}
|
||||||
|
|
||||||
|
fitting_info = {
|
||||||
|
"scale_avg": float(scale),
|
||||||
|
"scale_x": float(S[0, 0]),
|
||||||
|
"scale_y": float(S[1, 1]),
|
||||||
|
"scale_z": float(S[2, 2]),
|
||||||
|
"template_type": template_type,
|
||||||
|
"body_extents": body_extents.tolist(),
|
||||||
|
"brace_extents_original": brace_extents.tolist(),
|
||||||
|
"brace_extents_final": transformed_mesh.extents.tolist(),
|
||||||
|
"clearance_mm": clearance_mm,
|
||||||
|
"body_center": body_center.tolist(),
|
||||||
|
"final_center": transformed_mesh.centroid.tolist(),
|
||||||
|
"body_up_axis": int(body_up_axis_idx),
|
||||||
|
"ironing": ironing_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformed_mesh, transform, fitting_info
|
||||||
|
|
||||||
|
|
||||||
|
def generate_both_brace_types(
|
||||||
|
rigo_type: str,
|
||||||
|
output_dir: Path,
|
||||||
|
case_id: str,
|
||||||
|
cobb_angles: Dict[str, float],
|
||||||
|
body_scan_path: Optional[str] = None,
|
||||||
|
clearance_mm: float = 8.0
|
||||||
|
) -> Dict[str, BraceGenerationResult]:
|
||||||
|
"""
|
||||||
|
Generate both regular and vase brace types for comparison.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with "regular" and "vase" results
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
# Generate regular brace
|
||||||
|
try:
|
||||||
|
results["regular"] = generate_glb_brace(
|
||||||
|
rigo_type=rigo_type,
|
||||||
|
template_type="regular",
|
||||||
|
output_dir=output_dir,
|
||||||
|
case_id=case_id,
|
||||||
|
cobb_angles=cobb_angles,
|
||||||
|
body_scan_path=body_scan_path,
|
||||||
|
clearance_mm=clearance_mm
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
results["regular"] = {"error": str(e)}
|
||||||
|
|
||||||
|
# Generate vase brace
|
||||||
|
try:
|
||||||
|
results["vase"] = generate_glb_brace(
|
||||||
|
rigo_type=rigo_type,
|
||||||
|
template_type="vase",
|
||||||
|
output_dir=output_dir,
|
||||||
|
case_id=case_id,
|
||||||
|
cobb_angles=cobb_angles,
|
||||||
|
body_scan_path=body_scan_path,
|
||||||
|
clearance_mm=clearance_mm
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
results["vase"] = {"error": str(e)}
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# Available templates
|
||||||
|
AVAILABLE_RIGO_TYPES = ["A1", "A2", "A3", "B1", "B2", "C1", "C2", "E1", "E2"]
|
||||||
|
|
||||||
|
def list_available_templates() -> Dict[str, list]:
|
||||||
|
"""List all available template files."""
|
||||||
|
regular = []
|
||||||
|
vase = []
|
||||||
|
|
||||||
|
for rigo_type in AVAILABLE_RIGO_TYPES:
|
||||||
|
glb_path, _ = get_template_paths(rigo_type, "regular")
|
||||||
|
if glb_path.exists():
|
||||||
|
regular.append(rigo_type)
|
||||||
|
|
||||||
|
glb_path, _ = get_template_paths(rigo_type, "vase")
|
||||||
|
if glb_path.exists():
|
||||||
|
vase.append(rigo_type)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"regular": regular,
|
||||||
|
"vase": vase
|
||||||
|
}
|
||||||
26
brace-generator/requirements.txt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Server dependencies
|
||||||
|
fastapi>=0.100.0
|
||||||
|
uvicorn[standard]>=0.22.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
pydantic>=2.0.0
|
||||||
|
requests>=2.28.0
|
||||||
|
|
||||||
|
# AWS SDK
|
||||||
|
boto3>=1.26.0
|
||||||
|
|
||||||
|
# Core ML dependencies
|
||||||
|
torch>=2.0.0
|
||||||
|
torchvision>=0.15.0
|
||||||
|
|
||||||
|
# Image processing
|
||||||
|
numpy>=1.20.0
|
||||||
|
scipy>=1.7.0
|
||||||
|
pillow>=8.0.0
|
||||||
|
opencv-python-headless>=4.5.0
|
||||||
|
pydicom>=2.2.0
|
||||||
|
|
||||||
|
# 3D mesh processing
|
||||||
|
trimesh>=3.10.0
|
||||||
|
|
||||||
|
# Visualization
|
||||||
|
matplotlib>=3.4.0
|
||||||
990
brace-generator/routes.py
Normal file
@@ -0,0 +1,990 @@
|
|||||||
|
"""
|
||||||
|
API routes for Brace Generator.
|
||||||
|
|
||||||
|
Note: S3 operations are handled by the Lambda function.
|
||||||
|
This server only handles ML inference and returns local file paths.
|
||||||
|
"""
|
||||||
|
import torch
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
AnalysisResult, HealthResponse, ExperimentType, BraceConfigRequest
|
||||||
|
)
|
||||||
|
from .config import config
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", summary="Root endpoint")
|
||||||
|
async def root():
|
||||||
|
"""Welcome endpoint."""
|
||||||
|
return {
|
||||||
|
"service": "Brace Generator API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"docs": "/docs",
|
||||||
|
"health": "/health"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_model=HealthResponse, summary="Health check")
|
||||||
|
async def health_check():
|
||||||
|
"""Check server health and GPU status."""
|
||||||
|
cuda_available = torch.cuda.is_available()
|
||||||
|
|
||||||
|
gpu_name = None
|
||||||
|
gpu_memory_mb = None
|
||||||
|
if cuda_available:
|
||||||
|
gpu_name = torch.cuda.get_device_name(0)
|
||||||
|
gpu_memory_mb = int(torch.cuda.get_device_properties(0).total_memory / (1024**2))
|
||||||
|
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy",
|
||||||
|
device=config.get_device(),
|
||||||
|
cuda_available=cuda_available,
|
||||||
|
model_loaded=True,
|
||||||
|
gpu_name=gpu_name,
|
||||||
|
gpu_memory_mb=gpu_memory_mb
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze/upload", response_model=AnalysisResult, summary="Analyze uploaded X-ray")
|
||||||
|
async def analyze_upload(
|
||||||
|
req: Request,
|
||||||
|
file: UploadFile = File(..., description="X-ray image file"),
|
||||||
|
case_id: Optional[str] = Form(None, description="Case ID"),
|
||||||
|
experiment: str = Form("experiment_3", description="Experiment type"),
|
||||||
|
config_json: Optional[str] = Form(None, description="Brace config as JSON"),
|
||||||
|
landmarks_json: Optional[str] = Form(None, description="Pre-computed landmarks with manual edits")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Analyze an uploaded X-ray image and generate brace.
|
||||||
|
|
||||||
|
This endpoint accepts multipart/form-data for direct file upload.
|
||||||
|
Returns analysis results with local file paths that can be downloaded
|
||||||
|
via the /download endpoint.
|
||||||
|
|
||||||
|
If landmarks_json is provided, it will use those landmarks (with manual edits)
|
||||||
|
instead of re-running automatic detection. This allows manual corrections
|
||||||
|
to be incorporated into the brace generation.
|
||||||
|
|
||||||
|
The Lambda function is responsible for:
|
||||||
|
1. Downloading the X-ray from S3
|
||||||
|
2. Calling this endpoint
|
||||||
|
3. Downloading output files via /download
|
||||||
|
4. Uploading files to S3
|
||||||
|
"""
|
||||||
|
# Validate file
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=400, detail="No file provided")
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
contents = await file.read()
|
||||||
|
if len(contents) > config.MAX_IMAGE_SIZE_MB * 1024 * 1024:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File too large. Maximum size is {config.MAX_IMAGE_SIZE_MB}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse config if provided
|
||||||
|
brace_config = None
|
||||||
|
if config_json:
|
||||||
|
try:
|
||||||
|
config_data = json.loads(config_json)
|
||||||
|
brace_config = BraceConfigRequest(**config_data)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid config: {e}")
|
||||||
|
|
||||||
|
# Parse landmarks if provided (manual edits)
|
||||||
|
landmarks_data = None
|
||||||
|
if landmarks_json:
|
||||||
|
try:
|
||||||
|
landmarks_data = json.loads(landmarks_json)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid landmarks JSON: {e}")
|
||||||
|
|
||||||
|
# Parse experiment type
|
||||||
|
try:
|
||||||
|
exp_type = ExperimentType(experiment)
|
||||||
|
except ValueError:
|
||||||
|
exp_type = ExperimentType.EXPERIMENT_3
|
||||||
|
|
||||||
|
service = req.app.state.brace_service
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await service.analyze_from_bytes(
|
||||||
|
image_data=contents,
|
||||||
|
filename=file.filename,
|
||||||
|
experiment=exp_type,
|
||||||
|
case_id=case_id,
|
||||||
|
brace_config=brace_config,
|
||||||
|
landmarks_data=landmarks_data # Pass pre-computed landmarks
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/download/{case_id}/{filename}", summary="Download output file")
|
||||||
|
async def download_file(case_id: str, filename: str):
|
||||||
|
"""
|
||||||
|
Download a generated output file.
|
||||||
|
|
||||||
|
This endpoint is called by the Lambda function to retrieve
|
||||||
|
generated files (STL, PLY, PNG, JSON) for upload to S3.
|
||||||
|
"""
|
||||||
|
file_path = config.TEMP_DIR / case_id / filename
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
|
||||||
|
|
||||||
|
# Determine media type
|
||||||
|
ext = file_path.suffix.lower()
|
||||||
|
media_types = {
|
||||||
|
".stl": "application/octet-stream",
|
||||||
|
".ply": "application/octet-stream",
|
||||||
|
".obj": "application/octet-stream",
|
||||||
|
".glb": "model/gltf-binary",
|
||||||
|
".gltf": "model/gltf+json",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".json": "application/json",
|
||||||
|
}
|
||||||
|
media_type = media_types.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(file_path),
|
||||||
|
filename=filename,
|
||||||
|
media_type=media_type
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/extract-body-measurements", summary="Extract body measurements from 3D scan")
|
||||||
|
async def extract_body_measurements(
|
||||||
|
file: UploadFile = File(..., description="3D body scan file (STL/OBJ/PLY)")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Extract body measurements from a 3D body scan.
|
||||||
|
|
||||||
|
Returns measurements needed for brace fitting:
|
||||||
|
- Total height
|
||||||
|
- Shoulder, chest, waist, hip widths and depths
|
||||||
|
- Circumferences
|
||||||
|
- Brace coverage region
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from server_DEV.body_integration import extract_measurements_from_scan
|
||||||
|
except ImportError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Body integration module not available: {e}")
|
||||||
|
|
||||||
|
# Validate file type
|
||||||
|
allowed_extensions = ['.stl', '.obj', '.ply', '.glb', '.gltf']
|
||||||
|
ext = Path(file.filename).suffix.lower() if file.filename else '.stl'
|
||||||
|
if ext not in allowed_extensions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to temp file
|
||||||
|
contents = await file.read()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
|
||||||
|
f.write(contents)
|
||||||
|
temp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
measurements = extract_measurements_from_scan(temp_path)
|
||||||
|
return measurements
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
Path(temp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate-with-body", summary="Generate brace with body scan fitting")
|
||||||
|
async def generate_with_body_scan(
|
||||||
|
req: Request,
|
||||||
|
xray_file: UploadFile = File(..., description="X-ray image"),
|
||||||
|
body_scan_file: UploadFile = File(..., description="3D body scan (STL/OBJ/PLY)"),
|
||||||
|
case_id: Optional[str] = Form(None, description="Case ID"),
|
||||||
|
landmarks_json: Optional[str] = Form(None, description="Pre-computed landmarks"),
|
||||||
|
clearance_mm: float = Form(8.0, description="Shell clearance in mm"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate a patient-specific brace using X-ray analysis and 3D body scan.
|
||||||
|
|
||||||
|
This endpoint:
|
||||||
|
1. Analyzes X-ray to detect spine landmarks and compute Cobb angles
|
||||||
|
2. Classifies curve type using Rigo-Cheneau system
|
||||||
|
3. Fits a shell template to the 3D body scan
|
||||||
|
4. Returns STL, GLB, and visualization files
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from server_DEV.body_integration import generate_fitted_brace, extract_measurements_from_scan
|
||||||
|
except ImportError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Body integration module not available: {e}")
|
||||||
|
|
||||||
|
# Generate case ID if not provided
|
||||||
|
case_id = case_id or f"case_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
# Save files to temp directory
|
||||||
|
temp_dir = config.TEMP_DIR / case_id
|
||||||
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save X-ray
|
||||||
|
xray_contents = await xray_file.read()
|
||||||
|
xray_ext = Path(xray_file.filename).suffix if xray_file.filename else '.jpg'
|
||||||
|
xray_path = temp_dir / f"xray{xray_ext}"
|
||||||
|
xray_path.write_bytes(xray_contents)
|
||||||
|
|
||||||
|
# Save body scan
|
||||||
|
body_contents = await body_scan_file.read()
|
||||||
|
body_ext = Path(body_scan_file.filename).suffix if body_scan_file.filename else '.stl'
|
||||||
|
body_scan_path = temp_dir / f"body_scan{body_ext}"
|
||||||
|
body_scan_path.write_bytes(body_contents)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse landmarks if provided
|
||||||
|
landmarks_data = None
|
||||||
|
if landmarks_json:
|
||||||
|
import json
|
||||||
|
landmarks_data = json.loads(landmarks_json)
|
||||||
|
|
||||||
|
# Step 1: Analyze X-ray to get Rigo classification (this generates the brace)
|
||||||
|
service = req.app.state.brace_service
|
||||||
|
|
||||||
|
xray_result = await service.analyze_from_bytes(
|
||||||
|
image_data=xray_contents,
|
||||||
|
filename=xray_file.filename,
|
||||||
|
experiment=ExperimentType.EXPERIMENT_3,
|
||||||
|
case_id=case_id,
|
||||||
|
landmarks_data=landmarks_data
|
||||||
|
)
|
||||||
|
|
||||||
|
rigo_type = xray_result.rigo_classification.type if xray_result.rigo_classification else "A1"
|
||||||
|
|
||||||
|
# Step 2: Try to extract body measurements (optional - EXPERIMENT_10 may not be deployed)
|
||||||
|
body_measurements = None
|
||||||
|
fitting_result = None
|
||||||
|
body_scan_error = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
body_measurements = extract_measurements_from_scan(str(body_scan_path))
|
||||||
|
|
||||||
|
# Step 3: Generate fitted brace (only if measurements worked)
|
||||||
|
fitting_result = generate_fitted_brace(
|
||||||
|
body_scan_path=str(body_scan_path),
|
||||||
|
rigo_type=rigo_type,
|
||||||
|
output_dir=str(temp_dir),
|
||||||
|
case_id=case_id,
|
||||||
|
clearance_mm=clearance_mm
|
||||||
|
)
|
||||||
|
except Exception as body_err:
|
||||||
|
print(f"Warning: Body scan processing failed, using X-ray only: {body_err}")
|
||||||
|
body_scan_error = str(body_err)
|
||||||
|
|
||||||
|
# If body fitting worked, return full result
|
||||||
|
if fitting_result:
|
||||||
|
return {
|
||||||
|
"case_id": case_id,
|
||||||
|
"experiment": "experiment_10",
|
||||||
|
"model_used": xray_result.model_used,
|
||||||
|
"vertebrae_detected": xray_result.vertebrae_detected,
|
||||||
|
"cobb_angles": {
|
||||||
|
"PT": xray_result.cobb_angles.PT,
|
||||||
|
"MT": xray_result.cobb_angles.MT,
|
||||||
|
"TL": xray_result.cobb_angles.TL,
|
||||||
|
},
|
||||||
|
"curve_type": xray_result.curve_type,
|
||||||
|
"rigo_classification": {
|
||||||
|
"type": rigo_type,
|
||||||
|
"description": xray_result.rigo_classification.description if xray_result.rigo_classification else ""
|
||||||
|
},
|
||||||
|
"body_scan": {
|
||||||
|
"measurements": body_measurements,
|
||||||
|
},
|
||||||
|
"brace_fitting": fitting_result,
|
||||||
|
"outputs": {
|
||||||
|
"shell_stl": fitting_result["outputs"]["shell_stl"],
|
||||||
|
"shell_glb": fitting_result["outputs"]["shell_glb"],
|
||||||
|
"combined_stl": fitting_result["outputs"]["combined_stl"],
|
||||||
|
"visualization": fitting_result["outputs"].get("visualization"),
|
||||||
|
"feedback_json": fitting_result["outputs"]["feedback_json"],
|
||||||
|
"xray_visualization": str(xray_result.outputs.get("visualization", "")),
|
||||||
|
},
|
||||||
|
"mesh_vertices": fitting_result["mesh_stats"]["vertices"],
|
||||||
|
"mesh_faces": fitting_result["mesh_stats"]["faces"],
|
||||||
|
"processing_time_ms": xray_result.processing_time_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback: return X-ray only result (body scan processing not available)
|
||||||
|
return {
|
||||||
|
"case_id": case_id,
|
||||||
|
"experiment": "experiment_3_fallback",
|
||||||
|
"model_used": xray_result.model_used,
|
||||||
|
"vertebrae_detected": xray_result.vertebrae_detected,
|
||||||
|
"cobb_angles": {
|
||||||
|
"PT": xray_result.cobb_angles.PT,
|
||||||
|
"MT": xray_result.cobb_angles.MT,
|
||||||
|
"TL": xray_result.cobb_angles.TL,
|
||||||
|
},
|
||||||
|
"curve_type": xray_result.curve_type,
|
||||||
|
"rigo_classification": {
|
||||||
|
"type": rigo_type,
|
||||||
|
"description": xray_result.rigo_classification.description if xray_result.rigo_classification else ""
|
||||||
|
},
|
||||||
|
"body_scan": {
|
||||||
|
"error": body_scan_error or "Body scan processing not available",
|
||||||
|
"fallback": "Using X-ray only brace generation"
|
||||||
|
},
|
||||||
|
"outputs": xray_result.outputs,
|
||||||
|
"mesh_vertices": xray_result.mesh_vertices,
|
||||||
|
"mesh_faces": xray_result.mesh_faces,
|
||||||
|
"processing_time_ms": xray_result.processing_time_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/experiments", summary="List available experiments")
|
||||||
|
async def list_experiments():
|
||||||
|
"""List available brace generation experiments."""
|
||||||
|
return {
|
||||||
|
"experiments": [
|
||||||
|
{
|
||||||
|
"id": "standard",
|
||||||
|
"name": "Standard Pipeline",
|
||||||
|
"description": "Original template-based brace generation using Rigo classification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "experiment_3",
|
||||||
|
"name": "Research-Based Adaptive",
|
||||||
|
"description": "Adaptive brace generation based on Guy et al. (2024) with patch-based deformation optimization"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "experiment_10",
|
||||||
|
"name": "Patient-Specific Body Fitting",
|
||||||
|
"description": "X-ray analysis + 3D body scan for precise patient-specific brace fitting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "experiment_3"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", summary="List available detection models")
|
||||||
|
async def list_models():
|
||||||
|
"""List available landmark detection models."""
|
||||||
|
return {
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "scoliovis",
|
||||||
|
"name": "ScolioVis",
|
||||||
|
"description": "Keypoint R-CNN model for vertebrae detection",
|
||||||
|
"supports_gpu": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vertebra-landmark",
|
||||||
|
"name": "Vertebra-Landmark-Detection",
|
||||||
|
"description": "SpineNet-based detection (alternative)",
|
||||||
|
"supports_gpu": True
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": config.MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# NEW ENDPOINTS FOR PIPELINE DEV
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@router.post("/detect-landmarks", summary="Detect landmarks only (Stage 1)")
|
||||||
|
async def detect_landmarks(
|
||||||
|
req: Request,
|
||||||
|
file: UploadFile = File(..., description="X-ray image file"),
|
||||||
|
case_id: Optional[str] = Form(None, description="Case ID"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Detect vertebrae landmarks without generating a brace.
|
||||||
|
Returns landmarks, visualization, and vertebrae_structure for manual editing.
|
||||||
|
|
||||||
|
This is Stage 1 of the pipeline - just detection, no brace generation.
|
||||||
|
"""
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=400, detail="No file provided")
|
||||||
|
|
||||||
|
contents = await file.read()
|
||||||
|
if len(contents) > config.MAX_IMAGE_SIZE_MB * 1024 * 1024:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File too large. Maximum size is {config.MAX_IMAGE_SIZE_MB}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = req.app.state.brace_service
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await service.detect_landmarks_only(
|
||||||
|
image_data=contents,
|
||||||
|
filename=file.filename,
|
||||||
|
case_id=case_id
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recalculate", summary="Recalculate Cobb/Rigo from landmarks")
|
||||||
|
async def recalculate_analysis(req: Request):
|
||||||
|
"""
|
||||||
|
Recalculate Cobb angles and Rigo classification from provided landmarks.
|
||||||
|
|
||||||
|
Use this after manual landmark editing to get updated analysis.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"case_id": "case-xxx",
|
||||||
|
"landmarks": { ... vertebrae_structure from detect-landmarks ... }
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
body = await req.json()
|
||||||
|
case_id = body.get("case_id")
|
||||||
|
landmarks = body.get("landmarks")
|
||||||
|
|
||||||
|
if not landmarks:
|
||||||
|
raise HTTPException(status_code=400, detail="landmarks data required")
|
||||||
|
|
||||||
|
service = req.app.state.brace_service
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await service.recalculate_from_landmarks(
|
||||||
|
landmarks_data=landmarks,
|
||||||
|
case_id=case_id
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GLB BRACE GENERATION WITH MARKERS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
from .glb_generator import (
|
||||||
|
generate_glb_brace,
|
||||||
|
generate_both_brace_types,
|
||||||
|
list_available_templates,
|
||||||
|
calculate_pressure_zones,
|
||||||
|
load_template_markers,
|
||||||
|
AVAILABLE_RIGO_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates", summary="List available brace templates")
|
||||||
|
async def list_templates():
|
||||||
|
"""
|
||||||
|
List all available brace templates (regular and vase types).
|
||||||
|
|
||||||
|
Returns which Rigo types have templates available.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"available_templates": list_available_templates(),
|
||||||
|
"rigo_types": AVAILABLE_RIGO_TYPES,
|
||||||
|
"template_types": ["regular", "vase"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates/{rigo_type}/markers", summary="Get template markers")
|
||||||
|
async def get_template_markers(
|
||||||
|
rigo_type: str,
|
||||||
|
template_type: str = "regular"
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get marker positions for a specific template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rigo_type: Rigo classification (A1, A2, A3, B1, B2, C1, C2, E1, E2)
|
||||||
|
template_type: "regular" or "vase"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Marker positions and basis vectors
|
||||||
|
"""
|
||||||
|
if rigo_type not in AVAILABLE_RIGO_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid rigo_type. Must be one of: {AVAILABLE_RIGO_TYPES}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if template_type not in ["regular", "vase"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="template_type must be 'regular' or 'vase'"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
markers = load_template_markers(rigo_type, template_type)
|
||||||
|
return {
|
||||||
|
"rigo_type": rigo_type,
|
||||||
|
"template_type": template_type,
|
||||||
|
**markers
|
||||||
|
}
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate-glb", summary="Generate GLB brace with markers")
|
||||||
|
async def generate_glb_endpoint(
|
||||||
|
req: Request,
|
||||||
|
rigo_type: str = Form(..., description="Rigo classification (A1-E2)"),
|
||||||
|
template_type: str = Form("regular", description="Template type: 'regular' or 'vase'"),
|
||||||
|
case_id: str = Form(..., description="Case identifier"),
|
||||||
|
cobb_pt: float = Form(0.0, description="Proximal Thoracic Cobb angle"),
|
||||||
|
cobb_mt: float = Form(0.0, description="Main Thoracic Cobb angle"),
|
||||||
|
cobb_tl: float = Form(0.0, description="Thoracolumbar Cobb angle"),
|
||||||
|
body_scan: Optional[UploadFile] = File(None, description="Optional 3D body scan STL")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate a GLB brace with embedded markers.
|
||||||
|
|
||||||
|
This endpoint generates a brace file that includes marker positions
|
||||||
|
for later editing. Optionally fits to a body scan.
|
||||||
|
|
||||||
|
**Pressure Zones in Output:**
|
||||||
|
- LM_PAD_TH: Thoracic pad (pushes INWARD on curve convex side)
|
||||||
|
- LM_BAY_TH: Thoracic bay (creates SPACE on curve concave side)
|
||||||
|
- LM_PAD_LUM: Lumbar pad (pushes INWARD)
|
||||||
|
- LM_BAY_LUM: Lumbar bay (creates SPACE)
|
||||||
|
- LM_ANCHOR_HIP_L/R: Hip anchors (stabilize brace)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GLB and STL file paths, marker positions, pressure zone info
|
||||||
|
"""
|
||||||
|
if rigo_type not in AVAILABLE_RIGO_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid rigo_type. Must be one of: {AVAILABLE_RIGO_TYPES}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if template_type not in ["regular", "vase"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="template_type must be 'regular' or 'vase'"
|
||||||
|
)
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
output_dir = Path(tempfile.gettempdir()) / "brace_generator" / case_id
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
body_scan_path = None
|
||||||
|
|
||||||
|
# Save body scan if provided
|
||||||
|
if body_scan:
|
||||||
|
body_ext = Path(body_scan.filename).suffix if body_scan.filename else ".stl"
|
||||||
|
body_scan_path = str(output_dir / f"body_scan{body_ext}")
|
||||||
|
with open(body_scan_path, "wb") as f:
|
||||||
|
content = await body_scan.read()
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
cobb_angles = {
|
||||||
|
"PT": cobb_pt,
|
||||||
|
"MT": cobb_mt,
|
||||||
|
"TL": cobb_tl
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = generate_glb_brace(
|
||||||
|
rigo_type=rigo_type,
|
||||||
|
template_type=template_type,
|
||||||
|
output_dir=output_dir,
|
||||||
|
case_id=case_id,
|
||||||
|
cobb_angles=cobb_angles,
|
||||||
|
body_scan_path=body_scan_path,
|
||||||
|
clearance_mm=8.0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"case_id": case_id,
|
||||||
|
"rigo_type": rigo_type,
|
||||||
|
"template_type": template_type,
|
||||||
|
"outputs": {
|
||||||
|
"glb": result.glb_path,
|
||||||
|
"stl": result.stl_path,
|
||||||
|
"json": result.json_path
|
||||||
|
},
|
||||||
|
"markers": result.markers,
|
||||||
|
"basis": result.basis,
|
||||||
|
"pressure_zones": result.pressure_zones,
|
||||||
|
"mesh_stats": result.mesh_stats,
|
||||||
|
"body_fitting": result.transform_applied
|
||||||
|
}
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate-both-braces", summary="Generate both brace types for comparison")
|
||||||
|
async def generate_both_braces_endpoint(
|
||||||
|
req: Request,
|
||||||
|
rigo_type: str = Form(..., description="Rigo classification (A1-E2)"),
|
||||||
|
case_id: str = Form(..., description="Case identifier"),
|
||||||
|
cobb_pt: float = Form(0.0, description="Proximal Thoracic Cobb angle"),
|
||||||
|
cobb_mt: float = Form(0.0, description="Main Thoracic Cobb angle"),
|
||||||
|
cobb_tl: float = Form(0.0, description="Thoracolumbar Cobb angle"),
|
||||||
|
body_scan: Optional[UploadFile] = File(None, description="Optional 3D body scan STL"),
|
||||||
|
body_scan_path: Optional[str] = Form(None, description="Optional path to existing body scan file"),
|
||||||
|
clearance_mm: float = Form(8.0, description="Brace clearance from body in mm")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate BOTH regular and vase brace types for side-by-side comparison.
|
||||||
|
|
||||||
|
This allows the user to compare the two brace shapes and choose
|
||||||
|
the preferred design.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Both brace files with markers and pressure zones
|
||||||
|
"""
|
||||||
|
if rigo_type not in AVAILABLE_RIGO_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid rigo_type. Must be one of: {AVAILABLE_RIGO_TYPES}"
|
||||||
|
)
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
output_dir = Path(tempfile.gettempdir()) / "brace_generator" / case_id
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
final_body_scan_path = None
|
||||||
|
|
||||||
|
# Save body scan if uploaded as file
|
||||||
|
if body_scan:
|
||||||
|
body_ext = Path(body_scan.filename).suffix if body_scan.filename else ".stl"
|
||||||
|
final_body_scan_path = str(output_dir / f"body_scan{body_ext}")
|
||||||
|
with open(final_body_scan_path, "wb") as f:
|
||||||
|
content = await body_scan.read()
|
||||||
|
f.write(content)
|
||||||
|
# Or use provided path if it exists
|
||||||
|
elif body_scan_path and Path(body_scan_path).exists():
|
||||||
|
final_body_scan_path = body_scan_path
|
||||||
|
print(f"Using existing body scan at: {body_scan_path}")
|
||||||
|
|
||||||
|
cobb_angles = {
|
||||||
|
"PT": cobb_pt,
|
||||||
|
"MT": cobb_mt,
|
||||||
|
"TL": cobb_tl
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = generate_both_brace_types(
|
||||||
|
rigo_type=rigo_type,
|
||||||
|
output_dir=output_dir,
|
||||||
|
case_id=case_id,
|
||||||
|
cobb_angles=cobb_angles,
|
||||||
|
body_scan_path=final_body_scan_path,
|
||||||
|
clearance_mm=clearance_mm
|
||||||
|
)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"success": True,
|
||||||
|
"case_id": case_id,
|
||||||
|
"rigo_type": rigo_type,
|
||||||
|
"cobb_angles": cobb_angles,
|
||||||
|
"body_scan_used": final_body_scan_path is not None,
|
||||||
|
"braces": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for brace_type, result in results.items():
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
response["braces"][brace_type] = result
|
||||||
|
else:
|
||||||
|
response["braces"][brace_type] = {
|
||||||
|
"outputs": {
|
||||||
|
"glb": result.glb_path,
|
||||||
|
"stl": result.stl_path,
|
||||||
|
"json": result.json_path
|
||||||
|
},
|
||||||
|
"markers": result.markers,
|
||||||
|
"pressure_zones": result.pressure_zones,
|
||||||
|
"mesh_stats": result.mesh_stats
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pressure-zones/{rigo_type}", summary="Get pressure zone information")
|
||||||
|
async def get_pressure_zones(
|
||||||
|
rigo_type: str,
|
||||||
|
template_type: str = "regular",
|
||||||
|
cobb_mt: float = 25.0,
|
||||||
|
cobb_tl: float = 15.0
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get detailed pressure zone information for a Rigo type.
|
||||||
|
|
||||||
|
This explains WHERE and HOW MUCH pressure is applied based on
|
||||||
|
the Cobb angles.
|
||||||
|
|
||||||
|
**Pressure Zone Types:**
|
||||||
|
|
||||||
|
- **PAD (Push Zone)**: Pushes INWARD on the convex side of the curve
|
||||||
|
to apply corrective force. Depth increases with Cobb angle severity.
|
||||||
|
|
||||||
|
- **BAY (Expansion Zone)**: Creates SPACE on the concave side for the
|
||||||
|
body to shift into during correction. Clearance is ~1.3x pad depth.
|
||||||
|
|
||||||
|
- **ANCHOR (Stability Zone)**: Grips the pelvis to prevent the brace
|
||||||
|
from riding up. Light inward pressure.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Detailed pressure zone descriptions with depths in mm
|
||||||
|
"""
|
||||||
|
if rigo_type not in AVAILABLE_RIGO_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid rigo_type. Must be one of: {AVAILABLE_RIGO_TYPES}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
markers = load_template_markers(rigo_type, template_type)
|
||||||
|
zones = calculate_pressure_zones(
|
||||||
|
markers,
|
||||||
|
rigo_type,
|
||||||
|
{"PT": 0, "MT": cobb_mt, "TL": cobb_tl}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rigo_type": rigo_type,
|
||||||
|
"template_type": template_type,
|
||||||
|
"cobb_angles": {"MT": cobb_mt, "TL": cobb_tl},
|
||||||
|
"pressure_zones": [
|
||||||
|
{
|
||||||
|
"name": z.name,
|
||||||
|
"marker": z.marker_name,
|
||||||
|
"position": list(z.position),
|
||||||
|
"type": z.zone_type,
|
||||||
|
"direction": z.direction,
|
||||||
|
"function": z.function,
|
||||||
|
"depth_mm": round(z.depth_mm, 1),
|
||||||
|
"radius_mm": list(z.radius_mm)
|
||||||
|
}
|
||||||
|
for z in zones
|
||||||
|
],
|
||||||
|
"explanation": {
|
||||||
|
"pad_depth": f"Based on Cobb angle severity: {cobb_mt}° MT → {round(8 + min(max((cobb_mt - 10) / 40, 0), 1) * 14, 1)}mm thoracic pad",
|
||||||
|
"bay_clearance": "Bay clearance = 1.3 × pad depth + 4-5mm to allow body movement",
|
||||||
|
"hip_anchors": "4mm inward pressure to grip pelvis and stabilize brace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DEV MODE: LOCAL FILE STORAGE AND SERVING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Local storage directory for DEV mode
|
||||||
|
DEV_STORAGE_DIR = config.TEMP_DIR / "dev_storage"
|
||||||
|
DEV_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cases", summary="Create a new case (DEV)")
|
||||||
|
async def create_case():
|
||||||
|
"""Create a new case with a generated ID (DEV mode)."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
case_id = f"case-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8]}"
|
||||||
|
case_dir = DEV_STORAGE_DIR / case_id
|
||||||
|
(case_dir / "uploads").mkdir(parents=True, exist_ok=True)
|
||||||
|
(case_dir / "outputs").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save case metadata
|
||||||
|
metadata = {
|
||||||
|
"case_id": case_id,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"status": "created"
|
||||||
|
}
|
||||||
|
(case_dir / "case.json").write_text(json.dumps(metadata, indent=2))
|
||||||
|
|
||||||
|
return {"caseId": case_id, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cases/{case_id}", summary="Get case details (DEV)")
|
||||||
|
async def get_case(case_id: str):
|
||||||
|
"""Get case details (DEV mode)."""
|
||||||
|
case_dir = DEV_STORAGE_DIR / case_id
|
||||||
|
|
||||||
|
if not case_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Case not found: {case_id}")
|
||||||
|
|
||||||
|
metadata_file = case_dir / "case.json"
|
||||||
|
if metadata_file.exists():
|
||||||
|
metadata = json.loads(metadata_file.read_text())
|
||||||
|
else:
|
||||||
|
metadata = {"case_id": case_id, "status": "unknown"}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cases/{case_id}/upload", summary="Upload X-ray for case (DEV)")
|
||||||
|
async def upload_xray(
|
||||||
|
case_id: str,
|
||||||
|
file: UploadFile = File(..., description="X-ray image file")
|
||||||
|
):
|
||||||
|
"""Upload X-ray image for a case (DEV mode - saves locally)."""
|
||||||
|
case_dir = DEV_STORAGE_DIR / case_id
|
||||||
|
uploads_dir = case_dir / "uploads"
|
||||||
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Determine extension from filename
|
||||||
|
ext = Path(file.filename).suffix.lower() if file.filename else ".jpg"
|
||||||
|
if ext not in [".jpg", ".jpeg", ".png", ".webp"]:
|
||||||
|
ext = ".jpg"
|
||||||
|
|
||||||
|
# Save as xray.{ext}
|
||||||
|
xray_path = uploads_dir / f"xray{ext}"
|
||||||
|
contents = await file.read()
|
||||||
|
xray_path.write_bytes(contents)
|
||||||
|
|
||||||
|
# Update case metadata
|
||||||
|
metadata_file = case_dir / "case.json"
|
||||||
|
if metadata_file.exists():
|
||||||
|
metadata = json.loads(metadata_file.read_text())
|
||||||
|
else:
|
||||||
|
metadata = {"case_id": case_id}
|
||||||
|
|
||||||
|
metadata["xray_uploaded"] = True
|
||||||
|
metadata["xray_filename"] = f"xray{ext}"
|
||||||
|
metadata_file.write_text(json.dumps(metadata, indent=2))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"filename": f"xray{ext}",
|
||||||
|
"path": f"/files/uploads/{case_id}/xray{ext}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cases/{case_id}/assets", summary="Get case assets (DEV)")
|
||||||
|
async def get_case_assets(case_id: str):
|
||||||
|
"""List all uploaded and output files for a case (DEV mode)."""
|
||||||
|
case_dir = DEV_STORAGE_DIR / case_id
|
||||||
|
|
||||||
|
if not case_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Case not found: {case_id}")
|
||||||
|
|
||||||
|
uploads = []
|
||||||
|
outputs = []
|
||||||
|
|
||||||
|
# List uploads
|
||||||
|
uploads_dir = case_dir / "uploads"
|
||||||
|
if uploads_dir.exists():
|
||||||
|
for f in uploads_dir.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
uploads.append({
|
||||||
|
"filename": f.name,
|
||||||
|
"url": f"/files/uploads/{case_id}/{f.name}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# List outputs
|
||||||
|
outputs_dir = case_dir / "outputs"
|
||||||
|
if outputs_dir.exists():
|
||||||
|
for f in outputs_dir.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
outputs.append({
|
||||||
|
"filename": f.name,
|
||||||
|
"url": f"/files/outputs/{case_id}/{f.name}"
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"caseId": case_id,
|
||||||
|
"assets": {
|
||||||
|
"uploads": uploads,
|
||||||
|
"outputs": outputs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/uploads/{case_id}/{filename}", summary="Serve uploaded file (DEV)")
|
||||||
|
async def serve_upload_file(case_id: str, filename: str):
|
||||||
|
"""Serve an uploaded file (DEV mode)."""
|
||||||
|
file_path = DEV_STORAGE_DIR / case_id / "uploads" / filename
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
|
||||||
|
|
||||||
|
# Determine media type
|
||||||
|
ext = file_path.suffix.lower()
|
||||||
|
media_types = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".stl": "application/octet-stream",
|
||||||
|
".glb": "model/gltf-binary",
|
||||||
|
".json": "application/json",
|
||||||
|
}
|
||||||
|
media_type = media_types.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(file_path),
|
||||||
|
filename=filename,
|
||||||
|
media_type=media_type
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/outputs/{case_id}/{filename}", summary="Serve output file (DEV)")
|
||||||
|
async def serve_output_file(case_id: str, filename: str):
|
||||||
|
"""Serve an output file (DEV mode)."""
|
||||||
|
file_path = DEV_STORAGE_DIR / case_id / "outputs" / filename
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
|
||||||
|
|
||||||
|
# Determine media type
|
||||||
|
ext = file_path.suffix.lower()
|
||||||
|
media_types = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".stl": "application/octet-stream",
|
||||||
|
".ply": "application/octet-stream",
|
||||||
|
".obj": "application/octet-stream",
|
||||||
|
".glb": "model/gltf-binary",
|
||||||
|
".gltf": "model/gltf+json",
|
||||||
|
".json": "application/json",
|
||||||
|
}
|
||||||
|
media_type = media_types.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(file_path),
|
||||||
|
filename=filename,
|
||||||
|
media_type=media_type
|
||||||
|
)
|
||||||
125
brace-generator/schemas.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for API request/response validation.
|
||||||
|
"""
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentType(str, Enum):
|
||||||
|
"""Available brace generation experiments."""
|
||||||
|
STANDARD = "standard" # Original pipeline
|
||||||
|
EXPERIMENT_3 = "experiment_3" # Research-based adaptive
|
||||||
|
|
||||||
|
|
||||||
|
class BraceConfigRequest(BaseModel):
|
||||||
|
"""Brace configuration parameters."""
|
||||||
|
brace_height_mm: float = Field(default=400.0, ge=200, le=600)
|
||||||
|
torso_width_mm: float = Field(default=280.0, ge=150, le=400)
|
||||||
|
torso_depth_mm: float = Field(default=200.0, ge=100, le=350)
|
||||||
|
wall_thickness_mm: float = Field(default=4.0, ge=2, le=10)
|
||||||
|
pressure_strength_mm: float = Field(default=15.0, ge=0, le=30)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeRequest(BaseModel):
|
||||||
|
"""Request to analyze X-ray and generate brace."""
|
||||||
|
s3_key: Optional[str] = Field(None, description="S3 key of uploaded X-ray image")
|
||||||
|
case_id: Optional[str] = Field(None, description="Case ID for organizing outputs")
|
||||||
|
experiment: ExperimentType = Field(default=ExperimentType.EXPERIMENT_3)
|
||||||
|
config: Optional[BraceConfigRequest] = None
|
||||||
|
|
||||||
|
# Output options
|
||||||
|
save_visualization: bool = Field(default=True)
|
||||||
|
save_landmarks: bool = Field(default=True)
|
||||||
|
output_format: str = Field(default="stl", description="stl, ply, or both")
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeFromUrlRequest(BaseModel):
|
||||||
|
"""Request with direct image URL."""
|
||||||
|
image_url: str = Field(..., description="URL to download X-ray image from")
|
||||||
|
case_id: Optional[str] = Field(None)
|
||||||
|
experiment: ExperimentType = Field(default=ExperimentType.EXPERIMENT_3)
|
||||||
|
config: Optional[BraceConfigRequest] = None
|
||||||
|
save_visualization: bool = True
|
||||||
|
save_landmarks: bool = True
|
||||||
|
output_format: str = "stl"
|
||||||
|
|
||||||
|
|
||||||
|
class Vertebra(BaseModel):
|
||||||
|
"""Single vertebra data."""
|
||||||
|
level: str
|
||||||
|
centroid_px: List[float]
|
||||||
|
orientation_deg: Optional[float] = None
|
||||||
|
confidence: Optional[float] = None
|
||||||
|
corners_px: Optional[List[List[float]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CobbAngles(BaseModel):
|
||||||
|
"""Cobb angle measurements."""
|
||||||
|
PT: float = Field(..., description="Proximal Thoracic angle")
|
||||||
|
MT: float = Field(..., description="Main Thoracic angle")
|
||||||
|
TL: float = Field(..., description="Thoracolumbar angle")
|
||||||
|
|
||||||
|
|
||||||
|
class RigoClassification(BaseModel):
|
||||||
|
"""Rigo-Chêneau classification result."""
|
||||||
|
type: str
|
||||||
|
description: str
|
||||||
|
curve_pattern: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeformationReport(BaseModel):
|
||||||
|
"""Patch-based deformation report (Experiment 3)."""
|
||||||
|
patch_grid: str
|
||||||
|
deformations: Optional[List[List[float]]] = None
|
||||||
|
zones: Optional[List[Dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisResult(BaseModel):
|
||||||
|
"""Complete analysis result."""
|
||||||
|
case_id: Optional[str] = None
|
||||||
|
experiment: str
|
||||||
|
|
||||||
|
# Input
|
||||||
|
input_image: str
|
||||||
|
|
||||||
|
# Detection results
|
||||||
|
model_used: str
|
||||||
|
vertebrae_detected: int
|
||||||
|
vertebrae: Optional[List[Vertebra]] = None
|
||||||
|
|
||||||
|
# Measurements
|
||||||
|
cobb_angles: CobbAngles
|
||||||
|
curve_type: str
|
||||||
|
|
||||||
|
# Classification
|
||||||
|
rigo_classification: RigoClassification
|
||||||
|
|
||||||
|
# Brace mesh info
|
||||||
|
mesh_vertices: int
|
||||||
|
mesh_faces: int
|
||||||
|
|
||||||
|
# Deformation (Experiment 3)
|
||||||
|
deformation_report: Optional[DeformationReport] = None
|
||||||
|
|
||||||
|
# Output URLs/paths
|
||||||
|
outputs: Dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
processing_time_ms: float
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""Health check response."""
|
||||||
|
status: str
|
||||||
|
device: str
|
||||||
|
cuda_available: bool
|
||||||
|
model_loaded: bool
|
||||||
|
gpu_name: Optional[str] = None
|
||||||
|
gpu_memory_mb: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""Error response."""
|
||||||
|
error: str
|
||||||
|
detail: Optional[str] = None
|
||||||
884
brace-generator/services.py
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
"""
|
||||||
|
Business logic service for brace generation.
|
||||||
|
|
||||||
|
This service handles ML inference and file generation.
|
||||||
|
S3 operations are handled by the Lambda function, not here.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import tempfile
|
||||||
|
import numpy as np
|
||||||
|
import trimesh
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, Tuple
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .schemas import (
|
||||||
|
AnalyzeRequest, AnalyzeFromUrlRequest, BraceConfigRequest,
|
||||||
|
AnalysisResult, CobbAngles, RigoClassification, Vertebra,
|
||||||
|
DeformationReport, ExperimentType
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BraceService:
|
||||||
|
"""
|
||||||
|
Service for X-ray analysis and brace generation.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Model loading and inference
|
||||||
|
- Pipeline orchestration
|
||||||
|
- Local file management
|
||||||
|
|
||||||
|
Note: S3 operations are handled by Lambda, not here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device: str = "cuda", model: str = "scoliovis"):
|
||||||
|
self.device = device
|
||||||
|
self.model_name = model
|
||||||
|
|
||||||
|
# Initialize pipelines
|
||||||
|
self._init_pipelines()
|
||||||
|
|
||||||
|
def _init_pipelines(self):
|
||||||
|
"""Initialize brace generation pipelines."""
|
||||||
|
from brace_generator.data_models import BraceConfig
|
||||||
|
from brace_generator.pipeline import BracePipeline
|
||||||
|
|
||||||
|
# Standard pipeline
|
||||||
|
self.standard_pipeline = BracePipeline(
|
||||||
|
model=self.model_name,
|
||||||
|
device=self.device
|
||||||
|
)
|
||||||
|
|
||||||
|
# Experiment 3 pipeline (lazy load)
|
||||||
|
self._exp3_pipeline = None
|
||||||
|
|
||||||
|
def _get_exp3_pipeline(self):
|
||||||
|
"""Return standard pipeline (EXPERIMENT_3 not deployed)."""
|
||||||
|
return self.standard_pipeline
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_loaded(self) -> bool:
|
||||||
|
"""Check if model is loaded."""
|
||||||
|
return self.standard_pipeline is not None
|
||||||
|
|
||||||
|
async def analyze_from_bytes(
|
||||||
|
self,
|
||||||
|
image_data: bytes,
|
||||||
|
filename: str,
|
||||||
|
experiment: ExperimentType = ExperimentType.EXPERIMENT_3,
|
||||||
|
case_id: Optional[str] = None,
|
||||||
|
brace_config: Optional[BraceConfigRequest] = None,
|
||||||
|
landmarks_data: Optional[Dict[str, Any]] = None
|
||||||
|
) -> AnalysisResult:
|
||||||
|
"""
|
||||||
|
Analyze X-ray from raw bytes.
|
||||||
|
|
||||||
|
If landmarks_data is provided, it will use those landmarks (with manual edits)
|
||||||
|
instead of re-running automatic detection.
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Generate case ID if not provided
|
||||||
|
case_id = case_id or str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
# Save image to temp file
|
||||||
|
suffix = Path(filename).suffix or ".jpg"
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
||||||
|
f.write(image_data)
|
||||||
|
input_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Prepare output directory
|
||||||
|
output_dir = config.TEMP_DIR / case_id
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
output_base = output_dir / f"brace_{case_id}"
|
||||||
|
|
||||||
|
# Select pipeline based on experiment
|
||||||
|
if experiment == ExperimentType.EXPERIMENT_3:
|
||||||
|
result = await self._run_experiment_3(
|
||||||
|
input_path, output_base, brace_config, landmarks_data
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = await self._run_standard(input_path, output_base, brace_config)
|
||||||
|
|
||||||
|
# Add timing and case ID
|
||||||
|
result.processing_time_ms = (time.time() - start_time) * 1000
|
||||||
|
result.case_id = case_id
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup temp input
|
||||||
|
Path(input_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
async def _run_standard(
|
||||||
|
self,
|
||||||
|
input_path: str,
|
||||||
|
output_base: Path,
|
||||||
|
brace_config: Optional[BraceConfigRequest]
|
||||||
|
) -> AnalysisResult:
|
||||||
|
"""Run standard pipeline."""
|
||||||
|
from brace_generator.data_models import BraceConfig
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
if brace_config:
|
||||||
|
self.standard_pipeline.config = BraceConfig(
|
||||||
|
brace_height_mm=brace_config.brace_height_mm,
|
||||||
|
torso_width_mm=brace_config.torso_width_mm,
|
||||||
|
torso_depth_mm=brace_config.torso_depth_mm,
|
||||||
|
wall_thickness_mm=brace_config.wall_thickness_mm,
|
||||||
|
pressure_strength_mm=brace_config.pressure_strength_mm,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run pipeline
|
||||||
|
results = self.standard_pipeline.process(
|
||||||
|
input_path,
|
||||||
|
str(output_base) + ".stl",
|
||||||
|
visualize=True,
|
||||||
|
save_landmarks=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response with local file paths
|
||||||
|
outputs = {
|
||||||
|
"stl": str(output_base) + ".stl",
|
||||||
|
}
|
||||||
|
|
||||||
|
vis_path = str(output_base) + ".png"
|
||||||
|
json_path = str(output_base) + ".json"
|
||||||
|
if Path(vis_path).exists():
|
||||||
|
outputs["visualization"] = vis_path
|
||||||
|
if Path(json_path).exists():
|
||||||
|
outputs["landmarks"] = json_path
|
||||||
|
|
||||||
|
return AnalysisResult(
|
||||||
|
experiment="standard",
|
||||||
|
input_image=input_path,
|
||||||
|
model_used=results["model"],
|
||||||
|
vertebrae_detected=results["vertebrae_detected"],
|
||||||
|
cobb_angles=CobbAngles(
|
||||||
|
PT=results["cobb_angles"]["PT"],
|
||||||
|
MT=results["cobb_angles"]["MT"],
|
||||||
|
TL=results["cobb_angles"]["TL"],
|
||||||
|
),
|
||||||
|
curve_type=results["curve_type"],
|
||||||
|
rigo_classification=RigoClassification(
|
||||||
|
type=results["rigo_type"],
|
||||||
|
description=results.get("rigo_description", "")
|
||||||
|
),
|
||||||
|
mesh_vertices=results["mesh_vertices"],
|
||||||
|
mesh_faces=results["mesh_faces"],
|
||||||
|
outputs=outputs,
|
||||||
|
processing_time_ms=0 # Will be set by caller
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_experiment_3(
|
||||||
|
self,
|
||||||
|
input_path: str,
|
||||||
|
output_base: Path,
|
||||||
|
brace_config: Optional[BraceConfigRequest],
|
||||||
|
landmarks_data: Optional[Dict[str, Any]] = None
|
||||||
|
) -> AnalysisResult:
|
||||||
|
"""
|
||||||
|
Run Experiment 3 (research-based adaptive) pipeline.
|
||||||
|
|
||||||
|
If landmarks_data is provided, it uses those landmarks (with manual edits)
|
||||||
|
instead of running automatic detection.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from brace_generator.data_models import BraceConfig
|
||||||
|
|
||||||
|
pipeline = self._get_exp3_pipeline()
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
if brace_config:
|
||||||
|
pipeline.config = BraceConfig(
|
||||||
|
brace_height_mm=brace_config.brace_height_mm,
|
||||||
|
torso_width_mm=brace_config.torso_width_mm,
|
||||||
|
torso_depth_mm=brace_config.torso_depth_mm,
|
||||||
|
wall_thickness_mm=brace_config.wall_thickness_mm,
|
||||||
|
pressure_strength_mm=brace_config.pressure_strength_mm,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If landmarks_data is provided, use it instead of running detection
|
||||||
|
if landmarks_data:
|
||||||
|
results = await self._run_experiment_3_with_landmarks(
|
||||||
|
input_path, output_base, pipeline, landmarks_data
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Run full pipeline with automatic detection
|
||||||
|
results = pipeline.process(
|
||||||
|
input_path,
|
||||||
|
str(output_base),
|
||||||
|
visualize=True,
|
||||||
|
save_landmarks=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build deformation report
|
||||||
|
deformation_report = None
|
||||||
|
if results.get("deformation_report"):
|
||||||
|
dr = results["deformation_report"]
|
||||||
|
deformation_report = DeformationReport(
|
||||||
|
patch_grid=dr.get("patch_grid", "6x8"),
|
||||||
|
deformations=dr.get("deformations"),
|
||||||
|
zones=dr.get("zones")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect output file paths
|
||||||
|
outputs = {}
|
||||||
|
|
||||||
|
if results.get("output_stl"):
|
||||||
|
outputs["stl"] = results["output_stl"]
|
||||||
|
if results.get("output_ply"):
|
||||||
|
outputs["ply"] = results["output_ply"]
|
||||||
|
|
||||||
|
# Check for visualization and landmarks files
|
||||||
|
vis_path = str(output_base) + ".png"
|
||||||
|
json_path = str(output_base) + ".json"
|
||||||
|
if Path(vis_path).exists():
|
||||||
|
outputs["visualization"] = vis_path
|
||||||
|
if Path(json_path).exists():
|
||||||
|
outputs["landmarks"] = json_path
|
||||||
|
|
||||||
|
return AnalysisResult(
|
||||||
|
experiment="experiment_3",
|
||||||
|
input_image=input_path,
|
||||||
|
model_used=results.get("model", "manual_landmarks"),
|
||||||
|
vertebrae_detected=results.get("vertebrae_detected", 0),
|
||||||
|
cobb_angles=CobbAngles(
|
||||||
|
PT=results["cobb_angles"]["PT"],
|
||||||
|
MT=results["cobb_angles"]["MT"],
|
||||||
|
TL=results["cobb_angles"]["TL"],
|
||||||
|
),
|
||||||
|
curve_type=results["curve_type"],
|
||||||
|
rigo_classification=RigoClassification(
|
||||||
|
type=results["rigo_type"],
|
||||||
|
description=results.get("rigo_description", "")
|
||||||
|
),
|
||||||
|
mesh_vertices=results.get("mesh_vertices", 0),
|
||||||
|
mesh_faces=results.get("mesh_faces", 0),
|
||||||
|
deformation_report=deformation_report,
|
||||||
|
outputs=outputs,
|
||||||
|
processing_time_ms=0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_experiment_3_with_landmarks(
|
||||||
|
self,
|
||||||
|
input_path: str,
|
||||||
|
output_base: Path,
|
||||||
|
pipeline,
|
||||||
|
landmarks_data: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Run experiment 3 brace generation using pre-computed landmarks.
|
||||||
|
Uses final_values from landmarks_data (which may include manual edits).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Load analysis modules from brace_generator root
|
||||||
|
from brace_generator.data_models import Spine2D, VertebraLandmark
|
||||||
|
from brace_generator.spine_analysis import compute_cobb_angles, find_apex_vertebrae, classify_rigo_type, get_curve_severity
|
||||||
|
from image_loader import load_xray_rgb
|
||||||
|
|
||||||
|
# Load the image for visualization
|
||||||
|
image_rgb, pixel_spacing = load_xray_rgb(input_path)
|
||||||
|
|
||||||
|
# Build Spine2D from landmarks_data final_values
|
||||||
|
vertebrae_structure = landmarks_data.get("vertebrae_structure", landmarks_data)
|
||||||
|
vertebrae_list = vertebrae_structure.get("vertebrae", [])
|
||||||
|
|
||||||
|
spine = Spine2D()
|
||||||
|
for vdata in vertebrae_list:
|
||||||
|
final = vdata.get("final_values", {})
|
||||||
|
centroid = final.get("centroid_px")
|
||||||
|
|
||||||
|
if centroid is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
v = VertebraLandmark(
|
||||||
|
level=vdata.get("level"),
|
||||||
|
centroid_px=np.array(centroid, dtype=np.float32),
|
||||||
|
confidence=float(final.get("confidence", 0.5))
|
||||||
|
)
|
||||||
|
|
||||||
|
corners = final.get("corners_px")
|
||||||
|
if corners:
|
||||||
|
v.corners_px = np.array(corners, dtype=np.float32)
|
||||||
|
|
||||||
|
spine.vertebrae.append(v)
|
||||||
|
|
||||||
|
if len(spine.vertebrae) < 3:
|
||||||
|
raise ValueError("Need at least 3 vertebrae for brace generation")
|
||||||
|
|
||||||
|
spine.pixel_spacing_mm = pixel_spacing
|
||||||
|
spine.image_shape = image_rgb.shape[:2]
|
||||||
|
spine.sort_vertebrae()
|
||||||
|
|
||||||
|
# Compute Cobb angles and classification
|
||||||
|
compute_cobb_angles(spine)
|
||||||
|
apex_indices = find_apex_vertebrae(spine)
|
||||||
|
rigo_result = classify_rigo_type(spine)
|
||||||
|
|
||||||
|
# Generate adaptive brace using the pipeline's brace generator directly
|
||||||
|
# This uses our manually-edited spine instead of re-detecting
|
||||||
|
brace_mesh = pipeline.brace_generator.generate(spine)
|
||||||
|
|
||||||
|
mesh_vertices = 0
|
||||||
|
mesh_faces = 0
|
||||||
|
output_stl = None
|
||||||
|
output_ply = None
|
||||||
|
deformation_report = None
|
||||||
|
|
||||||
|
if brace_mesh is not None:
|
||||||
|
mesh_vertices = len(brace_mesh.vertices)
|
||||||
|
mesh_faces = len(brace_mesh.faces)
|
||||||
|
|
||||||
|
# Get deformation report if available
|
||||||
|
if hasattr(pipeline.brace_generator, 'get_deformation_report'):
|
||||||
|
deformation_report = pipeline.brace_generator.get_deformation_report()
|
||||||
|
|
||||||
|
# Export STL and PLY
|
||||||
|
output_base_path = Path(output_base)
|
||||||
|
output_stl = str(output_base_path.with_suffix('.stl'))
|
||||||
|
output_ply = str(output_base_path.with_suffix('.ply'))
|
||||||
|
|
||||||
|
brace_mesh.export(output_stl)
|
||||||
|
|
||||||
|
# Export PLY if method available
|
||||||
|
if hasattr(pipeline.brace_generator, 'export_ply'):
|
||||||
|
pipeline.brace_generator.export_ply(brace_mesh, output_ply)
|
||||||
|
else:
|
||||||
|
brace_mesh.export(output_ply)
|
||||||
|
print(f" Exported: {output_stl}, {output_ply}")
|
||||||
|
|
||||||
|
# Save visualization with the manual/combined landmarks and deformation heatmap
|
||||||
|
vis_path = str(output_base) + ".png"
|
||||||
|
self._save_landmarks_visualization_with_spine(
|
||||||
|
image_rgb, spine, rigo_result, deformation_report, vis_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build result dict
|
||||||
|
result = {
|
||||||
|
"model": "manual_landmarks",
|
||||||
|
"vertebrae_detected": len(spine.vertebrae),
|
||||||
|
"cobb_angles": {
|
||||||
|
"PT": float(spine.cobb_pt or 0),
|
||||||
|
"MT": float(spine.cobb_mt or 0),
|
||||||
|
"TL": float(spine.cobb_tl or 0),
|
||||||
|
},
|
||||||
|
"curve_type": spine.curve_type or "Unknown",
|
||||||
|
"rigo_type": rigo_result["rigo_type"],
|
||||||
|
"rigo_description": rigo_result.get("description", ""),
|
||||||
|
"mesh_vertices": mesh_vertices,
|
||||||
|
"mesh_faces": mesh_faces,
|
||||||
|
"output_stl": output_stl,
|
||||||
|
"output_ply": output_ply,
|
||||||
|
"deformation_report": deformation_report,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save landmarks JSON
|
||||||
|
json_path = str(output_base) + ".json"
|
||||||
|
with open(json_path, "w") as f:
|
||||||
|
json.dump({
|
||||||
|
"source": "manual_landmarks",
|
||||||
|
"vertebrae_count": len(spine.vertebrae),
|
||||||
|
"cobb_angles": result["cobb_angles"],
|
||||||
|
"rigo_type": result["rigo_type"],
|
||||||
|
"curve_type": result["curve_type"],
|
||||||
|
"deformation_report": deformation_report,
|
||||||
|
}, f, indent=2, default=lambda x: x.tolist() if hasattr(x, 'tolist') else x)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _save_landmarks_visualization_with_spine(self, image, spine, rigo_result, deformation_report, path):
|
||||||
|
"""Save visualization using a pre-built Spine2D object with deformation heatmap."""
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from matplotlib.colors import TwoSlopeNorm
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3-panel layout like the original pipeline
|
||||||
|
fig, axes = plt.subplots(1, 3, figsize=(18, 10))
|
||||||
|
|
||||||
|
# Left: landmarks with X-shaped markers
|
||||||
|
ax1 = axes[0]
|
||||||
|
ax1.imshow(image)
|
||||||
|
|
||||||
|
# Draw green X-shaped vertebra markers and red centroids
|
||||||
|
for v in spine.vertebrae:
|
||||||
|
if v.corners_px is not None:
|
||||||
|
corners = v.corners_px
|
||||||
|
for i in range(4):
|
||||||
|
j = (i + 1) % 4
|
||||||
|
ax1.plot([corners[i, 0], corners[j, 0]],
|
||||||
|
[corners[i, 1], corners[j, 1]],
|
||||||
|
'g-', linewidth=1.5, zorder=4)
|
||||||
|
|
||||||
|
if v.centroid_px is not None:
|
||||||
|
ax1.scatter(v.centroid_px[0], v.centroid_px[1], c='red', s=40, zorder=5)
|
||||||
|
|
||||||
|
# Add labels
|
||||||
|
for v in spine.vertebrae:
|
||||||
|
if v.centroid_px is not None:
|
||||||
|
label = v.level or "?"
|
||||||
|
ax1.annotate(
|
||||||
|
label, (v.centroid_px[0] + 8, v.centroid_px[1]),
|
||||||
|
fontsize=7, color='yellow', fontweight='bold',
|
||||||
|
bbox=dict(boxstyle='round,pad=0.2', facecolor='black', alpha=0.6)
|
||||||
|
)
|
||||||
|
|
||||||
|
ax1.set_title(f"Landmarks ({len(spine.vertebrae)} vertebrae)")
|
||||||
|
ax1.axis('off')
|
||||||
|
|
||||||
|
# Middle: analysis with spine curve
|
||||||
|
ax2 = axes[1]
|
||||||
|
ax2.imshow(image, alpha=0.5)
|
||||||
|
|
||||||
|
# Draw spine curve line through centroids
|
||||||
|
centroids = [v.centroid_px for v in spine.vertebrae if v.centroid_px is not None]
|
||||||
|
if len(centroids) > 1:
|
||||||
|
centroids_arr = np.array(centroids)
|
||||||
|
ax2.plot(centroids_arr[:, 0], centroids_arr[:, 1], 'b-', linewidth=2, alpha=0.8)
|
||||||
|
|
||||||
|
text = f"Cobb Angles:\n"
|
||||||
|
text += f"PT: {spine.cobb_pt:.1f}°\n"
|
||||||
|
text += f"MT: {spine.cobb_mt:.1f}°\n"
|
||||||
|
text += f"TL: {spine.cobb_tl:.1f}°\n\n"
|
||||||
|
text += f"Curve: {spine.curve_type}\n"
|
||||||
|
text += f"Rigo: {rigo_result['rigo_type']}"
|
||||||
|
|
||||||
|
ax2.text(0.02, 0.98, text, transform=ax2.transAxes, fontsize=10,
|
||||||
|
verticalalignment='top', bbox=dict(facecolor='white', alpha=0.8))
|
||||||
|
ax2.set_title("Spine Analysis")
|
||||||
|
ax2.axis('off')
|
||||||
|
|
||||||
|
# Right: deformation heatmap
|
||||||
|
ax3 = axes[2]
|
||||||
|
|
||||||
|
if deformation_report and deformation_report.get('deformations'):
|
||||||
|
deform_array = np.array(deformation_report['deformations'])
|
||||||
|
|
||||||
|
# Create heatmap with diverging colormap
|
||||||
|
vmax = max(abs(deform_array.min()), abs(deform_array.max()), 1)
|
||||||
|
norm = TwoSlopeNorm(vmin=-vmax, vcenter=0, vmax=vmax)
|
||||||
|
|
||||||
|
im = ax3.imshow(deform_array, cmap='RdBu_r', aspect='auto',
|
||||||
|
norm=norm, origin='upper')
|
||||||
|
|
||||||
|
# Add colorbar
|
||||||
|
cbar = plt.colorbar(im, ax=ax3, shrink=0.8)
|
||||||
|
cbar.set_label('Radial deformation (mm)')
|
||||||
|
|
||||||
|
# Labels
|
||||||
|
ax3.set_xlabel('Angular Position (patches)')
|
||||||
|
ax3.set_ylabel('Height (patches)')
|
||||||
|
ax3.set_title('Patch Deformations (mm)\nBlue=Relief, Red=Pressure')
|
||||||
|
|
||||||
|
# Add zone labels on y-axis
|
||||||
|
height_labels = ['Pelvis', 'Low Lumb', 'Up Lumb', 'Low Thor', 'Up Thor', 'Shoulder']
|
||||||
|
if deform_array.shape[0] <= len(height_labels):
|
||||||
|
ax3.set_yticks(range(deform_array.shape[0]))
|
||||||
|
ax3.set_yticklabels(height_labels[:deform_array.shape[0]])
|
||||||
|
|
||||||
|
# Angular position labels
|
||||||
|
angle_labels = ['BR', 'R', 'FR', 'F', 'FL', 'L', 'BL', 'B']
|
||||||
|
if deform_array.shape[1] <= len(angle_labels):
|
||||||
|
ax3.set_xticks(range(deform_array.shape[1]))
|
||||||
|
ax3.set_xticklabels(angle_labels[:deform_array.shape[1]])
|
||||||
|
else:
|
||||||
|
ax3.text(0.5, 0.5, 'No deformation data', ha='center', va='center',
|
||||||
|
transform=ax3.transAxes, fontsize=14, color='gray')
|
||||||
|
ax3.set_title('Patch Deformations')
|
||||||
|
ax3.axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(path, dpi=150, bbox_inches='tight')
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# NEW METHODS FOR PIPELINE DEV
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
async def detect_landmarks_only(
|
||||||
|
self,
|
||||||
|
image_data: bytes,
|
||||||
|
filename: str,
|
||||||
|
case_id: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Detect landmarks only, without generating a brace.
|
||||||
|
Returns full vertebrae_structure with manual_override support.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
case_id = case_id or str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
# Save image to temp file
|
||||||
|
suffix = Path(filename).suffix or ".jpg"
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
||||||
|
f.write(image_data)
|
||||||
|
input_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Import from brace_generator root (modules are already in PYTHONPATH)
|
||||||
|
# Note: In Docker, PYTHONPATH includes /app/brace_generator
|
||||||
|
from image_loader import load_xray_rgb
|
||||||
|
from adapters import ScolioVisAdapter
|
||||||
|
from spine_analysis import compute_cobb_angles, find_apex_vertebrae, classify_rigo_type, get_curve_severity
|
||||||
|
from data_models import Spine2D
|
||||||
|
|
||||||
|
# Load full image
|
||||||
|
image_rgb_full, pixel_spacing = load_xray_rgb(input_path)
|
||||||
|
image_h, image_w = image_rgb_full.shape[:2]
|
||||||
|
|
||||||
|
# Smart cropping: Only crop to middle 1/3 if image is wide enough
|
||||||
|
# Wide images (e.g., full chest X-rays) benefit from cropping to spine area
|
||||||
|
# Narrow images (e.g., already cropped to spine) should not be cropped further
|
||||||
|
MIN_WIDTH_FOR_CROPPING = 500 # Only crop if wider than 500 pixels
|
||||||
|
CROPPED_MIN_WIDTH = 200 # Ensure cropped width is at least 200 pixels
|
||||||
|
|
||||||
|
left_margin = 0 # Initialize offset for coordinate mapping
|
||||||
|
|
||||||
|
if image_w >= MIN_WIDTH_FOR_CROPPING:
|
||||||
|
# Image is wide - crop to middle 1/3 for better spine detection
|
||||||
|
left_margin = image_w // 3
|
||||||
|
right_margin = 2 * image_w // 3
|
||||||
|
cropped_width = right_margin - left_margin
|
||||||
|
|
||||||
|
# Ensure minimum width after cropping
|
||||||
|
if cropped_width >= CROPPED_MIN_WIDTH:
|
||||||
|
image_rgb_for_detection = image_rgb_full[:, left_margin:right_margin]
|
||||||
|
print(f"[SpineCrop] Full image: {image_w}x{image_h}, Cropped to middle 1/3: {cropped_width}x{image_h}")
|
||||||
|
else:
|
||||||
|
# Cropped would be too narrow, use full image
|
||||||
|
image_rgb_for_detection = image_rgb_full
|
||||||
|
left_margin = 0
|
||||||
|
print(f"[SpineCrop] Full image: {image_w}x{image_h}, Cropped would be too narrow ({cropped_width}px), using full image")
|
||||||
|
else:
|
||||||
|
# Image is already narrow - use full image
|
||||||
|
image_rgb_for_detection = image_rgb_full
|
||||||
|
print(f"[SpineCrop] Full image: {image_w}x{image_h}, Already narrow (< {MIN_WIDTH_FOR_CROPPING}px), using full image")
|
||||||
|
|
||||||
|
# Detect landmarks (on cropped or full image depending on width)
|
||||||
|
adapter = ScolioVisAdapter(device=self.device)
|
||||||
|
spine = adapter.predict(image_rgb_for_detection)
|
||||||
|
spine.pixel_spacing_mm = pixel_spacing
|
||||||
|
|
||||||
|
# Offset all detected coordinates back to full image space if cropping was applied
|
||||||
|
if left_margin > 0:
|
||||||
|
for v in spine.vertebrae:
|
||||||
|
if v.centroid_px is not None:
|
||||||
|
# Offset centroid X coordinate
|
||||||
|
v.centroid_px[0] += left_margin
|
||||||
|
if v.corners_px is not None:
|
||||||
|
# Offset all corner X coordinates
|
||||||
|
v.corners_px[:, 0] += left_margin
|
||||||
|
|
||||||
|
# Keep reference to full image for visualization
|
||||||
|
image_rgb = image_rgb_full
|
||||||
|
|
||||||
|
# Compute analysis
|
||||||
|
compute_cobb_angles(spine)
|
||||||
|
apex_indices = find_apex_vertebrae(spine)
|
||||||
|
rigo_result = classify_rigo_type(spine)
|
||||||
|
|
||||||
|
# Prepare output directory
|
||||||
|
output_dir = config.TEMP_DIR / case_id
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save visualization
|
||||||
|
vis_path = output_dir / "visualization.png"
|
||||||
|
self._save_landmarks_visualization(image_rgb, spine, rigo_result, str(vis_path))
|
||||||
|
|
||||||
|
# Build full vertebrae structure (all T1-L5)
|
||||||
|
ALL_LEVELS = ["T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10", "T11", "T12", "L1", "L2", "L3", "L4", "L5"]
|
||||||
|
|
||||||
|
# ScolioVis doesn't assign levels - assign based on Y position (top to bottom)
|
||||||
|
# Sort detected vertebrae by Y coordinate (centroid)
|
||||||
|
detected_verts = sorted(
|
||||||
|
[v for v in spine.vertebrae if v.centroid_px is not None],
|
||||||
|
key=lambda v: v.centroid_px[1] # Sort by Y (top to bottom)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assign levels based on count
|
||||||
|
# If we detect 17 vertebrae, assign T1-L5
|
||||||
|
# If fewer, we need to figure out which ones are missing
|
||||||
|
num_detected = len(detected_verts)
|
||||||
|
|
||||||
|
if num_detected >= 17:
|
||||||
|
# All vertebrae detected - assign directly
|
||||||
|
for i, v in enumerate(detected_verts[:17]):
|
||||||
|
v.level = ALL_LEVELS[i]
|
||||||
|
elif num_detected > 0:
|
||||||
|
# Fewer than 17 - assign from T1 onwards (assuming top vertebrae visible)
|
||||||
|
# This is a simplification - ideally we'd use anatomical features
|
||||||
|
for i, v in enumerate(detected_verts):
|
||||||
|
if i < len(ALL_LEVELS):
|
||||||
|
v.level = ALL_LEVELS[i]
|
||||||
|
|
||||||
|
# Build detected_map with assigned levels
|
||||||
|
detected_map = {v.level: v for v in detected_verts if v.level}
|
||||||
|
|
||||||
|
vertebrae_list = []
|
||||||
|
for level in ALL_LEVELS:
|
||||||
|
if level in detected_map:
|
||||||
|
v = detected_map[level]
|
||||||
|
centroid = v.centroid_px.tolist() if v.centroid_px is not None else None
|
||||||
|
corners = v.corners_px.tolist() if v.corners_px is not None else None
|
||||||
|
orientation = float(v.compute_orientation()) if centroid else None
|
||||||
|
|
||||||
|
vertebrae_list.append({
|
||||||
|
"level": level,
|
||||||
|
"detected": True,
|
||||||
|
"scoliovis_data": {
|
||||||
|
"centroid_px": centroid,
|
||||||
|
"corners_px": corners,
|
||||||
|
"orientation_deg": orientation,
|
||||||
|
"confidence": float(v.confidence),
|
||||||
|
},
|
||||||
|
"manual_override": {
|
||||||
|
"enabled": False,
|
||||||
|
"centroid_px": None,
|
||||||
|
"corners_px": None,
|
||||||
|
"orientation_deg": None,
|
||||||
|
"confidence": None,
|
||||||
|
"notes": None,
|
||||||
|
},
|
||||||
|
"final_values": {
|
||||||
|
"centroid_px": centroid,
|
||||||
|
"corners_px": corners,
|
||||||
|
"orientation_deg": orientation,
|
||||||
|
"confidence": float(v.confidence),
|
||||||
|
"source": "scoliovis",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
vertebrae_list.append({
|
||||||
|
"level": level,
|
||||||
|
"detected": False,
|
||||||
|
"scoliovis_data": {
|
||||||
|
"centroid_px": None,
|
||||||
|
"corners_px": None,
|
||||||
|
"orientation_deg": None,
|
||||||
|
"confidence": 0.0,
|
||||||
|
},
|
||||||
|
"manual_override": {
|
||||||
|
"enabled": False,
|
||||||
|
"centroid_px": None,
|
||||||
|
"corners_px": None,
|
||||||
|
"orientation_deg": None,
|
||||||
|
"confidence": None,
|
||||||
|
"notes": None,
|
||||||
|
},
|
||||||
|
"final_values": {
|
||||||
|
"centroid_px": None,
|
||||||
|
"corners_px": None,
|
||||||
|
"orientation_deg": None,
|
||||||
|
"confidence": 0.0,
|
||||||
|
"source": "undetected",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build result
|
||||||
|
result = {
|
||||||
|
"case_id": case_id,
|
||||||
|
"status": "landmarks_detected",
|
||||||
|
"input": {
|
||||||
|
"image_dimensions": {"width": image_w, "height": image_h},
|
||||||
|
"pixel_spacing_mm": pixel_spacing,
|
||||||
|
},
|
||||||
|
"detection_quality": {
|
||||||
|
"vertebrae_count": len(spine.vertebrae),
|
||||||
|
"average_confidence": float(np.mean([v.confidence for v in spine.vertebrae])) if spine.vertebrae else 0.0,
|
||||||
|
},
|
||||||
|
"cobb_angles": {
|
||||||
|
"PT": float(spine.cobb_pt),
|
||||||
|
"MT": float(spine.cobb_mt),
|
||||||
|
"TL": float(spine.cobb_tl),
|
||||||
|
"max": float(max(spine.cobb_pt, spine.cobb_mt, spine.cobb_tl)),
|
||||||
|
"PT_severity": get_curve_severity(spine.cobb_pt),
|
||||||
|
"MT_severity": get_curve_severity(spine.cobb_mt),
|
||||||
|
"TL_severity": get_curve_severity(spine.cobb_tl),
|
||||||
|
},
|
||||||
|
"rigo_classification": {
|
||||||
|
"type": rigo_result["rigo_type"],
|
||||||
|
"description": rigo_result["description"],
|
||||||
|
},
|
||||||
|
"curve_type": spine.curve_type,
|
||||||
|
"vertebrae_structure": {
|
||||||
|
"all_levels": ALL_LEVELS,
|
||||||
|
"detected_count": len(spine.vertebrae),
|
||||||
|
"total_count": len(ALL_LEVELS),
|
||||||
|
"vertebrae": vertebrae_list,
|
||||||
|
"manual_edit_instructions": {
|
||||||
|
"to_override": "Set manual_override.enabled=true and fill manual_override fields",
|
||||||
|
"final_values_rule": "When manual_override.enabled=true, final_values uses manual values",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"visualization_path": str(vis_path),
|
||||||
|
"processing_time_ms": (time.time() - start_time) * 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save JSON
|
||||||
|
json_path = output_dir / "landmarks.json"
|
||||||
|
import json
|
||||||
|
with open(json_path, "w") as f:
|
||||||
|
json.dump(result, f, indent=2)
|
||||||
|
|
||||||
|
result["json_path"] = str(json_path)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
finally:
|
||||||
|
Path(input_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _save_landmarks_visualization(self, image, spine, rigo_result, path):
|
||||||
|
"""Save visualization with landmarks and green quadrilateral boxes."""
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(1, 2, figsize=(14, 10))
|
||||||
|
|
||||||
|
# Left: landmarks with green boxes
|
||||||
|
ax1 = axes[0]
|
||||||
|
ax1.imshow(image)
|
||||||
|
|
||||||
|
# Draw green X-shaped vertebra markers and red centroids
|
||||||
|
for v in spine.vertebrae:
|
||||||
|
# Draw green X-shape if corners exist
|
||||||
|
# Corner order: [0]=top_left, [1]=top_right, [2]=bottom_left, [3]=bottom_right
|
||||||
|
# Drawing 0→1→2→3→0 creates the X pattern showing endplate orientations
|
||||||
|
if v.corners_px is not None:
|
||||||
|
corners = v.corners_px
|
||||||
|
for i in range(4):
|
||||||
|
j = (i + 1) % 4
|
||||||
|
ax1.plot([corners[i, 0], corners[j, 0]],
|
||||||
|
[corners[i, 1], corners[j, 1]],
|
||||||
|
'g-', linewidth=1.5, zorder=4)
|
||||||
|
|
||||||
|
# Draw red centroid dot
|
||||||
|
if v.centroid_px is not None:
|
||||||
|
ax1.scatter(v.centroid_px[0], v.centroid_px[1], c='red', s=40, zorder=5)
|
||||||
|
|
||||||
|
# Add labels
|
||||||
|
for i, v in enumerate(spine.vertebrae):
|
||||||
|
if v.centroid_px is not None:
|
||||||
|
label = v.level or str(i)
|
||||||
|
ax1.annotate(
|
||||||
|
label, (v.centroid_px[0] + 8, v.centroid_px[1]),
|
||||||
|
fontsize=7, color='yellow', fontweight='bold',
|
||||||
|
bbox=dict(boxstyle='round,pad=0.2', facecolor='black', alpha=0.6)
|
||||||
|
)
|
||||||
|
|
||||||
|
ax1.set_title(f"Automatic Detection ({len(spine.vertebrae)} vertebrae)")
|
||||||
|
ax1.axis('off')
|
||||||
|
|
||||||
|
# Right: analysis
|
||||||
|
ax2 = axes[1]
|
||||||
|
ax2.imshow(image, alpha=0.5)
|
||||||
|
|
||||||
|
text = f"Cobb Angles:\n"
|
||||||
|
text += f"PT: {spine.cobb_pt:.1f}°\n"
|
||||||
|
text += f"MT: {spine.cobb_mt:.1f}°\n"
|
||||||
|
text += f"TL: {spine.cobb_tl:.1f}°\n\n"
|
||||||
|
text += f"Curve: {spine.curve_type}\n"
|
||||||
|
text += f"Rigo: {rigo_result['rigo_type']}"
|
||||||
|
|
||||||
|
ax2.text(0.02, 0.98, text, transform=ax2.transAxes, fontsize=10,
|
||||||
|
verticalalignment='top', bbox=dict(facecolor='white', alpha=0.8))
|
||||||
|
ax2.set_title("Spine Analysis")
|
||||||
|
ax2.axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(path, dpi=150, bbox_inches='tight')
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
async def recalculate_from_landmarks(
|
||||||
|
self,
|
||||||
|
landmarks_data: Dict[str, Any],
|
||||||
|
case_id: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Recalculate Cobb angles and Rigo classification from landmarks data.
|
||||||
|
Uses final_values from each vertebra (which may be manual overrides).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
case_id = case_id or str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
# Load analysis modules from brace_generator root
|
||||||
|
from brace_generator.data_models import Spine2D, VertebraLandmark
|
||||||
|
from brace_generator.spine_analysis import compute_cobb_angles, find_apex_vertebrae, classify_rigo_type, get_curve_severity
|
||||||
|
|
||||||
|
# Reconstruct spine from landmarks data
|
||||||
|
vertebrae_structure = landmarks_data.get("vertebrae_structure", landmarks_data)
|
||||||
|
vertebrae_list = vertebrae_structure.get("vertebrae", [])
|
||||||
|
|
||||||
|
# Build Spine2D from final_values
|
||||||
|
spine = Spine2D()
|
||||||
|
for vdata in vertebrae_list:
|
||||||
|
final = vdata.get("final_values", {})
|
||||||
|
centroid = final.get("centroid_px")
|
||||||
|
|
||||||
|
if centroid is None:
|
||||||
|
continue # Skip undetected/empty vertebrae
|
||||||
|
|
||||||
|
v = VertebraLandmark(
|
||||||
|
level=vdata.get("level"),
|
||||||
|
centroid_px=np.array(centroid, dtype=np.float32),
|
||||||
|
confidence=float(final.get("confidence", 0.5))
|
||||||
|
)
|
||||||
|
|
||||||
|
corners = final.get("corners_px")
|
||||||
|
if corners:
|
||||||
|
v.corners_px = np.array(corners, dtype=np.float32)
|
||||||
|
|
||||||
|
spine.vertebrae.append(v)
|
||||||
|
|
||||||
|
if len(spine.vertebrae) < 3:
|
||||||
|
raise ValueError("Need at least 3 vertebrae for analysis")
|
||||||
|
|
||||||
|
# Sort by Y position (top to bottom)
|
||||||
|
spine.sort_vertebrae()
|
||||||
|
|
||||||
|
# Compute Cobb angles and Rigo
|
||||||
|
compute_cobb_angles(spine)
|
||||||
|
apex_indices = find_apex_vertebrae(spine)
|
||||||
|
rigo_result = classify_rigo_type(spine)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"case_id": case_id,
|
||||||
|
"status": "analysis_recalculated",
|
||||||
|
"cobb_angles": {
|
||||||
|
"PT": float(spine.cobb_pt),
|
||||||
|
"MT": float(spine.cobb_mt),
|
||||||
|
"TL": float(spine.cobb_tl),
|
||||||
|
"max": float(max(spine.cobb_pt, spine.cobb_mt, spine.cobb_tl)),
|
||||||
|
"PT_severity": get_curve_severity(spine.cobb_pt),
|
||||||
|
"MT_severity": get_curve_severity(spine.cobb_mt),
|
||||||
|
"TL_severity": get_curve_severity(spine.cobb_tl),
|
||||||
|
},
|
||||||
|
"rigo_classification": {
|
||||||
|
"type": rigo_result["rigo_type"],
|
||||||
|
"description": rigo_result["description"],
|
||||||
|
},
|
||||||
|
"curve_type": spine.curve_type,
|
||||||
|
"apex_indices": apex_indices,
|
||||||
|
"vertebrae_used": len(spine.vertebrae),
|
||||||
|
"processing_time_ms": (time.time() - start_time) * 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
456
brace-generator/simple_server.py
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
"""
|
||||||
|
Simple FastAPI server for brace generation - CPU optimized.
|
||||||
|
Designed to work standalone with minimal dependencies.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
os.environ['CUDA_VISIBLE_DEVICES'] = '' # Force CPU
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
import torch
|
||||||
|
import trimesh
|
||||||
|
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse, FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
MODELS_DIR = BASE_DIR / "models"
|
||||||
|
OUTPUTS_DIR = BASE_DIR / "outputs"
|
||||||
|
TEMPLATES_DIR = BASE_DIR / "templates"
|
||||||
|
|
||||||
|
OUTPUTS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Global model (loaded once)
|
||||||
|
model = None
|
||||||
|
model_loaded = False
|
||||||
|
|
||||||
|
|
||||||
|
def get_kprcnn_model():
|
||||||
|
"""Load Keypoint RCNN model."""
|
||||||
|
from torchvision.models.detection.rpn import AnchorGenerator
|
||||||
|
import torchvision
|
||||||
|
|
||||||
|
model_path = MODELS_DIR / "keypointsrcnn_weights.pt"
|
||||||
|
if not model_path.exists():
|
||||||
|
raise FileNotFoundError(f"Model not found: {model_path}")
|
||||||
|
|
||||||
|
num_keypoints = 4
|
||||||
|
anchor_generator = AnchorGenerator(
|
||||||
|
sizes=(32, 64, 128, 256, 512),
|
||||||
|
aspect_ratios=(0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0)
|
||||||
|
)
|
||||||
|
model = torchvision.models.detection.keypointrcnn_resnet50_fpn(
|
||||||
|
weights=None,
|
||||||
|
weights_backbone='IMAGENET1K_V1',
|
||||||
|
num_keypoints=num_keypoints,
|
||||||
|
num_classes=2,
|
||||||
|
rpn_anchor_generator=anchor_generator
|
||||||
|
)
|
||||||
|
|
||||||
|
state_dict = torch.load(model_path, map_location=torch.device('cpu'), weights_only=True)
|
||||||
|
model.load_state_dict(state_dict)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def predict_keypoints(model, image_rgb):
|
||||||
|
"""Run keypoint detection."""
|
||||||
|
from torchvision.transforms import functional as F
|
||||||
|
import torchvision
|
||||||
|
|
||||||
|
# Convert to tensor
|
||||||
|
image_tensor = F.to_tensor(image_rgb).unsqueeze(0)
|
||||||
|
|
||||||
|
# Inference
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model(image_tensor)
|
||||||
|
|
||||||
|
output = outputs[0]
|
||||||
|
|
||||||
|
# Filter results
|
||||||
|
scores = output['scores'].cpu().numpy()
|
||||||
|
high_scores_idxs = np.where(scores > 0.5)[0].tolist()
|
||||||
|
|
||||||
|
if not high_scores_idxs:
|
||||||
|
return [], [], []
|
||||||
|
|
||||||
|
post_nms_idxs = torchvision.ops.nms(
|
||||||
|
output['boxes'][high_scores_idxs],
|
||||||
|
output['scores'][high_scores_idxs],
|
||||||
|
0.3
|
||||||
|
).cpu().numpy()
|
||||||
|
|
||||||
|
np_keypoints = output['keypoints'][high_scores_idxs][post_nms_idxs].cpu().numpy()
|
||||||
|
np_bboxes = output['boxes'][high_scores_idxs][post_nms_idxs].cpu().numpy()
|
||||||
|
np_scores = scores[high_scores_idxs][post_nms_idxs]
|
||||||
|
|
||||||
|
# Sort by score, take top 18
|
||||||
|
sorted_idxs = np.argsort(-np_scores)[:18]
|
||||||
|
np_keypoints = np_keypoints[sorted_idxs]
|
||||||
|
np_bboxes = np_bboxes[sorted_idxs]
|
||||||
|
np_scores = np_scores[sorted_idxs]
|
||||||
|
|
||||||
|
# Sort by y position
|
||||||
|
ymins = np.array([kps[0][1] for kps in np_keypoints])
|
||||||
|
sorted_ymin_idxs = np.argsort(ymins)
|
||||||
|
|
||||||
|
np_keypoints = np_keypoints[sorted_ymin_idxs]
|
||||||
|
np_bboxes = np_bboxes[sorted_ymin_idxs]
|
||||||
|
np_scores = np_scores[sorted_ymin_idxs]
|
||||||
|
|
||||||
|
# Convert to list format
|
||||||
|
keypoints_list = [[list(map(float, kp[:2])) for kp in kps] for kps in np_keypoints]
|
||||||
|
bboxes_list = [list(map(int, bbox.tolist())) for bbox in np_bboxes]
|
||||||
|
scores_list = np_scores.tolist()
|
||||||
|
|
||||||
|
return bboxes_list, keypoints_list, scores_list
|
||||||
|
|
||||||
|
|
||||||
|
def compute_cobb_angles(keypoints):
|
||||||
|
"""Compute Cobb angles from keypoints."""
|
||||||
|
if len(keypoints) < 5:
|
||||||
|
return {"pt": 0, "mt": 0, "tl": 0}
|
||||||
|
|
||||||
|
# Calculate midpoints and angles
|
||||||
|
midpoints = []
|
||||||
|
angles = []
|
||||||
|
|
||||||
|
for kps in keypoints:
|
||||||
|
# kps is list of [x, y] for 4 corners
|
||||||
|
corners = np.array(kps)
|
||||||
|
|
||||||
|
# Top midpoint (average of corners 0 and 1)
|
||||||
|
top = (corners[0] + corners[1]) / 2
|
||||||
|
# Bottom midpoint (average of corners 2 and 3)
|
||||||
|
bottom = (corners[2] + corners[3]) / 2
|
||||||
|
|
||||||
|
midpoints.append((top, bottom))
|
||||||
|
|
||||||
|
# Vertebra angle
|
||||||
|
dx = bottom[0] - top[0]
|
||||||
|
dy = bottom[1] - top[1]
|
||||||
|
angle = np.degrees(np.arctan2(dx, dy))
|
||||||
|
angles.append(angle)
|
||||||
|
|
||||||
|
angles = np.array(angles)
|
||||||
|
|
||||||
|
# Find inflection points for curve regions
|
||||||
|
n = len(angles)
|
||||||
|
|
||||||
|
# Simple approach: divide into 3 regions
|
||||||
|
third = n // 3
|
||||||
|
|
||||||
|
pt_region = angles[:third] if third > 0 else angles[:2]
|
||||||
|
mt_region = angles[third:2*third] if 2*third > third else angles[2:4]
|
||||||
|
tl_region = angles[2*third:] if n > 2*third else angles[-2:]
|
||||||
|
|
||||||
|
# Cobb angle = difference between max and min tilt in region
|
||||||
|
pt_angle = float(np.max(pt_region) - np.min(pt_region)) if len(pt_region) > 1 else 0
|
||||||
|
mt_angle = float(np.max(mt_region) - np.min(mt_region)) if len(mt_region) > 1 else 0
|
||||||
|
tl_angle = float(np.max(tl_region) - np.min(tl_region)) if len(tl_region) > 1 else 0
|
||||||
|
|
||||||
|
return {"pt": pt_angle, "mt": mt_angle, "tl": tl_angle}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_rigo_type(cobb_angles):
|
||||||
|
"""Classify Rigo-Chêneau type based on Cobb angles."""
|
||||||
|
pt = abs(cobb_angles['pt'])
|
||||||
|
mt = abs(cobb_angles['mt'])
|
||||||
|
tl = abs(cobb_angles['tl'])
|
||||||
|
|
||||||
|
max_angle = max(pt, mt, tl)
|
||||||
|
|
||||||
|
if max_angle < 10:
|
||||||
|
return "Normal"
|
||||||
|
elif mt >= tl and mt >= pt:
|
||||||
|
if mt < 25:
|
||||||
|
return "A1"
|
||||||
|
elif mt < 40:
|
||||||
|
return "A2"
|
||||||
|
else:
|
||||||
|
return "A3"
|
||||||
|
elif tl >= mt:
|
||||||
|
if tl < 30:
|
||||||
|
return "C1"
|
||||||
|
else:
|
||||||
|
return "C2"
|
||||||
|
else:
|
||||||
|
return "B1"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_brace(rigo_type: str, case_id: str):
|
||||||
|
"""Load brace template and export."""
|
||||||
|
if rigo_type == "Normal":
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
template_path = TEMPLATES_DIR / f"{rigo_type}.obj"
|
||||||
|
if not template_path.exists():
|
||||||
|
# Try fallback templates
|
||||||
|
fallback = {"A1": "A2", "A2": "A1", "A3": "A2", "C1": "C2", "C2": "C1", "B1": "A1", "B2": "A1"}
|
||||||
|
if rigo_type in fallback:
|
||||||
|
template_path = TEMPLATES_DIR / f"{fallback[rigo_type]}.obj"
|
||||||
|
|
||||||
|
if not template_path.exists():
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
mesh = trimesh.load(str(template_path))
|
||||||
|
if isinstance(mesh, trimesh.Scene):
|
||||||
|
meshes = [g for g in mesh.geometry.values() if isinstance(g, trimesh.Trimesh)]
|
||||||
|
if meshes:
|
||||||
|
mesh = trimesh.util.concatenate(meshes)
|
||||||
|
else:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Export
|
||||||
|
case_dir = OUTPUTS_DIR / case_id
|
||||||
|
case_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
stl_path = case_dir / f"{case_id}_brace.stl"
|
||||||
|
mesh.export(str(stl_path))
|
||||||
|
|
||||||
|
return str(stl_path), mesh
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Load model on startup."""
|
||||||
|
global model, model_loaded
|
||||||
|
print("Loading ScolioVis model...")
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
model = get_kprcnn_model()
|
||||||
|
model_loaded = True
|
||||||
|
print(f"Model loaded in {time.time() - start:.1f}s")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load model: {e}")
|
||||||
|
model_loaded = False
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Brace Generator API",
|
||||||
|
description="CPU-based scoliosis brace generation",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisResult(BaseModel):
|
||||||
|
case_id: str
|
||||||
|
experiment: str = "experiment_4"
|
||||||
|
model_used: str = "ScolioVis"
|
||||||
|
vertebrae_detected: int
|
||||||
|
cobb_angles: dict
|
||||||
|
curve_type: str = "Unknown"
|
||||||
|
rigo_classification: dict
|
||||||
|
mesh_vertices: int = 0
|
||||||
|
mesh_faces: int = 0
|
||||||
|
timing_ms: float
|
||||||
|
outputs: dict
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"model_loaded": model_loaded,
|
||||||
|
"device": "CPU"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/analyze/upload", response_model=AnalysisResult)
|
||||||
|
async def analyze_upload(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
case_id: str = Form(None)
|
||||||
|
):
|
||||||
|
"""Analyze X-ray and generate brace."""
|
||||||
|
global model
|
||||||
|
|
||||||
|
if not model_loaded:
|
||||||
|
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Generate case ID if not provided
|
||||||
|
if not case_id:
|
||||||
|
case_id = f"case_{int(time.time())}"
|
||||||
|
|
||||||
|
# Save uploaded file
|
||||||
|
case_dir = OUTPUTS_DIR / case_id
|
||||||
|
case_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
input_path = case_dir / file.filename
|
||||||
|
with open(input_path, "wb") as f:
|
||||||
|
content = await file.read()
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# Load image
|
||||||
|
img = cv2.imread(str(input_path))
|
||||||
|
if img is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Could not read image")
|
||||||
|
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||||
|
|
||||||
|
# Detect keypoints
|
||||||
|
bboxes, keypoints, scores = predict_keypoints(model, img_rgb)
|
||||||
|
n_vertebrae = len(keypoints)
|
||||||
|
|
||||||
|
if n_vertebrae < 3:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Insufficient vertebrae detected: {n_vertebrae}")
|
||||||
|
|
||||||
|
# Compute Cobb angles
|
||||||
|
cobb_angles = compute_cobb_angles(keypoints)
|
||||||
|
|
||||||
|
# Classify Rigo type
|
||||||
|
rigo_type = classify_rigo_type(cobb_angles)
|
||||||
|
|
||||||
|
# Generate brace
|
||||||
|
stl_path, mesh = generate_brace(rigo_type, case_id)
|
||||||
|
|
||||||
|
outputs = {}
|
||||||
|
mesh_vertices = 0
|
||||||
|
mesh_faces = 0
|
||||||
|
|
||||||
|
if stl_path:
|
||||||
|
outputs["stl"] = stl_path
|
||||||
|
if mesh is not None:
|
||||||
|
mesh_vertices = len(mesh.vertices)
|
||||||
|
mesh_faces = len(mesh.faces)
|
||||||
|
|
||||||
|
# Determine curve type
|
||||||
|
curve_type = "Normal"
|
||||||
|
if cobb_angles['pt'] > 10 or cobb_angles['mt'] > 10 or cobb_angles['tl'] > 10:
|
||||||
|
if (cobb_angles['mt'] > 10 and cobb_angles['tl'] > 10) or (cobb_angles['pt'] > 10 and cobb_angles['tl'] > 10):
|
||||||
|
curve_type = "S-shaped"
|
||||||
|
else:
|
||||||
|
curve_type = "C-shaped"
|
||||||
|
|
||||||
|
# Rigo classification details
|
||||||
|
rigo_descriptions = {
|
||||||
|
"Normal": "No significant curve - normal spine",
|
||||||
|
"A1": "Main thoracic curve (mild) - 3C pattern",
|
||||||
|
"A2": "Main thoracic curve (moderate) - 3C pattern",
|
||||||
|
"A3": "Main thoracic curve (severe) - 3C pattern",
|
||||||
|
"B1": "Double curve (thoracic dominant) - 4C pattern",
|
||||||
|
"B2": "Double curve (lumbar dominant) - 4C pattern",
|
||||||
|
"C1": "Main thoracolumbar/lumbar (mild) - 4C pattern",
|
||||||
|
"C2": "Main thoracolumbar/lumbar (moderate-severe) - 4C pattern",
|
||||||
|
"E1": "Not structural - functional curve",
|
||||||
|
"E2": "Not structural - compensatory curve",
|
||||||
|
}
|
||||||
|
|
||||||
|
rigo_classification = {
|
||||||
|
"type": rigo_type,
|
||||||
|
"description": rigo_descriptions.get(rigo_type, "Unknown classification"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate visualization
|
||||||
|
vis_path = None
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(1, 1, figsize=(10, 12))
|
||||||
|
ax.imshow(img_rgb)
|
||||||
|
|
||||||
|
# Draw keypoints
|
||||||
|
for kps in keypoints:
|
||||||
|
corners = np.array(kps) # List of [x, y]
|
||||||
|
centroid = corners.mean(axis=0)
|
||||||
|
ax.scatter(centroid[0], centroid[1], c='red', s=50, zorder=5)
|
||||||
|
|
||||||
|
# Draw corners (vertebra outline)
|
||||||
|
for i in range(4):
|
||||||
|
j = (i + 1) % 4
|
||||||
|
ax.plot([corners[i][0], corners[j][0]],
|
||||||
|
[corners[i][1], corners[j][1]], 'g-', linewidth=1)
|
||||||
|
|
||||||
|
# Add text overlay
|
||||||
|
text = f"ScolioVis Analysis\n"
|
||||||
|
text += f"-" * 20 + "\n"
|
||||||
|
text += f"Vertebrae: {n_vertebrae}\n"
|
||||||
|
text += f"Cobb Angles:\n"
|
||||||
|
text += f" PT: {cobb_angles['pt']:.1f}°\n"
|
||||||
|
text += f" MT: {cobb_angles['mt']:.1f}°\n"
|
||||||
|
text += f" TL: {cobb_angles['tl']:.1f}°\n"
|
||||||
|
text += f"Curve: {curve_type}\n"
|
||||||
|
text += f"Rigo: {rigo_type}\n"
|
||||||
|
text += f"{rigo_classification['description']}"
|
||||||
|
|
||||||
|
ax.text(0.02, 0.98, text, transform=ax.transAxes, fontsize=10,
|
||||||
|
verticalalignment='top', fontfamily='monospace',
|
||||||
|
bbox=dict(facecolor='white', alpha=0.9))
|
||||||
|
|
||||||
|
ax.set_title(f"Case: {case_id}")
|
||||||
|
ax.axis('off')
|
||||||
|
|
||||||
|
vis_path = case_dir / f"{case_id}_visualization.png"
|
||||||
|
plt.savefig(str(vis_path), dpi=150, bbox_inches='tight')
|
||||||
|
plt.close()
|
||||||
|
outputs["visualization"] = str(vis_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Visualization failed: {e}")
|
||||||
|
|
||||||
|
# Save analysis JSON
|
||||||
|
analysis = {
|
||||||
|
"case_id": case_id,
|
||||||
|
"input_file": file.filename,
|
||||||
|
"experiment": "experiment_4",
|
||||||
|
"model_used": "ScolioVis",
|
||||||
|
"vertebrae_detected": n_vertebrae,
|
||||||
|
"cobb_angles": cobb_angles,
|
||||||
|
"curve_type": curve_type,
|
||||||
|
"rigo_type": rigo_type,
|
||||||
|
"rigo_classification": rigo_classification,
|
||||||
|
"keypoints": keypoints,
|
||||||
|
"bboxes": bboxes,
|
||||||
|
"scores": scores,
|
||||||
|
"mesh_vertices": mesh_vertices,
|
||||||
|
"mesh_faces": mesh_faces,
|
||||||
|
}
|
||||||
|
|
||||||
|
analysis_path = case_dir / f"{case_id}_analysis.json"
|
||||||
|
with open(analysis_path, "w") as f:
|
||||||
|
json.dump(analysis, f, indent=2)
|
||||||
|
outputs["analysis"] = str(analysis_path)
|
||||||
|
|
||||||
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
return AnalysisResult(
|
||||||
|
case_id=case_id,
|
||||||
|
experiment="experiment_4",
|
||||||
|
model_used="ScolioVis",
|
||||||
|
vertebrae_detected=n_vertebrae,
|
||||||
|
cobb_angles=cobb_angles,
|
||||||
|
curve_type=curve_type,
|
||||||
|
rigo_classification=rigo_classification,
|
||||||
|
mesh_vertices=mesh_vertices,
|
||||||
|
mesh_faces=mesh_faces,
|
||||||
|
timing_ms=elapsed_ms,
|
||||||
|
outputs=outputs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/download/{case_id}/{filename}")
|
||||||
|
async def download_file(case_id: str, filename: str):
|
||||||
|
"""Download generated file."""
|
||||||
|
file_path = OUTPUTS_DIR / case_id / filename
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
return FileResponse(str(file_path), filename=filename)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
138
docker-compose.yml
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# ============================================
|
||||||
|
# BraceIQMed - Docker Compose Configuration
|
||||||
|
# ============================================
|
||||||
|
# Usage:
|
||||||
|
# docker compose build # Build all images
|
||||||
|
# docker compose up -d # Start all services
|
||||||
|
# docker compose logs -f # View logs
|
||||||
|
# docker compose down # Stop all services
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ============================================
|
||||||
|
# Frontend - React + nginx
|
||||||
|
# ============================================
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: braceiqmed-frontend:latest
|
||||||
|
container_name: braceiqmed-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- braceiqmed-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# API - Node.js Express Server
|
||||||
|
# ============================================
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: braceiqmed-api:latest
|
||||||
|
container_name: braceiqmed-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3002
|
||||||
|
- BRACE_GENERATOR_URL=http://brace-generator:8002
|
||||||
|
- DATA_DIR=/app/data
|
||||||
|
- DB_PATH=/app/data/braceflow.db
|
||||||
|
volumes:
|
||||||
|
- braceiqmed-data:/app/data
|
||||||
|
depends_on:
|
||||||
|
brace-generator:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- braceiqmed-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Brace Generator - FastAPI + PyTorch (CPU)
|
||||||
|
# ============================================
|
||||||
|
brace-generator:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: brace-generator/Dockerfile
|
||||||
|
image: braceiqmed-brace-generator:latest
|
||||||
|
container_name: braceiqmed-brace-generator
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- PORT=8002
|
||||||
|
- DEVICE=cpu
|
||||||
|
- MODEL=scoliovis
|
||||||
|
- TEMP_DIR=/tmp/brace_generator
|
||||||
|
- CORS_ORIGINS=*
|
||||||
|
volumes:
|
||||||
|
- braceiqmed-data:/app/data
|
||||||
|
- braceiqmed-models:/app/models
|
||||||
|
networks:
|
||||||
|
- braceiqmed-net
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 8G
|
||||||
|
reservations:
|
||||||
|
memory: 4G
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Networks
|
||||||
|
# ============================================
|
||||||
|
networks:
|
||||||
|
braceiqmed-net:
|
||||||
|
driver: bridge
|
||||||
|
name: braceiqmed-net
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Volumes
|
||||||
|
# ============================================
|
||||||
|
volumes:
|
||||||
|
# Persistent data storage (SQLite, uploads, outputs)
|
||||||
|
braceiqmed-data:
|
||||||
|
driver: local
|
||||||
|
name: braceiqmed-data
|
||||||
|
|
||||||
|
# Model storage (ScolioVis weights)
|
||||||
|
braceiqmed-models:
|
||||||
|
driver: local
|
||||||
|
name: braceiqmed-models
|
||||||
80
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
# Node deps
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Environment files (sensitive)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# VCS and tooling
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor / IDE history and temp
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Test & coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
jest.cache
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
.cache/
|
||||||
|
.parcel-cache/
|
||||||
|
.pnp.*
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Serverless / local dev artifacts
|
||||||
|
.serverless/
|
||||||
|
.firebase/
|
||||||
|
|
||||||
|
# Packages / artifacts
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Optional: lockfiles should be committed (do NOT ignore package-lock.json / yarn.lock)
|
||||||
382
frontend/BRACE_GENERATOR_INTEGRATION.md
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
# Brace Generator Integration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes how the GPU-based brace generator is integrated into the Braceflow frontend via AWS Lambda functions.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend (React)
|
||||||
|
│
|
||||||
|
├── Upload X-ray
|
||||||
|
│ └── POST /cases → Create case
|
||||||
|
│ └── POST /cases/{caseId}/upload-url → Get presigned URL
|
||||||
|
│ └── PUT to S3 → Upload file directly
|
||||||
|
│
|
||||||
|
└── Generate Brace
|
||||||
|
└── POST /cases/{caseId}/generate-brace
|
||||||
|
└── Lambda: braceflow_invoke_brace_generator
|
||||||
|
└── Download image from S3
|
||||||
|
└── Call EC2 GPU server /analyze/upload
|
||||||
|
└── Download outputs from GPU server
|
||||||
|
└── Upload outputs to S3
|
||||||
|
└── Update database with results
|
||||||
|
└── Return results with presigned URLs
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. Create Case
|
||||||
|
```
|
||||||
|
POST /cases
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"caseId": "case-20260125-abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Get Upload URL
|
||||||
|
```
|
||||||
|
POST /cases/{caseId}/upload-url
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "ap.jpg",
|
||||||
|
"contentType": "image/jpeg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://braceflow-uploads-xxx.s3.amazonaws.com/...",
|
||||||
|
"s3Key": "cases/case-xxx/input/ap.jpg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Generate Brace
|
||||||
|
```
|
||||||
|
POST /cases/{caseId}/generate-brace
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experiment": "experiment_3",
|
||||||
|
"config": {
|
||||||
|
"brace_height_mm": 400,
|
||||||
|
"torso_width_mm": 280,
|
||||||
|
"torso_depth_mm": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"caseId": "case-20260125-abc123",
|
||||||
|
"status": "brace_generated",
|
||||||
|
"experiment": "experiment_3",
|
||||||
|
"model": "ScolioVis",
|
||||||
|
"vertebrae_detected": 17,
|
||||||
|
"cobb_angles": {
|
||||||
|
"PT": 12.5,
|
||||||
|
"MT": 28.3,
|
||||||
|
"TL": 15.2
|
||||||
|
},
|
||||||
|
"curve_type": "S-shaped",
|
||||||
|
"rigo_classification": {
|
||||||
|
"type": "A3",
|
||||||
|
"description": "Major thoracic with compensatory lumbar"
|
||||||
|
},
|
||||||
|
"mesh": {
|
||||||
|
"vertices": 6204,
|
||||||
|
"faces": 12404
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"stl": { "s3Key": "cases/.../brace.stl", "url": "https://..." },
|
||||||
|
"ply": { "s3Key": "cases/.../brace.ply", "url": "https://..." },
|
||||||
|
"visualization": { "s3Key": "cases/.../viz.png", "url": "https://..." },
|
||||||
|
"landmarks": { "s3Key": "cases/.../landmarks.json", "url": "https://..." }
|
||||||
|
},
|
||||||
|
"processing_time_ms": 3250
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Get Brace Outputs
|
||||||
|
```
|
||||||
|
GET /cases/{caseId}/brace-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"caseId": "case-20260125-abc123",
|
||||||
|
"status": "brace_generated",
|
||||||
|
"analysis": {
|
||||||
|
"experiment": "experiment_3",
|
||||||
|
"model": "ScolioVis",
|
||||||
|
"cobb_angles": { "PT": 12.5, "MT": 28.3, "TL": 15.2 },
|
||||||
|
"curve_type": "S-shaped",
|
||||||
|
"rigo_classification": { "type": "A3", "description": "..." }
|
||||||
|
},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"filename": "brace.stl",
|
||||||
|
"type": "stl",
|
||||||
|
"s3Key": "cases/.../brace.stl",
|
||||||
|
"size": 1234567,
|
||||||
|
"url": "https://...",
|
||||||
|
"expiresIn": 3600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Implementation
|
||||||
|
|
||||||
|
### API Client (`src/api/braceflowApi.ts`)
|
||||||
|
|
||||||
|
The API client includes these functions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create a new case
|
||||||
|
export async function createCase(body?: { notes?: string }): Promise<{ caseId: string }>;
|
||||||
|
|
||||||
|
// Get presigned URL for S3 upload
|
||||||
|
export async function getUploadUrl(caseId: string, filename: string, contentType: string):
|
||||||
|
Promise<{ url: string; s3Key: string }>;
|
||||||
|
|
||||||
|
// Upload file directly to S3
|
||||||
|
export async function uploadToS3(presignedUrl: string, file: File): Promise<void>;
|
||||||
|
|
||||||
|
// Invoke brace generator Lambda
|
||||||
|
export async function generateBrace(caseId: string, options?: {
|
||||||
|
experiment?: string;
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
}): Promise<GenerateBraceResponse>;
|
||||||
|
|
||||||
|
// Get brace outputs with presigned URLs
|
||||||
|
export async function getBraceOutputs(caseId: string): Promise<BraceOutputsResponse>;
|
||||||
|
|
||||||
|
// Full workflow helper
|
||||||
|
export async function analyzeXray(file: File, options?: {
|
||||||
|
experiment?: string;
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
}): Promise<{ caseId: string; result: GenerateBraceResponse }>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type CobbAngles = {
|
||||||
|
PT?: number;
|
||||||
|
MT?: number;
|
||||||
|
TL?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RigoClassification = {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
curve_pattern?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisResult = {
|
||||||
|
experiment?: string;
|
||||||
|
model?: string;
|
||||||
|
vertebrae_detected?: number;
|
||||||
|
cobb_angles?: CobbAngles;
|
||||||
|
curve_type?: string;
|
||||||
|
rigo_classification?: RigoClassification;
|
||||||
|
mesh_info?: { vertices?: number; faces?: number };
|
||||||
|
outputs?: Record<string, { s3Key: string; url: string }>;
|
||||||
|
processing_time_ms?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BraceOutput = {
|
||||||
|
filename: string;
|
||||||
|
type: "stl" | "ply" | "obj" | "image" | "json" | "other";
|
||||||
|
s3Key: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
expiresIn: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Page | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `/analyze` | BraceAnalysisPage | New analysis with X-ray upload |
|
||||||
|
| `/cases/:caseId/analysis` | BraceAnalysisPage | View existing case analysis |
|
||||||
|
| `/generate` | ShellGenerationPage | Direct brace generation (legacy) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page: BraceAnalysisPage
|
||||||
|
|
||||||
|
Located at `src/pages/BraceAnalysisPage.tsx`
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
1. **Upload Panel** - Drag-and-drop X-ray upload
|
||||||
|
2. **3D Viewer** - Interactive brace model preview
|
||||||
|
3. **Analysis Results** - Displays:
|
||||||
|
- Overall severity assessment
|
||||||
|
- Curve type classification
|
||||||
|
- Cobb angles (PT, MT, TL)
|
||||||
|
- Rigo-Chêneau classification
|
||||||
|
- Mesh information
|
||||||
|
4. **Downloads** - All generated files with presigned S3 URLs
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
Three-column layout:
|
||||||
|
- Left: Upload panel with case ID display
|
||||||
|
- Center: 3D brace viewer with processing info
|
||||||
|
- Right: Analysis results and download links
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Reusable Components
|
||||||
|
|
||||||
|
| Component | Location | Description |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `UploadPanel` | `src/components/rigo/UploadPanel.tsx` | Drag-and-drop file upload |
|
||||||
|
| `BraceViewer` | `src/components/rigo/BraceViewer.tsx` | 3D model viewer (React Three Fiber) |
|
||||||
|
| `AnalysisResults` | `src/components/rigo/AnalysisResults.tsx` | Analysis display component |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lambda Functions
|
||||||
|
|
||||||
|
### braceflow_invoke_brace_generator
|
||||||
|
|
||||||
|
Located at: `braceflow_lambda/braceflow_invoke_brace_generator/index.mjs`
|
||||||
|
|
||||||
|
**Process:**
|
||||||
|
1. Validate environment and request
|
||||||
|
2. Get case from database
|
||||||
|
3. Update status to `processing_brace`
|
||||||
|
4. Download X-ray from S3
|
||||||
|
5. Call GPU server `/analyze/upload`
|
||||||
|
6. Download outputs from GPU server `/download/{caseId}/{filename}`
|
||||||
|
7. Upload outputs to S3
|
||||||
|
8. Update database with analysis results
|
||||||
|
9. Return results with presigned URLs
|
||||||
|
|
||||||
|
**Environment Variables:**
|
||||||
|
- `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`
|
||||||
|
- `BRACE_GENERATOR_URL` - EC2 GPU server URL
|
||||||
|
- `BUCKET_NAME` - S3 bucket name
|
||||||
|
|
||||||
|
### braceflow_get_brace_outputs
|
||||||
|
|
||||||
|
Located at: `braceflow_lambda/braceflow_get_brace_outputs/index.mjs`
|
||||||
|
|
||||||
|
**Process:**
|
||||||
|
1. Get case from database
|
||||||
|
2. List files in S3 `cases/{caseId}/outputs/`
|
||||||
|
3. Generate presigned URLs for each file
|
||||||
|
4. Return files list with analysis data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S3 Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
braceflow-uploads-{date}/
|
||||||
|
├── cases/
|
||||||
|
│ └── {caseId}/
|
||||||
|
│ ├── input/
|
||||||
|
│ │ └── ap.jpg # Original X-ray
|
||||||
|
│ └── outputs/
|
||||||
|
│ ├── brace_{caseId}.stl # 3D printable model
|
||||||
|
│ ├── brace_{caseId}_adaptive.ply # Adaptive mesh
|
||||||
|
│ ├── brace_{caseId}.png # Visualization
|
||||||
|
│ └── brace_{caseId}.json # Landmarks data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE brace_cases (
|
||||||
|
case_id VARCHAR(64) PRIMARY KEY,
|
||||||
|
status ENUM('created', 'processing_brace', 'brace_generated', 'brace_failed'),
|
||||||
|
current_step VARCHAR(64),
|
||||||
|
analysis_result JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Deploy Lambda Functions
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd braceflow_lambda/deploy
|
||||||
|
.\deploy-brace-generator-lambdas.ps1 -BraceGeneratorUrl "http://YOUR_EC2_IP:8000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add API Gateway Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /cases/{caseId}/generate-brace → braceflow_invoke_brace_generator
|
||||||
|
GET /cases/{caseId}/brace-outputs → braceflow_get_brace_outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd braceflow
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# Open http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigate to `/analyze` to use the new brace analysis page.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
1. Go to http://localhost:5173/analyze
|
||||||
|
2. Upload an X-ray image
|
||||||
|
3. Wait for analysis to complete
|
||||||
|
4. View results and download files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **CORS errors**: Ensure API Gateway has CORS configured
|
||||||
|
2. **Timeout errors**: Lambda timeout is 120s, may need increase for large images
|
||||||
|
3. **S3 access denied**: Check Lambda role has S3 permissions
|
||||||
|
4. **GPU server unreachable**: Check EC2 security group allows port 8000
|
||||||
|
|
||||||
|
### Checking Lambda Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws logs tail /aws/lambda/braceflow_invoke_brace_generator --follow
|
||||||
|
```
|
||||||
37
frontend/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# ============================================
|
||||||
|
# BraceIQMed Frontend - React + Vite + nginx
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Stage 1: Build the React app
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the app (uses relative API URLs)
|
||||||
|
ENV VITE_API_URL=""
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Create log directory
|
||||||
|
RUN mkdir -p /var/log/nginx
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
9
frontend/cors.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"AllowedMethods": ["GET"],
|
||||||
|
"AllowedOrigins": ["*"],
|
||||||
|
"ExposeHeaders": [],
|
||||||
|
"MaxAgeSeconds": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BraceiQ</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
61
frontend/nginx.conf
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
|
||||||
|
# Increase max body size for file uploads
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
# Proxy API requests to the API container
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:3002/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy file requests to the API container
|
||||||
|
location /files/ {
|
||||||
|
proxy_pass http://api:3002/files/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve static assets with caching
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback - serve index.html for all other routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 'OK';
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
3689
frontend/package-lock.json
generated
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "braceflow-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-three/drei": "^10.0.0",
|
||||||
|
"@react-three/fiber": "^9.0.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.12.0",
|
||||||
|
"three": "^0.170.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "^0.170.0",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/policy.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "PublicReadGetObject",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": "s3:GetObject",
|
||||||
|
"Resource": "arn:aws:s3:::braceflow-ui-www/*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
18
frontend/public/sculptgl/authSuccess.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE HTML>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Title</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
This window will close in 2 seconds. You can close it manually if it doesn't.
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
setTimeout(function(){
|
||||||
|
window.close();
|
||||||
|
}, 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
507
frontend/public/sculptgl/css/yagui.css
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
* {
|
||||||
|
font: 13px 'Open Sans', sans-serif;
|
||||||
|
color: #bbb;
|
||||||
|
font-weight: 400;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-khtml-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tablet-plugin {
|
||||||
|
position: absolute;
|
||||||
|
top: -1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#viewport {
|
||||||
|
position: absolute;
|
||||||
|
left: 310px; /* Add left margin to account for sidebar on the left */
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** SIDE BAR ******/
|
||||||
|
|
||||||
|
.gui-sidebar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
top: 0;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
width: 310px;
|
||||||
|
background: #3c3c3c;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: double;
|
||||||
|
border-width: 4px;
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-sidebar::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-sidebar::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-sidebar::-webkit-scrollbar-corner {
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-resize {
|
||||||
|
cursor: ew-resize;
|
||||||
|
position: absolute;
|
||||||
|
left: 310px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 10px;
|
||||||
|
margin-left: -3px;
|
||||||
|
margin-right: -3px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** folder ******/
|
||||||
|
|
||||||
|
.gui-sidebar > ul > label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #999;
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
line-height: 30px;
|
||||||
|
margin: 5px 0 5px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-sidebar > ul[opened=true] > label:before {
|
||||||
|
content: '▼';
|
||||||
|
text-indent: 1em;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-sidebar > ul[opened=false] > label:before {
|
||||||
|
content: '►';
|
||||||
|
text-indent: 1em;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-sidebar > ul {
|
||||||
|
display: block;
|
||||||
|
list-style: none;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-transition: max-height 0.3s ease;
|
||||||
|
-moz-transition: max-height 0.3s ease;
|
||||||
|
-ms-transition: max-height 0.3s ease;
|
||||||
|
-o-transition: max-height 0.3s ease;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-sidebar > ul[opened=true] {
|
||||||
|
max-height: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-sidebar > ul[opened=false] {
|
||||||
|
height: 35px;
|
||||||
|
max-height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-sidebar > ul > li {
|
||||||
|
height: 22px;
|
||||||
|
margin: 4px 5px 4px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-glowOnHover:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-pointerOnHover:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** label ******/
|
||||||
|
|
||||||
|
.gui-label-side {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
height: 100%;
|
||||||
|
width: 36%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** checkbox ******/
|
||||||
|
|
||||||
|
.gui-input-checkbox {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-input-checkbox + label {
|
||||||
|
float: right;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-input-checkbox + label::before {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
left: 5px;
|
||||||
|
height: 14px;
|
||||||
|
width: 6px;
|
||||||
|
border-right: 2px solid;
|
||||||
|
border-bottom: 2px solid;
|
||||||
|
-webkit-transform: rotate(60deg) skew(25deg, 0);
|
||||||
|
-ms-transform: rotate(60deg) skew(25deg, 0);
|
||||||
|
transform: rotate(60deg) skew(25deg, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-input-checkbox:checked + label::before {
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** input number ******/
|
||||||
|
|
||||||
|
.gui-input-number {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
float: right;
|
||||||
|
position: relative;
|
||||||
|
width: 10%;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 2%;
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
font-size: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-widget-color > input::-webkit-inner-spin-button,
|
||||||
|
.gui-widget-color > input::-webkit-outer-spin-button,
|
||||||
|
.gui-input-number::-webkit-inner-spin-button,
|
||||||
|
.gui-input-number::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** input on hover ******/
|
||||||
|
|
||||||
|
.gui-slider:hover,
|
||||||
|
.gui-input-number:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** slider ******/
|
||||||
|
|
||||||
|
.gui-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
cursor: ew-resize;
|
||||||
|
float: right;
|
||||||
|
width: 52%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-slider > div {
|
||||||
|
height: 100%;
|
||||||
|
background: #525f63;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** button ******/
|
||||||
|
|
||||||
|
.gui-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
float: right;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #525f63;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-button::-moz-focus-inner {
|
||||||
|
padding: 0 !important;
|
||||||
|
border: 0 none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-button:enabled:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-button:active {
|
||||||
|
box-shadow: 0 1px 0 hsla(0, 0%, 100%, .1), inset 0 1px 4px hsla(0, 0%, 0%, .8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-button:disabled {
|
||||||
|
background: #444;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** widget color ******/
|
||||||
|
|
||||||
|
.gui-widget-color {
|
||||||
|
float: right;
|
||||||
|
display: block;
|
||||||
|
width: 64%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-widget-color > input {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
position: relative;
|
||||||
|
display: inline;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-widget-color > input + div:hover,
|
||||||
|
.gui-widget-color > input:hover + div {
|
||||||
|
display: block;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-widget-color > input + div {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
padding: 3px;
|
||||||
|
width: 125px;
|
||||||
|
height: 105px;
|
||||||
|
z-index: 2;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* saturation */
|
||||||
|
|
||||||
|
.gui-color-saturation {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin-right: 3px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-color-saturation > div {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-knob-saturation {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
z-index: 4;
|
||||||
|
border: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* hue*/
|
||||||
|
|
||||||
|
.gui-color-hue {
|
||||||
|
display: inline-block;
|
||||||
|
width: 15px;
|
||||||
|
height: 100px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-knob-hue {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
width: 15px;
|
||||||
|
height: 2px;
|
||||||
|
border-right: 4px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* alpha */
|
||||||
|
|
||||||
|
.gui-color-alpha {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 3px;
|
||||||
|
height: 100px;
|
||||||
|
width: 15px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-knob-alpha {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
width: 15px;
|
||||||
|
height: 2px;
|
||||||
|
border-right: 4px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** select ******/
|
||||||
|
|
||||||
|
.gui-select {
|
||||||
|
float: right;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 64%;
|
||||||
|
height: 100%;
|
||||||
|
padding-left: 1%;
|
||||||
|
outline: none;
|
||||||
|
background: #525f63;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-select:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****** TOP BAR ******/
|
||||||
|
|
||||||
|
.gui-topbar {
|
||||||
|
position: absolute;
|
||||||
|
background: #20211d;
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-topbar ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-topbar ul > li {
|
||||||
|
float: left;
|
||||||
|
line-height: 40px;
|
||||||
|
padding: 0 15px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-topbar ul > li.gui-logo {
|
||||||
|
padding: 0 12px 0 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-topbar ul > li.gui-logo:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-topbar ul > li.gui-logo img {
|
||||||
|
display: block;
|
||||||
|
height: 28px;
|
||||||
|
margin-top: 6px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-topbar ul > li .shortcut {
|
||||||
|
float: right;
|
||||||
|
font-style: oblique;
|
||||||
|
margin-right: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-topbar ul > li:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-topbar ul > li:hover > ul {
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-topbar ul > li > ul {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 10px;
|
||||||
|
background: #222;
|
||||||
|
width: 220px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: 0.15s all ease;
|
||||||
|
-ms-transition: 0.15s all ease;
|
||||||
|
-moz-transition: 0.15s all ease;
|
||||||
|
-o-transition: 0.15s all ease;
|
||||||
|
transition: 0.15s all ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gui-topbar ul > li > ul > li {
|
||||||
|
float: none;
|
||||||
|
height: 22px;
|
||||||
|
line-height: 22px;
|
||||||
|
margin: 6px 0 6px 0;
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #999 !important;
|
||||||
|
cursor: default !important;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #444444;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
}
|
||||||
47
frontend/public/sculptgl/index.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang='en'>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8' />
|
||||||
|
<meta name='description' content='SculptGL is a small sculpting application powered by JavaScript and webGL.'>
|
||||||
|
<meta name='author' content='stéphane GINIER'>
|
||||||
|
<meta name='mobile-web-app-capable' content='yes'>
|
||||||
|
<meta name='apple-mobile-web-app-capable' content='yes'>
|
||||||
|
|
||||||
|
<title> BRACE iQ </title>
|
||||||
|
|
||||||
|
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'>
|
||||||
|
<link rel='stylesheet' href='css/yagui.css' type='text/css' />
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
window.sketchfabOAuth2Config = {
|
||||||
|
hostname: 'sketchfab.com',
|
||||||
|
client_id: 'OWoAmrd1QCS9wB54Ly17rMl2i5AHGvDNfmN4pEUH',
|
||||||
|
redirect_uri: 'https://stephaneginier.com/sculptgl/authSuccess.html'
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
var app = new window.SculptGL();
|
||||||
|
app.start();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body oncontextmenu='return false;'>
|
||||||
|
<input type='file' id='fileopen' multiple style='display: none' />
|
||||||
|
<input type='file' id='backgroundopen' style='display: none' />
|
||||||
|
<input type='file' id='alphaopen' style='display: none' />
|
||||||
|
<input type='file' id='textureopen' style='display: none' />
|
||||||
|
<input type='file' id='matcapopen' style='display: none' />
|
||||||
|
|
||||||
|
<div id='viewport'>
|
||||||
|
<canvas id='canvas'></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src='sculptgl.js'></script>
|
||||||
|
<!-- <script src='//cdn.webglstats.com/stat.js' defer='defer' async='async'></script> -->
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
BIN
frontend/public/sculptgl/resources/alpha/skin.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
frontend/public/sculptgl/resources/alpha/square.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/sculptgl/resources/dropper.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 817 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 862 KiB |
|
After Width: | Height: | Size: 881 KiB |
|
After Width: | Height: | Size: 705 KiB |
BIN
frontend/public/sculptgl/resources/logo.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/clay.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/green.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/matcapFV.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/pearl.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/redClay.jpg
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/skin.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/skinHazardousarts.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/sculptgl/resources/matcaps/white.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/sculptgl/resources/uv.png
Normal file
|
After Width: | Height: | Size: 408 KiB |
2
frontend/public/sculptgl/sculptgl.js
Normal file
77
frontend/public/sculptgl/sculptgl.js.LICENSE.txt
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/*! Hammer.JS - v2.0.7 - 2016-04-22
|
||||||
|
* http://hammerjs.github.io/
|
||||||
|
*
|
||||||
|
* Copyright (c) 2016 Jorik Tangelder;
|
||||||
|
* Licensed under the MIT license */
|
||||||
|
|
||||||
|
/*! exports provided: default */
|
||||||
|
|
||||||
|
/*! no static exports found */
|
||||||
|
|
||||||
|
/*!**********************!*\
|
||||||
|
!*** ./src/yagui.js ***!
|
||||||
|
\**********************/
|
||||||
|
|
||||||
|
/*!************************!*\
|
||||||
|
!*** ./src/GuiMain.js ***!
|
||||||
|
\************************/
|
||||||
|
|
||||||
|
/*!******************************!*\
|
||||||
|
!*** ./src/widgets/Color.js ***!
|
||||||
|
\******************************/
|
||||||
|
|
||||||
|
/*!******************************!*\
|
||||||
|
!*** ./src/widgets/Title.js ***!
|
||||||
|
\******************************/
|
||||||
|
|
||||||
|
/*!*******************************!*\
|
||||||
|
!*** ./src/utils/GuiUtils.js ***!
|
||||||
|
\*******************************/
|
||||||
|
|
||||||
|
/*!*******************************!*\
|
||||||
|
!*** ./src/widgets/Button.js ***!
|
||||||
|
\*******************************/
|
||||||
|
|
||||||
|
/*!*******************************!*\
|
||||||
|
!*** ./src/widgets/Slider.js ***!
|
||||||
|
\*******************************/
|
||||||
|
|
||||||
|
/*!********************************!*\
|
||||||
|
!*** ./src/containers/Menu.js ***!
|
||||||
|
\********************************/
|
||||||
|
|
||||||
|
/*!********************************!*\
|
||||||
|
!*** ./src/utils/EditStyle.js ***!
|
||||||
|
\********************************/
|
||||||
|
|
||||||
|
/*!*********************************!*\
|
||||||
|
!*** ./src/widgets/Checkbox.js ***!
|
||||||
|
\*********************************/
|
||||||
|
|
||||||
|
/*!*********************************!*\
|
||||||
|
!*** ./src/widgets/Combobox.js ***!
|
||||||
|
\*********************************/
|
||||||
|
|
||||||
|
/*!**********************************!*\
|
||||||
|
!*** ./src/containers/Folder.js ***!
|
||||||
|
\**********************************/
|
||||||
|
|
||||||
|
/*!**********************************!*\
|
||||||
|
!*** ./src/containers/Topbar.js ***!
|
||||||
|
\**********************************/
|
||||||
|
|
||||||
|
/*!***********************************!*\
|
||||||
|
!*** ./src/containers/Sidebar.js ***!
|
||||||
|
\***********************************/
|
||||||
|
|
||||||
|
/*!***********************************!*\
|
||||||
|
!*** ./src/widgets/BaseWidget.js ***!
|
||||||
|
\***********************************/
|
||||||
|
|
||||||
|
/*!***********************************!*\
|
||||||
|
!*** ./src/widgets/MenuButton.js ***!
|
||||||
|
\***********************************/
|
||||||
|
|
||||||
|
/*!*****************************************!*\
|
||||||
|
!*** ./src/containers/BaseContainer.js ***!
|
||||||
|
\*****************************************/
|
||||||
1
frontend/public/sculptgl/worker/deflate.js
Normal file
147
frontend/public/sculptgl/worker/z-worker.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/* jshint worker:true */
|
||||||
|
(function main(global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
if (global.zWorkerInitialized)
|
||||||
|
throw new Error('z-worker.js should be run only once');
|
||||||
|
global.zWorkerInitialized = true;
|
||||||
|
|
||||||
|
addEventListener("message", function(event) {
|
||||||
|
var message = event.data, type = message.type, sn = message.sn;
|
||||||
|
var handler = handlers[type];
|
||||||
|
if (handler) {
|
||||||
|
try {
|
||||||
|
handler(message);
|
||||||
|
} catch (e) {
|
||||||
|
onError(type, sn, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//for debug
|
||||||
|
//postMessage({type: 'echo', originalType: type, sn: sn});
|
||||||
|
});
|
||||||
|
|
||||||
|
var handlers = {
|
||||||
|
importScripts: doImportScripts,
|
||||||
|
newTask: newTask,
|
||||||
|
append: processData,
|
||||||
|
flush: processData,
|
||||||
|
};
|
||||||
|
|
||||||
|
// deflater/inflater tasks indexed by serial numbers
|
||||||
|
var tasks = {};
|
||||||
|
|
||||||
|
function doImportScripts(msg) {
|
||||||
|
if (msg.scripts && msg.scripts.length > 0)
|
||||||
|
importScripts.apply(undefined, msg.scripts);
|
||||||
|
postMessage({type: 'importScripts'});
|
||||||
|
}
|
||||||
|
|
||||||
|
function newTask(msg) {
|
||||||
|
var CodecClass = global[msg.codecClass];
|
||||||
|
var sn = msg.sn;
|
||||||
|
if (tasks[sn])
|
||||||
|
throw Error('duplicated sn');
|
||||||
|
tasks[sn] = {
|
||||||
|
codec: new CodecClass(msg.options),
|
||||||
|
crcInput: msg.crcType === 'input',
|
||||||
|
crcOutput: msg.crcType === 'output',
|
||||||
|
crc: new Crc32(),
|
||||||
|
};
|
||||||
|
postMessage({type: 'newTask', sn: sn});
|
||||||
|
}
|
||||||
|
|
||||||
|
// performance may not be supported
|
||||||
|
var now = global.performance ? global.performance.now.bind(global.performance) : Date.now;
|
||||||
|
|
||||||
|
function processData(msg) {
|
||||||
|
var sn = msg.sn, type = msg.type, input = msg.data;
|
||||||
|
var task = tasks[sn];
|
||||||
|
// allow creating codec on first append
|
||||||
|
if (!task && msg.codecClass) {
|
||||||
|
newTask(msg);
|
||||||
|
task = tasks[sn];
|
||||||
|
}
|
||||||
|
var isAppend = type === 'append';
|
||||||
|
var start = now();
|
||||||
|
var output;
|
||||||
|
if (isAppend) {
|
||||||
|
try {
|
||||||
|
output = task.codec.append(input, function onprogress(loaded) {
|
||||||
|
postMessage({type: 'progress', sn: sn, loaded: loaded});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
delete tasks[sn];
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete tasks[sn];
|
||||||
|
output = task.codec.flush();
|
||||||
|
}
|
||||||
|
var codecTime = now() - start;
|
||||||
|
|
||||||
|
start = now();
|
||||||
|
if (input && task.crcInput)
|
||||||
|
task.crc.append(input);
|
||||||
|
if (output && task.crcOutput)
|
||||||
|
task.crc.append(output);
|
||||||
|
var crcTime = now() - start;
|
||||||
|
|
||||||
|
var rmsg = {type: type, sn: sn, codecTime: codecTime, crcTime: crcTime};
|
||||||
|
var transferables = [];
|
||||||
|
if (output) {
|
||||||
|
rmsg.data = output;
|
||||||
|
transferables.push(output.buffer);
|
||||||
|
}
|
||||||
|
if (!isAppend && (task.crcInput || task.crcOutput))
|
||||||
|
rmsg.crc = task.crc.get();
|
||||||
|
postMessage(rmsg, transferables);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError(type, sn, e) {
|
||||||
|
var msg = {
|
||||||
|
type: type,
|
||||||
|
sn: sn,
|
||||||
|
error: formatError(e)
|
||||||
|
};
|
||||||
|
postMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatError(e) {
|
||||||
|
return { message: e.message, stack: e.stack };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crc32 code copied from file zip.js
|
||||||
|
function Crc32() {
|
||||||
|
this.crc = -1;
|
||||||
|
}
|
||||||
|
Crc32.prototype.append = function append(data) {
|
||||||
|
var crc = this.crc | 0, table = this.table;
|
||||||
|
for (var offset = 0, len = data.length | 0; offset < len; offset++)
|
||||||
|
crc = (crc >>> 8) ^ table[(crc ^ data[offset]) & 0xFF];
|
||||||
|
this.crc = crc;
|
||||||
|
};
|
||||||
|
Crc32.prototype.get = function get() {
|
||||||
|
return ~this.crc;
|
||||||
|
};
|
||||||
|
Crc32.prototype.table = (function() {
|
||||||
|
var i, j, t, table = []; // Uint32Array is actually slower than []
|
||||||
|
for (i = 0; i < 256; i++) {
|
||||||
|
t = i;
|
||||||
|
for (j = 0; j < 8; j++)
|
||||||
|
if (t & 1)
|
||||||
|
t = (t >>> 1) ^ 0xEDB88320;
|
||||||
|
else
|
||||||
|
t = t >>> 1;
|
||||||
|
table[i] = t;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// "no-op" codec
|
||||||
|
function NOOP() {}
|
||||||
|
global.NOOP = NOOP;
|
||||||
|
NOOP.prototype.append = function append(bytes, onprogress) {
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
NOOP.prototype.flush = function flush() {};
|
||||||
|
})(this);
|
||||||
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
838
frontend/src/App.css
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
/* #root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Typography */
|
||||||
|
font-family: Inter, system-ui, Arial;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.45;
|
||||||
|
|
||||||
|
/* Core background */
|
||||||
|
--bg-main:
|
||||||
|
radial-gradient(
|
||||||
|
900px 520px at 86% 20%,
|
||||||
|
rgba(209, 118, 69, 0.12) 0%,
|
||||||
|
rgba(23, 27, 34, 0.35) 55%,
|
||||||
|
rgba(12, 15, 20, 0.0) 100%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
900px 560px at 12% 86%,
|
||||||
|
rgba(96, 122, 155, 0.14) 0%,
|
||||||
|
rgba(18, 23, 30, 0.35) 55%,
|
||||||
|
rgba(12, 15, 20, 0.0) 100%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, #151a22 0%, #10141a 100%);
|
||||||
|
|
||||||
|
--bg-surface: rgba(255, 255, 255, 0.14);
|
||||||
|
--bg-surface-hover: rgba(255, 255, 255, 0.2);
|
||||||
|
--bg-surface-active: rgba(255, 255, 255, 0.26);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-main: #f1f5f9;
|
||||||
|
--text-muted: #9aa4b2;
|
||||||
|
|
||||||
|
/* Accents */
|
||||||
|
--accent-primary: #dd8250; /* copper (slightly brighter) */
|
||||||
|
--accent-secondary: #f3b886; /* warm copper (slightly brighter) */
|
||||||
|
--accent-success: #43c59e;
|
||||||
|
--accent-danger: #f37f7f;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-soft: rgba(255, 255, 255, 0.11);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.24);
|
||||||
|
|
||||||
|
background: var(--bg-main);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
/* =========================================================
|
||||||
|
BraceFlow AppShell (isolated styles) — uses bf-* classes
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.bf-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-header {
|
||||||
|
min-height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: center;
|
||||||
|
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(249, 115, 22, 0.18),
|
||||||
|
rgba(15, 23, 42, 0.65)
|
||||||
|
);
|
||||||
|
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow:
|
||||||
|
0 14px 40px rgba(8, 12, 18, 0.55),
|
||||||
|
0 0 48px rgba(210, 225, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0; /* allows nav to shrink properly */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-brand {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 19px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-brand-accent {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-brand:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-nav-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-nav-item:hover:not(:disabled) {
|
||||||
|
color: var(--text-main);
|
||||||
|
background: rgba(209, 118, 69, 0.10);
|
||||||
|
border-color: rgba(209, 118, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-nav-item:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-nav-item.is-active {
|
||||||
|
color: var(--text-main);
|
||||||
|
background: rgba(249, 115, 22, 0.16);
|
||||||
|
border-color: rgba(249, 115, 22, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-nav-item:disabled,
|
||||||
|
.bf-nav-item.is-disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-case-context {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-case-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-main);
|
||||||
|
max-width: 240px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-case-badge.is-empty {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-case-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.35px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-case-id {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||||
|
"Courier New", monospace;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-case-input {
|
||||||
|
height: 32px;
|
||||||
|
width: 150px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-case-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-case-input:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
background: rgba(0, 0, 0, 0.28);
|
||||||
|
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-go {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-go:hover:not(:disabled) {
|
||||||
|
background: rgba(209, 118, 69, 0.14);
|
||||||
|
border-color: rgba(209, 118, 69, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-go:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 0 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step:hover:not(:disabled) {
|
||||||
|
color: var(--text-main);
|
||||||
|
background: rgba(209, 118, 69, 0.10);
|
||||||
|
border-color: rgba(209, 118, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step.is-active {
|
||||||
|
color: var(--text-main);
|
||||||
|
background: rgba(249, 115, 22, 0.16);
|
||||||
|
border-color: rgba(249, 115, 22, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step.is-complete {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step.is-disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step-dot {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step.is-active .bf-step-dot {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #0b1020;
|
||||||
|
box-shadow: 0 8px 18px rgba(249, 115, 22, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step.is-complete .bf-step-dot {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #0b1020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step-connector {
|
||||||
|
width: 26px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step-connector.is-complete {
|
||||||
|
background: rgba(249, 115, 22, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-content--landing {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-content--fade-in {
|
||||||
|
animation: bf-content-fade-in 560ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bf-content-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor page needs less padding to maximize space */
|
||||||
|
.bf-content:has(.shell-editor-page) {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the Rigo shell page aligned with the standard page spacing */
|
||||||
|
.bf-content:has(.rigo-shell-page) {
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: on small screens, keep header usable */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.bf-case-input {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
.bf-nav {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.bf-nav-item {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.bf-left {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-right {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
order: 2;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-stepper {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
order: 3;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow-x: visible;
|
||||||
|
padding: 6px 0 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-step-connector {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app { min-height: 100vh; }
|
||||||
|
.header { padding: 18px 24px; border-bottom: 1px solid rgba(255,255,255,0.08); display:flex; gap:12px; align-items: baseline; }
|
||||||
|
.brand { font-weight: 700; letter-spacing: 0.3px; }
|
||||||
|
.subtitle { opacity: 0.7; }
|
||||||
|
.container { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 30px rgba(10, 15, 35, 0.35),
|
||||||
|
0 0 32px rgba(210, 225, 255, 0.08);
|
||||||
|
}
|
||||||
|
.row { display:flex; align-items:center; }
|
||||||
|
.row.space { justify-content: space-between; }
|
||||||
|
.row.right { justify-content: flex-end; }
|
||||||
|
.row.gap { gap: 10px; }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex:1;
|
||||||
|
padding: 12px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: rgba(0,0,0,0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: rgba(255,255,255,0.10);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: rgba(209, 118, 69, 0.14);
|
||||||
|
border-color: rgba(209, 118, 69, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #0b1020;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-secondary);
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn.secondary { background: transparent; }
|
||||||
|
|
||||||
|
.muted { opacity: 0.7; margin-top: 8px; }
|
||||||
|
.error { margin-top: 12px; padding: 10px; border-radius: 10px; background: rgba(255,0,0,0.12); border: 1px solid rgba(255,0,0,0.25); }
|
||||||
|
.notice { margin-top: 12px; padding: 10px; border-radius: 10px; background: rgba(0,170,255,0.12); border: 1px solid rgba(0,170,255,0.25); }
|
||||||
|
|
||||||
|
.landmark-layout { display:grid; grid-template-columns: 320px 1fr; gap: 14px; margin-top: 14px; }
|
||||||
|
.panel { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 14px; }
|
||||||
|
.list { margin: 10px 0 0; padding-left: 18px; }
|
||||||
|
.list li { margin: 10px 0; }
|
||||||
|
.list li.active .label { font-weight: 700; }
|
||||||
|
.label { margin-bottom: 4px; }
|
||||||
|
.meta { opacity: 0.7; font-size: 12px; }
|
||||||
|
.pill { opacity: 0.8; font-size: 12px; padding: 6px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.12); }
|
||||||
|
|
||||||
|
.canvasWrap { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 12px; }
|
||||||
|
.imgWrap { position: relative; display: inline-block; cursor: crosshair; }
|
||||||
|
.xray { max-width: 100%; border-radius: 12px; display:block; }
|
||||||
|
.overlay { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; }
|
||||||
|
.hint { margin-top: 8px; opacity: 0.7; font-size: 12px; }
|
||||||
|
|
||||||
|
.table { width:100%; border-collapse: collapse; margin-top: 14px; }
|
||||||
|
.table th, .table td { text-align:left; padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||||
|
.summary { display:flex; gap: 20px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.tag { padding: 4px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.10); font-size: 12px; }
|
||||||
|
.tag.pending { opacity: 0.8; }
|
||||||
|
.tag.running { border-color: rgba(0,170,255,0.35); background: rgba(0,170,255,0.10); }
|
||||||
|
.tag.done { border-color: rgba(0,255,140,0.35); background: rgba(0,255,140,0.10); }
|
||||||
|
.tag.waiting_for_landmarks { border-color: rgba(255,200,0,0.35); background: rgba(255,200,0,0.10); }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
BraceFlow - Slide-in Drawer (Artifacts)
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.bf-drawer-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 160ms ease;
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-drawer-backdrop.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: min(560px, 92vw);
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
border-left: 1px solid var(--border-soft);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 180ms ease;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-drawer.is-open { transform: translateX(0); }
|
||||||
|
|
||||||
|
.bf-drawer-header {
|
||||||
|
padding: 14px 14px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-drawer-title { font-weight: 900; letter-spacing: 0.2px; }
|
||||||
|
.bf-drawer-subtitle { opacity: 0.7; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.bf-tabs {
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-tab {
|
||||||
|
height: 32px;
|
||||||
|
min-width: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bf-tab:hover { background: rgba(209, 118, 69, 0.12); border-color: rgba(209, 118, 69, 0.32); }
|
||||||
|
.bf-tab.is-active { background: rgba(255,255,255,0.16); border-color: rgba(255,255,255,0.35); }
|
||||||
|
|
||||||
|
.bf-drawer-body { flex: 1; padding: 14px; overflow: auto; }
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Landmark Capture – Thumbnail + Fixed Canvas
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.lc-imageRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-thumbCol {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-thumbBox {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-thumbBox img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-landmarkBox {
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
background: rgba(0,0,0,0.22);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force LandmarkCanvas to respect size */
|
||||||
|
.fixed-250 {
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-250 img,
|
||||||
|
.fixed-250 svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imgWrap.fixed-250 {
|
||||||
|
position: relative;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imgWrap.fixed-250 img {
|
||||||
|
display: block;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imgWrap.fixed-250 .overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Landmark Capture page – Thumbnail top, Workspace below
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.lc-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnail row (top) */
|
||||||
|
.lc-thumbRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-thumbCol {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-thumbBox {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(0,0,0,0.22);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-thumbBox img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-thumbActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspace (below thumbnail) */
|
||||||
|
.lc-workspace {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-workspace-title {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-workspace-body {
|
||||||
|
/* just spacing wrapper */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------
|
||||||
|
IMPORTANT: Fix the "ruined" layout by letting LandmarkCanvas
|
||||||
|
keep its own grid, but constrain ONLY the image holder.
|
||||||
|
LandmarkCanvas uses:
|
||||||
|
.landmark-layout (grid)
|
||||||
|
.panel (left)
|
||||||
|
.canvasWrap (right)
|
||||||
|
.imgWrap (image container)
|
||||||
|
--------------------------------------------------------- */
|
||||||
|
|
||||||
|
.lc-workspace .landmark-layout {
|
||||||
|
grid-template-columns: 340px 1fr; /* nicer proportion */
|
||||||
|
align-items: start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.lc-workspace .landmark-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Constrain ONLY the DCM/image holder (not the whole component) */
|
||||||
|
.lc-workspace .canvasWrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-workspace .imgWrap {
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make image + overlay fill that 250x250 cleanly */
|
||||||
|
.lc-workspace .xray {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain; /* keeps anatomy proportions */
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-workspace .overlay {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the landmarks panel feel aligned with the 250 box */
|
||||||
|
.lc-workspace .panel {
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
175
frontend/src/App.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { Routes, Route, useNavigate, Navigate } from "react-router-dom";
|
||||||
|
import { AppShell } from "./components/AppShell";
|
||||||
|
import { AuthProvider, useAuth } from "./context/AuthContext";
|
||||||
|
import HomePage from "./pages/HomePage";
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
|
import Dashboard from "./pages/Dashboard";
|
||||||
|
import CaseDetailPage from "./pages/CaseDetail";
|
||||||
|
import PipelineCaseDetail from "./pages/PipelineCaseDetail";
|
||||||
|
import ShellEditorPage from "./pages/ShellEditorPage";
|
||||||
|
|
||||||
|
// Admin pages
|
||||||
|
import AdminDashboard from "./pages/admin/AdminDashboard";
|
||||||
|
import AdminUsers from "./pages/admin/AdminUsers";
|
||||||
|
import AdminCases from "./pages/admin/AdminCases";
|
||||||
|
import AdminActivity from "./pages/admin/AdminActivity";
|
||||||
|
|
||||||
|
// Import pipeline styles
|
||||||
|
import "./components/pipeline/pipeline.css";
|
||||||
|
|
||||||
|
// Protected route wrapper - redirects to login if not authenticated
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bf-loading-screen">
|
||||||
|
<div className="bf-loading-spinner"></div>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin route wrapper - requires admin role
|
||||||
|
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isAdmin, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bf-loading-screen">
|
||||||
|
<div className="bf-loading-spinner"></div>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return <Navigate to="/cases" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* Protected routes - wrapped in AppShell */}
|
||||||
|
<Route
|
||||||
|
path="/cases"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<DashboardWrapper />
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cases/:caseId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<PipelineCaseDetail />
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cases-legacy/:caseId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<CaseDetailPage />
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/editor"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<ShellEditorPage />
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<AppShell>
|
||||||
|
<AdminDashboard />
|
||||||
|
</AppShell>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/users"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<AppShell>
|
||||||
|
<AdminUsers />
|
||||||
|
</AppShell>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/cases"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<AppShell>
|
||||||
|
<AdminCases />
|
||||||
|
</AppShell>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/activity"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<AppShell>
|
||||||
|
<AdminActivity />
|
||||||
|
</AppShell>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Legacy redirects */}
|
||||||
|
<Route path="/dashboard" element={<Navigate to="/cases" replace />} />
|
||||||
|
|
||||||
|
{/* Catch-all redirect */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardWrapper() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
return <Dashboard onView={(id: string) => nav(`/cases/${encodeURIComponent(id)}`)} />;
|
||||||
|
}
|
||||||
236
frontend/src/api/adminApi.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Admin API Client
|
||||||
|
* API functions for admin dashboard features
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAuthHeaders } from "../context/AuthContext";
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api";
|
||||||
|
|
||||||
|
async function adminFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const url = `${API_BASE}${path}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(),
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = text ? JSON.parse(text) : { message: "Request failed" };
|
||||||
|
throw new Error(error.message || `Request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text ? JSON.parse(text) : ({} as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USER MANAGEMENT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type AdminUser = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
full_name: string | null;
|
||||||
|
role: "admin" | "user" | "viewer";
|
||||||
|
is_active: number;
|
||||||
|
last_login: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listUsers(): Promise<{ users: AdminUser[] }> {
|
||||||
|
return adminFetch("/admin/users");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(userId: number): Promise<{ user: AdminUser }> {
|
||||||
|
return adminFetch(`/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(data: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
email?: string;
|
||||||
|
fullName?: string;
|
||||||
|
role?: "admin" | "user" | "viewer";
|
||||||
|
}): Promise<{ user: AdminUser }> {
|
||||||
|
return adminFetch("/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(
|
||||||
|
userId: number,
|
||||||
|
data: {
|
||||||
|
email?: string;
|
||||||
|
fullName?: string;
|
||||||
|
role?: "admin" | "user" | "viewer";
|
||||||
|
isActive?: boolean;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
): Promise<{ user: AdminUser }> {
|
||||||
|
return adminFetch(`/admin/users/${userId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(userId: number): Promise<{ message: string }> {
|
||||||
|
return adminFetch(`/admin/users/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CASES WITH FILTERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type AdminCase = {
|
||||||
|
caseId: string;
|
||||||
|
case_type: string;
|
||||||
|
status: string;
|
||||||
|
current_step: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
analysis_result: any;
|
||||||
|
landmarks_data: any;
|
||||||
|
body_scan_path: string | null;
|
||||||
|
created_by: number | null;
|
||||||
|
created_by_username: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListCasesResponse = {
|
||||||
|
cases: AdminCase[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listCasesAdmin(params?: {
|
||||||
|
status?: string;
|
||||||
|
createdBy?: number;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "ASC" | "DESC";
|
||||||
|
}): Promise<ListCasesResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.status) searchParams.set("status", params.status);
|
||||||
|
if (params?.createdBy) searchParams.set("createdBy", params.createdBy.toString());
|
||||||
|
if (params?.search) searchParams.set("search", params.search);
|
||||||
|
if (params?.limit) searchParams.set("limit", params.limit.toString());
|
||||||
|
if (params?.offset) searchParams.set("offset", params.offset.toString());
|
||||||
|
if (params?.sortBy) searchParams.set("sortBy", params.sortBy);
|
||||||
|
if (params?.sortOrder) searchParams.set("sortOrder", params.sortOrder);
|
||||||
|
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return adminFetch(`/admin/cases${query ? `?${query}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ANALYTICS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type CaseStats = {
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<string, number>;
|
||||||
|
last7Days: { date: string; count: number }[];
|
||||||
|
last30Days: { date: string; count: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserStats = {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
inactive: number;
|
||||||
|
byRole: Record<string, number>;
|
||||||
|
recentLogins: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CobbAngleStats = {
|
||||||
|
PT: { min: number; max: number; avg: number; count: number };
|
||||||
|
MT: { min: number; max: number; avg: number; count: number };
|
||||||
|
TL: { min: number; max: number; avg: number; count: number };
|
||||||
|
totalCasesWithAngles: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProcessingTimeStats = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
avg: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BodyScanStats = {
|
||||||
|
total: number;
|
||||||
|
withBodyScan: number;
|
||||||
|
withoutBodyScan: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardAnalytics = {
|
||||||
|
cases: CaseStats;
|
||||||
|
users: UserStats;
|
||||||
|
rigoDistribution: Record<string, number>;
|
||||||
|
cobbAngles: CobbAngleStats;
|
||||||
|
processingTime: ProcessingTimeStats;
|
||||||
|
bodyScan: BodyScanStats;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getDashboardAnalytics(): Promise<DashboardAnalytics> {
|
||||||
|
return adminFetch("/admin/analytics/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRigoDistribution(): Promise<{ distribution: Record<string, number> }> {
|
||||||
|
return adminFetch("/admin/analytics/rigo");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCobbAngleStats(): Promise<{ stats: CobbAngleStats }> {
|
||||||
|
return adminFetch("/admin/analytics/cobb-angles");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProcessingTimeStats(): Promise<{ stats: ProcessingTimeStats }> {
|
||||||
|
return adminFetch("/admin/analytics/processing-time");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AUDIT LOG
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type AuditLogEntry = {
|
||||||
|
id: number;
|
||||||
|
user_id: number | null;
|
||||||
|
username: string | null;
|
||||||
|
action: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: string | null;
|
||||||
|
details: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAuditLog(params?: {
|
||||||
|
userId?: number;
|
||||||
|
action?: string;
|
||||||
|
entityType?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<{ entries: AuditLogEntry[] }> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.userId) searchParams.set("userId", params.userId.toString());
|
||||||
|
if (params?.action) searchParams.set("action", params.action);
|
||||||
|
if (params?.entityType) searchParams.set("entityType", params.entityType);
|
||||||
|
if (params?.limit) searchParams.set("limit", params.limit.toString());
|
||||||
|
if (params?.offset) searchParams.set("offset", params.offset.toString());
|
||||||
|
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return adminFetch(`/admin/audit-log${query ? `?${query}` : ""}`);
|
||||||
|
}
|
||||||
884
frontend/src/api/braceflowApi.ts
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
export type CaseRecord = {
|
||||||
|
caseId: string;
|
||||||
|
status: string;
|
||||||
|
current_step: string | null;
|
||||||
|
created_at: string;
|
||||||
|
analysis_result?: AnalysisResult | null;
|
||||||
|
landmarks_data?: LandmarksResult | null;
|
||||||
|
analysis_data?: RecalculationResult | null;
|
||||||
|
body_scan_path?: string | null;
|
||||||
|
body_scan_url?: string | null;
|
||||||
|
body_scan_metadata?: BodyScanMetadata | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BodyScanMetadata = {
|
||||||
|
total_height_mm?: number;
|
||||||
|
shoulder_width_mm?: number;
|
||||||
|
chest_width_mm?: number;
|
||||||
|
chest_depth_mm?: number;
|
||||||
|
waist_width_mm?: number;
|
||||||
|
waist_depth_mm?: number;
|
||||||
|
hip_width_mm?: number;
|
||||||
|
brace_coverage_height_mm?: number;
|
||||||
|
file_format?: string;
|
||||||
|
vertex_count?: number;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CobbAngles = {
|
||||||
|
PT?: number;
|
||||||
|
MT?: number;
|
||||||
|
TL?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RigoClassification = {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
curve_pattern?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeformationZone = {
|
||||||
|
zone: string;
|
||||||
|
patch: [number, number];
|
||||||
|
deform_mm: number;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeformationReport = {
|
||||||
|
patch_grid: string;
|
||||||
|
deformations: number[][];
|
||||||
|
zones: DeformationZone[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisResult = {
|
||||||
|
experiment?: string;
|
||||||
|
model?: string;
|
||||||
|
vertebrae_detected?: number;
|
||||||
|
cobb_angles?: CobbAngles;
|
||||||
|
curve_type?: string;
|
||||||
|
rigo_classification?: RigoClassification;
|
||||||
|
mesh_info?: {
|
||||||
|
vertices?: number;
|
||||||
|
faces?: number;
|
||||||
|
};
|
||||||
|
deformation_report?: DeformationReport;
|
||||||
|
outputs?: Record<string, { s3Key: string; url: string }>;
|
||||||
|
processing_time_ms?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BraceOutput = {
|
||||||
|
filename: string;
|
||||||
|
type: "stl" | "ply" | "obj" | "image" | "json" | "other";
|
||||||
|
s3Key: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
expiresIn: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BraceOutputsResponse = {
|
||||||
|
caseId: string;
|
||||||
|
status: string;
|
||||||
|
analysis: AnalysisResult | null;
|
||||||
|
outputs: BraceOutput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateBraceResponse = {
|
||||||
|
caseId: string;
|
||||||
|
status: string;
|
||||||
|
experiment: string;
|
||||||
|
model: string;
|
||||||
|
vertebrae_detected: number;
|
||||||
|
cobb_angles: CobbAngles;
|
||||||
|
curve_type: string;
|
||||||
|
rigo_classification: RigoClassification;
|
||||||
|
mesh: {
|
||||||
|
vertices: number;
|
||||||
|
faces: number;
|
||||||
|
};
|
||||||
|
outputs: Record<string, { s3Key: string; url: string }>;
|
||||||
|
processing_time_ms: number;
|
||||||
|
deformation_report?: DeformationReport;
|
||||||
|
};
|
||||||
|
|
||||||
|
// API Base URL
|
||||||
|
// - In production (Docker): empty string uses relative URLs with /api prefix
|
||||||
|
// - In development: set VITE_API_BASE=http://localhost:8001 in .env.local
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
|
||||||
|
|
||||||
|
// API prefix for relative URLs (when API_BASE is empty or doesn't include /api)
|
||||||
|
const API_PREFIX = "/api";
|
||||||
|
|
||||||
|
// File server base URL (same as API base for dev server)
|
||||||
|
const FILE_BASE = API_BASE.replace(/\/api\/?$/, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert relative file URLs to absolute URLs
|
||||||
|
* e.g., "/files/outputs/..." -> "http://localhost:8001/files/outputs/..."
|
||||||
|
*/
|
||||||
|
export function toAbsoluteFileUrl(relativeUrl: string | undefined | null): string | undefined {
|
||||||
|
if (!relativeUrl) return undefined;
|
||||||
|
if (relativeUrl.startsWith('http://') || relativeUrl.startsWith('https://')) {
|
||||||
|
return relativeUrl; // Already absolute
|
||||||
|
}
|
||||||
|
return `${FILE_BASE}${relativeUrl.startsWith('/') ? '' : '/'}${relativeUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : "";
|
||||||
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
// If no base URL, use API_PREFIX for relative URLs (production with nginx proxy)
|
||||||
|
const prefix = base ? "" : API_PREFIX;
|
||||||
|
const url = `${base}${prefix}${normalizedPath}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Request failed: ${res.status} ${res.statusText}${text ? ` :: ${text}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text ? (JSON.parse(text) as T) : ({} as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supports common backend shapes:
|
||||||
|
* - Array: [ {caseId...}, ... ]
|
||||||
|
* - Object: { cases: [...], nextToken: "..." }
|
||||||
|
* - Object: { items: [...], nextToken: "..." }
|
||||||
|
*/
|
||||||
|
function normalizeCasesResponse(
|
||||||
|
json: any
|
||||||
|
): { cases: CaseRecord[]; nextToken?: string | null } {
|
||||||
|
if (Array.isArray(json)) return { cases: json, nextToken: null };
|
||||||
|
|
||||||
|
const cases = (json?.cases ?? json?.items ?? []) as CaseRecord[];
|
||||||
|
const nextToken = (json?.nextToken ?? json?.next_token ?? json?.nextCursor ?? json?.next_cursor ?? null) as
|
||||||
|
| string
|
||||||
|
| null;
|
||||||
|
|
||||||
|
return { cases: Array.isArray(cases) ? cases : [], nextToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch ALL cases, following nextToken if backend paginates.
|
||||||
|
* Uses query param `?nextToken=` (common pattern).
|
||||||
|
*/
|
||||||
|
export async function fetchCases(): Promise<CaseRecord[]> {
|
||||||
|
const all: CaseRecord[] = [];
|
||||||
|
let nextToken: string | null = null;
|
||||||
|
|
||||||
|
// Safety guard to prevent infinite loops if backend misbehaves
|
||||||
|
const MAX_PAGES = 50;
|
||||||
|
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const path = nextToken ? `/cases?nextToken=${encodeURIComponent(nextToken)}` : `/cases`;
|
||||||
|
const json = await safeFetch<any>(path);
|
||||||
|
|
||||||
|
const normalized = normalizeCasesResponse(json);
|
||||||
|
all.push(...normalized.cases);
|
||||||
|
|
||||||
|
if (!normalized.nextToken) break;
|
||||||
|
nextToken = normalized.nextToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort newest-first
|
||||||
|
all.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCase(caseId: string): Promise<CaseRecord | null> {
|
||||||
|
try {
|
||||||
|
const result = await safeFetch<CaseRecord>(`/cases/${encodeURIComponent(caseId)}`);
|
||||||
|
|
||||||
|
// Convert relative URLs to absolute URLs for braces data
|
||||||
|
if (result?.analysis_result) {
|
||||||
|
const ar = result.analysis_result as any;
|
||||||
|
// Convert both braces format URLs
|
||||||
|
if (ar.braces) {
|
||||||
|
if (ar.braces.regular?.outputs) {
|
||||||
|
ar.braces.regular.outputs = {
|
||||||
|
glb: toAbsoluteFileUrl(ar.braces.regular.outputs.glb),
|
||||||
|
stl: toAbsoluteFileUrl(ar.braces.regular.outputs.stl),
|
||||||
|
json: toAbsoluteFileUrl(ar.braces.regular.outputs.json),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ar.braces.vase?.outputs) {
|
||||||
|
ar.braces.vase.outputs = {
|
||||||
|
glb: toAbsoluteFileUrl(ar.braces.vase.outputs.glb),
|
||||||
|
stl: toAbsoluteFileUrl(ar.braces.vase.outputs.stl),
|
||||||
|
json: toAbsoluteFileUrl(ar.braces.vase.outputs.json),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Convert single brace format URLs
|
||||||
|
if (ar.brace?.outputs) {
|
||||||
|
ar.brace.outputs = {
|
||||||
|
stl: toAbsoluteFileUrl(ar.brace.outputs.stl),
|
||||||
|
ply: toAbsoluteFileUrl(ar.brace.outputs.ply),
|
||||||
|
visualization: toAbsoluteFileUrl(ar.brace.outputs.visualization),
|
||||||
|
landmarks: toAbsoluteFileUrl(ar.brace.outputs.landmarks),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCase(body: { notes?: string } = {}): Promise<{ caseId: string }> {
|
||||||
|
return await safeFetch<{ caseId: string }>(`/cases`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a presigned URL for uploading an X-ray image to S3
|
||||||
|
*/
|
||||||
|
export async function getUploadUrl(
|
||||||
|
caseId: string,
|
||||||
|
filename: string,
|
||||||
|
contentType: string
|
||||||
|
): Promise<{ url: string; s3Key: string }> {
|
||||||
|
return await safeFetch<{ url: string; s3Key: string }>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/upload-url`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ filename, contentType }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file directly to S3 using a presigned URL
|
||||||
|
*/
|
||||||
|
export async function uploadToS3(presignedUrl: string, file: File): Promise<void> {
|
||||||
|
const response = await fetch(presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a case and upload an X-ray without starting processing.
|
||||||
|
*/
|
||||||
|
export async function createCaseAndUploadXray(
|
||||||
|
file: File,
|
||||||
|
options?: { notes?: string }
|
||||||
|
): Promise<{ caseId: string }> {
|
||||||
|
const { caseId } = await createCase({
|
||||||
|
notes: options?.notes ?? `X-ray upload: ${file.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { url: uploadUrl } = await getUploadUrl(caseId, "ap.jpg", file.type);
|
||||||
|
await uploadToS3(uploadUrl, file);
|
||||||
|
|
||||||
|
return { caseId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the brace generator Lambda for a case
|
||||||
|
*/
|
||||||
|
export async function generateBrace(
|
||||||
|
caseId: string,
|
||||||
|
options?: { experiment?: string; config?: Record<string, unknown> }
|
||||||
|
): Promise<GenerateBraceResponse> {
|
||||||
|
return await safeFetch<GenerateBraceResponse>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/generate-brace`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
caseId,
|
||||||
|
experiment: options?.experiment ?? "experiment_3",
|
||||||
|
config: options?.config,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get brace generation outputs for a case (with presigned URLs)
|
||||||
|
*/
|
||||||
|
export async function getBraceOutputs(caseId: string): Promise<BraceOutputsResponse> {
|
||||||
|
return await safeFetch<BraceOutputsResponse>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/brace-outputs`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a presigned download URL for a case asset (e.g., X-ray)
|
||||||
|
*/
|
||||||
|
export async function getDownloadUrl(
|
||||||
|
caseId: string,
|
||||||
|
assetType: "xray" | "landmarks" | "measurements"
|
||||||
|
): Promise<{ url: string }> {
|
||||||
|
return await safeFetch<{ url: string }>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/download-url?type=${assetType}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full workflow: Create case -> Upload X-ray -> Generate brace
|
||||||
|
*/
|
||||||
|
export async function analyzeXray(
|
||||||
|
file: File,
|
||||||
|
options?: { experiment?: string; config?: Record<string, unknown> }
|
||||||
|
): Promise<{ caseId: string; result: GenerateBraceResponse }> {
|
||||||
|
// Step 1: Create a new case
|
||||||
|
const { caseId } = await createCase({ notes: `X-ray analysis: ${file.name}` });
|
||||||
|
console.log(`Created case: ${caseId}`);
|
||||||
|
|
||||||
|
// Step 2: Get presigned URL for upload
|
||||||
|
const { url: uploadUrl } = await getUploadUrl(caseId, "ap.jpg", file.type);
|
||||||
|
console.log(`Got upload URL for case: ${caseId}`);
|
||||||
|
|
||||||
|
// Step 3: Upload file to S3
|
||||||
|
await uploadToS3(uploadUrl, file);
|
||||||
|
console.log(`Uploaded X-ray to S3 for case: ${caseId}`);
|
||||||
|
|
||||||
|
// Step 4: Generate brace
|
||||||
|
const result = await generateBrace(caseId, options);
|
||||||
|
console.log(`Brace generation complete for case: ${caseId}`);
|
||||||
|
|
||||||
|
return { caseId, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a case and all associated files
|
||||||
|
*/
|
||||||
|
export async function deleteCase(caseId: string): Promise<{ message: string }> {
|
||||||
|
return await safeFetch<{ message: string }>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PIPELINE DEV API - New Stage-based endpoints
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type VertebraData = {
|
||||||
|
level: string;
|
||||||
|
detected: boolean;
|
||||||
|
scoliovis_data: {
|
||||||
|
centroid_px: [number, number] | null;
|
||||||
|
corners_px: [number, number][] | null;
|
||||||
|
orientation_deg: number | null;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
manual_override: {
|
||||||
|
enabled: boolean;
|
||||||
|
centroid_px: [number, number] | null;
|
||||||
|
corners_px: [number, number][] | null;
|
||||||
|
orientation_deg: number | null;
|
||||||
|
confidence: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
final_values: {
|
||||||
|
centroid_px: [number, number] | null;
|
||||||
|
corners_px: [number, number][] | null;
|
||||||
|
orientation_deg: number | null;
|
||||||
|
confidence: number;
|
||||||
|
source: 'scoliovis' | 'manual' | 'undetected';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VertebraeStructure = {
|
||||||
|
all_levels: string[];
|
||||||
|
detected_count: number;
|
||||||
|
total_count: number;
|
||||||
|
vertebrae: VertebraData[];
|
||||||
|
manual_edit_instructions: {
|
||||||
|
to_override: string;
|
||||||
|
final_values_rule: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LandmarksResult = {
|
||||||
|
case_id: string;
|
||||||
|
status: string;
|
||||||
|
input: {
|
||||||
|
image_dimensions: { width: number; height: number };
|
||||||
|
pixel_spacing_mm: number | null;
|
||||||
|
};
|
||||||
|
detection_quality: {
|
||||||
|
vertebrae_count: number;
|
||||||
|
average_confidence: number;
|
||||||
|
};
|
||||||
|
cobb_angles: {
|
||||||
|
PT: number;
|
||||||
|
MT: number;
|
||||||
|
TL: number;
|
||||||
|
max: number;
|
||||||
|
PT_severity: string;
|
||||||
|
MT_severity: string;
|
||||||
|
TL_severity: string;
|
||||||
|
};
|
||||||
|
rigo_classification: RigoClassification;
|
||||||
|
curve_type: string;
|
||||||
|
vertebrae_structure: VertebraeStructure;
|
||||||
|
visualization_path?: string;
|
||||||
|
visualization_url?: string;
|
||||||
|
json_path?: string;
|
||||||
|
json_url?: string;
|
||||||
|
processing_time_ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecalculationResult = {
|
||||||
|
case_id: string;
|
||||||
|
status: string;
|
||||||
|
cobb_angles: {
|
||||||
|
PT: number;
|
||||||
|
MT: number;
|
||||||
|
TL: number;
|
||||||
|
max: number;
|
||||||
|
PT_severity: string;
|
||||||
|
MT_severity: string;
|
||||||
|
TL_severity: string;
|
||||||
|
};
|
||||||
|
rigo_classification: RigoClassification;
|
||||||
|
curve_type: string;
|
||||||
|
apex_indices: number[];
|
||||||
|
vertebrae_used: number;
|
||||||
|
processing_time_ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 1: Upload X-ray and detect landmarks
|
||||||
|
*/
|
||||||
|
export async function uploadXrayForCase(caseId: string, file: File): Promise<{ filename: string; path: string }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
// Use API_BASE if set, otherwise use /api prefix for production
|
||||||
|
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : API_PREFIX;
|
||||||
|
const res = await fetch(`${base}/cases/${encodeURIComponent(caseId)}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Upload failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 1: Detect landmarks (no brace generation)
|
||||||
|
*/
|
||||||
|
export async function detectLandmarks(caseId: string): Promise<LandmarksResult> {
|
||||||
|
return await safeFetch<LandmarksResult>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/detect-landmarks`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 1: Update landmarks (manual edits)
|
||||||
|
*/
|
||||||
|
export async function updateLandmarks(
|
||||||
|
caseId: string,
|
||||||
|
landmarksData: VertebraeStructure
|
||||||
|
): Promise<{ caseId: string; status: string }> {
|
||||||
|
return await safeFetch<{ caseId: string; status: string }>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/landmarks`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ landmarks_data: landmarksData }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 1->2: Approve landmarks and move to analysis
|
||||||
|
*/
|
||||||
|
export async function approveLandmarks(
|
||||||
|
caseId: string,
|
||||||
|
updatedLandmarks?: VertebraeStructure
|
||||||
|
): Promise<{ caseId: string; status: string; next_step: string }> {
|
||||||
|
return await safeFetch<{ caseId: string; status: string; next_step: string }>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/approve-landmarks`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ updated_landmarks: updatedLandmarks }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 2: Recalculate Cobb angles and Rigo from landmarks
|
||||||
|
*/
|
||||||
|
export async function recalculateAnalysis(caseId: string): Promise<RecalculationResult> {
|
||||||
|
return await safeFetch<RecalculationResult>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/recalculate`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 3: Generate brace from approved landmarks
|
||||||
|
*/
|
||||||
|
export async function generateBraceFromLandmarks(
|
||||||
|
caseId: string,
|
||||||
|
options?: { experiment?: string }
|
||||||
|
): Promise<GenerateBraceResponse> {
|
||||||
|
return await safeFetch<GenerateBraceResponse>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/generate-brace`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
experiment: options?.experiment ?? 'experiment_9',
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 3: Update brace markers (manual edits)
|
||||||
|
*/
|
||||||
|
export async function updateMarkers(
|
||||||
|
caseId: string,
|
||||||
|
markersData: Record<string, unknown>
|
||||||
|
): Promise<{ caseId: string; status: string }> {
|
||||||
|
return await safeFetch<{ caseId: string; status: string }>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/markers`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ markers_data: markersData }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate GLB brace with markers
|
||||||
|
*/
|
||||||
|
export async function generateGlbBrace(
|
||||||
|
caseId: string,
|
||||||
|
options?: {
|
||||||
|
rigoType?: string;
|
||||||
|
templateType?: 'regular' | 'vase';
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
caseId: string;
|
||||||
|
rigoType: string;
|
||||||
|
templateType: string;
|
||||||
|
outputs: { glb?: string; stl?: string; json?: string };
|
||||||
|
markers: Record<string, number[]>;
|
||||||
|
pressureZones: Array<{
|
||||||
|
name: string;
|
||||||
|
marker_name: string;
|
||||||
|
position: number[];
|
||||||
|
zone_type: string;
|
||||||
|
direction: string;
|
||||||
|
depth_mm: number;
|
||||||
|
}>;
|
||||||
|
meshStats: { vertices: number; faces: number };
|
||||||
|
}> {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (options?.rigoType) formData.append('rigo_type', options.rigoType);
|
||||||
|
if (options?.templateType) formData.append('template_type', options.templateType);
|
||||||
|
formData.append('case_id', caseId);
|
||||||
|
|
||||||
|
return await safeFetch(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/generate-glb`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate both brace types (regular + vase) for comparison
|
||||||
|
*/
|
||||||
|
export type BothBracesResponse = {
|
||||||
|
caseId: string;
|
||||||
|
rigoType: string;
|
||||||
|
cobbAngles: { PT: number; MT: number; TL: number };
|
||||||
|
bodyScanUsed: boolean;
|
||||||
|
braces: {
|
||||||
|
regular?: {
|
||||||
|
outputs: { glb?: string; stl?: string; json?: string };
|
||||||
|
markers: Record<string, number[]>;
|
||||||
|
pressureZones: Array<{
|
||||||
|
name: string;
|
||||||
|
marker_name: string;
|
||||||
|
position: number[];
|
||||||
|
zone_type: string;
|
||||||
|
direction: string;
|
||||||
|
depth_mm: number;
|
||||||
|
}>;
|
||||||
|
meshStats: { vertices: number; faces: number };
|
||||||
|
};
|
||||||
|
vase?: {
|
||||||
|
outputs: { glb?: string; stl?: string; json?: string };
|
||||||
|
markers: Record<string, number[]>;
|
||||||
|
pressureZones: Array<{
|
||||||
|
name: string;
|
||||||
|
marker_name: string;
|
||||||
|
position: number[];
|
||||||
|
zone_type: string;
|
||||||
|
direction: string;
|
||||||
|
depth_mm: number;
|
||||||
|
}>;
|
||||||
|
meshStats: { vertices: number; faces: number };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateBothBraces(
|
||||||
|
caseId: string,
|
||||||
|
options?: { rigoType?: string }
|
||||||
|
): Promise<BothBracesResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (options?.rigoType) formData.append('rigo_type', options.rigoType);
|
||||||
|
formData.append('case_id', caseId);
|
||||||
|
|
||||||
|
const result = await safeFetch<BothBracesResponse>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/generate-both-braces`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert relative URLs to absolute URLs for 3D loaders
|
||||||
|
if (result.braces?.regular?.outputs) {
|
||||||
|
result.braces.regular.outputs = {
|
||||||
|
glb: toAbsoluteFileUrl(result.braces.regular.outputs.glb),
|
||||||
|
stl: toAbsoluteFileUrl(result.braces.regular.outputs.stl),
|
||||||
|
json: toAbsoluteFileUrl(result.braces.regular.outputs.json),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result.braces?.vase?.outputs) {
|
||||||
|
result.braces.vase.outputs = {
|
||||||
|
glb: toAbsoluteFileUrl(result.braces.vase.outputs.glb),
|
||||||
|
stl: toAbsoluteFileUrl(result.braces.vase.outputs.stl),
|
||||||
|
json: toAbsoluteFileUrl(result.braces.vase.outputs.json),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get case assets (uploaded files and outputs)
|
||||||
|
*/
|
||||||
|
export async function getCaseAssets(caseId: string): Promise<{
|
||||||
|
caseId: string;
|
||||||
|
assets: {
|
||||||
|
uploads: { filename: string; url: string }[];
|
||||||
|
outputs: { filename: string; url: string }[];
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const result = await safeFetch<{
|
||||||
|
caseId: string;
|
||||||
|
assets: {
|
||||||
|
uploads: { filename: string; url: string }[];
|
||||||
|
outputs: { filename: string; url: string }[];
|
||||||
|
};
|
||||||
|
}>(`/cases/${encodeURIComponent(caseId)}/assets`);
|
||||||
|
|
||||||
|
// Convert relative URLs to absolute
|
||||||
|
if (result.assets?.uploads) {
|
||||||
|
result.assets.uploads = result.assets.uploads.map(f => ({
|
||||||
|
...f,
|
||||||
|
url: toAbsoluteFileUrl(f.url) || f.url
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (result.assets?.outputs) {
|
||||||
|
result.assets.outputs = result.assets.outputs.map(f => ({
|
||||||
|
...f,
|
||||||
|
url: toAbsoluteFileUrl(f.url) || f.url
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// BODY SCAN API (Stage 3)
|
||||||
|
// ==============================================
|
||||||
|
|
||||||
|
export type BodyScanResponse = {
|
||||||
|
caseId: string;
|
||||||
|
has_body_scan: boolean;
|
||||||
|
body_scan: {
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
|
metadata: BodyScanMetadata;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BodyScanUploadResponse = {
|
||||||
|
caseId: string;
|
||||||
|
status: string;
|
||||||
|
body_scan: {
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
|
metadata: BodyScanMetadata;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload body scan (STL/OBJ/PLY)
|
||||||
|
*/
|
||||||
|
export async function uploadBodyScan(
|
||||||
|
caseId: string,
|
||||||
|
file: File
|
||||||
|
): Promise<BodyScanUploadResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
// Use API_BASE if set, otherwise use /api prefix for production
|
||||||
|
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : API_PREFIX;
|
||||||
|
const response = await fetch(`${base}/cases/${encodeURIComponent(caseId)}/body-scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({ message: 'Upload failed' }));
|
||||||
|
throw new Error(err.message || 'Failed to upload body scan');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get body scan info
|
||||||
|
*/
|
||||||
|
export async function getBodyScan(caseId: string): Promise<BodyScanResponse> {
|
||||||
|
return await safeFetch(`/cases/${encodeURIComponent(caseId)}/body-scan`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete body scan
|
||||||
|
*/
|
||||||
|
export async function deleteBodyScan(
|
||||||
|
caseId: string
|
||||||
|
): Promise<{ caseId: string; status: string; message: string }> {
|
||||||
|
return await safeFetch(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/body-scan`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip body scan stage and proceed to brace generation
|
||||||
|
*/
|
||||||
|
export async function skipBodyScan(
|
||||||
|
caseId: string
|
||||||
|
): Promise<{ caseId: string; status: string; message: string }> {
|
||||||
|
return await safeFetch(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/skip-body-scan`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a modified brace file to the case outputs
|
||||||
|
* Used when the user modifies a brace in the inline editor
|
||||||
|
*/
|
||||||
|
export async function uploadModifiedBrace(
|
||||||
|
caseId: string,
|
||||||
|
braceType: 'regular' | 'vase',
|
||||||
|
fileType: 'stl' | 'glb',
|
||||||
|
blob: Blob,
|
||||||
|
transformParams?: Record<string, unknown>
|
||||||
|
): Promise<{
|
||||||
|
caseId: string;
|
||||||
|
status: string;
|
||||||
|
output: {
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
s3Key: string;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const filename = `${braceType}_modified.${fileType}`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob, filename);
|
||||||
|
formData.append('brace_type', braceType);
|
||||||
|
formData.append('file_type', fileType);
|
||||||
|
if (transformParams) {
|
||||||
|
formData.append('transform_params', JSON.stringify(transformParams));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use API_BASE if set, otherwise use /api prefix for production
|
||||||
|
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : API_PREFIX;
|
||||||
|
const response = await fetch(
|
||||||
|
`${base}/cases/${encodeURIComponent(caseId)}/upload-modified-brace`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({ message: 'Upload failed' }));
|
||||||
|
throw new Error(err.message || 'Failed to upload modified brace');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get presigned URL for uploading modified brace to case storage
|
||||||
|
*/
|
||||||
|
export async function getModifiedBraceUploadUrl(
|
||||||
|
caseId: string,
|
||||||
|
braceType: 'regular' | 'vase',
|
||||||
|
fileType: 'stl' | 'glb'
|
||||||
|
): Promise<{ url: string; s3Key: string; contentType: string }> {
|
||||||
|
const filename = `${braceType}_modified.${fileType}`;
|
||||||
|
return await safeFetch<{ url: string; s3Key: string; contentType: string }>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/upload-url`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
uploadType: 'modified_brace',
|
||||||
|
braceType,
|
||||||
|
fileType,
|
||||||
|
filename,
|
||||||
|
contentType: fileType === 'stl' ? 'application/octet-stream' : 'model/gltf-binary',
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload blob to S3 using presigned URL
|
||||||
|
*/
|
||||||
|
export async function uploadBlobToS3(presignedUrl: string, blob: Blob, contentType: string): Promise<void> {
|
||||||
|
const response = await fetch(presignedUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: blob,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
frontend/src/api/rigoApi.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Rigo Backend API Client
|
||||||
|
// Backend URL: http://3.129.218.141:8000
|
||||||
|
|
||||||
|
const RIGO_API_BASE = "http://3.129.218.141:8000";
|
||||||
|
|
||||||
|
export interface AnalysisResult {
|
||||||
|
pattern: string;
|
||||||
|
apex: string;
|
||||||
|
cobb_angle: number;
|
||||||
|
thoracic_convexity?: string;
|
||||||
|
lumbar_cobb_deg?: number;
|
||||||
|
l4_tilt_deg?: number;
|
||||||
|
l5_tilt_deg?: number;
|
||||||
|
pelvic_tilt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BraceParameters {
|
||||||
|
pressure_pad_level: string;
|
||||||
|
pressure_pad_depth: string;
|
||||||
|
expansion_window_side: string;
|
||||||
|
lumbar_support: boolean;
|
||||||
|
include_shell: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisResponse {
|
||||||
|
success: boolean;
|
||||||
|
analysis: AnalysisResult;
|
||||||
|
brace_params: BraceParameters;
|
||||||
|
model_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegenerateRequest {
|
||||||
|
pressure_pad_level: string;
|
||||||
|
pressure_pad_depth: string;
|
||||||
|
expansion_window_side: string;
|
||||||
|
lumbar_support: boolean;
|
||||||
|
include_shell: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rigoApi = {
|
||||||
|
/**
|
||||||
|
* Check backend health
|
||||||
|
*/
|
||||||
|
health: async (): Promise<{ status: string; service: string }> => {
|
||||||
|
const res = await fetch(`${RIGO_API_BASE}/api/health`);
|
||||||
|
if (!res.ok) throw new Error(`Health check failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze X-ray image and generate brace model
|
||||||
|
*/
|
||||||
|
analyze: async (imageFile: File): Promise<AnalysisResponse> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("image", imageFile);
|
||||||
|
|
||||||
|
const res = await fetch(`${RIGO_API_BASE}/api/analyze`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Analysis failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate brace with different parameters
|
||||||
|
*/
|
||||||
|
regenerate: async (params: RegenerateRequest): Promise<{ success: boolean; model_url: string }> => {
|
||||||
|
const res = await fetch(`${RIGO_API_BASE}/api/regenerate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({ detail: "Unknown error" }));
|
||||||
|
throw new Error(error.detail || "Regeneration failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full URL for a model file
|
||||||
|
*/
|
||||||
|
getModelUrl: (filename: string): string => {
|
||||||
|
return `${RIGO_API_BASE}/api/models/${filename}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base URL (for constructing model URLs from relative paths)
|
||||||
|
*/
|
||||||
|
getBaseUrl: (): string => {
|
||||||
|
return RIGO_API_BASE;
|
||||||
|
},
|
||||||
|
};
|
||||||
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
109
frontend/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
type NavItemProps = {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function NavItem({ label, onClick, disabled, active }: NavItemProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"bf-nav-item",
|
||||||
|
active ? "is-active" : "",
|
||||||
|
disabled ? "is-disabled" : "",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
const [shouldFadeIn, setShouldFadeIn] = useState(false);
|
||||||
|
const prevPathRef = useRef(location.pathname);
|
||||||
|
|
||||||
|
const isCases = location.pathname === "/cases" || location.pathname.startsWith("/cases/");
|
||||||
|
const isEditShell = location.pathname.startsWith("/editor");
|
||||||
|
const isAdmin = location.pathname.startsWith("/admin");
|
||||||
|
const isLanding = location.pathname === "/landing";
|
||||||
|
const userIsAdmin = user?.role === "admin";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prevPath = prevPathRef.current;
|
||||||
|
prevPathRef.current = location.pathname;
|
||||||
|
|
||||||
|
if (prevPath === "/landing" && location.pathname !== "/landing") {
|
||||||
|
setShouldFadeIn(true);
|
||||||
|
const t = window.setTimeout(() => setShouldFadeIn(false), 560);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
nav("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bf-shell">
|
||||||
|
{!isLanding && (
|
||||||
|
<header className="bf-header">
|
||||||
|
<div className="bf-left">
|
||||||
|
<div
|
||||||
|
className="bf-brand"
|
||||||
|
onClick={() => nav("/")}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && nav("/")}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
Brace<span className="bf-brand-accent">iQ</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="bf-nav" aria-label="Primary navigation">
|
||||||
|
<NavItem label="Cases" active={isCases} onClick={() => nav("/cases")} />
|
||||||
|
<NavItem label="Editor" active={isEditShell} onClick={() => nav("/editor")} />
|
||||||
|
{userIsAdmin && (
|
||||||
|
<NavItem label="Admin" active={isAdmin} onClick={() => nav("/admin")} />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-right">
|
||||||
|
{user && (
|
||||||
|
<div className="bf-user-menu">
|
||||||
|
<span className="bf-user-name">{user.fullName || user.username}</span>
|
||||||
|
<button className="bf-logout-btn" onClick={handleLogout}>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main
|
||||||
|
className={[
|
||||||
|
"bf-content",
|
||||||
|
isLanding ? "bf-content--landing" : "",
|
||||||
|
!isLanding && shouldFadeIn ? "bf-content--fade-in" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/components/CaseTimeline.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export default function CaseTimeline({ }: { caseId?: string }) {
|
||||||
|
// Placeholder timeline. Real implementation would fetch step records.
|
||||||
|
const steps = [
|
||||||
|
'XrayIngestNormalize',
|
||||||
|
'BiomechMeasurementExtractor',
|
||||||
|
'RigoRuleClassifier',
|
||||||
|
'BraceTemplateSelector',
|
||||||
|
'BraceParametricDeformer',
|
||||||
|
'MeshFinalizerExporter',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{steps.map((s) => (
|
||||||
|
<div key={s} style={{ padding: 8, border: '1px solid #ddd', borderRadius: 4 }}>
|
||||||
|
{s}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/components/LandmarkCanvas.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type Point = { x: number; y: number };
|
||||||
|
export type LandmarkKey =
|
||||||
|
| "pelvis_mid"
|
||||||
|
| "t1_center"
|
||||||
|
| "tp_point"
|
||||||
|
| "csl_p1"
|
||||||
|
| "csl_p2";
|
||||||
|
|
||||||
|
const LANDMARK_ORDER: Array<{ key: LandmarkKey; label: string }> = [
|
||||||
|
{ key: "pelvis_mid", label: "Pelvis Mid" },
|
||||||
|
{ key: "t1_center", label: "T1 Center" },
|
||||||
|
{ key: "tp_point", label: "Thoracic Prominence (TP)" },
|
||||||
|
{ key: "csl_p1", label: "CSL Point 1 (top)" },
|
||||||
|
{ key: "csl_p2", label: "CSL Point 2 (bottom)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LandmarkCanvas({
|
||||||
|
imageUrl,
|
||||||
|
onChange,
|
||||||
|
initialLandmarks,
|
||||||
|
}: {
|
||||||
|
imageUrl?: string;
|
||||||
|
onChange: (landmarks: Record<string, Point>, completed: boolean) => void;
|
||||||
|
initialLandmarks?: Record<string, Point>;
|
||||||
|
}) {
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
const [landmarks, setLandmarks] = useState<Record<string, Point>>({});
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
|
const active = LANDMARK_ORDER[activeIndex];
|
||||||
|
|
||||||
|
const completed = useMemo(
|
||||||
|
() => LANDMARK_ORDER.every((l) => Boolean(landmarks[l.key])),
|
||||||
|
[landmarks]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange(landmarks, completed);
|
||||||
|
}, [landmarks, completed, onChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialLandmarks && Object.keys(initialLandmarks).length) {
|
||||||
|
setLandmarks(initialLandmarks);
|
||||||
|
}
|
||||||
|
}, [initialLandmarks]);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setLandmarks({});
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e: React.MouseEvent) {
|
||||||
|
if (!imgRef.current) return;
|
||||||
|
|
||||||
|
const rect = imgRef.current.getBoundingClientRect();
|
||||||
|
const x = Math.round(e.clientX - rect.left);
|
||||||
|
const y = Math.round(e.clientY - rect.top);
|
||||||
|
|
||||||
|
const next = { ...landmarks, [active.key]: { x, y } };
|
||||||
|
setLandmarks(next);
|
||||||
|
|
||||||
|
if (activeIndex < LANDMARK_ORDER.length - 1) {
|
||||||
|
setActiveIndex(activeIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="landmark-layout">
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Landmarks</h3>
|
||||||
|
|
||||||
|
<ol className="list">
|
||||||
|
{LANDMARK_ORDER.map((l, idx) => (
|
||||||
|
<li key={l.key} className={idx === activeIndex ? "active" : ""}>
|
||||||
|
<div className="label">{l.label}</div>
|
||||||
|
<div className="meta">
|
||||||
|
{landmarks[l.key]
|
||||||
|
? `x=${landmarks[l.key].x}, y=${landmarks[l.key].y}`
|
||||||
|
: "pending"}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="row gap">
|
||||||
|
<button className="btn secondary" onClick={reset}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<div className="pill">
|
||||||
|
{completed ? "Ready to submit" : `Next: ${active.label}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="canvasWrap">
|
||||||
|
<div className="imgWrap fixed-250" onClick={handleClick}>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={imageUrl}
|
||||||
|
className="xray"
|
||||||
|
alt="AP X-ray"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<svg className="overlay">
|
||||||
|
{landmarks["csl_p1"] && landmarks["csl_p2"] && (
|
||||||
|
<line
|
||||||
|
x1={landmarks["csl_p1"].x}
|
||||||
|
y1={landmarks["csl_p1"].y}
|
||||||
|
x2={landmarks["csl_p2"].x}
|
||||||
|
y2={landmarks["csl_p2"].y}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="6 6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.entries(landmarks).map(([k, p]) => (
|
||||||
|
<g key={k}>
|
||||||
|
<circle cx={p.x} cy={p.y} r="6" fill="white" />
|
||||||
|
<circle cx={p.x} cy={p.y} r="3" fill="black" />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hint">Click to place the active landmark.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export default function StatusBadge({ status }: { status: string }) {
|
||||||
|
const normalized = (status || "").toLowerCase();
|
||||||
|
|
||||||
|
const badgeClass = [
|
||||||
|
"bf-status-badge",
|
||||||
|
normalized === "created" ? "is-created" : "",
|
||||||
|
normalized === "processing" ? "is-processing" : "",
|
||||||
|
normalized === "failed" ? "is-failed" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return <span className={badgeClass}>{status}</span>;
|
||||||
|
}
|
||||||
90
frontend/src/components/XrayUploader.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
caseId?: string;
|
||||||
|
onUploaded?: () => void; // optional callback to refresh assets/status
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function XrayUploader({ caseId, onUploaded }: Props) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!caseId) {
|
||||||
|
setError('No caseId provided. Create or load a case first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1️⃣ Ask backend for pre-signed upload URL
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/cases/${caseId}/upload-url`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ view: "ap" }) // AP view for MVP
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to get upload URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uploadUrl } = await res.json();
|
||||||
|
|
||||||
|
// 2️⃣ Upload file directly to S3
|
||||||
|
const uploadRes = await fetch(uploadUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadRes.ok) {
|
||||||
|
throw new Error("Upload to S3 failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ Notify parent to refresh assets / status
|
||||||
|
onUploaded?.();
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err.message || "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ border: "1px dashed #ccc", padding: 16 }}>
|
||||||
|
<h3>X-ray Upload (AP View)</h3>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".dcm,.jpg,.png"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!file || loading}
|
||||||
|
>
|
||||||
|
{loading ? "Uploading..." : "Upload X-ray"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: "red", marginTop: 8 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
frontend/src/components/pipeline/BodyScanUploadStage.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Stage 3: Body Scan Upload
|
||||||
|
* Allows uploading a 3D body scan (STL/OBJ/PLY) for patient-specific fitting
|
||||||
|
*/
|
||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
import type { BodyScanMetadata, BodyScanResponse } from '../../api/braceflowApi';
|
||||||
|
import BodyScanViewer from '../three/BodyScanViewer';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
caseId: string;
|
||||||
|
bodyScanData: BodyScanResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onUpload: (file: File) => Promise<void>;
|
||||||
|
onSkip: () => Promise<void>;
|
||||||
|
onContinue: () => void;
|
||||||
|
onDelete: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BodyScanUploadStage({
|
||||||
|
caseId,
|
||||||
|
bodyScanData,
|
||||||
|
isLoading,
|
||||||
|
onUpload,
|
||||||
|
onSkip,
|
||||||
|
onContinue,
|
||||||
|
onDelete,
|
||||||
|
}: Props) {
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const viewerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const hasBodyScan = bodyScanData?.has_body_scan && bodyScanData.body_scan;
|
||||||
|
const metadata = bodyScanData?.body_scan?.metadata;
|
||||||
|
|
||||||
|
// Handle drag events
|
||||||
|
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (e.type === 'dragleave') {
|
||||||
|
setDragActive(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle file drop
|
||||||
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragActive(false);
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
await handleFile(files[0]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
await handleFile(files[0]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Process uploaded file
|
||||||
|
const handleFile = async (file: File) => {
|
||||||
|
// Validate file type
|
||||||
|
const allowedTypes = ['.stl', '.obj', '.ply', '.glb', '.gltf'];
|
||||||
|
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(ext)) {
|
||||||
|
setError(`Invalid file type. Allowed: ${allowedTypes.join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setUploadProgress('Uploading...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onUpload(file);
|
||||||
|
setUploadProgress(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Upload failed');
|
||||||
|
setUploadProgress(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format measurement
|
||||||
|
const formatMeasurement = (value: number | undefined, unit = 'mm') => {
|
||||||
|
if (value === undefined || value === null) return 'N/A';
|
||||||
|
return `${value.toFixed(1)} ${unit}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage body-scan-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 3: Body Scan Upload</h2>
|
||||||
|
<div className="stage-status">
|
||||||
|
<span className="status-badge status-processing">Processing...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stage-content">
|
||||||
|
<div className="body-scan-loading">
|
||||||
|
<div className="spinner large"></div>
|
||||||
|
<p>Processing body scan...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage body-scan-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 3: Body Scan Upload</h2>
|
||||||
|
<div className="stage-status">
|
||||||
|
{hasBodyScan ? (
|
||||||
|
<span className="status-badge status-complete">Uploaded</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-badge status-pending">Optional</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-content body-scan-content">
|
||||||
|
{/* Upload Area / Preview */}
|
||||||
|
<div className="body-scan-main">
|
||||||
|
{!hasBodyScan ? (
|
||||||
|
<div
|
||||||
|
className={`upload-dropzone ${dragActive ? 'drag-active' : ''}`}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".stl,.obj,.ply,.glb,.gltf"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<div className="dropzone-content">
|
||||||
|
<div className="dropzone-icon">📦</div>
|
||||||
|
<h3>Upload 3D Body Scan</h3>
|
||||||
|
<p>Drag and drop or click to select</p>
|
||||||
|
<p className="file-types">STL, OBJ, PLY, GLB supported</p>
|
||||||
|
{uploadProgress && <p className="upload-progress">{uploadProgress}</p>}
|
||||||
|
{error && <p className="upload-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="body-scan-preview body-scan-preview-3d" ref={viewerRef}>
|
||||||
|
{/* 3D Spinning Preview - fills container, slow lazy susan rotation */}
|
||||||
|
<BodyScanViewer
|
||||||
|
scanUrl={bodyScanData?.body_scan?.url || null}
|
||||||
|
autoRotate={true}
|
||||||
|
rotationSpeed={0.005}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* File info overlay */}
|
||||||
|
<div className="preview-info-overlay">
|
||||||
|
<span className="filename">{metadata?.filename || 'body_scan.stl'}</span>
|
||||||
|
{metadata?.vertex_count && (
|
||||||
|
<span className="vertex-count">{metadata.vertex_count.toLocaleString()} vertices</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-remove" onClick={onDelete}>
|
||||||
|
Remove Scan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Panel */}
|
||||||
|
<div className="body-scan-sidebar">
|
||||||
|
<div className="info-panel">
|
||||||
|
<h3>Why Upload a Body Scan?</h3>
|
||||||
|
<p>
|
||||||
|
A 3D body scan allows us to generate a perfectly fitted brace
|
||||||
|
that matches your exact body measurements.
|
||||||
|
</p>
|
||||||
|
<ul className="benefits-list">
|
||||||
|
<li>Precise fit based on body shape</li>
|
||||||
|
<li>Automatic clearance calculation</li>
|
||||||
|
<li>Better pressure zone placement</li>
|
||||||
|
<li>3D printable shell output</li>
|
||||||
|
</ul>
|
||||||
|
<p className="optional-note">
|
||||||
|
<strong>Optional:</strong> You can skip this step to generate
|
||||||
|
a standard-sized brace based on X-ray analysis only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body Measurements */}
|
||||||
|
{hasBodyScan && metadata && (
|
||||||
|
<div className="measurements-panel">
|
||||||
|
<h3>Body Measurements</h3>
|
||||||
|
<div className="measurements-grid">
|
||||||
|
{metadata.total_height_mm !== undefined && (
|
||||||
|
<div className="measurement-item">
|
||||||
|
<span className="label">Total Height</span>
|
||||||
|
<span className="value">{formatMeasurement(metadata.total_height_mm)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadata.shoulder_width_mm !== undefined && (
|
||||||
|
<div className="measurement-item">
|
||||||
|
<span className="label">Shoulder Width</span>
|
||||||
|
<span className="value">{formatMeasurement(metadata.shoulder_width_mm)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadata.chest_width_mm !== undefined && (
|
||||||
|
<div className="measurement-item">
|
||||||
|
<span className="label">Chest Width</span>
|
||||||
|
<span className="value">{formatMeasurement(metadata.chest_width_mm)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadata.chest_depth_mm !== undefined && (
|
||||||
|
<div className="measurement-item">
|
||||||
|
<span className="label">Chest Depth</span>
|
||||||
|
<span className="value">{formatMeasurement(metadata.chest_depth_mm)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadata.waist_width_mm !== undefined && (
|
||||||
|
<div className="measurement-item">
|
||||||
|
<span className="label">Waist Width</span>
|
||||||
|
<span className="value">{formatMeasurement(metadata.waist_width_mm)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadata.hip_width_mm !== undefined && (
|
||||||
|
<div className="measurement-item">
|
||||||
|
<span className="label">Hip Width</span>
|
||||||
|
<span className="value">{formatMeasurement(metadata.hip_width_mm)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadata.total_height_mm !== undefined && (
|
||||||
|
<div className="measurement-item highlight">
|
||||||
|
<span className="label">Brace Coverage (65%)</span>
|
||||||
|
<span className="value">{formatMeasurement(metadata.total_height_mm * 0.65)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="stage-actions">
|
||||||
|
{!hasBodyScan ? (
|
||||||
|
<>
|
||||||
|
<button className="btn secondary" onClick={onSkip}>
|
||||||
|
Skip (Use X-ray Only)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
Upload Body Scan
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="btn secondary" onClick={onDelete}>
|
||||||
|
Remove & Skip
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" onClick={onContinue}>
|
||||||
|
Continue to Brace Generation
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
frontend/src/components/pipeline/BraceEditorStage.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Stage 5: Brace Editor
|
||||||
|
* 3D visualization and deformation controls for the generated brace
|
||||||
|
*
|
||||||
|
* Based on EXPERIMENT_6's brace-transform-playground-v2
|
||||||
|
*/
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import BraceViewer from '../three/BraceViewer';
|
||||||
|
import type { GenerateBraceResponse } from '../../api/braceflowApi';
|
||||||
|
|
||||||
|
type MarkerInfo = {
|
||||||
|
name: string;
|
||||||
|
position: [number, number, number];
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeformationParams = {
|
||||||
|
thoracicPadDepth: number;
|
||||||
|
lumbarPadDepth: number;
|
||||||
|
trunkShift: number;
|
||||||
|
rotationCorrection: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
caseId: string;
|
||||||
|
braceData: GenerateBraceResponse | null;
|
||||||
|
onRegenerate: (params: DeformationParams) => Promise<void>;
|
||||||
|
onExportSTL: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PARAMS: DeformationParams = {
|
||||||
|
thoracicPadDepth: 15,
|
||||||
|
lumbarPadDepth: 10,
|
||||||
|
trunkShift: 0,
|
||||||
|
rotationCorrection: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BraceEditorStage({
|
||||||
|
caseId,
|
||||||
|
braceData,
|
||||||
|
onRegenerate,
|
||||||
|
onExportSTL,
|
||||||
|
}: Props) {
|
||||||
|
const [params, setParams] = useState<DeformationParams>(DEFAULT_PARAMS);
|
||||||
|
const [markers, setMarkers] = useState<MarkerInfo[]>([]);
|
||||||
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
|
const [showMarkers, setShowMarkers] = useState(true);
|
||||||
|
|
||||||
|
// Get GLB URL from brace data
|
||||||
|
const glbUrl = braceData?.outputs?.glb?.url || braceData?.outputs?.shell_glb?.url || null;
|
||||||
|
const stlUrl = braceData?.outputs?.stl?.url || braceData?.outputs?.shell_stl?.url || null;
|
||||||
|
|
||||||
|
// Handle parameter change
|
||||||
|
const handleParamChange = useCallback((key: keyof DeformationParams, value: number) => {
|
||||||
|
setParams(prev => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle regenerate
|
||||||
|
const handleRegenerate = useCallback(async () => {
|
||||||
|
setIsRegenerating(true);
|
||||||
|
try {
|
||||||
|
await onRegenerate(params);
|
||||||
|
} finally {
|
||||||
|
setIsRegenerating(false);
|
||||||
|
}
|
||||||
|
}, [params, onRegenerate]);
|
||||||
|
|
||||||
|
// Handle markers loaded
|
||||||
|
const handleMarkersLoaded = useCallback((loadedMarkers: MarkerInfo[]) => {
|
||||||
|
setMarkers(loadedMarkers);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset parameters
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setParams(DEFAULT_PARAMS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!braceData) {
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage brace-editor-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 5: Brace Editor</h2>
|
||||||
|
<div className="stage-status">
|
||||||
|
<span className="status-badge status-pending">Waiting for Brace</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stage-content">
|
||||||
|
<div className="editor-empty">
|
||||||
|
<p>Generate a brace first to use the 3D editor.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage brace-editor-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 5: Brace Editor</h2>
|
||||||
|
<div className="stage-status">
|
||||||
|
<span className="status-badge status-complete">Ready</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-content">
|
||||||
|
{/* 3D Viewer */}
|
||||||
|
<div className="brace-editor-main">
|
||||||
|
<BraceViewer
|
||||||
|
glbUrl={glbUrl}
|
||||||
|
width={700}
|
||||||
|
height={550}
|
||||||
|
showMarkers={showMarkers}
|
||||||
|
onMarkersLoaded={handleMarkersLoaded}
|
||||||
|
deformationParams={{
|
||||||
|
thoracicPadDepth: params.thoracicPadDepth,
|
||||||
|
lumbarPadDepth: params.lumbarPadDepth,
|
||||||
|
trunkShift: params.trunkShift,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* View Controls */}
|
||||||
|
<div className="viewer-controls">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showMarkers}
|
||||||
|
onChange={(e) => setShowMarkers(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Show Markers
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls Sidebar */}
|
||||||
|
<div className="brace-editor-sidebar">
|
||||||
|
{/* Deformation Controls */}
|
||||||
|
<div className="deformation-controls">
|
||||||
|
<h3>Deformation Parameters</h3>
|
||||||
|
|
||||||
|
<div className="control-group">
|
||||||
|
<label>Thoracic Pad Depth (mm)</label>
|
||||||
|
<div className="control-slider">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="30"
|
||||||
|
step="1"
|
||||||
|
value={params.thoracicPadDepth}
|
||||||
|
onChange={(e) => handleParamChange('thoracicPadDepth', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="value">{params.thoracicPadDepth}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-group">
|
||||||
|
<label>Lumbar Pad Depth (mm)</label>
|
||||||
|
<div className="control-slider">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="25"
|
||||||
|
step="1"
|
||||||
|
value={params.lumbarPadDepth}
|
||||||
|
onChange={(e) => handleParamChange('lumbarPadDepth', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="value">{params.lumbarPadDepth}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-group">
|
||||||
|
<label>Trunk Shift (mm)</label>
|
||||||
|
<div className="control-slider">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-20"
|
||||||
|
max="20"
|
||||||
|
step="1"
|
||||||
|
value={params.trunkShift}
|
||||||
|
onChange={(e) => handleParamChange('trunkShift', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="value">{params.trunkShift}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-group">
|
||||||
|
<label>Rotation Correction (°)</label>
|
||||||
|
<div className="control-slider">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-15"
|
||||||
|
max="15"
|
||||||
|
step="1"
|
||||||
|
value={params.rotationCorrection}
|
||||||
|
onChange={(e) => handleParamChange('rotationCorrection', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="value">{params.rotationCorrection}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-actions">
|
||||||
|
<button className="btn secondary small" onClick={handleReset}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn primary small"
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
disabled={isRegenerating}
|
||||||
|
>
|
||||||
|
{isRegenerating ? 'Regenerating...' : 'Apply Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Markers Panel */}
|
||||||
|
{markers.length > 0 && (
|
||||||
|
<div className="markers-panel">
|
||||||
|
<h3>Markers ({markers.length})</h3>
|
||||||
|
<div className="markers-list">
|
||||||
|
{markers.map((marker, idx) => (
|
||||||
|
<div key={idx} className="marker-item">
|
||||||
|
<span
|
||||||
|
className="marker-color"
|
||||||
|
style={{ backgroundColor: marker.color }}
|
||||||
|
/>
|
||||||
|
<span className="marker-name">{marker.name.replace('LM_', '')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Export Panel */}
|
||||||
|
<div className="export-panel">
|
||||||
|
<h3>Export</h3>
|
||||||
|
<div className="export-buttons">
|
||||||
|
{stlUrl && (
|
||||||
|
<a href={stlUrl} download={`brace_${caseId}.stl`} className="btn secondary">
|
||||||
|
Download STL
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{glbUrl && (
|
||||||
|
<a href={glbUrl} download={`brace_${caseId}.glb`} className="btn secondary">
|
||||||
|
Download GLB
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Panel */}
|
||||||
|
<div className="info-panel">
|
||||||
|
<h3>About the Editor</h3>
|
||||||
|
<p>
|
||||||
|
Adjust the deformation parameters to customize the brace fit.
|
||||||
|
Changes are previewed in real-time.
|
||||||
|
</p>
|
||||||
|
<ul className="tips-list">
|
||||||
|
<li><strong>Thoracic Pad:</strong> Pressure on thoracic curve convexity</li>
|
||||||
|
<li><strong>Lumbar Pad:</strong> Counter-pressure on lumbar region</li>
|
||||||
|
<li><strong>Trunk Shift:</strong> Lateral correction force</li>
|
||||||
|
<li><strong>Rotation:</strong> De-rotation effect</li>
|
||||||
|
</ul>
|
||||||
|
<p className="hint">
|
||||||
|
Use mouse to orbit, scroll to zoom, right-click to pan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
621
frontend/src/components/pipeline/BraceFittingStage.tsx
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
/**
|
||||||
|
* Stage 5: Brace Fitting Inspection
|
||||||
|
* Shows both braces overlaid on the body scan to visualize fit
|
||||||
|
* LEFT panel: Position, Rotation, Scale controls
|
||||||
|
* RIGHT panel: Deformation sliders (Cobb angle, apex, etc.) from Stage 4
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import BraceInlineEditor, {
|
||||||
|
type BraceTransformParams,
|
||||||
|
DEFAULT_TRANSFORM_PARAMS
|
||||||
|
} from './BraceInlineEditor';
|
||||||
|
|
||||||
|
// Three.js is loaded dynamically
|
||||||
|
let THREE: any = null;
|
||||||
|
let STLLoader: any = null;
|
||||||
|
let GLTFLoader: any = null;
|
||||||
|
let OrbitControls: any = null;
|
||||||
|
|
||||||
|
type BraceFittingStageProps = {
|
||||||
|
caseId: string;
|
||||||
|
bodyScanUrl: string | null;
|
||||||
|
regularBraceUrl: string | null;
|
||||||
|
vaseBraceUrl: string | null;
|
||||||
|
braceData: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BraceFittingStage({
|
||||||
|
caseId,
|
||||||
|
bodyScanUrl,
|
||||||
|
regularBraceUrl,
|
||||||
|
vaseBraceUrl,
|
||||||
|
braceData,
|
||||||
|
}: BraceFittingStageProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rendererRef = useRef<any>(null);
|
||||||
|
const sceneRef = useRef<any>(null);
|
||||||
|
const cameraRef = useRef<any>(null);
|
||||||
|
const controlsRef = useRef<any>(null);
|
||||||
|
const animationFrameRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Mesh references
|
||||||
|
const bodyMeshRef = useRef<any>(null);
|
||||||
|
const regularBraceMeshRef = useRef<any>(null);
|
||||||
|
const vaseBraceMeshRef = useRef<any>(null);
|
||||||
|
const regularBaseGeomRef = useRef<any>(null);
|
||||||
|
const vaseBaseGeomRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Store base transforms for relative positioning
|
||||||
|
const baseScaleRef = useRef<number>(1);
|
||||||
|
|
||||||
|
const [threeLoaded, setThreeLoaded] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Visibility controls
|
||||||
|
const [showBody, setShowBody] = useState(true);
|
||||||
|
const [showRegularBrace, setShowRegularBrace] = useState(true);
|
||||||
|
const [showVaseBrace, setShowVaseBrace] = useState(false);
|
||||||
|
const [bodyOpacity, setBodyOpacity] = useState(0.3);
|
||||||
|
const [autoRotate, setAutoRotate] = useState(false);
|
||||||
|
|
||||||
|
// Position/Rotation/Scale controls (for moving meshes)
|
||||||
|
const [bracePositionX, setBracePositionX] = useState(0);
|
||||||
|
const [bracePositionY, setBracePositionY] = useState(0);
|
||||||
|
const [bracePositionZ, setBracePositionZ] = useState(0);
|
||||||
|
const [braceRotationX, setBraceRotationX] = useState(0);
|
||||||
|
const [braceRotationY, setBraceRotationY] = useState(0);
|
||||||
|
const [braceRotationZ, setBraceRotationZ] = useState(0);
|
||||||
|
const [braceScaleX, setBraceScaleX] = useState(1.0);
|
||||||
|
const [braceScaleY, setBraceScaleY] = useState(1.0);
|
||||||
|
const [braceScaleZ, setBraceScaleZ] = useState(1.0);
|
||||||
|
|
||||||
|
// Body position/rotation/scale
|
||||||
|
const [bodyPositionX, setBodyPositionX] = useState(0);
|
||||||
|
const [bodyPositionY, setBodyPositionY] = useState(0);
|
||||||
|
const [bodyPositionZ, setBodyPositionZ] = useState(0);
|
||||||
|
const [bodyRotationX, setBodyRotationX] = useState(0);
|
||||||
|
const [bodyRotationY, setBodyRotationY] = useState(0);
|
||||||
|
const [bodyRotationZ, setBodyRotationZ] = useState(0);
|
||||||
|
const [bodyScale, setBodyScale] = useState(1.0);
|
||||||
|
|
||||||
|
// Which brace is being edited
|
||||||
|
const [activeBrace, setActiveBrace] = useState<'regular' | 'vase'>('regular');
|
||||||
|
|
||||||
|
// Transform params for each brace (deformation sliders)
|
||||||
|
const [regularParams, setRegularParams] = useState<BraceTransformParams>(() => ({
|
||||||
|
...DEFAULT_TRANSFORM_PARAMS,
|
||||||
|
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || 25,
|
||||||
|
}));
|
||||||
|
const [vaseParams, setVaseParams] = useState<BraceTransformParams>(() => ({
|
||||||
|
...DEFAULT_TRANSFORM_PARAMS,
|
||||||
|
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || 25,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
const BODY_COLOR = 0xf5d0c5;
|
||||||
|
const REGULAR_BRACE_COLOR = 0x4a90d9;
|
||||||
|
const VASE_BRACE_COLOR = 0x50c878;
|
||||||
|
|
||||||
|
// Load Three.js
|
||||||
|
useEffect(() => {
|
||||||
|
const loadThree = async () => {
|
||||||
|
try {
|
||||||
|
const threeModule = await import('three');
|
||||||
|
THREE = threeModule;
|
||||||
|
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
|
||||||
|
STLLoader = STL;
|
||||||
|
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||||
|
GLTFLoader = GLTF;
|
||||||
|
const { OrbitControls: Controls } = await import('three/examples/jsm/controls/OrbitControls.js');
|
||||||
|
OrbitControls = Controls;
|
||||||
|
setThreeLoaded(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load Three.js:', e);
|
||||||
|
setError('Failed to load 3D viewer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadThree();
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
if (rendererRef.current) rendererRef.current.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize scene
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const width = container.clientWidth;
|
||||||
|
const height = container.clientHeight;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x1a1a2e);
|
||||||
|
sceneRef.current = scene;
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
|
||||||
|
// For Z-up meshes (medical/scanner convention), view from front (Y axis)
|
||||||
|
// Camera at Y=-800 looking at torso level (Z~300)
|
||||||
|
camera.position.set(0, -800, 300);
|
||||||
|
camera.lookAt(0, 0, 300);
|
||||||
|
camera.up.set(0, 0, 1); // Z is up
|
||||||
|
cameraRef.current = camera;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.sortObjects = true;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.05;
|
||||||
|
controls.target.set(0, 0, 300); // Look at torso level
|
||||||
|
controlsRef.current = controls;
|
||||||
|
|
||||||
|
// Lighting (adjusted for Z-up)
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
keyLight.position.set(200, -300, 500); // Front-top-right
|
||||||
|
scene.add(keyLight);
|
||||||
|
const fillLight = new THREE.DirectionalLight(0x88ccff, 0.5);
|
||||||
|
fillLight.position.set(-200, -100, 400); // Front-left
|
||||||
|
scene.add(fillLight);
|
||||||
|
const backLight = new THREE.DirectionalLight(0xffffcc, 0.4);
|
||||||
|
backLight.position.set(0, 300, 300); // Back
|
||||||
|
scene.add(backLight);
|
||||||
|
|
||||||
|
// Grid on XY plane (floor for Z-up world)
|
||||||
|
const gridHelper = new THREE.GridHelper(400, 20, 0x444444, 0x333333);
|
||||||
|
gridHelper.rotation.x = Math.PI / 2; // Rotate to XY plane
|
||||||
|
gridHelper.position.z = 0; // At Z=0 (floor level)
|
||||||
|
scene.add(gridHelper);
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!container || !renderer || !camera) return;
|
||||||
|
camera.aspect = container.clientWidth / container.clientHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
|
};
|
||||||
|
const resizeObserver = new ResizeObserver(handleResize);
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [threeLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (controlsRef.current) controlsRef.current.autoRotate = autoRotate;
|
||||||
|
}, [autoRotate]);
|
||||||
|
|
||||||
|
// Deformation algorithm
|
||||||
|
const applyDeformation = useCallback((geometry: any, params: BraceTransformParams) => {
|
||||||
|
if (!THREE || !geometry) return geometry;
|
||||||
|
const deformed = geometry.clone();
|
||||||
|
const positions = deformed.getAttribute('position');
|
||||||
|
if (!positions) return deformed;
|
||||||
|
|
||||||
|
deformed.computeBoundingBox();
|
||||||
|
const bbox = deformed.boundingBox;
|
||||||
|
// For Z-up convention (medical/scanner), Z is vertical (height)
|
||||||
|
const minZ = bbox?.min?.z || 0, maxZ = bbox?.max?.z || 1;
|
||||||
|
const minX = bbox?.min?.x || -0.5, maxX = bbox?.max?.x || 0.5;
|
||||||
|
const minY = bbox?.min?.y || -0.5, maxY = bbox?.max?.y || 0.5;
|
||||||
|
const centerX = (minX + maxX) / 2, centerY = (minY + maxY) / 2;
|
||||||
|
const bboxHeight = maxZ - minZ, bboxWidth = maxX - minX, bboxDepth = maxY - minY;
|
||||||
|
|
||||||
|
const pelvis = { x: centerX, y: centerY, z: minZ };
|
||||||
|
const braceHeight = bboxHeight || 1;
|
||||||
|
const unitsPerMm = braceHeight / Math.max(1e-6, params.expectedBraceHeightMm);
|
||||||
|
const sev = Math.max(0, Math.min(1, (params.cobbDeg - 15) / 40));
|
||||||
|
const padDepthMm = (8 + 12 * sev) * params.strengthMult;
|
||||||
|
const bayClearMm = padDepthMm * 1.2;
|
||||||
|
const padDepth = padDepthMm * unitsPerMm;
|
||||||
|
const bayClear = bayClearMm * unitsPerMm;
|
||||||
|
const sizeScale = 0.9 + 0.5 * sev;
|
||||||
|
|
||||||
|
// For Z-up convention: Z is height, Y is front-back depth, X is left-right width
|
||||||
|
const features: Array<{ center: {x:number,y:number,z:number}; radii: {x:number,y:number,z:number}; depth: number; direction: 1|-1; falloffPower: number }> = [];
|
||||||
|
|
||||||
|
// Thoracic pad & bay (z is now height, y is depth)
|
||||||
|
features.push({ center: { x: centerX + bboxWidth * 0.35, y: centerY - bboxDepth * 0.1, z: minZ + bboxHeight * params.apexNorm }, radii: { x: 45*unitsPerMm*sizeScale, y: 35*unitsPerMm*sizeScale, z: 90*unitsPerMm*sizeScale }, depth: padDepth, direction: -1, falloffPower: 2.0 });
|
||||||
|
features.push({ center: { x: centerX - bboxWidth * 0.35, y: centerY - bboxDepth * 0.1, z: minZ + bboxHeight * params.apexNorm }, radii: { x: 60*unitsPerMm*sizeScale, y: 55*unitsPerMm*sizeScale, z: 110*unitsPerMm*sizeScale }, depth: bayClear, direction: 1, falloffPower: 1.6 });
|
||||||
|
// Lumbar pad & bay
|
||||||
|
features.push({ center: { x: centerX - bboxWidth * 0.3, y: centerY, z: minZ + bboxHeight * params.lumbarApexNorm }, radii: { x: 50*unitsPerMm*sizeScale, y: 40*unitsPerMm*sizeScale, z: 80*unitsPerMm*sizeScale }, depth: padDepth*0.9, direction: -1, falloffPower: 2.0 });
|
||||||
|
features.push({ center: { x: centerX + bboxWidth * 0.3, y: centerY, z: minZ + bboxHeight * params.lumbarApexNorm }, radii: { x: 65*unitsPerMm*sizeScale, y: 55*unitsPerMm*sizeScale, z: 95*unitsPerMm*sizeScale }, depth: bayClear*0.9, direction: 1, falloffPower: 1.6 });
|
||||||
|
// Hip anchors
|
||||||
|
const hipDepth = params.hipAnchorStrengthMm * unitsPerMm * params.strengthMult;
|
||||||
|
if (hipDepth > 0) {
|
||||||
|
features.push({ center: { x: centerX - bboxWidth * 0.4, y: centerY, z: minZ + bboxHeight * 0.1 }, radii: { x: 35*unitsPerMm, y: 35*unitsPerMm, z: 55*unitsPerMm }, depth: hipDepth, direction: -1, falloffPower: 2.2 });
|
||||||
|
features.push({ center: { x: centerX + bboxWidth * 0.4, y: centerY, z: minZ + bboxHeight * 0.1 }, radii: { x: 35*unitsPerMm, y: 35*unitsPerMm, z: 55*unitsPerMm }, depth: hipDepth, direction: -1, falloffPower: 2.2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < positions.count; i++) {
|
||||||
|
let x = positions.getX(i), y = positions.getY(i), z = positions.getZ(i);
|
||||||
|
if (params.mirrorX) x = -x;
|
||||||
|
// For Z-up: height is along Z axis
|
||||||
|
const heightNorm = Math.max(0, Math.min(1, (z - pelvis.z) / braceHeight));
|
||||||
|
x += params.trunkShiftMm * unitsPerMm * heightNorm * 0.8;
|
||||||
|
for (const f of features) {
|
||||||
|
const dx = (x - f.center.x) / f.radii.x, dy = (y - f.center.y) / f.radii.y, dz = (z - f.center.z) / f.radii.z;
|
||||||
|
const d2 = dx*dx + dy*dy + dz*dz;
|
||||||
|
if (d2 >= 1) continue;
|
||||||
|
const t = Math.pow(1 - d2, f.falloffPower);
|
||||||
|
const disp = f.depth * t * f.direction;
|
||||||
|
// For Z-up: radial direction is in XY plane
|
||||||
|
const axisX = x - pelvis.x, axisY = y - pelvis.y;
|
||||||
|
const len = Math.sqrt(axisX*axisX + axisY*axisY) || 1;
|
||||||
|
x += (axisX/len) * disp;
|
||||||
|
y += (axisY/len) * disp;
|
||||||
|
}
|
||||||
|
positions.setXYZ(i, x, y, z);
|
||||||
|
}
|
||||||
|
positions.needsUpdate = true;
|
||||||
|
deformed.computeVertexNormals();
|
||||||
|
return deformed;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load mesh
|
||||||
|
const loadMesh = useCallback(async (url: string, color: number, opacity: number, isBody: boolean = false): Promise<{ mesh: any; baseGeometry: any }> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ext = url.toLowerCase().split('.').pop() || '';
|
||||||
|
const createMaterial = () => new THREE.MeshStandardMaterial({
|
||||||
|
color, roughness: 0.6, metalness: 0.1, transparent: true, opacity,
|
||||||
|
side: THREE.DoubleSide, depthWrite: !isBody, depthTest: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ext === 'stl') {
|
||||||
|
new STLLoader().load(url, (geometry: any) => {
|
||||||
|
geometry.center();
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
const baseGeometry = geometry.clone();
|
||||||
|
const mesh = new THREE.Mesh(geometry, createMaterial());
|
||||||
|
// Don't apply fixed rotation - let user adjust with controls
|
||||||
|
// mesh.rotation.x = -Math.PI / 2;
|
||||||
|
mesh.renderOrder = isBody ? 10 : 1;
|
||||||
|
resolve({ mesh, baseGeometry });
|
||||||
|
}, undefined, reject);
|
||||||
|
} else if (ext === 'glb' || ext === 'gltf') {
|
||||||
|
new GLTFLoader().load(url, (gltf: any) => {
|
||||||
|
const mesh = gltf.scene;
|
||||||
|
let baseGeometry: any = null;
|
||||||
|
mesh.traverse((child: any) => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
child.material = createMaterial();
|
||||||
|
child.renderOrder = isBody ? 10 : 1;
|
||||||
|
if (!baseGeometry) baseGeometry = child.geometry.clone();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resolve({ mesh, baseGeometry });
|
||||||
|
}, undefined, reject);
|
||||||
|
} else reject(new Error(`Unsupported: ${ext}`));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load all meshes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threeLoaded || !sceneRef.current) return;
|
||||||
|
const scene = sceneRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
[bodyMeshRef, regularBraceMeshRef, vaseBraceMeshRef].forEach(ref => {
|
||||||
|
if (ref.current) { scene.remove(ref.current); ref.current = null; }
|
||||||
|
});
|
||||||
|
regularBaseGeomRef.current = null;
|
||||||
|
vaseBaseGeomRef.current = null;
|
||||||
|
|
||||||
|
const loadAll = async () => {
|
||||||
|
try {
|
||||||
|
const meshes: any[] = [];
|
||||||
|
|
||||||
|
if (regularBraceUrl) {
|
||||||
|
const { mesh, baseGeometry } = await loadMesh(regularBraceUrl, REGULAR_BRACE_COLOR, 0.9, false);
|
||||||
|
regularBraceMeshRef.current = mesh;
|
||||||
|
regularBaseGeomRef.current = baseGeometry;
|
||||||
|
scene.add(mesh);
|
||||||
|
meshes.push(mesh);
|
||||||
|
}
|
||||||
|
if (vaseBraceUrl) {
|
||||||
|
const { mesh, baseGeometry } = await loadMesh(vaseBraceUrl, VASE_BRACE_COLOR, 0.9, false);
|
||||||
|
mesh.visible = showVaseBrace;
|
||||||
|
vaseBraceMeshRef.current = mesh;
|
||||||
|
vaseBaseGeomRef.current = baseGeometry;
|
||||||
|
scene.add(mesh);
|
||||||
|
meshes.push(mesh);
|
||||||
|
}
|
||||||
|
if (bodyScanUrl) {
|
||||||
|
const { mesh } = await loadMesh(bodyScanUrl, BODY_COLOR, bodyOpacity, true);
|
||||||
|
bodyMeshRef.current = mesh;
|
||||||
|
scene.add(mesh);
|
||||||
|
meshes.push(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meshes.length > 0) {
|
||||||
|
const combinedBox = new THREE.Box3();
|
||||||
|
meshes.forEach(m => combinedBox.union(new THREE.Box3().setFromObject(m)));
|
||||||
|
const size = combinedBox.getSize(new THREE.Vector3());
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const scale = 350 / maxDim;
|
||||||
|
baseScaleRef.current = scale;
|
||||||
|
meshes.forEach(m => m.scale.multiplyScalar(scale));
|
||||||
|
|
||||||
|
const newBox = new THREE.Box3();
|
||||||
|
meshes.forEach(m => newBox.union(new THREE.Box3().setFromObject(m)));
|
||||||
|
const center = newBox.getCenter(new THREE.Vector3());
|
||||||
|
meshes.forEach(m => m.position.sub(center));
|
||||||
|
|
||||||
|
cameraRef.current.position.set(0, 50, 500);
|
||||||
|
cameraRef.current.lookAt(0, 0, 0);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load:', err);
|
||||||
|
setError('Failed to load 3D models');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAll();
|
||||||
|
}, [threeLoaded, bodyScanUrl, regularBraceUrl, vaseBraceUrl, loadMesh, bodyOpacity, showVaseBrace]);
|
||||||
|
|
||||||
|
// Visibility updates
|
||||||
|
useEffect(() => { if (bodyMeshRef.current) bodyMeshRef.current.visible = showBody; }, [showBody]);
|
||||||
|
useEffect(() => { if (regularBraceMeshRef.current) regularBraceMeshRef.current.visible = showRegularBrace; }, [showRegularBrace]);
|
||||||
|
useEffect(() => { if (vaseBraceMeshRef.current) vaseBraceMeshRef.current.visible = showVaseBrace; }, [showVaseBrace]);
|
||||||
|
|
||||||
|
// Body opacity
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bodyMeshRef.current) return;
|
||||||
|
const update = (mat: any) => { mat.opacity = bodyOpacity; mat.needsUpdate = true; };
|
||||||
|
bodyMeshRef.current.traverse((c: any) => { if (c.isMesh && c.material) update(c.material); });
|
||||||
|
if (bodyMeshRef.current.material) update(bodyMeshRef.current.material);
|
||||||
|
}, [bodyOpacity]);
|
||||||
|
|
||||||
|
// Apply position/rotation/scale to braces
|
||||||
|
useEffect(() => {
|
||||||
|
const applyToBrace = (mesh: any) => {
|
||||||
|
if (!mesh) return;
|
||||||
|
mesh.position.x = bracePositionX;
|
||||||
|
mesh.position.y = bracePositionY;
|
||||||
|
mesh.position.z = bracePositionZ;
|
||||||
|
// Convert degrees to radians, no fixed offset
|
||||||
|
mesh.rotation.x = braceRotationX * Math.PI / 180;
|
||||||
|
mesh.rotation.y = braceRotationY * Math.PI / 180;
|
||||||
|
mesh.rotation.z = braceRotationZ * Math.PI / 180;
|
||||||
|
const base = baseScaleRef.current;
|
||||||
|
mesh.scale.set(base * braceScaleX, base * braceScaleY, base * braceScaleZ);
|
||||||
|
};
|
||||||
|
applyToBrace(regularBraceMeshRef.current);
|
||||||
|
applyToBrace(vaseBraceMeshRef.current);
|
||||||
|
}, [bracePositionX, bracePositionY, bracePositionZ, braceRotationX, braceRotationY, braceRotationZ, braceScaleX, braceScaleY, braceScaleZ]);
|
||||||
|
|
||||||
|
// Apply position/rotation/scale to body
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bodyMeshRef.current) return;
|
||||||
|
bodyMeshRef.current.position.x = bodyPositionX;
|
||||||
|
bodyMeshRef.current.position.y = bodyPositionY;
|
||||||
|
bodyMeshRef.current.position.z = bodyPositionZ;
|
||||||
|
// Convert degrees to radians, no fixed offset
|
||||||
|
bodyMeshRef.current.rotation.x = bodyRotationX * Math.PI / 180;
|
||||||
|
bodyMeshRef.current.rotation.y = bodyRotationY * Math.PI / 180;
|
||||||
|
bodyMeshRef.current.rotation.z = bodyRotationZ * Math.PI / 180;
|
||||||
|
const base = baseScaleRef.current;
|
||||||
|
bodyMeshRef.current.scale.set(base * bodyScale, base * bodyScale, base * bodyScale);
|
||||||
|
}, [bodyPositionX, bodyPositionY, bodyPositionZ, bodyRotationX, bodyRotationY, bodyRotationZ, bodyScale]);
|
||||||
|
|
||||||
|
// Apply deformation to braces
|
||||||
|
useEffect(() => {
|
||||||
|
if (!regularBraceMeshRef.current || !regularBaseGeomRef.current) return;
|
||||||
|
const deformed = applyDeformation(regularBaseGeomRef.current.clone(), regularParams);
|
||||||
|
regularBraceMeshRef.current.traverse((c: any) => { if (c.isMesh) { c.geometry.dispose(); c.geometry = deformed; }});
|
||||||
|
if (regularBraceMeshRef.current.geometry) { regularBraceMeshRef.current.geometry.dispose(); regularBraceMeshRef.current.geometry = deformed; }
|
||||||
|
}, [regularParams, applyDeformation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vaseBraceMeshRef.current || !vaseBaseGeomRef.current) return;
|
||||||
|
const deformed = applyDeformation(vaseBaseGeomRef.current.clone(), vaseParams);
|
||||||
|
vaseBraceMeshRef.current.traverse((c: any) => { if (c.isMesh) { c.geometry.dispose(); c.geometry = deformed; }});
|
||||||
|
if (vaseBraceMeshRef.current.geometry) { vaseBraceMeshRef.current.geometry.dispose(); vaseBraceMeshRef.current.geometry = deformed; }
|
||||||
|
}, [vaseParams, applyDeformation]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleParamsChange = useCallback((params: BraceTransformParams) => {
|
||||||
|
if (activeBrace === 'regular') setRegularParams(params);
|
||||||
|
else setVaseParams(params);
|
||||||
|
}, [activeBrace]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => { console.log('Save not implemented'); }, []);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
const reset = { ...DEFAULT_TRANSFORM_PARAMS, cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || 25 };
|
||||||
|
if (activeBrace === 'regular') setRegularParams(reset);
|
||||||
|
else setVaseParams(reset);
|
||||||
|
}, [activeBrace, braceData?.cobb_angles]);
|
||||||
|
|
||||||
|
const handleResetTransforms = () => {
|
||||||
|
setBracePositionX(0); setBracePositionY(0); setBracePositionZ(0);
|
||||||
|
setBraceRotationX(0); setBraceRotationY(0); setBraceRotationZ(0);
|
||||||
|
setBraceScaleX(1); setBraceScaleY(1); setBraceScaleZ(1);
|
||||||
|
setBodyPositionX(0); setBodyPositionY(0); setBodyPositionZ(0);
|
||||||
|
setBodyRotationX(0); setBodyRotationY(0); setBodyRotationZ(0);
|
||||||
|
setBodyScale(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasBodyScan = !!bodyScanUrl;
|
||||||
|
const hasBraces = regularBraceUrl || vaseBraceUrl;
|
||||||
|
|
||||||
|
if (!hasBodyScan && !hasBraces) {
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage fitting-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 5: Brace Fitting Inspection</h2>
|
||||||
|
<div className="stage-status"><span className="status-badge status-pending">Pending</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="stage-content">
|
||||||
|
<div className="fitting-empty">
|
||||||
|
<p>Body scan and braces are required to inspect fitting.</p>
|
||||||
|
<p className="hint">Complete Stage 3 (Body Scan) and Stage 4 (Brace Generation) first.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage fitting-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 5: Brace Fitting Inspection</h2>
|
||||||
|
<div className="stage-status"><span className="status-badge status-complete">Ready</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fitting-layout-3col">
|
||||||
|
{/* LEFT PANEL: Position, Rotation, Scale */}
|
||||||
|
<div className="fitting-panel left-panel">
|
||||||
|
<h3>Transform Controls</h3>
|
||||||
|
|
||||||
|
{/* Visibility */}
|
||||||
|
<div className="panel-section">
|
||||||
|
<h4>Visibility</h4>
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input type="checkbox" checked={showBody} onChange={e => setShowBody(e.target.checked)} />
|
||||||
|
<span className="color-dot" style={{background:'#f5d0c5'}} />
|
||||||
|
<span>Body</span>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input type="checkbox" checked={showRegularBrace} onChange={e => setShowRegularBrace(e.target.checked)} disabled={!regularBraceUrl} />
|
||||||
|
<span className="color-dot" style={{background:'#4a90d9'}} />
|
||||||
|
<span>Regular Brace</span>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input type="checkbox" checked={showVaseBrace} onChange={e => setShowVaseBrace(e.target.checked)} disabled={!vaseBraceUrl} />
|
||||||
|
<span className="color-dot" style={{background:'#50c878'}} />
|
||||||
|
<span>Vase Brace</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body Opacity */}
|
||||||
|
<div className="panel-section">
|
||||||
|
<h4>Body Opacity: {Math.round(bodyOpacity*100)}%</h4>
|
||||||
|
<input type="range" min="0" max="100" value={bodyOpacity*100} onChange={e => setBodyOpacity(Number(e.target.value)/100)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brace Position */}
|
||||||
|
<div className="panel-section">
|
||||||
|
<h4>Brace Position</h4>
|
||||||
|
<div className="slider-compact"><span>X</span><input type="range" min="-100" max="100" value={bracePositionX} onChange={e => setBracePositionX(Number(e.target.value))} /><span>{bracePositionX}</span></div>
|
||||||
|
<div className="slider-compact"><span>Y</span><input type="range" min="-100" max="100" value={bracePositionY} onChange={e => setBracePositionY(Number(e.target.value))} /><span>{bracePositionY}</span></div>
|
||||||
|
<div className="slider-compact"><span>Z</span><input type="range" min="-100" max="100" value={bracePositionZ} onChange={e => setBracePositionZ(Number(e.target.value))} /><span>{bracePositionZ}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brace Rotation */}
|
||||||
|
<div className="panel-section">
|
||||||
|
<h4>Brace Rotation</h4>
|
||||||
|
<div className="slider-compact"><span>X</span><input type="range" min="-180" max="180" value={braceRotationX} onChange={e => setBraceRotationX(Number(e.target.value))} /><span>{braceRotationX}°</span></div>
|
||||||
|
<div className="slider-compact"><span>Y</span><input type="range" min="-180" max="180" value={braceRotationY} onChange={e => setBraceRotationY(Number(e.target.value))} /><span>{braceRotationY}°</span></div>
|
||||||
|
<div className="slider-compact"><span>Z</span><input type="range" min="-180" max="180" value={braceRotationZ} onChange={e => setBraceRotationZ(Number(e.target.value))} /><span>{braceRotationZ}°</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brace Scale */}
|
||||||
|
<div className="panel-section">
|
||||||
|
<h4>Brace Scale</h4>
|
||||||
|
<div className="slider-compact"><span>X</span><input type="range" min="50" max="150" value={braceScaleX*100} onChange={e => setBraceScaleX(Number(e.target.value)/100)} /><span>{braceScaleX.toFixed(2)}</span></div>
|
||||||
|
<div className="slider-compact"><span>Y</span><input type="range" min="50" max="150" value={braceScaleY*100} onChange={e => setBraceScaleY(Number(e.target.value)/100)} /><span>{braceScaleY.toFixed(2)}</span></div>
|
||||||
|
<div className="slider-compact"><span>Z</span><input type="range" min="50" max="150" value={braceScaleZ*100} onChange={e => setBraceScaleZ(Number(e.target.value)/100)} /><span>{braceScaleZ.toFixed(2)}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body Transform */}
|
||||||
|
<div className="panel-section">
|
||||||
|
<h4>Body Position</h4>
|
||||||
|
<div className="slider-compact"><span>X</span><input type="range" min="-100" max="100" value={bodyPositionX} onChange={e => setBodyPositionX(Number(e.target.value))} /><span>{bodyPositionX}</span></div>
|
||||||
|
<div className="slider-compact"><span>Y</span><input type="range" min="-100" max="100" value={bodyPositionY} onChange={e => setBodyPositionY(Number(e.target.value))} /><span>{bodyPositionY}</span></div>
|
||||||
|
<div className="slider-compact"><span>Z</span><input type="range" min="-100" max="100" value={bodyPositionZ} onChange={e => setBodyPositionZ(Number(e.target.value))} /><span>{bodyPositionZ}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-section">
|
||||||
|
<h4>Body Rotation</h4>
|
||||||
|
<div className="slider-compact"><span>X</span><input type="range" min="-180" max="180" value={bodyRotationX} onChange={e => setBodyRotationX(Number(e.target.value))} /><span>{bodyRotationX}°</span></div>
|
||||||
|
<div className="slider-compact"><span>Y</span><input type="range" min="-180" max="180" value={bodyRotationY} onChange={e => setBodyRotationY(Number(e.target.value))} /><span>{bodyRotationY}°</span></div>
|
||||||
|
<div className="slider-compact"><span>Z</span><input type="range" min="-180" max="180" value={bodyRotationZ} onChange={e => setBodyRotationZ(Number(e.target.value))} /><span>{bodyRotationZ}°</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-section">
|
||||||
|
<h4>Body Scale: {bodyScale.toFixed(2)}</h4>
|
||||||
|
<input type="range" min="50" max="150" value={bodyScale*100} onChange={e => setBodyScale(Number(e.target.value)/100)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-reset-all" onClick={handleResetTransforms}>Reset All Transforms</button>
|
||||||
|
|
||||||
|
{/* View Options */}
|
||||||
|
<div className="panel-section">
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input type="checkbox" checked={autoRotate} onChange={e => setAutoRotate(e.target.checked)} />
|
||||||
|
<span>Auto Rotate</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CENTER: 3D Viewer */}
|
||||||
|
<div className="fitting-viewer-container">
|
||||||
|
{!threeLoaded ? (
|
||||||
|
<div className="fitting-viewer-loading"><div className="spinner"></div><p>Loading 3D viewer...</p></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div ref={containerRef} className="fitting-viewer-canvas" />
|
||||||
|
{loading && <div className="fitting-viewer-overlay"><div className="spinner"></div><p>Loading models...</p></div>}
|
||||||
|
{error && <div className="fitting-viewer-overlay error"><p>{error}</p></div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT PANEL: Deformation Sliders (Stage 4 Editor) */}
|
||||||
|
<div className="fitting-panel right-panel">
|
||||||
|
<h3>Deformation Controls</h3>
|
||||||
|
|
||||||
|
{/* Brace Selector */}
|
||||||
|
<div className="panel-section">
|
||||||
|
<h4>Edit Brace</h4>
|
||||||
|
<div className="brace-selector">
|
||||||
|
<button className={`brace-select-btn ${activeBrace === 'regular' ? 'active' : ''}`} onClick={() => setActiveBrace('regular')} disabled={!regularBraceUrl}>
|
||||||
|
Regular (Blue)
|
||||||
|
</button>
|
||||||
|
<button className={`brace-select-btn ${activeBrace === 'vase' ? 'active' : ''}`} onClick={() => setActiveBrace('vase')} disabled={!vaseBraceUrl}>
|
||||||
|
Vase (Green)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The Stage 4 Editor */}
|
||||||
|
<BraceInlineEditor
|
||||||
|
braceType={activeBrace}
|
||||||
|
initialParams={activeBrace === 'regular' ? regularParams : vaseParams}
|
||||||
|
cobbAngles={braceData?.cobb_angles}
|
||||||
|
onParamsChange={handleParamsChange}
|
||||||
|
onSave={handleSave}
|
||||||
|
onReset={handleReset}
|
||||||
|
isModified={false}
|
||||||
|
className="fitting-inline-editor"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<div className="panel-section tips">
|
||||||
|
<h4>Tips</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Blue areas = brace pushing INTO body</li>
|
||||||
|
<li>Increase Cobb angle for more pressure</li>
|
||||||
|
<li>Adjust apex to move correction zone</li>
|
||||||
|
<li>Use transforms on left to align meshes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
587
frontend/src/components/pipeline/BraceGenerationStage.tsx
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
/**
|
||||||
|
* Stage 4: Brace Generation
|
||||||
|
* Shows generated brace with 3D viewer and marker editing
|
||||||
|
* Displays both Regular and Vase brace types side-by-side
|
||||||
|
* Includes inline editors for real-time brace transformation
|
||||||
|
*/
|
||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
import type { GenerateBraceResponse, DeformationZone } from '../../api/braceflowApi';
|
||||||
|
import { getModifiedBraceUploadUrl, uploadBlobToS3 } from '../../api/braceflowApi';
|
||||||
|
import BraceTransformViewer from '../three/BraceTransformViewer';
|
||||||
|
import type { BraceTransformViewerRef } from '../three/BraceTransformViewer';
|
||||||
|
import BraceInlineEditor, {
|
||||||
|
type BraceTransformParams,
|
||||||
|
DEFAULT_TRANSFORM_PARAMS
|
||||||
|
} from './BraceInlineEditor';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
caseId: string;
|
||||||
|
braceData: GenerateBraceResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onGenerate: () => Promise<void>;
|
||||||
|
onUpdateMarkers: (markers: Record<string, unknown>) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BraceGenerationStage({
|
||||||
|
caseId,
|
||||||
|
braceData,
|
||||||
|
isLoading,
|
||||||
|
onGenerate,
|
||||||
|
onUpdateMarkers,
|
||||||
|
}: Props) {
|
||||||
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
|
const [showInlineEditors, setShowInlineEditors] = useState(true);
|
||||||
|
const regularViewerRef = useRef<BraceTransformViewerRef>(null);
|
||||||
|
const vaseViewerRef = useRef<BraceTransformViewerRef>(null);
|
||||||
|
|
||||||
|
// Transform params state for each brace type
|
||||||
|
const [regularParams, setRegularParams] = useState<BraceTransformParams>(() => ({
|
||||||
|
...DEFAULT_TRANSFORM_PARAMS,
|
||||||
|
}));
|
||||||
|
const [vaseParams, setVaseParams] = useState<BraceTransformParams>(() => ({
|
||||||
|
...DEFAULT_TRANSFORM_PARAMS,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Track if braces have been modified
|
||||||
|
const [regularModified, setRegularModified] = useState(false);
|
||||||
|
const [vaseModified, setVaseModified] = useState(false);
|
||||||
|
|
||||||
|
// Handle transform params changes
|
||||||
|
const handleRegularParamsChange = useCallback((params: BraceTransformParams) => {
|
||||||
|
setRegularParams(params);
|
||||||
|
setRegularModified(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVaseParamsChange = useCallback((params: BraceTransformParams) => {
|
||||||
|
setVaseParams(params);
|
||||||
|
setVaseModified(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Track save status
|
||||||
|
const [saveStatus, setSaveStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Handle save/upload for modified braces
|
||||||
|
const handleSaveRegular = useCallback(async (params: BraceTransformParams) => {
|
||||||
|
if (!regularViewerRef.current) return;
|
||||||
|
|
||||||
|
setSaveStatus('Exporting regular brace...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let savedCount = 0;
|
||||||
|
|
||||||
|
// Export STL
|
||||||
|
const stlBlob = await regularViewerRef.current.exportSTL();
|
||||||
|
if (stlBlob) {
|
||||||
|
setSaveStatus('Uploading regular STL...');
|
||||||
|
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'regular', 'stl');
|
||||||
|
await uploadBlobToS3(uploadUrl, stlBlob, contentType || 'application/octet-stream');
|
||||||
|
console.log('Regular STL uploaded to case storage');
|
||||||
|
savedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export GLB
|
||||||
|
const glbBlob = await regularViewerRef.current.exportGLB();
|
||||||
|
if (glbBlob) {
|
||||||
|
setSaveStatus('Uploading regular GLB...');
|
||||||
|
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'regular', 'glb');
|
||||||
|
await uploadBlobToS3(uploadUrl, glbBlob, contentType || 'model/gltf-binary');
|
||||||
|
console.log('Regular GLB uploaded to case storage');
|
||||||
|
savedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus(`Regular brace saved! (${savedCount} files)`);
|
||||||
|
setRegularModified(false);
|
||||||
|
setTimeout(() => setSaveStatus(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save regular brace:', err);
|
||||||
|
setSaveStatus(`Error: ${err instanceof Error ? err.message : 'Failed to save'}`);
|
||||||
|
setTimeout(() => setSaveStatus(null), 5000);
|
||||||
|
}
|
||||||
|
}, [caseId]);
|
||||||
|
|
||||||
|
const handleSaveVase = useCallback(async (params: BraceTransformParams) => {
|
||||||
|
if (!vaseViewerRef.current) return;
|
||||||
|
|
||||||
|
setSaveStatus('Exporting vase brace...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let savedCount = 0;
|
||||||
|
|
||||||
|
// Export STL
|
||||||
|
const stlBlob = await vaseViewerRef.current.exportSTL();
|
||||||
|
if (stlBlob) {
|
||||||
|
setSaveStatus('Uploading vase STL...');
|
||||||
|
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'vase', 'stl');
|
||||||
|
await uploadBlobToS3(uploadUrl, stlBlob, contentType || 'application/octet-stream');
|
||||||
|
console.log('Vase STL uploaded to case storage');
|
||||||
|
savedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export GLB
|
||||||
|
const glbBlob = await vaseViewerRef.current.exportGLB();
|
||||||
|
if (glbBlob) {
|
||||||
|
setSaveStatus('Uploading vase GLB...');
|
||||||
|
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'vase', 'glb');
|
||||||
|
await uploadBlobToS3(uploadUrl, glbBlob, contentType || 'model/gltf-binary');
|
||||||
|
console.log('Vase GLB uploaded to case storage');
|
||||||
|
savedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus(`Vase brace saved! (${savedCount} files)`);
|
||||||
|
setVaseModified(false);
|
||||||
|
setTimeout(() => setSaveStatus(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save vase brace:', err);
|
||||||
|
setSaveStatus(`Error: ${err instanceof Error ? err.message : 'Failed to save'}`);
|
||||||
|
setTimeout(() => setSaveStatus(null), 5000);
|
||||||
|
}
|
||||||
|
}, [caseId]);
|
||||||
|
|
||||||
|
// Handle reset
|
||||||
|
const handleResetRegular = useCallback(() => {
|
||||||
|
setRegularParams({
|
||||||
|
...DEFAULT_TRANSFORM_PARAMS,
|
||||||
|
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
|
||||||
|
});
|
||||||
|
setRegularModified(false);
|
||||||
|
}, [braceData?.cobb_angles]);
|
||||||
|
|
||||||
|
const handleResetVase = useCallback(() => {
|
||||||
|
setVaseParams({
|
||||||
|
...DEFAULT_TRANSFORM_PARAMS,
|
||||||
|
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
|
||||||
|
});
|
||||||
|
setVaseModified(false);
|
||||||
|
}, [braceData?.cobb_angles]);
|
||||||
|
|
||||||
|
// Get output URLs for regular brace
|
||||||
|
const outputs = braceData?.outputs || {};
|
||||||
|
const stlUrl = outputs.stl?.url || (outputs as any).stl;
|
||||||
|
const glbUrl = outputs.glb?.url || (outputs as any).glb;
|
||||||
|
const vizUrl = outputs.visualization?.url || (outputs as any).visualization;
|
||||||
|
const jsonUrl = outputs.landmarks?.url || (outputs as any).landmarks;
|
||||||
|
|
||||||
|
// Get output URLs for vase brace (if available)
|
||||||
|
const braces = (braceData as any)?.braces || {};
|
||||||
|
const regularBrace = braces.regular || { outputs: { stl: stlUrl, glb: glbUrl } };
|
||||||
|
const vaseBrace = braces.vase || {};
|
||||||
|
|
||||||
|
// Helper function to format file size
|
||||||
|
const formatFileSize = (bytes?: number): string => {
|
||||||
|
if (!bytes) return '';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!braceData && !isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage brace-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 4: Brace Generation</h2>
|
||||||
|
<div className="stage-status">
|
||||||
|
<span className="status-badge status-pending">Pending</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stage-content">
|
||||||
|
<div className="brace-empty">
|
||||||
|
<p>Ready to generate custom brace based on approved landmarks and analysis.</p>
|
||||||
|
<button className="btn primary btn-large" onClick={onGenerate}>
|
||||||
|
Generate Brace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage brace-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 4: Brace Generation</h2>
|
||||||
|
<div className="stage-status">
|
||||||
|
<span className="status-badge status-processing">Generating...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stage-content">
|
||||||
|
<div className="brace-loading">
|
||||||
|
<div className="spinner large"></div>
|
||||||
|
<p>Generating custom braces...</p>
|
||||||
|
<p className="loading-hint">
|
||||||
|
Generating both Regular and Vase brace designs for comparison.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage brace-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 4: Brace Generation</h2>
|
||||||
|
<div className="stage-status">
|
||||||
|
<span className="status-badge status-complete">Complete</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Panels - Right under header */}
|
||||||
|
<div className="brace-summary-row">
|
||||||
|
{/* Generation Summary */}
|
||||||
|
<div className="brace-panel summary-panel">
|
||||||
|
<h3>Generation Summary</h3>
|
||||||
|
<div className="summary-grid horizontal">
|
||||||
|
{braceData?.rigo_classification && (
|
||||||
|
<div className="summary-item">
|
||||||
|
<span className="summary-label">Rigo Type</span>
|
||||||
|
<span className="summary-value badge">
|
||||||
|
{braceData.rigo_classification.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{braceData?.curve_type && (
|
||||||
|
<div className="summary-item">
|
||||||
|
<span className="summary-label">Curve</span>
|
||||||
|
<span className="summary-value">{braceData.curve_type}-Curve</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{braceData?.processing_time_ms && (
|
||||||
|
<div className="summary-item">
|
||||||
|
<span className="summary-label">Time</span>
|
||||||
|
<span className="summary-value">
|
||||||
|
{(braceData.processing_time_ms / 1000).toFixed(1)}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cobb Angles Used */}
|
||||||
|
{braceData?.cobb_angles && (
|
||||||
|
<div className="brace-panel cobb-panel">
|
||||||
|
<h3>Cobb Angles Used</h3>
|
||||||
|
<div className="cobb-mini-grid horizontal">
|
||||||
|
<div className="cobb-mini">
|
||||||
|
<span className="cobb-label">PT</span>
|
||||||
|
<span className="cobb-value">{braceData.cobb_angles.PT?.toFixed(1)}°</span>
|
||||||
|
</div>
|
||||||
|
<div className="cobb-mini">
|
||||||
|
<span className="cobb-label">MT</span>
|
||||||
|
<span className="cobb-value">{braceData.cobb_angles.MT?.toFixed(1)}°</span>
|
||||||
|
</div>
|
||||||
|
<div className="cobb-mini">
|
||||||
|
<span className="cobb-label">TL</span>
|
||||||
|
<span className="cobb-value">{braceData.cobb_angles.TL?.toFixed(1)}°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle Editor Button + Save Status */}
|
||||||
|
<div className="editor-toggle-row">
|
||||||
|
{saveStatus && (
|
||||||
|
<span className={`save-status ${saveStatus.includes('Error') ? 'error' : saveStatus.includes('saved') ? 'success' : ''}`}>
|
||||||
|
{saveStatus}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`btn-toggle-editor ${showInlineEditors ? 'active' : ''}`}
|
||||||
|
onClick={() => setShowInlineEditors(!showInlineEditors)}
|
||||||
|
>
|
||||||
|
{showInlineEditors ? 'Hide Editors' : 'Show Editors'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dual Brace Viewers with Inline Editors */}
|
||||||
|
<div className={`dual-brace-viewers ${showInlineEditors ? 'with-editors' : ''}`}>
|
||||||
|
{/* Regular Brace Viewer + Editor */}
|
||||||
|
<div className="brace-viewer-with-editor">
|
||||||
|
<div className="brace-viewer-container">
|
||||||
|
<div className="viewer-header">
|
||||||
|
<h3>Regular Brace</h3>
|
||||||
|
<span className="viewer-subtitle">Fitted design for precise correction</span>
|
||||||
|
{regularModified && <span className="modified-indicator">Modified</span>}
|
||||||
|
</div>
|
||||||
|
<div className="brace-viewer brace-viewer-3d">
|
||||||
|
<BraceTransformViewer
|
||||||
|
ref={regularViewerRef}
|
||||||
|
stlUrl={regularBrace.outputs?.stl || stlUrl}
|
||||||
|
glbUrl={regularBrace.outputs?.glb || glbUrl}
|
||||||
|
transformParams={regularParams}
|
||||||
|
autoRotate={!showInlineEditors}
|
||||||
|
rotationSpeed={0.005}
|
||||||
|
showMarkers={true}
|
||||||
|
showGrid={showInlineEditors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{regularBrace.meshStats && (
|
||||||
|
<div className="viewer-stats">
|
||||||
|
<span>{regularBrace.meshStats.vertices?.toLocaleString()} vertices</span>
|
||||||
|
<span>{regularBrace.meshStats.faces?.toLocaleString()} faces</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline Editor for Regular Brace */}
|
||||||
|
{showInlineEditors && (
|
||||||
|
<BraceInlineEditor
|
||||||
|
braceType="regular"
|
||||||
|
initialParams={regularParams}
|
||||||
|
cobbAngles={braceData?.cobb_angles}
|
||||||
|
onParamsChange={handleRegularParamsChange}
|
||||||
|
onSave={handleSaveRegular}
|
||||||
|
onReset={handleResetRegular}
|
||||||
|
isModified={regularModified}
|
||||||
|
className="viewer-inline-editor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vase Brace Viewer + Editor */}
|
||||||
|
<div className="brace-viewer-with-editor">
|
||||||
|
<div className="brace-viewer-container">
|
||||||
|
<div className="viewer-header">
|
||||||
|
<h3>Vase Brace</h3>
|
||||||
|
<span className="viewer-subtitle">Smooth contoured design</span>
|
||||||
|
{vaseModified && <span className="modified-indicator">Modified</span>}
|
||||||
|
</div>
|
||||||
|
<div className="brace-viewer brace-viewer-3d">
|
||||||
|
{vaseBrace.outputs?.stl || vaseBrace.outputs?.glb ? (
|
||||||
|
<BraceTransformViewer
|
||||||
|
ref={vaseViewerRef}
|
||||||
|
stlUrl={vaseBrace.outputs?.stl}
|
||||||
|
glbUrl={vaseBrace.outputs?.glb}
|
||||||
|
transformParams={vaseParams}
|
||||||
|
autoRotate={!showInlineEditors}
|
||||||
|
rotationSpeed={0.005}
|
||||||
|
showMarkers={true}
|
||||||
|
showGrid={showInlineEditors}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="viewer-placeholder">
|
||||||
|
<div className="placeholder-icon">🏺</div>
|
||||||
|
<p>Vase brace not generated</p>
|
||||||
|
<p className="hint">Click "Generate Both" to create vase design</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{vaseBrace.meshStats && (
|
||||||
|
<div className="viewer-stats">
|
||||||
|
<span>{vaseBrace.meshStats.vertices?.toLocaleString()} vertices</span>
|
||||||
|
<span>{vaseBrace.meshStats.faces?.toLocaleString()} faces</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline Editor for Vase Brace */}
|
||||||
|
{showInlineEditors && (vaseBrace.outputs?.stl || vaseBrace.outputs?.glb) && (
|
||||||
|
<BraceInlineEditor
|
||||||
|
braceType="vase"
|
||||||
|
initialParams={vaseParams}
|
||||||
|
cobbAngles={braceData?.cobb_angles}
|
||||||
|
onParamsChange={handleVaseParamsChange}
|
||||||
|
onSave={handleSaveVase}
|
||||||
|
onReset={handleResetVase}
|
||||||
|
isModified={vaseModified}
|
||||||
|
className="viewer-inline-editor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brace Pressure Zones - Full Width Section */}
|
||||||
|
{braceData?.deformation_report?.zones && braceData.deformation_report.zones.length > 0 && (
|
||||||
|
<div className="pressure-zones-section">
|
||||||
|
<h3>Brace Pressure Zones</h3>
|
||||||
|
<p className="zones-desc">
|
||||||
|
Based on the Cobb angles and Rigo classification, the following pressure modifications were applied to the brace:
|
||||||
|
</p>
|
||||||
|
<div className="pressure-zones-grid">
|
||||||
|
{braceData.deformation_report.zones.map((zone: DeformationZone, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`zone-card ${zone.deform_mm < 0 ? 'zone-pressure' : 'zone-relief'}`}
|
||||||
|
>
|
||||||
|
<div className="zone-header">
|
||||||
|
<span className="zone-name">{zone.zone}</span>
|
||||||
|
<span className={`zone-value ${zone.deform_mm < 0 ? 'pressure' : 'relief'}`}>
|
||||||
|
{zone.deform_mm > 0 ? '+' : ''}{zone.deform_mm.toFixed(1)} mm
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="zone-reason">{zone.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{braceData.deformation_report.patch_grid && (
|
||||||
|
<p className="patch-grid-info">
|
||||||
|
Patch Grid: {braceData.deformation_report.patch_grid}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download Files - Bottom Section */}
|
||||||
|
<div className="downloads-section">
|
||||||
|
<h3>Download Files</h3>
|
||||||
|
<div className="downloads-columns">
|
||||||
|
{/* Regular Brace Downloads */}
|
||||||
|
<div className="download-column">
|
||||||
|
<h4>Regular Brace</h4>
|
||||||
|
<div className="downloads-list">
|
||||||
|
{(regularBrace.outputs?.stl || stlUrl) && (
|
||||||
|
<a
|
||||||
|
href={regularBrace.outputs?.stl || stlUrl}
|
||||||
|
className="download-item"
|
||||||
|
download={`brace_${caseId}_regular.stl`}
|
||||||
|
>
|
||||||
|
<span className="download-icon">📦</span>
|
||||||
|
<span className="download-info">
|
||||||
|
<span className="download-name">regular.stl</span>
|
||||||
|
<span className="download-desc">For 3D printing</span>
|
||||||
|
</span>
|
||||||
|
<span className="download-action">↓</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{(regularBrace.outputs?.glb || glbUrl) && (
|
||||||
|
<a
|
||||||
|
href={regularBrace.outputs?.glb || glbUrl}
|
||||||
|
className="download-item"
|
||||||
|
download={`brace_${caseId}_regular.glb`}
|
||||||
|
>
|
||||||
|
<span className="download-icon">🎮</span>
|
||||||
|
<span className="download-info">
|
||||||
|
<span className="download-name">regular.glb</span>
|
||||||
|
<span className="download-desc">For web/AR</span>
|
||||||
|
</span>
|
||||||
|
<span className="download-action">↓</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{(regularBrace.outputs?.json || jsonUrl) && (
|
||||||
|
<a
|
||||||
|
href={regularBrace.outputs?.json || jsonUrl}
|
||||||
|
className="download-item"
|
||||||
|
download={`brace_${caseId}_regular_markers.json`}
|
||||||
|
>
|
||||||
|
<span className="download-icon">📄</span>
|
||||||
|
<span className="download-info">
|
||||||
|
<span className="download-name">markers.json</span>
|
||||||
|
<span className="download-desc">With markers</span>
|
||||||
|
</span>
|
||||||
|
<span className="download-action">↓</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vase Brace Downloads */}
|
||||||
|
<div className="download-column">
|
||||||
|
<h4>Vase Brace</h4>
|
||||||
|
<div className="downloads-list">
|
||||||
|
{vaseBrace.outputs?.stl ? (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={vaseBrace.outputs.stl}
|
||||||
|
className="download-item"
|
||||||
|
download={`brace_${caseId}_vase.stl`}
|
||||||
|
>
|
||||||
|
<span className="download-icon">📦</span>
|
||||||
|
<span className="download-info">
|
||||||
|
<span className="download-name">vase.stl</span>
|
||||||
|
<span className="download-desc">For 3D printing</span>
|
||||||
|
</span>
|
||||||
|
<span className="download-action">↓</span>
|
||||||
|
</a>
|
||||||
|
{vaseBrace.outputs?.glb && (
|
||||||
|
<a
|
||||||
|
href={vaseBrace.outputs.glb}
|
||||||
|
className="download-item"
|
||||||
|
download={`brace_${caseId}_vase.glb`}
|
||||||
|
>
|
||||||
|
<span className="download-icon">🎮</span>
|
||||||
|
<span className="download-info">
|
||||||
|
<span className="download-name">vase.glb</span>
|
||||||
|
<span className="download-desc">For web/AR</span>
|
||||||
|
</span>
|
||||||
|
<span className="download-action">↓</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{vaseBrace.outputs?.json && (
|
||||||
|
<a
|
||||||
|
href={vaseBrace.outputs.json}
|
||||||
|
className="download-item"
|
||||||
|
download={`brace_${caseId}_vase_markers.json`}
|
||||||
|
>
|
||||||
|
<span className="download-icon">📄</span>
|
||||||
|
<span className="download-info">
|
||||||
|
<span className="download-name">markers.json</span>
|
||||||
|
<span className="download-desc">With markers</span>
|
||||||
|
</span>
|
||||||
|
<span className="download-action">↓</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="download-placeholder">
|
||||||
|
<span>Not generated</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="stage-actions">
|
||||||
|
<button
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => setShowEditor(!showEditor)}
|
||||||
|
>
|
||||||
|
{showEditor ? 'Hide Editor' : 'Edit Brace Markers'}
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" onClick={onGenerate}>
|
||||||
|
Regenerate Brace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marker Editor Modal (placeholder) */}
|
||||||
|
{showEditor && (
|
||||||
|
<div className="marker-editor-overlay">
|
||||||
|
<div className="marker-editor-modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>Brace Marker Editor</h3>
|
||||||
|
<button className="close-btn" onClick={() => setShowEditor(false)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-content">
|
||||||
|
<p>
|
||||||
|
The 3D marker editor integration is coming soon. This will allow you to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Drag and reposition pressure pad markers</li>
|
||||||
|
<li>Adjust deformation depths</li>
|
||||||
|
<li>Preview changes in real-time</li>
|
||||||
|
<li>Regenerate the brace with modified parameters</li>
|
||||||
|
</ul>
|
||||||
|
<p className="hint">
|
||||||
|
For now, you can download the JSON file to manually edit marker positions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn secondary" onClick={() => setShowEditor(false)}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
frontend/src/components/pipeline/BraceInlineEditor.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* BraceInlineEditor - Inline slider controls for brace transformation
|
||||||
|
* Based on EXPERIMENT_6's brace-transform-playground-v2
|
||||||
|
*
|
||||||
|
* Provides real-time deformation controls that can be embedded
|
||||||
|
* directly within the BraceGenerationStage component.
|
||||||
|
*/
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
export type BraceTransformParams = {
|
||||||
|
// Analysis parameters
|
||||||
|
cobbDeg: number;
|
||||||
|
apexNorm: number; // 0..1 along brace height
|
||||||
|
lumbarApexNorm: number; // 0..1
|
||||||
|
rotationScore: number; // 0..3
|
||||||
|
trunkShiftMm: number;
|
||||||
|
|
||||||
|
// Calibration
|
||||||
|
expectedBraceHeightMm: number;
|
||||||
|
strengthMult: number;
|
||||||
|
|
||||||
|
// Direction strategy
|
||||||
|
pushMode: 'normal' | 'radial' | 'lateral';
|
||||||
|
|
||||||
|
// Pelvis/anchors
|
||||||
|
hipAnchorStrengthMm: number;
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
mirrorX: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TRANSFORM_PARAMS: BraceTransformParams = {
|
||||||
|
cobbDeg: 25,
|
||||||
|
apexNorm: 0.62,
|
||||||
|
lumbarApexNorm: 0.40,
|
||||||
|
rotationScore: 0,
|
||||||
|
trunkShiftMm: 0,
|
||||||
|
expectedBraceHeightMm: 400,
|
||||||
|
strengthMult: 1.0,
|
||||||
|
pushMode: 'radial',
|
||||||
|
hipAnchorStrengthMm: 4,
|
||||||
|
mirrorX: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type SliderConfig = {
|
||||||
|
key: keyof BraceTransformParams;
|
||||||
|
label: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
unit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SLIDER_CONFIGS: SliderConfig[] = [
|
||||||
|
{ key: 'cobbDeg', label: 'Cobb Angle', min: 0, max: 80, step: 1, unit: '°' },
|
||||||
|
{ key: 'apexNorm', label: 'Thoracic Apex Height', min: 0, max: 1, step: 0.01 },
|
||||||
|
{ key: 'lumbarApexNorm', label: 'Lumbar Apex Height', min: 0, max: 1, step: 0.01 },
|
||||||
|
{ key: 'rotationScore', label: 'Rotation', min: 0, max: 3, step: 0.1 },
|
||||||
|
{ key: 'trunkShiftMm', label: 'Trunk Shift', min: -40, max: 40, step: 1, unit: 'mm' },
|
||||||
|
{ key: 'strengthMult', label: 'Strength', min: 0.2, max: 2.0, step: 0.05, unit: 'x' },
|
||||||
|
{ key: 'hipAnchorStrengthMm', label: 'Hip Anchor', min: 0, max: 12, step: 1, unit: 'mm' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ADVANCED_SLIDER_CONFIGS: SliderConfig[] = [
|
||||||
|
{ key: 'expectedBraceHeightMm', label: 'Expected Height', min: 250, max: 650, step: 10, unit: 'mm' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
braceType: 'regular' | 'vase';
|
||||||
|
initialParams?: Partial<BraceTransformParams>;
|
||||||
|
cobbAngles?: { PT?: number; MT?: number; TL?: number };
|
||||||
|
onParamsChange: (params: BraceTransformParams) => void;
|
||||||
|
onSave: (params: BraceTransformParams) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
isModified?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BraceInlineEditor({
|
||||||
|
braceType,
|
||||||
|
initialParams,
|
||||||
|
cobbAngles,
|
||||||
|
onParamsChange,
|
||||||
|
onSave,
|
||||||
|
onReset,
|
||||||
|
isModified = false,
|
||||||
|
className = '',
|
||||||
|
}: Props) {
|
||||||
|
const [params, setParams] = useState<BraceTransformParams>(() => ({
|
||||||
|
...DEFAULT_TRANSFORM_PARAMS,
|
||||||
|
...initialParams,
|
||||||
|
// Auto-set Cobb angle from analysis if available
|
||||||
|
cobbDeg: cobbAngles?.MT || cobbAngles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Update params when initial params change
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialParams) {
|
||||||
|
setParams(prev => ({
|
||||||
|
...prev,
|
||||||
|
...initialParams,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [initialParams]);
|
||||||
|
|
||||||
|
// Notify parent of changes
|
||||||
|
const handleParamChange = useCallback((key: keyof BraceTransformParams, value: number | string | boolean) => {
|
||||||
|
setParams(prev => {
|
||||||
|
const updated = { ...prev, [key]: value };
|
||||||
|
onParamsChange(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, [onParamsChange]);
|
||||||
|
|
||||||
|
// Handle save
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(params);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [params, onSave]);
|
||||||
|
|
||||||
|
// Handle reset
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
const resetParams = {
|
||||||
|
...DEFAULT_TRANSFORM_PARAMS,
|
||||||
|
...initialParams,
|
||||||
|
cobbDeg: cobbAngles?.MT || cobbAngles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
|
||||||
|
};
|
||||||
|
setParams(resetParams);
|
||||||
|
onReset();
|
||||||
|
}, [initialParams, cobbAngles, onReset]);
|
||||||
|
|
||||||
|
const formatValue = (value: number, step: number, unit?: string): string => {
|
||||||
|
const formatted = step < 1 ? value.toFixed(2) : value.toString();
|
||||||
|
return unit ? `${formatted}${unit}` : formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`brace-inline-editor ${className}`}>
|
||||||
|
<div className="editor-header">
|
||||||
|
<h4>{braceType === 'regular' ? 'Regular' : 'Vase'} Brace Editor</h4>
|
||||||
|
{isModified && <span className="modified-badge">Modified</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-sliders">
|
||||||
|
{SLIDER_CONFIGS.map(config => (
|
||||||
|
<div key={config.key} className="slider-row">
|
||||||
|
<div className="slider-label">
|
||||||
|
<span>{config.label}</span>
|
||||||
|
<span className="slider-value">
|
||||||
|
{formatValue(params[config.key] as number, config.step, config.unit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={config.min}
|
||||||
|
max={config.max}
|
||||||
|
step={config.step}
|
||||||
|
value={params[config.key] as number}
|
||||||
|
onChange={(e) => handleParamChange(config.key, Number(e.target.value))}
|
||||||
|
className="slider-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Push Mode Toggle */}
|
||||||
|
<div className="editor-mode-toggle">
|
||||||
|
<span className="toggle-label">Push Mode:</span>
|
||||||
|
<div className="toggle-buttons">
|
||||||
|
{(['normal', 'radial', 'lateral'] as const).map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
className={`toggle-btn ${params.pushMode === mode ? 'active' : ''}`}
|
||||||
|
onClick={() => handleParamChange('pushMode', mode)}
|
||||||
|
>
|
||||||
|
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mirror Toggle */}
|
||||||
|
<div className="editor-checkbox">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={params.mirrorX}
|
||||||
|
onChange={(e) => handleParamChange('mirrorX', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Mirror X (flip left/right)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Toggle */}
|
||||||
|
<button
|
||||||
|
className="advanced-toggle"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
>
|
||||||
|
{showAdvanced ? '▼ Hide Advanced' : '▶ Show Advanced'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="editor-sliders advanced">
|
||||||
|
{ADVANCED_SLIDER_CONFIGS.map(config => (
|
||||||
|
<div key={config.key} className="slider-row">
|
||||||
|
<div className="slider-label">
|
||||||
|
<span>{config.label}</span>
|
||||||
|
<span className="slider-value">
|
||||||
|
{formatValue(params[config.key] as number, config.step, config.unit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={config.min}
|
||||||
|
max={config.max}
|
||||||
|
step={config.step}
|
||||||
|
value={params[config.key] as number}
|
||||||
|
onChange={(e) => handleParamChange(config.key, Number(e.target.value))}
|
||||||
|
className="slider-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="editor-actions">
|
||||||
|
<button
|
||||||
|
className="btn-editor reset"
|
||||||
|
onClick={handleReset}
|
||||||
|
title="Reset to default values"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-editor save"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
title="Save modified brace to case storage"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
703
frontend/src/components/pipeline/LandmarkDetectionStage.tsx
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
/**
|
||||||
|
* Stage 1: Landmark Detection
|
||||||
|
* Interactive canvas - always editable, draw landmarks from JSON
|
||||||
|
* Supports green quadrilateral boxes around vertebrae with corner editing
|
||||||
|
*/
|
||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import type { LandmarksResult, VertebraeStructure, VertebraData } from '../../api/braceflowApi';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
caseId: string;
|
||||||
|
landmarksData: LandmarksResult | null;
|
||||||
|
xrayUrl: string | null;
|
||||||
|
visualizationUrl: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onDetect: () => Promise<void>;
|
||||||
|
onApprove: (updatedLandmarks?: VertebraeStructure) => Promise<void>;
|
||||||
|
onUpdateLandmarks: (landmarks: VertebraeStructure) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
type: 'centroid' | 'corner';
|
||||||
|
level: string;
|
||||||
|
cornerIdx?: number; // 0-3 for corner drag
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
originalCorners: [number, number][] | null;
|
||||||
|
originalCentroid: [number, number];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CornerHit = {
|
||||||
|
level: string;
|
||||||
|
cornerIdx: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LandmarkDetectionStage({
|
||||||
|
caseId,
|
||||||
|
landmarksData,
|
||||||
|
xrayUrl,
|
||||||
|
visualizationUrl,
|
||||||
|
isLoading,
|
||||||
|
onDetect,
|
||||||
|
onApprove,
|
||||||
|
onUpdateLandmarks,
|
||||||
|
}: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||||
|
const [structure, setStructure] = useState<VertebraeStructure | null>(null);
|
||||||
|
const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
|
||||||
|
const [hoveredLevel, setHoveredLevel] = useState<string | null>(null);
|
||||||
|
const [hoveredCorner, setHoveredCorner] = useState<CornerHit | null>(null);
|
||||||
|
const [dragState, setDragState] = useState<DragState | null>(null);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [imageError, setImageError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Initialize structure from landmarks data
|
||||||
|
useEffect(() => {
|
||||||
|
if (landmarksData?.vertebrae_structure) {
|
||||||
|
setStructure(JSON.parse(JSON.stringify(landmarksData.vertebrae_structure)));
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
}, [landmarksData]);
|
||||||
|
|
||||||
|
// Load X-ray image
|
||||||
|
useEffect(() => {
|
||||||
|
if (!xrayUrl) {
|
||||||
|
setImage(null);
|
||||||
|
setImageError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageError(null);
|
||||||
|
console.log('Loading X-ray from URL:', xrayUrl);
|
||||||
|
const img = new Image();
|
||||||
|
// Note: crossOrigin removed for local dev - add back for production with proper CORS
|
||||||
|
// img.crossOrigin = 'anonymous';
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
console.log('X-ray loaded successfully:', img.naturalWidth, 'x', img.naturalHeight);
|
||||||
|
setImage(img);
|
||||||
|
setImageError(null);
|
||||||
|
// Calculate scale to fit container
|
||||||
|
if (containerRef.current) {
|
||||||
|
const maxWidth = containerRef.current.clientWidth - 40;
|
||||||
|
const maxHeight = containerRef.current.clientHeight - 40;
|
||||||
|
const scaleX = maxWidth / img.naturalWidth;
|
||||||
|
const scaleY = maxHeight / img.naturalHeight;
|
||||||
|
setScale(Math.min(scaleX, scaleY, 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = (e) => {
|
||||||
|
setImage(null);
|
||||||
|
setImageError('Failed to load X-ray image. Please try refreshing the page.');
|
||||||
|
console.error('Failed to load X-ray image:', xrayUrl);
|
||||||
|
console.error('Error event:', e);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = xrayUrl;
|
||||||
|
}, [xrayUrl]);
|
||||||
|
|
||||||
|
// Get vertebra by level
|
||||||
|
const getVertebra = useCallback((level: string): VertebraData | undefined => {
|
||||||
|
return structure?.vertebrae.find(v => v.level === level);
|
||||||
|
}, [structure]);
|
||||||
|
|
||||||
|
// Calculate centroid from corners
|
||||||
|
const calculateCentroid = (corners: [number, number][]): [number, number] => {
|
||||||
|
const sumX = corners.reduce((acc, c) => acc + c[0], 0);
|
||||||
|
const sumY = corners.reduce((acc, c) => acc + c[1], 0);
|
||||||
|
return [sumX / corners.length, sumY / corners.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update vertebra with new corners and recalculate centroid
|
||||||
|
const updateVertebraCorners = useCallback((level: string, newCorners: [number, number][]) => {
|
||||||
|
const newCentroid = calculateCentroid(newCorners);
|
||||||
|
|
||||||
|
setStructure(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
vertebrae: prev.vertebrae.map(v => {
|
||||||
|
if (v.level !== level) return v;
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
manual_override: {
|
||||||
|
...v.manual_override,
|
||||||
|
enabled: true,
|
||||||
|
centroid_px: newCentroid,
|
||||||
|
corners_px: newCorners,
|
||||||
|
},
|
||||||
|
final_values: {
|
||||||
|
...v.final_values,
|
||||||
|
centroid_px: newCentroid,
|
||||||
|
corners_px: newCorners,
|
||||||
|
source: 'manual' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update vertebra position (move centroid and all corners together)
|
||||||
|
const updateVertebraPosition = useCallback((level: string, newCentroid: [number, number], originalCorners: [number, number][] | null, originalCentroid: [number, number]) => {
|
||||||
|
// Calculate delta from original centroid
|
||||||
|
const dx = newCentroid[0] - originalCentroid[0];
|
||||||
|
const dy = newCentroid[1] - originalCentroid[1];
|
||||||
|
|
||||||
|
// Move all corners by same delta
|
||||||
|
let newCorners: [number, number][] | null = null;
|
||||||
|
if (originalCorners) {
|
||||||
|
newCorners = originalCorners.map(c => [c[0] + dx, c[1] + dy] as [number, number]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStructure(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
vertebrae: prev.vertebrae.map(v => {
|
||||||
|
if (v.level !== level) return v;
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
manual_override: {
|
||||||
|
...v.manual_override,
|
||||||
|
enabled: true,
|
||||||
|
centroid_px: newCentroid,
|
||||||
|
corners_px: newCorners,
|
||||||
|
},
|
||||||
|
final_values: {
|
||||||
|
...v.final_values,
|
||||||
|
centroid_px: newCentroid,
|
||||||
|
corners_px: newCorners,
|
||||||
|
source: 'manual' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset vertebra to original
|
||||||
|
const resetVertebra = useCallback((level: string) => {
|
||||||
|
const original = landmarksData?.vertebrae_structure.vertebrae.find(v => v.level === level);
|
||||||
|
if (!original) return;
|
||||||
|
|
||||||
|
setStructure(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
vertebrae: prev.vertebrae.map(v => {
|
||||||
|
if (v.level !== level) return v;
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
manual_override: {
|
||||||
|
enabled: false,
|
||||||
|
centroid_px: null,
|
||||||
|
corners_px: null,
|
||||||
|
orientation_deg: null,
|
||||||
|
confidence: null,
|
||||||
|
notes: null,
|
||||||
|
},
|
||||||
|
final_values: {
|
||||||
|
...original.scoliovis_data,
|
||||||
|
source: original.detected ? 'scoliovis' as const : 'undetected' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
}, [landmarksData]);
|
||||||
|
|
||||||
|
// Draw canvas with green boxes and red centroids
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas?.getContext('2d');
|
||||||
|
if (!canvas || !ctx || !image) return;
|
||||||
|
|
||||||
|
// Set canvas size
|
||||||
|
canvas.width = image.naturalWidth * scale;
|
||||||
|
canvas.height = image.naturalHeight * scale;
|
||||||
|
|
||||||
|
// Clear and draw image
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
|
// Draw vertebrae if we have structure
|
||||||
|
if (structure) {
|
||||||
|
structure.vertebrae.forEach(v => {
|
||||||
|
const centroid = v.final_values?.centroid_px;
|
||||||
|
if (!centroid) return;
|
||||||
|
|
||||||
|
const [x, y] = centroid;
|
||||||
|
const isSelected = selectedLevel === v.level;
|
||||||
|
const isHovered = hoveredLevel === v.level;
|
||||||
|
const isManual = v.manual_override?.enabled;
|
||||||
|
|
||||||
|
// Draw green X-shape vertebra marker if corners exist
|
||||||
|
// Corner order: [top_left, top_right, bottom_left, bottom_right]
|
||||||
|
// Drawing 0→1→2→3→0 creates the X pattern that shows endplate orientations
|
||||||
|
const corners = v.final_values?.corners_px;
|
||||||
|
if (corners && corners.length === 4) {
|
||||||
|
// Set line style based on state
|
||||||
|
if (isSelected) {
|
||||||
|
ctx.strokeStyle = '#00ff00';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
} else if (isManual) {
|
||||||
|
ctx.strokeStyle = '#00cc00';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = '#22aa22';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the X-shape: 0→1, 1→2, 2→3, 3→0
|
||||||
|
// This creates: top edge, diagonal, bottom edge, diagonal (X pattern)
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const j = (i + 1) % 4;
|
||||||
|
ctx.moveTo(corners[i][0], corners[i][1]);
|
||||||
|
ctx.lineTo(corners[j][0], corners[j][1]);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw corner handles for selected vertebra
|
||||||
|
if (isSelected) {
|
||||||
|
corners.forEach((corner, idx) => {
|
||||||
|
const isCornerHovered = hoveredCorner?.level === v.level && hoveredCorner?.cornerIdx === idx;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(corner[0], corner[1], isCornerHovered ? 6 : 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = isCornerHovered ? '#ffff00' : '#00ff00';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine centroid color
|
||||||
|
let fillColor = '#ff3333'; // Detected (red)
|
||||||
|
if (isManual) fillColor = '#ff6600'; // Manual (orange-red to distinguish)
|
||||||
|
else if (!v.detected) fillColor = '#888888'; // Undetected (gray)
|
||||||
|
|
||||||
|
// Draw centroid circle
|
||||||
|
const radius = isSelected || isHovered ? 8 : 5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = fillColor;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Highlight ring for centroid
|
||||||
|
if (isSelected) {
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
} else if (isHovered) {
|
||||||
|
ctx.strokeStyle = '#ffff00';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw label
|
||||||
|
ctx.font = 'bold 11px Arial';
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeText(v.level, x + 12, y + 4);
|
||||||
|
ctx.fillText(v.level, x + 12, y + 4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}, [image, scale, structure, selectedLevel, hoveredLevel, hoveredCorner]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draw();
|
||||||
|
}, [draw]);
|
||||||
|
|
||||||
|
// Convert screen coords to image coords
|
||||||
|
const screenToImage = useCallback((clientX: number, clientY: number): [number, number] => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return [0, 0];
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = (clientX - rect.left) / scale;
|
||||||
|
const y = (clientY - rect.top) / scale;
|
||||||
|
return [x, y];
|
||||||
|
}, [scale]);
|
||||||
|
|
||||||
|
// Find corner point at position
|
||||||
|
const findCornerAt = useCallback((x: number, y: number): CornerHit | null => {
|
||||||
|
if (!structure) return null;
|
||||||
|
const threshold = 15 / scale;
|
||||||
|
|
||||||
|
for (const v of structure.vertebrae) {
|
||||||
|
const corners = v.final_values?.corners_px;
|
||||||
|
if (!corners) continue;
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const dist = Math.sqrt((x - corners[i][0]) ** 2 + (y - corners[i][1]) ** 2);
|
||||||
|
if (dist < threshold) {
|
||||||
|
return { level: v.level, cornerIdx: i };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [structure, scale]);
|
||||||
|
|
||||||
|
// Find vertebra centroid at position
|
||||||
|
const findVertebraAt = useCallback((x: number, y: number): string | null => {
|
||||||
|
if (!structure) return null;
|
||||||
|
const threshold = 20 / scale;
|
||||||
|
let closest: string | null = null;
|
||||||
|
let minDist = threshold;
|
||||||
|
|
||||||
|
structure.vertebrae.forEach(v => {
|
||||||
|
const centroid = v.final_values?.centroid_px;
|
||||||
|
if (!centroid) return;
|
||||||
|
const dist = Math.sqrt((x - centroid[0]) ** 2 + (y - centroid[1]) ** 2);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
closest = v.level;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return closest;
|
||||||
|
}, [structure, scale]);
|
||||||
|
|
||||||
|
// Mouse handlers
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (!structure) return;
|
||||||
|
const [x, y] = screenToImage(e.clientX, e.clientY);
|
||||||
|
|
||||||
|
// Check for corner hit first (only on selected vertebra for precision)
|
||||||
|
if (selectedLevel) {
|
||||||
|
const cornerHit = findCornerAt(x, y);
|
||||||
|
if (cornerHit && cornerHit.level === selectedLevel) {
|
||||||
|
const v = getVertebra(cornerHit.level);
|
||||||
|
if (v?.final_values?.corners_px && v?.final_values?.centroid_px) {
|
||||||
|
setDragState({
|
||||||
|
type: 'corner',
|
||||||
|
level: cornerHit.level,
|
||||||
|
cornerIdx: cornerHit.cornerIdx,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
originalCorners: v.final_values.corners_px.map(c => [...c] as [number, number]),
|
||||||
|
originalCentroid: [...v.final_values.centroid_px] as [number, number],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for centroid hit
|
||||||
|
const level = findVertebraAt(x, y);
|
||||||
|
if (level) {
|
||||||
|
setSelectedLevel(level);
|
||||||
|
const v = getVertebra(level);
|
||||||
|
if (v?.final_values?.centroid_px) {
|
||||||
|
setDragState({
|
||||||
|
type: 'centroid',
|
||||||
|
level,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
originalCorners: v.final_values.corners_px
|
||||||
|
? v.final_values.corners_px.map(c => [...c] as [number, number])
|
||||||
|
: null,
|
||||||
|
originalCentroid: [...v.final_values.centroid_px] as [number, number],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (selectedLevel) {
|
||||||
|
// Place selected (missing) vertebra at click position
|
||||||
|
const v = getVertebra(selectedLevel);
|
||||||
|
if (v && (!v.final_values?.centroid_px || !v.detected)) {
|
||||||
|
// Create default corners around click position
|
||||||
|
const defaultHalfWidth = 15;
|
||||||
|
const defaultHalfHeight = 12;
|
||||||
|
const defaultCorners: [number, number][] = [
|
||||||
|
[x - defaultHalfWidth, y - defaultHalfHeight], // top_left
|
||||||
|
[x + defaultHalfWidth, y - defaultHalfHeight], // top_right
|
||||||
|
[x - defaultHalfWidth, y + defaultHalfHeight], // bottom_left
|
||||||
|
[x + defaultHalfWidth, y + defaultHalfHeight], // bottom_right
|
||||||
|
];
|
||||||
|
updateVertebraCorners(selectedLevel, defaultCorners);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
const [x, y] = screenToImage(e.clientX, e.clientY);
|
||||||
|
|
||||||
|
if (dragState) {
|
||||||
|
if (dragState.type === 'corner' && dragState.cornerIdx !== undefined && dragState.originalCorners) {
|
||||||
|
// Dragging a corner
|
||||||
|
const dx = (e.clientX - dragState.startX) / scale;
|
||||||
|
const dy = (e.clientY - dragState.startY) / scale;
|
||||||
|
|
||||||
|
// Update just the dragged corner
|
||||||
|
const newCorners = dragState.originalCorners.map((c, i) => {
|
||||||
|
if (i === dragState.cornerIdx) {
|
||||||
|
return [c[0] + dx, c[1] + dy] as [number, number];
|
||||||
|
}
|
||||||
|
return [...c] as [number, number];
|
||||||
|
});
|
||||||
|
|
||||||
|
updateVertebraCorners(dragState.level, newCorners);
|
||||||
|
} else if (dragState.type === 'centroid') {
|
||||||
|
// Dragging the centroid (moves entire vertebra)
|
||||||
|
const dx = (e.clientX - dragState.startX) / scale;
|
||||||
|
const dy = (e.clientY - dragState.startY) / scale;
|
||||||
|
const newCentroid: [number, number] = [
|
||||||
|
dragState.originalCentroid[0] + dx,
|
||||||
|
dragState.originalCentroid[1] + dy,
|
||||||
|
];
|
||||||
|
updateVertebraPosition(dragState.level, newCentroid, dragState.originalCorners, dragState.originalCentroid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hovering - check for corner first if a vertebra is selected
|
||||||
|
if (selectedLevel) {
|
||||||
|
const cornerHit = findCornerAt(x, y);
|
||||||
|
if (cornerHit && cornerHit.level === selectedLevel) {
|
||||||
|
setHoveredCorner(cornerHit);
|
||||||
|
setHoveredLevel(cornerHit.level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHoveredCorner(null);
|
||||||
|
setHoveredLevel(findVertebraAt(x, y));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setDragState(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine cursor style
|
||||||
|
const getCursor = () => {
|
||||||
|
if (dragState) return 'grabbing';
|
||||||
|
if (hoveredCorner) return 'move';
|
||||||
|
if (hoveredLevel) return 'grab';
|
||||||
|
return 'crosshair';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle approve
|
||||||
|
const handleApprove = async () => {
|
||||||
|
if (structure && hasChanges) {
|
||||||
|
await onUpdateLandmarks(structure);
|
||||||
|
}
|
||||||
|
await onApprove(structure || undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const detectedCount = structure?.vertebrae.filter(v => v.detected).length || 0;
|
||||||
|
const manualCount = structure?.vertebrae.filter(v => v.manual_override?.enabled).length || 0;
|
||||||
|
const placedCount = structure?.vertebrae.filter(v => v.final_values?.centroid_px).length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage landmark-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 1: Vertebrae Detection</h2>
|
||||||
|
<div className="stage-status">
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="status-badge status-processing">Processing...</span>
|
||||||
|
) : landmarksData ? (
|
||||||
|
<span className="status-badge status-complete">
|
||||||
|
{hasChanges ? 'Modified' : 'Detected'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-badge status-pending">Pending</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-content landmark-interactive">
|
||||||
|
{/* Canvas area */}
|
||||||
|
<div className="landmark-canvas-area" ref={containerRef}>
|
||||||
|
{!landmarksData && !isLoading && (
|
||||||
|
<div className="landmark-empty">
|
||||||
|
{xrayUrl ? (
|
||||||
|
<>
|
||||||
|
<img src={xrayUrl} alt="X-ray" className="xray-preview" />
|
||||||
|
<p>X-ray uploaded. Click to detect landmarks.</p>
|
||||||
|
<button className="btn primary" onClick={onDetect}>
|
||||||
|
Detect Landmarks
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>Please upload an X-ray image first.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="landmark-loading">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Detecting vertebrae landmarks...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state when image fails to load */}
|
||||||
|
{imageError && (
|
||||||
|
<div className="landmark-error">
|
||||||
|
<p>{imageError}</p>
|
||||||
|
{xrayUrl && (
|
||||||
|
<button className="btn secondary" onClick={() => {
|
||||||
|
setImageError(null);
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.onload = () => setImage(img);
|
||||||
|
img.onerror = () => setImageError('Failed to load image');
|
||||||
|
img.src = xrayUrl;
|
||||||
|
}}>
|
||||||
|
Retry Loading
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state while image is being fetched */}
|
||||||
|
{landmarksData && !image && !imageError && xrayUrl && (
|
||||||
|
<div className="landmark-loading">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading X-ray image...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{landmarksData && image && (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="landmark-canvas-interactive"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
style={{ cursor: getCursor() }}
|
||||||
|
/>
|
||||||
|
<div className="canvas-hint">
|
||||||
|
{selectedLevel && !getVertebra(selectedLevel)?.final_values?.centroid_px ? (
|
||||||
|
<span>Click on image to place <strong>{selectedLevel}</strong></span>
|
||||||
|
) : selectedLevel ? (
|
||||||
|
<span>Drag corners to adjust box shape • Drag center to move • Click another to select</span>
|
||||||
|
) : (
|
||||||
|
<span>Click a vertebra to select and edit • Drag center to move</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vertebrae panel */}
|
||||||
|
{landmarksData && structure && (
|
||||||
|
<div className="vertebrae-panel-inline">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h3>Vertebrae</h3>
|
||||||
|
<div className="panel-stats">
|
||||||
|
<span className="stat-pill detected">{detectedCount} detected</span>
|
||||||
|
{manualCount > 0 && <span className="stat-pill manual">{manualCount} edited</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vertebrae-scroll">
|
||||||
|
{structure.vertebrae.map(v => {
|
||||||
|
const hasCentroid = !!v.final_values?.centroid_px;
|
||||||
|
const hasCorners = !!v.final_values?.corners_px;
|
||||||
|
const isSelected = selectedLevel === v.level;
|
||||||
|
const isManual = v.manual_override?.enabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.level}
|
||||||
|
className={`vert-row ${isSelected ? 'selected' : ''} ${isManual ? 'manual' : ''} ${!hasCentroid ? 'missing' : ''}`}
|
||||||
|
onClick={() => setSelectedLevel(v.level)}
|
||||||
|
>
|
||||||
|
<span className="vert-level">{v.level}</span>
|
||||||
|
<span className="vert-info">
|
||||||
|
{isManual ? (
|
||||||
|
<span className="info-manual">Manual</span>
|
||||||
|
) : v.detected ? (
|
||||||
|
<span className="info-conf">
|
||||||
|
{((v.scoliovis_data?.confidence || 0) * 100).toFixed(0)}%
|
||||||
|
{hasCorners && <span className="has-corners" title="Has box corners"> ◻</span>}
|
||||||
|
</span>
|
||||||
|
) : hasCentroid ? (
|
||||||
|
<span className="info-placed">Placed</span>
|
||||||
|
) : (
|
||||||
|
<span className="info-missing">Click to place</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{isManual && (
|
||||||
|
<button
|
||||||
|
className="vert-reset"
|
||||||
|
onClick={(e) => { e.stopPropagation(); resetVertebra(v.level); }}
|
||||||
|
title="Reset to original"
|
||||||
|
>
|
||||||
|
↩
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="panel-legend">
|
||||||
|
<span><i className="dot red"></i>Detected</span>
|
||||||
|
<span><i className="dot green"></i>Manual</span>
|
||||||
|
<span><i className="dot gray"></i>Missing</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick analysis preview */}
|
||||||
|
{landmarksData.cobb_angles && (
|
||||||
|
<div className="quick-analysis">
|
||||||
|
<div className="qa-row">
|
||||||
|
<span>PT</span>
|
||||||
|
<span>{(landmarksData.cobb_angles.PT || 0).toFixed(1)}°</span>
|
||||||
|
</div>
|
||||||
|
<div className="qa-row">
|
||||||
|
<span>MT</span>
|
||||||
|
<span>{(landmarksData.cobb_angles.MT || 0).toFixed(1)}°</span>
|
||||||
|
</div>
|
||||||
|
<div className="qa-row">
|
||||||
|
<span>TL</span>
|
||||||
|
<span>{(landmarksData.cobb_angles.TL || 0).toFixed(1)}°</span>
|
||||||
|
</div>
|
||||||
|
<div className="qa-rigo">
|
||||||
|
<span className="rigo-tag">{landmarksData.rigo_classification?.type || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{landmarksData && (
|
||||||
|
<div className="stage-actions">
|
||||||
|
{hasChanges && (
|
||||||
|
<span className="changes-indicator">Unsaved changes</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{hasChanges ? 'Save & Continue' : 'Approve & Continue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
frontend/src/components/pipeline/PipelineSteps.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Pipeline Step Indicator
|
||||||
|
* Shows the current stage in the 5-stage pipeline
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PipelineStage = 'upload' | 'landmarks' | 'analysis' | 'bodyscan' | 'brace' | 'fitting';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentStage: PipelineStage;
|
||||||
|
landmarksApproved: boolean;
|
||||||
|
analysisComplete: boolean;
|
||||||
|
bodyScanComplete: boolean;
|
||||||
|
braceGenerated: boolean;
|
||||||
|
onStageClick?: (stage: PipelineStage) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stages = [
|
||||||
|
{ id: 'landmarks' as const, label: 'Landmark Detection', step: 1 },
|
||||||
|
{ id: 'analysis' as const, label: 'Spine Analysis', step: 2 },
|
||||||
|
{ id: 'bodyscan' as const, label: 'Body Scan', step: 3 },
|
||||||
|
{ id: 'brace' as const, label: 'Brace Generation', step: 4 },
|
||||||
|
{ id: 'fitting' as const, label: 'Fitting Inspection', step: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PipelineSteps({
|
||||||
|
currentStage,
|
||||||
|
landmarksApproved,
|
||||||
|
analysisComplete,
|
||||||
|
bodyScanComplete,
|
||||||
|
braceGenerated,
|
||||||
|
onStageClick
|
||||||
|
}: Props) {
|
||||||
|
const getStageStatus = (stageId: typeof stages[number]['id']) => {
|
||||||
|
if (stageId === 'landmarks') {
|
||||||
|
if (landmarksApproved) return 'complete';
|
||||||
|
if (currentStage === 'landmarks') return 'active';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
if (stageId === 'analysis') {
|
||||||
|
if (analysisComplete) return 'complete';
|
||||||
|
if (currentStage === 'analysis') return 'active';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
if (stageId === 'bodyscan') {
|
||||||
|
if (bodyScanComplete) return 'complete';
|
||||||
|
if (currentStage === 'bodyscan') return 'active';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
if (stageId === 'brace') {
|
||||||
|
if (braceGenerated) return 'complete';
|
||||||
|
if (currentStage === 'brace') return 'active';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
if (stageId === 'fitting') {
|
||||||
|
if (currentStage === 'fitting') return 'active';
|
||||||
|
if (braceGenerated) return 'available'; // Can navigate to fitting after brace is generated
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
return 'pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
const canNavigateToStage = (stageId: typeof stages[number]['id']) => {
|
||||||
|
// Can always go back to completed stages
|
||||||
|
const status = getStageStatus(stageId);
|
||||||
|
if (status === 'complete') return true;
|
||||||
|
if (status === 'active') return true;
|
||||||
|
if (status === 'available') return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pipeline-steps">
|
||||||
|
{stages.map((stage, idx) => {
|
||||||
|
const status = getStageStatus(stage.id);
|
||||||
|
const canNavigate = canNavigateToStage(stage.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stage.id}
|
||||||
|
className={`pipeline-step pipeline-step--${status} ${canNavigate ? 'clickable' : ''}`}
|
||||||
|
onClick={() => canNavigate && onStageClick?.(stage.id)}
|
||||||
|
style={{ cursor: canNavigate ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
<div className="pipeline-step-number">
|
||||||
|
{status === 'complete' ? '✓' : stage.step}
|
||||||
|
</div>
|
||||||
|
<div className="pipeline-step-label">{stage.label}</div>
|
||||||
|
{idx < stages.length - 1 && <div className="pipeline-step-connector" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
frontend/src/components/pipeline/SpineAnalysisStage.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* Stage 2: Spine Analysis
|
||||||
|
* Shows Cobb angles, Rigo classification, and curve analysis
|
||||||
|
*/
|
||||||
|
import type { LandmarksResult, RecalculationResult } from '../../api/braceflowApi';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
landmarksData: LandmarksResult | null;
|
||||||
|
analysisData: RecalculationResult | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onRecalculate: () => Promise<void>;
|
||||||
|
onContinue: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function getSeverityClass(severity: string): string {
|
||||||
|
switch (severity?.toLowerCase()) {
|
||||||
|
case 'normal':
|
||||||
|
return 'severity-normal';
|
||||||
|
case 'mild':
|
||||||
|
return 'severity-mild';
|
||||||
|
case 'moderate':
|
||||||
|
return 'severity-moderate';
|
||||||
|
case 'severe':
|
||||||
|
case 'very severe':
|
||||||
|
return 'severity-severe';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRigoDescription(type: string): string {
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
A1: 'Three-curve pattern with lumbar modifier - Main thoracic curve with compensatory lumbar',
|
||||||
|
A2: 'Three-curve pattern with thoracolumbar modifier - Thoracolumbar prominence',
|
||||||
|
A3: 'Three-curve pattern balanced - Balanced thoracic and lumbar curves',
|
||||||
|
B1: 'Four-curve pattern with lumbar modifier - Double thoracic with lumbar',
|
||||||
|
B2: 'Four-curve pattern with double thoracic - Primary double thoracic',
|
||||||
|
C1: 'Non-3 non-4 with thoracolumbar curve - Single thoracolumbar focus',
|
||||||
|
C2: 'Non-3 non-4 with lumbar curve - Single lumbar focus',
|
||||||
|
E1: 'Single thoracic curve - Primary thoracic scoliosis',
|
||||||
|
E2: 'Single thoracolumbar curve - Primary thoracolumbar scoliosis',
|
||||||
|
};
|
||||||
|
return descriptions[type] || `Rigo-Chêneau classification type ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTreatmentRecommendation(maxCobb: number): {
|
||||||
|
recommendation: string;
|
||||||
|
urgency: string;
|
||||||
|
} {
|
||||||
|
if (maxCobb < 10) {
|
||||||
|
return {
|
||||||
|
recommendation: 'Observation only - no treatment required',
|
||||||
|
urgency: 'routine',
|
||||||
|
};
|
||||||
|
} else if (maxCobb < 25) {
|
||||||
|
return {
|
||||||
|
recommendation: 'Physical therapy and observation recommended',
|
||||||
|
urgency: 'standard',
|
||||||
|
};
|
||||||
|
} else if (maxCobb < 40) {
|
||||||
|
return {
|
||||||
|
recommendation: 'Brace treatment indicated - custom brace recommended',
|
||||||
|
urgency: 'priority',
|
||||||
|
};
|
||||||
|
} else if (maxCobb < 50) {
|
||||||
|
return {
|
||||||
|
recommendation: 'Aggressive bracing required - consider surgical consultation',
|
||||||
|
urgency: 'high',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
recommendation: 'Surgical consultation recommended',
|
||||||
|
urgency: 'urgent',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpineAnalysisStage({
|
||||||
|
landmarksData,
|
||||||
|
analysisData,
|
||||||
|
isLoading,
|
||||||
|
onRecalculate,
|
||||||
|
onContinue,
|
||||||
|
}: Props) {
|
||||||
|
// Use recalculated data if available, otherwise use initial detection data
|
||||||
|
const cobbAngles = analysisData?.cobb_angles || landmarksData?.cobb_angles;
|
||||||
|
const rigoClass = analysisData?.rigo_classification || landmarksData?.rigo_classification;
|
||||||
|
const curveType = analysisData?.curve_type || landmarksData?.curve_type;
|
||||||
|
|
||||||
|
if (!landmarksData) {
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage analysis-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 2: Spine Analysis</h2>
|
||||||
|
<div className="stage-status">
|
||||||
|
<span className="status-badge status-pending">Pending</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stage-content">
|
||||||
|
<div className="stage-empty">
|
||||||
|
<p>Complete Stage 1 (Landmark Detection) first.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCobb = cobbAngles ? Math.max(cobbAngles.PT, cobbAngles.MT, cobbAngles.TL) : 0;
|
||||||
|
const treatment = getTreatmentRecommendation(maxCobb);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pipeline-stage analysis-stage">
|
||||||
|
<div className="stage-header">
|
||||||
|
<h2>Stage 2: Spine Analysis</h2>
|
||||||
|
<div className="stage-status">
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="status-badge status-processing">Recalculating...</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-badge status-complete">Analyzed</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-content">
|
||||||
|
{/* Cobb Angles Panel */}
|
||||||
|
<div className="analysis-panel cobb-panel">
|
||||||
|
<h3>Cobb Angle Measurements</h3>
|
||||||
|
{cobbAngles && (
|
||||||
|
<div className="cobb-grid">
|
||||||
|
<div className={`cobb-card ${getSeverityClass(cobbAngles.PT_severity)}`}>
|
||||||
|
<div className="cobb-region">PT</div>
|
||||||
|
<div className="cobb-label">Proximal Thoracic</div>
|
||||||
|
<div className="cobb-value">{cobbAngles.PT.toFixed(1)}°</div>
|
||||||
|
<div className="cobb-severity">{cobbAngles.PT_severity}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`cobb-card ${getSeverityClass(cobbAngles.MT_severity)}`}>
|
||||||
|
<div className="cobb-region">MT</div>
|
||||||
|
<div className="cobb-label">Main Thoracic</div>
|
||||||
|
<div className="cobb-value">{cobbAngles.MT.toFixed(1)}°</div>
|
||||||
|
<div className="cobb-severity">{cobbAngles.MT_severity}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`cobb-card ${getSeverityClass(cobbAngles.TL_severity)}`}>
|
||||||
|
<div className="cobb-region">TL</div>
|
||||||
|
<div className="cobb-label">Thoracolumbar/Lumbar</div>
|
||||||
|
<div className="cobb-value">{cobbAngles.TL.toFixed(1)}°</div>
|
||||||
|
<div className="cobb-severity">{cobbAngles.TL_severity}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Classification Panel */}
|
||||||
|
<div className="analysis-panel classification-panel">
|
||||||
|
<h3>Classification</h3>
|
||||||
|
<div className="classification-grid">
|
||||||
|
{rigoClass && (
|
||||||
|
<div className="classification-card rigo-card">
|
||||||
|
<div className="classification-type">Rigo-Chêneau</div>
|
||||||
|
<div className="classification-badge">{rigoClass.type}</div>
|
||||||
|
<div className="classification-desc">
|
||||||
|
{rigoClass.description || getRigoDescription(rigoClass.type)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{curveType && (
|
||||||
|
<div className="classification-card curve-card">
|
||||||
|
<div className="classification-type">Curve Pattern</div>
|
||||||
|
<div className="classification-badge">{curveType}-Curve</div>
|
||||||
|
<div className="classification-desc">
|
||||||
|
{curveType === 'S'
|
||||||
|
? 'Double curve pattern with thoracic and lumbar components'
|
||||||
|
: curveType === 'C'
|
||||||
|
? 'Single curve pattern'
|
||||||
|
: 'Curve pattern identified'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Treatment Recommendation */}
|
||||||
|
<div className={`analysis-panel treatment-panel treatment-${treatment.urgency}`}>
|
||||||
|
<h3>Clinical Recommendation</h3>
|
||||||
|
<div className="treatment-content">
|
||||||
|
<div className="treatment-summary">
|
||||||
|
<span className="max-cobb">
|
||||||
|
Maximum Cobb Angle: <strong>{maxCobb.toFixed(1)}°</strong>
|
||||||
|
</span>
|
||||||
|
<span className={`urgency-badge urgency-${treatment.urgency}`}>
|
||||||
|
{treatment.urgency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="treatment-recommendation">{treatment.recommendation}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis Metadata */}
|
||||||
|
<div className="analysis-panel metadata-panel">
|
||||||
|
<h3>Analysis Details</h3>
|
||||||
|
<div className="metadata-grid">
|
||||||
|
<div className="metadata-item">
|
||||||
|
<span className="metadata-label">Vertebrae Analyzed</span>
|
||||||
|
<span className="metadata-value">
|
||||||
|
{analysisData?.vertebrae_used || landmarksData?.vertebrae_structure.detected_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="metadata-item">
|
||||||
|
<span className="metadata-label">Processing Time</span>
|
||||||
|
<span className="metadata-value">
|
||||||
|
{((analysisData?.processing_time_ms || landmarksData?.processing_time_ms || 0) / 1000).toFixed(2)}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{analysisData && (
|
||||||
|
<div className="metadata-item">
|
||||||
|
<span className="metadata-label">Source</span>
|
||||||
|
<span className="metadata-value">Recalculated</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="stage-actions">
|
||||||
|
<button
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={onRecalculate}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Recalculating...' : 'Recalculate'}
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" onClick={onContinue}>
|
||||||
|
Continue to Brace Generation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/components/pipeline/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { default as PipelineSteps } from './PipelineSteps';
|
||||||
|
export { default as LandmarkDetectionStage } from './LandmarkDetectionStage';
|
||||||
|
export { default as SpineAnalysisStage } from './SpineAnalysisStage';
|
||||||
|
export { default as BodyScanUploadStage } from './BodyScanUploadStage';
|
||||||
|
export { default as BraceGenerationStage } from './BraceGenerationStage';
|
||||||
|
export { default as BraceEditorStage } from './BraceEditorStage';
|
||||||
|
export { default as BraceInlineEditor } from './BraceInlineEditor';
|
||||||
|
export { default as BraceFittingStage } from './BraceFittingStage';
|
||||||
|
|
||||||
|
export type { PipelineStage } from './PipelineSteps';
|
||||||
|
export type { BraceTransformParams } from './BraceInlineEditor';
|
||||||
3707
frontend/src/components/pipeline/pipeline.css
Normal file
301
frontend/src/components/rigo/AnalysisResults.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { rigoApi, type AnalysisResult } from "../../api/rigoApi";
|
||||||
|
|
||||||
|
interface AnalysisResultsProps {
|
||||||
|
data: AnalysisResult | null;
|
||||||
|
modelUrl: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onModelUpdate: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalysisResults({ data, modelUrl, isLoading, error, onModelUpdate }: AnalysisResultsProps) {
|
||||||
|
const [showPadsOnly, setShowPadsOnly] = useState(true);
|
||||||
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
|
|
||||||
|
const handleTogglePadsOnly = async () => {
|
||||||
|
if (!data || !onModelUpdate) return;
|
||||||
|
|
||||||
|
setIsRegenerating(true);
|
||||||
|
const newShowPadsOnly = !showPadsOnly;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await rigoApi.regenerate({
|
||||||
|
pressure_pad_level: data.apex,
|
||||||
|
pressure_pad_depth: data.cobb_angle > 40 ? "aggressive" : data.cobb_angle > 20 ? "moderate" : "standard",
|
||||||
|
expansion_window_side: data.pelvic_tilt === "Left" ? "right" : "left",
|
||||||
|
lumbar_support: true,
|
||||||
|
include_shell: !newShowPadsOnly,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Construct full URL from relative path
|
||||||
|
const fullUrl = result.model_url.startsWith("http")
|
||||||
|
? result.model_url
|
||||||
|
: `${rigoApi.getBaseUrl()}${result.model_url}`;
|
||||||
|
onModelUpdate(fullUrl);
|
||||||
|
setShowPadsOnly(newShowPadsOnly);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
alert(`Failed to regenerate: ${message}`);
|
||||||
|
} finally {
|
||||||
|
setIsRegenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadGLB = () => {
|
||||||
|
if (modelUrl) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = modelUrl;
|
||||||
|
link.download = `brace_${data?.apex || "custom"}.glb`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else {
|
||||||
|
alert("3D model generation requires Blender. The placeholder model is for visualization only.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadSTL = () => {
|
||||||
|
if (modelUrl) {
|
||||||
|
const stlUrl = modelUrl.replace(".glb", ".stl");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = stlUrl;
|
||||||
|
link.download = `brace_${data?.apex || "custom"}.stl`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else {
|
||||||
|
alert("3D model generation requires Blender. The placeholder model is for visualization only.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rigo-analysis-error">
|
||||||
|
<div className="rigo-analysis-card" style={{ borderColor: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }}>
|
||||||
|
<div className="rigo-analysis-label" style={{ color: "#f87171" }}>
|
||||||
|
Error
|
||||||
|
</div>
|
||||||
|
<div className="rigo-analysis-value" style={{ color: "#f87171", fontSize: "1rem" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<div className="rigo-analysis-description">Please try uploading a clearer X-ray image.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rigo-analysis-loading">
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-loading-skeleton" style={{ height: "20px", marginBottom: "8px", width: "40%" }}></div>
|
||||||
|
<div className="rigo-loading-skeleton" style={{ height: "36px", width: "60%" }}></div>
|
||||||
|
</div>
|
||||||
|
<div className="rigo-analysis-grid">
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-loading-skeleton" style={{ height: "16px", marginBottom: "8px", width: "50%" }}></div>
|
||||||
|
<div className="rigo-loading-skeleton" style={{ height: "28px", width: "40%" }}></div>
|
||||||
|
</div>
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-loading-skeleton" style={{ height: "16px", marginBottom: "8px", width: "50%" }}></div>
|
||||||
|
<div className="rigo-loading-skeleton" style={{ height: "28px", width: "40%" }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ textAlign: "center", color: "#64748b", marginTop: "24px", fontSize: "0.875rem" }}>
|
||||||
|
<span className="rigo-spinner" style={{ display: "inline-block", marginRight: "8px", verticalAlign: "middle" }}></span>
|
||||||
|
Analyzing X-ray with Claude Vision...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="rigo-analysis-empty">
|
||||||
|
<div style={{ textAlign: "center", padding: "32px", color: "#64748b" }}>
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
style={{ margin: "0 auto 16px", opacity: 0.3 }}
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
<p>Upload an EOS X-ray to see the analysis results here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine severity based on Cobb angle
|
||||||
|
const getSeverity = (angle: number) => {
|
||||||
|
if (angle < 20) return { level: "Mild", class: "success" };
|
||||||
|
if (angle < 40) return { level: "Moderate", class: "highlight" };
|
||||||
|
return { level: "Severe", class: "warning" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const severity = getSeverity(data.cobb_angle);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rigo-analysis-results">
|
||||||
|
{/* Diagnosis */}
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-analysis-label">Diagnosis</div>
|
||||||
|
<div className="rigo-analysis-value highlight">
|
||||||
|
{data.pattern === "Type_3C" ? "Right Thoracic Scoliosis" : data.pattern}
|
||||||
|
</div>
|
||||||
|
<div className="rigo-analysis-description">
|
||||||
|
Rigo-Cheneau Classification: <strong>Type 3C</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thoracic Measurements */}
|
||||||
|
<div className="rigo-analysis-grid">
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-analysis-label">Thoracic Cobb</div>
|
||||||
|
<div className={`rigo-analysis-value ${severity.class}`}>{data.cobb_angle}°</div>
|
||||||
|
<div className="rigo-analysis-description">{severity.level} curve</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-analysis-label">Apex Vertebra</div>
|
||||||
|
<div className="rigo-analysis-value">{data.apex}</div>
|
||||||
|
<div className="rigo-analysis-description">Curve apex location</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thoracic Convexity & Lumbar Cobb */}
|
||||||
|
<div className="rigo-analysis-grid">
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-analysis-label">Thoracic Convexity</div>
|
||||||
|
<div className="rigo-analysis-value">{data.thoracic_convexity || "Right"}</div>
|
||||||
|
<div className="rigo-analysis-description">Curve direction</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-analysis-label">Lumbar Cobb</div>
|
||||||
|
<div className="rigo-analysis-value">{data.lumbar_cobb_deg != null ? `${data.lumbar_cobb_deg}°` : "—"}</div>
|
||||||
|
<div className="rigo-analysis-description">Compensatory curve</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* L4/L5 Tilt */}
|
||||||
|
<div className="rigo-analysis-grid">
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-analysis-label">L4 Tilt</div>
|
||||||
|
<div className="rigo-analysis-value">{data.l4_tilt_deg != null ? `${data.l4_tilt_deg.toFixed(1)}°` : "—"}</div>
|
||||||
|
<div className="rigo-analysis-description">Vertebra angle</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-analysis-label">L5 Tilt</div>
|
||||||
|
<div className="rigo-analysis-value">{data.l5_tilt_deg != null ? `${data.l5_tilt_deg.toFixed(1)}°` : "—"}</div>
|
||||||
|
<div className="rigo-analysis-description">Vertebra angle</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pelvic Tilt */}
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-analysis-label">Pelvic Tilt</div>
|
||||||
|
<div className="rigo-analysis-value">{data.pelvic_tilt} Side</div>
|
||||||
|
<div className="rigo-analysis-description">Compensatory pelvic position</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brace Parameters */}
|
||||||
|
<div
|
||||||
|
className="rigo-analysis-card"
|
||||||
|
style={{ marginTop: "16px", background: "rgba(59, 130, 246, 0.1)", borderColor: "#2563eb" }}
|
||||||
|
>
|
||||||
|
<div className="rigo-analysis-label" style={{ color: "#60a5fa" }}>
|
||||||
|
Generated Brace Parameters
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.875rem", color: "#94a3b8", marginTop: "8px" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "4px" }}>
|
||||||
|
<span>Pressure Pad Position:</span>
|
||||||
|
<strong style={{ color: "#f1f5f9" }}>{data.apex} Level</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "4px" }}>
|
||||||
|
<span>Pad Depth:</span>
|
||||||
|
<strong style={{ color: "#f1f5f9" }}>{data.cobb_angle > 30 ? "Aggressive" : "Standard"}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<span>Expansion Window:</span>
|
||||||
|
<strong style={{ color: "#f1f5f9" }}>{data.pelvic_tilt === "Left" ? "Right" : "Left"} Side</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
{modelUrl && (
|
||||||
|
<div style={{ marginTop: "16px" }}>
|
||||||
|
<button
|
||||||
|
className={`rigo-btn ${showPadsOnly ? "rigo-btn-primary" : "rigo-btn-secondary"} rigo-btn-block`}
|
||||||
|
onClick={handleTogglePadsOnly}
|
||||||
|
disabled={isRegenerating}
|
||||||
|
style={{ fontSize: "0.875rem" }}
|
||||||
|
>
|
||||||
|
{isRegenerating ? (
|
||||||
|
<>
|
||||||
|
<span className="rigo-spinner" style={{ width: "16px", height: "16px", marginRight: "8px" }}></span>
|
||||||
|
Regenerating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
{showPadsOnly ? (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<path d="M9 9h6v6H9z" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 5v2M12 17v2M5 12h2M17 12h2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{showPadsOnly ? "Show Full Brace" : "Show Pads Only"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px", marginTop: "16px" }}>
|
||||||
|
<button
|
||||||
|
className={`rigo-btn ${modelUrl ? "rigo-btn-primary" : "rigo-btn-secondary"} rigo-btn-block`}
|
||||||
|
onClick={handleDownloadSTL}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
{modelUrl ? "Download STL (3D Print)" : "Download (Blender Required)"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{modelUrl && (
|
||||||
|
<button className="rigo-btn rigo-btn-secondary rigo-btn-block" onClick={handleDownloadGLB}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
Download GLB (Web Viewer)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
frontend/src/components/rigo/BraceViewer.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { Suspense, useState, useRef } from "react";
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import { OrbitControls, Environment, ContactShadows, useGLTF, Center, Grid } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
// Placeholder brace model when no model is loaded
|
||||||
|
function PlaceholderBrace({ opacity = 1 }: { opacity?: number }) {
|
||||||
|
const meshRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (meshRef.current) {
|
||||||
|
meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.3) * 0.1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={meshRef}>
|
||||||
|
{/* Main brace body - simplified torso shape */}
|
||||||
|
<mesh position={[0, 0, 0]}>
|
||||||
|
<cylinderGeometry args={[0.8, 1, 2.5, 32, 1, true]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#4a90d9"
|
||||||
|
transparent
|
||||||
|
opacity={opacity * 0.8}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
metalness={0.1}
|
||||||
|
roughness={0.6}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Right pressure pad (thoracic) */}
|
||||||
|
<mesh position={[0.85, 0.3, 0]} rotation={[0, 0, Math.PI / 6]}>
|
||||||
|
<boxGeometry args={[0.15, 0.8, 0.6]} />
|
||||||
|
<meshStandardMaterial color="#2563eb" transparent opacity={opacity} metalness={0.2} roughness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Left expansion window cutout visual */}
|
||||||
|
<mesh position={[-0.75, 0.3, 0.2]}>
|
||||||
|
<boxGeometry args={[0.1, 0.6, 0.5]} />
|
||||||
|
<meshStandardMaterial color="#0f172a" transparent opacity={opacity * 0.9} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Lumbar support */}
|
||||||
|
<mesh position={[0, -0.8, 0.5]} rotation={[0.3, 0, 0]}>
|
||||||
|
<boxGeometry args={[0.8, 0.4, 0.2]} />
|
||||||
|
<meshStandardMaterial color="#3b82f6" transparent opacity={opacity} metalness={0.2} roughness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load actual GLB model
|
||||||
|
function BraceModel({ url, opacity = 1 }: { url: string; opacity?: number }) {
|
||||||
|
const { scene } = useGLTF(url);
|
||||||
|
const meshRef = useRef<THREE.Object3D>(null);
|
||||||
|
|
||||||
|
// Apply materials to all meshes
|
||||||
|
scene.traverse((child) => {
|
||||||
|
if ((child as THREE.Mesh).isMesh) {
|
||||||
|
(child as THREE.Mesh).material = new THREE.MeshStandardMaterial({
|
||||||
|
color: "#4a90d9",
|
||||||
|
transparent: true,
|
||||||
|
opacity: opacity * 0.8,
|
||||||
|
metalness: 0.1,
|
||||||
|
roughness: 0.6,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center>
|
||||||
|
<primitive ref={meshRef} object={scene} scale={1} />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading indicator
|
||||||
|
function LoadingIndicator() {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (meshRef.current) {
|
||||||
|
meshRef.current.rotation.y = state.clock.elapsedTime * 2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={meshRef}>
|
||||||
|
<torusGeometry args={[0.5, 0.1, 16, 32]} />
|
||||||
|
<meshStandardMaterial color="#3b82f6" emissive="#3b82f6" emissiveIntensity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BraceViewerProps {
|
||||||
|
modelUrl: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BraceViewer({ modelUrl, isLoading }: BraceViewerProps) {
|
||||||
|
const [transparency, setTransparency] = useState(false);
|
||||||
|
const [showGrid, setShowGrid] = useState(true);
|
||||||
|
|
||||||
|
const opacity = transparency ? 0.4 : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Canvas
|
||||||
|
className="rigo-viewer-canvas"
|
||||||
|
camera={{ position: [3, 2, 3], fov: 45 }}
|
||||||
|
gl={{ antialias: true, alpha: true }}
|
||||||
|
style={{ width: "100%", height: "100%", minHeight: "400px" }}
|
||||||
|
>
|
||||||
|
<color attach="background" args={["#111827"]} />
|
||||||
|
|
||||||
|
{/* Lighting */}
|
||||||
|
<ambientLight intensity={0.4} />
|
||||||
|
<directionalLight position={[5, 5, 5]} intensity={1} castShadow shadow-mapSize={[2048, 2048]} />
|
||||||
|
<directionalLight position={[-5, 3, -5]} intensity={0.3} />
|
||||||
|
<pointLight position={[0, 3, 0]} intensity={0.5} color="#60a5fa" />
|
||||||
|
|
||||||
|
{/* Environment for reflections */}
|
||||||
|
<Environment preset="city" />
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
{showGrid && (
|
||||||
|
<Grid
|
||||||
|
args={[10, 10]}
|
||||||
|
cellSize={0.5}
|
||||||
|
cellThickness={0.5}
|
||||||
|
cellColor="#334155"
|
||||||
|
sectionSize={2}
|
||||||
|
sectionThickness={1}
|
||||||
|
sectionColor="#475569"
|
||||||
|
fadeDistance={10}
|
||||||
|
fadeStrength={1}
|
||||||
|
position={[0, -1.5, 0]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact shadows */}
|
||||||
|
<ContactShadows position={[0, -1.5, 0]} opacity={0.4} scale={5} blur={2.5} />
|
||||||
|
|
||||||
|
{/* Model */}
|
||||||
|
<Suspense fallback={<LoadingIndicator />}>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : modelUrl ? (
|
||||||
|
<BraceModel url={modelUrl} opacity={opacity} />
|
||||||
|
) : (
|
||||||
|
<PlaceholderBrace opacity={opacity} />
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<OrbitControls
|
||||||
|
makeDefault
|
||||||
|
enableDamping
|
||||||
|
dampingFactor={0.05}
|
||||||
|
minDistance={2}
|
||||||
|
maxDistance={10}
|
||||||
|
minPolarAngle={Math.PI / 6}
|
||||||
|
maxPolarAngle={Math.PI / 1.5}
|
||||||
|
/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
{/* Viewer Controls */}
|
||||||
|
<div className="rigo-viewer-controls">
|
||||||
|
<button
|
||||||
|
className={`rigo-viewer-control-btn ${transparency ? "active" : ""}`}
|
||||||
|
onClick={() => setTransparency(!transparency)}
|
||||||
|
title="Toggle Transparency"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" strokeDasharray="4 2" />
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`rigo-viewer-control-btn ${showGrid ? "active" : ""}`}
|
||||||
|
onClick={() => setShowGrid(!showGrid)}
|
||||||
|
title="Toggle Grid"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9" />
|
||||||
|
<line x1="3" y1="15" x2="21" y2="15" />
|
||||||
|
<line x1="9" y1="3" x2="9" y2="21" />
|
||||||
|
<line x1="15" y1="3" x2="15" y2="21" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
frontend/src/components/rigo/UploadPanel.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
interface UploadPanelProps {
|
||||||
|
onUpload: (file: File) => void;
|
||||||
|
isAnalyzing: boolean;
|
||||||
|
onReset: () => void;
|
||||||
|
hasResults: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadPanel({ onUpload, isAnalyzing, onReset, hasResults }: UploadPanelProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
const [fileName, setFileName] = useState("");
|
||||||
|
|
||||||
|
const handleFile = useCallback((file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const validTypes = ["image/jpeg", "image/png", "image/webp", "image/bmp"];
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
alert("Please upload a valid image file (JPEG, PNG, WebP, or BMP)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create preview
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setPreview(e.target?.result as string);
|
||||||
|
setFileName(file.name);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
handleFile(file);
|
||||||
|
},
|
||||||
|
[handleFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0] || null;
|
||||||
|
handleFile(file);
|
||||||
|
},
|
||||||
|
[handleFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAnalyze = useCallback(() => {
|
||||||
|
if (!preview) return;
|
||||||
|
|
||||||
|
// Convert base64 to file for upload
|
||||||
|
fetch(preview)
|
||||||
|
.then((res) => res.blob())
|
||||||
|
.then((blob) => {
|
||||||
|
const file = new File([blob], fileName, { type: blob.type });
|
||||||
|
onUpload(file);
|
||||||
|
});
|
||||||
|
}, [preview, fileName, onUpload]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setPreview(null);
|
||||||
|
setFileName("");
|
||||||
|
onReset();
|
||||||
|
}, [onReset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rigo-upload-panel">
|
||||||
|
{!preview ? (
|
||||||
|
<label
|
||||||
|
id="rigo-upload-zone"
|
||||||
|
className={`rigo-upload-zone ${isDragging ? "dragging" : ""}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
<div className="rigo-upload-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="rigo-upload-text">
|
||||||
|
<strong>Drop your EOS X-ray here</strong>
|
||||||
|
<br />
|
||||||
|
or click to browse
|
||||||
|
</p>
|
||||||
|
<p className="rigo-upload-hint">Supports JPEG, PNG, WebP, BMP</p>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<div className="rigo-upload-preview-container">
|
||||||
|
<div className="rigo-upload-preview">
|
||||||
|
<img src={preview} alt="X-ray preview" />
|
||||||
|
<div className="rigo-upload-preview-overlay">
|
||||||
|
<span style={{ color: "white", fontSize: "0.875rem" }}>{fileName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||||
|
<button
|
||||||
|
className="rigo-btn rigo-btn-primary rigo-btn-block rigo-btn-lg"
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
disabled={isAnalyzing}
|
||||||
|
>
|
||||||
|
{isAnalyzing ? (
|
||||||
|
<>
|
||||||
|
<span className="rigo-spinner"></span>
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : hasResults ? (
|
||||||
|
<>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
Complete
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Analyze
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rigo-btn rigo-btn-secondary rigo-btn-block"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={isAnalyzing}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
Clear & Upload New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress Steps */}
|
||||||
|
<div className="rigo-progress-steps" style={{ marginTop: "32px" }}>
|
||||||
|
<div className={`rigo-progress-step ${preview ? "completed" : "active"}`}>
|
||||||
|
<div className="rigo-progress-step-indicator">1</div>
|
||||||
|
<div className="rigo-progress-step-content">
|
||||||
|
<div className="rigo-progress-step-title">Upload X-Ray</div>
|
||||||
|
<div className="rigo-progress-step-description">EOS or standard spinal radiograph</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`rigo-progress-step ${isAnalyzing ? "active" : hasResults ? "completed" : ""}`}>
|
||||||
|
<div className="rigo-progress-step-indicator">2</div>
|
||||||
|
<div className="rigo-progress-step-content">
|
||||||
|
<div className="rigo-progress-step-title">Generate Brace</div>
|
||||||
|
<div className="rigo-progress-step-description">3D model with corrective parameters</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`rigo-progress-step ${hasResults ? "completed" : ""}`}>
|
||||||
|
<div className="rigo-progress-step-indicator">3</div>
|
||||||
|
<div className="rigo-progress-step-content">
|
||||||
|
<div className="rigo-progress-step-title">Download STL</div>
|
||||||
|
<div className="rigo-progress-step-description">Ready for 3D printing or editing</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
frontend/src/components/three/BodyScanViewer.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* 3D Body Scan Viewer with Auto-Rotation
|
||||||
|
* Displays STL/OBJ/GLB body scans with spinning animation
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
// Three.js is loaded dynamically
|
||||||
|
let THREE: any = null;
|
||||||
|
let STLLoader: any = null;
|
||||||
|
let OBJLoader: any = null;
|
||||||
|
let GLTFLoader: any = null;
|
||||||
|
|
||||||
|
type BodyScanViewerProps = {
|
||||||
|
scanUrl: string | null;
|
||||||
|
autoRotate?: boolean;
|
||||||
|
rotationSpeed?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BodyScanViewer({
|
||||||
|
scanUrl,
|
||||||
|
autoRotate = true,
|
||||||
|
rotationSpeed = 0.01,
|
||||||
|
}: BodyScanViewerProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rendererRef = useRef<any>(null);
|
||||||
|
const sceneRef = useRef<any>(null);
|
||||||
|
const cameraRef = useRef<any>(null);
|
||||||
|
const meshRef = useRef<any>(null);
|
||||||
|
const animationFrameRef = useRef<number>(0);
|
||||||
|
const autoRotateRef = useRef(autoRotate);
|
||||||
|
const rotationSpeedRef = useRef(rotationSpeed);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [threeLoaded, setThreeLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Keep refs in sync with props
|
||||||
|
useEffect(() => {
|
||||||
|
autoRotateRef.current = autoRotate;
|
||||||
|
rotationSpeedRef.current = rotationSpeed;
|
||||||
|
}, [autoRotate, rotationSpeed]);
|
||||||
|
|
||||||
|
// Load Three.js dynamically
|
||||||
|
useEffect(() => {
|
||||||
|
const loadThree = async () => {
|
||||||
|
try {
|
||||||
|
const threeModule = await import('three');
|
||||||
|
THREE = threeModule;
|
||||||
|
|
||||||
|
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
|
||||||
|
STLLoader = STL;
|
||||||
|
|
||||||
|
const { OBJLoader: OBJ } = await import('three/examples/jsm/loaders/OBJLoader.js');
|
||||||
|
OBJLoader = OBJ;
|
||||||
|
|
||||||
|
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||||
|
GLTFLoader = GLTF;
|
||||||
|
|
||||||
|
setThreeLoaded(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load Three.js:', e);
|
||||||
|
setError('Failed to load 3D viewer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadThree();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
if (rendererRef.current) {
|
||||||
|
rendererRef.current.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize scene when Three.js is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const width = container.clientWidth;
|
||||||
|
const height = container.clientHeight;
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x1a1a2e);
|
||||||
|
sceneRef.current = scene;
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
|
||||||
|
camera.position.set(0, 50, 500);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
cameraRef.current = camera;
|
||||||
|
|
||||||
|
// Renderer - fills container
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
|
||||||
|
// Lighting - multiple lights for good coverage during rotation
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
|
||||||
|
const frontLight = new THREE.DirectionalLight(0xffffff, 0.7);
|
||||||
|
frontLight.position.set(0, 100, 400);
|
||||||
|
scene.add(frontLight);
|
||||||
|
|
||||||
|
const backLight = new THREE.DirectionalLight(0x88ccff, 0.5);
|
||||||
|
backLight.position.set(0, 100, -400);
|
||||||
|
scene.add(backLight);
|
||||||
|
|
||||||
|
const topLight = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||||
|
topLight.position.set(0, 400, 0);
|
||||||
|
scene.add(topLight);
|
||||||
|
|
||||||
|
const sideLight1 = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||||
|
sideLight1.position.set(400, 100, 0);
|
||||||
|
scene.add(sideLight1);
|
||||||
|
|
||||||
|
const sideLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||||
|
sideLight2.position.set(-400, 100, 0);
|
||||||
|
scene.add(sideLight2);
|
||||||
|
|
||||||
|
// Animation loop - rotate the mesh around its own Y-axis (self-rotation)
|
||||||
|
const animate = () => {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
// Self-rotation: rotate around Z axis (the original vertical axis of STL files)
|
||||||
|
if (meshRef.current && autoRotateRef.current) {
|
||||||
|
meshRef.current.rotation.z += rotationSpeedRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!container || !renderer || !camera) return;
|
||||||
|
const newWidth = container.clientWidth;
|
||||||
|
const newHeight = container.clientHeight;
|
||||||
|
camera.aspect = newWidth / newHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(newWidth, newHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(handleResize);
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [threeLoaded]);
|
||||||
|
|
||||||
|
// Load mesh when URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threeLoaded || !scanUrl || !sceneRef.current) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Clear previous mesh
|
||||||
|
if (meshRef.current) {
|
||||||
|
sceneRef.current.remove(meshRef.current);
|
||||||
|
meshRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine loader based on file extension
|
||||||
|
const ext = scanUrl.toLowerCase().split('.').pop() || '';
|
||||||
|
|
||||||
|
const onLoad = (result: any) => {
|
||||||
|
let mesh: any;
|
||||||
|
|
||||||
|
if (ext === 'stl') {
|
||||||
|
// STL returns geometry directly - center the geometry itself
|
||||||
|
const geometry = result;
|
||||||
|
geometry.center(); // This centers the geometry at origin
|
||||||
|
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
color: 0xccaa88,
|
||||||
|
specular: 0x222222,
|
||||||
|
shininess: 50,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
mesh = new THREE.Mesh(geometry, material);
|
||||||
|
|
||||||
|
// STL files are typically Z-up, rotate to Y-up
|
||||||
|
mesh.rotation.x = -Math.PI / 2;
|
||||||
|
|
||||||
|
} else if (ext === 'obj') {
|
||||||
|
mesh = result;
|
||||||
|
mesh.traverse((child: any) => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
child.material = new THREE.MeshPhongMaterial({
|
||||||
|
color: 0xccaa88,
|
||||||
|
specular: 0x222222,
|
||||||
|
shininess: 50,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (ext === 'glb' || ext === 'gltf') {
|
||||||
|
mesh = result.scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mesh) {
|
||||||
|
// Get bounding box to scale appropriately
|
||||||
|
const box = new THREE.Box3().setFromObject(mesh);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
|
||||||
|
// For non-STL, center the mesh position
|
||||||
|
if (ext !== 'stl') {
|
||||||
|
mesh.position.sub(center);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale to fit in view (smaller = further away appearance)
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const targetSize = 250;
|
||||||
|
const scale = targetSize / maxDim;
|
||||||
|
mesh.scale.multiplyScalar(scale);
|
||||||
|
|
||||||
|
// Position at scene center
|
||||||
|
mesh.position.set(0, 0, 0);
|
||||||
|
|
||||||
|
sceneRef.current.add(mesh);
|
||||||
|
meshRef.current = mesh;
|
||||||
|
|
||||||
|
// Position camera to see the whole model
|
||||||
|
cameraRef.current.position.set(0, 80, 400);
|
||||||
|
cameraRef.current.lookAt(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (err: any) => {
|
||||||
|
console.error('Failed to load mesh:', err);
|
||||||
|
setError('Failed to load 3D model');
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load based on extension
|
||||||
|
if (ext === 'stl') {
|
||||||
|
const loader = new STLLoader();
|
||||||
|
loader.load(scanUrl, onLoad, undefined, onError);
|
||||||
|
} else if (ext === 'obj') {
|
||||||
|
const loader = new OBJLoader();
|
||||||
|
loader.load(scanUrl, onLoad, undefined, onError);
|
||||||
|
} else if (ext === 'glb' || ext === 'gltf') {
|
||||||
|
const loader = new GLTFLoader();
|
||||||
|
loader.load(scanUrl, onLoad, undefined, onError);
|
||||||
|
} else if (ext === 'ply') {
|
||||||
|
// PLY not directly supported, show message
|
||||||
|
setError('PLY format preview not supported');
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
setError(`Unsupported format: ${ext}`);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [threeLoaded, scanUrl]);
|
||||||
|
|
||||||
|
if (!threeLoaded) {
|
||||||
|
return (
|
||||||
|
<div className="body-scan-viewer-loading">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading 3D viewer...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="body-scan-viewer-container">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="body-scan-viewer-canvas"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="body-scan-viewer-overlay">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading model...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="body-scan-viewer-overlay error">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!scanUrl && !loading && (
|
||||||
|
<div className="body-scan-viewer-overlay placeholder">
|
||||||
|
<p>No scan loaded</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
frontend/src/components/three/BraceModelViewer.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* 3D Brace Model Viewer with Auto-Rotation
|
||||||
|
* Displays STL/GLB brace models with spinning animation
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
// Three.js is loaded dynamically
|
||||||
|
let THREE: any = null;
|
||||||
|
let STLLoader: any = null;
|
||||||
|
let GLTFLoader: any = null;
|
||||||
|
|
||||||
|
type BraceModelViewerProps = {
|
||||||
|
stlUrl?: string | null;
|
||||||
|
glbUrl?: string | null;
|
||||||
|
autoRotate?: boolean;
|
||||||
|
rotationSpeed?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BraceModelViewer({
|
||||||
|
stlUrl,
|
||||||
|
glbUrl,
|
||||||
|
autoRotate = true,
|
||||||
|
rotationSpeed = 0.005,
|
||||||
|
}: BraceModelViewerProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rendererRef = useRef<any>(null);
|
||||||
|
const sceneRef = useRef<any>(null);
|
||||||
|
const cameraRef = useRef<any>(null);
|
||||||
|
const meshRef = useRef<any>(null);
|
||||||
|
const animationFrameRef = useRef<number>(0);
|
||||||
|
const autoRotateRef = useRef(autoRotate);
|
||||||
|
const rotationSpeedRef = useRef(rotationSpeed);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [threeLoaded, setThreeLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Keep refs in sync with props
|
||||||
|
useEffect(() => {
|
||||||
|
autoRotateRef.current = autoRotate;
|
||||||
|
rotationSpeedRef.current = rotationSpeed;
|
||||||
|
}, [autoRotate, rotationSpeed]);
|
||||||
|
|
||||||
|
// Determine which URL to use (prefer GLB over STL)
|
||||||
|
const modelUrl = glbUrl || stlUrl;
|
||||||
|
const modelType = glbUrl ? 'glb' : (stlUrl ? 'stl' : null);
|
||||||
|
|
||||||
|
// Load Three.js dynamically
|
||||||
|
useEffect(() => {
|
||||||
|
const loadThree = async () => {
|
||||||
|
try {
|
||||||
|
const threeModule = await import('three');
|
||||||
|
THREE = threeModule;
|
||||||
|
|
||||||
|
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
|
||||||
|
STLLoader = STL;
|
||||||
|
|
||||||
|
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||||
|
GLTFLoader = GLTF;
|
||||||
|
|
||||||
|
setThreeLoaded(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load Three.js:', e);
|
||||||
|
setError('Failed to load 3D viewer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadThree();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
if (rendererRef.current) {
|
||||||
|
rendererRef.current.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize scene when Three.js is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const width = container.clientWidth;
|
||||||
|
const height = container.clientHeight;
|
||||||
|
|
||||||
|
// Scene with gradient background
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x1e1e2e);
|
||||||
|
sceneRef.current = scene;
|
||||||
|
|
||||||
|
// Camera - positioned to view upright brace from front
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
|
||||||
|
camera.position.set(0, 0, 400);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
cameraRef.current = camera;
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.0;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
|
||||||
|
// Improved lighting for skin-like appearance with shadows
|
||||||
|
// Soft ambient for base illumination
|
||||||
|
scene.add(new THREE.AmbientLight(0xffeedd, 0.4));
|
||||||
|
|
||||||
|
// Hemisphere light for natural sky/ground coloring
|
||||||
|
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x8d7c6d, 0.5);
|
||||||
|
hemiLight.position.set(0, 200, 0);
|
||||||
|
scene.add(hemiLight);
|
||||||
|
|
||||||
|
// Key light (main light with shadows)
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xfff5e8, 1.0);
|
||||||
|
keyLight.position.set(150, 200, 300);
|
||||||
|
keyLight.castShadow = true;
|
||||||
|
keyLight.shadow.mapSize.width = 1024;
|
||||||
|
keyLight.shadow.mapSize.height = 1024;
|
||||||
|
keyLight.shadow.camera.near = 50;
|
||||||
|
keyLight.shadow.camera.far = 1000;
|
||||||
|
keyLight.shadow.camera.left = -200;
|
||||||
|
keyLight.shadow.camera.right = 200;
|
||||||
|
keyLight.shadow.camera.top = 200;
|
||||||
|
keyLight.shadow.camera.bottom = -200;
|
||||||
|
keyLight.shadow.bias = -0.0005;
|
||||||
|
scene.add(keyLight);
|
||||||
|
|
||||||
|
// Fill light (softer, opposite side)
|
||||||
|
const fillLight = new THREE.DirectionalLight(0xe8f0ff, 0.5);
|
||||||
|
fillLight.position.set(-200, 100, -100);
|
||||||
|
scene.add(fillLight);
|
||||||
|
|
||||||
|
// Rim light (back light for edge definition)
|
||||||
|
const rimLight = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||||
|
rimLight.position.set(0, 50, -300);
|
||||||
|
scene.add(rimLight);
|
||||||
|
|
||||||
|
// Bottom fill (subtle, prevents too dark shadows underneath)
|
||||||
|
const bottomFill = new THREE.DirectionalLight(0xffe8d0, 0.2);
|
||||||
|
bottomFill.position.set(0, -200, 100);
|
||||||
|
scene.add(bottomFill);
|
||||||
|
|
||||||
|
// Animation loop - rotate around Y axis (lazy susan for Y-up brace)
|
||||||
|
const animate = () => {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
if (meshRef.current && autoRotateRef.current) {
|
||||||
|
meshRef.current.rotation.y += rotationSpeedRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!container || !renderer || !camera) return;
|
||||||
|
const newWidth = container.clientWidth;
|
||||||
|
const newHeight = container.clientHeight;
|
||||||
|
camera.aspect = newWidth / newHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(newWidth, newHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(handleResize);
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [threeLoaded]);
|
||||||
|
|
||||||
|
// Load model when URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threeLoaded || !modelUrl || !sceneRef.current) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Clear previous mesh
|
||||||
|
if (meshRef.current) {
|
||||||
|
sceneRef.current.remove(meshRef.current);
|
||||||
|
meshRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoadSTL = (geometry: any) => {
|
||||||
|
// Center the geometry
|
||||||
|
geometry.center();
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
|
||||||
|
// Skin-like material for natural appearance
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
|
||||||
|
roughness: 0.7, // Slightly smooth for skin-like appearance
|
||||||
|
metalness: 0.0,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
flatShading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
|
||||||
|
// Brace template is Y-up, no rotation needed
|
||||||
|
|
||||||
|
// Scale to fit
|
||||||
|
const box = new THREE.Box3().setFromObject(mesh);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const scale = 200 / maxDim;
|
||||||
|
mesh.scale.multiplyScalar(scale);
|
||||||
|
|
||||||
|
mesh.position.set(0, 0, 0);
|
||||||
|
|
||||||
|
sceneRef.current.add(mesh);
|
||||||
|
meshRef.current = mesh;
|
||||||
|
|
||||||
|
// Position camera - view from front at torso level
|
||||||
|
cameraRef.current.position.set(0, 0, 350);
|
||||||
|
cameraRef.current.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoadGLB = (gltf: any) => {
|
||||||
|
const mesh = gltf.scene;
|
||||||
|
|
||||||
|
// Apply skin-like material to all meshes
|
||||||
|
mesh.traverse((child: any) => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
child.material = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
|
||||||
|
roughness: 0.7, // Slightly smooth for skin-like appearance
|
||||||
|
metalness: 0.0,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
flatShading: false,
|
||||||
|
});
|
||||||
|
child.castShadow = true;
|
||||||
|
child.receiveShadow = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Center and scale
|
||||||
|
const box = new THREE.Box3().setFromObject(mesh);
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
|
||||||
|
mesh.position.sub(center);
|
||||||
|
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const scale = 200 / maxDim;
|
||||||
|
mesh.scale.multiplyScalar(scale);
|
||||||
|
|
||||||
|
mesh.position.set(0, 0, 0);
|
||||||
|
|
||||||
|
sceneRef.current.add(mesh);
|
||||||
|
meshRef.current = mesh;
|
||||||
|
|
||||||
|
// Position camera - view from front at torso level
|
||||||
|
cameraRef.current.position.set(0, 0, 350);
|
||||||
|
cameraRef.current.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (err: any) => {
|
||||||
|
console.error('Failed to load model:', err);
|
||||||
|
setError('Failed to load 3D model');
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (modelType === 'stl') {
|
||||||
|
const loader = new STLLoader();
|
||||||
|
loader.load(modelUrl, onLoadSTL, undefined, onError);
|
||||||
|
} else if (modelType === 'glb') {
|
||||||
|
const loader = new GLTFLoader();
|
||||||
|
loader.load(modelUrl, onLoadGLB, undefined, onError);
|
||||||
|
}
|
||||||
|
}, [threeLoaded, modelUrl, modelType]);
|
||||||
|
|
||||||
|
if (!threeLoaded) {
|
||||||
|
return (
|
||||||
|
<div className="brace-model-viewer-loading">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading 3D viewer...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="brace-model-viewer-container">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="brace-model-viewer-canvas"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="brace-model-viewer-overlay">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading brace model...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="brace-model-viewer-overlay error">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!modelUrl && !loading && (
|
||||||
|
<div className="brace-model-viewer-overlay placeholder">
|
||||||
|
<div className="placeholder-icon">🦾</div>
|
||||||
|
<p>Generate a brace to see 3D preview</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
980
frontend/src/components/three/BraceTransformViewer.tsx
Normal file
@@ -0,0 +1,980 @@
|
|||||||
|
/**
|
||||||
|
* BraceTransformViewer - 3D Brace Viewer with Real-time Deformation
|
||||||
|
* Based on EXPERIMENT_6's brace-transform-playground-v2
|
||||||
|
*
|
||||||
|
* Extends BraceModelViewer with the ability to apply ellipsoid deformations
|
||||||
|
* in real-time based on transformation parameters.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||||
|
import type { BraceTransformParams } from '../pipeline/BraceInlineEditor';
|
||||||
|
|
||||||
|
// Three.js is loaded dynamically
|
||||||
|
let THREE: any = null;
|
||||||
|
let STLLoader: any = null;
|
||||||
|
let GLTFLoader: any = null;
|
||||||
|
let STLExporter: any = null;
|
||||||
|
let GLTFExporter: any = null;
|
||||||
|
let OrbitControls: any = null;
|
||||||
|
|
||||||
|
type MarkerInfo = {
|
||||||
|
name: string;
|
||||||
|
position: [number, number, number];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarkerMap = Record<string, { x: number; y: number; z: number }>;
|
||||||
|
|
||||||
|
export type BraceTransformViewerRef = {
|
||||||
|
exportSTL: () => Promise<Blob | null>;
|
||||||
|
exportGLB: () => Promise<Blob | null>;
|
||||||
|
getModifiedGeometry: () => any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
glbUrl?: string | null;
|
||||||
|
stlUrl?: string | null;
|
||||||
|
transformParams?: BraceTransformParams;
|
||||||
|
autoRotate?: boolean;
|
||||||
|
rotationSpeed?: number;
|
||||||
|
showMarkers?: boolean;
|
||||||
|
showGrid?: boolean;
|
||||||
|
onMarkersLoaded?: (markers: MarkerInfo[]) => void;
|
||||||
|
onGeometryUpdated?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BraceTransformViewer = forwardRef<BraceTransformViewerRef, Props>(({
|
||||||
|
glbUrl,
|
||||||
|
stlUrl,
|
||||||
|
transformParams,
|
||||||
|
autoRotate = true,
|
||||||
|
rotationSpeed = 0.005,
|
||||||
|
showMarkers = false,
|
||||||
|
showGrid = false,
|
||||||
|
onMarkersLoaded,
|
||||||
|
onGeometryUpdated,
|
||||||
|
}, ref) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rendererRef = useRef<any>(null);
|
||||||
|
const sceneRef = useRef<any>(null);
|
||||||
|
const cameraRef = useRef<any>(null);
|
||||||
|
const controlsRef = useRef<any>(null);
|
||||||
|
const meshRef = useRef<any>(null);
|
||||||
|
const baseGeometryRef = useRef<any>(null);
|
||||||
|
const gridGroupRef = useRef<any>(null);
|
||||||
|
const realWorldScaleRef = useRef<number>(1); // units per cm
|
||||||
|
const markersRef = useRef<MarkerMap>({});
|
||||||
|
const modelGroupRef = useRef<any>(null);
|
||||||
|
const animationFrameRef = useRef<number>(0);
|
||||||
|
const autoRotateRef = useRef(autoRotate);
|
||||||
|
const rotationSpeedRef = useRef(rotationSpeed);
|
||||||
|
const paramsRef = useRef<BraceTransformParams | undefined>(transformParams);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [threeLoaded, setThreeLoaded] = useState(false);
|
||||||
|
const [dimensions, setDimensions] = useState<{ width: number; height: number; depth: number } | null>(null);
|
||||||
|
|
||||||
|
// Keep refs in sync with props
|
||||||
|
useEffect(() => {
|
||||||
|
autoRotateRef.current = autoRotate;
|
||||||
|
rotationSpeedRef.current = rotationSpeed;
|
||||||
|
if (controlsRef.current) {
|
||||||
|
controlsRef.current.autoRotate = autoRotate;
|
||||||
|
controlsRef.current.autoRotateSpeed = rotationSpeed * 100;
|
||||||
|
}
|
||||||
|
}, [autoRotate, rotationSpeed]);
|
||||||
|
|
||||||
|
// Create measurement grid with labels
|
||||||
|
const createMeasurementGrid = useCallback((scene: any, unitsPerCm: number, modelHeight: number) => {
|
||||||
|
if (!THREE) return;
|
||||||
|
|
||||||
|
// Remove existing grid
|
||||||
|
if (gridGroupRef.current) {
|
||||||
|
scene.remove(gridGroupRef.current);
|
||||||
|
gridGroupRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridGroup = new THREE.Group();
|
||||||
|
gridGroup.name = 'measurementGrid';
|
||||||
|
|
||||||
|
// Calculate grid size based on model (add some padding)
|
||||||
|
const gridSizeCm = Math.ceil(modelHeight / unitsPerCm / 10) * 10 + 20; // Round up to nearest 10cm + padding
|
||||||
|
const gridSizeUnits = gridSizeCm * unitsPerCm;
|
||||||
|
const divisionsPerCm = 1;
|
||||||
|
const totalDivisions = gridSizeCm * divisionsPerCm;
|
||||||
|
|
||||||
|
// Create XZ grid (floor)
|
||||||
|
const gridXZ = new THREE.GridHelper(gridSizeUnits, totalDivisions, 0x666688, 0x444466);
|
||||||
|
gridXZ.position.y = -modelHeight / 2 - 10; // Below the model
|
||||||
|
gridGroup.add(gridXZ);
|
||||||
|
|
||||||
|
// Create XY grid (back wall) - vertical
|
||||||
|
const gridXY = new THREE.GridHelper(gridSizeUnits, totalDivisions, 0x668866, 0x446644);
|
||||||
|
gridXY.rotation.x = Math.PI / 2;
|
||||||
|
gridXY.position.z = -gridSizeUnits / 4;
|
||||||
|
gridGroup.add(gridXY);
|
||||||
|
|
||||||
|
// Add axis lines (thicker)
|
||||||
|
const axisMaterial = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
|
||||||
|
|
||||||
|
// X-axis (red)
|
||||||
|
const xAxisGeom = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(-gridSizeUnits / 2, -modelHeight / 2 - 10, 0),
|
||||||
|
new THREE.Vector3(gridSizeUnits / 2, -modelHeight / 2 - 10, 0)
|
||||||
|
]);
|
||||||
|
const xAxis = new THREE.Line(xAxisGeom, new THREE.LineBasicMaterial({ color: 0xff4444 }));
|
||||||
|
gridGroup.add(xAxis);
|
||||||
|
|
||||||
|
// Y-axis (green)
|
||||||
|
const yAxisGeom = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(0, -modelHeight / 2 - 10, 0),
|
||||||
|
new THREE.Vector3(0, modelHeight / 2 + 50, 0)
|
||||||
|
]);
|
||||||
|
const yAxis = new THREE.Line(yAxisGeom, new THREE.LineBasicMaterial({ color: 0x44ff44 }));
|
||||||
|
gridGroup.add(yAxis);
|
||||||
|
|
||||||
|
// Z-axis (blue)
|
||||||
|
const zAxisGeom = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(0, -modelHeight / 2 - 10, -gridSizeUnits / 2),
|
||||||
|
new THREE.Vector3(0, -modelHeight / 2 - 10, gridSizeUnits / 2)
|
||||||
|
]);
|
||||||
|
const zAxis = new THREE.Line(zAxisGeom, new THREE.LineBasicMaterial({ color: 0x4444ff }));
|
||||||
|
gridGroup.add(zAxis);
|
||||||
|
|
||||||
|
// Add measurement tick marks and labels every 10cm on Y-axis
|
||||||
|
const tickSize = 5;
|
||||||
|
const tickMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
|
||||||
|
|
||||||
|
for (let cm = 0; cm <= gridSizeCm; cm += 10) {
|
||||||
|
const y = -modelHeight / 2 - 10 + cm * unitsPerCm;
|
||||||
|
if (y > modelHeight / 2 + 50) break;
|
||||||
|
|
||||||
|
// Tick mark
|
||||||
|
const tickGeom = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(-tickSize, y, 0),
|
||||||
|
new THREE.Vector3(tickSize, y, 0)
|
||||||
|
]);
|
||||||
|
const tick = new THREE.Line(tickGeom, tickMaterial);
|
||||||
|
gridGroup.add(tick);
|
||||||
|
|
||||||
|
// Create text sprite for label
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 64;
|
||||||
|
canvas.height = 32;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 20px Arial';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(`${cm}`, 32, 16);
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
||||||
|
const sprite = new THREE.Sprite(spriteMaterial);
|
||||||
|
sprite.position.set(-tickSize - 15, y, 0);
|
||||||
|
sprite.scale.set(20, 10, 1);
|
||||||
|
gridGroup.add(sprite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add "cm" label at top
|
||||||
|
const cmCanvas = document.createElement('canvas');
|
||||||
|
cmCanvas.width = 64;
|
||||||
|
cmCanvas.height = 32;
|
||||||
|
const cmCtx = cmCanvas.getContext('2d');
|
||||||
|
if (cmCtx) {
|
||||||
|
cmCtx.fillStyle = '#88ff88';
|
||||||
|
cmCtx.font = 'bold 18px Arial';
|
||||||
|
cmCtx.textAlign = 'center';
|
||||||
|
cmCtx.textBaseline = 'middle';
|
||||||
|
cmCtx.fillText('cm', 32, 16);
|
||||||
|
|
||||||
|
const cmTexture = new THREE.CanvasTexture(cmCanvas);
|
||||||
|
const cmSpriteMaterial = new THREE.SpriteMaterial({ map: cmTexture, transparent: true });
|
||||||
|
const cmSprite = new THREE.Sprite(cmSpriteMaterial);
|
||||||
|
cmSprite.position.set(-25, modelHeight / 2 + 30, 0);
|
||||||
|
cmSprite.scale.set(25, 12, 1);
|
||||||
|
gridGroup.add(cmSprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
gridGroupRef.current = gridGroup;
|
||||||
|
scene.add(gridGroup);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep params ref in sync
|
||||||
|
useEffect(() => {
|
||||||
|
paramsRef.current = transformParams;
|
||||||
|
}, [transformParams]);
|
||||||
|
|
||||||
|
const modelUrl = glbUrl || stlUrl;
|
||||||
|
const modelType = glbUrl ? 'glb' : (stlUrl ? 'stl' : null);
|
||||||
|
|
||||||
|
// Load Three.js dynamically
|
||||||
|
useEffect(() => {
|
||||||
|
const loadThree = async () => {
|
||||||
|
try {
|
||||||
|
const threeModule = await import('three');
|
||||||
|
THREE = threeModule;
|
||||||
|
|
||||||
|
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
|
||||||
|
STLLoader = STL;
|
||||||
|
|
||||||
|
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||||
|
GLTFLoader = GLTF;
|
||||||
|
|
||||||
|
const { STLExporter: STLE } = await import('three/examples/jsm/exporters/STLExporter.js');
|
||||||
|
STLExporter = STLE;
|
||||||
|
|
||||||
|
const { GLTFExporter: GLTFE } = await import('three/examples/jsm/exporters/GLTFExporter.js');
|
||||||
|
GLTFExporter = GLTFE;
|
||||||
|
|
||||||
|
const { OrbitControls: OC } = await import('three/examples/jsm/controls/OrbitControls.js');
|
||||||
|
OrbitControls = OC;
|
||||||
|
|
||||||
|
setThreeLoaded(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load Three.js:', e);
|
||||||
|
setError('Failed to load 3D viewer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadThree();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
if (rendererRef.current) {
|
||||||
|
rendererRef.current.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize scene
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const width = container.clientWidth;
|
||||||
|
const height = container.clientHeight;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x1e1e2e);
|
||||||
|
sceneRef.current = scene;
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
|
||||||
|
camera.position.set(0, 0, 400);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
cameraRef.current = camera;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.0;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
|
||||||
|
// OrbitControls for manual rotation
|
||||||
|
if (OrbitControls) {
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.05;
|
||||||
|
controls.autoRotate = autoRotateRef.current;
|
||||||
|
controls.autoRotateSpeed = rotationSpeedRef.current * 100;
|
||||||
|
controls.enablePan = true;
|
||||||
|
controls.enableZoom = true;
|
||||||
|
controls.minDistance = 100;
|
||||||
|
controls.maxDistance = 800;
|
||||||
|
controls.target.set(0, 0, 0);
|
||||||
|
controlsRef.current = controls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved lighting for skin-like appearance with shadows
|
||||||
|
// Soft ambient for base illumination
|
||||||
|
scene.add(new THREE.AmbientLight(0xffeedd, 0.4));
|
||||||
|
|
||||||
|
// Hemisphere light for natural sky/ground coloring
|
||||||
|
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x8d7c6d, 0.5);
|
||||||
|
hemiLight.position.set(0, 200, 0);
|
||||||
|
scene.add(hemiLight);
|
||||||
|
|
||||||
|
// Key light (main light with shadows)
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xfff5e8, 1.0);
|
||||||
|
keyLight.position.set(150, 200, 300);
|
||||||
|
keyLight.castShadow = true;
|
||||||
|
keyLight.shadow.mapSize.width = 1024;
|
||||||
|
keyLight.shadow.mapSize.height = 1024;
|
||||||
|
keyLight.shadow.camera.near = 50;
|
||||||
|
keyLight.shadow.camera.far = 1000;
|
||||||
|
keyLight.shadow.camera.left = -200;
|
||||||
|
keyLight.shadow.camera.right = 200;
|
||||||
|
keyLight.shadow.camera.top = 200;
|
||||||
|
keyLight.shadow.camera.bottom = -200;
|
||||||
|
keyLight.shadow.bias = -0.0005;
|
||||||
|
scene.add(keyLight);
|
||||||
|
|
||||||
|
// Fill light (softer, opposite side)
|
||||||
|
const fillLight = new THREE.DirectionalLight(0xe8f0ff, 0.5);
|
||||||
|
fillLight.position.set(-200, 100, -100);
|
||||||
|
scene.add(fillLight);
|
||||||
|
|
||||||
|
// Rim light (back light for edge definition)
|
||||||
|
const rimLight = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||||
|
rimLight.position.set(0, 50, -300);
|
||||||
|
scene.add(rimLight);
|
||||||
|
|
||||||
|
// Bottom fill (subtle, prevents too dark shadows underneath)
|
||||||
|
const bottomFill = new THREE.DirectionalLight(0xffe8d0, 0.2);
|
||||||
|
bottomFill.position.set(0, -200, 100);
|
||||||
|
scene.add(bottomFill);
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
const animate = () => {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
// Update orbit controls
|
||||||
|
if (controlsRef.current) {
|
||||||
|
controlsRef.current.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!container || !renderer || !camera) return;
|
||||||
|
const newWidth = container.clientWidth;
|
||||||
|
const newHeight = container.clientHeight;
|
||||||
|
camera.aspect = newWidth / newHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(newWidth, newHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(handleResize);
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [threeLoaded]);
|
||||||
|
|
||||||
|
// Extract markers from GLB
|
||||||
|
const extractMarkers = useCallback((root: any): MarkerMap => {
|
||||||
|
const markers: MarkerMap = {};
|
||||||
|
|
||||||
|
root.traverse((obj: any) => {
|
||||||
|
if (obj.name && obj.name.startsWith('LM_')) {
|
||||||
|
const worldPos = new THREE.Vector3();
|
||||||
|
obj.getWorldPosition(worldPos);
|
||||||
|
markers[obj.name] = { x: worldPos.x, y: worldPos.y, z: worldPos.z };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return markers;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply ellipsoid deformation to geometry
|
||||||
|
const applyDeformation = useCallback((geometry: any, params: BraceTransformParams, markers: MarkerMap) => {
|
||||||
|
if (!THREE || !geometry) return geometry;
|
||||||
|
|
||||||
|
// Clone geometry to avoid mutating original
|
||||||
|
const deformed = geometry.clone();
|
||||||
|
const positions = deformed.getAttribute('position');
|
||||||
|
|
||||||
|
if (!positions) return deformed;
|
||||||
|
|
||||||
|
// Compute bounding box for fallback positioning
|
||||||
|
deformed.computeBoundingBox();
|
||||||
|
const bbox = deformed.boundingBox;
|
||||||
|
const minY = bbox?.min?.y || 0;
|
||||||
|
const maxY = bbox?.max?.y || 1;
|
||||||
|
const minX = bbox?.min?.x || -0.5;
|
||||||
|
const maxX = bbox?.max?.x || 0.5;
|
||||||
|
const minZ = bbox?.min?.z || -0.5;
|
||||||
|
const maxZ = bbox?.max?.z || 0.5;
|
||||||
|
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerZ = (minZ + maxZ) / 2;
|
||||||
|
const bboxHeight = maxY - minY;
|
||||||
|
const bboxWidth = maxX - minX;
|
||||||
|
const bboxDepth = maxZ - minZ;
|
||||||
|
|
||||||
|
// Get brace basis from markers OR fallback to bbox
|
||||||
|
const pelvis = markers['LM_PELVIS_CENTER'] || { x: centerX, y: minY, z: centerZ };
|
||||||
|
const top = markers['LM_TOP_CENTER'] || { x: centerX, y: maxY, z: centerZ };
|
||||||
|
|
||||||
|
const braceHeight = Math.sqrt(
|
||||||
|
Math.pow(top.x - pelvis.x, 2) +
|
||||||
|
Math.pow(top.y - pelvis.y, 2) +
|
||||||
|
Math.pow(top.z - pelvis.z, 2)
|
||||||
|
) || bboxHeight || 1;
|
||||||
|
|
||||||
|
const unitsPerMm = braceHeight / Math.max(1e-6, params.expectedBraceHeightMm);
|
||||||
|
|
||||||
|
// Get pad/bay markers - with fallback positions based on bounding box
|
||||||
|
// Thoracic pad: right side, upper region
|
||||||
|
const thPad = markers['LM_PAD_TH'] || {
|
||||||
|
x: centerX + bboxWidth * 0.35,
|
||||||
|
y: minY + bboxHeight * params.apexNorm,
|
||||||
|
z: centerZ - bboxDepth * 0.1
|
||||||
|
};
|
||||||
|
// Thoracic bay: left side, upper region (opposite side of pad)
|
||||||
|
const thBay = markers['LM_BAY_TH'] || {
|
||||||
|
x: centerX - bboxWidth * 0.35,
|
||||||
|
y: minY + bboxHeight * params.apexNorm,
|
||||||
|
z: centerZ - bboxDepth * 0.1
|
||||||
|
};
|
||||||
|
// Lumbar pad: left side, lower region
|
||||||
|
const lumPad = markers['LM_PAD_LUM'] || {
|
||||||
|
x: centerX - bboxWidth * 0.3,
|
||||||
|
y: minY + bboxHeight * params.lumbarApexNorm,
|
||||||
|
z: centerZ
|
||||||
|
};
|
||||||
|
// Lumbar bay: right side, lower region
|
||||||
|
const lumBay = markers['LM_BAY_LUM'] || {
|
||||||
|
x: centerX + bboxWidth * 0.3,
|
||||||
|
y: minY + bboxHeight * params.lumbarApexNorm,
|
||||||
|
z: centerZ
|
||||||
|
};
|
||||||
|
|
||||||
|
// Severity mapping
|
||||||
|
const sev = Math.max(0, Math.min(1, (params.cobbDeg - 15) / 40));
|
||||||
|
|
||||||
|
// Pad depth based on severity
|
||||||
|
const padDepthMm = (8 + 12 * sev) * params.strengthMult;
|
||||||
|
const bayClearMm = padDepthMm * 1.2;
|
||||||
|
|
||||||
|
const padDepth = padDepthMm * unitsPerMm;
|
||||||
|
const bayClear = bayClearMm * unitsPerMm;
|
||||||
|
|
||||||
|
// Size scale based on severity
|
||||||
|
const sizeScale = 0.9 + 0.5 * sev;
|
||||||
|
|
||||||
|
// Define features (pads and bays) - always create them with fallback positions
|
||||||
|
const features: Array<{
|
||||||
|
center: { x: number; y: number; z: number };
|
||||||
|
radii: { x: number; y: number; z: number };
|
||||||
|
depth: number;
|
||||||
|
direction: 1 | -1;
|
||||||
|
falloffPower: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Add thoracic pad (push inward on convex side)
|
||||||
|
features.push({
|
||||||
|
center: {
|
||||||
|
x: thPad.x,
|
||||||
|
y: pelvis.y + params.apexNorm * braceHeight,
|
||||||
|
z: thPad.z,
|
||||||
|
},
|
||||||
|
radii: {
|
||||||
|
x: 45 * unitsPerMm * sizeScale,
|
||||||
|
y: 90 * unitsPerMm * sizeScale,
|
||||||
|
z: 35 * unitsPerMm * sizeScale
|
||||||
|
},
|
||||||
|
depth: padDepth,
|
||||||
|
direction: -1,
|
||||||
|
falloffPower: 2.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add thoracic bay (relief on concave side)
|
||||||
|
features.push({
|
||||||
|
center: {
|
||||||
|
x: thBay.x,
|
||||||
|
y: pelvis.y + params.apexNorm * braceHeight,
|
||||||
|
z: thBay.z,
|
||||||
|
},
|
||||||
|
radii: {
|
||||||
|
x: 60 * unitsPerMm * sizeScale,
|
||||||
|
y: 110 * unitsPerMm * sizeScale,
|
||||||
|
z: 55 * unitsPerMm * sizeScale
|
||||||
|
},
|
||||||
|
depth: bayClear,
|
||||||
|
direction: 1,
|
||||||
|
falloffPower: 1.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add lumbar pad
|
||||||
|
features.push({
|
||||||
|
center: {
|
||||||
|
x: lumPad.x,
|
||||||
|
y: pelvis.y + params.lumbarApexNorm * braceHeight,
|
||||||
|
z: lumPad.z,
|
||||||
|
},
|
||||||
|
radii: {
|
||||||
|
x: 50 * unitsPerMm * sizeScale,
|
||||||
|
y: 80 * unitsPerMm * sizeScale,
|
||||||
|
z: 40 * unitsPerMm * sizeScale
|
||||||
|
},
|
||||||
|
depth: padDepth * 0.9,
|
||||||
|
direction: -1,
|
||||||
|
falloffPower: 2.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add lumbar bay
|
||||||
|
features.push({
|
||||||
|
center: {
|
||||||
|
x: lumBay.x,
|
||||||
|
y: pelvis.y + params.lumbarApexNorm * braceHeight,
|
||||||
|
z: lumBay.z,
|
||||||
|
},
|
||||||
|
radii: {
|
||||||
|
x: 65 * unitsPerMm * sizeScale,
|
||||||
|
y: 95 * unitsPerMm * sizeScale,
|
||||||
|
z: 55 * unitsPerMm * sizeScale
|
||||||
|
},
|
||||||
|
depth: bayClear * 0.9,
|
||||||
|
direction: 1,
|
||||||
|
falloffPower: 1.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add hip anchors - with fallback positions
|
||||||
|
const hipL = markers['LM_ANCHOR_HIP_L'] || {
|
||||||
|
x: centerX - bboxWidth * 0.4,
|
||||||
|
y: minY + bboxHeight * 0.1,
|
||||||
|
z: centerZ,
|
||||||
|
};
|
||||||
|
const hipR = markers['LM_ANCHOR_HIP_R'] || {
|
||||||
|
x: centerX + bboxWidth * 0.4,
|
||||||
|
y: minY + bboxHeight * 0.1,
|
||||||
|
z: centerZ,
|
||||||
|
};
|
||||||
|
const hipDepth = params.hipAnchorStrengthMm * unitsPerMm * params.strengthMult;
|
||||||
|
|
||||||
|
if (hipDepth > 0) {
|
||||||
|
features.push({
|
||||||
|
center: { x: hipL.x, y: hipL.y, z: hipL.z },
|
||||||
|
radii: {
|
||||||
|
x: 35 * unitsPerMm,
|
||||||
|
y: 55 * unitsPerMm,
|
||||||
|
z: 35 * unitsPerMm
|
||||||
|
},
|
||||||
|
depth: hipDepth,
|
||||||
|
direction: -1,
|
||||||
|
falloffPower: 2.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
features.push({
|
||||||
|
center: { x: hipR.x, y: hipR.y, z: hipR.z },
|
||||||
|
radii: {
|
||||||
|
x: 35 * unitsPerMm,
|
||||||
|
y: 55 * unitsPerMm,
|
||||||
|
z: 35 * unitsPerMm
|
||||||
|
},
|
||||||
|
depth: hipDepth,
|
||||||
|
direction: -1,
|
||||||
|
falloffPower: 2.2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply deformations
|
||||||
|
for (let i = 0; i < positions.count; i++) {
|
||||||
|
let x = positions.getX(i);
|
||||||
|
let y = positions.getY(i);
|
||||||
|
let z = positions.getZ(i);
|
||||||
|
|
||||||
|
// Mirror X if enabled
|
||||||
|
if (params.mirrorX) {
|
||||||
|
x = -x;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply trunk shift
|
||||||
|
const heightNorm = Math.max(0, Math.min(1, (y - pelvis.y) / braceHeight));
|
||||||
|
x += params.trunkShiftMm * unitsPerMm * heightNorm * 0.8;
|
||||||
|
|
||||||
|
// Apply ellipsoid deformations
|
||||||
|
for (const feature of features) {
|
||||||
|
const dx = (x - feature.center.x) / feature.radii.x;
|
||||||
|
const dy = (y - feature.center.y) / feature.radii.y;
|
||||||
|
const dz = (z - feature.center.z) / feature.radii.z;
|
||||||
|
|
||||||
|
const d2 = dx * dx + dy * dy + dz * dz;
|
||||||
|
if (d2 >= 1) continue;
|
||||||
|
|
||||||
|
// Smooth falloff
|
||||||
|
const t = Math.pow(1 - d2, feature.falloffPower);
|
||||||
|
const displacement = feature.depth * t * feature.direction;
|
||||||
|
|
||||||
|
// Apply based on push mode
|
||||||
|
if (params.pushMode === 'radial') {
|
||||||
|
// Radial: push away from/toward brace axis
|
||||||
|
const axisPoint = { x: pelvis.x, y: y, z: pelvis.z };
|
||||||
|
const radialX = x - axisPoint.x;
|
||||||
|
const radialZ = z - axisPoint.z;
|
||||||
|
const radialLen = Math.sqrt(radialX * radialX + radialZ * radialZ) || 1;
|
||||||
|
|
||||||
|
x += (radialX / radialLen) * displacement;
|
||||||
|
z += (radialZ / radialLen) * displacement;
|
||||||
|
} else if (params.pushMode === 'lateral') {
|
||||||
|
// Lateral: purely left/right
|
||||||
|
const side = Math.sign(x - pelvis.x) || 1;
|
||||||
|
x += side * displacement;
|
||||||
|
} else {
|
||||||
|
// Normal: would require vertex normals, approximate with radial
|
||||||
|
const axisPoint = { x: pelvis.x, y: y, z: pelvis.z };
|
||||||
|
const radialX = x - axisPoint.x;
|
||||||
|
const radialZ = z - axisPoint.z;
|
||||||
|
const radialLen = Math.sqrt(radialX * radialX + radialZ * radialZ) || 1;
|
||||||
|
|
||||||
|
x += (radialX / radialLen) * displacement;
|
||||||
|
z += (radialZ / radialLen) * displacement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
positions.setXYZ(i, x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
positions.needsUpdate = true;
|
||||||
|
deformed.computeVertexNormals();
|
||||||
|
|
||||||
|
return deformed;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load model
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threeLoaded || !modelUrl || !sceneRef.current) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Clear previous model
|
||||||
|
if (modelGroupRef.current) {
|
||||||
|
sceneRef.current.remove(modelGroupRef.current);
|
||||||
|
modelGroupRef.current = null;
|
||||||
|
}
|
||||||
|
meshRef.current = null;
|
||||||
|
baseGeometryRef.current = null;
|
||||||
|
markersRef.current = {};
|
||||||
|
|
||||||
|
const group = new THREE.Group();
|
||||||
|
modelGroupRef.current = group;
|
||||||
|
|
||||||
|
const onLoadGLB = (gltf: any) => {
|
||||||
|
// Extract markers
|
||||||
|
markersRef.current = extractMarkers(gltf.scene);
|
||||||
|
|
||||||
|
// Notify parent of markers
|
||||||
|
if (onMarkersLoaded) {
|
||||||
|
const markerList: MarkerInfo[] = Object.entries(markersRef.current).map(([name, pos]) => ({
|
||||||
|
name,
|
||||||
|
position: [pos.x, pos.y, pos.z],
|
||||||
|
}));
|
||||||
|
onMarkersLoaded(markerList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the main mesh
|
||||||
|
let mainMesh: any = null;
|
||||||
|
let maxVertices = 0;
|
||||||
|
|
||||||
|
gltf.scene.traverse((child: any) => {
|
||||||
|
if (child.isMesh && !child.name.startsWith('LM_')) {
|
||||||
|
const count = child.geometry.getAttribute('position')?.count || 0;
|
||||||
|
if (count > maxVertices) {
|
||||||
|
maxVertices = count;
|
||||||
|
mainMesh = child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mainMesh) {
|
||||||
|
setError('No mesh found in model');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store base geometry
|
||||||
|
baseGeometryRef.current = mainMesh.geometry.clone();
|
||||||
|
|
||||||
|
// Create mesh with skin-like material
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
|
||||||
|
roughness: 0.7, // Slightly smooth for skin-like appearance
|
||||||
|
metalness: 0.0,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
flatShading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(baseGeometryRef.current.clone(), material);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
meshRef.current = mesh;
|
||||||
|
|
||||||
|
// Center and scale
|
||||||
|
const box = new THREE.Box3().setFromObject(mesh);
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
|
||||||
|
mesh.position.sub(center);
|
||||||
|
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const scale = 200 / maxDim;
|
||||||
|
mesh.scale.multiplyScalar(scale);
|
||||||
|
|
||||||
|
// Calculate real-world dimensions (assuming model units are mm)
|
||||||
|
// Typical brace height is 350-450mm, width 250-350mm
|
||||||
|
const scaledSize = {
|
||||||
|
x: size.x * scale,
|
||||||
|
y: size.y * scale,
|
||||||
|
z: size.z * scale,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store units per cm for grid (model is in mm, so 10mm = 1cm)
|
||||||
|
const unitsPerCm = scale * 10; // scale * 10mm
|
||||||
|
realWorldScaleRef.current = unitsPerCm;
|
||||||
|
|
||||||
|
// Store dimensions in cm
|
||||||
|
setDimensions({
|
||||||
|
width: Math.round(size.x / 10 * 10) / 10, // X dimension in cm
|
||||||
|
height: Math.round(size.y / 10 * 10) / 10, // Y dimension in cm
|
||||||
|
depth: Math.round(size.z / 10 * 10) / 10, // Z dimension in cm
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create measurement grid if enabled
|
||||||
|
if (showGrid && sceneRef.current) {
|
||||||
|
createMeasurementGrid(sceneRef.current, unitsPerCm, scaledSize.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale markers to match
|
||||||
|
const scaledMarkers: MarkerMap = {};
|
||||||
|
for (const [name, pos] of Object.entries(markersRef.current)) {
|
||||||
|
scaledMarkers[name] = {
|
||||||
|
x: (pos.x - center.x) * scale,
|
||||||
|
y: (pos.y - center.y) * scale,
|
||||||
|
z: (pos.z - center.z) * scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
markersRef.current = scaledMarkers;
|
||||||
|
|
||||||
|
// Add marker spheres if enabled
|
||||||
|
if (showMarkers) {
|
||||||
|
Object.entries(scaledMarkers).forEach(([name, pos]) => {
|
||||||
|
const sphereGeom = new THREE.SphereGeometry(3, 16, 16);
|
||||||
|
const sphereMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: name.includes('PAD') ? 0x00ff00 :
|
||||||
|
name.includes('BAY') ? 0x0088ff :
|
||||||
|
name.includes('HIP') ? 0xff8800 : 0xff0000,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
});
|
||||||
|
const sphere = new THREE.Mesh(sphereGeom, sphereMat);
|
||||||
|
sphere.position.set(pos.x, pos.y, pos.z);
|
||||||
|
sphere.name = `marker_${name}`;
|
||||||
|
group.add(sphere);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.add(mesh);
|
||||||
|
group.position.set(0, 0, 0);
|
||||||
|
sceneRef.current.add(group);
|
||||||
|
|
||||||
|
// Apply initial deformation if params provided
|
||||||
|
if (transformParams) {
|
||||||
|
applyTransformToMesh(transformParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraRef.current.position.set(0, 0, 350);
|
||||||
|
cameraRef.current.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoadSTL = (geometry: any) => {
|
||||||
|
geometry.center();
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
|
||||||
|
// Store base geometry
|
||||||
|
baseGeometryRef.current = geometry.clone();
|
||||||
|
|
||||||
|
// Skin-like material matching GLB loader
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
|
||||||
|
roughness: 0.7, // Slightly smooth for skin-like appearance
|
||||||
|
metalness: 0.0,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
flatShading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
meshRef.current = mesh;
|
||||||
|
|
||||||
|
// Scale to fit
|
||||||
|
const box = new THREE.Box3().setFromObject(mesh);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const scale = 200 / maxDim;
|
||||||
|
mesh.scale.multiplyScalar(scale);
|
||||||
|
|
||||||
|
// Calculate real-world dimensions (assuming model units are mm)
|
||||||
|
const scaledSize = {
|
||||||
|
x: size.x * scale,
|
||||||
|
y: size.y * scale,
|
||||||
|
z: size.z * scale,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store units per cm for grid (model is in mm, so 10mm = 1cm)
|
||||||
|
const unitsPerCm = scale * 10;
|
||||||
|
realWorldScaleRef.current = unitsPerCm;
|
||||||
|
|
||||||
|
// Store dimensions in cm
|
||||||
|
setDimensions({
|
||||||
|
width: Math.round(size.x / 10 * 10) / 10,
|
||||||
|
height: Math.round(size.y / 10 * 10) / 10,
|
||||||
|
depth: Math.round(size.z / 10 * 10) / 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create measurement grid if enabled
|
||||||
|
if (showGrid && sceneRef.current) {
|
||||||
|
createMeasurementGrid(sceneRef.current, unitsPerCm, scaledSize.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
group.add(mesh);
|
||||||
|
group.position.set(0, 0, 0);
|
||||||
|
sceneRef.current.add(group);
|
||||||
|
|
||||||
|
cameraRef.current.position.set(0, 0, 350);
|
||||||
|
cameraRef.current.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (err: any) => {
|
||||||
|
console.error('Failed to load model:', err);
|
||||||
|
setError('Failed to load 3D model');
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (modelType === 'stl') {
|
||||||
|
const loader = new STLLoader();
|
||||||
|
loader.load(modelUrl, onLoadSTL, undefined, onError);
|
||||||
|
} else if (modelType === 'glb') {
|
||||||
|
const loader = new GLTFLoader();
|
||||||
|
loader.load(modelUrl, onLoadGLB, undefined, onError);
|
||||||
|
}
|
||||||
|
}, [threeLoaded, modelUrl, modelType, showMarkers, showGrid, extractMarkers, onMarkersLoaded, createMeasurementGrid]);
|
||||||
|
|
||||||
|
// Apply transform when params change
|
||||||
|
const applyTransformToMesh = useCallback((params: BraceTransformParams) => {
|
||||||
|
if (!meshRef.current || !baseGeometryRef.current || !THREE) return;
|
||||||
|
|
||||||
|
const deformedGeometry = applyDeformation(
|
||||||
|
baseGeometryRef.current.clone(),
|
||||||
|
params,
|
||||||
|
markersRef.current
|
||||||
|
);
|
||||||
|
|
||||||
|
meshRef.current.geometry.dispose();
|
||||||
|
meshRef.current.geometry = deformedGeometry;
|
||||||
|
|
||||||
|
if (onGeometryUpdated) {
|
||||||
|
onGeometryUpdated();
|
||||||
|
}
|
||||||
|
}, [applyDeformation, onGeometryUpdated]);
|
||||||
|
|
||||||
|
// Watch for transform params changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (transformParams && meshRef.current && baseGeometryRef.current) {
|
||||||
|
applyTransformToMesh(transformParams);
|
||||||
|
}
|
||||||
|
}, [transformParams, applyTransformToMesh]);
|
||||||
|
|
||||||
|
// Export functions
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
exportSTL: async () => {
|
||||||
|
if (!meshRef.current || !STLExporter) return null;
|
||||||
|
|
||||||
|
const exporter = new STLExporter();
|
||||||
|
const stlString = exporter.parse(meshRef.current, { binary: true });
|
||||||
|
return new Blob([stlString], { type: 'application/octet-stream' });
|
||||||
|
},
|
||||||
|
|
||||||
|
exportGLB: async () => {
|
||||||
|
if (!meshRef.current || !GLTFExporter) return null;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const exporter = new GLTFExporter();
|
||||||
|
exporter.parse(
|
||||||
|
meshRef.current,
|
||||||
|
(result: any) => {
|
||||||
|
const blob = new Blob([result], { type: 'application/octet-stream' });
|
||||||
|
resolve(blob);
|
||||||
|
},
|
||||||
|
(error: any) => {
|
||||||
|
console.error('GLB export error:', error);
|
||||||
|
resolve(null);
|
||||||
|
},
|
||||||
|
{ binary: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getModifiedGeometry: () => {
|
||||||
|
return meshRef.current?.geometry || null;
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
if (!threeLoaded) {
|
||||||
|
return (
|
||||||
|
<div className="brace-transform-viewer-loading">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading 3D viewer...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="brace-transform-viewer-container">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="brace-transform-viewer-canvas"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dimensions overlay */}
|
||||||
|
{showGrid && dimensions && !loading && (
|
||||||
|
<div className="brace-dimensions-overlay">
|
||||||
|
<div className="dimensions-title">Dimensions (cm)</div>
|
||||||
|
<div className="dimension-row">
|
||||||
|
<span className="dim-label">Width:</span>
|
||||||
|
<span className="dim-value">{dimensions.width.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dimension-row">
|
||||||
|
<span className="dim-label">Height:</span>
|
||||||
|
<span className="dim-value">{dimensions.height.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dimension-row">
|
||||||
|
<span className="dim-label">Depth:</span>
|
||||||
|
<span className="dim-value">{dimensions.depth.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="brace-transform-viewer-overlay">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading brace model...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="brace-transform-viewer-overlay error">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!modelUrl && !loading && (
|
||||||
|
<div className="brace-transform-viewer-overlay placeholder">
|
||||||
|
<div className="placeholder-icon">🦾</div>
|
||||||
|
<p>Generate a brace to see 3D preview</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
BraceTransformViewer.displayName = 'BraceTransformViewer';
|
||||||
|
|
||||||
|
export default BraceTransformViewer;
|
||||||
303
frontend/src/components/three/BraceViewer.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* 3D Brace Viewer Component
|
||||||
|
* Uses Three.js to display GLB brace models with markers
|
||||||
|
*
|
||||||
|
* Based on EXPERIMENT_6's brace-transform-playground-v2
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
// Three.js is loaded dynamically to avoid SSR issues
|
||||||
|
let THREE: any = null;
|
||||||
|
let GLTFLoader: any = null;
|
||||||
|
let OrbitControls: any = null;
|
||||||
|
|
||||||
|
type MarkerInfo = {
|
||||||
|
name: string;
|
||||||
|
position: [number, number, number];
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BraceViewerProps = {
|
||||||
|
glbUrl: string | null;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
showMarkers?: boolean;
|
||||||
|
onMarkersLoaded?: (markers: MarkerInfo[]) => void;
|
||||||
|
deformationParams?: {
|
||||||
|
thoracicPadDepth?: number;
|
||||||
|
lumbarPadDepth?: number;
|
||||||
|
trunkShift?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BraceViewer({
|
||||||
|
glbUrl,
|
||||||
|
width = 600,
|
||||||
|
height = 500,
|
||||||
|
showMarkers = true,
|
||||||
|
onMarkersLoaded,
|
||||||
|
deformationParams,
|
||||||
|
}: BraceViewerProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rendererRef = useRef<any>(null);
|
||||||
|
const sceneRef = useRef<any>(null);
|
||||||
|
const cameraRef = useRef<any>(null);
|
||||||
|
const controlsRef = useRef<any>(null);
|
||||||
|
const braceMeshRef = useRef<any>(null);
|
||||||
|
const animationFrameRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [threeLoaded, setThreeLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Load Three.js dynamically
|
||||||
|
useEffect(() => {
|
||||||
|
const loadThree = async () => {
|
||||||
|
try {
|
||||||
|
const threeModule = await import('three');
|
||||||
|
THREE = threeModule;
|
||||||
|
|
||||||
|
const { GLTFLoader: Loader } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||||
|
GLTFLoader = Loader;
|
||||||
|
|
||||||
|
const { OrbitControls: Controls } = await import('three/examples/jsm/controls/OrbitControls.js');
|
||||||
|
OrbitControls = Controls;
|
||||||
|
|
||||||
|
setThreeLoaded(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load Three.js:', e);
|
||||||
|
setError('Failed to load 3D viewer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadThree();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup on unmount
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
if (rendererRef.current) {
|
||||||
|
rendererRef.current.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize scene
|
||||||
|
const initScene = useCallback(() => {
|
||||||
|
if (!THREE || !containerRef.current) return;
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x1a1a1a);
|
||||||
|
sceneRef.current = scene;
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 10000);
|
||||||
|
camera.position.set(300, 200, 400);
|
||||||
|
cameraRef.current = camera;
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
containerRef.current.appendChild(renderer.domElement);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.05;
|
||||||
|
controls.target.set(0, 100, 0);
|
||||||
|
controls.update();
|
||||||
|
controlsRef.current = controls;
|
||||||
|
|
||||||
|
// Lighting
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
|
||||||
|
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.4);
|
||||||
|
scene.add(hemisphereLight);
|
||||||
|
|
||||||
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
directionalLight.position.set(200, 300, 200);
|
||||||
|
scene.add(directionalLight);
|
||||||
|
|
||||||
|
// Grid helper (optional)
|
||||||
|
const gridHelper = new THREE.GridHelper(500, 20, 0x444444, 0x333333);
|
||||||
|
scene.add(gridHelper);
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
const animate = () => {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
// Initialize scene when Three.js is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (threeLoaded && containerRef.current && !rendererRef.current) {
|
||||||
|
initScene();
|
||||||
|
}
|
||||||
|
}, [threeLoaded, initScene]);
|
||||||
|
|
||||||
|
// Load GLB when URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threeLoaded || !glbUrl || !sceneRef.current) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const loader = new GLTFLoader();
|
||||||
|
|
||||||
|
loader.load(
|
||||||
|
glbUrl,
|
||||||
|
(gltf: any) => {
|
||||||
|
// Clear previous mesh
|
||||||
|
if (braceMeshRef.current) {
|
||||||
|
sceneRef.current.remove(braceMeshRef.current);
|
||||||
|
braceMeshRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process loaded model
|
||||||
|
const scene = gltf.scene;
|
||||||
|
const markers: MarkerInfo[] = [];
|
||||||
|
let mainMesh: any = null;
|
||||||
|
|
||||||
|
// Find main mesh and markers
|
||||||
|
scene.traverse((child: any) => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
// Check if it's a marker
|
||||||
|
if (child.name.startsWith('LM_')) {
|
||||||
|
markers.push({
|
||||||
|
name: child.name,
|
||||||
|
position: child.position.toArray() as [number, number, number],
|
||||||
|
color: getMarkerColor(child.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Style marker for visibility
|
||||||
|
if (showMarkers) {
|
||||||
|
child.material = new THREE.MeshBasicMaterial({
|
||||||
|
color: getMarkerColor(child.name),
|
||||||
|
depthTest: false,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
});
|
||||||
|
child.renderOrder = 999;
|
||||||
|
} else {
|
||||||
|
child.visible = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Main brace mesh
|
||||||
|
if (!mainMesh || child.geometry.attributes.position.count > mainMesh.geometry.attributes.position.count) {
|
||||||
|
mainMesh = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply standard material
|
||||||
|
child.material = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xcccccc,
|
||||||
|
metalness: 0.2,
|
||||||
|
roughness: 0.5,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to scene
|
||||||
|
sceneRef.current.add(scene);
|
||||||
|
braceMeshRef.current = scene;
|
||||||
|
|
||||||
|
// Center camera on mesh
|
||||||
|
if (mainMesh) {
|
||||||
|
const box = new THREE.Box3().setFromObject(scene);
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
|
||||||
|
controlsRef.current.target.copy(center);
|
||||||
|
cameraRef.current.position.set(
|
||||||
|
center.x + size.x,
|
||||||
|
center.y + size.y * 0.5,
|
||||||
|
center.z + size.z
|
||||||
|
);
|
||||||
|
controlsRef.current.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify about markers
|
||||||
|
if (onMarkersLoaded && markers.length > 0) {
|
||||||
|
onMarkersLoaded(markers);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(err: any) => {
|
||||||
|
console.error('Failed to load GLB:', err);
|
||||||
|
setError('Failed to load 3D model');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [threeLoaded, glbUrl, showMarkers, onMarkersLoaded]);
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
useEffect(() => {
|
||||||
|
if (rendererRef.current && cameraRef.current) {
|
||||||
|
rendererRef.current.setSize(width, height);
|
||||||
|
cameraRef.current.aspect = width / height;
|
||||||
|
cameraRef.current.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (!threeLoaded) {
|
||||||
|
return (
|
||||||
|
<div className="brace-viewer-loading" style={{ width, height }}>
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading 3D viewer...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="brace-viewer-container" style={{ width, height }}>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="brace-viewer-canvas"
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="brace-viewer-overlay">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading model...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="brace-viewer-overlay error">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!glbUrl && !loading && (
|
||||||
|
<div className="brace-viewer-overlay placeholder">
|
||||||
|
<p>No 3D model loaded</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get marker color based on name
|
||||||
|
function getMarkerColor(name: string): string {
|
||||||
|
if (name.includes('PELVIS')) return '#ff0000'; // Red
|
||||||
|
if (name.includes('TOP')) return '#00ff00'; // Green
|
||||||
|
if (name.includes('PAD_TH')) return '#ff00ff'; // Magenta (thoracic pad)
|
||||||
|
if (name.includes('BAY_TH')) return '#00ffff'; // Cyan (thoracic bay)
|
||||||
|
if (name.includes('PAD_LUM')) return '#ffff00'; // Yellow (lumbar pad)
|
||||||
|
if (name.includes('BAY_LUM')) return '#ff8800'; // Orange (lumbar bay)
|
||||||
|
if (name.includes('ANCHOR')) return '#8800ff'; // Purple (anchors)
|
||||||
|
return '#ffffff'; // White (default)
|
||||||
|
}
|
||||||
5
frontend/src/components/three/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as BraceViewer } from './BraceViewer';
|
||||||
|
export { default as BodyScanViewer } from './BodyScanViewer';
|
||||||
|
export { default as BraceModelViewer } from './BraceModelViewer';
|
||||||
|
export { default as BraceTransformViewer } from './BraceTransformViewer';
|
||||||
|
export type { BraceTransformViewerRef } from './BraceTransformViewer';
|
||||||
174
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
fullName: string | null;
|
||||||
|
role: "admin" | "user" | "viewer";
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthContextType = {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
error: string | null;
|
||||||
|
clearError: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const AUTH_STORAGE_KEY = "braceflow_auth";
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api";
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Check for existing session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed.user && parsed.token && parsed.expiresAt > Date.now()) {
|
||||||
|
setUser(parsed.user);
|
||||||
|
setToken(parsed.token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || "Invalid username or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const userData: User = {
|
||||||
|
id: data.user.id,
|
||||||
|
username: data.user.username,
|
||||||
|
fullName: data.user.full_name,
|
||||||
|
role: data.user.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store auth data
|
||||||
|
const authData = {
|
||||||
|
user: userData,
|
||||||
|
token: data.token,
|
||||||
|
expiresAt: new Date(data.expiresAt).getTime(),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(authData));
|
||||||
|
setUser(userData);
|
||||||
|
setToken(data.token);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Login failed. Please try again.");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
// Call logout endpoint if we have a token
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore logout API errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading,
|
||||||
|
isAdmin: user?.role === "admin",
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get auth headers for API calls
|
||||||
|
*/
|
||||||
|
export function getAuthHeaders(): Record<string, string> {
|
||||||
|
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed.token) {
|
||||||
|
return { "Authorization": `Bearer ${parsed.token}` };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get the auth token
|
||||||
|
*/
|
||||||
|
export function getAuthToken(): string | null {
|
||||||
|
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
return parsed.token || null;
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
68
frontend/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
const API_BASE = 'https://cfx9z50wj2.execute-api.ca-central-1.amazonaws.com/prod';
|
||||||
|
|
||||||
|
async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const base = API_BASE ? API_BASE.replace(/\/+$/, '') : '';
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
const url = `${base}${normalizedPath}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers || {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CaseStatus = {
|
||||||
|
case: {
|
||||||
|
case_id: string;
|
||||||
|
status: string;
|
||||||
|
current_step: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
steps: Array<{
|
||||||
|
step_name: string;
|
||||||
|
step_order: number;
|
||||||
|
status: string;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseAssets = {
|
||||||
|
caseId: string;
|
||||||
|
apImageUrl: string;
|
||||||
|
bucket?: string;
|
||||||
|
key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmitLandmarksRequest = {
|
||||||
|
// Backend Lambda requires caseId in body (even though it's also in the URL)
|
||||||
|
caseId?: string;
|
||||||
|
view: "ap";
|
||||||
|
landmarks: Record<string, { x: number; y: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmitLandmarksResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
caseId: string;
|
||||||
|
resumedPipeline: boolean;
|
||||||
|
values: {
|
||||||
|
pelvis_offset_px: number;
|
||||||
|
t1_offset_px: number;
|
||||||
|
tp_offset_px: number;
|
||||||
|
dominant_curve: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadUrlResponse = {
|
||||||
|
uploadUrl: string;
|
||||||
|
key?: string;
|
||||||
|
s3Key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
createCase: (body: { notes?: string } = {}) =>
|
||||||
|
http<{ caseId: string }>(`/cases`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
startCase: (caseId: string) =>
|
||||||
|
http<{ caseId: string; executionArn?: string; status?: string }>(
|
||||||
|
`/cases/${encodeURIComponent(caseId)}/start`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
getUploadUrl: (caseId: string, body: { view: string; contentType?: string; filename?: string }) =>
|
||||||
|
http<UploadUrlResponse>(`/cases/${encodeURIComponent(caseId)}/upload-url`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getCaseStatus: (caseId: string) => http<CaseStatus>(`/cases/${encodeURIComponent(caseId)}`),
|
||||||
|
getCaseAssets: (caseId: string) => http<CaseAssets>(`/cases/${encodeURIComponent(caseId)}/assets`),
|
||||||
|
|
||||||
|
// FIX: include caseId in JSON body to satisfy backend Lambda contract
|
||||||
|
submitLandmarks: (caseId: string, body: SubmitLandmarksRequest) =>
|
||||||
|
http<SubmitLandmarksResponse>(`/cases/${encodeURIComponent(caseId)}/landmarks`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
...body,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
14
frontend/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
472
frontend/src/pages/BraceAnalysisPage.tsx
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useParams, Link } from "react-router-dom";
|
||||||
|
import UploadPanel from "../components/rigo/UploadPanel";
|
||||||
|
import BraceViewer from "../components/rigo/BraceViewer";
|
||||||
|
import {
|
||||||
|
analyzeXray,
|
||||||
|
getBraceOutputs,
|
||||||
|
type GenerateBraceResponse,
|
||||||
|
type BraceOutput,
|
||||||
|
type CobbAngles,
|
||||||
|
type RigoClassification,
|
||||||
|
} from "../api/braceflowApi";
|
||||||
|
|
||||||
|
// Helper to format file size
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get severity level
|
||||||
|
function getSeverity(cobbAngles: CobbAngles | undefined): { level: string; class: string; angle: number } {
|
||||||
|
const maxAngle = Math.max(cobbAngles?.PT ?? 0, cobbAngles?.MT ?? 0, cobbAngles?.TL ?? 0);
|
||||||
|
if (maxAngle < 10) return { level: "Normal", class: "success", angle: maxAngle };
|
||||||
|
if (maxAngle < 25) return { level: "Mild", class: "success", angle: maxAngle };
|
||||||
|
if (maxAngle < 40) return { level: "Moderate", class: "highlight", angle: maxAngle };
|
||||||
|
return { level: "Severe", class: "warning", angle: maxAngle };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metric Card component
|
||||||
|
function MetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
description?: string;
|
||||||
|
highlight?: "success" | "highlight" | "warning";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div className="rigo-analysis-label">{label}</div>
|
||||||
|
<div className={`rigo-analysis-value ${highlight || ""}`}>{value}</div>
|
||||||
|
{description && <div className="rigo-analysis-description">{description}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download button for outputs
|
||||||
|
function DownloadButton({ output }: { output: BraceOutput }) {
|
||||||
|
const getIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "stl":
|
||||||
|
case "ply":
|
||||||
|
case "obj":
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<polyline points="21 15 16 10 5 21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "json":
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={output.url}
|
||||||
|
download={output.filename}
|
||||||
|
className="rigo-btn rigo-btn-secondary"
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8, textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
{getIcon(output.type)}
|
||||||
|
<span style={{ flex: 1, textAlign: "left" }}>
|
||||||
|
{output.filename}
|
||||||
|
<span style={{ color: "#64748b", fontSize: "0.75rem", marginLeft: 8 }}>
|
||||||
|
({formatBytes(output.size)})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cobb Angles Display
|
||||||
|
function CobbAnglesDisplay({ angles }: { angles: CobbAngles | undefined }) {
|
||||||
|
if (!angles) return null;
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
{ label: "Proximal Thoracic (PT)", value: angles.PT },
|
||||||
|
{ label: "Main Thoracic (MT)", value: angles.MT },
|
||||||
|
{ label: "Thoracolumbar (TL)", value: angles.TL },
|
||||||
|
].filter((e) => e.value !== undefined && e.value !== null);
|
||||||
|
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rigo-analysis-grid" style={{ gridTemplateColumns: `repeat(${entries.length}, 1fr)` }}>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<MetricCard
|
||||||
|
key={entry.label}
|
||||||
|
label={entry.label}
|
||||||
|
value={`${entry.value?.toFixed(1)}°`}
|
||||||
|
highlight={entry.value && entry.value > 25 ? (entry.value > 40 ? "warning" : "highlight") : "success"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rigo Classification Display
|
||||||
|
function RigoDisplay({ classification }: { classification: RigoClassification | undefined }) {
|
||||||
|
if (!classification) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rigo-analysis-card"
|
||||||
|
style={{ background: "rgba(59, 130, 246, 0.1)", borderColor: "#2563eb" }}
|
||||||
|
>
|
||||||
|
<div className="rigo-analysis-label" style={{ color: "#60a5fa" }}>
|
||||||
|
Rigo-Chêneau Classification
|
||||||
|
</div>
|
||||||
|
<div className="rigo-analysis-value highlight">{classification.type}</div>
|
||||||
|
<div className="rigo-analysis-description">{classification.description}</div>
|
||||||
|
{classification.curve_pattern && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: "0.875rem", color: "#94a3b8" }}>
|
||||||
|
Curve Pattern: <strong style={{ color: "#f1f5f9" }}>{classification.curve_pattern}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BraceAnalysisPage() {
|
||||||
|
const { caseId: routeCaseId } = useParams<{ caseId?: string }>();
|
||||||
|
|
||||||
|
const [caseId, setCaseId] = useState<string | null>(routeCaseId || null);
|
||||||
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<GenerateBraceResponse | null>(null);
|
||||||
|
const [outputs, setOutputs] = useState<BraceOutput[]>([]);
|
||||||
|
const [modelUrl, setModelUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Handle file upload and analysis
|
||||||
|
const handleUpload = useCallback(async (file: File) => {
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
setOutputs([]);
|
||||||
|
setModelUrl(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run the full workflow
|
||||||
|
const { caseId: newCaseId, result: analysisResult } = await analyzeXray(file);
|
||||||
|
setCaseId(newCaseId);
|
||||||
|
setResult(analysisResult);
|
||||||
|
|
||||||
|
// Find the GLB or STL model URL for 3D viewer
|
||||||
|
const modelOutput =
|
||||||
|
analysisResult.outputs?.["glb"] ||
|
||||||
|
analysisResult.outputs?.["ply"] ||
|
||||||
|
analysisResult.outputs?.["stl"];
|
||||||
|
|
||||||
|
if (modelOutput?.url) {
|
||||||
|
setModelUrl(modelOutput.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all outputs with presigned URLs
|
||||||
|
const outputsResponse = await getBraceOutputs(newCaseId);
|
||||||
|
setOutputs(outputsResponse.outputs);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "Analysis failed. Please try again.";
|
||||||
|
setError(message);
|
||||||
|
console.error("Analysis error:", err);
|
||||||
|
} finally {
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setCaseId(null);
|
||||||
|
setResult(null);
|
||||||
|
setOutputs([]);
|
||||||
|
setModelUrl(null);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const severity = getSeverity(result?.cobb_angles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bf-page bf-page--wide">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bf-page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="bf-page-title">Brace Analysis</h1>
|
||||||
|
<p className="bf-page-subtitle">
|
||||||
|
Upload an X-ray image to analyze spinal curvature and generate a custom brace design.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{caseId && (
|
||||||
|
<Link to={`/cases/${caseId}/status`} className="rigo-btn rigo-btn-secondary">
|
||||||
|
View Case Details
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content - Three Column Layout */}
|
||||||
|
<div className="rigo-shell-page" style={{ gridTemplateColumns: "320px 1fr 380px", gap: 24 }}>
|
||||||
|
{/* Left Panel - Upload */}
|
||||||
|
<aside className="rigo-panel">
|
||||||
|
<div className="rigo-panel-header">
|
||||||
|
<h2 className="rigo-panel-title">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
Upload X-Ray
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rigo-panel-content">
|
||||||
|
<UploadPanel
|
||||||
|
onUpload={handleUpload}
|
||||||
|
isAnalyzing={isAnalyzing}
|
||||||
|
onReset={handleReset}
|
||||||
|
hasResults={!!result}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{caseId && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: 12,
|
||||||
|
background: "rgba(59, 130, 246, 0.1)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#60a5fa", marginBottom: 4 }}>Case ID</div>
|
||||||
|
<code style={{ color: "#f1f5f9", fontSize: "0.75rem" }}>{caseId}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="rigo-error-message"
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: 12,
|
||||||
|
background: "rgba(255,0,0,0.1)",
|
||||||
|
borderRadius: 8,
|
||||||
|
color: "#f87171",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Center - 3D Viewer */}
|
||||||
|
<main className="rigo-viewer-container">
|
||||||
|
<BraceViewer modelUrl={modelUrl} isLoading={isAnalyzing} />
|
||||||
|
|
||||||
|
{/* Processing Info */}
|
||||||
|
{result && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
background: "rgba(0,0,0,0.7)",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "#94a3b8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Model: {result.model}</span>
|
||||||
|
<span>Experiment: {result.experiment}</span>
|
||||||
|
<span>Processing: {result.processing_time_ms}ms</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Right Panel - Analysis Results */}
|
||||||
|
<aside className="rigo-panel" style={{ overflow: "auto", maxHeight: "calc(100vh - 200px)" }}>
|
||||||
|
<div className="rigo-panel-header">
|
||||||
|
<h2 className="rigo-panel-title">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
Analysis Results
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rigo-panel-content">
|
||||||
|
{isAnalyzing ? (
|
||||||
|
<div className="rigo-analysis-loading">
|
||||||
|
<div className="rigo-analysis-card">
|
||||||
|
<div
|
||||||
|
className="rigo-loading-skeleton"
|
||||||
|
style={{ height: "20px", marginBottom: "8px", width: "40%" }}
|
||||||
|
></div>
|
||||||
|
<div className="rigo-loading-skeleton" style={{ height: "36px", width: "60%" }}></div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#64748b",
|
||||||
|
marginTop: "24px",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="rigo-spinner"
|
||||||
|
style={{ display: "inline-block", marginRight: "8px", verticalAlign: "middle" }}
|
||||||
|
></span>
|
||||||
|
Analyzing X-ray...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : result ? (
|
||||||
|
<div className="rigo-analysis-results">
|
||||||
|
{/* Severity Summary */}
|
||||||
|
<MetricCard
|
||||||
|
label="Overall Assessment"
|
||||||
|
value={`${severity.level} Scoliosis`}
|
||||||
|
description={`Max Cobb angle: ${severity.angle.toFixed(1)}°`}
|
||||||
|
highlight={severity.class as "success" | "highlight" | "warning"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Curve Type */}
|
||||||
|
<div className="rigo-analysis-grid">
|
||||||
|
<MetricCard label="Curve Type" value={result.curve_type || "—"} />
|
||||||
|
<MetricCard label="Vertebrae Detected" value={result.vertebrae_detected || "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cobb Angles */}
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#64748b",
|
||||||
|
marginBottom: 8,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cobb Angles
|
||||||
|
</h3>
|
||||||
|
<CobbAnglesDisplay angles={result.cobb_angles} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rigo Classification */}
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<RigoDisplay classification={result.rigo_classification} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mesh Info */}
|
||||||
|
{result.mesh && (
|
||||||
|
<div className="rigo-analysis-grid" style={{ marginTop: 16 }}>
|
||||||
|
<MetricCard
|
||||||
|
label="Mesh Vertices"
|
||||||
|
value={result.mesh.vertices?.toLocaleString() || "—"}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Mesh Faces"
|
||||||
|
value={result.mesh.faces?.toLocaleString() || "—"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download Section */}
|
||||||
|
{outputs.length > 0 && (
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#64748b",
|
||||||
|
marginBottom: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Downloads
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{outputs
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort: STL first, then PLY, then images, then JSON
|
||||||
|
const order: Record<string, number> = {
|
||||||
|
stl: 0,
|
||||||
|
ply: 1,
|
||||||
|
obj: 2,
|
||||||
|
image: 3,
|
||||||
|
json: 4,
|
||||||
|
other: 5,
|
||||||
|
};
|
||||||
|
return (order[a.type] ?? 5) - (order[b.type] ?? 5);
|
||||||
|
})
|
||||||
|
.map((output) => (
|
||||||
|
<DownloadButton key={output.s3Key} output={output} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rigo-analysis-empty">
|
||||||
|
<div style={{ textAlign: "center", padding: "32px", color: "#64748b" }}>
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
style={{ margin: "0 auto 16px", opacity: 0.3 }}
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
<p>Upload an X-ray to see analysis results.</p>
|
||||||
|
<p style={{ fontSize: "0.75rem", marginTop: 8 }}>
|
||||||
|
Supported formats: JPEG, PNG, WebP, BMP
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
372
frontend/src/pages/CaseDetail.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { fetchCase, getBraceOutputs, getDownloadUrl, generateBrace } from "../api/braceflowApi";
|
||||||
|
import type { CaseRecord, BraceOutputsResponse } from "../api/braceflowApi";
|
||||||
|
|
||||||
|
// Helper function to determine curve severity from Cobb angle
|
||||||
|
function getCurveSeverity(angle: number): string {
|
||||||
|
if (angle < 10) return "Normal";
|
||||||
|
if (angle < 25) return "Mild";
|
||||||
|
if (angle < 40) return "Moderate";
|
||||||
|
if (angle < 50) return "Severe";
|
||||||
|
return "Very Severe";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurveSeverityClass(angle: number): string {
|
||||||
|
if (angle < 10) return "severity-normal";
|
||||||
|
if (angle < 25) return "severity-mild";
|
||||||
|
if (angle < 40) return "severity-moderate";
|
||||||
|
return "severity-severe";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get Rigo type description
|
||||||
|
function getRigoDescription(rigoType: string): string {
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
'A1': 'Three-curve pattern with lumbar modifier',
|
||||||
|
'A2': 'Three-curve pattern with thoracolumbar modifier',
|
||||||
|
'A3': 'Three-curve pattern balanced',
|
||||||
|
'B1': 'Four-curve pattern with lumbar modifier',
|
||||||
|
'B2': 'Four-curve pattern with double thoracic',
|
||||||
|
'C1': 'Non-3 non-4 with thoracolumbar curve',
|
||||||
|
'C2': 'Non-3 non-4 with lumbar curve',
|
||||||
|
'E1': 'Single thoracic curve',
|
||||||
|
'E2': 'Single thoracolumbar curve',
|
||||||
|
};
|
||||||
|
return descriptions[rigoType] || `Rigo type ${rigoType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format file size
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (!bytes || bytes === 0) return '';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CaseDetailPage() {
|
||||||
|
const { caseId } = useParams<{ caseId: string }>();
|
||||||
|
const nav = useNavigate();
|
||||||
|
|
||||||
|
const [caseData, setCaseData] = useState<CaseRecord | null>(null);
|
||||||
|
const [outputs, setOutputs] = useState<BraceOutputsResponse | null>(null);
|
||||||
|
const [xrayUrl, setXrayUrl] = useState<string | null>(null);
|
||||||
|
const [xrayError, setXrayError] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [genError, setGenError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadCaseData = useCallback(async () => {
|
||||||
|
if (!caseId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
setXrayError(false);
|
||||||
|
try {
|
||||||
|
// Fetch case data first
|
||||||
|
const caseResult = await fetchCase(caseId);
|
||||||
|
setCaseData(caseResult);
|
||||||
|
|
||||||
|
// Fetch outputs and X-ray URL in parallel (don't fail if they error)
|
||||||
|
const [outputsResult, xrayResult] = await Promise.all([
|
||||||
|
getBraceOutputs(caseId).catch(() => null),
|
||||||
|
getDownloadUrl(caseId, "xray").catch(() => null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
setOutputs(outputsResult);
|
||||||
|
setXrayUrl(xrayResult?.url || null);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || "Failed to load case");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [caseId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCaseData();
|
||||||
|
}, [loadCaseData]);
|
||||||
|
|
||||||
|
// Handle generate brace button
|
||||||
|
const handleGenerateBrace = async () => {
|
||||||
|
if (!caseId) return;
|
||||||
|
setGenerating(true);
|
||||||
|
setGenError(null);
|
||||||
|
try {
|
||||||
|
await generateBrace(caseId, { experiment: "experiment_3" });
|
||||||
|
// Reload case data after generation
|
||||||
|
await loadCaseData();
|
||||||
|
} catch (e: any) {
|
||||||
|
setGenError(e?.message || "Failed to generate brace");
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the visualization PNG from outputs
|
||||||
|
const vizUrl = outputs?.outputs?.find(o =>
|
||||||
|
o.filename.endsWith('.png') && !o.filename.includes('ap')
|
||||||
|
)?.url;
|
||||||
|
|
||||||
|
// Check if brace has been generated
|
||||||
|
const hasBrace = caseData?.status === "brace_generated" ||
|
||||||
|
caseData?.status === "completed" ||
|
||||||
|
caseData?.analysis_result?.cobb_angles;
|
||||||
|
|
||||||
|
const isProcessing = caseData?.status === "processing_brace" || generating;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bf-page">
|
||||||
|
<div className="muted">Loading case...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err || !caseData) {
|
||||||
|
return (
|
||||||
|
<div className="bf-page">
|
||||||
|
<div className="error">{err || "Case not found"}</div>
|
||||||
|
<button className="btn secondary" onClick={() => nav("/")}>Back to Cases</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bf-page bf-page--wide">
|
||||||
|
{/* Header with case ID */}
|
||||||
|
<div className="bf-case-header">
|
||||||
|
<div className="bf-case-header-left">
|
||||||
|
<button className="bf-back-btn" onClick={() => nav("/")}>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<h1 className="bf-case-title">{caseId}</h1>
|
||||||
|
<span className={`bf-case-status bf-case-status--${caseData.status}`}>
|
||||||
|
{caseData.status?.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* X-ray Image Section */}
|
||||||
|
<div className="bf-case-content">
|
||||||
|
<div className="bf-case-xray-section">
|
||||||
|
<h2 className="bf-section-title">Original X-ray</h2>
|
||||||
|
<div className="bf-xray-container">
|
||||||
|
{xrayUrl && !xrayError ? (
|
||||||
|
<img
|
||||||
|
src={xrayUrl}
|
||||||
|
alt="X-ray"
|
||||||
|
className="bf-xray-image"
|
||||||
|
onError={() => setXrayError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bf-xray-placeholder">
|
||||||
|
<span>{xrayError ? "Failed to load X-ray" : "X-ray not available yet"}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Processing State */}
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="bf-processing-indicator">
|
||||||
|
<div className="bf-processing-spinner"></div>
|
||||||
|
<span>Processing... Generating brace from X-ray analysis</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Generate Button - show if X-ray exists but brace not generated */}
|
||||||
|
{xrayUrl && !hasBrace && !isProcessing && (
|
||||||
|
<div className="bf-generate-section">
|
||||||
|
<button
|
||||||
|
className="btn primary bf-generate-btn"
|
||||||
|
onClick={handleGenerateBrace}
|
||||||
|
disabled={generating}
|
||||||
|
>
|
||||||
|
{generating ? "Generating..." : "Generate Brace"}
|
||||||
|
</button>
|
||||||
|
{genError && <div className="error">{genError}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visualization Section */}
|
||||||
|
{vizUrl && (
|
||||||
|
<div className="bf-case-viz-section">
|
||||||
|
<h2 className="bf-section-title">Spine Analysis Visualization</h2>
|
||||||
|
<div className="bf-viz-container">
|
||||||
|
<img src={vizUrl} alt="Analysis visualization" className="bf-viz-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detailed Analysis Under Visualization */}
|
||||||
|
{caseData.analysis_result && (
|
||||||
|
<div className="bf-detailed-analysis">
|
||||||
|
{/* Cobb Angles with Severity */}
|
||||||
|
{caseData.analysis_result.cobb_angles && (
|
||||||
|
<div className="bf-analysis-block">
|
||||||
|
<h3>Cobb Angle Measurements</h3>
|
||||||
|
<div className="bf-cobb-detailed">
|
||||||
|
{caseData.analysis_result.cobb_angles.PT !== undefined && (
|
||||||
|
<div className="bf-cobb-row">
|
||||||
|
<span className="bf-cobb-name">PT (Proximal Thoracic)</span>
|
||||||
|
<span className={`bf-cobb-value ${getCurveSeverityClass(caseData.analysis_result.cobb_angles.PT)}`}>
|
||||||
|
{caseData.analysis_result.cobb_angles.PT.toFixed(1)}°
|
||||||
|
</span>
|
||||||
|
<span className="bf-cobb-severity">{getCurveSeverity(caseData.analysis_result.cobb_angles.PT)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{caseData.analysis_result.cobb_angles.MT !== undefined && (
|
||||||
|
<div className="bf-cobb-row">
|
||||||
|
<span className="bf-cobb-name">MT (Main Thoracic)</span>
|
||||||
|
<span className={`bf-cobb-value ${getCurveSeverityClass(caseData.analysis_result.cobb_angles.MT)}`}>
|
||||||
|
{caseData.analysis_result.cobb_angles.MT.toFixed(1)}°
|
||||||
|
</span>
|
||||||
|
<span className="bf-cobb-severity">{getCurveSeverity(caseData.analysis_result.cobb_angles.MT)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{caseData.analysis_result.cobb_angles.TL !== undefined && (
|
||||||
|
<div className="bf-cobb-row">
|
||||||
|
<span className="bf-cobb-name">TL (Thoracolumbar/Lumbar)</span>
|
||||||
|
<span className={`bf-cobb-value ${getCurveSeverityClass(caseData.analysis_result.cobb_angles.TL)}`}>
|
||||||
|
{caseData.analysis_result.cobb_angles.TL.toFixed(1)}°
|
||||||
|
</span>
|
||||||
|
<span className="bf-cobb-severity">{getCurveSeverity(caseData.analysis_result.cobb_angles.TL)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Classification Summary */}
|
||||||
|
<div className="bf-analysis-block">
|
||||||
|
<h3>Classification</h3>
|
||||||
|
<div className="bf-classification-grid">
|
||||||
|
{caseData.analysis_result.curve_type && (
|
||||||
|
<div className="bf-classification-item">
|
||||||
|
<span className="bf-classification-label">Curve Pattern</span>
|
||||||
|
<span className="bf-classification-value bf-curve-badge">
|
||||||
|
{caseData.analysis_result.curve_type}-Curve
|
||||||
|
</span>
|
||||||
|
<span className="bf-classification-desc">
|
||||||
|
{caseData.analysis_result.curve_type === 'S' ? 'Double curve (thoracic + lumbar)' :
|
||||||
|
caseData.analysis_result.curve_type === 'C' ? 'Single curve pattern' :
|
||||||
|
'Curve pattern identified'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{caseData.analysis_result.rigo_classification && (
|
||||||
|
<div className="bf-classification-item">
|
||||||
|
<span className="bf-classification-label">Rigo-Chêneau Type</span>
|
||||||
|
<span className="bf-classification-value bf-rigo-badge">
|
||||||
|
{caseData.analysis_result.rigo_classification.type}
|
||||||
|
</span>
|
||||||
|
<span className="bf-classification-desc">
|
||||||
|
{caseData.analysis_result.rigo_classification.description || getRigoDescription(caseData.analysis_result.rigo_classification.type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brace Generation Details */}
|
||||||
|
<div className="bf-analysis-block">
|
||||||
|
<h3>Brace Generation Details</h3>
|
||||||
|
<div className="bf-brace-details-grid">
|
||||||
|
{caseData.analysis_result.vertebrae_detected && (
|
||||||
|
<div className="bf-detail-item">
|
||||||
|
<span className="bf-detail-label">Vertebrae Detected</span>
|
||||||
|
<span className="bf-detail-value">{caseData.analysis_result.vertebrae_detected}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{caseData.analysis_result.mesh_info && (
|
||||||
|
<>
|
||||||
|
<div className="bf-detail-item">
|
||||||
|
<span className="bf-detail-label">Mesh Vertices</span>
|
||||||
|
<span className="bf-detail-value">{caseData.analysis_result.mesh_info.vertices?.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bf-detail-item">
|
||||||
|
<span className="bf-detail-label">Mesh Faces</span>
|
||||||
|
<span className="bf-detail-value">{caseData.analysis_result.mesh_info.faces?.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{caseData.analysis_result.processing_time_ms && (
|
||||||
|
<div className="bf-detail-item">
|
||||||
|
<span className="bf-detail-label">Processing Time</span>
|
||||||
|
<span className="bf-detail-value">{(caseData.analysis_result.processing_time_ms / 1000).toFixed(2)}s</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deformation/Pressure Zones */}
|
||||||
|
{caseData.analysis_result.deformation_report?.zones && caseData.analysis_result.deformation_report.zones.length > 0 && (
|
||||||
|
<div className="bf-analysis-block">
|
||||||
|
<h3>Brace Pressure Zones</h3>
|
||||||
|
<p className="bf-block-desc">
|
||||||
|
Based on the Cobb angles and Rigo classification, the following pressure modifications were applied to the brace:
|
||||||
|
</p>
|
||||||
|
<div className="bf-pressure-zones">
|
||||||
|
{caseData.analysis_result.deformation_report.zones.map((zone, idx) => (
|
||||||
|
<div key={idx} className={`bf-zone-item ${zone.deform_mm < 0 ? 'bf-zone-pressure' : 'bf-zone-relief'}`}>
|
||||||
|
<div className="bf-zone-header">
|
||||||
|
<span className="bf-zone-name">{zone.zone}</span>
|
||||||
|
<span className={`bf-zone-value ${zone.deform_mm < 0 ? 'bf-pressure' : 'bf-relief'}`}>
|
||||||
|
{zone.deform_mm > 0 ? '+' : ''}{zone.deform_mm.toFixed(1)} mm
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="bf-zone-reason">{zone.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{caseData.analysis_result.deformation_report.patch_grid && (
|
||||||
|
<p className="bf-patch-info">
|
||||||
|
Patch Grid: {caseData.analysis_result.deformation_report.patch_grid}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Downloads */}
|
||||||
|
{outputs?.outputs && outputs.outputs.length > 0 && (
|
||||||
|
<div className="bf-case-downloads">
|
||||||
|
<h2 className="bf-section-title">Generated Brace Files</h2>
|
||||||
|
<div className="bf-downloads-grid">
|
||||||
|
{outputs.outputs
|
||||||
|
.filter(o => o.type === 'stl' || o.type === 'obj')
|
||||||
|
.map(o => (
|
||||||
|
<div key={o.filename} className="bf-download-card">
|
||||||
|
<div className="bf-download-card-icon">
|
||||||
|
{o.type === 'stl' ? '🧊' : '📦'}
|
||||||
|
</div>
|
||||||
|
<div className="bf-download-card-info">
|
||||||
|
<span className="bf-download-card-name">{o.filename}</span>
|
||||||
|
<span className="bf-download-card-size">{formatFileSize(o.size)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bf-download-card-actions">
|
||||||
|
<a
|
||||||
|
href={o.url}
|
||||||
|
className="bf-action-btn bf-action-download"
|
||||||
|
download={o.filename}
|
||||||
|
title="Download file"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="bf-download-hint">STL files can be 3D printed or opened in any 3D modeling software.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/src/pages/CaseLoaderPage.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
export function CaseLoaderPage() {
|
||||||
|
const [caseId, setCaseId] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const nav = useNavigate();
|
||||||
|
|
||||||
|
const onLoad = async () => {
|
||||||
|
setErr(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.getCaseStatus(caseId.trim());
|
||||||
|
nav(`/cases/${encodeURIComponent(caseId.trim())}/status`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || "Failed to load case");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bf-page">
|
||||||
|
<div className="bf-page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="bf-page-title">Load A Case</h1>
|
||||||
|
<p className="bf-page-subtitle">
|
||||||
|
Enter a case ID to view status or resume landmark capture.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bf-spacer" />
|
||||||
|
<div className="bf-toolbar">
|
||||||
|
<button className="btn primary" disabled={!caseId.trim() || loading} onClick={onLoad}>
|
||||||
|
{loading ? "Loading..." : "Load Case"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<p className="muted muted--tight">
|
||||||
|
To create a new case and upload an X-ray, use "Start A Case" in the header.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="row gap">
|
||||||
|
<input
|
||||||
|
value={caseId}
|
||||||
|
onChange={(e) => setCaseId(e.target.value)}
|
||||||
|
placeholder="case-20260122-..."
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
<button className="btn secondary" disabled={!caseId.trim() || loading} onClick={onLoad}>
|
||||||
|
{loading ? "Loading..." : "Load Case"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/pages/CaseStatusPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { CaseStatus } from "../lib/api";
|
||||||
|
|
||||||
|
export function CaseStatusPage() {
|
||||||
|
const { caseId } = useParams();
|
||||||
|
const id = caseId || "";
|
||||||
|
const nav = useNavigate();
|
||||||
|
|
||||||
|
const [data, setData] = useState<CaseStatus | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const res = await api.getCaseStatus(id);
|
||||||
|
setData(res);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || "Failed to load status");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 4000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bf-page bf-page--wide">
|
||||||
|
<div className="bf-page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="bf-page-title">Case Status</h1>
|
||||||
|
<p className="bf-page-subtitle">Track pipeline progress and jump to the next step.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bf-spacer" />
|
||||||
|
<div className="bf-toolbar">
|
||||||
|
<button className="btn secondary" onClick={load}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => nav(`/cases/${encodeURIComponent(id)}/landmarks`)}
|
||||||
|
>
|
||||||
|
Landmark Tool
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="muted muted--tight">
|
||||||
|
Case: <strong>{id}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
{!data ? (
|
||||||
|
<div className="muted">Loading status...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bf-summary-grid">
|
||||||
|
<div className="bf-summary-item">
|
||||||
|
<span className="bf-summary-label">Status</span>
|
||||||
|
<span className="bf-summary-value">{data.case.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bf-summary-item">
|
||||||
|
<span className="bf-summary-label">Current Step</span>
|
||||||
|
<span className="bf-summary-value">{data.case.current_step || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bf-summary-item">
|
||||||
|
<span className="bf-summary-label">Last Updated</span>
|
||||||
|
<span className="bf-summary-value">
|
||||||
|
{new Date(data.case.updated_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Step</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Finished</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.steps.map((s) => (
|
||||||
|
<tr key={s.step_name}>
|
||||||
|
<td>{s.step_order}</td>
|
||||||
|
<td>{s.step_name}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`tag ${s.status}`}>{s.status}</span>
|
||||||
|
</td>
|
||||||
|
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : "-"}</td>
|
||||||
|
<td>{s.finished_at ? new Date(s.finished_at).toLocaleString() : "-"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="muted">
|
||||||
|
Step3 output (classification.json) display can be added next (optional for first demo).
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { fetchCases, createCaseAndUploadXray, getDownloadUrl, deleteCase } from "../api/braceflowApi";
|
||||||
|
import type { CaseRecord } from "../api/braceflowApi";
|
||||||
|
|
||||||
|
export default function Dashboard({ onView }: { onView?: (id: string) => void }) {
|
||||||
|
const nav = useNavigate();
|
||||||
|
|
||||||
|
const [cases, setCases] = useState<CaseRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Upload state
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState("");
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Thumbnail URLs for each case
|
||||||
|
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Dropdown menu state
|
||||||
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const c = await fetchCases();
|
||||||
|
setCases(c);
|
||||||
|
// Load thumbnails for each case
|
||||||
|
loadThumbnails(c);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || "Failed to load cases");
|
||||||
|
setCases([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load X-ray thumbnails for cases
|
||||||
|
async function loadThumbnails(caseList: CaseRecord[]) {
|
||||||
|
const newThumbnails: Record<string, string> = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
caseList.map(async (c) => {
|
||||||
|
try {
|
||||||
|
const result = await getDownloadUrl(c.caseId, "xray");
|
||||||
|
newThumbnails[c.caseId] = result.url;
|
||||||
|
} catch {
|
||||||
|
// No thumbnail available
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setThumbnails(prev => ({ ...prev, ...newThumbnails }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete case
|
||||||
|
async function handleDelete(caseId: string, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to delete case "${caseId}"?\n\nThis will permanently remove the case and all associated files.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleting(caseId);
|
||||||
|
setOpenMenu(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteCase(caseId);
|
||||||
|
setCases(prev => prev.filter(c => c.caseId !== caseId));
|
||||||
|
setThumbnails(prev => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
delete updated[caseId];
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || "Failed to delete case");
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside() {
|
||||||
|
setOpenMenu(null);
|
||||||
|
}
|
||||||
|
document.addEventListener("click", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("click", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const c = await fetchCases();
|
||||||
|
if (mounted) {
|
||||||
|
setCases(c);
|
||||||
|
loadThumbnails(c);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (mounted) setErr(e?.message || "Failed to load cases");
|
||||||
|
} finally {
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function viewCase(caseId: string) {
|
||||||
|
if (onView) {
|
||||||
|
onView(caseId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nav(`/cases/${encodeURIComponent(caseId)}/analysis`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileUpload = useCallback(async (file: File) => {
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
setErr("Please upload an image file (JPEG, PNG, etc.)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setErr(null);
|
||||||
|
setUploadProgress("Creating case...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploadProgress("Uploading X-ray...");
|
||||||
|
const { caseId } = await createCaseAndUploadXray(file);
|
||||||
|
setUploadProgress("Complete!");
|
||||||
|
|
||||||
|
// Refresh the case list and navigate to the new case
|
||||||
|
await load();
|
||||||
|
viewCase(caseId);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
setUploadProgress("");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragActive(false);
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(file);
|
||||||
|
}
|
||||||
|
}, [handleFileUpload]);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragActive(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragActive(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(file);
|
||||||
|
}
|
||||||
|
}, [handleFileUpload]);
|
||||||
|
|
||||||
|
const hasCases = cases.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bf-page bf-page--wide">
|
||||||
|
<div className="bf-page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="bf-page-title">Cases</h1>
|
||||||
|
<p className="bf-page-subtitle">Upload an X-ray to create a new case, or select an existing one.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bf-spacer" />
|
||||||
|
<div className="bf-toolbar">
|
||||||
|
<button className="btn secondary bf-btn-fixed" onClick={load} disabled={loading || uploading}>
|
||||||
|
{loading ? "Loading..." : "Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Area */}
|
||||||
|
<div
|
||||||
|
className={`bf-upload-zone ${dragActive ? "bf-upload-zone--active" : ""} ${uploading ? "bf-upload-zone--uploading" : ""}`}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
{uploading ? (
|
||||||
|
<div className="bf-upload-content">
|
||||||
|
<div className="bf-upload-spinner"></div>
|
||||||
|
<p className="bf-upload-text">{uploadProgress}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bf-upload-content">
|
||||||
|
<div className="bf-upload-icon">+</div>
|
||||||
|
<p className="bf-upload-text">
|
||||||
|
<strong>Click to upload</strong> or drag and drop an X-ray image
|
||||||
|
</p>
|
||||||
|
<p className="bf-upload-hint">JPEG, PNG, WebP supported</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="error" style={{ marginTop: "1rem" }}>{err}</div>}
|
||||||
|
|
||||||
|
{/* Cases List */}
|
||||||
|
<div className="card" style={{ marginTop: "1.5rem" }}>
|
||||||
|
<h2 className="bf-section-title">Recent Cases</h2>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="muted">Loading cases...</div>
|
||||||
|
) : !hasCases ? (
|
||||||
|
<div className="bf-empty">No cases yet. Upload an X-ray above to create your first case.</div>
|
||||||
|
) : (
|
||||||
|
<div className="bf-cases-list">
|
||||||
|
{cases.map((c) => {
|
||||||
|
const date = new Date(c.created_at);
|
||||||
|
const isValidDate = !isNaN(date.getTime());
|
||||||
|
const thumbUrl = thumbnails[c.caseId];
|
||||||
|
const isDeleting = deleting === c.caseId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.caseId}
|
||||||
|
className={`bf-case-row ${isDeleting ? "bf-case-row--deleting" : ""}`}
|
||||||
|
onClick={() => !isDeleting && viewCase(c.caseId)}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="bf-case-thumb">
|
||||||
|
{thumbUrl ? (
|
||||||
|
<img src={thumbUrl} alt="X-ray" className="bf-case-thumb-img" />
|
||||||
|
) : (
|
||||||
|
<div className="bf-case-thumb-placeholder">X</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Case Info */}
|
||||||
|
<div className="bf-case-row-info">
|
||||||
|
<span className="bf-case-row-id">{c.caseId}</span>
|
||||||
|
{isValidDate && (
|
||||||
|
<span className="bf-case-row-date">
|
||||||
|
{date.toLocaleDateString()} {date.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu Button */}
|
||||||
|
<div className="bf-case-menu-container">
|
||||||
|
{isDeleting ? (
|
||||||
|
<div className="bf-case-menu-spinner"></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="bf-case-menu-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenMenu(openMenu === c.caseId ? null : c.caseId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
{openMenu === c.caseId && (
|
||||||
|
<div className="bf-case-dropdown">
|
||||||
|
<button
|
||||||
|
className="bf-case-dropdown-item bf-case-dropdown-item--danger"
|
||||||
|
onClick={(e) => handleDelete(c.caseId, e)}
|
||||||
|
>
|
||||||
|
Delete Case
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { useNavigate, Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
// If authenticated, redirect directly to cases
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bf-loading-screen">
|
||||||
|
<div className="bf-loading-spinner"></div>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/cases" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bf-home-page">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="bf-hero">
|
||||||
|
<div className="bf-hero-content">
|
||||||
|
<h1 className="bf-hero-title">
|
||||||
|
Intelligent Scoliosis
|
||||||
|
<br />
|
||||||
|
<span className="bf-hero-accent">Brace Design</span>
|
||||||
|
</h1>
|
||||||
|
<p className="bf-hero-subtitle">
|
||||||
|
Advanced AI-powered analysis and custom brace generation for scoliosis treatment.
|
||||||
|
Upload an X-ray, get precise Cobb angle measurements and Rigo classification,
|
||||||
|
and generate patient-specific 3D-printable braces.
|
||||||
|
</p>
|
||||||
|
<div className="bf-hero-actions">
|
||||||
|
<button
|
||||||
|
className="bf-hero-btn bf-hero-btn--primary"
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Visual - Spine illustration */}
|
||||||
|
<div className="bf-hero-visual">
|
||||||
|
<svg
|
||||||
|
className="bf-hero-svg"
|
||||||
|
viewBox="0 0 200 300"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
{/* Spine curve */}
|
||||||
|
<path
|
||||||
|
d="M100 20 C 80 60 120 100 100 140 C 80 180 120 220 100 260"
|
||||||
|
stroke="rgba(255,255,255,0.3)"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
className="bf-hero-spine"
|
||||||
|
/>
|
||||||
|
{/* Vertebrae */}
|
||||||
|
{[40, 80, 120, 160, 200, 240].map((y, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={100 + (i % 2 === 0 ? -10 : 10) * Math.sin((i * Math.PI) / 3)}
|
||||||
|
cy={y}
|
||||||
|
r="12"
|
||||||
|
fill="rgba(221, 130, 80, 0.15)"
|
||||||
|
stroke="var(--accent-primary)"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="bf-hero-vertebra"
|
||||||
|
style={{ animationDelay: `${i * 0.15}s` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Brace outline */}
|
||||||
|
<path
|
||||||
|
d="M60 60 Q 40 150 60 240 L 140 240 Q 160 150 140 60 Z"
|
||||||
|
stroke="var(--accent-primary)"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeDasharray="8 4"
|
||||||
|
fill="none"
|
||||||
|
opacity="0.6"
|
||||||
|
className="bf-hero-brace"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<section className="bf-features">
|
||||||
|
<div className="bf-features-grid">
|
||||||
|
<div className="bf-feature-card">
|
||||||
|
<div className="bf-feature-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<circle cx="12" cy="15" r="3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="bf-feature-title">X-ray Analysis</h3>
|
||||||
|
<p className="bf-feature-desc">
|
||||||
|
Upload spinal X-rays for automatic vertebrae detection and landmark identification
|
||||||
|
using advanced computer vision.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-feature-card">
|
||||||
|
<div className="bf-feature-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="bf-feature-title">Cobb Angle Measurement</h3>
|
||||||
|
<p className="bf-feature-desc">
|
||||||
|
Precise calculation of Cobb angles (PT, MT, TL) with severity classification
|
||||||
|
and Rigo-Chêneau type determination.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-feature-card">
|
||||||
|
<div className="bf-feature-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
|
<polyline points="7.5 4.21 12 6.81 16.5 4.21" />
|
||||||
|
<polyline points="7.5 19.79 7.5 14.6 3 12" />
|
||||||
|
<polyline points="21 12 16.5 14.6 16.5 19.79" />
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="bf-feature-title">3D Brace Generation</h3>
|
||||||
|
<p className="bf-feature-desc">
|
||||||
|
Generate custom 3D-printable braces with patient-specific pressure zones
|
||||||
|
and relief windows based on curve analysis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Workflow Section */}
|
||||||
|
<section className="bf-workflow">
|
||||||
|
<h2 className="bf-section-heading">How It Works</h2>
|
||||||
|
<div className="bf-workflow-steps">
|
||||||
|
<div className="bf-workflow-step">
|
||||||
|
<div className="bf-workflow-number">1</div>
|
||||||
|
<div className="bf-workflow-content">
|
||||||
|
<h4>Upload X-ray</h4>
|
||||||
|
<p>Upload a spinal PA/AP X-ray image</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bf-workflow-connector" />
|
||||||
|
<div className="bf-workflow-step">
|
||||||
|
<div className="bf-workflow-number">2</div>
|
||||||
|
<div className="bf-workflow-content">
|
||||||
|
<h4>Review Analysis</h4>
|
||||||
|
<p>Verify landmarks and measurements</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bf-workflow-connector" />
|
||||||
|
<div className="bf-workflow-step">
|
||||||
|
<div className="bf-workflow-number">3</div>
|
||||||
|
<div className="bf-workflow-content">
|
||||||
|
<h4>Generate Brace</h4>
|
||||||
|
<p>Create custom 3D brace design</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bf-workflow-connector" />
|
||||||
|
<div className="bf-workflow-step">
|
||||||
|
<div className="bf-workflow-number">4</div>
|
||||||
|
<div className="bf-workflow-content">
|
||||||
|
<h4>Download & Print</h4>
|
||||||
|
<p>Export STL files for 3D printing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bf-home-footer">
|
||||||
|
<p>BraceIQ Development Environment</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
const ENTER_DURATION_MS = 2300;
|
||||||
|
const EXIT_DURATION_MS = 650;
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const exitTimer = window.setTimeout(() => setIsExiting(true), ENTER_DURATION_MS);
|
||||||
|
const navTimer = window.setTimeout(
|
||||||
|
() => nav("/dashboard", { replace: true }),
|
||||||
|
ENTER_DURATION_MS + EXIT_DURATION_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(exitTimer);
|
||||||
|
window.clearTimeout(navTimer);
|
||||||
|
};
|
||||||
|
}, [nav]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bf-landing ${isExiting ? "is-exiting" : ""}`} role="status" aria-live="polite">
|
||||||
|
<div className="bf-landing-inner">
|
||||||
|
<div className="bf-landing-visual" aria-hidden="true">
|
||||||
|
<svg className="bf-landing-svg" viewBox="0 0 200 200" role="presentation">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bfBraceGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#efb07a" stopOpacity="0.95" />
|
||||||
|
<stop offset="100%" stopColor="#d17645" stopOpacity="0.95" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<path
|
||||||
|
className="bf-landing-spine"
|
||||||
|
d="M100 34 C 92 60, 108 86, 100 112 C 92 138, 108 164, 100 188"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<circle className="bf-landing-vertebra" cx="100" cy="48" r="6.2" />
|
||||||
|
<circle className="bf-landing-vertebra" cx="100" cy="74" r="5.8" />
|
||||||
|
<circle className="bf-landing-vertebra" cx="100" cy="100" r="5.8" />
|
||||||
|
<circle className="bf-landing-vertebra" cx="100" cy="126" r="5.8" />
|
||||||
|
<circle className="bf-landing-vertebra" cx="100" cy="152" r="6.2" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
className="bf-landing-brace bf-landing-brace--left"
|
||||||
|
d="M58 62 C 44 82, 44 118, 58 138"
|
||||||
|
stroke="url(#bfBraceGrad)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="bf-landing-brace bf-landing-brace--right"
|
||||||
|
d="M142 62 C 156 82, 156 118, 142 138"
|
||||||
|
stroke="url(#bfBraceGrad)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
className="bf-landing-pad"
|
||||||
|
d="M70 86 C 64 96, 64 104, 70 114"
|
||||||
|
stroke="url(#bfBraceGrad)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="bf-landing-pad"
|
||||||
|
d="M130 86 C 136 96, 136 104, 130 114"
|
||||||
|
stroke="url(#bfBraceGrad)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-landing-mark">
|
||||||
|
<div className="bf-landing-wordmark">
|
||||||
|
Brace<span className="bf-brand-accent">iQ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="bf-landing-slogan">
|
||||||
|
Guided support design, from imaging to fabrication.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bf-landing-progress" aria-hidden="true">
|
||||||
|
<span className="bf-landing-progress-bar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
584
frontend/src/pages/LandmarkCapturePage.tsx
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { SubmitLandmarksRequest } from "../lib/api";
|
||||||
|
import { LandmarkCanvas } from "../components/LandmarkCanvas";
|
||||||
|
import type { Point } from "../components/LandmarkCanvas";
|
||||||
|
|
||||||
|
const API_BASE = "https://cfx9z50wj2.execute-api.ca-central-1.amazonaws.com/prod";
|
||||||
|
|
||||||
|
type AnyObj = Record<string, any>;
|
||||||
|
|
||||||
|
async function httpJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const base = API_BASE.replace(/\/+$/, "");
|
||||||
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
const url = `${base}${normalizedPath}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicS3Url(bucket: string, key: string) {
|
||||||
|
return `https://${bucket}.s3.amazonaws.com/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryResolveApUrlFromApi(caseId: string): Promise<string | null> {
|
||||||
|
const candidates: Array<{
|
||||||
|
method: "GET" | "POST";
|
||||||
|
path: string;
|
||||||
|
body?: any;
|
||||||
|
pickUrl: (json: AnyObj) => string | null;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/cases/${encodeURIComponent(caseId)}/xray-url?view=ap`,
|
||||||
|
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/cases/${encodeURIComponent(caseId)}/xray-preview?view=ap`,
|
||||||
|
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/cases/${encodeURIComponent(caseId)}/download-url?type=xray&view=ap`,
|
||||||
|
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/cases/${encodeURIComponent(caseId)}/download-url`,
|
||||||
|
body: { type: "xray", view: "ap" },
|
||||||
|
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/cases/${encodeURIComponent(caseId)}/file-url`,
|
||||||
|
body: { kind: "xray", view: "ap" },
|
||||||
|
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
const json =
|
||||||
|
c.method === "GET"
|
||||||
|
? await httpJson<AnyObj>(c.path)
|
||||||
|
: await httpJson<AnyObj>(c.path, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(c.body ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = c.pickUrl(json);
|
||||||
|
if (url && typeof url === "string") return url;
|
||||||
|
} catch {
|
||||||
|
// ignore and continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetHasAp(assets: AnyObj | null): boolean {
|
||||||
|
if (!assets) return false;
|
||||||
|
const xr = assets.xrays ?? assets.assets?.xrays;
|
||||||
|
|
||||||
|
if (Array.isArray(xr)) return xr.includes("ap");
|
||||||
|
if (xr && typeof xr === "object") return !!xr.ap;
|
||||||
|
if (typeof assets.apImageUrl === "string" && assets.apImageUrl) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickApUrlFromAssets(assets: AnyObj | null): string | null {
|
||||||
|
if (!assets) return null;
|
||||||
|
const a = assets.assets ?? assets;
|
||||||
|
|
||||||
|
if (typeof a.apImageUrl === "string" && a.apImageUrl) return a.apImageUrl;
|
||||||
|
if (typeof a.xrays?.ap === "string" && a.xrays.ap) return a.xrays.ap;
|
||||||
|
|
||||||
|
const apObj = a?.ap || a?.xrays?.ap || a?.assets?.xrays?.ap;
|
||||||
|
if (apObj && apObj.bucket && apObj.key) return buildPublicS3Url(apObj.bucket, apObj.key);
|
||||||
|
|
||||||
|
const apUrl = a?.xrays?.ap?.url || a?.xrays?.ap?.downloadUrl || a?.xrays?.ap?.imageUrl;
|
||||||
|
if (typeof apUrl === "string" && apUrl) return apUrl;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonPretty(v: any) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(v, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtifactTab = { n: number; label: string; path: string };
|
||||||
|
type ArtifactState = { loading: boolean; error: string | null; json: any | null; lastLoadedAt?: number };
|
||||||
|
|
||||||
|
function buildArtifactUrl(caseId: string, path: string) {
|
||||||
|
return `https://braceflow-uploads-20260125.s3.ca-central-1.amazonaws.com/cases/${encodeURIComponent(caseId)}/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LandmarkCapturePage() {
|
||||||
|
const { caseId } = useParams();
|
||||||
|
const nav = useNavigate();
|
||||||
|
const id = (caseId || "").trim();
|
||||||
|
|
||||||
|
const [assets, setAssets] = useState<AnyObj | null>(null);
|
||||||
|
const [assetsLoaded, setAssetsLoaded] = useState(false);
|
||||||
|
|
||||||
|
const [imageUrl, setImageUrl] = useState<string>("");
|
||||||
|
const [imageLoading, setImageLoading] = useState(false);
|
||||||
|
const [imageError, setImageError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [manualUrl, setManualUrl] = useState<string>("");
|
||||||
|
|
||||||
|
// ✅ FIX: was Point[]; must be Record<string, Point> to match LandmarkCanvas + SubmitLandmarksRequest
|
||||||
|
const [landmarks, setLandmarks] = useState<Record<string, Point>>({});
|
||||||
|
const [completed, setCompleted] = useState(false);
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// --- Artifacts slide panel ---
|
||||||
|
const [artifactsOpen, setArtifactsOpen] = useState(false);
|
||||||
|
const [activeArtifactIdx, setActiveArtifactIdx] = useState(0);
|
||||||
|
const [artifactStateByIdx, setArtifactStateByIdx] = useState<Record<number, ArtifactState>>({});
|
||||||
|
|
||||||
|
const artifactTabs: ArtifactTab[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ n: 1, label: "1", path: "step1_normalized/meta.json" },
|
||||||
|
{ n: 2, label: "2", path: "step2_measurements/landmarks.json" },
|
||||||
|
{ n: 3, label: "3", path: "step2_measurements/measurements.json" },
|
||||||
|
{ n: 4, label: "4", path: "step3_rigo/classification.json" },
|
||||||
|
{ n: 5, label: "5", path: "step4_template/template.json" },
|
||||||
|
{ n: 6, label: "6", path: "step5_deformation/brace_spec.json" },
|
||||||
|
{ n: 7, label: "7", path: "step6_export/print_manifest.json" },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const apExists = useMemo(() => assetHasAp(assets), [assets]);
|
||||||
|
|
||||||
|
const codeBoxStyle: React.CSSProperties = {
|
||||||
|
border: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
background: "rgba(0,0,0,0.20)",
|
||||||
|
overflow: "auto",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkStyle: React.CSSProperties = {
|
||||||
|
textDecoration: "underline",
|
||||||
|
fontWeight: 700,
|
||||||
|
opacity: 0.95,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadAssetsAndResolveImage() {
|
||||||
|
if (!id) {
|
||||||
|
setMsg("No case id in route.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssetsLoaded(false);
|
||||||
|
setMsg(null);
|
||||||
|
setImageError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const a = await httpJson<AnyObj>(`/cases/${encodeURIComponent(id)}/assets`);
|
||||||
|
setAssets(a);
|
||||||
|
|
||||||
|
const direct = pickApUrlFromAssets(a);
|
||||||
|
if (direct) {
|
||||||
|
setImageLoading(true);
|
||||||
|
setImageUrl(direct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetHasAp(a)) {
|
||||||
|
const resolved = await tryResolveApUrlFromApi(id);
|
||||||
|
if (resolved) {
|
||||||
|
setImageLoading(true);
|
||||||
|
setImageUrl(resolved);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageUrl("");
|
||||||
|
setImageError(
|
||||||
|
"AP x-ray exists (assets.xrays includes 'ap') but no viewable image URL was returned. " +
|
||||||
|
"Backend likely needs a presigned GET/preview endpoint (e.g., /cases/{caseId}/xray-url?view=ap). " +
|
||||||
|
"Use the manual URL field below as a temporary workaround."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageUrl("");
|
||||||
|
setImageError(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
setMsg(e?.message || "Failed to load assets");
|
||||||
|
} finally {
|
||||||
|
setAssetsLoaded(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArtifactAt(idx: number) {
|
||||||
|
if (!id) {
|
||||||
|
setArtifactStateByIdx((p) => ({
|
||||||
|
...p,
|
||||||
|
[idx]: { loading: false, error: "Missing caseId in route.", json: null },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = artifactTabs[idx];
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
setArtifactStateByIdx((p) => ({
|
||||||
|
...p,
|
||||||
|
[idx]: { ...(p[idx] ?? { json: null, error: null }), loading: true, error: null },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const url = buildArtifactUrl(id, tab.path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
setArtifactStateByIdx((p) => ({
|
||||||
|
...p,
|
||||||
|
[idx]: { loading: false, error: null, json, lastLoadedAt: Date.now() },
|
||||||
|
}));
|
||||||
|
} catch (e: any) {
|
||||||
|
setArtifactStateByIdx((p) => ({
|
||||||
|
...p,
|
||||||
|
[idx]: { loading: false, error: e?.message || "Failed to load JSON", json: null },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAssetsAndResolveImage();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!artifactsOpen) return;
|
||||||
|
const st = artifactStateByIdx[activeArtifactIdx];
|
||||||
|
if (!st || (!st.loading && st.json == null && st.error == null)) {
|
||||||
|
loadArtifactAt(activeArtifactIdx);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [artifactsOpen, activeArtifactIdx]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!artifactsOpen) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setArtifactsOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [artifactsOpen]);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!id) {
|
||||||
|
setMsg("No case id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const body: SubmitLandmarksRequest = { caseId: id, view: "ap", landmarks };
|
||||||
|
const res = await api.submitLandmarks(id, body);
|
||||||
|
|
||||||
|
if (res?.ok) {
|
||||||
|
setMsg("Landmarks submitted. Pipeline should resume from Step2.");
|
||||||
|
nav(`/cases/${encodeURIComponent(id)}/status`);
|
||||||
|
} else {
|
||||||
|
setMsg("Submission completed but response did not include ok=true.");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setMsg(e?.message || "Failed to submit landmarks");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTab = artifactTabs[activeArtifactIdx];
|
||||||
|
const activeUrl = activeTab && id ? buildArtifactUrl(id, activeTab.path) : "";
|
||||||
|
const activeState = artifactStateByIdx[activeArtifactIdx] ?? { loading: false, error: null, json: null };
|
||||||
|
const canSubmit = completed && !submitting && !!imageUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bf-page bf-page--wide">
|
||||||
|
<div className="bf-page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="bf-page-title">Landmark Capture</h1>
|
||||||
|
<p className="bf-page-subtitle">
|
||||||
|
Place landmarks on the AP X-ray, then submit to resume the pipeline.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bf-spacer" />
|
||||||
|
<div className="bf-toolbar">
|
||||||
|
<button className="btn secondary" onClick={() => setArtifactsOpen(true)}>
|
||||||
|
Artifacts
|
||||||
|
</button>
|
||||||
|
<button className="btn secondary" onClick={() => nav(`/cases/${encodeURIComponent(id)}/status`)}>
|
||||||
|
View Status
|
||||||
|
</button>
|
||||||
|
<button className="btn secondary" onClick={() => loadAssetsAndResolveImage()}>
|
||||||
|
Reload Assets
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" disabled={!canSubmit} onClick={onSubmit}>
|
||||||
|
{submitting ? "Submitting..." : "Submit Landmarks"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="muted muted--tight">
|
||||||
|
Case: <strong>{id || "(missing)"}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && <div className="notice">{msg}</div>}
|
||||||
|
|
||||||
|
{!assetsLoaded ? (
|
||||||
|
<div className="muted">Loading assets...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bf-row-gap-16">
|
||||||
|
<div className="bf-flex-1">
|
||||||
|
{imageUrl ? (
|
||||||
|
<div>
|
||||||
|
<div className="bf-mb-8">
|
||||||
|
{imageLoading && <div className="muted">Loading image…</div>}
|
||||||
|
{imageError && <div className="error">{imageError}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ============================
|
||||||
|
Thumbnail on top + Workspace below
|
||||||
|
============================ */}
|
||||||
|
<div className="lc-stack">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="lc-thumbRow">
|
||||||
|
<div className="lc-thumbCol">
|
||||||
|
<div className="lc-thumbBox">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="AP x-ray thumbnail"
|
||||||
|
onLoad={() => {
|
||||||
|
setImageLoading(false);
|
||||||
|
setImageError(null);
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
setImageLoading(false);
|
||||||
|
setImageError(
|
||||||
|
"Image failed to load. Most common causes: (1) URL is not public/presigned, (2) S3 CORS blocks browser, (3) URL points to a DICOM (not browser-renderable)."
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lc-thumbActions">
|
||||||
|
<button className="btn secondary" onClick={() => window.open(imageUrl, "_blank")}>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setImageLoading(true);
|
||||||
|
setImageError(null);
|
||||||
|
setImageUrl(imageUrl);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageLoading && <div className="muted">Loading image…</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace */}
|
||||||
|
<div className="lc-workspace">
|
||||||
|
<div className="lc-workspace-title muted">Landmark capture</div>
|
||||||
|
|
||||||
|
{/* IMPORTANT: do NOT wrap LandmarkCanvas in a 250x250 box.
|
||||||
|
We will constrain ONLY the image holder via CSS. */}
|
||||||
|
<div className="lc-workspace-body">
|
||||||
|
<LandmarkCanvas
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
initialLandmarks={landmarks}
|
||||||
|
onChange={(lm, done) => {
|
||||||
|
setLandmarks(lm);
|
||||||
|
setCompleted(done);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* ============================ */}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bf-dashed-panel">
|
||||||
|
<div className="muted">
|
||||||
|
{apExists
|
||||||
|
? "AP x-ray exists for this case, but no viewable URL is available yet."
|
||||||
|
: "AP image not available for this case."}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-mt-12">
|
||||||
|
<div className="muted bf-mb-6">
|
||||||
|
Temporary workaround: paste a viewable image URL (presigned GET to a JPG/PNG).
|
||||||
|
</div>
|
||||||
|
<div className="row gap">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="https://... (presigned GET to preview image)"
|
||||||
|
value={manualUrl}
|
||||||
|
onChange={(e) => setManualUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
if (!manualUrl.trim()) return;
|
||||||
|
setImageLoading(true);
|
||||||
|
setImageError(null);
|
||||||
|
setImageUrl(manualUrl.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-mt-12">
|
||||||
|
<button className="btn" onClick={() => nav(`/cases/${encodeURIComponent(id)}/xray`)}>
|
||||||
|
Upload AP X-ray
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageError && (
|
||||||
|
<div className="error bf-mt-12">
|
||||||
|
{imageError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ==========================
|
||||||
|
Slide-in Artifacts Drawer
|
||||||
|
========================== */}
|
||||||
|
<div
|
||||||
|
className={`bf-drawer-backdrop ${artifactsOpen ? "is-open" : ""}`}
|
||||||
|
onClick={() => setArtifactsOpen(false)}
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside className={`bf-drawer ${artifactsOpen ? "is-open" : ""}`} aria-hidden={!artifactsOpen}>
|
||||||
|
<div className="bf-drawer-header">
|
||||||
|
<div className="bf-col-tight">
|
||||||
|
<div className="bf-drawer-title">Artifacts</div>
|
||||||
|
<div className="bf-drawer-subtitle">Case: {id || "(missing)"}</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn secondary" onClick={() => setArtifactsOpen(false)}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-tabs">
|
||||||
|
{artifactTabs.map((t, idx) => (
|
||||||
|
<button
|
||||||
|
key={t.n}
|
||||||
|
className={`bf-tab ${idx === activeArtifactIdx ? "is-active" : ""}`}
|
||||||
|
onClick={() => setActiveArtifactIdx(idx)}
|
||||||
|
title={t.path}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-drawer-body">
|
||||||
|
<div className="row space center">
|
||||||
|
<div className="bf-col-tight">
|
||||||
|
<div className="bf-strong">Artifact {activeTab?.label}</div>
|
||||||
|
<div className="muted muted--small bf-ellipsis">
|
||||||
|
{activeTab?.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-row-end-wrap">
|
||||||
|
<button className="btn secondary" onClick={() => window.open(activeUrl, "_blank")} disabled={!activeUrl}>
|
||||||
|
Open JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => loadArtifactAt(activeArtifactIdx)}
|
||||||
|
disabled={!id || activeState.loading}
|
||||||
|
>
|
||||||
|
{activeState.loading ? "Loading…" : "Reload"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeState.error && (
|
||||||
|
<div className="error bf-mt-10">
|
||||||
|
{activeState.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bf-mt-12">
|
||||||
|
<pre style={codeBoxStyle}>
|
||||||
|
{activeState.loading ? "Loading…" : activeState.json ? jsonPretty(activeState.json) : "(not available yet)"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-mt-14">
|
||||||
|
<div className="muted muted--small bf-mb-6">
|
||||||
|
Assets (debug)
|
||||||
|
</div>
|
||||||
|
<pre style={codeBoxStyle}>{jsonPretty(assets ?? {})}</pre>
|
||||||
|
<div className="bf-mt-10">
|
||||||
|
<div className="muted">AP exists: {String(apExists)}</div>
|
||||||
|
<div className="muted">Image URL resolved: {imageUrl ? "yes" : "no"}</div>
|
||||||
|
{activeUrl && (
|
||||||
|
<div className="muted">
|
||||||
|
URL:{" "}
|
||||||
|
<a style={linkStyle} href={activeUrl} target="_blank" rel="noreferrer">
|
||||||
|
{activeUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
121
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { login, isAuthenticated, error, clearError } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Get the redirect path from state, or default to "/"
|
||||||
|
const from = (location.state as any)?.from?.pathname || "/";
|
||||||
|
|
||||||
|
// Redirect if already logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate, from]);
|
||||||
|
|
||||||
|
// Clear errors when inputs change
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
clearError();
|
||||||
|
}
|
||||||
|
}, [username, password]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!username.trim() || !password.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch {
|
||||||
|
// Error is handled by AuthContext
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bf-login-page">
|
||||||
|
<div className="bf-login-container">
|
||||||
|
{/* Logo/Brand */}
|
||||||
|
<div className="bf-login-header">
|
||||||
|
<h1 className="bf-login-brand">
|
||||||
|
Brace<span className="bf-brand-accent">iQ</span>
|
||||||
|
</h1>
|
||||||
|
<p className="bf-login-subtitle">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Form */}
|
||||||
|
<form className="bf-login-form" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="bf-login-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bf-form-group">
|
||||||
|
<label htmlFor="username" className="bf-form-label">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
className="bf-form-input"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter your username"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bf-form-group">
|
||||||
|
<label htmlFor="password" className="bf-form-label">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
className="bf-form-input"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bf-login-btn"
|
||||||
|
disabled={isSubmitting || !username.trim() || !password.trim()}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<span className="bf-login-spinner"></span>
|
||||||
|
Signing in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Sign In"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||