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

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

21
.env.example Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

21
api/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

@@ -0,0 +1,80 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Node deps
node_modules/
# Build output
dist/
build/
.vite/
# Environment files (sensitive)
.env
.env.local
.env.*.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.log
# VCS and tooling
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
# OS files
.DS_Store
Thumbs.db
# Editor / IDE history and temp
*.swp
*~
.history/
# Test & coverage
coverage/
.nyc_output/
jest.cache
# Caches
.cache/
.parcel-cache/
.pnp.*
.eslintcache
# Serverless / local dev artifacts
.serverless/
.firebase/
# Packages / artifacts
*.tgz
# Optional: lockfiles should be committed (do NOT ignore package-lock.json / yarn.lock)

View File

@@ -0,0 +1,382 @@
# Brace Generator Integration Guide
## Overview
This document describes how the GPU-based brace generator is integrated into the Braceflow frontend via AWS Lambda functions.
## Architecture
```
Frontend (React)
├── Upload X-ray
│ └── POST /cases → Create case
│ └── POST /cases/{caseId}/upload-url → Get presigned URL
│ └── PUT to S3 → Upload file directly
└── Generate Brace
└── POST /cases/{caseId}/generate-brace
└── Lambda: braceflow_invoke_brace_generator
└── Download image from S3
└── Call EC2 GPU server /analyze/upload
└── Download outputs from GPU server
└── Upload outputs to S3
└── Update database with results
└── Return results with presigned URLs
```
## API Endpoints
### 1. Create Case
```
POST /cases
```
**Response:**
```json
{
"caseId": "case-20260125-abc123"
}
```
### 2. Get Upload URL
```
POST /cases/{caseId}/upload-url
```
**Request Body:**
```json
{
"filename": "ap.jpg",
"contentType": "image/jpeg"
}
```
**Response:**
```json
{
"url": "https://braceflow-uploads-xxx.s3.amazonaws.com/...",
"s3Key": "cases/case-xxx/input/ap.jpg"
}
```
### 3. Generate Brace
```
POST /cases/{caseId}/generate-brace
```
**Request Body:**
```json
{
"experiment": "experiment_3",
"config": {
"brace_height_mm": 400,
"torso_width_mm": 280,
"torso_depth_mm": 200
}
}
```
**Response:**
```json
{
"caseId": "case-20260125-abc123",
"status": "brace_generated",
"experiment": "experiment_3",
"model": "ScolioVis",
"vertebrae_detected": 17,
"cobb_angles": {
"PT": 12.5,
"MT": 28.3,
"TL": 15.2
},
"curve_type": "S-shaped",
"rigo_classification": {
"type": "A3",
"description": "Major thoracic with compensatory lumbar"
},
"mesh": {
"vertices": 6204,
"faces": 12404
},
"outputs": {
"stl": { "s3Key": "cases/.../brace.stl", "url": "https://..." },
"ply": { "s3Key": "cases/.../brace.ply", "url": "https://..." },
"visualization": { "s3Key": "cases/.../viz.png", "url": "https://..." },
"landmarks": { "s3Key": "cases/.../landmarks.json", "url": "https://..." }
},
"processing_time_ms": 3250
}
```
### 4. Get Brace Outputs
```
GET /cases/{caseId}/brace-outputs
```
**Response:**
```json
{
"caseId": "case-20260125-abc123",
"status": "brace_generated",
"analysis": {
"experiment": "experiment_3",
"model": "ScolioVis",
"cobb_angles": { "PT": 12.5, "MT": 28.3, "TL": 15.2 },
"curve_type": "S-shaped",
"rigo_classification": { "type": "A3", "description": "..." }
},
"outputs": [
{
"filename": "brace.stl",
"type": "stl",
"s3Key": "cases/.../brace.stl",
"size": 1234567,
"url": "https://...",
"expiresIn": 3600
}
]
}
```
---
## Frontend Implementation
### API Client (`src/api/braceflowApi.ts`)
The API client includes these functions:
```typescript
// Create a new case
export async function createCase(body?: { notes?: string }): Promise<{ caseId: string }>;
// Get presigned URL for S3 upload
export async function getUploadUrl(caseId: string, filename: string, contentType: string):
Promise<{ url: string; s3Key: string }>;
// Upload file directly to S3
export async function uploadToS3(presignedUrl: string, file: File): Promise<void>;
// Invoke brace generator Lambda
export async function generateBrace(caseId: string, options?: {
experiment?: string;
config?: Record<string, unknown>
}): Promise<GenerateBraceResponse>;
// Get brace outputs with presigned URLs
export async function getBraceOutputs(caseId: string): Promise<BraceOutputsResponse>;
// Full workflow helper
export async function analyzeXray(file: File, options?: {
experiment?: string;
config?: Record<string, unknown>
}): Promise<{ caseId: string; result: GenerateBraceResponse }>;
```
### Types
```typescript
export type CobbAngles = {
PT?: number;
MT?: number;
TL?: number;
};
export type RigoClassification = {
type: string;
description: string;
curve_pattern?: string;
};
export type AnalysisResult = {
experiment?: string;
model?: string;
vertebrae_detected?: number;
cobb_angles?: CobbAngles;
curve_type?: string;
rigo_classification?: RigoClassification;
mesh_info?: { vertices?: number; faces?: number };
outputs?: Record<string, { s3Key: string; url: string }>;
processing_time_ms?: number;
};
export type BraceOutput = {
filename: string;
type: "stl" | "ply" | "obj" | "image" | "json" | "other";
s3Key: string;
size: number;
url: string;
expiresIn: number;
};
```
---
## Routes
| Route | Page | Description |
|-------|------|-------------|
| `/analyze` | BraceAnalysisPage | New analysis with X-ray upload |
| `/cases/:caseId/analysis` | BraceAnalysisPage | View existing case analysis |
| `/generate` | ShellGenerationPage | Direct brace generation (legacy) |
---
## Page: BraceAnalysisPage
Located at `src/pages/BraceAnalysisPage.tsx`
### Features
1. **Upload Panel** - Drag-and-drop X-ray upload
2. **3D Viewer** - Interactive brace model preview
3. **Analysis Results** - Displays:
- Overall severity assessment
- Curve type classification
- Cobb angles (PT, MT, TL)
- Rigo-Chêneau classification
- Mesh information
4. **Downloads** - All generated files with presigned S3 URLs
### Layout
Three-column layout:
- Left: Upload panel with case ID display
- Center: 3D brace viewer with processing info
- Right: Analysis results and download links
---
## Components
### Reusable Components
| Component | Location | Description |
|-----------|----------|-------------|
| `UploadPanel` | `src/components/rigo/UploadPanel.tsx` | Drag-and-drop file upload |
| `BraceViewer` | `src/components/rigo/BraceViewer.tsx` | 3D model viewer (React Three Fiber) |
| `AnalysisResults` | `src/components/rigo/AnalysisResults.tsx` | Analysis display component |
---
## Lambda Functions
### braceflow_invoke_brace_generator
Located at: `braceflow_lambda/braceflow_invoke_brace_generator/index.mjs`
**Process:**
1. Validate environment and request
2. Get case from database
3. Update status to `processing_brace`
4. Download X-ray from S3
5. Call GPU server `/analyze/upload`
6. Download outputs from GPU server `/download/{caseId}/{filename}`
7. Upload outputs to S3
8. Update database with analysis results
9. Return results with presigned URLs
**Environment Variables:**
- `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`
- `BRACE_GENERATOR_URL` - EC2 GPU server URL
- `BUCKET_NAME` - S3 bucket name
### braceflow_get_brace_outputs
Located at: `braceflow_lambda/braceflow_get_brace_outputs/index.mjs`
**Process:**
1. Get case from database
2. List files in S3 `cases/{caseId}/outputs/`
3. Generate presigned URLs for each file
4. Return files list with analysis data
---
## S3 Structure
```
braceflow-uploads-{date}/
├── cases/
│ └── {caseId}/
│ ├── input/
│ │ └── ap.jpg # Original X-ray
│ └── outputs/
│ ├── brace_{caseId}.stl # 3D printable model
│ ├── brace_{caseId}_adaptive.ply # Adaptive mesh
│ ├── brace_{caseId}.png # Visualization
│ └── brace_{caseId}.json # Landmarks data
```
---
## Database Schema
```sql
CREATE TABLE brace_cases (
case_id VARCHAR(64) PRIMARY KEY,
status ENUM('created', 'processing_brace', 'brace_generated', 'brace_failed'),
current_step VARCHAR(64),
analysis_result JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
---
## Deployment
### Deploy Lambda Functions
```powershell
cd braceflow_lambda/deploy
.\deploy-brace-generator-lambdas.ps1 -BraceGeneratorUrl "http://YOUR_EC2_IP:8000"
```
### Add API Gateway Routes
```
POST /cases/{caseId}/generate-brace → braceflow_invoke_brace_generator
GET /cases/{caseId}/brace-outputs → braceflow_get_brace_outputs
```
---
## Running Locally
### Frontend
```bash
cd braceflow
npm install
npm run dev
# Open http://localhost:5173
```
Navigate to `/analyze` to use the new brace analysis page.
### Testing
1. Go to http://localhost:5173/analyze
2. Upload an X-ray image
3. Wait for analysis to complete
4. View results and download files
---
## Troubleshooting
### Common Issues
1. **CORS errors**: Ensure API Gateway has CORS configured
2. **Timeout errors**: Lambda timeout is 120s, may need increase for large images
3. **S3 access denied**: Check Lambda role has S3 permissions
4. **GPU server unreachable**: Check EC2 security group allows port 8000
### Checking Lambda Logs
```bash
aws logs tail /aws/lambda/braceflow_invoke_brace_generator --follow
```

37
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# ============================================
# BraceIQMed Frontend - React + Vite + nginx
# ============================================
# Stage 1: Build the React app
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the app (uses relative API URLs)
ENV VITE_API_URL=""
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Create log directory
RUN mkdir -p /var/log/nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

9
frontend/cors.json Normal file
View File

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

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BraceiQ</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

61
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,61 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
# Increase max body size for file uploads
client_max_body_size 100M;
# Proxy API requests to the API container
location /api/ {
proxy_pass http://api:3002/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Proxy file requests to the API container
location /files/ {
proxy_pass http://api:3002/files/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
# Serve static assets with caching
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback - serve index.html for all other routes
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 'OK';
add_header Content-Type text/plain;
}
}

3689
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "braceflow-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.0.0",
"@react-three/fiber": "^9.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0",
"three": "^0.170.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.170.0",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "npm:rolldown-vite@7.2.5"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}

12
frontend/policy.json Normal file
View File

@@ -0,0 +1,12 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::braceflow-ui-www/*"
}
]
}

View File

@@ -0,0 +1,18 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Title</title>
</head>
<body>
This window will close in 2 seconds. You can close it manually if it doesn't.
<script type="text/javascript">
setTimeout(function(){
window.close();
}, 2000);
</script>
</body>
</html>

View File

@@ -0,0 +1,507 @@
* {
font: 13px 'Open Sans', sans-serif;
color: #bbb;
font-weight: 400;
box-sizing: border-box;
border: 0;
margin: 0;
padding: 0;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
body {
overflow: hidden;
margin: 0;
}
#tablet-plugin {
position: absolute;
top: -1000px;
}
#viewport {
position: absolute;
left: 310px; /* Add left margin to account for sidebar on the left */
right: 0;
top: 0;
bottom: 0;
}
#canvas {
width: 100%;
height: 100%;
display: block;
}
/****** SIDE BAR ******/
.gui-sidebar {
position: absolute;
bottom: 0;
top: 0;
padding-bottom: 20px;
width: 310px;
background: #3c3c3c;
overflow-x: hidden;
overflow-y: auto;
border-right: double;
border-width: 4px;
border-color: rgba(255, 255, 255, 0.3);
}
.gui-sidebar::-webkit-scrollbar {
width: 7px;
background: rgba(0, 0, 0, 0.3);
}
.gui-sidebar::-webkit-scrollbar-thumb {
border-radius: 2px;
background: rgba(255, 255, 255, 0.2);
}
.gui-sidebar::-webkit-scrollbar-corner {
height: 0;
display: none;
}
.gui-resize {
cursor: ew-resize;
position: absolute;
left: 310px;
top: 0;
bottom: 0;
width: 10px;
margin-left: -3px;
margin-right: -3px;
opacity: 0;
}
/****** folder ******/
.gui-sidebar > ul > label {
font-size: 15px;
font-weight: 600;
color: #999;
position: relative;
display: block;
line-height: 30px;
margin: 5px 0 5px 0;
text-transform: uppercase;
cursor: pointer;
vertical-align: middle;
text-align: center;
background: rgba(0, 0, 0, 0.3);
}
.gui-sidebar > ul[opened=true] > label:before {
content: '▼';
text-indent: 1em;
float: left;
}
.gui-sidebar > ul[opened=false] > label:before {
content: '►';
text-indent: 1em;
float: left;
}
.gui-sidebar > ul {
display: block;
list-style: none;
overflow: hidden;
-webkit-transition: max-height 0.3s ease;
-moz-transition: max-height 0.3s ease;
-ms-transition: max-height 0.3s ease;
-o-transition: max-height 0.3s ease;
transition: max-height 0.3s ease;
}
.gui-sidebar > ul[opened=true] {
max-height: 700px;
}
.gui-sidebar > ul[opened=false] {
height: 35px;
max-height: 35px;
}
.gui-sidebar > ul > li {
height: 22px;
margin: 4px 5px 4px 5px;
}
.gui-glowOnHover:hover {
background: rgba(0, 0, 0, 0.2);
}
.gui-pointerOnHover:hover {
cursor: pointer;
}
/****** label ******/
.gui-label-side {
position: relative;
display: inline-block;
height: 100%;
width: 36%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/****** checkbox ******/
.gui-input-checkbox {
display: none;
}
.gui-input-checkbox + label {
float: right;
cursor: pointer;
position: relative;
border: 1px solid;
border-radius: 4px;
margin-top: 2px;
width: 18px;
height: 18px;
}
.gui-input-checkbox + label::before {
position: absolute;
top: -5px;
left: 5px;
height: 14px;
width: 6px;
border-right: 2px solid;
border-bottom: 2px solid;
-webkit-transform: rotate(60deg) skew(25deg, 0);
-ms-transform: rotate(60deg) skew(25deg, 0);
transform: rotate(60deg) skew(25deg, 0);
}
.gui-input-checkbox:checked + label::before {
content: '';
}
/****** input number ******/
.gui-input-number {
-moz-appearance: textfield;
float: right;
position: relative;
width: 10%;
height: 100%;
margin-left: 2%;
text-align: center;
outline: none;
font-size: 10px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.3);
}
.gui-widget-color > input::-webkit-inner-spin-button,
.gui-widget-color > input::-webkit-outer-spin-button,
.gui-input-number::-webkit-inner-spin-button,
.gui-input-number::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/****** input on hover ******/
.gui-slider:hover,
.gui-input-number:hover {
background: rgba(0, 0, 0, 0.4);
}
/****** slider ******/
.gui-slider {
-webkit-appearance: none;
cursor: ew-resize;
float: right;
width: 52%;
height: 100%;
overflow: hidden;
border-radius: 4px;
background: rgba(0, 0, 0, 0.3);
}
.gui-slider > div {
height: 100%;
background: #525f63;
}
/****** button ******/
.gui-button {
-webkit-appearance: none;
float: right;
cursor: pointer;
position: relative;
display: inline-block;
text-align: center;
width: 100%;
height: 100%;
outline: none;
border-radius: 4px;
background: #525f63;
}
.gui-button::-moz-focus-inner {
padding: 0 !important;
border: 0 none !important;
}
.gui-button:enabled:hover {
color: #fff;
}
.gui-button:active {
box-shadow: 0 1px 0 hsla(0, 0%, 100%, .1), inset 0 1px 4px hsla(0, 0%, 0%, .8);
}
.gui-button:disabled {
background: #444;
color: #555;
}
/****** widget color ******/
.gui-widget-color {
float: right;
display: block;
width: 64%;
height: 100%;
}
.gui-widget-color > input {
-moz-appearance: textfield;
position: relative;
display: inline;
width: 100%;
height: 100%;
text-align: center;
outline: none;
border-radius: 4px;
font-size: 13px;
background: #f00;
}
.gui-widget-color > input + div:hover,
.gui-widget-color > input:hover + div {
display: block;
pointer-events: auto;
}
.gui-widget-color > input + div {
display: none;
position: absolute;
pointer-events: none;
padding: 3px;
width: 125px;
height: 105px;
z-index: 2;
background: #111;
}
/* saturation */
.gui-color-saturation {
display: inline-block;
width: 100px;
height: 100px;
margin-right: 3px;
border: 1px solid #555;
cursor: pointer;
}
.gui-color-saturation > div {
width: 100%;
height: 100%;
pointer-events: none;
border: none;
background: none;
}
.gui-knob-saturation {
position: absolute;
pointer-events: none;
width: 10px;
height: 10px;
z-index: 4;
border: #fff;
border-radius: 10px;
border: 2px solid white;
}
/* hue*/
.gui-color-hue {
display: inline-block;
width: 15px;
height: 100px;
border: 1px solid #555;
cursor: ns-resize;
}
.gui-knob-hue {
pointer-events: none;
position: absolute;
width: 15px;
height: 2px;
border-right: 4px solid #fff;
}
/* alpha */
.gui-color-alpha {
display: inline-block;
margin-left: 3px;
height: 100px;
width: 15px;
border: 1px solid #555;
cursor: ns-resize;
}
.gui-knob-alpha {
pointer-events: none;
position: absolute;
width: 15px;
height: 2px;
border-right: 4px solid #fff;
}
/****** select ******/
.gui-select {
float: right;
cursor: pointer;
position: relative;
display: inline-block;
width: 64%;
height: 100%;
padding-left: 1%;
outline: none;
background: #525f63;
border-radius: 4px;
}
.gui-select:hover {
color: #fff;
}
/****** TOP BAR ******/
.gui-topbar {
position: absolute;
background: #20211d;
width: 100%;
padding-right: 10px;
padding-left: 10px;
z-index: 1;
float: left;
}
.gui-topbar ul {
list-style-type: none;
padding: 0;
margin: 0;
}
.gui-topbar ul > li {
float: left;
line-height: 40px;
padding: 0 15px;
position: relative;
cursor: pointer;
}
.gui-topbar ul > li.gui-logo {
padding: 0 12px 0 0;
cursor: default;
}
.gui-topbar ul > li.gui-logo:hover {
color: inherit;
}
.gui-topbar ul > li.gui-logo img {
display: block;
height: 28px;
margin-top: 6px;
width: auto;
}
.gui-topbar ul > li .shortcut {
float: right;
font-style: oblique;
margin-right: 11px;
}
.gui-topbar ul > li:hover {
color: #fff;
}
.gui-topbar ul > li:hover > ul {
display: block;
opacity: 1;
pointer-events: auto;
top: 30px;
}
.gui-topbar ul > li > ul {
position: absolute;
top: 20px;
left: 10px;
background: #222;
width: 220px;
padding: 8px;
border-radius: 0 4px 4px 0;
pointer-events: none;
opacity: 0;
-webkit-transition: 0.15s all ease;
-ms-transition: 0.15s all ease;
-moz-transition: 0.15s all ease;
-o-transition: 0.15s all ease;
transition: 0.15s all ease;
}
.gui-topbar ul > li > ul > li {
float: none;
height: 22px;
line-height: 22px;
margin: 6px 0 6px 0;
padding-left: 5px;
}
.group-title {
font-size: 14px;
font-weight: 600;
color: #999 !important;
cursor: default !important;
text-align: center;
border-bottom: 1px solid #444444;
padding-bottom: 5px;
margin: 10px 0 10px 0;
}

View File

@@ -0,0 +1,47 @@
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<meta name='description' content='SculptGL is a small sculpting application powered by JavaScript and webGL.'>
<meta name='author' content='stéphane GINIER'>
<meta name='mobile-web-app-capable' content='yes'>
<meta name='apple-mobile-web-app-capable' content='yes'>
<title> BRACE iQ </title>
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'>
<link rel='stylesheet' href='css/yagui.css' type='text/css' />
<script>
'use strict';
window.sketchfabOAuth2Config = {
hostname: 'sketchfab.com',
client_id: 'OWoAmrd1QCS9wB54Ly17rMl2i5AHGvDNfmN4pEUH',
redirect_uri: 'https://stephaneginier.com/sculptgl/authSuccess.html'
};
window.addEventListener('load', function () {
var app = new window.SculptGL();
app.start();
});
</script>
</head>
<body oncontextmenu='return false;'>
<input type='file' id='fileopen' multiple style='display: none' />
<input type='file' id='backgroundopen' style='display: none' />
<input type='file' id='alphaopen' style='display: none' />
<input type='file' id='textureopen' style='display: none' />
<input type='file' id='matcapopen' style='display: none' />
<div id='viewport'>
<canvas id='canvas'></canvas>
</div>
<script src='sculptgl.js'></script>
<!-- <script src='//cdn.webglstats.com/stat.js' defer='defer' async='async'></script> -->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,77 @@
/*! Hammer.JS - v2.0.7 - 2016-04-22
* http://hammerjs.github.io/
*
* Copyright (c) 2016 Jorik Tangelder;
* Licensed under the MIT license */
/*! exports provided: default */
/*! no static exports found */
/*!**********************!*\
!*** ./src/yagui.js ***!
\**********************/
/*!************************!*\
!*** ./src/GuiMain.js ***!
\************************/
/*!******************************!*\
!*** ./src/widgets/Color.js ***!
\******************************/
/*!******************************!*\
!*** ./src/widgets/Title.js ***!
\******************************/
/*!*******************************!*\
!*** ./src/utils/GuiUtils.js ***!
\*******************************/
/*!*******************************!*\
!*** ./src/widgets/Button.js ***!
\*******************************/
/*!*******************************!*\
!*** ./src/widgets/Slider.js ***!
\*******************************/
/*!********************************!*\
!*** ./src/containers/Menu.js ***!
\********************************/
/*!********************************!*\
!*** ./src/utils/EditStyle.js ***!
\********************************/
/*!*********************************!*\
!*** ./src/widgets/Checkbox.js ***!
\*********************************/
/*!*********************************!*\
!*** ./src/widgets/Combobox.js ***!
\*********************************/
/*!**********************************!*\
!*** ./src/containers/Folder.js ***!
\**********************************/
/*!**********************************!*\
!*** ./src/containers/Topbar.js ***!
\**********************************/
/*!***********************************!*\
!*** ./src/containers/Sidebar.js ***!
\***********************************/
/*!***********************************!*\
!*** ./src/widgets/BaseWidget.js ***!
\***********************************/
/*!***********************************!*\
!*** ./src/widgets/MenuButton.js ***!
\***********************************/
/*!*****************************************!*\
!*** ./src/containers/BaseContainer.js ***!
\*****************************************/

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,147 @@
/* jshint worker:true */
(function main(global) {
"use strict";
if (global.zWorkerInitialized)
throw new Error('z-worker.js should be run only once');
global.zWorkerInitialized = true;
addEventListener("message", function(event) {
var message = event.data, type = message.type, sn = message.sn;
var handler = handlers[type];
if (handler) {
try {
handler(message);
} catch (e) {
onError(type, sn, e);
}
}
//for debug
//postMessage({type: 'echo', originalType: type, sn: sn});
});
var handlers = {
importScripts: doImportScripts,
newTask: newTask,
append: processData,
flush: processData,
};
// deflater/inflater tasks indexed by serial numbers
var tasks = {};
function doImportScripts(msg) {
if (msg.scripts && msg.scripts.length > 0)
importScripts.apply(undefined, msg.scripts);
postMessage({type: 'importScripts'});
}
function newTask(msg) {
var CodecClass = global[msg.codecClass];
var sn = msg.sn;
if (tasks[sn])
throw Error('duplicated sn');
tasks[sn] = {
codec: new CodecClass(msg.options),
crcInput: msg.crcType === 'input',
crcOutput: msg.crcType === 'output',
crc: new Crc32(),
};
postMessage({type: 'newTask', sn: sn});
}
// performance may not be supported
var now = global.performance ? global.performance.now.bind(global.performance) : Date.now;
function processData(msg) {
var sn = msg.sn, type = msg.type, input = msg.data;
var task = tasks[sn];
// allow creating codec on first append
if (!task && msg.codecClass) {
newTask(msg);
task = tasks[sn];
}
var isAppend = type === 'append';
var start = now();
var output;
if (isAppend) {
try {
output = task.codec.append(input, function onprogress(loaded) {
postMessage({type: 'progress', sn: sn, loaded: loaded});
});
} catch (e) {
delete tasks[sn];
throw e;
}
} else {
delete tasks[sn];
output = task.codec.flush();
}
var codecTime = now() - start;
start = now();
if (input && task.crcInput)
task.crc.append(input);
if (output && task.crcOutput)
task.crc.append(output);
var crcTime = now() - start;
var rmsg = {type: type, sn: sn, codecTime: codecTime, crcTime: crcTime};
var transferables = [];
if (output) {
rmsg.data = output;
transferables.push(output.buffer);
}
if (!isAppend && (task.crcInput || task.crcOutput))
rmsg.crc = task.crc.get();
postMessage(rmsg, transferables);
}
function onError(type, sn, e) {
var msg = {
type: type,
sn: sn,
error: formatError(e)
};
postMessage(msg);
}
function formatError(e) {
return { message: e.message, stack: e.stack };
}
// Crc32 code copied from file zip.js
function Crc32() {
this.crc = -1;
}
Crc32.prototype.append = function append(data) {
var crc = this.crc | 0, table = this.table;
for (var offset = 0, len = data.length | 0; offset < len; offset++)
crc = (crc >>> 8) ^ table[(crc ^ data[offset]) & 0xFF];
this.crc = crc;
};
Crc32.prototype.get = function get() {
return ~this.crc;
};
Crc32.prototype.table = (function() {
var i, j, t, table = []; // Uint32Array is actually slower than []
for (i = 0; i < 256; i++) {
t = i;
for (j = 0; j < 8; j++)
if (t & 1)
t = (t >>> 1) ^ 0xEDB88320;
else
t = t >>> 1;
table[i] = t;
}
return table;
})();
// "no-op" codec
function NOOP() {}
global.NOOP = NOOP;
NOOP.prototype.append = function append(bytes, onprogress) {
return bytes;
};
NOOP.prototype.flush = function flush() {};
})(this);

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

838
frontend/src/App.css Normal file
View File

@@ -0,0 +1,838 @@
/* #root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
*/
:root {
/* Typography */
font-family: Inter, system-ui, Arial;
font-size: 15px;
line-height: 1.45;
/* Core background */
--bg-main:
radial-gradient(
900px 520px at 86% 20%,
rgba(209, 118, 69, 0.12) 0%,
rgba(23, 27, 34, 0.35) 55%,
rgba(12, 15, 20, 0.0) 100%
),
radial-gradient(
900px 560px at 12% 86%,
rgba(96, 122, 155, 0.14) 0%,
rgba(18, 23, 30, 0.35) 55%,
rgba(12, 15, 20, 0.0) 100%
),
linear-gradient(180deg, #151a22 0%, #10141a 100%);
--bg-surface: rgba(255, 255, 255, 0.14);
--bg-surface-hover: rgba(255, 255, 255, 0.2);
--bg-surface-active: rgba(255, 255, 255, 0.26);
/* Text */
--text-main: #f1f5f9;
--text-muted: #9aa4b2;
/* Accents */
--accent-primary: #dd8250; /* copper (slightly brighter) */
--accent-secondary: #f3b886; /* warm copper (slightly brighter) */
--accent-success: #43c59e;
--accent-danger: #f37f7f;
/* Borders */
--border-soft: rgba(255, 255, 255, 0.11);
--border-strong: rgba(255, 255, 255, 0.24);
background: var(--bg-main);
color: var(--text-main);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
/* =========================================================
BraceFlow AppShell (isolated styles) — uses bf-* classes
========================================================= */
.bf-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.bf-header {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 16px;
flex-wrap: wrap;
align-content: center;
background: linear-gradient(
180deg,
rgba(249, 115, 22, 0.18),
rgba(15, 23, 42, 0.65)
);
border-bottom: 1px solid var(--border-soft);
backdrop-filter: blur(12px);
box-shadow:
0 14px 40px rgba(8, 12, 18, 0.55),
0 0 48px rgba(210, 225, 255, 0.08);
}
.bf-left {
display: flex;
align-items: center;
gap: 14px;
min-width: 0; /* allows nav to shrink properly */
}
.bf-brand {
font-weight: 800;
font-size: 19px;
letter-spacing: 0.2px;
cursor: pointer;
user-select: none;
white-space: nowrap;
display: inline-flex;
align-items: center;
color: var(--text-main);
transition: background 120ms ease, border-color 120ms ease;
}
.bf-brand-accent {
color: var(--accent-primary);
font-weight: 800;
}
.bf-brand:hover {
color: var(--text-main);
}
.bf-nav {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
}
.bf-nav-item {
padding: 6px 8px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
cursor: pointer;
outline: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
}
.bf-nav-item:hover:not(:disabled) {
color: var(--text-main);
background: rgba(209, 118, 69, 0.10);
border-color: rgba(209, 118, 69, 0.3);
}
.bf-nav-item:active:not(:disabled) {
transform: translateY(1px);
}
.bf-nav-item.is-active {
color: var(--text-main);
background: rgba(249, 115, 22, 0.16);
border-color: rgba(249, 115, 22, 0.45);
}
.bf-nav-item:disabled,
.bf-nav-item.is-disabled {
opacity: 0.35;
cursor: not-allowed;
}
.bf-right {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
}
.bf-case-context {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 4px;
}
.bf-case-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 10px;
border: 1px solid var(--border-soft);
background: var(--bg-surface);
color: var(--text-main);
max-width: 240px;
white-space: nowrap;
}
.bf-case-badge.is-empty {
background: transparent;
color: var(--text-muted);
}
.bf-case-label {
flex: 0 0 auto;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.35px;
color: var(--text-muted);
margin-top: 1px;
}
.bf-case-id {
flex: 1 1 auto;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
font-weight: 800;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bf-case-input {
height: 32px;
width: 150px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: rgba(0, 0, 0, 0.22);
color: var(--text-main);
font-size: 13px;
outline: none;
}
.bf-case-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.bf-case-input:focus {
border-color: var(--accent-primary);
background: rgba(0, 0, 0, 0.28);
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.25);
}
.bf-go {
height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--bg-surface);
color: var(--text-main);
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.bf-go:hover:not(:disabled) {
background: rgba(209, 118, 69, 0.14);
border-color: rgba(209, 118, 69, 0.35);
}
.bf-go:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.bf-stepper {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
padding: 4px 8px;
margin: 0 4px;
overflow-x: auto;
}
.bf-step {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
}
.bf-step:hover:not(:disabled) {
color: var(--text-main);
background: rgba(209, 118, 69, 0.10);
border-color: rgba(209, 118, 69, 0.3);
}
.bf-step.is-active {
color: var(--text-main);
background: rgba(249, 115, 22, 0.16);
border-color: rgba(249, 115, 22, 0.45);
}
.bf-step.is-complete {
color: var(--text-main);
}
.bf-step.is-disabled {
opacity: 0.45;
cursor: not-allowed;
}
.bf-step-dot {
width: 24px;
height: 24px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-soft);
background: rgba(255, 255, 255, 0.06);
color: var(--text-main);
font-size: 12px;
font-weight: 800;
}
.bf-step.is-active .bf-step-dot {
background: var(--accent-primary);
border-color: transparent;
color: #0b1020;
box-shadow: 0 8px 18px rgba(249, 115, 22, 0.35);
}
.bf-step.is-complete .bf-step-dot {
background: var(--accent-primary);
border-color: transparent;
color: #0b1020;
}
.bf-step-label {
font-size: 13px;
}
.bf-step-connector {
width: 26px;
height: 2px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
flex: 0 0 auto;
}
.bf-step-connector.is-complete {
background: rgba(249, 115, 22, 0.65);
}
.bf-content {
flex: 1;
padding: 20px 24px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.bf-content--landing {
padding: 0;
overflow: hidden;
align-items: stretch;
justify-content: center;
}
.bf-content--fade-in {
animation: bf-content-fade-in 560ms ease both;
}
@keyframes bf-content-fade-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Editor page needs less padding to maximize space */
.bf-content:has(.shell-editor-page) {
padding: 8px 12px;
}
/* Keep the Rigo shell page aligned with the standard page spacing */
.bf-content:has(.rigo-shell-page) {
padding: 20px 24px;
}
/* Optional: on small screens, keep header usable */
@media (max-width: 720px) {
.bf-case-input {
width: 150px;
}
.bf-nav {
gap: 6px;
}
.bf-nav-item {
padding: 0 10px;
}
}
@media (max-width: 1024px) {
.bf-left {
flex: 1 1 100%;
order: 1;
}
.bf-right {
flex: 1 1 100%;
order: 2;
justify-content: flex-start;
}
.bf-stepper {
flex: 1 1 100%;
order: 3;
flex-wrap: wrap;
justify-content: flex-start;
overflow-x: visible;
padding: 6px 0 0;
margin: 0;
}
.bf-step-connector {
display: none;
}
}
.app { min-height: 100vh; }
.header { padding: 18px 24px; border-bottom: 1px solid rgba(255,255,255,0.08); display:flex; gap:12px; align-items: baseline; }
.brand { font-weight: 700; letter-spacing: 0.3px; }
.subtitle { opacity: 0.7; }
.container { padding: 24px; max-width: 1200px; margin: 0 auto; }
.card {
background: var(--bg-surface);
border: 1px solid var(--border-soft);
border-radius: 14px;
padding: 18px;
box-shadow:
0 10px 30px rgba(10, 15, 35, 0.35),
0 0 32px rgba(210, 225, 255, 0.08);
}
.row { display:flex; align-items:center; }
.row.space { justify-content: space-between; }
.row.right { justify-content: flex-end; }
.row.gap { gap: 10px; }
.input {
flex:1;
padding: 12px 12px;
border-radius: 10px;
border: 1px solid var(--border-soft);
background: rgba(0,0,0,0.25);
color: var(--text-main);
}
.input:focus {
outline: none;
border-color: var(--border-strong);
background: rgba(0,0,0,0.32);
}
.btn {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-soft);
background: rgba(255,255,255,0.10);
color: var(--text-main);
font-weight: 600;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
}
.btn:hover:not(:disabled) {
background: rgba(209, 118, 69, 0.14);
border-color: rgba(209, 118, 69, 0.35);
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.btn.primary {
background: var(--accent-primary);
border-color: transparent;
color: #0b1020;
font-weight: 800;
}
.btn.primary:hover:not(:disabled) {
background: var(--accent-secondary);
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn.secondary { background: transparent; }
.muted { opacity: 0.7; margin-top: 8px; }
.error { margin-top: 12px; padding: 10px; border-radius: 10px; background: rgba(255,0,0,0.12); border: 1px solid rgba(255,0,0,0.25); }
.notice { margin-top: 12px; padding: 10px; border-radius: 10px; background: rgba(0,170,255,0.12); border: 1px solid rgba(0,170,255,0.25); }
.landmark-layout { display:grid; grid-template-columns: 320px 1fr; gap: 14px; margin-top: 14px; }
.panel { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 14px; }
.list { margin: 10px 0 0; padding-left: 18px; }
.list li { margin: 10px 0; }
.list li.active .label { font-weight: 700; }
.label { margin-bottom: 4px; }
.meta { opacity: 0.7; font-size: 12px; }
.pill { opacity: 0.8; font-size: 12px; padding: 6px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.12); }
.canvasWrap { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 12px; }
.imgWrap { position: relative; display: inline-block; cursor: crosshair; }
.xray { max-width: 100%; border-radius: 12px; display:block; }
.overlay { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; }
.hint { margin-top: 8px; opacity: 0.7; font-size: 12px; }
.table { width:100%; border-collapse: collapse; margin-top: 14px; }
.table th, .table td { text-align:left; padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.08); }
.summary { display:flex; gap: 20px; margin-top: 12px; flex-wrap: wrap; }
.tag { padding: 4px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.10); font-size: 12px; }
.tag.pending { opacity: 0.8; }
.tag.running { border-color: rgba(0,170,255,0.35); background: rgba(0,170,255,0.10); }
.tag.done { border-color: rgba(0,255,140,0.35); background: rgba(0,255,140,0.10); }
.tag.waiting_for_landmarks { border-color: rgba(255,200,0,0.35); background: rgba(255,200,0,0.10); }
/* =========================================================
BraceFlow - Slide-in Drawer (Artifacts)
========================================================= */
.bf-drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
opacity: 0;
pointer-events: none;
transition: opacity 160ms ease;
z-index: 90;
}
.bf-drawer-backdrop.is-open {
opacity: 1;
pointer-events: auto;
}
.bf-drawer {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: min(560px, 92vw);
background: rgba(15, 23, 42, 0.92);
border-left: 1px solid var(--border-soft);
backdrop-filter: blur(16px);
transform: translateX(100%);
transition: transform 180ms ease;
z-index: 100;
display: flex;
flex-direction: column;
}
.bf-drawer.is-open { transform: translateX(0); }
.bf-drawer-header {
padding: 14px 14px 10px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.bf-drawer-title { font-weight: 900; letter-spacing: 0.2px; }
.bf-drawer-subtitle { opacity: 0.7; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bf-tabs {
padding: 10px 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.bf-tab {
height: 32px;
min-width: 38px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.92);
font-size: 13px;
font-weight: 800;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.bf-tab:hover { background: rgba(209, 118, 69, 0.12); border-color: rgba(209, 118, 69, 0.32); }
.bf-tab.is-active { background: rgba(255,255,255,0.16); border-color: rgba(255,255,255,0.35); }
.bf-drawer-body { flex: 1; padding: 14px; overflow: auto; }
/* =========================================================
Landmark Capture Thumbnail + Fixed Canvas
========================================================= */
.lc-imageRow {
display: flex;
gap: 20px;
align-items: flex-start;
flex-wrap: wrap;
}
.lc-thumbCol {
width: 160px;
}
.lc-thumbBox {
width: 140px;
height: 140px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.25);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.lc-thumbBox img {
width: 100%;
height: 100%;
object-fit: cover;
}
.lc-landmarkBox {
width: 250px;
height: 250px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(0,0,0,0.22);
padding: 8px;
}
/* Force LandmarkCanvas to respect size */
.fixed-250 {
width: 250px;
height: 250px;
}
.fixed-250 img,
.fixed-250 svg {
width: 100%;
height: 100%;
}
.imgWrap.fixed-250 {
position: relative;
cursor: crosshair;
}
.imgWrap.fixed-250 img {
display: block;
border-radius: 10px;
}
.imgWrap.fixed-250 .overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
/* =========================================================
Landmark Capture page Thumbnail top, Workspace below
========================================================= */
.lc-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Thumbnail row (top) */
.lc-thumbRow {
display: flex;
justify-content: flex-start;
}
.lc-thumbCol {
width: 170px;
}
.lc-thumbBox {
width: 140px;
height: 140px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.22);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.lc-thumbBox img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.lc-thumbActions {
display: flex;
gap: 10px;
margin-top: 10px;
}
/* Workspace (below thumbnail) */
.lc-workspace {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px;
padding: 14px;
}
.lc-workspace-title {
margin-bottom: 10px;
font-weight: 700;
letter-spacing: 0.2px;
}
.lc-workspace-body {
/* just spacing wrapper */
}
/* ---------------------------------------------------------
IMPORTANT: Fix the "ruined" layout by letting LandmarkCanvas
keep its own grid, but constrain ONLY the image holder.
LandmarkCanvas uses:
.landmark-layout (grid)
.panel (left)
.canvasWrap (right)
.imgWrap (image container)
--------------------------------------------------------- */
.lc-workspace .landmark-layout {
grid-template-columns: 340px 1fr; /* nicer proportion */
align-items: start;
gap: 16px;
margin-top: 0;
}
@media (max-width: 900px) {
.lc-workspace .landmark-layout {
grid-template-columns: 1fr;
}
}
/* Constrain ONLY the DCM/image holder (not the whole component) */
.lc-workspace .canvasWrap {
display: flex;
flex-direction: column;
gap: 10px;
}
.lc-workspace .imgWrap {
width: 250px;
height: 250px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.18);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
/* Make image + overlay fill that 250x250 cleanly */
.lc-workspace .xray {
width: 100%;
height: 100%;
object-fit: contain; /* keeps anatomy proportions */
border-radius: 14px;
}
.lc-workspace .overlay {
width: 100%;
height: 100%;
}
/* Make the landmarks panel feel aligned with the 250 box */
.lc-workspace .panel {
border-radius: 16px;
}

175
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,175 @@
import { Routes, Route, useNavigate, Navigate } from "react-router-dom";
import { AppShell } from "./components/AppShell";
import { AuthProvider, useAuth } from "./context/AuthContext";
import HomePage from "./pages/HomePage";
import LoginPage from "./pages/LoginPage";
import Dashboard from "./pages/Dashboard";
import CaseDetailPage from "./pages/CaseDetail";
import PipelineCaseDetail from "./pages/PipelineCaseDetail";
import ShellEditorPage from "./pages/ShellEditorPage";
// Admin pages
import AdminDashboard from "./pages/admin/AdminDashboard";
import AdminUsers from "./pages/admin/AdminUsers";
import AdminCases from "./pages/admin/AdminCases";
import AdminActivity from "./pages/admin/AdminActivity";
// Import pipeline styles
import "./components/pipeline/pipeline.css";
// Protected route wrapper - redirects to login if not authenticated
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="bf-loading-screen">
<div className="bf-loading-spinner"></div>
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// Admin route wrapper - requires admin role
function AdminRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isAdmin, isLoading } = useAuth();
if (isLoading) {
return (
<div className="bf-loading-screen">
<div className="bf-loading-spinner"></div>
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (!isAdmin) {
return <Navigate to="/cases" replace />;
}
return <>{children}</>;
}
function AppRoutes() {
return (
<Routes>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
{/* Protected routes - wrapped in AppShell */}
<Route
path="/cases"
element={
<ProtectedRoute>
<AppShell>
<DashboardWrapper />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/cases/:caseId"
element={
<ProtectedRoute>
<AppShell>
<PipelineCaseDetail />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/cases-legacy/:caseId"
element={
<ProtectedRoute>
<AppShell>
<CaseDetailPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/editor"
element={
<ProtectedRoute>
<AppShell>
<ShellEditorPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
path="/admin"
element={
<AdminRoute>
<AppShell>
<AdminDashboard />
</AppShell>
</AdminRoute>
}
/>
<Route
path="/admin/users"
element={
<AdminRoute>
<AppShell>
<AdminUsers />
</AppShell>
</AdminRoute>
}
/>
<Route
path="/admin/cases"
element={
<AdminRoute>
<AppShell>
<AdminCases />
</AppShell>
</AdminRoute>
}
/>
<Route
path="/admin/activity"
element={
<AdminRoute>
<AppShell>
<AdminActivity />
</AppShell>
</AdminRoute>
}
/>
{/* Legacy redirects */}
<Route path="/dashboard" element={<Navigate to="/cases" replace />} />
{/* Catch-all redirect */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default function App() {
return (
<AuthProvider>
<AppRoutes />
</AuthProvider>
);
}
function DashboardWrapper() {
const nav = useNavigate();
return <Dashboard onView={(id: string) => nav(`/cases/${encodeURIComponent(id)}`)} />;
}

View File

@@ -0,0 +1,236 @@
/**
* Admin API Client
* API functions for admin dashboard features
*/
import { getAuthHeaders } from "../context/AuthContext";
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api";
async function adminFetch<T>(path: string, init?: RequestInit): Promise<T> {
const url = `${API_BASE}${path}`;
const response = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
...init?.headers,
},
});
const text = await response.text();
if (!response.ok) {
const error = text ? JSON.parse(text) : { message: "Request failed" };
throw new Error(error.message || `Request failed: ${response.status}`);
}
return text ? JSON.parse(text) : ({} as T);
}
// ============================================
// USER MANAGEMENT
// ============================================
export type AdminUser = {
id: number;
username: string;
email: string | null;
full_name: string | null;
role: "admin" | "user" | "viewer";
is_active: number;
last_login: string | null;
created_at: string;
updated_at: string;
};
export async function listUsers(): Promise<{ users: AdminUser[] }> {
return adminFetch("/admin/users");
}
export async function getUser(userId: number): Promise<{ user: AdminUser }> {
return adminFetch(`/admin/users/${userId}`);
}
export async function createUser(data: {
username: string;
password: string;
email?: string;
fullName?: string;
role?: "admin" | "user" | "viewer";
}): Promise<{ user: AdminUser }> {
return adminFetch("/admin/users", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function updateUser(
userId: number,
data: {
email?: string;
fullName?: string;
role?: "admin" | "user" | "viewer";
isActive?: boolean;
password?: string;
}
): Promise<{ user: AdminUser }> {
return adminFetch(`/admin/users/${userId}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
export async function deleteUser(userId: number): Promise<{ message: string }> {
return adminFetch(`/admin/users/${userId}`, {
method: "DELETE",
});
}
// ============================================
// CASES WITH FILTERS
// ============================================
export type AdminCase = {
caseId: string;
case_type: string;
status: string;
current_step: string | null;
notes: string | null;
analysis_result: any;
landmarks_data: any;
body_scan_path: string | null;
created_by: number | null;
created_by_username: string | null;
created_at: string;
updated_at: string;
};
export type ListCasesResponse = {
cases: AdminCase[];
total: number;
limit: number;
offset: number;
};
export async function listCasesAdmin(params?: {
status?: string;
createdBy?: number;
search?: string;
limit?: number;
offset?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}): Promise<ListCasesResponse> {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status);
if (params?.createdBy) searchParams.set("createdBy", params.createdBy.toString());
if (params?.search) searchParams.set("search", params.search);
if (params?.limit) searchParams.set("limit", params.limit.toString());
if (params?.offset) searchParams.set("offset", params.offset.toString());
if (params?.sortBy) searchParams.set("sortBy", params.sortBy);
if (params?.sortOrder) searchParams.set("sortOrder", params.sortOrder);
const query = searchParams.toString();
return adminFetch(`/admin/cases${query ? `?${query}` : ""}`);
}
// ============================================
// ANALYTICS
// ============================================
export type CaseStats = {
total: number;
byStatus: Record<string, number>;
last7Days: { date: string; count: number }[];
last30Days: { date: string; count: number }[];
};
export type UserStats = {
total: number;
active: number;
inactive: number;
byRole: Record<string, number>;
recentLogins: number;
};
export type CobbAngleStats = {
PT: { min: number; max: number; avg: number; count: number };
MT: { min: number; max: number; avg: number; count: number };
TL: { min: number; max: number; avg: number; count: number };
totalCasesWithAngles: number;
};
export type ProcessingTimeStats = {
min: number;
max: number;
avg: number;
count: number;
};
export type BodyScanStats = {
total: number;
withBodyScan: number;
withoutBodyScan: number;
percentage: number;
};
export type DashboardAnalytics = {
cases: CaseStats;
users: UserStats;
rigoDistribution: Record<string, number>;
cobbAngles: CobbAngleStats;
processingTime: ProcessingTimeStats;
bodyScan: BodyScanStats;
};
export async function getDashboardAnalytics(): Promise<DashboardAnalytics> {
return adminFetch("/admin/analytics/dashboard");
}
export async function getRigoDistribution(): Promise<{ distribution: Record<string, number> }> {
return adminFetch("/admin/analytics/rigo");
}
export async function getCobbAngleStats(): Promise<{ stats: CobbAngleStats }> {
return adminFetch("/admin/analytics/cobb-angles");
}
export async function getProcessingTimeStats(): Promise<{ stats: ProcessingTimeStats }> {
return adminFetch("/admin/analytics/processing-time");
}
// ============================================
// AUDIT LOG
// ============================================
export type AuditLogEntry = {
id: number;
user_id: number | null;
username: string | null;
action: string;
entity_type: string;
entity_id: string | null;
details: string | null;
ip_address: string | null;
created_at: string;
};
export async function getAuditLog(params?: {
userId?: number;
action?: string;
entityType?: string;
limit?: number;
offset?: number;
}): Promise<{ entries: AuditLogEntry[] }> {
const searchParams = new URLSearchParams();
if (params?.userId) searchParams.set("userId", params.userId.toString());
if (params?.action) searchParams.set("action", params.action);
if (params?.entityType) searchParams.set("entityType", params.entityType);
if (params?.limit) searchParams.set("limit", params.limit.toString());
if (params?.offset) searchParams.set("offset", params.offset.toString());
const query = searchParams.toString();
return adminFetch(`/admin/audit-log${query ? `?${query}` : ""}`);
}

View File

@@ -0,0 +1,884 @@
export type CaseRecord = {
caseId: string;
status: string;
current_step: string | null;
created_at: string;
analysis_result?: AnalysisResult | null;
landmarks_data?: LandmarksResult | null;
analysis_data?: RecalculationResult | null;
body_scan_path?: string | null;
body_scan_url?: string | null;
body_scan_metadata?: BodyScanMetadata | null;
};
export type BodyScanMetadata = {
total_height_mm?: number;
shoulder_width_mm?: number;
chest_width_mm?: number;
chest_depth_mm?: number;
waist_width_mm?: number;
waist_depth_mm?: number;
hip_width_mm?: number;
brace_coverage_height_mm?: number;
file_format?: string;
vertex_count?: number;
filename?: string;
};
export type CobbAngles = {
PT?: number;
MT?: number;
TL?: number;
};
export type RigoClassification = {
type: string;
description: string;
curve_pattern?: string;
};
export type DeformationZone = {
zone: string;
patch: [number, number];
deform_mm: number;
reason: string;
};
export type DeformationReport = {
patch_grid: string;
deformations: number[][];
zones: DeformationZone[];
};
export type AnalysisResult = {
experiment?: string;
model?: string;
vertebrae_detected?: number;
cobb_angles?: CobbAngles;
curve_type?: string;
rigo_classification?: RigoClassification;
mesh_info?: {
vertices?: number;
faces?: number;
};
deformation_report?: DeformationReport;
outputs?: Record<string, { s3Key: string; url: string }>;
processing_time_ms?: number;
};
export type BraceOutput = {
filename: string;
type: "stl" | "ply" | "obj" | "image" | "json" | "other";
s3Key: string;
size: number;
url: string;
expiresIn: number;
};
export type BraceOutputsResponse = {
caseId: string;
status: string;
analysis: AnalysisResult | null;
outputs: BraceOutput[];
};
export type GenerateBraceResponse = {
caseId: string;
status: string;
experiment: string;
model: string;
vertebrae_detected: number;
cobb_angles: CobbAngles;
curve_type: string;
rigo_classification: RigoClassification;
mesh: {
vertices: number;
faces: number;
};
outputs: Record<string, { s3Key: string; url: string }>;
processing_time_ms: number;
deformation_report?: DeformationReport;
};
// API Base URL
// - In production (Docker): empty string uses relative URLs with /api prefix
// - In development: set VITE_API_BASE=http://localhost:8001 in .env.local
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
// API prefix for relative URLs (when API_BASE is empty or doesn't include /api)
const API_PREFIX = "/api";
// File server base URL (same as API base for dev server)
const FILE_BASE = API_BASE.replace(/\/api\/?$/, '');
/**
* Convert relative file URLs to absolute URLs
* e.g., "/files/outputs/..." -> "http://localhost:8001/files/outputs/..."
*/
export function toAbsoluteFileUrl(relativeUrl: string | undefined | null): string | undefined {
if (!relativeUrl) return undefined;
if (relativeUrl.startsWith('http://') || relativeUrl.startsWith('https://')) {
return relativeUrl; // Already absolute
}
return `${FILE_BASE}${relativeUrl.startsWith('/') ? '' : '/'}${relativeUrl}`;
}
async function safeFetch<T>(path: string, init?: RequestInit): Promise<T> {
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : "";
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
// If no base URL, use API_PREFIX for relative URLs (production with nginx proxy)
const prefix = base ? "" : API_PREFIX;
const url = `${base}${prefix}${normalizedPath}`;
const res = await fetch(url, init);
const text = await res.text().catch(() => "");
if (!res.ok) {
throw new Error(`Request failed: ${res.status} ${res.statusText}${text ? ` :: ${text}` : ""}`);
}
return text ? (JSON.parse(text) as T) : ({} as T);
}
/**
* Supports common backend shapes:
* - Array: [ {caseId...}, ... ]
* - Object: { cases: [...], nextToken: "..." }
* - Object: { items: [...], nextToken: "..." }
*/
function normalizeCasesResponse(
json: any
): { cases: CaseRecord[]; nextToken?: string | null } {
if (Array.isArray(json)) return { cases: json, nextToken: null };
const cases = (json?.cases ?? json?.items ?? []) as CaseRecord[];
const nextToken = (json?.nextToken ?? json?.next_token ?? json?.nextCursor ?? json?.next_cursor ?? null) as
| string
| null;
return { cases: Array.isArray(cases) ? cases : [], nextToken };
}
/**
* Fetch ALL cases, following nextToken if backend paginates.
* Uses query param `?nextToken=` (common pattern).
*/
export async function fetchCases(): Promise<CaseRecord[]> {
const all: CaseRecord[] = [];
let nextToken: string | null = null;
// Safety guard to prevent infinite loops if backend misbehaves
const MAX_PAGES = 50;
for (let page = 0; page < MAX_PAGES; page++) {
const path = nextToken ? `/cases?nextToken=${encodeURIComponent(nextToken)}` : `/cases`;
const json = await safeFetch<any>(path);
const normalized = normalizeCasesResponse(json);
all.push(...normalized.cases);
if (!normalized.nextToken) break;
nextToken = normalized.nextToken;
}
// Sort newest-first
all.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
return all;
}
export async function fetchCase(caseId: string): Promise<CaseRecord | null> {
try {
const result = await safeFetch<CaseRecord>(`/cases/${encodeURIComponent(caseId)}`);
// Convert relative URLs to absolute URLs for braces data
if (result?.analysis_result) {
const ar = result.analysis_result as any;
// Convert both braces format URLs
if (ar.braces) {
if (ar.braces.regular?.outputs) {
ar.braces.regular.outputs = {
glb: toAbsoluteFileUrl(ar.braces.regular.outputs.glb),
stl: toAbsoluteFileUrl(ar.braces.regular.outputs.stl),
json: toAbsoluteFileUrl(ar.braces.regular.outputs.json),
};
}
if (ar.braces.vase?.outputs) {
ar.braces.vase.outputs = {
glb: toAbsoluteFileUrl(ar.braces.vase.outputs.glb),
stl: toAbsoluteFileUrl(ar.braces.vase.outputs.stl),
json: toAbsoluteFileUrl(ar.braces.vase.outputs.json),
};
}
}
// Convert single brace format URLs
if (ar.brace?.outputs) {
ar.brace.outputs = {
stl: toAbsoluteFileUrl(ar.brace.outputs.stl),
ply: toAbsoluteFileUrl(ar.brace.outputs.ply),
visualization: toAbsoluteFileUrl(ar.brace.outputs.visualization),
landmarks: toAbsoluteFileUrl(ar.brace.outputs.landmarks),
};
}
}
return result;
} catch {
return null;
}
}
export async function createCase(body: { notes?: string } = {}): Promise<{ caseId: string }> {
return await safeFetch<{ caseId: string }>(`/cases`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/**
* Get a presigned URL for uploading an X-ray image to S3
*/
export async function getUploadUrl(
caseId: string,
filename: string,
contentType: string
): Promise<{ url: string; s3Key: string }> {
return await safeFetch<{ url: string; s3Key: string }>(
`/cases/${encodeURIComponent(caseId)}/upload-url`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, contentType }),
}
);
}
/**
* Upload a file directly to S3 using a presigned URL
*/
export async function uploadToS3(presignedUrl: string, file: File): Promise<void> {
const response = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
}
/**
* Create a case and upload an X-ray without starting processing.
*/
export async function createCaseAndUploadXray(
file: File,
options?: { notes?: string }
): Promise<{ caseId: string }> {
const { caseId } = await createCase({
notes: options?.notes ?? `X-ray upload: ${file.name}`,
});
const { url: uploadUrl } = await getUploadUrl(caseId, "ap.jpg", file.type);
await uploadToS3(uploadUrl, file);
return { caseId };
}
/**
* Invoke the brace generator Lambda for a case
*/
export async function generateBrace(
caseId: string,
options?: { experiment?: string; config?: Record<string, unknown> }
): Promise<GenerateBraceResponse> {
return await safeFetch<GenerateBraceResponse>(
`/cases/${encodeURIComponent(caseId)}/generate-brace`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
caseId,
experiment: options?.experiment ?? "experiment_3",
config: options?.config,
}),
}
);
}
/**
* Get brace generation outputs for a case (with presigned URLs)
*/
export async function getBraceOutputs(caseId: string): Promise<BraceOutputsResponse> {
return await safeFetch<BraceOutputsResponse>(
`/cases/${encodeURIComponent(caseId)}/brace-outputs`
);
}
/**
* Get a presigned download URL for a case asset (e.g., X-ray)
*/
export async function getDownloadUrl(
caseId: string,
assetType: "xray" | "landmarks" | "measurements"
): Promise<{ url: string }> {
return await safeFetch<{ url: string }>(
`/cases/${encodeURIComponent(caseId)}/download-url?type=${assetType}`
);
}
/**
* Full workflow: Create case -> Upload X-ray -> Generate brace
*/
export async function analyzeXray(
file: File,
options?: { experiment?: string; config?: Record<string, unknown> }
): Promise<{ caseId: string; result: GenerateBraceResponse }> {
// Step 1: Create a new case
const { caseId } = await createCase({ notes: `X-ray analysis: ${file.name}` });
console.log(`Created case: ${caseId}`);
// Step 2: Get presigned URL for upload
const { url: uploadUrl } = await getUploadUrl(caseId, "ap.jpg", file.type);
console.log(`Got upload URL for case: ${caseId}`);
// Step 3: Upload file to S3
await uploadToS3(uploadUrl, file);
console.log(`Uploaded X-ray to S3 for case: ${caseId}`);
// Step 4: Generate brace
const result = await generateBrace(caseId, options);
console.log(`Brace generation complete for case: ${caseId}`);
return { caseId, result };
}
/**
* Delete a case and all associated files
*/
export async function deleteCase(caseId: string): Promise<{ message: string }> {
return await safeFetch<{ message: string }>(
`/cases/${encodeURIComponent(caseId)}`,
{ method: "DELETE" }
);
}
// ============================================
// PIPELINE DEV API - New Stage-based endpoints
// ============================================
export type VertebraData = {
level: string;
detected: boolean;
scoliovis_data: {
centroid_px: [number, number] | null;
corners_px: [number, number][] | null;
orientation_deg: number | null;
confidence: number;
};
manual_override: {
enabled: boolean;
centroid_px: [number, number] | null;
corners_px: [number, number][] | null;
orientation_deg: number | null;
confidence: number | null;
notes: string | null;
};
final_values: {
centroid_px: [number, number] | null;
corners_px: [number, number][] | null;
orientation_deg: number | null;
confidence: number;
source: 'scoliovis' | 'manual' | 'undetected';
};
};
export type VertebraeStructure = {
all_levels: string[];
detected_count: number;
total_count: number;
vertebrae: VertebraData[];
manual_edit_instructions: {
to_override: string;
final_values_rule: string;
};
};
export type LandmarksResult = {
case_id: string;
status: string;
input: {
image_dimensions: { width: number; height: number };
pixel_spacing_mm: number | null;
};
detection_quality: {
vertebrae_count: number;
average_confidence: number;
};
cobb_angles: {
PT: number;
MT: number;
TL: number;
max: number;
PT_severity: string;
MT_severity: string;
TL_severity: string;
};
rigo_classification: RigoClassification;
curve_type: string;
vertebrae_structure: VertebraeStructure;
visualization_path?: string;
visualization_url?: string;
json_path?: string;
json_url?: string;
processing_time_ms: number;
};
export type RecalculationResult = {
case_id: string;
status: string;
cobb_angles: {
PT: number;
MT: number;
TL: number;
max: number;
PT_severity: string;
MT_severity: string;
TL_severity: string;
};
rigo_classification: RigoClassification;
curve_type: string;
apex_indices: number[];
vertebrae_used: number;
processing_time_ms: number;
};
/**
* Stage 1: Upload X-ray and detect landmarks
*/
export async function uploadXrayForCase(caseId: string, file: File): Promise<{ filename: string; path: string }> {
const formData = new FormData();
formData.append('file', file);
// Use API_BASE if set, otherwise use /api prefix for production
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : API_PREFIX;
const res = await fetch(`${base}/cases/${encodeURIComponent(caseId)}/upload`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Upload failed: ${res.status} ${text}`);
}
return res.json();
}
/**
* Stage 1: Detect landmarks (no brace generation)
*/
export async function detectLandmarks(caseId: string): Promise<LandmarksResult> {
return await safeFetch<LandmarksResult>(
`/cases/${encodeURIComponent(caseId)}/detect-landmarks`,
{ method: 'POST' }
);
}
/**
* Stage 1: Update landmarks (manual edits)
*/
export async function updateLandmarks(
caseId: string,
landmarksData: VertebraeStructure
): Promise<{ caseId: string; status: string }> {
return await safeFetch<{ caseId: string; status: string }>(
`/cases/${encodeURIComponent(caseId)}/landmarks`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ landmarks_data: landmarksData }),
}
);
}
/**
* Stage 1->2: Approve landmarks and move to analysis
*/
export async function approveLandmarks(
caseId: string,
updatedLandmarks?: VertebraeStructure
): Promise<{ caseId: string; status: string; next_step: string }> {
return await safeFetch<{ caseId: string; status: string; next_step: string }>(
`/cases/${encodeURIComponent(caseId)}/approve-landmarks`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updated_landmarks: updatedLandmarks }),
}
);
}
/**
* Stage 2: Recalculate Cobb angles and Rigo from landmarks
*/
export async function recalculateAnalysis(caseId: string): Promise<RecalculationResult> {
return await safeFetch<RecalculationResult>(
`/cases/${encodeURIComponent(caseId)}/recalculate`,
{ method: 'POST' }
);
}
/**
* Stage 3: Generate brace from approved landmarks
*/
export async function generateBraceFromLandmarks(
caseId: string,
options?: { experiment?: string }
): Promise<GenerateBraceResponse> {
return await safeFetch<GenerateBraceResponse>(
`/cases/${encodeURIComponent(caseId)}/generate-brace`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
experiment: options?.experiment ?? 'experiment_9',
}),
}
);
}
/**
* Stage 3: Update brace markers (manual edits)
*/
export async function updateMarkers(
caseId: string,
markersData: Record<string, unknown>
): Promise<{ caseId: string; status: string }> {
return await safeFetch<{ caseId: string; status: string }>(
`/cases/${encodeURIComponent(caseId)}/markers`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markers_data: markersData }),
}
);
}
/**
* Generate GLB brace with markers
*/
export async function generateGlbBrace(
caseId: string,
options?: {
rigoType?: string;
templateType?: 'regular' | 'vase';
}
): Promise<{
caseId: string;
rigoType: string;
templateType: string;
outputs: { glb?: string; stl?: string; json?: string };
markers: Record<string, number[]>;
pressureZones: Array<{
name: string;
marker_name: string;
position: number[];
zone_type: string;
direction: string;
depth_mm: number;
}>;
meshStats: { vertices: number; faces: number };
}> {
const formData = new FormData();
if (options?.rigoType) formData.append('rigo_type', options.rigoType);
if (options?.templateType) formData.append('template_type', options.templateType);
formData.append('case_id', caseId);
return await safeFetch(
`/cases/${encodeURIComponent(caseId)}/generate-glb`,
{
method: 'POST',
body: formData,
}
);
}
/**
* Generate both brace types (regular + vase) for comparison
*/
export type BothBracesResponse = {
caseId: string;
rigoType: string;
cobbAngles: { PT: number; MT: number; TL: number };
bodyScanUsed: boolean;
braces: {
regular?: {
outputs: { glb?: string; stl?: string; json?: string };
markers: Record<string, number[]>;
pressureZones: Array<{
name: string;
marker_name: string;
position: number[];
zone_type: string;
direction: string;
depth_mm: number;
}>;
meshStats: { vertices: number; faces: number };
};
vase?: {
outputs: { glb?: string; stl?: string; json?: string };
markers: Record<string, number[]>;
pressureZones: Array<{
name: string;
marker_name: string;
position: number[];
zone_type: string;
direction: string;
depth_mm: number;
}>;
meshStats: { vertices: number; faces: number };
error?: string;
};
};
};
export async function generateBothBraces(
caseId: string,
options?: { rigoType?: string }
): Promise<BothBracesResponse> {
const formData = new FormData();
if (options?.rigoType) formData.append('rigo_type', options.rigoType);
formData.append('case_id', caseId);
const result = await safeFetch<BothBracesResponse>(
`/cases/${encodeURIComponent(caseId)}/generate-both-braces`,
{
method: 'POST',
body: formData,
}
);
// Convert relative URLs to absolute URLs for 3D loaders
if (result.braces?.regular?.outputs) {
result.braces.regular.outputs = {
glb: toAbsoluteFileUrl(result.braces.regular.outputs.glb),
stl: toAbsoluteFileUrl(result.braces.regular.outputs.stl),
json: toAbsoluteFileUrl(result.braces.regular.outputs.json),
};
}
if (result.braces?.vase?.outputs) {
result.braces.vase.outputs = {
glb: toAbsoluteFileUrl(result.braces.vase.outputs.glb),
stl: toAbsoluteFileUrl(result.braces.vase.outputs.stl),
json: toAbsoluteFileUrl(result.braces.vase.outputs.json),
};
}
return result;
}
/**
* Get case assets (uploaded files and outputs)
*/
export async function getCaseAssets(caseId: string): Promise<{
caseId: string;
assets: {
uploads: { filename: string; url: string }[];
outputs: { filename: string; url: string }[];
};
}> {
const result = await safeFetch<{
caseId: string;
assets: {
uploads: { filename: string; url: string }[];
outputs: { filename: string; url: string }[];
};
}>(`/cases/${encodeURIComponent(caseId)}/assets`);
// Convert relative URLs to absolute
if (result.assets?.uploads) {
result.assets.uploads = result.assets.uploads.map(f => ({
...f,
url: toAbsoluteFileUrl(f.url) || f.url
}));
}
if (result.assets?.outputs) {
result.assets.outputs = result.assets.outputs.map(f => ({
...f,
url: toAbsoluteFileUrl(f.url) || f.url
}));
}
return result;
}
// ==============================================
// BODY SCAN API (Stage 3)
// ==============================================
export type BodyScanResponse = {
caseId: string;
has_body_scan: boolean;
body_scan: {
path: string;
url: string;
metadata: BodyScanMetadata;
} | null;
};
export type BodyScanUploadResponse = {
caseId: string;
status: string;
body_scan: {
path: string;
url: string;
metadata: BodyScanMetadata;
};
};
/**
* Upload body scan (STL/OBJ/PLY)
*/
export async function uploadBodyScan(
caseId: string,
file: File
): Promise<BodyScanUploadResponse> {
const formData = new FormData();
formData.append('file', file);
// Use API_BASE if set, otherwise use /api prefix for production
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : API_PREFIX;
const response = await fetch(`${base}/cases/${encodeURIComponent(caseId)}/body-scan`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const err = await response.json().catch(() => ({ message: 'Upload failed' }));
throw new Error(err.message || 'Failed to upload body scan');
}
return response.json();
}
/**
* Get body scan info
*/
export async function getBodyScan(caseId: string): Promise<BodyScanResponse> {
return await safeFetch(`/cases/${encodeURIComponent(caseId)}/body-scan`);
}
/**
* Delete body scan
*/
export async function deleteBodyScan(
caseId: string
): Promise<{ caseId: string; status: string; message: string }> {
return await safeFetch(
`/cases/${encodeURIComponent(caseId)}/body-scan`,
{ method: 'DELETE' }
);
}
/**
* Skip body scan stage and proceed to brace generation
*/
export async function skipBodyScan(
caseId: string
): Promise<{ caseId: string; status: string; message: string }> {
return await safeFetch(
`/cases/${encodeURIComponent(caseId)}/skip-body-scan`,
{ method: 'POST' }
);
}
/**
* Upload a modified brace file to the case outputs
* Used when the user modifies a brace in the inline editor
*/
export async function uploadModifiedBrace(
caseId: string,
braceType: 'regular' | 'vase',
fileType: 'stl' | 'glb',
blob: Blob,
transformParams?: Record<string, unknown>
): Promise<{
caseId: string;
status: string;
output: {
filename: string;
url: string;
s3Key: string;
};
}> {
const filename = `${braceType}_modified.${fileType}`;
const formData = new FormData();
formData.append('file', blob, filename);
formData.append('brace_type', braceType);
formData.append('file_type', fileType);
if (transformParams) {
formData.append('transform_params', JSON.stringify(transformParams));
}
// Use API_BASE if set, otherwise use /api prefix for production
const base = API_BASE ? API_BASE.replace(/\/+$/, "") : API_PREFIX;
const response = await fetch(
`${base}/cases/${encodeURIComponent(caseId)}/upload-modified-brace`,
{
method: 'POST',
body: formData,
}
);
if (!response.ok) {
const err = await response.json().catch(() => ({ message: 'Upload failed' }));
throw new Error(err.message || 'Failed to upload modified brace');
}
return response.json();
}
/**
* Get presigned URL for uploading modified brace to case storage
*/
export async function getModifiedBraceUploadUrl(
caseId: string,
braceType: 'regular' | 'vase',
fileType: 'stl' | 'glb'
): Promise<{ url: string; s3Key: string; contentType: string }> {
const filename = `${braceType}_modified.${fileType}`;
return await safeFetch<{ url: string; s3Key: string; contentType: string }>(
`/cases/${encodeURIComponent(caseId)}/upload-url`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uploadType: 'modified_brace',
braceType,
fileType,
filename,
contentType: fileType === 'stl' ? 'application/octet-stream' : 'model/gltf-binary',
}),
}
);
}
/**
* Upload blob to S3 using presigned URL
*/
export async function uploadBlobToS3(presignedUrl: string, blob: Blob, contentType: string): Promise<void> {
const response = await fetch(presignedUrl, {
method: 'PUT',
body: blob,
headers: {
'Content-Type': contentType,
},
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
}

101
frontend/src/api/rigoApi.ts Normal file
View File

@@ -0,0 +1,101 @@
// Rigo Backend API Client
// Backend URL: http://3.129.218.141:8000
const RIGO_API_BASE = "http://3.129.218.141:8000";
export interface AnalysisResult {
pattern: string;
apex: string;
cobb_angle: number;
thoracic_convexity?: string;
lumbar_cobb_deg?: number;
l4_tilt_deg?: number;
l5_tilt_deg?: number;
pelvic_tilt: string;
}
export interface BraceParameters {
pressure_pad_level: string;
pressure_pad_depth: string;
expansion_window_side: string;
lumbar_support: boolean;
include_shell: boolean;
}
export interface AnalysisResponse {
success: boolean;
analysis: AnalysisResult;
brace_params: BraceParameters;
model_url: string | null;
}
export interface RegenerateRequest {
pressure_pad_level: string;
pressure_pad_depth: string;
expansion_window_side: string;
lumbar_support: boolean;
include_shell: boolean;
}
export const rigoApi = {
/**
* Check backend health
*/
health: async (): Promise<{ status: string; service: string }> => {
const res = await fetch(`${RIGO_API_BASE}/api/health`);
if (!res.ok) throw new Error(`Health check failed: ${res.status}`);
return res.json();
},
/**
* Analyze X-ray image and generate brace model
*/
analyze: async (imageFile: File): Promise<AnalysisResponse> => {
const formData = new FormData();
formData.append("image", imageFile);
const res = await fetch(`${RIGO_API_BASE}/api/analyze`, {
method: "POST",
body: formData,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Analysis failed: ${res.status} ${text}`);
}
return res.json();
},
/**
* Regenerate brace with different parameters
*/
regenerate: async (params: RegenerateRequest): Promise<{ success: boolean; model_url: string }> => {
const res = await fetch(`${RIGO_API_BASE}/api/regenerate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Unknown error" }));
throw new Error(error.detail || "Regeneration failed");
}
return res.json();
},
/**
* Get full URL for a model file
*/
getModelUrl: (filename: string): string => {
return `${RIGO_API_BASE}/api/models/${filename}`;
},
/**
* Get the base URL (for constructing model URLs from relative paths)
*/
getBaseUrl: (): string => {
return RIGO_API_BASE;
},
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,109 @@
import React, { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
type NavItemProps = {
label: string;
onClick: () => void;
disabled?: boolean;
active?: boolean;
};
function NavItem({ label, onClick, disabled, active }: NavItemProps) {
return (
<button
type="button"
className={[
"bf-nav-item",
active ? "is-active" : "",
disabled ? "is-disabled" : "",
].join(" ")}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
);
}
export function AppShell({ children }: { children: React.ReactNode }) {
const nav = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const [shouldFadeIn, setShouldFadeIn] = useState(false);
const prevPathRef = useRef(location.pathname);
const isCases = location.pathname === "/cases" || location.pathname.startsWith("/cases/");
const isEditShell = location.pathname.startsWith("/editor");
const isAdmin = location.pathname.startsWith("/admin");
const isLanding = location.pathname === "/landing";
const userIsAdmin = user?.role === "admin";
useEffect(() => {
const prevPath = prevPathRef.current;
prevPathRef.current = location.pathname;
if (prevPath === "/landing" && location.pathname !== "/landing") {
setShouldFadeIn(true);
const t = window.setTimeout(() => setShouldFadeIn(false), 560);
return () => window.clearTimeout(t);
}
}, [location.pathname]);
const handleLogout = () => {
logout();
nav("/");
};
return (
<div className="bf-shell">
{!isLanding && (
<header className="bf-header">
<div className="bf-left">
<div
className="bf-brand"
onClick={() => nav("/")}
onKeyDown={(e) => e.key === "Enter" && nav("/")}
role="button"
tabIndex={0}
>
Brace<span className="bf-brand-accent">iQ</span>
</div>
<nav className="bf-nav" aria-label="Primary navigation">
<NavItem label="Cases" active={isCases} onClick={() => nav("/cases")} />
<NavItem label="Editor" active={isEditShell} onClick={() => nav("/editor")} />
{userIsAdmin && (
<NavItem label="Admin" active={isAdmin} onClick={() => nav("/admin")} />
)}
</nav>
</div>
<div className="bf-right">
{user && (
<div className="bf-user-menu">
<span className="bf-user-name">{user.fullName || user.username}</span>
<button className="bf-logout-btn" onClick={handleLogout}>
Sign Out
</button>
</div>
)}
</div>
</header>
)}
<main
className={[
"bf-content",
isLanding ? "bf-content--landing" : "",
!isLanding && shouldFadeIn ? "bf-content--fade-in" : "",
]
.filter(Boolean)
.join(" ")}
>
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,25 @@
export default function CaseTimeline({ }: { caseId?: string }) {
// Placeholder timeline. Real implementation would fetch step records.
const steps = [
'XrayIngestNormalize',
'BiomechMeasurementExtractor',
'RigoRuleClassifier',
'BraceTemplateSelector',
'BraceParametricDeformer',
'MeshFinalizerExporter',
];
return (
<div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{steps.map((s) => (
<div key={s} style={{ padding: 8, border: '1px solid #ddd', borderRadius: 4 }}>
{s}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
export type Point = { x: number; y: number };
export type LandmarkKey =
| "pelvis_mid"
| "t1_center"
| "tp_point"
| "csl_p1"
| "csl_p2";
const LANDMARK_ORDER: Array<{ key: LandmarkKey; label: string }> = [
{ key: "pelvis_mid", label: "Pelvis Mid" },
{ key: "t1_center", label: "T1 Center" },
{ key: "tp_point", label: "Thoracic Prominence (TP)" },
{ key: "csl_p1", label: "CSL Point 1 (top)" },
{ key: "csl_p2", label: "CSL Point 2 (bottom)" },
];
export function LandmarkCanvas({
imageUrl,
onChange,
initialLandmarks,
}: {
imageUrl?: string;
onChange: (landmarks: Record<string, Point>, completed: boolean) => void;
initialLandmarks?: Record<string, Point>;
}) {
const imgRef = useRef<HTMLImageElement | null>(null);
const [landmarks, setLandmarks] = useState<Record<string, Point>>({});
const [activeIndex, setActiveIndex] = useState(0);
const active = LANDMARK_ORDER[activeIndex];
const completed = useMemo(
() => LANDMARK_ORDER.every((l) => Boolean(landmarks[l.key])),
[landmarks]
);
useEffect(() => {
onChange(landmarks, completed);
}, [landmarks, completed, onChange]);
useEffect(() => {
if (initialLandmarks && Object.keys(initialLandmarks).length) {
setLandmarks(initialLandmarks);
}
}, [initialLandmarks]);
function reset() {
setLandmarks({});
setActiveIndex(0);
}
function handleClick(e: React.MouseEvent) {
if (!imgRef.current) return;
const rect = imgRef.current.getBoundingClientRect();
const x = Math.round(e.clientX - rect.left);
const y = Math.round(e.clientY - rect.top);
const next = { ...landmarks, [active.key]: { x, y } };
setLandmarks(next);
if (activeIndex < LANDMARK_ORDER.length - 1) {
setActiveIndex(activeIndex + 1);
}
}
return (
<div className="landmark-layout">
<div className="panel">
<h3>Landmarks</h3>
<ol className="list">
{LANDMARK_ORDER.map((l, idx) => (
<li key={l.key} className={idx === activeIndex ? "active" : ""}>
<div className="label">{l.label}</div>
<div className="meta">
{landmarks[l.key]
? `x=${landmarks[l.key].x}, y=${landmarks[l.key].y}`
: "pending"}
</div>
</li>
))}
</ol>
<div className="row gap">
<button className="btn secondary" onClick={reset}>
Reset
</button>
<div className="pill">
{completed ? "Ready to submit" : `Next: ${active.label}`}
</div>
</div>
</div>
<div className="canvasWrap">
<div className="imgWrap fixed-250" onClick={handleClick}>
<img
ref={imgRef}
src={imageUrl}
className="xray"
alt="AP X-ray"
draggable={false}
/>
<svg className="overlay">
{landmarks["csl_p1"] && landmarks["csl_p2"] && (
<line
x1={landmarks["csl_p1"].x}
y1={landmarks["csl_p1"].y}
x2={landmarks["csl_p2"].x}
y2={landmarks["csl_p2"].y}
stroke="white"
strokeWidth="2"
strokeDasharray="6 6"
/>
)}
{Object.entries(landmarks).map(([k, p]) => (
<g key={k}>
<circle cx={p.x} cy={p.y} r="6" fill="white" />
<circle cx={p.x} cy={p.y} r="3" fill="black" />
</g>
))}
</svg>
</div>
<div className="hint">Click to place the active landmark.</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
export default function StatusBadge({ status }: { status: string }) {
const normalized = (status || "").toLowerCase();
const badgeClass = [
"bf-status-badge",
normalized === "created" ? "is-created" : "",
normalized === "processing" ? "is-processing" : "",
normalized === "failed" ? "is-failed" : "",
]
.filter(Boolean)
.join(" ");
return <span className={badgeClass}>{status}</span>;
}

View File

@@ -0,0 +1,90 @@
import { useState } from "react";
const API_BASE = import.meta.env.VITE_API_BASE;
type Props = {
caseId?: string;
onUploaded?: () => void; // optional callback to refresh assets/status
};
export default function XrayUploader({ caseId, onUploaded }: Props) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleUpload() {
if (!file) return;
if (!caseId) {
setError('No caseId provided. Create or load a case first.');
return;
}
setLoading(true);
setError(null);
try {
// 1⃣ Ask backend for pre-signed upload URL
const res = await fetch(
`${API_BASE}/cases/${caseId}/upload-url`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ view: "ap" }) // AP view for MVP
}
);
if (!res.ok) {
throw new Error("Failed to get upload URL");
}
const { uploadUrl } = await res.json();
// 2⃣ Upload file directly to S3
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
body: file
});
if (!uploadRes.ok) {
throw new Error("Upload to S3 failed");
}
// 3⃣ Notify parent to refresh assets / status
onUploaded?.();
} catch (err: any) {
console.error(err);
setError(err.message || "Upload failed");
} finally {
setLoading(false);
}
}
return (
<div style={{ border: "1px dashed #ccc", padding: 16 }}>
<h3>X-ray Upload (AP View)</h3>
<input
type="file"
accept=".dcm,.jpg,.png"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<div style={{ marginTop: 8 }}>
<button
onClick={handleUpload}
disabled={!file || loading}
>
{loading ? "Uploading..." : "Upload X-ray"}
</button>
</div>
{error && (
<div style={{ color: "red", marginTop: 8 }}>
{error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,282 @@
/**
* Stage 3: Body Scan Upload
* Allows uploading a 3D body scan (STL/OBJ/PLY) for patient-specific fitting
*/
import { useState, useRef, useCallback } from 'react';
import type { BodyScanMetadata, BodyScanResponse } from '../../api/braceflowApi';
import BodyScanViewer from '../three/BodyScanViewer';
type Props = {
caseId: string;
bodyScanData: BodyScanResponse | null;
isLoading: boolean;
onUpload: (file: File) => Promise<void>;
onSkip: () => Promise<void>;
onContinue: () => void;
onDelete: () => Promise<void>;
};
export default function BodyScanUploadStage({
caseId,
bodyScanData,
isLoading,
onUpload,
onSkip,
onContinue,
onDelete,
}: Props) {
const [dragActive, setDragActive] = useState(false);
const [uploadProgress, setUploadProgress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const viewerRef = useRef<HTMLDivElement>(null);
const hasBodyScan = bodyScanData?.has_body_scan && bodyScanData.body_scan;
const metadata = bodyScanData?.body_scan?.metadata;
// Handle drag events
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
}, []);
// Handle file drop
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
await handleFile(files[0]);
}
}, []);
// Handle file selection
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
await handleFile(files[0]);
}
}, []);
// Process uploaded file
const handleFile = async (file: File) => {
// Validate file type
const allowedTypes = ['.stl', '.obj', '.ply', '.glb', '.gltf'];
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
if (!allowedTypes.includes(ext)) {
setError(`Invalid file type. Allowed: ${allowedTypes.join(', ')}`);
return;
}
setError(null);
setUploadProgress('Uploading...');
try {
await onUpload(file);
setUploadProgress(null);
} catch (e: any) {
setError(e?.message || 'Upload failed');
setUploadProgress(null);
}
};
// Format measurement
const formatMeasurement = (value: number | undefined, unit = 'mm') => {
if (value === undefined || value === null) return 'N/A';
return `${value.toFixed(1)} ${unit}`;
};
// Render loading state
if (isLoading) {
return (
<div className="pipeline-stage body-scan-stage">
<div className="stage-header">
<h2>Stage 3: Body Scan Upload</h2>
<div className="stage-status">
<span className="status-badge status-processing">Processing...</span>
</div>
</div>
<div className="stage-content">
<div className="body-scan-loading">
<div className="spinner large"></div>
<p>Processing body scan...</p>
</div>
</div>
</div>
);
}
return (
<div className="pipeline-stage body-scan-stage">
<div className="stage-header">
<h2>Stage 3: Body Scan Upload</h2>
<div className="stage-status">
{hasBodyScan ? (
<span className="status-badge status-complete">Uploaded</span>
) : (
<span className="status-badge status-pending">Optional</span>
)}
</div>
</div>
<div className="stage-content body-scan-content">
{/* Upload Area / Preview */}
<div className="body-scan-main">
{!hasBodyScan ? (
<div
className={`upload-dropzone ${dragActive ? 'drag-active' : ''}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".stl,.obj,.ply,.glb,.gltf"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<div className="dropzone-content">
<div className="dropzone-icon">📦</div>
<h3>Upload 3D Body Scan</h3>
<p>Drag and drop or click to select</p>
<p className="file-types">STL, OBJ, PLY, GLB supported</p>
{uploadProgress && <p className="upload-progress">{uploadProgress}</p>}
{error && <p className="upload-error">{error}</p>}
</div>
</div>
) : (
<div className="body-scan-preview body-scan-preview-3d" ref={viewerRef}>
{/* 3D Spinning Preview - fills container, slow lazy susan rotation */}
<BodyScanViewer
scanUrl={bodyScanData?.body_scan?.url || null}
autoRotate={true}
rotationSpeed={0.005}
/>
{/* File info overlay */}
<div className="preview-info-overlay">
<span className="filename">{metadata?.filename || 'body_scan.stl'}</span>
{metadata?.vertex_count && (
<span className="vertex-count">{metadata.vertex_count.toLocaleString()} vertices</span>
)}
</div>
<button className="btn-remove" onClick={onDelete}>
Remove Scan
</button>
</div>
)}
</div>
{/* Info Panel */}
<div className="body-scan-sidebar">
<div className="info-panel">
<h3>Why Upload a Body Scan?</h3>
<p>
A 3D body scan allows us to generate a perfectly fitted brace
that matches your exact body measurements.
</p>
<ul className="benefits-list">
<li>Precise fit based on body shape</li>
<li>Automatic clearance calculation</li>
<li>Better pressure zone placement</li>
<li>3D printable shell output</li>
</ul>
<p className="optional-note">
<strong>Optional:</strong> You can skip this step to generate
a standard-sized brace based on X-ray analysis only.
</p>
</div>
{/* Body Measurements */}
{hasBodyScan && metadata && (
<div className="measurements-panel">
<h3>Body Measurements</h3>
<div className="measurements-grid">
{metadata.total_height_mm !== undefined && (
<div className="measurement-item">
<span className="label">Total Height</span>
<span className="value">{formatMeasurement(metadata.total_height_mm)}</span>
</div>
)}
{metadata.shoulder_width_mm !== undefined && (
<div className="measurement-item">
<span className="label">Shoulder Width</span>
<span className="value">{formatMeasurement(metadata.shoulder_width_mm)}</span>
</div>
)}
{metadata.chest_width_mm !== undefined && (
<div className="measurement-item">
<span className="label">Chest Width</span>
<span className="value">{formatMeasurement(metadata.chest_width_mm)}</span>
</div>
)}
{metadata.chest_depth_mm !== undefined && (
<div className="measurement-item">
<span className="label">Chest Depth</span>
<span className="value">{formatMeasurement(metadata.chest_depth_mm)}</span>
</div>
)}
{metadata.waist_width_mm !== undefined && (
<div className="measurement-item">
<span className="label">Waist Width</span>
<span className="value">{formatMeasurement(metadata.waist_width_mm)}</span>
</div>
)}
{metadata.hip_width_mm !== undefined && (
<div className="measurement-item">
<span className="label">Hip Width</span>
<span className="value">{formatMeasurement(metadata.hip_width_mm)}</span>
</div>
)}
{metadata.total_height_mm !== undefined && (
<div className="measurement-item highlight">
<span className="label">Brace Coverage (65%)</span>
<span className="value">{formatMeasurement(metadata.total_height_mm * 0.65)}</span>
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="stage-actions">
{!hasBodyScan ? (
<>
<button className="btn secondary" onClick={onSkip}>
Skip (Use X-ray Only)
</button>
<button
className="btn primary"
onClick={() => fileInputRef.current?.click()}
>
Upload Body Scan
</button>
</>
) : (
<>
<button className="btn secondary" onClick={onDelete}>
Remove & Skip
</button>
<button className="btn primary" onClick={onContinue}>
Continue to Brace Generation
</button>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,270 @@
/**
* Stage 5: Brace Editor
* 3D visualization and deformation controls for the generated brace
*
* Based on EXPERIMENT_6's brace-transform-playground-v2
*/
import { useState, useCallback } from 'react';
import BraceViewer from '../three/BraceViewer';
import type { GenerateBraceResponse } from '../../api/braceflowApi';
type MarkerInfo = {
name: string;
position: [number, number, number];
color: string;
};
type DeformationParams = {
thoracicPadDepth: number;
lumbarPadDepth: number;
trunkShift: number;
rotationCorrection: number;
};
type Props = {
caseId: string;
braceData: GenerateBraceResponse | null;
onRegenerate: (params: DeformationParams) => Promise<void>;
onExportSTL: () => void;
};
const DEFAULT_PARAMS: DeformationParams = {
thoracicPadDepth: 15,
lumbarPadDepth: 10,
trunkShift: 0,
rotationCorrection: 0,
};
export default function BraceEditorStage({
caseId,
braceData,
onRegenerate,
onExportSTL,
}: Props) {
const [params, setParams] = useState<DeformationParams>(DEFAULT_PARAMS);
const [markers, setMarkers] = useState<MarkerInfo[]>([]);
const [isRegenerating, setIsRegenerating] = useState(false);
const [showMarkers, setShowMarkers] = useState(true);
// Get GLB URL from brace data
const glbUrl = braceData?.outputs?.glb?.url || braceData?.outputs?.shell_glb?.url || null;
const stlUrl = braceData?.outputs?.stl?.url || braceData?.outputs?.shell_stl?.url || null;
// Handle parameter change
const handleParamChange = useCallback((key: keyof DeformationParams, value: number) => {
setParams(prev => ({ ...prev, [key]: value }));
}, []);
// Handle regenerate
const handleRegenerate = useCallback(async () => {
setIsRegenerating(true);
try {
await onRegenerate(params);
} finally {
setIsRegenerating(false);
}
}, [params, onRegenerate]);
// Handle markers loaded
const handleMarkersLoaded = useCallback((loadedMarkers: MarkerInfo[]) => {
setMarkers(loadedMarkers);
}, []);
// Reset parameters
const handleReset = useCallback(() => {
setParams(DEFAULT_PARAMS);
}, []);
if (!braceData) {
return (
<div className="pipeline-stage brace-editor-stage">
<div className="stage-header">
<h2>Stage 5: Brace Editor</h2>
<div className="stage-status">
<span className="status-badge status-pending">Waiting for Brace</span>
</div>
</div>
<div className="stage-content">
<div className="editor-empty">
<p>Generate a brace first to use the 3D editor.</p>
</div>
</div>
</div>
);
}
return (
<div className="pipeline-stage brace-editor-stage">
<div className="stage-header">
<h2>Stage 5: Brace Editor</h2>
<div className="stage-status">
<span className="status-badge status-complete">Ready</span>
</div>
</div>
<div className="stage-content">
{/* 3D Viewer */}
<div className="brace-editor-main">
<BraceViewer
glbUrl={glbUrl}
width={700}
height={550}
showMarkers={showMarkers}
onMarkersLoaded={handleMarkersLoaded}
deformationParams={{
thoracicPadDepth: params.thoracicPadDepth,
lumbarPadDepth: params.lumbarPadDepth,
trunkShift: params.trunkShift,
}}
/>
{/* View Controls */}
<div className="viewer-controls">
<label className="checkbox-label">
<input
type="checkbox"
checked={showMarkers}
onChange={(e) => setShowMarkers(e.target.checked)}
/>
Show Markers
</label>
</div>
</div>
{/* Controls Sidebar */}
<div className="brace-editor-sidebar">
{/* Deformation Controls */}
<div className="deformation-controls">
<h3>Deformation Parameters</h3>
<div className="control-group">
<label>Thoracic Pad Depth (mm)</label>
<div className="control-slider">
<input
type="range"
min="0"
max="30"
step="1"
value={params.thoracicPadDepth}
onChange={(e) => handleParamChange('thoracicPadDepth', Number(e.target.value))}
/>
<span className="value">{params.thoracicPadDepth}</span>
</div>
</div>
<div className="control-group">
<label>Lumbar Pad Depth (mm)</label>
<div className="control-slider">
<input
type="range"
min="0"
max="25"
step="1"
value={params.lumbarPadDepth}
onChange={(e) => handleParamChange('lumbarPadDepth', Number(e.target.value))}
/>
<span className="value">{params.lumbarPadDepth}</span>
</div>
</div>
<div className="control-group">
<label>Trunk Shift (mm)</label>
<div className="control-slider">
<input
type="range"
min="-20"
max="20"
step="1"
value={params.trunkShift}
onChange={(e) => handleParamChange('trunkShift', Number(e.target.value))}
/>
<span className="value">{params.trunkShift}</span>
</div>
</div>
<div className="control-group">
<label>Rotation Correction (°)</label>
<div className="control-slider">
<input
type="range"
min="-15"
max="15"
step="1"
value={params.rotationCorrection}
onChange={(e) => handleParamChange('rotationCorrection', Number(e.target.value))}
/>
<span className="value">{params.rotationCorrection}</span>
</div>
</div>
<div className="control-actions">
<button className="btn secondary small" onClick={handleReset}>
Reset
</button>
<button
className="btn primary small"
onClick={handleRegenerate}
disabled={isRegenerating}
>
{isRegenerating ? 'Regenerating...' : 'Apply Changes'}
</button>
</div>
</div>
{/* Markers Panel */}
{markers.length > 0 && (
<div className="markers-panel">
<h3>Markers ({markers.length})</h3>
<div className="markers-list">
{markers.map((marker, idx) => (
<div key={idx} className="marker-item">
<span
className="marker-color"
style={{ backgroundColor: marker.color }}
/>
<span className="marker-name">{marker.name.replace('LM_', '')}</span>
</div>
))}
</div>
</div>
)}
{/* Export Panel */}
<div className="export-panel">
<h3>Export</h3>
<div className="export-buttons">
{stlUrl && (
<a href={stlUrl} download={`brace_${caseId}.stl`} className="btn secondary">
Download STL
</a>
)}
{glbUrl && (
<a href={glbUrl} download={`brace_${caseId}.glb`} className="btn secondary">
Download GLB
</a>
)}
</div>
</div>
{/* Info Panel */}
<div className="info-panel">
<h3>About the Editor</h3>
<p>
Adjust the deformation parameters to customize the brace fit.
Changes are previewed in real-time.
</p>
<ul className="tips-list">
<li><strong>Thoracic Pad:</strong> Pressure on thoracic curve convexity</li>
<li><strong>Lumbar Pad:</strong> Counter-pressure on lumbar region</li>
<li><strong>Trunk Shift:</strong> Lateral correction force</li>
<li><strong>Rotation:</strong> De-rotation effect</li>
</ul>
<p className="hint">
Use mouse to orbit, scroll to zoom, right-click to pan.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,621 @@
/**
* Stage 5: Brace Fitting Inspection
* Shows both braces overlaid on the body scan to visualize fit
* LEFT panel: Position, Rotation, Scale controls
* RIGHT panel: Deformation sliders (Cobb angle, apex, etc.) from Stage 4
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import BraceInlineEditor, {
type BraceTransformParams,
DEFAULT_TRANSFORM_PARAMS
} from './BraceInlineEditor';
// Three.js is loaded dynamically
let THREE: any = null;
let STLLoader: any = null;
let GLTFLoader: any = null;
let OrbitControls: any = null;
type BraceFittingStageProps = {
caseId: string;
bodyScanUrl: string | null;
regularBraceUrl: string | null;
vaseBraceUrl: string | null;
braceData: any;
};
export default function BraceFittingStage({
caseId,
bodyScanUrl,
regularBraceUrl,
vaseBraceUrl,
braceData,
}: BraceFittingStageProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<any>(null);
const sceneRef = useRef<any>(null);
const cameraRef = useRef<any>(null);
const controlsRef = useRef<any>(null);
const animationFrameRef = useRef<number>(0);
// Mesh references
const bodyMeshRef = useRef<any>(null);
const regularBraceMeshRef = useRef<any>(null);
const vaseBraceMeshRef = useRef<any>(null);
const regularBaseGeomRef = useRef<any>(null);
const vaseBaseGeomRef = useRef<any>(null);
// Store base transforms for relative positioning
const baseScaleRef = useRef<number>(1);
const [threeLoaded, setThreeLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Visibility controls
const [showBody, setShowBody] = useState(true);
const [showRegularBrace, setShowRegularBrace] = useState(true);
const [showVaseBrace, setShowVaseBrace] = useState(false);
const [bodyOpacity, setBodyOpacity] = useState(0.3);
const [autoRotate, setAutoRotate] = useState(false);
// Position/Rotation/Scale controls (for moving meshes)
const [bracePositionX, setBracePositionX] = useState(0);
const [bracePositionY, setBracePositionY] = useState(0);
const [bracePositionZ, setBracePositionZ] = useState(0);
const [braceRotationX, setBraceRotationX] = useState(0);
const [braceRotationY, setBraceRotationY] = useState(0);
const [braceRotationZ, setBraceRotationZ] = useState(0);
const [braceScaleX, setBraceScaleX] = useState(1.0);
const [braceScaleY, setBraceScaleY] = useState(1.0);
const [braceScaleZ, setBraceScaleZ] = useState(1.0);
// Body position/rotation/scale
const [bodyPositionX, setBodyPositionX] = useState(0);
const [bodyPositionY, setBodyPositionY] = useState(0);
const [bodyPositionZ, setBodyPositionZ] = useState(0);
const [bodyRotationX, setBodyRotationX] = useState(0);
const [bodyRotationY, setBodyRotationY] = useState(0);
const [bodyRotationZ, setBodyRotationZ] = useState(0);
const [bodyScale, setBodyScale] = useState(1.0);
// Which brace is being edited
const [activeBrace, setActiveBrace] = useState<'regular' | 'vase'>('regular');
// Transform params for each brace (deformation sliders)
const [regularParams, setRegularParams] = useState<BraceTransformParams>(() => ({
...DEFAULT_TRANSFORM_PARAMS,
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || 25,
}));
const [vaseParams, setVaseParams] = useState<BraceTransformParams>(() => ({
...DEFAULT_TRANSFORM_PARAMS,
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || 25,
}));
// Colors
const BODY_COLOR = 0xf5d0c5;
const REGULAR_BRACE_COLOR = 0x4a90d9;
const VASE_BRACE_COLOR = 0x50c878;
// Load Three.js
useEffect(() => {
const loadThree = async () => {
try {
const threeModule = await import('three');
THREE = threeModule;
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
STLLoader = STL;
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
GLTFLoader = GLTF;
const { OrbitControls: Controls } = await import('three/examples/jsm/controls/OrbitControls.js');
OrbitControls = Controls;
setThreeLoaded(true);
} catch (e) {
console.error('Failed to load Three.js:', e);
setError('Failed to load 3D viewer');
}
};
loadThree();
return () => {
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
if (rendererRef.current) rendererRef.current.dispose();
};
}, []);
// Initialize scene
useEffect(() => {
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
const container = containerRef.current;
const width = container.clientWidth;
const height = container.clientHeight;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
// For Z-up meshes (medical/scanner convention), view from front (Y axis)
// Camera at Y=-800 looking at torso level (Z~300)
camera.position.set(0, -800, 300);
camera.lookAt(0, 0, 300);
camera.up.set(0, 0, 1); // Z is up
cameraRef.current = camera;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.sortObjects = true;
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 0, 300); // Look at torso level
controlsRef.current = controls;
// Lighting (adjusted for Z-up)
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const keyLight = new THREE.DirectionalLight(0xffffff, 0.8);
keyLight.position.set(200, -300, 500); // Front-top-right
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x88ccff, 0.5);
fillLight.position.set(-200, -100, 400); // Front-left
scene.add(fillLight);
const backLight = new THREE.DirectionalLight(0xffffcc, 0.4);
backLight.position.set(0, 300, 300); // Back
scene.add(backLight);
// Grid on XY plane (floor for Z-up world)
const gridHelper = new THREE.GridHelper(400, 20, 0x444444, 0x333333);
gridHelper.rotation.x = Math.PI / 2; // Rotate to XY plane
gridHelper.position.z = 0; // At Z=0 (floor level)
scene.add(gridHelper);
const animate = () => {
animationFrameRef.current = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
const handleResize = () => {
if (!container || !renderer || !camera) return;
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [threeLoaded]);
useEffect(() => {
if (controlsRef.current) controlsRef.current.autoRotate = autoRotate;
}, [autoRotate]);
// Deformation algorithm
const applyDeformation = useCallback((geometry: any, params: BraceTransformParams) => {
if (!THREE || !geometry) return geometry;
const deformed = geometry.clone();
const positions = deformed.getAttribute('position');
if (!positions) return deformed;
deformed.computeBoundingBox();
const bbox = deformed.boundingBox;
// For Z-up convention (medical/scanner), Z is vertical (height)
const minZ = bbox?.min?.z || 0, maxZ = bbox?.max?.z || 1;
const minX = bbox?.min?.x || -0.5, maxX = bbox?.max?.x || 0.5;
const minY = bbox?.min?.y || -0.5, maxY = bbox?.max?.y || 0.5;
const centerX = (minX + maxX) / 2, centerY = (minY + maxY) / 2;
const bboxHeight = maxZ - minZ, bboxWidth = maxX - minX, bboxDepth = maxY - minY;
const pelvis = { x: centerX, y: centerY, z: minZ };
const braceHeight = bboxHeight || 1;
const unitsPerMm = braceHeight / Math.max(1e-6, params.expectedBraceHeightMm);
const sev = Math.max(0, Math.min(1, (params.cobbDeg - 15) / 40));
const padDepthMm = (8 + 12 * sev) * params.strengthMult;
const bayClearMm = padDepthMm * 1.2;
const padDepth = padDepthMm * unitsPerMm;
const bayClear = bayClearMm * unitsPerMm;
const sizeScale = 0.9 + 0.5 * sev;
// For Z-up convention: Z is height, Y is front-back depth, X is left-right width
const features: Array<{ center: {x:number,y:number,z:number}; radii: {x:number,y:number,z:number}; depth: number; direction: 1|-1; falloffPower: number }> = [];
// Thoracic pad & bay (z is now height, y is depth)
features.push({ center: { x: centerX + bboxWidth * 0.35, y: centerY - bboxDepth * 0.1, z: minZ + bboxHeight * params.apexNorm }, radii: { x: 45*unitsPerMm*sizeScale, y: 35*unitsPerMm*sizeScale, z: 90*unitsPerMm*sizeScale }, depth: padDepth, direction: -1, falloffPower: 2.0 });
features.push({ center: { x: centerX - bboxWidth * 0.35, y: centerY - bboxDepth * 0.1, z: minZ + bboxHeight * params.apexNorm }, radii: { x: 60*unitsPerMm*sizeScale, y: 55*unitsPerMm*sizeScale, z: 110*unitsPerMm*sizeScale }, depth: bayClear, direction: 1, falloffPower: 1.6 });
// Lumbar pad & bay
features.push({ center: { x: centerX - bboxWidth * 0.3, y: centerY, z: minZ + bboxHeight * params.lumbarApexNorm }, radii: { x: 50*unitsPerMm*sizeScale, y: 40*unitsPerMm*sizeScale, z: 80*unitsPerMm*sizeScale }, depth: padDepth*0.9, direction: -1, falloffPower: 2.0 });
features.push({ center: { x: centerX + bboxWidth * 0.3, y: centerY, z: minZ + bboxHeight * params.lumbarApexNorm }, radii: { x: 65*unitsPerMm*sizeScale, y: 55*unitsPerMm*sizeScale, z: 95*unitsPerMm*sizeScale }, depth: bayClear*0.9, direction: 1, falloffPower: 1.6 });
// Hip anchors
const hipDepth = params.hipAnchorStrengthMm * unitsPerMm * params.strengthMult;
if (hipDepth > 0) {
features.push({ center: { x: centerX - bboxWidth * 0.4, y: centerY, z: minZ + bboxHeight * 0.1 }, radii: { x: 35*unitsPerMm, y: 35*unitsPerMm, z: 55*unitsPerMm }, depth: hipDepth, direction: -1, falloffPower: 2.2 });
features.push({ center: { x: centerX + bboxWidth * 0.4, y: centerY, z: minZ + bboxHeight * 0.1 }, radii: { x: 35*unitsPerMm, y: 35*unitsPerMm, z: 55*unitsPerMm }, depth: hipDepth, direction: -1, falloffPower: 2.2 });
}
for (let i = 0; i < positions.count; i++) {
let x = positions.getX(i), y = positions.getY(i), z = positions.getZ(i);
if (params.mirrorX) x = -x;
// For Z-up: height is along Z axis
const heightNorm = Math.max(0, Math.min(1, (z - pelvis.z) / braceHeight));
x += params.trunkShiftMm * unitsPerMm * heightNorm * 0.8;
for (const f of features) {
const dx = (x - f.center.x) / f.radii.x, dy = (y - f.center.y) / f.radii.y, dz = (z - f.center.z) / f.radii.z;
const d2 = dx*dx + dy*dy + dz*dz;
if (d2 >= 1) continue;
const t = Math.pow(1 - d2, f.falloffPower);
const disp = f.depth * t * f.direction;
// For Z-up: radial direction is in XY plane
const axisX = x - pelvis.x, axisY = y - pelvis.y;
const len = Math.sqrt(axisX*axisX + axisY*axisY) || 1;
x += (axisX/len) * disp;
y += (axisY/len) * disp;
}
positions.setXYZ(i, x, y, z);
}
positions.needsUpdate = true;
deformed.computeVertexNormals();
return deformed;
}, []);
// Load mesh
const loadMesh = useCallback(async (url: string, color: number, opacity: number, isBody: boolean = false): Promise<{ mesh: any; baseGeometry: any }> => {
return new Promise((resolve, reject) => {
const ext = url.toLowerCase().split('.').pop() || '';
const createMaterial = () => new THREE.MeshStandardMaterial({
color, roughness: 0.6, metalness: 0.1, transparent: true, opacity,
side: THREE.DoubleSide, depthWrite: !isBody, depthTest: true,
});
if (ext === 'stl') {
new STLLoader().load(url, (geometry: any) => {
geometry.center();
geometry.computeVertexNormals();
const baseGeometry = geometry.clone();
const mesh = new THREE.Mesh(geometry, createMaterial());
// Don't apply fixed rotation - let user adjust with controls
// mesh.rotation.x = -Math.PI / 2;
mesh.renderOrder = isBody ? 10 : 1;
resolve({ mesh, baseGeometry });
}, undefined, reject);
} else if (ext === 'glb' || ext === 'gltf') {
new GLTFLoader().load(url, (gltf: any) => {
const mesh = gltf.scene;
let baseGeometry: any = null;
mesh.traverse((child: any) => {
if (child.isMesh) {
child.material = createMaterial();
child.renderOrder = isBody ? 10 : 1;
if (!baseGeometry) baseGeometry = child.geometry.clone();
}
});
resolve({ mesh, baseGeometry });
}, undefined, reject);
} else reject(new Error(`Unsupported: ${ext}`));
});
}, []);
// Load all meshes
useEffect(() => {
if (!threeLoaded || !sceneRef.current) return;
const scene = sceneRef.current;
setLoading(true);
setError(null);
[bodyMeshRef, regularBraceMeshRef, vaseBraceMeshRef].forEach(ref => {
if (ref.current) { scene.remove(ref.current); ref.current = null; }
});
regularBaseGeomRef.current = null;
vaseBaseGeomRef.current = null;
const loadAll = async () => {
try {
const meshes: any[] = [];
if (regularBraceUrl) {
const { mesh, baseGeometry } = await loadMesh(regularBraceUrl, REGULAR_BRACE_COLOR, 0.9, false);
regularBraceMeshRef.current = mesh;
regularBaseGeomRef.current = baseGeometry;
scene.add(mesh);
meshes.push(mesh);
}
if (vaseBraceUrl) {
const { mesh, baseGeometry } = await loadMesh(vaseBraceUrl, VASE_BRACE_COLOR, 0.9, false);
mesh.visible = showVaseBrace;
vaseBraceMeshRef.current = mesh;
vaseBaseGeomRef.current = baseGeometry;
scene.add(mesh);
meshes.push(mesh);
}
if (bodyScanUrl) {
const { mesh } = await loadMesh(bodyScanUrl, BODY_COLOR, bodyOpacity, true);
bodyMeshRef.current = mesh;
scene.add(mesh);
meshes.push(mesh);
}
if (meshes.length > 0) {
const combinedBox = new THREE.Box3();
meshes.forEach(m => combinedBox.union(new THREE.Box3().setFromObject(m)));
const size = combinedBox.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 350 / maxDim;
baseScaleRef.current = scale;
meshes.forEach(m => m.scale.multiplyScalar(scale));
const newBox = new THREE.Box3();
meshes.forEach(m => newBox.union(new THREE.Box3().setFromObject(m)));
const center = newBox.getCenter(new THREE.Vector3());
meshes.forEach(m => m.position.sub(center));
cameraRef.current.position.set(0, 50, 500);
cameraRef.current.lookAt(0, 0, 0);
}
setLoading(false);
} catch (err) {
console.error('Failed to load:', err);
setError('Failed to load 3D models');
setLoading(false);
}
};
loadAll();
}, [threeLoaded, bodyScanUrl, regularBraceUrl, vaseBraceUrl, loadMesh, bodyOpacity, showVaseBrace]);
// Visibility updates
useEffect(() => { if (bodyMeshRef.current) bodyMeshRef.current.visible = showBody; }, [showBody]);
useEffect(() => { if (regularBraceMeshRef.current) regularBraceMeshRef.current.visible = showRegularBrace; }, [showRegularBrace]);
useEffect(() => { if (vaseBraceMeshRef.current) vaseBraceMeshRef.current.visible = showVaseBrace; }, [showVaseBrace]);
// Body opacity
useEffect(() => {
if (!bodyMeshRef.current) return;
const update = (mat: any) => { mat.opacity = bodyOpacity; mat.needsUpdate = true; };
bodyMeshRef.current.traverse((c: any) => { if (c.isMesh && c.material) update(c.material); });
if (bodyMeshRef.current.material) update(bodyMeshRef.current.material);
}, [bodyOpacity]);
// Apply position/rotation/scale to braces
useEffect(() => {
const applyToBrace = (mesh: any) => {
if (!mesh) return;
mesh.position.x = bracePositionX;
mesh.position.y = bracePositionY;
mesh.position.z = bracePositionZ;
// Convert degrees to radians, no fixed offset
mesh.rotation.x = braceRotationX * Math.PI / 180;
mesh.rotation.y = braceRotationY * Math.PI / 180;
mesh.rotation.z = braceRotationZ * Math.PI / 180;
const base = baseScaleRef.current;
mesh.scale.set(base * braceScaleX, base * braceScaleY, base * braceScaleZ);
};
applyToBrace(regularBraceMeshRef.current);
applyToBrace(vaseBraceMeshRef.current);
}, [bracePositionX, bracePositionY, bracePositionZ, braceRotationX, braceRotationY, braceRotationZ, braceScaleX, braceScaleY, braceScaleZ]);
// Apply position/rotation/scale to body
useEffect(() => {
if (!bodyMeshRef.current) return;
bodyMeshRef.current.position.x = bodyPositionX;
bodyMeshRef.current.position.y = bodyPositionY;
bodyMeshRef.current.position.z = bodyPositionZ;
// Convert degrees to radians, no fixed offset
bodyMeshRef.current.rotation.x = bodyRotationX * Math.PI / 180;
bodyMeshRef.current.rotation.y = bodyRotationY * Math.PI / 180;
bodyMeshRef.current.rotation.z = bodyRotationZ * Math.PI / 180;
const base = baseScaleRef.current;
bodyMeshRef.current.scale.set(base * bodyScale, base * bodyScale, base * bodyScale);
}, [bodyPositionX, bodyPositionY, bodyPositionZ, bodyRotationX, bodyRotationY, bodyRotationZ, bodyScale]);
// Apply deformation to braces
useEffect(() => {
if (!regularBraceMeshRef.current || !regularBaseGeomRef.current) return;
const deformed = applyDeformation(regularBaseGeomRef.current.clone(), regularParams);
regularBraceMeshRef.current.traverse((c: any) => { if (c.isMesh) { c.geometry.dispose(); c.geometry = deformed; }});
if (regularBraceMeshRef.current.geometry) { regularBraceMeshRef.current.geometry.dispose(); regularBraceMeshRef.current.geometry = deformed; }
}, [regularParams, applyDeformation]);
useEffect(() => {
if (!vaseBraceMeshRef.current || !vaseBaseGeomRef.current) return;
const deformed = applyDeformation(vaseBaseGeomRef.current.clone(), vaseParams);
vaseBraceMeshRef.current.traverse((c: any) => { if (c.isMesh) { c.geometry.dispose(); c.geometry = deformed; }});
if (vaseBraceMeshRef.current.geometry) { vaseBraceMeshRef.current.geometry.dispose(); vaseBraceMeshRef.current.geometry = deformed; }
}, [vaseParams, applyDeformation]);
// Handlers
const handleParamsChange = useCallback((params: BraceTransformParams) => {
if (activeBrace === 'regular') setRegularParams(params);
else setVaseParams(params);
}, [activeBrace]);
const handleSave = useCallback(async () => { console.log('Save not implemented'); }, []);
const handleReset = useCallback(() => {
const reset = { ...DEFAULT_TRANSFORM_PARAMS, cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || 25 };
if (activeBrace === 'regular') setRegularParams(reset);
else setVaseParams(reset);
}, [activeBrace, braceData?.cobb_angles]);
const handleResetTransforms = () => {
setBracePositionX(0); setBracePositionY(0); setBracePositionZ(0);
setBraceRotationX(0); setBraceRotationY(0); setBraceRotationZ(0);
setBraceScaleX(1); setBraceScaleY(1); setBraceScaleZ(1);
setBodyPositionX(0); setBodyPositionY(0); setBodyPositionZ(0);
setBodyRotationX(0); setBodyRotationY(0); setBodyRotationZ(0);
setBodyScale(1);
};
const hasBodyScan = !!bodyScanUrl;
const hasBraces = regularBraceUrl || vaseBraceUrl;
if (!hasBodyScan && !hasBraces) {
return (
<div className="pipeline-stage fitting-stage">
<div className="stage-header">
<h2>Stage 5: Brace Fitting Inspection</h2>
<div className="stage-status"><span className="status-badge status-pending">Pending</span></div>
</div>
<div className="stage-content">
<div className="fitting-empty">
<p>Body scan and braces are required to inspect fitting.</p>
<p className="hint">Complete Stage 3 (Body Scan) and Stage 4 (Brace Generation) first.</p>
</div>
</div>
</div>
);
}
return (
<div className="pipeline-stage fitting-stage">
<div className="stage-header">
<h2>Stage 5: Brace Fitting Inspection</h2>
<div className="stage-status"><span className="status-badge status-complete">Ready</span></div>
</div>
<div className="fitting-layout-3col">
{/* LEFT PANEL: Position, Rotation, Scale */}
<div className="fitting-panel left-panel">
<h3>Transform Controls</h3>
{/* Visibility */}
<div className="panel-section">
<h4>Visibility</h4>
<label className="checkbox-row">
<input type="checkbox" checked={showBody} onChange={e => setShowBody(e.target.checked)} />
<span className="color-dot" style={{background:'#f5d0c5'}} />
<span>Body</span>
</label>
<label className="checkbox-row">
<input type="checkbox" checked={showRegularBrace} onChange={e => setShowRegularBrace(e.target.checked)} disabled={!regularBraceUrl} />
<span className="color-dot" style={{background:'#4a90d9'}} />
<span>Regular Brace</span>
</label>
<label className="checkbox-row">
<input type="checkbox" checked={showVaseBrace} onChange={e => setShowVaseBrace(e.target.checked)} disabled={!vaseBraceUrl} />
<span className="color-dot" style={{background:'#50c878'}} />
<span>Vase Brace</span>
</label>
</div>
{/* Body Opacity */}
<div className="panel-section">
<h4>Body Opacity: {Math.round(bodyOpacity*100)}%</h4>
<input type="range" min="0" max="100" value={bodyOpacity*100} onChange={e => setBodyOpacity(Number(e.target.value)/100)} />
</div>
{/* Brace Position */}
<div className="panel-section">
<h4>Brace Position</h4>
<div className="slider-compact"><span>X</span><input type="range" min="-100" max="100" value={bracePositionX} onChange={e => setBracePositionX(Number(e.target.value))} /><span>{bracePositionX}</span></div>
<div className="slider-compact"><span>Y</span><input type="range" min="-100" max="100" value={bracePositionY} onChange={e => setBracePositionY(Number(e.target.value))} /><span>{bracePositionY}</span></div>
<div className="slider-compact"><span>Z</span><input type="range" min="-100" max="100" value={bracePositionZ} onChange={e => setBracePositionZ(Number(e.target.value))} /><span>{bracePositionZ}</span></div>
</div>
{/* Brace Rotation */}
<div className="panel-section">
<h4>Brace Rotation</h4>
<div className="slider-compact"><span>X</span><input type="range" min="-180" max="180" value={braceRotationX} onChange={e => setBraceRotationX(Number(e.target.value))} /><span>{braceRotationX}°</span></div>
<div className="slider-compact"><span>Y</span><input type="range" min="-180" max="180" value={braceRotationY} onChange={e => setBraceRotationY(Number(e.target.value))} /><span>{braceRotationY}°</span></div>
<div className="slider-compact"><span>Z</span><input type="range" min="-180" max="180" value={braceRotationZ} onChange={e => setBraceRotationZ(Number(e.target.value))} /><span>{braceRotationZ}°</span></div>
</div>
{/* Brace Scale */}
<div className="panel-section">
<h4>Brace Scale</h4>
<div className="slider-compact"><span>X</span><input type="range" min="50" max="150" value={braceScaleX*100} onChange={e => setBraceScaleX(Number(e.target.value)/100)} /><span>{braceScaleX.toFixed(2)}</span></div>
<div className="slider-compact"><span>Y</span><input type="range" min="50" max="150" value={braceScaleY*100} onChange={e => setBraceScaleY(Number(e.target.value)/100)} /><span>{braceScaleY.toFixed(2)}</span></div>
<div className="slider-compact"><span>Z</span><input type="range" min="50" max="150" value={braceScaleZ*100} onChange={e => setBraceScaleZ(Number(e.target.value)/100)} /><span>{braceScaleZ.toFixed(2)}</span></div>
</div>
{/* Body Transform */}
<div className="panel-section">
<h4>Body Position</h4>
<div className="slider-compact"><span>X</span><input type="range" min="-100" max="100" value={bodyPositionX} onChange={e => setBodyPositionX(Number(e.target.value))} /><span>{bodyPositionX}</span></div>
<div className="slider-compact"><span>Y</span><input type="range" min="-100" max="100" value={bodyPositionY} onChange={e => setBodyPositionY(Number(e.target.value))} /><span>{bodyPositionY}</span></div>
<div className="slider-compact"><span>Z</span><input type="range" min="-100" max="100" value={bodyPositionZ} onChange={e => setBodyPositionZ(Number(e.target.value))} /><span>{bodyPositionZ}</span></div>
</div>
<div className="panel-section">
<h4>Body Rotation</h4>
<div className="slider-compact"><span>X</span><input type="range" min="-180" max="180" value={bodyRotationX} onChange={e => setBodyRotationX(Number(e.target.value))} /><span>{bodyRotationX}°</span></div>
<div className="slider-compact"><span>Y</span><input type="range" min="-180" max="180" value={bodyRotationY} onChange={e => setBodyRotationY(Number(e.target.value))} /><span>{bodyRotationY}°</span></div>
<div className="slider-compact"><span>Z</span><input type="range" min="-180" max="180" value={bodyRotationZ} onChange={e => setBodyRotationZ(Number(e.target.value))} /><span>{bodyRotationZ}°</span></div>
</div>
<div className="panel-section">
<h4>Body Scale: {bodyScale.toFixed(2)}</h4>
<input type="range" min="50" max="150" value={bodyScale*100} onChange={e => setBodyScale(Number(e.target.value)/100)} />
</div>
<button className="btn-reset-all" onClick={handleResetTransforms}>Reset All Transforms</button>
{/* View Options */}
<div className="panel-section">
<label className="checkbox-row">
<input type="checkbox" checked={autoRotate} onChange={e => setAutoRotate(e.target.checked)} />
<span>Auto Rotate</span>
</label>
</div>
</div>
{/* CENTER: 3D Viewer */}
<div className="fitting-viewer-container">
{!threeLoaded ? (
<div className="fitting-viewer-loading"><div className="spinner"></div><p>Loading 3D viewer...</p></div>
) : (
<>
<div ref={containerRef} className="fitting-viewer-canvas" />
{loading && <div className="fitting-viewer-overlay"><div className="spinner"></div><p>Loading models...</p></div>}
{error && <div className="fitting-viewer-overlay error"><p>{error}</p></div>}
</>
)}
</div>
{/* RIGHT PANEL: Deformation Sliders (Stage 4 Editor) */}
<div className="fitting-panel right-panel">
<h3>Deformation Controls</h3>
{/* Brace Selector */}
<div className="panel-section">
<h4>Edit Brace</h4>
<div className="brace-selector">
<button className={`brace-select-btn ${activeBrace === 'regular' ? 'active' : ''}`} onClick={() => setActiveBrace('regular')} disabled={!regularBraceUrl}>
Regular (Blue)
</button>
<button className={`brace-select-btn ${activeBrace === 'vase' ? 'active' : ''}`} onClick={() => setActiveBrace('vase')} disabled={!vaseBraceUrl}>
Vase (Green)
</button>
</div>
</div>
{/* The Stage 4 Editor */}
<BraceInlineEditor
braceType={activeBrace}
initialParams={activeBrace === 'regular' ? regularParams : vaseParams}
cobbAngles={braceData?.cobb_angles}
onParamsChange={handleParamsChange}
onSave={handleSave}
onReset={handleReset}
isModified={false}
className="fitting-inline-editor"
/>
{/* Tips */}
<div className="panel-section tips">
<h4>Tips</h4>
<ul>
<li>Blue areas = brace pushing INTO body</li>
<li>Increase Cobb angle for more pressure</li>
<li>Adjust apex to move correction zone</li>
<li>Use transforms on left to align meshes</li>
</ul>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,587 @@
/**
* Stage 4: Brace Generation
* Shows generated brace with 3D viewer and marker editing
* Displays both Regular and Vase brace types side-by-side
* Includes inline editors for real-time brace transformation
*/
import { useState, useRef, useCallback } from 'react';
import type { GenerateBraceResponse, DeformationZone } from '../../api/braceflowApi';
import { getModifiedBraceUploadUrl, uploadBlobToS3 } from '../../api/braceflowApi';
import BraceTransformViewer from '../three/BraceTransformViewer';
import type { BraceTransformViewerRef } from '../three/BraceTransformViewer';
import BraceInlineEditor, {
type BraceTransformParams,
DEFAULT_TRANSFORM_PARAMS
} from './BraceInlineEditor';
type Props = {
caseId: string;
braceData: GenerateBraceResponse | null;
isLoading: boolean;
onGenerate: () => Promise<void>;
onUpdateMarkers: (markers: Record<string, unknown>) => Promise<void>;
};
export default function BraceGenerationStage({
caseId,
braceData,
isLoading,
onGenerate,
onUpdateMarkers,
}: Props) {
const [showEditor, setShowEditor] = useState(false);
const [showInlineEditors, setShowInlineEditors] = useState(true);
const regularViewerRef = useRef<BraceTransformViewerRef>(null);
const vaseViewerRef = useRef<BraceTransformViewerRef>(null);
// Transform params state for each brace type
const [regularParams, setRegularParams] = useState<BraceTransformParams>(() => ({
...DEFAULT_TRANSFORM_PARAMS,
}));
const [vaseParams, setVaseParams] = useState<BraceTransformParams>(() => ({
...DEFAULT_TRANSFORM_PARAMS,
}));
// Track if braces have been modified
const [regularModified, setRegularModified] = useState(false);
const [vaseModified, setVaseModified] = useState(false);
// Handle transform params changes
const handleRegularParamsChange = useCallback((params: BraceTransformParams) => {
setRegularParams(params);
setRegularModified(true);
}, []);
const handleVaseParamsChange = useCallback((params: BraceTransformParams) => {
setVaseParams(params);
setVaseModified(true);
}, []);
// Track save status
const [saveStatus, setSaveStatus] = useState<string | null>(null);
// Handle save/upload for modified braces
const handleSaveRegular = useCallback(async (params: BraceTransformParams) => {
if (!regularViewerRef.current) return;
setSaveStatus('Exporting regular brace...');
try {
let savedCount = 0;
// Export STL
const stlBlob = await regularViewerRef.current.exportSTL();
if (stlBlob) {
setSaveStatus('Uploading regular STL...');
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'regular', 'stl');
await uploadBlobToS3(uploadUrl, stlBlob, contentType || 'application/octet-stream');
console.log('Regular STL uploaded to case storage');
savedCount++;
}
// Export GLB
const glbBlob = await regularViewerRef.current.exportGLB();
if (glbBlob) {
setSaveStatus('Uploading regular GLB...');
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'regular', 'glb');
await uploadBlobToS3(uploadUrl, glbBlob, contentType || 'model/gltf-binary');
console.log('Regular GLB uploaded to case storage');
savedCount++;
}
setSaveStatus(`Regular brace saved! (${savedCount} files)`);
setRegularModified(false);
setTimeout(() => setSaveStatus(null), 3000);
} catch (err) {
console.error('Failed to save regular brace:', err);
setSaveStatus(`Error: ${err instanceof Error ? err.message : 'Failed to save'}`);
setTimeout(() => setSaveStatus(null), 5000);
}
}, [caseId]);
const handleSaveVase = useCallback(async (params: BraceTransformParams) => {
if (!vaseViewerRef.current) return;
setSaveStatus('Exporting vase brace...');
try {
let savedCount = 0;
// Export STL
const stlBlob = await vaseViewerRef.current.exportSTL();
if (stlBlob) {
setSaveStatus('Uploading vase STL...');
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'vase', 'stl');
await uploadBlobToS3(uploadUrl, stlBlob, contentType || 'application/octet-stream');
console.log('Vase STL uploaded to case storage');
savedCount++;
}
// Export GLB
const glbBlob = await vaseViewerRef.current.exportGLB();
if (glbBlob) {
setSaveStatus('Uploading vase GLB...');
const { url: uploadUrl, contentType } = await getModifiedBraceUploadUrl(caseId, 'vase', 'glb');
await uploadBlobToS3(uploadUrl, glbBlob, contentType || 'model/gltf-binary');
console.log('Vase GLB uploaded to case storage');
savedCount++;
}
setSaveStatus(`Vase brace saved! (${savedCount} files)`);
setVaseModified(false);
setTimeout(() => setSaveStatus(null), 3000);
} catch (err) {
console.error('Failed to save vase brace:', err);
setSaveStatus(`Error: ${err instanceof Error ? err.message : 'Failed to save'}`);
setTimeout(() => setSaveStatus(null), 5000);
}
}, [caseId]);
// Handle reset
const handleResetRegular = useCallback(() => {
setRegularParams({
...DEFAULT_TRANSFORM_PARAMS,
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
});
setRegularModified(false);
}, [braceData?.cobb_angles]);
const handleResetVase = useCallback(() => {
setVaseParams({
...DEFAULT_TRANSFORM_PARAMS,
cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
});
setVaseModified(false);
}, [braceData?.cobb_angles]);
// Get output URLs for regular brace
const outputs = braceData?.outputs || {};
const stlUrl = outputs.stl?.url || (outputs as any).stl;
const glbUrl = outputs.glb?.url || (outputs as any).glb;
const vizUrl = outputs.visualization?.url || (outputs as any).visualization;
const jsonUrl = outputs.landmarks?.url || (outputs as any).landmarks;
// Get output URLs for vase brace (if available)
const braces = (braceData as any)?.braces || {};
const regularBrace = braces.regular || { outputs: { stl: stlUrl, glb: glbUrl } };
const vaseBrace = braces.vase || {};
// Helper function to format file size
const formatFileSize = (bytes?: number): string => {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
if (!braceData && !isLoading) {
return (
<div className="pipeline-stage brace-stage">
<div className="stage-header">
<h2>Stage 4: Brace Generation</h2>
<div className="stage-status">
<span className="status-badge status-pending">Pending</span>
</div>
</div>
<div className="stage-content">
<div className="brace-empty">
<p>Ready to generate custom brace based on approved landmarks and analysis.</p>
<button className="btn primary btn-large" onClick={onGenerate}>
Generate Brace
</button>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="pipeline-stage brace-stage">
<div className="stage-header">
<h2>Stage 4: Brace Generation</h2>
<div className="stage-status">
<span className="status-badge status-processing">Generating...</span>
</div>
</div>
<div className="stage-content">
<div className="brace-loading">
<div className="spinner large"></div>
<p>Generating custom braces...</p>
<p className="loading-hint">
Generating both Regular and Vase brace designs for comparison.
</p>
</div>
</div>
</div>
);
}
return (
<div className="pipeline-stage brace-stage">
<div className="stage-header">
<h2>Stage 4: Brace Generation</h2>
<div className="stage-status">
<span className="status-badge status-complete">Complete</span>
</div>
</div>
{/* Summary Panels - Right under header */}
<div className="brace-summary-row">
{/* Generation Summary */}
<div className="brace-panel summary-panel">
<h3>Generation Summary</h3>
<div className="summary-grid horizontal">
{braceData?.rigo_classification && (
<div className="summary-item">
<span className="summary-label">Rigo Type</span>
<span className="summary-value badge">
{braceData.rigo_classification.type}
</span>
</div>
)}
{braceData?.curve_type && (
<div className="summary-item">
<span className="summary-label">Curve</span>
<span className="summary-value">{braceData.curve_type}-Curve</span>
</div>
)}
{braceData?.processing_time_ms && (
<div className="summary-item">
<span className="summary-label">Time</span>
<span className="summary-value">
{(braceData.processing_time_ms / 1000).toFixed(1)}s
</span>
</div>
)}
</div>
</div>
{/* Cobb Angles Used */}
{braceData?.cobb_angles && (
<div className="brace-panel cobb-panel">
<h3>Cobb Angles Used</h3>
<div className="cobb-mini-grid horizontal">
<div className="cobb-mini">
<span className="cobb-label">PT</span>
<span className="cobb-value">{braceData.cobb_angles.PT?.toFixed(1)}°</span>
</div>
<div className="cobb-mini">
<span className="cobb-label">MT</span>
<span className="cobb-value">{braceData.cobb_angles.MT?.toFixed(1)}°</span>
</div>
<div className="cobb-mini">
<span className="cobb-label">TL</span>
<span className="cobb-value">{braceData.cobb_angles.TL?.toFixed(1)}°</span>
</div>
</div>
</div>
)}
</div>
{/* Toggle Editor Button + Save Status */}
<div className="editor-toggle-row">
{saveStatus && (
<span className={`save-status ${saveStatus.includes('Error') ? 'error' : saveStatus.includes('saved') ? 'success' : ''}`}>
{saveStatus}
</span>
)}
<button
className={`btn-toggle-editor ${showInlineEditors ? 'active' : ''}`}
onClick={() => setShowInlineEditors(!showInlineEditors)}
>
{showInlineEditors ? 'Hide Editors' : 'Show Editors'}
</button>
</div>
{/* Dual Brace Viewers with Inline Editors */}
<div className={`dual-brace-viewers ${showInlineEditors ? 'with-editors' : ''}`}>
{/* Regular Brace Viewer + Editor */}
<div className="brace-viewer-with-editor">
<div className="brace-viewer-container">
<div className="viewer-header">
<h3>Regular Brace</h3>
<span className="viewer-subtitle">Fitted design for precise correction</span>
{regularModified && <span className="modified-indicator">Modified</span>}
</div>
<div className="brace-viewer brace-viewer-3d">
<BraceTransformViewer
ref={regularViewerRef}
stlUrl={regularBrace.outputs?.stl || stlUrl}
glbUrl={regularBrace.outputs?.glb || glbUrl}
transformParams={regularParams}
autoRotate={!showInlineEditors}
rotationSpeed={0.005}
showMarkers={true}
showGrid={showInlineEditors}
/>
</div>
{regularBrace.meshStats && (
<div className="viewer-stats">
<span>{regularBrace.meshStats.vertices?.toLocaleString()} vertices</span>
<span>{regularBrace.meshStats.faces?.toLocaleString()} faces</span>
</div>
)}
</div>
{/* Inline Editor for Regular Brace */}
{showInlineEditors && (
<BraceInlineEditor
braceType="regular"
initialParams={regularParams}
cobbAngles={braceData?.cobb_angles}
onParamsChange={handleRegularParamsChange}
onSave={handleSaveRegular}
onReset={handleResetRegular}
isModified={regularModified}
className="viewer-inline-editor"
/>
)}
</div>
{/* Vase Brace Viewer + Editor */}
<div className="brace-viewer-with-editor">
<div className="brace-viewer-container">
<div className="viewer-header">
<h3>Vase Brace</h3>
<span className="viewer-subtitle">Smooth contoured design</span>
{vaseModified && <span className="modified-indicator">Modified</span>}
</div>
<div className="brace-viewer brace-viewer-3d">
{vaseBrace.outputs?.stl || vaseBrace.outputs?.glb ? (
<BraceTransformViewer
ref={vaseViewerRef}
stlUrl={vaseBrace.outputs?.stl}
glbUrl={vaseBrace.outputs?.glb}
transformParams={vaseParams}
autoRotate={!showInlineEditors}
rotationSpeed={0.005}
showMarkers={true}
showGrid={showInlineEditors}
/>
) : (
<div className="viewer-placeholder">
<div className="placeholder-icon">🏺</div>
<p>Vase brace not generated</p>
<p className="hint">Click "Generate Both" to create vase design</p>
</div>
)}
</div>
{vaseBrace.meshStats && (
<div className="viewer-stats">
<span>{vaseBrace.meshStats.vertices?.toLocaleString()} vertices</span>
<span>{vaseBrace.meshStats.faces?.toLocaleString()} faces</span>
</div>
)}
</div>
{/* Inline Editor for Vase Brace */}
{showInlineEditors && (vaseBrace.outputs?.stl || vaseBrace.outputs?.glb) && (
<BraceInlineEditor
braceType="vase"
initialParams={vaseParams}
cobbAngles={braceData?.cobb_angles}
onParamsChange={handleVaseParamsChange}
onSave={handleSaveVase}
onReset={handleResetVase}
isModified={vaseModified}
className="viewer-inline-editor"
/>
)}
</div>
</div>
{/* Brace Pressure Zones - Full Width Section */}
{braceData?.deformation_report?.zones && braceData.deformation_report.zones.length > 0 && (
<div className="pressure-zones-section">
<h3>Brace Pressure Zones</h3>
<p className="zones-desc">
Based on the Cobb angles and Rigo classification, the following pressure modifications were applied to the brace:
</p>
<div className="pressure-zones-grid">
{braceData.deformation_report.zones.map((zone: DeformationZone, idx: number) => (
<div
key={idx}
className={`zone-card ${zone.deform_mm < 0 ? 'zone-pressure' : 'zone-relief'}`}
>
<div className="zone-header">
<span className="zone-name">{zone.zone}</span>
<span className={`zone-value ${zone.deform_mm < 0 ? 'pressure' : 'relief'}`}>
{zone.deform_mm > 0 ? '+' : ''}{zone.deform_mm.toFixed(1)} mm
</span>
</div>
<span className="zone-reason">{zone.reason}</span>
</div>
))}
</div>
{braceData.deformation_report.patch_grid && (
<p className="patch-grid-info">
Patch Grid: {braceData.deformation_report.patch_grid}
</p>
)}
</div>
)}
{/* Download Files - Bottom Section */}
<div className="downloads-section">
<h3>Download Files</h3>
<div className="downloads-columns">
{/* Regular Brace Downloads */}
<div className="download-column">
<h4>Regular Brace</h4>
<div className="downloads-list">
{(regularBrace.outputs?.stl || stlUrl) && (
<a
href={regularBrace.outputs?.stl || stlUrl}
className="download-item"
download={`brace_${caseId}_regular.stl`}
>
<span className="download-icon">📦</span>
<span className="download-info">
<span className="download-name">regular.stl</span>
<span className="download-desc">For 3D printing</span>
</span>
<span className="download-action"></span>
</a>
)}
{(regularBrace.outputs?.glb || glbUrl) && (
<a
href={regularBrace.outputs?.glb || glbUrl}
className="download-item"
download={`brace_${caseId}_regular.glb`}
>
<span className="download-icon">🎮</span>
<span className="download-info">
<span className="download-name">regular.glb</span>
<span className="download-desc">For web/AR</span>
</span>
<span className="download-action"></span>
</a>
)}
{(regularBrace.outputs?.json || jsonUrl) && (
<a
href={regularBrace.outputs?.json || jsonUrl}
className="download-item"
download={`brace_${caseId}_regular_markers.json`}
>
<span className="download-icon">📄</span>
<span className="download-info">
<span className="download-name">markers.json</span>
<span className="download-desc">With markers</span>
</span>
<span className="download-action"></span>
</a>
)}
</div>
</div>
{/* Vase Brace Downloads */}
<div className="download-column">
<h4>Vase Brace</h4>
<div className="downloads-list">
{vaseBrace.outputs?.stl ? (
<>
<a
href={vaseBrace.outputs.stl}
className="download-item"
download={`brace_${caseId}_vase.stl`}
>
<span className="download-icon">📦</span>
<span className="download-info">
<span className="download-name">vase.stl</span>
<span className="download-desc">For 3D printing</span>
</span>
<span className="download-action"></span>
</a>
{vaseBrace.outputs?.glb && (
<a
href={vaseBrace.outputs.glb}
className="download-item"
download={`brace_${caseId}_vase.glb`}
>
<span className="download-icon">🎮</span>
<span className="download-info">
<span className="download-name">vase.glb</span>
<span className="download-desc">For web/AR</span>
</span>
<span className="download-action"></span>
</a>
)}
{vaseBrace.outputs?.json && (
<a
href={vaseBrace.outputs.json}
className="download-item"
download={`brace_${caseId}_vase_markers.json`}
>
<span className="download-icon">📄</span>
<span className="download-info">
<span className="download-name">markers.json</span>
<span className="download-desc">With markers</span>
</span>
<span className="download-action"></span>
</a>
)}
</>
) : (
<div className="download-placeholder">
<span>Not generated</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="stage-actions">
<button
className="btn secondary"
onClick={() => setShowEditor(!showEditor)}
>
{showEditor ? 'Hide Editor' : 'Edit Brace Markers'}
</button>
<button className="btn primary" onClick={onGenerate}>
Regenerate Brace
</button>
</div>
{/* Marker Editor Modal (placeholder) */}
{showEditor && (
<div className="marker-editor-overlay">
<div className="marker-editor-modal">
<div className="modal-header">
<h3>Brace Marker Editor</h3>
<button className="close-btn" onClick={() => setShowEditor(false)}>
×
</button>
</div>
<div className="modal-content">
<p>
The 3D marker editor integration is coming soon. This will allow you to:
</p>
<ul>
<li>Drag and reposition pressure pad markers</li>
<li>Adjust deformation depths</li>
<li>Preview changes in real-time</li>
<li>Regenerate the brace with modified parameters</li>
</ul>
<p className="hint">
For now, you can download the JSON file to manually edit marker positions.
</p>
</div>
<div className="modal-actions">
<button className="btn secondary" onClick={() => setShowEditor(false)}>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,253 @@
/**
* BraceInlineEditor - Inline slider controls for brace transformation
* Based on EXPERIMENT_6's brace-transform-playground-v2
*
* Provides real-time deformation controls that can be embedded
* directly within the BraceGenerationStage component.
*/
import { useState, useCallback, useEffect } from 'react';
export type BraceTransformParams = {
// Analysis parameters
cobbDeg: number;
apexNorm: number; // 0..1 along brace height
lumbarApexNorm: number; // 0..1
rotationScore: number; // 0..3
trunkShiftMm: number;
// Calibration
expectedBraceHeightMm: number;
strengthMult: number;
// Direction strategy
pushMode: 'normal' | 'radial' | 'lateral';
// Pelvis/anchors
hipAnchorStrengthMm: number;
// Debug
mirrorX: boolean;
};
export const DEFAULT_TRANSFORM_PARAMS: BraceTransformParams = {
cobbDeg: 25,
apexNorm: 0.62,
lumbarApexNorm: 0.40,
rotationScore: 0,
trunkShiftMm: 0,
expectedBraceHeightMm: 400,
strengthMult: 1.0,
pushMode: 'radial',
hipAnchorStrengthMm: 4,
mirrorX: false,
};
type SliderConfig = {
key: keyof BraceTransformParams;
label: string;
min: number;
max: number;
step: number;
unit?: string;
};
const SLIDER_CONFIGS: SliderConfig[] = [
{ key: 'cobbDeg', label: 'Cobb Angle', min: 0, max: 80, step: 1, unit: '°' },
{ key: 'apexNorm', label: 'Thoracic Apex Height', min: 0, max: 1, step: 0.01 },
{ key: 'lumbarApexNorm', label: 'Lumbar Apex Height', min: 0, max: 1, step: 0.01 },
{ key: 'rotationScore', label: 'Rotation', min: 0, max: 3, step: 0.1 },
{ key: 'trunkShiftMm', label: 'Trunk Shift', min: -40, max: 40, step: 1, unit: 'mm' },
{ key: 'strengthMult', label: 'Strength', min: 0.2, max: 2.0, step: 0.05, unit: 'x' },
{ key: 'hipAnchorStrengthMm', label: 'Hip Anchor', min: 0, max: 12, step: 1, unit: 'mm' },
];
const ADVANCED_SLIDER_CONFIGS: SliderConfig[] = [
{ key: 'expectedBraceHeightMm', label: 'Expected Height', min: 250, max: 650, step: 10, unit: 'mm' },
];
type Props = {
braceType: 'regular' | 'vase';
initialParams?: Partial<BraceTransformParams>;
cobbAngles?: { PT?: number; MT?: number; TL?: number };
onParamsChange: (params: BraceTransformParams) => void;
onSave: (params: BraceTransformParams) => void;
onReset: () => void;
isModified?: boolean;
className?: string;
};
export default function BraceInlineEditor({
braceType,
initialParams,
cobbAngles,
onParamsChange,
onSave,
onReset,
isModified = false,
className = '',
}: Props) {
const [params, setParams] = useState<BraceTransformParams>(() => ({
...DEFAULT_TRANSFORM_PARAMS,
...initialParams,
// Auto-set Cobb angle from analysis if available
cobbDeg: cobbAngles?.MT || cobbAngles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
}));
const [showAdvanced, setShowAdvanced] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Update params when initial params change
useEffect(() => {
if (initialParams) {
setParams(prev => ({
...prev,
...initialParams,
}));
}
}, [initialParams]);
// Notify parent of changes
const handleParamChange = useCallback((key: keyof BraceTransformParams, value: number | string | boolean) => {
setParams(prev => {
const updated = { ...prev, [key]: value };
onParamsChange(updated);
return updated;
});
}, [onParamsChange]);
// Handle save
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
await onSave(params);
} finally {
setIsSaving(false);
}
}, [params, onSave]);
// Handle reset
const handleReset = useCallback(() => {
const resetParams = {
...DEFAULT_TRANSFORM_PARAMS,
...initialParams,
cobbDeg: cobbAngles?.MT || cobbAngles?.TL || DEFAULT_TRANSFORM_PARAMS.cobbDeg,
};
setParams(resetParams);
onReset();
}, [initialParams, cobbAngles, onReset]);
const formatValue = (value: number, step: number, unit?: string): string => {
const formatted = step < 1 ? value.toFixed(2) : value.toString();
return unit ? `${formatted}${unit}` : formatted;
};
return (
<div className={`brace-inline-editor ${className}`}>
<div className="editor-header">
<h4>{braceType === 'regular' ? 'Regular' : 'Vase'} Brace Editor</h4>
{isModified && <span className="modified-badge">Modified</span>}
</div>
<div className="editor-sliders">
{SLIDER_CONFIGS.map(config => (
<div key={config.key} className="slider-row">
<div className="slider-label">
<span>{config.label}</span>
<span className="slider-value">
{formatValue(params[config.key] as number, config.step, config.unit)}
</span>
</div>
<input
type="range"
min={config.min}
max={config.max}
step={config.step}
value={params[config.key] as number}
onChange={(e) => handleParamChange(config.key, Number(e.target.value))}
className="slider-input"
/>
</div>
))}
</div>
{/* Push Mode Toggle */}
<div className="editor-mode-toggle">
<span className="toggle-label">Push Mode:</span>
<div className="toggle-buttons">
{(['normal', 'radial', 'lateral'] as const).map(mode => (
<button
key={mode}
className={`toggle-btn ${params.pushMode === mode ? 'active' : ''}`}
onClick={() => handleParamChange('pushMode', mode)}
>
{mode.charAt(0).toUpperCase() + mode.slice(1)}
</button>
))}
</div>
</div>
{/* Mirror Toggle */}
<div className="editor-checkbox">
<label>
<input
type="checkbox"
checked={params.mirrorX}
onChange={(e) => handleParamChange('mirrorX', e.target.checked)}
/>
<span>Mirror X (flip left/right)</span>
</label>
</div>
{/* Advanced Toggle */}
<button
className="advanced-toggle"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? '▼ Hide Advanced' : '▶ Show Advanced'}
</button>
{showAdvanced && (
<div className="editor-sliders advanced">
{ADVANCED_SLIDER_CONFIGS.map(config => (
<div key={config.key} className="slider-row">
<div className="slider-label">
<span>{config.label}</span>
<span className="slider-value">
{formatValue(params[config.key] as number, config.step, config.unit)}
</span>
</div>
<input
type="range"
min={config.min}
max={config.max}
step={config.step}
value={params[config.key] as number}
onChange={(e) => handleParamChange(config.key, Number(e.target.value))}
className="slider-input"
/>
</div>
))}
</div>
)}
{/* Action Buttons */}
<div className="editor-actions">
<button
className="btn-editor reset"
onClick={handleReset}
title="Reset to default values"
>
Reset
</button>
<button
className="btn-editor save"
onClick={handleSave}
disabled={isSaving}
title="Save modified brace to case storage"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,703 @@
/**
* Stage 1: Landmark Detection
* Interactive canvas - always editable, draw landmarks from JSON
* Supports green quadrilateral boxes around vertebrae with corner editing
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import type { LandmarksResult, VertebraeStructure, VertebraData } from '../../api/braceflowApi';
type Props = {
caseId: string;
landmarksData: LandmarksResult | null;
xrayUrl: string | null;
visualizationUrl: string | null;
isLoading: boolean;
onDetect: () => Promise<void>;
onApprove: (updatedLandmarks?: VertebraeStructure) => Promise<void>;
onUpdateLandmarks: (landmarks: VertebraeStructure) => Promise<void>;
};
type DragState = {
type: 'centroid' | 'corner';
level: string;
cornerIdx?: number; // 0-3 for corner drag
startX: number;
startY: number;
originalCorners: [number, number][] | null;
originalCentroid: [number, number];
};
type CornerHit = {
level: string;
cornerIdx: number;
};
export default function LandmarkDetectionStage({
caseId,
landmarksData,
xrayUrl,
visualizationUrl,
isLoading,
onDetect,
onApprove,
onUpdateLandmarks,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [structure, setStructure] = useState<VertebraeStructure | null>(null);
const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
const [hoveredLevel, setHoveredLevel] = useState<string | null>(null);
const [hoveredCorner, setHoveredCorner] = useState<CornerHit | null>(null);
const [dragState, setDragState] = useState<DragState | null>(null);
const [scale, setScale] = useState(1);
const [hasChanges, setHasChanges] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
// Initialize structure from landmarks data
useEffect(() => {
if (landmarksData?.vertebrae_structure) {
setStructure(JSON.parse(JSON.stringify(landmarksData.vertebrae_structure)));
setHasChanges(false);
}
}, [landmarksData]);
// Load X-ray image
useEffect(() => {
if (!xrayUrl) {
setImage(null);
setImageError(null);
return;
}
setImageError(null);
console.log('Loading X-ray from URL:', xrayUrl);
const img = new Image();
// Note: crossOrigin removed for local dev - add back for production with proper CORS
// img.crossOrigin = 'anonymous';
img.onload = () => {
console.log('X-ray loaded successfully:', img.naturalWidth, 'x', img.naturalHeight);
setImage(img);
setImageError(null);
// Calculate scale to fit container
if (containerRef.current) {
const maxWidth = containerRef.current.clientWidth - 40;
const maxHeight = containerRef.current.clientHeight - 40;
const scaleX = maxWidth / img.naturalWidth;
const scaleY = maxHeight / img.naturalHeight;
setScale(Math.min(scaleX, scaleY, 1));
}
};
img.onerror = (e) => {
setImage(null);
setImageError('Failed to load X-ray image. Please try refreshing the page.');
console.error('Failed to load X-ray image:', xrayUrl);
console.error('Error event:', e);
};
img.src = xrayUrl;
}, [xrayUrl]);
// Get vertebra by level
const getVertebra = useCallback((level: string): VertebraData | undefined => {
return structure?.vertebrae.find(v => v.level === level);
}, [structure]);
// Calculate centroid from corners
const calculateCentroid = (corners: [number, number][]): [number, number] => {
const sumX = corners.reduce((acc, c) => acc + c[0], 0);
const sumY = corners.reduce((acc, c) => acc + c[1], 0);
return [sumX / corners.length, sumY / corners.length];
};
// Update vertebra with new corners and recalculate centroid
const updateVertebraCorners = useCallback((level: string, newCorners: [number, number][]) => {
const newCentroid = calculateCentroid(newCorners);
setStructure(prev => {
if (!prev) return prev;
return {
...prev,
vertebrae: prev.vertebrae.map(v => {
if (v.level !== level) return v;
return {
...v,
manual_override: {
...v.manual_override,
enabled: true,
centroid_px: newCentroid,
corners_px: newCorners,
},
final_values: {
...v.final_values,
centroid_px: newCentroid,
corners_px: newCorners,
source: 'manual' as const,
},
};
}),
};
});
setHasChanges(true);
}, []);
// Update vertebra position (move centroid and all corners together)
const updateVertebraPosition = useCallback((level: string, newCentroid: [number, number], originalCorners: [number, number][] | null, originalCentroid: [number, number]) => {
// Calculate delta from original centroid
const dx = newCentroid[0] - originalCentroid[0];
const dy = newCentroid[1] - originalCentroid[1];
// Move all corners by same delta
let newCorners: [number, number][] | null = null;
if (originalCorners) {
newCorners = originalCorners.map(c => [c[0] + dx, c[1] + dy] as [number, number]);
}
setStructure(prev => {
if (!prev) return prev;
return {
...prev,
vertebrae: prev.vertebrae.map(v => {
if (v.level !== level) return v;
return {
...v,
manual_override: {
...v.manual_override,
enabled: true,
centroid_px: newCentroid,
corners_px: newCorners,
},
final_values: {
...v.final_values,
centroid_px: newCentroid,
corners_px: newCorners,
source: 'manual' as const,
},
};
}),
};
});
setHasChanges(true);
}, []);
// Reset vertebra to original
const resetVertebra = useCallback((level: string) => {
const original = landmarksData?.vertebrae_structure.vertebrae.find(v => v.level === level);
if (!original) return;
setStructure(prev => {
if (!prev) return prev;
return {
...prev,
vertebrae: prev.vertebrae.map(v => {
if (v.level !== level) return v;
return {
...v,
manual_override: {
enabled: false,
centroid_px: null,
corners_px: null,
orientation_deg: null,
confidence: null,
notes: null,
},
final_values: {
...original.scoliovis_data,
source: original.detected ? 'scoliovis' as const : 'undetected' as const,
},
};
}),
};
});
setHasChanges(true);
}, [landmarksData]);
// Draw canvas with green boxes and red centroids
const draw = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx || !image) return;
// Set canvas size
canvas.width = image.naturalWidth * scale;
canvas.height = image.naturalHeight * scale;
// Clear and draw image
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(scale, scale);
ctx.drawImage(image, 0, 0);
// Draw vertebrae if we have structure
if (structure) {
structure.vertebrae.forEach(v => {
const centroid = v.final_values?.centroid_px;
if (!centroid) return;
const [x, y] = centroid;
const isSelected = selectedLevel === v.level;
const isHovered = hoveredLevel === v.level;
const isManual = v.manual_override?.enabled;
// Draw green X-shape vertebra marker if corners exist
// Corner order: [top_left, top_right, bottom_left, bottom_right]
// Drawing 0→1→2→3→0 creates the X pattern that shows endplate orientations
const corners = v.final_values?.corners_px;
if (corners && corners.length === 4) {
// Set line style based on state
if (isSelected) {
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
} else if (isManual) {
ctx.strokeStyle = '#00cc00';
ctx.lineWidth = 1.5;
} else {
ctx.strokeStyle = '#22aa22';
ctx.lineWidth = 1;
}
// Draw the X-shape: 0→1, 1→2, 2→3, 3→0
// This creates: top edge, diagonal, bottom edge, diagonal (X pattern)
ctx.beginPath();
for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4;
ctx.moveTo(corners[i][0], corners[i][1]);
ctx.lineTo(corners[j][0], corners[j][1]);
}
ctx.stroke();
// Draw corner handles for selected vertebra
if (isSelected) {
corners.forEach((corner, idx) => {
const isCornerHovered = hoveredCorner?.level === v.level && hoveredCorner?.cornerIdx === idx;
ctx.beginPath();
ctx.arc(corner[0], corner[1], isCornerHovered ? 6 : 4, 0, Math.PI * 2);
ctx.fillStyle = isCornerHovered ? '#ffff00' : '#00ff00';
ctx.fill();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.stroke();
});
}
}
// Determine centroid color
let fillColor = '#ff3333'; // Detected (red)
if (isManual) fillColor = '#ff6600'; // Manual (orange-red to distinguish)
else if (!v.detected) fillColor = '#888888'; // Undetected (gray)
// Draw centroid circle
const radius = isSelected || isHovered ? 8 : 5;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = fillColor;
ctx.fill();
// Highlight ring for centroid
if (isSelected) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
} else if (isHovered) {
ctx.strokeStyle = '#ffff00';
ctx.lineWidth = 2;
ctx.stroke();
} else {
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.stroke();
}
// Draw label
ctx.font = 'bold 11px Arial';
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 3;
ctx.strokeText(v.level, x + 12, y + 4);
ctx.fillText(v.level, x + 12, y + 4);
});
}
ctx.restore();
}, [image, scale, structure, selectedLevel, hoveredLevel, hoveredCorner]);
useEffect(() => {
draw();
}, [draw]);
// Convert screen coords to image coords
const screenToImage = useCallback((clientX: number, clientY: number): [number, number] => {
const canvas = canvasRef.current;
if (!canvas) return [0, 0];
const rect = canvas.getBoundingClientRect();
const x = (clientX - rect.left) / scale;
const y = (clientY - rect.top) / scale;
return [x, y];
}, [scale]);
// Find corner point at position
const findCornerAt = useCallback((x: number, y: number): CornerHit | null => {
if (!structure) return null;
const threshold = 15 / scale;
for (const v of structure.vertebrae) {
const corners = v.final_values?.corners_px;
if (!corners) continue;
for (let i = 0; i < 4; i++) {
const dist = Math.sqrt((x - corners[i][0]) ** 2 + (y - corners[i][1]) ** 2);
if (dist < threshold) {
return { level: v.level, cornerIdx: i };
}
}
}
return null;
}, [structure, scale]);
// Find vertebra centroid at position
const findVertebraAt = useCallback((x: number, y: number): string | null => {
if (!structure) return null;
const threshold = 20 / scale;
let closest: string | null = null;
let minDist = threshold;
structure.vertebrae.forEach(v => {
const centroid = v.final_values?.centroid_px;
if (!centroid) return;
const dist = Math.sqrt((x - centroid[0]) ** 2 + (y - centroid[1]) ** 2);
if (dist < minDist) {
minDist = dist;
closest = v.level;
}
});
return closest;
}, [structure, scale]);
// Mouse handlers
const handleMouseDown = (e: React.MouseEvent) => {
if (!structure) return;
const [x, y] = screenToImage(e.clientX, e.clientY);
// Check for corner hit first (only on selected vertebra for precision)
if (selectedLevel) {
const cornerHit = findCornerAt(x, y);
if (cornerHit && cornerHit.level === selectedLevel) {
const v = getVertebra(cornerHit.level);
if (v?.final_values?.corners_px && v?.final_values?.centroid_px) {
setDragState({
type: 'corner',
level: cornerHit.level,
cornerIdx: cornerHit.cornerIdx,
startX: e.clientX,
startY: e.clientY,
originalCorners: v.final_values.corners_px.map(c => [...c] as [number, number]),
originalCentroid: [...v.final_values.centroid_px] as [number, number],
});
return;
}
}
}
// Check for centroid hit
const level = findVertebraAt(x, y);
if (level) {
setSelectedLevel(level);
const v = getVertebra(level);
if (v?.final_values?.centroid_px) {
setDragState({
type: 'centroid',
level,
startX: e.clientX,
startY: e.clientY,
originalCorners: v.final_values.corners_px
? v.final_values.corners_px.map(c => [...c] as [number, number])
: null,
originalCentroid: [...v.final_values.centroid_px] as [number, number],
});
}
} else if (selectedLevel) {
// Place selected (missing) vertebra at click position
const v = getVertebra(selectedLevel);
if (v && (!v.final_values?.centroid_px || !v.detected)) {
// Create default corners around click position
const defaultHalfWidth = 15;
const defaultHalfHeight = 12;
const defaultCorners: [number, number][] = [
[x - defaultHalfWidth, y - defaultHalfHeight], // top_left
[x + defaultHalfWidth, y - defaultHalfHeight], // top_right
[x - defaultHalfWidth, y + defaultHalfHeight], // bottom_left
[x + defaultHalfWidth, y + defaultHalfHeight], // bottom_right
];
updateVertebraCorners(selectedLevel, defaultCorners);
}
}
};
const handleMouseMove = (e: React.MouseEvent) => {
const [x, y] = screenToImage(e.clientX, e.clientY);
if (dragState) {
if (dragState.type === 'corner' && dragState.cornerIdx !== undefined && dragState.originalCorners) {
// Dragging a corner
const dx = (e.clientX - dragState.startX) / scale;
const dy = (e.clientY - dragState.startY) / scale;
// Update just the dragged corner
const newCorners = dragState.originalCorners.map((c, i) => {
if (i === dragState.cornerIdx) {
return [c[0] + dx, c[1] + dy] as [number, number];
}
return [...c] as [number, number];
});
updateVertebraCorners(dragState.level, newCorners);
} else if (dragState.type === 'centroid') {
// Dragging the centroid (moves entire vertebra)
const dx = (e.clientX - dragState.startX) / scale;
const dy = (e.clientY - dragState.startY) / scale;
const newCentroid: [number, number] = [
dragState.originalCentroid[0] + dx,
dragState.originalCentroid[1] + dy,
];
updateVertebraPosition(dragState.level, newCentroid, dragState.originalCorners, dragState.originalCentroid);
}
} else {
// Hovering - check for corner first if a vertebra is selected
if (selectedLevel) {
const cornerHit = findCornerAt(x, y);
if (cornerHit && cornerHit.level === selectedLevel) {
setHoveredCorner(cornerHit);
setHoveredLevel(cornerHit.level);
return;
}
}
setHoveredCorner(null);
setHoveredLevel(findVertebraAt(x, y));
}
};
const handleMouseUp = () => {
setDragState(null);
};
// Determine cursor style
const getCursor = () => {
if (dragState) return 'grabbing';
if (hoveredCorner) return 'move';
if (hoveredLevel) return 'grab';
return 'crosshair';
};
// Handle approve
const handleApprove = async () => {
if (structure && hasChanges) {
await onUpdateLandmarks(structure);
}
await onApprove(structure || undefined);
};
// Stats
const detectedCount = structure?.vertebrae.filter(v => v.detected).length || 0;
const manualCount = structure?.vertebrae.filter(v => v.manual_override?.enabled).length || 0;
const placedCount = structure?.vertebrae.filter(v => v.final_values?.centroid_px).length || 0;
return (
<div className="pipeline-stage landmark-stage">
<div className="stage-header">
<h2>Stage 1: Vertebrae Detection</h2>
<div className="stage-status">
{isLoading ? (
<span className="status-badge status-processing">Processing...</span>
) : landmarksData ? (
<span className="status-badge status-complete">
{hasChanges ? 'Modified' : 'Detected'}
</span>
) : (
<span className="status-badge status-pending">Pending</span>
)}
</div>
</div>
<div className="stage-content landmark-interactive">
{/* Canvas area */}
<div className="landmark-canvas-area" ref={containerRef}>
{!landmarksData && !isLoading && (
<div className="landmark-empty">
{xrayUrl ? (
<>
<img src={xrayUrl} alt="X-ray" className="xray-preview" />
<p>X-ray uploaded. Click to detect landmarks.</p>
<button className="btn primary" onClick={onDetect}>
Detect Landmarks
</button>
</>
) : (
<p>Please upload an X-ray image first.</p>
)}
</div>
)}
{isLoading && (
<div className="landmark-loading">
<div className="spinner"></div>
<p>Detecting vertebrae landmarks...</p>
</div>
)}
{/* Error state when image fails to load */}
{imageError && (
<div className="landmark-error">
<p>{imageError}</p>
{xrayUrl && (
<button className="btn secondary" onClick={() => {
setImageError(null);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => setImage(img);
img.onerror = () => setImageError('Failed to load image');
img.src = xrayUrl;
}}>
Retry Loading
</button>
)}
</div>
)}
{/* Loading state while image is being fetched */}
{landmarksData && !image && !imageError && xrayUrl && (
<div className="landmark-loading">
<div className="spinner"></div>
<p>Loading X-ray image...</p>
</div>
)}
{landmarksData && image && (
<>
<canvas
ref={canvasRef}
className="landmark-canvas-interactive"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: getCursor() }}
/>
<div className="canvas-hint">
{selectedLevel && !getVertebra(selectedLevel)?.final_values?.centroid_px ? (
<span>Click on image to place <strong>{selectedLevel}</strong></span>
) : selectedLevel ? (
<span>Drag corners to adjust box shape Drag center to move Click another to select</span>
) : (
<span>Click a vertebra to select and edit Drag center to move</span>
)}
</div>
</>
)}
</div>
{/* Vertebrae panel */}
{landmarksData && structure && (
<div className="vertebrae-panel-inline">
<div className="panel-header">
<h3>Vertebrae</h3>
<div className="panel-stats">
<span className="stat-pill detected">{detectedCount} detected</span>
{manualCount > 0 && <span className="stat-pill manual">{manualCount} edited</span>}
</div>
</div>
<div className="vertebrae-scroll">
{structure.vertebrae.map(v => {
const hasCentroid = !!v.final_values?.centroid_px;
const hasCorners = !!v.final_values?.corners_px;
const isSelected = selectedLevel === v.level;
const isManual = v.manual_override?.enabled;
return (
<div
key={v.level}
className={`vert-row ${isSelected ? 'selected' : ''} ${isManual ? 'manual' : ''} ${!hasCentroid ? 'missing' : ''}`}
onClick={() => setSelectedLevel(v.level)}
>
<span className="vert-level">{v.level}</span>
<span className="vert-info">
{isManual ? (
<span className="info-manual">Manual</span>
) : v.detected ? (
<span className="info-conf">
{((v.scoliovis_data?.confidence || 0) * 100).toFixed(0)}%
{hasCorners && <span className="has-corners" title="Has box corners"> </span>}
</span>
) : hasCentroid ? (
<span className="info-placed">Placed</span>
) : (
<span className="info-missing">Click to place</span>
)}
</span>
{isManual && (
<button
className="vert-reset"
onClick={(e) => { e.stopPropagation(); resetVertebra(v.level); }}
title="Reset to original"
>
</button>
)}
</div>
);
})}
</div>
{/* Legend */}
<div className="panel-legend">
<span><i className="dot red"></i>Detected</span>
<span><i className="dot green"></i>Manual</span>
<span><i className="dot gray"></i>Missing</span>
</div>
{/* Quick analysis preview */}
{landmarksData.cobb_angles && (
<div className="quick-analysis">
<div className="qa-row">
<span>PT</span>
<span>{(landmarksData.cobb_angles.PT || 0).toFixed(1)}°</span>
</div>
<div className="qa-row">
<span>MT</span>
<span>{(landmarksData.cobb_angles.MT || 0).toFixed(1)}°</span>
</div>
<div className="qa-row">
<span>TL</span>
<span>{(landmarksData.cobb_angles.TL || 0).toFixed(1)}°</span>
</div>
<div className="qa-rigo">
<span className="rigo-tag">{landmarksData.rigo_classification?.type || 'N/A'}</span>
</div>
</div>
)}
</div>
)}
</div>
{/* Actions */}
{landmarksData && (
<div className="stage-actions">
{hasChanges && (
<span className="changes-indicator">Unsaved changes</span>
)}
<button
className="btn primary"
onClick={handleApprove}
disabled={isLoading}
>
{hasChanges ? 'Save & Continue' : 'Approve & Continue'}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
/**
* Pipeline Step Indicator
* Shows the current stage in the 5-stage pipeline
*/
export type PipelineStage = 'upload' | 'landmarks' | 'analysis' | 'bodyscan' | 'brace' | 'fitting';
type Props = {
currentStage: PipelineStage;
landmarksApproved: boolean;
analysisComplete: boolean;
bodyScanComplete: boolean;
braceGenerated: boolean;
onStageClick?: (stage: PipelineStage) => void;
};
const stages = [
{ id: 'landmarks' as const, label: 'Landmark Detection', step: 1 },
{ id: 'analysis' as const, label: 'Spine Analysis', step: 2 },
{ id: 'bodyscan' as const, label: 'Body Scan', step: 3 },
{ id: 'brace' as const, label: 'Brace Generation', step: 4 },
{ id: 'fitting' as const, label: 'Fitting Inspection', step: 5 },
];
export default function PipelineSteps({
currentStage,
landmarksApproved,
analysisComplete,
bodyScanComplete,
braceGenerated,
onStageClick
}: Props) {
const getStageStatus = (stageId: typeof stages[number]['id']) => {
if (stageId === 'landmarks') {
if (landmarksApproved) return 'complete';
if (currentStage === 'landmarks') return 'active';
return 'pending';
}
if (stageId === 'analysis') {
if (analysisComplete) return 'complete';
if (currentStage === 'analysis') return 'active';
return 'pending';
}
if (stageId === 'bodyscan') {
if (bodyScanComplete) return 'complete';
if (currentStage === 'bodyscan') return 'active';
return 'pending';
}
if (stageId === 'brace') {
if (braceGenerated) return 'complete';
if (currentStage === 'brace') return 'active';
return 'pending';
}
if (stageId === 'fitting') {
if (currentStage === 'fitting') return 'active';
if (braceGenerated) return 'available'; // Can navigate to fitting after brace is generated
return 'pending';
}
return 'pending';
};
const canNavigateToStage = (stageId: typeof stages[number]['id']) => {
// Can always go back to completed stages
const status = getStageStatus(stageId);
if (status === 'complete') return true;
if (status === 'active') return true;
if (status === 'available') return true;
return false;
};
return (
<div className="pipeline-steps">
{stages.map((stage, idx) => {
const status = getStageStatus(stage.id);
const canNavigate = canNavigateToStage(stage.id);
return (
<div
key={stage.id}
className={`pipeline-step pipeline-step--${status} ${canNavigate ? 'clickable' : ''}`}
onClick={() => canNavigate && onStageClick?.(stage.id)}
style={{ cursor: canNavigate ? 'pointer' : 'default' }}
>
<div className="pipeline-step-number">
{status === 'complete' ? '✓' : stage.step}
</div>
<div className="pipeline-step-label">{stage.label}</div>
{idx < stages.length - 1 && <div className="pipeline-step-connector" />}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,239 @@
/**
* Stage 2: Spine Analysis
* Shows Cobb angles, Rigo classification, and curve analysis
*/
import type { LandmarksResult, RecalculationResult } from '../../api/braceflowApi';
type Props = {
landmarksData: LandmarksResult | null;
analysisData: RecalculationResult | null;
isLoading: boolean;
onRecalculate: () => Promise<void>;
onContinue: () => void;
};
// Helper functions
function getSeverityClass(severity: string): string {
switch (severity?.toLowerCase()) {
case 'normal':
return 'severity-normal';
case 'mild':
return 'severity-mild';
case 'moderate':
return 'severity-moderate';
case 'severe':
case 'very severe':
return 'severity-severe';
default:
return '';
}
}
function getRigoDescription(type: string): string {
const descriptions: Record<string, string> = {
A1: 'Three-curve pattern with lumbar modifier - Main thoracic curve with compensatory lumbar',
A2: 'Three-curve pattern with thoracolumbar modifier - Thoracolumbar prominence',
A3: 'Three-curve pattern balanced - Balanced thoracic and lumbar curves',
B1: 'Four-curve pattern with lumbar modifier - Double thoracic with lumbar',
B2: 'Four-curve pattern with double thoracic - Primary double thoracic',
C1: 'Non-3 non-4 with thoracolumbar curve - Single thoracolumbar focus',
C2: 'Non-3 non-4 with lumbar curve - Single lumbar focus',
E1: 'Single thoracic curve - Primary thoracic scoliosis',
E2: 'Single thoracolumbar curve - Primary thoracolumbar scoliosis',
};
return descriptions[type] || `Rigo-Chêneau classification type ${type}`;
}
function getTreatmentRecommendation(maxCobb: number): {
recommendation: string;
urgency: string;
} {
if (maxCobb < 10) {
return {
recommendation: 'Observation only - no treatment required',
urgency: 'routine',
};
} else if (maxCobb < 25) {
return {
recommendation: 'Physical therapy and observation recommended',
urgency: 'standard',
};
} else if (maxCobb < 40) {
return {
recommendation: 'Brace treatment indicated - custom brace recommended',
urgency: 'priority',
};
} else if (maxCobb < 50) {
return {
recommendation: 'Aggressive bracing required - consider surgical consultation',
urgency: 'high',
};
} else {
return {
recommendation: 'Surgical consultation recommended',
urgency: 'urgent',
};
}
}
export default function SpineAnalysisStage({
landmarksData,
analysisData,
isLoading,
onRecalculate,
onContinue,
}: Props) {
// Use recalculated data if available, otherwise use initial detection data
const cobbAngles = analysisData?.cobb_angles || landmarksData?.cobb_angles;
const rigoClass = analysisData?.rigo_classification || landmarksData?.rigo_classification;
const curveType = analysisData?.curve_type || landmarksData?.curve_type;
if (!landmarksData) {
return (
<div className="pipeline-stage analysis-stage">
<div className="stage-header">
<h2>Stage 2: Spine Analysis</h2>
<div className="stage-status">
<span className="status-badge status-pending">Pending</span>
</div>
</div>
<div className="stage-content">
<div className="stage-empty">
<p>Complete Stage 1 (Landmark Detection) first.</p>
</div>
</div>
</div>
);
}
const maxCobb = cobbAngles ? Math.max(cobbAngles.PT, cobbAngles.MT, cobbAngles.TL) : 0;
const treatment = getTreatmentRecommendation(maxCobb);
return (
<div className="pipeline-stage analysis-stage">
<div className="stage-header">
<h2>Stage 2: Spine Analysis</h2>
<div className="stage-status">
{isLoading ? (
<span className="status-badge status-processing">Recalculating...</span>
) : (
<span className="status-badge status-complete">Analyzed</span>
)}
</div>
</div>
<div className="stage-content">
{/* Cobb Angles Panel */}
<div className="analysis-panel cobb-panel">
<h3>Cobb Angle Measurements</h3>
{cobbAngles && (
<div className="cobb-grid">
<div className={`cobb-card ${getSeverityClass(cobbAngles.PT_severity)}`}>
<div className="cobb-region">PT</div>
<div className="cobb-label">Proximal Thoracic</div>
<div className="cobb-value">{cobbAngles.PT.toFixed(1)}°</div>
<div className="cobb-severity">{cobbAngles.PT_severity}</div>
</div>
<div className={`cobb-card ${getSeverityClass(cobbAngles.MT_severity)}`}>
<div className="cobb-region">MT</div>
<div className="cobb-label">Main Thoracic</div>
<div className="cobb-value">{cobbAngles.MT.toFixed(1)}°</div>
<div className="cobb-severity">{cobbAngles.MT_severity}</div>
</div>
<div className={`cobb-card ${getSeverityClass(cobbAngles.TL_severity)}`}>
<div className="cobb-region">TL</div>
<div className="cobb-label">Thoracolumbar/Lumbar</div>
<div className="cobb-value">{cobbAngles.TL.toFixed(1)}°</div>
<div className="cobb-severity">{cobbAngles.TL_severity}</div>
</div>
</div>
)}
</div>
{/* Classification Panel */}
<div className="analysis-panel classification-panel">
<h3>Classification</h3>
<div className="classification-grid">
{rigoClass && (
<div className="classification-card rigo-card">
<div className="classification-type">Rigo-Chêneau</div>
<div className="classification-badge">{rigoClass.type}</div>
<div className="classification-desc">
{rigoClass.description || getRigoDescription(rigoClass.type)}
</div>
</div>
)}
{curveType && (
<div className="classification-card curve-card">
<div className="classification-type">Curve Pattern</div>
<div className="classification-badge">{curveType}-Curve</div>
<div className="classification-desc">
{curveType === 'S'
? 'Double curve pattern with thoracic and lumbar components'
: curveType === 'C'
? 'Single curve pattern'
: 'Curve pattern identified'}
</div>
</div>
)}
</div>
</div>
{/* Treatment Recommendation */}
<div className={`analysis-panel treatment-panel treatment-${treatment.urgency}`}>
<h3>Clinical Recommendation</h3>
<div className="treatment-content">
<div className="treatment-summary">
<span className="max-cobb">
Maximum Cobb Angle: <strong>{maxCobb.toFixed(1)}°</strong>
</span>
<span className={`urgency-badge urgency-${treatment.urgency}`}>
{treatment.urgency}
</span>
</div>
<p className="treatment-recommendation">{treatment.recommendation}</p>
</div>
</div>
{/* Analysis Metadata */}
<div className="analysis-panel metadata-panel">
<h3>Analysis Details</h3>
<div className="metadata-grid">
<div className="metadata-item">
<span className="metadata-label">Vertebrae Analyzed</span>
<span className="metadata-value">
{analysisData?.vertebrae_used || landmarksData?.vertebrae_structure.detected_count}
</span>
</div>
<div className="metadata-item">
<span className="metadata-label">Processing Time</span>
<span className="metadata-value">
{((analysisData?.processing_time_ms || landmarksData?.processing_time_ms || 0) / 1000).toFixed(2)}s
</span>
</div>
{analysisData && (
<div className="metadata-item">
<span className="metadata-label">Source</span>
<span className="metadata-value">Recalculated</span>
</div>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="stage-actions">
<button
className="btn secondary"
onClick={onRecalculate}
disabled={isLoading}
>
{isLoading ? 'Recalculating...' : 'Recalculate'}
</button>
<button className="btn primary" onClick={onContinue}>
Continue to Brace Generation
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
export { default as PipelineSteps } from './PipelineSteps';
export { default as LandmarkDetectionStage } from './LandmarkDetectionStage';
export { default as SpineAnalysisStage } from './SpineAnalysisStage';
export { default as BodyScanUploadStage } from './BodyScanUploadStage';
export { default as BraceGenerationStage } from './BraceGenerationStage';
export { default as BraceEditorStage } from './BraceEditorStage';
export { default as BraceInlineEditor } from './BraceInlineEditor';
export { default as BraceFittingStage } from './BraceFittingStage';
export type { PipelineStage } from './PipelineSteps';
export type { BraceTransformParams } from './BraceInlineEditor';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,301 @@
import { useState } from "react";
import { rigoApi, type AnalysisResult } from "../../api/rigoApi";
interface AnalysisResultsProps {
data: AnalysisResult | null;
modelUrl: string | null;
isLoading: boolean;
error: string | null;
onModelUpdate: (url: string) => void;
}
export default function AnalysisResults({ data, modelUrl, isLoading, error, onModelUpdate }: AnalysisResultsProps) {
const [showPadsOnly, setShowPadsOnly] = useState(true);
const [isRegenerating, setIsRegenerating] = useState(false);
const handleTogglePadsOnly = async () => {
if (!data || !onModelUpdate) return;
setIsRegenerating(true);
const newShowPadsOnly = !showPadsOnly;
try {
const result = await rigoApi.regenerate({
pressure_pad_level: data.apex,
pressure_pad_depth: data.cobb_angle > 40 ? "aggressive" : data.cobb_angle > 20 ? "moderate" : "standard",
expansion_window_side: data.pelvic_tilt === "Left" ? "right" : "left",
lumbar_support: true,
include_shell: !newShowPadsOnly,
});
if (result.success) {
// Construct full URL from relative path
const fullUrl = result.model_url.startsWith("http")
? result.model_url
: `${rigoApi.getBaseUrl()}${result.model_url}`;
onModelUpdate(fullUrl);
setShowPadsOnly(newShowPadsOnly);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
alert(`Failed to regenerate: ${message}`);
} finally {
setIsRegenerating(false);
}
};
const handleDownloadGLB = () => {
if (modelUrl) {
const link = document.createElement("a");
link.href = modelUrl;
link.download = `brace_${data?.apex || "custom"}.glb`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
alert("3D model generation requires Blender. The placeholder model is for visualization only.");
}
};
const handleDownloadSTL = () => {
if (modelUrl) {
const stlUrl = modelUrl.replace(".glb", ".stl");
const link = document.createElement("a");
link.href = stlUrl;
link.download = `brace_${data?.apex || "custom"}.stl`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
alert("3D model generation requires Blender. The placeholder model is for visualization only.");
}
};
if (error) {
return (
<div className="rigo-analysis-error">
<div className="rigo-analysis-card" style={{ borderColor: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }}>
<div className="rigo-analysis-label" style={{ color: "#f87171" }}>
Error
</div>
<div className="rigo-analysis-value" style={{ color: "#f87171", fontSize: "1rem" }}>
{error}
</div>
<div className="rigo-analysis-description">Please try uploading a clearer X-ray image.</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="rigo-analysis-loading">
<div className="rigo-analysis-card">
<div className="rigo-loading-skeleton" style={{ height: "20px", marginBottom: "8px", width: "40%" }}></div>
<div className="rigo-loading-skeleton" style={{ height: "36px", width: "60%" }}></div>
</div>
<div className="rigo-analysis-grid">
<div className="rigo-analysis-card">
<div className="rigo-loading-skeleton" style={{ height: "16px", marginBottom: "8px", width: "50%" }}></div>
<div className="rigo-loading-skeleton" style={{ height: "28px", width: "40%" }}></div>
</div>
<div className="rigo-analysis-card">
<div className="rigo-loading-skeleton" style={{ height: "16px", marginBottom: "8px", width: "50%" }}></div>
<div className="rigo-loading-skeleton" style={{ height: "28px", width: "40%" }}></div>
</div>
</div>
<p style={{ textAlign: "center", color: "#64748b", marginTop: "24px", fontSize: "0.875rem" }}>
<span className="rigo-spinner" style={{ display: "inline-block", marginRight: "8px", verticalAlign: "middle" }}></span>
Analyzing X-ray with Claude Vision...
</p>
</div>
);
}
if (!data) {
return (
<div className="rigo-analysis-empty">
<div style={{ textAlign: "center", padding: "32px", color: "#64748b" }}>
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
style={{ margin: "0 auto 16px", opacity: 0.3 }}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<p>Upload an EOS X-ray to see the analysis results here.</p>
</div>
</div>
);
}
// Determine severity based on Cobb angle
const getSeverity = (angle: number) => {
if (angle < 20) return { level: "Mild", class: "success" };
if (angle < 40) return { level: "Moderate", class: "highlight" };
return { level: "Severe", class: "warning" };
};
const severity = getSeverity(data.cobb_angle);
return (
<div className="rigo-analysis-results">
{/* Diagnosis */}
<div className="rigo-analysis-card">
<div className="rigo-analysis-label">Diagnosis</div>
<div className="rigo-analysis-value highlight">
{data.pattern === "Type_3C" ? "Right Thoracic Scoliosis" : data.pattern}
</div>
<div className="rigo-analysis-description">
Rigo-Cheneau Classification: <strong>Type 3C</strong>
</div>
</div>
{/* Thoracic Measurements */}
<div className="rigo-analysis-grid">
<div className="rigo-analysis-card">
<div className="rigo-analysis-label">Thoracic Cobb</div>
<div className={`rigo-analysis-value ${severity.class}`}>{data.cobb_angle}°</div>
<div className="rigo-analysis-description">{severity.level} curve</div>
</div>
<div className="rigo-analysis-card">
<div className="rigo-analysis-label">Apex Vertebra</div>
<div className="rigo-analysis-value">{data.apex}</div>
<div className="rigo-analysis-description">Curve apex location</div>
</div>
</div>
{/* Thoracic Convexity & Lumbar Cobb */}
<div className="rigo-analysis-grid">
<div className="rigo-analysis-card">
<div className="rigo-analysis-label">Thoracic Convexity</div>
<div className="rigo-analysis-value">{data.thoracic_convexity || "Right"}</div>
<div className="rigo-analysis-description">Curve direction</div>
</div>
<div className="rigo-analysis-card">
<div className="rigo-analysis-label">Lumbar Cobb</div>
<div className="rigo-analysis-value">{data.lumbar_cobb_deg != null ? `${data.lumbar_cobb_deg}°` : "—"}</div>
<div className="rigo-analysis-description">Compensatory curve</div>
</div>
</div>
{/* L4/L5 Tilt */}
<div className="rigo-analysis-grid">
<div className="rigo-analysis-card">
<div className="rigo-analysis-label">L4 Tilt</div>
<div className="rigo-analysis-value">{data.l4_tilt_deg != null ? `${data.l4_tilt_deg.toFixed(1)}°` : "—"}</div>
<div className="rigo-analysis-description">Vertebra angle</div>
</div>
<div className="rigo-analysis-card">
<div className="rigo-analysis-label">L5 Tilt</div>
<div className="rigo-analysis-value">{data.l5_tilt_deg != null ? `${data.l5_tilt_deg.toFixed(1)}°` : "—"}</div>
<div className="rigo-analysis-description">Vertebra angle</div>
</div>
</div>
{/* Pelvic Tilt */}
<div className="rigo-analysis-card">
<div className="rigo-analysis-label">Pelvic Tilt</div>
<div className="rigo-analysis-value">{data.pelvic_tilt} Side</div>
<div className="rigo-analysis-description">Compensatory pelvic position</div>
</div>
{/* Brace Parameters */}
<div
className="rigo-analysis-card"
style={{ marginTop: "16px", background: "rgba(59, 130, 246, 0.1)", borderColor: "#2563eb" }}
>
<div className="rigo-analysis-label" style={{ color: "#60a5fa" }}>
Generated Brace Parameters
</div>
<div style={{ fontSize: "0.875rem", color: "#94a3b8", marginTop: "8px" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "4px" }}>
<span>Pressure Pad Position:</span>
<strong style={{ color: "#f1f5f9" }}>{data.apex} Level</strong>
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "4px" }}>
<span>Pad Depth:</span>
<strong style={{ color: "#f1f5f9" }}>{data.cobb_angle > 30 ? "Aggressive" : "Standard"}</strong>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>Expansion Window:</span>
<strong style={{ color: "#f1f5f9" }}>{data.pelvic_tilt === "Left" ? "Right" : "Left"} Side</strong>
</div>
</div>
</div>
{/* View Toggle */}
{modelUrl && (
<div style={{ marginTop: "16px" }}>
<button
className={`rigo-btn ${showPadsOnly ? "rigo-btn-primary" : "rigo-btn-secondary"} rigo-btn-block`}
onClick={handleTogglePadsOnly}
disabled={isRegenerating}
style={{ fontSize: "0.875rem" }}
>
{isRegenerating ? (
<>
<span className="rigo-spinner" style={{ width: "16px", height: "16px", marginRight: "8px" }}></span>
Regenerating...
</>
) : (
<>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
{showPadsOnly ? (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 9h6v6H9z" />
</>
) : (
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 5v2M12 17v2M5 12h2M17 12h2" />
</>
)}
</svg>
{showPadsOnly ? "Show Full Brace" : "Show Pads Only"}
</>
)}
</button>
</div>
)}
{/* Download Buttons */}
<div style={{ display: "flex", flexDirection: "column", gap: "12px", marginTop: "16px" }}>
<button
className={`rigo-btn ${modelUrl ? "rigo-btn-primary" : "rigo-btn-secondary"} rigo-btn-block`}
onClick={handleDownloadSTL}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{modelUrl ? "Download STL (3D Print)" : "Download (Blender Required)"}
</button>
{modelUrl && (
<button className="rigo-btn rigo-btn-secondary rigo-btn-block" onClick={handleDownloadGLB}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Download GLB (Web Viewer)
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { Suspense, useState, useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Environment, ContactShadows, useGLTF, Center, Grid } from "@react-three/drei";
import * as THREE from "three";
// Placeholder brace model when no model is loaded
function PlaceholderBrace({ opacity = 1 }: { opacity?: number }) {
const meshRef = useRef<THREE.Group>(null);
useFrame((state) => {
if (meshRef.current) {
meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.3) * 0.1;
}
});
return (
<group ref={meshRef}>
{/* Main brace body - simplified torso shape */}
<mesh position={[0, 0, 0]}>
<cylinderGeometry args={[0.8, 1, 2.5, 32, 1, true]} />
<meshStandardMaterial
color="#4a90d9"
transparent
opacity={opacity * 0.8}
side={THREE.DoubleSide}
metalness={0.1}
roughness={0.6}
/>
</mesh>
{/* Right pressure pad (thoracic) */}
<mesh position={[0.85, 0.3, 0]} rotation={[0, 0, Math.PI / 6]}>
<boxGeometry args={[0.15, 0.8, 0.6]} />
<meshStandardMaterial color="#2563eb" transparent opacity={opacity} metalness={0.2} roughness={0.4} />
</mesh>
{/* Left expansion window cutout visual */}
<mesh position={[-0.75, 0.3, 0.2]}>
<boxGeometry args={[0.1, 0.6, 0.5]} />
<meshStandardMaterial color="#0f172a" transparent opacity={opacity * 0.9} />
</mesh>
{/* Lumbar support */}
<mesh position={[0, -0.8, 0.5]} rotation={[0.3, 0, 0]}>
<boxGeometry args={[0.8, 0.4, 0.2]} />
<meshStandardMaterial color="#3b82f6" transparent opacity={opacity} metalness={0.2} roughness={0.4} />
</mesh>
</group>
);
}
// Load actual GLB model
function BraceModel({ url, opacity = 1 }: { url: string; opacity?: number }) {
const { scene } = useGLTF(url);
const meshRef = useRef<THREE.Object3D>(null);
// Apply materials to all meshes
scene.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
(child as THREE.Mesh).material = new THREE.MeshStandardMaterial({
color: "#4a90d9",
transparent: true,
opacity: opacity * 0.8,
metalness: 0.1,
roughness: 0.6,
side: THREE.DoubleSide,
});
}
});
return (
<Center>
<primitive ref={meshRef} object={scene} scale={1} />
</Center>
);
}
// Loading indicator
function LoadingIndicator() {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (meshRef.current) {
meshRef.current.rotation.y = state.clock.elapsedTime * 2;
}
});
return (
<mesh ref={meshRef}>
<torusGeometry args={[0.5, 0.1, 16, 32]} />
<meshStandardMaterial color="#3b82f6" emissive="#3b82f6" emissiveIntensity={0.5} />
</mesh>
);
}
interface BraceViewerProps {
modelUrl: string | null;
isLoading: boolean;
}
export default function BraceViewer({ modelUrl, isLoading }: BraceViewerProps) {
const [transparency, setTransparency] = useState(false);
const [showGrid, setShowGrid] = useState(true);
const opacity = transparency ? 0.4 : 1;
return (
<>
<Canvas
className="rigo-viewer-canvas"
camera={{ position: [3, 2, 3], fov: 45 }}
gl={{ antialias: true, alpha: true }}
style={{ width: "100%", height: "100%", minHeight: "400px" }}
>
<color attach="background" args={["#111827"]} />
{/* Lighting */}
<ambientLight intensity={0.4} />
<directionalLight position={[5, 5, 5]} intensity={1} castShadow shadow-mapSize={[2048, 2048]} />
<directionalLight position={[-5, 3, -5]} intensity={0.3} />
<pointLight position={[0, 3, 0]} intensity={0.5} color="#60a5fa" />
{/* Environment for reflections */}
<Environment preset="city" />
{/* Grid */}
{showGrid && (
<Grid
args={[10, 10]}
cellSize={0.5}
cellThickness={0.5}
cellColor="#334155"
sectionSize={2}
sectionThickness={1}
sectionColor="#475569"
fadeDistance={10}
fadeStrength={1}
position={[0, -1.5, 0]}
/>
)}
{/* Contact shadows */}
<ContactShadows position={[0, -1.5, 0]} opacity={0.4} scale={5} blur={2.5} />
{/* Model */}
<Suspense fallback={<LoadingIndicator />}>
{isLoading ? (
<LoadingIndicator />
) : modelUrl ? (
<BraceModel url={modelUrl} opacity={opacity} />
) : (
<PlaceholderBrace opacity={opacity} />
)}
</Suspense>
{/* Controls */}
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.05}
minDistance={2}
maxDistance={10}
minPolarAngle={Math.PI / 6}
maxPolarAngle={Math.PI / 1.5}
/>
</Canvas>
{/* Viewer Controls */}
<div className="rigo-viewer-controls">
<button
className={`rigo-viewer-control-btn ${transparency ? "active" : ""}`}
onClick={() => setTransparency(!transparency)}
title="Toggle Transparency"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" strokeDasharray="4 2" />
<circle cx="12" cy="12" r="4" />
</svg>
</button>
<button
className={`rigo-viewer-control-btn ${showGrid ? "active" : ""}`}
onClick={() => setShowGrid(!showGrid)}
title="Toggle Grid"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="15" x2="21" y2="15" />
<line x1="9" y1="3" x2="9" y2="21" />
<line x1="15" y1="3" x2="15" y2="21" />
</svg>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,192 @@
import { useState, useCallback } from "react";
interface UploadPanelProps {
onUpload: (file: File) => void;
isAnalyzing: boolean;
onReset: () => void;
hasResults: boolean;
}
export default function UploadPanel({ onUpload, isAnalyzing, onReset, hasResults }: UploadPanelProps) {
const [isDragging, setIsDragging] = useState(false);
const [preview, setPreview] = useState<string | null>(null);
const [fileName, setFileName] = useState("");
const handleFile = useCallback((file: File | null) => {
if (!file) return;
// Validate file type
const validTypes = ["image/jpeg", "image/png", "image/webp", "image/bmp"];
if (!validTypes.includes(file.type)) {
alert("Please upload a valid image file (JPEG, PNG, WebP, or BMP)");
return;
}
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setPreview(e.target?.result as string);
setFileName(file.name);
};
reader.readAsDataURL(file);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
handleFile(file);
},
[handleFile]
);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
handleFile(file);
},
[handleFile]
);
const handleAnalyze = useCallback(() => {
if (!preview) return;
// Convert base64 to file for upload
fetch(preview)
.then((res) => res.blob())
.then((blob) => {
const file = new File([blob], fileName, { type: blob.type });
onUpload(file);
});
}, [preview, fileName, onUpload]);
const handleClear = useCallback(() => {
setPreview(null);
setFileName("");
onReset();
}, [onReset]);
return (
<div className="rigo-upload-panel">
{!preview ? (
<label
id="rigo-upload-zone"
className={`rigo-upload-zone ${isDragging ? "dragging" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
accept="image/*"
onChange={handleInputChange}
style={{ display: "none" }}
/>
<div className="rigo-upload-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</div>
<p className="rigo-upload-text">
<strong>Drop your EOS X-ray here</strong>
<br />
or click to browse
</p>
<p className="rigo-upload-hint">Supports JPEG, PNG, WebP, BMP</p>
</label>
) : (
<div className="rigo-upload-preview-container">
<div className="rigo-upload-preview">
<img src={preview} alt="X-ray preview" />
<div className="rigo-upload-preview-overlay">
<span style={{ color: "white", fontSize: "0.875rem" }}>{fileName}</span>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<button
className="rigo-btn rigo-btn-primary rigo-btn-block rigo-btn-lg"
onClick={handleAnalyze}
disabled={isAnalyzing}
>
{isAnalyzing ? (
<>
<span className="rigo-spinner"></span>
Generating...
</>
) : hasResults ? (
<>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
Complete
</>
) : (
<>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
Analyze
</>
)}
</button>
<button
className="rigo-btn rigo-btn-secondary rigo-btn-block"
onClick={handleClear}
disabled={isAnalyzing}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
Clear &amp; Upload New
</button>
</div>
</div>
)}
{/* Progress Steps */}
<div className="rigo-progress-steps" style={{ marginTop: "32px" }}>
<div className={`rigo-progress-step ${preview ? "completed" : "active"}`}>
<div className="rigo-progress-step-indicator">1</div>
<div className="rigo-progress-step-content">
<div className="rigo-progress-step-title">Upload X-Ray</div>
<div className="rigo-progress-step-description">EOS or standard spinal radiograph</div>
</div>
</div>
<div className={`rigo-progress-step ${isAnalyzing ? "active" : hasResults ? "completed" : ""}`}>
<div className="rigo-progress-step-indicator">2</div>
<div className="rigo-progress-step-content">
<div className="rigo-progress-step-title">Generate Brace</div>
<div className="rigo-progress-step-description">3D model with corrective parameters</div>
</div>
</div>
<div className={`rigo-progress-step ${hasResults ? "completed" : ""}`}>
<div className="rigo-progress-step-indicator">3</div>
<div className="rigo-progress-step-content">
<div className="rigo-progress-step-title">Download STL</div>
<div className="rigo-progress-step-description">Ready for 3D printing or editing</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,303 @@
/**
* 3D Body Scan Viewer with Auto-Rotation
* Displays STL/OBJ/GLB body scans with spinning animation
*/
import { useEffect, useRef, useState } from 'react';
// Three.js is loaded dynamically
let THREE: any = null;
let STLLoader: any = null;
let OBJLoader: any = null;
let GLTFLoader: any = null;
type BodyScanViewerProps = {
scanUrl: string | null;
autoRotate?: boolean;
rotationSpeed?: number;
};
export default function BodyScanViewer({
scanUrl,
autoRotate = true,
rotationSpeed = 0.01,
}: BodyScanViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<any>(null);
const sceneRef = useRef<any>(null);
const cameraRef = useRef<any>(null);
const meshRef = useRef<any>(null);
const animationFrameRef = useRef<number>(0);
const autoRotateRef = useRef(autoRotate);
const rotationSpeedRef = useRef(rotationSpeed);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [threeLoaded, setThreeLoaded] = useState(false);
// Keep refs in sync with props
useEffect(() => {
autoRotateRef.current = autoRotate;
rotationSpeedRef.current = rotationSpeed;
}, [autoRotate, rotationSpeed]);
// Load Three.js dynamically
useEffect(() => {
const loadThree = async () => {
try {
const threeModule = await import('three');
THREE = threeModule;
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
STLLoader = STL;
const { OBJLoader: OBJ } = await import('three/examples/jsm/loaders/OBJLoader.js');
OBJLoader = OBJ;
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
GLTFLoader = GLTF;
setThreeLoaded(true);
} catch (e) {
console.error('Failed to load Three.js:', e);
setError('Failed to load 3D viewer');
}
};
loadThree();
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (rendererRef.current) {
rendererRef.current.dispose();
}
};
}, []);
// Initialize scene when Three.js is loaded
useEffect(() => {
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
const container = containerRef.current;
const width = container.clientWidth;
const height = container.clientHeight;
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
sceneRef.current = scene;
// Camera
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
camera.position.set(0, 50, 500);
camera.lookAt(0, 0, 0);
cameraRef.current = camera;
// Renderer - fills container
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
// Lighting - multiple lights for good coverage during rotation
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const frontLight = new THREE.DirectionalLight(0xffffff, 0.7);
frontLight.position.set(0, 100, 400);
scene.add(frontLight);
const backLight = new THREE.DirectionalLight(0x88ccff, 0.5);
backLight.position.set(0, 100, -400);
scene.add(backLight);
const topLight = new THREE.DirectionalLight(0xffffff, 0.4);
topLight.position.set(0, 400, 0);
scene.add(topLight);
const sideLight1 = new THREE.DirectionalLight(0xffffff, 0.3);
sideLight1.position.set(400, 100, 0);
scene.add(sideLight1);
const sideLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
sideLight2.position.set(-400, 100, 0);
scene.add(sideLight2);
// Animation loop - rotate the mesh around its own Y-axis (self-rotation)
const animate = () => {
animationFrameRef.current = requestAnimationFrame(animate);
// Self-rotation: rotate around Z axis (the original vertical axis of STL files)
if (meshRef.current && autoRotateRef.current) {
meshRef.current.rotation.z += rotationSpeedRef.current;
}
renderer.render(scene, camera);
};
animate();
// Handle resize
const handleResize = () => {
if (!container || !renderer || !camera) return;
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
camera.aspect = newWidth / newHeight;
camera.updateProjectionMatrix();
renderer.setSize(newWidth, newHeight);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [threeLoaded]);
// Load mesh when URL changes
useEffect(() => {
if (!threeLoaded || !scanUrl || !sceneRef.current) return;
setLoading(true);
setError(null);
// Clear previous mesh
if (meshRef.current) {
sceneRef.current.remove(meshRef.current);
meshRef.current = null;
}
// Determine loader based on file extension
const ext = scanUrl.toLowerCase().split('.').pop() || '';
const onLoad = (result: any) => {
let mesh: any;
if (ext === 'stl') {
// STL returns geometry directly - center the geometry itself
const geometry = result;
geometry.center(); // This centers the geometry at origin
const material = new THREE.MeshPhongMaterial({
color: 0xccaa88,
specular: 0x222222,
shininess: 50,
side: THREE.DoubleSide,
});
mesh = new THREE.Mesh(geometry, material);
// STL files are typically Z-up, rotate to Y-up
mesh.rotation.x = -Math.PI / 2;
} else if (ext === 'obj') {
mesh = result;
mesh.traverse((child: any) => {
if (child.isMesh) {
child.material = new THREE.MeshPhongMaterial({
color: 0xccaa88,
specular: 0x222222,
shininess: 50,
side: THREE.DoubleSide,
});
}
});
} else if (ext === 'glb' || ext === 'gltf') {
mesh = result.scene;
}
if (mesh) {
// Get bounding box to scale appropriately
const box = new THREE.Box3().setFromObject(mesh);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
// For non-STL, center the mesh position
if (ext !== 'stl') {
mesh.position.sub(center);
}
// Scale to fit in view (smaller = further away appearance)
const maxDim = Math.max(size.x, size.y, size.z);
const targetSize = 250;
const scale = targetSize / maxDim;
mesh.scale.multiplyScalar(scale);
// Position at scene center
mesh.position.set(0, 0, 0);
sceneRef.current.add(mesh);
meshRef.current = mesh;
// Position camera to see the whole model
cameraRef.current.position.set(0, 80, 400);
cameraRef.current.lookAt(0, 0, 0);
}
setLoading(false);
};
const onError = (err: any) => {
console.error('Failed to load mesh:', err);
setError('Failed to load 3D model');
setLoading(false);
};
// Load based on extension
if (ext === 'stl') {
const loader = new STLLoader();
loader.load(scanUrl, onLoad, undefined, onError);
} else if (ext === 'obj') {
const loader = new OBJLoader();
loader.load(scanUrl, onLoad, undefined, onError);
} else if (ext === 'glb' || ext === 'gltf') {
const loader = new GLTFLoader();
loader.load(scanUrl, onLoad, undefined, onError);
} else if (ext === 'ply') {
// PLY not directly supported, show message
setError('PLY format preview not supported');
setLoading(false);
} else {
setError(`Unsupported format: ${ext}`);
setLoading(false);
}
}, [threeLoaded, scanUrl]);
if (!threeLoaded) {
return (
<div className="body-scan-viewer-loading">
<div className="spinner"></div>
<p>Loading 3D viewer...</p>
</div>
);
}
return (
<div className="body-scan-viewer-container">
<div
ref={containerRef}
className="body-scan-viewer-canvas"
/>
{loading && (
<div className="body-scan-viewer-overlay">
<div className="spinner"></div>
<p>Loading model...</p>
</div>
)}
{error && (
<div className="body-scan-viewer-overlay error">
<p>{error}</p>
</div>
)}
{!scanUrl && !loading && (
<div className="body-scan-viewer-overlay placeholder">
<p>No scan loaded</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,324 @@
/**
* 3D Brace Model Viewer with Auto-Rotation
* Displays STL/GLB brace models with spinning animation
*/
import { useEffect, useRef, useState } from 'react';
// Three.js is loaded dynamically
let THREE: any = null;
let STLLoader: any = null;
let GLTFLoader: any = null;
type BraceModelViewerProps = {
stlUrl?: string | null;
glbUrl?: string | null;
autoRotate?: boolean;
rotationSpeed?: number;
};
export default function BraceModelViewer({
stlUrl,
glbUrl,
autoRotate = true,
rotationSpeed = 0.005,
}: BraceModelViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<any>(null);
const sceneRef = useRef<any>(null);
const cameraRef = useRef<any>(null);
const meshRef = useRef<any>(null);
const animationFrameRef = useRef<number>(0);
const autoRotateRef = useRef(autoRotate);
const rotationSpeedRef = useRef(rotationSpeed);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [threeLoaded, setThreeLoaded] = useState(false);
// Keep refs in sync with props
useEffect(() => {
autoRotateRef.current = autoRotate;
rotationSpeedRef.current = rotationSpeed;
}, [autoRotate, rotationSpeed]);
// Determine which URL to use (prefer GLB over STL)
const modelUrl = glbUrl || stlUrl;
const modelType = glbUrl ? 'glb' : (stlUrl ? 'stl' : null);
// Load Three.js dynamically
useEffect(() => {
const loadThree = async () => {
try {
const threeModule = await import('three');
THREE = threeModule;
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
STLLoader = STL;
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
GLTFLoader = GLTF;
setThreeLoaded(true);
} catch (e) {
console.error('Failed to load Three.js:', e);
setError('Failed to load 3D viewer');
}
};
loadThree();
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (rendererRef.current) {
rendererRef.current.dispose();
}
};
}, []);
// Initialize scene when Three.js is loaded
useEffect(() => {
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
const container = containerRef.current;
const width = container.clientWidth;
const height = container.clientHeight;
// Scene with gradient background
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e1e2e);
sceneRef.current = scene;
// Camera - positioned to view upright brace from front
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
camera.position.set(0, 0, 400);
camera.lookAt(0, 0, 0);
cameraRef.current = camera;
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
// Improved lighting for skin-like appearance with shadows
// Soft ambient for base illumination
scene.add(new THREE.AmbientLight(0xffeedd, 0.4));
// Hemisphere light for natural sky/ground coloring
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x8d7c6d, 0.5);
hemiLight.position.set(0, 200, 0);
scene.add(hemiLight);
// Key light (main light with shadows)
const keyLight = new THREE.DirectionalLight(0xfff5e8, 1.0);
keyLight.position.set(150, 200, 300);
keyLight.castShadow = true;
keyLight.shadow.mapSize.width = 1024;
keyLight.shadow.mapSize.height = 1024;
keyLight.shadow.camera.near = 50;
keyLight.shadow.camera.far = 1000;
keyLight.shadow.camera.left = -200;
keyLight.shadow.camera.right = 200;
keyLight.shadow.camera.top = 200;
keyLight.shadow.camera.bottom = -200;
keyLight.shadow.bias = -0.0005;
scene.add(keyLight);
// Fill light (softer, opposite side)
const fillLight = new THREE.DirectionalLight(0xe8f0ff, 0.5);
fillLight.position.set(-200, 100, -100);
scene.add(fillLight);
// Rim light (back light for edge definition)
const rimLight = new THREE.DirectionalLight(0xffffff, 0.4);
rimLight.position.set(0, 50, -300);
scene.add(rimLight);
// Bottom fill (subtle, prevents too dark shadows underneath)
const bottomFill = new THREE.DirectionalLight(0xffe8d0, 0.2);
bottomFill.position.set(0, -200, 100);
scene.add(bottomFill);
// Animation loop - rotate around Y axis (lazy susan for Y-up brace)
const animate = () => {
animationFrameRef.current = requestAnimationFrame(animate);
if (meshRef.current && autoRotateRef.current) {
meshRef.current.rotation.y += rotationSpeedRef.current;
}
renderer.render(scene, camera);
};
animate();
// Handle resize
const handleResize = () => {
if (!container || !renderer || !camera) return;
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
camera.aspect = newWidth / newHeight;
camera.updateProjectionMatrix();
renderer.setSize(newWidth, newHeight);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [threeLoaded]);
// Load model when URL changes
useEffect(() => {
if (!threeLoaded || !modelUrl || !sceneRef.current) return;
setLoading(true);
setError(null);
// Clear previous mesh
if (meshRef.current) {
sceneRef.current.remove(meshRef.current);
meshRef.current = null;
}
const onLoadSTL = (geometry: any) => {
// Center the geometry
geometry.center();
geometry.computeVertexNormals();
// Skin-like material for natural appearance
const material = new THREE.MeshStandardMaterial({
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
roughness: 0.7, // Slightly smooth for skin-like appearance
metalness: 0.0,
side: THREE.DoubleSide,
flatShading: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
// Brace template is Y-up, no rotation needed
// Scale to fit
const box = new THREE.Box3().setFromObject(mesh);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 200 / maxDim;
mesh.scale.multiplyScalar(scale);
mesh.position.set(0, 0, 0);
sceneRef.current.add(mesh);
meshRef.current = mesh;
// Position camera - view from front at torso level
cameraRef.current.position.set(0, 0, 350);
cameraRef.current.lookAt(0, 0, 0);
setLoading(false);
};
const onLoadGLB = (gltf: any) => {
const mesh = gltf.scene;
// Apply skin-like material to all meshes
mesh.traverse((child: any) => {
if (child.isMesh) {
child.material = new THREE.MeshStandardMaterial({
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
roughness: 0.7, // Slightly smooth for skin-like appearance
metalness: 0.0,
side: THREE.DoubleSide,
flatShading: false,
});
child.castShadow = true;
child.receiveShadow = true;
}
});
// Center and scale
const box = new THREE.Box3().setFromObject(mesh);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
mesh.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 200 / maxDim;
mesh.scale.multiplyScalar(scale);
mesh.position.set(0, 0, 0);
sceneRef.current.add(mesh);
meshRef.current = mesh;
// Position camera - view from front at torso level
cameraRef.current.position.set(0, 0, 350);
cameraRef.current.lookAt(0, 0, 0);
setLoading(false);
};
const onError = (err: any) => {
console.error('Failed to load model:', err);
setError('Failed to load 3D model');
setLoading(false);
};
if (modelType === 'stl') {
const loader = new STLLoader();
loader.load(modelUrl, onLoadSTL, undefined, onError);
} else if (modelType === 'glb') {
const loader = new GLTFLoader();
loader.load(modelUrl, onLoadGLB, undefined, onError);
}
}, [threeLoaded, modelUrl, modelType]);
if (!threeLoaded) {
return (
<div className="brace-model-viewer-loading">
<div className="spinner"></div>
<p>Loading 3D viewer...</p>
</div>
);
}
return (
<div className="brace-model-viewer-container">
<div
ref={containerRef}
className="brace-model-viewer-canvas"
/>
{loading && (
<div className="brace-model-viewer-overlay">
<div className="spinner"></div>
<p>Loading brace model...</p>
</div>
)}
{error && (
<div className="brace-model-viewer-overlay error">
<p>{error}</p>
</div>
)}
{!modelUrl && !loading && (
<div className="brace-model-viewer-overlay placeholder">
<div className="placeholder-icon">🦾</div>
<p>Generate a brace to see 3D preview</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,980 @@
/**
* BraceTransformViewer - 3D Brace Viewer with Real-time Deformation
* Based on EXPERIMENT_6's brace-transform-playground-v2
*
* Extends BraceModelViewer with the ability to apply ellipsoid deformations
* in real-time based on transformation parameters.
*/
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
import type { BraceTransformParams } from '../pipeline/BraceInlineEditor';
// Three.js is loaded dynamically
let THREE: any = null;
let STLLoader: any = null;
let GLTFLoader: any = null;
let STLExporter: any = null;
let GLTFExporter: any = null;
let OrbitControls: any = null;
type MarkerInfo = {
name: string;
position: [number, number, number];
};
type MarkerMap = Record<string, { x: number; y: number; z: number }>;
export type BraceTransformViewerRef = {
exportSTL: () => Promise<Blob | null>;
exportGLB: () => Promise<Blob | null>;
getModifiedGeometry: () => any;
};
type Props = {
glbUrl?: string | null;
stlUrl?: string | null;
transformParams?: BraceTransformParams;
autoRotate?: boolean;
rotationSpeed?: number;
showMarkers?: boolean;
showGrid?: boolean;
onMarkersLoaded?: (markers: MarkerInfo[]) => void;
onGeometryUpdated?: () => void;
};
const BraceTransformViewer = forwardRef<BraceTransformViewerRef, Props>(({
glbUrl,
stlUrl,
transformParams,
autoRotate = true,
rotationSpeed = 0.005,
showMarkers = false,
showGrid = false,
onMarkersLoaded,
onGeometryUpdated,
}, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<any>(null);
const sceneRef = useRef<any>(null);
const cameraRef = useRef<any>(null);
const controlsRef = useRef<any>(null);
const meshRef = useRef<any>(null);
const baseGeometryRef = useRef<any>(null);
const gridGroupRef = useRef<any>(null);
const realWorldScaleRef = useRef<number>(1); // units per cm
const markersRef = useRef<MarkerMap>({});
const modelGroupRef = useRef<any>(null);
const animationFrameRef = useRef<number>(0);
const autoRotateRef = useRef(autoRotate);
const rotationSpeedRef = useRef(rotationSpeed);
const paramsRef = useRef<BraceTransformParams | undefined>(transformParams);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [threeLoaded, setThreeLoaded] = useState(false);
const [dimensions, setDimensions] = useState<{ width: number; height: number; depth: number } | null>(null);
// Keep refs in sync with props
useEffect(() => {
autoRotateRef.current = autoRotate;
rotationSpeedRef.current = rotationSpeed;
if (controlsRef.current) {
controlsRef.current.autoRotate = autoRotate;
controlsRef.current.autoRotateSpeed = rotationSpeed * 100;
}
}, [autoRotate, rotationSpeed]);
// Create measurement grid with labels
const createMeasurementGrid = useCallback((scene: any, unitsPerCm: number, modelHeight: number) => {
if (!THREE) return;
// Remove existing grid
if (gridGroupRef.current) {
scene.remove(gridGroupRef.current);
gridGroupRef.current = null;
}
const gridGroup = new THREE.Group();
gridGroup.name = 'measurementGrid';
// Calculate grid size based on model (add some padding)
const gridSizeCm = Math.ceil(modelHeight / unitsPerCm / 10) * 10 + 20; // Round up to nearest 10cm + padding
const gridSizeUnits = gridSizeCm * unitsPerCm;
const divisionsPerCm = 1;
const totalDivisions = gridSizeCm * divisionsPerCm;
// Create XZ grid (floor)
const gridXZ = new THREE.GridHelper(gridSizeUnits, totalDivisions, 0x666688, 0x444466);
gridXZ.position.y = -modelHeight / 2 - 10; // Below the model
gridGroup.add(gridXZ);
// Create XY grid (back wall) - vertical
const gridXY = new THREE.GridHelper(gridSizeUnits, totalDivisions, 0x668866, 0x446644);
gridXY.rotation.x = Math.PI / 2;
gridXY.position.z = -gridSizeUnits / 4;
gridGroup.add(gridXY);
// Add axis lines (thicker)
const axisMaterial = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
// X-axis (red)
const xAxisGeom = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-gridSizeUnits / 2, -modelHeight / 2 - 10, 0),
new THREE.Vector3(gridSizeUnits / 2, -modelHeight / 2 - 10, 0)
]);
const xAxis = new THREE.Line(xAxisGeom, new THREE.LineBasicMaterial({ color: 0xff4444 }));
gridGroup.add(xAxis);
// Y-axis (green)
const yAxisGeom = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, -modelHeight / 2 - 10, 0),
new THREE.Vector3(0, modelHeight / 2 + 50, 0)
]);
const yAxis = new THREE.Line(yAxisGeom, new THREE.LineBasicMaterial({ color: 0x44ff44 }));
gridGroup.add(yAxis);
// Z-axis (blue)
const zAxisGeom = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, -modelHeight / 2 - 10, -gridSizeUnits / 2),
new THREE.Vector3(0, -modelHeight / 2 - 10, gridSizeUnits / 2)
]);
const zAxis = new THREE.Line(zAxisGeom, new THREE.LineBasicMaterial({ color: 0x4444ff }));
gridGroup.add(zAxis);
// Add measurement tick marks and labels every 10cm on Y-axis
const tickSize = 5;
const tickMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
for (let cm = 0; cm <= gridSizeCm; cm += 10) {
const y = -modelHeight / 2 - 10 + cm * unitsPerCm;
if (y > modelHeight / 2 + 50) break;
// Tick mark
const tickGeom = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-tickSize, y, 0),
new THREE.Vector3(tickSize, y, 0)
]);
const tick = new THREE.Line(tickGeom, tickMaterial);
gridGroup.add(tick);
// Create text sprite for label
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 32;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 20px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${cm}`, 32, 16);
const texture = new THREE.CanvasTexture(canvas);
const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true });
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.set(-tickSize - 15, y, 0);
sprite.scale.set(20, 10, 1);
gridGroup.add(sprite);
}
}
// Add "cm" label at top
const cmCanvas = document.createElement('canvas');
cmCanvas.width = 64;
cmCanvas.height = 32;
const cmCtx = cmCanvas.getContext('2d');
if (cmCtx) {
cmCtx.fillStyle = '#88ff88';
cmCtx.font = 'bold 18px Arial';
cmCtx.textAlign = 'center';
cmCtx.textBaseline = 'middle';
cmCtx.fillText('cm', 32, 16);
const cmTexture = new THREE.CanvasTexture(cmCanvas);
const cmSpriteMaterial = new THREE.SpriteMaterial({ map: cmTexture, transparent: true });
const cmSprite = new THREE.Sprite(cmSpriteMaterial);
cmSprite.position.set(-25, modelHeight / 2 + 30, 0);
cmSprite.scale.set(25, 12, 1);
gridGroup.add(cmSprite);
}
gridGroupRef.current = gridGroup;
scene.add(gridGroup);
}, []);
// Keep params ref in sync
useEffect(() => {
paramsRef.current = transformParams;
}, [transformParams]);
const modelUrl = glbUrl || stlUrl;
const modelType = glbUrl ? 'glb' : (stlUrl ? 'stl' : null);
// Load Three.js dynamically
useEffect(() => {
const loadThree = async () => {
try {
const threeModule = await import('three');
THREE = threeModule;
const { STLLoader: STL } = await import('three/examples/jsm/loaders/STLLoader.js');
STLLoader = STL;
const { GLTFLoader: GLTF } = await import('three/examples/jsm/loaders/GLTFLoader.js');
GLTFLoader = GLTF;
const { STLExporter: STLE } = await import('three/examples/jsm/exporters/STLExporter.js');
STLExporter = STLE;
const { GLTFExporter: GLTFE } = await import('three/examples/jsm/exporters/GLTFExporter.js');
GLTFExporter = GLTFE;
const { OrbitControls: OC } = await import('three/examples/jsm/controls/OrbitControls.js');
OrbitControls = OC;
setThreeLoaded(true);
} catch (e) {
console.error('Failed to load Three.js:', e);
setError('Failed to load 3D viewer');
}
};
loadThree();
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (rendererRef.current) {
rendererRef.current.dispose();
}
};
}, []);
// Initialize scene
useEffect(() => {
if (!threeLoaded || !containerRef.current || rendererRef.current) return;
const container = containerRef.current;
const width = container.clientWidth;
const height = container.clientHeight;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e1e2e);
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
camera.position.set(0, 0, 400);
camera.lookAt(0, 0, 0);
cameraRef.current = camera;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
// OrbitControls for manual rotation
if (OrbitControls) {
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.autoRotate = autoRotateRef.current;
controls.autoRotateSpeed = rotationSpeedRef.current * 100;
controls.enablePan = true;
controls.enableZoom = true;
controls.minDistance = 100;
controls.maxDistance = 800;
controls.target.set(0, 0, 0);
controlsRef.current = controls;
}
// Improved lighting for skin-like appearance with shadows
// Soft ambient for base illumination
scene.add(new THREE.AmbientLight(0xffeedd, 0.4));
// Hemisphere light for natural sky/ground coloring
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x8d7c6d, 0.5);
hemiLight.position.set(0, 200, 0);
scene.add(hemiLight);
// Key light (main light with shadows)
const keyLight = new THREE.DirectionalLight(0xfff5e8, 1.0);
keyLight.position.set(150, 200, 300);
keyLight.castShadow = true;
keyLight.shadow.mapSize.width = 1024;
keyLight.shadow.mapSize.height = 1024;
keyLight.shadow.camera.near = 50;
keyLight.shadow.camera.far = 1000;
keyLight.shadow.camera.left = -200;
keyLight.shadow.camera.right = 200;
keyLight.shadow.camera.top = 200;
keyLight.shadow.camera.bottom = -200;
keyLight.shadow.bias = -0.0005;
scene.add(keyLight);
// Fill light (softer, opposite side)
const fillLight = new THREE.DirectionalLight(0xe8f0ff, 0.5);
fillLight.position.set(-200, 100, -100);
scene.add(fillLight);
// Rim light (back light for edge definition)
const rimLight = new THREE.DirectionalLight(0xffffff, 0.4);
rimLight.position.set(0, 50, -300);
scene.add(rimLight);
// Bottom fill (subtle, prevents too dark shadows underneath)
const bottomFill = new THREE.DirectionalLight(0xffe8d0, 0.2);
bottomFill.position.set(0, -200, 100);
scene.add(bottomFill);
// Animation loop
const animate = () => {
animationFrameRef.current = requestAnimationFrame(animate);
// Update orbit controls
if (controlsRef.current) {
controlsRef.current.update();
}
renderer.render(scene, camera);
};
animate();
// Handle resize
const handleResize = () => {
if (!container || !renderer || !camera) return;
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
camera.aspect = newWidth / newHeight;
camera.updateProjectionMatrix();
renderer.setSize(newWidth, newHeight);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [threeLoaded]);
// Extract markers from GLB
const extractMarkers = useCallback((root: any): MarkerMap => {
const markers: MarkerMap = {};
root.traverse((obj: any) => {
if (obj.name && obj.name.startsWith('LM_')) {
const worldPos = new THREE.Vector3();
obj.getWorldPosition(worldPos);
markers[obj.name] = { x: worldPos.x, y: worldPos.y, z: worldPos.z };
}
});
return markers;
}, []);
// Apply ellipsoid deformation to geometry
const applyDeformation = useCallback((geometry: any, params: BraceTransformParams, markers: MarkerMap) => {
if (!THREE || !geometry) return geometry;
// Clone geometry to avoid mutating original
const deformed = geometry.clone();
const positions = deformed.getAttribute('position');
if (!positions) return deformed;
// Compute bounding box for fallback positioning
deformed.computeBoundingBox();
const bbox = deformed.boundingBox;
const minY = bbox?.min?.y || 0;
const maxY = bbox?.max?.y || 1;
const minX = bbox?.min?.x || -0.5;
const maxX = bbox?.max?.x || 0.5;
const minZ = bbox?.min?.z || -0.5;
const maxZ = bbox?.max?.z || 0.5;
const centerX = (minX + maxX) / 2;
const centerZ = (minZ + maxZ) / 2;
const bboxHeight = maxY - minY;
const bboxWidth = maxX - minX;
const bboxDepth = maxZ - minZ;
// Get brace basis from markers OR fallback to bbox
const pelvis = markers['LM_PELVIS_CENTER'] || { x: centerX, y: minY, z: centerZ };
const top = markers['LM_TOP_CENTER'] || { x: centerX, y: maxY, z: centerZ };
const braceHeight = Math.sqrt(
Math.pow(top.x - pelvis.x, 2) +
Math.pow(top.y - pelvis.y, 2) +
Math.pow(top.z - pelvis.z, 2)
) || bboxHeight || 1;
const unitsPerMm = braceHeight / Math.max(1e-6, params.expectedBraceHeightMm);
// Get pad/bay markers - with fallback positions based on bounding box
// Thoracic pad: right side, upper region
const thPad = markers['LM_PAD_TH'] || {
x: centerX + bboxWidth * 0.35,
y: minY + bboxHeight * params.apexNorm,
z: centerZ - bboxDepth * 0.1
};
// Thoracic bay: left side, upper region (opposite side of pad)
const thBay = markers['LM_BAY_TH'] || {
x: centerX - bboxWidth * 0.35,
y: minY + bboxHeight * params.apexNorm,
z: centerZ - bboxDepth * 0.1
};
// Lumbar pad: left side, lower region
const lumPad = markers['LM_PAD_LUM'] || {
x: centerX - bboxWidth * 0.3,
y: minY + bboxHeight * params.lumbarApexNorm,
z: centerZ
};
// Lumbar bay: right side, lower region
const lumBay = markers['LM_BAY_LUM'] || {
x: centerX + bboxWidth * 0.3,
y: minY + bboxHeight * params.lumbarApexNorm,
z: centerZ
};
// Severity mapping
const sev = Math.max(0, Math.min(1, (params.cobbDeg - 15) / 40));
// Pad depth based on severity
const padDepthMm = (8 + 12 * sev) * params.strengthMult;
const bayClearMm = padDepthMm * 1.2;
const padDepth = padDepthMm * unitsPerMm;
const bayClear = bayClearMm * unitsPerMm;
// Size scale based on severity
const sizeScale = 0.9 + 0.5 * sev;
// Define features (pads and bays) - always create them with fallback positions
const features: Array<{
center: { x: number; y: number; z: number };
radii: { x: number; y: number; z: number };
depth: number;
direction: 1 | -1;
falloffPower: number;
}> = [];
// Add thoracic pad (push inward on convex side)
features.push({
center: {
x: thPad.x,
y: pelvis.y + params.apexNorm * braceHeight,
z: thPad.z,
},
radii: {
x: 45 * unitsPerMm * sizeScale,
y: 90 * unitsPerMm * sizeScale,
z: 35 * unitsPerMm * sizeScale
},
depth: padDepth,
direction: -1,
falloffPower: 2.0,
});
// Add thoracic bay (relief on concave side)
features.push({
center: {
x: thBay.x,
y: pelvis.y + params.apexNorm * braceHeight,
z: thBay.z,
},
radii: {
x: 60 * unitsPerMm * sizeScale,
y: 110 * unitsPerMm * sizeScale,
z: 55 * unitsPerMm * sizeScale
},
depth: bayClear,
direction: 1,
falloffPower: 1.6,
});
// Add lumbar pad
features.push({
center: {
x: lumPad.x,
y: pelvis.y + params.lumbarApexNorm * braceHeight,
z: lumPad.z,
},
radii: {
x: 50 * unitsPerMm * sizeScale,
y: 80 * unitsPerMm * sizeScale,
z: 40 * unitsPerMm * sizeScale
},
depth: padDepth * 0.9,
direction: -1,
falloffPower: 2.0,
});
// Add lumbar bay
features.push({
center: {
x: lumBay.x,
y: pelvis.y + params.lumbarApexNorm * braceHeight,
z: lumBay.z,
},
radii: {
x: 65 * unitsPerMm * sizeScale,
y: 95 * unitsPerMm * sizeScale,
z: 55 * unitsPerMm * sizeScale
},
depth: bayClear * 0.9,
direction: 1,
falloffPower: 1.6,
});
// Add hip anchors - with fallback positions
const hipL = markers['LM_ANCHOR_HIP_L'] || {
x: centerX - bboxWidth * 0.4,
y: minY + bboxHeight * 0.1,
z: centerZ,
};
const hipR = markers['LM_ANCHOR_HIP_R'] || {
x: centerX + bboxWidth * 0.4,
y: minY + bboxHeight * 0.1,
z: centerZ,
};
const hipDepth = params.hipAnchorStrengthMm * unitsPerMm * params.strengthMult;
if (hipDepth > 0) {
features.push({
center: { x: hipL.x, y: hipL.y, z: hipL.z },
radii: {
x: 35 * unitsPerMm,
y: 55 * unitsPerMm,
z: 35 * unitsPerMm
},
depth: hipDepth,
direction: -1,
falloffPower: 2.2,
});
features.push({
center: { x: hipR.x, y: hipR.y, z: hipR.z },
radii: {
x: 35 * unitsPerMm,
y: 55 * unitsPerMm,
z: 35 * unitsPerMm
},
depth: hipDepth,
direction: -1,
falloffPower: 2.2,
});
}
// Apply deformations
for (let i = 0; i < positions.count; i++) {
let x = positions.getX(i);
let y = positions.getY(i);
let z = positions.getZ(i);
// Mirror X if enabled
if (params.mirrorX) {
x = -x;
}
// Apply trunk shift
const heightNorm = Math.max(0, Math.min(1, (y - pelvis.y) / braceHeight));
x += params.trunkShiftMm * unitsPerMm * heightNorm * 0.8;
// Apply ellipsoid deformations
for (const feature of features) {
const dx = (x - feature.center.x) / feature.radii.x;
const dy = (y - feature.center.y) / feature.radii.y;
const dz = (z - feature.center.z) / feature.radii.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 >= 1) continue;
// Smooth falloff
const t = Math.pow(1 - d2, feature.falloffPower);
const displacement = feature.depth * t * feature.direction;
// Apply based on push mode
if (params.pushMode === 'radial') {
// Radial: push away from/toward brace axis
const axisPoint = { x: pelvis.x, y: y, z: pelvis.z };
const radialX = x - axisPoint.x;
const radialZ = z - axisPoint.z;
const radialLen = Math.sqrt(radialX * radialX + radialZ * radialZ) || 1;
x += (radialX / radialLen) * displacement;
z += (radialZ / radialLen) * displacement;
} else if (params.pushMode === 'lateral') {
// Lateral: purely left/right
const side = Math.sign(x - pelvis.x) || 1;
x += side * displacement;
} else {
// Normal: would require vertex normals, approximate with radial
const axisPoint = { x: pelvis.x, y: y, z: pelvis.z };
const radialX = x - axisPoint.x;
const radialZ = z - axisPoint.z;
const radialLen = Math.sqrt(radialX * radialX + radialZ * radialZ) || 1;
x += (radialX / radialLen) * displacement;
z += (radialZ / radialLen) * displacement;
}
}
positions.setXYZ(i, x, y, z);
}
positions.needsUpdate = true;
deformed.computeVertexNormals();
return deformed;
}, []);
// Load model
useEffect(() => {
if (!threeLoaded || !modelUrl || !sceneRef.current) return;
setLoading(true);
setError(null);
// Clear previous model
if (modelGroupRef.current) {
sceneRef.current.remove(modelGroupRef.current);
modelGroupRef.current = null;
}
meshRef.current = null;
baseGeometryRef.current = null;
markersRef.current = {};
const group = new THREE.Group();
modelGroupRef.current = group;
const onLoadGLB = (gltf: any) => {
// Extract markers
markersRef.current = extractMarkers(gltf.scene);
// Notify parent of markers
if (onMarkersLoaded) {
const markerList: MarkerInfo[] = Object.entries(markersRef.current).map(([name, pos]) => ({
name,
position: [pos.x, pos.y, pos.z],
}));
onMarkersLoaded(markerList);
}
// Find the main mesh
let mainMesh: any = null;
let maxVertices = 0;
gltf.scene.traverse((child: any) => {
if (child.isMesh && !child.name.startsWith('LM_')) {
const count = child.geometry.getAttribute('position')?.count || 0;
if (count > maxVertices) {
maxVertices = count;
mainMesh = child;
}
}
});
if (!mainMesh) {
setError('No mesh found in model');
setLoading(false);
return;
}
// Store base geometry
baseGeometryRef.current = mainMesh.geometry.clone();
// Create mesh with skin-like material
const material = new THREE.MeshStandardMaterial({
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
roughness: 0.7, // Slightly smooth for skin-like appearance
metalness: 0.0,
side: THREE.DoubleSide,
flatShading: false,
});
const mesh = new THREE.Mesh(baseGeometryRef.current.clone(), material);
mesh.castShadow = true;
mesh.receiveShadow = true;
meshRef.current = mesh;
// Center and scale
const box = new THREE.Box3().setFromObject(mesh);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
mesh.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 200 / maxDim;
mesh.scale.multiplyScalar(scale);
// Calculate real-world dimensions (assuming model units are mm)
// Typical brace height is 350-450mm, width 250-350mm
const scaledSize = {
x: size.x * scale,
y: size.y * scale,
z: size.z * scale,
};
// Store units per cm for grid (model is in mm, so 10mm = 1cm)
const unitsPerCm = scale * 10; // scale * 10mm
realWorldScaleRef.current = unitsPerCm;
// Store dimensions in cm
setDimensions({
width: Math.round(size.x / 10 * 10) / 10, // X dimension in cm
height: Math.round(size.y / 10 * 10) / 10, // Y dimension in cm
depth: Math.round(size.z / 10 * 10) / 10, // Z dimension in cm
});
// Create measurement grid if enabled
if (showGrid && sceneRef.current) {
createMeasurementGrid(sceneRef.current, unitsPerCm, scaledSize.y);
}
// Scale markers to match
const scaledMarkers: MarkerMap = {};
for (const [name, pos] of Object.entries(markersRef.current)) {
scaledMarkers[name] = {
x: (pos.x - center.x) * scale,
y: (pos.y - center.y) * scale,
z: (pos.z - center.z) * scale,
};
}
markersRef.current = scaledMarkers;
// Add marker spheres if enabled
if (showMarkers) {
Object.entries(scaledMarkers).forEach(([name, pos]) => {
const sphereGeom = new THREE.SphereGeometry(3, 16, 16);
const sphereMat = new THREE.MeshBasicMaterial({
color: name.includes('PAD') ? 0x00ff00 :
name.includes('BAY') ? 0x0088ff :
name.includes('HIP') ? 0xff8800 : 0xff0000,
transparent: true,
opacity: 0.8,
});
const sphere = new THREE.Mesh(sphereGeom, sphereMat);
sphere.position.set(pos.x, pos.y, pos.z);
sphere.name = `marker_${name}`;
group.add(sphere);
});
}
group.add(mesh);
group.position.set(0, 0, 0);
sceneRef.current.add(group);
// Apply initial deformation if params provided
if (transformParams) {
applyTransformToMesh(transformParams);
}
cameraRef.current.position.set(0, 0, 350);
cameraRef.current.lookAt(0, 0, 0);
setLoading(false);
};
const onLoadSTL = (geometry: any) => {
geometry.center();
geometry.computeVertexNormals();
// Store base geometry
baseGeometryRef.current = geometry.clone();
// Skin-like material matching GLB loader
const material = new THREE.MeshStandardMaterial({
color: 0xf5d0c5, // Warm skin tone (light peach/beige)
roughness: 0.7, // Slightly smooth for skin-like appearance
metalness: 0.0,
side: THREE.DoubleSide,
flatShading: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
meshRef.current = mesh;
// Scale to fit
const box = new THREE.Box3().setFromObject(mesh);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 200 / maxDim;
mesh.scale.multiplyScalar(scale);
// Calculate real-world dimensions (assuming model units are mm)
const scaledSize = {
x: size.x * scale,
y: size.y * scale,
z: size.z * scale,
};
// Store units per cm for grid (model is in mm, so 10mm = 1cm)
const unitsPerCm = scale * 10;
realWorldScaleRef.current = unitsPerCm;
// Store dimensions in cm
setDimensions({
width: Math.round(size.x / 10 * 10) / 10,
height: Math.round(size.y / 10 * 10) / 10,
depth: Math.round(size.z / 10 * 10) / 10,
});
// Create measurement grid if enabled
if (showGrid && sceneRef.current) {
createMeasurementGrid(sceneRef.current, unitsPerCm, scaledSize.y);
}
group.add(mesh);
group.position.set(0, 0, 0);
sceneRef.current.add(group);
cameraRef.current.position.set(0, 0, 350);
cameraRef.current.lookAt(0, 0, 0);
setLoading(false);
};
const onError = (err: any) => {
console.error('Failed to load model:', err);
setError('Failed to load 3D model');
setLoading(false);
};
if (modelType === 'stl') {
const loader = new STLLoader();
loader.load(modelUrl, onLoadSTL, undefined, onError);
} else if (modelType === 'glb') {
const loader = new GLTFLoader();
loader.load(modelUrl, onLoadGLB, undefined, onError);
}
}, [threeLoaded, modelUrl, modelType, showMarkers, showGrid, extractMarkers, onMarkersLoaded, createMeasurementGrid]);
// Apply transform when params change
const applyTransformToMesh = useCallback((params: BraceTransformParams) => {
if (!meshRef.current || !baseGeometryRef.current || !THREE) return;
const deformedGeometry = applyDeformation(
baseGeometryRef.current.clone(),
params,
markersRef.current
);
meshRef.current.geometry.dispose();
meshRef.current.geometry = deformedGeometry;
if (onGeometryUpdated) {
onGeometryUpdated();
}
}, [applyDeformation, onGeometryUpdated]);
// Watch for transform params changes
useEffect(() => {
if (transformParams && meshRef.current && baseGeometryRef.current) {
applyTransformToMesh(transformParams);
}
}, [transformParams, applyTransformToMesh]);
// Export functions
useImperativeHandle(ref, () => ({
exportSTL: async () => {
if (!meshRef.current || !STLExporter) return null;
const exporter = new STLExporter();
const stlString = exporter.parse(meshRef.current, { binary: true });
return new Blob([stlString], { type: 'application/octet-stream' });
},
exportGLB: async () => {
if (!meshRef.current || !GLTFExporter) return null;
return new Promise((resolve) => {
const exporter = new GLTFExporter();
exporter.parse(
meshRef.current,
(result: any) => {
const blob = new Blob([result], { type: 'application/octet-stream' });
resolve(blob);
},
(error: any) => {
console.error('GLB export error:', error);
resolve(null);
},
{ binary: true }
);
});
},
getModifiedGeometry: () => {
return meshRef.current?.geometry || null;
},
}), []);
if (!threeLoaded) {
return (
<div className="brace-transform-viewer-loading">
<div className="spinner"></div>
<p>Loading 3D viewer...</p>
</div>
);
}
return (
<div className="brace-transform-viewer-container">
<div
ref={containerRef}
className="brace-transform-viewer-canvas"
/>
{/* Dimensions overlay */}
{showGrid && dimensions && !loading && (
<div className="brace-dimensions-overlay">
<div className="dimensions-title">Dimensions (cm)</div>
<div className="dimension-row">
<span className="dim-label">Width:</span>
<span className="dim-value">{dimensions.width.toFixed(1)}</span>
</div>
<div className="dimension-row">
<span className="dim-label">Height:</span>
<span className="dim-value">{dimensions.height.toFixed(1)}</span>
</div>
<div className="dimension-row">
<span className="dim-label">Depth:</span>
<span className="dim-value">{dimensions.depth.toFixed(1)}</span>
</div>
</div>
)}
{loading && (
<div className="brace-transform-viewer-overlay">
<div className="spinner"></div>
<p>Loading brace model...</p>
</div>
)}
{error && (
<div className="brace-transform-viewer-overlay error">
<p>{error}</p>
</div>
)}
{!modelUrl && !loading && (
<div className="brace-transform-viewer-overlay placeholder">
<div className="placeholder-icon">🦾</div>
<p>Generate a brace to see 3D preview</p>
</div>
)}
</div>
);
});
BraceTransformViewer.displayName = 'BraceTransformViewer';
export default BraceTransformViewer;

View File

@@ -0,0 +1,303 @@
/**
* 3D Brace Viewer Component
* Uses Three.js to display GLB brace models with markers
*
* Based on EXPERIMENT_6's brace-transform-playground-v2
*/
import { useEffect, useRef, useState, useCallback } from 'react';
// Three.js is loaded dynamically to avoid SSR issues
let THREE: any = null;
let GLTFLoader: any = null;
let OrbitControls: any = null;
type MarkerInfo = {
name: string;
position: [number, number, number];
color: string;
};
type BraceViewerProps = {
glbUrl: string | null;
width?: number;
height?: number;
showMarkers?: boolean;
onMarkersLoaded?: (markers: MarkerInfo[]) => void;
deformationParams?: {
thoracicPadDepth?: number;
lumbarPadDepth?: number;
trunkShift?: number;
};
};
export default function BraceViewer({
glbUrl,
width = 600,
height = 500,
showMarkers = true,
onMarkersLoaded,
deformationParams,
}: BraceViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<any>(null);
const sceneRef = useRef<any>(null);
const cameraRef = useRef<any>(null);
const controlsRef = useRef<any>(null);
const braceMeshRef = useRef<any>(null);
const animationFrameRef = useRef<number>(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [threeLoaded, setThreeLoaded] = useState(false);
// Load Three.js dynamically
useEffect(() => {
const loadThree = async () => {
try {
const threeModule = await import('three');
THREE = threeModule;
const { GLTFLoader: Loader } = await import('three/examples/jsm/loaders/GLTFLoader.js');
GLTFLoader = Loader;
const { OrbitControls: Controls } = await import('three/examples/jsm/controls/OrbitControls.js');
OrbitControls = Controls;
setThreeLoaded(true);
} catch (e) {
console.error('Failed to load Three.js:', e);
setError('Failed to load 3D viewer');
}
};
loadThree();
return () => {
// Cleanup on unmount
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (rendererRef.current) {
rendererRef.current.dispose();
}
};
}, []);
// Initialize scene
const initScene = useCallback(() => {
if (!THREE || !containerRef.current) return;
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
sceneRef.current = scene;
// Camera
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 10000);
camera.position.set(300, 200, 400);
cameraRef.current = camera;
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
containerRef.current.appendChild(renderer.domElement);
rendererRef.current = renderer;
// Controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 100, 0);
controls.update();
controlsRef.current = controls;
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.4);
scene.add(hemisphereLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(200, 300, 200);
scene.add(directionalLight);
// Grid helper (optional)
const gridHelper = new THREE.GridHelper(500, 20, 0x444444, 0x333333);
scene.add(gridHelper);
// Animation loop
const animate = () => {
animationFrameRef.current = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
}, [width, height]);
// Initialize scene when Three.js is loaded
useEffect(() => {
if (threeLoaded && containerRef.current && !rendererRef.current) {
initScene();
}
}, [threeLoaded, initScene]);
// Load GLB when URL changes
useEffect(() => {
if (!threeLoaded || !glbUrl || !sceneRef.current) return;
setLoading(true);
setError(null);
const loader = new GLTFLoader();
loader.load(
glbUrl,
(gltf: any) => {
// Clear previous mesh
if (braceMeshRef.current) {
sceneRef.current.remove(braceMeshRef.current);
braceMeshRef.current = null;
}
// Process loaded model
const scene = gltf.scene;
const markers: MarkerInfo[] = [];
let mainMesh: any = null;
// Find main mesh and markers
scene.traverse((child: any) => {
if (child.isMesh) {
// Check if it's a marker
if (child.name.startsWith('LM_')) {
markers.push({
name: child.name,
position: child.position.toArray() as [number, number, number],
color: getMarkerColor(child.name),
});
// Style marker for visibility
if (showMarkers) {
child.material = new THREE.MeshBasicMaterial({
color: getMarkerColor(child.name),
depthTest: false,
transparent: true,
opacity: 0.8,
});
child.renderOrder = 999;
} else {
child.visible = false;
}
} else {
// Main brace mesh
if (!mainMesh || child.geometry.attributes.position.count > mainMesh.geometry.attributes.position.count) {
mainMesh = child;
}
// Apply standard material
child.material = new THREE.MeshStandardMaterial({
color: 0xcccccc,
metalness: 0.2,
roughness: 0.5,
side: THREE.DoubleSide,
});
}
}
});
// Add to scene
sceneRef.current.add(scene);
braceMeshRef.current = scene;
// Center camera on mesh
if (mainMesh) {
const box = new THREE.Box3().setFromObject(scene);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
controlsRef.current.target.copy(center);
cameraRef.current.position.set(
center.x + size.x,
center.y + size.y * 0.5,
center.z + size.z
);
controlsRef.current.update();
}
// Notify about markers
if (onMarkersLoaded && markers.length > 0) {
onMarkersLoaded(markers);
}
setLoading(false);
},
undefined,
(err: any) => {
console.error('Failed to load GLB:', err);
setError('Failed to load 3D model');
setLoading(false);
}
);
}, [threeLoaded, glbUrl, showMarkers, onMarkersLoaded]);
// Handle resize
useEffect(() => {
if (rendererRef.current && cameraRef.current) {
rendererRef.current.setSize(width, height);
cameraRef.current.aspect = width / height;
cameraRef.current.updateProjectionMatrix();
}
}, [width, height]);
// Loading state
if (!threeLoaded) {
return (
<div className="brace-viewer-loading" style={{ width, height }}>
<div className="spinner"></div>
<p>Loading 3D viewer...</p>
</div>
);
}
return (
<div className="brace-viewer-container" style={{ width, height }}>
<div
ref={containerRef}
className="brace-viewer-canvas"
style={{ width: '100%', height: '100%' }}
/>
{loading && (
<div className="brace-viewer-overlay">
<div className="spinner"></div>
<p>Loading model...</p>
</div>
)}
{error && (
<div className="brace-viewer-overlay error">
<p>{error}</p>
</div>
)}
{!glbUrl && !loading && (
<div className="brace-viewer-overlay placeholder">
<p>No 3D model loaded</p>
</div>
)}
</div>
);
}
// Helper function to get marker color based on name
function getMarkerColor(name: string): string {
if (name.includes('PELVIS')) return '#ff0000'; // Red
if (name.includes('TOP')) return '#00ff00'; // Green
if (name.includes('PAD_TH')) return '#ff00ff'; // Magenta (thoracic pad)
if (name.includes('BAY_TH')) return '#00ffff'; // Cyan (thoracic bay)
if (name.includes('PAD_LUM')) return '#ffff00'; // Yellow (lumbar pad)
if (name.includes('BAY_LUM')) return '#ff8800'; // Orange (lumbar bay)
if (name.includes('ANCHOR')) return '#8800ff'; // Purple (anchors)
return '#ffffff'; // White (default)
}

View File

@@ -0,0 +1,5 @@
export { default as BraceViewer } from './BraceViewer';
export { default as BodyScanViewer } from './BodyScanViewer';
export { default as BraceModelViewer } from './BraceModelViewer';
export { default as BraceTransformViewer } from './BraceTransformViewer';
export type { BraceTransformViewerRef } from './BraceTransformViewer';

View File

@@ -0,0 +1,174 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
export type User = {
id: number;
username: string;
fullName: string | null;
role: "admin" | "user" | "viewer";
};
type AuthContextType = {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
isAdmin: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
error: string | null;
clearError: () => void;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AUTH_STORAGE_KEY = "braceflow_auth";
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api";
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Check for existing session on mount
useEffect(() => {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (parsed.user && parsed.token && parsed.expiresAt > Date.now()) {
setUser(parsed.user);
setToken(parsed.token);
} else {
localStorage.removeItem(AUTH_STORAGE_KEY);
}
} catch {
localStorage.removeItem(AUTH_STORAGE_KEY);
}
}
setIsLoading(false);
}, []);
const login = useCallback(async (username: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || "Invalid username or password");
}
const data = await response.json();
const userData: User = {
id: data.user.id,
username: data.user.username,
fullName: data.user.full_name,
role: data.user.role,
};
// Store auth data
const authData = {
user: userData,
token: data.token,
expiresAt: new Date(data.expiresAt).getTime(),
};
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(authData));
setUser(userData);
setToken(data.token);
} catch (err: any) {
setError(err.message || "Login failed. Please try again.");
throw err;
} finally {
setIsLoading(false);
}
}, []);
const logout = useCallback(async () => {
// Call logout endpoint if we have a token
if (token) {
try {
await fetch(`${API_BASE}/auth/logout`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
},
});
} catch {
// Ignore logout API errors
}
}
localStorage.removeItem(AUTH_STORAGE_KEY);
setUser(null);
setToken(null);
}, [token]);
const clearError = useCallback(() => {
setError(null);
}, []);
const value: AuthContextType = {
user,
token,
isAuthenticated: !!user,
isLoading,
isAdmin: user?.role === "admin",
login,
logout,
error,
clearError,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
/**
* Helper function to get auth headers for API calls
*/
export function getAuthHeaders(): Record<string, string> {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (parsed.token) {
return { "Authorization": `Bearer ${parsed.token}` };
}
} catch {
// Ignore
}
}
return {};
}
/**
* Helper function to get the auth token
*/
export function getAuthToken(): string | null {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
return parsed.token || null;
} catch {
// Ignore
}
}
return null;
}

68
frontend/src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

107
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,107 @@
const API_BASE = 'https://cfx9z50wj2.execute-api.ca-central-1.amazonaws.com/prod';
async function http<T>(path: string, init?: RequestInit): Promise<T> {
const base = API_BASE ? API_BASE.replace(/\/+$/, '') : '';
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const url = `${base}${normalizedPath}`;
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers || {})
}
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
return (await res.json()) as T;
}
export type CaseStatus = {
case: {
case_id: string;
status: string;
current_step: string | null;
created_at: string;
updated_at: string;
};
steps: Array<{
step_name: string;
step_order: number;
status: string;
started_at: string | null;
finished_at: string | null;
error_message: string | null;
}>;
};
export type CaseAssets = {
caseId: string;
apImageUrl: string;
bucket?: string;
key?: string;
};
export type SubmitLandmarksRequest = {
// Backend Lambda requires caseId in body (even though it's also in the URL)
caseId?: string;
view: "ap";
landmarks: Record<string, { x: number; y: number }>;
};
export type SubmitLandmarksResponse = {
ok: boolean;
caseId: string;
resumedPipeline: boolean;
values: {
pelvis_offset_px: number;
t1_offset_px: number;
tp_offset_px: number;
dominant_curve: string;
};
};
export type UploadUrlResponse = {
uploadUrl: string;
key?: string;
s3Key?: string;
};
export const api = {
createCase: (body: { notes?: string } = {}) =>
http<{ caseId: string }>(`/cases`, {
method: "POST",
body: JSON.stringify(body),
}),
startCase: (caseId: string) =>
http<{ caseId: string; executionArn?: string; status?: string }>(
`/cases/${encodeURIComponent(caseId)}/start`,
{
method: "POST",
body: JSON.stringify({}),
}
),
getUploadUrl: (caseId: string, body: { view: string; contentType?: string; filename?: string }) =>
http<UploadUrlResponse>(`/cases/${encodeURIComponent(caseId)}/upload-url`, {
method: "POST",
body: JSON.stringify(body),
}),
getCaseStatus: (caseId: string) => http<CaseStatus>(`/cases/${encodeURIComponent(caseId)}`),
getCaseAssets: (caseId: string) => http<CaseAssets>(`/cases/${encodeURIComponent(caseId)}/assets`),
// FIX: include caseId in JSON body to satisfy backend Lambda contract
submitLandmarks: (caseId: string, body: SubmitLandmarksRequest) =>
http<SubmitLandmarksResponse>(`/cases/${encodeURIComponent(caseId)}/landmarks`, {
method: "POST",
body: JSON.stringify({
...body,
}),
}),
};

14
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
import "./App.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,472 @@
import { useState, useCallback } from "react";
import { useParams, Link } from "react-router-dom";
import UploadPanel from "../components/rigo/UploadPanel";
import BraceViewer from "../components/rigo/BraceViewer";
import {
analyzeXray,
getBraceOutputs,
type GenerateBraceResponse,
type BraceOutput,
type CobbAngles,
type RigoClassification,
} from "../api/braceflowApi";
// Helper to format file size
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
// Helper to get severity level
function getSeverity(cobbAngles: CobbAngles | undefined): { level: string; class: string; angle: number } {
const maxAngle = Math.max(cobbAngles?.PT ?? 0, cobbAngles?.MT ?? 0, cobbAngles?.TL ?? 0);
if (maxAngle < 10) return { level: "Normal", class: "success", angle: maxAngle };
if (maxAngle < 25) return { level: "Mild", class: "success", angle: maxAngle };
if (maxAngle < 40) return { level: "Moderate", class: "highlight", angle: maxAngle };
return { level: "Severe", class: "warning", angle: maxAngle };
}
// Metric Card component
function MetricCard({
label,
value,
description,
highlight,
}: {
label: string;
value: string | number;
description?: string;
highlight?: "success" | "highlight" | "warning";
}) {
return (
<div className="rigo-analysis-card">
<div className="rigo-analysis-label">{label}</div>
<div className={`rigo-analysis-value ${highlight || ""}`}>{value}</div>
{description && <div className="rigo-analysis-description">{description}</div>}
</div>
);
}
// Download button for outputs
function DownloadButton({ output }: { output: BraceOutput }) {
const getIcon = (type: string) => {
switch (type) {
case "stl":
case "ply":
case "obj":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
);
case "image":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
);
case "json":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
);
default:
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
}
};
return (
<a
href={output.url}
download={output.filename}
className="rigo-btn rigo-btn-secondary"
style={{ display: "flex", alignItems: "center", gap: 8, textDecoration: "none" }}
>
{getIcon(output.type)}
<span style={{ flex: 1, textAlign: "left" }}>
{output.filename}
<span style={{ color: "#64748b", fontSize: "0.75rem", marginLeft: 8 }}>
({formatBytes(output.size)})
</span>
</span>
</a>
);
}
// Cobb Angles Display
function CobbAnglesDisplay({ angles }: { angles: CobbAngles | undefined }) {
if (!angles) return null;
const entries = [
{ label: "Proximal Thoracic (PT)", value: angles.PT },
{ label: "Main Thoracic (MT)", value: angles.MT },
{ label: "Thoracolumbar (TL)", value: angles.TL },
].filter((e) => e.value !== undefined && e.value !== null);
if (entries.length === 0) return null;
return (
<div className="rigo-analysis-grid" style={{ gridTemplateColumns: `repeat(${entries.length}, 1fr)` }}>
{entries.map((entry) => (
<MetricCard
key={entry.label}
label={entry.label}
value={`${entry.value?.toFixed(1)}°`}
highlight={entry.value && entry.value > 25 ? (entry.value > 40 ? "warning" : "highlight") : "success"}
/>
))}
</div>
);
}
// Rigo Classification Display
function RigoDisplay({ classification }: { classification: RigoClassification | undefined }) {
if (!classification) return null;
return (
<div
className="rigo-analysis-card"
style={{ background: "rgba(59, 130, 246, 0.1)", borderColor: "#2563eb" }}
>
<div className="rigo-analysis-label" style={{ color: "#60a5fa" }}>
Rigo-Chêneau Classification
</div>
<div className="rigo-analysis-value highlight">{classification.type}</div>
<div className="rigo-analysis-description">{classification.description}</div>
{classification.curve_pattern && (
<div style={{ marginTop: 8, fontSize: "0.875rem", color: "#94a3b8" }}>
Curve Pattern: <strong style={{ color: "#f1f5f9" }}>{classification.curve_pattern}</strong>
</div>
)}
</div>
);
}
export default function BraceAnalysisPage() {
const { caseId: routeCaseId } = useParams<{ caseId?: string }>();
const [caseId, setCaseId] = useState<string | null>(routeCaseId || null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<GenerateBraceResponse | null>(null);
const [outputs, setOutputs] = useState<BraceOutput[]>([]);
const [modelUrl, setModelUrl] = useState<string | null>(null);
// Handle file upload and analysis
const handleUpload = useCallback(async (file: File) => {
setIsAnalyzing(true);
setError(null);
setResult(null);
setOutputs([]);
setModelUrl(null);
try {
// Run the full workflow
const { caseId: newCaseId, result: analysisResult } = await analyzeXray(file);
setCaseId(newCaseId);
setResult(analysisResult);
// Find the GLB or STL model URL for 3D viewer
const modelOutput =
analysisResult.outputs?.["glb"] ||
analysisResult.outputs?.["ply"] ||
analysisResult.outputs?.["stl"];
if (modelOutput?.url) {
setModelUrl(modelOutput.url);
}
// Get all outputs with presigned URLs
const outputsResponse = await getBraceOutputs(newCaseId);
setOutputs(outputsResponse.outputs);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Analysis failed. Please try again.";
setError(message);
console.error("Analysis error:", err);
} finally {
setIsAnalyzing(false);
}
}, []);
// Reset state
const handleReset = useCallback(() => {
setCaseId(null);
setResult(null);
setOutputs([]);
setModelUrl(null);
setError(null);
}, []);
const severity = getSeverity(result?.cobb_angles);
return (
<div className="bf-page bf-page--wide">
{/* Header */}
<div className="bf-page-header">
<div>
<h1 className="bf-page-title">Brace Analysis</h1>
<p className="bf-page-subtitle">
Upload an X-ray image to analyze spinal curvature and generate a custom brace design.
</p>
</div>
{caseId && (
<Link to={`/cases/${caseId}/status`} className="rigo-btn rigo-btn-secondary">
View Case Details
</Link>
)}
</div>
{/* Main Content - Three Column Layout */}
<div className="rigo-shell-page" style={{ gridTemplateColumns: "320px 1fr 380px", gap: 24 }}>
{/* Left Panel - Upload */}
<aside className="rigo-panel">
<div className="rigo-panel-header">
<h2 className="rigo-panel-title">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Upload X-Ray
</h2>
</div>
<div className="rigo-panel-content">
<UploadPanel
onUpload={handleUpload}
isAnalyzing={isAnalyzing}
onReset={handleReset}
hasResults={!!result}
/>
{caseId && (
<div
style={{
marginTop: 16,
padding: 12,
background: "rgba(59, 130, 246, 0.1)",
borderRadius: 8,
fontSize: "0.875rem",
}}
>
<div style={{ color: "#60a5fa", marginBottom: 4 }}>Case ID</div>
<code style={{ color: "#f1f5f9", fontSize: "0.75rem" }}>{caseId}</code>
</div>
)}
{error && (
<div
className="rigo-error-message"
style={{
marginTop: 16,
padding: 12,
background: "rgba(255,0,0,0.1)",
borderRadius: 8,
color: "#f87171",
}}
>
{error}
</div>
)}
</div>
</aside>
{/* Center - 3D Viewer */}
<main className="rigo-viewer-container">
<BraceViewer modelUrl={modelUrl} isLoading={isAnalyzing} />
{/* Processing Info */}
{result && (
<div
style={{
position: "absolute",
bottom: 16,
left: 16,
right: 16,
display: "flex",
justifyContent: "space-between",
background: "rgba(0,0,0,0.7)",
padding: "8px 12px",
borderRadius: 8,
fontSize: "0.75rem",
color: "#94a3b8",
}}
>
<span>Model: {result.model}</span>
<span>Experiment: {result.experiment}</span>
<span>Processing: {result.processing_time_ms}ms</span>
</div>
)}
</main>
{/* Right Panel - Analysis Results */}
<aside className="rigo-panel" style={{ overflow: "auto", maxHeight: "calc(100vh - 200px)" }}>
<div className="rigo-panel-header">
<h2 className="rigo-panel-title">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
Analysis Results
</h2>
</div>
<div className="rigo-panel-content">
{isAnalyzing ? (
<div className="rigo-analysis-loading">
<div className="rigo-analysis-card">
<div
className="rigo-loading-skeleton"
style={{ height: "20px", marginBottom: "8px", width: "40%" }}
></div>
<div className="rigo-loading-skeleton" style={{ height: "36px", width: "60%" }}></div>
</div>
<p
style={{
textAlign: "center",
color: "#64748b",
marginTop: "24px",
fontSize: "0.875rem",
}}
>
<span
className="rigo-spinner"
style={{ display: "inline-block", marginRight: "8px", verticalAlign: "middle" }}
></span>
Analyzing X-ray...
</p>
</div>
) : result ? (
<div className="rigo-analysis-results">
{/* Severity Summary */}
<MetricCard
label="Overall Assessment"
value={`${severity.level} Scoliosis`}
description={`Max Cobb angle: ${severity.angle.toFixed(1)}°`}
highlight={severity.class as "success" | "highlight" | "warning"}
/>
{/* Curve Type */}
<div className="rigo-analysis-grid">
<MetricCard label="Curve Type" value={result.curve_type || "—"} />
<MetricCard label="Vertebrae Detected" value={result.vertebrae_detected || "—"} />
</div>
{/* Cobb Angles */}
<div style={{ marginTop: 16 }}>
<h3
style={{
fontSize: "0.875rem",
color: "#64748b",
marginBottom: 8,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Cobb Angles
</h3>
<CobbAnglesDisplay angles={result.cobb_angles} />
</div>
{/* Rigo Classification */}
<div style={{ marginTop: 16 }}>
<RigoDisplay classification={result.rigo_classification} />
</div>
{/* Mesh Info */}
{result.mesh && (
<div className="rigo-analysis-grid" style={{ marginTop: 16 }}>
<MetricCard
label="Mesh Vertices"
value={result.mesh.vertices?.toLocaleString() || "—"}
/>
<MetricCard
label="Mesh Faces"
value={result.mesh.faces?.toLocaleString() || "—"}
/>
</div>
)}
{/* Download Section */}
{outputs.length > 0 && (
<div style={{ marginTop: 24 }}>
<h3
style={{
fontSize: "0.875rem",
color: "#64748b",
marginBottom: 12,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Downloads
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{outputs
.sort((a, b) => {
// Sort: STL first, then PLY, then images, then JSON
const order: Record<string, number> = {
stl: 0,
ply: 1,
obj: 2,
image: 3,
json: 4,
other: 5,
};
return (order[a.type] ?? 5) - (order[b.type] ?? 5);
})
.map((output) => (
<DownloadButton key={output.s3Key} output={output} />
))}
</div>
</div>
)}
</div>
) : (
<div className="rigo-analysis-empty">
<div style={{ textAlign: "center", padding: "32px", color: "#64748b" }}>
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
style={{ margin: "0 auto 16px", opacity: 0.3 }}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
<p>Upload an X-ray to see analysis results.</p>
<p style={{ fontSize: "0.75rem", marginTop: 8 }}>
Supported formats: JPEG, PNG, WebP, BMP
</p>
</div>
</div>
)}
</div>
</aside>
</div>
</div>
);
}

View File

@@ -0,0 +1,372 @@
import { useEffect, useState, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { fetchCase, getBraceOutputs, getDownloadUrl, generateBrace } from "../api/braceflowApi";
import type { CaseRecord, BraceOutputsResponse } from "../api/braceflowApi";
// Helper function to determine curve severity from Cobb angle
function getCurveSeverity(angle: number): string {
if (angle < 10) return "Normal";
if (angle < 25) return "Mild";
if (angle < 40) return "Moderate";
if (angle < 50) return "Severe";
return "Very Severe";
}
function getCurveSeverityClass(angle: number): string {
if (angle < 10) return "severity-normal";
if (angle < 25) return "severity-mild";
if (angle < 40) return "severity-moderate";
return "severity-severe";
}
// Helper function to get Rigo type description
function getRigoDescription(rigoType: string): string {
const descriptions: Record<string, string> = {
'A1': 'Three-curve pattern with lumbar modifier',
'A2': 'Three-curve pattern with thoracolumbar modifier',
'A3': 'Three-curve pattern balanced',
'B1': 'Four-curve pattern with lumbar modifier',
'B2': 'Four-curve pattern with double thoracic',
'C1': 'Non-3 non-4 with thoracolumbar curve',
'C2': 'Non-3 non-4 with lumbar curve',
'E1': 'Single thoracic curve',
'E2': 'Single thoracolumbar curve',
};
return descriptions[rigoType] || `Rigo type ${rigoType}`;
}
// Helper function to format file size
function formatFileSize(bytes: number): string {
if (!bytes || bytes === 0) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
export default function CaseDetailPage() {
const { caseId } = useParams<{ caseId: string }>();
const nav = useNavigate();
const [caseData, setCaseData] = useState<CaseRecord | null>(null);
const [outputs, setOutputs] = useState<BraceOutputsResponse | null>(null);
const [xrayUrl, setXrayUrl] = useState<string | null>(null);
const [xrayError, setXrayError] = useState(false);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const [genError, setGenError] = useState<string | null>(null);
const loadCaseData = useCallback(async () => {
if (!caseId) return;
setLoading(true);
setErr(null);
setXrayError(false);
try {
// Fetch case data first
const caseResult = await fetchCase(caseId);
setCaseData(caseResult);
// Fetch outputs and X-ray URL in parallel (don't fail if they error)
const [outputsResult, xrayResult] = await Promise.all([
getBraceOutputs(caseId).catch(() => null),
getDownloadUrl(caseId, "xray").catch(() => null)
]);
setOutputs(outputsResult);
setXrayUrl(xrayResult?.url || null);
} catch (e: any) {
setErr(e?.message || "Failed to load case");
} finally {
setLoading(false);
}
}, [caseId]);
useEffect(() => {
loadCaseData();
}, [loadCaseData]);
// Handle generate brace button
const handleGenerateBrace = async () => {
if (!caseId) return;
setGenerating(true);
setGenError(null);
try {
await generateBrace(caseId, { experiment: "experiment_3" });
// Reload case data after generation
await loadCaseData();
} catch (e: any) {
setGenError(e?.message || "Failed to generate brace");
} finally {
setGenerating(false);
}
};
// Find the visualization PNG from outputs
const vizUrl = outputs?.outputs?.find(o =>
o.filename.endsWith('.png') && !o.filename.includes('ap')
)?.url;
// Check if brace has been generated
const hasBrace = caseData?.status === "brace_generated" ||
caseData?.status === "completed" ||
caseData?.analysis_result?.cobb_angles;
const isProcessing = caseData?.status === "processing_brace" || generating;
if (loading) {
return (
<div className="bf-page">
<div className="muted">Loading case...</div>
</div>
);
}
if (err || !caseData) {
return (
<div className="bf-page">
<div className="error">{err || "Case not found"}</div>
<button className="btn secondary" onClick={() => nav("/")}>Back to Cases</button>
</div>
);
}
return (
<div className="bf-page bf-page--wide">
{/* Header with case ID */}
<div className="bf-case-header">
<div className="bf-case-header-left">
<button className="bf-back-btn" onClick={() => nav("/")}>
Back
</button>
<h1 className="bf-case-title">{caseId}</h1>
<span className={`bf-case-status bf-case-status--${caseData.status}`}>
{caseData.status?.replace(/_/g, ' ')}
</span>
</div>
</div>
{/* X-ray Image Section */}
<div className="bf-case-content">
<div className="bf-case-xray-section">
<h2 className="bf-section-title">Original X-ray</h2>
<div className="bf-xray-container">
{xrayUrl && !xrayError ? (
<img
src={xrayUrl}
alt="X-ray"
className="bf-xray-image"
onError={() => setXrayError(true)}
/>
) : (
<div className="bf-xray-placeholder">
<span>{xrayError ? "Failed to load X-ray" : "X-ray not available yet"}</span>
</div>
)}
</div>
{/* Processing State */}
{isProcessing && (
<div className="bf-processing-indicator">
<div className="bf-processing-spinner"></div>
<span>Processing... Generating brace from X-ray analysis</span>
</div>
)}
{/* Generate Button - show if X-ray exists but brace not generated */}
{xrayUrl && !hasBrace && !isProcessing && (
<div className="bf-generate-section">
<button
className="btn primary bf-generate-btn"
onClick={handleGenerateBrace}
disabled={generating}
>
{generating ? "Generating..." : "Generate Brace"}
</button>
{genError && <div className="error">{genError}</div>}
</div>
)}
</div>
{/* Visualization Section */}
{vizUrl && (
<div className="bf-case-viz-section">
<h2 className="bf-section-title">Spine Analysis Visualization</h2>
<div className="bf-viz-container">
<img src={vizUrl} alt="Analysis visualization" className="bf-viz-image" />
</div>
{/* Detailed Analysis Under Visualization */}
{caseData.analysis_result && (
<div className="bf-detailed-analysis">
{/* Cobb Angles with Severity */}
{caseData.analysis_result.cobb_angles && (
<div className="bf-analysis-block">
<h3>Cobb Angle Measurements</h3>
<div className="bf-cobb-detailed">
{caseData.analysis_result.cobb_angles.PT !== undefined && (
<div className="bf-cobb-row">
<span className="bf-cobb-name">PT (Proximal Thoracic)</span>
<span className={`bf-cobb-value ${getCurveSeverityClass(caseData.analysis_result.cobb_angles.PT)}`}>
{caseData.analysis_result.cobb_angles.PT.toFixed(1)}°
</span>
<span className="bf-cobb-severity">{getCurveSeverity(caseData.analysis_result.cobb_angles.PT)}</span>
</div>
)}
{caseData.analysis_result.cobb_angles.MT !== undefined && (
<div className="bf-cobb-row">
<span className="bf-cobb-name">MT (Main Thoracic)</span>
<span className={`bf-cobb-value ${getCurveSeverityClass(caseData.analysis_result.cobb_angles.MT)}`}>
{caseData.analysis_result.cobb_angles.MT.toFixed(1)}°
</span>
<span className="bf-cobb-severity">{getCurveSeverity(caseData.analysis_result.cobb_angles.MT)}</span>
</div>
)}
{caseData.analysis_result.cobb_angles.TL !== undefined && (
<div className="bf-cobb-row">
<span className="bf-cobb-name">TL (Thoracolumbar/Lumbar)</span>
<span className={`bf-cobb-value ${getCurveSeverityClass(caseData.analysis_result.cobb_angles.TL)}`}>
{caseData.analysis_result.cobb_angles.TL.toFixed(1)}°
</span>
<span className="bf-cobb-severity">{getCurveSeverity(caseData.analysis_result.cobb_angles.TL)}</span>
</div>
)}
</div>
</div>
)}
{/* Classification Summary */}
<div className="bf-analysis-block">
<h3>Classification</h3>
<div className="bf-classification-grid">
{caseData.analysis_result.curve_type && (
<div className="bf-classification-item">
<span className="bf-classification-label">Curve Pattern</span>
<span className="bf-classification-value bf-curve-badge">
{caseData.analysis_result.curve_type}-Curve
</span>
<span className="bf-classification-desc">
{caseData.analysis_result.curve_type === 'S' ? 'Double curve (thoracic + lumbar)' :
caseData.analysis_result.curve_type === 'C' ? 'Single curve pattern' :
'Curve pattern identified'}
</span>
</div>
)}
{caseData.analysis_result.rigo_classification && (
<div className="bf-classification-item">
<span className="bf-classification-label">Rigo-Chêneau Type</span>
<span className="bf-classification-value bf-rigo-badge">
{caseData.analysis_result.rigo_classification.type}
</span>
<span className="bf-classification-desc">
{caseData.analysis_result.rigo_classification.description || getRigoDescription(caseData.analysis_result.rigo_classification.type)}
</span>
</div>
)}
</div>
</div>
{/* Brace Generation Details */}
<div className="bf-analysis-block">
<h3>Brace Generation Details</h3>
<div className="bf-brace-details-grid">
{caseData.analysis_result.vertebrae_detected && (
<div className="bf-detail-item">
<span className="bf-detail-label">Vertebrae Detected</span>
<span className="bf-detail-value">{caseData.analysis_result.vertebrae_detected}</span>
</div>
)}
{caseData.analysis_result.mesh_info && (
<>
<div className="bf-detail-item">
<span className="bf-detail-label">Mesh Vertices</span>
<span className="bf-detail-value">{caseData.analysis_result.mesh_info.vertices?.toLocaleString()}</span>
</div>
<div className="bf-detail-item">
<span className="bf-detail-label">Mesh Faces</span>
<span className="bf-detail-value">{caseData.analysis_result.mesh_info.faces?.toLocaleString()}</span>
</div>
</>
)}
{caseData.analysis_result.processing_time_ms && (
<div className="bf-detail-item">
<span className="bf-detail-label">Processing Time</span>
<span className="bf-detail-value">{(caseData.analysis_result.processing_time_ms / 1000).toFixed(2)}s</span>
</div>
)}
</div>
</div>
{/* Deformation/Pressure Zones */}
{caseData.analysis_result.deformation_report?.zones && caseData.analysis_result.deformation_report.zones.length > 0 && (
<div className="bf-analysis-block">
<h3>Brace Pressure Zones</h3>
<p className="bf-block-desc">
Based on the Cobb angles and Rigo classification, the following pressure modifications were applied to the brace:
</p>
<div className="bf-pressure-zones">
{caseData.analysis_result.deformation_report.zones.map((zone, idx) => (
<div key={idx} className={`bf-zone-item ${zone.deform_mm < 0 ? 'bf-zone-pressure' : 'bf-zone-relief'}`}>
<div className="bf-zone-header">
<span className="bf-zone-name">{zone.zone}</span>
<span className={`bf-zone-value ${zone.deform_mm < 0 ? 'bf-pressure' : 'bf-relief'}`}>
{zone.deform_mm > 0 ? '+' : ''}{zone.deform_mm.toFixed(1)} mm
</span>
</div>
<span className="bf-zone-reason">{zone.reason}</span>
</div>
))}
</div>
{caseData.analysis_result.deformation_report.patch_grid && (
<p className="bf-patch-info">
Patch Grid: {caseData.analysis_result.deformation_report.patch_grid}
</p>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Downloads */}
{outputs?.outputs && outputs.outputs.length > 0 && (
<div className="bf-case-downloads">
<h2 className="bf-section-title">Generated Brace Files</h2>
<div className="bf-downloads-grid">
{outputs.outputs
.filter(o => o.type === 'stl' || o.type === 'obj')
.map(o => (
<div key={o.filename} className="bf-download-card">
<div className="bf-download-card-icon">
{o.type === 'stl' ? '🧊' : '📦'}
</div>
<div className="bf-download-card-info">
<span className="bf-download-card-name">{o.filename}</span>
<span className="bf-download-card-size">{formatFileSize(o.size)}</span>
</div>
<div className="bf-download-card-actions">
<a
href={o.url}
className="bf-action-btn bf-action-download"
download={o.filename}
title="Download file"
>
Download
</a>
</div>
</div>
))}
</div>
<p className="bf-download-hint">STL files can be 3D printed or opened in any 3D modeling software.</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../lib/api";
export function CaseLoaderPage() {
const [caseId, setCaseId] = useState("");
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const nav = useNavigate();
const onLoad = async () => {
setErr(null);
setLoading(true);
try {
await api.getCaseStatus(caseId.trim());
nav(`/cases/${encodeURIComponent(caseId.trim())}/status`);
} catch (e: any) {
setErr(e?.message || "Failed to load case");
} finally {
setLoading(false);
}
};
return (
<div className="bf-page">
<div className="bf-page-header">
<div>
<h1 className="bf-page-title">Load A Case</h1>
<p className="bf-page-subtitle">
Enter a case ID to view status or resume landmark capture.
</p>
</div>
<div className="bf-spacer" />
<div className="bf-toolbar">
<button className="btn primary" disabled={!caseId.trim() || loading} onClick={onLoad}>
{loading ? "Loading..." : "Load Case"}
</button>
</div>
</div>
<div className="card">
<p className="muted muted--tight">
To create a new case and upload an X-ray, use "Start A Case" in the header.
</p>
<div className="row gap">
<input
value={caseId}
onChange={(e) => setCaseId(e.target.value)}
placeholder="case-20260122-..."
className="input"
/>
<button className="btn secondary" disabled={!caseId.trim() || loading} onClick={onLoad}>
{loading ? "Loading..." : "Load Case"}
</button>
</div>
{err && <div className="error">{err}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { api } from "../lib/api";
import type { CaseStatus } from "../lib/api";
export function CaseStatusPage() {
const { caseId } = useParams();
const id = caseId || "";
const nav = useNavigate();
const [data, setData] = useState<CaseStatus | null>(null);
const [err, setErr] = useState<string | null>(null);
async function load() {
setErr(null);
try {
const res = await api.getCaseStatus(id);
setData(res);
} catch (e: any) {
setErr(e?.message || "Failed to load status");
}
}
useEffect(() => {
load();
const t = setInterval(load, 4000);
return () => clearInterval(t);
}, [id]);
return (
<div className="bf-page bf-page--wide">
<div className="bf-page-header">
<div>
<h1 className="bf-page-title">Case Status</h1>
<p className="bf-page-subtitle">Track pipeline progress and jump to the next step.</p>
</div>
<div className="bf-spacer" />
<div className="bf-toolbar">
<button className="btn secondary" onClick={load}>
Refresh
</button>
<button
className="btn primary"
onClick={() => nav(`/cases/${encodeURIComponent(id)}/landmarks`)}
>
Landmark Tool
</button>
</div>
</div>
<div className="card">
<div className="muted muted--tight">
Case: <strong>{id}</strong>
</div>
{err && <div className="error">{err}</div>}
{!data ? (
<div className="muted">Loading status...</div>
) : (
<>
<div className="bf-summary-grid">
<div className="bf-summary-item">
<span className="bf-summary-label">Status</span>
<span className="bf-summary-value">{data.case.status}</span>
</div>
<div className="bf-summary-item">
<span className="bf-summary-label">Current Step</span>
<span className="bf-summary-value">{data.case.current_step || "-"}</span>
</div>
<div className="bf-summary-item">
<span className="bf-summary-label">Last Updated</span>
<span className="bf-summary-value">
{new Date(data.case.updated_at).toLocaleString()}
</span>
</div>
</div>
<table className="table">
<thead>
<tr>
<th>#</th>
<th>Step</th>
<th>Status</th>
<th>Started</th>
<th>Finished</th>
</tr>
</thead>
<tbody>
{data.steps.map((s) => (
<tr key={s.step_name}>
<td>{s.step_order}</td>
<td>{s.step_name}</td>
<td>
<span className={`tag ${s.status}`}>{s.status}</span>
</td>
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : "-"}</td>
<td>{s.finished_at ? new Date(s.finished_at).toLocaleString() : "-"}</td>
</tr>
))}
</tbody>
</table>
<div className="muted">
Step3 output (classification.json) display can be added next (optional for first demo).
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,304 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { fetchCases, createCaseAndUploadXray, getDownloadUrl, deleteCase } from "../api/braceflowApi";
import type { CaseRecord } from "../api/braceflowApi";
export default function Dashboard({ onView }: { onView?: (id: string) => void }) {
const nav = useNavigate();
const [cases, setCases] = useState<CaseRecord[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
// Upload state
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Thumbnail URLs for each case
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
// Dropdown menu state
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
async function load() {
setLoading(true);
setErr(null);
try {
const c = await fetchCases();
setCases(c);
// Load thumbnails for each case
loadThumbnails(c);
} catch (e: any) {
setErr(e?.message || "Failed to load cases");
setCases([]);
} finally {
setLoading(false);
}
}
// Load X-ray thumbnails for cases
async function loadThumbnails(caseList: CaseRecord[]) {
const newThumbnails: Record<string, string> = {};
await Promise.all(
caseList.map(async (c) => {
try {
const result = await getDownloadUrl(c.caseId, "xray");
newThumbnails[c.caseId] = result.url;
} catch {
// No thumbnail available
}
})
);
setThumbnails(prev => ({ ...prev, ...newThumbnails }));
}
// Handle delete case
async function handleDelete(caseId: string, e: React.MouseEvent) {
e.stopPropagation();
if (!confirm(`Are you sure you want to delete case "${caseId}"?\n\nThis will permanently remove the case and all associated files.`)) {
return;
}
setDeleting(caseId);
setOpenMenu(null);
try {
await deleteCase(caseId);
setCases(prev => prev.filter(c => c.caseId !== caseId));
setThumbnails(prev => {
const updated = { ...prev };
delete updated[caseId];
return updated;
});
} catch (e: any) {
setErr(e?.message || "Failed to delete case");
} finally {
setDeleting(null);
}
}
// Close menu when clicking outside
useEffect(() => {
function handleClickOutside() {
setOpenMenu(null);
}
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);
useEffect(() => {
let mounted = true;
(async () => {
try {
const c = await fetchCases();
if (mounted) {
setCases(c);
loadThumbnails(c);
}
} catch (e: any) {
if (mounted) setErr(e?.message || "Failed to load cases");
} finally {
if (mounted) setLoading(false);
}
})();
return () => {
mounted = false;
};
}, []);
function viewCase(caseId: string) {
if (onView) {
onView(caseId);
return;
}
nav(`/cases/${encodeURIComponent(caseId)}/analysis`);
}
const handleFileUpload = useCallback(async (file: File) => {
if (!file.type.startsWith("image/")) {
setErr("Please upload an image file (JPEG, PNG, etc.)");
return;
}
setUploading(true);
setErr(null);
setUploadProgress("Creating case...");
try {
setUploadProgress("Uploading X-ray...");
const { caseId } = await createCaseAndUploadXray(file);
setUploadProgress("Complete!");
// Refresh the case list and navigate to the new case
await load();
viewCase(caseId);
} catch (e: any) {
setErr(e?.message || "Upload failed");
} finally {
setUploading(false);
setUploadProgress("");
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files[0];
if (file) {
handleFileUpload(file);
}
}, [handleFileUpload]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragActive(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
}, []);
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileUpload(file);
}
}, [handleFileUpload]);
const hasCases = cases.length > 0;
return (
<div className="bf-page bf-page--wide">
<div className="bf-page-header">
<div>
<h1 className="bf-page-title">Cases</h1>
<p className="bf-page-subtitle">Upload an X-ray to create a new case, or select an existing one.</p>
</div>
<div className="bf-spacer" />
<div className="bf-toolbar">
<button className="btn secondary bf-btn-fixed" onClick={load} disabled={loading || uploading}>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
</div>
{/* Upload Area */}
<div
className={`bf-upload-zone ${dragActive ? "bf-upload-zone--active" : ""} ${uploading ? "bf-upload-zone--uploading" : ""}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => !uploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileInputChange}
style={{ display: "none" }}
disabled={uploading}
/>
{uploading ? (
<div className="bf-upload-content">
<div className="bf-upload-spinner"></div>
<p className="bf-upload-text">{uploadProgress}</p>
</div>
) : (
<div className="bf-upload-content">
<div className="bf-upload-icon">+</div>
<p className="bf-upload-text">
<strong>Click to upload</strong> or drag and drop an X-ray image
</p>
<p className="bf-upload-hint">JPEG, PNG, WebP supported</p>
</div>
)}
</div>
{err && <div className="error" style={{ marginTop: "1rem" }}>{err}</div>}
{/* Cases List */}
<div className="card" style={{ marginTop: "1.5rem" }}>
<h2 className="bf-section-title">Recent Cases</h2>
{loading ? (
<div className="muted">Loading cases...</div>
) : !hasCases ? (
<div className="bf-empty">No cases yet. Upload an X-ray above to create your first case.</div>
) : (
<div className="bf-cases-list">
{cases.map((c) => {
const date = new Date(c.created_at);
const isValidDate = !isNaN(date.getTime());
const thumbUrl = thumbnails[c.caseId];
const isDeleting = deleting === c.caseId;
return (
<div
key={c.caseId}
className={`bf-case-row ${isDeleting ? "bf-case-row--deleting" : ""}`}
onClick={() => !isDeleting && viewCase(c.caseId)}
>
{/* Thumbnail */}
<div className="bf-case-thumb">
{thumbUrl ? (
<img src={thumbUrl} alt="X-ray" className="bf-case-thumb-img" />
) : (
<div className="bf-case-thumb-placeholder">X</div>
)}
</div>
{/* Case Info */}
<div className="bf-case-row-info">
<span className="bf-case-row-id">{c.caseId}</span>
{isValidDate && (
<span className="bf-case-row-date">
{date.toLocaleDateString()} {date.toLocaleTimeString()}
</span>
)}
</div>
{/* Menu Button */}
<div className="bf-case-menu-container">
{isDeleting ? (
<div className="bf-case-menu-spinner"></div>
) : (
<>
<button
className="bf-case-menu-btn"
onClick={(e) => {
e.stopPropagation();
setOpenMenu(openMenu === c.caseId ? null : c.caseId);
}}
>
</button>
{openMenu === c.caseId && (
<div className="bf-case-dropdown">
<button
className="bf-case-dropdown-item bf-case-dropdown-item--danger"
onClick={(e) => handleDelete(c.caseId, e)}
>
Delete Case
</button>
</div>
)}
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
import { useNavigate, Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
export default function HomePage() {
const navigate = useNavigate();
const { isAuthenticated, isLoading } = useAuth();
// If authenticated, redirect directly to cases
if (isLoading) {
return (
<div className="bf-loading-screen">
<div className="bf-loading-spinner"></div>
<p>Loading...</p>
</div>
);
}
if (isAuthenticated) {
return <Navigate to="/cases" replace />;
}
return (
<div className="bf-home-page">
{/* Hero Section */}
<section className="bf-hero">
<div className="bf-hero-content">
<h1 className="bf-hero-title">
Intelligent Scoliosis
<br />
<span className="bf-hero-accent">Brace Design</span>
</h1>
<p className="bf-hero-subtitle">
Advanced AI-powered analysis and custom brace generation for scoliosis treatment.
Upload an X-ray, get precise Cobb angle measurements and Rigo classification,
and generate patient-specific 3D-printable braces.
</p>
<div className="bf-hero-actions">
<button
className="bf-hero-btn bf-hero-btn--primary"
onClick={() => navigate("/login")}
>
Sign In
</button>
</div>
</div>
{/* Hero Visual - Spine illustration */}
<div className="bf-hero-visual">
<svg
className="bf-hero-svg"
viewBox="0 0 200 300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Spine curve */}
<path
d="M100 20 C 80 60 120 100 100 140 C 80 180 120 220 100 260"
stroke="rgba(255,255,255,0.3)"
strokeWidth="4"
strokeLinecap="round"
fill="none"
className="bf-hero-spine"
/>
{/* Vertebrae */}
{[40, 80, 120, 160, 200, 240].map((y, i) => (
<circle
key={i}
cx={100 + (i % 2 === 0 ? -10 : 10) * Math.sin((i * Math.PI) / 3)}
cy={y}
r="12"
fill="rgba(221, 130, 80, 0.15)"
stroke="var(--accent-primary)"
strokeWidth="2"
className="bf-hero-vertebra"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
{/* Brace outline */}
<path
d="M60 60 Q 40 150 60 240 L 140 240 Q 160 150 140 60 Z"
stroke="var(--accent-primary)"
strokeWidth="3"
strokeDasharray="8 4"
fill="none"
opacity="0.6"
className="bf-hero-brace"
/>
</svg>
</div>
</section>
{/* Features Section */}
<section className="bf-features">
<div className="bf-features-grid">
<div className="bf-feature-card">
<div className="bf-feature-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<circle cx="12" cy="15" r="3" />
</svg>
</div>
<h3 className="bf-feature-title">X-ray Analysis</h3>
<p className="bf-feature-desc">
Upload spinal X-rays for automatic vertebrae detection and landmark identification
using advanced computer vision.
</p>
</div>
<div className="bf-feature-card">
<div className="bf-feature-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
</div>
<h3 className="bf-feature-title">Cobb Angle Measurement</h3>
<p className="bf-feature-desc">
Precise calculation of Cobb angles (PT, MT, TL) with severity classification
and Rigo-Chêneau type determination.
</p>
</div>
<div className="bf-feature-card">
<div className="bf-feature-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="7.5 4.21 12 6.81 16.5 4.21" />
<polyline points="7.5 19.79 7.5 14.6 3 12" />
<polyline points="21 12 16.5 14.6 16.5 19.79" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
</div>
<h3 className="bf-feature-title">3D Brace Generation</h3>
<p className="bf-feature-desc">
Generate custom 3D-printable braces with patient-specific pressure zones
and relief windows based on curve analysis.
</p>
</div>
</div>
</section>
{/* Workflow Section */}
<section className="bf-workflow">
<h2 className="bf-section-heading">How It Works</h2>
<div className="bf-workflow-steps">
<div className="bf-workflow-step">
<div className="bf-workflow-number">1</div>
<div className="bf-workflow-content">
<h4>Upload X-ray</h4>
<p>Upload a spinal PA/AP X-ray image</p>
</div>
</div>
<div className="bf-workflow-connector" />
<div className="bf-workflow-step">
<div className="bf-workflow-number">2</div>
<div className="bf-workflow-content">
<h4>Review Analysis</h4>
<p>Verify landmarks and measurements</p>
</div>
</div>
<div className="bf-workflow-connector" />
<div className="bf-workflow-step">
<div className="bf-workflow-number">3</div>
<div className="bf-workflow-content">
<h4>Generate Brace</h4>
<p>Create custom 3D brace design</p>
</div>
</div>
<div className="bf-workflow-connector" />
<div className="bf-workflow-step">
<div className="bf-workflow-number">4</div>
<div className="bf-workflow-content">
<h4>Download & Print</h4>
<p>Export STL files for 3D printing</p>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="bf-home-footer">
<p>BraceIQ Development Environment</p>
</footer>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
const ENTER_DURATION_MS = 2300;
const EXIT_DURATION_MS = 650;
export default function LandingPage() {
const nav = useNavigate();
const [isExiting, setIsExiting] = useState(false);
useEffect(() => {
const exitTimer = window.setTimeout(() => setIsExiting(true), ENTER_DURATION_MS);
const navTimer = window.setTimeout(
() => nav("/dashboard", { replace: true }),
ENTER_DURATION_MS + EXIT_DURATION_MS
);
return () => {
window.clearTimeout(exitTimer);
window.clearTimeout(navTimer);
};
}, [nav]);
return (
<div className={`bf-landing ${isExiting ? "is-exiting" : ""}`} role="status" aria-live="polite">
<div className="bf-landing-inner">
<div className="bf-landing-visual" aria-hidden="true">
<svg className="bf-landing-svg" viewBox="0 0 200 200" role="presentation">
<defs>
<linearGradient id="bfBraceGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#efb07a" stopOpacity="0.95" />
<stop offset="100%" stopColor="#d17645" stopOpacity="0.95" />
</linearGradient>
</defs>
<path
className="bf-landing-spine"
d="M100 34 C 92 60, 108 86, 100 112 C 92 138, 108 164, 100 188"
/>
<circle className="bf-landing-vertebra" cx="100" cy="48" r="6.2" />
<circle className="bf-landing-vertebra" cx="100" cy="74" r="5.8" />
<circle className="bf-landing-vertebra" cx="100" cy="100" r="5.8" />
<circle className="bf-landing-vertebra" cx="100" cy="126" r="5.8" />
<circle className="bf-landing-vertebra" cx="100" cy="152" r="6.2" />
<path
className="bf-landing-brace bf-landing-brace--left"
d="M58 62 C 44 82, 44 118, 58 138"
stroke="url(#bfBraceGrad)"
/>
<path
className="bf-landing-brace bf-landing-brace--right"
d="M142 62 C 156 82, 156 118, 142 138"
stroke="url(#bfBraceGrad)"
/>
<path
className="bf-landing-pad"
d="M70 86 C 64 96, 64 104, 70 114"
stroke="url(#bfBraceGrad)"
/>
<path
className="bf-landing-pad"
d="M130 86 C 136 96, 136 104, 130 114"
stroke="url(#bfBraceGrad)"
/>
</svg>
</div>
<div className="bf-landing-mark">
<div className="bf-landing-wordmark">
Brace<span className="bf-brand-accent">iQ</span>
</div>
</div>
<p className="bf-landing-slogan">
Guided support design, from imaging to fabrication.
</p>
<div className="bf-landing-progress" aria-hidden="true">
<span className="bf-landing-progress-bar" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,584 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { api } from "../lib/api";
import type { SubmitLandmarksRequest } from "../lib/api";
import { LandmarkCanvas } from "../components/LandmarkCanvas";
import type { Point } from "../components/LandmarkCanvas";
const API_BASE = "https://cfx9z50wj2.execute-api.ca-central-1.amazonaws.com/prod";
type AnyObj = Record<string, any>;
async function httpJson<T>(path: string, init?: RequestInit): Promise<T> {
const base = API_BASE.replace(/\/+$/, "");
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const url = `${base}${normalizedPath}`;
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers || {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
return (await res.json()) as T;
}
function buildPublicS3Url(bucket: string, key: string) {
return `https://${bucket}.s3.amazonaws.com/${key}`;
}
async function tryResolveApUrlFromApi(caseId: string): Promise<string | null> {
const candidates: Array<{
method: "GET" | "POST";
path: string;
body?: any;
pickUrl: (json: AnyObj) => string | null;
}> = [
{
method: "GET",
path: `/cases/${encodeURIComponent(caseId)}/xray-url?view=ap`,
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
},
{
method: "GET",
path: `/cases/${encodeURIComponent(caseId)}/xray-preview?view=ap`,
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
},
{
method: "GET",
path: `/cases/${encodeURIComponent(caseId)}/download-url?type=xray&view=ap`,
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
},
{
method: "POST",
path: `/cases/${encodeURIComponent(caseId)}/download-url`,
body: { type: "xray", view: "ap" },
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
},
{
method: "POST",
path: `/cases/${encodeURIComponent(caseId)}/file-url`,
body: { kind: "xray", view: "ap" },
pickUrl: (j) => j?.url || j?.downloadUrl || j?.imageUrl || null,
},
];
for (const c of candidates) {
try {
const json =
c.method === "GET"
? await httpJson<AnyObj>(c.path)
: await httpJson<AnyObj>(c.path, {
method: "POST",
body: JSON.stringify(c.body ?? {}),
});
const url = c.pickUrl(json);
if (url && typeof url === "string") return url;
} catch {
// ignore and continue
}
}
return null;
}
function assetHasAp(assets: AnyObj | null): boolean {
if (!assets) return false;
const xr = assets.xrays ?? assets.assets?.xrays;
if (Array.isArray(xr)) return xr.includes("ap");
if (xr && typeof xr === "object") return !!xr.ap;
if (typeof assets.apImageUrl === "string" && assets.apImageUrl) return true;
return false;
}
function pickApUrlFromAssets(assets: AnyObj | null): string | null {
if (!assets) return null;
const a = assets.assets ?? assets;
if (typeof a.apImageUrl === "string" && a.apImageUrl) return a.apImageUrl;
if (typeof a.xrays?.ap === "string" && a.xrays.ap) return a.xrays.ap;
const apObj = a?.ap || a?.xrays?.ap || a?.assets?.xrays?.ap;
if (apObj && apObj.bucket && apObj.key) return buildPublicS3Url(apObj.bucket, apObj.key);
const apUrl = a?.xrays?.ap?.url || a?.xrays?.ap?.downloadUrl || a?.xrays?.ap?.imageUrl;
if (typeof apUrl === "string" && apUrl) return apUrl;
return null;
}
function jsonPretty(v: any) {
try {
return JSON.stringify(v, null, 2);
} catch {
return String(v);
}
}
type ArtifactTab = { n: number; label: string; path: string };
type ArtifactState = { loading: boolean; error: string | null; json: any | null; lastLoadedAt?: number };
function buildArtifactUrl(caseId: string, path: string) {
return `https://braceflow-uploads-20260125.s3.ca-central-1.amazonaws.com/cases/${encodeURIComponent(caseId)}/${path}`;
}
export function LandmarkCapturePage() {
const { caseId } = useParams();
const nav = useNavigate();
const id = (caseId || "").trim();
const [assets, setAssets] = useState<AnyObj | null>(null);
const [assetsLoaded, setAssetsLoaded] = useState(false);
const [imageUrl, setImageUrl] = useState<string>("");
const [imageLoading, setImageLoading] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
const [manualUrl, setManualUrl] = useState<string>("");
// ✅ FIX: was Point[]; must be Record<string, Point> to match LandmarkCanvas + SubmitLandmarksRequest
const [landmarks, setLandmarks] = useState<Record<string, Point>>({});
const [completed, setCompleted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
// --- Artifacts slide panel ---
const [artifactsOpen, setArtifactsOpen] = useState(false);
const [activeArtifactIdx, setActiveArtifactIdx] = useState(0);
const [artifactStateByIdx, setArtifactStateByIdx] = useState<Record<number, ArtifactState>>({});
const artifactTabs: ArtifactTab[] = useMemo(
() => [
{ n: 1, label: "1", path: "step1_normalized/meta.json" },
{ n: 2, label: "2", path: "step2_measurements/landmarks.json" },
{ n: 3, label: "3", path: "step2_measurements/measurements.json" },
{ n: 4, label: "4", path: "step3_rigo/classification.json" },
{ n: 5, label: "5", path: "step4_template/template.json" },
{ n: 6, label: "6", path: "step5_deformation/brace_spec.json" },
{ n: 7, label: "7", path: "step6_export/print_manifest.json" },
],
[]
);
const apExists = useMemo(() => assetHasAp(assets), [assets]);
const codeBoxStyle: React.CSSProperties = {
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 10,
padding: 12,
background: "rgba(0,0,0,0.20)",
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
};
const linkStyle: React.CSSProperties = {
textDecoration: "underline",
fontWeight: 700,
opacity: 0.95,
};
async function loadAssetsAndResolveImage() {
if (!id) {
setMsg("No case id in route.");
return;
}
setAssetsLoaded(false);
setMsg(null);
setImageError(null);
try {
const a = await httpJson<AnyObj>(`/cases/${encodeURIComponent(id)}/assets`);
setAssets(a);
const direct = pickApUrlFromAssets(a);
if (direct) {
setImageLoading(true);
setImageUrl(direct);
return;
}
if (assetHasAp(a)) {
const resolved = await tryResolveApUrlFromApi(id);
if (resolved) {
setImageLoading(true);
setImageUrl(resolved);
return;
}
setImageUrl("");
setImageError(
"AP x-ray exists (assets.xrays includes 'ap') but no viewable image URL was returned. " +
"Backend likely needs a presigned GET/preview endpoint (e.g., /cases/{caseId}/xray-url?view=ap). " +
"Use the manual URL field below as a temporary workaround."
);
return;
}
setImageUrl("");
setImageError(null);
} catch (e: any) {
setMsg(e?.message || "Failed to load assets");
} finally {
setAssetsLoaded(true);
}
}
async function loadArtifactAt(idx: number) {
if (!id) {
setArtifactStateByIdx((p) => ({
...p,
[idx]: { loading: false, error: "Missing caseId in route.", json: null },
}));
return;
}
const tab = artifactTabs[idx];
if (!tab) return;
setArtifactStateByIdx((p) => ({
...p,
[idx]: { ...(p[idx] ?? { json: null, error: null }), loading: true, error: null },
}));
const url = buildArtifactUrl(id, tab.path);
try {
const res = await fetch(url);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
}
const json = await res.json();
setArtifactStateByIdx((p) => ({
...p,
[idx]: { loading: false, error: null, json, lastLoadedAt: Date.now() },
}));
} catch (e: any) {
setArtifactStateByIdx((p) => ({
...p,
[idx]: { loading: false, error: e?.message || "Failed to load JSON", json: null },
}));
}
}
useEffect(() => {
loadAssetsAndResolveImage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
if (!artifactsOpen) return;
const st = artifactStateByIdx[activeArtifactIdx];
if (!st || (!st.loading && st.json == null && st.error == null)) {
loadArtifactAt(activeArtifactIdx);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artifactsOpen, activeArtifactIdx]);
useEffect(() => {
if (!artifactsOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setArtifactsOpen(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [artifactsOpen]);
async function onSubmit() {
if (!id) {
setMsg("No case id");
return;
}
setSubmitting(true);
setMsg(null);
try {
const body: SubmitLandmarksRequest = { caseId: id, view: "ap", landmarks };
const res = await api.submitLandmarks(id, body);
if (res?.ok) {
setMsg("Landmarks submitted. Pipeline should resume from Step2.");
nav(`/cases/${encodeURIComponent(id)}/status`);
} else {
setMsg("Submission completed but response did not include ok=true.");
}
} catch (e: any) {
setMsg(e?.message || "Failed to submit landmarks");
} finally {
setSubmitting(false);
}
}
const activeTab = artifactTabs[activeArtifactIdx];
const activeUrl = activeTab && id ? buildArtifactUrl(id, activeTab.path) : "";
const activeState = artifactStateByIdx[activeArtifactIdx] ?? { loading: false, error: null, json: null };
const canSubmit = completed && !submitting && !!imageUrl;
return (
<div className="bf-page bf-page--wide">
<div className="bf-page-header">
<div>
<h1 className="bf-page-title">Landmark Capture</h1>
<p className="bf-page-subtitle">
Place landmarks on the AP X-ray, then submit to resume the pipeline.
</p>
</div>
<div className="bf-spacer" />
<div className="bf-toolbar">
<button className="btn secondary" onClick={() => setArtifactsOpen(true)}>
Artifacts
</button>
<button className="btn secondary" onClick={() => nav(`/cases/${encodeURIComponent(id)}/status`)}>
View Status
</button>
<button className="btn secondary" onClick={() => loadAssetsAndResolveImage()}>
Reload Assets
</button>
<button className="btn primary" disabled={!canSubmit} onClick={onSubmit}>
{submitting ? "Submitting..." : "Submit Landmarks"}
</button>
</div>
</div>
<div className="card">
<div className="muted muted--tight">
Case: <strong>{id || "(missing)"}</strong>
</div>
{msg && <div className="notice">{msg}</div>}
{!assetsLoaded ? (
<div className="muted">Loading assets...</div>
) : (
<>
<div className="bf-row-gap-16">
<div className="bf-flex-1">
{imageUrl ? (
<div>
<div className="bf-mb-8">
{imageLoading && <div className="muted">Loading image</div>}
{imageError && <div className="error">{imageError}</div>}
</div>
{/* ============================
Thumbnail on top + Workspace below
============================ */}
<div className="lc-stack">
{/* Thumbnail */}
<div className="lc-thumbRow">
<div className="lc-thumbCol">
<div className="lc-thumbBox">
<img
src={imageUrl}
alt="AP x-ray thumbnail"
onLoad={() => {
setImageLoading(false);
setImageError(null);
}}
onError={() => {
setImageLoading(false);
setImageError(
"Image failed to load. Most common causes: (1) URL is not public/presigned, (2) S3 CORS blocks browser, (3) URL points to a DICOM (not browser-renderable)."
);
}}
/>
</div>
<div className="lc-thumbActions">
<button className="btn secondary" onClick={() => window.open(imageUrl, "_blank")}>
Open
</button>
<button
className="btn secondary"
onClick={() => {
setImageLoading(true);
setImageError(null);
setImageUrl(imageUrl);
}}
>
Reload
</button>
</div>
{imageLoading && <div className="muted">Loading image</div>}
</div>
</div>
{/* Workspace */}
<div className="lc-workspace">
<div className="lc-workspace-title muted">Landmark capture</div>
{/* IMPORTANT: do NOT wrap LandmarkCanvas in a 250x250 box.
We will constrain ONLY the image holder via CSS. */}
<div className="lc-workspace-body">
<LandmarkCanvas
imageUrl={imageUrl}
initialLandmarks={landmarks}
onChange={(lm, done) => {
setLandmarks(lm);
setCompleted(done);
}}
/>
</div>
</div>
</div>
{/* ============================ */}
</div>
) : (
<div className="bf-dashed-panel">
<div className="muted">
{apExists
? "AP x-ray exists for this case, but no viewable URL is available yet."
: "AP image not available for this case."}
</div>
<div className="bf-mt-12">
<div className="muted bf-mb-6">
Temporary workaround: paste a viewable image URL (presigned GET to a JPG/PNG).
</div>
<div className="row gap">
<input
className="input"
placeholder="https://... (presigned GET to preview image)"
value={manualUrl}
onChange={(e) => setManualUrl(e.target.value)}
/>
<button
className="btn"
onClick={() => {
if (!manualUrl.trim()) return;
setImageLoading(true);
setImageError(null);
setImageUrl(manualUrl.trim());
}}
>
Use URL
</button>
</div>
</div>
<div className="bf-mt-12">
<button className="btn" onClick={() => nav(`/cases/${encodeURIComponent(id)}/xray`)}>
Upload AP X-ray
</button>
</div>
{imageError && (
<div className="error bf-mt-12">
{imageError}
</div>
)}
</div>
)}
</div>
</div>
</>
)}
</div>
{/* ==========================
Slide-in Artifacts Drawer
========================== */}
<div
className={`bf-drawer-backdrop ${artifactsOpen ? "is-open" : ""}`}
onClick={() => setArtifactsOpen(false)}
role="presentation"
/>
<aside className={`bf-drawer ${artifactsOpen ? "is-open" : ""}`} aria-hidden={!artifactsOpen}>
<div className="bf-drawer-header">
<div className="bf-col-tight">
<div className="bf-drawer-title">Artifacts</div>
<div className="bf-drawer-subtitle">Case: {id || "(missing)"}</div>
</div>
<button className="btn secondary" onClick={() => setArtifactsOpen(false)}>
Close
</button>
</div>
<div className="bf-tabs">
{artifactTabs.map((t, idx) => (
<button
key={t.n}
className={`bf-tab ${idx === activeArtifactIdx ? "is-active" : ""}`}
onClick={() => setActiveArtifactIdx(idx)}
title={t.path}
>
{t.label}
</button>
))}
</div>
<div className="bf-drawer-body">
<div className="row space center">
<div className="bf-col-tight">
<div className="bf-strong">Artifact {activeTab?.label}</div>
<div className="muted muted--small bf-ellipsis">
{activeTab?.path}
</div>
</div>
<div className="bf-row-end-wrap">
<button className="btn secondary" onClick={() => window.open(activeUrl, "_blank")} disabled={!activeUrl}>
Open JSON
</button>
<button
className="btn secondary"
onClick={() => loadArtifactAt(activeArtifactIdx)}
disabled={!id || activeState.loading}
>
{activeState.loading ? "Loading…" : "Reload"}
</button>
</div>
</div>
{activeState.error && (
<div className="error bf-mt-10">
{activeState.error}
</div>
)}
<div className="bf-mt-12">
<pre style={codeBoxStyle}>
{activeState.loading ? "Loading…" : activeState.json ? jsonPretty(activeState.json) : "(not available yet)"}
</pre>
</div>
<div className="bf-mt-14">
<div className="muted muted--small bf-mb-6">
Assets (debug)
</div>
<pre style={codeBoxStyle}>{jsonPretty(assets ?? {})}</pre>
<div className="bf-mt-10">
<div className="muted">AP exists: {String(apExists)}</div>
<div className="muted">Image URL resolved: {imageUrl ? "yes" : "no"}</div>
{activeUrl && (
<div className="muted">
URL:{" "}
<a style={linkStyle} href={activeUrl} target="_blank" rel="noreferrer">
{activeUrl}
</a>
</div>
)}
</div>
</div>
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { login, isAuthenticated, error, clearError } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// Get the redirect path from state, or default to "/"
const from = (location.state as any)?.from?.pathname || "/";
// Redirect if already logged in
useEffect(() => {
if (isAuthenticated) {
navigate(from, { replace: true });
}
}, [isAuthenticated, navigate, from]);
// Clear errors when inputs change
useEffect(() => {
if (error) {
clearError();
}
}, [username, password]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password.trim()) {
return;
}
setIsSubmitting(true);
try {
await login(username, password);
navigate(from, { replace: true });
} catch {
// Error is handled by AuthContext
} finally {
setIsSubmitting(false);
}
};
return (
<div className="bf-login-page">
<div className="bf-login-container">
{/* Logo/Brand */}
<div className="bf-login-header">
<h1 className="bf-login-brand">
Brace<span className="bf-brand-accent">iQ</span>
</h1>
<p className="bf-login-subtitle">Sign in to your account</p>
</div>
{/* Login Form */}
<form className="bf-login-form" onSubmit={handleSubmit}>
{error && (
<div className="bf-login-error">
{error}
</div>
)}
<div className="bf-form-group">
<label htmlFor="username" className="bf-form-label">
Username
</label>
<input
id="username"
type="text"
className="bf-form-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
autoComplete="username"
autoFocus
disabled={isSubmitting}
/>
</div>
<div className="bf-form-group">
<label htmlFor="password" className="bf-form-label">
Password
</label>
<input
id="password"
type="password"
className="bf-form-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
autoComplete="current-password"
disabled={isSubmitting}
/>
</div>
<button
type="submit"
className="bf-login-btn"
disabled={isSubmitting || !username.trim() || !password.trim()}
>
{isSubmitting ? (
<>
<span className="bf-login-spinner"></span>
Signing in...
</>
) : (
"Sign In"
)}
</button>
</form>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More