commit 745f9f827fbeb1d00e328871065913b177cf8629 Author: Mo_Saghafian Date: Thu Jan 29 14:34:05 2026 -0800 Initial commit - BraceIQMed platform with frontend, API, and brace generator diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6e3ae78 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb40703 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e697096 --- /dev/null +++ b/README.md @@ -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. diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..4b3cd03 --- /dev/null +++ b/api/Dockerfile @@ -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"] diff --git a/api/db/sqlite.js b/api/db/sqlite.js new file mode 100644 index 0000000..9eda87c --- /dev/null +++ b/api/db/sqlite.js @@ -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 +}; diff --git a/api/package-lock.json b/api/package-lock.json new file mode 100644 index 0000000..0bf968a --- /dev/null +++ b/api/package-lock.json @@ -0,0 +1,1619 @@ +{ + "name": "braceflow-api-dev", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "braceflow-api-dev", + "version": "1.0.0", + "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" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.19.2.tgz", + "integrity": "sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..9be1a30 --- /dev/null +++ b/api/package.json @@ -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" + } +} diff --git a/api/server.js b/api/server.js new file mode 100644 index 0000000..f266266 --- /dev/null +++ b/api/server.js @@ -0,0 +1,2065 @@ +/** + * BraceFlow DEV API Server + * Express server that replaces AWS Lambda functions for local development + */ +import express from 'express'; +import cors from 'cors'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { fileURLToPath } from 'url'; +// Note: Using undici for HTTP requests and FormData (native fetch + FormData) +// Do NOT import 'form-data' package - it conflicts with native FormData +import undici from 'undici'; +const { fetch: undiciFetch, FormData: UndiciFormData, File: UndiciFile } = undici; + +import db from './db/sqlite.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const app = express(); + +// Configuration +const PORT = process.env.PORT || 3001; +const BRACE_GENERATOR_URL = process.env.BRACE_GENERATOR_URL || 'http://localhost:8001'; +// Use DATA_DIR from env (Docker) or local path (dev) +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); +const OUTPUTS_DIR = path.join(DATA_DIR, 'outputs'); + +// PUBLIC_URL for constructing URLs in responses +// In production, this should be empty to use relative URLs +// In local dev, set to http://localhost:3001 +const PUBLIC_URL = process.env.PUBLIC_URL || ''; + +/** + * Get base URL for constructing file/API URLs + * In production: returns empty string (uses relative URLs) + * In local dev: returns http://localhost:PORT + */ +function getBaseUrl(req) { + if (PUBLIC_URL) return PUBLIC_URL; + + // In local development, return absolute URL with port + // This is needed for CORS when frontend runs on different port (e.g., 5173) + if (process.env.NODE_ENV !== 'production' && req) { + const protocol = req.protocol || 'http'; + const host = req.get('host') || `localhost:${PORT}`; + return `${protocol}://${host}`; + } + + // In production behind proxy, use relative URLs + return ''; +} + +// Ensure directories exist +[DATA_DIR, UPLOADS_DIR, OUTPUTS_DIR].forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +}); + +// Middleware - CORS: Allow ALL origins, methods, headers +app.use(cors({ + origin: '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin'], + credentials: false, + maxAge: 86400 +})); + +// Handle preflight requests for all routes +app.options('*', cors()); + +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ extended: true, limit: '50mb' })); + +// Static file serving for outputs with proper MIME types for 3D files +const customMimeTypes = { + '.glb': 'model/gltf-binary', + '.gltf': 'model/gltf+json', + '.stl': 'application/octet-stream', + '.obj': 'text/plain', + '.ply': 'application/octet-stream' +}; + +app.use('/files', express.static(DATA_DIR, { + setHeaders: (res, filePath) => { + const ext = path.extname(filePath).toLowerCase(); + if (customMimeTypes[ext]) { + res.setHeader('Content-Type', customMimeTypes[ext]); + } + } +})); + +// File upload configuration +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const caseId = req.params.caseId || req.body.caseId || 'temp'; + const caseDir = path.join(UPLOADS_DIR, caseId); + if (!fs.existsSync(caseDir)) { + fs.mkdirSync(caseDir, { recursive: true }); + } + cb(null, caseDir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `xray${ext}`); + } +}); +const upload = multer({ storage, limits: { fileSize: 50 * 1024 * 1024 } }); + +// ============================================ +// API Routes +// ============================================ + +/** + * Health check + */ +app.get('/api/health', (req, res) => { + res.json({ + status: 'ok', + service: 'braceflow-api-dev', + timestamp: new Date().toISOString() + }); +}); + +/** + * Create a new case + * POST /api/cases + */ +app.post('/api/cases', (req, res) => { + try { + const { caseType = 'braceflow', notes = null } = req.body; + + // Generate case ID + const yyyymmdd = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const rand = crypto.randomBytes(4).toString('hex'); + const caseId = `case-${yyyymmdd}-${rand}`; + + const result = db.createCase(caseId, caseType, notes); + + // Create case directories + const caseUploadDir = path.join(UPLOADS_DIR, caseId); + const caseOutputDir = path.join(OUTPUTS_DIR, caseId); + fs.mkdirSync(caseUploadDir, { recursive: true }); + fs.mkdirSync(caseOutputDir, { recursive: true }); + + res.status(201).json(result); + } catch (err) { + console.error('Create case error:', err); + res.status(500).json({ message: 'Failed to create case', error: err.message }); + } +}); + +/** + * List all cases + * GET /api/cases + */ +app.get('/api/cases', (req, res) => { + try { + const cases = db.listCases(); + res.json(cases); + } catch (err) { + console.error('List cases error:', err); + res.status(500).json({ message: 'Failed to list cases', error: err.message }); + } +}); + +/** + * Get case by ID + * GET /api/cases/:caseId + */ +app.get('/api/cases/:caseId', (req, res) => { + try { + const { caseId } = req.params; + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + const baseUrl = getBaseUrl(req); + + // Add xray_url if file exists + const caseUploadDir = path.join(UPLOADS_DIR, caseId); + if (fs.existsSync(caseUploadDir)) { + const files = fs.readdirSync(caseUploadDir); + const xrayFile = files.find(f => f.startsWith('xray')); + if (xrayFile) { + caseData.xray_url = `${baseUrl}/files/uploads/${caseId}/${xrayFile}`; + } + } + + // Add visualization_url if file exists + const vizPath = path.join(OUTPUTS_DIR, caseId, 'visualization.png'); + if (fs.existsSync(vizPath)) { + caseData.visualization_url = `${baseUrl}/files/outputs/${caseId}/visualization.png`; + } + + // Add landmarks_json_url if file exists + const landmarksPath = path.join(OUTPUTS_DIR, caseId, 'landmarks.json'); + if (fs.existsSync(landmarksPath)) { + caseData.landmarks_json_url = `${baseUrl}/files/outputs/${caseId}/landmarks.json`; + } + + res.json(caseData); + } catch (err) { + console.error('Get case error:', err); + res.status(500).json({ message: 'Failed to get case', error: err.message }); + } +}); + +/** + * Get case status + * GET /api/cases/:caseId/status + */ +app.get('/api/cases/:caseId/status', (req, res) => { + try { + const caseData = db.getCase(req.params.caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + res.json(caseData); + } catch (err) { + console.error('Get case status error:', err); + res.status(500).json({ message: 'Failed to get case status', error: err.message }); + } +}); + +/** + * Get upload URL (for S3 compatibility - returns direct upload endpoint) + * POST /api/cases/:caseId/upload-url + */ +app.post('/api/cases/:caseId/upload-url', (req, res) => { + try { + const { caseId } = req.params; + const { filename, contentType } = req.body; + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + // For DEV, return a URL that points to our direct upload endpoint + // The frontend will PUT to this URL with the file + const baseUrl = getBaseUrl(req); + const uploadUrl = `${baseUrl}/api/cases/${caseId}/upload-direct`; + const s3Key = `cases/${caseId}/input/${filename || 'ap.jpg'}`; + + res.json({ + url: uploadUrl, + s3Key: s3Key + }); + } catch (err) { + console.error('Get upload URL error:', err); + res.status(500).json({ message: 'Failed to get upload URL', error: err.message }); + } +}); + +/** + * Direct upload (PUT) - for presigned URL compatibility + * PUT /api/cases/:caseId/upload-direct + */ +app.put('/api/cases/:caseId/upload-direct', express.raw({ type: '*/*', limit: '50mb' }), (req, res) => { + try { + const { caseId } = req.params; + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + // Save the raw body as a file + const caseUploadDir = path.join(UPLOADS_DIR, caseId); + if (!fs.existsSync(caseUploadDir)) { + fs.mkdirSync(caseUploadDir, { recursive: true }); + } + + // Determine extension from content-type + const contentType = req.headers['content-type'] || 'image/jpeg'; + const ext = contentType.includes('png') ? '.png' : '.jpg'; + const filename = `xray${ext}`; + const filePath = path.join(caseUploadDir, filename); + + fs.writeFileSync(filePath, req.body); + + res.status(200).send('OK'); + } catch (err) { + console.error('Direct upload error:', err); + res.status(500).json({ message: 'Failed to upload file', error: err.message }); + } +}); + +/** + * Upload X-ray image (multipart form) + * POST /api/cases/:caseId/upload + */ +app.post('/api/cases/:caseId/upload', upload.single('file'), (req, res) => { + try { + const { caseId } = req.params; + + if (!req.file) { + return res.status(400).json({ message: 'No file uploaded' }); + } + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + const filePath = `/files/uploads/${caseId}/${req.file.filename}`; + + res.json({ + caseId, + filename: req.file.filename, + path: filePath, + size: req.file.size + }); + } catch (err) { + console.error('Upload error:', err); + res.status(500).json({ message: 'Failed to upload file', error: err.message }); + } +}); + +/** + * Detect landmarks (Stage 1) + * POST /api/cases/:caseId/detect-landmarks + */ +app.post('/api/cases/:caseId/detect-landmarks', async (req, res) => { + const { caseId } = req.params; + + try { + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + // Find the X-ray file + const caseUploadDir = path.join(UPLOADS_DIR, caseId); + const files = fs.readdirSync(caseUploadDir); + const xrayFile = files.find(f => f.startsWith('xray')); + + if (!xrayFile) { + return res.status(400).json({ message: 'No X-ray image found. Please upload first.' }); + } + + const xrayPath = path.join(caseUploadDir, xrayFile); + + // Update status + db.updateCaseStatus(caseId, 'running', 'LandmarkDetection'); + db.updateStepStatus(caseId, 'LandmarkDetection', 'running'); + + // Call brace generator for landmark detection only + // Use undici FormData with File for proper multipart handling + const formData = new UndiciFormData(); + const fileBuffer = fs.readFileSync(xrayPath); + // Use File instead of Blob for proper filename handling + const file = new File([fileBuffer], xrayFile, { type: 'image/jpeg' }); + formData.append('file', file); + formData.append('case_id', caseId); + formData.append('detect_only', 'true'); + + console.log(`Calling brace generator: ${BRACE_GENERATOR_URL}/detect-landmarks`); + + const response = await undiciFetch(`${BRACE_GENERATOR_URL}/detect-landmarks`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Brace generator error: ${errorText}`); + } + + const result = await response.json(); + + const baseUrl = getBaseUrl(req); + + // Save visualization to outputs + if (result.visualization_path) { + const vizResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${path.basename(result.visualization_path)}`); + if (vizResponse.ok) { + const vizBuffer = Buffer.from(await vizResponse.arrayBuffer()); + const vizPath = path.join(OUTPUTS_DIR, caseId, 'visualization.png'); + fs.mkdirSync(path.dirname(vizPath), { recursive: true }); + fs.writeFileSync(vizPath, vizBuffer); + result.visualization_url = `${baseUrl}/files/outputs/${caseId}/visualization.png`; + } + } + + // Save JSON to outputs + const jsonPath = path.join(OUTPUTS_DIR, caseId, 'landmarks.json'); + fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2)); + result.json_url = `${baseUrl}/files/outputs/${caseId}/landmarks.json`; + + // Save to database + db.saveLandmarks(caseId, result); + db.updateStepStatus(caseId, 'LandmarkDetection', 'done'); + + res.json({ + caseId, + status: 'landmarks_detected', + ...result + }); + + } catch (err) { + console.error('Detect landmarks error:', err); + db.updateStepStatus(caseId, 'LandmarkDetection', 'failed', err.message); + db.updateCaseStatus(caseId, 'failed'); + res.status(500).json({ message: 'Failed to detect landmarks', error: err.message }); + } +}); + +/** + * Save/update landmarks (manual edit) + * PUT /api/cases/:caseId/landmarks + */ +app.put('/api/cases/:caseId/landmarks', (req, res) => { + try { + const { caseId } = req.params; + const { landmarks_data } = req.body; + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + db.saveLandmarks(caseId, landmarks_data); + + // Save updated JSON + const jsonPath = path.join(OUTPUTS_DIR, caseId, 'landmarks.json'); + fs.writeFileSync(jsonPath, JSON.stringify(landmarks_data, null, 2)); + + res.json({ caseId, status: 'landmarks_updated' }); + } catch (err) { + console.error('Update landmarks error:', err); + res.status(500).json({ message: 'Failed to update landmarks', error: err.message }); + } +}); + +/** + * Approve landmarks and move to analysis (Stage 2) + * POST /api/cases/:caseId/approve-landmarks + */ +app.post('/api/cases/:caseId/approve-landmarks', (req, res) => { + try { + const { caseId } = req.params; + const { updated_landmarks } = req.body; + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + db.approveLandmarks(caseId, updated_landmarks); + db.updateStepStatus(caseId, 'LandmarkApproval', 'done'); + + res.json({ caseId, status: 'landmarks_approved', next_step: 'SpineAnalysis' }); + } catch (err) { + console.error('Approve landmarks error:', err); + res.status(500).json({ message: 'Failed to approve landmarks', error: err.message }); + } +}); + +/** + * Recalculate spine analysis from landmarks + * POST /api/cases/:caseId/recalculate + */ +app.post('/api/cases/:caseId/recalculate', async (req, res) => { + const { caseId } = req.params; + + try { + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + if (!caseData.landmarks_data) { + return res.status(400).json({ message: 'No landmarks data found' }); + } + + db.updateStepStatus(caseId, 'SpineAnalysis', 'running'); + + // Call brace generator to recalculate + const response = await fetch(`${BRACE_GENERATOR_URL}/recalculate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + case_id: caseId, + landmarks: caseData.landmarks_data + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Recalculation error: ${errorText}`); + } + + const result = await response.json(); + + // Save analysis data separately (for Stage 2 display) + db.saveAnalysisData(caseId, result); + + // Also update landmarks_data with new Cobb angles so it persists + const updatedLandmarks = { + ...caseData.landmarks_data, + cobb_angles: result.cobb_angles, + rigo_classification: result.rigo_classification, + curve_type: result.curve_type + }; + db.saveLandmarks(caseId, updatedLandmarks); + + db.updateStepStatus(caseId, 'SpineAnalysis', 'done'); + + // Save JSON to outputs + const caseOutputDir = path.join(OUTPUTS_DIR, caseId); + fs.mkdirSync(caseOutputDir, { recursive: true }); + const jsonPath = path.join(caseOutputDir, 'analysis.json'); + fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2)); + + res.json({ + caseId, + status: 'analysis_complete', + ...result + }); + + } catch (err) { + console.error('Recalculate error:', err); + db.updateStepStatus(caseId, 'SpineAnalysis', 'failed', err.message); + res.status(500).json({ message: 'Failed to recalculate', error: err.message }); + } +}); + +/** + * Generate brace (Stage 4) + * POST /api/cases/:caseId/generate-brace + * + * If body scan is available, uses EXPERIMENT_10 (body-fitted brace) + * Otherwise, uses EXPERIMENT_3 (X-ray only adaptive brace) + */ +app.post('/api/cases/:caseId/generate-brace', async (req, res) => { + const { caseId } = req.params; + + try { + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + // Find the X-ray file + const caseUploadDir = path.join(UPLOADS_DIR, caseId); + const files = fs.readdirSync(caseUploadDir); + const xrayFile = files.find(f => f.startsWith('xray')); + + if (!xrayFile) { + return res.status(400).json({ message: 'No X-ray image found' }); + } + + const xrayPath = path.join(caseUploadDir, xrayFile); + + // Check if body scan should be used (based on database state, not just file existence) + // This ensures "skip body scan" works correctly even if a file was uploaded earlier + const hasBodyScanInDb = caseData.body_scan_path && caseData.body_scan_url; + const bodyScanFile = hasBodyScanInDb ? files.find(f => f.startsWith('body_scan')) : null; + const hasBodyScan = !!bodyScanFile; + const bodyScanPath = hasBodyScan ? path.join(caseUploadDir, bodyScanFile) : null; + + db.updateCaseStatus(caseId, 'processing_brace', 'BraceGeneration'); + db.updateStepStatus(caseId, 'BraceGeneration', 'running'); + + let result; + + if (hasBodyScan) { + // Use EXPERIMENT_10: Body-fitted brace generation + console.log(`Calling brace generator with body scan: ${BRACE_GENERATOR_URL}/generate-with-body`); + + // Use undici FormData with File for proper multipart handling + const formData = new UndiciFormData(); + + // Add X-ray file + const xrayBuffer = fs.readFileSync(xrayPath); + const xrayFileObj = new File([xrayBuffer], xrayFile, { type: 'image/jpeg' }); + formData.append('xray_file', xrayFileObj); + + // Add body scan file + const bodyScanBuffer = fs.readFileSync(bodyScanPath); + const bodyScanFileObj = new File([bodyScanBuffer], bodyScanFile, { type: 'application/octet-stream' }); + formData.append('body_scan_file', bodyScanFileObj); + + formData.append('case_id', caseId); + + // Include landmarks if manually edited + if (caseData.landmarks_data) { + formData.append('landmarks_json', JSON.stringify(caseData.landmarks_data)); + } + + // Add clearance from body scan metadata if available + const clearance = caseData.body_scan_metadata?.clearance_mm || 8.0; + formData.append('clearance_mm', clearance.toString()); + + const response = await undiciFetch(`${BRACE_GENERATOR_URL}/generate-with-body`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Body-fitted brace generator error: ${errorText}`); + } + + result = await response.json(); + result.generation_mode = 'body_fitted'; + + } else { + // Use EXPERIMENT_3: X-ray only adaptive brace + console.log(`Calling brace generator (X-ray only): ${BRACE_GENERATOR_URL}/analyze/upload`); + + // Use undici FormData with File for proper multipart handling + const formData = new UndiciFormData(); + const xrayBuffer = fs.readFileSync(xrayPath); + const xrayFileObj = new File([xrayBuffer], xrayFile, { type: 'image/jpeg' }); + formData.append('file', xrayFileObj); + formData.append('case_id', caseId); + formData.append('experiment', 'experiment_3'); + + // Include landmarks if manually edited + if (caseData.landmarks_data) { + formData.append('landmarks_json', JSON.stringify(caseData.landmarks_data)); + } + + const response = await undiciFetch(`${BRACE_GENERATOR_URL}/analyze/upload`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Brace generator error: ${errorText}`); + } + + result = await response.json(); + result.generation_mode = 'xray_only'; + } + + // Download output files from brace generator + const baseUrl = getBaseUrl(req); + const outputFiles = {}; + const caseOutputDir = path.join(OUTPUTS_DIR, caseId); + fs.mkdirSync(caseOutputDir, { recursive: true }); + + for (const [key, serverPath] of Object.entries(result.outputs || {})) { + if (!serverPath) continue; + + const filename = path.basename(serverPath); + try { + const fileResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${filename}`); + if (fileResponse.ok) { + const buffer = Buffer.from(await fileResponse.arrayBuffer()); + const localPath = path.join(caseOutputDir, filename); + fs.writeFileSync(localPath, buffer); + outputFiles[key] = `${baseUrl}/files/outputs/${caseId}/${filename}`; + } + } catch (e) { + console.error(`Failed to download ${key}:`, e.message); + } + } + + // Also generate BOTH GLB braces (regular + vase) for the dual viewer + const rigoType = result.rigo_classification?.type || result.rigo_type || 'A1'; + const cobbAngles = result.cobb_angles || {}; + + console.log(`Generating both GLB braces: ${rigoType}`); + + try { + const bothFormData = new UndiciFormData(); + bothFormData.append('rigo_type', rigoType); + bothFormData.append('case_id', caseId); + bothFormData.append('cobb_pt', String(cobbAngles.PT || 0)); + bothFormData.append('cobb_mt', String(cobbAngles.MT || 0)); + bothFormData.append('cobb_tl', String(cobbAngles.TL || 0)); + + // Add body scan if available + if (hasBodyScan && bodyScanPath && fs.existsSync(bodyScanPath)) { + const bodyScanBuffer = fs.readFileSync(bodyScanPath); + const bodyScanFileObj = new File([bodyScanBuffer], path.basename(bodyScanPath), { type: 'application/octet-stream' }); + bothFormData.append('body_scan', bodyScanFileObj); + } + + const bothResponse = await undiciFetch(`${BRACE_GENERATOR_URL}/generate-both-braces`, { + method: 'POST', + body: bothFormData + }); + + if (bothResponse.ok) { + const bothResult = await bothResponse.json(); + + // Download both brace files via HTTP (they're in different container) + const braces = {}; + for (const [braceType, braceData] of Object.entries(bothResult.braces || {})) { + if (braceData.error) { + braces[braceType] = { error: braceData.error }; + continue; + } + + const outputs = {}; + + // Download GLB via HTTP + if (braceData.outputs?.glb) { + const glbFilename = path.basename(braceData.outputs.glb); + const glbName = `${caseId}_${rigoType}_${braceType}.glb`; + try { + const glbResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${glbFilename}`); + if (glbResponse.ok) { + const buffer = Buffer.from(await glbResponse.arrayBuffer()); + fs.writeFileSync(path.join(caseOutputDir, glbName), buffer); + outputs.glb = `${baseUrl}/files/outputs/${caseId}/${glbName}`; + } + } catch (e) { console.warn(`Failed to download ${braceType} GLB:`, e.message); } + } + + // Download STL via HTTP + if (braceData.outputs?.stl) { + const stlFilename = path.basename(braceData.outputs.stl); + const stlName = `${caseId}_${rigoType}_${braceType}.stl`; + try { + const stlResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${stlFilename}`); + if (stlResponse.ok) { + const buffer = Buffer.from(await stlResponse.arrayBuffer()); + fs.writeFileSync(path.join(caseOutputDir, stlName), buffer); + outputs.stl = `${baseUrl}/files/outputs/${caseId}/${stlName}`; + } + } catch (e) { console.warn(`Failed to download ${braceType} STL:`, e.message); } + } + + // Download markers JSON via HTTP + if (braceData.outputs?.json) { + const jsonFilename = path.basename(braceData.outputs.json); + const jsonName = `${caseId}_${rigoType}_${braceType}_markers.json`; + try { + const jsonResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${jsonFilename}`); + if (jsonResponse.ok) { + const buffer = Buffer.from(await jsonResponse.arrayBuffer()); + fs.writeFileSync(path.join(caseOutputDir, jsonName), buffer); + outputs.json = `${baseUrl}/files/outputs/${caseId}/${jsonName}`; + } + } catch (e) { console.warn(`Failed to download ${braceType} markers:`, e.message); } + } + + braces[braceType] = { + outputs, + markers: braceData.markers, + pressureZones: braceData.pressure_zones, + meshStats: braceData.mesh_stats + }; + } + + // Add both braces to result + result.braces = braces; + console.log('Both GLB braces generated and downloaded successfully'); + } else { + console.warn('Failed to generate both braces:', await bothResponse.text()); + } + } catch (bothErr) { + console.warn('Error generating both braces:', bothErr.message); + } + + // Save result to database + const braceResult = { + ...result, + outputs: outputFiles + }; + + // If we have both braces, use saveBothBracesResult + if (result.braces) { + const rigoType = result.rigo_classification?.type || result.rigo_type || 'A1'; + db.saveBothBracesResult(caseId, { + braces: result.braces, + rigoType: rigoType, + cobbAngles: result.cobb_angles, + bodyScanUsed: hasBodyScan + }); + } else { + db.saveBraceResult(caseId, braceResult); + } + db.updateStepStatus(caseId, 'BraceGeneration', 'done'); + + res.json({ + caseId, + status: 'brace_generated', + ...braceResult + }); + + } catch (err) { + console.error('Generate brace error:', err); + db.updateStepStatus(caseId, 'BraceGeneration', 'failed', err.message); + db.updateCaseStatus(caseId, 'brace_failed'); + res.status(500).json({ message: 'Failed to generate brace', error: err.message }); + } +}); + +/** + * Get brace outputs + * GET /api/cases/:caseId/brace-outputs + */ +app.get('/api/cases/:caseId/brace-outputs', (req, res) => { + try { + const { caseId } = req.params; + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + const outputs = caseData.analysis_result?.brace?.outputs || {}; + + res.json({ + caseId, + status: caseData.status, + outputs + }); + } catch (err) { + console.error('Get brace outputs error:', err); + res.status(500).json({ message: 'Failed to get brace outputs', error: err.message }); + } +}); + +// ============================================== +// GLB BRACE GENERATION WITH MARKERS (NEW) +// ============================================== + +/** + * Get available brace templates + * GET /api/templates + */ +app.get('/api/templates', async (req, res) => { + try { + const response = await fetch(`${BRACE_GENERATOR_URL}/templates`); + const data = await response.json(); + res.json(data); + } catch (err) { + console.error('Get templates error:', err); + res.status(500).json({ message: 'Failed to get templates', error: err.message }); + } +}); + +/** + * Get template markers + * GET /api/templates/:rigoType/markers + */ +app.get('/api/templates/:rigoType/markers', async (req, res) => { + try { + const { rigoType } = req.params; + const { template_type = 'regular' } = req.query; + + const response = await fetch( + `${BRACE_GENERATOR_URL}/templates/${rigoType}/markers?template_type=${template_type}` + ); + const data = await response.json(); + + if (!response.ok) { + return res.status(response.status).json(data); + } + + res.json(data); + } catch (err) { + console.error('Get template markers error:', err); + res.status(500).json({ message: 'Failed to get markers', error: err.message }); + } +}); + +/** + * Get pressure zone information + * GET /api/pressure-zones/:rigoType + */ +app.get('/api/pressure-zones/:rigoType', async (req, res) => { + try { + const { rigoType } = req.params; + const { template_type = 'regular', cobb_mt = 25, cobb_tl = 15 } = req.query; + + const response = await fetch( + `${BRACE_GENERATOR_URL}/pressure-zones/${rigoType}?template_type=${template_type}&cobb_mt=${cobb_mt}&cobb_tl=${cobb_tl}` + ); + const data = await response.json(); + + if (!response.ok) { + return res.status(response.status).json(data); + } + + res.json(data); + } catch (err) { + console.error('Get pressure zones error:', err); + res.status(500).json({ message: 'Failed to get pressure zones', error: err.message }); + } +}); + +/** + * Generate GLB brace with markers + * POST /api/cases/:caseId/generate-glb + */ +app.post('/api/cases/:caseId/generate-glb', upload.single('body_scan'), async (req, res) => { + const { caseId } = req.params; + + try { + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + // Get analysis data for Cobb angles + // analysis_data may already be an object or a JSON string + let analysisData = {}; + if (caseData.analysis_data) { + if (typeof caseData.analysis_data === 'string') { + try { + analysisData = JSON.parse(caseData.analysis_data); + } catch (e) { + console.warn('Could not parse analysis_data:', e.message); + } + } else { + analysisData = caseData.analysis_data; + } + } + const cobbAngles = analysisData.cobb_angles || {}; + + // Get Rigo type from analysis or request body + const rigoType = req.body.rigo_type || analysisData.rigo_type || 'A1'; + const templateType = req.body.template_type || 'regular'; + + // Create form data for brace generator (use UndiciFormData for proper compatibility) + const formData = new UndiciFormData(); + + formData.append('rigo_type', rigoType); + formData.append('template_type', templateType); + formData.append('case_id', caseId); + formData.append('cobb_pt', String(cobbAngles.PT || 0)); + formData.append('cobb_mt', String(cobbAngles.MT || 0)); + formData.append('cobb_tl', String(cobbAngles.TL || 0)); + + // Add body scan if provided in request or exists in case + let bodyScanPath = req.file?.path; + if (!bodyScanPath && caseData.body_scan_path) { + bodyScanPath = caseData.body_scan_path; + } + + if (bodyScanPath && fs.existsSync(bodyScanPath)) { + const bodyScanBuffer = fs.readFileSync(bodyScanPath); + const bodyScanFileObj = new File([bodyScanBuffer], path.basename(bodyScanPath), { type: 'application/octet-stream' }); + formData.append('body_scan', bodyScanFileObj); + } + + // Call brace generator + const response = await undiciFetch(`${BRACE_GENERATOR_URL}/generate-glb`, { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (!response.ok) { + return res.status(response.status).json(result); + } + + // Copy output files to case directory + const caseOutputDir = path.join(OUTPUTS_DIR, caseId); + if (!fs.existsSync(caseOutputDir)) { + fs.mkdirSync(caseOutputDir, { recursive: true }); + } + + const outputs = {}; + + // Copy GLB + if (result.outputs?.glb && fs.existsSync(result.outputs.glb)) { + const glbName = `${caseId}_${rigoType}_${templateType}.glb`; + const glbDest = path.join(caseOutputDir, glbName); + fs.copyFileSync(result.outputs.glb, glbDest); + outputs.glb = `/files/outputs/${caseId}/${glbName}`; + } + + // Copy STL + if (result.outputs?.stl && fs.existsSync(result.outputs.stl)) { + const stlName = `${caseId}_${rigoType}_${templateType}.stl`; + const stlDest = path.join(caseOutputDir, stlName); + fs.copyFileSync(result.outputs.stl, stlDest); + outputs.stl = `/files/outputs/${caseId}/${stlName}`; + } + + // Copy JSON + if (result.outputs?.json && fs.existsSync(result.outputs.json)) { + const jsonName = `${caseId}_${rigoType}_${templateType}_markers.json`; + const jsonDest = path.join(caseOutputDir, jsonName); + fs.copyFileSync(result.outputs.json, jsonDest); + outputs.json = `/files/outputs/${caseId}/${jsonName}`; + } + + res.json({ + caseId, + rigoType, + templateType, + outputs, + markers: result.markers, + pressureZones: result.pressure_zones, + meshStats: result.mesh_stats, + bodyFitting: result.body_fitting + }); + + } catch (err) { + console.error('Generate GLB error:', err); + res.status(500).json({ message: 'Failed to generate GLB brace', error: err.message }); + } +}); + +/** + * Generate both brace types (regular + vase) for comparison + * POST /api/cases/:caseId/generate-both-braces + */ +app.post('/api/cases/:caseId/generate-both-braces', upload.single('body_scan'), async (req, res) => { + const { caseId } = req.params; + + try { + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + // Get analysis data for Cobb angles + // analysis_data may already be an object or a JSON string + let analysisData = {}; + if (caseData.analysis_data) { + if (typeof caseData.analysis_data === 'string') { + try { + analysisData = JSON.parse(caseData.analysis_data); + } catch (e) { + console.warn('Could not parse analysis_data:', e.message); + } + } else { + analysisData = caseData.analysis_data; + } + } + const cobbAngles = analysisData.cobb_angles || {}; + + // Get Rigo type from analysis or request body + const rigoType = req.body.rigo_type || analysisData.rigo_classification?.type || analysisData.rigo_type || 'A1'; + + console.log('Generating both braces:', { caseId, rigoType, cobbAngles }); + + // Create form data for brace generator using undici FormData + const formData = new UndiciFormData(); + + formData.append('rigo_type', rigoType); + formData.append('case_id', caseId); + formData.append('cobb_pt', String(cobbAngles.PT || 0)); + formData.append('cobb_mt', String(cobbAngles.MT || 0)); + formData.append('cobb_tl', String(cobbAngles.TL || 0)); + + // Add body scan if provided in request or exists in case + let bodyScanPath = req.file?.path; + if (!bodyScanPath && caseData.body_scan_path) { + bodyScanPath = caseData.body_scan_path; + } + + if (bodyScanPath && fs.existsSync(bodyScanPath)) { + const bodyScanBuffer = fs.readFileSync(bodyScanPath); + const bodyScanFileObj = new File([bodyScanBuffer], path.basename(bodyScanPath), { type: 'application/octet-stream' }); + formData.append('body_scan', bodyScanFileObj); + } + + // Call brace generator using undici fetch + const response = await undiciFetch(`${BRACE_GENERATOR_URL}/generate-both-braces`, { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (!response.ok) { + return res.status(response.status).json(result); + } + + // Copy output files to case directory + const caseOutputDir = path.join(OUTPUTS_DIR, caseId); + if (!fs.existsSync(caseOutputDir)) { + fs.mkdirSync(caseOutputDir, { recursive: true }); + } + + const braces = {}; + const baseUrl = getBaseUrl(req); + + // Process both brace types - download files via HTTP since containers are separate + for (const [braceType, braceData] of Object.entries(result.braces || {})) { + if (braceData.error) { + braces[braceType] = { error: braceData.error }; + continue; + } + + const outputs = {}; + + // Download GLB via HTTP + if (braceData.outputs?.glb) { + const glbFilename = path.basename(braceData.outputs.glb); + const glbName = `${caseId}_${rigoType}_${braceType}.glb`; + try { + const glbResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${glbFilename}`); + if (glbResponse.ok) { + const buffer = Buffer.from(await glbResponse.arrayBuffer()); + fs.writeFileSync(path.join(caseOutputDir, glbName), buffer); + outputs.glb = `/files/outputs/${caseId}/${glbName}`; + } + } catch (e) { console.warn(`Failed to download ${braceType} GLB:`, e.message); } + } + + // Download STL via HTTP + if (braceData.outputs?.stl) { + const stlFilename = path.basename(braceData.outputs.stl); + const stlName = `${caseId}_${rigoType}_${braceType}.stl`; + try { + const stlResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${stlFilename}`); + if (stlResponse.ok) { + const buffer = Buffer.from(await stlResponse.arrayBuffer()); + fs.writeFileSync(path.join(caseOutputDir, stlName), buffer); + outputs.stl = `/files/outputs/${caseId}/${stlName}`; + } + } catch (e) { console.warn(`Failed to download ${braceType} STL:`, e.message); } + } + + // Download markers JSON via HTTP + if (braceData.outputs?.json) { + const jsonFilename = path.basename(braceData.outputs.json); + const jsonName = `${caseId}_${rigoType}_${braceType}_markers.json`; + try { + const jsonResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${jsonFilename}`); + if (jsonResponse.ok) { + const buffer = Buffer.from(await jsonResponse.arrayBuffer()); + fs.writeFileSync(path.join(caseOutputDir, jsonName), buffer); + outputs.json = `/files/outputs/${caseId}/${jsonName}`; + } + } catch (e) { console.warn(`Failed to download ${braceType} markers:`, e.message); } + } + + braces[braceType] = { + outputs, + markers: braceData.markers, + pressureZones: braceData.pressure_zones, + meshStats: braceData.mesh_stats + }; + } + + // Build the response + const responseData = { + caseId, + rigoType, + cobbAngles, + bodyScanUsed: bodyScanPath !== null && bodyScanPath !== undefined, + braces + }; + + // Save to database so it persists on refresh + db.saveBothBracesResult(caseId, responseData); + db.updateStepStatus(caseId, 'BraceGeneration', 'done'); + + res.json(responseData); + + } catch (err) { + console.error('Generate both braces error:', err); + db.updateStepStatus(caseId, 'BraceGeneration', 'failed', err.message); + res.status(500).json({ message: 'Failed to generate braces', error: err.message }); + } +}); + +/** + * Save markers (for brace editor) + * PUT /api/cases/:caseId/markers + */ +app.put('/api/cases/:caseId/markers', (req, res) => { + try { + const { caseId } = req.params; + const { markers_data } = req.body; + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + db.saveMarkers(caseId, markers_data); + + res.json({ caseId, status: 'markers_updated' }); + } catch (err) { + console.error('Update markers error:', err); + res.status(500).json({ message: 'Failed to update markers', error: err.message }); + } +}); + +// ============================================== +// BODY SCAN ENDPOINTS (NEW - Stage 3) +// ============================================== + +/** + * Upload body scan (STL/OBJ/PLY) + * POST /api/cases/:caseId/body-scan + */ +app.post('/api/cases/:caseId/body-scan', upload.single('file'), async (req, res) => { + const { caseId } = req.params; + + try { + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + if (!req.file) { + return res.status(400).json({ message: 'No file uploaded' }); + } + + // Validate file type + const allowedExtensions = ['.stl', '.obj', '.ply', '.glb', '.gltf']; + const ext = path.extname(req.file.originalname).toLowerCase(); + if (!allowedExtensions.includes(ext)) { + // Clean up uploaded file + fs.unlinkSync(req.file.path); + return res.status(400).json({ + message: `Invalid file type. Allowed: ${allowedExtensions.join(', ')}` + }); + } + + // Move file to case uploads directory + const caseUploadDir = path.join(UPLOADS_DIR, caseId); + fs.mkdirSync(caseUploadDir, { recursive: true }); + + const destFilename = `body_scan${ext}`; + const destPath = path.join(caseUploadDir, destFilename); + + // Remove existing body scan if any + const existingFiles = fs.readdirSync(caseUploadDir).filter(f => f.startsWith('body_scan')); + for (const f of existingFiles) { + fs.unlinkSync(path.join(caseUploadDir, f)); + } + + // Move uploaded file + fs.renameSync(req.file.path, destPath); + + const baseUrl = getBaseUrl(req); + const scanUrl = `${baseUrl}/files/uploads/${caseId}/${destFilename}`; + + // Try to extract body measurements via brace generator + let metadata = { + file_format: ext.replace('.', ''), + filename: destFilename, + uploaded_at: new Date().toISOString() + }; + + try { + // Call brace generator to extract body measurements + const formData = new UndiciFormData(); + const fileBuffer = fs.readFileSync(destPath); + const fileObj = new File([fileBuffer], destFilename, { type: 'application/octet-stream' }); + formData.append('file', fileObj); + + const measureResponse = await undiciFetch(`${BRACE_GENERATOR_URL}/extract-body-measurements`, { + method: 'POST', + body: formData + }); + + if (measureResponse.ok) { + const measurements = await measureResponse.json(); + metadata = { ...metadata, ...measurements }; + } + } catch (e) { + console.log('Could not extract body measurements:', e.message); + // Continue without measurements + } + + // Save to database + db.saveBodyScan(caseId, destPath, scanUrl, metadata); + + res.json({ + caseId, + status: 'body_scan_uploaded', + body_scan: { + path: destPath, + url: scanUrl, + metadata + } + }); + + } catch (err) { + console.error('Upload body scan error:', err); + res.status(500).json({ message: 'Failed to upload body scan', error: err.message }); + } +}); + +/** + * Get body scan info + * GET /api/cases/:caseId/body-scan + * Auto-extracts measurements if missing + */ +app.get('/api/cases/:caseId/body-scan', async (req, res) => { + try { + const { caseId } = req.params; + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + if (!caseData.body_scan_path) { + return res.json({ + caseId, + has_body_scan: false, + body_scan: null + }); + } + + // Ensure URL is absolute (might be stored as relative in older entries) + let bodyScanUrl = caseData.body_scan_url; + if (bodyScanUrl && !bodyScanUrl.startsWith('http')) { + const baseUrl = getBaseUrl(req); + bodyScanUrl = baseUrl + bodyScanUrl; + } + + let metadata = caseData.body_scan_metadata || {}; + + // Auto-extract measurements if missing and file exists + if (!metadata.total_height_mm && fs.existsSync(caseData.body_scan_path)) { + try { + console.log(`Auto-extracting measurements for case ${caseId}`); + const formData = new UndiciFormData(); + const fileBuffer = fs.readFileSync(caseData.body_scan_path); + const filename = path.basename(caseData.body_scan_path); + const fileObj = new File([fileBuffer], filename, { type: 'application/octet-stream' }); + formData.append('file', fileObj); + + const measureResponse = await undiciFetch(`${BRACE_GENERATOR_URL}/extract-body-measurements`, { + method: 'POST', + body: formData + }); + + if (measureResponse.ok) { + const measurements = await measureResponse.json(); + metadata = { ...metadata, ...measurements }; + // Save updated metadata + db.saveBodyScan(caseId, caseData.body_scan_path, caseData.body_scan_url, metadata); + console.log(`Measurements extracted and saved for case ${caseId}`); + } + } catch (e) { + console.log(`Could not auto-extract measurements: ${e.message}`); + } + } + + res.json({ + caseId, + has_body_scan: true, + body_scan: { + path: caseData.body_scan_path, + url: bodyScanUrl, + metadata + } + }); + + } catch (err) { + console.error('Get body scan error:', err); + res.status(500).json({ message: 'Failed to get body scan', error: err.message }); + } +}); + +/** + * Re-extract body measurements from existing body scan + * POST /api/cases/:caseId/body-scan/refresh-measurements + */ +app.post('/api/cases/:caseId/body-scan/refresh-measurements', async (req, res) => { + const { caseId } = req.params; + + try { + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + if (!caseData.body_scan_path || !fs.existsSync(caseData.body_scan_path)) { + return res.status(400).json({ message: 'No body scan found for this case' }); + } + + // Extract measurements from existing body scan + const formData = new UndiciFormData(); + const fileBuffer = fs.readFileSync(caseData.body_scan_path); + const filename = path.basename(caseData.body_scan_path); + const fileObj = new File([fileBuffer], filename, { type: 'application/octet-stream' }); + formData.append('file', fileObj); + + const measureResponse = await undiciFetch(`${BRACE_GENERATOR_URL}/extract-body-measurements`, { + method: 'POST', + body: formData + }); + + if (!measureResponse.ok) { + const errorText = await measureResponse.text(); + return res.status(500).json({ message: 'Failed to extract measurements', error: errorText }); + } + + const measurements = await measureResponse.json(); + + // Merge with existing metadata + const updatedMetadata = { + ...(caseData.body_scan_metadata || {}), + ...measurements + }; + + // Update database + db.saveBodyScan(caseId, caseData.body_scan_path, caseData.body_scan_url, updatedMetadata); + + const baseUrl = getBaseUrl(req); + let bodyScanUrl = caseData.body_scan_url; + if (bodyScanUrl && !bodyScanUrl.startsWith('http')) { + bodyScanUrl = baseUrl + bodyScanUrl; + } + + res.json({ + caseId, + status: 'measurements_updated', + body_scan: { + path: caseData.body_scan_path, + url: bodyScanUrl, + metadata: updatedMetadata + } + }); + + } catch (err) { + console.error('Refresh measurements error:', err); + res.status(500).json({ message: 'Failed to refresh measurements', error: err.message }); + } +}); + +/** + * Delete body scan (skip body scan stage) + * DELETE /api/cases/:caseId/body-scan + */ +app.delete('/api/cases/:caseId/body-scan', (req, res) => { + try { + const { caseId } = req.params; + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + // Delete the file if it exists + if (caseData.body_scan_path && fs.existsSync(caseData.body_scan_path)) { + fs.unlinkSync(caseData.body_scan_path); + } + + // Clear from database + db.clearBodyScan(caseId); + + res.json({ + caseId, + status: 'body_scan_removed', + message: 'Body scan removed. Will generate brace from X-ray only.' + }); + + } catch (err) { + console.error('Delete body scan error:', err); + res.status(500).json({ message: 'Failed to delete body scan', error: err.message }); + } +}); + +/** + * Skip body scan and proceed to brace generation + * POST /api/cases/:caseId/skip-body-scan + */ +app.post('/api/cases/:caseId/skip-body-scan', (req, res) => { + try { + const { caseId } = req.params; + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + // Clear any existing body scan data so we use X-ray only generation + db.clearBodyScan(caseId); + + // Update step status to skip body scan + db.updateStepStatus(caseId, 'BodyScanUpload', 'done'); + db.updateCaseStatus(caseId, 'analysis_complete', 'BraceGeneration'); + + res.json({ + caseId, + status: 'analysis_complete', + message: 'Skipped body scan. Ready for brace generation.' + }); + + } catch (err) { + console.error('Skip body scan error:', err); + res.status(500).json({ message: 'Failed to skip body scan', error: err.message }); + } +}); + +// ============================================== +// END BODY SCAN ENDPOINTS +// ============================================== + +/** + * Delete case + * DELETE /api/cases/:caseId + */ +app.delete('/api/cases/:caseId', (req, res) => { + try { + const { caseId } = req.params; + + // Delete from database + db.deleteCase(caseId); + + // Delete files + const uploadDir = path.join(UPLOADS_DIR, caseId); + const outputDir = path.join(OUTPUTS_DIR, caseId); + + if (fs.existsSync(uploadDir)) { + fs.rmSync(uploadDir, { recursive: true }); + } + if (fs.existsSync(outputDir)) { + fs.rmSync(outputDir, { recursive: true }); + } + + res.json({ caseId, deleted: true }); + } catch (err) { + console.error('Delete case error:', err); + res.status(500).json({ message: 'Failed to delete case', error: err.message }); + } +}); + +/** + * Get download URL for case assets (S3 presigned URL compatibility) + * GET /api/cases/:caseId/download-url?type=xray|landmarks|measurements + */ +app.get('/api/cases/:caseId/download-url', (req, res) => { + try { + const { caseId } = req.params; + const { type = 'xray', view = 'ap' } = req.query; + + const caseData = db.getCase(caseId); + if (!caseData) { + return res.status(404).json({ message: 'Case not found' }); + } + + const baseUrl = getBaseUrl(req); + let url = null; + + if (type === 'xray') { + // Find X-ray file + const uploadDir = path.join(UPLOADS_DIR, caseId); + if (fs.existsSync(uploadDir)) { + const files = fs.readdirSync(uploadDir); + const xrayFile = files.find(f => f.match(/\.(jpg|jpeg|png)$/i)); + if (xrayFile) { + url = `${baseUrl}/files/uploads/${caseId}/${xrayFile}`; + } + } + } else if (type === 'landmarks') { + const landmarksPath = path.join(OUTPUTS_DIR, caseId, 'landmarks.json'); + if (fs.existsSync(landmarksPath)) { + url = `${baseUrl}/files/outputs/${caseId}/landmarks.json`; + } + } else if (type === 'visualization') { + const vizPath = path.join(OUTPUTS_DIR, caseId, 'visualization.png'); + if (fs.existsSync(vizPath)) { + url = `${baseUrl}/files/outputs/${caseId}/visualization.png`; + } + } + + if (!url) { + return res.status(404).json({ message: `${type} file not found for case` }); + } + + res.json({ url }); + } catch (err) { + console.error('Get download URL error:', err); + res.status(500).json({ message: 'Failed to get download URL', error: err.message }); + } +}); + +/** + * Get case assets (files) + * GET /api/cases/:caseId/assets + */ +app.get('/api/cases/:caseId/assets', (req, res) => { + try { + const { caseId } = req.params; + const baseUrl = getBaseUrl(req); + + const assets = { + uploads: [], + outputs: [] + }; + + const uploadDir = path.join(UPLOADS_DIR, caseId); + const outputDir = path.join(OUTPUTS_DIR, caseId); + + if (fs.existsSync(uploadDir)) { + assets.uploads = fs.readdirSync(uploadDir).map(f => ({ + filename: f, + url: `${baseUrl}/files/uploads/${caseId}/${f}` + })); + } + + if (fs.existsSync(outputDir)) { + assets.outputs = fs.readdirSync(outputDir).map(f => ({ + filename: f, + url: `${baseUrl}/files/outputs/${caseId}/${f}` + })); + } + + res.json({ caseId, assets }); + } catch (err) { + console.error('Get assets error:', err); + res.status(500).json({ message: 'Failed to get assets', error: err.message }); + } +}); + +// ============================================ +// AUTHENTICATION API +// ============================================ + +/** + * Login + * POST /api/auth/login + */ +app.post('/api/auth/login', (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Username and password are required' }); + } + + const user = db.getUserByUsername(username); + + if (!user) { + return res.status(401).json({ message: 'Invalid username or password' }); + } + + if (!user.is_active) { + return res.status(401).json({ message: 'Account is disabled' }); + } + + // Simple password check for dev (in production, use bcrypt.compare) + if (user.password_hash !== password) { + return res.status(401).json({ message: 'Invalid username or password' }); + } + + // Generate session token + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24 hours + + db.createSession(user.id, token, expiresAt); + db.updateLastLogin(user.id); + db.logAudit(user.id, 'login', 'user', user.id.toString(), null, req.ip); + + res.json({ + token, + user: { + id: user.id, + username: user.username, + email: user.email, + full_name: user.full_name, + role: user.role + }, + expiresAt + }); + } catch (err) { + console.error('Login error:', err); + res.status(500).json({ message: 'Login failed', error: err.message }); + } +}); + +/** + * Logout + * POST /api/auth/logout + */ +app.post('/api/auth/logout', (req, res) => { + try { + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.slice(7); + const session = db.getSessionByToken(token); + if (session) { + db.logAudit(session.user_id, 'logout', 'user', session.user_id.toString(), null, req.ip); + db.deleteSession(token); + } + } + res.json({ message: 'Logged out successfully' }); + } catch (err) { + console.error('Logout error:', err); + res.status(500).json({ message: 'Logout failed', error: err.message }); + } +}); + +/** + * Get current user + * GET /api/auth/me + */ +app.get('/api/auth/me', (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ message: 'No token provided' }); + } + + const token = authHeader.slice(7); + const session = db.getSessionByToken(token); + + if (!session) { + return res.status(401).json({ message: 'Invalid or expired token' }); + } + + if (!session.is_active) { + return res.status(401).json({ message: 'Account is disabled' }); + } + + res.json({ + user: { + id: session.user_id, + username: session.username, + full_name: session.full_name, + role: session.role + } + }); + } catch (err) { + console.error('Get user error:', err); + res.status(500).json({ message: 'Failed to get user', error: err.message }); + } +}); + +// ============================================ +// AUTH MIDDLEWARE +// ============================================ + +function authMiddleware(req, res, next) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ message: 'Authentication required' }); + } + + const token = authHeader.slice(7); + const session = db.getSessionByToken(token); + + if (!session) { + return res.status(401).json({ message: 'Invalid or expired token' }); + } + + if (!session.is_active) { + return res.status(401).json({ message: 'Account is disabled' }); + } + + req.user = { + id: session.user_id, + username: session.username, + role: session.role, + fullName: session.full_name + }; + + next(); +} + +function adminMiddleware(req, res, next) { + if (req.user.role !== 'admin') { + return res.status(403).json({ message: 'Admin access required' }); + } + next(); +} + +// ============================================ +// ADMIN API - USER MANAGEMENT +// ============================================ + +/** + * List all users (admin only) + * GET /api/admin/users + */ +app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { + try { + const users = db.listUsers(); + res.json({ users }); + } catch (err) { + console.error('List users error:', err); + res.status(500).json({ message: 'Failed to list users', error: err.message }); + } +}); + +/** + * Get user by ID (admin only) + * GET /api/admin/users/:userId + */ +app.get('/api/admin/users/:userId', authMiddleware, adminMiddleware, (req, res) => { + try { + const user = db.getUserById(parseInt(req.params.userId)); + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + res.json({ user }); + } catch (err) { + console.error('Get user error:', err); + res.status(500).json({ message: 'Failed to get user', error: err.message }); + } +}); + +/** + * Create user (admin only) + * POST /api/admin/users + */ +app.post('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { + try { + const { username, password, email, fullName, role = 'user' } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Username and password are required' }); + } + + // Check if username exists + const existing = db.getUserByUsername(username); + if (existing) { + return res.status(400).json({ message: 'Username already exists' }); + } + + // In production, hash the password with bcrypt + const user = db.createUser(username, password, email, fullName, role); + db.logAudit(req.user.id, 'create_user', 'user', user.id.toString(), { username, role }, req.ip); + + res.status(201).json({ user }); + } catch (err) { + console.error('Create user error:', err); + res.status(500).json({ message: 'Failed to create user', error: err.message }); + } +}); + +/** + * Update user (admin only) + * PUT /api/admin/users/:userId + */ +app.put('/api/admin/users/:userId', authMiddleware, adminMiddleware, (req, res) => { + try { + const userId = parseInt(req.params.userId); + const { email, fullName, role, isActive, password } = req.body; + + const user = db.getUserById(userId); + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const updates = {}; + if (email !== undefined) updates.email = email; + if (fullName !== undefined) updates.fullName = fullName; + if (role !== undefined) updates.role = role; + if (isActive !== undefined) updates.isActive = isActive; + if (password !== undefined) updates.passwordHash = password; // In production, hash with bcrypt + + db.updateUser(userId, updates); + db.logAudit(req.user.id, 'update_user', 'user', userId.toString(), updates, req.ip); + + const updatedUser = db.getUserById(userId); + res.json({ user: updatedUser }); + } catch (err) { + console.error('Update user error:', err); + res.status(500).json({ message: 'Failed to update user', error: err.message }); + } +}); + +/** + * Delete user (admin only) + * DELETE /api/admin/users/:userId + */ +app.delete('/api/admin/users/:userId', authMiddleware, adminMiddleware, (req, res) => { + try { + const userId = parseInt(req.params.userId); + + // Prevent self-deletion + if (userId === req.user.id) { + return res.status(400).json({ message: 'Cannot delete your own account' }); + } + + const user = db.getUserById(userId); + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + db.deleteUser(userId); + db.logAudit(req.user.id, 'delete_user', 'user', userId.toString(), { username: user.username }, req.ip); + + res.json({ message: 'User deleted successfully' }); + } catch (err) { + console.error('Delete user error:', err); + res.status(500).json({ message: 'Failed to delete user', error: err.message }); + } +}); + +// ============================================ +// ADMIN API - CASES (WITH FILTERS) +// ============================================ + +/** + * List all cases with filters (admin only) + * GET /api/admin/cases + */ +app.get('/api/admin/cases', authMiddleware, adminMiddleware, (req, res) => { + try { + const { status, createdBy, search, limit = 50, offset = 0, sortBy, sortOrder } = req.query; + + const result = db.listCasesFiltered({ + status, + createdBy: createdBy ? parseInt(createdBy) : undefined, + search, + limit: parseInt(limit), + offset: parseInt(offset), + sortBy, + sortOrder + }); + + res.json(result); + } catch (err) { + console.error('List cases error:', err); + res.status(500).json({ message: 'Failed to list cases', error: err.message }); + } +}); + +// ============================================ +// ADMIN API - ANALYTICS +// ============================================ + +/** + * Get dashboard analytics (admin only) + * GET /api/admin/analytics/dashboard + */ +app.get('/api/admin/analytics/dashboard', authMiddleware, adminMiddleware, (req, res) => { + try { + const caseStats = db.getCaseStats(); + const userStats = db.getUserStats(); + const rigoDistribution = db.getRigoDistribution(); + const cobbStats = db.getCobbAngleStats(); + const processingStats = db.getProcessingTimeStats(); + const bodyScanStats = db.getBodyScanStats(); + + res.json({ + cases: caseStats, + users: userStats, + rigoDistribution, + cobbAngles: cobbStats, + processingTime: processingStats, + bodyScan: bodyScanStats + }); + } catch (err) { + console.error('Get analytics error:', err); + res.status(500).json({ message: 'Failed to get analytics', error: err.message }); + } +}); + +/** + * Get Rigo distribution (admin only) + * GET /api/admin/analytics/rigo + */ +app.get('/api/admin/analytics/rigo', authMiddleware, adminMiddleware, (req, res) => { + try { + const distribution = db.getRigoDistribution(); + res.json({ distribution }); + } catch (err) { + console.error('Get Rigo distribution error:', err); + res.status(500).json({ message: 'Failed to get Rigo distribution', error: err.message }); + } +}); + +/** + * Get Cobb angle statistics (admin only) + * GET /api/admin/analytics/cobb-angles + */ +app.get('/api/admin/analytics/cobb-angles', authMiddleware, adminMiddleware, (req, res) => { + try { + const stats = db.getCobbAngleStats(); + res.json({ stats }); + } catch (err) { + console.error('Get Cobb angle stats error:', err); + res.status(500).json({ message: 'Failed to get Cobb angle stats', error: err.message }); + } +}); + +/** + * Get processing time statistics (admin only) + * GET /api/admin/analytics/processing-time + */ +app.get('/api/admin/analytics/processing-time', authMiddleware, adminMiddleware, (req, res) => { + try { + const stats = db.getProcessingTimeStats(); + res.json({ stats }); + } catch (err) { + console.error('Get processing time stats error:', err); + res.status(500).json({ message: 'Failed to get processing time stats', error: err.message }); + } +}); + +// ============================================ +// ADMIN API - AUDIT LOG +// ============================================ + +/** + * Get audit log (admin only) + * GET /api/admin/audit-log + */ +app.get('/api/admin/audit-log', authMiddleware, adminMiddleware, (req, res) => { + try { + const { userId, action, entityType, limit = 100, offset = 0 } = req.query; + + const entries = db.getAuditLog({ + userId: userId ? parseInt(userId) : undefined, + action, + entityType, + limit: parseInt(limit), + offset: parseInt(offset) + }); + + res.json({ entries }); + } catch (err) { + console.error('Get audit log error:', err); + res.status(500).json({ message: 'Failed to get audit log', error: err.message }); + } +}); + +// ============================================ +// Start server +// ============================================ +app.listen(PORT, () => { + console.log(''); + console.log('============================================'); + console.log('BraceFlow DEV API Server'); + console.log('============================================'); + console.log(`Port: ${PORT}`); + console.log(`Brace Generator: ${BRACE_GENERATOR_URL}`); + console.log(`Data Directory: ${DATA_DIR}`); + console.log('============================================'); + console.log(''); + console.log('Endpoints:'); + console.log(' GET /api/health'); + console.log(' POST /api/cases Create case'); + console.log(' GET /api/cases List cases'); + console.log(' GET /api/cases/:id Get case'); + console.log(' GET /api/cases/:id/status Get case status'); + console.log(' POST /api/cases/:id/upload Upload X-ray'); + console.log(' POST /api/cases/:id/detect-landmarks Stage 1: Detect'); + console.log(' PUT /api/cases/:id/landmarks Update landmarks'); + console.log(' POST /api/cases/:id/approve-landmarks Approve landmarks'); + console.log(' POST /api/cases/:id/recalculate Recalculate analysis'); + console.log(' POST /api/cases/:id/generate-brace Stage 3: Generate'); + console.log(' GET /api/cases/:id/brace-outputs Get outputs'); + console.log(' PUT /api/cases/:id/markers Update markers'); + console.log(' DELETE /api/cases/:id Delete case'); + console.log(' GET /api/cases/:id/assets Get files'); + console.log(''); + console.log('Auth Endpoints:'); + console.log(' POST /api/auth/login Login'); + console.log(' POST /api/auth/logout Logout'); + console.log(' GET /api/auth/me Get current user'); + console.log(''); + console.log('Admin Endpoints (require admin role):'); + console.log(' GET /api/admin/users List users'); + console.log(' POST /api/admin/users Create user'); + console.log(' PUT /api/admin/users/:id Update user'); + console.log(' DELETE /api/admin/users/:id Delete user'); + console.log(' GET /api/admin/cases List cases (filtered)'); + console.log(' GET /api/admin/analytics/dashboard Get dashboard stats'); + console.log(' GET /api/admin/audit-log Get audit log'); + console.log(''); +}); diff --git a/brace-generator/Dockerfile b/brace-generator/Dockerfile new file mode 100644 index 0000000..c361659 --- /dev/null +++ b/brace-generator/Dockerfile @@ -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"] diff --git a/brace-generator/__init__.py b/brace-generator/__init__.py new file mode 100644 index 0000000..9e7fa1f --- /dev/null +++ b/brace-generator/__init__.py @@ -0,0 +1,8 @@ +""" +Brace Generator Server Package. +""" +from .app import app +from .config import config +from .services import BraceService + +__all__ = ["app", "config", "BraceService"] diff --git a/brace-generator/app.py b/brace-generator/app.py new file mode 100644 index 0000000..5556f92 --- /dev/null +++ b/brace-generator/app.py @@ -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 + ) diff --git a/brace-generator/body_integration.py b/brace-generator/body_integration.py new file mode 100644 index 0000000..9e83e55 --- /dev/null +++ b/brace-generator/body_integration.py @@ -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") diff --git a/brace-generator/config.py b/brace-generator/config.py new file mode 100644 index 0000000..cccfd13 --- /dev/null +++ b/brace-generator/config.py @@ -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() diff --git a/brace-generator/glb_generator.py b/brace-generator/glb_generator.py new file mode 100644 index 0000000..44a92a2 --- /dev/null +++ b/brace-generator/glb_generator.py @@ -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 + } diff --git a/brace-generator/requirements.txt b/brace-generator/requirements.txt new file mode 100644 index 0000000..c553331 --- /dev/null +++ b/brace-generator/requirements.txt @@ -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 diff --git a/brace-generator/routes.py b/brace-generator/routes.py new file mode 100644 index 0000000..ce6a2c2 --- /dev/null +++ b/brace-generator/routes.py @@ -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 + ) diff --git a/brace-generator/schemas.py b/brace-generator/schemas.py new file mode 100644 index 0000000..4766037 --- /dev/null +++ b/brace-generator/schemas.py @@ -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 diff --git a/brace-generator/services.py b/brace-generator/services.py new file mode 100644 index 0000000..b461d04 --- /dev/null +++ b/brace-generator/services.py @@ -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 diff --git a/brace-generator/simple_server.py b/brace-generator/simple_server.py new file mode 100644 index 0000000..840de91 --- /dev/null +++ b/brace-generator/simple_server.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..18a10ed --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3bd8d26 --- /dev/null +++ b/frontend/.gitignore @@ -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) diff --git a/frontend/BRACE_GENERATOR_INTEGRATION.md b/frontend/BRACE_GENERATOR_INTEGRATION.md new file mode 100644 index 0000000..1b55c49 --- /dev/null +++ b/frontend/BRACE_GENERATOR_INTEGRATION.md @@ -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; + +// Invoke brace generator Lambda +export async function generateBrace(caseId: string, options?: { + experiment?: string; + config?: Record +}): Promise; + +// Get brace outputs with presigned URLs +export async function getBraceOutputs(caseId: string): Promise; + +// Full workflow helper +export async function analyzeXray(file: File, options?: { + experiment?: string; + config?: Record +}): 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; + 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 +``` diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..160619f --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -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... + }, + }, +]) +``` diff --git a/frontend/cors.json b/frontend/cors.json new file mode 100644 index 0000000..460928a --- /dev/null +++ b/frontend/cors.json @@ -0,0 +1,9 @@ +[ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET"], + "AllowedOrigins": ["*"], + "ExposeHeaders": [], + "MaxAgeSeconds": 3000 + } +] diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -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, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4d5ee5f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + BraceiQ + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..9c8005e --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e60810d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3689 @@ +{ + "name": "braceflow-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "braceflow-ui", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.97.0.tgz", + "integrity": "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.97.0.tgz", + "integrity": "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", + "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.50.tgz", + "integrity": "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.50.tgz", + "integrity": "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.50.tgz", + "integrity": "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.50.tgz", + "integrity": "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.50.tgz", + "integrity": "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.50.tgz", + "integrity": "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.50.tgz", + "integrity": "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.50.tgz", + "integrity": "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-ia32-msvc": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", + "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.170.0.tgz", + "integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", + "integrity": "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/type-utils": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.53.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.1.tgz", + "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.1.tgz", + "integrity": "sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.1", + "@typescript-eslint/types": "^8.53.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.1.tgz", + "integrity": "sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.1.tgz", + "integrity": "sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.1.tgz", + "integrity": "sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.1.tgz", + "integrity": "sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.1.tgz", + "integrity": "sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.53.1", + "@typescript-eslint/tsconfig-utils": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.1.tgz", + "integrity": "sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz", + "integrity": "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz", + "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001765", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", + "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.277", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.277.tgz", + "integrity": "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "license": "MIT", + "dependencies": { + "react-router": "7.12.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.50.tgz", + "integrity": "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.97.0", + "@rolldown/pluginutils": "1.0.0-beta.50" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-beta.50", + "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", + "@rolldown/binding-darwin-x64": "1.0.0-beta.50", + "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", + "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", + "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", + "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.1.tgz", + "integrity": "sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.53.1", + "@typescript-eslint/parser": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/vite": { + "name": "rolldown-vite", + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.2.5.tgz", + "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "0.97.0", + "fdir": "^6.5.0", + "lightningcss": "^1.30.2", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rolldown": "1.0.0-beta.50", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "esbuild": "^0.25.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", + "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2bbe6eb --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/policy.json b/frontend/policy.json new file mode 100644 index 0000000..9eefb23 --- /dev/null +++ b/frontend/policy.json @@ -0,0 +1,12 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::braceflow-ui-www/*" + } + ] +} diff --git a/frontend/public/sculptgl/authSuccess.html b/frontend/public/sculptgl/authSuccess.html new file mode 100644 index 0000000..c64232a --- /dev/null +++ b/frontend/public/sculptgl/authSuccess.html @@ -0,0 +1,18 @@ + + + + + + Title + + + + This window will close in 2 seconds. You can close it manually if it doesn't. + + + + \ No newline at end of file diff --git a/frontend/public/sculptgl/css/yagui.css b/frontend/public/sculptgl/css/yagui.css new file mode 100644 index 0000000..5580c5e --- /dev/null +++ b/frontend/public/sculptgl/css/yagui.css @@ -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; +} diff --git a/frontend/public/sculptgl/index.html b/frontend/public/sculptgl/index.html new file mode 100644 index 0000000..ab211c4 --- /dev/null +++ b/frontend/public/sculptgl/index.html @@ -0,0 +1,47 @@ + + + + + + + + + + + BRACE iQ + + + + + + + + + + + + + + +
+ +
+ + + + + + diff --git a/frontend/public/sculptgl/resources/alpha/skin.jpg b/frontend/public/sculptgl/resources/alpha/skin.jpg new file mode 100644 index 0000000..4451aef Binary files /dev/null and b/frontend/public/sculptgl/resources/alpha/skin.jpg differ diff --git a/frontend/public/sculptgl/resources/alpha/square.jpg b/frontend/public/sculptgl/resources/alpha/square.jpg new file mode 100644 index 0000000..53cfce1 Binary files /dev/null and b/frontend/public/sculptgl/resources/alpha/square.jpg differ diff --git a/frontend/public/sculptgl/resources/dropper.png b/frontend/public/sculptgl/resources/dropper.png new file mode 100644 index 0000000..0f4b44d Binary files /dev/null and b/frontend/public/sculptgl/resources/dropper.png differ diff --git a/frontend/public/sculptgl/resources/environments/moonless_golf_1k.png b/frontend/public/sculptgl/resources/environments/moonless_golf_1k.png new file mode 100644 index 0000000..5c74f6a Binary files /dev/null and b/frontend/public/sculptgl/resources/environments/moonless_golf_1k.png differ diff --git a/frontend/public/sculptgl/resources/environments/mpumalanga_veld_1k.png b/frontend/public/sculptgl/resources/environments/mpumalanga_veld_1k.png new file mode 100644 index 0000000..55c5785 Binary files /dev/null and b/frontend/public/sculptgl/resources/environments/mpumalanga_veld_1k.png differ diff --git a/frontend/public/sculptgl/resources/environments/studio_small_01_1k.png b/frontend/public/sculptgl/resources/environments/studio_small_01_1k.png new file mode 100644 index 0000000..d6771e8 Binary files /dev/null and b/frontend/public/sculptgl/resources/environments/studio_small_01_1k.png differ diff --git a/frontend/public/sculptgl/resources/environments/venetian_crossroads_1k.png b/frontend/public/sculptgl/resources/environments/venetian_crossroads_1k.png new file mode 100644 index 0000000..8f894a1 Binary files /dev/null and b/frontend/public/sculptgl/resources/environments/venetian_crossroads_1k.png differ diff --git a/frontend/public/sculptgl/resources/environments/winter_river_1k.png b/frontend/public/sculptgl/resources/environments/winter_river_1k.png new file mode 100644 index 0000000..b85c4cb Binary files /dev/null and b/frontend/public/sculptgl/resources/environments/winter_river_1k.png differ diff --git a/frontend/public/sculptgl/resources/logo.png b/frontend/public/sculptgl/resources/logo.png new file mode 100644 index 0000000..2d106ba Binary files /dev/null and b/frontend/public/sculptgl/resources/logo.png differ diff --git a/frontend/public/sculptgl/resources/matcaps/clay.jpg b/frontend/public/sculptgl/resources/matcaps/clay.jpg new file mode 100644 index 0000000..047152d Binary files /dev/null and b/frontend/public/sculptgl/resources/matcaps/clay.jpg differ diff --git a/frontend/public/sculptgl/resources/matcaps/green.jpg b/frontend/public/sculptgl/resources/matcaps/green.jpg new file mode 100644 index 0000000..bf7f29c Binary files /dev/null and b/frontend/public/sculptgl/resources/matcaps/green.jpg differ diff --git a/frontend/public/sculptgl/resources/matcaps/matcapFV.jpg b/frontend/public/sculptgl/resources/matcaps/matcapFV.jpg new file mode 100644 index 0000000..71cc872 Binary files /dev/null and b/frontend/public/sculptgl/resources/matcaps/matcapFV.jpg differ diff --git a/frontend/public/sculptgl/resources/matcaps/pearl.jpg b/frontend/public/sculptgl/resources/matcaps/pearl.jpg new file mode 100644 index 0000000..e8c2f41 Binary files /dev/null and b/frontend/public/sculptgl/resources/matcaps/pearl.jpg differ diff --git a/frontend/public/sculptgl/resources/matcaps/redClay.jpg b/frontend/public/sculptgl/resources/matcaps/redClay.jpg new file mode 100644 index 0000000..5f46f3a Binary files /dev/null and b/frontend/public/sculptgl/resources/matcaps/redClay.jpg differ diff --git a/frontend/public/sculptgl/resources/matcaps/skin.jpg b/frontend/public/sculptgl/resources/matcaps/skin.jpg new file mode 100644 index 0000000..bcfc541 Binary files /dev/null and b/frontend/public/sculptgl/resources/matcaps/skin.jpg differ diff --git a/frontend/public/sculptgl/resources/matcaps/skinHazardousarts.jpg b/frontend/public/sculptgl/resources/matcaps/skinHazardousarts.jpg new file mode 100644 index 0000000..9a859ef Binary files /dev/null and b/frontend/public/sculptgl/resources/matcaps/skinHazardousarts.jpg differ diff --git a/frontend/public/sculptgl/resources/matcaps/skinHazardousarts2.jpg b/frontend/public/sculptgl/resources/matcaps/skinHazardousarts2.jpg new file mode 100644 index 0000000..9b70517 Binary files /dev/null and b/frontend/public/sculptgl/resources/matcaps/skinHazardousarts2.jpg differ diff --git a/frontend/public/sculptgl/resources/matcaps/white.jpg b/frontend/public/sculptgl/resources/matcaps/white.jpg new file mode 100644 index 0000000..1619fc7 Binary files /dev/null and b/frontend/public/sculptgl/resources/matcaps/white.jpg differ diff --git a/frontend/public/sculptgl/resources/uv.png b/frontend/public/sculptgl/resources/uv.png new file mode 100644 index 0000000..d61e84a Binary files /dev/null and b/frontend/public/sculptgl/resources/uv.png differ diff --git a/frontend/public/sculptgl/sculptgl.js b/frontend/public/sculptgl/sculptgl.js new file mode 100644 index 0000000..7281bd0 --- /dev/null +++ b/frontend/public/sculptgl/sculptgl.js @@ -0,0 +1,2 @@ +/*! For license information please see sculptgl.js.LICENSE.txt */ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.sculptgl=t():e.sculptgl=t()}(self,(function(){return(()=>{var __webpack_modules__={751:function(e,t,r){var i,n,a,s;function o(e){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}e=r.nmd(e),s=function(){return function(e){function t(i){if(r[i])return r[i].exports;var n=r[i]={exports:{},id:i,loaded:!1};return e[i].call(n.exports,n,n.exports,t),n.loaded=!0,n.exports}var r={};return t.m=e,t.c=r,t.p="",t(0)}([function(e,t,r){function i(e){if(!e)throw new Error("SketchfabOAuth2 config is missing.");if(e.hasOwnProperty("hostname")||(e.hostname="sketchfab.com"),!e.hasOwnProperty("client_id"))throw new Error("client_id is missing. Please check the config of SketchfabOAuth2.");if(!e.hasOwnProperty("redirect_uri"))throw new Error("redirect_uri is missing. Please check the config of SketchfabOAuth2.");this.config=e}r(5);var n=r(2),a=r(6),s=a.buildQueryString,o=a.parseQueryString;i.prototype.connect=function(e){return new n(function(t,r){if(this.config.client_id)var i={response_type:"token",state:+new Date,client_id:this.config.client_id,redirect_uri:this.config.redirect_uri},n=Object.assign({},i,e),a="https://"+this.config.hostname+"/oauth2/authorize/?"+s(n),l=window.open(a,"loginWindow","width=640,height=400"),u=setInterval(function(){try{var e=l.location.href;if(void 0===e)return clearInterval(u),void r(new Error("Access denied (User closed popup)"));if(-1!==e.indexOf("?error=access_denied"))return clearInterval(u),void r(new Error("Access denied (User canceled)"));if(-1!==e.indexOf(this.config.redirect_uri)){clearInterval(u);var i,n=l.location.hash,a=RegExp("access_token=([^&]+)");return n.match(a)?(i=o(n.substring(1)),void t(i)):void r(new Error("Access denied (missing token)"))}}catch(e){}}.bind(this),1e3);else r(new Error("client_id is missing."))}.bind(this))},e.exports=i},function(e,t){function r(){throw new Error("setTimeout has not been defined")}function i(){throw new Error("clearTimeout has not been defined")}function n(e){if(u===setTimeout)return setTimeout(e,0);if((u===r||!u)&&setTimeout)return u=setTimeout,setTimeout(e,0);try{return u(e,0)}catch(t){try{return u.call(null,e,0)}catch(t){return u.call(this,e,0)}}}function a(){_&&d&&(_=!1,d.length?f=d.concat(f):p=-1,f.length&&s())}function s(){if(!_){var e=n(a);_=!0;for(var t=f.length;t;){for(d=f,f=[];++p1)for(var r=1;r=0&&(e._idleTimeoutId=setTimeout((function(){e._onTimeout&&e._onTimeout()}),t))},r(3),t.setImmediate="undefined"!=typeof self&&self.setImmediate||void 0!==e&&e.setImmediate||this&&this.setImmediate,t.clearImmediate="undefined"!=typeof self&&self.clearImmediate||void 0!==e&&e.clearImmediate||this&&this.clearImmediate}).call(t,function(){return this}())},function(e,t){"function"!=typeof Object.assign&&Object.defineProperty(Object,"assign",{value:function(e,t){"use strict";if(null==e)throw new TypeError("Cannot convert undefined or null to object");for(var r=Object(e),i=1;ie.size)throw new RangeError("offset:"+t+", length:"+r+", size:"+e.size);return e.slice?e.slice(t,t+r):e.webkitSlice?e.webkitSlice(t,t+r):e.mozSlice?e.mozSlice(t,t+r):e.msSlice?e.msSlice(t,t+r):void 0}(e,t,r))}catch(e){n(e)}}}function _(){}function p(e){var r,i=this;i.init=function(e){r=new Blob([],{type:s}),e()},i.writeUint8Array=function(e,i){r=new Blob([r,t?e:e.buffer],{type:s}),i()},i.getData=function(t,i){var n=new FileReader;n.onload=function(e){t(e.target.result)},n.onerror=i,n.readAsText(r,e)}}function g(t){var r=this,i="",n="";r.init=function(e){i+="data:"+(t||"")+";base64,",e()},r.writeUint8Array=function(t,r){var a,s=n.length,o=n;for(n="",a=0;a<3*Math.floor((s+t.length)/3)-s;a++)o+=String.fromCharCode(t[a]);for(;a2?i+=e.btoa(o):n=o,r()},r.getData=function(t){t(i+e.btoa(n))}}function m(e){var r,i=this;i.init=function(t){r=new Blob([],{type:e}),t()},i.writeUint8Array=function(i,n){r=new Blob([r,t?i:i.buffer],{type:e}),n()},i.getData=function(e){e(r)}}function v(e,t,r,i,n,s,o,l,u,c){var h,d,f,_=0,p=t.sn;function g(){e.removeEventListener("message",m,!1),l(d,f)}function m(t){var r=t.data,n=r.data,a=r.error;if(a)return a.toString=function(){return"Error: "+this.message},void u(a);if(r.sn===p)switch("number"==typeof r.codecTime&&(e.codecTime+=r.codecTime),"number"==typeof r.crcTime&&(e.crcTime+=r.crcTime),r.type){case"append":n?(d+=n.length,i.writeUint8Array(n,(function(){v()}),c)):v();break;case"flush":f=r.crc,n?(d+=n.length,i.writeUint8Array(n,(function(){g()}),c)):g();break;case"progress":o&&o(h+r.loaded,s);break;case"importScripts":case"newTask":case"echo":break;default:console.warn("zip.js:launchWorkerProcess: unknown message: ",r)}}function v(){(h=_*a)127?n[r-128]:String.fromCharCode(r);return i}function M(e){return decodeURIComponent(escape(e))}function S(e){var t,r="";for(t=0;t>16,r=65535&e;try{return new Date(1980+((65024&t)>>9),((480&t)>>5)-1,31&t,(63488&r)>>11,(2016&r)>>5,2*(31&r),0)}catch(e){}}(e.lastModDateRaw),1!=(1&e.bitFlag)?((i||8!=(8&e.bitFlag))&&(e.crc32=t.view.getUint32(r+10,!0),e.compressedSize=t.view.getUint32(r+14,!0),e.uncompressedSize=t.view.getUint32(r+18,!0)),4294967295!==e.compressedSize&&4294967295!==e.uncompressedSize?(e.filenameLength=t.view.getUint16(r+22,!0),e.extraFieldLength=t.view.getUint16(r+24,!0)):n("File is using Zip64 (4gb+ file size).")):n("File contains encrypted entry.")}function E(e){return unescape(encodeURIComponent(e))}function A(e){var t,r=[];for(t=0;t>>8^r[255&(t^e[i])];this.crc=t},o.prototype.get=function(){return~this.crc},o.prototype.table=function(){var e,t,r,i=[];for(e=0;e<256;e++){for(r=e,t=0;t<8;t++)1&r?r=r>>>1^3988292384:r>>>=1;i[e]=r}return i}(),l.prototype.append=function(e,t){return e},l.prototype.flush=function(){},h.prototype=new c,h.prototype.constructor=h,d.prototype=new c,d.prototype.constructor=d,f.prototype=new c,f.prototype.constructor=f,_.prototype.getData=function(e){e(this.data)},p.prototype=new _,p.prototype.constructor=p,g.prototype=new _,g.prototype.constructor=g,m.prototype=new _,m.prototype.constructor=m;var w={deflater:["z-worker.js","deflate.js"],inflater:["z-worker.js","inflate.js"]};function R(t,r,i){if(null===e.zip.workerScripts||null===e.zip.workerScriptsPath){var n,a,s;if(e.zip.workerScripts){if(n=e.zip.workerScripts[t],!Array.isArray(n))return void i(new Error("zip.workerScripts."+t+" is not an array!"));a=n,s=document.createElement("a"),n=a.map((function(e){return s.href=e,s.href}))}else(n=w[t].slice(0))[0]=(e.zip.workerScriptsPath||"")+n[0];var o=new Worker(n[0]);o.codecTime=o.crcTime=0,o.postMessage({type:"importScripts",scripts:n.slice(1)}),o.addEventListener("message",(function e(t){var n=t.data;if(n.error)return o.terminate(),void i(n.error);"importScripts"===n.type&&(o.removeEventListener("message",e),o.removeEventListener("error",l),r(o))})),o.addEventListener("error",l)}else i(new Error("Either zip.workerScripts or zip.workerScriptsPath may be set, not both."));function l(e){o.terminate(),i(e)}}function x(e){console.error(e)}e.zip={Reader:c,Writer:_,BlobReader:f,Data64URIReader:d,TextReader:h,BlobWriter:m,Data64URIWriter:g,TextWriter:p,createReader:function(t,a,s){s=s||x,t.init((function(){!function(t,a,s){var o=0;function l(){}l.prototype.getData=function(i,a,l,c){var h=this;function d(e,t){c&&!function(e){var t=u(4);return t.view.setUint32(0,e),h.crc32==t.view.getUint32(0)}(t)?s("CRC failed."):i.getData((function(e){a(e)}))}function f(e){s(e||n)}function _(e){s(e||"Error while writing file data.")}t.readUint8Array(h.offset,30,(function(n){var a,p=u(n.length,n);1347093252==p.view.getUint32(0)?(k(h,p,4,!1,s),a=h.offset+30+h.filenameLength+h.extraFieldLength,i.init((function(){0===h.compressionMethod?b(h._worker,o++,t,i,a,h.compressedSize,c,d,l,f,_):function(t,r,i,n,a,s,o,l,u,c,h){var d=o?"output":"none";e.zip.useWebWorkers?v(t,{sn:r,codecClass:"Inflater",crcType:d},i,n,a,s,u,l,c,h):y(new e.zip.Inflater,i,n,a,s,d,u,l,c,h)}(h._worker,o++,t,i,a,h.compressedSize,c,d,l,f,_)}),_)):s(r)}),f)};var c={getEntries:function(e){var n=this._worker;!function(e){function n(r,n){t.readUint8Array(t.size-r,r,(function(t){for(var r=t.length-22;r>=0;r--)if(80===t[r]&&75===t[r+1]&&5===t[r+2]&&6===t[r+3])return void e(new DataView(t.buffer,r,22));n()}),(function(){s(i)}))}t.size<22?s(r):n(22,(function(){n(Math.min(65558,t.size),(function(){s(r)}))}))}((function(a){var o,c;o=a.getUint32(16,!0),c=a.getUint16(8,!0),o<0||o>=t.size?s(r):t.readUint8Array(o,t.size-o,(function(t){var i,a,o,h,d=0,f=[],_=u(t.length,t);for(i=0;i{"use strict";r.r(t),r(50);var i=r(928),n=r(297),a=r(522),s=r(839),o=r(781),l=r(89),u=r(52),c=r(663);function h(){var e=new c.WT(9);return c.WT!=Float32Array&&(e[1]=0,e[2]=0,e[3]=0,e[5]=0,e[6]=0,e[7]=0),e[0]=1,e[4]=1,e[8]=1,e}function d(e,t){return e[0]=t[0],e[1]=t[1],e[2]=t[2],e[3]=t[4],e[4]=t[5],e[5]=t[6],e[6]=t[8],e[7]=t[9],e[8]=t[10],e}function f(e,t){if(e===t){var r=t[1],i=t[2],n=t[5];e[1]=t[3],e[2]=t[6],e[3]=r,e[5]=t[7],e[6]=i,e[7]=n}else e[0]=t[0],e[1]=t[3],e[2]=t[6],e[3]=t[1],e[4]=t[4],e[5]=t[7],e[6]=t[2],e[7]=t[5],e[8]=t[8];return e}function _(e,t){var r=t[0],i=t[1],n=t[2],a=t[3],s=t[4],o=t[5],l=t[6],u=t[7],c=t[8],h=t[9],d=t[10],f=t[11],_=t[12],p=t[13],g=t[14],m=t[15],v=r*o-i*s,y=r*l-n*s,b=r*u-a*s,T=i*l-n*o,M=i*u-a*o,S=n*u-a*l,k=c*p-h*_,E=c*g-d*_,A=c*m-f*_,C=h*g-d*p,w=h*m-f*p,R=d*m-f*g,x=v*R-y*w+b*C+T*A-M*E+S*k;return x?(x=1/x,e[0]=(o*R-l*w+u*C)*x,e[1]=(l*A-s*R-u*E)*x,e[2]=(s*w-o*A+u*k)*x,e[3]=(n*w-i*R-a*C)*x,e[4]=(r*R-n*A+a*E)*x,e[5]=(i*A-r*w-a*k)*x,e[6]=(p*S-g*M+m*T)*x,e[7]=(g*b-_*S-m*y)*x,e[8]=(_*M-p*b+m*v)*x,e):null}function p(e,t){for(var r=0;rthis._size?(this._gl.bufferData(this._type,e,this._hint),this._size=e.length):this._gl.bufferSubData(this._type,0,e)}}])&&p(t.prototype,r),e}();function m(e,t){for(var r=0;r 0.0 ? length(vertex) * fov : -fov;\n float cur = (cross(xneg, xpos).y - cross(yneg, ypos).x) * str * 80.0 / depth;\n return mix(mix(color, color * 0.3, clamp(-cur * 15.0, 0.0, 1.0)), color * 2.0, clamp(cur * 25.0, 0.0, 1.0));\n#endif\n}\n",y,"#extension GL_OES_standard_derivatives : enable","bool isClipped(const in vec3 pos) {"," if (uClipEnabled == 0) return false;"," float side = dot(uClipPlaneN, pos - uClipPlaneO);"," return uClipInvert == 0 ? side > 0.0 : side < 0.0;","}","vec3 getNormal() {"," #ifndef GL_OES_standard_derivatives"," return normalize(gl_FrontFacing ? vNormal : -vNormal);"," #else"," return uFlat == 0 ? normalize(gl_FrontFacing ? vNormal : -vNormal) : -normalize(cross(dFdy(vVertex), dFdx(vVertex)));"," #endif","}","vec4 encodeFragColor(const in vec3 frag, const in float alpha) {"," if (isClipped(vVertex)) discard;"," vec3 col = computeCurvature(vVertex, vNormal, frag, uCurvature, uFov);"," if(uDarken == 1) col *= 0.3;"," col *= (0.3 + 0.7 * vMasking);"," if(uSym == 1 && abs(dot(uPlaneN, vVertex - uPlaneO)) < 0.15)"," col = min(col * 1.5, 1.0);"," return alpha != 1.0 ? vec4(col * alpha, alpha) : encodeRGBM(col);","}"].join("\n");var T,M,S,k,E,A,C,w,R=function(e){var t="",r=e.match(/^\s*(#extension).*/gm);if(r)for(var i={},n=0,a=r.length;n2)&&(o=0),A[0]=C[o][0],A[1]=C[o][1],A[2]=C[o][2];var l=a.computeLocalRadius()*a.getScale(),u=b.sectionCutOffset*l;i.fF(T,a.getCenter(),a.getMatrix()),i.od(M,T,A,u);var c=n.getCamera().getView();i.fF(S,M,c),d(E,c),i.Fv(k,i.kK(k,A,E)),e.uniform3fv(t.uClipPlaneO,S),e.uniform3fv(t.uClipPlaneN,k)}}),b.updateUniforms=(w=[0,0,0],function(e,t){var r=e.getGL(),n=b.darkenUnselected&&t.getIndexSelectMesh(e)<0,a=b.showSymmetryLine&&e.getID()===t.getMesh().getID()&&t.getSculptManager().getSymmetry(),s=this.uniforms;r.uniformMatrix4fv(s.uEM,!1,e.getEditMatrix()),r.uniformMatrix3fv(s.uEN,!1,e.getEN()),r.uniformMatrix4fv(s.uMV,!1,e.getMV()),r.uniformMatrix4fv(s.uMVP,!1,e.getMVP()),r.uniformMatrix3fv(s.uN,!1,e.getN()),r.uniform1i(s.uDarken,n?1:0),r.uniform1i(s.uFlat,e.getFlatShading()),r.uniform3fv(s.uPlaneO,i.fF(w,e.getSymmetryOrigin(),e.getMV())),r.uniform3fv(s.uPlaneN,i.Fv(w,i.kK(w,e.getSymmetryNormal(),e.getN()))),r.uniform1i(s.uSym,a?1:0),r.uniform1f(s.uAlpha,e.getOpacity()),b.updateClipUniforms(r,s,e,t),r.uniform1f(s.uCurvature,e.getCurvature());var o=t.getCamera();r.uniform1f(s.uFov,o.isOrthographic()?25*-Math.abs(o._trans[2]):o.getFov())}),b.draw=function(e,t){e.getGL().useProgram(this.program),this.bindAttributes(e),this.updateUniforms(e,t),this.drawBuffer(e)},b.drawBuffer=function(e){var t=e.getGL();e.isUsingDrawArrays()?t.drawArrays(e.getMode(),0,e.getCount()):(e.getIndexBuffer().bind(),t.drawElements(e.getMode(),e.getCount(),t.UNSIGNED_INT,0)),t.bindBuffer(t.ARRAY_BUFFER,null),t.bindBuffer(t.ELEMENT_ARRAY_BUFFER,null),this.unbindAttributes()},b.setTextureParameters=function(e,t){e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MAG_FILTER,e.LINEAR),o.Z.isPowerOfTwo(t.width)&&o.Z.isPowerOfTwo(t.height)?(e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR_MIPMAP_LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_S,e.REPEAT),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_T,e.REPEAT),e.generateMipmap(e.TEXTURE_2D)):(e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_S,e.CLAMP_TO_EDGE),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_T,e.CLAMP_TO_EDGE))},b.onLoadTexture0=function(e,t,r){this.texture0=e.createTexture(),e.bindTexture(e.TEXTURE_2D,this.texture0),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,e.RGBA,e.UNSIGNED_BYTE,t),b.setTextureParameters(e,t),e.bindTexture(e.TEXTURE_2D,null),r&&r.render()},b.getDummyTexture=function(e){return this._dummyTex||(this._dummyTex=e.createTexture(),e.bindTexture(e.TEXTURE_2D,this._dummyTex),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,1,1,0,e.RGBA,e.UNSIGNED_BYTE,new Uint8Array(4)),e.bindTexture(e.TEXTURE_2D,null)),this._dummyTex},b.getOrCreateTexture0=function(e,t,r){if(void 0!==this.texture0)return this.texture0;this.texture0=null;var i=new Image;return i.src=t,i.onload=this.onLoadTexture0.bind(this,e,i,r),!1},b.initAttributes=function(e){var t=this.program,r=this.attributes;r.aVertex=new v(e,t,"aVertex",3,e.FLOAT),r.aNormal=new v(e,t,"aNormal",3,e.FLOAT),r.aColor=new v(e,t,"aColor",3,e.FLOAT),r.aMaterial=new v(e,t,"aMaterial",3,e.FLOAT)},b.bindAttributes=function(e){var t=this.attributes,r=this.activeAttributes;r.vertex&&t.aVertex.bindToBuffer(e.getVertexBuffer()),r.normal&&t.aNormal.bindToBuffer(e.getNormalBuffer()),r.color&&t.aColor.bindToBuffer(e.getColorBuffer()),r.material&&t.aMaterial.bindToBuffer(e.getMaterialBuffer())},b.unbindAttributes=function(){var e=this.attributes,t=this.activeAttributes;t.vertex&&e.aVertex.unbind(),t.normal&&e.aNormal.unbind(),t.color&&e.aColor.unbind(),t.material&&e.aMaterial.unbind()},b.getCopy=function(){for(var e=Object.keys(b),t={},r=0,i=e.length;r= 0.0 ? uAlbedo : aColor;"," vRoughness = uRoughness >= 0.0 ? uRoughness : aMaterial.x;"," vMetallic = uMetallic >= 0.0 ? uMetallic : aMaterial.y;"," vMasking = aMaterial.z;"," vNormal = mix(aNormal, uEN * aNormal, vMasking);"," vNormal = normalize(uN * vNormal);"," vec4 vertex4 = vec4(aVertex, 1.0);"," vertex4 = mix(vertex4, uEM * vertex4, vMasking);"," vVertex = vec3(uMV * vertex4);"," gl_Position = uMVP * vertex4;","}"].join("\n"),D.fragment=["varying vec3 vVertex;","varying vec3 vNormal;","varying vec3 vAlbedo;","varying float vRoughness;","varying float vMetallic;","uniform float uAlpha;",x.strings.fragColorUniforms,x.strings.fragColorFunction,P,"","void main(void) {"," vec3 normal = getNormal();"," float roughness = max( 0.0001, vRoughness );"," vec3 linColor = sRGBToLinear(vAlbedo);"," vec3 albedo = linColor * (1.0 - vMetallic);"," vec3 specular = mix( vec3(0.04), linColor, vMetallic);",""," vec3 color = uExposure * computeIBL_UE4( normal, -normalize(vVertex), albedo, roughness, specular );"," gl_FragColor = encodeFragColor(color, uAlpha);","}"].join("\n"),D.onLoadEnvironment=function(e,t,r,i){if(!e.status||200===e.status||e.response&&e.response.byteLength){var n=e.width||Math.sqrt(e.response.byteLength/8),a=2*n;i.size=[n,a],i.texture=t.createTexture(),t.bindTexture(t.TEXTURE_2D,i.texture),e.response?t.texImage2D(t.TEXTURE_2D,0,t.RGBA,n,a,0,t.RGBA,t.UNSIGNED_BYTE,new Uint8Array(e.response)):t.texImage2D(t.TEXTURE_2D,0,t.RGBA,t.RGBA,t.UNSIGNED_BYTE,e),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MIN_FILTER,t.LINEAR),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MAG_FILTER,t.LINEAR),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_S,t.REPEAT),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_T,t.REPEAT),t.bindTexture(t.TEXTURE_2D,null),r&&r.render()}},D.getOrCreateEnvironment=function(e,t,r){if(void 0!==r.texture)return r.texture;if(r.texture=null,r.path.endsWith("png")){var i=new Image;return i.src=r.path,i.onload=D.onLoadEnvironment.bind(this,i,e,t,r),null}var n=new XMLHttpRequest;return n.open("GET",r.path,!0),n.responseType="arraybuffer",n.onload=D.onLoadEnvironment.bind(this,n,e,t,r),n.send(null),null};var O=h();D.updateUniforms=function(e,t){var r=e.getGL(),i=this.uniforms;d(O,t.getCamera().getView()),r.uniformMatrix3fv(i.uIblTransform,!1,f(O,O)),r.uniform3fv(i.uAlbedo,e.getAlbedo()),r.uniform1f(i.uRoughness,e.getRoughness()),r.uniform1f(i.uMetallic,e.getMetallic()),r.uniform1f(i.uExposure,D.exposure);var n=D.environments[D.idEnv];r.uniform3fv(i.uSPH,n.sph),n.size&&r.uniform2fv(i.uEnvSize,n.size),r.activeTexture(r.TEXTURE0),r.bindTexture(r.TEXTURE_2D,D.getOrCreateEnvironment(r,t,n)||this.getDummyTexture(r)),r.uniform1i(i.uTexture0,0),x.updateUniforms.call(this,e,t)};const L=D;var V=r(183),N=x.getCopy();N.vertexName=N.fragmentName="Matcap",N.textures={},N.createTexture=function(e,t,r){var i=e.createTexture();e.bindTexture(e.TEXTURE_2D,i),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,e.RGBA,e.UNSIGNED_BYTE,t),N.setTextureParameters(e,t),e.bindTexture(e.TEXTURE_2D,null),N.textures[r]=i},N.matcaps=[{path:"resources/matcaps/matcapFV.jpg",name:"matcap FV"},{path:"resources/matcaps/redClay.jpg",name:"Red clay"},{path:"resources/matcaps/skinHazardousarts.jpg",name:"Skin hazardousarts"},{path:"resources/matcaps/skinHazardousarts2.jpg",name:"Skin Hazardousarts2"},{path:"resources/matcaps/pearl.jpg",name:(0,V.Z)("matcapPearl")},{path:"resources/matcaps/clay.jpg",name:(0,V.Z)("matcapClay")},{path:"resources/matcaps/skin.jpg",name:(0,V.Z)("matcapSkin")},{path:"resources/matcaps/green.jpg",name:(0,V.Z)("matcapGreen")},{path:"resources/matcaps/white.jpg",name:(0,V.Z)("matcapWhite")}],N.uniforms={},N.attributes={},N.uniformNames=["uTexture0","uAlbedo"],Array.prototype.push.apply(N.uniformNames,x.uniformNames.commonUniforms),N.vertex=["attribute vec3 aVertex;","attribute vec3 aNormal;","attribute vec3 aColor;","attribute vec3 aMaterial;",x.strings.vertUniforms,"varying vec3 vVertex;","varying vec3 vNormal;","varying vec3 vColor;","varying float vMasking;","varying vec3 vVertexPres;","uniform vec3 uAlbedo;","void main() {"," vColor = uAlbedo.x >= 0.0 ? uAlbedo : aColor;"," vMasking = aMaterial.z;"," vNormal = mix(aNormal, uEN * aNormal, vMasking);"," vNormal = normalize(uN * vNormal);"," vec4 vertex4 = vec4(aVertex, 1.0);"," vertex4 = mix(vertex4, uEM * vertex4, vMasking);"," vVertex = vec3(uMV * vertex4);"," vVertexPres = vVertex / max(1.0, abs(uMV[3][2]));"," gl_Position = uMVP * vertex4;","}"].join("\n"),N.fragment=["uniform sampler2D uTexture0;","varying vec3 vVertex;","varying vec3 vVertexPres;","varying vec3 vNormal;","varying vec3 vColor;","uniform float uAlpha;",x.strings.fragColorUniforms,x.strings.fragColorFunction,"void main() {"," vec3 normal = getNormal();"," vec3 nm_z = normalize(vVertexPres);"," vec3 nm_x = vec3(-nm_z.z, 0.0, nm_z.x);"," vec3 nm_y = cross(nm_x, nm_z);"," vec2 texCoord = 0.5 + 0.5 * vec2(dot(normal, nm_x), dot(normal, nm_y));"," vec3 color = sRGBToLinear(texture2D(uTexture0, texCoord).rgb) * sRGBToLinear(vColor);"," gl_FragColor = encodeFragColor(color, uAlpha);","}"].join("\n"),N.updateUniforms=function(e,t){var r=e.getGL(),i=this.uniforms;r.activeTexture(r.TEXTURE0),e.setTexture0(N.textures[e.getMatcap()]),r.bindTexture(r.TEXTURE_2D,e.getTexture0()||this.getDummyTexture(r)),r.uniform1i(i.uTexture0,0),r.uniform3fv(i.uAlbedo,e.getAlbedo()),x.updateUniforms.call(this,e,t)};const B=N;var U=x.getCopy();U.vertexName=U.fragmentName="ShowNormal",U.uniforms={},U.attributes={},U.activeAttributes={vertex:!0,normal:!0,material:!0},U.uniformNames=[],Array.prototype.push.apply(U.uniformNames,x.uniformNames.commonUniforms),U.vertex=["attribute vec3 aVertex;","attribute vec3 aNormal;","attribute vec3 aMaterial;",x.strings.vertUniforms,"varying vec3 vVertex;","varying vec3 vNormal;","varying float vMasking;","void main() {"," vMasking = aMaterial.z;"," vNormal = mix(aNormal, uEN * aNormal, vMasking);"," vNormal = normalize(uN * vNormal);"," vec4 vertex4 = vec4(aVertex, 1.0);"," vertex4 = mix(vertex4, uEM *vertex4, vMasking);"," vVertex = vec3(uMV * vertex4);"," gl_Position = uMVP * vertex4;","}"].join("\n"),U.fragment=["varying vec3 vVertex;","varying vec3 vNormal;","uniform float uAlpha;",x.strings.fragColorUniforms,x.strings.fragColorFunction,"void main() {"," gl_FragColor = encodeFragColor(sRGBToLinear(getNormal() * 0.5 + 0.5), uAlpha);","}"].join("\n");const Z=U;var G=x.getCopy();G.vertexName=G.fragmentName="ShowUV",G.texPath="resources/uv.png",G.uniforms={},G.attributes={},G.uniformNames=["uTexture0","uAlbedo"],Array.prototype.push.apply(G.uniformNames,x.uniformNames.commonUniforms),G.vertex=["attribute vec3 aVertex;","attribute vec3 aNormal;","attribute vec3 aColor;","attribute vec2 aTexCoord;","attribute vec3 aMaterial;",x.strings.vertUniforms,"varying vec3 vVertex;","varying vec3 vNormal;","varying vec3 vColor;","varying vec2 vTexCoord;","varying float vMasking;","uniform vec3 uAlbedo;","void main() {"," vColor = uAlbedo.x >= 0.0 ? uAlbedo : aColor;"," vTexCoord = aTexCoord;"," vMasking = aMaterial.z;"," vNormal = mix(aNormal, uEN * aNormal, vMasking);"," vNormal = normalize(uN * vNormal);"," vec4 vertex4 = vec4(aVertex, 1.0);"," vertex4 = mix(vertex4, uEM *vertex4, vMasking);"," vVertex = vec3(uMV * vertex4);"," gl_Position = uMVP * vertex4;","}"].join("\n"),G.fragment=["uniform sampler2D uTexture0;","varying vec3 vVertex;","varying vec3 vNormal;","varying vec3 vColor;","varying vec2 vTexCoord;","uniform float uAlpha;",x.strings.fragColorUniforms,x.strings.fragColorFunction,"void main() {"," vec3 color = sRGBToLinear(texture2D(uTexture0, vTexCoord).rgb) * sRGBToLinear(vColor);"," gl_FragColor = encodeFragColor(color, uAlpha);","}"].join("\n"),G.draw=x.draw,G.drawBuffer=x.drawBuffer,G.getOrCreate=x.getOrCreate,G.initUniforms=x.initUniforms,G.initAttributes=function(e){x.initAttributes.call(this,e),G.attributes.aTexCoord=new v(e,G.program,"aTexCoord",2,e.FLOAT)},G.bindAttributes=function(e){x.bindAttributes.call(this,e),G.attributes.aTexCoord.bindToBuffer(e.getTexCoordBuffer())},G.updateUniforms=function(e,t){var r=e.getGL(),i=this.uniforms;r.activeTexture(r.TEXTURE0),r.bindTexture(r.TEXTURE_2D,this.getOrCreateTexture0(r,G.texPath,t)||this.getDummyTexture(r)),r.uniform1i(i.uTexture0,0),r.uniform3fv(i.uAlbedo,e.getAlbedo()),x.updateUniforms.call(this,e,t)};const W=G;var j=x.getCopy();j.vertexName=j.fragmentName="Wireframe",j.uniforms={},j.attributes={},j.activeAttributes={vertex:!0,material:!0},j.uniformNames=["uMVP","uMV","uEM","uClipPlaneO","uClipPlaneN","uClipEnabled","uClipInvert"],j.vertex=["attribute vec3 aVertex;","attribute vec3 aMaterial;","uniform mat4 uMVP;","uniform mat4 uMV;","uniform mat4 uEM;","varying vec3 vVertex;","void main() {"," vec4 vertex4 = vec4(aVertex, 1.0);"," vec4 skinned = mix(vertex4, uEM * vertex4, aMaterial.z);"," vVertex = vec3(uMV * skinned);"," vec4 pos = uMVP * skinned;"," pos[3] += 0.0001;"," gl_Position = pos;","}"].join("\n"),j.fragment=["uniform vec3 uClipPlaneN;","uniform vec3 uClipPlaneO;","uniform int uClipEnabled;","uniform int uClipInvert;","varying vec3 vVertex;","void main() {"," if (uClipEnabled == 1) {"," float side = dot(uClipPlaneN, vVertex - uClipPlaneO);"," if ((uClipInvert == 0 && side > 0.0) || (uClipInvert == 1 && side < 0.0)) discard;"," }"," gl_FragColor = vec4(0.0, 0.0, 0.0, 0.4);","}"].join("\n"),j.getOrCreate=x.getOrCreate,j.draw=function(e,t){var r=e.getGL();r.useProgram(this.program),this.bindAttributes(e),this.updateUniforms(e,t),e.getWireframeBuffer().bind(),r.drawElements(r.LINES,2*e.getRenderNbEdges(),r.UNSIGNED_INT,0)},j.updateUniforms=function(e,t){var r=e.getGL();r.uniformMatrix4fv(this.uniforms.uMVP,!1,e.getMVP()),r.uniformMatrix4fv(this.uniforms.uMV,!1,e.getMV()),r.uniformMatrix4fv(this.uniforms.uEM,!1,e.getEditMatrix()),x.updateClipUniforms(r,this.uniforms,e,t)};const z=j;var X=x.getCopy();X.vertexName=X.fragmentName="FlatColor",X.uniforms={},X.attributes={},X.activeAttributes={vertex:!0,material:!0},X.uniformNames=["uColor"],Array.prototype.push.apply(X.uniformNames,x.uniformNames.commonUniforms),X.vertex=["attribute vec3 aVertex;","attribute vec3 aMaterial;",x.strings.vertUniforms,"varying vec3 vVertex;","void main() {"," vec4 vertex4 = vec4(aVertex, 1.0);"," vec4 skinned = mix(vertex4, uEM * vertex4, aMaterial.z);"," vVertex = vec3(uMV * skinned);"," gl_Position = uMVP * skinned;","}"].join("\n"),X.fragment=["uniform vec3 uColor;","uniform vec3 uClipPlaneN;","uniform vec3 uClipPlaneO;","uniform int uClipEnabled;","uniform int uClipInvert;","varying vec3 vVertex;","void main() {"," if (uClipEnabled == 1) {"," float side = dot(uClipPlaneN, vVertex - uClipPlaneO);"," if ((uClipInvert == 0 && side > 0.0) || (uClipInvert == 1 && side < 0.0)) discard;"," }"," gl_FragColor = vec4(uColor, 1.0);","}"].join("\n"),X.updateUniforms=function(e,t){e.getGL().uniform3fv(this.uniforms.uColor,e.getFlatColor()),x.updateUniforms.call(this,e,t)};const K=X;var H=x.getCopy();H.vertexName=H.fragmentName="ShowSelection",H.uniforms={},H.attributes={},H.activeAttributes={vertex:!0},H.uniformNames=["uMVP","uColor"],H.vertex=["attribute vec3 aVertex;","uniform mat4 uMVP;","void main() {"," gl_Position = uMVP * vec4(aVertex, 1.0);","}"].join("\n"),H.fragment=["uniform vec3 uColor;","void main() {"," gl_FragColor = vec4(uColor, 1.0);","}"].join("\n"),H.draw=function(e,t,r){var i=e.getGL();i.useProgram(this.program),i.uniform3fv(this.uniforms.uColor,e.getColor()),t&&(i.uniformMatrix4fv(this.uniforms.uMVP,!1,e.getCircleMVP()),H.attributes.aVertex.bindToBuffer(e.getCircleBuffer()),i.drawArrays(i.LINE_LOOP,0,e.getCircleBuffer()._size/3)),i.uniformMatrix4fv(this.uniforms.uMVP,!1,e.getDotMVP()),H.attributes.aVertex.bindToBuffer(e.getDotBuffer()),i.drawArrays(i.TRIANGLE_FAN,0,e.getDotBuffer()._size/3),r&&(i.uniformMatrix4fv(this.uniforms.uMVP,!1,e.getDotSymmetryMVP()),i.drawArrays(i.TRIANGLE_FAN,0,e.getDotBuffer()._size/3))};const Y=H;var q=x.getCopy();q.vertexName=q.fragmentName="Background",q.uniforms={},q.attributes={},q.uniformNames=["uTexture0","uBackgroundType","uIblTransform","uSPH","uBlur","uEnvSize"],q.vertex=["attribute vec2 aVertex;","attribute vec2 aTexCoord;","varying vec2 vTexCoord;","void main() {"," vTexCoord = aTexCoord;"," gl_Position = vec4(aVertex, 1.0, 1.0);","}"].join("\n"),q.fragment=["varying vec2 vTexCoord;",x.strings.colorSpaceGLSL,P,"uniform int uBackgroundType;\nuniform float uBlur;\n\nvoid main() {\n vec3 color;\n if (uBackgroundType == 0) {\n color = sRGBToLinear(texture2D(uTexture0, vTexCoord).rgb);\n } else {\n vec3 dir = uIblTransform * vec3(vTexCoord.xy * 2.0 - 1.0, -1.0);\n dir = normalize(dir);\n if (uBackgroundType == 1) {\n color = texturePanoramaLod(dir, uBlur * uBlur);\n } else {\n color = sphericalHarmonics(dir);\n }\n }\n gl_FragColor = encodeRGBM(color);\n}\n"].join("\n"),q.draw=function(e){var t=e.getGL();t.useProgram(this.program),this.bindAttributes(e),this.updateUniforms(e),t.drawArrays(t.TRIANGLE_STRIP,0,4)},q.initAttributes=function(e){var t=q.program,r=q.attributes;r.aVertex=new v(e,t,"aVertex",2,e.FLOAT),r.aTexCoord=new v(e,t,"aTexCoord",2,e.FLOAT)},q.bindAttributes=function(e){var t=q.attributes;t.aVertex.bindToBuffer(e.getVertexBuffer()),t.aTexCoord.bindToBuffer(e.getTexCoordBuffer())};var J=h();q.updateUniforms=function(e){var t,r=this.uniforms,i=e._main,n=L.environments[L.idEnv],a=e.getGL();a.uniform1i(r.uBackgroundType,e.getType()),t=0===e.getType()?e.getTexture():L.getOrCreateEnvironment(a,i,n)||e.getTexture(),a.activeTexture(a.TEXTURE0),a.bindTexture(a.TEXTURE_2D,t),a.uniform1i(r.uTexture0,0),d(J,i.getCamera().getView()),a.uniformMatrix3fv(r.uIblTransform,!1,f(J,J)),a.uniform3fv(r.uSPH,n.sph),n.size&&a.uniform2fv(r.uEnvSize,n.size),a.uniform1f(r.uBlur,e.getBlur())};const Q=q;var $=x.getCopy();$.vertexName=$.fragmentName="Merge",$.FILMIC=(0,u.Z)().filmic,$.uniforms={},$.attributes={},$.uniformNames=["uTexture0","uTexture1","uFilmic"],$.vertex=["attribute vec2 aVertex;","varying vec2 vTexCoord;","void main() {"," vTexCoord = aVertex * 0.5 + 0.5;"," gl_Position = vec4(aVertex, 0.5, 1.0);","}"].join("\n"),$.fragment=["uniform sampler2D uTexture0;","uniform sampler2D uTexture1;","uniform int uFilmic;","varying vec2 vTexCoord;",x.strings.colorSpaceGLSL,"void main() {"," vec4 transparent = texture2D(uTexture1, vTexCoord);"," vec3 color = decodeRGBM(texture2D(uTexture0, vTexCoord))*(1.0-transparent.a) + transparent.rgb;"," if(uFilmic == 1){"," vec3 x = max(vec3(0.0), color - vec3(0.004));"," gl_FragColor = vec4((x*(6.2*x+0.5))/(x*(6.2*x+1.7)+0.06), 1.0);"," }else{"," gl_FragColor = vec4(linearTosRGB(color), 1.0);"," }","}"].join("\n"),$.draw=function(e,t){var r=e.getGL();r.useProgram(this.program),$.attributes.aVertex.bindToBuffer(e.getVertexBuffer()),r.activeTexture(r.TEXTURE0),r.bindTexture(r.TEXTURE_2D,t._rttOpaque.getTexture()),r.uniform1i(this.uniforms.uTexture0,0),r.activeTexture(r.TEXTURE1),r.bindTexture(r.TEXTURE_2D,t._rttTransparent.getTexture()),r.uniform1i(this.uniforms.uTexture1,1),r.uniform1i(this.uniforms.uFilmic,$.FILMIC),r.drawArrays(r.TRIANGLES,0,3)},$.initAttributes=function(e){$.attributes.aVertex=new v(e,$.program,"aVertex",2,e.FLOAT)};const ee=$;var te=x.getCopy();te.vertexName=te.fragmentName="Fxaa",te.FILMIC=(0,u.Z)().filmic,te.uniforms={},te.attributes={},te.uniformNames=["uTexture0","uInvSize"],te.vertex=["attribute vec2 aVertex;","uniform vec2 uInvSize;","varying vec2 vUVNW;","varying vec2 vUVNE;","varying vec2 vUVSW;","varying vec2 vUVSE;","varying vec2 vUVM;","void main() {"," vUVM = aVertex * 0.5 + 0.5;"," vUVNW = vUVM + vec2(-1.0, -1.0) * uInvSize;"," vUVNE = vUVM + vec2(1.0, -1.0) * uInvSize;"," vUVSW = vUVM + vec2(-1.0, 1.0) * uInvSize;"," vUVSE = vUVM + vec2(1.0, 1.0) * uInvSize;"," gl_Position = vec4(aVertex, 0.5, 1.0);","}"].join("\n"),te.fragment=["uniform sampler2D uTexture0;","uniform vec2 uInvSize;","varying vec2 vUVNW;","varying vec2 vUVNE;","varying vec2 vUVSW;","varying vec2 vUVSE;","varying vec2 vUVM;","// https://github.com/mattdesl/glsl-fxaa\n#define FXAA_REDUCE_MIN (1.0/ 128.0)\n#define FXAA_REDUCE_MUL (1.0 / 8.0)\n#define FXAA_SPAN_MAX 8.0\n\nvec3 fxaa(const in sampler2D tex, const in vec2 uvNW, const in vec2 uvNE, const in vec2 uvSW, const in vec2 uvSE, const in vec2 uvM, const in vec2 invRes) {\n const vec3 luma = vec3(0.299, 0.587, 0.114);\n float lumaNW = dot(texture2D(tex, uvNW).xyz, luma);\n float lumaNE = dot(texture2D(tex, uvNE).xyz, luma);\n float lumaSW = dot(texture2D(tex, uvSW).xyz, luma);\n float lumaSE = dot(texture2D(tex, uvSE).xyz, luma);\n float lumaM = dot(texture2D(tex, uvM).xyz, luma);\n float lumaMin = min(lumaM, min(min(lumaNW, lumaNE), min(lumaSW, lumaSE)));\n float lumaMax = max(lumaM, max(max(lumaNW, lumaNE), max(lumaSW, lumaSE)));\n\n vec2 dir = vec2(-((lumaNW + lumaNE) - (lumaSW + lumaSE)), ((lumaNW + lumaSW) - (lumaNE + lumaSE)));\n float dirReduce = max((lumaNW + lumaNE + lumaSW + lumaSE) * (0.25 * FXAA_REDUCE_MUL), FXAA_REDUCE_MIN);\n float rcpDirMin = 1.0 / (min(abs(dir.x), abs(dir.y)) + dirReduce);\n dir = min(vec2(FXAA_SPAN_MAX, FXAA_SPAN_MAX), max(vec2(-FXAA_SPAN_MAX, -FXAA_SPAN_MAX), dir * rcpDirMin)) * invRes;\n \n vec3 rgbA = 0.5 * ( texture2D(tex, uvM + dir * (1.0 / 3.0 - 0.5)).xyz + texture2D(tex, uvM + dir * (2.0 / 3.0 - 0.5)).xyz);\n vec3 rgbB = rgbA * 0.5 + 0.25 * ( texture2D(tex, uvM - dir * 0.5).xyz + texture2D(tex, uvM + dir * 0.5).xyz);\n \n float lumaB = dot(rgbB, luma);\n if((lumaB < lumaMin) || (lumaB > lumaMax))\n return rgbA;\n return rgbB;\n}",x.strings.colorSpaceGLSL,"void main() {"," gl_FragColor = vec4(fxaa(uTexture0, vUVNW, vUVNE, vUVSW, vUVSE, vUVM, uInvSize), 1.0);","}"].join("\n"),te.draw=function(e,t){var r=e.getGL();r.useProgram(this.program),te.attributes.aVertex.bindToBuffer(e.getVertexBuffer()),r.activeTexture(r.TEXTURE0),r.bindTexture(r.TEXTURE_2D,t._rttMerge.getTexture()),r.uniform1i(this.uniforms.uTexture0,0),r.uniform2fv(this.uniforms.uInvSize,e.getInverseSize()),r.drawArrays(r.TRIANGLES,0,3)},te.initAttributes=function(e){te.attributes.aVertex=new v(e,te.program,"aVertex",2,e.FLOAT)};const re=te;var ie=x.getCopy();ie.vertexName=ie.fragmentName="SobelContour",ie.color=(0,u.Z)().outlinecolor,ie.uniforms={},ie.attributes={},ie.uniformNames=["uTexture0","uColor","uInvSize"],ie.vertex=["attribute vec2 aVertex;","varying vec2 vTexCoord;","void main() {"," vTexCoord = aVertex * 0.5 + 0.5;"," gl_Position = vec4(aVertex, 0.0, 1.0);","}"].join("\n"),ie.fragment=["#extension GL_OES_standard_derivatives : enable","uniform sampler2D uTexture0;","uniform vec4 uColor;","varying vec2 vTexCoord;","uniform vec2 uInvSize;","\nfloat outlineDistance( const in vec2 uv, const in sampler2D tex, const in vec2 invSize ) {\n float fac0 = 2.0;\n float fac1 = 1.0;\n float ox = invSize.x;\n float oy = invSize.y;\n vec4 texel0 = texture2D(tex, uv + vec2(ox, oy));\n vec4 texel1 = texture2D(tex, uv + vec2(ox, 0.0));\n vec4 texel2 = texture2D(tex, uv + vec2(ox, -oy));\n vec4 texel3 = texture2D(tex, uv + vec2(0.0, -oy));\n vec4 texel4 = texture2D(tex, uv + vec2(-ox, -oy));\n vec4 texel5 = texture2D(tex, uv + vec2(-ox, 0.0));\n vec4 texel6 = texture2D(tex, uv + vec2(-ox, oy));\n vec4 texel7 = texture2D(tex, uv + vec2(0.0, oy));\n vec4 rowx = -fac0 * texel5 + fac0 * texel1 + -fac1 * texel6 + fac1 * texel0 + -fac1 * texel4 + fac1 * texel2;\n vec4 rowy = -fac0 * texel3 + fac0 * texel7 + -fac1 * texel4 + fac1 * texel6 + -fac1 * texel2 + fac1 * texel0;\n return dot(rowy, rowy) + dot(rowx, rowx);\n}\n",x.strings.colorSpaceGLSL,"void main() {"," float mag = outlineDistance(vTexCoord, uTexture0, uInvSize);"," if (mag < 1.5) discard;"," gl_FragColor = vec4(sRGBToLinear(uColor.rgb) * uColor.a, uColor.a);","}"].join("\n"),ie.draw=function(e){var t=e.getGL();t.useProgram(this.program),ie.attributes.aVertex.bindToBuffer(e.getVertexBuffer()),t.activeTexture(t.TEXTURE0),t.bindTexture(t.TEXTURE_2D,e.getTexture()),t.uniform1i(this.uniforms.uTexture0,0),t.uniform4fv(this.uniforms.uColor,ie.color),t.uniform2fv(this.uniforms.uInvSize,e.getInverseSize()),t.drawArrays(t.TRIANGLES,0,3)},ie.initAttributes=function(e){ie.attributes.aVertex=new v(e,ie.program,"aVertex",2,e.FLOAT)};const ne=ie;var ae=x.getCopy();ae.CHANNEL_VALUE=0,ae.vertexName=ae.fragmentName="PaintUV",ae.uniforms={},ae.attributes={},ae.uniformNames=["uChannelPaint"],ae.activeAttributes={color:!0,material:!0},ae.vertex=["attribute vec2 aTexCoord;","attribute vec3 aColor;","attribute vec3 aMaterial;","varying vec3 vColor;","varying vec2 vMaterial;","void main() {"," vColor = aColor;"," vMaterial = aMaterial.xy;"," gl_Position = vec4((aTexCoord * 2.0 - 1.0) * vec2(1.0, -1.0), 0.5, 1.0);","}"].join("\n"),ae.fragment=["varying vec3 vColor;","varying vec2 vMaterial;","uniform int uChannelPaint;","void main() {"," gl_FragColor = vec4(uChannelPaint == 0 ? vColor : uChannelPaint == 1 ? vMaterial.xxx : vMaterial.yyy, 1.0);","}"].join("\n"),ae.initAttributes=function(e){x.initAttributes.call(this,e),ae.attributes.aTexCoord=new v(e,ae.program,"aTexCoord",2,e.FLOAT)},ae.bindAttributes=function(e){x.bindAttributes.call(this,e),ae.attributes.aTexCoord.bindToBuffer(e.getTexCoordBuffer())},ae.updateUniforms=function(e){e.getGL().uniform1i(this.uniforms.uChannelPaint,ae.CHANNEL_VALUE)};const se=ae;var oe=x.getCopy();oe.vertexName=oe.fragmentName="Blur",oe.INPUT_TEXTURE=null,oe.uniforms={},oe.attributes={},oe.uniformNames=["uTexture0","uInvSize"],oe.vertex=["attribute vec2 aVertex;","varying vec2 vTexCoord;","void main() {"," vTexCoord = aVertex * 0.5 + 0.5;"," gl_Position = vec4(aVertex, 0.5, 1.0);","}"].join("\n"),oe.fragment=["uniform sampler2D uTexture0;","varying vec2 vTexCoord;","uniform vec2 uInvSize;",x.strings.colorSpaceGLSL,"void main() {"," const int KER_SIZE = 8;"," vec4 avg = texture2D(uTexture0, vTexCoord);"," if (avg.a < 0.1) {"," for (int i = -KER_SIZE; i <= KER_SIZE; ++i) {"," for (int j = -KER_SIZE; j <= KER_SIZE; ++j) {"," if (i == 0 && j == 0) continue;"," vec4 fetch = texture2D(uTexture0, vTexCoord + vec2(i, j) * uInvSize);"," avg.rgb += sRGBToLinear(fetch.rgb) * fetch.a;"," avg.a += fetch.a;"," }"," }"," avg.rgb = avg.a == 0.0 ? vec3(0.0) : linearTosRGB(avg.rgb / avg.a);"," }"," gl_FragColor = vec4(avg.rgb, 1.0);","}"].join("\n"),oe.draw=function(e){var t=e.getGL();t.useProgram(this.program),oe.attributes.aVertex.bindToBuffer(e.getVertexBuffer()),t.activeTexture(t.TEXTURE0),t.bindTexture(t.TEXTURE_2D,oe.INPUT_TEXTURE.getTexture()),t.uniform1i(this.uniforms.uTexture0,0),t.uniform2fv(this.uniforms.uInvSize,oe.INPUT_TEXTURE.getInverseSize()),t.drawArrays(t.TRIANGLES,0,3)},oe.initAttributes=function(e){oe.attributes.aVertex=new v(e,oe.program,"aVertex",2,e.FLOAT)};const le=oe;var ue=x.getCopy();ue.vertexName=ue.fragmentName="ComparisonImage",ue.uniforms={},ue.attributes={},ue.uniformNames=["uMVP","uTexture","uOpacity","uBorderColor","uIsBorder"],ue.vertex=["attribute vec3 aPosition;","attribute vec2 aTexCoord;","uniform mat4 uMVP;","varying vec2 vTexCoord;","void main() {"," gl_Position = uMVP * vec4(aPosition, 1.0);"," vTexCoord = aTexCoord;","}"].join("\n"),ue.fragment=["#ifdef GL_FRAGMENT_PRECISION_HIGH"," precision highp float;","#else"," precision mediump float;","#endif","uniform sampler2D uTexture;","uniform float uOpacity;","uniform vec3 uBorderColor;","uniform int uIsBorder;","varying vec2 vTexCoord;","void main() {"," if (uIsBorder == 1) {"," gl_FragColor = vec4(uBorderColor, 1.0);"," } else {"," vec4 color = texture2D(uTexture, vTexCoord);"," gl_FragColor = vec4(color.rgb, color.a * uOpacity);"," }","}"].join("\n"),ue.initAttributes=function(e){var t=ue.program;if(t){var r=ue.attributes;r.aPosition=new v(e,t,"aPosition",3,e.FLOAT),r.aTexCoord=new v(e,t,"aTexCoord",2,e.FLOAT)}else console.error("ShaderComparisonImage: program not initialized")},ue.initUniforms=function(e){x.initUniforms.call(this,e)},ue.bindAttributes=function(e){};const ce=ue;var he=[];he[s.Z.Shader.PBR]=L,he[s.Z.Shader.MATCAP]=B,he[s.Z.Shader.NORMAL]=Z,he[s.Z.Shader.UV]=W,he[s.Z.Shader.WIREFRAME]=z,he[s.Z.Shader.FLAT]=K,he[s.Z.Shader.SELECTION]=Y,he[s.Z.Shader.BACKGROUND]=Q,he[s.Z.Shader.MERGE]=ee,he[s.Z.Shader.FXAA]=re,he[s.Z.Shader.CONTOUR]=ne,he[s.Z.Shader.PAINTUV]=se,he[s.Z.Shader.BLUR]=le,he[s.Z.Shader.COMPARISON_IMAGE]=ce;const de=he;function fe(e,t){for(var r=0;r0&&void 0!==arguments[0]?arguments[0]:1,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:50,r=arguments.length>2&&void 0!==arguments[2]&&arguments[2],i=2*Math.PI,n=r?1:0,a=r?t+2:t,s=new Float32Array(3*a),o=n;o=2){t[f]=m/T,t[f+1]=v/T,t[f+2]=y/T;continue}m=v=y=0}for(b=h;b0&&void 0!==arguments[0]?arguments[0]:-1/0,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1/0,r=this.getMesh(),i=r.getMaterials(),n=r.getNbVertices(),a=new Uint32Array(o.Z.getMemory(4*n),0,n),s=0,l=0;le&&u0)){var R=h[k]-d,x=h[k+1]-f,P=h[k+2]-_,D=Math.sqrt(R*R+x*x+P*P)/c;if(!(D>=1)){var I=D*D;I=3*I*I-4*I*D+1,I*=w*a*u[k+2]*s.getAlpha(E,A,C),l[k]-=v*I,l[k+1]-=y*I,l[k+2]-=b*I}}}}}])&&Se(t.prototype,r),o}(Te);function we(e){return(we="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function Re(e,t){for(var r=0;r=1)){var E=o[b],A=o[b+1],C=o[b+2],w=k*k;w=3*w*w-4*w*k+1,w*=l[b+2]*h*a.getAlpha(E,A,C),o[b]=E+p*w,o[b+1]=A+g*w,o[b+2]=C+m*w}}}}])&&Re(t.prototype,r),l}(Te);function Fe(e){return(Fe="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function Oe(e,t){for(var r=0;r=1)){var M=T*T;M=3*M*M-4*M*T+1,M*=h;var S=s[m],k=s[m+1],E=s[m+2],A=u[m],C=u[m+1],w=u[m+2];M/=Math.sqrt(A*A+C*C+w*w),M*=o[m+2]*n.getAlpha(S,k,E),s[m]=S+A*M,s[m+1]=k+C*M,s[m+2]=E+w*M}}}}])&&Oe(t.prototype,r),o}(Te);function Ue(e,t,r){return e[0]=t,e[1]=r,e}function Ze(e,t,r,i){return e[0]=t[0]+r[0]*i,e[1]=t[1]+r[1]*i,e}function Ge(e,t){var r=t[0],i=t[1],n=r*r+i*i;return n>0&&(n=1/Math.sqrt(n)),e[0]=t[0]*n,e[1]=t[1]*n,e}function We(e,t){return e[0]*t[0]+e[1]*t[1]}var je,ze=function(e,t,r){return e[0]=t[0]-r[0],e[1]=t[1]-r[1],e},Xe=function(e,t){var r=t[0]-e[0],i=t[1]-e[1];return Math.hypot(r,i)};function Ke(){var e=new c.WT(4);return c.WT!=Float32Array&&(e[0]=0,e[1]=0,e[2]=0),e[3]=1,e}function He(e){return e[0]=0,e[1]=0,e[2]=0,e[3]=1,e}function Ye(e,t,r){r*=.5;var i=Math.sin(r);return e[0]=i*t[0],e[1]=i*t[1],e[2]=i*t[2],e[3]=Math.cos(r),e}function qe(e,t,r){r*=.5;var i=t[0],n=t[1],a=t[2],s=t[3],o=Math.sin(r),l=Math.cos(r);return e[0]=i*l+s*o,e[1]=n*l+a*o,e[2]=a*l-n*o,e[3]=s*l-i*o,e}function Je(e,t,r){r*=.5;var i=t[0],n=t[1],a=t[2],s=t[3],o=Math.sin(r),l=Math.cos(r);return e[0]=i*l-a*o,e[1]=n*l+s*o,e[2]=a*l+i*o,e[3]=s*l-n*o,e}je=new c.WT(2),c.WT!=Float32Array&&(je[0]=0,je[1]=0),function(){var e=new c.WT(4);c.WT!=Float32Array&&(e[0]=0,e[1]=0,e[2]=0,e[3]=0)}();var Qe=function(e,t,r,i){var n=new c.WT(4);return n[0]=e,n[1]=t,n[2]=r,n[3]=i,n},$e=function(e,t,r){var i=t[0],n=t[1],a=t[2],s=t[3],o=r[0],l=r[1],u=r[2],c=r[3];return e[0]=i*c+s*o+n*u-a*l,e[1]=n*c+s*l+a*o-i*u,e[2]=a*c+s*u+i*l-n*o,e[3]=s*c-i*o-n*l-a*u,e},et=function(e,t){var r=t[0],i=t[1],n=t[2],a=t[3],s=r*r+i*i+n*n+a*a;return s>0&&(s=1/Math.sqrt(s)),e[0]=r*s,e[1]=i*s,e[2]=n*s,e[3]=a*s,e},tt=(i.Ue(),i.al(1,0,0),i.al(0,1,0),Ke(),Ke(),h(),r(165));function rt(e){return(rt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function it(e,t){for(var r=0;r=1)){var A=o[T],C=o[T+1],w=o[T+2],R=E*E;R=3*R*R-4*R*E+1,R*=l[T+2]*a.getAlpha(A,C,w);var x=Math.pow(R,5)*v;R*=m,o[T]=A+M*R+_*x,o[T+1]=C+S*R+p*x,o[T+2]=w+k*R+g*x}}}}])&&Tt(t.prototype,r),o}(Te);function At(e){return(At="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function Ct(e,t){for(var r=0;r1&&(x=1);var P=Math.pow(1-x,b),D=1-(P*=i*u[S+2]*a.getAlpha(k,E,A));this._writeAlbedo&&(l[S]=l[S]*D+_*P,l[S+1]=l[S+1]*D+p*P,l[S+2]=l[S+2]*D+g*P),this._writeRoughness&&(u[S]=u[S]*D+h*P),this._writeMetalness&&(u[S+1]=u[S+1]*D+d*P)}}},{key:"paintAll",value:function(){var e=this.getMesh(),t=this.getUnmaskedVertices();if(0!==t.length){this.pushState(!0),this._main.getStateManager().pushVertices(t);for(var r=e.getColors(),i=e.getMaterials(),n=this._color,a=this._material[0],s=this._material[1],o=n[0],l=n[1],u=n[2],c=0,h=t.length;c0;){var s=i[--n],o=s._iFaces.length;if(o>e.MAX_FACES&&s._depth0&&a.push(s)}var u=a.length;for(r=0;rs&&(s=m),po&&(o=v),gl&&(l=y)}this.expandsAabbLoose(i,n,a,s,o,l)}},{key:"constructChildren",value:function(t){for(var r=this._aabbSplit,i=r[0],n=r[1],a=r[2],s=r[3],o=r[4],l=r[5],u=.5*(s-i),c=.5*(o-n),h=.5*(l-a),d=.5*(s+i),f=.5*(o+n),_=.5*(l+a),p=new e(this),g=new e(this),m=new e(this),v=new e(this),y=new e(this),b=new e(this),T=new e(this),M=new e(this),S=p._iFaces,k=g._iFaces,E=m._iFaces,A=v._iFaces,C=y._iFaces,w=b._iFaces,R=T._iFaces,x=M._iFaces,P=t.getFaceCenters(),D=this._iFaces,I=D.length,F=0;Fd?N>f?B>_?R.push(O):w.push(O):B>_?E.push(O):k.push(O):N>f?B>_?x.push(O):C.push(O):B>_?A.push(O):S.push(O)}p.setAabbSplit(i,n,a,d,f,_),g.setAabbSplit(i+u,n,a,d+u,f,_),m.setAabbSplit(d,f-c,_,s,o-c,l),v.setAabbSplit(i,n,a+h,d,f,_+h),y.setAabbSplit(i,n+c,a,d,f+c,_),b.setAabbSplit(d,f,_-h,s,o,l-h),T.setAabbSplit(d,f,_,s,o,l),M.setAabbSplit(d-u,f,_,s-u,o,l),this._children.length=0,this._children.push(p,g,m,v,y,b,T,M),D.length=0}},{key:"setAabbSplit",value:function(e,t,r,i,n,a){var s=this._aabbSplit;s[0]=e,s[1]=t,s[2]=r,s[3]=i,s[4]=n,s[5]=a}},{key:"setAabbLoose",value:function(e,t,r,i,n,a){var s=this._aabbLoose;s[0]=e,s[1]=t,s[2]=r,s[3]=i,s[4]=n,s[5]=a}},{key:"collectIntersectRay",value:function(t,r,i,n){var a=t[0],s=t[1],o=t[2],l=1/r[0],u=1/r[1],c=1/r[2],h=0,d=e.STACK;d[0]=this;for(var f=1;f>0;){var _=d[--f],p=_._aabbLoose,g=(p[0]-a)*l,m=(p[3]-a)*l,v=(p[1]-s)*u,y=(p[4]-s)*u,b=(p[2]-o)*c,T=(p[5]-o)*c,M=Math.max(Math.min(g,m),Math.min(v,y),Math.min(b,T)),S=Math.min(Math.max(g,m),Math.max(v,y),Math.max(b,T));if(!(S<0||M>S)){var k=_._children;if(8===k.length){for(var E=0;E<8;++E)d[f+E]=k[E];f+=8}else{n&&n.push(_);var A=_._iFaces;i.set(A,h),h+=A.length}}}return new Uint32Array(i.subarray(0,h))}},{key:"collectIntersectSphere",value:function(t,r,i,n){var a=t[0],s=t[1],o=t[2],l=0,u=e.STACK;u[0]=this;for(var c=1;c>0;){var h,d,f,_=u[--c],p=_._aabbLoose;if(!((h=p[0]>a?p[0]-a:p[3]s?p[1]-s:p[4]o?p[2]-o:p[5]r)){var g=_._children;if(8===g.length){for(var m=0;m<8;++m)u[c+m]=g[m];c+=8}else{n&&n.push(_);var v=_._iFaces;i.set(v,l),l+=v.length}}}return new Uint32Array(i.subarray(0,l))}},{key:"addFace",value:function(t,r,i,n,a,s,o,l,u,c){var h=e.STACK;h[0]=this;for(var d=1;d>0;){var f=h[--d],_=f._aabbSplit;if(!(l<=_[0]||u<=_[1]||c<=_[2]||l>_[3]||u>_[4]||c>_[5])){var p=f._aabbLoose;rp[3]&&(p[3]=a),s>p[4]&&(p[4]=s),o>p[5]&&(p[5]=o);var g=f._children;if(8!==g.length)return f._iFaces.push(t),f;for(var m=0;m<8;++m)h[d+m]=g[m];d+=8}}}},{key:"pruneIfPossible",value:function(){for(var e=this;e._parent;){var t=e._parent,r=t._children;if(0===r.length)return;for(var i=0;i<8;++i){var n=r[i];if(n._iFaces.length>0||8===n._children.length)return}r.length=0,e=t}}},{key:"expandsAabbLoose",value:function(e,t,r,i,n,a){for(var s=this;s;){var o=s._aabbLoose,l=!1;eo[3]&&(o[3]=i,l=!0),n>o[4]&&(o[4]=n,l=!0),a>o[5]&&(o[5]=a,l=!0),s=l?s._parent:null}}}])&&zt(t.prototype,r),e}();Xt.FLAG=0,Xt.MAX_DEPTH=8,Xt.MAX_FACES=100,function(){for(var e=1+7*Xt.MAX_DEPTH,t=Xt.STACK=new Array(e),r=0;rM?y>E?y:E:M>E?M:E,B=bS?b>A?b:A:S>A?S:A,Z=Tk?T>C?T:C:k>C?k:C;if(v){var W=n[m],j=n[m+1],z=n[m+2];WN&&(N=W),jU&&(U=j),zG&&(G=z),F+=(R=A-j)*I-(x=C-z)*D,O+=x*P-(w=E-W)*I,L+=w*D-R*P}t[h]=F,t[h+1]=O,t[h+2]=L,r[f]=V,r[f+1]=B,r[f+2]=Z,r[f+3]=N,r[f+4]=U,r[f+5]=G,i[h]=.5*(V+N),i[h+1]=.5*(B+U),i[h+2]=.5*(Z+G)}}},{key:"expandsFaces",value:function(e,t){var r=++o.Z.TAG_FLAG,i=e.length,n=this.getVerticesRingFaceStartCount(),a=this.getVerticesRingFace(),s=a instanceof Array?a:null,l=this.getFacesTagFlags(),u=this.getFaces(),c=i,h=new Uint32Array(o.Z.getMemory(4*this.getNbFaces()),0,this.getNbFaces());h.set(e);var d=0;for(d=0;d_&&(y=f+(s===p?0:2),(v=i[_])<=h?(t[y]=r,i[_]=++r):t[y]=v-1),s>p&&(y=f+(s===_?0:1),(v=i[p])<=h?(t[y]=r,i[p]=++r):t[y]=v-1),s>g&&(y=f+(s===_?2:1),(v=i[g])<=h?(t[y]=r,i[g]=++r):t[y]=v-1),t[f+3]=o.Z.TRI_INDEX):(s>_&&s!==g&&(y=f+(s===p?0:3),(v=i[_])<=h?(t[y]=r,i[_]=++r):t[y]=v-1),s>p&&s!==m&&(y=f+(s===_?0:1),(v=i[p])<=h?(t[y]=r,i[p]=++r):t[y]=v-1),s>g&&s!==_&&(y=f+(s===p?1:2),(v=i[g])<=h?(t[y]=r,i[g]=++r):t[y]=v-1),s>m&&s!==p&&(y=f+(s===_?3:2),(v=i[m])<=h?(t[y]=r,i[m]=++r):t[y]=v-1))}for(var b=this._meshData._edges=new Uint8ClampedArray(r),T=0,M=this.getNbFaces();T=n&&(t[s]=r[s]);var a=this.getNbVertices(),s=0,l=0,u=0,c=0,h=new Int32Array(a),d=new Float32Array(o.Z.getMemory(4*a*2),0,2*a),f=[],_=0,p=0;for(s=0;s0)h[u]=--_,f.push([g]),++p;else{var m=f[-c-1],v=m.length;for(l=0;l=0)){var T=f[-c-1],M=T.length,S=a+_;for(b[2*s]=S,b[2*s+1]=M,_+=M,l=0;l0)){var C=t[s],w=f[-c-1],R=w.length;for(l=0;la&&(a=c),hs&&(s=h),do&&(o=d)}return[r,i,n,a,s,o]}},{key:"computeOctree",value:function(){var e=this.computeAabb(),t=e[0],r=e[1],i=e[2],n=e[3],a=e[4],s=e[5],o=n-t,l=a-r,u=s-i,c=.2*Math.sqrt(o*o+l*l+u*u),h=1e-16;n-t_[3]||g>_[4]||m>_[5]){l[s++]=e[u];var v=f._iFaces;if(v.length>0){var y=v[v.length-1],b=i[c];v[b]=y,i[y]=b,v.pop()}}else f.expandsAabbLoose(r[h],r[h+1],r[h+2],r[h+3],r[h+4],r[h+5])}return new Uint32Array(l.subarray(0,s))}},{key:"updateOctreeAdd",value:function(e){for(var t=this.getFaceCenters(),r=this.getFaceBoxes(),i=this._meshData._facePosInLeaf,n=this._meshData._faceLeaf,a=e.length,s=this._meshData._octree,o=s._aabbSplit,l=o[0],u=o[1],c=o[2],h=o[3],d=o[4],f=o[5],_=0;_h||bd||Tf||MKt.MAX_FACES&&i._depth=0;){var A=[],C=E>=a?u[E-a]:E,w=f[2*C],R=w+f[2*C+1];for(e=w;e32&&(g[D]=m++),m-g[I]>32&&(g[I]=m++),m-g[F]>32&&(g[F]=m++),L&&(y[b++]=O,A.push(O),--p[O],m-g[O]>32&&(g[O]=m++))}}E=-1;var V=-1,N=A.length;for(e=0;eV&&(V=Z,E=B)}}if(-1===E){for(;b>0;){var W=y[--b];if(p[W]>0){E=W;break}}if(-1===E)for(;v0){E=v-1;break}}}i.set(M.subarray(0,4*n)),t&&r.set(S.subarray(0,4*n))}},{key:"optimizePreTransform",value:function(){var e=this.getVertices(),t=this.getColors(),r=this.getMaterials(),i=this.getNbVertices(),n=this.getFaces(),a=4*this.getNbFaces(),s=new Float32Array(3*i),l=new Float32Array(3*i),u=new Float32Array(3*i),c=new Uint32Array(i),h=0,d=0;for(d=0;d0&&this.setNbVertices(h),this.hasUV()){var v=this.getFacesTexCoord();for(d=0;d0&&(k[w]=C-m,k[w+1]=E[A+1]),S[w]=M[A],S[w+1]=M[A+1])}for(d=i;d1&&(k=1);var E=Math.pow(1-k,f);E*=_*a.getAlpha(v,y,b),l[m+2]=Math.min(Math.max(l[m+2]+E,0),1)}}},{key:"updateAndRenderMask",value:function(){var e=this.getMesh();e.updateDuplicateColorsAndMaterials(),e.updateDrawArrays(),this.updateRender()}},{key:"blur",value:function(){var e=this.getMesh(),t=this.getMaskedVertices();if(0!==t.length){t=e.expandsVertices(t,1),this.pushState(),this._main.getStateManager().pushVertices(t);var r=e.getMaterials(),i=t.length,n=new Float32Array(3*i);this.laplacianSmooth(t,n,r);for(var a=0;a.5?Math.min(s+.1,1):Math.max(s-1,0)}this.updateAndRenderMask()}}},{key:"clear",value:function(){var e=this.getMesh(),t=this.getMaskedVertices();if(0!==t.length){this.pushState(),this._main.getStateManager().pushVertices(t);for(var r=e.getMaterials(),i=0,n=t.length;i=r||s[p]>=1,T=a[3*g+2]>=r||s[g]>=1,M=a[3*m+2]>=r||s[m]>=1,S=!!y&&(a[3*v+2]>=r||s[v]>=1);c[f+=4*u]=m,c[f+1]=g,c[f+2]=p,c[f+3]=v,T&&(b&&(c[f=4*h++]=c[f+3]=g,c[f+1]=c[f+2]=p),M&&(c[f=4*h++]=c[f+3]=m,c[f+1]=c[f+2]=g)),y?S&&(b&&(c[f=4*h++]=c[f+3]=p,c[f+1]=c[f+2]=v),M&&(c[f=4*h++]=c[f+3]=v,c[f+1]=c[f+2]=m)):b&&M&&(c[f=4*h++]=c[f+3]=p,c[f+1]=c[f+2]=m)}}var k=new Uint32Array(c.subarray(0,4*h));return this.remapAndMirrorIndices(k,u,t),this._thickness>0&&this.invertFaces(k),k}},{key:"extractVertices",value:function(e){var t=this.getMesh(),r=t.getVertices(),n=t.getNormals(),a=t.getMatrix(),s=_(h(),a),o=e.length,l=new Float32Array(2*o*3),u=[0,0,0],c=[0,0,0],d=3*o,f=this._thickness,p=.01;f<0&&(p=-p);for(var g=0;gu&&(h.set(i,u),d.set(n,u),f.set(a,u));var _=new Uint32Array(4*e.getNbFaces()+r.length);return _.set(e.getFaces()),r.length>0&&_.set(r,4*e.getNbFaces()),Mr(e,h,_,d,f)}(e);if(t===e){var r=3*e.getNbVertices(),i=4*e.getNbFaces(),n=new Uint32Array(e.getFaces().subarray(0,i)),a=new Float32Array(e.getVertices().subarray(0,r)),s=new Float32Array(e.getColors().subarray(0,r)),l=new Float32Array(e.getMaterials().subarray(0,r));t=Mr(e,a,n,s,l)}return t}};var kr={BLOCK:!1},Er=function(){for(var e=new Uint32Array(24),t=0,r=0;r<8;++r)for(var i=1;i<=4;i<<=1){var n=r^i;r<=n&&(e[t++]=r,e[t++]=n)}return e}(),Ar=function(e){for(var t=new Uint32Array(256),r=0;r<256;++r){for(var i=0,n=0;n<24;n+=2)i|=!!(r&1<>1):0;t[r]=i}return t}(Er),Cr=function(e,t,r,i,n,a){for(var s=e.colorField,o=e.materialField,l=e.distanceField,u=0,c=0,h=0,d=0,f=0,_=0,p=0,g=0,m=0,v=r[0],y=r[0]*r[1],b=0;b<2;++b)for(var T=0;T<2;++T)for(var M=0;M<2;++M){var S=i+M+T*v+b*y,k=3*S,E=l[S];t[m]=E,g|=E<0?1<0&&(p=1/p),n.push(u*p,c*p,h*p),a.push(d*p,f*p,_*p)),g},wr=[0,0,0],Rr=function(e,t,r,i,n){wr[0]=wr[1]=wr[2]=0;for(var a=0,s=0;s<12;++s)if(e&1<0?1<0&&(p=1/p),n[0]=u*p,n[1]=c*p,n[2]=h*p,a[0]=d*p,a[1]=f*p,a[2]=_*p),g};Dr.computeSurface=function(e){var t=e.dims,r=new Map,i=[],n=[],a=[],s=[],l=0,u=new Int32Array(3),c=new Float32Array(8),h=new Array(12),d=new Float32Array(3),f=new Float32Array(3),_=new Float32Array(3);for(u[2]=0;u[2]1e-6&&(S=T/M);for(var k=0;k<3;++k)d[k]=u[k]+y[k]+S*(b[k]-y[k]);var E=d[0]+"+"+d[1]+"+"+d[2],A=r.get(E);A>=0?h[m]=A:(h[m]=i.length/3,r.set(E,h[m]),i.push(d[0],d[1],d[2]),n.push(f[0],f[1],f[2]),a.push(_[0],_[1],_[2]))}for(var C=Fr[p],w=0;wV?F>U?F:U:V>U?V:U,J=ON?O>Z?O:Z:N>Z?N:Z,$=LB?L>G?L:G:B>G?B:G,te=S[0]=V-F,re=S[1]=N-O,ie=S[2]=B-L,ne=k[0]=U-F,ae=k[1]=Z-O,se=k[2]=G-L,oe=te*te+re*re+ie*ie,le=te*ne+re*ae+ie*se,ue=ne*ne+ae*ae+se*se,ce=Math.floor((Y-s)*a),he=Math.floor((J-o)*a),de=Math.floor(($-l)*a),fe=Math.ceil((q-s)*a),_e=Math.ceil((Q-o)*a),pe=Math.ceil((ee-l)*a),ge=de;ge<=pe;++ge)for(var me=he;me<=_e;++me)for(var ve=ce;ve<=fe;++ve){var ye=s+ve*i,be=o+me*i,Te=l+ge*i,Me=ve+me*u+ge*c;E[0]=ye,E[1]=be,E[2]=Te;var Se=tt.Z.distance2PointTriangleEdges(E,S,k,b,oe,le,ue,A);if((Se=Math.sqrt(Se))i))for(var Ee=0;Ee<3;++Ee){var Ae=A[Ee]-E[Ee];if(!(Ae<0||Ae>i)){var Ce=3*Me+Ee;if(1!==d[Ce]){var we=tt.Z.intersectionRayTriangleEdges(E,C[Ee],S,k,b);we<0||we>i||(d[Ce]=1)}}}}},Zr=function(e,t,r,i,n){var a=new sr(e.getGL());return a.setID(e.getID()),a.setFaces(t),a.setVertices(r),i&&a.setColors(i),n&&a.setMaterials(n),a.setRenderData(e.getRenderData()),a.init(),a.initRender(),a};Br.remesh=function(e,t,r){console.time("remesh total"),console.time("1. prepareMeshes");var n=function(e){for(var t=[1/0,1/0,1/0,-1/0,-1/0,-1/0],r=[0,0,0],n=0,a=e.length;nt[3]&&(t[3]=f),_>t[4]&&(t[4]=_),p>t[5]&&(t[5]=p)}}return t}(e=e.slice());console.timeEnd("1. prepareMeshes"),console.time("2. voxelization");for(var a,u=function(e){for(var t=Math.max(e[3]-e[0],e[4]-e[1],e[5]-e[2])/Br.RESOLUTION,r=1.51*t,i=1.51*t,n=[e[0]-r,e[1]-r,e[2]-r],a=[e[3]+i,e[4]+i,e[5]+i],s=Math.ceil((a[0]-n[0])/t),l=Math.ceil((a[1]-n[1])/t),u=Math.ceil((a[2]-n[2])/t),c=s*l*u,h=o.Z.getMemory(31*c),d=new Float32Array(h,0,c),f=new Float32Array(h,4*c,3*c),_=new Float32Array(h,16*c,3*c),p=new Uint8Array(h,28*c,3*c),g=0;g0;){var g=u[--c];if(s[g]=o||p<0||1!==l[p]&&s[p]!==1/0&&0===a[3*(m>=0?g:p)+d[_]]&&(l[p]=1,u[c++]=p)}else for(_=0;_=o||p<0||1!==l[p]&&(l[p]=1,u[c++]=p)}for(var v=0;v0&&void 0!==arguments[0]?arguments[0]:-.5,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:-.5,i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:0,a=arguments.length>5&&void 0!==arguments[5]?arguments[5]:0,s=arguments.length>6&&void 0!==arguments[6]?arguments[6]:0,o=arguments.length>7&&void 0!==arguments[7]?arguments[7]:0,l=arguments.length>8&&void 0!==arguments[8]?arguments[8]:1,u=new Float32Array(4);u[0]=0,u[1]=1,u[2]=2,u[3]=3;var c=new Float32Array(12);return c[0]=e,c[1]=t,c[2]=r,c[3]=e+i,c[4]=t+n,c[5]=r+a,c[6]=e+i+s,c[7]=t+n+o,c[8]=r+a+l,c[9]=e+s,c[10]=t+o,c[11]=r+l,{faces:u,vertices:c}},zr=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:1,t=new Float32Array(24);t[1]=t[2]=t[4]=t[6]=t[7]=t[9]=t[10]=t[11]=t[14]=t[18]=t[21]=t[23]=.5*-e,t[0]=t[3]=t[5]=t[8]=t[12]=t[13]=t[15]=t[16]=t[17]=t[19]=t[20]=t[22]=.5*e;var r=new Float32Array(28);r[0]=r[6]=r[8]=r[10]=r[11]=r[13]=r[16]=r[23]=r[25]=.5,r[1]=r[3]=1,r[2]=r[4]=r[9]=r[12]=r[14]=r[15]=r[18]=.25,r[5]=r[7]=r[21]=r[24]=r[26]=r[27]=.75,r[17]=r[19]=r[20]=r[22]=0;var i=new Uint32Array(24),n=new Uint32Array(24);return i[0]=i[8]=i[21]=n[0]=0,i[1]=i[11]=i[12]=n[1]=1,i[2]=i[15]=i[16]=n[2]=n[15]=n[16]=2,i[3]=i[19]=i[22]=n[3]=n[19]=n[22]=3,i[4]=i[9]=i[20]=n[4]=n[9]=4,i[7]=i[10]=i[13]=n[5]=n[18]=n[23]=5,i[6]=i[14]=i[17]=n[6]=n[14]=n[17]=6,i[5]=i[18]=i[23]=n[7]=n[10]=7,n[8]=8,n[11]=9,n[12]=10,n[13]=11,n[20]=12,n[21]=13,{vertices:t,uv:r,faces:i,facesUV:n}},Xr=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:.5,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:.5,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:2,i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:64,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:64,a=!(arguments.length>5&&void 0!==arguments[5])||arguments[5],s=!(arguments.length>6&&void 0!==arguments[6])||arguments[6],l=a&&0===e,u=s&&0===t,c=.5*r,h=(n+1)*i,d=n*i;a&&(h+=1,d+=i),s&&(h+=1,d+=i),(l||u)&&(h-=i,d-=i);var f,_=new Float32Array(3*h),p=new Uint32Array(4*d),g=0,m=0,v=0,y=0,b=l?1:0,T=u?n-1:n;for(v=b;v<=T;v++){var M=v/n,S=M*(t-e)+e;for(y=0;y0&&void 0!==arguments[0]?arguments[0]:.5,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:.1,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:2*Math.PI,i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:32,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:128,a=2*Math.PI-r<.01,s=i*n,l=s;a||(s+=2,l+=i);var u=a?n:n-1,c=new Float32Array(3*s),h=new Uint32Array(4*l),d=0,f=0,_=0,p=0;for(_=0;_0&&void 0!==arguments[0]?arguments[0]:-.5,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:-.5,i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:0,a=arguments.length>5&&void 0!==arguments[5]?arguments[5]:0,s=arguments.length>6&&void 0!==arguments[6]?arguments[6]:0,o=arguments.length>7&&void 0!==arguments[7]?arguments[7]:0,l=arguments.length>8&&void 0!==arguments[8]?arguments[8]:1,u=arguments.length>9&&void 0!==arguments[9]?arguments[9]:20,c=arguments.length>10&&void 0!==arguments[10]?arguments[10]:u;u+=2,c+=2;var h=new Float32Array(2*(u+c)*3),d=0,f=0,_=i/(u-1),p=n/(u-1),g=a/(u-1),m=e+i+s,v=t+n+o,y=r+a+l;for(d=0;d1&&void 0!==arguments[1]?arguments[1]:.5,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:2,i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:5,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:.2,a=arguments.length>5&&void 0!==arguments[5]?arguments[5]:4,s=arguments.length>6&&void 0!==arguments[6]?arguments[6]:1,o=Yr(null,Xr(t,t,r,a,s)),l=Yr(null,Xr(0,t*i,r*n,a,s));l.getMatrix()[13]=.5*r;var u={vertices:null,faces:null};return Gr.mergeArrays([o,l],u),Yr(e,u)},Wr.createLine2D=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0,i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:0,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:0,a=Yr(e,{vertices:new Float32Array([t,r,0,i,n,0])});return e&&(a.setMode(e.LINES),a.setUseDrawArrays(!0),a.setAlreadyDrawArrays()),a};const Jr=Wr;function Qr(e,t){for(var r=0;r1&&void 0!==arguments[1]?arguments[1]:-1;return{_finalMatrix:l.Ue(),_baseMatrix:l.Ue(),_color:i.Ue(),_colorSelect:i.al(1,1,0),_drawGeo:null,_pickGeo:null,_isSelected:!1,_type:e,_nbAxis:t,_lastInter:[0,0,0],updateMatrix:function(){l.JG(this._drawGeo.getMatrix(),this._finalMatrix),l.JG(this._pickGeo.getMatrix(),this._finalMatrix)},updateFinalMatrix:function(e){l.dC(this._finalMatrix,e,this._baseMatrix)}}},oi=1024,li=2048,ui=4096,ci=8192,hi=15360;const di=function(){function e(t){!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),this._main=t,this._gl=t._gl,this._activatedType=e.TRANS_XYZ|e.ROT_XYZ|e.PLANE_XYZ|e.SCALE_XYZW|e.ROT_W,this._transX=si(e.TRANS_X,0),this._transY=si(e.TRANS_Y,1),this._transZ=si(e.TRANS_Z,2),this._planeX=si(e.PLANE_X,0),this._planeY=si(e.PLANE_Y,1),this._planeZ=si(e.PLANE_Z,2),this._scaleX=si(e.SCALE_X,0),this._scaleY=si(e.SCALE_Y,1),this._scaleZ=si(e.SCALE_Z,2),this._scaleW=si(e.SCALE_W),this._rotX=si(e.ROT_X,0),this._rotY=si(e.ROT_Y,1),this._rotZ=si(e.ROT_Z,2),this._rotW=si(e.ROT_W),this._lineHelper=Jr.createLine2D(this._gl),this._lineHelper.setShaderType(s.Z.Shader.FLAT),this._lastDistToEye=0,this._isEditing=!1,this._selected=null,this._pickables=[],this._editLineOrigin=[0,0,0],this._editLineDirection=[0,0,0],this._editOffset=[0,0,0],this._editLocal=[],this._editTrans=l.Ue(),this._editScaleRot=[],this._editLocalInv=[],this._editTransInv=l.Ue(),this._editScaleRotInv=[],this._initTranslate(),this._initRotate(),this._initScale(),this._initPickables()}var t,r,n;return t=e,n=[{key:"TRANS_X",get:function(){return 1}},{key:"TRANS_Y",get:function(){return 2}},{key:"TRANS_Z",get:function(){return 4}},{key:"ROT_X",get:function(){return 8}},{key:"ROT_Y",get:function(){return 16}},{key:"ROT_Z",get:function(){return 32}},{key:"ROT_W",get:function(){return 64}},{key:"PLANE_X",get:function(){return 128}},{key:"PLANE_Y",get:function(){return 256}},{key:"PLANE_Z",get:function(){return 512}},{key:"SCALE_X",get:function(){return oi}},{key:"SCALE_Y",get:function(){return li}},{key:"SCALE_Z",get:function(){return ui}},{key:"SCALE_W",get:function(){return ci}},{key:"TRANS_XYZ",get:function(){return 7}},{key:"ROT_XYZ",get:function(){return 56}},{key:"PLANE_XYZ",get:function(){return 896}},{key:"SCALE_XYZW",get:function(){return hi}}],(r=[{key:"setActivatedType",value:function(e){this._activatedType=e,this._initPickables()}},{key:"_initPickables",value:function(){var e=this._pickables;e.length=0;var t=this._activatedType;1&t&&e.push(this._transX._pickGeo),2&t&&e.push(this._transY._pickGeo),4&t&&e.push(this._transZ._pickGeo),128&t&&e.push(this._planeX._pickGeo),256&t&&e.push(this._planeY._pickGeo),512&t&&e.push(this._planeZ._pickGeo),8&t&&e.push(this._rotX._pickGeo),16&t&&e.push(this._rotY._pickGeo),32&t&&e.push(this._rotZ._pickGeo),t&oi&&e.push(this._scaleX._pickGeo),t&li&&e.push(this._scaleY._pickGeo),t&ui&&e.push(this._scaleZ._pickGeo),t&ci&&e.push(this._scaleW._pickGeo)}},{key:"_createArrow",value:function(e,t,r){var n=e._baseMatrix;l.U1(n,n,.5*Math.PI,t),l.Iu(n,n,[0,1.25,0]),i.JG(e._color,r),e._pickGeo=Jr.createArrow(this._gl,.1,2.5,6*.4),e._pickGeo._gizmo=e,e._drawGeo=Jr.createArrow(this._gl,ni,2.5,6,.25),e._drawGeo.setShaderType(s.Z.Shader.FLAT)}},{key:"_createPlane",value:function(e,t,r,n,a,o,l,u){i.JG(e._color,t),e._pickGeo=Jr.createPlane(this._gl,0,0,0,r,n,a,o,l,u),e._pickGeo._gizmo=e,e._drawGeo=Jr.createPlane(this._gl,0,0,0,r,n,a,o,l,u),e._drawGeo.setShaderType(s.Z.Shader.FLAT)}},{key:"_initTranslate",value:function(){var e=[0,0,0];this._createArrow(this._transX,i.t8(e,0,0,-1),$r),this._createArrow(this._transY,i.t8(e,0,1,0),ei),this._createArrow(this._transZ,i.t8(e,1,0,0),ti);var t=.5;this._createPlane(this._planeX,$r,0,t,0,0,0,t),this._createPlane(this._planeY,ei,t,0,0,0,0,t),this._createPlane(this._planeZ,ti,t,0,0,0,t,0)}},{key:"_createCircle",value:function(e,t,r){var n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1.5,a=arguments.length>4&&void 0!==arguments[4]?arguments[4]:1;i.JG(e._color,r),e._pickGeo=Jr.createTorus(this._gl,n,.1*a,t,6,64),e._pickGeo._gizmo=e,e._drawGeo=Jr.createTorus(this._gl,n,ni*a,t,6,64),e._drawGeo.setShaderType(s.Z.Shader.FLAT)}},{key:"_initRotate",value:function(){this._createCircle(this._rotX,Math.PI,$r),this._createCircle(this._rotY,Math.PI,ei),this._createCircle(this._rotZ,Math.PI,ti),this._createCircle(this._rotW,2*Math.PI,ri)}},{key:"_createCube",value:function(e,t,r){var n=e._baseMatrix;l.U1(n,n,.5*Math.PI,t),l.Iu(n,n,[0,1.5,0]),i.JG(e._color,r),e._pickGeo=Jr.createCube(this._gl,.42),e._pickGeo._gizmo=e,e._drawGeo=Jr.createCube(this._gl,.35),e._drawGeo.setShaderType(s.Z.Shader.FLAT)}},{key:"_initScale",value:function(){var e=[0,0,0];this._createCube(this._scaleX,i.t8(e,0,0,-1),$r),this._createCube(this._scaleY,i.t8(e,0,1,0),ei),this._createCube(this._scaleZ,i.t8(e,1,0,0),ti),this._createCircle(this._scaleW,2*Math.PI,ii,1.9500000000000002,2)}},{key:"_updateArcRotation",value:function(e){ai[0]=e[2],ai[1]=0,ai[2]=-e[0],ai[3]=1+e[1],et(ai,ai),l.en(this._rotW._baseMatrix,ai),l.en(this._scaleW._baseMatrix,ai),function(e,t,r){r*=.5;var i=t[0],n=t[1],a=t[2],s=t[3],o=Math.sin(r),l=Math.cos(r);e[0]=i*l+n*o,e[1]=n*l-i*o,e[2]=a*l+s*o,e[3]=s*l-a*o}(ai,He(ai),.5*Math.PI),Je(ai,ai,Math.atan2(-e[1],-e[2])),l.en(this._rotX._baseMatrix,ai),Je(ai,He(ai),Math.atan2(-e[0],-e[2])),l.en(this._rotY._baseMatrix,ai),qe(ai,He(ai),.5*Math.PI),Je(ai,ai,Math.atan2(-e[0],e[1])),l.en(this._rotZ._baseMatrix,ai)}},{key:"_computeCenterGizmo",value:function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[0,0,0],t=this._main.getSelectedMeshes(),r=[0,0,0],n=[0,0,0],a=0;a0&&(this._main.getStateManager().getCurrentState().squash=!0);var r=this.getUnmaskedVertices();this._main.getStateManager().pushVertices(r),this.applyEditMatrix(r),0!==r.length&&this.updateMeshBuffers()}this._forceToolMesh=null}}},{key:"applyEditMatrix",value:function(e){for(var t=this.getMesh(),r=t.getEditMatrix(),n=t.getMaterials(),a=t.getVertices(),s=[0,0,0],o=0,u=e.length;oT.radius+o)){var E=this._distanceToFeature(g,m,v,t[b],T.positions);Eo)){var A=c*(1-y/o);i[p]+=n[p]*A,i[p+1]+=n[p+1]*A,i[p+2]+=n[p+2]*A,f.push(_)}}if(f.length){var C=new Uint32Array(f),w=r.getFacesFromVertices?r.getFacesFromVertices(C):null,R=this._main.getStateManager&&this._main.getStateManager();R&&R.pushVertices&&R.pushVertices(C),R&&R.pushFaces&&w&&R.pushFaces(w),r.updateGeometry(w||void 0,C),r.updateGeometryBuffers&&r.updateGeometryBuffers(),this._debugLog("applySolidFeature:done",{featureId:e.id,type:e.type,affected:f.length,influence:o,amount:c}),this._main.render()}else this._debugLog("applySolidFeature:noAffected",{feature:e,influence:o})}else this._debugLog("applySolidFeature:noGeometry",{})}else this._debugLog("applySolidFeature:noMesh",{meshId:t[0].meshId})}else this._debugLog("applySolidFeature:missingSources",{feature:e})}},{key:"_getFeaturesByIds",value:function(e){for(var t=[],r=this._features,i=0,n=r.length;io&&(o=d)}return{center:[i,n,a],radius:o,positions:r}}},{key:"_distanceToFeature",value:function(e,t,r,i,n){if(!n||0===n.length)return 1/0;if(1===n.length){var a=e-n[0][0],s=t-n[0][1],o=r-n[0][2];return Math.sqrt(a*a+s*s+o*o)}for(var l=1/0,u=n.length,c=i.closed?u:u-1,h=0;h0?(l*a+u*s+c*o)/h:0;d=Math.max(0,Math.min(1,d));var f=e-(i[0]+a*d),_=t-(i[1]+s*d),p=r-(i[2]+o*d);return Math.sqrt(f*f+_*_+p*p)}},{key:"_estimateMeshRadius",value:function(e){var t=e.getVertices(),r=e.getNbVertices?e.getNbVertices():t.length/3;if(!t||0===r)return 1;for(var i=1/0,n=1/0,a=1/0,s=-1/0,o=-1/0,l=-1/0,u=0;us&&(s=h),d>o&&(o=d),f>l&&(l=f)}var _=s-i,p=o-n,g=l-a;return.5*Math.sqrt(_*_+p*p+g*g)}},{key:"_debugLog",value:function(e,t){"undefined"!=typeof window&&void 0===window.DEBUG_PADS_TRIMS&&(window.DEBUG_PADS_TRIMS=!0),"undefined"!=typeof console&&console.log("[Pen]",e,t)}}])&&Ei(t.prototype,r),s}(Te);Ri.MODE_POINTS=0,Ri.MODE_STROKE=1,Ri.COLOR_DEFAULT=[.2,.8,.2],Ri.COLOR_SELECTED=[1,.8,.2],Ri.POINT_SIZE=6,Ri.POINT_SIZE_ACTIVE=8;const xi=Ri;var Pi=[];Pi[s.Z.Tools.BRUSH]=Ie,Pi[s.Z.Tools.INFLATE]=Be,Pi[s.Z.Tools.TWIST]=ot,Pi[s.Z.Tools.SMOOTH]=ft,Pi[s.Z.Tools.FLATTEN]=Ce,Pi[s.Z.Tools.PINCH]=yt,Pi[s.Z.Tools.CREASE]=Et,Pi[s.Z.Tools.DRAG]=Pt,Pi[s.Z.Tools.PAINT]=Nt,Pi[s.Z.Tools.MOVE]=jt,Pi[s.Z.Tools.MASKING]=dr,Pi[s.Z.Tools.LOCALSCALE]=vr,Pi[s.Z.Tools.TRANSFORM]=vi,Pi[s.Z.Tools.PEN]=xi,Pi[s.Z.Tools.BRUSH].uiName="sculptBrush",Pi[s.Z.Tools.INFLATE].uiName="sculptInflate",Pi[s.Z.Tools.TWIST].uiName="sculptTwist",Pi[s.Z.Tools.SMOOTH].uiName="sculptSmooth",Pi[s.Z.Tools.FLATTEN].uiName="sculptFlatten",Pi[s.Z.Tools.PINCH].uiName="sculptPinch",Pi[s.Z.Tools.CREASE].uiName="sculptCrease",Pi[s.Z.Tools.DRAG].uiName="sculptDrag",Pi[s.Z.Tools.PAINT].uiName="sculptPaint",Pi[s.Z.Tools.MOVE].uiName="sculptMove",Pi[s.Z.Tools.MASKING].uiName="sculptMasking",Pi[s.Z.Tools.LOCALSCALE].uiName="sculptLocalScale",Pi[s.Z.Tools.TRANSFORM].uiName="sculptTransform",Pi[s.Z.Tools.PEN].uiName="sculptPen";const Di=Pi;function Ii(e,t){for(var r=0;r=3||Li.LINEAR){if(-1!==p)return g;h[i]=g+1,l[m]=.5*(n[d]+n[f]),l[m+1]=.5*(n[d+1]+n[f+1]),l[m+2]=.5*(n[d+2]+n[f+2]),u[m]=.5*(a[d]+a[f]),u[m+1]=.5*(a[d+1]+a[f+1]),u[m+2]=.5*(a[d+2]+a[f+2]),c[m]=.5*(s[d]+s[f]),c[m+1]=.5*(s[d+1]+s[f+1]),c[m+2]=.5*(s[d+2]+s[f+2])}else-1===p?(h[i]=g+1,l[m]=.125*n[_]+.375*(n[d]+n[f]),l[m+1]=.125*n[_+1]+.375*(n[d+1]+n[f+1]),l[m+2]=.125*n[_+2]+.375*(n[d+2]+n[f+2]),u[m]=.125*a[_]+.375*(a[d]+a[f]),u[m+1]=.125*a[_+1]+.375*(a[d+1]+a[f+1]),u[m+2]=.125*a[_+2]+.375*(a[d+2]+a[f+2]),c[m]=.125*s[_]+.375*(s[d]+s[f]),c[m+1]=.125*s[_+1]+.375*(s[d+1]+s[f+1]),c[m+2]=.125*s[_+2]+.375*(s[d+2]+s[f+2])):(l[m]+=.125*n[_],l[m+1]+=.125*n[_+1],l[m+2]+=.125*n[_+2],u[m]+=.125*a[_],u[m+1]+=.125*a[_+1],u[m+2]+=.125*a[_+2],c[m]+=.125*s[_],c[m+1]+=.125*s[_+1],c[m+2]+=.125*s[_+2]);return g}},{key:"computeQuadEdgeVertex",value:function(e,t,r,i,n){var a=this._vAr,s=this._cAr,o=this._mAr,l=this._eAr,u=this._vArOut,c=this._cArOut,h=this._mArOut,d=this._tagEdges,f=3*e,_=3*t,p=3*r,g=3*i,m=d[n]-1,v=-1===m?this._nbVertices++:m,y=3*v,b=l[n];if(1===b||b>=3||Li.LINEAR){if(-1!==m)return v;d[n]=v+1,u[y]=.5*(a[f]+a[_]),u[y+1]=.5*(a[f+1]+a[_+1]),u[y+2]=.5*(a[f+2]+a[_+2]),c[y]=.5*(s[f]+s[_]),c[y+1]=.5*(s[f+1]+s[_+1]),c[y+2]=.5*(s[f+2]+s[_+2]),h[y]=.5*(o[f]+o[_]),h[y+1]=.5*(o[f+1]+o[_+1]),h[y+2]=.5*(o[f+2]+o[_+2])}else-1===m?(d[n]=v+1,u[y]=.0625*(a[p]+a[g])+.375*(a[f]+a[_]),u[y+1]=.0625*(a[p+1]+a[g+1])+.375*(a[f+1]+a[_+1]),u[y+2]=.0625*(a[p+2]+a[g+2])+.375*(a[f+2]+a[_+2]),c[y]=.0625*(s[p]+s[g])+.375*(s[f]+s[_]),c[y+1]=.0625*(s[p+1]+s[g+1])+.375*(s[f+1]+s[_+1]),c[y+2]=.0625*(s[p+2]+s[g+2])+.375*(s[f+2]+s[_+2]),h[y]=.0625*(o[p]+o[g])+.375*(o[f]+o[_]),h[y+1]=.0625*(o[p+1]+o[g+1])+.375*(o[f+1]+o[_+1]),h[y+2]=.0625*(o[p+2]+o[g+2])+.375*(o[f+2]+o[_+2])):(u[y]+=.0625*(a[p]+a[g]),u[y+1]+=.0625*(a[p+1]+a[g+1]),u[y+2]+=.0625*(a[p+2]+a[g+2]),c[y]+=.0625*(s[p]+s[g]),c[y+1]+=.0625*(s[p+1]+s[g+1]),c[y+2]+=.0625*(s[p+2]+s[g+2]),h[y]+=.0625*(o[p]+o[g]),h[y+1]+=.0625*(o[p+1]+o[g+1]),h[y+2]+=.0625*(o[p+2]+o[g+2]));return v}},{key:"computeFaceVertex",value:function(e,t,r,i){var n=3*e,a=3*t,s=3*r,o=3*i,l=this._vAr,u=this._cAr,c=this._mAr,h=this._vArOut,d=this._cArOut,f=this._mArOut,_=this._nbVertices++,p=3*_;return h[p]=.25*(l[n]+l[a]+l[s]+l[o]),h[p+1]=.25*(l[n+1]+l[a+1]+l[s+1]+l[o+1]),h[p+2]=.25*(l[n+2]+l[a+2]+l[s+2]+l[o+2]),d[p]=.25*(u[n]+u[a]+u[s]+u[o]),d[p+1]=.25*(u[n+1]+u[a+1]+u[s+1]+u[o+1]),d[p+2]=.25*(u[n+2]+u[a+2]+u[s+2]+u[o+2]),f[p]=.25*(c[n]+c[a]+c[s]+c[o]),f[p+1]=.25*(c[n+1]+c[a+1]+c[s+1]+c[o+1]),f[p+2]=.25*(c[n+2]+c[a+2]+c[s+2]+c[o+2]),_}}])&&Oi(t.prototype,r),e}(),Ni=function(e,t,r,i){var n=e.getNbVertices();r.set(e.getColors().subarray(0,3*n)),i.set(e.getMaterials().subarray(0,3*n));for(var a=e.getVertices(),s=e.getFaces(),l=e.getEdges(),u=e.getFaceEdges(),c=e.getVerticesOnEdge(),h=e.getVerticesRingVertStartCount(),d=e.getVerticesRingVert(),f=e.getVerticesRingFaceStartCount(),_=e.getVerticesRingFace(),p=e.hasOnlyTriangles(),g=0;g0&&(n[2*h]=_+f)}c.set(u),c.set(u.subarray(2*d),2*(d+f));var p=f+e.getNbTexCoords(),g=e.getFaceEdges(),m=new Uint32Array(r.length),v=new Uint32Array(r.length);for(d=a.length,h=0;hM?T:M,A=T=l&&(p+=u),g>=l&&(g+=u),m>=l&&(m+=u),v!==o.Z.TRI_INDEX&&v>=l&&(v+=u);var y=s[_],b=r[y]-1;b<0?b=c[2*(-b-1)]:c[2*b]>0&&(r[y]=-b);var T=r[y=s[_+1]]-1;T<0?T=c[2*(-T-1)]:c[2*T]>0&&(r[y]=-T);var M=r[y=s[_+2]]-1;if(M<0?M=c[2*(-M-1)]:c[2*M]>0&&(r[y]=-M),_*=4,v!==o.Z.TRI_INDEX){var S=r[y=s[4*d+3]]-1;S<0?S=c[2*(-S-1)]:c[2*S]>0&&(r[y]=-S),a[_+1]=a[_+4]=b,a[_+6]=a[_+9]=T,a[_+11]=a[_+14]=M,a[_+3]=a[_+12]=S,a[_+2]=a[_+7]=a[_+8]=a[_+13]=n[_+2],a[_]=p,a[_+5]=g,a[_+10]=m,a[_+15]=v;var k=2*n[_+2];h[k]=.25*(h[2*p]+h[2*g]+h[2*m]+h[2*v]),h[k+1]=.25*(h[2*p+1]+h[2*g+1]+h[2*m+1]+h[2*v+1])}else a[_]=a[_+5]=a[_+8]=b,a[_+1]=a[_+10]=a[_+12]=T,a[_+2]=a[_+6]=a[_+14]=M,a[_+3]=a[_+7]=a[_+11]=a[_+15]=o.Z.TRI_INDEX,a[_+4]=p,a[_+9]=g,a[_+13]=m}t.setFacesTexCoord(a)}(e,t,r)}(e,t,i),t.allocateArrays()},Li.partialSubdivision=function(e,t,r,i){Ni(e,t,r,i),Bi(e,t,r,i)};const Ui=Li;var Zi={importOBJ:function(e,t){for(var r=[],i=[],n=[],a=[],s=[],l=[],u=[],c=[],h=[],d=0,f=0,_=0,p=0,g=e.split("\n"),m=[],v=1/255,y=g.length,b=0;b0&&h.push(L,V,N,O?B:o.Z.TRI_INDEX)}}else if("#"===S){if("M"!==T[1])continue;if(T.startsWith("#MRGB "))for(var j=(m=T.split(/\s+/))[1],z=2,X=j.length;z>16)*v,(K>>8&255)*v,(255&K)*v)}else if(T.startsWith("#MAT "))for(var H=(m=T.split(/\s+/))[1],Y=0,q=H.length;Y>16)*v,(J>>8&255)*v,(255&J)*v)}}else T.startsWith("o ")&&(r.length>0&&(Zi.initMeshOBJ(r[r.length-1],i,c,n,s,u,h,a,l),d=_,f=p),r.push(new sr(t)))}}return 0===r.length&&(r[0]=new sr(t)),Zi.initMeshOBJ(r[r.length-1],i,c,n,s,u,h,a,l),r},initMeshOBJ:function(e,t,r,i,n,a,s,o,l){e.setVertices(new Float32Array(t)),e.setFaces(new Uint32Array(r)),o.length===t.length?e.setColors(new Float32Array(o)):i.length===t.length&&e.setColors(new Float32Array(i)),l.length===t.length?e.setMaterials(new Float32Array(l)):n.length===t.length&&e.setMaterials(new Float32Array(n)),a.length>0&&s.length===r.length&&e.initTexCoordsDataFromOBJData(a,s),t.length=r.length=0,o.length=i.length=0,l.length=n.length=0,a.length=s.length=0}};const Gi=Zi;var Wi={VERSION:3,exportSGL:function(e,t){var r,i=e.length,n=4*(9+31*i),a=0;for(a=0;a0&&o.set(r.getColors().subarray(0,3*h),u),u+=3*d;var f=r.getMaterials()?h:0;l[u++]=f,f>0&&o.set(r.getMaterials().subarray(0,3*h),u),u+=3*f;var _=r.getNbFaces();l[u++]=_,l.set(r.getFaces().subarray(0,4*_),u),u+=4*_;var p=r.hasUV(),g=r.getNbTexCoords();l[u++]=p?g:0,p&&(o.set(r.getTexCoords().subarray(0,2*g),u),u+=2*g),l[u++]=p?_:0,p&&(l.set(r.getFacesTexCoord().subarray(0,4*_),u),u+=4*_)}var m=new DataView(s,0,4*u);return new Blob([m])}};const ji=Wi;var zi={},Xi=function(e){for(var t=new Uint32Array(e),r=t.length/4,i=0;iji.VERSION)return[];if(o>=2){r._showGrid=n[s++],x.showSymmetryLine=n[s++],r._showContour=n[s++];var l=r.getCamera();l.setProjectionType(n[s++]),l.setMode(n[s++]),l.setFov(i[s++]),l.setUsePivot(n[s++])}for(var u=n[s++],c=new Array(u),h=0;h=2){var f=d.getRenderData();f._shaderType=n[s++],f._matcap=n[s++],f._showWireframe=n[s++],f._flatShading=n[s++],f._alpha=i[s++]}d.getCenter().set(i.subarray(s,s+3)),s+=3,d.getMatrix().set(i.subarray(s,s+16)),s+=16,s++;var _=n[s++];d.setVertices(i.subarray(s,s+3*_)),s+=3*_,(_=n[s++])>0&&d.setColors(i.subarray(s,s+3*_)),s+=3*_,(_=n[s++])>0&&d.setMaterials(i.subarray(s,s+3*_)),s+=3*_,_=n[s++],o<=2?d.setFaces(Xi(a.subarray(s,s+4*_))):d.setFaces(n.subarray(s,s+4*_)),s+=4*_;var p=null;(_=n[s++])&&(p=i.subarray(s,s+2*_)),s+=2*_;var g=null;(_=n[s++])&&(g=o<=2?Xi(a.subarray(s,s+4*_)):n.subarray(s,s+4*_)),s+=4*_,p&&g&&d.initTexCoordsDataFromOBJData(p,g)}return c};const Ki=zi;var Hi={},Yi=function(e){switch(e){case"uchar":case"char":case"int8":case"uint8":return 1;case"ushort":case"short":case"int16":case"uint16":return 2;case"uint":case"int":case"float":case"int32":case"uint32":case"float32":return 4;case"double":case"float64":return 8;default:return 0}},qi=function(e,t){var r=t?1/255:1;switch(e){case"char":case"uchar":case"short":case"ushort":case"int":case"uint":case"int8":case"uint8":case"int16":case"uint16":case"int32":case"uint32":return function(e){return parseInt(e,10)*r};case"float":case"double":case"float32":case"float64":return parseFloat;default:return function(e){return e}}},Ji=function(e,t,r){var i=r?1/255:1,n=t.offsetOctet;switch(t.type){case"int8":case"char":return function(t){return e.getInt8(t+n)*i};case"uint8":case"uchar":return function(t){return e.getUint8(t+n)*i};case"int16":case"short":return function(t){return e.getInt16(t+n,!0)*i};case"uint16":case"ushort":return function(t){return e.getUint16(t+n,!0)*i};case"int32":case"int":return function(t){return e.getInt32(t+n,!0)*i};case"uint32":case"uint":return function(t){return e.getUint32(t+n,!0)*i};case"float32":case"float":return function(t){return e.getFloat32(t+n,!0)};case"float64":case"double":return function(t){return e.getFloat64(t+n,!0)}}},Qi=function(e,t){var r=e.properties,i=e.objProperties={};e.offsetOctet=0;for(var n=0,a=r.length;n>5&31)*f,m=(_>>10&31)*f):v&&(p=(_>>10&31)*f,g=(_>>5&31)*f,m=(31&_)*f),o[u]=o[u+3]=o[u+6]=p,o[u+1]=o[u+4]=o[u+7]=g,o[u+2]=o[u+5]=o[u+8]=m}return[new Float32Array(s.buffer),o]}};const sn=an,on={importOBJ:Gi.importOBJ,importSGL:Ki.importSGL,importPLY:nn.importPLY,importSTL:sn.importSTL};var ln=r(88),un=r.n(ln);function cn(e,t){for(var r=0;r1&&void 0!==arguments[1])||arguments[1],r=arguments.length>2&&void 0!==arguments[2]&&arguments[2],i="s 0\n",n=[1,1],a=0,s=e.length;a>8}return new Blob([d])};const An=kn;var Cn=r(751),wn=r.n(Cn);function Rn(e){return(Rn="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}var xn={exportSketchfab:function(e,t){if(window.sketchfabOAuth2Config){var r=new XMLHttpRequest;return new(wn())(window.sketchfabOAuth2Config).connect().then((function(i){!function(e,t,r,i){e.open("POST","https://api.sketchfab.com/v2/models",!0),e.onprogress=function(e){if(e.lengthComputable&&e.total){var t=Math.round(100*e.loaded/e.total);i.setVisibility("Sketchfab upload: "+t+"%")}};var n=function(){i.setMessage("")};e.onerror=n,e.onabort=n,e.onload=function(){n();var t=JSON.parse(e.responseText),i=t.uid;i?(window.prompt((0,V.Z)("sketchfabUploadProcessing"),"https://sketchfab.com/models/"+i),function e(){var t=new XMLHttpRequest,n="https://api.sketchfab.com/v2/models/"+i+"/status";"object"===Rn(r)&&r.hasOwnProperty("token_type")&&"Bearer"===r.token_type?(t.open("GET",n,!0),t.setRequestHeader("Authorization","Bearer "+r.access_token)):t.open("GET",n+"?token="+r,!0),t.onload=function(){var r=JSON.parse(t.responseText);"FAILED"===r.processing?window.alert((0,V.Z)("sketchfabUploadError",r.warning.generic.join("\n"))):"SUCCEEDED"===r.processing?window.prompt((0,V.Z)("sketchfabUploadSuccess"),"https://sketchfab.com/models/"+i):window.setTimeout(e,5e3)},t.send()}()):window.alert((0,V.Z)("sketchfabUploadError",t.detail))},i.setMessage("Creating zip..."),mn.zip.useWebWorkers=!0,mn.zip.workerScriptsPath="worker/",mn.zip.createWriter(new mn.zip.BlobWriter("application/zip"),(function(n){var a=Sn.exportBinaryPLY(t.getMeshes(),{swapXY:!0});n.add("yourMesh.ply",new mn.zip.BlobReader(a),(function(){n.close(xn.exportFileSketchfab.bind(this,r,e,i))}))}),onerror)}(r,e,i,t)})).catch((function(e){console.error(e)})),r}},exportFileSketchfab:function(e,t,r,i){if(!t.isAborted){var n=new FormData;n.append("modelFile",i,"sculptglModel.zip"),n.append("name","My model"),n.append("tags","sculptgl"),"object"===Rn(e)&&e.hasOwnProperty("token_type")&&"Bearer"===e.token_type?t.setRequestHeader("Authorization","Bearer "+e.access_token):n.append("token",e),r.setMessage("Sketchfab upload..."),t.send(n)}}};const Pn=xn;var Dn={exportSculpteo:function(e,t){var r=new XMLHttpRequest;r.open("POST","uploadSculpteo.php",!0),r.onprogress=function(e){if(e.lengthComputable&&e.total){var r=Math.round(100*e.loaded/e.total);t.setMessage("Sculpteo upload: "+r+"%")}};var i=function(){t.setMessage("")};r.onerror=i,r.onabort=i,r.onload=function(){if(i(),200===this.status){var e=r.responseText.match(/\/print\/(.+?)(?=")/);e&&window.open("https://www.sculpteo.com/en"+e[0],"_blank")}};var n=e.getMeshes(),a=e.computeBoundingBoxMeshes(n),s=e.computeRadiusFromBoundingBox(a),o=Sn.exportBinaryPLY(n,{swapXY:!0});return t.setMessage("Creating zip..."),mn.zip.useWebWorkers=!0,mn.zip.workerScriptsPath="worker/",mn.zip.createWriter(new mn.zip.BlobWriter("application/zip"),(function(e){e.add("yourMesh.ply",new mn.zip.BlobReader(o),(function(){e.close(Dn.exportFileSculpteo.bind(this,s,r,t))}))}),onerror),r},exportFileSculpteo:function(e,t,r,i){if(!t.isAborted){var n=new FormData;n.append("file",i),n.append("name","fromSculptgl"),n.append("scale",4/e),r.setMessage("Sculpteo upload..."),t.send(n)}}};const In=Dn;var Fn={exportMaterialise:function(e,t){var r=new XMLHttpRequest;r.open("POST","https://i.materialise.com/web-api/tool/20cc0fd6-3cef-4111-a201-0b87026d892c/model",!0),r.onprogress=function(e){if(e.lengthComputable&&e.total){var r=Math.round(100*e.loaded/e.total);t.setMessage("Materialise upload: "+r+"%")}};var i=function(){t.setMessage("")};r.onerror=i,r.onabort=i,r.onload=function(){if(i(),200===r.status){var e=JSON.parse(r.responseText);window.open("https://i.materialise.com/en/3dprint#modelId="+e.modelID,"_blank")}};var n=e.getMeshes(),a=e.computeBoundingBoxMeshes(n),s=e.computeRadiusFromBoundingBox(a),o=An.exportBinarySTL(n,{colorMagic:!0,swapXY:!0});return t.setMessage("Creating zip..."),mn.zip.useWebWorkers=!0,mn.zip.workerScriptsPath="worker/",mn.zip.createWriter(new mn.zip.BlobWriter("application/zip"),(function(e){e.add("yourMesh.stl",new mn.zip.BlobReader(o),(function(){e.close(Fn.exportFileMaterialise.bind(this,s,r,t))}))}),onerror),r},exportFileMaterialise:function(e,t,r,i){if(!t.isAborted){var n=new FormData;n.append("file",i,"yourMesh.zip"),r.setMessage("Materialise upload..."),t.send(n)}}};const On=Fn;var Ln={};Ln.exportOBJ=bn.exportOBJ,Ln.exportSGL=ji.exportSGL,Ln.exportAsciiPLY=Sn.exportAsciiPLY,Ln.exportBinaryPLY=Sn.exportBinaryPLY,Ln.exportAsciiSTL=An.exportAsciiSTL,Ln.exportBinarySTL=An.exportBinarySTL,Ln.exportSketchfab=Pn.exportSketchfab,Ln.exportSculpteo=In.exportSculpteo,Ln.exportMaterialise=On.exportMaterialise;const Vn=Ln;var Nn={_gl:null,_checkRTT:{},_webGLExtensions:{}};Nn.HALF_FLOAT=Nn.HALF_FLOAT_OES=36193,Nn.checkRTTSupport=function(e,t){var r=Nn._gl;if(void 0===r)return!1;var i=e+","+t;if(void 0!==Nn._checkRTT[i])return Nn._checkRTT[i];var n=r.createTexture();r.bindTexture(r.TEXTURE_2D,n),r.texImage2D(r.TEXTURE_2D,0,r.RGBA,2,2,0,r.RGBA,e,null),r.texParameteri(r.TEXTURE_2D,r.TEXTURE_MAG_FILTER,t),r.texParameteri(r.TEXTURE_2D,r.TEXTURE_MIN_FILTER,t);var a=r.createFramebuffer();r.bindFramebuffer(r.FRAMEBUFFER,a),r.framebufferTexture2D(r.FRAMEBUFFER,r.COLOR_ATTACHMENT0,r.TEXTURE_2D,n,0);var s=Nn._checkRTT[i]=r.checkFramebufferStatus(r.FRAMEBUFFER)===r.FRAMEBUFFER_COMPLETE;return r.deleteTexture(n),r.deleteFramebuffer(a),r.bindTexture(r.TEXTURE_2D,null),r.bindFramebuffer(r.FRAMEBUFFER,null),s},Nn.hasRTTLinearHalfFloat=function(){return Nn._webGLExtensions.OES_texture_half_float_linear&&Nn.checkRTTSupport(Nn.HALF_FLOAT,Nn._gl.LINEAR)},Nn.hasRTTLinearFloat=function(){return Nn._webGLExtensions.OES_texture_float_linear&&Nn.checkRTTSupport(Nn._gl.FLOAT,Nn._gl.LINEAR)},Nn.hasRTTHalfFloat=function(){return Nn._webGLExtensions.OES_texture_half_float&&Nn.checkRTTSupport(Nn.HALF_FLOAT,Nn._gl.NEAREST)},Nn.hasRTTFloat=function(){return Nn._webGLExtensions.OES_texture_float&&Nn.checkRTTSupport(Nn._gl.FLOAT,Nn._gl.NEAREST)},Nn.getWebGLExtension=function(e){return Nn._webGLExtensions[e]},Nn.getWebGLExtensions=function(){return Nn._webGLExtensions},Nn.initWebGLExtensions=function(e){Nn._gl=e;for(var t=e.getSupportedExtensions(),r=Nn._webGLExtensions,i=0,n=t.length;i1&&void 0!==arguments[1]?arguments[1]:null,i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:t.createRenderbuffer(),n=arguments.length>3&&void 0!==arguments[3]&&arguments[3];Un(this,e),this._gl=t,this._texture=t.createTexture(),this._depth=i,this._framebuffer=t.createFramebuffer(),this._shaderType=r,this._invSize=new Float32Array(2),this._vertexBuffer=null,n&&Bn.hasRTTHalfFloat()?this._type=Bn.HALF_FLOAT_OES:n&&Bn.hasRTTFloat()?this._type=t.FLOAT:this._type=t.UNSIGNED_BYTE,this.setWrapRepeat(!1),this.setFilterNearest(!1),this.init()}var t,r;return t=e,(r=[{key:"getGL",value:function(){return this._gl}},{key:"getVertexBuffer",value:function(){return this._vertexBuffer}},{key:"getFramebuffer",value:function(){return this._framebuffer}},{key:"getTexture",value:function(){return this._texture}},{key:"getDepth",value:function(){return this._depth}},{key:"getInverseSize",value:function(){return this._invSize}},{key:"init",value:function(){var e=this._gl;Gn||(Gn=new g(e,e.ARRAY_BUFFER,e.STATIC_DRAW)).update(new Float32Array([-1,-1,4,-1,-1,4])),this._vertexBuffer=Gn}},{key:"setWrapRepeat",value:function(e){this._wrapRepeat=e}},{key:"setFilterNearest",value:function(e){this._filterNearest=e}},{key:"onResize",value:function(e,t){var r=this._gl;this._invSize[0]=1/e,this._invSize[1]=1/t,r.bindTexture(r.TEXTURE_2D,this._texture),r.texImage2D(r.TEXTURE_2D,0,r.RGBA,e,t,0,r.RGBA,this._type,null);var i=this._filterNearest?r.NEAREST:r.LINEAR;r.texParameteri(r.TEXTURE_2D,r.TEXTURE_MAG_FILTER,i),r.texParameteri(r.TEXTURE_2D,r.TEXTURE_MIN_FILTER,i);var n=this._wrapRepeat?r.REPEAT:r.CLAMP_TO_EDGE;r.texParameteri(r.TEXTURE_2D,r.TEXTURE_WRAP_S,n),r.texParameteri(r.TEXTURE_2D,r.TEXTURE_WRAP_T,n),this._depth&&(r.bindRenderbuffer(r.RENDERBUFFER,this._depth),r.renderbufferStorage(r.RENDERBUFFER,r.DEPTH_STENCIL,e,t)),r.bindFramebuffer(r.FRAMEBUFFER,this._framebuffer),r.framebufferTexture2D(r.FRAMEBUFFER,r.COLOR_ATTACHMENT0,r.TEXTURE_2D,this._texture,0),r.framebufferRenderbuffer(r.FRAMEBUFFER,r.DEPTH_STENCIL_ATTACHMENT,r.RENDERBUFFER,this._depth),r.bindTexture(r.TEXTURE_2D,null)}},{key:"release",value:function(){this._texture&&this._gl.deleteTexture(this._texture),this.getVertexBuffer().release()}},{key:"render",value:function(e){de[this._shaderType].getOrCreate(this._gl).draw(this,e)}}])&&Zn(t.prototype,r),e}();function jn(e,t){for(var r=0;r0&&(console.log("[GuiFiles] Adding comparison image:",e.target.files[0].name),t._main.addComparisonImage(e.target.files[0]),e.target.value="")}),!1)}e?(console.log("[GuiFiles] Found comparison image input, clicking..."),e.value="",e.click()):console.error("[GuiFiles] Failed to create comparison image input element!")}},{key:"onTextureSize",value:function(e){this._texSize=1<0&&(e.set(a.subarray(0,3*l)),t.set(s.subarray(0,3*l)),r.set(o.subarray(0,3*l)));for(var u=l;u0;){var d=c[--h],f=a[2*d],_=f+a[2*d+1],p=0,g=++i;for(p=f;p<_;++p){var m=s[p];if(n[m]=g,1===r[m])return o.Z.TAG_FLAG=i,!1;r[m]=-1,n[m]=g}for(g=++i,p=f;p<_;++p){var v=s[p];if(0!==t[v]&&!l[v])return o.Z.TAG_FLAG=i,!1;for(var y=a[2*v],b=y+a[2*v+1],T=y;T=g-1)&&(n[M]=g,0===r[M])){for(var S=a[2*M],k=S+a[2*M+1],E=0,A=S;A=o.Z.TRI_INDEX-1?(d[E+2]=T,d[E]=o.Z.TRI_INDEX-1):d[E]===o.Z.TRI_INDEX?d[E+1]=T:(d[E+1]=d[E+2],d[E+2]=T):d[E+1]===S?d[E]=T:(d[E]=d[E+1],d[E+2]===S?d[E+1]=T:(d[E+1]=d[E+2],d[E+2]=T))}}else{if(v+y+b===-3){i[c++]=l;continue}1===v?s[n[f+1]]=_+1:1===y?s[n[f+2]]=p+1:1===b&&(s[n[f]]=g+1)}}for(u/=4,l=0;l=w||d[C+1]>=o.Z.TRI_INDEX||d[C+2]>=o.Z.TRI_INDEX)return!1}else{var R=4*A;d[C]=s[n[R]]-1,d[C+1]=s[n[R+1]]-1,d[C+2]=s[n[R+2]]-1}}return t.setFaces(d),!0}(e,t,i,n)&&(function(e,t,r){var i=0,n=new Uint32Array(e.getNbVertices());t.setVerticesMapping(n);var a=t.getFaces(),s=new Int32Array(e.getNbVertices()),l=0,u=4*t.getNbFaces();for(l=0;l=0){var C=4*A;g=d[C+1],m=d[C+2],v=d[C],y=o.Z.TRI_INDEX,b=o.Z.TRI_INDEX}else{b=-A-1;for(var w=4*l,R=n[a[w]],x=n[a[w+1]],P=n[a[w+2]],D=n[a[w+3]],I=_[2*b],F=I+4,O=I;O=h);++c);if(c===h){var d=t.getFaces(),f=d.length;for(c=0;c=0){var b,T,M=4*v;0===m?(b=n[M+1],T=n[M]):1===m?(b=n[M+2],T=n[M+1]):(b=n[M],T=n[M+2]);for(var S=s[2*g],k=S+s[2*g+1],E=S;E "+4*e;console.time(t);var r=this.getCurrentMesh(),i=new $n(r);return r.setVerticesMapping(void 0),Ui.fullSubdivision(r,i),i.initTopology(),this.pushMesh(i),this.initRender(),console.timeEnd(t),i}},{key:"computeReverse",value:function(){if(0!==this._sel)return this.getCurrentMesh();var e=this.getCurrentMesh(),t=new $n(e);return ra.computeReverse(e,t)?(t.initTopology(),this.unshiftMesh(t),this.initRender(),t):void 0}},{key:"lowerLevel",value:function(){return 0===this._sel?this._meshes[0]:(this._meshes[this._sel-1].lowerAnalysis(this.getCurrentMesh()),this.setSelection(this._sel-1),this.updateResolution(),this.getCurrentMesh())}},{key:"higherLevel",value:function(){return this._sel===this._meshes.length-1||(this._meshes[this._sel+1].higherSynthesis(this.getCurrentMesh()),this.setSelection(this._sel+1),this.updateResolution()),this.getCurrentMesh()}},{key:"updateResolution",value:function(){this.updateGeometry(),this.updateDuplicateColorsAndMaterials(),this.updateBuffers();var e=this._meshes[this.getLowIndexRender()];this._indexBuffer.update(e.getTriangles()),this._wireframeBuffer.update(e.getWireframe())}},{key:"selectResolution",value:function(e){for(;this._sel>e;)this.lowerLevel();for(;this._sel=0;){var t=this._meshes[e];if(!0===t.getEvenMapping())return e===this._sel?e:e+1;if(t.getNbTriangles()<5e5)return e;--e}return 0}},{key:"_renderLow",value:function(e){var t=this.getRenderData(),r=this._sel,i=this.getIndexBuffer();this.setSelection(this.getLowIndexRender()),t._indexBuffer=this._indexBuffer,aa(la(o.prototype),"render",this).call(this,e),t._indexBuffer=i,this.setSelection(r)}},{key:"_renderWireframeLow",value:function(e){var t=this.getRenderData(),r=this._sel,i=this.getWireframeBuffer();this.setSelection(this.getLowIndexRender()),t._wireframeBuffer=this._wireframeBuffer,aa(la(o.prototype),"renderWireframe",this).call(this,e),t._wireframeBuffer=i,this.setSelection(r)}},{key:"_canUseLowRender",value:function(e){return!(this.isUsingTexCoords()||this.isUsingDrawArrays()||o.RENDER_HINT===o.PICKING||o.RENDER_HINT===o.NONE||e.getMesh()===this&&o.RENDER_HINT!==o.CAMERA||this.getLowIndexRender()===this._sel)}},{key:"render",value:function(e){return this._canUseLowRender(e)?this._renderLow(e):aa(la(o.prototype),"render",this).call(this,e)}},{key:"renderWireframe",value:function(e){return this._canUseLowRender(e)?this._renderWireframeLow(e):aa(la(o.prototype),"renderWireframe",this).call(this,e)}}])&&na(t.prototype,r),i&&na(t,i),o}(Qt);ua.RENDER_HINT=0;const ca=ua;var ha,da,fa,_a,pa,ga={_mesh:null,_linear:!1,_verticesMap:new Map,_states:null,_center:[0,0,0],_radius2:0,_edgeMax2:0},ma=function(e,t,r,i,n){var a=ga._mesh,s=a.getVerticesRingVert(),l=a.getVerticesRingFace(),u=a.getFacePosInLeaf(),c=a.getFaceLeaf(),h=a.getFacesStateFlags(),d=a.getFaces(),f=4*e;d[f]=t,d[f+1]=n,d[f+2]=i,d[f+3]=o.Z.TRI_INDEX;var _=c[e],p=_._iFaces;s[n].push(i),s[i].push(n);var g=a.getNbTriangles();l[n].push(e,g),d[f=4*g]=n,d[f+1]=r,d[f+2]=i,d[f+3]=o.Z.TRI_INDEX,h[g]=o.Z.STATE_FLAG,c[g]=_,u[g]=p.length,l[i].push(g),o.Z.replaceElement(l[r],e,g),p.push(g),a.addNbFace(1)},va=function(e){for(var t=ga._mesh,r=t.getVerticesRingVert(),i=t.getFaces(),n=e.length,a=new Uint32Array(o.Z.getMemory(8*n),0,2*n),s=0,l=ga._verticesMap,u=0;u=1?0:Math.acos(W));K*=Math.sqrt(j*j+z*z+X*X),(G=B*B+U*U+Z*Z)>0&&(K/=Math.sqrt(G)),j*(R-L)+z*(x-V)+X*(P-N)<0&&(K=-K),a[T]=.5*(A+I)+B*K,a[T+1]=.5*(C+F)+U*K,a[T+2]=.5*(w+O)+Z*K}g[b]=o.Z.STATE_FLAG,f[b]=[t,r,i],_[b]=[e,M],o.Z.replaceElement(f[t],r,b),o.Z.replaceElement(f[r],t,b),n.addNbVertice(1),n.addNbFace(1)},ba=(ha=[0,0,0],da=[0,0,0],fa=[0,0,0],_a=tt.Z.triangleInsideSphere,pa=tt.Z.pointInsideTriangle,function(e,t){var r=ga._mesh,n=r.getVertices(),a=r.getFaces(),s=4*e,o=3*a[s],l=3*a[s+1],u=3*a[s+2];if(ha[0]=n[o],ha[1]=n[o+1],ha[2]=n[o+2],da[0]=n[l],da[1]=n[l+1],da[2]=n[l+2],fa[0]=n[u],fa[1]=n[u+1],fa[2]=n[u+2],t&&!_a(ga._center,ga._radius2,ha,da,fa)&&!pa(ga._center,ha,da,fa))return 0;var c=r.getMaterials(),h=c[o+2],d=c[l+2],f=c[u+2],_=i.nI(ha,da),p=i.nI(da,fa),g=i.nI(ha,fa);return _>p&&_>g?.5*(h+d)*_>ga._edgeMax2?1:0:p>g?.5*(d+f)*p>ga._edgeMax2?2:0:.5*(h+f)*g>ga._edgeMax2?3:0}),Ta=function(e){var t=ga._mesh,r=t.getNbVertices(),i=t.getNbTriangles();ga._verticesMap=new Map;var n=function(e){for(var t=e.length,r=o.Z.getMemory(5*t),i=new Uint32Array(r,0,t),n=new Uint8Array(r,4*t,t),a=0,s=0;s5&&(a=t.expandsFaces(a,3),(s=new Uint8Array(a.length)).set(n[1])),ga._states.pushVertices(t.getVerticesFromFaces(a)),ga._states.pushFaces(a),t.reAllocateArrays(s.length),function(e,t){for(var r=ga._mesh.getFaces(),i=e.length,n=0;n0;)t.reAllocateArrays(c.length),c=va(c);for(u=t.getNbTriangles()-y,h=p,(p=new Uint32Array(g+u)).set(h),l=0;l=3)return o.Z.removeElement(b,t),o.Z.removeElement(T,e),k.push(t),E.push(e),f[A=4*e]===i?f[A]=a:f[A+1]===i?f[A+1]=a:f[A+2]=a,f[A=4*t]===r?f[A]=n:f[A+1]===r?f[A+1]=n:f[A+2]=n,l.computeRingVertices(r),l.computeRingVertices(i),l.computeRingVertices(n),void l.computeRingVertices(a);var C=3*i,w=c[A=3*r]+c[C],R=c[A+1]+c[C+1],x=c[A+2]+c[C+2],P=w*w+R*R+x*x;0===P?w=1:(w*=P=1/Math.sqrt(P),R*=P,x*=P),c[A]=w,c[A+1]=R,c[A+2]=x,h[A]=.5*(h[A]+h[C]),h[A+1]=.5*(h[A+1]+h[C+1]),h[A+2]=.5*(h[A+2]+h[C+2]),d[A]=.5*(d[A]+d[C]),d[A+1]=.5*(d[A+1]+d[C+1]),d[A+2]=.5*(d[A+2]+d[C+2]),o.Z.removeElement(b,e),o.Z.removeElement(b,t),o.Z.removeElement(T,e),o.Z.removeElement(T,t),o.Z.removeElement(k,e),o.Z.removeElement(E,t);var D=T.length,I=0;for(I=0;I=0;--t)Ea(e[t]);var r=Sa._iVertsToDelete;for(o.Z.tidy(r),t=r.length-1;t>=0;--t)Aa(r[t])}(),function(e,t){var r=Sa._mesh,i=r.getFacesTagFlags(),n=r.getNbTriangles(),a=r.getFacesFromVertices(e),s=t,l=t.length;(t=new Uint32Array(l+a.length)).set(s),t.set(a,l);var u=++o.Z.TAG_FLAG;l=t.length;for(var c=new Uint32Array(o.Z.getMemory(4*l),0,l),h=0,d=0;d=n||i[f]!==u&&(i[f]=u,c[h++]=f)}return new Uint32Array(c.subarray(0,h))}(function(){for(var e=Sa._mesh,t=e.getVerticesTagFlags(),r=e.getNbVertices(),i=++o.Z.TAG_FLAG,n=Sa._iVertsDecimated,a=n.length,s=new Uint32Array(o.Z.getMemory(4*a),0,a),l=0,u=0;u=r||t[c]!==i&&(t[c]=i,s[l++]=c)}return new Uint32Array(s.subarray(0,l))}(),m)}};const Pa=xa;function Da(e){return(Da="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function Ia(e,t){for(var r=0;r=t)return e.subarray(0,2*t);var r=new e.constructor(2*t);return r.set(e),r}},{key:"reAllocateArrays",value:function(e){var t=this._meshData,r=this._facesStateFlags.length,i=this.getNbTriangles(),n=i+e;(r4*n)&&(this._facesStateFlags=this.resizeArray(this._facesStateFlags,n),this.getShowWireframe()&&(this._wireframe=this.resizeArray(this._wireframe,6*n)),t._facesABCD=this.resizeArray(t._facesABCD,4*n),t._trianglesABC=this.resizeArray(t._trianglesABC,3*n),t._faceBoxes=this.resizeArray(t._faceBoxes,6*n),t._faceNormalsXYZ=this.resizeArray(t._faceNormalsXYZ,3*n),t._faceCentersXYZ=this.resizeArray(t._faceCentersXYZ,3*n),t._facesTagFlags=this.resizeArray(t._facesTagFlags,n),t._facePosInLeaf=this.resizeArray(t._facePosInLeaf,n)),((r=t._verticesXYZ.length/3)<(n=this.getNbVertices()+e)||r>4*n)&&(t._verticesXYZ=this.resizeArray(t._verticesXYZ,3*n),t._normalsXYZ=this.resizeArray(t._normalsXYZ,3*n),t._colorsRGB=this.resizeArray(t._colorsRGB,3*n),t._materialsPBR=this.resizeArray(t._materialsPBR,3*n),t._vertOnEdge=this.resizeArray(t._vertOnEdge,n),t._vertTagFlags=this.resizeArray(t._vertTagFlags,n),t._vertSculptFlags=this.resizeArray(t._vertSculptFlags,n),t._vertStateFlags=this.resizeArray(t._vertStateFlags,n)),this.isUsingDrawArrays()&&((r=t._verticesXYZ.length/9)<(n=i+10*e)||r>4*n)&&(t._verticesXYZ=this.resizeArray(t._verticesXYZ,9*n),t._normalsXYZ=this.resizeArray(t._normalsXYZ,9*n),t._colorsRGB=this.resizeArray(t._colorsRGB,9*n),t._materialsPBR=this.resizeArray(t._materialsPBR,9*n))}},{key:"initTopology",value:function(){var e=this.getVerticesRingVert(),t=this.getVerticesRingFace(),r=0,i=this.getNbVertices();for(e.length=t.length=i,r=0;r4e5&&!window.confirm((0,V.Z)("multiresWarnBigMesh",4*r.getNbFaces()))||(t!==r&&(e.replaceMesh(t,r),e.getStateManager().pushStateAddRemove(r,t,!0)),e.getStateManager().pushState(new Za(e,r,Za.SUBDIVISION)),r.addLevel(),e.setMesh(r),e.render()):window.alert((0,V.Z)("multiresSelectHighest"))}}},{key:"reverse",value:function(){var e=this._main,t=e.getMesh();if(t){var r=this.convertToMultimesh(t);if(0===r._sel){var i=new Za(e,r,Za.REVERSION);r.computeReverse()?(t!==r&&(e.replaceMesh(t,r),e.getStateManager().pushStateAddRemove(r,t,!0)),e.getStateManager().pushState(i),e.setMesh(r),e.render()):window.alert((0,V.Z)("multiresNotReversible"))}else window.alert((0,V.Z)("multiresSelectLowest"))}}},{key:"deleteLower",value:function(){var e=this._main,t=e._mesh;this.isMultimesh(t)&&0!==t._sel?(e.getStateManager().pushState(new Za(e,t,Za.DELETE_LOWER)),t.deleteLower(),this.updateMeshResolution()):window.alert((0,V.Z)("multiresNoLower"))}},{key:"deleteHigher",value:function(){var e=this._main,t=e.getMesh();this.isMultimesh(t)&&t._sel!==t._meshes.length-1?(e.getStateManager().pushState(new Za(e,t,Za.DELETE_HIGHER)),t.deleteHigher(),this.updateMeshResolution()):window.alert((0,V.Z)("multiresNoHigher"))}},{key:"onResolutionChanged",value:function(e){var t=e-1,r=this._main,i=r.getMesh();if(i){var n=this.isMultimesh(i),a=n&&i._meshes.length-1===t;this._ctrlReverse.setEnable(!n||0===t),this._ctrlSubdivide.setEnable(!n||a),this._ctrlDelLower.setEnable(n&&0!==t),this._ctrlDelHigher.setEnable(n&&!a),n&&i._sel!==t&&(r.getStateManager().pushState(new Za(r,i,Za.SELECTION)),i.selectResolution(t),this._ctrlGui.updateMeshInfo(),r.render())}}},{key:"updateMeshResolution",value:function(){var e=this._main.getMesh();if(!e||!this.isMultimesh(e))return this._ctrlResolution.setMax(1),void this._ctrlResolution.setValue(0);this._ctrlResolution.setMax(e._meshes.length),this._ctrlResolution.setValue(e._sel+1)}},{key:"updateMesh",value:function(){if(this._main.getMesh()){this._menu.setVisibility(!0),this.updateMeshResolution();var e=this._main.getMesh().isDynamic;this.updateDynamicVisibility(e),this._ctrlDynamic.setValue(e,!0)}else this._menu.setVisibility(!1)}}])&&Ga(t.prototype,r),e}();function ja(e,t){for(var r=0;r=1),this._ctrlMerge.setVisibility(t>1);var r=this._main.getMesh();this._ctrlOffSym.setValue(r?r.getSymmetryOffset():0)}},{key:"merge",value:function(){var e=this._main,t=e.getSelectedMeshes();if(!(t.length<2)){var r=Gr.mergeMeshes(t,e.getMesh()||t[0]);e.removeMeshes(t),e.getStateManager().pushStateAddRemove(r,t.slice()),e.getMeshes().push(r),e.setMesh(r)}}},{key:"toggleShowHide",value:function(e){this._ctrlIsolate.setValue(!this._ctrlIsolate.getValue(),!!e)}},{key:"showHide",value:function(e){e?this.isolate():this.showAll(),this.updateMesh()}},{key:"setMeshesVisible",value:function(e,t){for(var r=0;r=e)){u[p]=t[_].slice(),c[p]=r[_].slice();var g=3*_;h[p*=3]=i[g],h[p+1]=i[g+1],h[p+2]=i[g+2],d[p]=n[g],d[p+1]=n[g+1],d[p+2]=n[g+2],f[p]=a[g],f[p+1]=a[g+1],f[p+2]=a[g+2]}}}},{key:"pullFaces",value:function(){for(var e=this._nbFacesState,t=this._fArState,r=this._idFaceState,i=r.length,n=this._mesh.getFaces(),a=0;a=e)){var o=4*a;n[s*=4]=t[o],n[s+1]=t[o+1],n[s+2]=t[o+2],n[s+3]=t[o+3]}}}}])&&ys(t.prototype,r),e}();function Ts(e,t){for(var r=0;r=t;)r.shift(),--this._curUndoIndex;for(;r.length>t;)r.pop(),i.shift()}},{key:"pushState",value:function(t){++o.Z.STATE_FLAG;var r=this._undos;-1===this._curUndoIndex?r.length=0:r.length>=e.STACK_LENGTH&&(r.shift(),--this._curUndoIndex),this._redos.length=0,++this._curUndoIndex,r.length>0&&(r.length=this._curUndoIndex),r.push(t)}},{key:"getCurrentState",value:function(){return this._undos[this._curUndoIndex]}},{key:"pushVertices",value:function(e){e&&e.length>0&&this.getCurrentState().pushVertices(e)}},{key:"pushFaces",value:function(e){e&&e.length>0&&this.getCurrentState().pushFaces(e)}},{key:"undo",value:function(){if(this._undos.length&&!(this._curUndoIndex<0)){var e=this.getCurrentState(),t=e.createRedo();t.squash=e.squash,this._redos.push(t),e.undo(),this._curUndoIndex--,!0===e.squash&&this.undo()}}},{key:"redo",value:function(){this._redos.length&&(this._redos[this._redos.length-1].redo(),this._curUndoIndex++,this._redos.pop(),this._redos.length&&!0===this._redos[this._redos.length-1].squash&&this.redo())}},{key:"reset",value:function(){this._undos.length=0,this._redos.length=0,this._curUndoIndex=-1}},{key:"cleanNoop",value:function(){for(;this._curUndoIndex>=0&&this.getCurrentState().isNoop();)this._undos.length--,this._curUndoIndex--,this._redos.length=0}}])&&Ss(t.prototype,r),e}();ks.STACK_LENGTH=15;const Es=ks;function As(e,t){for(var r=0;r=0?this._prevToolIndex:s.Z.Tools.BRUSH;this._ctrlGui._ctrlSculpting&&this._ctrlGui._ctrlSculpting._ctrlSculpt?this._ctrlGui._ctrlSculpting._ctrlSculpt.setValue(t):this._sculptManager.setToolIndex(t),this._prevToolIndex=-1}this.updatePenControls(),this.updateFeatureList(),this._main.renderSelectOverRtt()}},{key:"onModeChange",value:function(e){this._tool._mode=0|e,this._main.render()}},{key:"onCloseLoop",value:function(e){this._tool.setCloseLoop(e)}},{key:"onSpacingChange",value:function(e){this._tool._spacing=e/1e3}},{key:"createNewSet",value:function(){this.onEnable(!0),this._tool.startNewFeature(),this.updateFeatureList()}},{key:"finishSet",value:function(){this._tool.finishActiveFeature(),this.updateFeatureList(),this._main.renderSelectOverRtt()}},{key:"updateFeatureList",value:function(){var e=this._tool.getFeatures(),t=this._tool.getSelectedFeatureIds();if(this._ctrlCloseLoop&&this._ctrlCloseLoop.setValue(this._tool._closeLoop,!0),e.length){this._featureListEmpty&&this._featureListEmpty.setVisibility(!1);for(var r=0,i=e.length;r=0?"rgba(255, 200, 50, 0.2)":"")}for(var u=e.length;u=0?"rgba(255, 200, 50, 0.2)":"",d.onclick=function(e){return function(t){var i=!!(t.shiftKey||t.ctrlKey||t.metaKey);r._penTool.selectFeature(e,i),r._expandedSourceId=r._expandedSourceId===e?null:e,r.updateLists()}}(s.id)),l.setVisibility(!0);var f=this._expandedSourceId===s.id,_=u.domButton||u.domContainer;_&&(_.textContent=" + "+(0,V.Z)("padsTrimsCreatePad"),_.dataset.featureId=s.id,_.onclick=function(e){return function(){r._penTool.selectFeature(e,!1),r._penTool.createSolidFeature("Pad",[e],{height:r._padHeight,depth:r._trimDepth,direction:r._padDirection}),r.updateLists()}}(s.id)),u.setVisibility(f);var p=c.domButton||c.domContainer;p&&(p.textContent=" + "+(0,V.Z)("padsTrimsCreateTrim"),p.dataset.featureId=s.id,p.onclick=function(e){return function(){r._penTool.selectFeature(e,!1),r._penTool.createSolidFeature("Trim",[e],{height:r._padHeight,depth:r._trimDepth,direction:r._trimDirection}),r.updateLists()}}(s.id)),c.setVisibility(f)}for(var g=3*e.length;g.99?this.resetViewBack():this.resetViewFront()}},{key:"toggleViewTop",value:function(){var e=this._quatRot[0]*Math.SQRT1_2+this._quatRot[3]*Math.SQRT1_2;e*e>.99?this.resetViewBottom():this.resetViewTop()}},{key:"toggleViewLeft",value:function(){var e=-this._quatRot[1]*Math.SQRT1_2+this._quatRot[3]*Math.SQRT1_2;e*e>.99?this.resetViewRight():this.resetViewLeft()}},{key:"computeWorldToScreenMatrix",value:function(e){return e=e||l.Ue(),l.dC(e,l.dC(e,this._viewport,this._proj),this._view)}},{key:"unproject",value:function(e,t,r){var n=[0,0,0];return l.U_(Xs,this.computeWorldToScreenMatrix(Xs)),i.fF(n,i.t8(n,e,this._height-t,r),Xs)}},{key:"project",value:function(e){var t=[0,0,0];return i.fF(t,e,this.computeWorldToScreenMatrix(Xs)),t[1]=this._height-t[1],t}},{key:"onResize",value:function(e,t){this._width=e,this._height=t;var r=this._viewport;l.yR(r),l.bA(r,r,i.t8(Gs,.5*e,.5*t,.5)),l.Iu(r,r,i.t8(Gs,1,1,1)),this.updateProjection()}},{key:"snapClosestRotation",value:function(){for(var e=this._quatRot,t=1/0,r=0,i=Ys.length,n=0;n=1&&this.clearTimerN(i)}.bind(this),16.6)}}},{key:"_translateDelta",value:function(e,t){var r=this._trans;i.od(r,r,e,t),this.setTrans(r),this._main.render()}},{key:"translateDelay",value:function(e,t){var r=this._translateDelta.bind(this,e);this.delay(r,t,"translate")}},{key:"_rotDelta",value:function(e,t){if(this._mode===s.Z.CameraMode.ORBIT){var r=this._rotX+e[0]*t,i=this._rotY+e[1]*t;this.setOrbit(r,i)}else $e(this._quatRot,Ye(zs,e,e[3]*t),this._quatRot);this.updateView(),this._main.render()}},{key:"rotateDelay",value:function(e,t){var r=this._rotDelta.bind(this,e);this.delay(r,t,"rotate")}},{key:"_quatDelta",value:function(e,t){He(zs),function(e,t,r,i){var n,a,s,o,l,u=t[0],h=t[1],d=t[2],f=t[3],_=r[0],p=r[1],g=r[2],m=r[3];(a=u*_+h*p+d*g+f*m)<0&&(a=-a,_=-_,p=-p,g=-g,m=-m),1-a>c.Ib?(n=Math.acos(a),s=Math.sin(n),o=Math.sin((1-i)*n)/s,l=Math.sin(i*n)/s):(o=1-i,l=i),e[0]=o*u+l*_,e[1]=o*h+l*p,e[2]=o*d+l*g,e[3]=o*f+l*m}(zs,zs,e,t);var r=this._quatRot;if($e(this._quatRot,this._quatRot,zs),this._mode===s.Z.CameraMode.ORBIT){var i=r[0],n=r[1],a=r[2],o=r[3];this._rotY=Math.atan2(2*(o*n+a*i),1-2*(n*n+a*a)),this._rotX=Math.atan2(2*(o*i+n*a),1-2*(a*a+i*i))}this.updateView(),this._main.render()}},{key:"quatDelay",value:function(e,t){var r=[0,0,0,0];!function(e,t){e[0]=-t[0],e[1]=-t[1],e[2]=-t[2],e[3]=t[3]}(r,this._quatRot),$e(r,r,e),et(r,r);var i=this._quatDelta.bind(this,r);this.delay(i,t,"quat")}},{key:"_centerDelta",value:function(e,t){i.od(this._center,this._center,e,t),this.updateView(),this._main.render()}},{key:"centerDelay",value:function(e,t){var r=[0,0,0];i.lu(r,e,this._center);var n=this._centerDelta.bind(this,r);this.delay(n,t,"center")}},{key:"_offsetDelta",value:function(e,t){i.od(this._offset,this._offset,e,t),this.updateView(),this._main.render()}},{key:"offsetDelay",value:function(e,t){var r=[0,0,0];i.lu(r,e,this._offset);var n=this._offsetDelta.bind(this,r);this.delay(n,t,"offset")}},{key:"computeFrustumFit",value:function(){var e,t=this._near;if(this._projectionType===s.Z.Projection.ORTHOGRAPHIC)return e=Math.min(this._width,this._height)/t*.5,Math.sqrt(1+e*e)/e;var r=this._proj,i=t*(r[8]-1)/r[0],n=t*(1+r[8])/r[0],a=t*(1+r[9])/r[5],o=t*(r[9]-1)/r[5],l=Math.abs(n-i),u=Math.abs(a-o);return e=Math.min(u,l)/t*.5,this._fov/45*Math.sqrt(1+e*e)/e}}])&&Ns(t.prototype,r),e}();function Js(e,t){for(var r=0;re||s>e){var o=Math.min(e/a,e/s);a=Math.floor(a*o),s=Math.floor(s*o),console.log("[ComparisonImage] Resizing image to:",a,"x",s,"(scale:",o,")"),i.width=a,i.height=s,n.drawImage(r,0,0,a,s),t._aspectRatio=a/s,console.log("[ComparisonImage] Aspect ratio:",t._aspectRatio),t._createTexture(i)}else t._aspectRatio=r.width/r.height,console.log("[ComparisonImage] Using original size, aspect ratio:",t._aspectRatio),t._createTexture(r);t._width>0&&(t._height=t._width/t._aspectRatio,console.log("[ComparisonImage] Updating matrix with size:",t._width,"x",t._height),t._updateMatrix()),console.log("[ComparisonImage] Image loaded successfully, triggering render"),t._main.render()},r.onerror=function(){console.error("[ComparisonImage] Image.onerror: Failed to load image"),alert("Failed to load image. Please try a different file.")}},r.onerror=function(){console.error("[ComparisonImage] FileReader.onerror: Failed to read file"),alert("Failed to read file. Please try again.")},console.log("[ComparisonImage] Starting FileReader.readAsDataURL..."),r.readAsDataURL(e)}else console.error("[ComparisonImage] No image file provided")}},{key:"_createTexture",value:function(e){console.log("[ComparisonImage] _createTexture called, image type:",e instanceof HTMLCanvasElement?"Canvas":"Image");var t=this._gl;if(t)if(this.deleteTexture(),console.log("[ComparisonImage] Creating WebGL texture..."),this._texture=t.createTexture(),this._texture){t.bindTexture(t.TEXTURE_2D,this._texture);try{e instanceof HTMLCanvasElement?(console.log("[ComparisonImage] Loading texture from canvas, size:",e.width,"x",e.height),t.texImage2D(t.TEXTURE_2D,0,t.RGBA,t.RGBA,t.UNSIGNED_BYTE,e)):(console.log("[ComparisonImage] Loading texture from image, size:",e.width,"x",e.height),t.texImage2D(t.TEXTURE_2D,0,t.RGBA,t.RGBA,t.UNSIGNED_BYTE,e)),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MIN_FILTER,t.LINEAR),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MAG_FILTER,t.LINEAR),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_S,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_T,t.CLAMP_TO_EDGE),t.bindTexture(t.TEXTURE_2D,null);var r=t.getError();r!==t.NO_ERROR?console.error("[ComparisonImage] WebGL error after texture creation:",r):console.log("[ComparisonImage] Texture created successfully")}catch(e){console.error("[ComparisonImage] Error creating texture:",e),t.bindTexture(t.TEXTURE_2D,null)}}else console.error("[ComparisonImage] Failed to create WebGL texture");else console.error("[ComparisonImage] No WebGL context for texture creation")}},{key:"updateMatrices",value:function(e){if(e){var t=this._transformData._matrix,r=this._transformData._center;t&&16===t.length||(console.warn("[ComparisonImage] updateMatrices: Invalid transform matrix, initializing..."),this._updateMatrix()),l.dC(this._transformData._lastComputedMV,e.getView(),t),l.dC(this._transformData._lastComputedMVP,e.getProjection(),this._transformData._lastComputedMV);var i=this._transformData._lastComputedMVP;this._transformData._lastComputedDepth=i[2]*r[0]+i[6]*r[1]+i[10]*r[2]+i[14],console.log("[ComparisonImage] updateMatrices: Matrices updated, depth:",this._transformData._lastComputedDepth)}else console.warn("[ComparisonImage] updateMatrices: No camera provided")}},{key:"setPosition",value:function(e,t,r){i.t8(this._transformData._center,e,t,r),this._updateMatrix()}},{key:"getPosition",value:function(){return this._transformData._center.slice()}},{key:"setSize",value:function(e,t){this._width=Math.max(.1,e),this._height=t||this._width/this._aspectRatio,this._updateMatrix()}},{key:"setSizeMaintainAspect",value:function(e,t,r){if(r){var i,n=e/this._width,a=t/this._height;i=Math.abs(n-1)>Math.abs(a-1)?n:Math.abs(a-1)>Math.abs(n-1)?a:n,this._width=Math.max(.1,this._width*i),this._height=Math.max(.1,this._width/this._aspectRatio)}else this._width=Math.max(.1,e),this._height=Math.max(.1,t||this._width/this._aspectRatio);this._updateMatrix()}},{key:"getSize",value:function(){return{width:this._width,height:this._height}}},{key:"getAspectRatio",value:function(){return this._aspectRatio}},{key:"_updateMatrix",value:function(){var e=this._transformData._matrix,t=this._transformData._center;if(e&&t){var r=[t[0],t[1],t[2]];l.yR(e),l.Iu(e,e,r),l.bA(e,e,[this._width,this._height,1])}else console.error("ComparisonImage: Transform data not initialized")}},{key:"getDepth",value:function(){return this._transformData._lastComputedDepth}},{key:"getID",value:function(){return this._id}},{key:"getMatrix",value:function(){return this._transformData._matrix}},{key:"getCenter",value:function(){return this._transformData._center}},{key:"intersectRay",value:function(e,t){var r=l.Ue();l.U_(r,this._transformData._matrix);var n=i.Ue(),a=i.Ue();i.fF(n,e,r),i.fF(a,t,r);var s=i.Ue();if(i.lu(s,a,n),Math.abs(s[2])<1e-6)return null;var o=-n[2]/s[2];if(o<0||o>1)return null;var u=i.Ue();if(i.od(u,n,s,o),Math.abs(u[0])<=.5&&Math.abs(u[1])<=.5){var c=i.Ue();return i.fF(c,u,this._transformData._matrix),c}return null}},{key:"setOpacity",value:function(e){this._opacity=Math.max(0,Math.min(1,e))}},{key:"getOpacity",value:function(){return this._opacity}},{key:"setUseDepth",value:function(e){this._useDepth=e}},{key:"getUseDepth",value:function(){return this._useDepth}},{key:"isSelected",value:function(){return this._isSelected}},{key:"setSelected",value:function(e){this._isSelected=e}},{key:"render",value:function(){if(this._texture){if(!this._transformData._lastComputedMVP||16!==this._transformData._lastComputedMVP.length){if(console.warn("[ComparisonImage] render: Matrices not initialized, attempting to update..."),!this._main||!this._main.getCamera)return void console.warn("[ComparisonImage] render: No main or getCamera, skipping render");var e=this._main.getCamera();if(!e)return void console.warn("[ComparisonImage] render: No camera available, skipping render");if(this.updateMatrices(e),!this._transformData._lastComputedMVP||16!==this._transformData._lastComputedMVP.length)return void console.warn("[ComparisonImage] render: Still no valid matrices after update, skipping render")}var t=this._gl;if(t){var r=de[s.Z.Shader.COMPARISON_IMAGE];if(r)try{var i=r.getOrCreate(t);if(!i||!i.program)return void console.error("[ComparisonImage] render: Shader program not available");var n=t.getError();n!==t.NO_ERROR&&console.warn("[ComparisonImage] render: WebGL error before render:",n);var a=t.isEnabled(t.DEPTH_TEST),o=t.isEnabled(t.BLEND),l=t.getParameter(t.DEPTH_FUNC),u=t.getParameter(t.BLEND_SRC_ALPHA),c=t.getParameter(t.BLEND_DST_ALPHA);t.useProgram(i.program),this._useDepth?(t.enable(t.DEPTH_TEST),t.depthFunc(t.LEQUAL)):t.disable(t.DEPTH_TEST),t.enable(t.BLEND),t.blendFunc(t.SRC_ALPHA,t.ONE_MINUS_SRC_ALPHA),t.activeTexture(t.TEXTURE0),t.bindTexture(t.TEXTURE_2D,this._texture),i.uniforms&&void 0!==i.uniforms.uTexture&&t.uniform1i(i.uniforms.uTexture,0),i.uniforms&&void 0!==i.uniforms.uMVP&&t.uniformMatrix4fv(i.uniforms.uMVP,!1,this._transformData._lastComputedMVP),i.uniforms&&void 0!==i.uniforms.uOpacity&&t.uniform1f(i.uniforms.uOpacity,this._opacity),i.uniforms&&void 0!==i.uniforms.uIsBorder&&t.uniform1i(i.uniforms.uIsBorder,0),i.attributes&&i.attributes.aPosition&&i.attributes.aPosition.bindToBuffer(this._vertexBuffer),i.attributes&&i.attributes.aTexCoord&&i.attributes.aTexCoord.bindToBuffer(this._texCoordBuffer),this._indexBuffer.bind(),t.drawElements(t.TRIANGLES,6,t.UNSIGNED_INT,0),this._isSelected&&this.renderBorder(t,i),i.attributes&&i.attributes.aPosition&&i.attributes.aPosition.unbind(),i.attributes&&i.attributes.aTexCoord&&i.attributes.aTexCoord.unbind(),t.bindBuffer(t.ARRAY_BUFFER,null),t.bindBuffer(t.ELEMENT_ARRAY_BUFFER,null),o?t.enable(t.BLEND):t.disable(t.BLEND),t.blendFunc(u,c),a?t.enable(t.DEPTH_TEST):t.disable(t.DEPTH_TEST),t.depthFunc(l),(n=t.getError())!==t.NO_ERROR&&console.error("ComparisonImage: WebGL error after render:",n)}catch(e){console.error("ComparisonImage: Error during render:",e)}else console.error("[ComparisonImage] render: Shader not found in ShaderLib")}else console.error("[ComparisonImage] render: No WebGL context")}}},{key:"deleteTexture",value:function(){this._texture&&(this._gl.deleteTexture(this._texture),this._texture=null)}},{key:"renderBorder",value:function(e,t){var r=e.isEnabled(e.DEPTH_TEST),i=e.isEnabled(e.BLEND);e.disable(e.DEPTH_TEST),e.enable(e.BLEND),e.blendFunc(e.SRC_ALPHA,e.ONE_MINUS_SRC_ALPHA),e.lineWidth(3),t.uniforms&&void 0!==t.uniforms.uBorderColor&&e.uniform3f(t.uniforms.uBorderColor,1,1,0),t.uniforms&&void 0!==t.uniforms.uIsBorder&&e.uniform1i(t.uniforms.uIsBorder,1),t.uniforms&&void 0!==t.uniforms.uOpacity&&e.uniform1f(t.uniforms.uOpacity,1),t.attributes&&t.attributes.aPosition&&t.attributes.aPosition.bindToBuffer(this._vertexBuffer),this._borderIndexBuffer.bind(),e.drawElements(e.LINES,8,e.UNSIGNED_INT,0),e.lineWidth(1),r&&e.enable(e.DEPTH_TEST),i||e.disable(e.BLEND),e.bindBuffer(e.ELEMENT_ARRAY_BUFFER,null)}},{key:"release",value:function(){this.deleteTexture(),this._vertexBuffer.release(),this._texCoordBuffer.release(),this._indexBuffer.release(),this._borderIndexBuffer.release()}}])&&$s(t.prototype,r),e}();eo.ID=0;const to=eo;function ro(e,t){for(var r=0;r=1?3:e,this._lastNbPointers=e,this.onDeviceDown(t)}},{key:"onPanEnd",value:function(e){"mouse"!==e.pointerType&&(this.onDeviceUp(),window.setTimeout(function(){e.pointers.length||(this._lastNbPointers=0)}.bind(this),60))}},{key:"onDoubleTap",value:function(e){if(!this._focusGui){var t=this._eventProxy;t.pageX=e.center.x,t.pageY=e.center.y,this.setMousePosition(t);var r=this._picking,n=r.intersectionMouseMeshes(),a=this._camera,s=[0,0,0];if(!n)return this.resetCameraMeshes();i.fF(s,r.getIntersectionPoint(),r.getMesh().getMatrix());var o=a._trans[2];a.isOrthographic()||(o=Math.min(o,i.TK(s,a.computePosition()))),a.setAndFocusOnPivot(s,o),this.render()}}},{key:"onDoubleTap2Fingers",value:function(){this._focusGui||this.resetCameraMeshes()}},{key:"onPinchStart",value:function(e){this._focusGui=!1,this._lastScale=e.scale}},{key:"onPinchInOut",value:function(e){var t=25*(e.scale-this._lastScale);this._lastScale=e.scale,this.onDeviceWheel(t)}},{key:"resetCameraMeshes",value:function(e){if(e||(e=this._meshes),e.length>0){var t=[0,0,0],r=this.computeBoundingBoxMeshes(e),n=.8*this.computeRadiusFromBoundingBox(r);n*=this._camera.computeFrustumFit(),i.t8(t,.5*(r[0]+r[3]),.5*(r[1]+r[4]),.5*(r[2]+r[5])),this._camera.setAndFocusOnPivot(t,n)}else this._camera.resetView();this.render()}},{key:"getFileType",value:function(e){var t=e.toLowerCase();return t.endsWith(".obj")?"obj":t.endsWith(".sgl")?"sgl":t.endsWith(".stl")?"stl":t.endsWith(".ply")?"ply":void 0}},{key:"loadFiles",value:function(e){e.stopPropagation(),e.preventDefault();for(var t=e.dataTransfer?e.dataTransfer.files:e.target.files,r=0,i=t.length;r0?1:-1)}},{key:"onDeviceUp",value:function(){if(this._transformingComparisonImage)return this._transformingComparisonImage=!1,this._transformStartX=null,this._transformStartY=null,this._transformStartPos=null,this._transformStartSize=null,void this.render();this.setCanvasCursor("default"),ca.RENDER_HINT=ca.NONE,this._sculptManager.end(),this._action===s.Z.Action.MASK_EDIT&&this._mesh&&(this._lastMouseX===this._maskX&&this._lastMouseY===this._maskY?this.getSculptManager().getTool(s.Z.Tools.MASKING).invert():this.getSculptManager().getTool(s.Z.Tools.MASKING).clear()),this._action=s.Z.Action.NOTHING,this.render(),this._stateManager.cleanNoop()}},{key:"onDeviceWheel",value:function(e){e>0&&!this._isWheelingIn&&(this._isWheelingIn=!0,this._camera.start(this._mouseX,this._mouseY)),this._camera.zoom(.02*e),ca.RENDER_HINT=ca.CAMERA,this.render(),this._timerEndWheel&&window.clearTimeout(this._timerEndWheel),this._timerEndWheel=window.setTimeout(this._endWheel.bind(this),300)}},{key:"_endWheel",value:function(){ca.RENDER_HINT=ca.NONE,this._isWheelingIn=!1,this.render()}},{key:"setMousePosition",value:function(e){this._mouseX=this._pixelRatio*(e.pageX-this._canvasOffsetLeft),this._mouseY=this._pixelRatio*(e.pageY-this._canvasOffsetTop)}},{key:"onDeviceDown",value:function(e){if(!this._focusGui){this.setMousePosition(e);var t=this._mouseX,r=this._mouseY,i=e.which;if(1===i&&!e.shiftKey&&!e.ctrlKey&&!e.altKey){var n=this.pickComparisonImage(t,r);if(n)return this.setSelectedComparisonImage(n),this._transformingComparisonImage=!0,this._transformStartX=t,this._transformStartY=r,this._transformStartPos=n.getPosition().slice(),this._transformStartSize=n.getSize(),void(this._action=s.Z.Action.NOTHING)}var a=!1;1===i&&(a=this._sculptManager.start(e.shiftKey)),1===i&&a&&this.setCanvasCursor("none"),3===i&&e.ctrlKey?this._action=s.Z.Action.CAMERA_ZOOM:2===i?this._action=s.Z.Action.CAMERA_PAN:!a&&e.ctrlKey?(this._maskX=t,this._maskY=r,this._action=s.Z.Action.MASK_EDIT):a&&3!==i||!e.altKey?this._action=3===i||1===i&&!a?s.Z.Action.CAMERA_ROTATE:s.Z.Action.SCULPT_EDIT:this._action=s.Z.Action.CAMERA_PAN_ZOOM_ALT,this._action!==s.Z.Action.CAMERA_ROTATE&&this._action!==s.Z.Action.CAMERA_ZOOM||this._camera.start(t,r),this._lastMouseX=t,this._lastMouseY=r}}},{key:"getSpeedFactor",value:function(){return this._cameraSpeed/(this._canvasHeight*this.getPixelRatio())}},{key:"onDeviceMove",value:function(e){if(!this._focusGui){this.setMousePosition(e);var t=this._mouseX,r=this._mouseY,n=this._action,a=this.getSpeedFactor();if(this._transformingComparisonImage&&this._selectedComparisonImage){var o=this._selectedComparisonImage,l=this._picking,u=l.unproject(t,r,0),c=l.unproject(t,r,.1),h=l.unproject(this._transformStartX,this._transformStartY,0),d=l.unproject(this._transformStartX,this._transformStartY,.1),f=o.intersectRay(h,d),_=o.intersectRay(u,c);if(f&&_){if(e.shiftKey){var p=i.TK(h,f),g=i.TK(u,_);if(p>.001){var m=g/p,v=this._transformStartSize.width*m,y=this._transformStartSize.height*m,b=!e.ctrlKey&&!e.metaKey;o.setSizeMaintainAspect(v,y,b),o.setPosition(this._transformStartPos[0],this._transformStartPos[1],this._transformStartPos[2])}}else{var T=i.Ue();i.lu(T,_,f);var M=i.Ue();i.IH(M,this._transformStartPos,T),o.setPosition(M[0],M[1],M[2])}this.render()}return this._lastMouseX=t,void(this._lastMouseY=r)}n===s.Z.Action.CAMERA_ZOOM||n===s.Z.Action.CAMERA_PAN_ZOOM_ALT&&!e.altKey?(ca.RENDER_HINT=ca.CAMERA,this._camera.zoom((t-this._lastMouseX+r-this._lastMouseY)*a),this.render()):n===s.Z.Action.CAMERA_PAN_ZOOM_ALT||n===s.Z.Action.CAMERA_PAN?(ca.RENDER_HINT=ca.CAMERA,this._camera.translate((t-this._lastMouseX)*a,(r-this._lastMouseY)*a),this.render()):n===s.Z.Action.CAMERA_ROTATE?(ca.RENDER_HINT=ca.CAMERA,e.shiftKey||this._camera.rotate(t,r),this.render()):(ca.RENDER_HINT=ca.PICKING,this._sculptManager.preUpdate(),n===s.Z.Action.SCULPT_EDIT&&(ca.RENDER_HINT=ca.SCULPT,this._sculptManager.update(this),this.getMesh().isDynamic&&this._gui.updateMeshInfo())),this._lastMouseX=t,this._lastMouseY=r,this.renderSelectOverRtt()}}}])&&no(t.prototype,r),h}(function(){function e(){!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),this._gl=null,this._cameraSpeed=.25,this._pixelRatio=1,this._viewport=document.getElementById("viewport"),this._canvas=document.getElementById("canvas"),this._canvasWidth=0,this._canvasHeight=0,this._canvasOffsetLeft=0,this._canvasOffsetTop=0,this._stateManager=new Es(this),this._sculptManager=null,this._camera=new qs(this),this._picking=new Qa.Z(this),this._pickingSym=new Qa.Z(this,!0),this._meshPreview=null,this._torusLength=.5,this._torusWidth=.1,this._torusRadius=2*Math.PI,this._torusRadial=32,this._torusTubular=128;var t=(0,u.Z)();this._showContour=t.outline,this._showGrid=t.grid,this._grid=null,this._background=null,this._meshes=[],this._selectMeshes=[],this._mesh=null,this._comparisonImages=[],this._selectedComparisonImage=null,this._rttContour=null,this._rttMerge=null,this._rttOpaque=null,this._rttTransparent=null,this._focusGui=!1,this._gui=new Vs(this),this._preventRender=!1,this._drawFullScene=!1,this._autoMatrix=t.scalecenter,this._vertexSRGB=!0}var t,r;return t=e,(r=[{key:"start",value:function(){if(this.initWebGL(),this._gl){this._sculptManager=new Fi(this),this._background=new Qs(this._gl,this),console.log("[Scene] Setting up comparison image file input listener...");var e=document.getElementById("comparisonimageopen");e||(console.warn("[Scene] comparisonimageopen input not found, creating it..."),(e=document.createElement("input")).type="file",e.id="comparisonimageopen",e.accept="image/*",e.style.display="none",document.body.appendChild(e)),e?(console.log("[Scene] Found comparison image input, adding event listener"),e.addEventListener("change",function(e){console.log("[Scene] Comparison image file selected:",e.target.files.length,"file(s)"),e.target.files.length>0&&(console.log("[Scene] Adding comparison image:",e.target.files[0].name),this.addComparisonImage(e.target.files[0]),e.target.value="")}.bind(this),!1)):console.error("[Scene] Failed to create comparison image input element!"),this._rttContour=new Wn(this._gl,s.Z.Shader.CONTOUR,null),this._rttMerge=new Wn(this._gl,s.Z.Shader.MERGE,null),this._rttOpaque=new Wn(this._gl,s.Z.Shader.FXAA),this._rttTransparent=new Wn(this._gl,null,this._rttOpaque.getDepth(),!0),this._grid=Jr.createGrid(this._gl),this.initGrid(),this.loadTextures(),this._gui.initGui(),this.onCanvasResize();var t=(0,u.Z)();t.modelurl?this.addModelURL(t.modelurl):t.nosphere||this.addSphere()}}},{key:"addModelURL",value:function(e){var t=this.getFileType(e);if(t){var r=new XMLHttpRequest;r.open("GET",e,!0),r.responseType="obj"===t?"text":"arraybuffer",r.onload=function(){200===r.status&&this.loadScene(r.response,t)}.bind(this),r.send(null)}}},{key:"getBackground",value:function(){return this._background}},{key:"getViewport",value:function(){return this._viewport}},{key:"getCanvas",value:function(){return this._canvas}},{key:"getPixelRatio",value:function(){return this._pixelRatio}},{key:"getCanvasWidth",value:function(){return this._canvasWidth}},{key:"getCanvasHeight",value:function(){return this._canvasHeight}},{key:"getCamera",value:function(){return this._camera}},{key:"getGui",value:function(){return this._gui}},{key:"getMeshes",value:function(){return this._meshes}},{key:"getMesh",value:function(){return this._mesh}},{key:"getSelectedMeshes",value:function(){return this._selectMeshes}},{key:"getComparisonImages",value:function(){return this._comparisonImages}},{key:"getSelectedComparisonImage",value:function(){return this._selectedComparisonImage}},{key:"addComparisonImage",value:function(e){console.log("[Scene] addComparisonImage called with file:",e?e.name:"null");var t=this._gl;if(!t)return console.error("[Scene] WebGL not initialized"),null;if(this._comparisonImages.length>=10)return console.warn("[Scene] Maximum comparison images reached"),alert("Maximum of 10 comparison images allowed. Please remove some before adding more."),null;console.log("[Scene] Creating new ComparisonImage instance...");var r=new to(t,this);return console.log("[Scene] ComparisonImage created, loading image..."),r.loadImage(e),console.log("[Scene] Setting initial position and size (defaultSize:",10,")"),r.setPosition(0,0,0),r.setSize(10,10/r._aspectRatio),console.log("[Scene] Adding comparison image to array, current count:",this._comparisonImages.length),this._comparisonImages.push(r),this._selectedComparisonImage=r,console.log("[Scene] Initializing matrices for comparison image..."),r.updateMatrices(this._camera),console.log("[Scene] Comparison image added, triggering render..."),this.render(),r}},{key:"removeComparisonImage",value:function(e){var t=this._comparisonImages.indexOf(e);-1!==t&&(e.release(),this._comparisonImages.splice(t,1),this._selectedComparisonImage===e&&(this._selectedComparisonImage=null),this.render())}},{key:"setSelectedComparisonImage",value:function(e){this._selectedComparisonImage&&this._selectedComparisonImage.setSelected(!1),this._selectedComparisonImage=e,e&&e.setSelected(!0),this._gui&&this._gui._ctrlComparisonImage&&this._gui._ctrlComparisonImage.updateVisibility(),this.render()}},{key:"pickComparisonImage",value:function(e,t){for(var r=this._picking,n=r.unproject(e,t,0),a=r.unproject(e,t,.1),s=this._comparisonImages,o=null,l=1/0,u=0;u=0?this._selectMeshes.length>1&&(this._selectMeshes.splice(r,1),e=this._selectMeshes[0]):this._selectMeshes.push(e)}else this._selectMeshes.length=0,this._selectMeshes.push(e);else this._selectMeshes.length=0;return this._mesh=e,this.getGui().updateMesh(),this.render(),e}},{key:"renderSelectOverRtt",value:function(){this._requestRender()&&(this._drawFullScene=!1)}},{key:"_requestRender",value:function(){return!0!==this._preventRender&&(window.requestAnimationFrame(this.applyRender.bind(this)),this._preventRender=!0,!0)}},{key:"render",value:function(){this._drawFullScene=!0,this._requestRender()}},{key:"applyRender",value:function(){this._preventRender=!1,this.updateMatricesAndSort();var e=this._gl;e&&(this._drawFullScene&&this._drawScene(),e.disable(e.DEPTH_TEST),e.bindFramebuffer(e.FRAMEBUFFER,this._rttMerge.getFramebuffer()),this._rttMerge.render(this),e.bindFramebuffer(e.FRAMEBUFFER,null),this._rttOpaque.render(this),e.enable(e.DEPTH_TEST),this._sculptManager.postRender())}},{key:"_drawScene",value:function(){var e=this._gl,t=0,r=this._meshes,i=r.length;e.disable(e.DEPTH_TEST);var n=this._selectMeshes.length>0&&this._showContour&&de[s.Z.Shader.CONTOUR].color[3]>0;if(n){e.bindFramebuffer(e.FRAMEBUFFER,this._rttContour.getFramebuffer()),e.clear(e.COLOR_BUFFER_BIT);for(var a=0,o=this._selectMeshes,l=o.length;a0&&t.optimizeNearFar(this.computeBoundingBoxScene());for(var r=0,i=e.length;rt[3]&&(t[3]=n[3]),n[4]>t[4]&&(t[4]=n[4]),n[5]>t[5]&&(t[5]=n[5])}return t}},{key:"computeBoundingBoxScene",value:function(){var e=this._meshes.slice();return e.push(this._grid),this._sculptManager.addSculptToScene(e),this.computeBoundingBoxMeshes(e)}},{key:"normalizeAndCenterMeshes",value:function(e){var t=this.computeBoundingBoxMeshes(e),r=o.Z.SCALE/i.TK([t[0],t[1],t[2]],[t[3],t[4],t[5]]),n=l.Ue();l.bA(n,n,[r,r,r]),l.Iu(n,n,[.5*-(t[0]+t[3]),.5*-(t[1]+t[4]),.5*-(t[2]+t[5])]);for(var a=0,s=e.length;a=0&&(this._meshes[r]=t),this._mesh===e&&this.setMesh(t)}},{key:"duplicateSelection",value:function(){for(var e=this._selectMeshes.slice(),t=null,r=0;r=t.status}function n(e){try{e.dispatchEvent(new MouseEvent("click"))}catch(r){var t=document.createEvent("MouseEvents");t.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),e.dispatchEvent(t)}}var s="object"==("undefined"==typeof window?"undefined":a(window))&&window.window===window?window:"object"==("undefined"==typeof self?"undefined":a(self))&&self.self===self?self:"object"==(void 0===r.g?"undefined":a(r.g))&&r.g.global===r.g?r.g:void 0,o=s.navigator&&/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),l=s.saveAs||("object"!=("undefined"==typeof window?"undefined":a(window))||window!==s?function(){}:"download"in HTMLAnchorElement.prototype&&!o?function(e,r,a){var o=s.URL||s.webkitURL,l=document.createElement("a");r=r||e.name||"download",l.download=r,l.rel="noopener","string"==typeof e?(l.href=e,l.origin===location.origin?n(l):i(l.href)?t(e,r,a):n(l,l.target="_blank")):(l.href=o.createObjectURL(e),setTimeout((function(){o.revokeObjectURL(l.href)}),4e4),setTimeout((function(){n(l)}),0))}:"msSaveOrOpenBlob"in navigator?function(e,r,s){if(r=r||e.name||"download","string"!=typeof e)navigator.msSaveOrOpenBlob(function(e,t){return void 0===t?t={autoBom:!1}:"object"!=a(t)&&(console.warn("Deprecated: Expected third argument to be a object"),t={autoBom:!t}),t.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(e.type)?new Blob(["\ufeff",e],{type:e.type}):e}(e,s),r);else if(i(e))t(e,r,s);else{var o=document.createElement("a");o.href=e,o.target="_blank",setTimeout((function(){n(o)}))}}:function(e,r,i,n){if((n=n||open("","_blank"))&&(n.document.title=n.document.body.innerText="downloading..."),"string"==typeof e)return t(e,r,i);var a="application/octet-stream"===e.type,l=/constructor/i.test(s.HTMLElement)||s.safari,u=/CriOS\/[\d]+/.test(navigator.userAgent);if((u||a&&l||o)&&"undefined"!=typeof FileReader){var c=new FileReader;c.onloadend=function(){var e=c.result;e=u?e:e.replace(/^data:[^;]*;/,"data:attachment/file;"),n?n.location.href=e:location=e,n=null},c.readAsDataURL(e)}else{var h=s.URL||s.webkitURL,d=h.createObjectURL(e);n?n.location=d:location.href=d,n=null,setTimeout((function(){h.revokeObjectURL(d)}),4e4)}});s.saveAs=l.saveAs=l,e.exports=l})?i.apply(t,[]):i)||(e.exports=n)},663:(e,t,r)=>{"use strict";r.d(t,{Ib:()=>i,WT:()=>n});var i=1e-6,n="undefined"!=typeof Float32Array?Float32Array:Array;Math.random,Math.PI,Math.hypot||(Math.hypot=function(){for(var e=0,t=arguments.length;t--;)e+=arguments[t]*arguments[t];return Math.sqrt(e)})},89:(e,t,r)=>{"use strict";r.d(t,{Ue:()=>n,JG:()=>a,yR:()=>s,U_:()=>o,Iu:()=>l,bA:()=>u,U1:()=>c,lM:()=>h,uD:()=>d,jI:()=>f,en:()=>_,G3:()=>p,M5:()=>g,zB:()=>m,dC:()=>v});var i=r(663);function n(){var e=new i.WT(16);return i.WT!=Float32Array&&(e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[11]=0,e[12]=0,e[13]=0,e[14]=0),e[0]=1,e[5]=1,e[10]=1,e[15]=1,e}function a(e,t){return e[0]=t[0],e[1]=t[1],e[2]=t[2],e[3]=t[3],e[4]=t[4],e[5]=t[5],e[6]=t[6],e[7]=t[7],e[8]=t[8],e[9]=t[9],e[10]=t[10],e[11]=t[11],e[12]=t[12],e[13]=t[13],e[14]=t[14],e[15]=t[15],e}function s(e){return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}function o(e,t){var r=t[0],i=t[1],n=t[2],a=t[3],s=t[4],o=t[5],l=t[6],u=t[7],c=t[8],h=t[9],d=t[10],f=t[11],_=t[12],p=t[13],g=t[14],m=t[15],v=r*o-i*s,y=r*l-n*s,b=r*u-a*s,T=i*l-n*o,M=i*u-a*o,S=n*u-a*l,k=c*p-h*_,E=c*g-d*_,A=c*m-f*_,C=h*g-d*p,w=h*m-f*p,R=d*m-f*g,x=v*R-y*w+b*C+T*A-M*E+S*k;return x?(x=1/x,e[0]=(o*R-l*w+u*C)*x,e[1]=(n*w-i*R-a*C)*x,e[2]=(p*S-g*M+m*T)*x,e[3]=(d*M-h*S-f*T)*x,e[4]=(l*A-s*R-u*E)*x,e[5]=(r*R-n*A+a*E)*x,e[6]=(g*b-_*S-m*y)*x,e[7]=(c*S-d*b+f*y)*x,e[8]=(s*w-o*A+u*k)*x,e[9]=(i*A-r*w-a*k)*x,e[10]=(_*M-p*b+m*v)*x,e[11]=(h*b-c*M-f*v)*x,e[12]=(o*E-s*C-l*k)*x,e[13]=(r*C-i*E+n*k)*x,e[14]=(p*y-_*T-g*v)*x,e[15]=(c*T-h*y+d*v)*x,e):null}function l(e,t,r){var i,n,a,s,o,l,u,c,h,d,f,_,p=r[0],g=r[1],m=r[2];return t===e?(e[12]=t[0]*p+t[4]*g+t[8]*m+t[12],e[13]=t[1]*p+t[5]*g+t[9]*m+t[13],e[14]=t[2]*p+t[6]*g+t[10]*m+t[14],e[15]=t[3]*p+t[7]*g+t[11]*m+t[15]):(i=t[0],n=t[1],a=t[2],s=t[3],o=t[4],l=t[5],u=t[6],c=t[7],h=t[8],d=t[9],f=t[10],_=t[11],e[0]=i,e[1]=n,e[2]=a,e[3]=s,e[4]=o,e[5]=l,e[6]=u,e[7]=c,e[8]=h,e[9]=d,e[10]=f,e[11]=_,e[12]=i*p+o*g+h*m+t[12],e[13]=n*p+l*g+d*m+t[13],e[14]=a*p+u*g+f*m+t[14],e[15]=s*p+c*g+_*m+t[15]),e}function u(e,t,r){var i=r[0],n=r[1],a=r[2];return e[0]=t[0]*i,e[1]=t[1]*i,e[2]=t[2]*i,e[3]=t[3]*i,e[4]=t[4]*n,e[5]=t[5]*n,e[6]=t[6]*n,e[7]=t[7]*n,e[8]=t[8]*a,e[9]=t[9]*a,e[10]=t[10]*a,e[11]=t[11]*a,e[12]=t[12],e[13]=t[13],e[14]=t[14],e[15]=t[15],e}function c(e,t,r,n){var a,s,o,l,u,c,h,d,f,_,p,g,m,v,y,b,T,M,S,k,E,A,C,w,R=n[0],x=n[1],P=n[2],D=Math.hypot(R,x,P);return D{"use strict";r.d(t,{Ue:()=>n,al:()=>a,JG:()=>s,t8:()=>o,IH:()=>l,bA:()=>u,od:()=>c,tk:()=>h,Fv:()=>d,AK:()=>f,kC:()=>_,fF:()=>p,kK:()=>g,VC:()=>m,lu:()=>v,TK:()=>y,nI:()=>b,Zh:()=>T,x9:()=>M});var i=r(663);function n(){var e=new i.WT(3);return i.WT!=Float32Array&&(e[0]=0,e[1]=0,e[2]=0),e}function a(e,t,r){var n=new i.WT(3);return n[0]=e,n[1]=t,n[2]=r,n}function s(e,t){return e[0]=t[0],e[1]=t[1],e[2]=t[2],e}function o(e,t,r,i){return e[0]=t,e[1]=r,e[2]=i,e}function l(e,t,r){return e[0]=t[0]+r[0],e[1]=t[1]+r[1],e[2]=t[2]+r[2],e}function u(e,t,r){return e[0]=t[0]*r,e[1]=t[1]*r,e[2]=t[2]*r,e}function c(e,t,r,i){return e[0]=t[0]+r[0]*i,e[1]=t[1]+r[1]*i,e[2]=t[2]+r[2]*i,e}function h(e,t){return e[0]=-t[0],e[1]=-t[1],e[2]=-t[2],e}function d(e,t){var r=t[0],i=t[1],n=t[2],a=r*r+i*i+n*n;return a>0&&(a=1/Math.sqrt(a)),e[0]=t[0]*a,e[1]=t[1]*a,e[2]=t[2]*a,e}function f(e,t){return e[0]*t[0]+e[1]*t[1]+e[2]*t[2]}function _(e,t,r){var i=t[0],n=t[1],a=t[2],s=r[0],o=r[1],l=r[2];return e[0]=n*l-a*o,e[1]=a*s-i*l,e[2]=i*o-n*s,e}function p(e,t,r){var i=t[0],n=t[1],a=t[2],s=r[3]*i+r[7]*n+r[11]*a+r[15];return s=s||1,e[0]=(r[0]*i+r[4]*n+r[8]*a+r[12])/s,e[1]=(r[1]*i+r[5]*n+r[9]*a+r[13])/s,e[2]=(r[2]*i+r[6]*n+r[10]*a+r[14])/s,e}function g(e,t,r){var i=t[0],n=t[1],a=t[2];return e[0]=i*r[0]+n*r[3]+a*r[6],e[1]=i*r[1]+n*r[4]+a*r[7],e[2]=i*r[2]+n*r[5]+a*r[8],e}function m(e,t,r){var i=r[0],n=r[1],a=r[2],s=r[3],o=t[0],l=t[1],u=t[2],c=n*u-a*l,h=a*o-i*u,d=i*l-n*o,f=n*d-a*h,_=a*c-i*d,p=i*h-n*c,g=2*s;return c*=g,h*=g,d*=g,f*=2,_*=2,p*=2,e[0]=o+c+f,e[1]=l+h+_,e[2]=u+d+p,e}var v=function(e,t,r){return e[0]=t[0]-r[0],e[1]=t[1]-r[1],e[2]=t[2]-r[2],e},y=function(e,t){var r=t[0]-e[0],i=t[1]-e[1],n=t[2]-e[2];return Math.hypot(r,i,n)},b=function(e,t){var r=t[0]-e[0],i=t[1]-e[1],n=t[2]-e[2];return r*r+i*i+n*n},T=function(e){var t=e[0],r=e[1],i=e[2];return Math.hypot(t,r,i)},M=function(e){var t=e[0],r=e[1],i=e[2];return t*t+r*r+i*i};n()},297:(e,t,r)=>{var i;function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}!function(a,s,o,l){"use strict";var u,c=["","webkit","Moz","MS","ms","o"],h=s.createElement("div"),d=Math.round,f=Math.abs,_=Date.now;function p(e,t,r){return setTimeout(M(e,r),t)}function g(e,t,r){return!!Array.isArray(e)&&(m(e,r[t],r),!0)}function m(e,t,r){var i;if(e)if(e.forEach)e.forEach(t,r);else if(e.length!==l)for(i=0;i\s*\(/gm,"{anonymous}()@"):"Unknown Stack Trace",n=a.console&&(a.console.warn||a.console.log);return n&&n.call(a.console,i,r),e.apply(this,arguments)}}u="function"!=typeof Object.assign?function(e){if(e===l||null===e)throw new TypeError("Cannot convert undefined or null to object");for(var t=Object(e),r=1;r-1}function R(e){return e.trim().split(/\s+/g)}function x(e,t,r){if(e.indexOf&&!r)return e.indexOf(t);for(var i=0;ir[t]})):i.sort()),i}function I(e,t){for(var r,i,n=t[0].toUpperCase()+t.slice(1),a=0;a1&&!r.firstMultiple?r.firstMultiple=z(t):1===n&&(r.firstMultiple=!1);var a=r.firstInput,s=r.firstMultiple,o=s?s.center:a.center,u=t.center=X(i);t.timeStamp=_(),t.deltaTime=t.timeStamp-a.timeStamp,t.angle=q(o,u),t.distance=Y(o,u),function(e,t){var r=t.center,i=e.offsetDelta||{},n=e.prevDelta||{},a=e.prevInput||{};1!==t.eventType&&4!==a.eventType||(n=e.prevDelta={x:a.deltaX||0,y:a.deltaY||0},i=e.offsetDelta={x:r.x,y:r.y}),t.deltaX=n.x+(r.x-i.x),t.deltaY=n.y+(r.y-i.y)}(r,t),t.offsetDirection=H(t.deltaX,t.deltaY);var c,h,d=K(t.deltaTime,t.deltaX,t.deltaY);t.overallVelocityX=d.x,t.overallVelocityY=d.y,t.overallVelocity=f(d.x)>f(d.y)?d.x:d.y,t.scale=s?(c=s.pointers,Y((h=i)[0],h[1],G)/Y(c[0],c[1],G)):1,t.rotation=s?function(e,t){return q(t[1],t[0],G)+q(e[1],e[0],G)}(s.pointers,i):0,t.maxPointers=r.prevInput?t.pointers.length>r.prevInput.maxPointers?t.pointers.length:r.prevInput.maxPointers:t.pointers.length,function(e,t){var r,i,n,a,s=e.lastInterval||t,o=t.timeStamp-s.timeStamp;if(8!=t.eventType&&(o>25||s.velocity===l)){var u=t.deltaX-s.deltaX,c=t.deltaY-s.deltaY,h=K(o,u,c);i=h.x,n=h.y,r=f(h.x)>f(h.y)?h.x:h.y,a=H(u,c),e.lastInterval=t}else r=s.velocity,i=s.velocityX,n=s.velocityY,a=s.direction;t.velocity=r,t.velocityX=i,t.velocityY=n,t.direction=a}(r,t);var p=e.element;C(t.srcEvent.target,p)&&(p=t.srcEvent.target),t.target=p}(e,r),e.emit("hammer.input",r),e.recognize(r),e.session.prevInput=r}function z(e){for(var t=[],r=0;r=f(t)?e<0?2:4:t<0?8:16}function Y(e,t,r){r||(r=Z);var i=t[r[0]]-e[r[0]],n=t[r[1]]-e[r[1]];return Math.sqrt(i*i+n*n)}function q(e,t,r){r||(r=Z);var i=t[r[0]]-e[r[0]],n=t[r[1]]-e[r[1]];return 180*Math.atan2(n,i)/Math.PI}W.prototype={handler:function(){},init:function(){this.evEl&&E(this.element,this.evEl,this.domHandler),this.evTarget&&E(this.target,this.evTarget,this.domHandler),this.evWin&&E(O(this.element),this.evWin,this.domHandler)},destroy:function(){this.evEl&&A(this.element,this.evEl,this.domHandler),this.evTarget&&A(this.target,this.evTarget,this.domHandler),this.evWin&&A(O(this.element),this.evWin,this.domHandler)}};var J={mousedown:1,mousemove:2,mouseup:4},Q="mousedown",$="mousemove mouseup";function ee(){this.evEl=Q,this.evWin=$,this.pressed=!1,W.apply(this,arguments)}T(ee,W,{handler:function(e){var t=J[e.type];1&t&&0===e.button&&(this.pressed=!0),2&t&&1!==e.which&&(t=4),this.pressed&&(4&t&&(this.pressed=!1),this.callback(this.manager,t,{pointers:[e],changedPointers:[e],pointerType:U,srcEvent:e}))}});var te={pointerdown:1,pointermove:2,pointerup:4,pointercancel:8,pointerout:8},re={2:B,3:"pen",4:U,5:"kinect"},ie="pointerdown",ne="pointermove pointerup pointercancel";function ae(){this.evEl=ie,this.evWin=ne,W.apply(this,arguments),this.store=this.manager.session.pointerEvents=[]}a.MSPointerEvent&&!a.PointerEvent&&(ie="MSPointerDown",ne="MSPointerMove MSPointerUp MSPointerCancel"),T(ae,W,{handler:function(e){var t=this.store,r=!1,i=e.type.toLowerCase().replace("ms",""),n=te[i],a=re[e.pointerType]||e.pointerType,s=a==B,o=x(t,e.pointerId,"pointerId");1&n&&(0===e.button||s)?o<0&&(t.push(e),o=t.length-1):12&n&&(r=!0),o<0||(t[o]=e,this.callback(this.manager,n,{pointers:t,changedPointers:[e],pointerType:a,srcEvent:e}),r&&t.splice(o,1))}});var se={touchstart:1,touchmove:2,touchend:4,touchcancel:8},oe="touchstart",le="touchstart touchmove touchend touchcancel";function ue(){this.evTarget=oe,this.evWin=le,this.started=!1,W.apply(this,arguments)}function ce(e,t){var r=P(e.touches),i=P(e.changedTouches);return 12&t&&(r=D(r.concat(i),"identifier",!0)),[r,i]}T(ue,W,{handler:function(e){var t=se[e.type];if(1===t&&(this.started=!0),this.started){var r=ce.call(this,e,t);12&t&&r[0].length-r[1].length==0&&(this.started=!1),this.callback(this.manager,t,{pointers:r[0],changedPointers:r[1],pointerType:B,srcEvent:e})}}});var he={touchstart:1,touchmove:2,touchend:4,touchcancel:8},de="touchstart touchmove touchend touchcancel";function fe(){this.evTarget=de,this.targetIds={},W.apply(this,arguments)}function _e(e,t){var r=P(e.touches),i=this.targetIds;if(3&t&&1===r.length)return i[r[0].identifier]=!0,[r,r];var n,a,s=P(e.changedTouches),o=[],l=this.target;if(a=r.filter((function(e){return C(e.target,l)})),1===t)for(n=0;n-1&&i.splice(e,1)}),2500)}}function ve(e){for(var t=e.srcEvent.clientX,r=e.srcEvent.clientY,i=0;i-1&&this.requireFail.splice(t,1),this},hasRequireFailures:function(){return this.requireFail.length>0},canRecognizeWith:function(e){return!!this.simultaneous[e.id]},emit:function(e){var t=this,r=this.state;function i(r){t.manager.emit(r,e)}r<8&&i(t.options.event+Pe(r)),i(t.options.event),e.additionalEvent&&i(e.additionalEvent),r>=8&&i(t.options.event+Pe(r))},tryEmit:function(e){if(this.canEmit())return this.emit(e);this.state=Re},canEmit:function(){for(var e=0;et.threshold&&n&t.direction},attrTest:function(e){return Fe.prototype.attrTest.call(this,e)&&(2&this.state||!(2&this.state)&&this.directionTest(e))},emit:function(e){this.pX=e.deltaX,this.pY=e.deltaY;var t=De(e.direction);t&&(e.additionalEvent=this.options.event+t),this._super.emit.call(this,e)}}),T(Le,Fe,{defaults:{event:"pinch",threshold:0,pointers:2},getTouchAction:function(){return[ke]},attrTest:function(e){return this._super.attrTest.call(this,e)&&(Math.abs(e.scale-1)>this.options.threshold||2&this.state)},emit:function(e){if(1!==e.scale){var t=e.scale<1?"in":"out";e.additionalEvent=this.options.event+t}this._super.emit.call(this,e)}}),T(Ve,xe,{defaults:{event:"press",pointers:1,time:251,threshold:9},getTouchAction:function(){return[Me]},process:function(e){var t=this.options,r=e.pointers.length===t.pointers,i=e.distancet.time;if(this._input=e,!i||!r||12&e.eventType&&!n)this.reset();else if(1&e.eventType)this.reset(),this._timer=p((function(){this.state=8,this.tryEmit()}),t.time,this);else if(4&e.eventType)return 8;return Re},reset:function(){clearTimeout(this._timer)},emit:function(e){8===this.state&&(e&&4&e.eventType?this.manager.emit(this.options.event+"up",e):(this._input.timeStamp=_(),this.manager.emit(this.options.event,this._input)))}}),T(Ne,Fe,{defaults:{event:"rotate",threshold:0,pointers:2},getTouchAction:function(){return[ke]},attrTest:function(e){return this._super.attrTest.call(this,e)&&(Math.abs(e.rotation)>this.options.threshold||2&this.state)}}),T(Be,Fe,{defaults:{event:"swipe",threshold:10,velocity:.3,direction:30,pointers:1},getTouchAction:function(){return Oe.prototype.getTouchAction.call(this)},attrTest:function(e){var t,r=this.options.direction;return 30&r?t=e.overallVelocity:6&r?t=e.overallVelocityX:24&r&&(t=e.overallVelocityY),this._super.attrTest.call(this,e)&&r&e.offsetDirection&&e.distance>this.options.threshold&&e.maxPointers==this.options.pointers&&f(t)>this.options.velocity&&4&e.eventType},emit:function(e){var t=De(e.offsetDirection);t&&this.manager.emit(this.options.event+t,e),this.manager.emit(this.options.event,e)}}),T(Ue,xe,{defaults:{event:"tap",pointers:1,taps:1,interval:300,time:250,threshold:9,posThreshold:10},getTouchAction:function(){return[Se]},process:function(e){var t=this.options,r=e.pointers.length===t.pointers,i=e.distance{var __WEBPACK_AMD_DEFINE_FACTORY__,__WEBPACK_AMD_DEFINE_ARRAY__,__WEBPACK_AMD_DEFINE_RESULT__,factory;function _typeof(e){return(_typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}module=__webpack_require__.nmd(module),window,factory=function(){return function(e){var t={};function r(i){if(t[i])return t[i].exports;var n=t[i]={i,l:!1,exports:{}};return e[i].call(n.exports,n,n.exports,r),n.l=!0,n.exports}return r.m=e,r.c=t,r.d=function(e,t,i){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:i})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"===_typeof(e)&&e&&e.__esModule)return e;var i=Object.create(null);if(r.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var n in e)r.d(i,n,function(t){return e[t]}.bind(null,n));return i},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s="./src/yagui.js")}({"./src/GuiMain.js":function srcGuiMainJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var containers_Sidebar__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! containers/Sidebar */ \"./src/containers/Sidebar.js\");\n/* harmony import */ var containers_Topbar__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! containers/Topbar */ \"./src/containers/Topbar.js\");\n\n\n\nclass GuiMain {\n\n constructor(viewport, callbackResize) {\n this.domMain = document.createElement('div');\n this.viewport = viewport;\n\n this.callbackResize = callbackResize;\n if (this.viewport) {\n this.viewport.style.width = document.documentElement.clientWidth + 'px';\n this.viewport.style.height = document.documentElement.clientHeight + 'px';\n }\n this.cbResize_ = this._onWindowResize.bind(this);\n\n document.body.appendChild(this.domMain);\n this.leftSidebar = undefined;\n this.rightSidebar = undefined;\n this.topbar = undefined;\n\n window.addEventListener('resize', this._onWindowResize.bind(this), false);\n }\n\n _onWindowResize() {\n if (this.viewport) {\n this.viewport.style.width = document.documentElement.clientWidth + 'px';\n this.viewport.style.height = document.documentElement.clientHeight + 'px';\n this.viewport.style.left = '0px';\n this.viewport.style.top = '0px';\n if (this.leftSidebar)\n this.leftSidebar._updateViewportPosition(this.viewport);\n if (this.rightSidebar)\n this.rightSidebar._updateViewportPosition(this.viewport);\n if (this.topbar)\n this.topbar._updateViewportPosition(this.viewport);\n }\n this._updateSidebarsPosition();\n if (this.callbackResize)\n this.callbackResize();\n }\n\n _updateSidebarsPosition() {\n if (!this.topbar) return;\n var off = this.topbar.domTopbar.offsetHeight;\n if (this.leftSidebar)\n this.leftSidebar._setTop(off);\n if (this.rightSidebar)\n this.rightSidebar._setTop(off);\n }\n\n addLeftSidebar() {\n this.leftSidebar = new containers_Sidebar__WEBPACK_IMPORTED_MODULE_0__[\"default\"](this.cbResize_);\n var domSide = this.leftSidebar.domSidebar;\n this.domMain.appendChild(domSide);\n this.domMain.appendChild(this.leftSidebar.domResize);\n\n this._updateSidebarsPosition();\n this.leftSidebar._updateViewportPosition(this.viewport);\n return this.leftSidebar;\n }\n\n addRightSidebar() {\n this.rightSidebar = new containers_Sidebar__WEBPACK_IMPORTED_MODULE_0__[\"default\"](this.cbResize_);\n var domSide = this.rightSidebar.domSidebar;\n this.domMain.appendChild(domSide);\n this.domMain.appendChild(this.rightSidebar.domResize);\n\n this.rightSidebar._onTheRight();\n this._updateSidebarsPosition();\n this.rightSidebar._updateViewportPosition(this.viewport);\n return this.rightSidebar;\n }\n\n addTopbar() {\n this.topbar = new containers_Topbar__WEBPACK_IMPORTED_MODULE_1__[\"default\"](this.cbResize_);\n this.domMain.appendChild(this.topbar.domTopbar);\n\n this._updateSidebarsPosition();\n this.topbar._updateViewportPosition(this.viewport);\n return this.topbar;\n }\n\n setVisibility(visible) {\n this.domMain.hidden = !visible;\n this._onWindowResize();\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (GuiMain);\n\n\n//# sourceURL=webpack://yagui/./src/GuiMain.js?")},"./src/containers/BaseContainer.js":function srcContainersBaseContainerJs(module,__webpack_exports__,__webpack_require__){"use strict";eval('__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var widgets_Button__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! widgets/Button */ "./src/widgets/Button.js");\n/* harmony import */ var widgets_Checkbox__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! widgets/Checkbox */ "./src/widgets/Checkbox.js");\n/* harmony import */ var widgets_Color__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! widgets/Color */ "./src/widgets/Color.js");\n/* harmony import */ var widgets_Combobox__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! widgets/Combobox */ "./src/widgets/Combobox.js");\n/* harmony import */ var widgets_Slider__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! widgets/Slider */ "./src/widgets/Slider.js");\n/* harmony import */ var widgets_Title__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! widgets/Title */ "./src/widgets/Title.js");\n\n\n\n\n\n\n\n// label : 36%\n// slider : bar 52% + margin 2% + input 10%\n// combobox : 64%\n// color : 64%\nclass BaseContainer {\n\n constructor() {}\n\n _addLine(name) {\n var domLine = document.createElement(\'li\');\n domLine.innerHTML = name || \'\';\n this.domUl.appendChild(domLine);\n return domLine;\n }\n\n _createLabel(name) {\n var domLabel = document.createElement(\'label\');\n domLabel.className = \'gui-label-side\';\n domLabel.innerHTML = name || \'\';\n return domLabel;\n }\n\n _setDomContainer(container) {\n this.domContainer = container;\n }\n\n addTitle(name) {\n var widget = new widgets_Title__WEBPACK_IMPORTED_MODULE_5__["default"](name);\n this.domUl.appendChild(widget.domText);\n return widget;\n }\n\n addCheckbox(name, valOrObject, callbackOrKey) {\n var widget = new widgets_Checkbox__WEBPACK_IMPORTED_MODULE_1__["default"](valOrObject, callbackOrKey);\n var domLine = this._addLine();\n domLine.className += \' gui-pointerOnHover gui-glowOnHover\';\n var domLabel = this._createLabel(name);\n domLabel.style.overflow = \'visible\';\n domLabel.className += \' gui-pointerOnHover\';\n domLine.appendChild(domLabel);\n domLine.appendChild(widget.domCheckbox);\n domLine.appendChild(widget.domLabelCheckbox);\n domLine.addEventListener(\'mousedown\', widget._onMouseDown.bind(widget));\n widget._setDomContainer(domLine);\n return widget;\n }\n\n addCombobox(name, valOrObject, callbackOrKey, options) {\n var widget = new widgets_Combobox__WEBPACK_IMPORTED_MODULE_3__["default"](valOrObject, callbackOrKey, options);\n var domLine = this._addLine();\n if (name) domLine.appendChild(this._createLabel(name));\n else widget.domSelect.style.width = \'100%\';\n domLine.appendChild(widget.domSelect);\n widget._setDomContainer(domLine);\n return widget;\n }\n\n addSlider(name, valOrObject, callbackOrKey, min, max, step) {\n var widget = new widgets_Slider__WEBPACK_IMPORTED_MODULE_4__["default"](valOrObject, callbackOrKey, min, max, step);\n var domLine = this._addLine();\n if (name) domLine.appendChild(this._createLabel(name));\n domLine.appendChild(widget.domInputText);\n domLine.appendChild(widget.domSlider);\n widget._setDomContainer(domLine);\n return widget;\n }\n\n addColor(name, valOrObject, callbackOrKey) {\n var widget = new widgets_Color__WEBPACK_IMPORTED_MODULE_2__["default"](valOrObject, callbackOrKey);\n var domLine = this._addLine();\n if (name) domLine.appendChild(this._createLabel(name));\n else widget.domColor.style.width = \'100%\';\n domLine.appendChild(widget.domColor);\n widget._setDomContainer(domLine);\n return widget;\n }\n\n addButton(name, callbackOrObject, key) {\n var widget = new widgets_Button__WEBPACK_IMPORTED_MODULE_0__["default"](name, callbackOrObject, key);\n var domLine = this._addLine();\n domLine.appendChild(widget.domButton);\n widget._setDomContainer(domLine);\n return widget;\n }\n\n addDualButton(name1, name2, callbackOrObject1, callbackOrObject2, key1, key2) {\n var widget1 = new widgets_Button__WEBPACK_IMPORTED_MODULE_0__["default"](name1, callbackOrObject1, key1);\n var widget2 = new widgets_Button__WEBPACK_IMPORTED_MODULE_0__["default"](name2, callbackOrObject2, key2);\n var domLine = this._addLine();\n domLine.appendChild(widget2.domButton);\n domLine.appendChild(widget1.domButton);\n var style1 = widget1.domButton.style;\n var style2 = widget2.domButton.style;\n style1.width = style2.width = \'49%\';\n style1.marginRight = style2.marginLeft = \'1%\';\n widget1._setDomContainer(domLine);\n widget2._setDomContainer(domLine);\n return [widget1, widget2];\n }\n\n setVisibility(visible) {\n if (!this.domContainer) return;\n this.domContainer.hidden = !visible;\n }\n}\n\n/* harmony default export */ __webpack_exports__["default"] = (BaseContainer);\n\n\n//# sourceURL=webpack://yagui/./src/containers/BaseContainer.js?')},"./src/containers/Folder.js":function srcContainersFolderJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var containers_BaseContainer__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! containers/BaseContainer */ \"./src/containers/BaseContainer.js\");\n\n\nclass Folder extends containers_BaseContainer__WEBPACK_IMPORTED_MODULE_0__[\"default\"] {\n\n constructor(name) {\n super();\n\n this.domUl = document.createElement('ul');\n this.domUl.setAttribute('opened', true);\n\n var domTitle = document.createElement('label');\n domTitle.innerHTML = name || '';\n\n domTitle.addEventListener('mousedown', this._onMouseDown.bind(this));\n\n this.domUl.appendChild(domTitle);\n this.isOpen = true;\n }\n\n _onMouseDown() {\n this.isOpen = !this.isOpen;\n this.domUl.setAttribute('opened', this.isOpen);\n }\n\n open() {\n this.isOpen = true;\n this.domUl.setAttribute('opened', true);\n }\n\n close() {\n this.isOpen = false;\n this.domUl.setAttribute('opened', false);\n }\n\n setVisibility(visible) {\n if (!visible) this.domUl.setAttribute('opened', false);\n else if (this.isOpen) this.domUl.setAttribute('opened', true);\n this.domUl.style.height = visible ? 'auto' : '0px';\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (Folder);\n\n\n//# sourceURL=webpack://yagui/./src/containers/Folder.js?")},"./src/containers/Menu.js":function srcContainersMenuJs(module,__webpack_exports__,__webpack_require__){"use strict";eval('__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var containers_BaseContainer__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! containers/BaseContainer */ "./src/containers/BaseContainer.js");\n/* harmony import */ var widgets_MenuButton__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! widgets/MenuButton */ "./src/widgets/MenuButton.js");\n\n\n\nclass Menu extends containers_BaseContainer__WEBPACK_IMPORTED_MODULE_0__["default"] {\n\n constructor() {\n super();\n\n this.domUl = document.createElement(\'ul\');\n }\n\n addButton(name, callbackOrObject, shortcutOrKey, shortcut) {\n var widget = new widgets_MenuButton__WEBPACK_IMPORTED_MODULE_1__["default"](callbackOrObject, shortcutOrKey, shortcut);\n var domLine = this._addLine(name);\n domLine.appendChild(widget.domSpan);\n widget._setDomContainer(domLine);\n return widget;\n }\n\n addSlider(name, valOrObject, callbackOrKey, min, max, step) {\n var wid = super.addSlider(name, valOrObject, callbackOrKey, min, max, step);\n // label 36% + slider ?% + 2% + input 18%\n wid.domInputText.style.width = \'18%\';\n wid.domSlider.style.width = name ? \'44%\' : \'80%\';\n return wid;\n }\n}\n\n/* harmony default export */ __webpack_exports__["default"] = (Menu);\n\n\n//# sourceURL=webpack://yagui/./src/containers/Menu.js?')},"./src/containers/Sidebar.js":function srcContainersSidebarJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var containers_Folder__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! containers/Folder */ \"./src/containers/Folder.js\");\n\n\nclass Sidebar {\n\n constructor(callbackResize) {\n this.domSidebar = document.createElement('div');\n this.domSidebar.className = 'gui-sidebar';\n\n this.domResize = document.createElement('div');\n this.domResize.className = 'gui-resize';\n\n this.isDragging = false;\n this.mouseX = 0;\n this.domResize.addEventListener('mousedown', this._onMouseDown.bind(this));\n window.addEventListener('mousemove', this._onMouseMove.bind(this));\n window.addEventListener('mouseup', this._onMouseUp.bind(this));\n\n this.callbackResize = callbackResize;\n this.isOnTheRight = false;\n }\n\n _setTop(nb) {\n this.domSidebar.style.top = this.domResize.style.top = nb + 'px';\n }\n\n _onTheRight() {\n this.isOnTheRight = true;\n this.domSidebar.style.right = 0;\n this.domSidebar.style.borderRight = 0;\n this.domSidebar.style.borderLeft = 'double';\n this.domSidebar.style.borderColor = 'rgba(255,255,255,0.3)';\n this.domResize.style.left = 'auto';\n this.domResize.style.right = this.domSidebar.offsetWidth + 'px';\n this.domResize.style.marginRight = '-3px';\n }\n\n _onMouseDown(ev) {\n this.isDragging = true;\n this.mouseX = ev.clientX;\n }\n\n _updateViewportPosition(viewport) {\n var w = this.domSidebar.hidden ? 0 : this.domSidebar.offsetWidth;\n if (this.isOnTheRight) {\n viewport.style.width = (viewport.clientWidth - w) + 'px';\n } else {\n viewport.style.left = (this.domSidebar.offsetLeft + w) + 'px';\n viewport.style.width = (viewport.clientWidth - w) + 'px';\n }\n }\n\n _onMouseMove(ev) {\n if (this.isDragging === false) return;\n var mouseX = ev.clientX;\n var delta = mouseX - this.mouseX;\n if (this.isOnTheRight) delta = -delta;\n var widthBar = Math.max(50, this.domSidebar.offsetWidth + delta);\n\n var val = widthBar + 'px';\n this.domSidebar.style.width = val;\n if (this.isOnTheRight) this.domResize.style.right = this.domSidebar.offsetWidth + 'px';\n else this.domResize.style.left = val;\n\n this.mouseX = mouseX;\n this.callbackResize();\n }\n\n _onMouseUp() {\n this.isDragging = false;\n }\n\n addMenu(name) {\n var folder = new containers_Folder__WEBPACK_IMPORTED_MODULE_0__[\"default\"](name);\n this.domSidebar.appendChild(folder.domUl);\n return folder;\n }\n\n setVisibility(visible) {\n this.domSidebar.hidden = !visible;\n this.domResize.hidden = !visible;\n this.callbackResize();\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (Sidebar);\n\n\n//# sourceURL=webpack://yagui/./src/containers/Sidebar.js?")},"./src/containers/Topbar.js":function srcContainersTopbarJs(module,__webpack_exports__,__webpack_require__){"use strict";eval('__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var containers_Menu__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! containers/Menu */ "./src/containers/Menu.js");\n/* harmony import */ var utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! utils/EditStyle */ "./src/utils/EditStyle.js");\n\n\n\nclass Topbar {\n\n constructor(callbackResize) {\n this.domTopbar = document.createElement(\'div\');\n this.domTopbar.className = \'gui-topbar\';\n\n this.domUl = document.createElement(\'ul\');\n this.domTopbar.appendChild(this.domUl);\n\n this.callbackResize = callbackResize;\n this.uiExtra = {};\n }\n\n _updateViewportPosition(viewport) {\n var h = this.domTopbar.hidden ? 0 : this.domTopbar.offsetHeight;\n viewport.style.top = h + \'px\';\n viewport.style.height = (viewport.clientHeight - h) + \'px\';\n }\n\n _onChangeColor(callback, color) {\n callback(color);\n this.uiExtra.overallColor.setValue(utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"]._curWidgetColor, true);\n this.uiExtra.widgetColor.setValue(utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"]._curWidgetColor, true);\n this.uiExtra.backColor.setValue(utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"]._curBackgroundColor, true);\n this.uiExtra.textColor.setValue(utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"]._curTextColor, true);\n }\n\n addMenu(name) {\n var menu = new containers_Menu__WEBPACK_IMPORTED_MODULE_0__["default"]();\n var li = document.createElement(\'li\');\n li.setAttribute(\'onclick\', \'void(0)\'); // iOS trick to trigger click on hover\n li.innerHTML = name || \'\';\n this.domUl.appendChild(li);\n li.appendChild(menu.domUl);\n menu._setDomContainer(li);\n return menu;\n }\n\n addExtra() {\n var cb = this._onChangeColor;\n var menu = this.addMenu(\'Extra UI\');\n var ext = this.uiExtra;\n menu.addTitle(\'Overall\');\n ext.overallColor = menu.addColor(\'\', utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"]._curWidgetColor, cb.bind(this, utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"].changeOverallColor));\n\n menu.addTitle(\'Advanced\');\n ext.widgetColor = menu.addColor(\'Widget\', utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"]._curWidgetColor, cb.bind(this, utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"].changeWidgetsColor));\n ext.backColor = menu.addColor(\'Back\', utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"]._curBackgroundColor, cb.bind(this, utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"].changeBackgroundColor));\n ext.textColor = menu.addColor(\'Text\', utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"]._curTextColor, cb.bind(this, utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"].changeTextColor));\n ext.showBorder = menu.addCheckbox(\'Border\', utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"]._curShowBorder, utils_EditStyle__WEBPACK_IMPORTED_MODULE_1__["default"].changeDisplayBoorder);\n return menu;\n }\n\n setVisibility(visible) {\n this.domTopbar.hidden = !visible;\n this.callbackResize();\n }\n}\n\n/* harmony default export */ __webpack_exports__["default"] = (Topbar);\n\n\n//# sourceURL=webpack://yagui/./src/containers/Topbar.js?')},"./src/utils/EditStyle.js":function srcUtilsEditStyleJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! utils/GuiUtils */ \"./src/utils/GuiUtils.js\");\n\n\nvar EditStyle = {};\n\nEditStyle.refRules = {};\n\nvar yaguiSheet;\nvar findSheet = function () {\n if (yaguiSheet) return yaguiSheet;\n var sheets = document.styleSheets;\n for (var i = 0, nb = sheets.length; i < nb; ++i) {\n var href = sheets[i].href;\n if (href && href.indexOf('yagui.css') !== -1) {\n yaguiSheet = sheets[i];\n return yaguiSheet;\n }\n }\n return;\n};\n\nvar editStyle = function (selector, key, value) {\n var sheet = findSheet();\n if (!sheet)\n return;\n var rules = sheet.cssRules || sheet.rules;\n var rule = EditStyle.refRules[selector];\n if (!rule) {\n var i = 0;\n var len = rules.length;\n for (i = 0; i < len; ++i) {\n if (rules[i].selectorText === selector) break;\n }\n if (i === len) return false;\n rule = EditStyle.refRules[selector] = rules[i];\n }\n if (rule)\n rule.style[key] = value;\n};\n\nEditStyle.changeWidgetsColor = function (color) {\n var str = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getStrColor(color);\n // button\n editStyle('.gui-button', 'background', str);\n // select\n editStyle('.gui-select', 'background', str);\n // slider\n editStyle('.gui-slider > div', 'background', str);\n EditStyle._curWidgetColor = color;\n};\n\nEditStyle.changeDisplayBoorder = function (bool) {\n var str = bool ? '1px solid #000' : '0';\n editStyle('.gui-button', 'border', str);\n // select\n editStyle('.gui-select', 'border', str);\n // slider\n editStyle('.gui-slider', 'border', str);\n editStyle('.gui-input-number', 'border', str);\n // folder\n editStyle('.gui-sidebar > ul > label', 'borderTop', str);\n editStyle('.gui-sidebar > ul > label', 'borderBottom', str);\n // side bar\n editStyle('.gui-sidebar', 'borderLeft', str);\n editStyle('.gui-sidebar', 'borderRight', str);\n // top bar\n editStyle('.gui-topbar', 'borderBottom', str);\n EditStyle._curShowBorder = bool;\n};\n\nEditStyle.changeBackgroundColor = function (color) {\n // side bar\n editStyle('.gui-sidebar', 'background', utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getStrColor(color));\n // top bar\n var colTop = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getStrColor(utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getColorMult(color, 0.5));\n editStyle('.gui-topbar', 'background', colTop);\n editStyle('.gui-topbar ul > li > ul', 'background', colTop);\n EditStyle._curBackgroundColor = color;\n};\n\nEditStyle.changeTextColor = function (color) {\n var strColor = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getStrColor(color);\n editStyle('*', 'color', strColor);\n editStyle('.gui-sidebar > ul > label', 'color', strColor);\n EditStyle._curTextColor = color;\n};\n\nEditStyle.changeOverallColor = function (color) {\n EditStyle.changeWidgetsColor(color);\n var bgCol = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getColorMult(color, 0.5);\n bgCol.length = 3;\n EditStyle.changeBackgroundColor(bgCol);\n\n var texCol = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getColorAdd(color, 0.5);\n for (var i = 0; i < 3; ++i) texCol[i] = Math.min(0.8, texCol[i]);\n EditStyle.changeTextColor(texCol);\n\n EditStyle._curWidgetColor = color;\n EditStyle._curBackgroundColor = bgCol;\n EditStyle._curTextColor = texCol;\n};\n\n// init value\nEditStyle._curTextColor = [0.73, 0.73, 0.73, 1.0];\nEditStyle._curWidgetColor = [0.32, 0.37, 0.39, 1.0];\nEditStyle._curBackgroundColor = [0.24, 0.24, 0.24];\nEditStyle._curShowBorder = false;\n\nEditStyle.changeOverallColor([0.3, 0.34, 0.4, 1.0]);\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (EditStyle);\n\n\n//# sourceURL=webpack://yagui/./src/utils/EditStyle.js?")},"./src/utils/GuiUtils.js":function srcUtilsGuiUtilsJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\nvar GuiUtils = {};\n\nGuiUtils.rgbToHsv = function (rgb) {\n var r = rgb[0];\n var g = rgb[1];\n var b = rgb[2];\n var maxRGB = Math.max(r, g, b);\n var minRGB = Math.min(r, g, b);\n if (minRGB === maxRGB) return [0, 0, minRGB];\n var d = (r === minRGB) ? g - b : ((b === minRGB) ? r - g : b - r);\n var h = (r === minRGB) ? 3 : ((b === minRGB) ? 1 : 5);\n return [(h - d / (maxRGB - minRGB)) / 6, (maxRGB - minRGB) / maxRGB, maxRGB];\n};\n\nGuiUtils.hsvToRgb = function (hsv) {\n var h = hsv[0] * 6;\n var s = hsv[1];\n var v = hsv[2];\n var i = Math.floor(h);\n var f = h - i;\n var p = v * (1.0 - s);\n var q = v * (1.0 - f * s);\n var t = v * (1.0 - (1.0 - f) * s);\n var mod = i % 6;\n if (mod === 0) return [v, t, p];\n else if (mod === 1) return [q, v, p];\n else if (mod === 2) return [p, v, t];\n else if (mod === 3) return [p, q, v];\n else if (mod === 4) return [t, p, v];\n else return [v, p, q];\n};\n\nGuiUtils.getValidColor = function (color) {\n for (var i = 0, len = color.length; i < len; ++i) color[i] = Math.max(0.0, Math.min(1.0, color[i]));\n return color;\n};\n\nGuiUtils.getStrColor = function (color) {\n if (color.length === 3) return GuiUtils.rgbToHex(color);\n return 'rgba(' + Math.round(color[0] * 255) + ',' + Math.round(color[1] * 255) + ',' + Math.round(color[2] * 255) + ',' + color[3] + ')';\n};\n\nGuiUtils.getColorMult = function (color, fac) {\n var out = [color[0] * fac, color[1] * fac, color[2] * fac];\n if (color.length === 4) out.push(color[3]);\n return GuiUtils.getValidColor(out);\n};\n\nGuiUtils.getColorAdd = function (color, add) {\n var out = [color[0] + add, color[1] + add, color[2] + add];\n if (color.length === 4) out.push(color[3]);\n return GuiUtils.getValidColor(out);\n};\n\nGuiUtils.rgbToHex = function (rgb) {\n var h = '#';\n for (var i = 0; i < 3; ++i) {\n var c = Math.round(rgb[i] * 255).toString(16);\n h += c.length === 1 ? '0' + c : c;\n }\n return h;\n};\n\nGuiUtils.hexToRgb = function (hex) {\n var i = 0;\n if (hex[0] === '#') hex = hex.slice(1);\n var h = hex;\n if (hex.length > 6) h = hex.slice(0, 6);\n else if (hex.length < 6) {\n h = '';\n for (i = 0; i < 3; ++i)\n h += hex[i] ? hex[i] + hex[i] : '00';\n }\n var col = [0, 0, 0];\n for (i = 0; i < 3; ++i) {\n var c = parseInt(h[i * 2] + h[i * 2 + 1], 16);\n col[i] = (c !== c ? 0 : c) / 255;\n }\n return col;\n};\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (GuiUtils);\n\n\n//# sourceURL=webpack://yagui/./src/utils/GuiUtils.js?")},"./src/widgets/BaseWidget.js":function srcWidgetsBaseWidgetJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\nclass BaseWidget {\n\n constructor() {}\n\n _getInitialValue(valOrObject, callbackOrKey) {\n if (typeof callbackOrKey !== 'string') return valOrObject;\n return valOrObject[callbackOrKey];\n }\n\n _getCheckCallback(valOrObject, callbackOrKey) {\n if (typeof callbackOrKey !== 'string') return callbackOrKey;\n return function (val) {\n valOrObject[callbackOrKey] = val;\n };\n }\n\n _setDomContainer(container) {\n this.domContainer = container;\n }\n\n setCallback(callback) {\n this.callback = callback;\n }\n\n setVisibility(visible) {\n if (!this.domContainer) return;\n this.domContainer.hidden = !visible;\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (BaseWidget);\n\n\n//# sourceURL=webpack://yagui/./src/widgets/BaseWidget.js?")},"./src/widgets/Button.js":function srcWidgetsButtonJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! widgets/BaseWidget */ \"./src/widgets/BaseWidget.js\");\n\n\nclass Button extends widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__[\"default\"] {\n\n constructor(name, callbackOrObject, key) {\n super();\n\n var callback = key ? callbackOrObject[key].bind(callbackOrObject) : callbackOrObject;\n\n this.domButton = document.createElement('button');\n this.domButton.className = 'gui-button';\n this.domButton.innerHTML = name || '';\n this.domButton.addEventListener('click', this._onClick.bind(this));\n\n this.setCallback(callback);\n }\n\n setEnable(bool) {\n this.domButton.disabled = bool === undefined ? false : !bool;\n }\n\n _onClick() {\n if (this.callback) this.callback();\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (Button);\n\n\n//# sourceURL=webpack://yagui/./src/widgets/Button.js?")},"./src/widgets/Checkbox.js":function srcWidgetsCheckboxJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! widgets/BaseWidget */ \"./src/widgets/BaseWidget.js\");\n\n\nclass Checkbox extends widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__[\"default\"] {\n\n constructor(valOrObject, callbackOrKey) {\n super();\n\n var value = this._getInitialValue(valOrObject, callbackOrKey);\n var callback = this._getCheckCallback(valOrObject, callbackOrKey);\n this.domCheckbox = document.createElement('input');\n this.domCheckbox.className = 'gui-input-checkbox';\n this.domCheckbox.type = 'checkbox';\n\n this.domLabelCheckbox = document.createElement('label');\n\n this.setValue(value === undefined ? true : value);\n this.setCallback(callback);\n }\n\n _onMouseDown() {\n this.setValue(!this.domCheckbox.checked);\n }\n\n setValue(val, ignoreCB) {\n this.domCheckbox.checked = val;\n if (!ignoreCB && this.callback) this.callback(val);\n }\n\n getValue() {\n return this.domCheckbox.checked;\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (Checkbox);\n\n\n//# sourceURL=webpack://yagui/./src/widgets/Checkbox.js?")},"./src/widgets/Color.js":function srcWidgetsColorJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! utils/GuiUtils */ \"./src/utils/GuiUtils.js\");\n/* harmony import */ var widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! widgets/BaseWidget */ \"./src/widgets/BaseWidget.js\");\n\n\n\nvar vendors = ['-moz-', '-o-', '-webkit-', '-ms-', ''];\nvar urlAlpha = 'url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAChJREFUeNpiPHPmDAMMGBsbw9lMDDgA6RKM%2F%2F%2F%2Fh3POnj1LCzsAAgwAQtYIcFfEyzkAAAAASUVORK5CYII%3D\")';\n\nclass Color extends widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_1__[\"default\"] {\n\n constructor(valOrObject, callbackOrKey) {\n super();\n\n var value = this._getInitialValue(valOrObject, callbackOrKey);\n var callback = this._getCheckCallback(valOrObject, callbackOrKey);\n if (value) this.color = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getValidColor(value.slice());\n else this.color = [1.0, 0.0, 0.0];\n\n // container\n this.domColor = document.createElement('div');\n this.domColor.className = 'gui-widget-color';\n\n // input text\n this.domInputColor = document.createElement('input');\n this.domPopup = document.createElement('div');\n\n // hue picker\n this.domHue = document.createElement('div');\n this.domHue.className = 'gui-color-hue';\n this.domHueKnob = document.createElement('div');\n this.domHueKnob.className = 'gui-knob-hue';\n\n // saturation picker\n this.domSaturation = document.createElement('div');\n this.domSaturation.className = 'gui-color-saturation';\n var zAlphaSat = document.createElement('div');\n this.domSaturation.appendChild(zAlphaSat);\n this.domSaturationKnob = document.createElement('div');\n this.domSaturationKnob.className = 'gui-knob-saturation';\n\n this.domHue.appendChild(this.domHueKnob);\n this.domPopup.appendChild(this.domSaturationKnob);\n this.domPopup.appendChild(this.domSaturation);\n this.domPopup.appendChild(this.domHue);\n this.domColor.appendChild(this.domInputColor);\n this.domColor.appendChild(this.domPopup);\n\n this._hueGradient(this.domHue);\n this._linearGradient(zAlphaSat, 'top', 'rgba(0,0,0,0)', '#000');\n\n this.domColor.addEventListener('keydown', this._onInputDown.bind(this));\n this.domSaturation.addEventListener('mousedown', this._onSaturationDown.bind(this));\n this.domHue.addEventListener('mousedown', this._onHueDown.bind(this));\n window.addEventListener('mouseup', this._onMouseUp.bind(this));\n window.addEventListener('mouseout', this._onMouseUp.bind(this));\n window.addEventListener('mousemove', this._onMouseMove.bind(this));\n\n // alpha picker\n this.hasAlpha = this.color.length === 4;\n this.alpha = 1.0;\n if (this.hasAlpha) {\n this.domPopup.style.width = '142px';\n this.domAlpha = document.createElement('div');\n this.domAlpha.className = 'gui-color-alpha';\n this.domAlphaKnob = document.createElement('div');\n this.domAlphaKnob.className = 'gui-knob-alpha';\n\n this._alphaGradient(this.domAlpha, 'top', 'rgba(0,0,0,1.0)', 'rgba(0,0,0,0.0)');\n\n this.domAlpha.addEventListener('mousedown', this._onAlphaDown.bind(this));\n this.domAlpha.appendChild(this.domAlphaKnob);\n this.domPopup.appendChild(this.domAlpha);\n }\n\n this.editHue = this.editSaturation = this.editAlpha = false;\n this.widgetHeight = this.widgetWidth = 100;\n this.setValue(this.color);\n this.setCallback(callback);\n }\n\n _onInputDown(ev) {\n ev.stopPropagation();\n if (ev.keyCode === 13)\n this.setValue(utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].hexToRgb(ev.target.value));\n }\n\n _onUpdateSaturation(ev) {\n var rect = this.domSaturation.getBoundingClientRect();\n var hsv = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].rgbToHsv(this.getValue());\n hsv[1] = Math.min(1.0, Math.max(0.0, (ev.clientX - rect.left) / rect.width));\n hsv[2] = Math.min(1.0, Math.max(0.0, 1.0 - (ev.clientY - rect.top) / rect.width));\n this.setValue(utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].hsvToRgb(hsv), false, true);\n this._updateGui();\n }\n\n _onUpdateHue(ev) {\n var rect = this.domHue.getBoundingClientRect();\n var hsv = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].rgbToHsv(this.getValue());\n hsv[0] = Math.min(1.0, Math.max(0.0, 1.0 - (ev.clientY - rect.top) / rect.height));\n this.setValue(utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].hsvToRgb(hsv), false, true);\n this._updateGui();\n }\n\n _onUpdateAlpha(ev) {\n var rect = this.domAlpha.getBoundingClientRect();\n var col = this.getValue();\n col[3] = this.alpha = Math.min(1.0, Math.max(0.0, 1.0 - (ev.clientY - rect.top) / rect.height));\n this.setValue(col, false, true);\n this._updateGui();\n }\n\n _updateGui() {\n var color = this.getValue();\n var hsv = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].rgbToHsv(color);\n\n this.domSaturationKnob.style.marginLeft = this.widgetWidth * hsv[1] - 7 + 'px';\n this.domSaturationKnob.style.marginTop = this.widgetHeight * (1.0 - hsv[2]) - 7 + 'px';\n\n hsv[1] = hsv[2] = 1.0;\n this._linearGradient(this.domSaturation, 'left', '#fff', utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getStrColor(utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].hsvToRgb(hsv)));\n\n this.domHueKnob.style.marginTop = (1.0 - hsv[0]) * this.widgetHeight + 'px';\n\n if (this.hasAlpha && color[3] !== undefined)\n this.domAlphaKnob.style.marginTop = (1.0 - this.alpha) * this.widgetHeight + 'px';\n }\n\n _onMouseMove(ev) {\n if (!this.editSaturation && !this.editHue && !this.editAlpha) return;\n if (this.editSaturation) return this._onUpdateSaturation(ev);\n if (this.editHue) return this._onUpdateHue(ev);\n if (this.editAlpha) return this._onUpdateAlpha(ev);\n }\n\n _onSaturationDown(ev) {\n this.editSaturation = true;\n this._onMouseMove(ev);\n }\n\n _onHueDown(ev) {\n this.editHue = true;\n this._onMouseMove(ev);\n }\n\n _onAlphaDown(ev) {\n this.editAlpha = true;\n this._onMouseMove(ev);\n }\n\n _onMouseUp() {\n this.editHue = this.editSaturation = this.editAlpha = false;\n }\n\n _hueGradient(dom) {\n dom.style.background = '';\n for (var i = 0, l = vendors.length; i < l; ++i)\n dom.style.cssText += 'background: ' + vendors[i] + 'linear-gradient(top, #ff0000 0%, #ff00ff 17%, #0000ff 34%, #00ffff 50%, #00ff00 67%, #ffff00 84%, #ff0000 100%);';\n }\n\n _alphaGradient(dom, dir, col1, col2) {\n dom.style.background = '';\n for (var i = 0, l = vendors.length; i < l; ++i)\n dom.style.cssText += 'background: ' + vendors[i] + 'linear-gradient(' + dir + ', ' + col1 + ',' + col2 + '),' + urlAlpha + ';';\n }\n\n _linearGradient(dom, dir, col1, col2) {\n dom.style.background = '';\n for (var i = 0, l = vendors.length; i < l; ++i)\n dom.style.cssText += 'background: ' + vendors[i] + 'linear-gradient(' + dir + ', ' + col1 + ' 0%, ' + col2 + ' 100%);';\n }\n\n setValue(color, ignoreCB, ignoreUI) {\n var c = this.color;\n for (var i = 0, nbC = color.length; i < nbC; ++i)\n c[i] = color[i];\n\n var hex = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].rgbToHex(color);\n this.domInputColor.value = hex;\n if (this.hasAlpha) {\n if (color.length >= 4) this.alpha = color[3];\n else color.push(this.alpha);\n var col = 'rgba(' + parseInt(color[0] * 255, 10) + ',' + parseInt(color[1] * 255, 10) + ',' + parseInt(color[2] * 255, 10) + ',' + this.alpha + ')';\n this._alphaGradient(this.domInputColor, '0deg', col, col);\n } else {\n this.domInputColor.style.background = hex;\n }\n\n // color of text\n var hsv = utils_GuiUtils__WEBPACK_IMPORTED_MODULE_0__[\"default\"].rgbToHsv(color);\n this.domSaturationKnob.style.borderColor = (hsv[2] < 0.5 || hsv[1] > 0.5) ? '#fff' : '#000';\n this.domInputColor.style.color = (this.alpha > 0.2 && (hsv[2] < 0.5 || hsv[1] > 0.5)) ? '#fff' : '#000';\n if (!ignoreUI) this._updateGui();\n if (!ignoreCB && this.callback) this.callback(color);\n }\n\n getValue() {\n return this.color;\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (Color);\n\n\n//# sourceURL=webpack://yagui/./src/widgets/Color.js?")},"./src/widgets/Combobox.js":function srcWidgetsComboboxJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! widgets/BaseWidget */ \"./src/widgets/BaseWidget.js\");\n\n\nclass Combobox extends widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__[\"default\"] {\n\n constructor(valOrObject, callbackOrKey, options) {\n super();\n\n var value = this._getInitialValue(valOrObject, callbackOrKey);\n var callback = this._getCheckCallback(valOrObject, callbackOrKey);\n options = options || {};\n value = value !== undefined ? value : options[0];\n\n this.isArray = options.length !== undefined;\n\n this.domSelect = document.createElement('select');\n this.domSelect.className = 'gui-select';\n this.addOptions(options);\n\n this.domSelect.addEventListener('change', this._onChange.bind(this));\n this.setValue(value);\n this.setCallback(callback);\n }\n\n _parseValue(val) {\n return this.isArray ? parseInt(val, 10) : val;\n }\n\n _onChange(ev) {\n this.setValue(ev.target.value);\n }\n\n addOptions(options) {\n var keys = Object.keys(options);\n for (var i = 0; i < keys.length; ++i) {\n var opt = document.createElement('option');\n opt.innerHTML = options[keys[i]];\n opt.value = keys[i];\n this.domSelect.appendChild(opt);\n }\n }\n\n setValue(val, ignoreCB) {\n this.domSelect.value = val;\n if (!ignoreCB && this.callback) this.callback(this._parseValue(val));\n }\n\n getValue() {\n return this._parseValue(this.domSelect.value);\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (Combobox);\n\n\n//# sourceURL=webpack://yagui/./src/widgets/Combobox.js?")},"./src/widgets/MenuButton.js":function srcWidgetsMenuButtonJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! widgets/BaseWidget */ \"./src/widgets/BaseWidget.js\");\n\n\nclass MenuButton extends widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__[\"default\"] {\n\n constructor(callbackOrObject, shortcutOrKey, shortcut) {\n super();\n\n var callback = callbackOrObject;\n if (callback && typeof callback !== 'function') callback = callbackOrObject[shortcutOrKey].bind(callbackOrObject);\n else shortcut = shortcutOrKey;\n\n this.domSpan = document.createElement('span');\n this.domSpan.className = 'shortcut';\n this.domSpan.innerHTML = shortcut || '';\n\n this.setCallback(callback);\n }\n\n _setDomContainer(container) {\n this.domContainer = container;\n container.addEventListener('click', this._onClick.bind(this));\n }\n\n _onClick() {\n if (this.callback) this.callback();\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (MenuButton);\n\n\n//# sourceURL=webpack://yagui/./src/widgets/MenuButton.js?")},"./src/widgets/Slider.js":function srcWidgetsSliderJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! widgets/BaseWidget */ \"./src/widgets/BaseWidget.js\");\n\n\nclass Slider extends widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__[\"default\"] {\n\n constructor(valOrObject, callbackOrKey, min, max, step) {\n super();\n\n var value = this._getInitialValue(valOrObject, callbackOrKey);\n var callback = this._getCheckCallback(valOrObject, callbackOrKey);\n value = value !== undefined ? value : 100;\n min = min !== undefined ? min : 0;\n max = max !== undefined ? max : 200;\n step = step !== undefined ? step : 1;\n\n // slider\n this.domSlider = document.createElement('div');\n this.domSlider.className = 'gui-slider';\n this.domSliderFill = document.createElement('div');\n this.domSlider.appendChild(this.domSliderFill);\n\n // text input\n this.domInputText = document.createElement('input');\n this.domInputText.className = 'gui-input-number';\n this.domInputText.type = 'number';\n this.min = this.domInputText.min = min;\n this.max = this.domInputText.max = max;\n this.step = this.domInputText.step = step;\n\n this.domInputText.addEventListener('keydown', this._onKeyDown.bind(this));\n this.domInputText.addEventListener('change', this._onInputText.bind(this));\n this.domInputText.addEventListener('blur', this._onInputText.bind(this));\n this.domSlider.addEventListener('mousedown', this._onMouseDown.bind(this));\n window.addEventListener('mouseup', this._onMouseUp.bind(this), true);\n window.addEventListener('mousemove', this._onMouseMove.bind(this));\n\n this.lastValue = value;\n this.isDown = false;\n this.setValue(value);\n this.setCallback(callback);\n }\n\n _onInputText(ev) {\n var val = parseFloat(ev.target.value);\n if (val !== val || val === this.lastValue) return;\n this.setValue(val);\n }\n\n _onKeyDown(ev) {\n ev.stopPropagation();\n if (ev.which === 13) // enter\n this.domInputText.blur();\n }\n\n _onMouseMove(ev) {\n ev.preventDefault();\n if (!this.isDown)\n return;\n var rect = this.domSlider.getBoundingClientRect();\n var val = this.min + (this.max - this.min) * ((ev.clientX - rect.left) / rect.width);\n this.setValue(val);\n }\n\n _onMouseDown(ev) {\n this.isDown = true;\n this._onMouseMove(ev);\n }\n\n _onMouseUp() {\n this.isDown = false;\n }\n\n _setDomContainer(container) {\n this.domContainer = container;\n }\n\n getValue() {\n return parseFloat(this.domInputText.value);\n }\n\n setValue(val, ignoreCB) {\n this.lastValue = val;\n val = Math.max(Math.min(val, this.max), this.min);\n val = Math.round(val / this.step) * this.step;\n this.domInputText.value = val;\n var per = this.min;\n if (this.max !== this.min) per = (val - this.min) / (this.max - this.min);\n this.domSliderFill.style.width = 100 * per + '%';\n if (!ignoreCB && this.callback) this.callback(val);\n }\n\n setMax(max) {\n this.domInputText.max = this.max = max;\n return this;\n }\n\n setMin(min) {\n this.min = min;\n return this;\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (Slider);\n\n\n//# sourceURL=webpack://yagui/./src/widgets/Slider.js?")},"./src/widgets/Title.js":function srcWidgetsTitleJs(module,__webpack_exports__,__webpack_require__){"use strict";eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! widgets/BaseWidget */ \"./src/widgets/BaseWidget.js\");\n\n\nclass Title extends widgets_BaseWidget__WEBPACK_IMPORTED_MODULE_0__[\"default\"] {\n\n constructor(name) {\n super();\n\n this.domText = document.createElement('div');\n this.domText.innerHTML = name || '';\n this.domText.className = 'group-title';\n }\n\n setText(text) {\n this.domText.innerHTML = text;\n }\n\n setVisibility(visible) {\n this.domText.hidden = !visible;\n }\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (Title);\n\n\n//# sourceURL=webpack://yagui/./src/widgets/Title.js?")},"./src/yagui.js":function srcYaguiJs(module,exports,__webpack_require__){eval('var GuiMain = __webpack_require__(/*! GuiMain */ "./src/GuiMain.js").default;\n\nvar yagui = {\n GuiMain: GuiMain\n};\n\nmodule.exports = yagui;\n\n\n//# sourceURL=webpack://yagui/./src/yagui.js?')}})},"object"===_typeof(exports)&&"object"===_typeof(module)?module.exports=factory():(__WEBPACK_AMD_DEFINE_ARRAY__=[],void 0===(__WEBPACK_AMD_DEFINE_RESULT__="function"==typeof(__WEBPACK_AMD_DEFINE_FACTORY__=factory)?__WEBPACK_AMD_DEFINE_FACTORY__.apply(exports,__WEBPACK_AMD_DEFINE_ARRAY__):__WEBPACK_AMD_DEFINE_FACTORY__)||(module.exports=__WEBPACK_AMD_DEFINE_RESULT__))},183:(e,t,r)=>{"use strict";r.d(t,{Z:()=>u});var i=r(52);var n;function a(e,t,r){return t in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}var s=(a(n={backgroundTitle:"背景",backgroundReset:"重設",backgroundImport:"匯入 (jpg, png...)",backgroundFill:"填充",cameraTitle:"鏡頭",cameraReset:"檢視",cameraCenter:"重設 (空白鍵)",cameraFront:"前視角 (F)",cameraLeft:"左視角 (L)",cameraTop:"俯視角 (T)",cameraMode:"模式",cameraOrbit:"軌道 (轉盤)",cameraSpherical:"球面 (軌跡球)",cameraPlane:"平面 (軌跡球)",cameraProjection:"投影",cameraPerspective:"透視角",cameraOrthographic:"等視角",cameraFov:"視野範圍",cameraPivot:"選擇軸心",fileTitle:"檔案 (匯入/匯出)",fileImportTitle:"匯入",fileAdd:"加入 (obj, sgl, ply, stl)",fileAutoMatrix:"縮放並置中",fileVertexSRGB:"sRGB 頂點色彩",fileExportSceneTitle:"匯出場景",fileExportAll:"匯出出口",fileExportSGL:"儲存 .sgl (SculptGL)",fileExportOBJ:"儲存 .obj",fileExportPLY:"儲存 .ply",fileExportSTL:"儲存 .stl",fileExportTextureTitle:null,fileExportTextureSize:null,fileExportColor:null,fileExportRoughness:null,fileExportMetalness:null,sceneTitle:"場景",sceneReset:"清除場景",sceneResetConfirm:"確認清除場景",sceneAddSphere:"加入球體",sceneAddCube:"加入立方體",sceneAddCylinder:"加入圓柱",sceneAddTorus:"加入圓環",sceneSelection:"選取項目",sceneMerge:"合併選取項目",sceneDuplicate:"刪除選擇"},"sceneDuplicate",null),a(n,"meshTitle","網面"),a(n,"meshNbVertices","頂點 : "),a(n,"meshNbFaces","面 : "),a(n,"topologyTitle","網面結構(拓撲)"),a(n,"multiresTitle","多重解析度"),a(n,"multiresSubdivide","細分"),a(n,"multiresReverse","反轉"),a(n,"multiresResolution","解析度"),a(n,"multiresNoLower","沒有更低等級的解析度。"),a(n,"multiresNoHigher","沒有更高等級的解析度。"),a(n,"multiresDelHigher","刪除較高等級"),a(n,"multiresDelLower","刪除較低等級"),a(n,"multiresSelectLowest","反轉前請先選擇最低的解析度。"),a(n,"multiresSelectHighest","細分前請先選擇最高的解析度。"),a(n,"multiresWarnBigMesh",(function(e){return"下一個細分等級會達到 "+e+" 個面。\n若你清楚你自己正在做什麼,再點擊「細分」一次。"})),a(n,"multiresNotReversible","抱歉,無法反轉此網面。\n此網面不是由流形網面經過細分曲面 (loop-catmull) 而來。"),a(n,"remeshTitle","立體像素網面重構"),a(n,"remeshRemesh","網面重構"),a(n,"remeshResolution","解析度"),a(n,"remeshBlock","塊狀重構"),a(n,"dynamicTitle","動態網面結構"),a(n,"dynamicActivated","啟用 (無四邊形)"),a(n,"dynamicSubdivision","細分"),a(n,"dynamicDecimation","削減面數"),a(n,"dynamicLinear","線性細分"),a(n,"sculptTitle","雕刻和塗繪"),a(n,"sculptBrush","筆刷"),a(n,"sculptInflate","膨脹"),a(n,"sculptTwist","扭轉"),a(n,"sculptSmooth","平滑 (-Shift)"),a(n,"sculptFlatten","抹平"),a(n,"sculptPinch","捏塑"),a(n,"sculptCrease","皺褶"),a(n,"sculptDrag","拖拉"),a(n,"sculptPaint","塗繪"),a(n,"sculptMasking","遮罩 (-Ctrl)"),a(n,"sculptMove","移動"),a(n,"sculptLocalScale","局部縮放"),a(n,"sculptTransform","變形 (E)"),a(n,"sculptCommon","通用"),a(n,"sculptTool","工具"),a(n,"sculptSymmetry","對稱"),a(n,"sculptContinuous","連續"),a(n,"sculptRadius","半徑 (-X)"),a(n,"sculptIntensity","強度 (-C)"),a(n,"sculptHardness","硬度"),a(n,"sculptCulling","薄曲面 (僅影響前面頂點)"),a(n,"sculptAlphaTitle","透明色版 (Alpha)"),a(n,"sculptLockPositon","鎖定位置"),a(n,"sculptAlphaTex","紋理"),a(n,"sculptImportAlpha","匯入 alpha 紋理 (jpg, png...)"),a(n,"sculptNegative","反向 (N 或 -Alt)"),a(n,"sculptColor","反照率"),a(n,"sculptRoughness","粗糙度"),a(n,"sculptMetallic","金屬性"),a(n,"sculptClay","黏土"),a(n,"sculptAccumulate","累積 (每道筆劃無限制)"),a(n,"sculptColorGlobal","總體"),a(n,"sculptPickColor","選擇材質或顏色 (-S)"),a(n,"sculptTangentialSmoothing","僅放鬆"),a(n,"sculptTopologicalCheck","網面結構檢查"),a(n,"sculptMoveAlongNormal","沿法線方向移動 (N 或 -Alt)"),a(n,"sculptMaskingClear","清除 (-Ctrl + 拖動)"),a(n,"sculptMaskingInvert","反轉 (-Ctrl + 點擊)"),a(n,"sculptMaskingBlur","模糊"),a(n,"sculptMaskingSharpen","銳利化"),a(n,"sculptPBRTitle","PBR 材質"),a(n,"sculptPaintAll","塗繪全部"),a(n,"sculptExtractTitle","提取"),a(n,"sculptExtractThickness","厚度"),a(n,"sculptExtractAction","提取 !"),a(n,"stateTitle","記錄"),a(n,"stateUndo","復原"),a(n,"stateRedo","取消復原"),a(n,"stateMaxStack","最大推疊"),a(n,"pressureTitle","感壓繪圖板"),a(n,"pressureRadius","半徑感壓"),a(n,"pressureIntensity","強度感壓"),a(n,"renderingTitle","圖形繪算"),a(n,"renderingGrid","顯示格線"),a(n,"renderingSymmetryLine","顯示鏡像線"),a(n,"renderingMatcap","材質捕捉 (Matcap)"),a(n,"renderingCurvature","曲率"),a(n,"renderingPBR","物理式繪算(PBR)"),a(n,"renderingTransparency","透明"),a(n,"renderingNormal","法線著色器"),a(n,"renderingUV","UV 著色器"),a(n,"renderingShader","著色器"),a(n,"renderingMaterial","材質"),a(n,"renderingImportUV","匯入 (jpg, png...)"),a(n,"renderingImportMatcap","匯入 (jpg, png...)"),a(n,"renderingExtra","額外"),a(n,"renderingFlat","平整面"),a(n,"renderingWireframe","線框 (W)"),a(n,"renderingExposure","曝光"),a(n,"renderingEnvironment","環境"),a(n,"renderingIsolate","隔離/顯示 (I)"),a(n,"renderingFilmic","電影色調對應"),a(n,"contour","輪廓"),a(n,"contourShow","顯示輪廓"),a(n,"contourColor","顏色"),a(n,"darkenUnselected","未選取部分變暗"),a(n,"resolution","解析度"),a(n,"matcapPearl","珍珠"),a(n,"matcapClay","黏土"),a(n,"matcapSkin","膚色"),a(n,"matcapGreen","綠色"),a(n,"matcapWhite","白色"),a(n,"sketchfabTitle","前往 Sketchfab !"),a(n,"sketchfabUpload","上傳"),a(n,"sketchfabUploadMessage",'請輸入你的 sketchfab API 密鑰.\n你也可以不填寫留下 "guest" 進行匿名上傳。\n(當上傳中和完成時會跳出新視窗)'),a(n,"sketchfabUploadError",(function(e){return"Sketchfab 上傳錯誤 :\n"+e})),a(n,"sketchfabUploadSuccess","上傳成功 !\n這是你的連結 :"),a(n,"sketchfabAbort","中止最後上傳的項目 ?"),a(n,"sketchfabUploadProcessing","處理中...\n你的模型將會存放在:"),a(n,"about","關於和說明"),a(n,"alphaNone","無"),a(n,"alphaSquare","方塊"),a(n,"alphaSkin","皮膚"),n),o=function e(t){var r=e.languages[e.select][t]||e.languages.english[t];return"string"==typeof r?r:"function"==typeof r?r.apply(this,Array.prototype.slice.call(arguments,1)):(console.error("No TR found for : "+t),t)};o.languages={english:{backgroundTitle:"Background",backgroundReset:"Reset",backgroundImport:"Import (jpg, png...)",backgroundFill:"Fill",cameraTitle:"Camera",cameraReset:"View",cameraCenter:"Reset (bar)",cameraFront:"Front (F)",cameraLeft:"Left (L)",cameraTop:"Top (T)",cameraMode:"Mode",cameraOrbit:"Orbit (Turntable)",cameraSpherical:"Spherical (Trackball)",cameraPlane:"Plane (Trackball)",cameraProjection:"Projection",cameraPerspective:"Perspective",cameraOrthographic:"Orthographic",cameraFov:"Fov",cameraPivot:"Picking pivot",fileTitle:"Files (import/export)",fileImportTitle:"Import",fileAdd:"Add (obj, sgl, ply, stl)",fileAutoMatrix:"Scale and center",fileVertexSRGB:"sRGB vertex color",fileExportSceneTitle:"Export Scene",fileExportAll:"Export all",fileExportSGL:"Save .sgl (SculptGL)",fileExportOBJ:"Save .obj",fileExportPLY:"Save .ply",fileExportSTL:"Save .stl",fileExportTextureTitle:"Export textures",fileExportTextureSize:"Size",fileExportColor:"Save diffuse",fileExportRoughness:"Save roughness",fileExportMetalness:"Save metalness",sceneTitle:"Scene",sceneReset:"Clear scene",sceneResetConfirm:"Confirm clear scene",sceneAddSphere:"Add sphere",sceneAddCube:"Add cube",sceneAddCylinder:"Add cylinder",sceneAddTorus:"Add torus",sceneSelection:"Selection",sceneMerge:"Merge selection",sceneDuplicate:"Copy selection",sceneDelete:"Delete selection",meshTitle:"Mesh",meshNbVertices:"Vertex : ",meshNbFaces:"Faces : ",topologyTitle:"Topology",multiresTitle:"Multiresolution",multiresSubdivide:"Subdivide",multiresReverse:"Reverse",multiresResolution:"Resolution",multiresNoLower:"There is no lower resolution level.",multiresNoHigher:"There is no higher resolution level.",multiresDelHigher:"Delete higher",multiresDelLower:"Delete lower",multiresSelectLowest:"Select the lowest resolution before reversing.",multiresSelectHighest:"Select the highest resolution before subdividing.",multiresWarnBigMesh:function(e){return"The next subdivision level will reach "+e+' faces.\nIf you know what you are doing, click again on "subdivide".'},multiresNotReversible:"Sorry it is not possile to reverse this mesh.\nThe mesh is not a product of a (loop-catmull) subdivision surface on a manifold mesh.",remeshTitle:"Voxel remeshing (quads)",remeshRemesh:"Remesh",remeshResolution:"Resolution",remeshBlock:"Block",dynamicTitle:"Dynamic Topology",dynamicActivated:"Activated (no quads)",dynamicSubdivision:"Subdivision",dynamicDecimation:"Decimation",dynamicLinear:"Linear subdivision",sculptTitle:"Sculpting & Painting",sculptBrush:"Brush",sculptInflate:"Inflate",sculptTwist:"Twist",sculptSmooth:"Smooth (-Shift)",sculptFlatten:"Flatten",sculptPinch:"Pinch",sculptCrease:"Crease",sculptDrag:"Drag",sculptPaint:"Paint",sculptMasking:"Masking (-Ctrl)",sculptMove:"Move",sculptLocalScale:"Local scale",sculptTransform:"Transform (E)",sculptPen:"Pen",sculptCommon:"Common",sculptTool:"Tool",sculptSymmetry:"Symmetry",sculptContinuous:"Continuous",sculptRadius:"Radius (-X)",sculptIntensity:"Intensity (-C)",sculptHardness:"Hardness",sculptCulling:"Thin surface (front vertex only)",sculptAlphaTitle:"Alpha",sculptLockPositon:"Lock position",sculptAlphaTex:"Texture",sculptImportAlpha:"Import alpha tex (jpg, png...)",sculptNegative:"Negative (N or -Alt)",sculptColor:"Albedo",sculptRoughness:"Roughness",sculptMetallic:"Metallic",sculptClay:"Clay",sculptAccumulate:"Accumulate (no limit per stroke)",sculptColorGlobal:"Global",sculptPickColor:"Material / Color picker (-S)",sculptTangentialSmoothing:"Relax only",sculptTopologicalCheck:"Topological check",sculptMoveAlongNormal:"Move along normal (N or -Alt)",sculptMaskingClear:"Clear (-Ctrl + Drag)",sculptMaskingInvert:"Invert (-Ctrl + Click)",sculptMaskingBlur:"Blur",sculptMaskingSharpen:"Sharpen",sculptPBRTitle:"PBR materials",sculptPaintAll:"Paint all",sculptExtractTitle:"Extract",sculptExtractThickness:"Thickness",sculptExtractAction:"Extract !",penMode:"Pen mode",penModePoints:"Points",penModeStroke:"Stroke",penTitle:"Pen",penEnable:"Enable pen tool",penCloseLoop:"Close loop",penSpacing:"Spacing",penClear:"Clear stroke",penNewSet:"New set",penFinishSet:"Finish set",penFeatureList:"Point sets",penNoFeatures:"No feature sets yet",penClosed:"Closed",padsTrimsTitle:"Pads & Trims",padsTrimsSourceSets:"Point sets",padsTrimsCreatePad:"Create pad",padsTrimsCreateTrim:"Create trim",padsTrimsPadHeight:"Pad height",padsTrimsTrimDepth:"Trim depth",padsTrimsPadDirection:"Pad direction",padsTrimsTrimDirection:"Trim direction",padsTrimsDirIn:"In",padsTrimsDirOut:"Out",padsTrimsDirBoth:"Both",padsTrimsFeatureSets:"Volumes",padsTrimsFrom:"from",padsTrimsPadLabel:"Pad",padsTrimsTrimLabel:"Trim",padsTrimsHeight:"height",padsTrimsDepth:"depth",stateTitle:"History",stateUndo:"Undo",stateRedo:"Redo",stateMaxStack:"Max Stack",pressureTitle:"Tablet pressure",pressureRadius:"Pressure radius",pressureIntensity:"Pressure intensity",renderingTitle:"Rendering",renderingGrid:"Show grid",renderingSymmetryLine:"Show mirror line",renderingMatcap:"Matcap",renderingCurvature:"Curvature",renderingPBR:"PBR",renderingTransparency:"Transparency",renderingNormal:"Normal shader",renderingUV:"UV shader",renderingShader:"Shader",renderingMaterial:"Material",renderingImportUV:"Import (jpg, png...)",renderingImportMatcap:"Import (jpg, png...)",renderingExtra:"Extra",renderingFlat:"Flat shading",renderingWireframe:"Wireframe (W)",renderingSectionCut:"Section cut",renderingSectionAxis:"Axis",renderingSectionOffset:"Offset",renderingSectionInvert:"Invert",renderingExposure:"Exposure",renderingEnvironment:"Environment",renderingIsolate:"Isolate/Show (I)",renderingFilmic:"Filmic tonemapping",contour:"Contour",contourShow:"Show contour",contourColor:"Color",darkenUnselected:"Darken unselected",resolution:"Resolution",matcapPearl:"Pearl",matcapClay:"Clay",matcapSkin:"Skin",matcapGreen:"Green",matcapWhite:"White",sketchfabTitle:"Go to Sketchfab !",sketchfabUpload:"Upload",sketchfabUploadMessage:'Please enter your sketchfab API Key.\nYou can also leave "guest" to upload anonymously.\n(a new window will pop up when the uploading and processing is finished)',sketchfabUploadError:function(e){return"Sketchfab upload error :\n"+e},sketchfabUploadSuccess:"Upload success !\nHere is your link :",sketchfabAbort:"Abort the last upload ?",sketchfabUploadProcessing:"Processing...\nYour model will be available at :",about:"About & Help",alphaNone:"None",alphaSquare:"Square",alphaSkin:"Skin",remeshTitleMC:"Voxel remeshing (manifold tris)",remeshRemeshMC:"Remesh",remeshSmoothingMC:"Relax topology"},日本語:{backgroundTitle:"バックグラウンド",backgroundReset:"リセット",backgroundImport:"インポート (jpg, png...)",backgroundFill:null,cameraTitle:"カメラ",cameraReset:"View",cameraCenter:null,cameraFront:null,cameraLeft:null,cameraTop:null,cameraMode:"モード",cameraOrbit:null,cameraSpherical:"球体 (Trackball)",cameraPlane:"平面 (Trackball)",cameraProjection:"タイプ",cameraPerspective:"透視投影",cameraOrthographic:"平行投影",cameraFov:"視野",cameraPivot:"ピボット選択",fileTitle:"ファイル (インポート/エクスポート)",fileImportTitle:null,fileAdd:"Add (obj, sgl, ply, stl)",fileAutoMatrix:null,fileVertexSRGB:null,fileExportSceneTitle:null,fileExportAll:null,fileExportSGL:null,fileExportOBJ:null,fileExportPLY:null,fileExportSTL:null,fileExportTextureTitle:null,fileExportTextureSize:null,fileExportColor:null,fileExportRoughness:null,fileExportMetalness:null,sceneTitle:null,sceneReset:null,sceneAddSphere:null,sceneAddCube:null,sceneAddCylinder:null,sceneAddTorus:null,sceneSelection:null,sceneMerge:null,sceneDuplicate:null,meshTitle:"メッシュ",meshNbVertices:null,meshNbFaces:null,topologyTitle:null,multiresTitle:null,multiresSubdivide:null,multiresReverse:null,multiresResolution:null,multiresNoLower:null,multiresNoHigher:null,multiresDelHigher:null,multiresDelLower:null,multiresSelectLowest:null,multiresSelectHighest:null,multiresWarnBigMesh:null,multiresNotReversible:null,remeshTitle:null,remeshRemesh:null,remeshResolution:null,remeshBlock:null,dynamicTitle:null,dynamicActivated:null,dynamicSubdivision:null,dynamicDecimation:null,dynamicLinear:null,sculptTitle:null,sculptBrush:"ブラシ",sculptInflate:"膨張",sculptTwist:"回転",sculptSmooth:"スムーズ化 (-Shift)",sculptFlatten:"フラット化",sculptPinch:"つまむ",sculptCrease:"しわ",sculptDrag:"ドラッグ",sculptPaint:"ペイント",sculptMasking:null,sculptMove:null,sculptLocalScale:null,sculptTransform:null,sculptCommon:null,sculptTool:"ツール",sculptSymmetry:"対称加工",sculptContinuous:"連続加工",sculptRadius:"半径 (-X)",sculptIntensity:"明るさ (-C)",sculptHardness:null,sculptCulling:null,sculptAlphaTitle:null,sculptLockPositon:null,sculptAlphaTex:null,sculptImportAlpha:null,sculptNegative:"ネガティブ (N or -Alt)",sculptColor:null,sculptRoughness:null,sculptMetallic:null,sculptClay:"クレイ",sculptAccumulate:null,sculptColorGlobal:null,sculptPickColor:null,sculptTangentialSmoothing:null,sculptTopologicalCheck:null,sculptMoveAlongNormal:null,sculptMaskingClear:null,sculptMaskingInvert:null,sculptMaskingBlur:null,sculptMaskingSharpen:null,sculptPBRTitle:null,sculptPaintAll:null,sculptExtractTitle:null,sculptExtractThickness:null,sculptExtractAction:null,stateTitle:"履歴",stateUndo:"アンドゥ",stateRedo:"リドゥ",stateMaxStack:null,pressureTitle:"ワコムタブレット",pressureRadius:"圧力半径?",pressureIntensity:"圧力の強さ?",renderingTitle:null,renderingGrid:null,renderingSymmetryLine:null,renderingMatcap:null,renderingCurvature:null,renderingPBR:null,renderingTransparency:"透過",renderingNormal:"ノーマル",renderingUV:null,renderingShader:"シェーダー",renderingMaterial:null,renderingImportUV:null,renderingImportMatcap:null,renderingExtra:null,renderingFlat:"フラットシェーディング",renderingWireframe:"ワイヤーフレーム (W)",renderingExposure:null,renderingEnvironment:null,renderingIsolate:null,renderingFilmic:null,contour:null,contourShow:null,contourColor:null,darkenUnselected:null,resolution:null,matcapPearl:null,matcapClay:"クレイ",matcapSkin:"スキン",matcapGreen:null,matcapWhite:null,sketchfabTitle:"Sketchfabへ移動",sketchfabUpload:"アップロード",sketchfabUploadMessage:null,sketchfabUploadError:null,sketchfabUploadSuccess:null,sketchfabAbort:null,sketchfabUploadProcessing:null,about:null,alphaNone:null,alphaSquare:null,alphaSkin:null},中文:s,한국어:{backgroundTitle:"배경",backgroundReset:"초기화",backgroundImport:"가져오기 (jpg, png...)",backgroundFill:"화면 가득 채우기",cameraTitle:"카메라",cameraReset:"뷰",cameraCenter:"초기화 (bar)",cameraFront:"앞쪽 (F)",cameraLeft:"왼쪽 (L)",cameraTop:"위쪽 (T)",cameraMode:"조작방식",cameraOrbit:"궤도 (Turntable)",cameraSpherical:"구형 (Trackball)",cameraPlane:"평면 (Trackball)",cameraProjection:"사영법",cameraPerspective:"투시원근법",cameraOrthographic:"정사영법",cameraFov:"시야각",cameraPivot:"피봇 찍기",fileTitle:"파일 (가져오기/내보내기)",fileImportTitle:"가져오기",fileAdd:"추가 (obj, sgl, ply, stl)",fileAutoMatrix:"크기조정 및 중앙정렬",fileVertexSRGB:"sRGB 버텍스 색상",fileExportSceneTitle:"장면 내보내기",fileExportAll:"모두 내보내기",fileExportSGL:".sgl 파일로 저장하기",fileExportOBJ:".obj 파일로 저장하기",fileExportPLY:".ply 파일로 저장하기",fileExportSTL:".stl 파일로 저장하기",fileExportTextureTitle:null,fileExportTextureSize:null,fileExportColor:null,fileExportRoughness:null,fileExportMetalness:null,sceneTitle:"장면",sceneReset:"모두 없애기",sceneResetConfirm:"모두 제거 확인",sceneAddSphere:"구 추가하기",sceneAddCube:"정육면체 추가하기",sceneAddCylinder:"기둥 추가하기",sceneAddTorus:"도넛 추가하기",sceneSelection:"선택",sceneMerge:"장면 병합하기",sceneDuplicate:null,sceneDelete:"선택 항목 삭제",meshTitle:"메시",meshNbVertices:"버텍스 개수 : ",meshNbFaces:"페이스 개수 : ",topologyTitle:"토폴로지",multiresTitle:"다중 해상도",multiresSubdivide:"세분화",multiresReverse:"간소화",multiresResolution:"해상도",multiresNoLower:"더 낮은 해상도가 존재하지 않습니다.",multiresNoHigher:"더 높은 해상도가 존재하지 않습니다.",multiresDelHigher:"상위 단계 제거",multiresDelLower:"하위 단계 제거",multiresSelectLowest:"간소화 하기 전에 더 낮은 해상도를 고르세요.",multiresSelectHighest:"세분화 하기 전에 더 높은 해상도를 고르세요",multiresWarnBigMesh:function(e){return"다음 분할 단계의 면 개수는 "+e+' 개가 될 것입니다.\n지금 뭘 하려는건지 정확히 이해하고 있다면 "세분화" 버튼을 다시 누르세요.'},multiresNotReversible:"이 메시는 더이상 병합할 수 없습니다.\n이 메시는 다양체 메시상의 세분화(loop-catmull) 결과가 아닙니다.",remeshTitle:"복셀 리메싱",remeshRemesh:"리메시",remeshResolution:"해상도",remeshBlock:"각지게",dynamicTitle:"동적 토폴로지",dynamicActivated:"활성화 (사각형 유지 안함)",dynamicSubdivision:"세분화",dynamicDecimation:"간소화",dynamicLinear:"선형 세분화",sculptTitle:"스컬핑 & 페인팅",sculptBrush:"브러시",sculptInflate:"부풀리기",sculptTwist:"비틀기",sculptSmooth:"부드럽게 (-Shift)",sculptFlatten:"평평하게",sculptPinch:"꼬집기",sculptCrease:"주름내기",sculptDrag:"잡아끌기",sculptPaint:"칠하기",sculptMasking:"마스킹 (-Ctrl)",sculptMove:"잡아당기기",sculptLocalScale:"지역 스케일",sculptTransform:"변환 (E)",sculptCommon:"일반",sculptTool:"도구",sculptSymmetry:"대칭",sculptContinuous:"누적",sculptRadius:"반경 (-X)",sculptIntensity:"세기 (-C)",sculptHardness:"경도",sculptCulling:"얇은 표면 (앞쪽 면만 적용)",sculptAlphaTitle:"알파",sculptLockPositon:"위치 고정",sculptAlphaTex:"텍스쳐",sculptImportAlpha:"알파 텍스쳐 가져오기 (jpg, png...)",sculptNegative:"반전 (N 또는 -Alt)",sculptColor:"알베도",sculptRoughness:"거칠기",sculptMetallic:"금속",sculptClay:"찰흙",sculptAccumulate:"누적 (획 당으로 제한받지 않음)",sculptColorGlobal:null,sculptPickColor:"재질 / 색상 선택 (-S)",sculptTangentialSmoothing:"완화 한정",sculptTopologicalCheck:"위상 검사",sculptMoveAlongNormal:"법선 방향으로 움직이기",sculptMaskingClear:"초기화 (-Ctrl + Drag)",sculptMaskingInvert:"반전 (-Ctrl + Click)",sculptMaskingBlur:"흐리게",sculptMaskingSharpen:"선명하게",sculptPBRTitle:"PBR 재질",sculptPaintAll:"전부 칠하기",sculptExtractTitle:"추출하기",sculptExtractThickness:"두께",sculptExtractAction:"추출!",stateTitle:"히스토리",stateUndo:"실행 취소",stateRedo:"다시 실행",stateMaxStack:"최대 실행취소 횟수",pressureTitle:"필압",pressureRadius:"필압반경",pressureIntensity:"필압 세기",renderingTitle:"렌더링",renderingGrid:"그리드 보기",renderingSymmetryLine:"대칭선 보기",renderingMatcap:"매트캡",renderingCurvature:"곡률 강조",renderingPBR:"PBR",renderingTransparency:"투명도",renderingNormal:"노말 셰이더",renderingUV:"UV 셰이더",renderingShader:"셰이더",renderingMaterial:"재질",renderingImportUV:"가져오기 (jpg, png...)",renderingImportMatcap:"가져오기 (jpg, png...)",renderingExtra:"그 외",renderingFlat:"플랫 셰이딩",renderingWireframe:"와이어프레임 (W)",renderingExposure:"노출",renderingEnvironment:"환경",renderingIsolate:"하나보기/모두보기 (I)",renderingFilmic:"영화적(filmic) 톤매핑",contour:"윤곽선",contourShow:"윤곽선 보기",contourColor:"색상",darkenUnselected:"선택안된건 어둡게",resolution:"해상도",matcapPearl:"진주",matcapClay:"찰흙",matcapSkin:"피부",matcapGreen:"초록",matcapWhite:"하양",sketchfabTitle:"Sketchfab에 올리기",sketchfabUpload:"올리기",sketchfabUploadMessage:null,sketchfabUploadError:null,sketchfabUploadSuccess:null,sketchfabAbort:null,sketchfabUploadProcessing:null,about:"SculptGL 정보",alphaNone:null,alphaSquare:null,alphaSkin:null},русский:{backgroundTitle:"Фон",backgroundReset:"Сброс",backgroundImport:"Импорт (jpg, png...)",backgroundFill:"Заполнить",cameraTitle:"Камера",cameraReset:"Вид",cameraCenter:"Сброс (bar)",cameraFront:"Спереди (F)",cameraLeft:"Слева (L)",cameraTop:"Сверху (T)",cameraMode:"Режим камеры",cameraOrbit:"Орбита (Вращение)",cameraSpherical:"Сферическая (трекбол)",cameraPlane:"Плоскость (трекбол)",cameraProjection:"Проекция",cameraPerspective:"Перспективная",cameraOrthographic:"Ортогональная",cameraFov:"Угол обзора",cameraPivot:"Выбор вращения",fileTitle:"Файл",fileImportTitle:"Импорт",fileAdd:"Добавить(obj,sgl,ply,stl)",fileAutoMatrix:"Масштаб-вид",fileVertexSRGB:"Цвета вершин sRGB",fileExportSceneTitle:"Экспорт сцены",fileExportAll:"Экспорт все",fileExportSGL:"Сохранить .sgl",fileExportOBJ:"Сохранить .obj",fileExportPLY:"Сохранить .ply",fileExportSTL:"Сохранить .stl",fileExportTextureTitle:null,fileExportTextureSize:null,fileExportColor:null,fileExportRoughness:null,fileExportMetalness:null,sceneTitle:"Сцена",sceneReset:"Очистить сцену",sceneResetConfirm:"Подтвердить четкую сцену",sceneAddSphere:"Добавить сферу",sceneAddCube:"Добавить куб",sceneAddCylinder:"Добавить цилиндр",sceneAddTorus:"Добавить тор",sceneSelection:"Выбрать",sceneMerge:"Объединить",sceneDuplicate:null,sceneDelete:"Удалить выделение",meshTitle:"Сетка",meshNbVertices:"Вершины : ",meshNbFaces:"Грани : ",topologyTitle:"Топология",multiresTitle:"Детализация",multiresSubdivide:"Увеличить",multiresReverse:"Реверс",multiresResolution:"Разрешение",multiresNoLower:"Ниже уровня не существует.",multiresNoHigher:"Выше уровня не существует.",multiresDelHigher:"Удалить высокое",multiresDelLower:"Удалить низкое",multiresSelectLowest:"Выберете низкий перед реверсом.",multiresSelectHighest:"Выберете бысокий перед увеличением.",multiresWarnBigMesh:function(e){return"Следующий уровень "+e+' faces.\nЕсли вы уверены, снова нажмите на "разрешение".'},multiresNotReversible:"К сожалению это не возможно, чтобы изменить эту сетку.\nСетка не продуктивна.",remeshTitle:"Перестроить воксели",remeshRemesh:"Перестроить",remeshResolution:"Разрешение",remeshBlock:"Блоки-кубики",dynamicTitle:"Динамическая топология",dynamicActivated:"Активировать (без кубиков)",dynamicSubdivision:"Увеличить",dynamicDecimation:"Прореживание",dynamicLinear:"Линейное увеличение",sculptTitle:"Лепить и красить",sculptBrush:"3D Кисть",sculptInflate:"3D Надуть",sculptTwist:"3D Закручивание",sculptSmooth:"3D Сгладить (-Shift)",sculptFlatten:"3D Расплющить",sculptPinch:"3D Сдавить",sculptCrease:"3D Складка",sculptDrag:"3D Тянучка",sculptMove:"3D Перемещать",sculptLocalScale:"3D масштабирование",sculptPaint:"2D Кисть",sculptMasking:"2D Маска (-Ctrl)",sculptTransform:"Трансформация",sculptCommon:"Общее",sculptTool:"Инструмент",sculptSymmetry:"Симетрия",sculptContinuous:"Спрей",sculptRadius:"Размер (-X)",sculptIntensity:"Сила (-C)",sculptHardness:"Жесткость",sculptCulling:"Тонкая поверхность(передние вершины)",sculptAlphaTitle:"Альфа",sculptLockPositon:"Фиксация",sculptAlphaTex:"Текстура",sculptImportAlpha:"Импорт альфа текстуры (jpg, png...)",sculptNegative:"Негатив (N или -Alt)",sculptColor:"Палитра",sculptRoughness:"Шероховатость",sculptMetallic:"Металлический",sculptClay:"Без учета содержимого",sculptAccumulate:"Наращивать без ограничений",sculptColorGlobal:"Глобальный",sculptPickColor:"Пипетка материал (-S)",sculptTangentialSmoothing:"Сглаживание по касательной",sculptTopologicalCheck:"Проверка топологии",sculptMoveAlongNormal:"Перемещать вдоль нормали",sculptMaskingClear:"Очистить (-Ctrl + Drag)",sculptMaskingInvert:"Инверсия (-Ctrl + Click)",sculptMaskingBlur:"Сгладить",sculptMaskingSharpen:"Резко",sculptPBRTitle:"PBR материал",sculptPaintAll:"Краска",sculptExtractTitle:"Извлечь",sculptExtractThickness:"Толщина",sculptExtractAction:"Извлечь!",stateTitle:"История",stateUndo:"Назад",stateRedo:"Вперед",stateMaxStack:"Количество шагов",pressureTitle:"Планшет pressure",pressureRadius:"Нажим-размер",pressureIntensity:"Нажим-жесткость",renderingTitle:"Визуализация",renderingGrid:"Показать сетку",renderingSymmetryLine:"Линия симетрии",renderingMatcap:null,renderingCurvature:"Рельефность",renderingPBR:"PBR",renderingTransparency:"Прозрачность",renderingNormal:"Карта нормалей",renderingUV:"UV",renderingShader:"Шейдеры",renderingMaterial:"Материал",renderingImportUV:"Импорт (jpg, png...)",renderingImportMatcap:"Импорт (jpg, png...)",renderingExtra:"Дополнительно",renderingFlat:"Плоскости",renderingWireframe:"Каркас (W)",renderingExposure:"Экспозиция",renderingEnvironment:"Отражение",renderingIsolate:"Изолировать / Показать (I)",renderingFilmic:"Пленка",contour:"Контур",contourShow:"Показать контур",contourColor:"Цвет",darkenUnselected:"Затемнить невыбранное",resolution:"Разрешение",matcapPearl:"Жемчуг",matcapClay:"Глина",matcapSkin:"Кожа",matcapGreen:"Зеленый",matcapWhite:"Белый",sketchfabTitle:"В Sketchfab.com ",sketchfabUpload:"Загрузить",sketchfabUploadMessage:"Ведите API-ключ sketchfab.\nОставьте слово «guest», чтобы загрузить модель анонимно.\n(По окончании загрузки и обработки откроется новое окно)",sketchfabUploadError:function(e){return"Ошибка загрузки в Sketchfab :\n"+e},sketchfabUploadSuccess:"Загрузка выполнена !\nВаша ссылка:",sketchfabAbort:"Прервать загрузку?",sketchfabUploadProcessing:"Обработка...\nВаша модель будет доступна после:",about:"О программе",alphaNone:"Нет",alphaSquare:"Квадрат",alphaSkin:"Кожа"},turkish:{backgroundTitle:"Arka plan",backgroundReset:"Temizle",backgroundImport:"İçe Aktar (jpg, png...)",backgroundFill:"Doldur",cameraTitle:"Kamera",cameraReset:"Görünüm",cameraCenter:"Temizle (boşluk)",cameraFront:"Ön (F)",cameraLeft:"Sol (L)",cameraTop:"Üst (T)",cameraMode:"Mod",cameraOrbit:"Eksen (Döner Tabla)",cameraSpherical:"Küresel (Trackball)",cameraPlane:"Düzlem (Trackball)",cameraProjection:"İz Düşüm",cameraPerspective:"Perspektif",cameraOrthographic:"Orthografik",cameraFov:"Görüş Alanı",cameraPivot:"Eksen",fileTitle:"Dosyalar (içeri/dışarı)",fileImportTitle:"İçe Aktar",fileAdd:"Aktar (obj, sgl, ply, stl)",fileAutoMatrix:"Ölçekle ve ortala",fileVertexSRGB:"sRGB verteks renk",fileExportSceneTitle:"Sahneyi Dışa Aktar",fileExportAll:"tümünü ver",fileExportSGL:"Kaydet .sgl (SculptGL)",fileExportOBJ:"Kaydet .obj",fileExportPLY:"Kaydet .ply",fileExportSTL:"Kaydet .stl",fileExportTextureTitle:null,fileExportTextureSize:null,fileExportColor:null,fileExportRoughness:null,fileExportMetalness:null,sceneTitle:"Sahne",sceneReset:"Sahneyi temizle",sceneResetConfirm:"Net sahneyi doğrulayın",sceneAddSphere:"Küre ekle",sceneAddCube:"Küp ekle",sceneAddCylinder:"Silindir ekle",sceneAddTorus:"Halka ekle",sceneSelection:"Seçim",sceneMerge:"Seçimi birleştir",sceneDuplicate:null,sceneDelete:"Seçimi sil",meshTitle:"Nesne",meshNbVertices:"Verteks : ",meshNbFaces:"Cephe : ",topologyTitle:"Topoloji",multiresTitle:"Çoklu çözünürlük",multiresSubdivide:"Böl",multiresReverse:"Ters",multiresResolution:"Çözünürlük",multiresNoLower:"Daha düşük çözünürlük yok.",multiresNoHigher:"Daha yüksek çözünürlük yok.",multiresDelHigher:"Üst çözünürlüğü sil",multiresDelLower:"Alt çözünürlüğü sil",multiresSelectLowest:"Tersine çevirmeden önce en düşük çözünürlüğü seç.",multiresSelectHighest:"Tesine çevirmeden önce en yüksek çözünürlüğü seç.",multiresWarnBigMesh:function(e){return"Sonraki çözünürlük "+e+' cepheden oluşacak.\nEğer devam etmek istiyorsanız, tekrar "böl" butonuna basın.'},multiresNotReversible:"Malesef bu nesneyi tersine çevirmek mümkün değil.\nThe mesh is not a product of a (loop-catmull) subdivision surface on a manifold mesh.",remeshTitle:"Voksel Remeshing",remeshRemesh:"Remesh",remeshResolution:"Çözünürlük",remeshBlock:"Engelle",dynamicTitle:"Dinamik Topoloji",dynamicActivated:"Aktifleştirildi (no quads)",dynamicSubdivision:"Altbölüm",dynamicDecimation:"Üstbölüm",dynamicLinear:"Doğrusal altbölüm",sculptTitle:"Oyma & Boyama",sculptBrush:"Fırça",sculptInflate:"Şişir",sculptTwist:"Bükülme",sculptSmooth:"Yumuşat (-Shift)",sculptFlatten:"Düzleştir",sculptPinch:"Çimdik",sculptCrease:"Kıvrım",sculptDrag:"Sürükle",sculptPaint:"Boya",sculptMasking:"Maskeleme (-Ctrl)",sculptMove:"Taşı",sculptLocalScale:"Yerel ölçek",sculptTransform:"Transform (E)",sculptCommon:"Genel",sculptTool:"Araç",sculptSymmetry:"Simetri",sculptContinuous:"Sürekli",sculptRadius:"Yarıçağ (-X)",sculptIntensity:"Yoğunluk (-C)",sculptHardness:"Sertlik",sculptCulling:"İnce yüzey (front vertex only)",sculptAlphaTitle:"Alfa",sculptLockPositon:"Pozisyonu kilitle",sculptAlphaTex:"Kaplama",sculptImportAlpha:"Alfa kaplama yükle (jpg, png...)",sculptNegative:"Negatif (N or -Alt)",sculptColor:"Aklık",sculptRoughness:"Pürüzlülük",sculptMetallic:"Metalik",sculptClay:"Kil",sculptAccumulate:"Biriktir (limitsiz)",sculptColorGlobal:"Evrensel",sculptPickColor:"Materyal / Renk seçici (-S)",sculptTangentialSmoothing:"Rahatlat",sculptTopologicalCheck:"Topoloji kontrol",sculptMoveAlongNormal:"Normal boyunca taşı (N or -Alt)",sculptMaskingClear:"Temizle (-Ctrl + Drag)",sculptMaskingInvert:"Tersine çevir (-Ctrl + Click)",sculptMaskingBlur:"Bulanıklaştır",sculptMaskingSharpen:"Keskinleştir",sculptPBRTitle:"PBR materyali",sculptPaintAll:"Tümünü boya",sculptExtractTitle:"Çıkar",sculptExtractThickness:"Kalınlık",sculptExtractAction:"Çıkar !",stateTitle:"Geçmiş",stateUndo:"Geri Al",stateRedo:"İleri Al",stateMaxStack:"Maksimum Yığın",pressureTitle:"Tablet pressure",pressureRadius:"Basınç çapı",pressureIntensity:"Basınç hassasiyeti",renderingTitle:"Sahneleme",renderingGrid:"Izgarayı göster",renderingSymmetryLine:"aynalama çizgisini göster",renderingMatcap:"Matcap",renderingCurvature:"Eğrilik",renderingPBR:"PBR",renderingTransparency:"Transparanlık",renderingNormal:"Normal shader",renderingUV:"UV shader",renderingShader:"Shader",renderingMaterial:"Materyal",renderingImportUV:"İçe AKtar (jpg, png...)",renderingImportMatcap:"İçe AKtar (jpg, png...)",renderingExtra:"Ekstra",renderingFlat:"Düz gölgeleme",renderingWireframe:"Tel kafes (W)",renderingExposure:"Teşir",renderingEnvironment:"Ortam",renderingIsolate:"İzole/Göster (I)",renderingFilmic:"Film Ton Eşleşmesi",contour:"Kontür",contourShow:"Kontürü göster",contourColor:"Renk",darkenUnselected:"Seçilmeyenleri koyu yap",resolution:"Çözünürlük",matcapPearl:"İnci",matcapClay:"Kil",matcapSkin:"Deri",matcapGreen:"Yeşil",matcapWhite:"Beyaz",sketchfabTitle:"Sketchfab'a git !",sketchfabUpload:"Yükle",sketchfabUploadMessage:'Lütfen sketchfab entegrasyon anahtarını giriniz.\nAnonim olarak kullanmak için "guest" olarak bırakınız.\n(İşleme ve yükleme tamamlandığında yeni bir pencere açılacak)',sketchfabUploadError:function(e){return"Sketchfab yükleme hatası :\n"+e},sketchfabUploadSuccess:"Yükleme başarılı !\nAdresiniz burada :",sketchfabAbort:"Son yüklemeyi iptal et ?",sketchfabUploadProcessing:"Hesaplanıyor...\nModeliniz şu adreste olacak :",about:"Hakkında & Yardım",alphaNone:"Hiç Biri",alphaSquare:"Kare",alphaSkin:"Deri"},svenska:{backgroundTitle:"Bakgrund",backgroundReset:"Återställ",backgroundImport:"Importera (jpg, png...)",backgroundFill:"Fyll",cameraTitle:"Kamera",cameraReset:"Vy",cameraCenter:"Återställ (bar)",cameraFront:"Fram (F)",cameraLeft:"Vänster (L)",cameraTop:"Över (T)",cameraMode:"Läge",cameraOrbit:"Omloppsbana (skivtallrik)",cameraSpherical:"Sfärisk (trackball)",cameraPlane:"Plan (trackball)",cameraProjection:"Projektion",cameraPerspective:"Perspektiv",cameraOrthographic:"Ortografisk",cameraFov:"Fov",cameraPivot:"Plocka pivot",fileTitle:"Filer (import/export)",fileImportTitle:"Importera",fileAdd:"Lägg till (obj, sgl, ply, stl)",fileAutoMatrix:"Skala och centrera",fileVertexSRGB:"sRGB vertexfärg",fileExportSceneTitle:"Exportera Scen",fileExportAll:"Exportera alla",fileExportSGL:"Spara .sgl (SculptGL)",fileExportOBJ:"Spara .obj",fileExportPLY:"Spara .ply",fileExportSTL:"Spara .stl",fileExportTextureTitle:null,fileExportTextureSize:null,fileExportColor:null,fileExportRoughness:null,fileExportMetalness:null,sceneTitle:"Scen",sceneReset:"Rensa scen",sceneResetConfirm:"Bekräfta klar scen",sceneAddSphere:"Lägg till sfär",sceneAddCube:"Lägg till kub",sceneAddCylinder:"Lägg till cylinder",sceneAddTorus:"Lägg till torus",sceneSelection:"Urval",sceneMerge:"Sammanfoga urval",sceneDuplicate:null,sceneDelete:"Radera valet",meshTitle:"Mesh",meshNbVertices:"Vertex : ",meshNbFaces:"Faces : ",topologyTitle:"Topologi",multiresTitle:"Upplösningar",multiresSubdivide:"Dela upp yta",multiresReverse:"Omvänd",multiresResolution:"Upplösning",multiresNoLower:"Det finns ingen lägre upplösningsnivå.",multiresNoHigher:"Det finns ingen högre upplösningsnivå.",multiresDelHigher:"Ta bort högre",multiresDelLower:"Ta bort lägre",multiresSelectLowest:"Välj den lägsta upplösningen innan omvändning.",multiresSelectHighest:"Välj den högsta upplösningen innan uppdelning.",multiresWarnBigMesh:function(e){return"Nästa underavdelning nivå kommer att ha "+e+' faces.\nOm du vet vad du gör, klicka igen på "dela upp yta".'},multiresNotReversible:"Tyvärr, det går inte att omvända denna mesh.\nMeshen är inte en produkt av en (loop-calmull) mångfaldsytindelning.",remeshTitle:"Voxel Meshombyggnation",remeshRemesh:"Bygg om mesh",remeshResolution:"Upplösning",remeshBlock:"Kuber",dynamicTitle:"Dynamisk Topologi",dynamicActivated:"Aktiverad (inga quads)",dynamicSubdivision:"Ytindelning",dynamicDecimation:"Decimering",dynamicLinear:"Linjär ytindelning",sculptTitle:"Skulptering & Måleri",sculptBrush:"Pensel",sculptInflate:"Blås upp",sculptTwist:"Tvista",sculptSmooth:"Jämna ut (-Shift)",sculptFlatten:"Platta till",sculptPinch:"Nyp",sculptCrease:"Vecka",sculptDrag:"Dra",sculptPaint:"Måla",sculptMasking:"Maskera (-Ctrl)",sculptMove:"Flytta",sculptLocalScale:"Skala lokalt",sculptTransform:"Transformera (E)",sculptCommon:"Generellt",sculptTool:"Verktyg",sculptSymmetry:"Symmetri",sculptContinuous:"Kontinuerlig",sculptRadius:"Radie (-X)",sculptIntensity:"Intensitet (-C)",sculptHardness:"Hårdhet",sculptCulling:"Tunn yta (endast främre vertex)",sculptAlphaTitle:"Alfa",sculptLockPositon:"Lås position",sculptAlphaTex:"Textur",sculptImportAlpha:"Importera alfatextur (jpg, png...)",sculptNegative:"Negativ (N or -Alt)",sculptColor:"Albedo",sculptRoughness:"Ytjämnhet",sculptMetallic:"Metallisk",sculptClay:"Lera",sculptAccumulate:"Ackumulera (ingen gräns per strykning)",sculptColorGlobal:"Global",sculptPickColor:"Material / Färgvälgare (-S)",sculptTangentialSmoothing:"Tangentiell utjämning",sculptTopologicalCheck:"Topologisk check",sculptMoveAlongNormal:"Flytta längsmed normal (N or -Alt)",sculptMaskingClear:"Rensa (-Ctrl + Drag)",sculptMaskingInvert:"Invertera (-Ctrl + Click)",sculptMaskingBlur:"Gör suddig",sculptMaskingSharpen:"Gör skarp",sculptPBRTitle:"PBR-material",sculptPaintAll:"Måla allt",sculptExtractTitle:"Extrahera",sculptExtractThickness:"Tjocklek",sculptExtractAction:"Extrahera!",stateTitle:"Historia",stateUndo:"Ångra",stateRedo:"Gör om",stateMaxStack:"Ågra antal steg",pressureTitle:"pressureplatta",pressureRadius:"Tryckradie",pressureIntensity:"Tryckintensitet",renderingTitle:"Rendering",renderingGrid:"Visa rutnät",renderingSymmetryLine:"Visa speglingslinje",renderingMatcap:"MatCap",renderingCurvature:"Kurvatur",renderingPBR:"PBR",renderingTransparency:"Genomskinlighet",renderingNormal:"Normal shader",renderingUV:"UV-shader",renderingShader:"Shader",renderingMaterial:"Material",renderingImportUV:"Importera (jpg, png...)",renderingImportMatcap:"Importera (jpg, png...)",renderingExtra:"Extra",renderingFlat:"Platt shading",renderingWireframe:"Wireframe (W)",renderingExposure:"Exponering",renderingEnvironment:"Miljö",renderingIsolate:"Isolera/Visa (I)",renderingFilmic:"Filmiska färgtoner",contour:"Kontur",contourShow:"Visa kontur",contourColor:"Färg",darkenUnselected:"Skym ej valda",resolution:"Upplösning",matcapPearl:"Pärla",matcapClay:"Lera",matcapSkin:"Hud",matcapGreen:"Grön",matcapWhite:"Vit",sketchfabTitle:"Gå till Sketchfab!",sketchfabUpload:"Ladda upp",sketchfabUploadMessage:'Vänligen fyll i din sketchfab API-nyckel.\nDu kan även ange "guest" för att ladda upp anonymt.\n(ett nytt fönster kommer att öppnas när uppladdning och bearbetning är klar)',sketchfabUploadError:function(e){return"Sketchfab uppladdningsfel:\n"+e},sketchfabUploadSuccess:"Uppladdning lyckades!\nHär är din länk:",sketchfabAbort:"Avbryta senaste uppladdningen?",sketchfabUploadProcessing:"Bearbetar...\nDin modell kommer bli tillgänglig här:",about:"Om & Hjälp",alphaNone:"Ingen",alphaSquare:"Fyrkant",alphaSkin:"Hud"},français:{backgroundTitle:"Fond d'écran",backgroundReset:"Réinitialiser",backgroundImport:"Importer (jpg, png...)",backgroundFill:"Remplir",cameraTitle:"Caméra",cameraReset:"Vue",cameraCenter:"Reset (bar)",cameraFront:"De face (F)",cameraLeft:"De gauche (L)",cameraTop:"De haut (T)",cameraMode:"Mode",cameraOrbit:"Orbite (Turntable)",cameraSpherical:"Spherique (Trackball)",cameraPlane:"Planaire (Trackball)",cameraProjection:"Projection",cameraPerspective:"Perspective",cameraOrthographic:"Orthographique",cameraFov:"Champs de vision",cameraPivot:"Point de pivot",fileTitle:"Fichiers (importer/exporter)",fileImportTitle:"Importer",fileAdd:"Ajouter (obj, sgl, ply, stl)",fileAutoMatrix:"Mise a l'échelle et centrage",fileVertexSRGB:"Couleur de vertex en sRGB",fileExportSceneTitle:"Exporter Scene",fileExportAll:"Tout exporter",fileExportSGL:"Sauver .sgl (SculptGL)",fileExportOBJ:"Sauver .obj",fileExportPLY:"Sauver .ply",fileExportSTL:"Sauver .stl",fileExportTextureTitle:null,fileExportTextureSize:null,fileExportColor:null,fileExportRoughness:null,fileExportMetalness:null,sceneTitle:"Scène",sceneReset:"Réinitialiser scène",sceneResetConfirm:"Confirmer réinitialiser la scène?",sceneAddSphere:"Ajouter sphere",sceneAddCube:"Ajouter cube",sceneAddCylinder:"Ajouter cylindre",sceneAddTorus:"Ajouter tore",sceneSelection:"Sélection",sceneMerge:"Fusionner selection",sceneDuplicate:null,sceneDelete:"Supprimer la sélection",meshTitle:"Mesh",meshNbVertices:"Vertex : ",meshNbFaces:"Faces : ",topologyTitle:"Topologie",multiresTitle:"Multirésolution",multiresSubdivide:"Subdiviser",multiresReverse:"Inverser",multiresResolution:"Résolution",multiresNoLower:"Il n'y a pas de niveau de résolution inférieur.",multiresNoHigher:"Il n'y a pas de niveau de résolution plus élevé.",multiresDelHigher:"Suppression supérieur",multiresDelLower:"Suppression inférieur",multiresSelectLowest:"Sélectionnez la résolution la plus basse avant d'inverser.",multiresSelectHighest:"Sélectionnez la résolution la plus élevée avant de subdiviser.",multiresWarnBigMesh:function(e){return"Le prochain niveau de subdivision atteindra "+e+' faces.\nSi vous savez ce que vous faites, cliquez de nouveau sur "subdiviser".'},multiresNotReversible:"Désolé, il n'est pas possible d'inverser ce maillage.\nLe mesh n'est pas un produit d'une surface de subdivision (loop-catmull) provenant manifold.",remeshTitle:"Remaillage volumétrique",remeshRemesh:"Remaillage",remeshResolution:"Résolution",remeshBlock:"Bloc",dynamicTitle:"Topologie dynamique",dynamicActivated:"Activaté (pas quads)",dynamicSubdivision:"Subdivision",dynamicDecimation:"Décimation",dynamicLinear:"Subdivision linéaire",sculptTitle:"Sculpture & Painture",sculptBrush:"Brosse",sculptInflate:"Gonfler",sculptTwist:"Tordre",sculptSmooth:"Lisser (-Shift)",sculptFlatten:"Aplatir",sculptPinch:"Pincer",sculptCrease:"Plier",sculptDrag:"Tirer",sculptPaint:"Peindre",sculptMasking:"Masquer (-Ctrl)",sculptMove:"Bouger",sculptLocalScale:"Mise à l'échelle locale",sculptTransform:"Transformer (E)",sculptCommon:"Commun",sculptTool:"Outil",sculptSymmetry:"Symétrie",sculptContinuous:"Continu",sculptRadius:"Rayon (-X)",sculptIntensity:"Intensité (-C)",sculptHardness:"Dureté",sculptCulling:"Surface fine (vertex de face uniquement)",sculptAlphaTitle:"Alpha",sculptLockPositon:"Bloquer position",sculptAlphaTex:"Texture",sculptImportAlpha:"Importer texture alpha (jpg, png...)",sculptNegative:"Négatif (N ou -Alt)",sculptColor:"Albedo",sculptRoughness:"Rugosité",sculptMetallic:"Métallique",sculptClay:"Argile",sculptAccumulate:"Accumuler effet",sculptColorGlobal:"Global",sculptPickColor:"Matériau / selection de couleur (-S)",sculptTangentialSmoothing:"Relaxer uniquement",sculptTopologicalCheck:"Vérification topologique",sculptMoveAlongNormal:"Extruder suivant la normale (N ou -Alt)",sculptMaskingClear:"Reset (-Ctrl + Drag)",sculptMaskingInvert:"Inverser (-Ctrl + Click)",sculptMaskingBlur:"Brouiller",sculptMaskingSharpen:"Aiguiser",sculptPBRTitle:"Matériaux physiques (PBR)",sculptPaintAll:"Peindre tout",sculptExtractTitle:"Extraire",sculptExtractThickness:"Epaisseur",sculptExtractAction:"Extraire !",stateTitle:"Historique",stateUndo:"Annuler",stateRedo:"Refaire",stateMaxStack:"Nombre maximale d'action",pressureTitle:"Pression de la tablette",pressureRadius:"Pression sur le rayon",pressureIntensity:"Pression sur l'intensité",renderingTitle:"Rendu",renderingGrid:"Afficher grille",renderingSymmetryLine:"Afficher ligne de symétrie",renderingMatcap:"Matcap",renderingCurvature:"Courbure",renderingPBR:"PBR",renderingTransparency:"Transparence",renderingNormal:"Normal shader",renderingUV:"UV shader",renderingShader:"Shader",renderingMaterial:"Matériau",renderingImportUV:"Importer (jpg, png...)",renderingImportMatcap:"Importer (jpg, png...)",renderingExtra:"Extra",renderingFlat:"Ombrage plat",renderingWireframe:"Fil de fer (W)",renderingExposure:"Exposition",renderingEnvironment:"Environment",renderingIsolate:"Isoler/Afficher (I)",renderingFilmic:"Tonemapping filmique",contour:"Contour",contourShow:"Afficher les contours",contourColor:"Couleur",darkenUnselected:"Assombrir non séléctioné",resolution:"Résolution",matcapPearl:"Perle",matcapClay:"Argile",matcapSkin:"Peau",matcapGreen:"Vert",matcapWhite:"Blanc",sketchfabTitle:"Vers Sketchfab !",sketchfabUpload:"Uploader",sketchfabUploadMessage:"Entrez votre clé d'API sketchfab.\n",sketchfabUploadError:function(e){return"Sketchfab upload error :\n"+e},sketchfabUploadSuccess:"Téléchargement fini !\nVoici le lien :",sketchfabAbort:"Annuler le dernier téléchargement ?",sketchfabUploadProcessing:"En traitement...\nLe modèle sera disponible sur :",about:"A propos & aide",alphaNone:"Vide",alphaSquare:"Carré",alphaSkin:"Peau",remeshTitleMC:"Remaillage surfacique (manifold tris)",remeshRemeshMC:"Remaillage",remeshSmoothingMC:"Lisser topologie"},deutsch:{backgroundTitle:"Hintergrund",backgroundReset:"Zurücksetzen",backgroundImport:"Importieren (jpg, png...)",backgroundFill:"Füllen",cameraTitle:"Kamera",cameraReset:"Ansicht",cameraCenter:"Reset (space)",cameraFront:"Front (F)",cameraLeft:"Links (L)",cameraTop:"Oben (T)",cameraMode:"Modus",cameraOrbit:"Orbit (Drehscheibe)",cameraSpherical:"Sphärisch (Rollkugel)",cameraPlane:"Ebene (Rollkugel)",cameraProjection:"Projektion",cameraPerspective:"Perspektivisch",cameraOrthographic:"Orthographisch",cameraFov:"Sichtfeld (fov)",cameraPivot:"Pivot auswählen",fileTitle:"Datei (import/export)",fileImportTitle:"Importieren",fileAdd:"Hinzufügen (obj, sgl, ply, stl)",fileAutoMatrix:"Skalieren und zentrieren",fileVertexSRGB:"Vertexfarbe sRGB",fileExportSceneTitle:"Exportieren",fileExportAll:"Alles Exportieren",fileExportSGL:"Speichern .sgl (SculptGL)",fileExportOBJ:"Speichern .obj",fileExportPLY:"Speichern .ply",fileExportSTL:"Speichern .stl",fileExportTextureTitle:"Texturen exportieren",fileExportTextureSize:"Größe",fileExportColor:"Farben speichern",fileExportRoughness:"Rauheit speichern",fileExportMetalness:"Metallisches speichern",sceneTitle:"Szene",sceneReset:"Szene löschen",sceneResetConfirm:"Bestätige das Löschen der aktuellen Szene",sceneAddSphere:"Kugel hinzufügen",sceneAddCube:"Würfel hinzufügen",sceneAddCylinder:"Zylinder hinzufügen",sceneAddTorus:"Torus hinzufügen",sceneSelection:"Auswahl",sceneMerge:"Auswahl zusammenführen",sceneDuplicate:"Auswahl kopieren",sceneDelete:"Auswahl löschen",meshTitle:"Mesh",meshNbVertices:"Vertex : ",meshNbFaces:"Faces : ",topologyTitle:"Topologie",multiresTitle:"Mehrfachauflösung",multiresSubdivide:"Aufteilen",multiresReverse:"Umkehren",multiresResolution:"Auflösung",multiresNoLower:"Es gibt keine kleinere Auflösung.",multiresNoHigher:"Es gibt keine höhere Auflösung.",multiresDelHigher:"Höhere löschen",multiresDelLower:"Niedrigere löschen",multiresSelectLowest:"Wählen Sie vor dem Umkehren die kleinste Auflösung aus.",multiresSelectHighest:"Wählen Sie vor dem Umkehren die höchste Auflösung aus.",multiresWarnBigMesh:function(e){return"Die nächste Unterteilungsebene wird "+e+' Flächen erreichen\nWenn Sie wissen, was Sie tun, dann klicken Sie nocheinmal auf "umkehren".'},multiresNotReversible:"Es ist leider nicht möglich dieses Netz umzukehren.\nDas Netz ist kein Produkt einer (loop/catmull) Unterteilungsfläche auf eines mannigfaltigen Netzes",remeshTitle:"Voxel remeshing (Vierecke)",remeshRemesh:"Remesh",remeshResolution:"Auflösung",remeshBlock:"Block",dynamicTitle:"Dynamische Topologie",dynamicActivated:"Aktiviert (ohne Vierecke)",dynamicSubdivision:"Aufteilung",dynamicDecimation:"Dezimierung",dynamicLinear:"Lineare Aufteilung",sculptTitle:"Modellieren & Bemalen",sculptBrush:"Pinsel",sculptInflate:"Aufblasen",sculptTwist:"Verdrehen",sculptSmooth:"Glätten (-Shift)",sculptFlatten:"Ebnen",sculptPinch:"Kneifen",sculptCrease:"Falten",sculptDrag:"Ziehen",sculptPaint:"Malen",sculptMasking:"Maskieren (-Strg)",sculptMove:"Bewegen",sculptLocalScale:"Lokales Skalieren",sculptTransform:"Transformieren (E)",sculptCommon:"Allgemein",sculptTool:"Werkzeuge",sculptSymmetry:"Symmetrie",sculptContinuous:"Kontinuierlich",sculptRadius:"Radius (-X)",sculptIntensity:"Intensität (-C)",sculptHardness:"Härte",sculptCulling:"Dünne Oberfläche (nur vorderer vertex)",sculptAlphaTitle:"Alpha",sculptLockPositon:"Verriegelungsposition",sculptAlphaTex:"Textur",sculptImportAlpha:"Alpha tex importieren(jpg, png...)",sculptNegative:"Negativ (N or -Alt)",sculptColor:"Albedo",sculptRoughness:"Rauheit",sculptMetallic:"Metallisches",sculptClay:"Lehm",sculptAccumulate:"Akkumulieren (Kein Limit pro Anschlag)",sculptColorGlobal:"Global",sculptPickColor:"Material / Farb Auswahl (-S)",sculptTangentialSmoothing:"Tangentiale Glättung",sculptTopologicalCheck:"Topologischer Check",sculptMoveAlongNormal:"Normal weiterlaufen (N or -Alt)",sculptMaskingClear:"Löschen (-Ctrl + Drag)",sculptMaskingInvert:"Invertieren (-Ctrl + Click)",sculptMaskingBlur:"Verwischen",sculptMaskingSharpen:"Schärfen",sculptPBRTitle:"PBR Materialen",sculptPaintAll:"Alles Anmalen",sculptExtractTitle:"Extrahieren",sculptExtractThickness:"Dicke",sculptExtractAction:"Extrahieren !",stateTitle:"Verlauf",stateUndo:"Rückgängig",stateRedo:"Wiederholen",stateMaxStack:"Max. Schritte",pressureTitle:"Tablet Druck",pressureRadius:"Druckradius",pressureIntensity:"Druckintensität",renderingTitle:"Rendering",renderingGrid:"Gitter anzeigen",renderingSymmetryLine:"Spiegellinie anzeigen",renderingMatcap:"Matcap",renderingCurvature:"Krümmung",renderingPBR:"PBR",renderingTransparency:"Transparenz",renderingNormal:"Normale Shader",renderingUV:"UV shader",renderingShader:"Shader",renderingMaterial:"Material",renderingImportUV:"Importieren (jpg, png...)",renderingImportMatcap:"Importieren (jpg, png...)",renderingExtra:"Extra",renderingFlat:"Flaches Schattieren",renderingWireframe:"Drahtgitter (W)",renderingExposure:"Belichtung",renderingEnvironment:"Umgebung",renderingIsolate:"Isoliert/Zeige (I)",renderingFilmic:"Filmische Tonzuordnung",contour:"Kontur",contourShow:"Kontur anzeigen",contourColor:"Farbe",darkenUnselected:"Nicht markiertes abdunkeln",resolution:"Auflösung",matcapPearl:"Perle",matcapClay:"Ton",matcapSkin:"Haut",matcapGreen:"Grün",matcapWhite:"Weiß",sketchfabTitle:"Hier gehts zu Sketchfab !",sketchfabUpload:"Hochladen",sketchfabUploadMessage:'Please enter your sketchfab API Key.\nYou can also leave "guest" to upload anonymously.\n(a new window will pop up when the uploading and processing is finished)',sketchfabUploadError:function(e){return"Sketchfab upload error :\n"+e},sketchfabUploadSuccess:"Upload success !\nHere is your link :",sketchfabAbort:"Abort the last upload ?",sketchfabUploadProcessing:"Processing...\nYour model will be available at :",about:"Über & Hilfe (Englisch)",alphaNone:"None",alphaSquare:"Würfel",alphaSkin:"Haut",remeshTitleMC:"Voxel remeshing (mannigfaltige Dreiecke)",remeshRemeshMC:"Remesh",remeshSmoothingMC:"Topologie entspannen"},italiano:{backgroundTitle:"Sfondo",backgroundReset:"Resetta",backgroundImport:"Importa (jpg, png...)",backgroundFill:"Riempi",cameraTitle:"Camera",cameraReset:"Vista",cameraCenter:"Resetta (bar)",cameraFront:"Fronte (F)",cameraLeft:"Sinistra (L)",cameraTop:"Sopra (T)",cameraMode:"Modalitá",cameraOrbit:"Orbita (Ruota intorno)",cameraSpherical:"Sferica (Trackball)",cameraPlane:"Planare (Trackball)",cameraProjection:"Proiezione",cameraPerspective:"Prospettiva",cameraOrthographic:"Ortografica",cameraFov:"Fov",cameraPivot:"Seleziona pivot",fileTitle:"Files (importa/esporta)",fileImportTitle:"Importa",fileAdd:"Aggiungi (obj, sgl, ply, stl)",fileAutoMatrix:"Scala e centra",fileVertexSRGB:"sRGB vertex color",fileExportSceneTitle:"Esporta Scena",fileExportAll:"Exporta tutto",fileExportSGL:"Salva .sgl (SculptGL)",fileExportOBJ:"Salva .obj",fileExportPLY:"Salva .ply",fileExportSTL:"Salva .stl",fileExportTextureTitle:"Esporta textures",fileExportTextureSize:"Dimensione",fileExportColor:"Salva diffuse",fileExportRoughness:"Salva roughness",fileExportMetalness:"Salva metalness",sceneTitle:"Scena",sceneReset:"Pulisci scena",sceneResetConfirm:"Conferma pulitura scena",sceneAddSphere:"Aggiungi sfera",sceneAddCube:"Aggiungi cubo",sceneAddCylinder:"Aggiungi cilindro",sceneAddTorus:"Aggiungi toroide",sceneSelection:"Selezione",sceneMerge:"Unisci selezione",sceneDuplicate:"Copia selezione",sceneDelete:"Elimina selezione",meshTitle:"Mesh",meshNbVertices:"Vertex : ",meshNbFaces:"Faces : ",topologyTitle:"Topologia",multiresTitle:"Multirisoluzione",multiresSubdivide:"Suddivisione",multiresReverse:"Invertire",multiresResolution:"Risoluzione",multiresNoLower:"Non esiste un livello di risoluzione inferiore.",multiresNoHigher:"Non esiste un livello di risoluzione più alto.",multiresDelHigher:"Elimina superiore",multiresDelLower:"Elimina inferiore",multiresSelectLowest:"Selezionare la risoluzione più bassa prima di invertire.",multiresSelectHighest:"Seleziona la risoluzione più alta prima di suddividere.",multiresWarnBigMesh:function(e){return"Il livello di suddivisione successivo raggiungerà "+e+' facce.\nSe sai cosa stai facendo, clicca di nuovo su "Suddividi".'},multiresNotReversible:"Spiacenti, non è possibile invertire questa mesh.\nLa mesh non è un prodotto di una superficie di suddivisione (loop-catmull) su una mesh collettore.",remeshTitle:"Voxel remeshing (quads)",remeshRemesh:"Remesh",remeshResolution:"Risolutione",remeshBlock:"Blocca",dynamicTitle:"Topologia Dinamica",dynamicActivated:"Attivata (no quads)",dynamicSubdivision:"Suddivisione",dynamicDecimation:"Decimazione",dynamicLinear:"Suddivisione Lineare",sculptTitle:"Scalpisci & Dipingi",sculptBrush:"Spazzola",sculptInflate:"Gonfia",sculptTwist:"Torci",sculptSmooth:"Ammorbidisci (-Shift)",sculptFlatten:"Appiattisci",sculptPinch:"Pizzica",sculptCrease:"Piega",sculptDrag:"Trascina",sculptPaint:"Dipingi",sculptMasking:"Maschera (-Ctrl)",sculptMove:"Muovi",sculptLocalScale:"Scala Locale",sculptTransform:"Trasformazioni (E)",sculptCommon:"Comune",sculptTool:"Strumento",sculptSymmetry:"Simmetria",sculptContinuous:"Continua",sculptRadius:"Raggio (-X)",sculptIntensity:"Intensità (-C)",sculptHardness:"Durezza",sculptCulling:"Superficie sottile (solo vertici frontali)",sculptAlphaTitle:"Alpha",sculptLockPositon:"Blocca posizione",sculptAlphaTex:"Texture",sculptImportAlpha:"Importa alpha tex (jpg, png...)",sculptNegative:"Negativo (N or -Alt)",sculptColor:"Albedo",sculptRoughness:"Rugosità",sculptMetallic:"Metallico",sculptClay:"Clay",sculptAccumulate:"Accumulazione (no limit per stroke)",sculptColorGlobal:"Globale",sculptPickColor:"Materiale / Color picker (-S)",sculptTangentialSmoothing:"Solo Relax",sculptTopologicalCheck:"Controllo Topologia",sculptMoveAlongNormal:"Muovi lungo la normale (N o -Alt)",sculptMaskingClear:"Pulisci (-Ctrl + Drag)",sculptMaskingInvert:"Inverti (-Ctrl + Click)",sculptMaskingBlur:"Sfoca",sculptMaskingSharpen:"Nitidezza",sculptPBRTitle:"Materiali PBR",sculptPaintAll:"Dipingi tutto",sculptExtractTitle:"Estrazione",sculptExtractThickness:"Assottigliamento",sculptExtractAction:"Estrai !",stateTitle:"Story",stateUndo:"Indietro",stateRedo:"Avanti",stateMaxStack:"Pila massima",pressureTitle:"Pressione tavoletta",pressureRadius:"Raggio pressione",pressureIntensity:"Intensità pressione",renderingTitle:"Rendering",renderingGrid:"Vedi Griglia",renderingSymmetryLine:"Vedi linea di specchiatura",renderingMatcap:"Matcap",renderingCurvature:"Curvature",renderingPBR:"PBR",renderingTransparency:"Trasparenza",renderingNormal:"Normal shader",renderingUV:"UV shader",renderingShader:"Shader",renderingMaterial:"Materiale",renderingImportUV:"Importa (jpg, png...)",renderingImportMatcap:"Importa (jpg, png...)",renderingExtra:"Extra",renderingFlat:"Shading piatto",renderingWireframe:"Wireframe (W)",renderingExposure:"Esposizione",renderingEnvironment:"Ambiente",renderingIsolate:"Isola/Vedi (I)",renderingFilmic:"Coloritura tipo Film",contour:"Contorno",contourShow:"Vedi contorno",contourColor:"Colore",darkenUnselected:"Scurisci i non selezionati",resolution:"Risolutione",matcapPearl:"Perla",matcapClay:"Argilla",matcapSkin:"Pelle",matcapGreen:"Verde",matcapWhite:"Bianco",sketchfabTitle:"Vai su Sketchfab !",sketchfabUpload:"Upload",sketchfabUploadMessage:'Inserisci la tua chiave API di sketchfab.\nPuoi anche lasciare "guest" per caricare in modo anonimo.\n(verrà visualizzata una nuova finestra al termine del caricamento e dell\'elaborazione)',sketchfabUploadError:function(e){return"Sketchfab errore upload :\n"+e},sketchfabUploadSuccess:"Caricato con successo !\nEcco il tuo link :",sketchfabAbort:"Annulla l'ultimo caricamento?",sketchfabUploadProcessing:"Processing...\nIl tuo modello sarà disponibile su :",about:"Informazioni & Aiuto",alphaNone:"Nessuno",alphaSquare:"Quadrata",alphaSkin:"Pelle",remeshTitleMC:"Voxel remeshing (manifold tris)",remeshRemeshMC:"Remesh",remeshSmoothingMC:"Relax topology"}},o.select="english";var l=window.navigator.language||window.navigator.userLanguage;switch(l&&(l=l.substr(0,2)),"ja"===l?o.select="日本語":"zh"===l?o.select="中文":"ko"===l?o.select="한국어":"tr"===l?o.select="turkish":"sv"===l?o.select="svenska":"fr"===l?o.select="français":"de"===l&&(o.select="deutsch"),(0,i.Z)().language){case"english":o.select="english";break;case"chinese":o.select="中文";break;case"korean":o.select="한국어";break;case"japanese":o.select="日本語";break;case"russian":o.select="русский";break;case"turkish":o.select="turkish";break;case"swedish":o.select="svenska";break;case"french":o.select="français";break;case"german":o.select="deutsch";break;case"italian":o.select="italiano"}const u=o},165:(e,t,r)=>{"use strict";r.d(t,{Z:()=>T});var i,n,a,s,o,l,u,c,h,d,f,_,p,g,m,v,y=r(928),b={normalizedMouse:function(e,t,r,i){return[2*e/r-1,1-2*t/i]},mouseOnUnitSphere:function(e){var t=e[0],r=e[1],i=1-t*t-r*r,n=[t,r,i>0?Math.sqrt(i):0];return y.Fv(n,n)}};b.intersectionRayTriangleEdges=(i=1+1e-15,n=-1e-15,a=[0,0,0],s=[0,0,0],o=[0,0,0],function(e,t,r,l,u,c){y.kC(a,t,l);var h=y.AK(r,a);if(h>-1e-15&&h<1e-15)return-1;var d=1/h;y.lu(s,e,u);var f=y.AK(s,a)*d;if(fi)return-1;y.kC(o,s,r);var _=y.AK(t,o)*d;if(_i)return-1;var p=y.AK(l,o)*d;return p=n?(v=1,l=n+2*_+g):l=_*(v=-_/n)+g):(v=0,p>=0?(b=0,l=g):-p>=s?(b=1,l=s+2*p+g):l=p*(b=-p/s)+g)):(T=3,v=0,p>=0?(b=0,l=g):-p>=s?(b=1,l=s+2*p+g):l=p*(b=-p/s)+g);else if(b<0)T=5,b=0,_>=0?(v=0,l=g):-_>=n?(v=1,l=n+2*_+g):l=_*(v=-_/n)+g;else{T=0;var M=1/m;l=(v*=M)*(n*v+a*(b*=M)+2*_)+b*(a*v+s*b+2*p)+g}else v<0?(T=2,(h=s+p)>(u=a+_)?(d=h-u)>=(f=n-2*a+s)?(v=1,b=0,l=n+2*_+g):l=(v=d/f)*(n*v+a*(b=1-v)+2*_)+b*(a*v+s*b+2*p)+g:(v=0,h<=0?(b=1,l=s+2*p+g):p>=0?(b=0,l=g):l=p*(b=-p/s)+g)):b<0?(T=6,(h=n+_)>(u=a+p)?(d=h-u)>=(f=n-2*a+s)?(b=1,v=0,l=s+2*p+g):l=(v=1-(b=d/f))*(n*v+a*b+2*_)+b*(a*v+s*b+2*p)+g:(b=0,h<=0?(v=1,l=n+2*_+g):_>=0?(v=0,l=g):l=_*(v=-_/n)+g)):(T=1,(d=s+p-a-_)<=0?(v=0,b=1,l=s+2*p+g):d>=(f=n-2*a+s)?(v=1,b=0,l=n+2*_+g):l=(v=d/f)*(n*v+a*(b=1-v)+2*_)+b*(a*v+s*b+2*p)+g);return l<0&&(l=0),o&&(o[0]=i[0]+v*t[0]+b*r[0],o[1]=i[1]+v*t[1]+b*r[1],o[2]=i[2]+v*t[2]+b*r[2],o[3]=T),l}),b.distance2PointTriangle=function(){var e=[0,0,0],t=[0,0,0];return function(r,i,n,a,s){y.lu(e,n,i),y.lu(t,a,i);var o=y.x9(e),l=y.AK(e,t),u=y.x9(t);return b.distance2PointTriangleEdges(r,e,t,i,o,l,u,s)}}(),b.pointInsideTriangle=(h=[0,0,0],d=[0,0,0],f=[0,0,0],_=[0,0,0],p=[0,0,0],function(e,t,r,i){y.lu(h,t,r),y.lu(d,t,i),y.lu(f,e,r),y.lu(_,e,i);var n=y.Zh(y.kC(p,h,d)),a=y.Zh(y.kC(p,h,f)),s=y.Zh(y.kC(p,d,_)),o=y.Zh(y.kC(p,f,_));return Math.abs(n-(a+s+o))<1e-20}),b.triangleInsideSphere=function(e,t,r,i,n){return b.distanceSqToSegment(e,r,i)1?y.x9(y.lu(g,e,r)):(g[0]=e[0]-t[0]-n*m[0],g[1]=e[1]-t[1]-n*m[1],g[2]=e[2]-t[2]-n*m[2],y.x9(g))}),b.signedAngle2d=function(e,t){var r=e[0],i=e[1],n=t[0],a=t[1];return Math.atan2(r*a-i*n,r*n+i*a)},b.pointPlaneDistance=function(){var e=[0,0,0];return function(t,r,i){return y.AK(y.lu(e,t,r),i)}}(),b.mirrorPoint=function(){var e=[0,0,0];return function(t,r,i){return y.lu(t,t,y.bA(e,i,2*b.pointPlaneDistance(t,r,i)))}}(),b.vertexOnLine=(v=[0,0,0],function(e,t,r){y.lu(v,r,t);var i=[0,0,0],n=y.AK(v,y.lu(i,e,t));return y.od(i,t,v,n/y.x9(v))}),b.intersectLinePlane=function(e,t,r,i,n){var a=y.AK(y.lu(n,e,r),i),s=y.AK(y.lu(n,t,r),i);if(a===s)return t;var o=-a/(s-a);return y.od(n,e,y.lu(n,t,e),o)},b.getPerpendicularVector=function(e){var t=[0,0,0];return 0===e[0]?t[0]=1:0===e[1]?t[1]=1:0===e[2]?t[2]=1:(t[0]=e[2],t[1]=e[2],t[2]=-e[0]-e[1],y.Fv(t,t)),t};const T=b},358:(__unused_webpack_module,__webpack_exports__,__webpack_require__)=>{"use strict";__webpack_require__.d(__webpack_exports__,{Z:()=>__WEBPACK_DEFAULT_EXPORT__});var gl_matrix__WEBPACK_IMPORTED_MODULE_4__=__webpack_require__(89),gl_matrix__WEBPACK_IMPORTED_MODULE_5__=__webpack_require__(928),math3d_Geometry__WEBPACK_IMPORTED_MODULE_0__=__webpack_require__(165),misc_Tablet__WEBPACK_IMPORTED_MODULE_1__=__webpack_require__(522),misc_Utils__WEBPACK_IMPORTED_MODULE_2__=__webpack_require__(781),gui_GuiTR__WEBPACK_IMPORTED_MODULE_3__=__webpack_require__(183);function _classCallCheck(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function _defineProperties(e,t){for(var r=0;r1)return 0;var o=i._ratioX*(n[1]*e+n[5]*t+n[9]*r+n[13])/a;if(Math.abs(o)>1)return 0;var l=i._width;return s=(.5-.5*s)*l,o=(.5-.5*o)*i._height,i._texture[(0|s)+l*(0|o)]/255}},{key:"updateAlpha",value:function(e){var t=_TMP_V1,r=_TMP_V2,i=Math.sqrt(this._rLocal2);if(this._alphaSide=i*Math.SQRT1_2,gl_matrix__WEBPACK_IMPORTED_MODULE_5__.lu(t,this._interPoint,this._alphaOrigin),0!==gl_matrix__WEBPACK_IMPORTED_MODULE_5__.Zh(t)){gl_matrix__WEBPACK_IMPORTED_MODULE_5__.Fv(t,t);var n=this._pickedNormal;gl_matrix__WEBPACK_IMPORTED_MODULE_5__.od(t,t,n,-gl_matrix__WEBPACK_IMPORTED_MODULE_5__.AK(t,n)),gl_matrix__WEBPACK_IMPORTED_MODULE_5__.Fv(t,t),e||gl_matrix__WEBPACK_IMPORTED_MODULE_5__.JG(this._alphaOrigin,this._interPoint),gl_matrix__WEBPACK_IMPORTED_MODULE_5__.od(r,this._alphaOrigin,n,i),gl_matrix__WEBPACK_IMPORTED_MODULE_4__.zB(this._alphaLookAt,this._alphaOrigin,r,t)}}},{key:"initAlpha",value:function(){this.computePickedNormal(),this.updateAlpha()}},{key:"getMesh",value:function(){return this._mesh}},{key:"setLocalRadius2",value:function(e){this._rLocal2=e}},{key:"getLocalRadius2",value:function(){return this._rLocal2}},{key:"getLocalRadius",value:function(){return Math.sqrt(this._rLocal2)}},{key:"getWorldRadius2",value:function(){return this._rWorld2}},{key:"getWorldRadius",value:function(){return Math.sqrt(this._rWorld2)}},{key:"setIntersectionPoint",value:function(e){this._interPoint=e}},{key:"getEyeDirection",value:function(){return this._eyeDir}},{key:"getIntersectionPoint",value:function(){return this._interPoint}},{key:"getPickedVertices",value:function(){return this._pickedVertices}},{key:"getPickedFace",value:function(){return this._pickedFace}},{key:"getPickedNormal",value:function(){return this._pickedNormal}},{key:"intersectionMouseMeshes",value:function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this._main.getMeshes(),t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this._main._mouseX,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this._main._mouseY,i=this.unproject(t,r,0),n=this.unproject(t,r,.1),a=1/0,s=null,o=-1,l=0,u=e.length;l0&&void 0!==arguments[0]?arguments[0]:this._main.getMesh(),t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this._main._mouseX,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this._main._mouseY,i=this.unproject(t,r,0),n=this.unproject(t,r,.1),a=gl_matrix__WEBPACK_IMPORTED_MODULE_4__.Ue();return gl_matrix__WEBPACK_IMPORTED_MODULE_4__.U_(a,e.getMatrix()),gl_matrix__WEBPACK_IMPORTED_MODULE_5__.fF(i,i,a),gl_matrix__WEBPACK_IMPORTED_MODULE_5__.fF(n,n,a),this.intersectionRayMesh(e,i,n)}},{key:"intersectionRayMesh",value:function(e,t,r){if(this._mesh=null,this._pickedFace=-1,gl_matrix__WEBPACK_IMPORTED_MODULE_5__.JG(_TMP_NEAR,t),gl_matrix__WEBPACK_IMPORTED_MODULE_5__.JG(_TMP_FAR,r),this._xSym){var i=e.getSymmetryOrigin(),n=e.getSymmetryNormal();math3d_Geometry__WEBPACK_IMPORTED_MODULE_0__.Z.mirrorPoint(_TMP_NEAR,i,n),math3d_Geometry__WEBPACK_IMPORTED_MODULE_0__.Z.mirrorPoint(_TMP_FAR,i,n)}var a=e.getVertices(),s=e.getFaces(),o=this.getEyeDirection();gl_matrix__WEBPACK_IMPORTED_MODULE_5__.lu(o,_TMP_FAR,_TMP_NEAR),gl_matrix__WEBPACK_IMPORTED_MODULE_5__.Fv(o,o);for(var l=e.intersectRay(_TMP_NEAR,o),u=1/0,c=l.length,h=0;h=0&&ge||(l[k]=c,g[v++]=k)}}}return this._pickedVertices=new Uint32Array(g.subarray(0,v)),this._pickedVertices}},{key:"computeWorldRadius2",value:function(e){gl_matrix__WEBPACK_IMPORTED_MODULE_5__.fF(_TMP_INTER,this.getIntersectionPoint(),this._mesh.getMatrix());var t=this._main.getSculptManager().getCurrentTool().getScreenRadius();e||(t*=misc_Tablet__WEBPACK_IMPORTED_MODULE_1__.Z.getPressureRadius());var r=this.project(_TMP_INTER);return gl_matrix__WEBPACK_IMPORTED_MODULE_5__.nI(_TMP_INTER,this.unproject(r[0]+t,r[1],r[2]))}},{key:"updateLocalAndWorldRadius2",value:function(){this._mesh&&(this._rWorld2=this.computeWorldRadius2(),this._rLocal2=this._rWorld2/this._mesh.getScale2())}},{key:"unproject",value:function(e,t,r){return this._main.getCamera().unproject(e,t,r)}},{key:"project",value:function(e){return this._main.getCamera().project(e)}},{key:"computePickedNormal",value:function(){if(this._mesh&&!(this._pickedFace<0))return this.polyLerp(this._mesh.getNormals(),this._pickedNormal),gl_matrix__WEBPACK_IMPORTED_MODULE_5__.Fv(this._pickedNormal,this._pickedNormal)}},{key:"polyLerp",value:function(e,t){var r=this._mesh.getVertices(),i=this._mesh.getFaces(),n=4*this._pickedFace,a=3*i[n],s=3*i[n+1],o=3*i[n+2],l=i[n+3],u=l!==misc_Utils__WEBPACK_IMPORTED_MODULE_2__.Z.TRI_INDEX;u&&(l*=3);var c=1/gl_matrix__WEBPACK_IMPORTED_MODULE_5__.TK(this._interPoint,r.subarray(a,a+3)),h=1/gl_matrix__WEBPACK_IMPORTED_MODULE_5__.TK(this._interPoint,r.subarray(s,s+3)),d=1/gl_matrix__WEBPACK_IMPORTED_MODULE_5__.TK(this._interPoint,r.subarray(o,o+3)),f=u?1/gl_matrix__WEBPACK_IMPORTED_MODULE_5__.TK(this._interPoint,r.subarray(l,l+3)):0,_=1/(c+h+d+f);return gl_matrix__WEBPACK_IMPORTED_MODULE_5__.t8(t,0,0,0),gl_matrix__WEBPACK_IMPORTED_MODULE_5__.od(t,t,e.subarray(a,a+3),c*_),gl_matrix__WEBPACK_IMPORTED_MODULE_5__.od(t,t,e.subarray(s,s+3),h*_),gl_matrix__WEBPACK_IMPORTED_MODULE_5__.od(t,t,e.subarray(o,o+3),d*_),u&&gl_matrix__WEBPACK_IMPORTED_MODULE_5__.od(t,t,e.subarray(l,l+3),f*_),t}}],[{key:"addAlpha",value:function(t,r,i,n){var a={};a._name=n,a._texture=t,a._ratioX=Math.max(1,r/i),a._ratioY=Math.max(1,i/r),a._ratioMax=Math.max(a._ratioX,a._ratioY),a._width=r,a._height=i;for(var s=1;e.ALPHAS[a._name];)a._name=n+s++;return e.ALPHAS[a._name]=a,e.ALPHAS_NAMES[a._name]=a._name,a}}]),e}();Picking.INIT_ALPHAS_NAMES=[(0,gui_GuiTR__WEBPACK_IMPORTED_MODULE_3__.Z)("alphaSquare"),(0,gui_GuiTR__WEBPACK_IMPORTED_MODULE_3__.Z)("alphaSkin")],Picking.INIT_ALPHAS_PATHS=["square.jpg","skin.jpg"];var readAlphas=function readAlphas(){if(window.module&&window.module.exports){var fs=eval("require")("fs"),path=eval("require")("path"),directoryPath=path.join(window.__filename,"../resources/alpha");fs.readdir(directoryPath,(function(e,t){if(!e)for(var r=0;r{"use strict";r.d(t,{Z:()=>s});var i=r(781),n={Action:{NOTHING:0,MASK_EDIT:1,SCULPT_EDIT:2,CAMERA_ZOOM:3,CAMERA_ROTATE:4,CAMERA_PAN:5,CAMERA_PAN_ZOOM_ALT:6},Tools:{BRUSH:0,INFLATE:1,TWIST:2,SMOOTH:3,FLATTEN:4,PINCH:5,CREASE:6,DRAG:7,PAINT:8,MOVE:9,MASKING:10,LOCALSCALE:11,TRANSFORM:12,PEN:13},Shader:{PBR:0,FLAT:1,NORMAL:2,WIREFRAME:3,UV:4,MATCAP:5,SELECTION:6,BACKGROUND:7,MERGE:8,FXAA:9,CONTOUR:10,PAINTUV:11,BLUR:12,COMPARISON_IMAGE:13},Projection:{PERSPECTIVE:0,ORTHOGRAPHIC:1},CameraMode:{ORBIT:0,SPHERICAL:1,PLANE:2},MultiState:{NONE:0,SCULPT:1,CAMERA:2,PICKING:3}},a=Object.keys(n.Tools).length;n.KeyAction=i.Z.extend({INTENSITY:a++,RADIUS:a++,NEGATIVE:a++,PICKER:a++,DELETE:a++,CAMERA_FRONT:a++,CAMERA_TOP:a++,CAMERA_LEFT:a++,CAMERA_RESET:a++,STRIFE_LEFT:a++,STRIFE_RIGHT:a++,STRIFE_UP:a++,STRIFE_DOWN:a++,WIREFRAME:a++,REMESH:a++},n.Tools);const s=n},50:()=>{if(window.Map||(window.Map=function(){this.map={}},window.Map.prototype={set:function(e,t){this.map[e]=t},get:function(e){return this.map[e]}}),!Float32Array.prototype.slice){var e=function(e,t){return new this.constructor(this.subarray(e,t))};Int8Array.prototype.slice=e,Uint8Array.prototype.slice=e,Uint8ClampedArray.prototype.slice=e,Int16Array.prototype.slice=e,Uint16Array.prototype.slice=e,Int32Array.prototype.slice=e,Uint32Array.prototype.slice=e,Float32Array.prototype.slice=e,Float64Array.prototype.slice=e}String.prototype.endsWith||(String.prototype.endsWith=function(e){return this.slice(-e.length)===e}),String.prototype.startsWith||(String.prototype.startsWith=function(e){return this.slice(0,e.length)===e});for(var t=["moz","webkit"],r=0;r{"use strict";r.d(t,{Z:()=>n});var i={radiusFactor:.75,intensityFactor:0,pressure:.5,getPressureIntensity:function(){return 1+i.intensityFactor*(2*i.pressure-1)},getPressureRadius:function(){return 1+i.radiusFactor*(2*i.pressure-1)}};const n=i},781:(e,t,r)=>{"use strict";r.d(t,{Z:()=>l});var i=r(928),n={SCALE:100,TAG_FLAG:1,SCULPT_FLAG:1,STATE_FLAG:1,TRI_INDEX:4294967295,cursors:{}};n.cursors.dropper="url(resources/dropper.png) 5 25, auto",n.linearToSRGB1=function(e){return e<.0031308?12.92*e:1.055*Math.pow(e,1/2.4)-.055},n.sRGBToLinear1=function(e){return e<.04045?e*(1/12.92):Math.pow((e+.055)*(1/1.055),2.4)},n.extend=function(e,t){for(var r=Object.keys(t),i=0,n=r.length;i>t;return e+1};var a,s,o=function(e,t){return e-t};n.tidy=function(e){e.sort(o);var t=e.length,r=0,i=0;for(r=1;r1&&(e.length=i+1)},n.intersectionArrays=function(e,t){for(var r=0,i=0,n=[],a=e.length,s=t.length;rt[i]?i++:(n.push(e[r]),++r,++i);return n},n.littleEndian=(a=new ArrayBuffer(2),new DataView(a).setInt16(0,256,!0),256===new Int16Array(a)[0]),n.getBytes=function(e,t){return[e[t].charCodeAt(),e[t+1].charCodeAt(),e[t+2].charCodeAt(),e[t+3].charCodeAt()]},n.getUint32=function(e,t){var r=n.getBytes(e,t);return r[0]<<0|r[1]<<8|r[2]<<16|r[3]<<24},n.getFloat32=function(e,t){var r=n.getBytes(e,t),i=1-2*(r[3]>>7),a=(r[3]<<1&255|r[2]>>7)-127,s=(127&r[2])<<16|r[1]<<8|r[0];return 128===a?0!==s?NaN:i*(1/0):-127===a?i*s*Math.pow(2,-149):i*(1+s*Math.pow(2,-23))*Math.pow(2,a)},n.ab2str=function(e){for(var t="",r=new Uint8Array(e),i=65535,n=0,a=r.length;n=e?s:s=new ArrayBuffer(e)}),n.now=Date.now||function(){return(new Date).getTime()},n.throttle=function(e,t){var r,i=[],a=null,s=0,o=function(){s=n.now(),a=null,r=e.apply(e,i)};return function(){for(var l=n.now(),u=t-(l-s),c=i.length=arguments.length,h=0;ht?(window.clearTimeout(a),a=null,s=l,r=e.apply(e,i)):a||(a=window.setTimeout(o,u)),r}},n.normalizeArrayVec3=function(e){for(var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:e,r=0,i=e.length/3;r1&&void 0!==arguments[1]?arguments[1]:e,r=0,i=e.length;r1&&void 0!==arguments[1]?arguments[1]:e,r=0,i=e.length;r{"use strict";r.d(t,{Z:()=>h});var i,n=r(839),a=n.Z.KeyAction,s=function(e,t){return void 0===e?t:"false"!==e&&"0"!==e},o=function(e,t,r,i){var n=parseFloat(e);return n||0===n?Math.max(t,Math.min(r,n)):i},l=function(e,t,r,i){var n=parseInt(e,10);return n||0===n?Math.max(t,Math.min(r,n)):i},u=function(e,t,r){if(t){var i=e[t.toUpperCase()];if(void 0!==i)return i}return r},c=function(){if(i)return i;i={};var e=function(){for(var e=window.location.search.substr(1).split("&"),t={},r=0,i=e.length;r=10?l:o.charCodeAt(0);var u=a[s[0].toUpperCase()];void 0!==u&&(t[o]=u)}}return t}(e.shortcuts),i};c(),c.getShortKey=function(e){return e>=96&&e<=105&&(e-=48),c().shortcuts[e]};const h=c}},__webpack_module_cache__={};function __webpack_require__(e){if(__webpack_module_cache__[e])return __webpack_module_cache__[e].exports;var t=__webpack_module_cache__[e]={id:e,loaded:!1,exports:{}};return __webpack_modules__[e].call(t.exports,t,t.exports,__webpack_require__),t.loaded=!0,t.exports}return __webpack_require__.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return __webpack_require__.d(t,{a:t}),t},__webpack_require__.d=(e,t)=>{for(var r in t)__webpack_require__.o(t,r)&&!__webpack_require__.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},__webpack_require__.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),__webpack_require__.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),__webpack_require__.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},__webpack_require__.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),__webpack_require__(866)})()})); \ No newline at end of file diff --git a/frontend/public/sculptgl/sculptgl.js.LICENSE.txt b/frontend/public/sculptgl/sculptgl.js.LICENSE.txt new file mode 100644 index 0000000..951e0ba --- /dev/null +++ b/frontend/public/sculptgl/sculptgl.js.LICENSE.txt @@ -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 ***! + \*****************************************/ diff --git a/frontend/public/sculptgl/worker/deflate.js b/frontend/public/sculptgl/worker/deflate.js new file mode 100644 index 0000000..1fb7907 --- /dev/null +++ b/frontend/public/sculptgl/worker/deflate.js @@ -0,0 +1 @@ +!function(e){"use strict";function t(){function e(e){var t,n,i,r,_,o,l=a.dyn_tree,d=a.stat_desc.static_tree,f=a.stat_desc.extra_bits,s=a.stat_desc.extra_base,c=a.stat_desc.max_length,p=0;for(r=0;u>=r;r++)e.bl_count[r]=0;for(l[2*e.heap[e.heap_max]+1]=0,t=e.heap_max+1;h>t;t++)n=e.heap[t],r=l[2*l[2*n+1]+1]+1,r>c&&(r=c,p++),l[2*n+1]=r,n>a.max_code||(e.bl_count[r]++,_=0,s>n||(_=f[n-s]),o=l[2*n],e.opt_len+=o*(r+_),d&&(e.static_len+=o*(d[2*n+1]+_)));if(0!==p){do{for(r=c-1;0===e.bl_count[r];)r--;e.bl_count[r]--,e.bl_count[r+1]+=2,e.bl_count[c]--,p-=2}while(p>0);for(r=c;0!==r;r--)for(n=e.bl_count[r];0!==n;)i=e.heap[--t],i>a.max_code||(l[2*i+1]!=r&&(e.opt_len+=(r-l[2*i+1])*l[2*i],l[2*i+1]=r),n--)}}function t(e,t){var n=0;do n|=1&e,e>>>=1,n<<=1;while(--t>0);return n>>>1}function n(e,n,a){var i,r,_,o=[],l=0;for(i=1;u>=i;i++)o[i]=l=l+a[i-1]<<1;for(r=0;n>=r;r++)_=e[2*r+1],0!==_&&(e[2*r]=t(o[_]++,_))}var a=this;a.build_tree=function(t){var i,r,_,o=a.dyn_tree,u=a.stat_desc.static_tree,l=a.stat_desc.elems,d=-1;for(t.heap_len=0,t.heap_max=h,i=0;l>i;i++)0!==o[2*i]?(t.heap[++t.heap_len]=d=i,t.depth[i]=0):o[2*i+1]=0;for(;t.heap_len<2;)_=t.heap[++t.heap_len]=2>d?++d:0,o[2*_]=1,t.depth[_]=0,t.opt_len--,u&&(t.static_len-=u[2*_+1]);for(a.max_code=d,i=Math.floor(t.heap_len/2);i>=1;i--)t.pqdownheap(o,i);_=l;do i=t.heap[1],t.heap[1]=t.heap[t.heap_len--],t.pqdownheap(o,1),r=t.heap[1],t.heap[--t.heap_max]=i,t.heap[--t.heap_max]=r,o[2*_]=o[2*i]+o[2*r],t.depth[_]=Math.max(t.depth[i],t.depth[r])+1,o[2*i+1]=o[2*r+1]=_,t.heap[1]=_++,t.pqdownheap(o,1);while(t.heap_len>=2);t.heap[--t.heap_max]=t.heap[1],e(t),n(o,a.max_code,t.bl_count)}}function n(e,t,n,a,i){var r=this;r.static_tree=e,r.extra_bits=t,r.extra_base=n,r.elems=a,r.max_length=i}function a(e,t,n,a,i){var r=this;r.good_length=e,r.max_lazy=t,r.nice_length=n,r.max_chain=a,r.func=i}function i(e,t,n,a){var i=e[2*t],r=e[2*n];return r>i||i==r&&a[t]<=a[n]}function r(){function e(){var e;for(qt=2*kt,Pt[jt-1]=0,e=0;jt-1>e;e++)Pt[e]=0;Tt=K[Vt].max_lazy,Xt=K[Vt].good_length,Yt=K[Vt].nice_length,Rt=K[Vt].max_chain,Lt=0,Gt=0,Ot=0,Ht=Qt=tt-1,Kt=0,St=0}function a(){var e;for(e=0;c>e;e++)Zt[2*e]=0;for(e=0;l>e;e++)$t[2*e]=0;for(e=0;d>e;e++)en[2*e]=0;Zt[2*p]=1,tn.opt_len=tn.static_len=0,un=dn=0}function r(){nn.dyn_tree=Zt,nn.stat_desc=n.static_l_desc,an.dyn_tree=$t,an.stat_desc=n.static_d_desc,rn.dyn_tree=en,rn.stat_desc=n.static_bl_desc,sn=0,cn=0,fn=8,a()}function _(e,t){var n,a,i=-1,r=e[1],_=0,o=7,u=4;for(0===r&&(o=138,u=3),e[2*(t+1)+1]=65535,n=0;t>=n;n++)a=r,r=e[2*(n+1)+1],++__?en[2*a]+=_:0!==a?(a!=i&&en[2*a]++,en[2*v]++):_>10?en[2*g]++:en[2*b]++,_=0,i=a,0===r?(o=138,u=3):a==r?(o=6,u=3):(o=7,u=4))}function o(){var e;for(_(Zt,nn.max_code),_($t,an.max_code),rn.build_tree(tn),e=d-1;e>=3&&0===en[2*t.bl_order[e]+1];e--);return tn.opt_len+=3*(e+1)+5+5+4,e}function u(e){tn.pending_buf[tn.pending++]=e}function f(e){u(255&e),u(e>>>8&255)}function h(e){u(e>>8&255),u(255&e&255)}function x(e,t){var n,a=t;cn>w-a?(n=e,sn|=n<>>w-cn,cn+=a-w):(sn|=e<=n;n++)if(a=r,r=e[2*(n+1)+1],++_>=o||a!=r){if(u>_){do B(a,en);while(0!==--_)}else 0!==a?(a!=i&&(B(a,en),_--),B(v,en),x(_-3,2)):_>10?(B(g,en),x(_-11,7)):(B(b,en),x(_-3,3));_=0,i=a,0===r?(o=138,u=3):a==r?(o=6,u=3):(o=7,u=4)}}function rt(e,n,a){var i;for(x(e-257,5),x(n-1,5),x(a-4,4),i=0;a>i;i++)x(en[2*t.bl_order[i]+1],3);it(Zt,e-1),it($t,n-1)}function _t(){16==cn?(f(sn),sn=0,cn=0):8>cn||(u(255&sn),sn>>>=8,cn-=8)}function ot(){x($<<1,3),B(p,n.static_ltree),_t(),9>1+fn+10-cn&&(x($<<1,3),B(p,n.static_ltree),_t()),fn=7}function ut(e,n){var a,i,r;if(tn.pending_buf[ln+2*un]=e>>>8&255,tn.pending_buf[ln+2*un+1]=255&e,tn.pending_buf[_n+un]=255&n,un++,0===e?Zt[2*n]++:(dn++,e--,Zt[2*(t._length_code[n]+s+1)]++,$t[2*t.d_code(e)]++),0===(8191&un)&&Vt>2){for(a=8*un,i=Lt-Gt,r=0;l>r;r++)a+=$t[2*r]*(5+t.extra_dbits[r]);if(a>>>=3,dno);B(p,e),fn=e[2*p+1]}function dt(){cn>8?f(sn):cn>0&&u(255&sn),sn=0,cn=0}function ft(e,t,n){dt(),fn=8,n&&(f(t),f(~t)),tn.pending_buf.set(Dt.subarray(e,e+t),tn.pending),tn.pending+=t}function st(e,t,n){x((Z<<1)+(n?1:0),3),ft(e,t,!0)}function ct(e,t,i){var r,_,u=0;Vt>0?(nn.build_tree(tn),an.build_tree(tn),u=o(),r=tn.opt_len+3+7>>>3,_=tn.static_len+3+7>>>3,_>r||(r=_)):r=_=t+5,t+4>r||-1==e?_==r?(x(($<<1)+(i?1:0),3),lt(n.static_ltree,n.static_dtree)):(x((et<<1)+(i?1:0),3),rt(nn.max_code+1,an.max_code+1,u+1),lt(Zt,$t)):st(e,t,i),a(),i&&dt()}function ht(e){ct(0>Gt?-1:Gt,Lt-Gt,e),Gt=Lt,mt.flush_pending()}function pt(){var e,t,n,a;do{if(a=qt-Ot-Lt,0===a&&0===Lt&&0===Ot)a=kt;else if(-1==a)a--;else if(Lt>=kt+kt-at){Dt.set(Dt.subarray(kt,kt+kt),0),Nt-=kt,Lt-=kt,Gt-=kt,e=jt,n=e;do t=65535&Pt[--n],Pt[n]=kt>t?0:t-kt;while(0!==--e);e=kt,n=e;do t=65535&It[--n],It[n]=kt>t?0:t-kt;while(0!==--e);a+=kt}if(0===mt.avail_in)return;e=mt.read_buf(Dt,Lt+Ot,a),Ot+=e,tt>Ot||(St=255&Dt[Lt],St=(St<Ot&&0!==mt.avail_in)}function xt(e){var t,n=65535;for(n>At-5&&(n=At-5);;){if(1>=Ot){if(pt(),0===Ot&&e==U)return N;if(0===Ot)break}if(Lt+=Ot,Ot=0,t=Gt+n,(0===Lt||Lt>=t)&&(Ot=Lt-t,Lt=t,ht(!1),0===mt.avail_out))return N;if(Lt-Gt>=kt-at&&(ht(!1),0===mt.avail_out))return N}return ht(e==z),0===mt.avail_out?e==z?Q:N:e==z?R:O}function vt(e){var t,n,a=Rt,i=Lt,r=Qt,_=Lt>kt-at?Lt-(kt-at):0,o=Yt,u=zt,l=Lt+nt,d=Dt[i+r-1],f=Dt[i+r];Xt>Qt||(a>>=2),o>Ot&&(o=Ot);do if(t=e,Dt[t+r]==f&&Dt[t+r-1]==d&&Dt[t]==Dt[i]&&Dt[++t]==Dt[i+1]){i+=2,t++;do;while(Dt[++i]==Dt[++t]&&Dt[++i]==Dt[++t]&&Dt[++i]==Dt[++t]&&Dt[++i]==Dt[++t]&&Dt[++i]==Dt[++t]&&Dt[++i]==Dt[++t]&&Dt[++i]==Dt[++t]&&Dt[++i]==Dt[++t]&&l>i);if(n=nt-(l-i),i=l-nt,n>r){if(Nt=e,r=n,n>=o)break;d=Dt[i+r-1],f=Dt[i+r]}}while((e=65535&It[e&u])>_&&0!==--a);return r>Ot?Ot:r}function bt(e){for(var t,n=0;;){if(at>Ot){if(pt(),at>Ot&&e==U)return N;if(0===Ot)break}if(tt>Ot||(St=(St<kt-at||Wt!=A&&(Ht=vt(n)),tt>Ht)t=ut(0,255&Dt[Lt]),Ot--,Lt++;else if(t=ut(Lt-Nt,Ht-tt),Ot-=Ht,Ht>Tt||tt>Ot)Lt+=Ht,Ht=0,St=255&Dt[Lt],St=(St<Ot){if(pt(),at>Ot&&e==U)return N;if(0===Ot)break}if(tt>Ot||(St=(St<Qt&&kt-at>=(Lt-a&65535)&&(Wt!=A&&(Ht=vt(a)),5>=Ht&&(Wt==y||Ht==tt&&Lt-Nt>4096)&&(Ht=tt-1)),tt>Qt||Ht>Qt)if(0!==Kt){if(t=ut(0,255&Dt[Lt-1]),t&&ht(!1),Lt++,Ot--,0===mt.avail_out)return N}else Kt=1,Lt++,Ot--;else{n=Lt+Ot-tt,t=ut(Lt-1-Jt,Qt-tt),Ot-=Qt-1,Qt-=2;do++Lt>n||(St=(St<i||i>C||a!=Y||9>n||n>15||0>t||t>9||0>r||r>A?P:(e.dstate=tn,Mt=n,kt=1<t||t>9||0>n||n>A?P:(K[Vt].func!=K[t].func&&0!==e.total_in&&(a=e.deflate(k)),Vt!=t&&(Vt=t,Tt=K[Vt].max_lazy,Xt=K[Vt].good_length,Yt=K[Vt].nice_length,Rt=K[Vt].max_chain),Wt=n,a)},tn.deflateSetDictionary=function(e,t,n){var a,i=n,r=0;if(!t||yt!=V)return P;if(tt>i)return D;for(i>kt-at&&(i=kt-at,r=n-i),Dt.set(t.subarray(r,r+i),0),Lt=i,Gt=i,St=255&Dt[0],St=(St<=a;a++)St=(St<z||0>t)return P;if(!e.next_out||!e.next_in&&0!==e.avail_in||yt==X&&t!=z)return e.msg=L[I-P],P;if(0===e.avail_out)return e.msg=L[I-j],j;if(mt=e,r=Ut,Ut=t,yt==V&&(a=Y+(Mt-8<<4)<<8,i=(Vt-1&255)>>1,i>3&&(i=3),a|=i<<6,0!==Lt&&(a|=T),a+=31-a%31,yt=W,h(a)),0!==tn.pending){if(mt.flush_pending(),0===mt.avail_out)return Ut=-1,D}else if(0===mt.avail_in&&r>=t&&t!=z)return mt.msg=L[I-j],j;if(yt==X&&0!==mt.avail_in)return e.msg=L[I-j],j;if(0!==mt.avail_in||0!==Ot||t!=U&&yt!=X){switch(_=-1,K[Vt].func){case G:_=xt(t);break;case H:_=bt(t);break;case J:_=gt(t)}if((_==Q||_==R)&&(yt=X),_==N||_==Q)return 0===mt.avail_out&&(Ut=-1),D;if(_==O){if(t==k)ot();else if(st(0,0,!1),t==M)for(n=0;jt>n;n++)Pt[n]=0;if(mt.flush_pending(),0===mt.avail_out)return Ut=-1,D}}return t!=z?D:q}}function _(){var e=this;e.next_in_index=0,e.next_out_index=0,e.avail_in=0,e.total_in=0,e.avail_out=0,e.total_out=0}function o(e){var t=this,n=new _,a=512,i=U,r=new Uint8Array(a),o=e?e.level:m;void 0===o&&(o=m),n.deflateInit(o),n.next_out=r,t.append=function(e,t){var _,o,u=[],l=0,d=0,f=0;if(e.length){n.next_in_index=0,n.next_in=e,n.avail_in=e.length;do{if(n.next_out_index=0,n.avail_out=a,_=n.deflate(i),_!=D)throw Error("deflating: "+n.msg);n.next_out_index&&u.push(n.next_out_index==a?new Uint8Array(r):new Uint8Array(r.subarray(0,n.next_out_index))),f+=n.next_out_index,t&&n.next_in_index>0&&n.next_in_index!=l&&(t(n.next_in_index),l=n.next_in_index)}while(n.avail_in>0||0===n.avail_out);return o=new Uint8Array(f),u.forEach(function(e){o.set(e,d),d+=e.length}),o}},t.flush=function(){var e,t,i=[],_=0,o=0;do{if(n.next_out_index=0,n.avail_out=a,e=n.deflate(z),e!=q&&e!=D)throw Error("deflating: "+n.msg);a-n.avail_out>0&&i.push(new Uint8Array(r.subarray(0,n.next_out_index))),o+=n.next_out_index}while(n.avail_in>0||0===n.avail_out);return n.deflateEnd(),t=new Uint8Array(o),i.forEach(function(e){t.set(e,_),_+=e.length}),t}}var u=15,l=30,d=19,f=29,s=256,c=s+1+f,h=2*c+1,p=256,x=7,v=16,b=17,g=18,w=16,m=-1,y=1,A=2,E=0,U=0,k=1,M=3,z=4,D=0,q=1,I=2,P=-2,S=-3,j=-5,B=[0,1,2,3,4,4,5,5,6,6,6,6,7,7,7,7,8,8,8,8,8,8,8,8,9,9,9,9,9,9,9,9,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,0,0,16,17,18,18,19,19,20,20,20,20,21,21,21,21,22,22,22,22,22,22,22,22,23,23,23,23,23,23,23,23,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29];t._length_code=[0,1,2,3,4,5,6,7,8,8,9,9,10,10,11,11,12,12,12,12,13,13,13,13,14,14,14,14,15,15,15,15,16,16,16,16,16,16,16,16,17,17,17,17,17,17,17,17,18,18,18,18,18,18,18,18,19,19,19,19,19,19,19,19,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,26,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,28],t.base_length=[0,1,2,3,4,5,6,7,8,10,12,14,16,20,24,28,32,40,48,56,64,80,96,112,128,160,192,224,0],t.base_dist=[0,1,2,3,4,6,8,12,16,24,32,48,64,96,128,192,256,384,512,768,1024,1536,2048,3072,4096,6144,8192,12288,16384,24576],t.d_code=function(e){return 256>e?B[e]:B[256+(e>>>7)]},t.extra_lbits=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0],t.extra_dbits=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],t.extra_blbits=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7],t.bl_order=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],n.static_ltree=[12,8,140,8,76,8,204,8,44,8,172,8,108,8,236,8,28,8,156,8,92,8,220,8,60,8,188,8,124,8,252,8,2,8,130,8,66,8,194,8,34,8,162,8,98,8,226,8,18,8,146,8,82,8,210,8,50,8,178,8,114,8,242,8,10,8,138,8,74,8,202,8,42,8,170,8,106,8,234,8,26,8,154,8,90,8,218,8,58,8,186,8,122,8,250,8,6,8,134,8,70,8,198,8,38,8,166,8,102,8,230,8,22,8,150,8,86,8,214,8,54,8,182,8,118,8,246,8,14,8,142,8,78,8,206,8,46,8,174,8,110,8,238,8,30,8,158,8,94,8,222,8,62,8,190,8,126,8,254,8,1,8,129,8,65,8,193,8,33,8,161,8,97,8,225,8,17,8,145,8,81,8,209,8,49,8,177,8,113,8,241,8,9,8,137,8,73,8,201,8,41,8,169,8,105,8,233,8,25,8,153,8,89,8,217,8,57,8,185,8,121,8,249,8,5,8,133,8,69,8,197,8,37,8,165,8,101,8,229,8,21,8,149,8,85,8,213,8,53,8,181,8,117,8,245,8,13,8,141,8,77,8,205,8,45,8,173,8,109,8,237,8,29,8,157,8,93,8,221,8,61,8,189,8,125,8,253,8,19,9,275,9,147,9,403,9,83,9,339,9,211,9,467,9,51,9,307,9,179,9,435,9,115,9,371,9,243,9,499,9,11,9,267,9,139,9,395,9,75,9,331,9,203,9,459,9,43,9,299,9,171,9,427,9,107,9,363,9,235,9,491,9,27,9,283,9,155,9,411,9,91,9,347,9,219,9,475,9,59,9,315,9,187,9,443,9,123,9,379,9,251,9,507,9,7,9,263,9,135,9,391,9,71,9,327,9,199,9,455,9,39,9,295,9,167,9,423,9,103,9,359,9,231,9,487,9,23,9,279,9,151,9,407,9,87,9,343,9,215,9,471,9,55,9,311,9,183,9,439,9,119,9,375,9,247,9,503,9,15,9,271,9,143,9,399,9,79,9,335,9,207,9,463,9,47,9,303,9,175,9,431,9,111,9,367,9,239,9,495,9,31,9,287,9,159,9,415,9,95,9,351,9,223,9,479,9,63,9,319,9,191,9,447,9,127,9,383,9,255,9,511,9,0,7,64,7,32,7,96,7,16,7,80,7,48,7,112,7,8,7,72,7,40,7,104,7,24,7,88,7,56,7,120,7,4,7,68,7,36,7,100,7,20,7,84,7,52,7,116,7,3,8,131,8,67,8,195,8,35,8,163,8,99,8,227,8],n.static_dtree=[0,5,16,5,8,5,24,5,4,5,20,5,12,5,28,5,2,5,18,5,10,5,26,5,6,5,22,5,14,5,30,5,1,5,17,5,9,5,25,5,5,5,21,5,13,5,29,5,3,5,19,5,11,5,27,5,7,5,23,5],n.static_l_desc=new n(n.static_ltree,t.extra_lbits,s+1,c,u),n.static_d_desc=new n(n.static_dtree,t.extra_dbits,0,l,u),n.static_bl_desc=new n(null,t.extra_blbits,0,d,x);var C=9,F=8,G=0,H=1,J=2,K=[new a(0,0,0,0,G),new a(4,4,8,4,H),new a(4,5,16,8,H),new a(4,6,32,32,H),new a(4,4,16,16,J),new a(8,16,32,32,J),new a(8,16,128,128,J),new a(8,32,128,256,J),new a(32,128,258,1024,J),new a(32,258,258,4096,J)],L=["need dictionary","stream end","","","stream error","data error","","buffer error","",""],N=0,O=1,Q=2,R=3,T=32,V=42,W=113,X=666,Y=8,Z=0,$=1,et=2,tt=3,nt=258,at=nt+tt+1;_.prototype={deflateInit:function(e,t){var n=this;return n.dstate=new r,t||(t=u),n.dstate.deflateInit(n,e,t)},deflate:function(e){var t=this;return t.dstate?t.dstate.deflate(t,e):P},deflateEnd:function(){var e=this;if(!e.dstate)return P;var t=e.dstate.deflateEnd();return e.dstate=null,t},deflateParams:function(e,t){var n=this;return n.dstate?n.dstate.deflateParams(n,e,t):P},deflateSetDictionary:function(e,t){var n=this;return n.dstate?n.dstate.deflateSetDictionary(n,e,t):P},read_buf:function(e,t,n){var a=this,i=a.avail_in;return i>n&&(i=n),0===i?0:(a.avail_in-=i,e.set(a.next_in.subarray(a.next_in_index,a.next_in_index+i),t),a.next_in_index+=i,a.total_in+=i,i)},flush_pending:function(){var e=this,t=e.dstate.pending;t>e.avail_out&&(t=e.avail_out),0!==t&&(e.next_out.set(e.dstate.pending_buf.subarray(e.dstate.pending_out,e.dstate.pending_out+t),e.next_out_index),e.next_out_index+=t,e.dstate.pending_out+=t,e.total_out+=t,e.avail_out-=t,e.dstate.pending-=t,0===e.dstate.pending&&(e.dstate.pending_out=0))}};var it=e.zip||e;it.Deflater=it._jzlib_Deflater=o}(this); \ No newline at end of file diff --git a/frontend/public/sculptgl/worker/z-worker.js b/frontend/public/sculptgl/worker/z-worker.js new file mode 100644 index 0000000..a42c86b --- /dev/null +++ b/frontend/public/sculptgl/worker/z-worker.js @@ -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); \ No newline at end of file diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..08bdd26 --- /dev/null +++ b/frontend/src/App.css @@ -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; + +} + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..4aface6 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( +
+
+

Loading...

+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + +// Admin route wrapper - requires admin role +function AdminRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isAdmin, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
+

Loading...

+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (!isAdmin) { + return ; + } + + return <>{children}; +} + +function AppRoutes() { + return ( + + {/* Public routes */} + } /> + } /> + + {/* Protected routes - wrapped in AppShell */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + {/* Admin routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + {/* Legacy redirects */} + } /> + + {/* Catch-all redirect */} + } /> + + ); +} + +export default function App() { + return ( + + + + ); +} + +function DashboardWrapper() { + const nav = useNavigate(); + return nav(`/cases/${encodeURIComponent(id)}`)} />; +} diff --git a/frontend/src/api/adminApi.ts b/frontend/src/api/adminApi.ts new file mode 100644 index 0000000..2c8a1b0 --- /dev/null +++ b/frontend/src/api/adminApi.ts @@ -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(path: string, init?: RequestInit): Promise { + 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 { + 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; + last7Days: { date: string; count: number }[]; + last30Days: { date: string; count: number }[]; +}; + +export type UserStats = { + total: number; + active: number; + inactive: number; + byRole: Record; + 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; + cobbAngles: CobbAngleStats; + processingTime: ProcessingTimeStats; + bodyScan: BodyScanStats; +}; + +export async function getDashboardAnalytics(): Promise { + return adminFetch("/admin/analytics/dashboard"); +} + +export async function getRigoDistribution(): Promise<{ distribution: Record }> { + 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}` : ""}`); +} diff --git a/frontend/src/api/braceflowApi.ts b/frontend/src/api/braceflowApi.ts new file mode 100644 index 0000000..8dbf144 --- /dev/null +++ b/frontend/src/api/braceflowApi.ts @@ -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; + 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; + 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(path: string, init?: RequestInit): Promise { + 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 { + 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(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 { + try { + const result = await safeFetch(`/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 { + 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 } +): Promise { + return await safeFetch( + `/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 { + return await safeFetch( + `/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 } +): 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 { + return await safeFetch( + `/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 { + return await safeFetch( + `/cases/${encodeURIComponent(caseId)}/recalculate`, + { method: 'POST' } + ); +} + +/** + * Stage 3: Generate brace from approved landmarks + */ +export async function generateBraceFromLandmarks( + caseId: string, + options?: { experiment?: string } +): Promise { + return await safeFetch( + `/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 +): 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; + 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; + 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; + 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 { + const formData = new FormData(); + if (options?.rigoType) formData.append('rigo_type', options.rigoType); + formData.append('case_id', caseId); + + const result = await safeFetch( + `/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 { + 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 { + 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 +): 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 { + 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}`); + } +} diff --git a/frontend/src/api/rigoApi.ts b/frontend/src/api/rigoApi.ts new file mode 100644 index 0000000..e3de212 --- /dev/null +++ b/frontend/src/api/rigoApi.ts @@ -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 => { + 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; + }, +}; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..632663a --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -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 ( + + ); +} + +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 ( +
+ {!isLanding && ( +
+
+
nav("/")} + onKeyDown={(e) => e.key === "Enter" && nav("/")} + role="button" + tabIndex={0} + > + BraceiQ +
+ + +
+ +
+ {user && ( +
+ {user.fullName || user.username} + +
+ )} +
+
+ )} + +
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/CaseTimeline.tsx b/frontend/src/components/CaseTimeline.tsx new file mode 100644 index 0000000..788ce5e --- /dev/null +++ b/frontend/src/components/CaseTimeline.tsx @@ -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 ( +
+
+ {steps.map((s) => ( +
+ {s} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/LandmarkCanvas.tsx b/frontend/src/components/LandmarkCanvas.tsx new file mode 100644 index 0000000..acf5732 --- /dev/null +++ b/frontend/src/components/LandmarkCanvas.tsx @@ -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, completed: boolean) => void; + initialLandmarks?: Record; +}) { + const imgRef = useRef(null); + + const [landmarks, setLandmarks] = useState>({}); + 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 ( +
+
+

Landmarks

+ +
    + {LANDMARK_ORDER.map((l, idx) => ( +
  1. +
    {l.label}
    +
    + {landmarks[l.key] + ? `x=${landmarks[l.key].x}, y=${landmarks[l.key].y}` + : "pending"} +
    +
  2. + ))} +
+ +
+ +
+ {completed ? "Ready to submit" : `Next: ${active.label}`} +
+
+
+ +
+
+ AP X-ray + + + {landmarks["csl_p1"] && landmarks["csl_p2"] && ( + + )} + + {Object.entries(landmarks).map(([k, p]) => ( + + + + + ))} + +
+ +
Click to place the active landmark.
+
+
+ ); +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..7c912bf --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -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 {status}; +} diff --git a/frontend/src/components/XrayUploader.tsx b/frontend/src/components/XrayUploader.tsx new file mode 100644 index 0000000..15d432e --- /dev/null +++ b/frontend/src/components/XrayUploader.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+

X-ray Upload (AP View)

+ + setFile(e.target.files?.[0] || null)} + /> + +
+ +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/frontend/src/components/pipeline/BodyScanUploadStage.tsx b/frontend/src/components/pipeline/BodyScanUploadStage.tsx new file mode 100644 index 0000000..b8775dc --- /dev/null +++ b/frontend/src/components/pipeline/BodyScanUploadStage.tsx @@ -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; + onSkip: () => Promise; + onContinue: () => void; + onDelete: () => Promise; +}; + +export default function BodyScanUploadStage({ + caseId, + bodyScanData, + isLoading, + onUpload, + onSkip, + onContinue, + onDelete, +}: Props) { + const [dragActive, setDragActive] = useState(false); + const [uploadProgress, setUploadProgress] = useState(null); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + const viewerRef = useRef(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) => { + 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 ( +
+
+

Stage 3: Body Scan Upload

+
+ Processing... +
+
+
+
+
+

Processing body scan...

+
+
+
+ ); + } + + return ( +
+
+

Stage 3: Body Scan Upload

+
+ {hasBodyScan ? ( + Uploaded + ) : ( + Optional + )} +
+
+ +
+ {/* Upload Area / Preview */} +
+ {!hasBodyScan ? ( +
fileInputRef.current?.click()} + > + +
+
📦
+

Upload 3D Body Scan

+

Drag and drop or click to select

+

STL, OBJ, PLY, GLB supported

+ {uploadProgress &&

{uploadProgress}

} + {error &&

{error}

} +
+
+ ) : ( +
+ {/* 3D Spinning Preview - fills container, slow lazy susan rotation */} + + + {/* File info overlay */} +
+ {metadata?.filename || 'body_scan.stl'} + {metadata?.vertex_count && ( + {metadata.vertex_count.toLocaleString()} vertices + )} +
+ + +
+ )} +
+ + {/* Info Panel */} +
+
+

Why Upload a Body Scan?

+

+ A 3D body scan allows us to generate a perfectly fitted brace + that matches your exact body measurements. +

+
    +
  • Precise fit based on body shape
  • +
  • Automatic clearance calculation
  • +
  • Better pressure zone placement
  • +
  • 3D printable shell output
  • +
+

+ Optional: You can skip this step to generate + a standard-sized brace based on X-ray analysis only. +

+
+ + {/* Body Measurements */} + {hasBodyScan && metadata && ( +
+

Body Measurements

+
+ {metadata.total_height_mm !== undefined && ( +
+ Total Height + {formatMeasurement(metadata.total_height_mm)} +
+ )} + {metadata.shoulder_width_mm !== undefined && ( +
+ Shoulder Width + {formatMeasurement(metadata.shoulder_width_mm)} +
+ )} + {metadata.chest_width_mm !== undefined && ( +
+ Chest Width + {formatMeasurement(metadata.chest_width_mm)} +
+ )} + {metadata.chest_depth_mm !== undefined && ( +
+ Chest Depth + {formatMeasurement(metadata.chest_depth_mm)} +
+ )} + {metadata.waist_width_mm !== undefined && ( +
+ Waist Width + {formatMeasurement(metadata.waist_width_mm)} +
+ )} + {metadata.hip_width_mm !== undefined && ( +
+ Hip Width + {formatMeasurement(metadata.hip_width_mm)} +
+ )} + {metadata.total_height_mm !== undefined && ( +
+ Brace Coverage (65%) + {formatMeasurement(metadata.total_height_mm * 0.65)} +
+ )} +
+
+ )} +
+
+ + {/* Actions */} +
+ {!hasBodyScan ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ ); +} diff --git a/frontend/src/components/pipeline/BraceEditorStage.tsx b/frontend/src/components/pipeline/BraceEditorStage.tsx new file mode 100644 index 0000000..f9d1f9a --- /dev/null +++ b/frontend/src/components/pipeline/BraceEditorStage.tsx @@ -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; + 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(DEFAULT_PARAMS); + const [markers, setMarkers] = useState([]); + 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 ( +
+
+

Stage 5: Brace Editor

+
+ Waiting for Brace +
+
+
+
+

Generate a brace first to use the 3D editor.

+
+
+
+ ); + } + + return ( +
+
+

Stage 5: Brace Editor

+
+ Ready +
+
+ +
+ {/* 3D Viewer */} +
+ + + {/* View Controls */} +
+ +
+
+ + {/* Controls Sidebar */} +
+ {/* Deformation Controls */} +
+

Deformation Parameters

+ +
+ +
+ handleParamChange('thoracicPadDepth', Number(e.target.value))} + /> + {params.thoracicPadDepth} +
+
+ +
+ +
+ handleParamChange('lumbarPadDepth', Number(e.target.value))} + /> + {params.lumbarPadDepth} +
+
+ +
+ +
+ handleParamChange('trunkShift', Number(e.target.value))} + /> + {params.trunkShift} +
+
+ +
+ +
+ handleParamChange('rotationCorrection', Number(e.target.value))} + /> + {params.rotationCorrection} +
+
+ +
+ + +
+
+ + {/* Markers Panel */} + {markers.length > 0 && ( +
+

Markers ({markers.length})

+
+ {markers.map((marker, idx) => ( +
+ + {marker.name.replace('LM_', '')} +
+ ))} +
+
+ )} + + {/* Export Panel */} +
+

Export

+
+ {stlUrl && ( + + Download STL + + )} + {glbUrl && ( + + Download GLB + + )} +
+
+ + {/* Info Panel */} +
+

About the Editor

+

+ Adjust the deformation parameters to customize the brace fit. + Changes are previewed in real-time. +

+
    +
  • Thoracic Pad: Pressure on thoracic curve convexity
  • +
  • Lumbar Pad: Counter-pressure on lumbar region
  • +
  • Trunk Shift: Lateral correction force
  • +
  • Rotation: De-rotation effect
  • +
+

+ Use mouse to orbit, scroll to zoom, right-click to pan. +

+
+
+
+
+ ); +} diff --git a/frontend/src/components/pipeline/BraceFittingStage.tsx b/frontend/src/components/pipeline/BraceFittingStage.tsx new file mode 100644 index 0000000..524b286 --- /dev/null +++ b/frontend/src/components/pipeline/BraceFittingStage.tsx @@ -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(null); + const rendererRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const animationFrameRef = useRef(0); + + // Mesh references + const bodyMeshRef = useRef(null); + const regularBraceMeshRef = useRef(null); + const vaseBraceMeshRef = useRef(null); + const regularBaseGeomRef = useRef(null); + const vaseBaseGeomRef = useRef(null); + + // Store base transforms for relative positioning + const baseScaleRef = useRef(1); + + const [threeLoaded, setThreeLoaded] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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(() => ({ + ...DEFAULT_TRANSFORM_PARAMS, + cobbDeg: braceData?.cobb_angles?.MT || braceData?.cobb_angles?.TL || 25, + })); + const [vaseParams, setVaseParams] = useState(() => ({ + ...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 ( +
+
+

Stage 5: Brace Fitting Inspection

+
Pending
+
+
+
+

Body scan and braces are required to inspect fitting.

+

Complete Stage 3 (Body Scan) and Stage 4 (Brace Generation) first.

+
+
+
+ ); + } + + return ( +
+
+

Stage 5: Brace Fitting Inspection

+
Ready
+
+ +
+ {/* LEFT PANEL: Position, Rotation, Scale */} +
+

Transform Controls

+ + {/* Visibility */} +
+

Visibility

+ + + +
+ + {/* Body Opacity */} +
+

Body Opacity: {Math.round(bodyOpacity*100)}%

+ setBodyOpacity(Number(e.target.value)/100)} /> +
+ + {/* Brace Position */} +
+

Brace Position

+
X setBracePositionX(Number(e.target.value))} />{bracePositionX}
+
Y setBracePositionY(Number(e.target.value))} />{bracePositionY}
+
Z setBracePositionZ(Number(e.target.value))} />{bracePositionZ}
+
+ + {/* Brace Rotation */} +
+

Brace Rotation

+
X setBraceRotationX(Number(e.target.value))} />{braceRotationX}°
+
Y setBraceRotationY(Number(e.target.value))} />{braceRotationY}°
+
Z setBraceRotationZ(Number(e.target.value))} />{braceRotationZ}°
+
+ + {/* Brace Scale */} +
+

Brace Scale

+
X setBraceScaleX(Number(e.target.value)/100)} />{braceScaleX.toFixed(2)}
+
Y setBraceScaleY(Number(e.target.value)/100)} />{braceScaleY.toFixed(2)}
+
Z setBraceScaleZ(Number(e.target.value)/100)} />{braceScaleZ.toFixed(2)}
+
+ + {/* Body Transform */} +
+

Body Position

+
X setBodyPositionX(Number(e.target.value))} />{bodyPositionX}
+
Y setBodyPositionY(Number(e.target.value))} />{bodyPositionY}
+
Z setBodyPositionZ(Number(e.target.value))} />{bodyPositionZ}
+
+ +
+

Body Rotation

+
X setBodyRotationX(Number(e.target.value))} />{bodyRotationX}°
+
Y setBodyRotationY(Number(e.target.value))} />{bodyRotationY}°
+
Z setBodyRotationZ(Number(e.target.value))} />{bodyRotationZ}°
+
+ +
+

Body Scale: {bodyScale.toFixed(2)}

+ setBodyScale(Number(e.target.value)/100)} /> +
+ + + + {/* View Options */} +
+ +
+
+ + {/* CENTER: 3D Viewer */} +
+ {!threeLoaded ? ( +

Loading 3D viewer...

+ ) : ( + <> +
+ {loading &&

Loading models...

} + {error &&

{error}

} + + )} +
+ + {/* RIGHT PANEL: Deformation Sliders (Stage 4 Editor) */} +
+

Deformation Controls

+ + {/* Brace Selector */} +
+

Edit Brace

+
+ + +
+
+ + {/* The Stage 4 Editor */} + + + {/* Tips */} +
+

Tips

+
    +
  • Blue areas = brace pushing INTO body
  • +
  • Increase Cobb angle for more pressure
  • +
  • Adjust apex to move correction zone
  • +
  • Use transforms on left to align meshes
  • +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/pipeline/BraceGenerationStage.tsx b/frontend/src/components/pipeline/BraceGenerationStage.tsx new file mode 100644 index 0000000..8e49301 --- /dev/null +++ b/frontend/src/components/pipeline/BraceGenerationStage.tsx @@ -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; + onUpdateMarkers: (markers: Record) => Promise; +}; + +export default function BraceGenerationStage({ + caseId, + braceData, + isLoading, + onGenerate, + onUpdateMarkers, +}: Props) { + const [showEditor, setShowEditor] = useState(false); + const [showInlineEditors, setShowInlineEditors] = useState(true); + const regularViewerRef = useRef(null); + const vaseViewerRef = useRef(null); + + // Transform params state for each brace type + const [regularParams, setRegularParams] = useState(() => ({ + ...DEFAULT_TRANSFORM_PARAMS, + })); + const [vaseParams, setVaseParams] = useState(() => ({ + ...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(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 ( +
+
+

Stage 4: Brace Generation

+
+ Pending +
+
+
+
+

Ready to generate custom brace based on approved landmarks and analysis.

+ +
+
+
+ ); + } + + if (isLoading) { + return ( +
+
+

Stage 4: Brace Generation

+
+ Generating... +
+
+
+
+
+

Generating custom braces...

+

+ Generating both Regular and Vase brace designs for comparison. +

+
+
+
+ ); + } + + return ( +
+
+

Stage 4: Brace Generation

+
+ Complete +
+
+ + {/* Summary Panels - Right under header */} +
+ {/* Generation Summary */} +
+

Generation Summary

+
+ {braceData?.rigo_classification && ( +
+ Rigo Type + + {braceData.rigo_classification.type} + +
+ )} + {braceData?.curve_type && ( +
+ Curve + {braceData.curve_type}-Curve +
+ )} + {braceData?.processing_time_ms && ( +
+ Time + + {(braceData.processing_time_ms / 1000).toFixed(1)}s + +
+ )} +
+
+ + {/* Cobb Angles Used */} + {braceData?.cobb_angles && ( +
+

Cobb Angles Used

+
+
+ PT + {braceData.cobb_angles.PT?.toFixed(1)}° +
+
+ MT + {braceData.cobb_angles.MT?.toFixed(1)}° +
+
+ TL + {braceData.cobb_angles.TL?.toFixed(1)}° +
+
+
+ )} +
+ + {/* Toggle Editor Button + Save Status */} +
+ {saveStatus && ( + + {saveStatus} + + )} + +
+ + {/* Dual Brace Viewers with Inline Editors */} +
+ {/* Regular Brace Viewer + Editor */} +
+
+
+

Regular Brace

+ Fitted design for precise correction + {regularModified && Modified} +
+
+ +
+ {regularBrace.meshStats && ( +
+ {regularBrace.meshStats.vertices?.toLocaleString()} vertices + {regularBrace.meshStats.faces?.toLocaleString()} faces +
+ )} +
+ + {/* Inline Editor for Regular Brace */} + {showInlineEditors && ( + + )} +
+ + {/* Vase Brace Viewer + Editor */} +
+
+
+

Vase Brace

+ Smooth contoured design + {vaseModified && Modified} +
+
+ {vaseBrace.outputs?.stl || vaseBrace.outputs?.glb ? ( + + ) : ( +
+
🏺
+

Vase brace not generated

+

Click "Generate Both" to create vase design

+
+ )} +
+ {vaseBrace.meshStats && ( +
+ {vaseBrace.meshStats.vertices?.toLocaleString()} vertices + {vaseBrace.meshStats.faces?.toLocaleString()} faces +
+ )} +
+ + {/* Inline Editor for Vase Brace */} + {showInlineEditors && (vaseBrace.outputs?.stl || vaseBrace.outputs?.glb) && ( + + )} +
+
+ + {/* Brace Pressure Zones - Full Width Section */} + {braceData?.deformation_report?.zones && braceData.deformation_report.zones.length > 0 && ( +
+

Brace Pressure Zones

+

+ Based on the Cobb angles and Rigo classification, the following pressure modifications were applied to the brace: +

+
+ {braceData.deformation_report.zones.map((zone: DeformationZone, idx: number) => ( +
+
+ {zone.zone} + + {zone.deform_mm > 0 ? '+' : ''}{zone.deform_mm.toFixed(1)} mm + +
+ {zone.reason} +
+ ))} +
+ {braceData.deformation_report.patch_grid && ( +

+ Patch Grid: {braceData.deformation_report.patch_grid} +

+ )} +
+ )} + + {/* Download Files - Bottom Section */} +
+

Download Files

+
+ {/* Regular Brace Downloads */} +
+

Regular Brace

+
+ {(regularBrace.outputs?.stl || stlUrl) && ( + + 📦 + + regular.stl + For 3D printing + + + + )} + {(regularBrace.outputs?.glb || glbUrl) && ( + + 🎮 + + regular.glb + For web/AR + + + + )} + {(regularBrace.outputs?.json || jsonUrl) && ( + + 📄 + + markers.json + With markers + + + + )} +
+
+ + {/* Vase Brace Downloads */} +
+

Vase Brace

+
+ {vaseBrace.outputs?.stl ? ( + <> + + 📦 + + vase.stl + For 3D printing + + + + {vaseBrace.outputs?.glb && ( + + 🎮 + + vase.glb + For web/AR + + + + )} + {vaseBrace.outputs?.json && ( + + 📄 + + markers.json + With markers + + + + )} + + ) : ( +
+ Not generated +
+ )} +
+
+
+
+ + {/* Actions */} +
+ + +
+ + {/* Marker Editor Modal (placeholder) */} + {showEditor && ( +
+
+
+

Brace Marker Editor

+ +
+
+

+ The 3D marker editor integration is coming soon. This will allow you to: +

+
    +
  • Drag and reposition pressure pad markers
  • +
  • Adjust deformation depths
  • +
  • Preview changes in real-time
  • +
  • Regenerate the brace with modified parameters
  • +
+

+ For now, you can download the JSON file to manually edit marker positions. +

+
+
+ +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/pipeline/BraceInlineEditor.tsx b/frontend/src/components/pipeline/BraceInlineEditor.tsx new file mode 100644 index 0000000..fc71e0c --- /dev/null +++ b/frontend/src/components/pipeline/BraceInlineEditor.tsx @@ -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; + 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(() => ({ + ...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 ( +
+
+

{braceType === 'regular' ? 'Regular' : 'Vase'} Brace Editor

+ {isModified && Modified} +
+ +
+ {SLIDER_CONFIGS.map(config => ( +
+
+ {config.label} + + {formatValue(params[config.key] as number, config.step, config.unit)} + +
+ handleParamChange(config.key, Number(e.target.value))} + className="slider-input" + /> +
+ ))} +
+ + {/* Push Mode Toggle */} +
+ Push Mode: +
+ {(['normal', 'radial', 'lateral'] as const).map(mode => ( + + ))} +
+
+ + {/* Mirror Toggle */} +
+ +
+ + {/* Advanced Toggle */} + + + {showAdvanced && ( +
+ {ADVANCED_SLIDER_CONFIGS.map(config => ( +
+
+ {config.label} + + {formatValue(params[config.key] as number, config.step, config.unit)} + +
+ handleParamChange(config.key, Number(e.target.value))} + className="slider-input" + /> +
+ ))} +
+ )} + + {/* Action Buttons */} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/pipeline/LandmarkDetectionStage.tsx b/frontend/src/components/pipeline/LandmarkDetectionStage.tsx new file mode 100644 index 0000000..f01cc09 --- /dev/null +++ b/frontend/src/components/pipeline/LandmarkDetectionStage.tsx @@ -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; + onApprove: (updatedLandmarks?: VertebraeStructure) => Promise; + onUpdateLandmarks: (landmarks: VertebraeStructure) => Promise; +}; + +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(null); + const containerRef = useRef(null); + + const [image, setImage] = useState(null); + const [structure, setStructure] = useState(null); + const [selectedLevel, setSelectedLevel] = useState(null); + const [hoveredLevel, setHoveredLevel] = useState(null); + const [hoveredCorner, setHoveredCorner] = useState(null); + const [dragState, setDragState] = useState(null); + const [scale, setScale] = useState(1); + const [hasChanges, setHasChanges] = useState(false); + const [imageError, setImageError] = useState(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 ( +
+
+

Stage 1: Vertebrae Detection

+
+ {isLoading ? ( + Processing... + ) : landmarksData ? ( + + {hasChanges ? 'Modified' : 'Detected'} + + ) : ( + Pending + )} +
+
+ +
+ {/* Canvas area */} +
+ {!landmarksData && !isLoading && ( +
+ {xrayUrl ? ( + <> + X-ray +

X-ray uploaded. Click to detect landmarks.

+ + + ) : ( +

Please upload an X-ray image first.

+ )} +
+ )} + + {isLoading && ( +
+
+

Detecting vertebrae landmarks...

+
+ )} + + {/* Error state when image fails to load */} + {imageError && ( +
+

{imageError}

+ {xrayUrl && ( + + )} +
+ )} + + {/* Loading state while image is being fetched */} + {landmarksData && !image && !imageError && xrayUrl && ( +
+
+

Loading X-ray image...

+
+ )} + + {landmarksData && image && ( + <> + +
+ {selectedLevel && !getVertebra(selectedLevel)?.final_values?.centroid_px ? ( + Click on image to place {selectedLevel} + ) : selectedLevel ? ( + Drag corners to adjust box shape • Drag center to move • Click another to select + ) : ( + Click a vertebra to select and edit • Drag center to move + )} +
+ + )} +
+ + {/* Vertebrae panel */} + {landmarksData && structure && ( +
+
+

Vertebrae

+
+ {detectedCount} detected + {manualCount > 0 && {manualCount} edited} +
+
+ +
+ {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 ( +
setSelectedLevel(v.level)} + > + {v.level} + + {isManual ? ( + Manual + ) : v.detected ? ( + + {((v.scoliovis_data?.confidence || 0) * 100).toFixed(0)}% + {hasCorners && } + + ) : hasCentroid ? ( + Placed + ) : ( + Click to place + )} + + {isManual && ( + + )} +
+ ); + })} +
+ + {/* Legend */} +
+ Detected + Manual + Missing +
+ + {/* Quick analysis preview */} + {landmarksData.cobb_angles && ( +
+
+ PT + {(landmarksData.cobb_angles.PT || 0).toFixed(1)}° +
+
+ MT + {(landmarksData.cobb_angles.MT || 0).toFixed(1)}° +
+
+ TL + {(landmarksData.cobb_angles.TL || 0).toFixed(1)}° +
+
+ {landmarksData.rigo_classification?.type || 'N/A'} +
+
+ )} +
+ )} +
+ + {/* Actions */} + {landmarksData && ( +
+ {hasChanges && ( + Unsaved changes + )} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/pipeline/PipelineSteps.tsx b/frontend/src/components/pipeline/PipelineSteps.tsx new file mode 100644 index 0000000..612cc56 --- /dev/null +++ b/frontend/src/components/pipeline/PipelineSteps.tsx @@ -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 ( +
+ {stages.map((stage, idx) => { + const status = getStageStatus(stage.id); + const canNavigate = canNavigateToStage(stage.id); + return ( +
canNavigate && onStageClick?.(stage.id)} + style={{ cursor: canNavigate ? 'pointer' : 'default' }} + > +
+ {status === 'complete' ? '✓' : stage.step} +
+
{stage.label}
+ {idx < stages.length - 1 &&
} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/pipeline/SpineAnalysisStage.tsx b/frontend/src/components/pipeline/SpineAnalysisStage.tsx new file mode 100644 index 0000000..2195840 --- /dev/null +++ b/frontend/src/components/pipeline/SpineAnalysisStage.tsx @@ -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; + 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 = { + 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 ( +
+
+

Stage 2: Spine Analysis

+
+ Pending +
+
+
+
+

Complete Stage 1 (Landmark Detection) first.

+
+
+
+ ); + } + + const maxCobb = cobbAngles ? Math.max(cobbAngles.PT, cobbAngles.MT, cobbAngles.TL) : 0; + const treatment = getTreatmentRecommendation(maxCobb); + + return ( +
+
+

Stage 2: Spine Analysis

+
+ {isLoading ? ( + Recalculating... + ) : ( + Analyzed + )} +
+
+ +
+ {/* Cobb Angles Panel */} +
+

Cobb Angle Measurements

+ {cobbAngles && ( +
+
+
PT
+
Proximal Thoracic
+
{cobbAngles.PT.toFixed(1)}°
+
{cobbAngles.PT_severity}
+
+
+
MT
+
Main Thoracic
+
{cobbAngles.MT.toFixed(1)}°
+
{cobbAngles.MT_severity}
+
+
+
TL
+
Thoracolumbar/Lumbar
+
{cobbAngles.TL.toFixed(1)}°
+
{cobbAngles.TL_severity}
+
+
+ )} +
+ + {/* Classification Panel */} +
+

Classification

+
+ {rigoClass && ( +
+
Rigo-Chêneau
+
{rigoClass.type}
+
+ {rigoClass.description || getRigoDescription(rigoClass.type)} +
+
+ )} + {curveType && ( +
+
Curve Pattern
+
{curveType}-Curve
+
+ {curveType === 'S' + ? 'Double curve pattern with thoracic and lumbar components' + : curveType === 'C' + ? 'Single curve pattern' + : 'Curve pattern identified'} +
+
+ )} +
+
+ + {/* Treatment Recommendation */} +
+

Clinical Recommendation

+
+
+ + Maximum Cobb Angle: {maxCobb.toFixed(1)}° + + + {treatment.urgency} + +
+

{treatment.recommendation}

+
+
+ + {/* Analysis Metadata */} +
+

Analysis Details

+
+
+ Vertebrae Analyzed + + {analysisData?.vertebrae_used || landmarksData?.vertebrae_structure.detected_count} + +
+
+ Processing Time + + {((analysisData?.processing_time_ms || landmarksData?.processing_time_ms || 0) / 1000).toFixed(2)}s + +
+ {analysisData && ( +
+ Source + Recalculated +
+ )} +
+
+
+ + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/pipeline/index.ts b/frontend/src/components/pipeline/index.ts new file mode 100644 index 0000000..295a72d --- /dev/null +++ b/frontend/src/components/pipeline/index.ts @@ -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'; \ No newline at end of file diff --git a/frontend/src/components/pipeline/pipeline.css b/frontend/src/components/pipeline/pipeline.css new file mode 100644 index 0000000..a04f304 --- /dev/null +++ b/frontend/src/components/pipeline/pipeline.css @@ -0,0 +1,3707 @@ +/* ============================================ + Pipeline Case Page Styles + ============================================ */ + +/* Pipeline Page Layout */ +.pipeline-page { + min-height: 100vh; + background: #0f0f0f; + color: #e0e0e0; + padding-bottom: 60px; +} + +/* Header */ +.pipeline-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + background: #1a1a1a; + border-bottom: 1px solid #333; +} + +.header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.back-btn { + background: none; + border: none; + color: #888; + cursor: pointer; + font-size: 14px; + padding: 8px 12px; + border-radius: 4px; + transition: all 0.2s; +} + +.back-btn:hover { + background: #333; + color: #fff; +} + +.case-title { + font-size: 18px; + font-weight: 600; + color: #fff; + font-family: monospace; +} + +/* Status Badges */ +.status-badge { + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; +} + +.status-badge.status-created { background: #333; color: #888; } +.status-badge.status-pending { background: #333; color: #888; } +.status-badge.status-processing { background: #2d4a77; color: #6bb5ff; } +.status-badge.status-complete { background: #1e4620; color: #4caf50; } +.status-badge.status-landmarks_detected { background: #2d4a77; color: #6bb5ff; } +.status-badge.status-landmarks_approved { background: #3d5a27; color: #8bc34a; } +.status-badge.status-analysis_complete { background: #3d5a27; color: #8bc34a; } +.status-badge.status-brace_generated { background: #1e4620; color: #4caf50; } + +/* Pipeline Steps Indicator */ +.pipeline-steps { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: #1a1a1a; + border-bottom: 1px solid #333; +} + +.pipeline-step { + display: flex; + align-items: center; + gap: 8px; + position: relative; +} + +.pipeline-step-number { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; + transition: all 0.3s; +} + +.pipeline-step--pending .pipeline-step-number { + background: #333; + color: #666; +} + +.pipeline-step--active .pipeline-step-number { + background: #2196f3; + color: #fff; + box-shadow: 0 0 12px rgba(33, 150, 243, 0.4); +} + +.pipeline-step--complete .pipeline-step-number { + background: #4caf50; + color: #fff; +} + +.pipeline-step-label { + font-size: 13px; + color: #888; +} + +.pipeline-step--active .pipeline-step-label { + color: #2196f3; + font-weight: 500; +} + +.pipeline-step--complete .pipeline-step-label { + color: #4caf50; +} + +.pipeline-step-connector { + width: 60px; + height: 2px; + background: #333; + margin: 0 12px; +} + +.pipeline-step--complete + .pipeline-step .pipeline-step-connector, +.pipeline-step--complete .pipeline-step-connector { + background: #4caf50; +} + +/* Error Banner */ +.error-banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: #5c2323; + border-bottom: 1px solid #7a2a2a; + color: #ff8a8a; +} + +.error-banner button { + background: none; + border: none; + color: #ff8a8a; + cursor: pointer; + font-size: 18px; +} + +/* Upload Section */ +.upload-section { + padding: 60px 24px; + display: flex; + justify-content: center; +} + +.upload-box { + background: #1a1a1a; + border: 2px dashed #444; + border-radius: 12px; + padding: 48px; + text-align: center; + max-width: 500px; +} + +.upload-box h2 { + margin-bottom: 12px; + color: #fff; +} + +.upload-box p { + color: #888; + margin-bottom: 24px; +} + +.upload-box input[type="file"] { + padding: 12px; + background: #333; + border-radius: 8px; + color: #fff; +} + +/* Pipeline Stages Container */ +.pipeline-stages { + padding: 24px; + display: flex; + flex-direction: column; + gap: 24px; +} + +/* Generic Stage Styles */ +.pipeline-stage { + background: #1a1a1a; + border-radius: 12px; + border: 1px solid #333; + overflow: hidden; +} + +.stage-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: #222; + border-bottom: 1px solid #333; +} + +.stage-header h2 { + font-size: 16px; + font-weight: 600; + color: #fff; +} + +.stage-content { + padding: 20px; + display: grid; + grid-template-columns: 1fr 300px; + gap: 20px; +} + +.stage-empty, .stage-loading { + grid-column: 1 / -1; + text-align: center; + padding: 48px; + color: #888; +} + +.stage-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 20px; + background: #222; + border-top: 1px solid #333; +} + +/* Buttons */ +.btn { + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.btn.primary { + background: #2196f3; + color: #fff; +} + +.btn.primary:hover { + background: #1976d2; +} + +.btn.primary:disabled { + background: #333; + color: #666; + cursor: not-allowed; +} + +.btn.secondary { + background: #333; + color: #e0e0e0; +} + +.btn.secondary:hover { + background: #444; +} + +.btn-large { + padding: 14px 32px; + font-size: 16px; +} + +/* Spinner */ +.spinner { + width: 32px; + height: 32px; + border: 3px solid #333; + border-top-color: #2196f3; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 16px; +} + +.spinner.large { + width: 48px; + height: 48px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ============================================ + Stage 1: Landmark Detection + ============================================ */ + +.landmark-stage .stage-content { + grid-template-columns: 1fr 280px; +} + +.landmark-main { + position: relative; +} + +.landmark-empty, +.landmark-loading, +.landmark-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + background: #111; + border-radius: 8px; +} + +.landmark-error { + color: #f44336; + gap: 16px; +} + +.landmark-error p { + margin: 0; + text-align: center; +} + +.landmark-canvas-container { + position: relative; +} + +.landmark-canvas, +.landmark-visualization { + max-width: 100%; + height: auto; + border-radius: 8px; +} + +.landmark-canvas { + cursor: crosshair; +} + +.edit-instructions { + padding: 12px; + background: #222; + border-radius: 8px; + margin-top: 12px; + font-size: 13px; + color: #888; +} + +.edit-legend { + display: flex; + gap: 16px; + margin-top: 8px; +} + +.legend-dot { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.legend-dot::before { + content: ''; + width: 10px; + height: 10px; + border-radius: 50%; +} + +.legend-detected::before { background: #ff0000; } +.legend-manual::before { background: #00ff00; } +.legend-undetected::before { background: #ffff00; } + +/* Landmark Sidebar */ +.landmark-sidebar { + display: flex; + flex-direction: column; + gap: 16px; +} + +.vertebrae-summary, +.undetected-panel, +.initial-analysis { + background: #222; + border-radius: 8px; + padding: 16px; +} + +.vertebrae-summary h3, +.undetected-panel h3, +.initial-analysis h3 { + font-size: 14px; + color: #fff; + margin-bottom: 12px; +} + +.summary-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + text-align: center; +} + +.stat-value { + display: block; + font-size: 24px; + font-weight: 600; + color: #2196f3; +} + +.stat-label { + font-size: 11px; + color: #888; + text-transform: uppercase; +} + +.panel-hint { + font-size: 12px; + color: #666; + margin-bottom: 12px; +} + +.vertebrae-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 200px; + overflow-y: auto; +} + +.vertebra-item { + display: flex; + justify-content: space-between; + padding: 8px 12px; + background: #1a1a1a; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s; +} + +.vertebra-item:hover { + background: #333; +} + +.vertebra-item.selected { + background: #2d4a77; +} + +.vertebra-level { + font-weight: 500; + color: #fff; +} + +.vertebra-status { + font-size: 12px; + color: #888; +} + +/* All vertebrae panel */ +.all-vertebrae-panel { + background: #222; + border-radius: 8px; + padding: 16px; +} + +.all-vertebrae-panel h3 { + font-size: 14px; + color: #fff; + margin-bottom: 12px; +} + +.vertebrae-scroll-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 250px; + overflow-y: auto; +} + +.vertebra-item-compact { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 10px; + background: #1a1a1a; + border-radius: 4px; + font-size: 13px; +} + +.vertebra-item-compact.manual { + border-left: 2px solid #00ff00; +} + +.vertebra-item-compact.missing { + opacity: 0.5; +} + +.tag { + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 500; +} + +.tag-detected { + background: #1e4620; + color: #4caf50; +} + +.tag-manual { + background: #0a3a0a; + color: #00ff00; +} + +.tag-placed { + background: #5c4020; + color: #ff9800; +} + +.tag-missing { + background: #333; + color: #888; +} + +.manual-summary { + margin-top: 12px; + padding: 8px 12px; + background: #0a3a0a; + border-radius: 6px; + color: #00ff00; + font-size: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.manual-edits-badge { + position: absolute; + top: 12px; + right: 12px; + background: #0a3a0a; + color: #00ff00; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.landmark-canvas-container { + position: relative; +} + +.xray-preview { + max-width: 100%; + max-height: 300px; + margin-bottom: 16px; + border-radius: 8px; +} + +.loading-hint { + font-size: 12px; + color: #666; + margin-top: 8px; +} + +/* Initial Analysis Preview */ +.cobb-preview { + margin-bottom: 12px; +} + +.cobb-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + font-size: 13px; +} + +.cobb-row span:first-child { + color: #888; +} + +.cobb-row span:last-child { + color: #fff; + font-weight: 500; +} + +.rigo-preview { + display: flex; + gap: 8px; +} + +.rigo-badge, +.curve-badge { + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.rigo-badge { + background: #2d4a77; + color: #6bb5ff; +} + +.curve-badge { + background: #3d5a27; + color: #8bc34a; +} + +/* ============================================ + Interactive Landmark Stage (Always editable) + ============================================ */ + +.landmark-interactive { + display: grid; + grid-template-columns: 1fr 260px; + gap: 16px; + padding: 16px; +} + +.landmark-canvas-area { + background: #111; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 500px; + padding: 16px; +} + +.landmark-canvas-interactive { + max-width: 100%; + max-height: calc(100vh - 300px); + border-radius: 6px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); +} + +.canvas-hint { + margin-top: 12px; + padding: 8px 16px; + background: #1a1a1a; + border-radius: 6px; + font-size: 12px; + color: #888; +} + +.canvas-hint strong { + color: #2196f3; +} + +/* Vertebrae Panel (inline) */ +.vertebrae-panel-inline { + background: #1a1a1a; + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + max-height: calc(100vh - 250px); +} + +.panel-header { + padding: 12px 16px; + border-bottom: 1px solid #333; +} + +.panel-header h3 { + font-size: 14px; + color: #fff; + margin: 0 0 8px 0; +} + +.panel-stats { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.stat-pill { + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; +} + +.stat-pill.detected { + background: #1e4620; + color: #4caf50; +} + +.stat-pill.manual { + background: #0a3a0a; + color: #00ff00; +} + +.vertebrae-scroll { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.vert-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 5px; + cursor: pointer; + transition: all 0.15s; + margin-bottom: 3px; +} + +.vert-row:hover { + background: #2a2a2a; +} + +.vert-row.selected { + background: #2d4a77; + box-shadow: inset 0 0 0 1px #2196f3; +} + +.vert-row.manual { + border-left: 2px solid #00ff00; +} + +.vert-row.missing { + opacity: 0.5; +} + +.vert-level { + font-weight: 600; + font-size: 13px; + color: #fff; + width: 28px; +} + +.vert-info { + flex: 1; + font-size: 11px; +} + +.info-conf { + color: #4caf50; +} + +.info-manual { + color: #00ff00; + font-weight: 500; +} + +.info-placed { + color: #ff9800; +} + +.info-missing { + color: #666; + font-style: italic; +} + +.has-corners { + color: #22aa22; + margin-left: 4px; + font-size: 10px; +} + +.vert-reset { + background: none; + border: none; + color: #666; + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + border-radius: 3px; + opacity: 0; + transition: opacity 0.15s; +} + +.vert-row:hover .vert-reset { + opacity: 1; +} + +.vert-reset:hover { + background: #333; + color: #fff; +} + +.panel-legend { + padding: 10px 16px; + border-top: 1px solid #333; + display: flex; + gap: 12px; + font-size: 10px; + color: #666; +} + +.panel-legend .dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 4px; +} + +.panel-legend .dot.red { background: #ff3333; } +.panel-legend .dot.green { background: #00ff00; } +.panel-legend .dot.gray { background: #888; } + +/* Quick Analysis */ +.quick-analysis { + padding: 12px 16px; + border-top: 1px solid #333; + background: #151515; +} + +.qa-row { + display: flex; + justify-content: space-between; + font-size: 12px; + padding: 3px 0; +} + +.qa-row span:first-child { + color: #888; +} + +.qa-row span:last-child { + color: #fff; + font-weight: 500; +} + +.qa-rigo { + margin-top: 8px; +} + +.rigo-tag { + background: #2d4a77; + color: #6bb5ff; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +/* Changes indicator */ +.changes-indicator { + color: #ff9800; + font-size: 13px; + margin-right: auto; +} + +/* ============================================ + Stage 2: Spine Analysis + ============================================ */ + +.analysis-stage .stage-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.analysis-panel { + background: #222; + border-radius: 8px; + padding: 20px; +} + +.analysis-panel h3 { + font-size: 14px; + color: #fff; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid #333; +} + +/* Cobb Angles Grid */ +.cobb-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.cobb-card { + background: #1a1a1a; + border-radius: 8px; + padding: 16px; + text-align: center; + border-left: 4px solid #444; +} + +.cobb-card.severity-normal { border-color: #4caf50; } +.cobb-card.severity-mild { border-color: #8bc34a; } +.cobb-card.severity-moderate { border-color: #ff9800; } +.cobb-card.severity-severe { border-color: #f44336; } + +.cobb-region { + font-size: 18px; + font-weight: 600; + color: #fff; +} + +.cobb-label { + font-size: 11px; + color: #666; + margin-bottom: 8px; +} + +.cobb-value { + font-size: 28px; + font-weight: 700; + color: #fff; +} + +.cobb-severity { + font-size: 12px; + text-transform: uppercase; + margin-top: 4px; +} + +.severity-normal .cobb-severity { color: #4caf50; } +.severity-mild .cobb-severity { color: #8bc34a; } +.severity-moderate .cobb-severity { color: #ff9800; } +.severity-severe .cobb-severity { color: #f44336; } + +/* Classification Grid */ +.classification-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.classification-card { + background: #1a1a1a; + border-radius: 8px; + padding: 16px; +} + +.classification-type { + font-size: 11px; + color: #666; + text-transform: uppercase; + margin-bottom: 8px; +} + +.classification-badge { + font-size: 24px; + font-weight: 700; + margin-bottom: 8px; +} + +.rigo-card .classification-badge { color: #6bb5ff; } +.curve-card .classification-badge { color: #8bc34a; } + +.classification-desc { + font-size: 12px; + color: #888; + line-height: 1.4; +} + +/* Treatment Panel */ +.treatment-panel { + grid-column: 1 / -1; +} + +.treatment-panel.treatment-routine { border-left: 4px solid #4caf50; } +.treatment-panel.treatment-standard { border-left: 4px solid #8bc34a; } +.treatment-panel.treatment-priority { border-left: 4px solid #ff9800; } +.treatment-panel.treatment-high { border-left: 4px solid #f44336; } +.treatment-panel.treatment-urgent { border-left: 4px solid #d32f2f; } + +.treatment-summary { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.max-cobb { + font-size: 14px; + color: #888; +} + +.max-cobb strong { + color: #fff; +} + +.urgency-badge { + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.urgency-routine { background: #1e4620; color: #4caf50; } +.urgency-standard { background: #3d5a27; color: #8bc34a; } +.urgency-priority { background: #5c4020; color: #ff9800; } +.urgency-high { background: #5c2323; color: #f44336; } +.urgency-urgent { background: #5c2323; color: #ff5252; } + +.treatment-recommendation { + font-size: 14px; + color: #e0e0e0; + line-height: 1.5; +} + +/* Metadata Grid */ +.metadata-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.metadata-item { + text-align: center; +} + +.metadata-label { + display: block; + font-size: 11px; + color: #666; + text-transform: uppercase; + margin-bottom: 4px; +} + +.metadata-value { + font-size: 16px; + font-weight: 500; + color: #fff; +} + +/* ============================================ + Stage 3: Brace Generation + ============================================ */ + +.brace-stage .stage-content { + grid-template-columns: 1fr 320px; +} + +.brace-main { + display: flex; + flex-direction: column; + gap: 16px; +} + +.brace-viewer { + background: #111; + border-radius: 8px; + min-height: 400px; + display: flex; + align-items: center; + justify-content: center; +} + +.brace-viz-image { + max-width: 100%; + max-height: 500px; + border-radius: 8px; +} + +.brace-viewer-placeholder { + text-align: center; + color: #666; +} + +.brace-viewer-placeholder .hint { + font-size: 12px; + margin-top: 8px; +} + +.brace-empty, +.brace-loading { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; +} + +.loading-hint { + font-size: 13px; + color: #666; + margin-top: 8px; +} + +/* Output Tabs */ +.output-tabs { + display: flex; + gap: 8px; +} + +.output-tab { + padding: 8px 16px; + background: #222; + border: 1px solid #333; + border-radius: 6px; + color: #888; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.output-tab:hover { + background: #333; +} + +.output-tab.active { + background: #2d4a77; + border-color: #2196f3; + color: #fff; +} + +.output-tab:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Brace Sidebar */ +.brace-sidebar { + display: flex; + flex-direction: column; + gap: 16px; +} + +.brace-panel { + background: #222; + border-radius: 8px; + padding: 16px; +} + +.brace-panel h3 { + font-size: 14px; + color: #fff; + margin-bottom: 12px; +} + +.summary-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.summary-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.summary-label { + font-size: 11px; + color: #666; + text-transform: uppercase; +} + +.summary-value { + font-size: 14px; + color: #fff; +} + +.summary-value.badge { + background: #2d4a77; + color: #6bb5ff; + padding: 2px 8px; + border-radius: 4px; + display: inline-block; + width: fit-content; +} + +/* Downloads List */ +.downloads-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.download-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: #1a1a1a; + border-radius: 8px; + text-decoration: none; + transition: background 0.2s; +} + +.download-item:hover { + background: #333; +} + +.download-icon { + font-size: 24px; +} + +.download-info { + flex: 1; + display: flex; + flex-direction: column; +} + +.download-name { + font-size: 13px; + color: #fff; + font-weight: 500; +} + +.download-desc { + font-size: 11px; + color: #666; +} + +.download-action { + color: #2196f3; + font-size: 18px; +} + +/* Cobb Mini Grid */ +.cobb-mini-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.cobb-mini { + text-align: center; + padding: 8px; + background: #1a1a1a; + border-radius: 6px; +} + +.cobb-mini .cobb-label { + font-size: 12px; + color: #888; +} + +.cobb-mini .cobb-value { + font-size: 16px; + font-weight: 600; + color: #fff; +} + +/* Marker Editor Overlay */ +.marker-editor-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.marker-editor-modal { + background: #1a1a1a; + border-radius: 12px; + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #333; +} + +.modal-header h3 { + font-size: 16px; + color: #fff; +} + +.close-btn { + background: none; + border: none; + color: #888; + font-size: 24px; + cursor: pointer; +} + +.modal-content { + padding: 20px; + color: #e0e0e0; + line-height: 1.6; +} + +.modal-content ul { + margin: 12px 0; + padding-left: 20px; +} + +.modal-content .hint { + color: #888; + font-size: 13px; + margin-top: 16px; +} + +.modal-actions { + padding: 16px 20px; + border-top: 1px solid #333; + display: flex; + justify-content: flex-end; +} + +/* ============================================ + Landmark Editor + ============================================ */ + +.landmark-editor { + position: fixed; + inset: 0; + background: #0a0a0a; + z-index: 1000; + display: flex; + flex-direction: column; +} + +.editor-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 20px; + background: #1a1a1a; + border-bottom: 1px solid #333; +} + +.toolbar-info { + display: flex; + gap: 20px; + color: #888; + font-size: 13px; +} + +.toolbar-actions { + display: flex; + gap: 12px; +} + +.editor-content { + flex: 1; + display: grid; + grid-template-columns: 280px 1fr; + overflow: hidden; +} + +.vertebrae-panel { + background: #1a1a1a; + border-right: 1px solid #333; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.vertebrae-panel h3 { + padding: 16px; + margin: 0; + font-size: 14px; + color: #fff; + border-bottom: 1px solid #333; +} + +.vertebrae-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.vertebra-row { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + margin-bottom: 4px; +} + +.vertebra-row:hover { + background: #2a2a2a; +} + +.vertebra-row.selected { + background: #2d4a77; + box-shadow: 0 0 0 2px #2196f3; +} + +.vertebra-row.manual { + border-left: 3px solid #00ff00; +} + +.vertebra-row.unplaced { + opacity: 0.6; +} + +.vertebra-level { + font-weight: 600; + color: #fff; + width: 32px; +} + +.vertebra-status { + flex: 1; + font-size: 12px; +} + +.status-detected { + color: #4caf50; +} + +.status-manual { + color: #00ff00; + font-weight: 500; +} + +.status-placed { + color: #ff9800; +} + +.status-undetected { + color: #666; + font-style: italic; +} + +.reset-btn { + background: none; + border: none; + color: #888; + cursor: pointer; + font-size: 16px; + padding: 4px 8px; + border-radius: 4px; +} + +.reset-btn:hover { + background: #333; + color: #fff; +} + +.legend { + padding: 12px 16px; + border-top: 1px solid #333; + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #888; +} + +.legend-item .dot { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.legend-item .dot.detected { + background: #ff0000; +} + +.legend-item .dot.manual { + background: #00ff00; +} + +.legend-item .dot.undetected { + background: #888; +} + +.canvas-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #111; + overflow: auto; + padding: 20px; +} + +.editor-canvas { + max-width: 100%; + max-height: 100%; + border-radius: 4px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.canvas-instructions { + margin-top: 12px; + padding: 8px 16px; + background: #222; + border-radius: 6px; + font-size: 13px; + color: #888; +} + +.canvas-instructions strong { + color: #2196f3; +} + +/* Brace Pressure Zones Section */ +.pressure-zones-section { + background: #1a1a1a; + border-radius: 8px; + padding: 20px; + margin-top: 20px; +} + +.pressure-zones-section h3 { + font-size: 16px; + font-weight: 600; + color: #fff; + margin-bottom: 8px; +} + +.zones-desc { + font-size: 13px; + color: #888; + margin-bottom: 16px; +} + +.pressure-zones-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; +} + +.zone-card { + background: #222; + border-radius: 8px; + padding: 14px 16px; + border-left: 4px solid transparent; + transition: all 0.2s; +} + +.zone-card:hover { + background: #282828; + transform: translateY(-1px); +} + +.zone-card.zone-pressure { + border-left-color: #22c55e; + background: rgba(34, 197, 94, 0.08); +} + +.zone-card.zone-relief { + border-left-color: #3b82f6; + background: rgba(59, 130, 246, 0.08); +} + +.zone-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.zone-name { + font-weight: 500; + color: #fff; + font-size: 14px; +} + +.zone-value { + font-weight: 600; + font-size: 13px; + padding: 4px 10px; + border-radius: 4px; +} + +.zone-value.pressure { + background: rgba(34, 197, 94, 0.2); + color: #4ade80; +} + +.zone-value.relief { + background: rgba(59, 130, 246, 0.2); + color: #60a5fa; +} + +.zone-reason { + font-size: 12px; + color: #888; + line-height: 1.4; +} + +.patch-grid-info { + margin-top: 16px; + font-size: 12px; + color: #666; + text-align: right; +} + +/* Downloads Section - Bottom of Stage 4 */ +.downloads-section { + margin-top: 24px; + padding: 20px; + background: #1a1a1a; + border-radius: 12px; +} + +.downloads-section h3 { + font-size: 16px; + color: #fff; + margin-bottom: 16px; +} + +.downloads-list.horizontal { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.downloads-list.horizontal .download-item { + flex: 1; + min-width: 200px; + max-width: 300px; +} + +/* Brace 3D Viewer */ +.brace-viewer.brace-viewer-3d { + position: relative; + min-height: 450px; + background: linear-gradient(180deg, #1e1e2e 0%, #16213e 100%); + border-radius: 12px; + overflow: hidden; +} + +.brace-model-viewer-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 12px; + overflow: hidden; +} + +.brace-model-viewer-canvas { + width: 100%; + height: 100%; +} + +.brace-model-viewer-canvas canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +.brace-model-viewer-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, #1e1e2e 0%, #16213e 100%); + color: #888; + border-radius: 12px; +} + +.brace-model-viewer-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(30, 30, 46, 0.9); + color: #888; + border-radius: 12px; + pointer-events: none; +} + +.brace-model-viewer-overlay.error { + color: #f44336; +} + +.brace-model-viewer-overlay.placeholder { + background: linear-gradient(180deg, #1e1e2e 0%, #16213e 100%); + pointer-events: auto; +} + +.brace-model-viewer-overlay .placeholder-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.brace-model-viewer-overlay p { + margin-top: 8px; + font-size: 14px; +} + +/* ======================================== + DUAL BRACE VIEWERS - Side by Side + ======================================== */ + +/* Summary row - right under header */ +.brace-summary-row { + display: flex; + gap: 16px; + margin-bottom: 20px; + padding: 0 4px; +} + +.brace-summary-row .brace-panel { + flex: 1; + margin: 0; + padding: 16px 20px; +} + +.brace-summary-row .summary-panel { + flex: 1.5; +} + +.brace-summary-row .cobb-panel { + flex: 1; +} + +.summary-grid.horizontal, +.cobb-mini-grid.horizontal { + display: flex; + gap: 24px; + align-items: center; +} + +.cobb-mini-grid.horizontal .cobb-mini { + display: flex; + align-items: center; + gap: 8px; +} + +/* Dual viewers container */ +.dual-brace-viewers { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 24px; +} + +.brace-viewer-container { + background: #1a1a1a; + border-radius: 12px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.viewer-header { + padding: 16px 20px; + background: linear-gradient(135deg, #2a2a3a 0%, #1e1e2e 100%); + border-bottom: 1px solid #333; +} + +.viewer-header h3 { + margin: 0 0 4px 0; + font-size: 18px; + font-weight: 600; + color: #fff; +} + +.viewer-subtitle { + font-size: 13px; + color: #888; +} + +.brace-viewer-container .brace-viewer { + flex: 1; + min-height: 350px; +} + +.viewer-stats { + display: flex; + gap: 16px; + padding: 12px 20px; + background: #151515; + font-size: 12px; + color: #666; +} + +.viewer-stats span { + display: flex; + align-items: center; + gap: 4px; +} + +.viewer-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 350px; + background: linear-gradient(180deg, #1e1e2e 0%, #16213e 100%); + color: #666; + text-align: center; +} + +.viewer-placeholder .placeholder-icon { + font-size: 48px; + margin-bottom: 12px; + opacity: 0.5; +} + +.viewer-placeholder p { + margin: 4px 0; +} + +.viewer-placeholder .hint { + font-size: 12px; + color: #555; +} + +/* Download columns */ +.downloads-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +.download-column h4 { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.download-column .downloads-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.download-placeholder { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: #1a1a1a; + border-radius: 8px; + color: #555; + font-size: 13px; +} + +/* Responsive: stack on smaller screens */ +@media (max-width: 1200px) { + .dual-brace-viewers { + grid-template-columns: 1fr; + } + + .downloads-columns { + grid-template-columns: 1fr; + } + + .brace-summary-row { + flex-direction: column; + } +} + +/* ============================================== + BRACE INLINE EDITOR STYLES + ============================================== */ + +/* Editor Toggle Row */ +.editor-toggle-row { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 16px; + padding: 0 4px; + margin-bottom: 12px; +} + +/* Save Status */ +.save-status { + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + background: #2a2a3a; + color: #888; + animation: fadeIn 0.2s ease-out; +} + +.save-status.success { + background: #1e4620; + color: #4caf50; +} + +.save-status.error { + background: #5c2323; + color: #f44336; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.btn-toggle-editor { + padding: 8px 16px; + background: #2a2a3a; + border: 1px solid #444; + border-radius: 6px; + color: #888; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.btn-toggle-editor:hover { + background: #3a3a4a; + color: #fff; +} + +.btn-toggle-editor.active { + background: #2d4a77; + border-color: #2196f3; + color: #fff; +} + +/* Modified Indicator */ +.modified-indicator { + display: inline-block; + padding: 2px 8px; + background: #5c4020; + color: #ff9800; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + margin-left: 8px; +} + +/* Viewer with Editor Layout */ +.dual-brace-viewers.with-editors { + grid-template-columns: 1fr 1fr; +} + +.brace-viewer-with-editor { + display: flex; + flex-direction: column; + gap: 12px; + background: #1a1a1a; + border-radius: 12px; + overflow: hidden; +} + +.brace-viewer-with-editor .brace-viewer-container { + border-radius: 0; + background: transparent; +} + +/* Inline Editor Panel */ +.brace-inline-editor { + background: #1f1f2a; + border-top: 1px solid #333; + padding: 16px; +} + +.viewer-inline-editor { + margin: 0; +} + +.brace-inline-editor .editor-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.brace-inline-editor .editor-header h4 { + font-size: 14px; + font-weight: 600; + color: #fff; + margin: 0; +} + +.brace-inline-editor .modified-badge { + padding: 2px 8px; + background: #5c4020; + color: #ff9800; + border-radius: 4px; + font-size: 10px; + font-weight: 500; +} + +/* Slider Styles */ +.brace-inline-editor .editor-sliders { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.brace-inline-editor .editor-sliders.advanced { + padding-top: 12px; + border-top: 1px solid #333; + margin-top: 4px; +} + +.brace-inline-editor .slider-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.brace-inline-editor .slider-label { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; +} + +.brace-inline-editor .slider-label span:first-child { + color: #888; +} + +.brace-inline-editor .slider-value { + color: #fff; + font-weight: 500; + font-family: monospace; + min-width: 50px; + text-align: right; +} + +.brace-inline-editor .slider-input { + -webkit-appearance: none; + width: 100%; + height: 4px; + background: #333; + border-radius: 2px; + outline: none; + cursor: pointer; +} + +.brace-inline-editor .slider-input::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + background: #2196f3; + border-radius: 50%; + cursor: pointer; + transition: transform 0.1s; +} + +.brace-inline-editor .slider-input::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.brace-inline-editor .slider-input::-moz-range-thumb { + width: 14px; + height: 14px; + background: #2196f3; + border-radius: 50%; + border: none; + cursor: pointer; +} + +/* Mode Toggle */ +.brace-inline-editor .editor-mode-toggle { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.brace-inline-editor .toggle-label { + font-size: 12px; + color: #888; +} + +.brace-inline-editor .toggle-buttons { + display: flex; + gap: 4px; +} + +.brace-inline-editor .toggle-btn { + padding: 4px 10px; + background: #2a2a3a; + border: 1px solid #444; + border-radius: 4px; + color: #888; + font-size: 11px; + cursor: pointer; + transition: all 0.15s; +} + +.brace-inline-editor .toggle-btn:hover { + background: #3a3a4a; + color: #fff; +} + +.brace-inline-editor .toggle-btn.active { + background: #2d4a77; + border-color: #2196f3; + color: #fff; +} + +/* Checkbox */ +.brace-inline-editor .editor-checkbox { + margin-bottom: 12px; +} + +.brace-inline-editor .editor-checkbox label { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #888; + cursor: pointer; +} + +.brace-inline-editor .editor-checkbox input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; +} + +.brace-inline-editor .editor-checkbox span { + user-select: none; +} + +/* Advanced Toggle */ +.brace-inline-editor .advanced-toggle { + display: block; + width: 100%; + padding: 8px; + background: transparent; + border: none; + color: #666; + font-size: 11px; + cursor: pointer; + text-align: left; + transition: color 0.15s; +} + +.brace-inline-editor .advanced-toggle:hover { + color: #2196f3; +} + +/* Action Buttons */ +.brace-inline-editor .editor-actions { + display: flex; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #333; +} + +.brace-inline-editor .btn-editor { + flex: 1; + padding: 10px 16px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.brace-inline-editor .btn-editor.reset { + background: #333; + color: #888; +} + +.brace-inline-editor .btn-editor.reset:hover { + background: #444; + color: #fff; +} + +.brace-inline-editor .btn-editor.save { + background: #2196f3; + color: #fff; +} + +.brace-inline-editor .btn-editor.save:hover { + background: #1976d2; +} + +.brace-inline-editor .btn-editor.save:disabled { + background: #333; + color: #666; + cursor: not-allowed; +} + +/* BraceTransformViewer Styles */ +.brace-transform-viewer-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 12px; + overflow: hidden; +} + +.brace-transform-viewer-canvas { + width: 100%; + height: 100%; +} + +.brace-transform-viewer-canvas canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +.brace-transform-viewer-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, #1e1e2e 0%, #16213e 100%); + color: #888; + border-radius: 12px; +} + +.brace-transform-viewer-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(30, 30, 46, 0.9); + color: #888; + border-radius: 12px; + pointer-events: none; +} + +.brace-transform-viewer-overlay.error { + color: #f44336; +} + +.brace-transform-viewer-overlay.placeholder { + background: linear-gradient(180deg, #1e1e2e 0%, #16213e 100%); + pointer-events: auto; +} + +.brace-transform-viewer-overlay .placeholder-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.brace-transform-viewer-overlay p { + margin-top: 8px; + font-size: 14px; +} + +/* Dimensions overlay for measurement grid */ +.brace-dimensions-overlay { + position: absolute; + top: 12px; + right: 12px; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); + border: 1px solid rgba(136, 255, 136, 0.3); + border-radius: 8px; + padding: 12px 16px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + z-index: 10; + min-width: 120px; +} + +.brace-dimensions-overlay .dimensions-title { + color: #88ff88; + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(136, 255, 136, 0.2); +} + +.brace-dimensions-overlay .dimension-row { + display: flex; + justify-content: space-between; + align-items: center; + margin: 4px 0; +} + +.brace-dimensions-overlay .dim-label { + color: #999; + font-size: 11px; +} + +.brace-dimensions-overlay .dim-value { + color: #fff; + font-weight: 600; + font-size: 13px; +} + +/* Responsive for editors */ +@media (max-width: 1400px) { + .dual-brace-viewers.with-editors { + grid-template-columns: 1fr; + } + + .brace-viewer-with-editor { + flex-direction: column; + } + + .brace-inline-editor { + max-height: none; + } +} + +/* ============================================== + BODY SCAN UPLOAD STAGE (Stage 3) + ============================================== */ + +.body-scan-stage .stage-content { + display: grid; + grid-template-columns: 1fr 320px; + gap: 24px; +} + +.body-scan-content { + display: contents; +} + +.body-scan-main { + background: #1a1a1a; + border-radius: 12px; + min-height: 400px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Upload Dropzone */ +.upload-dropzone { + width: 100%; + height: 100%; + min-height: 400px; + border: 2px dashed #444; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; +} + +.upload-dropzone:hover { + border-color: #2196f3; + background: rgba(33, 150, 243, 0.05); +} + +.upload-dropzone.drag-active { + border-color: #4caf50; + background: rgba(76, 175, 80, 0.1); +} + +.dropzone-content { + text-align: center; + padding: 40px; +} + +.dropzone-icon { + font-size: 64px; + margin-bottom: 16px; + opacity: 0.7; +} + +.dropzone-content h3 { + font-size: 20px; + color: #fff; + margin-bottom: 8px; +} + +.dropzone-content p { + color: #888; + margin-bottom: 4px; +} + +.dropzone-content .file-types { + font-size: 12px; + color: #666; + margin-top: 12px; +} + +.upload-progress { + color: #2196f3 !important; + font-weight: 500; + margin-top: 16px !important; +} + +.upload-error { + color: #f44336 !important; + font-weight: 500; + margin-top: 16px !important; +} + +/* Body Scan Preview with 3D Viewer */ +.body-scan-preview { + width: 100%; + height: 100%; + min-height: 400px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + border-radius: 12px; +} + +/* When 3D preview is active, fill the entire space */ +.body-scan-preview.body-scan-preview-3d { + padding: 0; +} + +/* 3D Body Scan Viewer - fills parent container */ +.body-scan-viewer-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); + border-radius: 12px; + overflow: hidden; +} + +.body-scan-viewer-canvas { + width: 100%; + height: 100%; +} + +.body-scan-viewer-canvas canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +.body-scan-viewer-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); + color: #888; + border-radius: 12px; +} + +.body-scan-viewer-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(26, 26, 46, 0.9); + color: #888; + border-radius: 12px; + pointer-events: none; +} + +.body-scan-viewer-overlay.error { + color: #f44336; +} + +.body-scan-viewer-overlay.placeholder { + background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); +} + +.body-scan-viewer-overlay p { + margin-top: 12px; + font-size: 14px; +} + +/* Preview info overlay */ +.preview-info-overlay { + position: absolute; + bottom: 60px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + background: rgba(0, 0, 0, 0.6); + padding: 8px 16px; + border-radius: 8px; + backdrop-filter: blur(4px); +} + +.preview-info-overlay .filename { + color: #fff; + font-family: monospace; + font-size: 13px; +} + +.preview-info-overlay .vertex-count { + color: #888; + font-size: 11px; +} + +/* Legacy placeholder styles (fallback) */ +.preview-placeholder { + text-align: center; +} + +.preview-icon { + width: 80px; + height: 80px; + background: linear-gradient(135deg, #4caf50 0%, #45a049 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: white; + margin: 0 auto 20px; +} + +.preview-placeholder h3 { + font-size: 20px; + color: #fff; + margin-bottom: 8px; +} + +.preview-placeholder .filename { + color: #888; + font-family: monospace; + font-size: 14px; +} + +.preview-placeholder .vertex-count { + color: #666; + font-size: 12px; + margin-top: 8px; +} + +.btn-remove { + position: absolute; + bottom: 20px; + right: 20px; + background: rgba(244, 67, 54, 0.2); + color: #f44336; + border: 1px solid rgba(244, 67, 54, 0.3); + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.btn-remove:hover { + background: rgba(244, 67, 54, 0.3); +} + +/* Body Scan Sidebar */ +.body-scan-sidebar { + display: flex; + flex-direction: column; + gap: 16px; +} + +.info-panel { + background: #1a1a1a; + border-radius: 8px; + padding: 20px; +} + +.info-panel h3 { + font-size: 16px; + color: #fff; + margin-bottom: 12px; +} + +.info-panel p { + color: #888; + font-size: 14px; + line-height: 1.6; + margin-bottom: 12px; +} + +.benefits-list { + list-style: none; + padding: 0; + margin: 0 0 16px 0; +} + +.benefits-list li { + padding: 8px 0 8px 24px; + color: #aaa; + font-size: 13px; + position: relative; +} + +.benefits-list li::before { + content: '✓'; + position: absolute; + left: 0; + color: #4caf50; +} + +.optional-note { + background: rgba(33, 150, 243, 0.1); + border-left: 3px solid #2196f3; + padding: 12px; + border-radius: 0 6px 6px 0; + font-size: 13px !important; +} + +.optional-note strong { + color: #2196f3; +} + +/* Measurements Panel */ +.measurements-panel { + background: #1a1a1a; + border-radius: 8px; + padding: 20px; +} + +.measurements-panel h3 { + font-size: 16px; + color: #fff; + margin-bottom: 16px; +} + +.measurements-grid { + display: flex; + flex-direction: column; + gap: 12px; +} + +.measurement-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: #222; + border-radius: 6px; +} + +.measurement-item .label { + color: #888; + font-size: 13px; +} + +.measurement-item .value { + color: #fff; + font-weight: 500; + font-family: monospace; +} + +.measurement-item.highlight { + background: rgba(33, 150, 243, 0.15); + border: 1px solid rgba(33, 150, 243, 0.3); +} + +.measurement-item.highlight .label { + color: #2196f3; +} + +.measurement-item.highlight .value { + color: #64b5f6; +} + +/* Body Scan Loading */ +.body-scan-loading { + text-align: center; + padding: 60px 40px; +} + +.body-scan-loading .spinner { + margin: 0 auto 20px; +} + +.body-scan-loading p { + color: #888; +} + +/* ============================================== + THREE.JS BRACE VIEWER + ============================================== */ + +.brace-viewer-container { + position: relative; + background: #1a1a1a; + border-radius: 8px; + overflow: hidden; +} + +.brace-viewer-canvas { + display: block; +} + +.brace-viewer-canvas canvas { + display: block; + border-radius: 8px; +} + +.brace-viewer-loading, +.brace-viewer-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(26, 26, 26, 0.9); + color: #888; +} + +.brace-viewer-overlay.error { + color: #f44336; +} + +.brace-viewer-overlay.placeholder { + background: #1a1a1a; +} + +.brace-viewer-overlay p { + margin-top: 12px; + font-size: 14px; +} + +/* Brace Editor Stage */ +.brace-editor-stage .stage-content { + display: grid; + grid-template-columns: 1fr 320px; + gap: 24px; +} + +.brace-editor-main { + background: #1a1a1a; + border-radius: 12px; + overflow: hidden; +} + +.brace-editor-sidebar { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Deformation Controls */ +.deformation-controls { + background: #1a1a1a; + border-radius: 8px; + padding: 20px; +} + +.deformation-controls h3 { + font-size: 16px; + color: #fff; + margin-bottom: 16px; +} + +.control-group { + margin-bottom: 16px; +} + +.control-group label { + display: block; + font-size: 13px; + color: #888; + margin-bottom: 6px; +} + +.control-slider { + display: flex; + align-items: center; + gap: 12px; +} + +.control-slider input[type="range"] { + flex: 1; + -webkit-appearance: none; + height: 4px; + background: #333; + border-radius: 2px; + outline: none; +} + +.control-slider input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + background: #2196f3; + border-radius: 50%; + cursor: pointer; +} + +.control-slider .value { + min-width: 50px; + text-align: right; + font-family: monospace; + font-size: 13px; + color: #fff; +} + +/* Markers Panel */ +.markers-panel { + background: #1a1a1a; + border-radius: 8px; + padding: 20px; +} + +.markers-panel h3 { + font-size: 16px; + color: #fff; + margin-bottom: 12px; +} + +.markers-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 200px; + overflow-y: auto; +} + +.marker-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: #222; + border-radius: 6px; + font-size: 13px; +} + +.marker-color { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.marker-name { + flex: 1; + color: #aaa; + font-family: monospace; +} + +.marker-position { + color: #666; + font-size: 11px; +} + +/* Export Panel */ +.export-panel { + background: #1a1a1a; + border-radius: 8px; + padding: 20px; +} + +.export-panel h3 { + font-size: 16px; + color: #fff; + margin-bottom: 12px; +} + +.export-buttons { + display: flex; + flex-direction: column; + gap: 8px; +} + +.export-buttons .btn { + width: 100%; + justify-content: center; +} + +/* Control Actions */ +.control-actions { + display: flex; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #333; +} + +.control-actions .btn.small { + padding: 8px 16px; + font-size: 13px; +} + +/* Tips List */ +.tips-list { + list-style: none; + padding: 0; + margin: 12px 0; +} + +.tips-list li { + padding: 6px 0; + font-size: 12px; + color: #888; +} + +.tips-list li strong { + color: #aaa; +} + +/* Viewer Controls */ +.viewer-controls { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 16px; + background: #222; + border-top: 1px solid #333; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #888; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; +} + +/* Editor Empty State */ +.editor-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 40px; + text-align: center; +} + +.editor-empty p { + color: #888; + font-size: 14px; +} + +/* ============================================ + Stage 5: Brace Fitting Inspection + ============================================ */ + +.fitting-stage { + background: #1a1a1a; + border-radius: 12px; + padding: 24px; + margin: 20px; +} + +.fitting-content { + display: grid; + grid-template-columns: 280px 1fr; + gap: 24px; + margin-top: 20px; +} + +/* 3-column layout for Stage 5 */ +.fitting-layout-3col { + display: grid; + grid-template-columns: 260px 1fr 280px; + gap: 16px; + margin-top: 20px; + min-height: 600px; +} + +.fitting-panel { + background: #222; + border-radius: 8px; + padding: 16px; + overflow-y: auto; + max-height: 700px; +} + +.fitting-panel h3 { + font-size: 14px; + color: #fff; + margin: 0 0 16px 0; + padding-bottom: 8px; + border-bottom: 1px solid #333; +} + +.fitting-panel .panel-section { + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #333; +} + +.fitting-panel .panel-section:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.fitting-panel h4 { + font-size: 12px; + color: #888; + margin: 0 0 8px 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Checkbox rows */ +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 12px; + color: #ccc; + cursor: pointer; +} + +.checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #4a90d9; +} + +.color-dot { + width: 12px; + height: 12px; + border-radius: 3px; + flex-shrink: 0; +} + +/* Compact sliders for transforms */ +.slider-compact { + display: grid; + grid-template-columns: 20px 1fr 45px; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font-size: 11px; + color: #888; +} + +.slider-compact input[type="range"] { + width: 100%; + height: 4px; + border-radius: 2px; + background: #333; + outline: none; + cursor: pointer; + accent-color: #4a90d9; +} + +.slider-compact span:last-child { + text-align: right; + font-family: monospace; + color: #aaa; +} + +/* Full width slider */ +.fitting-panel > .panel-section > input[type="range"] { + width: 100%; + height: 6px; + border-radius: 3px; + background: #333; + outline: none; + cursor: pointer; + accent-color: #4a90d9; +} + +/* Reset button */ +.btn-reset-all { + width: 100%; + padding: 10px; + margin: 12px 0; + background: #333; + border: 1px solid #444; + border-radius: 4px; + color: #aaa; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.btn-reset-all:hover { + background: #444; + color: #fff; + border-color: #555; +} + +/* Tips in panel */ +.fitting-panel .tips ul { + list-style: none; + padding: 0; + margin: 0; +} + +.fitting-panel .tips li { + padding: 4px 0; + font-size: 11px; + color: #777; + padding-left: 12px; + position: relative; +} + +.fitting-panel .tips li::before { + content: "•"; + position: absolute; + left: 0; + color: #555; +} + +/* Fitting Controls Panel */ +.fitting-controls { + background: #222; + border-radius: 8px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.fitting-controls .controls-section { + padding-bottom: 16px; + border-bottom: 1px solid #333; +} + +.fitting-controls .controls-section:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.fitting-controls h4 { + font-size: 13px; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +.fitting-controls .control-item { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + font-size: 13px; + color: #ccc; + cursor: pointer; +} + +.fitting-controls .control-item.checkbox { + padding: 6px 0; +} + +.fitting-controls .control-item.slider { + flex-direction: column; + align-items: stretch; + gap: 6px; +} + +.fitting-controls .control-item.slider span { + display: flex; + justify-content: space-between; +} + +.fitting-controls .control-item input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: #4a90d9; +} + +.fitting-controls .control-item input[type="range"] { + width: 100%; + height: 6px; + border-radius: 3px; + background: #333; + outline: none; + cursor: pointer; + accent-color: #4a90d9; +} + +.fitting-controls .color-indicator { + width: 14px; + height: 14px; + border-radius: 3px; + flex-shrink: 0; +} + +/* Legend */ +.fitting-controls .legend .legend-item { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 0; + font-size: 12px; + color: #aaa; +} + +.fitting-controls .legend .legend-color { + width: 16px; + height: 16px; + border-radius: 3px; + flex-shrink: 0; +} + +/* Tips */ +.fitting-controls .tips ul { + list-style: none; + padding: 0; + margin: 0; +} + +.fitting-controls .tips li { + padding: 4px 0; + font-size: 12px; + color: #777; + padding-left: 12px; + position: relative; +} + +.fitting-controls .tips li::before { + content: "•"; + position: absolute; + left: 0; + color: #555; +} + +/* Transform Controls */ +.fitting-controls .transform-group { + display: grid; + grid-template-columns: 60px 1fr 40px; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.fitting-controls .transform-label { + font-size: 12px; + color: #888; +} + +.fitting-controls .transform-value { + font-size: 12px; + color: #aaa; + text-align: right; + font-family: monospace; +} + +.fitting-controls .transform-group input[type="range"] { + width: 100%; + height: 4px; + border-radius: 2px; + background: #333; + outline: none; + cursor: pointer; + accent-color: #4a90d9; +} + +.fitting-controls .btn-reset { + width: 100%; + padding: 8px 12px; + margin-top: 12px; + background: #333; + border: 1px solid #444; + border-radius: 4px; + color: #aaa; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.fitting-controls .btn-reset:hover { + background: #444; + color: #fff; + border-color: #555; +} + +/* Brace Selector Buttons */ +.brace-selector { + display: flex; + gap: 8px; +} + +.brace-select-btn { + flex: 1; + padding: 8px 12px; + background: #333; + border: 1px solid #444; + border-radius: 4px; + color: #888; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.brace-select-btn:hover:not(:disabled) { + background: #444; + color: #ccc; +} + +.brace-select-btn.active { + background: #2d4a77; + border-color: #4a90d9; + color: #6bb5ff; +} + +.brace-select-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Control hint text */ +.control-hint { + font-size: 11px; + color: #666; + margin-top: 4px; + font-style: italic; +} + +/* Fitting Inline Editor */ +.fitting-inline-editor { + background: #252535; + border-radius: 6px; + padding: 12px; + margin-top: 8px; +} + +.fitting-inline-editor .editor-header { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #333; +} + +.fitting-inline-editor .editor-header h4 { + font-size: 13px; + color: #aaa; + margin: 0; +} + +.fitting-inline-editor .editor-sliders { + display: flex; + flex-direction: column; + gap: 10px; +} + +.fitting-inline-editor .slider-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.fitting-inline-editor .slider-label { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #888; +} + +.fitting-inline-editor .slider-value { + color: #aaa; + font-family: monospace; +} + +.fitting-inline-editor .slider-input { + width: 100%; + height: 4px; + border-radius: 2px; + background: #333; + outline: none; + cursor: pointer; + accent-color: #4a90d9; +} + +.fitting-inline-editor .editor-mode-toggle { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #333; +} + +.fitting-inline-editor .toggle-label { + font-size: 11px; + color: #888; + margin-bottom: 6px; + display: block; +} + +.fitting-inline-editor .toggle-buttons { + display: flex; + gap: 4px; +} + +.fitting-inline-editor .toggle-btn { + flex: 1; + padding: 4px 8px; + background: #333; + border: 1px solid #444; + border-radius: 3px; + color: #888; + font-size: 10px; + cursor: pointer; +} + +.fitting-inline-editor .toggle-btn.active { + background: #2d4a77; + border-color: #4a90d9; + color: #6bb5ff; +} + +.fitting-inline-editor .editor-checkbox { + margin-top: 10px; + font-size: 12px; + color: #888; +} + +.fitting-inline-editor .editor-checkbox label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.fitting-inline-editor .advanced-toggle { + width: 100%; + padding: 6px; + margin-top: 10px; + background: transparent; + border: none; + color: #666; + font-size: 11px; + cursor: pointer; + text-align: left; +} + +.fitting-inline-editor .advanced-toggle:hover { + color: #888; +} + +.fitting-inline-editor .editor-actions { + display: flex; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #333; +} + +.fitting-inline-editor .btn-editor { + flex: 1; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.fitting-inline-editor .btn-editor.reset { + background: #333; + border: 1px solid #444; + color: #888; +} + +.fitting-inline-editor .btn-editor.reset:hover { + background: #444; + color: #ccc; +} + +.fitting-inline-editor .btn-editor.save { + background: #2d5a27; + border: 1px solid #3d7a37; + color: #8bc34a; +} + +.fitting-inline-editor .btn-editor.save:hover { + background: #3d7a37; + color: #a5d66a; +} + +/* Fitting Viewer */ +.fitting-viewer-container { + position: relative; + background: #1a1a2e; + border-radius: 8px; + overflow: hidden; + min-height: 500px; +} + +.fitting-viewer-canvas { + width: 100%; + height: 500px; +} + +.fitting-viewer-loading, +.fitting-viewer-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(26, 26, 46, 0.9); + color: #888; +} + +.fitting-viewer-overlay.error { + color: #ff6b6b; +} + +/* Fitting Empty State */ +.fitting-empty { + text-align: center; + padding: 60px 40px; + color: #888; +} + +.fitting-empty p { + margin: 8px 0; +} + +.fitting-empty .hint { + font-size: 13px; + color: #666; +} + +/* Pressure Info Section */ +.fitting-pressure-info { + margin-top: 24px; + padding: 20px; + background: #222; + border-radius: 8px; +} + +.fitting-pressure-info h4 { + font-size: 14px; + color: #fff; + margin-bottom: 8px; +} + +.fitting-pressure-info > p { + font-size: 13px; + color: #888; + margin-bottom: 16px; +} + +.pressure-zones-mini { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.zone-mini { + display: flex; + flex-direction: column; + padding: 10px 16px; + background: #2a2a3a; + border-radius: 6px; + min-width: 100px; +} + +.zone-mini.pressure { + border-left: 3px solid #ff6b6b; +} + +.zone-mini.relief { + border-left: 3px solid #4caf50; +} + +.zone-mini .zone-name { + font-size: 12px; + color: #888; + margin-bottom: 4px; +} + +.zone-mini .zone-value { + font-size: 16px; + font-weight: 600; + color: #fff; +} + +/* Stage Continue Section */ +.stage-continue-section { + display: flex; + justify-content: center; + padding: 24px; + margin-top: 20px; + border-top: 1px solid #333; +} + +.stage-continue-section .btn-large { + padding: 14px 32px; + font-size: 16px; +} + +/* Pipeline step available state */ +.pipeline-step--available .pipeline-step-number { + background: #2d4a77; + color: #6bb5ff; +} + +.pipeline-step--available .pipeline-step-label { + color: #6bb5ff; +} + +.pipeline-step.clickable:hover .pipeline-step-number { + transform: scale(1.1); + transition: transform 0.2s; +} + +.pipeline-step.clickable:hover .pipeline-step-label { + color: #fff; +} + +/* Responsive */ +@media (max-width: 900px) { + .stage-content { + grid-template-columns: 1fr; + } + + .landmark-stage .stage-content, + .analysis-stage .stage-content, + .body-scan-stage .stage-content, + .brace-stage .stage-content, + .brace-editor-stage .stage-content { + grid-template-columns: 1fr; + } + + .fitting-content { + grid-template-columns: 1fr; + } + + .fitting-controls { + flex-direction: row; + flex-wrap: wrap; + } + + .fitting-controls .controls-section { + flex: 1; + min-width: 150px; + border-bottom: none; + border-right: 1px solid #333; + padding-right: 16px; + } + + .fitting-controls .controls-section:last-child { + border-right: none; + } + + .cobb-grid, + .classification-grid { + grid-template-columns: 1fr; + } + + .editor-content { + grid-template-columns: 1fr; + } + + .vertebrae-panel { + max-height: 200px; + } +} diff --git a/frontend/src/components/rigo/AnalysisResults.tsx b/frontend/src/components/rigo/AnalysisResults.tsx new file mode 100644 index 0000000..e320e26 --- /dev/null +++ b/frontend/src/components/rigo/AnalysisResults.tsx @@ -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 ( +
+
+
+ Error +
+
+ {error} +
+
Please try uploading a clearer X-ray image.
+
+
+ ); + } + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ + Analyzing X-ray with Claude Vision... +

+
+ ); + } + + if (!data) { + return ( +
+
+ + + + + + + +

Upload an EOS X-ray to see the analysis results here.

+
+
+ ); + } + + // 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 ( +
+ {/* Diagnosis */} +
+
Diagnosis
+
+ {data.pattern === "Type_3C" ? "Right Thoracic Scoliosis" : data.pattern} +
+
+ Rigo-Cheneau Classification: Type 3C +
+
+ + {/* Thoracic Measurements */} +
+
+
Thoracic Cobb
+
{data.cobb_angle}°
+
{severity.level} curve
+
+ +
+
Apex Vertebra
+
{data.apex}
+
Curve apex location
+
+
+ + {/* Thoracic Convexity & Lumbar Cobb */} +
+
+
Thoracic Convexity
+
{data.thoracic_convexity || "Right"}
+
Curve direction
+
+ +
+
Lumbar Cobb
+
{data.lumbar_cobb_deg != null ? `${data.lumbar_cobb_deg}°` : "—"}
+
Compensatory curve
+
+
+ + {/* L4/L5 Tilt */} +
+
+
L4 Tilt
+
{data.l4_tilt_deg != null ? `${data.l4_tilt_deg.toFixed(1)}°` : "—"}
+
Vertebra angle
+
+ +
+
L5 Tilt
+
{data.l5_tilt_deg != null ? `${data.l5_tilt_deg.toFixed(1)}°` : "—"}
+
Vertebra angle
+
+
+ + {/* Pelvic Tilt */} +
+
Pelvic Tilt
+
{data.pelvic_tilt} Side
+
Compensatory pelvic position
+
+ + {/* Brace Parameters */} +
+
+ Generated Brace Parameters +
+
+
+ Pressure Pad Position: + {data.apex} Level +
+
+ Pad Depth: + {data.cobb_angle > 30 ? "Aggressive" : "Standard"} +
+
+ Expansion Window: + {data.pelvic_tilt === "Left" ? "Right" : "Left"} Side +
+
+
+ + {/* View Toggle */} + {modelUrl && ( +
+ +
+ )} + + {/* Download Buttons */} +
+ + + {modelUrl && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/rigo/BraceViewer.tsx b/frontend/src/components/rigo/BraceViewer.tsx new file mode 100644 index 0000000..0593887 --- /dev/null +++ b/frontend/src/components/rigo/BraceViewer.tsx @@ -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(null); + + useFrame((state) => { + if (meshRef.current) { + meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.3) * 0.1; + } + }); + + return ( + + {/* Main brace body - simplified torso shape */} + + + + + + {/* Right pressure pad (thoracic) */} + + + + + + {/* Left expansion window cutout visual */} + + + + + + {/* Lumbar support */} + + + + + + ); +} + +// Load actual GLB model +function BraceModel({ url, opacity = 1 }: { url: string; opacity?: number }) { + const { scene } = useGLTF(url); + const meshRef = useRef(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 ( +
+ +
+ ); +} + +// Loading indicator +function LoadingIndicator() { + const meshRef = useRef(null); + + useFrame((state) => { + if (meshRef.current) { + meshRef.current.rotation.y = state.clock.elapsedTime * 2; + } + }); + + return ( + + + + + ); +} + +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 ( + <> + + + + {/* Lighting */} + + + + + + {/* Environment for reflections */} + + + {/* Grid */} + {showGrid && ( + + )} + + {/* Contact shadows */} + + + {/* Model */} + }> + {isLoading ? ( + + ) : modelUrl ? ( + + ) : ( + + )} + + + {/* Controls */} + + + + {/* Viewer Controls */} +
+ + +
+ + ); +} diff --git a/frontend/src/components/rigo/UploadPanel.tsx b/frontend/src/components/rigo/UploadPanel.tsx new file mode 100644 index 0000000..495decf --- /dev/null +++ b/frontend/src/components/rigo/UploadPanel.tsx @@ -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(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) => { + 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 ( +
+ {!preview ? ( + + ) : ( +
+
+ X-ray preview +
+ {fileName} +
+
+ +
+ + + +
+
+ )} + + {/* Progress Steps */} +
+
+
1
+
+
Upload X-Ray
+
EOS or standard spinal radiograph
+
+
+ +
+
2
+
+
Generate Brace
+
3D model with corrective parameters
+
+
+ +
+
3
+
+
Download STL
+
Ready for 3D printing or editing
+
+
+
+
+ ); +} diff --git a/frontend/src/components/three/BodyScanViewer.tsx b/frontend/src/components/three/BodyScanViewer.tsx new file mode 100644 index 0000000..f460734 --- /dev/null +++ b/frontend/src/components/three/BodyScanViewer.tsx @@ -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(null); + const rendererRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const meshRef = useRef(null); + const animationFrameRef = useRef(0); + const autoRotateRef = useRef(autoRotate); + const rotationSpeedRef = useRef(rotationSpeed); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+

Loading 3D viewer...

+
+ ); + } + + return ( +
+
+ + {loading && ( +
+
+

Loading model...

+
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!scanUrl && !loading && ( +
+

No scan loaded

+
+ )} +
+ ); +} diff --git a/frontend/src/components/three/BraceModelViewer.tsx b/frontend/src/components/three/BraceModelViewer.tsx new file mode 100644 index 0000000..2aa0f9d --- /dev/null +++ b/frontend/src/components/three/BraceModelViewer.tsx @@ -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(null); + const rendererRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const meshRef = useRef(null); + const animationFrameRef = useRef(0); + const autoRotateRef = useRef(autoRotate); + const rotationSpeedRef = useRef(rotationSpeed); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+

Loading 3D viewer...

+
+ ); + } + + return ( +
+
+ + {loading && ( +
+
+

Loading brace model...

+
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!modelUrl && !loading && ( +
+
🦾
+

Generate a brace to see 3D preview

+
+ )} +
+ ); +} diff --git a/frontend/src/components/three/BraceTransformViewer.tsx b/frontend/src/components/three/BraceTransformViewer.tsx new file mode 100644 index 0000000..c38187f --- /dev/null +++ b/frontend/src/components/three/BraceTransformViewer.tsx @@ -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; + +export type BraceTransformViewerRef = { + exportSTL: () => Promise; + exportGLB: () => Promise; + 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(({ + glbUrl, + stlUrl, + transformParams, + autoRotate = true, + rotationSpeed = 0.005, + showMarkers = false, + showGrid = false, + onMarkersLoaded, + onGeometryUpdated, +}, ref) => { + const containerRef = useRef(null); + const rendererRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const meshRef = useRef(null); + const baseGeometryRef = useRef(null); + const gridGroupRef = useRef(null); + const realWorldScaleRef = useRef(1); // units per cm + const markersRef = useRef({}); + const modelGroupRef = useRef(null); + const animationFrameRef = useRef(0); + const autoRotateRef = useRef(autoRotate); + const rotationSpeedRef = useRef(rotationSpeed); + const paramsRef = useRef(transformParams); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+

Loading 3D viewer...

+
+ ); + } + + return ( +
+
+ + {/* Dimensions overlay */} + {showGrid && dimensions && !loading && ( +
+
Dimensions (cm)
+
+ Width: + {dimensions.width.toFixed(1)} +
+
+ Height: + {dimensions.height.toFixed(1)} +
+
+ Depth: + {dimensions.depth.toFixed(1)} +
+
+ )} + + {loading && ( +
+
+

Loading brace model...

+
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!modelUrl && !loading && ( +
+
🦾
+

Generate a brace to see 3D preview

+
+ )} +
+ ); +}); + +BraceTransformViewer.displayName = 'BraceTransformViewer'; + +export default BraceTransformViewer; diff --git a/frontend/src/components/three/BraceViewer.tsx b/frontend/src/components/three/BraceViewer.tsx new file mode 100644 index 0000000..cebf1f9 --- /dev/null +++ b/frontend/src/components/three/BraceViewer.tsx @@ -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(null); + const rendererRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const braceMeshRef = useRef(null); + const animationFrameRef = useRef(0); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+

Loading 3D viewer...

+
+ ); + } + + return ( +
+
+ + {loading && ( +
+
+

Loading model...

+
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!glbUrl && !loading && ( +
+

No 3D model loaded

+
+ )} +
+ ); +} + +// 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) +} diff --git a/frontend/src/components/three/index.ts b/frontend/src/components/three/index.ts new file mode 100644 index 0000000..c44268a --- /dev/null +++ b/frontend/src/components/three/index.ts @@ -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'; \ No newline at end of file diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..633a3d9 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -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; + logout: () => void; + error: string | null; + clearError: () => void; +}; + +const AuthContext = createContext(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(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 {children}; +} + +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 { + 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; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..08a3ac9 --- /dev/null +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..0f7b0f9 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,107 @@ +const API_BASE = 'https://cfx9z50wj2.execute-api.ca-central-1.amazonaws.com/prod'; + +async function http(path: string, init?: RequestInit): Promise { + 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; +}; + +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(`/cases/${encodeURIComponent(caseId)}/upload-url`, { + method: "POST", + body: JSON.stringify(body), + }), + + getCaseStatus: (caseId: string) => http(`/cases/${encodeURIComponent(caseId)}`), + getCaseAssets: (caseId: string) => http(`/cases/${encodeURIComponent(caseId)}/assets`), + + // FIX: include caseId in JSON body to satisfy backend Lambda contract + submitLandmarks: (caseId: string, body: SubmitLandmarksRequest) => + http(`/cases/${encodeURIComponent(caseId)}/landmarks`, { + method: "POST", + body: JSON.stringify({ + ...body, + }), + }), +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..37fba84 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + +); diff --git a/frontend/src/pages/BraceAnalysisPage.tsx b/frontend/src/pages/BraceAnalysisPage.tsx new file mode 100644 index 0000000..011deb1 --- /dev/null +++ b/frontend/src/pages/BraceAnalysisPage.tsx @@ -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 ( +
+
{label}
+
{value}
+ {description &&
{description}
} +
+ ); +} + +// Download button for outputs +function DownloadButton({ output }: { output: BraceOutput }) { + const getIcon = (type: string) => { + switch (type) { + case "stl": + case "ply": + case "obj": + return ( + + + + + + ); + case "image": + return ( + + + + + + ); + case "json": + return ( + + + + + + + ); + default: + return ( + + + + + + ); + } + }; + + return ( + + {getIcon(output.type)} + + {output.filename} + + ({formatBytes(output.size)}) + + + + ); +} + +// 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 ( +
+ {entries.map((entry) => ( + 25 ? (entry.value > 40 ? "warning" : "highlight") : "success"} + /> + ))} +
+ ); +} + +// Rigo Classification Display +function RigoDisplay({ classification }: { classification: RigoClassification | undefined }) { + if (!classification) return null; + + return ( +
+
+ Rigo-Chêneau Classification +
+
{classification.type}
+
{classification.description}
+ {classification.curve_pattern && ( +
+ Curve Pattern: {classification.curve_pattern} +
+ )} +
+ ); +} + +export default function BraceAnalysisPage() { + const { caseId: routeCaseId } = useParams<{ caseId?: string }>(); + + const [caseId, setCaseId] = useState(routeCaseId || null); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [outputs, setOutputs] = useState([]); + const [modelUrl, setModelUrl] = useState(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 ( +
+ {/* Header */} +
+
+

Brace Analysis

+

+ Upload an X-ray image to analyze spinal curvature and generate a custom brace design. +

+
+ {caseId && ( + + View Case Details + + )} +
+ + {/* Main Content - Three Column Layout */} +
+ {/* Left Panel - Upload */} + + + {/* Center - 3D Viewer */} +
+ + + {/* Processing Info */} + {result && ( +
+ Model: {result.model} + Experiment: {result.experiment} + Processing: {result.processing_time_ms}ms +
+ )} +
+ + {/* Right Panel - Analysis Results */} + +
+
+ ); +} diff --git a/frontend/src/pages/CaseDetail.tsx b/frontend/src/pages/CaseDetail.tsx new file mode 100644 index 0000000..7224566 --- /dev/null +++ b/frontend/src/pages/CaseDetail.tsx @@ -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 = { + '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(null); + const [outputs, setOutputs] = useState(null); + const [xrayUrl, setXrayUrl] = useState(null); + const [xrayError, setXrayError] = useState(false); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [generating, setGenerating] = useState(false); + const [genError, setGenError] = useState(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 ( +
+
Loading case...
+
+ ); + } + + if (err || !caseData) { + return ( +
+
{err || "Case not found"}
+ +
+ ); + } + + return ( +
+ {/* Header with case ID */} +
+
+ +

{caseId}

+ + {caseData.status?.replace(/_/g, ' ')} + +
+
+ + {/* X-ray Image Section */} +
+
+

Original X-ray

+
+ {xrayUrl && !xrayError ? ( + X-ray setXrayError(true)} + /> + ) : ( +
+ {xrayError ? "Failed to load X-ray" : "X-ray not available yet"} +
+ )} +
+ + {/* Processing State */} + {isProcessing && ( +
+
+ Processing... Generating brace from X-ray analysis +
+ )} + + {/* Generate Button - show if X-ray exists but brace not generated */} + {xrayUrl && !hasBrace && !isProcessing && ( +
+ + {genError &&
{genError}
} +
+ )} +
+ + {/* Visualization Section */} + {vizUrl && ( +
+

Spine Analysis Visualization

+
+ Analysis visualization +
+ + {/* Detailed Analysis Under Visualization */} + {caseData.analysis_result && ( +
+ {/* Cobb Angles with Severity */} + {caseData.analysis_result.cobb_angles && ( +
+

Cobb Angle Measurements

+
+ {caseData.analysis_result.cobb_angles.PT !== undefined && ( +
+ PT (Proximal Thoracic) + + {caseData.analysis_result.cobb_angles.PT.toFixed(1)}° + + {getCurveSeverity(caseData.analysis_result.cobb_angles.PT)} +
+ )} + {caseData.analysis_result.cobb_angles.MT !== undefined && ( +
+ MT (Main Thoracic) + + {caseData.analysis_result.cobb_angles.MT.toFixed(1)}° + + {getCurveSeverity(caseData.analysis_result.cobb_angles.MT)} +
+ )} + {caseData.analysis_result.cobb_angles.TL !== undefined && ( +
+ TL (Thoracolumbar/Lumbar) + + {caseData.analysis_result.cobb_angles.TL.toFixed(1)}° + + {getCurveSeverity(caseData.analysis_result.cobb_angles.TL)} +
+ )} +
+
+ )} + + {/* Classification Summary */} +
+

Classification

+
+ {caseData.analysis_result.curve_type && ( +
+ Curve Pattern + + {caseData.analysis_result.curve_type}-Curve + + + {caseData.analysis_result.curve_type === 'S' ? 'Double curve (thoracic + lumbar)' : + caseData.analysis_result.curve_type === 'C' ? 'Single curve pattern' : + 'Curve pattern identified'} + +
+ )} + {caseData.analysis_result.rigo_classification && ( +
+ Rigo-Chêneau Type + + {caseData.analysis_result.rigo_classification.type} + + + {caseData.analysis_result.rigo_classification.description || getRigoDescription(caseData.analysis_result.rigo_classification.type)} + +
+ )} +
+
+ + {/* Brace Generation Details */} +
+

Brace Generation Details

+
+ {caseData.analysis_result.vertebrae_detected && ( +
+ Vertebrae Detected + {caseData.analysis_result.vertebrae_detected} +
+ )} + {caseData.analysis_result.mesh_info && ( + <> +
+ Mesh Vertices + {caseData.analysis_result.mesh_info.vertices?.toLocaleString()} +
+
+ Mesh Faces + {caseData.analysis_result.mesh_info.faces?.toLocaleString()} +
+ + )} + {caseData.analysis_result.processing_time_ms && ( +
+ Processing Time + {(caseData.analysis_result.processing_time_ms / 1000).toFixed(2)}s +
+ )} +
+
+ + {/* Deformation/Pressure Zones */} + {caseData.analysis_result.deformation_report?.zones && caseData.analysis_result.deformation_report.zones.length > 0 && ( +
+

Brace Pressure Zones

+

+ Based on the Cobb angles and Rigo classification, the following pressure modifications were applied to the brace: +

+
+ {caseData.analysis_result.deformation_report.zones.map((zone, idx) => ( +
+
+ {zone.zone} + + {zone.deform_mm > 0 ? '+' : ''}{zone.deform_mm.toFixed(1)} mm + +
+ {zone.reason} +
+ ))} +
+ {caseData.analysis_result.deformation_report.patch_grid && ( +

+ Patch Grid: {caseData.analysis_result.deformation_report.patch_grid} +

+ )} +
+ )} +
+ )} +
+ )} + + {/* Downloads */} + {outputs?.outputs && outputs.outputs.length > 0 && ( +
+

Generated Brace Files

+
+ {outputs.outputs + .filter(o => o.type === 'stl' || o.type === 'obj') + .map(o => ( +
+
+ {o.type === 'stl' ? '🧊' : '📦'} +
+
+ {o.filename} + {formatFileSize(o.size)} +
+ +
+ ))} +
+

STL files can be 3D printed or opened in any 3D modeling software.

+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/CaseLoaderPage.tsx b/frontend/src/pages/CaseLoaderPage.tsx new file mode 100644 index 0000000..d861d6a --- /dev/null +++ b/frontend/src/pages/CaseLoaderPage.tsx @@ -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(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 ( +
+
+
+

Load A Case

+

+ Enter a case ID to view status or resume landmark capture. +

+
+
+
+ +
+
+ +
+

+ To create a new case and upload an X-ray, use "Start A Case" in the header. +

+ +
+ setCaseId(e.target.value)} + placeholder="case-20260122-..." + className="input" + /> + +
+ + {err &&
{err}
} +
+
+ ); +} diff --git a/frontend/src/pages/CaseStatusPage.tsx b/frontend/src/pages/CaseStatusPage.tsx new file mode 100644 index 0000000..d8a984e --- /dev/null +++ b/frontend/src/pages/CaseStatusPage.tsx @@ -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(null); + const [err, setErr] = useState(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 ( +
+
+
+

Case Status

+

Track pipeline progress and jump to the next step.

+
+
+
+ + +
+
+ +
+
+ Case: {id} +
+ + {err &&
{err}
} + {!data ? ( +
Loading status...
+ ) : ( + <> +
+
+ Status + {data.case.status} +
+
+ Current Step + {data.case.current_step || "-"} +
+
+ Last Updated + + {new Date(data.case.updated_at).toLocaleString()} + +
+
+ + + + + + + + + + + + + {data.steps.map((s) => ( + + + + + + + + ))} + +
#StepStatusStartedFinished
{s.step_order}{s.step_name} + {s.status} + {s.started_at ? new Date(s.started_at).toLocaleString() : "-"}{s.finished_at ? new Date(s.finished_at).toLocaleString() : "-"}
+ +
+ Step3 output (classification.json) display can be added next (optional for first demo). +
+ + )} +
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..c4e06e0 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + // Upload state + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(""); + const [dragActive, setDragActive] = useState(false); + const fileInputRef = useRef(null); + + // Thumbnail URLs for each case + const [thumbnails, setThumbnails] = useState>({}); + + // Dropdown menu state + const [openMenu, setOpenMenu] = useState(null); + const [deleting, setDeleting] = useState(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 = {}; + + 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) => { + const file = e.target.files?.[0]; + if (file) { + handleFileUpload(file); + } + }, [handleFileUpload]); + + const hasCases = cases.length > 0; + + return ( +
+
+
+

Cases

+

Upload an X-ray to create a new case, or select an existing one.

+
+
+
+ +
+
+ + {/* Upload Area */} +
!uploading && fileInputRef.current?.click()} + > + + {uploading ? ( +
+
+

{uploadProgress}

+
+ ) : ( +
+
+
+

+ Click to upload or drag and drop an X-ray image +

+

JPEG, PNG, WebP supported

+
+ )} +
+ + {err &&
{err}
} + + {/* Cases List */} +
+

Recent Cases

+ + {loading ? ( +
Loading cases...
+ ) : !hasCases ? ( +
No cases yet. Upload an X-ray above to create your first case.
+ ) : ( +
+ {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 ( +
!isDeleting && viewCase(c.caseId)} + > + {/* Thumbnail */} +
+ {thumbUrl ? ( + X-ray + ) : ( +
X
+ )} +
+ + {/* Case Info */} +
+ {c.caseId} + {isValidDate && ( + + {date.toLocaleDateString()} {date.toLocaleTimeString()} + + )} +
+ + {/* Menu Button */} +
+ {isDeleting ? ( +
+ ) : ( + <> + + {openMenu === c.caseId && ( +
+ +
+ )} + + )} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..cf3e180 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -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 ( +
+
+

Loading...

+
+ ); + } + + if (isAuthenticated) { + return ; + } + + return ( +
+ {/* Hero Section */} +
+
+

+ Intelligent Scoliosis +
+ Brace Design +

+

+ 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. +

+
+ +
+
+ + {/* Hero Visual - Spine illustration */} +
+ + {/* Spine curve */} + + {/* Vertebrae */} + {[40, 80, 120, 160, 200, 240].map((y, i) => ( + + ))} + {/* Brace outline */} + + +
+
+ + {/* Features Section */} +
+
+
+
+ + + + + +
+

X-ray Analysis

+

+ Upload spinal X-rays for automatic vertebrae detection and landmark identification + using advanced computer vision. +

+
+ +
+
+ + + + + +
+

Cobb Angle Measurement

+

+ Precise calculation of Cobb angles (PT, MT, TL) with severity classification + and Rigo-Chêneau type determination. +

+
+ +
+
+ + + + + + + + +
+

3D Brace Generation

+

+ Generate custom 3D-printable braces with patient-specific pressure zones + and relief windows based on curve analysis. +

+
+
+
+ + {/* Workflow Section */} +
+

How It Works

+
+
+
1
+
+

Upload X-ray

+

Upload a spinal PA/AP X-ray image

+
+
+
+
+
2
+
+

Review Analysis

+

Verify landmarks and measurements

+
+
+
+
+
3
+
+

Generate Brace

+

Create custom 3D brace design

+
+
+
+
+
4
+
+

Download & Print

+

Export STL files for 3D printing

+
+
+
+
+ + {/* Footer */} +
+

BraceIQ Development Environment

+
+
+ ); +} diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx new file mode 100644 index 0000000..e873cb4 --- /dev/null +++ b/frontend/src/pages/LandingPage.tsx @@ -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 ( +
+
+ + +
+
+ BraceiQ +
+
+ +

+ Guided support design, from imaging to fabrication. +

+ + +
+
+ ); +} diff --git a/frontend/src/pages/LandmarkCapturePage.tsx b/frontend/src/pages/LandmarkCapturePage.tsx new file mode 100644 index 0000000..b578f7e --- /dev/null +++ b/frontend/src/pages/LandmarkCapturePage.tsx @@ -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; + +async function httpJson(path: string, init?: RequestInit): Promise { + 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 { + 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(c.path) + : await httpJson(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(null); + const [assetsLoaded, setAssetsLoaded] = useState(false); + + const [imageUrl, setImageUrl] = useState(""); + const [imageLoading, setImageLoading] = useState(false); + const [imageError, setImageError] = useState(null); + + const [manualUrl, setManualUrl] = useState(""); + + // ✅ FIX: was Point[]; must be Record to match LandmarkCanvas + SubmitLandmarksRequest + const [landmarks, setLandmarks] = useState>({}); + const [completed, setCompleted] = useState(false); + + const [submitting, setSubmitting] = useState(false); + const [msg, setMsg] = useState(null); + + // --- Artifacts slide panel --- + const [artifactsOpen, setArtifactsOpen] = useState(false); + const [activeArtifactIdx, setActiveArtifactIdx] = useState(0); + const [artifactStateByIdx, setArtifactStateByIdx] = useState>({}); + + 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(`/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 ( +
+
+
+

Landmark Capture

+

+ Place landmarks on the AP X-ray, then submit to resume the pipeline. +

+
+
+
+ + + + +
+
+ +
+
+ Case: {id || "(missing)"} +
+ + {msg &&
{msg}
} + + {!assetsLoaded ? ( +
Loading assets...
+ ) : ( + <> +
+
+ {imageUrl ? ( +
+
+ {imageLoading &&
Loading image…
} + {imageError &&
{imageError}
} +
+ + {/* ============================ + Thumbnail on top + Workspace below + ============================ */} +
+ {/* Thumbnail */} +
+
+
+ AP x-ray thumbnail { + 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)." + ); + }} + /> +
+ +
+ + +
+ + {imageLoading &&
Loading image…
} +
+
+ + {/* Workspace */} +
+
Landmark capture
+ + {/* IMPORTANT: do NOT wrap LandmarkCanvas in a 250x250 box. + We will constrain ONLY the image holder via CSS. */} +
+ { + setLandmarks(lm); + setCompleted(done); + }} + /> +
+
+
+ {/* ============================ */} +
+ ) : ( +
+
+ {apExists + ? "AP x-ray exists for this case, but no viewable URL is available yet." + : "AP image not available for this case."} +
+ +
+
+ Temporary workaround: paste a viewable image URL (presigned GET to a JPG/PNG). +
+
+ setManualUrl(e.target.value)} + /> + +
+
+ +
+ +
+ + {imageError && ( +
+ {imageError} +
+ )} +
+ )} +
+
+ + + )} +
+ + {/* ========================== + Slide-in Artifacts Drawer + ========================== */} +
setArtifactsOpen(false)} + role="presentation" + /> + + +
+ ); +} + diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..ab28970 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -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 ( +
+
+ {/* Logo/Brand */} +
+

+ BraceiQ +

+

Sign in to your account

+
+ + {/* Login Form */} +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + placeholder="Enter your username" + autoComplete="username" + autoFocus + disabled={isSubmitting} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + autoComplete="current-password" + disabled={isSubmitting} + /> +
+ + +
+ +
+
+ ); +} diff --git a/frontend/src/pages/PipelineCaseDetail.tsx b/frontend/src/pages/PipelineCaseDetail.tsx new file mode 100644 index 0000000..a613f1b --- /dev/null +++ b/frontend/src/pages/PipelineCaseDetail.tsx @@ -0,0 +1,552 @@ +/** + * Pipeline Case Detail Page + * 3-stage pipeline: Landmarks → Analysis → Brace + */ +import { useEffect, useState, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + fetchCase, + uploadXrayForCase, + detectLandmarks, + updateLandmarks, + approveLandmarks, + recalculateAnalysis, + generateBraceFromLandmarks, + generateBothBraces, + updateMarkers, + getCaseAssets, + uploadBodyScan, + getBodyScan, + deleteBodyScan, + skipBodyScan, +} from '../api/braceflowApi'; +import type { + CaseRecord, + LandmarksResult, + RecalculationResult, + GenerateBraceResponse, + VertebraeStructure, + BodyScanResponse, +} from '../api/braceflowApi'; + +import PipelineSteps, { type PipelineStage } from '../components/pipeline/PipelineSteps'; +import LandmarkDetectionStage from '../components/pipeline/LandmarkDetectionStage'; +import SpineAnalysisStage from '../components/pipeline/SpineAnalysisStage'; +import BodyScanUploadStage from '../components/pipeline/BodyScanUploadStage'; +import BraceGenerationStage from '../components/pipeline/BraceGenerationStage'; +import BraceFittingStage from '../components/pipeline/BraceFittingStage'; + +export default function PipelineCaseDetail() { + const { caseId } = useParams<{ caseId: string }>(); + const nav = useNavigate(); + + // Case data + const [caseData, setCaseData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Stage data + const [xrayUrl, setXrayUrl] = useState(null); + const [landmarksData, setLandmarksData] = useState(null); + const [analysisData, setAnalysisData] = useState(null); + const [bodyScanData, setBodyScanData] = useState(null); + const [braceData, setBraceData] = useState(null); + + // Current stage and loading states + const [currentStage, setCurrentStage] = useState('upload'); + const [detectingLandmarks, setDetectingLandmarks] = useState(false); + const [recalculating, setRecalculating] = useState(false); + const [uploadingBodyScan, setUploadingBodyScan] = useState(false); + const [generatingBrace, setGeneratingBrace] = useState(false); + + // Load case data + const loadCase = useCallback(async () => { + if (!caseId) return; + + setLoading(true); + setError(null); + + try { + const data = await fetchCase(caseId); + setCaseData(data); + + // Load assets + const assets = await getCaseAssets(caseId).catch((err) => { + console.error('Failed to get case assets:', err); + return null; + }); + console.log('Case assets:', assets); + const xray = assets?.assets?.uploads?.find((f) => + f.filename.match(/\.(jpg|jpeg|png)$/i) + ); + console.log('Found xray:', xray); + if (xray) { + console.log('Setting xrayUrl to:', xray.url); + setXrayUrl(xray.url); + } + + // Check existing state from case data + if (data?.landmarks_data) { + // Handle both formats - full LandmarksResult or just VertebraeStructure + const ld = data.landmarks_data as any; + if (ld.vertebrae_structure) { + // Full LandmarksResult format + setLandmarksData(ld as LandmarksResult); + } else if (ld.vertebrae || ld.all_levels) { + // Just VertebraeStructure - wrap it + setLandmarksData({ + case_id: caseId || '', + status: 'landmarks_detected', + input: { image_dimensions: { width: 0, height: 0 }, pixel_spacing_mm: null }, + detection_quality: { + vertebrae_count: ld.detected_count || ld.vertebrae?.filter((v: any) => v.detected).length || 0, + average_confidence: 0 + }, + cobb_angles: ld.cobb_angles || { PT: 0, MT: 0, TL: 0, max: 0, PT_severity: 'N/A', MT_severity: 'N/A', TL_severity: 'N/A' }, + rigo_classification: ld.rigo_classification || { type: 'Unknown', description: '' }, + curve_type: ld.curve_type || 'Unknown', + vertebrae_structure: ld as VertebraeStructure, + processing_time_ms: 0 + } as LandmarksResult); + } + } + + // Load saved analysis data if available (from previous recalculate) + if (data?.analysis_data) { + setAnalysisData(data.analysis_data as RecalculationResult); + } + + // Load body scan data if available + if (data?.body_scan_path || data?.body_scan_url) { + setBodyScanData({ + caseId: caseId || '', + has_body_scan: true, + body_scan: { + path: data.body_scan_path || '', + url: data.body_scan_url || '', + metadata: data.body_scan_metadata || {} + } + }); + } + + // Load brace data if available + let hasBraceData = false; + if (data?.analysis_result) { + const analysisResult = data.analysis_result as any; + // Check for both braces format (regular + vase) + if (analysisResult.braces) { + setBraceData({ + rigo_classification: { type: analysisResult.rigoType || 'A1' }, + cobb_angles: analysisResult.cobbAngles, + braces: analysisResult.braces, + } as any); + hasBraceData = true; + } + // Check for single brace format (legacy) + else if (analysisResult.brace) { + setBraceData(analysisResult.brace as GenerateBraceResponse); + hasBraceData = true; + } + } + + // Determine current stage - prioritize actual data over status + // This ensures the UI reflects reality even if status is out of sync + if (hasBraceData) { + // Brace exists - go straight to brace stage + setCurrentStage('brace'); + } else { + // Determine stage from status + switch (data?.status) { + case 'created': + setCurrentStage(xray ? 'landmarks' : 'upload'); + break; + case 'landmarks_detected': + // Landmarks detected but not approved - stay on landmarks stage + setCurrentStage('landmarks'); + break; + case 'landmarks_approved': + // Landmarks approved - move to analysis stage + setCurrentStage('analysis'); + break; + case 'analysis_complete': + // Analysis complete - move to body scan stage + setCurrentStage('bodyscan'); + break; + case 'body_scan_uploaded': + // Body scan uploaded - still on body scan stage (need to continue) + setCurrentStage('bodyscan'); + break; + case 'processing_brace': + // Currently generating brace + setCurrentStage('brace'); + break; + case 'brace_generated': + case 'completed': + // Brace already generated - show brace stage + setCurrentStage('brace'); + break; + default: + setCurrentStage(xray ? 'landmarks' : 'upload'); + } + } + } catch (e: any) { + setError(e?.message || 'Failed to load case'); + } finally { + setLoading(false); + } + }, [caseId]); + + useEffect(() => { + loadCase(); + }, [loadCase]); + + // Handle file upload + const handleUpload = async (file: File) => { + if (!caseId) return; + try { + await uploadXrayForCase(caseId, file); + // Reload to get new X-ray URL + await loadCase(); + } catch (e: any) { + setError(e?.message || 'Upload failed'); + } + }; + + // Stage 1: Detect landmarks + const handleDetectLandmarks = async () => { + if (!caseId) return; + setDetectingLandmarks(true); + setError(null); + try { + const result = await detectLandmarks(caseId); + setLandmarksData(result); + setCurrentStage('landmarks'); + } catch (e: any) { + setError(e?.message || 'Landmark detection failed'); + } finally { + setDetectingLandmarks(false); + } + }; + + // Stage 1: Update landmarks + const handleUpdateLandmarks = async (landmarks: VertebraeStructure) => { + if (!caseId) return; + try { + await updateLandmarks(caseId, landmarks); + // Update local state + if (landmarksData) { + setLandmarksData({ + ...landmarksData, + vertebrae_structure: landmarks, + }); + } + } catch (e: any) { + setError(e?.message || 'Failed to save landmarks'); + } + }; + + // Stage 1 -> 2: Approve landmarks + const handleApproveLandmarks = async (updatedLandmarks?: VertebraeStructure) => { + if (!caseId) return; + try { + await approveLandmarks(caseId, updatedLandmarks); + setCurrentStage('analysis'); + } catch (e: any) { + setError(e?.message || 'Failed to approve landmarks'); + } + }; + + // Stage 2: Recalculate analysis + const handleRecalculate = async () => { + if (!caseId) return; + setRecalculating(true); + setError(null); + try { + const result = await recalculateAnalysis(caseId); + setAnalysisData(result); + } catch (e: any) { + setError(e?.message || 'Recalculation failed'); + } finally { + setRecalculating(false); + } + }; + + // Stage 2 -> 3: Continue to body scan + const handleContinueToBodyScan = () => { + setCurrentStage('bodyscan'); + }; + + // Stage 3: Upload body scan + const handleUploadBodyScan = async (file: File) => { + if (!caseId) return; + setUploadingBodyScan(true); + setError(null); + try { + const result = await uploadBodyScan(caseId, file); + setBodyScanData({ + caseId, + has_body_scan: true, + body_scan: result.body_scan + }); + } catch (e: any) { + setError(e?.message || 'Body scan upload failed'); + } finally { + setUploadingBodyScan(false); + } + }; + + // Stage 3: Skip body scan + const handleSkipBodyScan = async () => { + if (!caseId) return; + try { + await skipBodyScan(caseId); + setBodyScanData({ caseId, has_body_scan: false, body_scan: null }); + setCurrentStage('brace'); + } catch (e: any) { + setError(e?.message || 'Failed to skip body scan'); + } + }; + + // Stage 3: Delete body scan + const handleDeleteBodyScan = async () => { + if (!caseId) return; + try { + await deleteBodyScan(caseId); + setBodyScanData({ caseId, has_body_scan: false, body_scan: null }); + } catch (e: any) { + setError(e?.message || 'Failed to delete body scan'); + } + }; + + // Stage 3 -> 4: Continue to brace generation + const handleContinueToBrace = () => { + setCurrentStage('brace'); + }; + + // Stage 4 -> 5: Continue to fitting inspection + const handleContinueToFitting = () => { + setCurrentStage('fitting'); + }; + + // Handle clicking on pipeline step to navigate + const handleStageClick = (stage: PipelineStage) => { + setCurrentStage(stage); + }; + + // Stage 3: Generate brace (both regular and vase types) + const handleGenerateBrace = async () => { + if (!caseId) return; + setGeneratingBrace(true); + setError(null); + + let standardResult: any = null; + + try { + // First try to generate the standard brace (for backward compatibility) + try { + standardResult = await generateBraceFromLandmarks(caseId, { + experiment: 'experiment_9', + }); + } catch (stdErr) { + console.warn('Standard brace generation failed, trying both braces:', stdErr); + } + + // Generate both brace types (regular + vase) with markers + const bothBraces = await generateBothBraces(caseId, { + rigoType: standardResult?.rigo_classification?.type, + }); + + // Merge both braces data into the result + setBraceData({ + ...(standardResult || {}), + rigo_classification: standardResult?.rigo_classification || { type: bothBraces.rigoType }, + cobb_angles: standardResult?.cobb_angles || bothBraces.cobbAngles, + braces: bothBraces.braces, + } as any); + + } catch (e: any) { + // If both fail, show error + if (standardResult) { + // At least we have the standard result + setBraceData(standardResult); + } else { + setError(e?.message || 'Brace generation failed'); + } + } finally { + setGeneratingBrace(false); + } + }; + + // Stage 3: Update markers + const handleUpdateMarkers = async (markers: Record) => { + if (!caseId) return; + try { + await updateMarkers(caseId, markers); + } catch (e: any) { + setError(e?.message || 'Failed to save markers'); + } + }; + + // Loading state + if (loading) { + return ( +
+
+
+

Loading case...

+
+
+ ); + } + + // Error state + if (error && !caseData) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + const visualizationUrl = + landmarksData?.visualization_url || + (landmarksData as any)?.visualization_path?.replace(/^.*[\\\/]/, '/files/outputs/' + caseId + '/'); + + return ( +
+ {/* Header */} +
+
+ +

{caseId}

+ + {caseData?.status?.replace(/_/g, ' ') || 'Created'} + +
+
+ + {/* Pipeline Steps Indicator */} + + + {/* Error Banner */} + {error && ( +
+ {error} + +
+ )} + + {/* Upload Section (if no X-ray) */} + {!xrayUrl && currentStage === 'upload' && ( +
+
+

Upload X-ray Image

+

Upload a frontal (AP) X-ray image to begin analysis.

+ { + const file = e.target.files?.[0]; + if (file) handleUpload(file); + }} + /> +
+
+ )} + + {/* Stage Components */} +
+ {(currentStage === 'landmarks' || xrayUrl) && ( + + )} + + {(currentStage === 'analysis' || currentStage === 'bodyscan' || currentStage === 'brace') && ( + + )} + + {(currentStage === 'bodyscan' || currentStage === 'brace') && ( + + )} + + {currentStage === 'brace' && ( + + )} + + {/* Stage 5: Brace Fitting - shows when brace is generated AND body scan exists */} + {(currentStage === 'brace' || currentStage === 'fitting') && braceData && bodyScanData?.has_body_scan && ( + + )} +
+
+ ); +} diff --git a/frontend/src/pages/ShellEditorPage.tsx b/frontend/src/pages/ShellEditorPage.tsx new file mode 100644 index 0000000..f1a75b6 --- /dev/null +++ b/frontend/src/pages/ShellEditorPage.tsx @@ -0,0 +1,60 @@ +import { useSearchParams } from "react-router-dom"; + +export default function ShellEditorPage() { + const [searchParams] = useSearchParams(); + const modelUrl = searchParams.get("model"); + + // Build the SculptGL URL with optional model parameter + // Always use nosphere=true to start with empty canvas + const sculptglUrl = modelUrl + ? `/sculptgl/index.html?nosphere=true&model=${encodeURIComponent(modelUrl)}` + : "/sculptgl/index.html?nosphere=true"; + + return ( +
+
+
+

Edit Shell

+

Use SculptGL to refine the generated brace shell.

+
+ + +
+
+